secret.rs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  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 use the same blob
  5. //! format as [`crate::crypto::totp`]: the writer is version-gated, emitting the
  6. //! v3 per-row-DEK envelope when `envelope = true` and the legacy v2
  7. //! HKDF-SHA256 blob (info `totp-gcm-v2`) otherwise. Version-3 blobs wrap a
  8. //! random DEK under HKDF-SHA256 (`totp-kek-v1`) and seal the data with a fixed
  9. //! AAD that never names the kid, so `totp rewrap` can move a DEK to a new key
  10. //! without touching the ciphertext. Version-2 and legacy version-1
  11. //! (bare `SHA256(totp_key)`) blobs still decrypt. The two implementations are
  12. //! intentionally small duplicates: `crypto` cannot depend on `cli`. `keygen`
  13. //! emits an ES256 keypair for test fixtures and client-side DPoP keys only —
  14. //! never an AS signing key.
  15. use base64::{
  16. Engine as _, engine::general_purpose::STANDARD, engine::general_purpose::URL_SAFE_NO_PAD,
  17. };
  18. use ring::{
  19. aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey},
  20. hkdf,
  21. rand::{SecureRandom as _, SystemRandom},
  22. signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair as _},
  23. };
  24. use secrecy::ExposeSecret as _;
  25. #[cfg(test)]
  26. use sha2::{Digest as _, Sha256};
  27. use zeroize::Zeroizing;
  28. use super::context::CliError;
  29. use crate::config::Config;
  30. /// Legacy v2 blob version (HKDF-SHA256 writes while `pii_dual_write` is on).
  31. const TOTP_BLOB_VERSION: u8 = 2;
  32. /// Envelope blob version (per-row DEK): written when `envelope = true`.
  33. const TOTP_BLOB_V3: u8 = 3;
  34. /// Legacy blob version (bare-`SHA256(totp_key)` writes): decrypt-only.
  35. #[cfg(test)]
  36. const TOTP_BLOB_V1: u8 = 1;
  37. /// HKDF domain-separation label binding the derived key to TOTP AES-GCM use.
  38. const HKDF_INFO: &[u8] = b"totp-gcm-v2";
  39. /// HKDF domain-separation label for the key-encryption key that wraps v3 DEKs.
  40. const KEK_INFO: &[u8] = b"totp-kek-v1";
  41. /// AAD sealing the v3 data ciphertext: format tag plus version only. It must
  42. /// NOT name the wrapping key, so a DEK re-wrap changes the wrap without
  43. /// touching this ciphertext.
  44. const DATA_AAD: &[u8] = b"totp-v3\x03";
  45. /// AES-GCM nonce length.
  46. const NONCE_LEN: usize = 12;
  47. /// AES-256 key length.
  48. const KEY_LEN: usize = 32;
  49. /// Wrapped-DEK length: a 32-byte DEK sealed under AES-256-GCM (16-byte tag).
  50. const WRAPPED_DEK_LEN: usize = 32 + 16;
  51. /// A generated ES256 keypair, serialized for test fixtures / DPoP clients.
  52. pub struct KeypairPem {
  53. /// PKCS#8 v1 PEM private key.
  54. pub private_key_pem: String,
  55. /// Public JWK JSON (`kty=EC`, `crv=P-256`, `alg=ES256`).
  56. pub public_jwk: String,
  57. }
  58. /// Hash a user password with the configured Argon2id parameters.
  59. ///
  60. /// # Errors
  61. /// Returns [`CliError::Secret`] when the parameters are invalid or hashing
  62. /// fails.
  63. pub fn hash_password(config: &Config, password: &str) -> Result<String, CliError> {
  64. let configured = &config.argon2;
  65. let params = crate::crypto::secret::Argon2Params::new(
  66. configured.memory_kib,
  67. configured.iterations,
  68. configured.parallelism,
  69. 32,
  70. )?;
  71. Ok(crate::crypto::secret::hash_secret(password, &params)?)
  72. }
  73. /// Keyed token hash for at-rest storage under the active pepper.
  74. ///
  75. /// The numeric generation is derived from the configured key id (`v1` → `1`)
  76. /// so rotated peppers retain distinct storage strings.
  77. ///
  78. /// # Errors
  79. /// Returns [`CliError::Crypto`] when no active pepper is configured.
  80. pub fn token_hash(config: &Config, token: &str) -> Result<String, CliError> {
  81. let (key_id, pepper) = config
  82. .keys
  83. .active_pepper()
  84. .ok_or_else(|| CliError::Crypto("no active token pepper configured".to_owned()))?;
  85. let generation = numeric_key_id(key_id.as_str());
  86. let hash = crate::crypto::tokens::token_hash(
  87. &crate::crypto::tokens::KeyId::new(generation),
  88. pepper.expose_secret().as_bytes(),
  89. token,
  90. );
  91. Ok(hash.to_storage_string())
  92. }
  93. /// Encrypt a TOTP secret with the active TOTP-encryption key.
  94. ///
  95. /// The writer is version-gated: `envelope = false` (while `pii_dual_write` is
  96. /// on) writes the legacy v2 blob so old replicas keep reading; `envelope =
  97. /// true` writes the v3 per-row-DEK envelope. Callers pass
  98. /// `!config.pii_dual_write`.
  99. ///
  100. /// # Errors
  101. /// Returns [`CliError::Crypto`] when no TOTP key is configured or the AEAD
  102. /// fails.
  103. pub fn encrypt_totp(config: &Config, plaintext: &str, envelope: bool) -> Result<Vec<u8>, CliError> {
  104. if envelope {
  105. encrypt_v3(config, plaintext)
  106. } else {
  107. encrypt_v2(config, plaintext)
  108. }
  109. }
  110. /// Legacy v2 write: `version(2) || kid_len(1) || kid || nonce(12) ||
  111. /// ciphertext+tag`, AES-256-GCM under HKDF-SHA256 with empty AAD.
  112. fn encrypt_v2(config: &Config, plaintext: &str) -> Result<Vec<u8>, CliError> {
  113. let (key_id, secret) = config
  114. .keys
  115. .active_totp_key()
  116. .ok_or_else(|| CliError::Crypto("no TOTP encryption key configured".to_owned()))?;
  117. let kid = key_id.as_str().as_bytes();
  118. if kid.len() > u8::MAX as usize {
  119. return Err(CliError::Crypto("TOTP key id is too long".to_owned()));
  120. }
  121. let key = derive_key(secret.expose_secret().as_bytes())
  122. .map_err(|_| CliError::Crypto("TOTP key derivation failed".to_owned()))?;
  123. let mut nonce_bytes = [0u8; NONCE_LEN];
  124. SystemRandom::new()
  125. .fill(&mut nonce_bytes)
  126. .map_err(|_| CliError::Crypto("operating-system randomness unavailable".to_owned()))?;
  127. let unbound = UnboundKey::new(&AES_256_GCM, &key)
  128. .map_err(|_| CliError::Crypto("TOTP key rejected by AEAD".to_owned()))?;
  129. let sealing = LessSafeKey::new(unbound);
  130. let mut in_out = plaintext.as_bytes().to_vec();
  131. sealing
  132. .seal_in_place_append_tag(
  133. Nonce::assume_unique_for_key(nonce_bytes),
  134. Aad::empty(),
  135. &mut in_out,
  136. )
  137. .map_err(|_| CliError::Crypto("TOTP secret encryption failed".to_owned()))?;
  138. let mut blob = Vec::with_capacity(2 + kid.len() + NONCE_LEN + in_out.len());
  139. blob.push(TOTP_BLOB_VERSION);
  140. blob.push(kid.len() as u8);
  141. blob.extend_from_slice(kid);
  142. blob.extend_from_slice(&nonce_bytes);
  143. blob.extend_from_slice(&in_out);
  144. Ok(blob)
  145. }
  146. /// Envelope write: `version(3) || kid_len(1) || kid || wrap_nonce(12) ||
  147. /// wrapped_dek(32+16) || data_nonce(12) || secret_ct+tag`.
  148. ///
  149. /// A random 32-byte DEK is generated per write, wrapped under a KEK derived
  150. /// via HKDF-SHA256 (info `totp-kek-v1`) from the active TOTP key, and the
  151. /// secret is sealed under the DEK. The data nonce is generated FIRST because
  152. /// the wrap AAD binds it: a wrapped DEK lifted from another row names the
  153. /// wrong nonce and fails to unwrap.
  154. fn encrypt_v3(config: &Config, plaintext: &str) -> Result<Vec<u8>, CliError> {
  155. let (key_id, secret) = config
  156. .keys
  157. .active_totp_key()
  158. .ok_or_else(|| CliError::Crypto("no TOTP encryption key configured".to_owned()))?;
  159. let kid = key_id.as_str().as_bytes();
  160. if kid.len() > u8::MAX as usize {
  161. return Err(CliError::Crypto("TOTP key id is too long".to_owned()));
  162. }
  163. let kek = Zeroizing::new(
  164. derive_labeled(secret.expose_secret().as_bytes(), KEK_INFO)
  165. .map_err(|_| CliError::Crypto("TOTP key derivation failed".to_owned()))?,
  166. );
  167. let rng = SystemRandom::new();
  168. let mut data_nonce = [0u8; NONCE_LEN];
  169. rng
  170. .fill(&mut data_nonce)
  171. .map_err(|_| CliError::Crypto("operating-system randomness unavailable".to_owned()))?;
  172. let mut wrap_nonce = [0u8; NONCE_LEN];
  173. rng
  174. .fill(&mut wrap_nonce)
  175. .map_err(|_| CliError::Crypto("operating-system randomness unavailable".to_owned()))?;
  176. let mut dek = Zeroizing::new([0u8; KEY_LEN]);
  177. rng
  178. .fill(&mut dek[..])
  179. .map_err(|_| CliError::Crypto("operating-system randomness unavailable".to_owned()))?;
  180. let mut wrapped: Vec<u8> = dek.to_vec();
  181. seal_with_aad(
  182. &kek[..],
  183. wrap_nonce,
  184. &wrap_aad(kid, &data_nonce),
  185. &mut wrapped,
  186. )?;
  187. let mut data_ct = plaintext.as_bytes().to_vec();
  188. seal_with_aad(&dek[..], data_nonce, DATA_AAD, &mut data_ct)?;
  189. let mut blob =
  190. Vec::with_capacity(2 + kid.len() + NONCE_LEN + WRAPPED_DEK_LEN + NONCE_LEN + data_ct.len());
  191. blob.push(TOTP_BLOB_V3);
  192. blob.push(kid.len() as u8);
  193. blob.extend_from_slice(kid);
  194. blob.extend_from_slice(&wrap_nonce);
  195. blob.extend_from_slice(&wrapped);
  196. blob.extend_from_slice(&data_nonce);
  197. blob.extend_from_slice(&data_ct);
  198. Ok(blob)
  199. }
  200. /// Wrap AAD binding a wrapped DEK to its row: format tag, version, wrapping
  201. /// kid, and the data nonce the DEK was sealed for.
  202. fn wrap_aad(kid: &[u8], data_nonce: &[u8; NONCE_LEN]) -> Vec<u8> {
  203. let mut aad = Vec::with_capacity(DATA_AAD.len() + 1 + kid.len() + NONCE_LEN);
  204. aad.extend_from_slice(DATA_AAD);
  205. aad.push(kid.len() as u8);
  206. aad.extend_from_slice(kid);
  207. aad.extend_from_slice(data_nonce);
  208. aad
  209. }
  210. /// Seal `in_out` in place under `key` and `aad`, appending the GCM tag.
  211. fn seal_with_aad(
  212. key: &[u8],
  213. nonce_bytes: [u8; NONCE_LEN],
  214. aad: &[u8],
  215. in_out: &mut Vec<u8>,
  216. ) -> Result<(), CliError> {
  217. let sealing = LessSafeKey::new(
  218. UnboundKey::new(&AES_256_GCM, key)
  219. .map_err(|_| CliError::Crypto("TOTP key rejected by AEAD".to_owned()))?,
  220. );
  221. sealing
  222. .seal_in_place_append_tag(
  223. Nonce::assume_unique_for_key(nonce_bytes),
  224. Aad::from(aad),
  225. in_out,
  226. )
  227. .map_err(|_| CliError::Crypto("TOTP secret encryption failed".to_owned()))
  228. }
  229. /// Decrypt a TOTP secret previously produced by [`encrypt_totp`].
  230. ///
  231. /// Version-3 envelopes unwrap the per-row DEK under the HKDF-SHA256 KEK, then
  232. /// open the data ciphertext; version-2 blobs open under the HKDF-SHA256 key;
  233. /// legacy version-1 blobs first try the HKDF key, then fall back to the
  234. /// bare-`SHA256(totp_key)` key, so rows written before the HKDF migration keep
  235. /// decrypting.
  236. ///
  237. /// Currently exercised only by tests; the login/verification path (a later
  238. /// task) is the production consumer.
  239. ///
  240. /// # Errors
  241. /// Returns [`CliError::Crypto`] for a malformed blob, an unknown key id, or an
  242. /// authentication failure.
  243. #[cfg(test)]
  244. pub fn decrypt_totp(config: &Config, blob: &[u8]) -> Result<String, CliError> {
  245. if blob.len() < 2 + NONCE_LEN
  246. || (blob[0] != TOTP_BLOB_V3 && blob[0] != TOTP_BLOB_VERSION && blob[0] != TOTP_BLOB_V1)
  247. {
  248. return Err(CliError::Crypto("malformed TOTP blob".to_owned()));
  249. }
  250. let version = blob[0];
  251. let kid_len = blob[1] as usize;
  252. let header = 2 + kid_len;
  253. if blob.len() < header + NONCE_LEN {
  254. return Err(CliError::Crypto("malformed TOTP blob".to_owned()));
  255. }
  256. let kid_bytes = blob
  257. .get(2..header)
  258. .ok_or_else(|| CliError::Crypto("malformed TOTP blob".to_owned()))?;
  259. let kid = std::str::from_utf8(kid_bytes)
  260. .map_err(|_| CliError::Crypto("malformed TOTP key id".to_owned()))?;
  261. let secret = config
  262. .keys
  263. .totp_key_by_id(kid)
  264. .ok_or_else(|| CliError::Crypto(format!("unknown TOTP key id: {kid}")))?;
  265. let ikm = secret.expose_secret().as_bytes();
  266. if version == TOTP_BLOB_V3 {
  267. return decrypt_v3(ikm, kid_bytes, &blob[header..]);
  268. }
  269. let mut nonce_bytes = [0u8; NONCE_LEN];
  270. nonce_bytes.copy_from_slice(&blob[header..header + NONCE_LEN]);
  271. let ciphertext = &blob[header + NONCE_LEN..];
  272. if version == TOTP_BLOB_VERSION {
  273. let key =
  274. derive_key(ikm).map_err(|_| CliError::Crypto("TOTP key derivation failed".to_owned()))?;
  275. return open_with_key(&key, &nonce_bytes, ciphertext);
  276. }
  277. if let Ok(key) = derive_key(ikm)
  278. && let Ok(plaintext) = open_with_key(&key, &nonce_bytes, ciphertext)
  279. {
  280. return Ok(plaintext);
  281. }
  282. open_with_key(&derive_key_legacy(ikm), &nonce_bytes, ciphertext)
  283. }
  284. /// Open a v3 envelope body: `wrap_nonce(12) || wrapped_dek(32+16) ||
  285. /// data_nonce(12) || secret_ct+tag`.
  286. ///
  287. /// The KEK is re-derived from the key named in the blob; the wrap AAD rebinds
  288. /// that kid plus the stored data nonce, so a wrapped DEK copied from another
  289. /// row fails closed.
  290. #[cfg(test)]
  291. fn decrypt_v3(ikm: &[u8], kid: &[u8], body: &[u8]) -> Result<String, CliError> {
  292. if body.len() < NONCE_LEN + WRAPPED_DEK_LEN + NONCE_LEN + 16 {
  293. return Err(CliError::Crypto("malformed TOTP blob".to_owned()));
  294. }
  295. let (wrap_nonce, rest) = body.split_at(NONCE_LEN);
  296. let (wrapped_dek, rest) = rest.split_at(WRAPPED_DEK_LEN);
  297. let (data_nonce, ciphertext) = rest.split_at(NONCE_LEN);
  298. let mut wrap_nonce_bytes = [0u8; NONCE_LEN];
  299. wrap_nonce_bytes.copy_from_slice(wrap_nonce);
  300. let mut data_nonce_bytes = [0u8; NONCE_LEN];
  301. data_nonce_bytes.copy_from_slice(data_nonce);
  302. let kek = Zeroizing::new(
  303. derive_labeled(ikm, KEK_INFO)
  304. .map_err(|_| CliError::Crypto("TOTP key derivation failed".to_owned()))?,
  305. );
  306. let dek_bytes = open_with_aad(
  307. &kek[..],
  308. &wrap_nonce_bytes,
  309. &wrap_aad(kid, &data_nonce_bytes),
  310. wrapped_dek,
  311. )?;
  312. if dek_bytes.len() != KEY_LEN {
  313. return Err(CliError::Crypto("TOTP secret decryption failed".to_owned()));
  314. }
  315. let mut dek_key = [0u8; KEY_LEN];
  316. dek_key.copy_from_slice(&dek_bytes);
  317. let dek = Zeroizing::new(dek_key);
  318. let plaintext = open_with_aad(&dek[..], &data_nonce_bytes, DATA_AAD, ciphertext)?;
  319. String::from_utf8(plaintext)
  320. .map_err(|_| CliError::Crypto("decrypted TOTP secret is not UTF-8".to_owned()))
  321. }
  322. /// Open `ciphertext` (with trailing GCM tag) under `key` with empty AAD,
  323. /// returning UTF-8.
  324. #[cfg(test)]
  325. fn open_with_key(
  326. key: &[u8; KEY_LEN],
  327. nonce_bytes: &[u8; NONCE_LEN],
  328. ciphertext: &[u8],
  329. ) -> Result<String, CliError> {
  330. let plaintext = open_with_aad(key, nonce_bytes, &[], ciphertext)?;
  331. String::from_utf8(plaintext)
  332. .map_err(|_| CliError::Crypto("decrypted TOTP secret is not UTF-8".to_owned()))
  333. }
  334. /// Open `ciphertext` (with trailing GCM tag) under `key` and `aad`, returning
  335. /// the raw plaintext.
  336. #[cfg(test)]
  337. fn open_with_aad(
  338. key: &[u8],
  339. nonce_bytes: &[u8; NONCE_LEN],
  340. aad: &[u8],
  341. ciphertext: &[u8],
  342. ) -> Result<Vec<u8>, CliError> {
  343. let mut in_out = ciphertext.to_vec();
  344. let unbound = UnboundKey::new(&AES_256_GCM, key)
  345. .map_err(|_| CliError::Crypto("TOTP key rejected by AEAD".to_owned()))?;
  346. let opening = LessSafeKey::new(unbound);
  347. let plaintext = opening
  348. .open_in_place(
  349. Nonce::assume_unique_for_key(*nonce_bytes),
  350. Aad::from(aad),
  351. &mut in_out,
  352. )
  353. .map_err(|_| CliError::Crypto("TOTP secret decryption failed".to_owned()))?;
  354. Ok(plaintext.to_vec())
  355. }
  356. /// Generate a fresh 160-bit TOTP secret, base32 (RFC 4648, no padding).
  357. ///
  358. /// # Errors
  359. /// Returns [`CliError::Crypto`] when the OS RNG fails.
  360. pub fn generate_totp_secret() -> Result<String, CliError> {
  361. let mut bytes = [0u8; 20];
  362. SystemRandom::new()
  363. .fill(&mut bytes)
  364. .map_err(|_| CliError::Crypto("operating-system randomness unavailable".to_owned()))?;
  365. Ok(base32::encode(
  366. base32::Alphabet::Rfc4648 { padding: false },
  367. &bytes,
  368. ))
  369. }
  370. /// Build an `otpauth://` enrollment URI for a generated TOTP secret.
  371. #[must_use]
  372. pub fn otpauth_uri(issuer: &str, account: &str, secret: &str) -> String {
  373. format!(
  374. "otpauth://totp/{}:{}?secret={}&issuer={}",
  375. percent_encode(issuer),
  376. percent_encode(account),
  377. secret,
  378. percent_encode(issuer)
  379. )
  380. }
  381. /// Generate an ES256 keypair for test fixtures / client DPoP keys only.
  382. ///
  383. /// # Errors
  384. /// Returns [`CliError::Crypto`] when generation or serialization fails.
  385. pub fn generate_es256() -> Result<KeypairPem, CliError> {
  386. let rng = SystemRandom::new();
  387. let document = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
  388. .map_err(|_| CliError::Crypto("ES256 key generation failed".to_owned()))?;
  389. let der = document.as_ref();
  390. let key_pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, der, &rng)
  391. .map_err(|_| CliError::Crypto("generated ES256 key failed to parse".to_owned()))?;
  392. let point = key_pair.public_key().as_ref();
  393. if point.len() != 65 || point[0] != 0x04 {
  394. return Err(CliError::Crypto(
  395. "ES256 public key was not an uncompressed P-256 point".to_owned(),
  396. ));
  397. }
  398. let x = URL_SAFE_NO_PAD.encode(&point[1..33]);
  399. let y = URL_SAFE_NO_PAD.encode(&point[33..65]);
  400. let jwk = serde_json::json!({
  401. "kty": "EC",
  402. "crv": "P-256",
  403. "alg": "ES256",
  404. "use": "sig",
  405. "x": x,
  406. "y": y,
  407. });
  408. let public_jwk =
  409. serde_json::to_string_pretty(&jwk).map_err(|error| CliError::Crypto(error.to_string()))?;
  410. Ok(KeypairPem {
  411. private_key_pem: pem_encode("PRIVATE KEY", der),
  412. public_jwk,
  413. })
  414. }
  415. fn derive_key(totp_key: &[u8]) -> Result<[u8; KEY_LEN], ring::error::Unspecified> {
  416. derive_labeled(totp_key, HKDF_INFO)
  417. }
  418. /// HKDF-SHA256 (RFC 5869, empty salt) under an explicit domain-separation
  419. /// label: `totp-gcm-v2` seals data directly, `totp-kek-v1` wraps v3 DEKs.
  420. fn derive_labeled(totp_key: &[u8], info: &[u8]) -> Result<[u8; KEY_LEN], ring::error::Unspecified> {
  421. let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, &[]).extract(totp_key);
  422. let info_labels = [info];
  423. let okm = prk.expand(&info_labels, HkdfAesKey)?;
  424. let mut key = [0u8; KEY_LEN];
  425. okm.fill(&mut key)?;
  426. Ok(key)
  427. }
  428. #[cfg(test)]
  429. fn derive_key_legacy(totp_key: &[u8]) -> [u8; KEY_LEN] {
  430. let digest = Sha256::digest(totp_key);
  431. let mut key = [0u8; KEY_LEN];
  432. key.copy_from_slice(&digest);
  433. key
  434. }
  435. struct HkdfAesKey;
  436. impl hkdf::KeyType for HkdfAesKey {
  437. fn len(&self) -> usize {
  438. KEY_LEN
  439. }
  440. }
  441. fn numeric_key_id(key_id: &str) -> u32 {
  442. key_id.trim_start_matches('v').parse::<u32>().unwrap_or(1)
  443. }
  444. fn pem_encode(label: &str, der: &[u8]) -> String {
  445. let encoded = STANDARD.encode(der);
  446. let mut out = format!("-----BEGIN {label}-----\n");
  447. for chunk in encoded.as_bytes().chunks(64) {
  448. out.push_str(std::str::from_utf8(chunk).unwrap_or_default());
  449. out.push('\n');
  450. }
  451. out.push_str(&format!("-----END {label}-----\n"));
  452. out
  453. }
  454. fn percent_encode(value: &str) -> String {
  455. let mut out = String::with_capacity(value.len());
  456. for byte in value.bytes() {
  457. if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
  458. out.push(char::from(byte));
  459. } else {
  460. out.push('%');
  461. out.push_str(&format!("{byte:02X}"));
  462. }
  463. }
  464. out
  465. }