jwp-receipts §7: a did:web signer_id names a web origin, which publishes a key log at /.well-known/wai-keys: revisions signed by a root key that pre-commits to its successor, listing the origin's keys, the authorities each holds, and whether each is active, retired or revoked. A verifier that has anchored the origin, pinned or fetched, confirms an accepted receipt, claim or module grant against the log offline, and reports the outcome beside the item's own verdict, never in it. Hardware-class energy, lawful-basis and pre-emption authorities are confirmed only under a pinned anchor. Revocation refuses every item under a revoked key. §7.9 authorises decoder and reconstruct modules, and pinned parameter sets, by SHA-256 digest through signed module grants from the sink's module origins, revokes them by digest in the key log, and bounds how long a revoked module keeps running by grant_max_s + status_max_age_s, under ceilings of 86400 s and 3600 s. Every failure, offline included, leaves the module not held and dispatch falls back. wai-rs gains signer_id (ungated) and key_log (feature key_discovery), the wai_signer_vectors and wai-keys binaries, and the signer-conformance corpus with an independent Python verifier. The reference meters and the world demo sign under labels. A section-reference guard checks that every section SPEC.md or an extension cites in another of them exists. This commit is the stream as merged onto link 14. The commits after it reconcile it with the parameter-set pin and the energy reports. Co-Authored-By: Claude Opus 5.5 <[email protected]>
319 lines
12 KiB
Rust
319 lines
12 KiB
Rust
//! `wai-keys` end to end (jwp-receipts §7): a key log built from operator-supplied seeds
|
|
//! anchors, a revocation appended to it still anchors and moves an anchor forward, a
|
|
//! rotation by the committed key anchors, one changed byte refuses the log, a published
|
|
//! test seed is refused without the flag that says the log is a test log, a seed is never
|
|
//! printed, a status and a module grant come out signed, and the DID document it emits is
|
|
//! consistent with the log while one with a key missing is not.
|
|
//!
|
|
//! Seeds are the corpus's published one-byte seeds, so every run passes
|
|
//! `--published-test-seed`, except the one that checks the refusal.
|
|
|
|
use ed25519_dalek::SigningKey;
|
|
use std::path::PathBuf;
|
|
use std::process::{Command, Output};
|
|
use wai::key_log::{read_log, KeyStatus, ModuleGrant};
|
|
|
|
const BIN: &str = env!("CARGO_BIN_EXE_wai-keys");
|
|
const ORIGIN: &str = "https://publisher.example";
|
|
|
|
fn pk(seed: u8) -> String {
|
|
SigningKey::from_bytes(&[seed; 32]).verifying_key().to_bytes().iter().map(|b| format!("{b:02x}")).collect()
|
|
}
|
|
|
|
struct Dir(PathBuf);
|
|
impl Drop for Dir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
impl Dir {
|
|
fn new(tag: &str) -> Dir {
|
|
let d = std::env::temp_dir().join(format!("wai-keys-cli-{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
Dir(d)
|
|
}
|
|
fn file(&self, name: &str, contents: &str) -> String {
|
|
let p = self.0.join(name);
|
|
std::fs::write(&p, contents).unwrap();
|
|
p.to_string_lossy().into_owned()
|
|
}
|
|
fn seed(&self, seed: u8) -> String {
|
|
self.file(&format!("seed-{seed:02x}"), &format!("{}\n", format!("{seed:02x}").repeat(32)))
|
|
}
|
|
}
|
|
|
|
fn run(args: &[&str]) -> Output {
|
|
Command::new(BIN).args(args).output().expect("run wai-keys")
|
|
}
|
|
|
|
fn stdout(o: &Output) -> String {
|
|
String::from_utf8_lossy(&o.stdout).into_owned()
|
|
}
|
|
|
|
fn ok(o: Output) -> String {
|
|
assert!(o.status.success(), "wai-keys failed: {}{}", stdout(&o), String::from_utf8_lossy(&o.stderr));
|
|
stdout(&o)
|
|
}
|
|
|
|
fn never_prints(o: &Output, seed: u8) {
|
|
let s = format!("{seed:02x}").repeat(32);
|
|
assert!(!stdout(o).contains(&s) && !String::from_utf8_lossy(&o.stderr).contains(&s), "seed {seed:#x} printed");
|
|
}
|
|
|
|
fn init(d: &Dir) -> String {
|
|
let root = d.seed(0x61);
|
|
let o = run(&[
|
|
"init",
|
|
"--origin",
|
|
ORIGIN,
|
|
"--root-seed-file",
|
|
&root,
|
|
"--next-root-key",
|
|
&pk(0x62),
|
|
"--issued-at",
|
|
"2026-09-01T00:00:00Z",
|
|
"--key",
|
|
&format!("id=d1,key={},may_sign=delivery|receipt", pk(0x63)),
|
|
"--key",
|
|
&format!("id=modd,key={},may_sign=module/decoder", pk(0x6b)),
|
|
"--grant-max-s",
|
|
"43200",
|
|
"--status-max-age-s",
|
|
"3600",
|
|
"--published-test-seed",
|
|
]);
|
|
never_prints(&o, 0x61);
|
|
d.file("log.json", &ok(o))
|
|
}
|
|
|
|
fn verify(log: &str, extra: &[&str]) -> Output {
|
|
let mut a = vec!["verify", log, "--origin", ORIGIN];
|
|
a.extend_from_slice(extra);
|
|
run(&a)
|
|
}
|
|
|
|
#[test]
|
|
fn init_append_rotate_and_verify() {
|
|
let d = Dir::new("chain");
|
|
let log = init(&d);
|
|
let first = ok(verify(&log, &[]));
|
|
assert!(first.starts_with("anchored revision=") && first.contains("anchor=fetched"), "{first}");
|
|
assert!(first.trim_end().ends_with("note=published_test_key"), "{first}");
|
|
let anchor0 = first.split_whitespace().nth(1).unwrap().trim_start_matches("revision=").to_owned();
|
|
|
|
// A revocation and a revoked module, appended under the same root.
|
|
let root = d.seed(0x61);
|
|
let digest = "ab".repeat(32);
|
|
let o = run(&[
|
|
"append",
|
|
&log,
|
|
"--root-seed-file",
|
|
&root,
|
|
"--issued-at",
|
|
"2026-09-02T00:00:00Z",
|
|
"--revoke",
|
|
"d1@2026-09-01T12:00:00Z",
|
|
"--revoke-module",
|
|
&digest,
|
|
"--add",
|
|
&format!("id=d2,key={},may_sign=delivery", pk(0x66)),
|
|
"--published-test-seed",
|
|
]);
|
|
never_prints(&o, 0x61);
|
|
let log2 = d.file("log2.json", &ok(o));
|
|
let revs = read_log(ORIGIN, &std::fs::read_to_string(&log2).unwrap()).unwrap();
|
|
assert_eq!(revs.len(), 2);
|
|
assert_eq!(revs[1].keys[0].compromised_since.as_deref(), Some("2026-09-01T12:00:00Z"));
|
|
assert_eq!(revs[1].revoked_modules.len(), 1);
|
|
// Anchored afresh, and as a later retrieval that moves an anchor forward.
|
|
assert!(ok(verify(&log2, &["--pinned"])).contains("anchor=pinned"));
|
|
let fwd = ok(verify(&log2, &["--anchor", &anchor0, "--held", &log]));
|
|
assert!(fwd.starts_with(&format!("anchored revision={}", hex(&revs[1].revision_hash()))), "{fwd}");
|
|
// An older log retrieved after the newer one was anchored is a rollback.
|
|
let anchor1 = hex(&revs[1].revision_hash());
|
|
let back = verify(&log, &["--anchor", &anchor1, "--held", &log2]);
|
|
assert_eq!(back.status.code(), Some(1));
|
|
assert_eq!(stdout(&back).trim(), "not_anchored reason=key_log_rolled_back");
|
|
|
|
// A rotation, signed by the committed key, naming its own successor.
|
|
let committed = d.seed(0x62);
|
|
let log3 = d.file(
|
|
"log3.json",
|
|
&ok(run(&[
|
|
"append",
|
|
&log2,
|
|
"--rotate-with-seed-file",
|
|
&committed,
|
|
"--next-root-key",
|
|
&pk(0x68),
|
|
"--issued-at",
|
|
"2026-09-03T00:00:00Z",
|
|
"--published-test-seed",
|
|
])),
|
|
);
|
|
assert!(ok(verify(&log3, &[])).starts_with("anchored"));
|
|
// The root in use cannot change the commitment without rotating.
|
|
let o = run(&["append", &log2, "--root-seed-file", &root, "--next-root-key", &pk(0x68), "--issued-at", "2026-09-03T00:00:00Z", "--published-test-seed"]);
|
|
assert_eq!(o.status.code(), Some(2));
|
|
|
|
// A status for the head, and a module grant.
|
|
let st = ok(run(&["status", &log3, "--root-seed-file", &committed, "--issued-at", "2026-09-03T01:00:00Z"]));
|
|
let st = KeyStatus::from_json(st.trim()).expect("a status");
|
|
let revs3 = read_log(ORIGIN, &std::fs::read_to_string(&log3).unwrap()).unwrap();
|
|
assert!(st.verifies_under(revs3.last().unwrap()));
|
|
let module = d.file("module.wasm", "\0asm a module");
|
|
let g = ok(run(&[
|
|
"grant",
|
|
"--origin",
|
|
ORIGIN,
|
|
"--seed-file",
|
|
&d.seed(0x6b),
|
|
"--key-id",
|
|
"modd",
|
|
"--module-file",
|
|
&module,
|
|
"--form",
|
|
"decoder",
|
|
"--capability",
|
|
"wai.neural.int_synth",
|
|
"--not-before",
|
|
"2026-09-03T00:00:00Z",
|
|
"--not-after",
|
|
"2026-09-03T12:00:00Z",
|
|
"--published-test-seed",
|
|
]));
|
|
let g = ModuleGrant::from_json(g.trim()).expect("a grant that verifies");
|
|
assert_eq!(g.module, wai::key_log::module_digest(b"\0asm a module"));
|
|
assert_eq!(g.signer_id, "did:web:publisher.example#modd");
|
|
}
|
|
|
|
fn hex(b: &[u8]) -> String {
|
|
b.iter().map(|x| format!("{x:02x}")).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn a_changed_byte_is_refused() {
|
|
let d = Dir::new("tamper");
|
|
let log = init(&d);
|
|
let text = std::fs::read_to_string(&log).unwrap();
|
|
let i = text.find("\"sig\":\"").unwrap() + 7;
|
|
let mut b = text.into_bytes();
|
|
b[i] = if b[i] == b'0' { b'1' } else { b'0' };
|
|
let bad = d.file("bad.json", std::str::from_utf8(&b).unwrap());
|
|
let o = verify(&bad, &[]);
|
|
assert_eq!(o.status.code(), Some(1));
|
|
assert_eq!(stdout(&o).trim(), "not_anchored reason=key_log_signature_invalid");
|
|
}
|
|
|
|
#[test]
|
|
fn a_published_test_seed_is_refused_without_the_flag() {
|
|
let d = Dir::new("published");
|
|
let root = d.seed(0x61);
|
|
let o = run(&[
|
|
"init",
|
|
"--origin",
|
|
ORIGIN,
|
|
"--root-seed-file",
|
|
&root,
|
|
"--next-root-key",
|
|
&pk(0x62),
|
|
"--issued-at",
|
|
"2026-09-01T00:00:00Z",
|
|
"--key",
|
|
&format!("id=d1,key={},may_sign=delivery", pk(0x63)),
|
|
]);
|
|
assert_eq!(o.status.code(), Some(2));
|
|
assert!(String::from_utf8_lossy(&o.stderr).contains("refusing a published test key"));
|
|
assert!(o.stdout.is_empty());
|
|
never_prints(&o, 0x61);
|
|
|
|
// Each command that puts a key into a log or signs with one refuses a published key
|
|
// without the flag: an append that adds one, a rotation to one, and a grant.
|
|
let log = init(&d);
|
|
let refused = |o: Output| {
|
|
assert_eq!(o.status.code(), Some(2), "{}", String::from_utf8_lossy(&o.stderr));
|
|
assert!(String::from_utf8_lossy(&o.stderr).contains("refusing a published test key"));
|
|
assert!(o.stdout.is_empty());
|
|
};
|
|
let add = format!("id=d2,key={},may_sign=delivery", pk(0x66));
|
|
refused(run(&["append", &log, "--root-seed-file", &root, "--issued-at", "2026-09-02T00:00:00Z", "--add", &add]));
|
|
let committed = d.seed(0x62);
|
|
refused(run(&[
|
|
"append",
|
|
&log,
|
|
"--rotate-with-seed-file",
|
|
&committed,
|
|
"--next-root-key",
|
|
&pk(0x68),
|
|
"--issued-at",
|
|
"2026-09-02T00:00:00Z",
|
|
]));
|
|
let module = d.file("module.wasm", "\0asm a module");
|
|
refused(run(&[
|
|
"grant",
|
|
"--origin",
|
|
ORIGIN,
|
|
"--seed-file",
|
|
&d.seed(0x6b),
|
|
"--key-id",
|
|
"modd",
|
|
"--module-file",
|
|
&module,
|
|
"--form",
|
|
"decoder",
|
|
"--capability",
|
|
"wai.neural.int_synth",
|
|
"--not-before",
|
|
"2026-09-03T00:00:00Z",
|
|
"--not-after",
|
|
"2026-09-03T12:00:00Z",
|
|
]));
|
|
}
|
|
|
|
/// A later retrieval checked against a pinned anchor: the record stays pinned and keeps
|
|
/// the revision that was pinned (§7.3), which `--held-pinned` lets an operator check.
|
|
#[test]
|
|
fn a_pinned_anchor_stays_pinned_on_a_forward_move() {
|
|
let d = Dir::new("heldpinned");
|
|
let log = init(&d);
|
|
let root = d.seed(0x61);
|
|
let add = format!("id=d2,key={},may_sign=delivery", pk(0x66));
|
|
let log2 = d.file(
|
|
"log2.json",
|
|
&ok(run(&["append", &log, "--root-seed-file", &root, "--issued-at", "2026-09-02T00:00:00Z", "--add", &add, "--published-test-seed"])),
|
|
);
|
|
let revs = read_log(ORIGIN, &std::fs::read_to_string(&log2).unwrap()).unwrap();
|
|
let (r0, r1) = (hex(&revs[0].revision_hash()), hex(&revs[1].revision_hash()));
|
|
let pinned = ok(verify(&log2, &["--anchor", &r0, "--held", &log, "--held-pinned"]));
|
|
assert_eq!(pinned.trim(), format!("anchored revision={r1} anchor=pinned pinned_revision={r0} note=published_test_key"));
|
|
let fetched = ok(verify(&log2, &["--anchor", &r0, "--held", &log]));
|
|
assert_eq!(fetched.trim(), format!("anchored revision={r1} anchor=fetched note=published_test_key"));
|
|
// The flag describes an anchor already held, and needs one.
|
|
assert_eq!(verify(&log2, &["--held-pinned"]).status.code(), Some(2));
|
|
}
|
|
|
|
#[test]
|
|
fn did_json_is_consistent_and_a_difference_is_reported() {
|
|
let d = Dir::new("did");
|
|
let log = init(&d);
|
|
let doc = ok(run(&["did-json", &log]));
|
|
assert!(doc.contains("\"id\": \"did:web:publisher.example\"") && doc.contains("did:web:publisher.example#d1"), "{doc}");
|
|
let did = d.file("did.json", &doc);
|
|
assert!(ok(run(&["check-did", &log, &did])).starts_with("consistent"));
|
|
let mut v: serde_json::Value = serde_json::from_str(&doc).unwrap();
|
|
v["verificationMethod"].as_array_mut().unwrap().retain(|m| m["id"] != "did:web:publisher.example#d1");
|
|
let missing = d.file("did-missing.json", &v.to_string());
|
|
let o = run(&["check-did", &log, &missing]);
|
|
assert_eq!(o.status.code(), Some(1));
|
|
assert!(stdout(&o).contains("did:web:publisher.example#d1: active in the key log, missing from the DID document"), "{}", stdout(&o));
|
|
}
|
|
|
|
#[test]
|
|
fn the_seed_file_must_be_a_seed() {
|
|
let d = Dir::new("seedfile");
|
|
let bad = d.file("seed", "not a seed");
|
|
let o = run(&["init", "--origin", ORIGIN, "--root-seed-file", &bad, "--next-root-key", &pk(0x62), "--issued-at", "2026-09-01T00:00:00Z"]);
|
|
assert_eq!(o.status.code(), Some(2));
|
|
assert!(!String::from_utf8_lossy(&o.stderr).contains("not a seed"));
|
|
}
|