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

nordisk/gunnar-ui

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