Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-crypt/src/userkey.rs

//! **The user's encryption key**: one key pair per user, the same for every
//! repository, opened by any one of the user's unlock methods.
//!
//! ```text
//! unlock methods (each is one age file on the server, wrapping the SAME user key)
//!   SSH key file ──────────────┐  age ssh-ed25519 / ssh-rsa stanza      (CLI)
//!   security key (PRF secret) ─┤  v2: HKDF-SHA256 → XChaCha20-Poly1305    (browser + CLI)
//!                              │  (v1, still opened: HKDF → X25519 → age X25519)
//!   age1pq1… key file ─────────┤  age mlkem768x25519 stanza              (CLI)
//!   printed recovery code ─────┘  age scrypt stanza                      (CLI + browser)
//!          │
//!          ▼
//! user key (AGE-SECRET-KEY-PQ-1…, quantum-safe: the default; or AGE-SECRET-KEY-1…, classic)
//!          │ age X25519 (or mlkem768x25519) stanza: one per member in .gunnar-crypt/keys
//!          ▼
//! repository key ring (id key + epochs)
//! ```
//!
//! * **A repository names only user public keys** (`alice age1…` in
//!   `.gunnar-crypt/members`). Adding or removing an unlock method changes one
//!   wrap of the user key and no repository at all.
//! * **The server keeps the user's public key and the wraps**, which are
//!   ciphertext to it. It never sees a method's secret: an SSH private key, the
//!   security key's PRF output or the recovery code.
//! * **A security key's recipient is fixed per credential**: the PRF output
//!   (WebAuthn `prf`, FIDO2 `hmac-secret` under the CLI) is evaluated on the
//!   fixed [`security_key::PRF_INPUT`], and HKDF-SHA256 with a fixed label and
//!   the credential id as context makes the X25519 secret. The same key and
//!   credential always give the same `age1…` recipient, in the browser and in
//!   the CLI.
//! * **No primitive is ours**: age's X25519, SSH, scrypt and (feature `pq`)
//!   hybrid stanzas do every wrap; RustCrypto `hkdf` and `sha2` do the
//!   derivation.
//! * **A new key is quantum-safe** ([`Level::NEW`], CRYPT-PQ-DEFAULT
//!   2026-10-30). Every method opens it. Each method's OWN wrap is as strong as
//!   the method ([`wrap_is_quantum_safe`]): the `age1pq1…` key file (hybrid
//!   stanza), the recovery code (scrypt, symmetric) and the security key's v2
//!   wrap (symmetric, CRYPT-PQ-SK) are quantum-safe; the SSH key's wrap
//!   (X25519 or RSA-OAEP) is not, and the CLI and the console say so
//!   ([`SSH_WRAP_IS_CLASSICAL`]). A security key's v1 wrap (X25519) still
//!   opens and is re-wrapped to v2 on the next unlock.

use std::io::{Read, Write};

use age::armor::{ArmoredReader, ArmoredWriter, Format};
use age::secrecy::{ExposeSecret as _, SecretString};
use zeroize::Zeroizing;

use crate::members::Level;

/// The first line of a wrapped user key's payload: a type tag, so a wrap that
/// opens to anything else is refused.
pub const PAYLOAD_TAG: &str = "gunnar-crypt user-key v1";

/// The largest wrap anyone parses (a hybrid stanza is ~1.6 KB).
pub const MAX_WRAP: usize = 64 << 10;

/// **Is an unlock method's wrap of the key quantum-safe?** By the method's
/// kind on the wire and, for a security key, the wrap itself: `age-pq` (age's
/// hybrid ML-KEM-768 + X25519 stanza), `recovery-code` (age's scrypt) and a
/// security key's v2 wrap ([`crate::skwrap`]: symmetric, HKDF over the PRF
/// output, XChaCha20-Poly1305) are, since a quantum computer only halves a
/// symmetric key; `ssh` (X25519 or RSA-OAEP) and a security key's v1 wrap (an
/// X25519 stanza) are not, and neither is any kind this version does not know.
pub fn wrap_is_quantum_safe(method_kind: &str, wrap: &str) -> bool {
    match method_kind {
        "age-pq" | "recovery-code" => true,
        "security-key" => crate::skwrap::is_v2(wrap),
        _ => false,
    }
}

/// Which of a security key's wraps opened ([`open_with_security_key`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecurityKeyWrap {
    /// v1: an age X25519 stanza to the credential's derived recipient. Not
    /// quantum-safe: re-wrap it to v2 ([`UserKey::wrap_to_security_key_v2`]).
    V1,
    /// v2: symmetric under HKDF of the PRF output ([`crate::skwrap`]).
    V2,
}

/// **Open a security key's wrap** (v2, or a v1 left from before CRYPT-PQ-SK)
/// with the credential's PRF / hmac-secret output, and check the key inside
/// against the account's published public half. Says which wrap it was: a v1
/// one is re-wrapped to v2 by the caller.
pub fn open_with_security_key(
    wrap: &[u8],
    prf_output: &[u8; 32],
    credential_id: &[u8],
    expected_public: &str,
) -> Result<(UserKey, SecurityKeyWrap), UserKeyError> {
    use chacha20poly1305::aead::{Aead as _, Payload};
    use chacha20poly1305::KeyInit as _;
    match std::str::from_utf8(wrap)
        .ok()
        .filter(|t| crate::skwrap::is_v2(t))
    {
        Some(text) => {
            let parts = crate::skwrap::parse(text).ok_or_else(|| {
                UserKeyError::Malformed("not a well-formed security-key wrap v2".into())
            })?;
            let key = security_key::wrap_key(prf_output, &parts.salt);
            let cipher =
                chacha20poly1305::XChaCha20Poly1305::new(&chacha20poly1305::Key::from(*key));
            let aad = crate::skwrap::aad(credential_id);
            let plain = Zeroizing::new(
                cipher
                    .decrypt(
                        &chacha20poly1305::XNonce::from(parts.nonce),
                        Payload {
                            msg: &parts.sealed,
                            aad: &aad,
                        },
                    )
                    .map_err(|_| UserKeyError::WrongKey)?,
            );
            let text = std::str::from_utf8(&plain).map_err(|_| UserKeyError::NotAUserKey)?;
            let key = UserKey::from_payload(text)?;
            if key.public() != expected_public.trim() {
                return Err(UserKeyError::NotThisAccountsKey);
            }
            Ok((key, SecurityKeyWrap::V2))
        }
        None => open(
            wrap,
            &security_key::identity(prf_output, credential_id),
            expected_public,
        )
        .map(|k| (k, SecurityKeyWrap::V1)),
    }
}

/// **What the CLI and the console say when an SSH key opens a quantum-safe
/// key.** The key stays quantum-safe and so do the repositories; this one wrap
/// does not, and nothing falls back to a classic key.
pub const SSH_WRAP_IS_CLASSICAL: &str =
    "Your encryption key is quantum-safe, but the SSH key's wrap of \
    it is not quantum-safe: age wraps it to an ed25519 key with X25519 and to an RSA key with \
    RSA-OAEP, which a quantum computer breaks, and Gunnar keeps that wrap. Your repositories stay \
    quantum-safe; this one way to open your key does not. To keep the key quantum-safe at the \
    server too, add a quantum-safe way to open it (`gunnar crypt keygen`, then `gunnar crypt key \
    add-pq`; a security key, `gunnar crypt key add-security-key`; or a printed recovery code, \
    `gunnar crypt key add-recovery-code`), then remove the SSH \
    one (`gunnar crypt key remove <id>`).";

/// One user's encryption key (the private half).
// One key per person, held once per command: a box would buy nothing (OX-GUNBR 2026-10-28,
// clippy -p gunnar-crypt).
#[allow(clippy::large_enum_variant)]
pub enum UserKey {
    /// An age X25519 key: classic, for classic repositories (the opt-out).
    X25519(age::x25519::Identity),
    /// age's hybrid ML-KEM-768 + X25519 key, for quantum-safe repositories:
    /// what a new key is unless asked otherwise ([`Level::NEW`]).
    #[cfg(feature = "pq")]
    Pq(crate::pq::Identity),
}

impl std::fmt::Debug for UserKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "UserKey({})", self.public())
    }
}

impl UserKey {
    /// A new user key for repositories of `level` ([`Level::NEW`] unless the
    /// person asked for a classic one).
    pub fn generate(level: Level) -> Result<UserKey, UserKeyError> {
        match level {
            Level::Classic => Ok(UserKey::X25519(age::x25519::Identity::generate())),
            #[cfg(feature = "pq")]
            Level::QuantumSafe => Ok(UserKey::Pq(crate::pq::Identity::generate())),
            #[cfg(not(feature = "pq"))]
            Level::QuantumSafe => Err(UserKeyError::QuantumSafeNeedsTheCli),
        }
    }

    /// The public half: what a members line and the server hold
    /// (`age1…`, or `age1pq1…`).
    pub fn public(&self) -> String {
        match self {
            UserKey::X25519(id) => id.to_public().to_string(),
            #[cfg(feature = "pq")]
            UserKey::Pq(id) => id.to_public().to_string(),
        }
    }

    /// The level of repositories this key can be a member of.
    pub fn level(&self) -> Level {
        match self {
            UserKey::X25519(_) => Level::Classic,
            #[cfg(feature = "pq")]
            UserKey::Pq(_) => Level::QuantumSafe,
        }
    }

    /// As an age identity: what opens a repository's key ring.
    pub fn identity(&self) -> &dyn age::Identity {
        match self {
            UserKey::X25519(id) => id,
            #[cfg(feature = "pq")]
            UserKey::Pq(id) => id,
        }
    }

    fn payload(&self) -> Zeroizing<String> {
        let secret = match self {
            UserKey::X25519(id) => id.to_string(),
            #[cfg(feature = "pq")]
            UserKey::Pq(id) => id.to_secret_string(),
        };
        Zeroizing::new(format!("{PAYLOAD_TAG}\n{}\n", secret.expose_secret()))
    }

    fn from_payload(text: &str) -> Result<UserKey, UserKeyError> {
        let mut lines = text.lines();
        if lines.next() != Some(PAYLOAD_TAG) {
            return Err(UserKeyError::NotAUserKey);
        }
        let secret = lines.next().map(str::trim).unwrap_or_default();
        if lines.any(|l| !l.trim().is_empty()) {
            return Err(UserKeyError::NotAUserKey);
        }
        if secret.starts_with("AGE-SECRET-KEY-PQ-1") {
            #[cfg(feature = "pq")]
            {
                return crate::pq::parse_identity_file(secret)
                    .map(UserKey::Pq)
                    .map_err(|_| UserKeyError::NotAUserKey);
            }
            #[cfg(not(feature = "pq"))]
            return Err(UserKeyError::QuantumSafeNeedsTheCli);
        }
        if secret.starts_with("AGE-SECRET-KEY-1") {
            return secret
                .parse::<age::x25519::Identity>()
                .map(UserKey::X25519)
                .map_err(|_| UserKeyError::NotAUserKey);
        }
        Err(UserKeyError::NotAUserKey)
    }

    /// Wrap this key to one unlock method's age recipient: one armored age
    /// file, to be stored on the server as the method's wrap.
    pub fn wrap_to(&self, recipient: &dyn age::Recipient) -> Result<String, UserKeyError> {
        let enc = age::Encryptor::with_recipients(std::iter::once(recipient))
            .map_err(|e| UserKeyError::Age(e.to_string()))?;
        seal(enc, &self.payload())
    }

    /// Wrap this key to a security key's recipient (the `age1…` string
    /// [`security_key::recipient`] gave at enrolment).
    pub fn wrap_to_security_key(&self, recipient: &str) -> Result<String, UserKeyError> {
        let r = recipient
            .trim()
            .parse::<age::x25519::Recipient>()
            .map_err(|_| UserKeyError::BadRecipient(recipient.trim().to_string()))?;
        self.wrap_to(&r)
    }

    /// **Wrap this key to a security key, v2** (CRYPT-PQ-SK): a fresh 32-byte
    /// salt, `HKDF-SHA256(salt, prf_output, "gunnar-crypt user-key wrap v2")`
    /// as the key, XChaCha20-Poly1305 with a fresh nonce over the key's
    /// payload, the credential id bound in the associated data. Symmetric, so
    /// quantum-safe; no X25519 step.
    pub fn wrap_to_security_key_v2(
        &self,
        prf_output: &[u8; 32],
        credential_id: &[u8],
    ) -> Result<String, UserKeyError> {
        use chacha20poly1305::aead::{Aead as _, Payload};
        use chacha20poly1305::KeyInit as _;
        let salt = crate::random_bytes::<{ crate::skwrap::SALT_BYTES }>();
        let nonce = crate::random_bytes::<{ crate::skwrap::NONCE_BYTES }>();
        let key = security_key::wrap_key(prf_output, &salt);
        let cipher = chacha20poly1305::XChaCha20Poly1305::new(&chacha20poly1305::Key::from(*key));
        let aad = crate::skwrap::aad(credential_id);
        let payload = self.payload();
        let sealed = cipher
            .encrypt(
                &chacha20poly1305::XNonce::from(nonce),
                Payload {
                    msg: payload.as_bytes(),
                    aad: &aad,
                },
            )
            .map_err(|_| UserKeyError::Age("XChaCha20-Poly1305 refused the payload".into()))?;
        Ok(crate::skwrap::format(&crate::skwrap::Parts {
            salt,
            nonce,
            sealed,
        }))
    }

    /// Wrap this key to a printed recovery code: age's scrypt stanza at the
    /// FIXED work factor [`recovery_code::LOG_N`] (scrypt N = 2^18, r = 8,
    /// p = 1: about 256 MiB and a second), on top of the code's 160 random bits.
    pub fn wrap_to_recovery_code(&self, code: &str) -> Result<String, UserKeyError> {
        let normal = recovery_code::normalize(code).ok_or(UserKeyError::BadRecoveryCode)?;
        let mut r = age::scrypt::Recipient::new(SecretString::from(normal.to_string()));
        r.set_work_factor(recovery_code::LOG_N);
        let enc = age::Encryptor::with_recipients(std::iter::once(&r as &dyn age::Recipient))
            .map_err(|e| UserKeyError::Age(e.to_string()))?;
        seal(enc, &self.payload())
    }

    /// Wrap this key to an SSH public key line (`ssh-ed25519 …`, `ssh-rsa …`).
    /// The wrap is age's SSH stanza (X25519 for ed25519, RSA-OAEP for RSA):
    /// CLASSICAL, also for a quantum-safe key ([`SSH_WRAP_IS_CLASSICAL`]).
    #[cfg(feature = "wrap")]
    pub fn wrap_to_ssh(&self, public_key: &str) -> Result<String, UserKeyError> {
        let r = public_key
            .trim()
            .parse::<age::ssh::Recipient>()
            .map_err(|_| UserKeyError::BadRecipient(public_key.trim().to_string()))?;
        self.wrap_to(&r)
    }

    /// Wrap this key to an `age1pq1…` key (a key file made by
    /// `gunnar crypt keygen`).
    #[cfg(feature = "pq")]
    pub fn wrap_to_pq(&self, public_key: &str) -> Result<String, UserKeyError> {
        let r = public_key
            .trim()
            .parse::<crate::pq::Recipient>()
            .map_err(|_| UserKeyError::BadRecipient(public_key.trim().to_string()))?;
        self.wrap_to(&r)
    }
}

fn seal(enc: age::Encryptor, payload: &str) -> Result<String, UserKeyError> {
    let age_err = |e: std::io::Error| UserKeyError::Age(e.to_string());
    let mut out = Vec::new();
    let armor = ArmoredWriter::wrap_output(&mut out, Format::AsciiArmor).map_err(age_err)?;
    let mut w = enc.wrap_output(armor).map_err(age_err)?;
    w.write_all(payload.as_bytes()).map_err(age_err)?;
    w.finish().and_then(|a| a.finish()).map_err(age_err)?;
    String::from_utf8(out).map_err(|e| UserKeyError::Age(e.to_string()))
}

/// Why a user key could not be wrapped or opened. Every refusal has a name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UserKeyError {
    /// The method's key is not the one this wrap is for: the wrong SSH key,
    /// another security key, a mistyped recovery code.
    WrongKey,
    /// Not a readable age file.
    Malformed(String),
    /// It opened, but what is inside is not a user key.
    NotAUserKey,
    /// It opened to a user key, but not the one the account publishes: a
    /// wrap substituted ALONE is refused. A server that replaces both the
    /// public half and the wrap is caught only where the public half is
    /// pinned: the CLI's pin (gunnar-cli `crypt_key::pins`, RA4-1).
    NotThisAccountsKey,
    /// A recipient string that is not one.
    BadRecipient(String),
    /// A recovery code that is not one (wrong length or characters).
    BadRecoveryCode,
    /// A quantum-safe user key in a build without feature `pq`.
    QuantumSafeNeedsTheCli,
    /// age itself failed.
    Age(String),
}

impl std::fmt::Display for UserKeyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            UserKeyError::WrongKey => write!(
                f,
                "this key does not open your encryption key: it is not one of your unlock methods \
                 (the wrong SSH key, another security key, or a mistyped recovery code)"
            ),
            UserKeyError::Malformed(e) => write!(f, "the stored wrap of your encryption key is not readable: {e}"),
            UserKeyError::NotAUserKey => write!(f, "the stored wrap opened, but it does not hold an encryption key; it is refused"),
            UserKeyError::NotThisAccountsKey => write!(
                f,
                "the stored wrap holds an encryption key that is not your account's: it is refused \
                 (someone replaced it on the server)"
            ),
            UserKeyError::BadRecipient(r) => write!(f, "{r:?} is not a key the encryption key can be wrapped to"),
            UserKeyError::BadRecoveryCode => write!(
                f,
                "that is not a recovery code: it is {} characters from 0-9 and A-Z (no I, L, O or U), in groups of four",
                recovery_code::CHARS
            ),
            UserKeyError::QuantumSafeNeedsTheCli => write!(
                f,
                "this build cannot open a quantum-safe encryption key (gunnar-crypt without its `pq` \
                 feature); the Gunnar CLI and the console can"
            ),
            UserKeyError::Age(e) => write!(f, "age: {e}"),
        }
    }
}
impl std::error::Error for UserKeyError {}

/// Open a stored wrap with one method's age identity, and check the key
/// inside against the account's published public key (`expected_public`).
pub fn open(
    wrap: &[u8],
    identity: &dyn age::Identity,
    expected_public: &str,
) -> Result<UserKey, UserKeyError> {
    let key = open_unchecked(wrap, std::iter::once(identity))?;
    if key.public() != expected_public.trim() {
        return Err(UserKeyError::NotThisAccountsKey);
    }
    Ok(key)
}

/// Open a stored wrap with a printed recovery code.
pub fn open_with_recovery_code(
    wrap: &[u8],
    code: &str,
    expected_public: &str,
) -> Result<UserKey, UserKeyError> {
    let normal = recovery_code::normalize(code).ok_or(UserKeyError::BadRecoveryCode)?;
    let mut id = age::scrypt::Identity::new(SecretString::from(normal.to_string()));
    id.set_max_work_factor(recovery_code::LOG_N);
    open(wrap, &id, expected_public)
}

fn open_unchecked<'a>(
    wrap: &[u8],
    identities: impl Iterator<Item = &'a dyn age::Identity>,
) -> Result<UserKey, UserKeyError> {
    if wrap.len() > MAX_WRAP {
        return Err(UserKeyError::Malformed(format!(
            "larger than {} KiB",
            MAX_WRAP >> 10
        )));
    }
    let decryptor = age::Decryptor::new_buffered(ArmoredReader::new(wrap))
        .map_err(|e| UserKeyError::Malformed(e.to_string()))?;
    let mut reader = match decryptor.decrypt(identities) {
        Ok(r) => r,
        Err(age::DecryptError::NoMatchingKeys | age::DecryptError::DecryptionFailed) => {
            return Err(UserKeyError::WrongKey)
        }
        Err(e) => return Err(UserKeyError::Malformed(e.to_string())),
    };
    let mut buf = Zeroizing::new(Vec::new());
    reader
        .by_ref()
        .take(MAX_WRAP as u64)
        .read_to_end(&mut buf)
        .map_err(|e| UserKeyError::Malformed(e.to_string()))?;
    let text = std::str::from_utf8(&buf).map_err(|_| UserKeyError::NotAUserKey)?;
    UserKey::from_payload(text)
}

/// **A security key as an unlock method**: WebAuthn `prf` in the browser,
/// FIDO2 `hmac-secret` in the CLI, one secret.
pub mod security_key {
    use sha2::{Digest as _, Sha256};
    use zeroize::Zeroizing;

    /// The PRF input every evaluation uses (WebAuthn `prf.eval.first`). Fixed,
    /// so a credential always gives the same secret.
    pub const PRF_INPUT: &[u8] = b"gunnar-crypt user-key unlock v1";

    /// The HKDF-SHA256 salt and label of the derivation.
    pub const LABEL: &[u8] = b"gunnar-crypt security-key x25519 v1";

    /// The `hmac-secret` salt a CTAP2 client sends so the authenticator
    /// answers what a browser's WebAuthn `prf` answers for [`PRF_INPUT`]
    /// (WebAuthn L3 §10.1.4: `SHA-256("WebAuthn PRF" ‖ 0x00 ‖ input)`).
    pub fn hmac_secret_salt() -> [u8; 32] {
        let mut h = Sha256::new();
        h.update(b"WebAuthn PRF");
        h.update([0u8]);
        h.update(PRF_INPUT);
        h.finalize().into()
    }

    /// The X25519 secret of a credential: HKDF-SHA256 (salt [`LABEL`], the
    /// PRF output as the input key, `LABEL ‖ 0x00 ‖ credential id` as the
    /// context), as an age identity.
    pub fn identity(prf_output: &[u8; 32], credential_id: &[u8]) -> age::x25519::Identity {
        use bech32::ToBase32 as _;
        let mut info = Vec::with_capacity(LABEL.len() + 1 + credential_id.len());
        info.extend_from_slice(LABEL);
        info.push(0);
        info.extend_from_slice(credential_id);
        let mut secret = Zeroizing::new([0u8; 32]);
        hkdf::Hkdf::<Sha256>::new(Some(LABEL), prf_output)
            .expand(&info, secret.as_mut())
            .expect("32 bytes is a valid HKDF-SHA256 length");
        // age's own key text is the one constructor its API offers.
        let text = Zeroizing::new(
            bech32::encode(
                "age-secret-key-",
                secret.to_base32(),
                bech32::Variant::Bech32,
            )
            .expect("the HRP is valid")
            .to_uppercase(),
        );
        text.parse().expect("a 32-byte age secret key parses")
    }

    /// **The v2 wrap key** (CRYPT-PQ-SK): HKDF-SHA256 with the wrap's own
    /// salt over the PRF output, info `gunnar-crypt user-key wrap v2`.
    pub fn wrap_key(prf_output: &[u8; 32], salt: &[u8]) -> Zeroizing<[u8; 32]> {
        let mut key = Zeroizing::new([0u8; 32]);
        hkdf::Hkdf::<Sha256>::new(Some(salt), prf_output)
            .expand(crate::skwrap::INFO, key.as_mut())
            .expect("32 bytes is a valid HKDF-SHA256 length");
        key
    }

    /// The credential's v1 recipient (`age1…`): what a v1 enrolment uploaded.
    pub fn recipient(prf_output: &[u8; 32], credential_id: &[u8]) -> String {
        identity(prf_output, credential_id).to_public().to_string()
    }
}

/// **A printed recovery code** as one more unlock method: 32 characters of
/// Crockford's base32 (160 random bits), in groups of four.
pub mod recovery_code {
    /// The alphabet: no I, L, O or U to misread.
    pub const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
    /// Characters in a code (without the dashes).
    pub const CHARS: usize = 32;
    /// The scrypt work factor every recovery wrap uses, and the most an opener
    /// accepts: N = 2^18 (age's own default class; about 256 MiB).
    pub const LOG_N: u8 = 18;

    /// A new code from 20 random bytes.
    pub fn generate() -> zeroize::Zeroizing<String> {
        from_bytes(&crate::random_bytes::<20>())
    }

    /// The code of 20 bytes (5 bits per character), grouped `XXXX-XXXX-…`.
    pub fn from_bytes(bytes: &[u8; 20]) -> zeroize::Zeroizing<String> {
        let mut out = zeroize::Zeroizing::new(String::with_capacity(CHARS + CHARS / 4));
        let (mut acc, mut bits) = (0u32, 0u32);
        let mut n = 0;
        for b in bytes {
            acc = (acc << 8) | u32::from(*b);
            bits += 8;
            while bits >= 5 {
                bits -= 5;
                if n > 0 && n % 4 == 0 {
                    out.push('-');
                }
                out.push(ALPHABET[((acc >> bits) & 31) as usize] as char);
                n += 1;
            }
        }
        out
    }

    /// The code as typed, normalised: upper case, no spaces or dashes, the
    /// look-alikes read as Crockford reads them (O → 0, I and L → 1).
    /// `None` unless it is [`CHARS`] characters of the alphabet.
    pub fn normalize(typed: &str) -> Option<zeroize::Zeroizing<String>> {
        let mut out = zeroize::Zeroizing::new(String::with_capacity(CHARS));
        for c in typed.chars() {
            let c = match c.to_ascii_uppercase() {
                ' ' | '-' => continue,
                'O' => '0',
                'I' | 'L' => '1',
                c => c,
            };
            if !c.is_ascii() || !ALPHABET.contains(&(c as u8)) {
                return None;
            }
            out.push(c);
        }
        (out.len() == CHARS).then_some(out)
    }
}