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

nordisk/gunnar

crates/gunnar-core/src/encryption_key.rs

//! **People's encryption keys** (`refs/gunnar/encryption-keys`, CRYPT-YUBI
//! 2026-10-26): one key pair per person for every encrypted repository.
//!
//! gunnar keeps the PUBLIC half (what members wrap a repository's key ring to;
//! `Repos.RecipientKeys` hands it out) and the private half only as
//! ciphertext: one armored age file per UNLOCK METHOD (an SSH key, a security
//! key's PRF secret, an `age1pq1…` key, a printed recovery code). Nothing here
//! can open a wrap: gunnar never holds a method's secret. What it checks is
//! FORMAT, and every refusal names its rule:
//!
//! * the public half is an `age1…` or `age1pq1…` key;
//! * a wrap is an age file, at most [`MAX_WRAP_BYTES`], with exactly one
//!   recipient stanza and of the kind its method says (`ssh-ed25519`/`ssh-rsa`,
//!   `X25519`, `mlkem768x25519`, `scrypt`), beside age's one grease stanza — or,
//!   for a security key, a well-formed wrap v2 (`gunnar_crypt::skwrap`,
//!   CRYPT-PQ-SK), which replaces that credential's v1 wrap when added;
//! * at most [`MAX_METHODS`] methods; the same method (kind and ref) once;
//! * the LAST method is never removed: nothing would open the key again.
//!
//! **Replacing the key** (CRYPT-PQ-REKEY, owner 2026-11-03): a lost or leaked
//! key is not a dead end. [`EncryptionKeys::replace`] puts a NEW public half
//! and its first method in place of the old key and its methods, and RETIRES
//! the old public half for good: nobody registers it again (as their key, as
//! the new one, or as an `age-pq` unlock method), and the server refuses a push
//! whose members file still names it (`gunnar_policy`'s encrypted gate), so a
//! retired key gets no new data. What it could read before, it still can: old
//! rings in history were wrapped to it, and nothing takes that back.
//!
//! Who may call is the gRPC door's: the person themselves only.

use std::sync::Arc;

use crate::control::ControlStore;
use crate::doc::{impl_doc, DocRef, ShardedMap};
use crate::error::Error;

/// Why a call was refused. Every variant says its rule.
#[derive(Debug)]
pub enum KeyRefusal {
    /// The person has a key already.
    AlreadyHasKey(String),
    /// The person has no key yet.
    NoKey(String),
    /// The same method is held already.
    MethodHeld(String),
    /// No method with that id.
    NoSuchMethod(String),
    /// The last method.
    LastMethod(String),
    /// A public key that was retired (replaced by its owner): it never comes back.
    Retired(String),
    /// A field or a wrap that breaks a rule.
    Invalid(String),
    /// The control store failed.
    Store(Error),
}

impl std::fmt::Display for KeyRefusal {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            KeyRefusal::AlreadyHasKey(s)
            | KeyRefusal::NoKey(s)
            | KeyRefusal::MethodHeld(s)
            | KeyRefusal::NoSuchMethod(s)
            | KeyRefusal::LastMethod(s)
            | KeyRefusal::Retired(s)
            | KeyRefusal::Invalid(s) => f.write_str(s),
            KeyRefusal::Store(e) => write!(f, "{e}"),
        }
    }
}
impl std::error::Error for KeyRefusal {}

impl From<Error> for KeyRefusal {
    fn from(e: Error) -> Self {
        KeyRefusal::Store(e)
    }
}

/// This module's result.
pub type Result<T> = std::result::Result<T, KeyRefusal>;

/// **The name the document is stored under** in the control store.
pub const ENCRYPTION_KEYS_REF: &str = "refs/gunnar/encryption-keys";
/// The largest wrap, in bytes (a hybrid stanza's armored file is ~2.3 KB).
pub const MAX_WRAP_BYTES: usize = 8 << 10;
/// The most unlock methods one person holds.
pub const MAX_METHODS: usize = 16;
/// The scrypt work factor of every recovery-code wrap (`gunnar_crypt::userkey::recovery_code::LOG_N`).
pub const RECOVERY_LOG_N: u8 = 18;
/// The longest label, in bytes.
pub const MAX_LABEL_BYTES: usize = 128;
/// The longest method ref (an RSA public key line is the longest), in bytes.
pub const MAX_REF_BYTES: usize = 4096;

/// What opens a person's encryption key.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MethodKind {
    /// An SSH key file (the CLI).
    Ssh,
    /// A security key: WebAuthn PRF in the browser, FIDO2 hmac-secret in the CLI.
    SecurityKey,
    /// An `age1pq1…` key file (the CLI).
    AgePq,
    /// A printed recovery code.
    RecoveryCode,
}

impl MethodKind {
    /// The word on the wire.
    pub fn word(self) -> &'static str {
        match self {
            MethodKind::Ssh => "ssh",
            MethodKind::SecurityKey => "security-key",
            MethodKind::AgePq => "age-pq",
            MethodKind::RecoveryCode => "recovery-code",
        }
    }

    /// The kind a word names, or the refusal.
    pub fn parse(word: &str) -> Result<MethodKind> {
        match word {
            "ssh" => Ok(MethodKind::Ssh),
            "security-key" => Ok(MethodKind::SecurityKey),
            "age-pq" => Ok(MethodKind::AgePq),
            "recovery-code" => Ok(MethodKind::RecoveryCode),
            other => Err(refuse(
                "unlock method",
                format!("{other:?} is not a method (ssh, security-key, age-pq or recovery-code)"),
            )),
        }
    }

    /// The age stanza types a wrap of this kind may carry.
    fn stanzas(self) -> &'static [&'static str] {
        match self {
            MethodKind::Ssh => &["ssh-ed25519", "ssh-rsa"],
            MethodKind::SecurityKey => &["X25519"],
            MethodKind::AgePq => &[gunnar_crypt::members::PQ_STANZA],
            MethodKind::RecoveryCode => &["scrypt"],
        }
    }
}

/// One unlock method: its kind, what names it, and the key wrapped to it.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct UnlockMethod {
    /// 16 hex characters of SHA-256 over the wrap; set by [`EncryptionKeys`].
    pub id: String,
    /// What opens it.
    pub kind: MethodKind,
    /// The person's own words.
    pub label: String,
    /// The SSH public key line, the credential id (base64url), the `age1pq1…`
    /// key; empty for a recovery code.
    pub method_ref: String,
    /// A security key's WebAuthn relying party id.
    #[serde(default)]
    pub rp_id: String,
    /// The armored age file.
    pub wrap: String,
    /// Unix ms; set by [`EncryptionKeys`].
    pub added_ms: u64,
}

/// One person's encryption key: the public half and its methods.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EncryptionKey {
    /// `age1…` or `age1pq1…`.
    pub public_key: String,
    /// Every unlock method, oldest first.
    pub methods: Vec<UnlockMethod>,
    /// The person's earlier public halves, oldest first (CRYPT-PQ-REKEY).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub retired: Vec<RetiredKey>,
}

/// A public half its owner replaced: it opens what was wrapped to it before,
/// and gunnar lets nothing new be wrapped to it.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RetiredKey {
    /// `age1…` or `age1pq1…`.
    pub public_key: String,
    /// Unix ms of the replacement.
    pub retired_ms: u64,
}

/// What [`EncryptionKeys::replace`] did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Replaced {
    /// The key as it is now: the new public half and its one method.
    pub key: EncryptionKey,
    /// The public half that was retired.
    pub retired: String,
}

#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EncryptionKeysDoc {
    /// Principal id → their key.
    #[serde(default)]
    keys: ShardedMap<EncryptionKey>,
    /// Every retired public half (trimmed) → the principal who retired it.
    #[serde(default)]
    retired: ShardedMap<String>,
}

impl_doc!(EncryptionKeysDoc { keys, retired });

fn refuse(what: &'static str, reason: String) -> KeyRefusal {
    KeyRefusal::Invalid(format!("{what}: {reason}"))
}

/// Pure: **is `public` a person's encryption public key**.
pub fn check_public_key(public: &str) -> Result<()> {
    use gunnar_crypt::members::{classify, KeyKind};
    match classify(public) {
        Ok(KeyKind::Age | KeyKind::AgePq) => Ok(()),
        _ => Err(refuse(
            "encryption key",
            format!("{:?} is not an age1… or age1pq1… public key", public.trim()),
        )),
    }
}

/// Pure: **is `m` a well-formed method** (its fields, and its wrap's shape).
pub fn check_method(m: &UnlockMethod) -> Result<()> {
    if m.label.len() > MAX_LABEL_BYTES || m.label.chars().any(char::is_control) {
        return Err(refuse(
            "unlock method",
            format!("a label is at most {MAX_LABEL_BYTES} bytes of printable text"),
        ));
    }
    if m.method_ref.len() > MAX_REF_BYTES || m.method_ref.chars().any(char::is_control) {
        return Err(refuse(
            "unlock method",
            format!("a method ref is at most {MAX_REF_BYTES} bytes of printable text"),
        ));
    }
    match m.kind {
        MethodKind::RecoveryCode if !m.method_ref.is_empty() => {
            return Err(refuse(
                "unlock method",
                "a recovery code has no ref: gunnar never holds the code".into(),
            ))
        }
        MethodKind::Ssh | MethodKind::SecurityKey | MethodKind::AgePq
            if m.method_ref.trim().is_empty() =>
        {
            return Err(refuse(
                "unlock method",
                format!("a {} method names its key (the method ref)", m.kind.word()),
            ))
        }
        MethodKind::SecurityKey if m.rp_id.trim().is_empty() || m.rp_id.len() > 253 => {
            return Err(refuse(
                "unlock method",
                "a security key names the relying party id its credential is bound to".into(),
            ))
        }
        _ => {}
    }
    if m.wrap.len() > MAX_WRAP_BYTES {
        return Err(refuse(
            "unlock method",
            format!(
                "the wrap is {} bytes, more than the {MAX_WRAP_BYTES} gunnar keeps",
                m.wrap.len()
            ),
        ));
    }
    // A security key's wrap v2 (CRYPT-PQ-SK): symmetric under HKDF of its PRF output,
    // XChaCha20-Poly1305. Its shape is checked; gunnar holds no key to open it.
    if gunnar_crypt::skwrap::is_v2(&m.wrap) {
        if m.kind != MethodKind::SecurityKey {
            return Err(refuse(
                "unlock method",
                format!(
                    "a wrap v2 is a security key's only; this is a {} method",
                    m.kind.word()
                ),
            ));
        }
        if gunnar_crypt::skwrap::parse(&m.wrap).is_none() {
            return Err(refuse(
                "unlock method",
                "the security key's wrap v2 is not well formed (a 32-byte salt, a 24-byte nonce and the sealed key)".into(),
            ));
        }
        return Ok(());
    }
    let stanzas = gunnar_crypt::policy::age_stanzas(m.wrap.as_bytes())
        .ok_or_else(|| refuse("unlock method", "the wrap is not an age file".into()))?;
    let real: Vec<&str> = stanzas
        .iter()
        .map(|s| s.tag.as_str())
        .filter(|t| !t.ends_with("-grease"))
        .collect();
    if real.len() != 1 || !m.kind.stanzas().contains(&real[0]) {
        return Err(refuse(
            "unlock method",
            format!(
                "a {} wrap has one {} stanza; this one has {real:?}",
                m.kind.word(),
                m.kind.stanzas().join(" or ")
            ),
        ));
    }
    // A recovery code goes through scrypt at gunnar's work factor, never a cheaper one.
    if m.kind == MethodKind::RecoveryCode {
        let s = stanzas
            .iter()
            .find(|s| s.tag == "scrypt")
            .expect("checked above");
        let log_n = s.args.get(1).and_then(|w| w.parse::<u8>().ok());
        if log_n != Some(RECOVERY_LOG_N) {
            return Err(refuse(
                "unlock method",
                format!(
                    "a recovery code's wrap uses scrypt at work factor {} (this one: {log_n:?})",
                    RECOVERY_LOG_N
                ),
            ));
        }
    }
    Ok(())
}

fn method_id(wrap: &str) -> String {
    use sha2::Digest as _;
    sha2::Sha256::digest(wrap.as_bytes())[..8]
        .iter()
        .map(|b| format!("{b:02x}"))
        .collect()
}

fn retired_refusal(public: &str) -> KeyRefusal {
    KeyRefusal::Retired(format!(
        "encryption key {} was retired when its owner replaced it: a retired key never comes back \
         (it could open what is wrapped to it); make a new key",
        gunnar_crypt::door::fingerprint(public)
    ))
}

/// A refusal when `method` would let a retired key open the person's key: an
/// `age-pq` method whose key file is a retired public half.
fn check_not_retired_method(doc: &EncryptionKeysDoc, method: &UnlockMethod) -> Result<()> {
    if method.kind == MethodKind::AgePq && doc.retired.contains_key(method.method_ref.trim())? {
        return Err(retired_refusal(&method.method_ref));
    }
    Ok(())
}

fn same_method(a: &UnlockMethod, b: &UnlockMethod) -> bool {
    a.kind == b.kind
        && a.kind != MethodKind::RecoveryCode
        && a.method_ref.trim() == b.method_ref.trim()
}

/// Read and write people's encryption keys. Cheap to clone.
#[derive(Clone, Debug)]
pub struct EncryptionKeys {
    doc: DocRef<EncryptionKeysDoc>,
}

impl EncryptionKeys {
    /// Open the document held in `store`.
    pub fn new(store: Arc<dyn ControlStore>) -> Result<Self> {
        Ok(EncryptionKeys {
            doc: DocRef::new(store, ENCRYPTION_KEYS_REF)?,
        })
    }

    /// One update of the document; a refusal leaves it as it was.
    fn edit<R>(&self, mut f: impl FnMut(&mut EncryptionKeysDoc) -> Result<R>) -> Result<R> {
        let mut refusal = None;
        let out = self.doc.update(|doc| match f(doc) {
            Ok(r) => Ok(Some(r)),
            Err(KeyRefusal::Store(e)) => Err(e),
            Err(r) => {
                refusal = Some(r);
                Ok(None)
            }
        })?;
        match (out, refusal) {
            (Some(r), _) => Ok(r),
            (None, Some(r)) => Err(r),
            (None, None) => unreachable!("a refusal was recorded"),
        }
    }

    /// `principal`'s key, or `None`.
    pub fn get(&self, principal: &str) -> Result<Option<EncryptionKey>> {
        Ok(self.doc.load()?.keys.get(principal)?)
    }

    /// `principal`'s public half, or `None`: what a member wraps a ring to.
    pub fn public_of(&self, principal: &str) -> Result<Option<String>> {
        Ok(self.get(principal)?.map(|k| k.public_key))
    }

    /// **Who retired `public`**, or `None` when it is no retired key
    /// (CRYPT-PQ-REKEY): the push gate's question for every members-file key.
    pub fn retired_by(&self, public: &str) -> Result<Option<String>> {
        Ok(self.doc.load()?.retired.get(public.trim())?)
    }

    /// **Replace `principal`'s key** (CRYPT-PQ-REKEY): `public_key` and `first`
    /// take the place of the key and EVERY method it had (a lost or leaked key's
    /// wraps open nothing new), and the old public half is retired for good.
    /// Refused when the person has no key, when the new half is the current one,
    /// a retired one (anybody's), or another person's key.
    pub fn replace(
        &self,
        principal: &str,
        public_key: &str,
        mut first: UnlockMethod,
        now_ms: u64,
    ) -> Result<Replaced> {
        check_public_key(public_key)?;
        check_method(&first)?;
        first.id = method_id(&first.wrap);
        first.added_ms = now_ms;
        let public = public_key.trim().to_owned();
        self.edit(|doc| {
            let mut key = doc.keys.get(principal)?.ok_or_else(|| no_key(principal))?;
            if key.public_key == public {
                return Err(refuse(
                    "encryption key",
                    "the new key is the one you hold now: a replacement is a NEW key".into(),
                ));
            }
            if doc.retired.contains_key(&public)? {
                return Err(retired_refusal(&public));
            }
            check_not_retired_method(doc, &first)?;
            if first.kind == MethodKind::AgePq && first.method_ref.trim() == key.public_key {
                return Err(retired_refusal(&key.public_key));
            }
            if doc
                .keys
                .any(|p, k| p != principal && k.public_key == public)?
            {
                return Err(refuse(
                    "encryption key",
                    "that public key is another person's encryption key".into(),
                ));
            }
            let old = std::mem::replace(&mut key.public_key, public.clone());
            key.retired.push(RetiredKey {
                public_key: old.clone(),
                retired_ms: now_ms,
            });
            key.methods = vec![first.clone()];
            doc.retired.insert(old.clone(), principal.to_owned())?;
            doc.keys.insert(principal.to_owned(), key.clone())?;
            Ok(Replaced { key, retired: old })
        })
    }

    /// The first time: the public half and the first method. Refused when the
    /// person has a key already (a new one would orphan every ring).
    pub fn create(
        &self,
        principal: &str,
        public_key: &str,
        mut first: UnlockMethod,
        now_ms: u64,
    ) -> Result<EncryptionKey> {
        check_public_key(public_key)?;
        check_method(&first)?;
        first.id = method_id(&first.wrap);
        first.added_ms = now_ms;
        self.edit(|doc| {
            if doc.retired.contains_key(public_key.trim())? {
                return Err(retired_refusal(public_key));
            }
            check_not_retired_method(doc, &first)?;
            if doc.keys.contains_key(principal)? {
                return Err(KeyRefusal::AlreadyHasKey(format!(
                    "{principal} has an encryption key already; add an unlock method to it instead (a new key would orphan every repository wrapped to the old one)"
                )));
            }
            let key = EncryptionKey {
                public_key: public_key.trim().to_owned(),
                methods: vec![first.clone()],
                retired: Vec::new(),
            };
            doc.keys.insert(principal.to_owned(), key.clone())?;
            Ok(key)
        })
    }

    /// One more method for `principal`'s key.
    pub fn add_method(
        &self,
        principal: &str,
        mut method: UnlockMethod,
        now_ms: u64,
    ) -> Result<EncryptionKey> {
        check_method(&method)?;
        method.id = method_id(&method.wrap);
        method.added_ms = now_ms;
        self.edit(|doc| {
            let mut key = doc.keys.get(principal)?.ok_or_else(|| no_key(principal))?;
            check_not_retired_method(doc, &method)?;
            // **The v1 → v2 re-wrap** (CRYPT-PQ-SK): a security key's v2 wrap for a
            // credential held with a v1 (X25519) wrap replaces it, keeping its label,
            // relying party and date. Never the other way round.
            if gunnar_crypt::skwrap::is_v2(&method.wrap) {
                if let Some(held) = key
                    .methods
                    .iter_mut()
                    .find(|m| same_method(m, &method) && !gunnar_crypt::skwrap::is_v2(&m.wrap) && m.rp_id == method.rp_id)
                {
                    held.wrap = method.wrap.clone();
                    held.id = method.id.clone();
                    doc.keys.insert(principal.to_owned(), key.clone())?;
                    return Ok(key);
                }
            }
            if key.methods.iter().any(|m| same_method(m, &method) || m.id == method.id) {
                return Err(KeyRefusal::MethodHeld(format!("this {} is an unlock method already", method.kind.word())));
            }
            if key.methods.len() >= MAX_METHODS {
                return Err(refuse("unlock method", format!("{principal} holds {MAX_METHODS} unlock methods, the most gunnar keeps; remove one first")));
            }
            key.methods.push(method.clone());
            doc.keys.insert(principal.to_owned(), key.clone())?;
            Ok(key)
        })
    }

    /// Delete one method's wrap. The last one is refused.
    pub fn remove_method(&self, principal: &str, id: &str) -> Result<EncryptionKey> {
        self.edit(|doc| {
            let mut key = doc.keys.get(principal)?.ok_or_else(|| no_key(principal))?;
            let before = key.methods.len();
            if !key.methods.iter().any(|m| m.id == id) {
                return Err(KeyRefusal::NoSuchMethod(format!("there is no unlock method {id}")));
            }
            if before == 1 {
                return Err(KeyRefusal::LastMethod(
                    "this is the last unlock method of your encryption key: without one, nothing opens it again. Add another method first".into(),
                ));
            }
            key.methods.retain(|m| m.id != id);
            doc.keys.insert(principal.to_owned(), key.clone())?;
            Ok(key)
        })
    }
}

impl UnlockMethod {
    /// From the forge door's line (`gunnar-user-key`).
    pub fn from_line(m: gunnar_crypt::door::MethodLine) -> Result<UnlockMethod> {
        Ok(UnlockMethod {
            id: m.id,
            kind: MethodKind::parse(&m.kind)?,
            label: m.label,
            method_ref: m.method_ref,
            rp_id: m.rp_id,
            wrap: m.wrap,
            added_ms: m.added_ms,
        })
    }

    /// As the forge door's line.
    pub fn to_line(&self) -> gunnar_crypt::door::MethodLine {
        gunnar_crypt::door::MethodLine {
            id: self.id.clone(),
            kind: self.kind.word().to_owned(),
            added_ms: self.added_ms,
            label: self.label.clone(),
            method_ref: self.method_ref.clone(),
            rp_id: self.rp_id.clone(),
            wrap: self.wrap.clone(),
        }
    }
}

impl EncryptionKey {
    /// As the forge door's answer.
    pub fn to_answer(&self) -> gunnar_crypt::door::Answer {
        gunnar_crypt::door::Answer {
            public_key: Some(self.public_key.clone()),
            methods: self.methods.iter().map(UnlockMethod::to_line).collect(),
            retired: self.retired.iter().map(|r| r.public_key.clone()).collect(),
        }
    }
}

fn no_key(principal: &str) -> KeyRefusal {
    KeyRefusal::NoKey(format!("{principal} has no encryption key yet"))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn store() -> EncryptionKeys {
        EncryptionKeys::new(Arc::new(
            crate::control::RedbControlStore::in_memory().expect("a store"),
        ))
        .expect("the document")
    }

    fn user() -> gunnar_crypt::userkey::UserKey {
        gunnar_crypt::userkey::UserKey::generate(gunnar_crypt::members::Level::Classic).unwrap()
    }

    fn sk_method(u: &gunnar_crypt::userkey::UserKey, fill: u8, cred: &[u8]) -> UnlockMethod {
        use gunnar_crypt::userkey::security_key;
        UnlockMethod {
            id: String::new(),
            kind: MethodKind::SecurityKey,
            label: format!("key {fill}"),
            method_ref: base64::Engine::encode(
                &base64::engine::general_purpose::URL_SAFE_NO_PAD,
                cred,
            ),
            rp_id: "console.mock.invalid".into(),
            wrap: u
                .wrap_to_security_key(&security_key::recipient(&[fill; 32], cred))
                .unwrap(),
            added_ms: 0,
        }
    }

    /// ★ Created once, a method added and removed, the public half never changes.
    #[test]
    fn a_key_takes_methods_and_keeps_its_public_half() {
        let s = store();
        let u = user();
        assert_eq!(s.get("alice").unwrap(), None);
        let k = s
            .create("alice", &u.public(), sk_method(&u, 1, b"one"), 5)
            .unwrap();
        assert_eq!(
            (
                k.public_key.as_str(),
                k.methods.len(),
                k.methods[0].added_ms
            ),
            (u.public().as_str(), 1, 5)
        );
        assert_eq!(k.methods[0].id.len(), 16);
        let k = s.add_method("alice", sk_method(&u, 2, b"two"), 6).unwrap();
        assert_eq!(k.methods.len(), 2);
        let first = k.methods[0].id.clone();
        let k = s.remove_method("alice", &first).unwrap();
        assert_eq!(k.methods.len(), 1);
        assert_eq!(s.public_of("alice").unwrap(), Some(u.public()));
    }

    /// ★ Every refusal by name: a second key, the same method twice, the last
    /// method, a wrap of the wrong kind, a public half that is no key.
    #[test]
    fn every_refusal_is_named() {
        let s = store();
        let u = user();
        s.create("alice", &u.public(), sk_method(&u, 1, b"one"), 1)
            .unwrap();
        let e = s
            .create("alice", &u.public(), sk_method(&u, 2, b"two"), 1)
            .unwrap_err();
        assert!(e.to_string().contains("orphan"), "{e}");
        let e = s
            .add_method("alice", sk_method(&u, 9, b"one"), 1)
            .unwrap_err();
        assert!(e.to_string().contains("already"), "{e}");
        let only = s.get("alice").unwrap().unwrap().methods[0].id.clone();
        let e = s.remove_method("alice", &only).unwrap_err();
        assert!(e.to_string().contains("last unlock method"), "{e}");
        // A security-key method whose wrap is a recovery-code (scrypt) file.
        let mut wrong = sk_method(&u, 3, b"three");
        wrong.wrap = u
            .wrap_to_recovery_code(&gunnar_crypt::userkey::recovery_code::from_bytes(&[1; 20]))
            .unwrap();
        let e = s.add_method("alice", wrong, 1).unwrap_err();
        assert!(e.to_string().contains("one X25519 stanza"), "{e}");
        let e = s
            .create("bob", "ssh-ed25519 AAAA", sk_method(&u, 4, b"x"), 1)
            .unwrap_err();
        assert!(e.to_string().contains("not an age1"), "{e}");
        let e = s
            .add_method("carol", sk_method(&u, 5, b"y"), 1)
            .unwrap_err();
        assert!(e.to_string().contains("no encryption key yet"), "{e}");
        let mut no_rp = sk_method(&u, 6, b"z");
        no_rp.rp_id.clear();
        assert!(s
            .add_method("alice", no_rp, 1)
            .unwrap_err()
            .to_string()
            .contains("relying party"));
    }

    fn pq_user() -> gunnar_crypt::userkey::UserKey {
        gunnar_crypt::userkey::UserKey::generate(gunnar_crypt::members::Level::QuantumSafe).unwrap()
    }

    fn pq_method(
        u: &gunnar_crypt::userkey::UserKey,
        file: &gunnar_crypt::pq::Identity,
    ) -> UnlockMethod {
        UnlockMethod {
            id: String::new(),
            kind: MethodKind::AgePq,
            label: "key file".into(),
            method_ref: file.to_public().to_string(),
            rp_id: String::new(),
            wrap: u.wrap_to_pq(&file.to_public().to_string()).unwrap(),
            added_ms: 0,
        }
    }

    /// ★ CRYPT-PQ-REKEY: a replacement puts the new public half and its ONE
    /// method in place of the old key and all its methods, and retires the old
    /// half under its owner's name.
    #[test]
    fn replacing_a_key_retires_the_old_one_and_drops_its_methods() {
        let s = store();
        let (old, new) = (user(), pq_user());
        s.create("alice", &old.public(), sk_method(&old, 1, b"one"), 1)
            .unwrap();
        s.add_method("alice", sk_method(&old, 2, b"two"), 2)
            .unwrap();
        let r = s
            .replace("alice", &new.public(), sk_method(&new, 3, b"three"), 7)
            .unwrap();
        assert_eq!(r.retired, old.public());
        assert_eq!(r.key.public_key, new.public());
        assert_eq!(r.key.methods.len(), 1, "the old key's wraps are gone");
        assert_eq!(r.key.methods[0].added_ms, 7);
        assert_eq!(
            r.key.retired,
            vec![RetiredKey {
                public_key: old.public(),
                retired_ms: 7
            }]
        );
        assert_eq!(s.public_of("alice").unwrap(), Some(new.public()));
        assert_eq!(
            s.retired_by(&old.public()).unwrap().as_deref(),
            Some("alice")
        );
        assert_eq!(s.retired_by(&new.public()).unwrap(), None);
        assert_eq!(s.get("alice").unwrap().unwrap(), r.key);
        let e = s
            .replace("bob", &pq_user().public(), sk_method(&new, 4, b"x"), 8)
            .unwrap_err();
        assert!(e.to_string().contains("no encryption key yet"), "{e}");
    }

    /// ★ CRYPT-PQ-REKEY: a retired (leaked) key cannot re-add itself — not as
    /// the owner's key again, not as anybody's new key, not as an `age-pq`
    /// unlock method of the new key; nor is the current key a "replacement",
    /// nor another person's key.
    #[test]
    fn a_retired_key_never_comes_back() {
        let s = store();
        let old_file = gunnar_crypt::pq::Identity::generate();
        let old_public = old_file.to_public().to_string();
        let old =
            gunnar_crypt::userkey::UserKey::generate(gunnar_crypt::members::Level::QuantumSafe)
                .unwrap();
        // The person's key is the pq key file's own public half here, so the
        // leaked secret is BOTH the retired key and a would-be method.
        s.create("alice", &old_public, sk_method(&old, 1, b"one"), 1)
            .unwrap();
        let new = pq_user();
        s.replace("alice", &new.public(), sk_method(&new, 2, b"two"), 2)
            .unwrap();
        let back = s
            .replace("alice", &old_public, sk_method(&old, 3, b"three"), 3)
            .unwrap_err();
        assert!(matches!(back, KeyRefusal::Retired(_)), "{back}");
        assert!(back.to_string().contains("retired"), "{back}");
        let e = s
            .create("mallory", &old_public, sk_method(&old, 4, b"four"), 4)
            .unwrap_err();
        assert!(matches!(e, KeyRefusal::Retired(_)), "{e}");
        let e = s
            .add_method("alice", pq_method(&new, &old_file), 5)
            .unwrap_err();
        assert!(
            matches!(e, KeyRefusal::Retired(_)),
            "a retired key as an unlock method: {e}"
        );
        let e = s
            .replace("alice", &new.public(), sk_method(&new, 6, b"six"), 6)
            .unwrap_err();
        assert!(e.to_string().contains("the one you hold now"), "{e}");
        let bob = pq_user();
        s.create("bob", &bob.public(), sk_method(&bob, 7, b"seven"), 7)
            .unwrap();
        let e = s
            .replace("alice", &bob.public(), sk_method(&new, 8, b"eight"), 8)
            .unwrap_err();
        assert!(e.to_string().contains("another person"), "{e}");
        assert_eq!(
            s.public_of("alice").unwrap(),
            Some(new.public()),
            "nothing changed"
        );
    }

    /// ★ A recovery code goes through scrypt at gunnar's work factor: a cheaper
    /// wrap is refused, and the client's constant is the server's.
    #[test]
    fn a_recovery_wrap_at_a_cheaper_work_factor_is_refused() {
        use std::io::Write as _;
        assert_eq!(RECOVERY_LOG_N, gunnar_crypt::userkey::recovery_code::LOG_N);
        let s = store();
        let u = user();
        let mut cheap = age_scrypt(10);
        let e = s
            .create(
                "alice",
                &u.public(),
                method(MethodKind::RecoveryCode, &mut cheap),
                1,
            )
            .unwrap_err();
        assert!(e.to_string().contains("work factor 18"), "{e}");
        let good = u
            .wrap_to_recovery_code(&gunnar_crypt::userkey::recovery_code::from_bytes(&[2; 20]))
            .unwrap();
        let stanzas = gunnar_crypt::policy::age_stanzas(good.as_bytes()).unwrap();
        assert_eq!(
            stanzas
                .iter()
                .map(|s| (s.tag.as_str(), s.args.get(1).map(String::as_str)))
                .collect::<Vec<_>>(),
            vec![("scrypt", Some("18"))]
        );
        fn age_scrypt(log_n: u8) -> String {
            let mut r = gunnar_crypt::wrap::age::scrypt::Recipient::new(
                gunnar_crypt::wrap::age::secrecy::SecretString::from(
                    "0123456789ABCDEFGHJKMNPQRSTVWXYZ".to_string(),
                ),
            );
            r.set_work_factor(log_n);
            let enc = gunnar_crypt::wrap::age::Encryptor::with_recipients(std::iter::once(
                &r as &dyn gunnar_crypt::wrap::age::Recipient,
            ))
            .unwrap();
            let mut out = Vec::new();
            let armor = gunnar_crypt::wrap::age::armor::ArmoredWriter::wrap_output(
                &mut out,
                gunnar_crypt::wrap::age::armor::Format::AsciiArmor,
            )
            .unwrap();
            let mut w = enc.wrap_output(armor).unwrap();
            w.write_all(b"x").unwrap();
            w.finish().and_then(|a| a.finish()).unwrap();
            String::from_utf8(out).unwrap()
        }
        fn method(kind: MethodKind, wrap: &mut String) -> UnlockMethod {
            UnlockMethod {
                id: String::new(),
                kind,
                label: "printed".into(),
                method_ref: String::new(),
                rp_id: String::new(),
                wrap: std::mem::take(wrap),
                added_ms: 0,
            }
        }
    }

    /// ★ A recovery code carries no ref; two codes are two methods.
    #[test]
    fn recovery_codes_carry_no_ref() {
        let s = store();
        let u = user();
        let code = |b: u8| UnlockMethod {
            id: String::new(),
            kind: MethodKind::RecoveryCode,
            label: "printed".into(),
            method_ref: String::new(),
            rp_id: String::new(),
            wrap: u
                .wrap_to_recovery_code(&gunnar_crypt::userkey::recovery_code::from_bytes(&[b; 20]))
                .unwrap(),
            added_ms: 0,
        };
        s.create("alice", &u.public(), code(1), 1).unwrap();
        assert_eq!(s.add_method("alice", code(2), 1).unwrap().methods.len(), 2);
        let mut with_ref = code(3);
        with_ref.method_ref = "the code".into();
        assert!(s
            .add_method("alice", with_ref, 1)
            .unwrap_err()
            .to_string()
            .contains("never holds the code"));
    }

    /// ★ CRYPT-PQ-SK: a security key's v2 wrap (symmetric, HKDF over the PRF
    /// output, XChaCha20-Poly1305) is taken; added for a credential held with a
    /// v1 (X25519) wrap, it REPLACES that wrap (the re-wrap on the next unlock);
    /// a v1 never replaces a v2; a malformed v2 is refused by name.
    #[test]
    fn a_v2_security_key_wrap_replaces_its_v1_and_never_the_other_way() {
        let s = store();
        let u = user();
        let v1 = sk_method(&u, 1, b"one");
        let k = s.create("alice", &u.public(), v1.clone(), 1).unwrap();
        let old_id = k.methods[0].id.clone();
        let mut v2 = v1.clone();
        v2.wrap = u.wrap_to_security_key_v2(&[1; 32], b"one").unwrap();
        let k = s.add_method("alice", v2.clone(), 2).unwrap();
        assert_eq!(k.methods.len(), 1, "the v2 wrap replaced the v1 one");
        assert!(gunnar_crypt::skwrap::is_v2(&k.methods[0].wrap));
        assert_ne!(k.methods[0].id, old_id);
        assert_eq!(
            (
                k.methods[0].label.as_str(),
                k.methods[0].rp_id.as_str(),
                k.methods[0].added_ms
            ),
            (v1.label.as_str(), v1.rp_id.as_str(), 1)
        );
        let e = s.add_method("alice", v1.clone(), 3).unwrap_err();
        assert!(e.to_string().contains("already"), "{e}");
        assert!(
            gunnar_crypt::skwrap::is_v2(&s.get("alice").unwrap().unwrap().methods[0].wrap),
            "never back to X25519"
        );
        let e = s.add_method("alice", v2.clone(), 4).unwrap_err();
        assert!(e.to_string().contains("already"), "the same v2 again: {e}");
        let mut bad = sk_method(&u, 2, b"two");
        bad.wrap = format!("{} AAAA AAAA AAAA", gunnar_crypt::skwrap::TAG);
        let e = s.add_method("alice", bad, 5).unwrap_err();
        assert!(e.to_string().contains("v2"), "{e}");
        let mut ssh_v2 = v2;
        ssh_v2.kind = MethodKind::Ssh;
        ssh_v2.method_ref = "ssh-ed25519 AAAA".into();
        let e = s.add_method("alice", ssh_v2, 6).unwrap_err();
        assert!(
            e.to_string().contains("ssh"),
            "a v2 wrap is a security key's only: {e}"
        );
    }
}