Skip to main content

forge_connectors/vault/
crypto.rs

1//! The unlock pipeline and the encryption primitives, on `bitwarden-crypto`.
2//!
3//! The shapes here are the ones the live spike proved against Vaultwarden
4//! 1.37.4: the wrapped user key and private key decrypt to bytes (the private
5//! key is PKCS#8 DER, not PEM), the organization key and the RSA-wrapped
6//! attachment keys unwrap with RSA-OAEP-SHA1, and an attachment is the
7//! `0x02 | IV | MAC | ciphertext` buffer the official clients upload.
8
9use std::collections::HashMap;
10use std::num::NonZeroU32;
11
12use anyhow::{Context, Result, anyhow, bail};
13use base64::Engine;
14use bitwarden_crypto::{
15    BitwardenLegacyKeyBytes, EncString, HashPurpose, Kdf, KeyDecryptable, KeyEncryptable,
16    MasterKey, OctetStreamBytes,
17};
18use hmac::{Hmac, KeyInit, Mac};
19use rsa::pkcs8::DecodePrivateKey;
20use sha2::{Digest, Sha256};
21use zeroize::Zeroizing;
22
23use crate::vault::VaultError;
24use crate::vault::models::{Prelogin, Profile, TokenResponse};
25
26/// The key type an [`Unlocked`] holds, re-exported so a caller building one
27/// for a test can name it.
28pub use bitwarden_crypto::SymmetricCryptoKey;
29
30/// An unlocked account's keys: the user key every personal cipher inherits,
31/// the account private key, and any organization keys the profile carries.
32///
33/// The caller (the workspace's `VaultSession`) owns these and is responsible
34/// for locking them; this type is the plain hand-off from the unlock path.
35pub struct Unlocked {
36    pub user_key: SymmetricCryptoKey,
37    pub private_key: Option<rsa::RsaPrivateKey>,
38    pub org_keys: HashMap<String, SymmetricCryptoKey>,
39    /// Organizations whose key the profile carries but this unlock could not
40    /// unwrap. A cipher in one of them fails with [`VaultError::MissingKey`]
41    /// rather than being encrypted or decrypted with the wrong key.
42    pub failed_orgs: Vec<String>,
43    /// SHA-256 of the wrapped user key this unlock used, so a later sync can
44    /// prove the account's keys did not rotate while the session was open.
45    pub user_key_fingerprint: String,
46}
47
48impl Unlocked {
49    /// The key a cipher inherits: its organization's when it carries one, else
50    /// the user key. A personal cipher is the common case; an organization
51    /// cipher whose key was not unlocked is refused rather than silently
52    /// falling back to the user key.
53    fn base_key<'a>(
54        &'a self,
55        cipher: &serde_json::Value,
56    ) -> Result<&'a SymmetricCryptoKey, VaultError> {
57        match cipher.get("organizationId").and_then(serde_json::Value::as_str) {
58            Some(org_id) => {
59                self.org_keys.get(org_id).ok_or_else(|| VaultError::MissingKey(org_id.to_owned()))
60            }
61            None => Ok(&self.user_key),
62        }
63    }
64
65    /// Decrypt one of a cipher's encrypted fields with the cipher's own key.
66    pub fn decrypt_item_str(
67        &self,
68        cipher: &serde_json::Value,
69        enc: &str,
70    ) -> Result<String, VaultError> {
71        let key = cipher_key(cipher, self.base_key(cipher)?)?;
72        decrypt_str(enc, &key).map_err(VaultError::Crypto)
73    }
74
75    /// Encrypt one of a cipher's fields with the cipher's own key.
76    pub fn encrypt_item_str(
77        &self,
78        cipher: &serde_json::Value,
79        value: &str,
80    ) -> Result<String, VaultError> {
81        let key = cipher_key(cipher, self.base_key(cipher)?)?;
82        encrypt_str(value, &key).map_err(VaultError::Crypto)
83    }
84
85    /// Decrypt a folder name or another value encrypted with the user key.
86    pub fn decrypt_user_str(&self, enc: &str) -> Result<String> {
87        decrypt_str(enc, &self.user_key)
88    }
89
90    /// Encrypt a folder name or another value with the user key.
91    pub fn encrypt_user_str(&self, value: &str) -> Result<String> {
92        encrypt_str(value, &self.user_key)
93    }
94
95    /// Prepare one attachment: a fresh key wrapped with the item key, the
96    /// encrypted file name, and the `0x02` blob the server stores.
97    pub fn prepare_attachment(
98        &self,
99        cipher: &serde_json::Value,
100        name: &str,
101        data: &[u8],
102    ) -> Result<(String, String, Vec<u8>), VaultError> {
103        let item_key = cipher_key(cipher, self.base_key(cipher)?)?;
104        let attachment_key = make_symmetric_key();
105        let wrapped = wrap_symmetric_key(&attachment_key, &item_key)?;
106        let encrypted_name = encrypt_str(name, &item_key)?;
107        let blob = encrypt_attachment_blob(data, &attachment_key)?;
108        Ok((wrapped, encrypted_name, blob))
109    }
110
111    /// Open one downloaded attachment: unwrap its key with the item key and
112    /// decrypt the `0x02` blob.
113    pub fn open_attachment(
114        &self,
115        cipher: &serde_json::Value,
116        wrapped_key: &str,
117        blob: &[u8],
118    ) -> Result<Vec<u8>, VaultError> {
119        let item_key = cipher_key(cipher, self.base_key(cipher)?)?;
120        let key = unwrap_attachment_key(wrapped_key, &item_key, self.private_key.as_ref())?;
121        decrypt_attachment_blob(blob, &key).map_err(VaultError::Crypto)
122    }
123}
124
125/// The KDF a prelogin answer names. An unknown type is an error rather than a
126/// guess, because guessing the KDF derives the wrong key.
127pub fn kdf_from_prelogin(pre: &Prelogin) -> Result<Kdf> {
128    let nz = |value: u32, name: &str| {
129        NonZeroU32::new(value).ok_or_else(|| anyhow!("prelogin returned {name} = 0"))
130    };
131    match pre.kdf {
132        0 => Ok(Kdf::PBKDF2 { iterations: nz(pre.kdf_iterations, "kdfIterations")? }),
133        1 => Ok(Kdf::Argon2id {
134            iterations: nz(pre.kdf_iterations, "kdfIterations")?,
135            // Argon2id without its memory and parallelism is not a parameter
136            // set; defaulting would derive a key the account does not hold.
137            memory: nz(
138                pre.kdf_memory.context("prelogin returned kdf = 1 without kdfMemory")?,
139                "kdfMemory",
140            )?,
141            parallelism: nz(
142                pre.kdf_parallelism.context("prelogin returned kdf = 1 without kdfParallelism")?,
143                "kdfParallelism",
144            )?,
145        }),
146        other => bail!("prelogin returned unknown kdf type {other}"),
147    }
148}
149
150/// The KDF in one line, for a log line or a refusal message.
151pub fn kdf_label(pre: &Prelogin) -> String {
152    match pre.kdf {
153        0 => format!("PBKDF2-HMAC-SHA256, iterations={}", pre.kdf_iterations),
154        1 => format!(
155            "Argon2id, iterations={}, memory={} MiB, parallelism={}",
156            pre.kdf_iterations,
157            pre.kdf_memory.unwrap_or(0),
158            pre.kdf_parallelism.unwrap_or(0)
159        ),
160        other => format!("unknown({other})"),
161    }
162}
163
164/// Derive the master key from the password and the configured email.
165pub fn derive_master_key(password: &str, email: &str, pre: &Prelogin) -> Result<MasterKey> {
166    let kdf = kdf_from_prelogin(pre)?;
167    MasterKey::derive(password, email, &kdf)
168        .map_err(|e| anyhow!("master key derivation failed: {e}"))
169}
170
171/// The hash the token endpoint takes as `password`.
172pub fn master_password_hash(master_key: &MasterKey, password: &str) -> String {
173    String::from(
174        master_key.derive_master_key_hash(password.as_bytes(), HashPurpose::ServerAuthorization),
175    )
176}
177
178/// Unwrap the user key, the account private key and the organization keys.
179pub fn unlock(
180    token: &TokenResponse,
181    master_key: &MasterKey,
182    profile: &Profile,
183) -> Result<Unlocked> {
184    let wrapped_user_key = wrapped_user_key(token, profile)?;
185    let user_key: EncString =
186        wrapped_user_key.parse().map_err(|e| anyhow!("wrapped user key did not parse: {e}"))?;
187    let user_key = master_key.decrypt_user_key(user_key)?;
188    with_user_key(token, user_key, profile, &wrapped_user_key)
189}
190
191/// Unlock from a user key a client released through its platform
192/// authenticator, rather than one derived from the master password.
193///
194/// The key arrives base64, as the client's enrolment wrapped it. Everything
195/// after it is the same journey [`unlock`] makes: the account private key and
196/// the organization keys unwrap from the same sync profile, and the
197/// fingerprint is the wrapped user key's, which is what proves the account's
198/// keys did not rotate while the session was open.
199pub fn unlock_with_user_key(
200    token: &TokenResponse,
201    user_key: &str,
202    profile: &Profile,
203) -> Result<Unlocked> {
204    let user_key = SymmetricCryptoKey::try_from(user_key.to_owned())
205        .map_err(|e| anyhow!("released user key did not parse: {e}"))?;
206    let wrapped_user_key = wrapped_user_key(token, profile)?;
207    with_user_key(token, user_key, profile, &wrapped_user_key)
208}
209
210/// The wrapped user key this unlock will report the fingerprint of. An empty
211/// string is a field a client left unset, not a wrapped key; it must not
212/// shadow the profile's real one.
213fn wrapped_user_key(token: &TokenResponse, profile: &Profile) -> Result<String> {
214    token
215        .key
216        .clone()
217        .filter(|key| !key.is_empty())
218        .or_else(|| profile.key.clone().filter(|key| !key.is_empty()))
219        .context("token response and sync profile both lack the wrapped user key")
220}
221
222/// The half of an unlock that starts from a known user key.
223fn with_user_key(
224    token: &TokenResponse,
225    user_key: SymmetricCryptoKey,
226    profile: &Profile,
227    wrapped_user_key: &str,
228) -> Result<Unlocked> {
229    let user_key_fingerprint = sha256_hex(wrapped_user_key.as_bytes());
230
231    // An empty `PrivateKey` is a client's unset value, not a wrapped key; it
232    // must not shadow the profile's real one either.
233    let private_key_enc = token
234        .private_key
235        .clone()
236        .filter(|key| !key.is_empty())
237        .or_else(|| profile.private_key.clone().filter(|key| !key.is_empty()));
238    let private_key = match private_key_enc {
239        Some(enc) => {
240            let der = Zeroizing::new(
241                decrypt_bytes(&enc, &user_key).context("decrypting the private key")?,
242            );
243            Some(parse_private_key(&der)?)
244        }
245        None => None,
246    };
247
248    let (org_keys, failed_orgs) = org_keys(private_key.as_ref(), profile);
249
250    Ok(Unlocked { user_key, private_key, org_keys, failed_orgs, user_key_fingerprint })
251}
252
253/// Unwrap every organization key the profile carries, naming the ones that
254/// failed. A failed org is not fatal to the unlock - the account is still
255/// usable - but its ciphers must not be touched with another key.
256fn org_keys(
257    private_key: Option<&rsa::RsaPrivateKey>,
258    profile: &Profile,
259) -> (HashMap<String, SymmetricCryptoKey>, Vec<String>) {
260    let mut keys = HashMap::new();
261    let mut failed = Vec::new();
262    let Some(private) = private_key else {
263        // No private key means no organization key can be unwrapped.
264        return (keys, profile.organizations.iter().map(|org| org.id.clone()).collect());
265    };
266    for org in &profile.organizations {
267        let Some(key) = org.key.as_deref().filter(|key| !key.is_empty()) else {
268            failed.push(org.id.clone());
269            continue;
270        };
271        match unwrap_org_key(key, private) {
272            Ok(unwrapped) => {
273                keys.insert(org.id.clone(), unwrapped);
274            }
275            Err(error) => {
276                tracing::warn!(
277                    target: "forge_connectors::vault",
278                    event_name = "vault_org_key_unwrap_failed",
279                    org_id = %org.id,
280                    error = %error,
281                    "an organization key was not unwrapped",
282                );
283                failed.push(org.id.clone());
284            }
285        }
286    }
287    (keys, failed)
288}
289
290/// Unwrap an organization key: EncString type 4, RSA-2048-OAEP-SHA1 with the
291/// user's private key. Type 6 is named rather than guessed at.
292pub fn unwrap_org_key(enc: &str, private: &rsa::RsaPrivateKey) -> Result<SymmetricCryptoKey> {
293    let (kind, rest) = enc.split_once('.').context("org key has no EncString header")?;
294    match kind {
295        "4" => {}
296        "6" => bail!("org key is EncString type 6 (OAEP-SHA1 + HMAC); only type 4 is unwrapped"),
297        other => bail!("org key is EncString type {other}, not an RSA-OAEP wrap"),
298    }
299    let parts: Vec<&str> = rest.split('|').collect();
300    let data = parts.first().ok_or_else(|| anyhow!("org key has no ciphertext part"))?;
301    rsa_oaep_unwrap(data, private, "org key")
302}
303
304fn rsa_oaep_unwrap(
305    data_b64: &str,
306    private: &rsa::RsaPrivateKey,
307    what: &str,
308) -> Result<SymmetricCryptoKey> {
309    let ciphertext = base64::engine::general_purpose::STANDARD
310        .decode(data_b64)
311        .with_context(|| format!("{what} ciphertext is not base64"))?;
312    let decrypted = Zeroizing::new(
313        private
314            .decrypt(rsa::Oaep::<sha1::Sha1>::new(), &ciphertext)
315            .map_err(|e| anyhow!("RSA-OAEP unwrap failed: {e}"))?,
316    );
317    let legacy = BitwardenLegacyKeyBytes::from(decrypted.to_vec());
318    SymmetricCryptoKey::try_from(&legacy)
319        .map_err(|e| anyhow!("unwrapped {what} is not a symmetric key: {e}"))
320}
321
322/// The EncString type byte of a string, without decoding it: `"2...."` gives
323/// `Some(2)`.
324pub fn enc_string_type(enc: &str) -> Option<u8> {
325    enc.split_once('.').and_then(|(kind, _)| kind.parse::<u8>().ok())
326}
327
328/// Unwrap an attachment key by its EncString type.
329///
330/// Types 0/1/2 are AES-wrapped with the item key. Types 4/6 are RSA-OAEP-SHA1
331/// wrapped with the user's public key, so they need the private key; type 6
332/// also carries an HMAC-SHA256 over `iv | ciphertext` keyed with the item key's
333/// MAC half. The live account uses type 2 for all 19 attachments.
334pub fn unwrap_attachment_key(
335    enc: &str,
336    item_key: &SymmetricCryptoKey,
337    private_key: Option<&rsa::RsaPrivateKey>,
338) -> Result<SymmetricCryptoKey, VaultError> {
339    let (kind, rest) = enc.split_once('.').context("attachment key has no EncString header")?;
340    match kind {
341        "0" | "1" | "2" => {
342            let parsed: EncString =
343                enc.parse().map_err(|e| anyhow!("attachment key EncString parse failed: {e}"))?;
344            let raw = Zeroizing::<Vec<u8>>::new(
345                parsed
346                    .decrypt_with_key(item_key)
347                    .map_err(|e| anyhow!("attachment key decrypt failed: {e}"))?,
348            );
349            let legacy = BitwardenLegacyKeyBytes::from(raw.to_vec());
350            SymmetricCryptoKey::try_from(&legacy).map_err(|e| {
351                VaultError::Crypto(anyhow!("unwrapped attachment key is not a symmetric key: {e}"))
352            })
353        }
354        "4" | "6" => {
355            // A missing private key is not a decrypt failure: the account has
356            // none to unlock, so it gets its own error.
357            let private_key = private_key.ok_or(VaultError::MissingPrivateKey)?;
358            let parts: Vec<&str> = rest.split('|').collect();
359            // `4.<ciphertext>`; `6.<ciphertext>|<mac>`. The MAC covers the
360            // ciphertext only, per bitwarden-crypto's UnsignedSharedKey.
361            let data = parts.first().context("attachment key has no ciphertext part")?;
362            if kind == "6" {
363                let mac = parts.get(1).context("attachment key type 6 has no MAC part")?;
364                verify_type6_mac(data, mac, item_key)?;
365            }
366            rsa_oaep_unwrap(data, private_key, "attachment key").map_err(VaultError::Crypto)
367        }
368        other => Err(VaultError::Crypto(anyhow!(
369            "attachment key is EncString type {other}; no unwrap implemented"
370        ))),
371    }
372}
373
374fn verify_type6_mac(data_b64: &str, mac_b64: &str, item_key: &SymmetricCryptoKey) -> Result<()> {
375    let decode = |value: &str, what: &str| {
376        base64::engine::general_purpose::STANDARD
377            .decode(value)
378            .with_context(|| format!("{what} is not base64"))
379    };
380    let data = decode(data_b64, "type-6 ciphertext")?;
381    let mac = decode(mac_b64, "type-6 MAC")?;
382    let encoded = item_key.to_encoded();
383    let mac_key = encoded.as_ref().get(32..64).context("item key has no MAC half")?;
384    let mut hmac =
385        Hmac::<sha2::Sha256>::new_from_slice(mac_key).map_err(|e| anyhow!("HMAC init: {e}"))?;
386    hmac.update(&data);
387    hmac.verify_slice(&mac).map_err(|_| anyhow!("attachment key type-6 MAC did not verify"))
388}
389
390pub fn decrypt_str(enc: &str, key: &SymmetricCryptoKey) -> Result<String> {
391    let parsed: EncString = enc.parse().map_err(|e| anyhow!("EncString parse failed: {e}"))?;
392    parsed.decrypt_with_key(key).map_err(|e| anyhow!("EncString decrypt failed: {e}"))
393}
394
395pub fn decrypt_bytes(enc: &str, key: &SymmetricCryptoKey) -> Result<Vec<u8>> {
396    let parsed: EncString = enc.parse().map_err(|e| anyhow!("EncString parse failed: {e}"))?;
397    parsed.decrypt_with_key(key).map_err(|e| anyhow!("EncString decrypt failed: {e}"))
398}
399
400/// The account private key is stored as raw PKCS#8 DER by the Bitwarden
401/// clients; older exports and other clients may hold PEM or base64 DER, so
402/// every shape is tried.
403fn parse_private_key(bytes: &[u8]) -> Result<rsa::RsaPrivateKey> {
404    if let Ok(text) = std::str::from_utf8(bytes) {
405        if text.trim_start().starts_with("-----BEGIN") {
406            return rsa::RsaPrivateKey::from_pkcs8_pem(text)
407                .context("parsing the private key as PEM");
408        }
409        if let Ok(der) = base64::engine::general_purpose::STANDARD.decode(text.trim())
410            && let Ok(key) = rsa::RsaPrivateKey::from_pkcs8_der(&Zeroizing::new(der))
411        {
412            return Ok(key);
413        }
414    }
415    rsa::RsaPrivateKey::from_pkcs8_der(bytes).context("parsing the private key as PKCS#8 DER")
416}
417
418/// A cipher's own key, when the item carries one, else the user/org key it
419/// inherits.
420pub fn cipher_key(
421    cipher: &serde_json::Value,
422    base: &SymmetricCryptoKey,
423) -> Result<SymmetricCryptoKey> {
424    match cipher.get("key").and_then(|v| v.as_str()) {
425        Some(enc) if !enc.is_empty() => {
426            let parsed: EncString =
427                enc.parse().map_err(|e| anyhow!("cipher key EncString parse failed: {e}"))?;
428            let raw = Zeroizing::<Vec<u8>>::new(
429                parsed
430                    .decrypt_with_key(base)
431                    .map_err(|e| anyhow!("cipher key decrypt failed: {e}"))?,
432            );
433            let legacy = BitwardenLegacyKeyBytes::from(raw.to_vec());
434            SymmetricCryptoKey::try_from(&legacy)
435                .map_err(|e| anyhow!("cipher key is not a symmetric key: {e}"))
436        }
437        _ => Ok(base.clone()),
438    }
439}
440
441pub fn wrap_symmetric_key(
442    key: &SymmetricCryptoKey,
443    wrapping: &SymmetricCryptoKey,
444) -> Result<String> {
445    let bytes = BitwardenLegacyKeyBytes::from(key.to_encoded().as_ref().to_vec());
446    (&bytes)
447        .encrypt_with_key(wrapping)
448        .map(|enc| enc.to_string())
449        .map_err(|e| anyhow!("wrapping a symmetric key failed: {e}"))
450}
451
452pub fn unwrap_symmetric_key(
453    enc: &str,
454    wrapping: &SymmetricCryptoKey,
455) -> Result<SymmetricCryptoKey> {
456    let parsed: EncString =
457        enc.parse().map_err(|e| anyhow!("wrapped key EncString parse failed: {e}"))?;
458    let raw = Zeroizing::<Vec<u8>>::new(
459        parsed
460            .decrypt_with_key(wrapping)
461            .map_err(|e| anyhow!("wrapped key decrypt failed: {e}"))?,
462    );
463    let legacy = BitwardenLegacyKeyBytes::from(raw.to_vec());
464    SymmetricCryptoKey::try_from(&legacy)
465        .map_err(|e| anyhow!("unwrapped key is not a symmetric key: {e}"))
466}
467
468pub fn encrypt_str(value: &str, key: &SymmetricCryptoKey) -> Result<String> {
469    value
470        .encrypt_with_key(key)
471        .map(|enc| enc.to_string())
472        .map_err(|e| anyhow!("EncString encrypt failed: {e}"))
473}
474
475/// One computed TOTP code and the window it belongs to.
476pub struct Totp {
477    pub code: String,
478    pub period: u64,
479    pub remaining: u64,
480}
481
482/// Compute a TOTP code from the stored `login.totp` value: an otpauth URI, or
483/// a bare base32 secret (Bitwarden accepts both). The seed never leaves the
484/// process.
485pub fn totp_from_uri(uri: &str, now: std::time::SystemTime) -> Result<Totp> {
486    let (secret, digits, period, algorithm) = if uri.trim_start().starts_with("otpauth://") {
487        let parsed = url::Url::parse(uri.trim()).context("parsing the otpauth URI")?;
488        let pairs: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
489        let secret = pairs.get("secret").cloned().context("otpauth URI has no secret")?;
490        let digits = pairs.get("digits").and_then(|v| v.parse().ok()).unwrap_or(6u32);
491        let period = pairs.get("period").and_then(|v| v.parse().ok()).unwrap_or(30u64);
492        let algorithm =
493            pairs.get("algorithm").map_or_else(|| "SHA1".to_owned(), |v| v.to_uppercase());
494        (secret, digits, period, algorithm)
495    } else {
496        (uri.trim().to_owned(), 6u32, 30u64, "SHA1".to_owned())
497    };
498    // The RFCs and every authenticator app issue 6 to 8 digits; a tenth digit
499    // overflows the u32 the truncation computes in.
500    if !(6..=8).contains(&digits) {
501        bail!("TOTP digits out of range: {digits}");
502    }
503    if period == 0 {
504        bail!("TOTP period is zero");
505    }
506    let key = decode_base32(&secret)?;
507    let unix =
508        now.duration_since(std::time::UNIX_EPOCH).context("system clock is before 1970")?.as_secs();
509    let counter = unix / period;
510    let remaining = period - (unix % period);
511
512    let digest = match algorithm.as_str() {
513        "SHA1" => {
514            let mut mac =
515                Hmac::<sha1::Sha1>::new_from_slice(&key).map_err(|e| anyhow!("HMAC init: {e}"))?;
516            mac.update(&counter.to_be_bytes());
517            mac.finalize().into_bytes().to_vec()
518        }
519        "SHA256" => {
520            let mut mac = Hmac::<sha2::Sha256>::new_from_slice(&key)
521                .map_err(|e| anyhow!("HMAC init: {e}"))?;
522            mac.update(&counter.to_be_bytes());
523            mac.finalize().into_bytes().to_vec()
524        }
525        "SHA512" => {
526            let mut mac = Hmac::<sha2::Sha512>::new_from_slice(&key)
527                .map_err(|e| anyhow!("HMAC init: {e}"))?;
528            mac.update(&counter.to_be_bytes());
529            mac.finalize().into_bytes().to_vec()
530        }
531        other => bail!("unsupported TOTP algorithm: {other}"),
532    };
533
534    let offset = usize::from(digest[digest.len() - 1] & 0x0f);
535    let binary = (u32::from(digest[offset] & 0x7f) << 24)
536        | (u32::from(digest[offset + 1]) << 16)
537        | (u32::from(digest[offset + 2]) << 8)
538        | u32::from(digest[offset + 3]);
539    let code = binary % 10u32.pow(digits);
540    Ok(Totp { code: format!("{code:0width$}", width = digits as usize), period, remaining })
541}
542
543fn decode_base32(secret: &str) -> Result<Vec<u8>> {
544    let cleaned: String = secret
545        .chars()
546        .filter(|c| !c.is_whitespace() && *c != '=')
547        .collect::<String>()
548        .to_uppercase();
549    data_encoding::BASE32_NOPAD.decode(cleaned.as_bytes()).context("TOTP secret is not base32")
550}
551
552/// Encrypt an attachment into the uploaded wire form: `0x02 | IV | MAC |
553/// ciphertext`, identical to `EncString::to_buffer`.
554pub fn encrypt_attachment_blob(data: &[u8], key: &SymmetricCryptoKey) -> Result<Vec<u8>> {
555    let typed = OctetStreamBytes::from(data.to_vec());
556    let encrypted =
557        (&typed).encrypt_with_key(key).map_err(|e| anyhow!("attachment encrypt failed: {e}"))?;
558    wire_from_enc_string(&encrypted)
559}
560
561fn wire_from_enc_string(encrypted: &EncString) -> Result<Vec<u8>> {
562    match encrypted {
563        EncString::Aes256Cbc_HmacSha256_B64 { iv, mac, data } => {
564            let mut out = Vec::with_capacity(49 + data.len());
565            out.push(0x02);
566            out.extend_from_slice(iv);
567            out.extend_from_slice(mac);
568            out.extend_from_slice(data);
569            Ok(out)
570        }
571        _ => bail!("attachment encryption did not produce an AES-256-CBC-HMAC string"),
572    }
573}
574
575/// Decrypt a downloaded attachment blob.
576pub fn decrypt_attachment_blob(wire: &[u8], key: &SymmetricCryptoKey) -> Result<Vec<u8>> {
577    if wire.first() != Some(&0x02) {
578        bail!("unknown attachment format header (expected 0x02)");
579    }
580    if wire.len() < 49 + 16 {
581        bail!("attachment blob is too short to be valid");
582    }
583    let iv: [u8; 16] = wire[1..17].try_into().map_err(|_| anyhow!("bad IV length"))?;
584    let mac: [u8; 32] = wire[17..49].try_into().map_err(|_| anyhow!("bad MAC length"))?;
585    let data = wire[49..].to_vec();
586    let encrypted = EncString::Aes256Cbc_HmacSha256_B64 { iv, mac, data };
587    encrypted.decrypt_with_key(key).map_err(|e| anyhow!("attachment decrypt failed: {e}"))
588}
589
590pub fn sha256_hex(data: &[u8]) -> String {
591    data_encoding::HEXLOWER.encode(&Sha256::digest(data))
592}
593
594/// A fresh symmetric key for one attachment, in the algorithm the live
595/// server stores its blobs with.
596pub fn make_symmetric_key() -> SymmetricCryptoKey {
597    SymmetricCryptoKey::make(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac)
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use bitwarden_crypto::SymmetricKeyAlgorithm;
604    use rsa::pkcs8::EncodePrivateKey;
605
606    /// The vector is bitwarden-crypto 4.0.0's own PBKDF2 test: password
607    /// "asdfasdf", salt "test@bitwarden.com", 100k iterations.
608    #[test]
609    fn master_password_hash_matches_the_reference_vector() {
610        let pre =
611            Prelogin { kdf: 0, kdf_iterations: 100_000, kdf_memory: None, kdf_parallelism: None };
612        let master_key = derive_master_key("asdfasdf", "test@bitwarden.com", &pre).unwrap();
613        assert_eq!(
614            master_password_hash(&master_key, "asdfasdf"),
615            "wmyadRMyBZOH7P/a/ucTCbSghKgdzDpPqUnu/DAVtSw="
616        );
617    }
618
619    #[test]
620    fn email_is_trimmed_and_lowercased_as_the_salt() {
621        let pre =
622            Prelogin { kdf: 0, kdf_iterations: 100_000, kdf_memory: None, kdf_parallelism: None };
623        let a = derive_master_key("asdfasdf", "test@bitwarden.com", &pre).unwrap();
624        let b = derive_master_key("asdfasdf", "  TEST@bitwarden.com ", &pre).unwrap();
625        assert_eq!(master_password_hash(&a, "asdfasdf"), master_password_hash(&b, "asdfasdf"));
626    }
627
628    #[test]
629    fn unknown_kdf_is_a_named_error() {
630        let pre = Prelogin { kdf: 7, kdf_iterations: 1, kdf_memory: None, kdf_parallelism: None };
631        assert!(derive_master_key("x", "y", &pre).is_err());
632    }
633
634    /// The live account's private key decrypts to raw PKCS#8 DER, not PEM.
635    #[test]
636    fn unlock_parses_a_private_key_stored_as_raw_der() {
637        let pre =
638            Prelogin { kdf: 0, kdf_iterations: 100_000, kdf_memory: None, kdf_parallelism: None };
639        let master_key = derive_master_key("asdfasdf", "test@bitwarden.com", &pre).unwrap();
640        let (user_key, wrapped_user_key) = master_key.make_user_key().unwrap();
641        let user_key = user_key.0;
642
643        let rsa_key = rsa::RsaPrivateKey::new(&mut bitwarden_random::rng(), 1024).unwrap();
644        let der = rsa_key.to_pkcs8_der().unwrap();
645        let der_bytes = BitwardenLegacyKeyBytes::from(der.as_bytes().to_vec());
646        let wrapped_private_key = (&der_bytes).encrypt_with_key(&user_key).unwrap().to_string();
647
648        let token = TokenResponse {
649            access_token: "token".to_owned(),
650            refresh_token: None,
651            key: Some(wrapped_user_key.to_string()),
652            private_key: Some(wrapped_private_key),
653        };
654        let unlocked = unlock(&token, &master_key, &Profile::default()).unwrap();
655        assert!(unlocked.private_key.is_some());
656    }
657
658    /// The release path: a user key a client holds unlocks the same keys the
659    /// password does, with the same fingerprint. This is what lets a start
660    /// stop crossing the master password.
661    #[test]
662    fn unlock_with_a_released_user_key_matches_the_password_unlock() {
663        let pre =
664            Prelogin { kdf: 0, kdf_iterations: 100_000, kdf_memory: None, kdf_parallelism: None };
665        let master_key = derive_master_key("asdfasdf", "test@bitwarden.com", &pre).unwrap();
666        let (user_key, wrapped_user_key) = master_key.make_user_key().unwrap();
667        let user_key = user_key.0;
668
669        let rsa_key = rsa::RsaPrivateKey::new(&mut bitwarden_random::rng(), 1024).unwrap();
670        let der = rsa_key.to_pkcs8_der().unwrap();
671        let der_bytes = BitwardenLegacyKeyBytes::from(der.as_bytes().to_vec());
672        let wrapped_private_key = (&der_bytes).encrypt_with_key(&user_key).unwrap().to_string();
673        let token = TokenResponse {
674            access_token: "token".to_owned(),
675            refresh_token: None,
676            key: Some(wrapped_user_key.to_string()),
677            private_key: Some(wrapped_private_key),
678        };
679
680        let from_password =
681            unlock(&token, &master_key, &Profile::default()).expect("the password unlock");
682        let released = user_key.to_base64().to_string();
683        let from_release = unlock_with_user_key(&token, &released, &Profile::default())
684            .expect("the release unlock");
685
686        assert_eq!(from_release.user_key, from_password.user_key, "the same user key");
687        assert_eq!(
688            from_release.user_key_fingerprint, from_password.user_key_fingerprint,
689            "the same wrapped-key fingerprint, which the rotation check reads",
690        );
691        assert!(from_release.private_key.is_some(), "the private key unwraps from the release");
692        assert_eq!(from_release.failed_orgs, from_password.failed_orgs);
693    }
694
695    /// A released user key that is not a symmetric key is refused rather than
696    /// guessed at.
697    #[test]
698    fn a_released_user_key_that_is_not_a_key_is_refused() {
699        let token = TokenResponse {
700            access_token: "t".to_owned(),
701            refresh_token: None,
702            key: Some("2.x|y|z".to_owned()),
703            private_key: None,
704        };
705        assert!(unlock_with_user_key(&token, "not a key", &Profile::default()).is_err());
706    }
707
708    /// **A well-formed key that is not the account's is refused.** With the
709    /// account's private key present, a valid user key from elsewhere cannot
710    /// decrypt it, so the release path does not accept a key on shape alone.
711    #[test]
712    fn a_mismatched_but_valid_user_key_is_refused() {
713        let pre =
714            Prelogin { kdf: 0, kdf_iterations: 100_000, kdf_memory: None, kdf_parallelism: None };
715        let account = derive_master_key("asdfasdf", "test@bitwarden.com", &pre).unwrap();
716        let (user_key, wrapped_user_key) = account.make_user_key().unwrap();
717        let rsa_key = rsa::RsaPrivateKey::new(&mut bitwarden_random::rng(), 1024).unwrap();
718        let der = rsa_key.to_pkcs8_der().unwrap();
719        let der_bytes = BitwardenLegacyKeyBytes::from(der.as_bytes().to_vec());
720        let wrapped_private_key = (&der_bytes).encrypt_with_key(&user_key.0).unwrap().to_string();
721        let token = TokenResponse {
722            access_token: "token".to_owned(),
723            refresh_token: None,
724            key: Some(wrapped_user_key.to_string()),
725            private_key: Some(wrapped_private_key),
726        };
727
728        let elsewhere = derive_master_key("another password", "other@example.com", &pre).unwrap();
729        let (wrong, _) = elsewhere.make_user_key().unwrap();
730        let refused =
731            unlock_with_user_key(&token, &wrong.0.to_base64().to_string(), &Profile::default());
732
733        assert!(refused.is_err(), "a valid key from another account is refused");
734    }
735
736    /// An empty `PrivateKey` on the token is a client's unset value, not a
737    /// wrapped key; it must not hide the profile's real one.
738    #[test]
739    fn an_empty_token_private_key_falls_back_to_the_profile_key() {
740        let pre =
741            Prelogin { kdf: 0, kdf_iterations: 100_000, kdf_memory: None, kdf_parallelism: None };
742        let master_key = derive_master_key("asdfasdf", "test@bitwarden.com", &pre).unwrap();
743        let (user_key, wrapped_user_key) = master_key.make_user_key().unwrap();
744        let user_key = user_key.0;
745
746        let rsa_key = rsa::RsaPrivateKey::new(&mut bitwarden_random::rng(), 1024).unwrap();
747        let der = rsa_key.to_pkcs8_der().unwrap();
748        let der_bytes = BitwardenLegacyKeyBytes::from(der.as_bytes().to_vec());
749        let wrapped_private_key = (&der_bytes).encrypt_with_key(&user_key).unwrap().to_string();
750
751        let token = TokenResponse {
752            access_token: "token".to_owned(),
753            refresh_token: None,
754            key: Some(wrapped_user_key.to_string()),
755            private_key: Some(String::new()),
756        };
757        let profile = Profile { private_key: Some(wrapped_private_key), ..Profile::default() };
758
759        let unlocked = unlock(&token, &master_key, &profile).expect("the profile key is used");
760
761        assert!(unlocked.private_key.is_some(), "the profile private key was unwrapped");
762    }
763
764    /// The org-key unwrap is the one hand-rolled crypto path; round-trip it the
765    /// way Vaultwarden stores `profile.organizations[].key` (EncString type 4).
766    #[test]
767    fn org_key_unwrap_round_trips_a_type_4_wrap() {
768        let rsa_key = rsa::RsaPrivateKey::new(&mut bitwarden_random::rng(), 2048).unwrap();
769        let public_key = rsa_key.to_public_key();
770        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
771        let encoded = key.to_encoded();
772        let ciphertext = public_key
773            .encrypt(&mut bitwarden_random::rng(), rsa::Oaep::<sha1::Sha1>::new(), encoded.as_ref())
774            .unwrap();
775
776        let data = base64::engine::general_purpose::STANDARD.encode(ciphertext);
777        let unwrapped = unwrap_org_key(&format!("4.{data}"), &rsa_key).unwrap();
778
779        assert_eq!(unwrapped, key);
780    }
781
782    #[test]
783    fn org_key_of_a_non_rsa_type_is_refused() {
784        let rsa_key = rsa::RsaPrivateKey::new(&mut bitwarden_random::rng(), 1024).unwrap();
785        let err = unwrap_org_key("2.abc|def|ghi", &rsa_key).unwrap_err().to_string();
786        assert!(err.contains("type 2"), "unexpected error: {err}");
787    }
788
789    #[test]
790    fn totp_matches_the_rfc_6238_sha1_vector() {
791        // RFC 6238 Appendix B, SHA1: secret "12345678901234567890", T=59 ->
792        // 94287082 (8 digits).
793        let at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(59);
794        let totp = totp_from_uri(
795            "otpauth://totp/spike?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&digits=8&period=30",
796            at,
797        )
798        .unwrap();
799        assert_eq!(totp.code, "94287082");
800        assert_eq!(totp.period, 30);
801        assert_eq!(totp.remaining, 1);
802    }
803
804    #[test]
805    fn totp_rejects_a_non_base32_secret() {
806        let at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(59);
807        assert!(totp_from_uri("otpauth://totp/x?secret=not!base32", at).is_err());
808    }
809
810    /// A tenth digit overflows the u32 the truncation computes in; the range
811    /// is the 6 to 8 every issuer actually uses.
812    #[test]
813    fn totp_rejects_a_digit_count_outside_six_to_eight() {
814        let at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(59);
815        let uri = |digits: u32| {
816            format!("otpauth://totp/x?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&digits={digits}")
817        };
818        let overflow = totp_from_uri(&uri(10), at).err().expect("ten digits must not overflow");
819        assert!(
820            overflow.to_string().contains("digits out of range"),
821            "the refusal names the bound: {overflow}"
822        );
823        assert!(totp_from_uri(&uri(5), at).is_err());
824        assert!(totp_from_uri(&uri(6), at).is_ok());
825        assert!(totp_from_uri(&uri(8), at).is_ok());
826    }
827
828    /// Argon2id without its memory and parallelism is not a parameter set;
829    /// defaulting would derive a key the account does not hold.
830    #[test]
831    fn argon2id_requires_its_parameters() {
832        let missing =
833            Prelogin { kdf: 1, kdf_iterations: 3, kdf_memory: None, kdf_parallelism: Some(4) };
834        assert!(derive_master_key("x", "y", &missing).is_err());
835        let missing_parallelism =
836            Prelogin { kdf: 1, kdf_iterations: 3, kdf_memory: Some(64), kdf_parallelism: None };
837        assert!(derive_master_key("x", "y", &missing_parallelism).is_err());
838        let complete =
839            Prelogin { kdf: 1, kdf_iterations: 3, kdf_memory: Some(64), kdf_parallelism: Some(4) };
840        assert!(derive_master_key("x", "y", &complete).is_ok());
841    }
842
843    /// An empty `Key` field is a client's unset value, not a wrapped key, and
844    /// must not shadow the profile's real one.
845    #[test]
846    fn an_empty_token_key_falls_back_to_the_profile_key() {
847        let pre =
848            Prelogin { kdf: 0, kdf_iterations: 100_000, kdf_memory: None, kdf_parallelism: None };
849        let master_key = derive_master_key("asdfasdf", "test@bitwarden.com", &pre).unwrap();
850        let (user_key, wrapped_user_key) = master_key.make_user_key().unwrap();
851        drop(user_key);
852
853        let token = TokenResponse {
854            access_token: "token".to_owned(),
855            refresh_token: None,
856            key: Some(String::new()),
857            private_key: None,
858        };
859        let profile = Profile { key: Some(wrapped_user_key.to_string()), ..Profile::default() };
860
861        let unlocked = unlock(&token, &master_key, &profile).expect("the profile key is used");
862
863        assert!(unlocked.failed_orgs.is_empty());
864    }
865
866    /// An organization cipher whose key this unlock did not get is refused by
867    /// name rather than encrypted or decrypted with the user key.
868    #[test]
869    fn an_organization_cipher_without_its_key_is_refused() {
870        let unlocked = Unlocked {
871            user_key: SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
872            private_key: None,
873            org_keys: HashMap::new(),
874            failed_orgs: vec!["org-1".to_owned()],
875            user_key_fingerprint: String::new(),
876        };
877        let org_cipher = serde_json::json!({ "organizationId": "org-1", "name": "x" });
878
879        let error = unlocked.decrypt_item_str(&org_cipher, "2.aGk=|ZGF0YQ==|bWFj").unwrap_err();
880
881        assert!(matches!(&error, VaultError::MissingKey(org_id) if org_id == "org-1"), "{error}");
882
883        let personal = serde_json::json!({ "name": "x" });
884        let enc = encrypt_str("hello", &unlocked.user_key).unwrap();
885        assert_eq!(unlocked.decrypt_item_str(&personal, &enc).unwrap(), "hello");
886    }
887
888    /// A failed org unwrap is recorded by id, so the session can report it and
889    /// its ciphers fail closed.
890    #[test]
891    fn a_failed_org_key_is_recorded_by_id() {
892        let rsa_key = rsa::RsaPrivateKey::new(&mut bitwarden_random::rng(), 1024).unwrap();
893        let profile = Profile {
894            organizations: vec![crate::vault::models::Organization {
895                id: "org-1".to_owned(),
896                key: Some("2.aGk=|ZGF0YQ==|bWFj".to_owned()),
897                name: None,
898            }],
899            ..Profile::default()
900        };
901
902        let (keys, failed) = org_keys(Some(&rsa_key), &profile);
903
904        assert!(keys.is_empty());
905        assert_eq!(failed, vec!["org-1"]);
906    }
907
908    #[test]
909    fn attachment_blob_round_trips_and_rejects_tampering() {
910        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
911        let data = b"vault attachment contents, round trip";
912        let wire = encrypt_attachment_blob(data, &key).unwrap();
913        assert_eq!(wire[0], 0x02);
914        assert_eq!(decrypt_attachment_blob(&wire, &key).unwrap(), data);
915
916        let mut tampered = wire.clone();
917        let last = tampered.len() - 1;
918        tampered[last] ^= 0xff;
919        assert!(decrypt_attachment_blob(&tampered, &key).is_err());
920    }
921
922    /// The live failure shape: a 47-byte plaintext becomes 48 bytes of CBC
923    /// ciphertext, so the stored blob is exactly 97 bytes with the 0x02 header.
924    #[test]
925    fn attachment_wire_layout_is_discriminator_iv_mac_ciphertext() {
926        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
927        let data = b"layout";
928        let typed = OctetStreamBytes::from(data.to_vec());
929        let encrypted = (&typed).encrypt_with_key(&key).unwrap();
930        let EncString::Aes256Cbc_HmacSha256_B64 { iv, mac, data: ciphertext } = &encrypted else {
931            panic!("expected an AES-256-CBC-HMAC EncString");
932        };
933        let mut expected = vec![0x02];
934        expected.extend_from_slice(iv);
935        expected.extend_from_slice(mac);
936        expected.extend_from_slice(ciphertext);
937
938        assert_eq!(wire_from_enc_string(&encrypted).unwrap(), expected);
939        assert_eq!(&expected[1..17], iv.as_slice());
940        assert_eq!(&expected[17..49], mac.as_slice());
941
942        let live_shape = encrypt_attachment_blob(&[b'x'; 47], &key).unwrap();
943        assert_eq!(live_shape.len(), 97);
944        assert_eq!(live_shape[0], 0x02);
945        assert_eq!(decrypt_attachment_blob(&live_shape, &key).unwrap(), vec![b'x'; 47]);
946    }
947
948    #[test]
949    fn attachment_key_unwraps_when_aes_wrapped_as_type_2() {
950        let item_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
951        let attachment_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
952        let wrapped = wrap_symmetric_key(&attachment_key, &item_key).unwrap();
953        let unwrapped = unwrap_attachment_key(&wrapped, &item_key, None).unwrap();
954        assert_eq!(unwrapped, attachment_key);
955    }
956
957    /// The real RSA layout: `4.<ciphertext>`, one part, no IV. A parser that
958    /// expected an IV at index 0 failed every org and attachment unwrap.
959    #[test]
960    fn attachment_key_unwraps_when_rsa_wrapped_as_type_4() {
961        use base64::Engine;
962        let item_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
963        let attachment_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
964        let rsa_key = rsa::RsaPrivateKey::new(&mut bitwarden_random::rng(), 2048).unwrap();
965        let public = rsa_key.to_public_key();
966        let encoded = attachment_key.to_encoded();
967        let ciphertext = public
968            .encrypt(&mut bitwarden_random::rng(), rsa::Oaep::<sha1::Sha1>::new(), encoded.as_ref())
969            .unwrap();
970        let data = base64::engine::general_purpose::STANDARD.encode(ciphertext);
971        let wrapped = format!("4.{data}");
972
973        let unwrapped = unwrap_attachment_key(&wrapped, &item_key, Some(&rsa_key)).unwrap();
974        assert_eq!(unwrapped, attachment_key);
975    }
976
977    /// The type-6 layout is `6.<ciphertext>|<mac>`, and the MAC covers the
978    /// ciphertext only.
979    #[test]
980    fn attachment_key_unwraps_when_rsa_wrapped_as_type_6_and_rejects_a_bad_mac() {
981        use base64::Engine;
982        use hmac::{Hmac, KeyInit, Mac};
983
984        let item_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
985        let attachment_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
986        let rsa_key = rsa::RsaPrivateKey::new(&mut bitwarden_random::rng(), 2048).unwrap();
987        let public = rsa_key.to_public_key();
988        let encoded = attachment_key.to_encoded();
989        let ciphertext = public
990            .encrypt(&mut bitwarden_random::rng(), rsa::Oaep::<sha1::Sha1>::new(), encoded.as_ref())
991            .unwrap();
992        let composite = item_key.to_encoded();
993        let mut hmac = Hmac::<sha2::Sha256>::new_from_slice(&composite.as_ref()[32..64]).unwrap();
994        hmac.update(&ciphertext);
995        let mac = hmac.finalize().into_bytes();
996        let engine = base64::engine::general_purpose::STANDARD;
997        let wrapped = format!("6.{}|{}", engine.encode(&ciphertext), engine.encode(mac));
998
999        let unwrapped = unwrap_attachment_key(&wrapped, &item_key, Some(&rsa_key)).unwrap();
1000        assert_eq!(unwrapped, attachment_key);
1001
1002        let mut bad_mac = mac.to_vec();
1003        bad_mac[0] ^= 0xff;
1004        let bad = format!("6.{}|{}", engine.encode(&ciphertext), engine.encode(bad_mac));
1005        let error = unwrap_attachment_key(&bad, &item_key, Some(&rsa_key))
1006            .expect_err("a tampered MAC is refused");
1007        assert!(
1008            error.to_string().contains("MAC did not verify"),
1009            "the refusal names its own reason: {error}"
1010        );
1011    }
1012
1013    /// An RSA-wrapped attachment key with no private key is not a decrypt
1014    /// failure: the account has none, and it is named as such.
1015    #[test]
1016    fn an_rsa_wrapped_attachment_key_without_a_private_key_is_named() {
1017        let item_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
1018
1019        let error = unwrap_attachment_key("4.aGk=", &item_key, None).unwrap_err();
1020
1021        assert!(matches!(error, VaultError::MissingPrivateKey), "{error}");
1022    }
1023
1024    #[test]
1025    fn enc_string_type_reads_the_header_byte_only() {
1026        assert_eq!(enc_string_type("2.aGk=|ZGF0YQ==|bWFj"), Some(2));
1027        assert_eq!(enc_string_type("4.aGk=|ZGF0YQ=="), Some(4));
1028        assert_eq!(enc_string_type("not-an-encstring"), None);
1029    }
1030
1031    #[test]
1032    fn sha256_hex_matches_the_known_digest_of_abc() {
1033        assert_eq!(
1034            sha256_hex(b"abc"),
1035            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
1036        );
1037    }
1038}