uitest/src/sealed.rs
//! **Encrypted repositories in the browser, opened with a security key**
//! (CRYPT-YUBI, owner 2026-10-26): an encrypted file and an encrypted diff are
//! opened IN THE TAB. The person's security key (the rig's virtual
//! authenticator, with the PRF extension, FIDO2 hmac-secret) opens their ONE
//! encryption key, which opens the repository's key ring. The console server
//! and gunnar hold ciphertext only, and nothing runs beside the browser.
//!
//! The fixture is the real thing end to end: the person enrols a security key
//! ONCE on Account → Security keys for encryption (gunnar keeps the public half
//! and a wrap), `gunnar-cli crypt init` seals a repository to that public key
//! — the member was not present for it — stock git pushes it, and the rows
//! open it in Chrome with the security key only. One row offers a key WITHOUT
//! hmac-secret and reads the refusal that names it.
use std::path::Path;
use std::process::Command;
use crate::cdp::Cdp;
use crate::http;
use crate::matrix::{go, want, Row};
use crate::rig::Rig;
const REPO: &str = "alice/sealed";
const V1: &str = "the vault opens at dawn\nbring the second key\n";
const V2: &str = "the vault opens at dusk\nbring the second key\nand a torch\n";
/// **A sealed text of `bytes`**: 1 KiB lines between a first and a last line the rows look for.
/// 1.5 MiB is two chunks of the v2 envelope (1 MiB each) and under the console's 2 MiB; 3 MiB
/// is over it.
fn long_text(bytes: usize, tag: &str) -> String {
let line = format!("{} the middle of the long plan\n", "x".repeat(1024 - 30));
let mut s = format!("BEGIN {tag}\n");
while s.len() + line.len() < bytes {
s.push_str(&line);
}
s.push_str(&format!("END {tag}\n"));
s
}
struct Fixture {
prev: String,
head: String,
}
fn gunnar(r: &Rig, dir: &Path, args: &[&str]) -> Result<String, String> {
let out = Command::new(&r.cli)
.current_dir(dir)
.args(args)
.output()
.map_err(|e| format!("gunnar-cli {}: {e}", args.join(" ")))?;
let text = String::from_utf8_lossy(&out.stdout).to_string() + &String::from_utf8_lossy(&out.stderr);
if out.status.success() {
Ok(text)
} else {
Err(format!("gunnar-cli {} exited {}: {}", args.join(" "), out.status, http::cut(&text, 400)))
}
}
/// The repository, sealed to `public` (the person's encryption key): the
/// member is not present; only their public half is used.
fn fixture(r: &Rig, public: &str) -> Result<Fixture, String> {
let wt = r.work.join("sealed-wt");
let _ = std::fs::remove_dir_all(&wt);
std::fs::create_dir_all(&wt).map_err(|e| e.to_string())?;
r.git(&wt, &["init", "-q", "--object-format=sha256", "-b", "main", "."])?;
r.git(&wt, &["config", "user.name", "uitest"])?;
r.git(&wt, &["config", "user.email", "info@example.invalid"])?;
gunnar(r, &wt, &["crypt", "init", "--member", "alice", "--key", public])?;
std::fs::write(wt.join("plan.txt"), V1).map_err(|e| e.to_string())?;
std::fs::write(wt.join("long.txt"), long_text(3 << 19, "long")).map_err(|e| e.to_string())?;
std::fs::write(wt.join("huge.txt"), long_text(3 << 20, "huge")).map_err(|e| e.to_string())?;
r.git(&wt, &["add", "plan.txt", "long.txt", "huge.txt"])?;
r.git(&wt, &["commit", "-qm", "the plan"])?;
std::fs::write(wt.join("plan.txt"), V2).map_err(|e| e.to_string())?;
r.git(&wt, &["commit", "-qam", "the plan, revised"])?;
r.git(&wt, &["push", "-q", &r.remote(REPO), "main"])?;
let prev = r.git(&wt, &["rev-parse", "HEAD~1"])?.trim().to_owned();
let head = r.git(&wt, &["rev-parse", "HEAD"])?.trim().to_owned();
Ok(Fixture { prev, head })
}
/// The environment variable `--sk-fixture` sets: gunnar-crypt's `sk_v1_fixture` example.
pub const SK_FIXTURE_ENV: &str = "GUNNAR_UITEST_SK_FIXTURE";
/// The first words of a security key's wrap v2 (gunnar-crypt `skwrap::TAG`).
const V2_TAG: &str = "gunnar-crypt user-key wrap v2";
/// In the page: a new credential on the authenticator that answers, with PRF,
/// and its PRF output for gunnar's input — `<credential id> <PRF output>`,
/// both base64url, as the console's own enrolment asks them.
const NEW_CREDENTIAL_WITH_PRF: &str = "(async () => {
const b64u = b => btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
const rp = location.hostname;
const cred = await navigator.credentials.create({ publicKey: {
rp: { id: rp, name: 'uitest v1' }, user: { id: crypto.getRandomValues(new Uint8Array(16)), name: 'v1', displayName: 'v1' },
challenge: crypto.getRandomValues(new Uint8Array(32)), pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
authenticatorSelection: { userVerification: 'discouraged', residentKey: 'discouraged' }, extensions: { prf: {} } } });
const got = await navigator.credentials.get({ publicKey: {
rpId: rp, challenge: crypto.getRandomValues(new Uint8Array(32)), userVerification: 'discouraged',
allowCredentials: [{ type: 'public-key', id: cred.rawId }],
extensions: { prf: { eval: { first: new TextEncoder().encode('gunnar-crypt user-key unlock v1') } } } } });
return b64u(cred.rawId) + ' ' + b64u(got.getClientExtensionResults().prf.results.first);
})()";
const STATE: &str = "(document.querySelector('[data-sealed]') || {}).getAttribute ? document.querySelector('[data-sealed]').getAttribute('data-sealed') : ''";
/// Type into a Leptos input: set its value and send the `input` event it listens to.
fn type_into(c: &mut Cdp, sess: &str, selector: &str, text: &str) -> Result<(), String> {
c.eval(
sess,
&format!(
"(() => {{ const i = document.querySelector({selector:?}); i.value = {text:?}; i.dispatchEvent(new Event('input', {{ bubbles: true }})); return true; }})()"
),
)
.map(|_| ())
}
/// The person's encryption key as the console relays it.
fn encryption_key(c: &mut Cdp, sess: &str) -> Result<String, String> {
c.eval_str(sess, "fetch('/api/account/encryption-key').then(r => r.text())")
}
pub fn rows(r: &Rig, rows: &mut Vec<Row>, c: &mut Cdp, sess: &str, auth: &str) {
const NAMES: [&str; 7] = [
"a-security-key-is-enrolled-once-for-encryption",
"an-encrypted-file-opens-in-the-browser-with-the-security-key-only",
"an-encrypted-diff-is-computed-in-the-browser-not-on-the-server",
"an-encrypted-file-of-two-chunks-opens-in-the-browser",
"an-encrypted-file-over-2-mib-says-to-read-it-with-the-cli",
"a-security-key-without-hmac-secret-is-refused-by-name",
"a-v1-security-key-wrap-is-rewrapped-to-v2-on-unlock",
];
let mut public = String::new();
let enrolled = go(rows, NAMES[0], || {
c.navigate(sess, &format!("{}/account", r.origin()))?;
c.wait_for(sess, "document.querySelector('.enc-enrol')", 20_000)?;
type_into(c, sess, "#enc-label", "uitest key")?;
c.eval(sess, "document.querySelector('.enc-enrol').click()")?;
c.wait_for(
sess,
"document.querySelector('.enc-methods tr[data-kind=\"security-key\"]') || document.querySelector('.enc-why')",
30_000,
)?;
let why = c.eval_str(sess, "(document.querySelector('.enc-why') || {}).innerText || ''")?;
want(why.is_empty(), || format!("the enrolment was refused: {why}"))?;
let held = encryption_key(c, sess)?;
let parsed = crate::json::parse(&held).map_err(|e| format!("{e}: {}", http::cut(&held, 200)))?;
public = parsed.text("public_key");
let methods = parsed.path("methods").map(|m| m.arr().to_vec()).unwrap_or_default();
// CRYPT-PQ-DEFAULT (2026-10-30): the key the console makes is quantum-safe, and so is the
// repository `crypt init` seals to it below (no --level: the default).
want(public.starts_with("age1pq1") && methods.len() == 1, || format!("gunnar holds: {}", http::cut(&held, 300)))?;
want(!held.contains("AGE-SECRET-KEY"), || "a secret key reached the server in the clear".to_owned())?;
// CRYPT-PQ-SK: the security key's wrap is v2 (symmetric, quantum-safe), and the list says so.
let wrap = methods.first().map(|m| m.text("wrap")).unwrap_or_default();
want(wrap.starts_with(V2_TAG), || format!("the security key's wrap is not v2: {}", http::cut(&wrap, 60)))?;
let strength = c.eval_str(sess, "(document.querySelector('.enc-methods tr[data-kind=\"security-key\"] .enc-strength') || {}).innerText || ''")?;
want(strength == "wrap: quantum-safe", || format!("the list marks the security key: {strength:?}"))?;
let urged = c.eval_str(sess, "String(!!document.querySelector('.spare-urge'))")?;
want(urged == "true", || "one way in, and no word urging a second".to_owned())?;
Ok(format!("one method; public {}", http::cut(&public, 20)))
});
let fx = match enrolled.then(|| fixture(r, &public)) {
Some(Ok(f)) => f,
failed => {
let e = match failed {
Some(Err(e)) => e,
_ => "the enrolment row failed".to_owned(),
};
for n in &NAMES[1..] {
let e = e.clone();
go(rows, n, || Err(format!("NOT RUN: the encrypted fixture: {e}")));
}
return;
}
};
go(rows, NAMES[1], || {
c.navigate(sess, &r.origin())?;
let raw = c.eval_str(
sess,
&format!("fetch('/api/repos/{REPO}/raw?path=plan.txt').then(async r => r.status + ' ' + (await r.text()).includes('vault'))"),
)?;
want(raw == "200 false", || format!("the console server's raw answer: {raw} (want 200 and no plaintext)"))?;
// A fresh document: no encryption key in the tab's memory yet.
c.navigate_fresh(sess, &format!("{}/repo/{REPO}/files?path=plan.txt", r.origin()))?;
c.wait_for(sess, &format!("({STATE}) === 'idle'"), 20_000)?;
let before = c.eval_str(sess, "document.querySelector('main').innerText")?;
want(!before.contains("vault opens"), || "plaintext shown before opening".to_owned())?;
want(before.contains("Open it with your security key or the Gunnar CLI"), || format!("the words: {}", http::cut(&before, 300)))?;
c.eval(sess, "document.querySelector('.sealed-open').click()")?;
c.wait_for(sess, &format!("['open','failed'].includes({STATE})"), 30_000)?;
let shown = c.eval_str(sess, "document.querySelector('[data-sealed]').innerText")?;
want(shown.contains("the vault opens at dusk") && shown.contains("and a torch"), || format!("not the plaintext: {}", http::cut(&shown, 300)))?;
Ok("opened in the tab with the security key".to_owned())
});
go(rows, NAMES[2], || {
c.navigate(sess, &r.origin())?;
let diff = c.eval_str(sess, &format!("fetch('/api/repos/{REPO}/diff?from={}&to={}').then(r => r.text())", fx.prev, fx.head))?;
want(diff.contains("\"sealed\":true"), || format!("the console server did not mark the change sealed: {}", http::cut(&diff, 200)))?;
want(!diff.contains("dusk"), || "the console server computed a plaintext diff".to_owned())?;
c.navigate_fresh(sess, &format!("{}/repo/{REPO}/diff?from={}&to={}", r.origin(), fx.prev, fx.head))?;
c.wait_for(sess, "document.querySelector('.sealed-diff .sealed-open')", 20_000)?;
c.eval(sess, "document.querySelector('.sealed-diff .sealed-open').click()")?;
c.wait_for(sess, "['open','failed'].includes(document.querySelector('.sealed-diff').getAttribute('data-sealed'))", 30_000)?;
let shown = c.eval_str(sess, "document.querySelector('.sealed-diff').innerText")?;
want(
shown.contains("the vault opens at dawn") && shown.contains("the vault opens at dusk") && shown.contains("and a torch"),
|| format!("not the plaintext diff: {}", http::cut(&shown, 300)),
)?;
Ok("both versions opened and compared in the tab".to_owned())
});
go(rows, NAMES[3], || {
c.navigate_fresh(sess, &format!("{}/repo/{REPO}/files?path=long.txt", r.origin()))?;
c.wait_for(sess, &format!("({STATE}) === 'idle'"), 20_000)?;
c.eval(sess, "document.querySelector('.sealed-open').click()")?;
c.wait_for(sess, &format!("['open','failed'].includes({STATE})"), 60_000)?;
let state = c.eval_str(sess, STATE)?;
let head_tail = c.eval_str(
sess,
"(() => { const t = document.querySelector('[data-sealed]').innerText; return String(t.includes('BEGIN long')) + ' ' + String(t.includes('END long')); })()",
)?;
want(state == "open" && head_tail == "true true", || format!("the two-chunk file did not open whole: {state} {head_tail}"))?;
Ok("1.5 MiB, two chunks: its first and last lines in the tab".to_owned())
});
go(rows, NAMES[4], || {
c.navigate(sess, &format!("{}/repo/{REPO}/files?path=huge.txt", r.origin()))?;
c.wait_for(sess, &format!("({STATE}) === 'failed'"), 20_000)?;
want(c.eval_str(sess, "String(!!document.querySelector('[data-sealed] .sealed-open'))")? == "false", || "an Open button that cannot work".to_owned())?;
let said = c.eval_str(sess, "document.querySelector('[data-sealed]').innerText")?;
want(said.contains("2 MiB") && said.contains("gunnar crypt unlock"), || format!("no plain way to read it: {}", http::cut(&said, 300)))?;
Ok(http::cut(&said, 140))
});
// A security key WITHOUT hmac-secret. The encryption key is opened first
// with the rig's own key; then that key is made to wait for a touch that
// never comes, so the plain key answers the enrolment, which is refused
// by name and changes nothing at gunnar.
go(rows, NAMES[5], || {
c.navigate_fresh(sess, &format!("{}/account", r.origin()))?;
c.wait_for(sess, "document.querySelector('.enc-open')", 20_000)?;
c.eval(sess, "document.querySelector('.enc-open').click()")?;
c.wait_for(sess, "document.querySelector('.encryption-keys[data-open=\"true\"]') || document.querySelector('.enc-why')", 30_000)?;
let opened = c.eval_str(sess, "(document.querySelector('.enc-why') || {}).innerText || ''")?;
want(opened.is_empty(), || format!("the encryption key did not open: {opened}"))?;
let before = encryption_key(c, sess)?;
let plain = c.add_authenticator_with(
sess,
r#"{"protocol":"ctap2","ctap2Version":"ctap2_1","transport":"usb","hasResidentKey":false,"hasUserVerification":false,"hasPrf":false,"isUserVerified":false,"automaticPresenceSimulation":true}"#,
)?;
c.presence(sess, auth, false)?;
let outcome = (|| {
c.eval(sess, "document.querySelector('.enc-enrol').click()")?;
c.wait_for(sess, "document.querySelector('.enc-why')", 30_000)?;
c.eval_str(sess, "document.querySelector('.enc-why').innerText")
})();
let _ = c.presence(sess, auth, true);
let _ = c.remove_authenticator(sess, &plain);
let why = outcome?;
let after = encryption_key(c, sess)?;
want(before == after, || "a refused key changed the encryption key".to_owned())?;
want(why.contains("hmac-secret"), || format!("not the named refusal: {why}"))?;
Ok(http::cut(&why, 160))
});
// ★ CRYPT-PQ-SK: a security key's v1 (X25519) wrap, as a console from before
// the wrap v2 made it, is re-wrapped to v2 by the console on the next unlock.
// The person's key is taken natively from a recovery code made here (the
// sk_v1_fixture example); a SECOND virtual authenticator with PRF holds the
// v1 credential, and only it answers the unlock.
go(rows, NAMES[6], || {
let fixture = std::env::var(SK_FIXTURE_ENV).map_err(|_| "NOT RUN: no --sk-fixture (gunnar-crypt's sk_v1_fixture example)".to_owned())?;
c.navigate_fresh(sess, &format!("{}/account", r.origin()))?;
c.wait_for(sess, "document.querySelector('.enc-open')", 20_000)?;
c.eval(sess, "document.querySelector('.enc-open').click()")?;
c.wait_for(sess, "document.querySelector('.encryption-keys[data-open=\"true\"]') || document.querySelector('.enc-why')", 30_000)?;
c.eval(sess, "document.querySelector('.enc-code-make').click()")?;
c.wait_for(sess, "document.querySelector('.recovery-code') || document.querySelector('.enc-why')", 60_000)?;
let code = c.eval_str(sess, "(document.querySelector('.recovery-code') || {}).innerText || ''")?;
want(!code.trim().is_empty(), || "no recovery code was shown".to_owned())?;
let held = crate::json::parse(&encryption_key(c, sess)?)?;
let public = held.text("public_key");
let methods = held.path("methods").map(|m| m.arr().to_vec()).unwrap_or_default();
let recovery = methods.iter().find(|m| m.text("kind") == "recovery-code").map(|m| m.text("wrap")).ok_or("no recovery-code method at gunnar")?;
let wrap_file = r.work.join("sk-v1-recovery.age");
std::fs::write(&wrap_file, recovery).map_err(|e| e.to_string())?;
let second = c.add_authenticator_with(
sess,
r#"{"protocol":"ctap2","ctap2Version":"ctap2_1","transport":"usb","hasResidentKey":true,"hasUserVerification":true,"hasPrf":true,"isUserVerified":true,"automaticPresenceSimulation":true}"#,
)?;
c.presence(sess, auth, false)?;
let outcome = (|| -> Result<String, String> {
let made = c.eval_str(sess, NEW_CREDENTIAL_WITH_PRF)?;
let (cred, prf) = made.split_once(' ').ok_or_else(|| format!("no credential and PRF output: {made}"))?;
let mut run = Command::new(&fixture)
.args([public.as_str(), wrap_file.to_str().unwrap_or_default(), cred, prf])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("{fixture}: {e}"))?;
use std::io::Write as _;
run.stdin.take().ok_or("no stdin")?.write_all(code.trim().as_bytes()).map_err(|e| e.to_string())?;
let out = run.wait_with_output().map_err(|e| e.to_string())?;
want(out.status.success(), || format!("sk_v1_fixture: {}", String::from_utf8_lossy(&out.stderr)))?;
let v1 = String::from_utf8_lossy(&out.stdout).to_string();
want(v1.starts_with("-----BEGIN AGE ENCRYPTED FILE-----"), || "the fixture made no v1 wrap".to_owned())?;
// JSON strings are JavaScript literals: the body is built in the page, its
// relying party the console's own host (what the console's enrolment names).
let body = format!(
"{{kind: 'security-key', label: 'old v1 key', method_ref: {}, rp_id: location.hostname, wrap: {}}}",
crate::json::esc(cred),
crate::json::esc(&v1)
);
let added = c.eval_str(
sess,
&format!("fetch('/api/account/encryption-key/methods', {{method:'POST', headers:{{'content-type':'application/json'}}, body: JSON.stringify({body})}}).then(async r => r.status + ' ' + await r.text())"),
)?;
want(added.starts_with("200"), || format!("gunnar did not take the v1 method: {}", http::cut(&added, 200)))?;
let before = crate::json::parse(&encryption_key(c, sess)?)?;
let v1_held = before.path("methods").map(|m| m.arr().to_vec()).unwrap_or_default().iter().filter(|m| m.text("method_ref") == cred).map(|m| m.text("wrap")).collect::<Vec<_>>();
want(v1_held.len() == 1 && !v1_held[0].starts_with(V2_TAG), || "the v1 method is not held as v1".to_owned())?;
let count = before.path("methods").map(|m| m.arr().len()).unwrap_or(0);
// A fresh tab: the key is closed; only the second authenticator answers.
c.navigate_fresh(sess, &format!("{}/account", r.origin()))?;
c.wait_for(sess, "document.querySelector('.enc-open')", 20_000)?;
c.eval(sess, "document.querySelector('.enc-open').click()")?;
c.wait_for(sess, "document.querySelector('.encryption-keys[data-open=\"true\"]') || document.querySelector('.enc-why')", 30_000)?;
let why = c.eval_str(sess, "(document.querySelector('.enc-why') || {}).innerText || ''")?;
want(why.is_empty(), || format!("the v1 method did not open the key: {why}"))?;
let mut after = String::new();
for _ in 0..40 {
let now = crate::json::parse(&encryption_key(c, sess)?)?;
let ms = now.path("methods").map(|m| m.arr().to_vec()).unwrap_or_default();
let mine: Vec<String> = ms.iter().filter(|m| m.text("method_ref") == cred).map(|m| m.text("wrap")).collect();
if mine.len() == 1 && mine[0].starts_with(V2_TAG) {
want(ms.len() == count, || format!("the re-wrap added a method instead of replacing it: {} → {}", count, ms.len()))?;
after = mine[0].clone();
break;
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
want(!after.is_empty(), || "the v1 wrap was not re-wrapped to v2".to_owned())?;
c.navigate_fresh(sess, &format!("{}/account", r.origin()))?;
c.wait_for(sess, "document.querySelector('.enc-methods')", 20_000)?;
let marks = c.eval_str(sess, "[...document.querySelectorAll('.enc-methods tr[data-kind=\"security-key\"] .enc-strength')].map(t => t.innerText).join('|')")?;
want(!marks.is_empty() && marks.split('|').all(|m| m == "wrap: quantum-safe"), || format!("the list marks: {marks:?}"))?;
Ok(format!("v1 → v2 on unlock; {count} method(s), every security key {marks}"))
})();
let _ = c.presence(sess, auth, true);
let _ = c.remove_authenticator(sess, &second);
outcome
});
}