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

nordisk/gunnar-ui

crates/gunnar-ui-web/src/sealed.rs

//! **Encrypted files, opened in this browser tab with the person's security
//! key** (gunnar encrypted repositories; gunnar's `.nornir/encrypted-repos.md`).
//!
//! The one thing in the console that is not "call the server and render", and
//! it is that way by design: the server must not hold the key, so the key ring
//! has to meet the ciphertext somewhere the server is not. That place is this
//! tab's wasm memory:
//!
//! ```text
//!   gunnar ── the person's encryption key, WRAPPED to each unlock method ──▶ this tab
//!   the security key ── touch: its PRF secret (WebAuthn prf) ──▶ this tab
//!   this tab: HKDF → XChaCha20-Poly1305 (v2; an old v1 wrap: X25519, re-wrapped to v2)
//!             → open the encryption key → open the key ring → open the file
//! ```
//!
//! * One encryption key per person opens every repository they are a member
//!   of. Gunnar keeps it only as ciphertext, wrapped to each of the person's
//!   unlock methods; a security key's PRF secret and a printed recovery code
//!   open it here, the SSH key file opens it in the Gunnar CLI.
//! * The person's key is quantum-safe (`age1pq1…`, gunnar's `Level::NEW`)
//!   unless they chose a classic one when it was made here; the tab makes,
//!   opens and wraps both (gunnar-crypt's `pq`), and says how strong each way
//!   in's own wrap is.
//! * The opened key and the key rings stay in this tab's memory and are never
//!   stored or sent. Nothing decrypted is sent either. Nothing is asked of any
//!   other program on this computer.
//! * Every decision stays the server's: who may read the ciphertext (the ACL),
//!   whether the repository is encrypted (its flag), who is a member (the
//!   repository's own `.gunnar-crypt/members`).
//! * The security key is asked with `userVerification: "discouraged"`, so the
//!   PRF secret is the one the CLI's FIDO2 `hmac-secret` call gets without a
//!   PIN: one key, both ways.
//!
//! The pure half (options, answers, words) is here and tested on the host; the
//! fetches, the touches and the state live under `wasm32`.

use gunnar_crypt::keyring::Keyring;
use gunnar_crypt::userkey::{self, security_key, UserKey, UserKeyError};
use gunnar_crypt::wrap::{self, UnwrapError};
use serde::{Deserialize, Serialize};

/// The scheme's key file inside a repository.
pub const KEYS_PATH: &str = ".gunnar-crypt/keys";
/// Who may read, beside the key file: what the ring's stanzas are checked against.
pub const MEMBERS_PATH: &str = ".gunnar-crypt/members";
/// The person's own encryption key, as the console server relays it.
pub const KEY_ROUTE: &str = "/api/account/encryption-key";

/// One unlock method of the person's encryption key (`Access.UnlockMethod`).
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Method {
    #[serde(default)]
    pub id: String,
    pub kind: String,
    #[serde(default)]
    pub label: String,
    #[serde(default)]
    pub method_ref: String,
    #[serde(default)]
    pub rp_id: String,
    #[serde(default)]
    pub wrap: String,
    #[serde(default)]
    pub added_ms: u64,
}

/// The person's encryption key: its public half and its methods.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct EncryptionKey {
    #[serde(default)]
    pub exists: bool,
    #[serde(default)]
    pub public_key: String,
    #[serde(default)]
    pub methods: Vec<Method>,
}

impl EncryptionKey {
    /// The security keys bound to `rp_id` (this console's host): the ones this
    /// tab can ask.
    pub fn security_keys(&self, rp_id: &str) -> Vec<&Method> {
        self.methods.iter().filter(|m| m.kind == "security-key" && m.rp_id == rp_id).collect()
    }

    /// The recovery codes.
    pub fn recovery_codes(&self) -> Vec<&Method> {
        self.methods.iter().filter(|m| m.kind == "recovery-code").collect()
    }

    /// Fewer than two methods: the person is urged to add a spare.
    pub fn wants_a_spare(&self) -> bool {
        self.methods.len() < 2
    }
}

/// **Why an encrypted file did not open**, each by name.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refusal {
    /// The browser offers no WebAuthn PRF extension.
    NoPrfInBrowser,
    /// The security key answered without a PRF secret: no FIDO2 hmac-secret.
    NoHmacSecret,
    /// The ring is not wrapped to the person's encryption key.
    NotAMember,
    /// The security key (or code) is not one of the person's unlock methods.
    WrongKey,
    /// The person has an encryption key but no security key for this console.
    NoSecurityKey,
    /// The person has no encryption key at all yet.
    NoEncryptionKey,
    /// The touch was cancelled or timed out.
    Cancelled,
}

impl Refusal {
    /// The sentence the page shows.
    pub fn words(self) -> &'static str {
        match self {
            Refusal::NoPrfInBrowser => "This browser cannot open encrypted files with a security key: it does not offer the WebAuthn PRF extension. Use a current Chrome, Edge or Firefox, or open it with the Gunnar CLI (`gunnar crypt unlock`).",
            Refusal::NoHmacSecret => "This security key cannot open encrypted files: it lacks the FIDO2 hmac-secret extension (a YubiKey 5 has it). Use another security key, your recovery code, or the Gunnar CLI.",
            Refusal::NotAMember => "You are not a member of this encrypted repository: its key ring is not wrapped to your encryption key. A member adds you with `gunnar crypt add-member <your name> --from-server`.",
            Refusal::WrongKey => "That security key is not one of your unlock methods. Touch one you added under Account → Security keys for encryption, or use your recovery code.",
            Refusal::NoSecurityKey => "You have no security key for encryption on this console yet. Add one under Account → Security keys for encryption, or open the file with the Gunnar CLI.",
            Refusal::NoEncryptionKey => "You have no encryption key yet. Add a security key under Account → Security keys for encryption, or open the file with the Gunnar CLI.",
            Refusal::Cancelled => "The security key was not touched (cancelled or timed out). Try again.",
        }
    }
}

/// A refused opening of the encryption key, in the page's words.
pub fn user_key_words(e: &UserKeyError) -> String {
    match e {
        UserKeyError::WrongKey => Refusal::WrongKey.words().to_owned(),
        other => other.to_string(),
    }
}

/// A refused ring, in the page's words.
pub fn ring_words(e: &UnwrapError) -> String {
    match e {
        UnwrapError::NotARecipient => Refusal::NotAMember.words().to_owned(),
        other => other.to_string(),
    }
}

/// The PRF secret in a security key's answer, or why there is none:
/// `prf_offered` is false when the browser gave no `prf` extension result at
/// all; `first` is its `results.first`.
pub fn prf_secret(prf_offered: bool, first: Option<&[u8]>) -> Result<[u8; 32], Refusal> {
    if !prf_offered {
        return Err(Refusal::NoPrfInBrowser);
    }
    first.and_then(|f| <[u8; 32]>::try_from(f).ok()).ok_or(Refusal::NoHmacSecret)
}

/// The option paths WebAuthn takes as BYTES (sent here as base64url); the rest
/// stay as they are — `rp.id` is a host name, and reading it as bytes made the
/// browser refuse the enrolment (SecurityError, the first rig run 2026-10-26).
pub const BUFFERS: &[&str] = &[
    "publicKey.challenge",
    "publicKey.user.id",
    "publicKey.allowCredentials[].id",
    "publicKey.excludeCredentials[].id",
    "publicKey.extensions.prf.eval.first",
];

/// Every string leaf of `v` with its option path (`a.b[].c`).
pub fn leaf_paths(v: &serde_json::Value, path: &str, out: &mut Vec<(String, String)>) {
    match v {
        serde_json::Value::Object(map) => {
            for (k, x) in map {
                leaf_paths(x, &if path.is_empty() { k.clone() } else { format!("{path}.{k}") }, out);
            }
        }
        serde_json::Value::Array(items) => items.iter().for_each(|x| leaf_paths(x, &format!("{path}[]"), out)),
        serde_json::Value::String(s) => out.push((path.to_owned(), s.clone())),
        _ => {}
    }
}

/// What `navigator.credentials.get` is asked, as JSON (ids in base64url): the
/// person's security keys for this console, the fixed PRF input, no PIN.
pub fn get_options(rp_id: &str, allow: &[&Method], challenge: &[u8]) -> serde_json::Value {
    serde_json::json!({
        "publicKey": {
            "challenge": b64url(challenge),
            "rpId": rp_id,
            "timeout": 60000,
            "userVerification": "discouraged",
            "allowCredentials": allow.iter().map(|m| serde_json::json!({ "type": "public-key", "id": m.method_ref })).collect::<Vec<_>>(),
            "extensions": { "prf": { "eval": { "first": b64url(security_key::PRF_INPUT) } } },
        }
    })
}

/// What `navigator.credentials.create` is asked to enrol a security key for
/// encryption: a non-discoverable credential (it is named by its id), no PIN,
/// no attestation, and the PRF evaluated at once where the key can.
pub fn create_options(rp_id: &str, user: &str, user_id: &[u8], challenge: &[u8], exclude: &[&Method]) -> serde_json::Value {
    serde_json::json!({
        "publicKey": {
            "challenge": b64url(challenge),
            "rp": { "id": rp_id, "name": "Gunnar encryption" },
            "user": { "id": b64url(user_id), "name": user, "displayName": user },
            "pubKeyCredParams": [{ "type": "public-key", "alg": -7 }, { "type": "public-key", "alg": -8 }],
            "timeout": 60000,
            "attestation": "none",
            "authenticatorSelection": { "residentKey": "discouraged", "requireResidentKey": false, "userVerification": "discouraged" },
            "excludeCredentials": exclude.iter().map(|m| serde_json::json!({ "type": "public-key", "id": m.method_ref })).collect::<Vec<_>>(),
            "extensions": { "prf": { "eval": { "first": b64url(security_key::PRF_INPUT) } } },
        }
    })
}

/// **A new encryption key, made in this tab**: quantum-safe ([`Level::NEW`],
/// gunnar's own default) unless the person chose a classic one. A person has
/// ONE key, so the choice is made once.
pub fn new_user_key(classic: bool) -> Result<UserKey, String> {
    let level = if classic { gunnar_crypt::members::Level::Classic } else { gunnar_crypt::members::Level::NEW };
    UserKey::generate(level).map_err(|e| e.to_string())
}

/// Is the person's key quantum-safe (`age1pq1…`)?
fn quantum_safe(key: &EncryptionKey) -> bool {
    gunnar_crypt::members::classify(&key.public_key) == Ok(gunnar_crypt::members::KeyKind::AgePq)
}

/// What kind of key the person has, in one line.
pub fn key_kind_words(key: &EncryptionKey) -> &'static str {
    if quantum_safe(key) {
        "Your encryption key is quantum-safe (ML-KEM-768 + X25519)."
    } else {
        "Your encryption key is classic (X25519): it opens classic repositories only."
    }
}

/// **How strong a way in's own wrap is**, for a quantum-safe key (gunnar's
/// [`userkey::wrap_is_quantum_safe`]); nothing for a classic key, whose
/// repositories are classic anyway.
pub fn wrap_strength(key: &EncryptionKey, m: &Method) -> Option<&'static str> {
    quantum_safe(key).then(|| if userkey::wrap_is_quantum_safe(&m.kind, &m.wrap) { "wrap: quantum-safe" } else { "wrap: not quantum-safe" })
}

/// Why a way in's wrap is not quantum-safe, said plainly (the title beside it).
pub fn wrap_strength_why(m: &Method) -> &'static str {
    match m.kind.as_str() {
        "ssh" => userkey::SSH_WRAP_IS_CLASSICAL,
        "security-key" if gunnar_crypt::skwrap::is_v2(&m.wrap) => {
            "This security key's wrap is symmetric (HKDF over its PRF secret, XChaCha20-Poly1305): quantum-safe."
        }
        "security-key" => "This security key's wrap is the older X25519 one, which is not quantum-safe. It is re-wrapped \
                           to the quantum-safe v2 the next time you open your encryption key with this key.",
        _ => "This wrap of your key is quantum-safe.",
    }
}

/// **Open the person's encryption key with a security key's PRF secret**: the
/// method whose credential this is, its wrap opened with the derived X25519
/// identity, the key checked against the account's public half.
pub fn open_with_security_key(key: &EncryptionKey, credential_id: &[u8], prf: &[u8; 32]) -> Result<UserKey, String> {
    open_with_security_key_rewrap(key, credential_id, prf).map(|(k, _)| k)
}

/// [`open_with_security_key`], and, when the wrap that opened was v1 (an X25519
/// stanza from before CRYPT-PQ-SK), the method body of its v2 re-wrap: the same
/// credential, relying party and label, to add at gunnar, which replaces the v1.
pub fn open_with_security_key_rewrap(
    key: &EncryptionKey,
    credential_id: &[u8],
    prf: &[u8; 32],
) -> Result<(UserKey, Option<serde_json::Value>), String> {
    if !key.exists {
        return Err(Refusal::NoEncryptionKey.words().to_owned());
    }
    let id = b64url(credential_id);
    // Every wrap under this credential is tried; one the server holds that does
    // not open to the person's key is skipped, and said if nothing opens (RA4-14).
    let mut refused = None;
    for method in key.methods.iter().filter(|m| m.kind == "security-key" && m.method_ref == id) {
        match userkey::open_with_security_key(method.wrap.as_bytes(), prf, credential_id, &key.public_key) {
            Ok((k, userkey::SecurityKeyWrap::V2)) => return Ok((k, None)),
            Ok((k, userkey::SecurityKeyWrap::V1)) => {
                let body = security_key_method(&k, credential_id, prf, &method.rp_id, &method.label)?;
                return Ok((k, Some(body)));
            }
            Err(e) => refused = Some(e),
        }
    }
    Err(match refused {
        None | Some(UserKeyError::WrongKey) => Refusal::WrongKey.words().to_owned(),
        Some(e) => user_key_words(&e),
    })
}

/// **Open the person's encryption key with a printed recovery code**: every
/// recovery-code wrap is tried.
pub fn open_with_recovery_code(key: &EncryptionKey, code: &str) -> Result<UserKey, String> {
    if !key.exists {
        return Err(Refusal::NoEncryptionKey.words().to_owned());
    }
    let codes = key.recovery_codes();
    if codes.is_empty() {
        return Err("You have no recovery code: add one under Account → Security keys for encryption.".to_owned());
    }
    let mut last = UserKeyError::WrongKey;
    for m in codes {
        match userkey::open_with_recovery_code(m.wrap.as_bytes(), code, &key.public_key) {
            Ok(k) => return Ok(k),
            Err(UserKeyError::BadRecoveryCode) => return Err(UserKeyError::BadRecoveryCode.to_string()),
            Err(e) => last = e,
        }
    }
    Err(match last {
        UserKeyError::WrongKey => "That recovery code does not open your encryption key; check it and try again.".to_owned(),
        other => user_key_words(&other),
    })
}

/// **The repository's key ring**, opened with the person's encryption key
/// after its stanzas are checked against the members file.
pub fn open_ring(keys: &[u8], members: Option<&str>, user: &UserKey) -> Result<Keyring, String> {
    match members {
        Some(m) => wrap::unwrap_checked(keys, m, &[user.identity()]),
        None => wrap::unwrap(keys, &[user.identity()]),
    }
    .map_err(|e| ring_words(&e))
}

/// The method body that enrols a security key: its credential, this console's
/// relying party, and the encryption key wrapped to the credential's PRF output (v2).
pub fn security_key_method(user: &UserKey, credential_id: &[u8], prf: &[u8; 32], rp_id: &str, label: &str) -> Result<serde_json::Value, String> {
    // v2 (CRYPT-PQ-SK): symmetric under HKDF of the PRF output, quantum-safe.
    let wrap = user.wrap_to_security_key_v2(prf, credential_id).map_err(|e| e.to_string())?;
    Ok(serde_json::json!({ "kind": "security-key", "label": label.trim(), "method_ref": b64url(credential_id), "rp_id": rp_id, "wrap": wrap }))
}

/// The method body of a printed recovery code.
pub fn recovery_code_method(user: &UserKey, code: &str) -> Result<serde_json::Value, String> {
    let wrap = user.wrap_to_recovery_code(code).map_err(|e| e.to_string())?;
    Ok(serde_json::json!({ "kind": "recovery-code", "label": "printed recovery code", "wrap": wrap }))
}

/// The words beside a method in the list.
pub fn method_line(m: &Method) -> String {
    let kind = match m.kind.as_str() {
        "security-key" => "security key",
        "recovery-code" => "recovery code",
        "ssh" => "SSH key (Gunnar CLI)",
        "age-pq" => "quantum-safe key file (Gunnar CLI)",
        other => other,
    };
    match m.label.trim() {
        "" => kind.to_owned(),
        label => format!("{label} — {kind}"),
    }
}

/// **One side of a diff, as the server answered it**: the bytes, ABSENT (404 only: the
/// path is not at that revision), or an error to say — a 413, a 403 or a network failure is
/// never read as "the file was added/deleted" (wiring tracer B 2026-10-08).
pub fn diff_side(answer: Result<Vec<u8>, (u16, String)>) -> Result<Option<Vec<u8>>, String> {
    match answer {
        Ok(bytes) => Ok(Some(bytes)),
        Err((404, _)) => Ok(None),
        Err((status, said)) => Err(raw_refusal(status, said)),
    }
}

/// **What a refused read of a sealed file says** (round 5.3.1): the console fetches at most
/// 2 MiB of one file (the server's raw route answers 413 above it), so a larger encrypted file
/// is read with the CLI, in plain words; any other refusal as the server said it.
pub fn raw_refusal(status: u16, said: String) -> String {
    if status == 413 {
        return "This file is larger than the 2 MiB the console opens in a browser. Read it in a clone \
                of the repository after `gunnar crypt unlock`."
            .to_owned();
    }
    said
}

/// Plain lines for the source table: one unclassed token per line.
pub fn plain_lines(text: &str) -> Vec<Vec<(String, String)>> {
    text.lines()
        .map(|l| {
            if l.is_empty() {
                vec![]
            } else {
                vec![(String::new(), l.to_owned())]
            }
        })
        .collect()
}

/// base64url without padding (WebAuthn's spelling of a credential id).
pub fn b64url(bytes: &[u8]) -> String {
    crate::machine_enrol::b64url(bytes)
}

/// The bytes of a base64url (or base64) text.
pub fn from_b64url(text: &str) -> Option<Vec<u8>> {
    crate::machine_enrol::from_b64url(text)
}

/// The per-browser pin of the person's own encryption public key
/// (localStorage, per console origin; RA4-1). Advisory: the wasm that checks
/// it is the server's own (encrypted-repos.md §9).
pub const PIN_KEY: &str = "gunnar-encryption-pin";

/// What the browser's pin says of the key the server answered.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pin {
    /// The pinned key: go on.
    Matches,
    /// Nothing pinned yet: pin this one.
    First,
    /// Another key than the pinned one: refused, with both fingerprints.
    Changed(String),
}

/// The verdict for `answered` against what this browser `stored`.
pub fn pin_verdict(stored: Option<&str>, answered: &str) -> Pin {
    match stored.map(str::trim).filter(|s| !s.is_empty()) {
        None => Pin::First,
        Some(p) if p == answered.trim() => Pin::Matches,
        Some(p) => Pin::Changed(format!(
            "The server answered an encryption key that is NOT the one this browser pinned for you \
             (pinned {}, answered {}). Nothing was opened with it. If you made a new key on purpose, or \
             another person uses this browser, forget the pinned key on your Account page after \
             checking its fingerprint with the Gunnar CLI (`gunnar crypt key status`).",
            gunnar_crypt::door::fingerprint(p),
            gunnar_crypt::door::fingerprint(answered)
        )),
    }
}

#[cfg(target_arch = "wasm32")]
pub use live::*;

#[cfg(target_arch = "wasm32")]
mod live {
    use std::cell::RefCell;
    use std::collections::HashMap;

    use gunnar_crypt::keyring::Keyring;
    use gunnar_crypt::userkey::UserKey;
    use js_sys::{Array, Function, Object, Promise, Reflect, Uint8Array};
    use wasm_bindgen::{JsCast, JsValue};

    use super::*;
    use crate::api::segment;

    thread_local! {
        /// The person's encryption key, once opened in this tab. Memory only;
        /// gone with the tab.
        static USER_KEY: RefCell<Option<UserKey>> = const { RefCell::new(None) };
        /// Key rings opened in this tab, by the exact key file they came from.
        static RINGS: RefCell<HashMap<String, Keyring>> = RefCell::new(HashMap::new());
    }

    /// Is the encryption key open in this tab?
    pub fn is_open() -> bool {
        USER_KEY.with(|k| k.borrow().is_some())
    }

    /// This console's host: the relying party a security key is bound to.
    pub fn rp_id() -> String {
        web_sys::window().and_then(|w| w.location().hostname().ok()).unwrap_or_default()
    }

    fn random<const N: usize>() -> [u8; N] {
        let mut b = [0u8; N];
        if let Some(crypto) = web_sys::window().and_then(|w| w.crypto().ok()) {
            let _ = crypto.get_random_values_with_u8_array(&mut b);
        }
        b
    }

    fn said(f: crate::api::Fail) -> String {
        match f.message.is_empty() {
            true => f.summary().to_owned(),
            false => format!("{}: {}", f.summary(), f.message),
        }
    }

    /// The person's encryption key as gunnar holds it.
    pub async fn encryption_key() -> Result<EncryptionKey, String> {
        let key = crate::api::get::<EncryptionKey>(KEY_ROUTE).await.map_err(said)?;
        if key.exists {
            let storage = web_sys::window().and_then(|w| w.local_storage().ok().flatten());
            let stored = storage.as_ref().and_then(|s| s.get_item(PIN_KEY).ok().flatten());
            match pin_verdict(stored.as_deref(), &key.public_key) {
                Pin::Matches => {}
                Pin::First => {
                    if let Some(s) = &storage {
                        let _ = s.set_item(PIN_KEY, key.public_key.trim());
                    }
                }
                Pin::Changed(words) => return Err(words),
            }
        }
        Ok(key)
    }

    /// Forget this browser's pinned key (Account page): the next answer is pinned again.
    pub fn forget_pin() {
        if let Some(s) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) {
            let _ = s.remove_item(PIN_KEY);
        }
    }

    fn js_err(e: JsValue) -> String {
        let name = Reflect::get(&e, &JsValue::from_str("name")).ok().and_then(|m| m.as_string()).unwrap_or_default();
        match name.as_str() {
            "NotAllowedError" | "AbortError" => Refusal::Cancelled.words().to_owned(),
            "InvalidStateError" => "That security key is already one of your unlock methods.".to_owned(),
            "NotSupportedError" => Refusal::NoPrfInBrowser.words().to_owned(),
            _ => crate::refusal::passkey_error_words(&name).to_owned(),
        }
    }

    fn set(o: &JsValue, k: &str, v: &JsValue) -> Result<(), String> {
        Reflect::set(o, &JsValue::from_str(k), v).map(|_| ()).map_err(js_err)
    }

    /// JSON options (bytes as base64url at [`BUFFERS`]) to the JS object
    /// WebAuthn takes.
    fn to_js(v: &serde_json::Value, path: &str) -> Result<JsValue, String> {
        if let (true, Some(s)) = (super::BUFFERS.contains(&path), v.as_str()) {
            return Ok(Uint8Array::from(&from_b64url(s).ok_or("bad base64url in the options")?[..]).into());
        }
        let at = |k: &str| if path.is_empty() { k.to_owned() } else { format!("{path}.{k}") };
        Ok(match v {
            serde_json::Value::Object(map) => {
                let o: JsValue = Object::new().into();
                for (k, x) in map {
                    set(&o, k, &to_js(x, &at(k))?)?;
                }
                o
            }
            serde_json::Value::Array(items) => {
                let a = Array::new();
                for x in items {
                    a.push(&to_js(x, &format!("{path}[]"))?);
                }
                a.into()
            }
            serde_json::Value::String(s) => JsValue::from_str(s),
            serde_json::Value::Number(n) => JsValue::from_f64(n.as_f64().unwrap_or(0.0)),
            serde_json::Value::Bool(b) => JsValue::from_bool(*b),
            serde_json::Value::Null => JsValue::NULL,
        })
    }

    async fn call(verb: &str, options: &serde_json::Value) -> Result<JsValue, String> {
        let window = web_sys::window().ok_or("no window")?;
        if Reflect::get(window.as_ref(), &JsValue::from_str("PublicKeyCredential")).map_or(true, |v| v.is_undefined()) {
            return Err(Refusal::NoPrfInBrowser.words().to_owned());
        }
        let request = to_js(options, "")?;
        let navigator = window.navigator();
        let credentials = Reflect::get(navigator.as_ref(), &JsValue::from_str("credentials")).map_err(js_err)?;
        let f: Function = Reflect::get(&credentials, &JsValue::from_str(verb))
            .map_err(js_err)?
            .dyn_into()
            .map_err(|_| Refusal::NoPrfInBrowser.words().to_owned())?;
        let promise: Promise = f.call1(&credentials, &request).map_err(js_err)?.dyn_into().map_err(js_err)?;
        wasm_bindgen_futures::JsFuture::from(promise).await.map_err(js_err)
    }

    /// The credential's id and its PRF answer: (id, prf offered by the
    /// browser, results.first, prf.enabled at create).
    #[allow(clippy::type_complexity)]
    fn prf_of(credential: &JsValue) -> Result<(Vec<u8>, bool, Option<Vec<u8>>, Option<bool>), String> {
        let raw = Reflect::get(credential, &JsValue::from_str("rawId")).map_err(js_err)?;
        let id = Uint8Array::new(&raw).to_vec();
        let results: Function = Reflect::get(credential, &JsValue::from_str("getClientExtensionResults"))
            .map_err(js_err)?
            .dyn_into()
            .map_err(|_| Refusal::NoPrfInBrowser.words().to_owned())?;
        let ext = results.call0(credential).map_err(js_err)?;
        let prf = Reflect::get(&ext, &JsValue::from_str("prf")).unwrap_or(JsValue::UNDEFINED);
        if prf.is_undefined() || prf.is_null() {
            return Ok((id, false, None, None));
        }
        let enabled = Reflect::get(&prf, &JsValue::from_str("enabled")).ok().and_then(|e| e.as_bool());
        let first = Reflect::get(&prf, &JsValue::from_str("results"))
            .ok()
            .filter(|r| !r.is_undefined() && !r.is_null())
            .and_then(|r| Reflect::get(&r, &JsValue::from_str("first")).ok())
            .filter(|f| !f.is_undefined() && !f.is_null())
            .map(|f| Uint8Array::new(&f).to_vec());
        Ok((id, true, first, enabled))
    }

    /// **Touch a security key**: one of `allow`, evaluated on the fixed PRF
    /// input. The credential's id and its PRF secret.
    pub async fn touch(allow: &[&Method]) -> Result<(Vec<u8>, [u8; 32]), String> {
        let options = get_options(&rp_id(), allow, &random::<32>());
        let credential = call("get", &options).await?;
        let (id, offered, first, _) = prf_of(&credential)?;
        let prf = prf_secret(offered, first.as_deref()).map_err(|r| r.words().to_owned())?;
        Ok((id, prf))
    }

    /// **Open the encryption key with a security key** (one touch), and keep
    /// it in this tab.
    pub async fn unlock_with_security_key() -> Result<(), String> {
        let key = encryption_key().await?;
        if !key.exists {
            return Err(Refusal::NoEncryptionKey.words().to_owned());
        }
        let host = rp_id();
        let allow = key.security_keys(&host);
        if allow.is_empty() {
            return Err(Refusal::NoSecurityKey.words().to_owned());
        }
        let (id, prf) = touch(&allow).await?;
        let (user, rewrap) = open_with_security_key_rewrap(&key, &id, &prf)?;
        // CRYPT-PQ-SK: a v1 (X25519) wrap that opened is replaced by its v2 re-wrap.
        if let Some(body) = rewrap {
            if let Err(why) = add_method(&body).await {
                web_sys::console::warn_1(&format!("the security key's v2 re-wrap was not taken: {why}").into());
            }
        }
        USER_KEY.with(|k| *k.borrow_mut() = Some(user));
        Ok(())
    }

    /// The encryption key open in this tab: as it is, or with a recovery code
    /// when one is typed, else with a security key (one touch).
    pub async fn ensure_open(code: Option<String>) -> Result<(), String> {
        if is_open() {
            return Ok(());
        }
        match code.filter(|c| !c.trim().is_empty()) {
            Some(code) => unlock_with_recovery_code(&code).await,
            None => unlock_with_security_key().await,
        }
    }

    /// **Open the encryption key with a printed recovery code.**
    pub async fn unlock_with_recovery_code(code: &str) -> Result<(), String> {
        let key = encryption_key().await?;
        let user = open_with_recovery_code(&key, code)?;
        USER_KEY.with(|k| *k.borrow_mut() = Some(user));
        Ok(())
    }

    /// **Enrol a security key** (Account → Security keys for encryption): a
    /// new credential (one touch), its PRF secret from a get (a second touch),
    /// the encryption key wrapped to its recipient. The first
    /// method MAKES the encryption key, here, in this tab (quantum-safe unless
    /// `classic`); a later one needs
    /// the key open in this tab first. Touches no repository.
    pub async fn enrol_security_key(user_name: &str, label: &str, classic: bool) -> Result<EncryptionKey, String> {
        let key = encryption_key().await?;
        if key.exists && !is_open() {
            return Err("Open your encryption key first (with a security key you added, or your recovery code): the new key gets a copy of it.".to_owned());
        }
        let host = rp_id();
        let options = create_options(&host, user_name, &random::<16>(), &random::<32>(), &key.security_keys(&host));
        let credential = call("create", &options).await?;
        let (id, offered, first, enabled) = prf_of(&credential)?;
        if !offered {
            return Err(Refusal::NoPrfInBrowser.words().to_owned());
        }
        if enabled == Some(false) {
            return Err(Refusal::NoHmacSecret.words().to_owned());
        }
        // The secret is always taken from a GET, the ceremony every later
        // opening runs: a key may evaluate the PRF at create under another
        // user-verification state than a get, and a recipient made from that
        // answer would never open again. So enrolment is two touches.
        let _ = first;
        let fresh = Method { kind: "security-key".into(), method_ref: b64url(&id), rp_id: host.clone(), ..Method::default() };
        let (again, prf) = touch(&[&fresh]).await?;
        if again != id {
            return Err(Refusal::WrongKey.words().to_owned());
        }
        if key.exists {
            let body = USER_KEY.with(|k| {
                let k = k.borrow();
                let user = k.as_ref().ok_or("your encryption key is not open in this tab")?;
                security_key_method(user, &id, &prf, &host, label)
            })?;
            add_method(&body).await
        } else {
            let user = new_user_key(classic)?;
            let first = security_key_method(&user, &id, &prf, &host, label)?;
            let body = serde_json::json!({ "public_key": user.public(), "first": first });
            let made = crate::api::post::<_, EncryptionKey>(KEY_ROUTE, &body).await.map_err(said)?;
            USER_KEY.with(|k| *k.borrow_mut() = Some(user));
            Ok(made)
        }
    }

    /// **A printed recovery code**: made here, wrapped here, shown once.
    pub async fn add_recovery_code() -> Result<(String, EncryptionKey), String> {
        let code = gunnar_crypt::userkey::recovery_code::generate();
        let body = USER_KEY.with(|k| {
            let k = k.borrow();
            let user = k
                .as_ref()
                .ok_or("Open your encryption key first (with a security key you added): the code gets a copy of it.")?;
            recovery_code_method(user, &code)
        })?;
        let key = add_method(&body).await?;
        Ok((code.to_string(), key))
    }

    async fn add_method(body: &serde_json::Value) -> Result<EncryptionKey, String> {
        crate::api::post::<_, EncryptionKey>(&format!("{KEY_ROUTE}/methods"), body).await.map_err(said)
    }

    /// Remove one method (gunnar refuses the last, in its own words).
    pub async fn remove_method(id: &str) -> Result<EncryptionKey, String> {
        crate::api::delete::<EncryptionKey>(&format!("{KEY_ROUTE}/methods/{}", segment(id))).await.map_err(said)
    }

    async fn raw(ns: &str, repo: &str, rev: &str, path: &str) -> Result<Vec<u8>, String> {
        raw_status(ns, repo, rev, path).await.map_err(|(status, said)| raw_refusal(status, said))
    }

    /// [`raw`], keeping the HTTP status: a diff tells a missing path (404) from a failure.
    async fn raw_status(ns: &str, repo: &str, rev: &str, path: &str) -> Result<Vec<u8>, (u16, String)> {
        crate::api::get_bytes(&format!(
            "/api/repos/{}/{}/raw?rev={}&path={}",
            segment(ns),
            segment(repo),
            crate::auth::percent_encode(rev),
            crate::auth::percent_encode(path)
        ))
        .await
        .map_err(|f| (f.status, said(f)))
    }

    /// The key ring for this repository at this revision, opened with the
    /// encryption key already open in this tab.
    pub async fn ring(ns: &str, repo: &str, rev: &str) -> Result<Keyring, String> {
        let keys = raw(ns, repo, rev, KEYS_PATH)
            .await
            .map_err(|e| format!("this repository's key file ({KEYS_PATH}) could not be read: {e}"))?;
        let id = String::from_utf8_lossy(&keys).into_owned();
        if let Some(r) = RINGS.with(|m| m.borrow().get(&id).cloned()) {
            return Ok(r);
        }
        let members = match diff_side(raw_status(ns, repo, rev, MEMBERS_PATH).await) {
            Ok(m) => m.map(|b| String::from_utf8_lossy(&b).into_owned()),
            Err(e) => return Err(format!("this repository's members file ({MEMBERS_PATH}) could not be read: {e}")),
        };
        let ring = USER_KEY.with(|k| {
            let k = k.borrow();
            let user = k.as_ref().ok_or_else(|| "Open your encryption key first.".to_owned())?;
            open_ring(&keys, members.as_deref(), user)
        })?;
        RINGS.with(|m| m.borrow_mut().insert(id, ring.clone()));
        Ok(ring)
    }

    /// Open one file, as text.
    pub async fn open_file(ns: &str, repo: &str, rev: &str, path: &str) -> Result<String, String> {
        let ring = ring(ns, repo, rev).await?;
        let sealed = raw(ns, repo, rev, path).await?;
        let plain = gunnar_crypt::open_blob(&ring, &sealed).map_err(|e| e.to_string())?;
        Ok(String::from_utf8_lossy(&plain).into_owned())
    }

    /// The plaintext diff of one path between two revisions, computed here.
    pub async fn diff_file(ns: &str, repo: &str, from: &str, to: &str, path: &str) -> Result<gunnar_crypt::diff::FileDiff, String> {
        let ring = ring(ns, repo, to).await?;
        let old = diff_side(raw_status(ns, repo, from, path).await)?;
        let new = diff_side(raw_status(ns, repo, to, path).await)?;
        gunnar_crypt::diff_blobs(&ring, old.as_deref(), new.as_deref()).map_err(|e| e.to_string())
    }
}

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

    /// ★ RA4-16: the recovery code is typed into a password field, never sent to a spellchecker.
    #[test]
    fn the_recovery_code_field_is_a_password_field() {
        let page = include_str!("pages/account.rs");
        let field = page.split("id=\"enc-code\"").nth(1).expect("the recovery code field");
        let tag = &field[..field.find("/>").expect("the tag ends")];
        assert!(tag.contains("type=\"password\"") && tag.contains("spellcheck=\"false\""), "{tag}");
    }

    /// ★ RA4-1: the browser pins the person's own key the first time and
    /// refuses another one by name, with both fingerprints.
    #[test]
    fn the_browser_pins_the_persons_key_and_refuses_a_changed_one() {
        assert_eq!(pin_verdict(None, "age1aaa"), Pin::First);
        assert_eq!(pin_verdict(Some(""), "age1aaa"), Pin::First);
        assert_eq!(pin_verdict(Some("age1aaa"), "age1aaa\n"), Pin::Matches);
        match pin_verdict(Some("age1aaa"), "age1bbb") {
            Pin::Changed(words) => {
                assert!(words.contains("NOT the one this browser pinned"), "{words}");
                assert!(words.contains(&gunnar_crypt::door::fingerprint("age1aaa")) && words.contains(&gunnar_crypt::door::fingerprint("age1bbb")), "{words}");
            }
            other => panic!("{other:?}"),
        }
    }
    use gunnar_crypt::members::{Level, Members};

    /// gunnar-crypt's test-vector key ring (`tests/vectors/keyring.txt` in
    /// gunnar): TEST keys, used nowhere else.
    const VECTOR_RING: &str = include_str!("../../../../gunnar/crates/gunnar-crypt/tests/vectors/keyring.txt");
    /// gunnar-crypt's TEST security key (`tests/vectors.rs` in gunnar): its PRF
    /// output and credential id, and the vector files wrapped to it natively.
    const TEST_PRF: [u8; 32] = [0x5A; 32];
    const TEST_CREDENTIAL: &[u8] = b"gunnar-crypt test credential";
    const VECTOR_KEYS: &str = include_str!("../../../../gunnar/crates/gunnar-crypt/tests/vectors/keys.age");
    const VECTOR_USER_KEY_SK: &str = include_str!("../../../../gunnar/crates/gunnar-crypt/tests/vectors/user-key-security-key.age");
    const VECTOR_USER_KEY: &str = include_str!("../../../../gunnar/crates/gunnar-crypt/tests/vectors/test_user_key");

    /// The public half of the TEST user key.
    fn vector_public() -> String {
        let id: gunnar_crypt::wrap::age::x25519::Identity = VECTOR_USER_KEY.trim().parse().expect("the TEST user key");
        id.to_public().to_string()
    }

    fn key_with(methods: Vec<Method>, public: &str) -> EncryptionKey {
        EncryptionKey { exists: true, public_key: public.to_owned(), methods }
    }

    fn sk_method(cred: &[u8], wrap: &str) -> Method {
        Method { id: "01".into(), kind: "security-key".into(), label: "YubiKey".into(), method_ref: b64url(cred), rp_id: "console.example".into(), wrap: wrap.into(), added_ms: 1 }
    }

    /// ★ **The browser's whole path on gunnar-crypt's vectors**: the TEST
    /// security key's PRF secret opens the TEST user key (wrapped natively),
    /// which opens the vector ring, which opens the vector envelope.
    #[test]
    fn a_security_key_opens_the_encryption_key_then_the_ring_then_the_file() {
        let public = vector_public();
        let key = key_with(vec![sk_method(TEST_CREDENTIAL, VECTOR_USER_KEY_SK)], &public);
        let user = open_with_security_key(&key, TEST_CREDENTIAL, &TEST_PRF).expect("the TEST security key opens it");
        let mut members = Members::at(Level::Classic);
        members.add("vector", &public).unwrap();
        let ring = open_ring(VECTOR_KEYS.as_bytes(), Some(&members.to_text()), &user).expect("the ring opens");
        assert_eq!(ring, Keyring::parse(VECTOR_RING).unwrap());
        // The "hello" case of the vectors, sealed by gunnar-crypt's NATIVE build.
        let hex = "00474e43525950540100000001bcd2dff337e5dd5482818bfc889b634e74cb77d72d67e2be497dad68d1";
        let sealed: Vec<u8> = (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()).collect();
        assert_eq!(gunnar_crypt::open_blob(&ring, &sealed).unwrap(), b"hello, world\n");
    }

    /// ★ RA4-14: a bogus wrap under the same credential (a planted key) does not
    /// block the real one: it is skipped and the next opens.
    #[test]
    fn a_bogus_wrap_first_is_skipped_and_the_real_one_opens() {
        let public = vector_public();
        let planted = UserKey::generate(gunnar_crypt::members::Level::Classic).unwrap();
        let bogus = planted.wrap_to_security_key(&security_key::recipient(&TEST_PRF, TEST_CREDENTIAL)).unwrap();
        let key = key_with(vec![sk_method(TEST_CREDENTIAL, &bogus), sk_method(TEST_CREDENTIAL, VECTOR_USER_KEY_SK)], &public);
        assert_eq!(open_with_security_key(&key, TEST_CREDENTIAL, &TEST_PRF).unwrap().public(), public);
        let only = key_with(vec![sk_method(TEST_CREDENTIAL, &bogus)], &public);
        assert!(open_with_security_key(&only, TEST_CREDENTIAL, &TEST_PRF).is_err());
    }

    /// ★ Every refusal by name: another security key (wrong key), an unknown
    /// credential, no PRF in the browser, a key without hmac-secret, not a
    /// member, no encryption key.
    #[test]
    fn every_refusal_is_named() {
        let public = vector_public();
        let key = key_with(vec![sk_method(TEST_CREDENTIAL, VECTOR_USER_KEY_SK)], &public);
        assert_eq!(open_with_security_key(&key, TEST_CREDENTIAL, &[0x11; 32]).unwrap_err(), Refusal::WrongKey.words());
        assert_eq!(open_with_security_key(&key, b"another credential", &TEST_PRF).unwrap_err(), Refusal::WrongKey.words());
        assert_eq!(prf_secret(false, None), Err(Refusal::NoPrfInBrowser));
        assert_eq!(prf_secret(true, None), Err(Refusal::NoHmacSecret));
        assert_eq!(prf_secret(true, Some(&[1; 16])), Err(Refusal::NoHmacSecret));
        assert_eq!(prf_secret(true, Some(&[1; 32])), Ok([1; 32]));
        assert!(Refusal::NoPrfInBrowser.words().contains("PRF"));
        assert!(Refusal::NoHmacSecret.words().contains("hmac-secret"));
        assert!(Refusal::NoSecurityKey.words().contains("Security keys for encryption"));
        let nobody = open_with_security_key(&EncryptionKey::default(), TEST_CREDENTIAL, &TEST_PRF).unwrap_err();
        assert_eq!(nobody, Refusal::NoEncryptionKey.words());
        // Not a member: a ring wrapped to somebody else.
        let user = open_with_security_key(&key, TEST_CREDENTIAL, &TEST_PRF).unwrap();
        let other = UserKey::generate(Level::Classic).unwrap();
        let mut members = Members::at(Level::Classic);
        members.add("other", &other.public()).unwrap();
        let theirs = gunnar_crypt::wrap::wrap(&Keyring::generate(), &members).unwrap();
        assert_eq!(open_ring(theirs.as_bytes(), Some(&members.to_text()), &user).unwrap_err(), Refusal::NotAMember.words());
        for r in [Refusal::NoPrfInBrowser, Refusal::NoHmacSecret, Refusal::NotAMember, Refusal::WrongKey, Refusal::NoSecurityKey, Refusal::NoEncryptionKey, Refusal::Cancelled] {
            assert!(!r.words().to_lowercase().contains("helper") && !r.words().contains("127.0.0.1"), "{r:?}");
        }
    }

    /// ★ Enrol once, open everywhere: a method made in the tab (security key,
    /// recovery code) opens the same encryption key, and the ring of every
    /// repository it is a member of; no repository is touched.
    #[test]
    fn a_method_made_here_opens_the_same_key_and_every_ring() {
        let user = UserKey::generate(Level::Classic).unwrap();
        let prf = [9u8; 32];
        let sk = security_key_method(&user, b"cred-9", &prf, "console.example", " Keyring key ").unwrap();
        assert_eq!(sk["kind"], "security-key");
        assert_eq!(sk["label"], "Keyring key");
        assert_eq!(sk["method_ref"], b64url(b"cred-9"));
        let code = gunnar_crypt::userkey::recovery_code::from_bytes(&[4; 20]);
        let rc = recovery_code_method(&user, &code).unwrap();
        let methods: Vec<Method> = [sk, rc].into_iter().map(|v| serde_json::from_value(v).unwrap()).collect();
        let key = key_with(methods, &user.public());
        assert!(!key.wants_a_spare());
        let mut rings = Vec::new();
        for _ in 0..3 {
            let ring = Keyring::generate();
            let mut m = Members::at(Level::Classic);
            m.add("anna", &user.public()).unwrap();
            rings.push((ring.clone(), gunnar_crypt::wrap::wrap(&ring, &m).unwrap(), m.to_text()));
        }
        for opened in [open_with_security_key(&key, b"cred-9", &prf).unwrap(), open_with_recovery_code(&key, &code.to_lowercase()).unwrap()] {
            for (ring, keys, members) in &rings {
                assert_eq!(&open_ring(keys.as_bytes(), Some(members), &opened).unwrap(), ring);
            }
        }
        let bad = open_with_recovery_code(&key, &gunnar_crypt::userkey::recovery_code::from_bytes(&[5; 20])).unwrap_err();
        assert!(bad.contains("does not open"), "{bad}");
    }

    /// ★ What the security key is asked: this console's credentials only, the
    /// fixed PRF input, and NO user verification — so the CLI's hmac-secret
    /// without a PIN gets the same secret.
    #[test]
    fn the_touch_asks_for_the_prf_without_a_pin() {
        let a = sk_method(b"one", "");
        let o = get_options("console.example", &[&a], &[7; 32]);
        let pk = &o["publicKey"];
        assert_eq!(pk["userVerification"], "discouraged");
        assert_eq!(pk["rpId"], "console.example");
        assert_eq!(pk["allowCredentials"][0]["id"], b64url(b"one"));
        assert_eq!(from_b64url(pk["extensions"]["prf"]["eval"]["first"].as_str().unwrap()).unwrap(), security_key::PRF_INPUT);
        let c = create_options("console.example", "anna", &[1; 16], &[2; 32], &[&a]);
        let pk = &c["publicKey"];
        assert_eq!(pk["authenticatorSelection"]["userVerification"], "discouraged");
        assert_eq!(pk["attestation"], "none");
        assert_eq!(pk["excludeCredentials"][0]["id"], b64url(b"one"), "a key enrolled twice is refused by the browser");
        assert!(pk["extensions"]["prf"]["eval"]["first"].is_string());
    }

    /// ★ Only the byte fields become bytes: the challenge, the user handle,
    /// the credential ids and the PRF input — never `rp.id` or `rpId`.
    #[test]
    fn only_the_byte_fields_of_the_options_are_read_as_bytes() {
        let a = sk_method(b"one", "");
        for o in [get_options("localhost", &[&a], &[7; 32]), create_options("localhost", "anna", &[1; 16], &[2; 32], &[&a])] {
            let mut leaves = Vec::new();
            leaf_paths(&o, "", &mut leaves);
            for (path, value) in leaves {
                let bytes = BUFFERS.contains(&path.as_str());
                assert_eq!(bytes, matches!(path.as_str(), "publicKey.challenge" | "publicKey.user.id" | "publicKey.allowCredentials[].id" | "publicKey.excludeCredentials[].id" | "publicKey.extensions.prf.eval.first"), "{path}");
                if path.ends_with("rp.id") || path.ends_with("rpId") {
                    assert!(!bytes && value == "localhost", "{path} is a host name");
                }
            }
        }
    }

    #[test]
    fn methods_read_in_words_and_a_single_one_wants_a_spare() {
        let one = key_with(vec![sk_method(b"x", "")], "age1x");
        assert!(one.wants_a_spare());
        assert_eq!(one.security_keys("console.example").len(), 1);
        assert_eq!(one.security_keys("other.example").len(), 0, "a key bound to another console cannot be asked here");
        assert_eq!(method_line(&one.methods[0]), "YubiKey — security key");
        assert_eq!(method_line(&Method { kind: "ssh".into(), ..Method::default() }), "SSH key (Gunnar CLI)");
    }

    /// ★ A sealed file over the console's 2 MiB says to read it with the CLI, in plain words —
    /// not the server's byte count; other refusals keep the server's words.
    #[test]
    fn a_sealed_file_over_two_mib_says_to_read_it_with_the_cli() {
        let said = raw_refusal(413, "The object is too large to fetch.: object huge.txt is 3145757 bytes, over the 2097152-byte limit the console fetches".into());
        assert!(said.contains("2 MiB") && said.contains("gunnar crypt unlock"), "{said}");
        assert!(!said.contains("bytes, over the"), "the server's byte count is not the person's: {said}");
        assert_eq!(raw_refusal(403, "Your role does not allow this.".into()), "Your role does not allow this.");
        assert!(matches!(diff_side(Err((413, "x".into()))), Err(e) if e.contains("gunnar crypt unlock")));
    }

    /// ★ Only a 404 is "not at this revision"; every other failure is said, never drawn as an
    /// added or deleted file.
    #[test]
    fn only_a_missing_path_is_an_absent_side_of_a_diff() {
        assert_eq!(diff_side(Ok(b"sealed".to_vec())), Ok(Some(b"sealed".to_vec())));
        assert_eq!(diff_side(Err((404, "It does not exist.".into()))), Ok(None));
        for (status, said) in [(413, "2 MiB"), (403, "Your role does not allow this."), (0, "The console could not reach the server."), (503, "Gunnar is not responding right now.")] {
            let side = diff_side(Err((status, said.to_owned())));
            assert!(matches!(&side, Err(e) if e.contains(said)), "{status}: {side:?}");
        }
    }

    #[test]
    fn a_chunked_v2_envelope_opens_and_diffs_here_as_in_the_cli() {
        let ring = Keyring::parse(VECTOR_RING).unwrap();
        let key = ring.newest();
        let old = gunnar_crypt::envelope::seal(key, b"a\nb\n");
        let new = gunnar_crypt::envelope::seal(key, b"a\nc\n");
        assert_eq!(gunnar_crypt::envelope::version_of(&old), Some(2));
        let d = gunnar_crypt::diff_blobs(&ring, Some(&old), Some(&new)).unwrap();
        assert!(gunnar_crypt::diff::unified(&d).contains("-b\n+c\n"));
        assert_eq!(plain_lines("a\n\nb\n").len(), 3);
    }

    /// ★ CRYPT-PQ-DEFAULT (owner 2026-10-30): the console makes a QUANTUM-SAFE key
    /// unless the person asks for a classic one, opens it with a security key
    /// (PRF through HKDF) and with a recovery code, and opens a quantum-safe ring
    /// with it — in the tab, no CLI needed.
    #[test]
    fn the_console_makes_and_opens_a_quantum_safe_key_by_default() {
        let user = new_user_key(false).unwrap();
        assert!(user.public().starts_with("age1pq1"), "a new key is quantum-safe: {}", user.public());
        let classic = new_user_key(true).unwrap();
        assert!(classic.public().starts_with("age1") && !classic.public().starts_with("age1pq1"));

        let cred = b"console credential";
        let sk = security_key_method(&user, cred, &TEST_PRF, "console.test", "YubiKey").unwrap();
        let code = gunnar_crypt::userkey::recovery_code::generate();
        let rc = recovery_code_method(&user, &code).unwrap();
        let methods = vec![
            Method { kind: "security-key".into(), method_ref: b64url(cred), rp_id: "console.test".into(), wrap: sk["wrap"].as_str().unwrap().into(), ..Method::default() },
            Method { kind: "recovery-code".into(), wrap: rc["wrap"].as_str().unwrap().into(), ..Method::default() },
        ];
        let key = key_with(methods, &user.public());
        let by_key = open_with_security_key(&key, cred, &TEST_PRF).unwrap();
        assert_eq!(by_key.public(), user.public());
        let by_code = open_with_recovery_code(&key, &code).unwrap();
        assert_eq!(by_code.public(), user.public());

        let mut members = Members::new();
        assert_eq!(members.level(), Level::QuantumSafe);
        members.add("alice", &user.public()).unwrap();
        let ring = Keyring::generate();
        let keys = gunnar_crypt::wrap::wrap(&ring, &members).unwrap();
        assert_eq!(open_ring(keys.as_bytes(), Some(&members.to_text()), &by_key).unwrap(), ring);
    }

    /// ★ A quantum-safe key's ways in say whether their OWN wrap is quantum-safe
    /// (the SSH key's and the security key's are X25519, the recovery code's and
    /// the key file's are not breakable by a quantum computer); a classic key's say nothing.
    #[test]
    fn a_quantum_safe_keys_ways_in_say_how_strong_their_wrap_is() {
        let pq = key_with(vec![], &new_user_key(false).unwrap().public());
        let classic = key_with(vec![], &new_user_key(true).unwrap().public());
        let m = |kind: &str| Method { kind: kind.into(), ..Method::default() };
        assert_eq!(wrap_strength(&pq, &m("recovery-code")), Some("wrap: quantum-safe"));
        assert_eq!(wrap_strength(&pq, &m("age-pq")), Some("wrap: quantum-safe"));
        assert_eq!(wrap_strength(&pq, &m("ssh")), Some("wrap: not quantum-safe"));
        assert_eq!(wrap_strength(&pq, &m("security-key")), Some("wrap: not quantum-safe"));
        assert_eq!(wrap_strength(&classic, &m("ssh")), None);
        assert!(key_kind_words(&pq).contains("quantum-safe"), "{}", key_kind_words(&pq));
        assert!(key_kind_words(&classic).contains("classic"), "{}", key_kind_words(&classic));
        let page = include_str!("pages/account.rs");
        assert!(page.contains("id=\"enc-classic\""), "the classic opt-out is a visible choice");
        assert!(page.contains("sealed::wrap_strength"), "the list shows each wrap's strength");
    }

    /// ★ CRYPT-PQ-SK: a security key enrolled here is wrapped v2 (symmetric, HKDF
    /// over the PRF output, no X25519); an old v1 wrap still opens and hands back
    /// the v2 re-wrap of the same method to send; a v2 wrap asks for nothing; a
    /// wrong PRF output opens nothing; the list marks a v2 security key quantum-safe.
    #[test]
    fn a_security_key_is_wrapped_v2_and_a_v1_wrap_is_rewrapped_on_unlock() {
        let user = new_user_key(false).unwrap();
        let cred = b"console credential v2";
        let body = security_key_method(&user, cred, &TEST_PRF, "console.test", "YubiKey").unwrap();
        let v2 = body["wrap"].as_str().unwrap().to_owned();
        assert!(gunnar_crypt::skwrap::is_v2(&v2), "{v2}");
        let m = |wrap: String| Method { kind: "security-key".into(), label: "YubiKey".into(), method_ref: b64url(cred), rp_id: "console.test".into(), wrap, ..Method::default() };
        let key = key_with(vec![m(v2.clone())], &user.public());
        let (opened, rewrap) = open_with_security_key_rewrap(&key, cred, &TEST_PRF).unwrap();
        assert_eq!((opened.public(), rewrap.is_none()), (user.public(), true));
        assert_eq!(wrap_strength(&key, &key.methods[0]), Some("wrap: quantum-safe"));
        assert_eq!(open_with_security_key(&key, cred, &[0x11; 32]).unwrap_err(), Refusal::WrongKey.words());

        let v1 = user.wrap_to_security_key(&security_key::recipient(&TEST_PRF, cred)).unwrap();
        let old = key_with(vec![m(v1)], &user.public());
        assert_eq!(wrap_strength(&old, &old.methods[0]), Some("wrap: not quantum-safe"));
        let (opened, rewrap) = open_with_security_key_rewrap(&old, cred, &TEST_PRF).unwrap();
        assert_eq!(opened.public(), user.public());
        let body = rewrap.expect("a v1 wrap is re-wrapped to v2");
        assert_eq!((body["kind"].as_str(), body["method_ref"].as_str(), body["rp_id"].as_str(), body["label"].as_str()), (Some("security-key"), Some(b64url(cred).as_str()), Some("console.test"), Some("YubiKey")));
        let again = key_with(vec![m(body["wrap"].as_str().unwrap().to_owned())], &user.public());
        assert_eq!(open_with_security_key_rewrap(&again, cred, &TEST_PRF).unwrap().0.public(), user.public());
        assert!(wrap_strength_why(&again.methods[0]).contains("quantum-safe"));
    }
}