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"));
}
}