secret.rs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. //! Secret handling for the CLI: password hashing, token hashing, TOTP-secret
  2. //! encryption, and ES256 keygen.
  3. //!
  4. //! Nothing here ever logs or prints a secret. TOTP secrets are encrypted with
  5. //! AES-256-GCM under a key derived from the active [`crate::keys::KeyRing`]
  6. //! TOTP key via HKDF-SHA256 (info `totp-gcm-v2`); the stored blob carries the
  7. //! key id so retained keys keep decrypting after rotation. Version-1 blobs
  8. //! (bare `SHA256(totp_key)`) still decrypt via a legacy fallback. `keygen`
  9. //! emits an ES256 keypair for test fixtures
  10. //! and client-side DPoP keys only — never an AS signing key.
  11. use base64::{
  12. Engine as _, engine::general_purpose::STANDARD, engine::general_purpose::URL_SAFE_NO_PAD,
  13. };
  14. use ring::{
  15. aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey},
  16. hkdf,
  17. rand::{SecureRandom as _, SystemRandom},
  18. signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair as _},
  19. };
  20. use secrecy::ExposeSecret as _;
  21. #[cfg(test)]
  22. use sha2::{Digest as _, Sha256};
  23. use super::context::CliError;
  24. use crate::config::Config;
  25. /// Current TOTP-blob format version (HKDF-SHA256 writes).
  26. const TOTP_BLOB_VERSION: u8 = 2;
  27. /// Legacy blob version (bare-`SHA256(totp_key)` writes): decrypt-only.
  28. #[cfg(test)]
  29. const TOTP_BLOB_V1: u8 = 1;
  30. /// HKDF domain-separation label binding the derived key to TOTP AES-GCM use.
  31. const HKDF_INFO: &[u8] = b"totp-gcm-v2";
  32. /// AES-GCM nonce length.
  33. const NONCE_LEN: usize = 12;
  34. /// AES-256 key length.
  35. const KEY_LEN: usize = 32;
  36. /// A generated ES256 keypair, serialized for test fixtures / DPoP clients.
  37. pub struct KeypairPem {
  38. /// PKCS#8 v1 PEM private key.
  39. pub private_key_pem: String,
  40. /// Public JWK JSON (`kty=EC`, `crv=P-256`, `alg=ES256`).
  41. pub public_jwk: String,
  42. }
  43. /// Hash a user password with the configured Argon2id parameters.
  44. ///
  45. /// # Errors
  46. /// Returns [`CliError::Secret`] when the parameters are invalid or hashing
  47. /// fails.
  48. pub fn hash_password(config: &Config, password: &str) -> Result<String, CliError> {
  49. let configured = &config.argon2;
  50. let params = crate::crypto::secret::Argon2Params::new(
  51. configured.memory_kib,
  52. configured.iterations,
  53. configured.parallelism,
  54. 32,
  55. )?;
  56. Ok(crate::crypto::secret::hash_secret(password, &params)?)
  57. }
  58. /// Keyed token hash for at-rest storage under the active pepper.
  59. ///
  60. /// The numeric generation is derived from the configured key id (`v1` → `1`)
  61. /// so rotated peppers retain distinct storage strings.
  62. ///
  63. /// # Errors
  64. /// Returns [`CliError::Crypto`] when no active pepper is configured.
  65. pub fn token_hash(config: &Config, token: &str) -> Result<String, CliError> {
  66. let (key_id, pepper) = config
  67. .keys
  68. .active_pepper()
  69. .ok_or_else(|| CliError::Crypto("no active token pepper configured".to_owned()))?;
  70. let generation = numeric_key_id(key_id.as_str());
  71. let hash = crate::crypto::tokens::token_hash(
  72. &crate::crypto::tokens::KeyId::new(generation),
  73. pepper.expose_secret().as_bytes(),
  74. token,
  75. );
  76. Ok(hash.to_storage_string())
  77. }
  78. /// Encrypt a TOTP secret with the active TOTP-encryption key.
  79. ///
  80. /// Blob layout: `version(2) || kid_len(1) || kid || nonce(12) || ciphertext+tag`.
  81. /// The key id travels with the blob so retained keys decrypt after rotation.
  82. ///
  83. /// # Errors
  84. /// Returns [`CliError::Crypto`] when no TOTP key is configured or the AEAD
  85. /// fails.
  86. pub fn encrypt_totp(config: &Config, plaintext: &str) -> Result<Vec<u8>, CliError> {
  87. let (key_id, secret) = config
  88. .keys
  89. .active_totp_key()
  90. .ok_or_else(|| CliError::Crypto("no TOTP encryption key configured".to_owned()))?;
  91. let kid = key_id.as_str().as_bytes();
  92. if kid.len() > u8::MAX as usize {
  93. return Err(CliError::Crypto("TOTP key id is too long".to_owned()));
  94. }
  95. let key = derive_key(secret.expose_secret().as_bytes())
  96. .map_err(|_| CliError::Crypto("TOTP key derivation failed".to_owned()))?;
  97. let mut nonce_bytes = [0u8; NONCE_LEN];
  98. SystemRandom::new()
  99. .fill(&mut nonce_bytes)
  100. .map_err(|_| CliError::Crypto("operating-system randomness unavailable".to_owned()))?;
  101. let unbound = UnboundKey::new(&AES_256_GCM, &key)
  102. .map_err(|_| CliError::Crypto("TOTP key rejected by AEAD".to_owned()))?;
  103. let sealing = LessSafeKey::new(unbound);
  104. let mut in_out = plaintext.as_bytes().to_vec();
  105. sealing
  106. .seal_in_place_append_tag(
  107. Nonce::assume_unique_for_key(nonce_bytes),
  108. Aad::empty(),
  109. &mut in_out,
  110. )
  111. .map_err(|_| CliError::Crypto("TOTP secret encryption failed".to_owned()))?;
  112. let mut blob = Vec::with_capacity(2 + kid.len() + NONCE_LEN + in_out.len());
  113. blob.push(TOTP_BLOB_VERSION);
  114. blob.push(kid.len() as u8);
  115. blob.extend_from_slice(kid);
  116. blob.extend_from_slice(&nonce_bytes);
  117. blob.extend_from_slice(&in_out);
  118. Ok(blob)
  119. }
  120. /// Decrypt a TOTP secret previously produced by [`encrypt_totp`].
  121. ///
  122. /// Version-2 blobs open under the HKDF-SHA256 key; legacy version-1 blobs
  123. /// first try the HKDF key, then fall back to the bare-`SHA256(totp_key)` key,
  124. /// so rows written before the HKDF migration keep decrypting.
  125. ///
  126. /// Currently exercised only by tests; the login/verification path (a later
  127. /// task) is the production consumer.
  128. ///
  129. /// # Errors
  130. /// Returns [`CliError::Crypto`] for a malformed blob, an unknown key id, or an
  131. /// authentication failure.
  132. #[cfg(test)]
  133. pub fn decrypt_totp(config: &Config, blob: &[u8]) -> Result<String, CliError> {
  134. if blob.len() < 2 + NONCE_LEN || (blob[0] != TOTP_BLOB_VERSION && blob[0] != TOTP_BLOB_V1) {
  135. return Err(CliError::Crypto("malformed TOTP blob".to_owned()));
  136. }
  137. let version = blob[0];
  138. let kid_len = blob[1] as usize;
  139. let header = 2 + kid_len;
  140. if blob.len() < header + NONCE_LEN {
  141. return Err(CliError::Crypto("malformed TOTP blob".to_owned()));
  142. }
  143. let kid = std::str::from_utf8(&blob[2..header])
  144. .map_err(|_| CliError::Crypto("malformed TOTP key id".to_owned()))?;
  145. let secret = config
  146. .keys
  147. .totp_key_by_id(kid)
  148. .ok_or_else(|| CliError::Crypto(format!("unknown TOTP key id: {kid}")))?;
  149. let ikm = secret.expose_secret().as_bytes();
  150. let mut nonce_bytes = [0u8; NONCE_LEN];
  151. nonce_bytes.copy_from_slice(&blob[header..header + NONCE_LEN]);
  152. let ciphertext = &blob[header + NONCE_LEN..];
  153. if version == TOTP_BLOB_VERSION {
  154. let key =
  155. derive_key(ikm).map_err(|_| CliError::Crypto("TOTP key derivation failed".to_owned()))?;
  156. return open_with_key(&key, &nonce_bytes, ciphertext);
  157. }
  158. if let Ok(key) = derive_key(ikm)
  159. && let Ok(plaintext) = open_with_key(&key, &nonce_bytes, ciphertext)
  160. {
  161. return Ok(plaintext);
  162. }
  163. open_with_key(&derive_key_legacy(ikm), &nonce_bytes, ciphertext)
  164. }
  165. #[cfg(test)]
  166. fn open_with_key(
  167. key: &[u8; KEY_LEN],
  168. nonce_bytes: &[u8; NONCE_LEN],
  169. ciphertext: &[u8],
  170. ) -> Result<String, CliError> {
  171. let mut in_out = ciphertext.to_vec();
  172. let unbound = UnboundKey::new(&AES_256_GCM, key)
  173. .map_err(|_| CliError::Crypto("TOTP key rejected by AEAD".to_owned()))?;
  174. let opening = LessSafeKey::new(unbound);
  175. let plaintext = opening
  176. .open_in_place(
  177. Nonce::assume_unique_for_key(*nonce_bytes),
  178. Aad::empty(),
  179. &mut in_out,
  180. )
  181. .map_err(|_| CliError::Crypto("TOTP secret decryption failed".to_owned()))?;
  182. String::from_utf8(plaintext.to_vec())
  183. .map_err(|_| CliError::Crypto("decrypted TOTP secret is not UTF-8".to_owned()))
  184. }
  185. /// Generate a fresh 160-bit TOTP secret, base32 (RFC 4648, no padding).
  186. ///
  187. /// # Errors
  188. /// Returns [`CliError::Crypto`] when the OS RNG fails.
  189. pub fn generate_totp_secret() -> Result<String, CliError> {
  190. let mut bytes = [0u8; 20];
  191. SystemRandom::new()
  192. .fill(&mut bytes)
  193. .map_err(|_| CliError::Crypto("operating-system randomness unavailable".to_owned()))?;
  194. Ok(base32::encode(
  195. base32::Alphabet::Rfc4648 { padding: false },
  196. &bytes,
  197. ))
  198. }
  199. /// Build an `otpauth://` enrollment URI for a generated TOTP secret.
  200. #[must_use]
  201. pub fn otpauth_uri(issuer: &str, account: &str, secret: &str) -> String {
  202. format!(
  203. "otpauth://totp/{}:{}?secret={}&issuer={}",
  204. percent_encode(issuer),
  205. percent_encode(account),
  206. secret,
  207. percent_encode(issuer)
  208. )
  209. }
  210. /// Generate an ES256 keypair for test fixtures / client DPoP keys only.
  211. ///
  212. /// # Errors
  213. /// Returns [`CliError::Crypto`] when generation or serialization fails.
  214. pub fn generate_es256() -> Result<KeypairPem, CliError> {
  215. let rng = SystemRandom::new();
  216. let document = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
  217. .map_err(|_| CliError::Crypto("ES256 key generation failed".to_owned()))?;
  218. let der = document.as_ref();
  219. let key_pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, der, &rng)
  220. .map_err(|_| CliError::Crypto("generated ES256 key failed to parse".to_owned()))?;
  221. let point = key_pair.public_key().as_ref();
  222. if point.len() != 65 || point[0] != 0x04 {
  223. return Err(CliError::Crypto(
  224. "ES256 public key was not an uncompressed P-256 point".to_owned(),
  225. ));
  226. }
  227. let x = URL_SAFE_NO_PAD.encode(&point[1..33]);
  228. let y = URL_SAFE_NO_PAD.encode(&point[33..65]);
  229. let jwk = serde_json::json!({
  230. "kty": "EC",
  231. "crv": "P-256",
  232. "alg": "ES256",
  233. "use": "sig",
  234. "x": x,
  235. "y": y,
  236. });
  237. let public_jwk =
  238. serde_json::to_string_pretty(&jwk).map_err(|error| CliError::Crypto(error.to_string()))?;
  239. Ok(KeypairPem {
  240. private_key_pem: pem_encode("PRIVATE KEY", der),
  241. public_jwk,
  242. })
  243. }
  244. fn derive_key(totp_key: &[u8]) -> Result<[u8; KEY_LEN], ring::error::Unspecified> {
  245. let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, &[]).extract(totp_key);
  246. let okm = prk.expand(&[HKDF_INFO], HkdfAesKey)?;
  247. let mut key = [0u8; KEY_LEN];
  248. okm.fill(&mut key)?;
  249. Ok(key)
  250. }
  251. #[cfg(test)]
  252. fn derive_key_legacy(totp_key: &[u8]) -> [u8; KEY_LEN] {
  253. let digest = Sha256::digest(totp_key);
  254. let mut key = [0u8; KEY_LEN];
  255. key.copy_from_slice(&digest);
  256. key
  257. }
  258. struct HkdfAesKey;
  259. impl hkdf::KeyType for HkdfAesKey {
  260. fn len(&self) -> usize {
  261. KEY_LEN
  262. }
  263. }
  264. fn numeric_key_id(key_id: &str) -> u32 {
  265. key_id.trim_start_matches('v').parse::<u32>().unwrap_or(1)
  266. }
  267. fn pem_encode(label: &str, der: &[u8]) -> String {
  268. let encoded = STANDARD.encode(der);
  269. let mut out = format!("-----BEGIN {label}-----\n");
  270. for chunk in encoded.as_bytes().chunks(64) {
  271. out.push_str(std::str::from_utf8(chunk).unwrap_or_default());
  272. out.push('\n');
  273. }
  274. out.push_str(&format!("-----END {label}-----\n"));
  275. out
  276. }
  277. fn percent_encode(value: &str) -> String {
  278. let mut out = String::with_capacity(value.len());
  279. for byte in value.bytes() {
  280. if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
  281. out.push(char::from(byte));
  282. } else {
  283. out.push('%');
  284. out.push_str(&format!("{byte:02X}"));
  285. }
  286. }
  287. out
  288. }