//! `wai key` through the `wai` binary, as a person and an agent meet it (`--features quickstart //! --test cli_key`; with `key_discovery` too, what it exports is read by `wai-keys`): //! //! - `wai key create|import` keep a key under a name, its secret in a file of mode 0600 in a //! keystore of mode 0700, and no command prints it or writes it anywhere else; //! - `wai key list|show|export` give the public halves, with each version's status as a key log //! states it (jwp-receipts §7.2); //! - `--key ` signs with the key's newest version on the operations that sign; //! - `wai key rotate|revoke` wait for `--yes` (exit 4, nothing changed), retire or revoke a version //! and destroy its secret; a revoked key signs nothing; //! - a published test seed, a seed file others may read, a name taken, a key held twice and a //! record that does not read are refused, each with its exit code and its stable code. //! //! Every test keeps its keys in a keystore of its own scratch directory, in files: none touches //! the person's keystore or keychain, and the seeds here are made for these tests. #![cfg(feature = "quickstart")] use serde_json::Value; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; fn scratch(name: &str) -> PathBuf { let d = Path::new(env!("CARGO_TARGET_TMPDIR")).join("cli_key").join(name); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d } /// `wai` in `d`, its keystore `d/ks` (`$WAI_KEYSTORE`), no input and no terminal. fn wai(d: &Path, args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(d).args(args).env("WAI_KEYSTORE", d.join("ks")).stdin(Stdio::null()).output().expect("wai runs") } fn text(o: &Output) -> String { format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) } /// The JSON envelope of a command that succeeds, and its `data`. fn ok(d: &Path, args: &[&str]) -> Value { let mut a = args.to_vec(); a.push("--json"); let o = wai(d, &a); assert!(o.status.success(), "{args:?}: {}", text(&o)); let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("{args:?}: not one envelope ({e}): {}", text(&o))); assert_eq!(v["wai"], "envelope/1", "{v}"); assert_eq!(v["ok"], true, "{v}"); v["data"].clone() } /// The exit status and stable code of a command that fails. fn refused(d: &Path, args: &[&str]) -> (i32, String) { let mut a = args.to_vec(); a.push("--json"); let o = wai(d, &a); let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("{args:?}: not one envelope ({e}): {}", text(&o))); assert_eq!(v["ok"], false, "{args:?}: {v}"); (o.status.code().unwrap(), v["error"]["code"].as_str().unwrap().to_string()) } fn create(d: &Path, name: &str) -> Value { ok(d, &["key", "create", name, "--for", "signer", "--store", "file"]) } /// The hexadecimal seed kept for key id `id`. fn seed_of(d: &Path, id: &str) -> String { std::fs::read_to_string(d.join("ks").join(format!("{id}.seed"))).unwrap().trim().to_string() } const NODE: &str = "1111111111111111111111111111111111111111111111111111111111111111"; /// The organisation key of an enrolment `--key arg` signs, so a test sees which key signed. fn signer_of_enrolment(d: &Path, arg: &str) -> String { let e = ok(d, &["enrolment", "create", NODE, "--role", "relay", "--key", arg, "--out", "n.wfe", "--yes"]); e["organisation"].as_str().unwrap().to_string() } #[test] fn a_key_is_made_kept_private_and_never_printed() { let d = scratch("create"); let mut all = String::new(); let run = |args: &[&str], all: &mut String| { let o = wai(&d, args); assert!(o.status.success(), "{args:?}: {}", text(&o)); all.push_str(&text(&o)); String::from_utf8_lossy(&o.stdout).into_owned() }; let t = run(&["key", "create", "signer", "--for", "signer", "--store", "file"], &mut all); assert!(t.contains("never printed") && t.contains("--key signer"), "{t}"); // It says what protects the seed here, and claims no mode where there is none. assert_eq!(t.contains("mode 0600"), cfg!(unix), "{t}"); assert_eq!(t.contains("an access list for this account alone"), cfg!(windows), "{t}"); let c = ok(&d, &["key", "show", "signer"]); let v = &c["versions"][0]; let (id, key) = (v["id"].as_str().unwrap(), v["key"].as_str().unwrap()); assert_eq!((id, v["status"].as_str(), v["store"].as_str(), c["signs_with"].as_str(), c["role"].as_str()), ("signer", Some("active"), Some("file"), Some("signer"), Some("signer"))); assert_eq!(key.len(), 64); assert_eq!(v["did_key"].as_str().unwrap(), wai::signer_id::did_key(&<[u8; 32]>::try_from(unhex(key)).unwrap())); // The secret is the key's, and in the store alone. let seed = seed_of(&d, "signer"); let sk = ed25519_dalek::SigningKey::from_bytes(&<[u8; 32]>::try_from(unhex(&seed)).unwrap()); assert_eq!(hex(&sk.verifying_key().to_bytes()), key); assert!(!seed.bytes().all(|b| b == seed.as_bytes()[0]), "a seed of one byte repeated"); let record = std::fs::read_to_string(d.join("ks/signer.json")).unwrap(); assert!(!record.contains(&seed), "the record holds no secret"); let r: Value = serde_json::from_str(&record).unwrap(); assert_eq!(r["format"], "wai.key/1"); // No command prints it, as text or in an envelope. for args in [&["key", "list"][..], &["key", "show", "signer"], &["key", "export", "signer"], &["key", "export", "signer", "--format", "did-key"], &["key", "export", "signer", "--format", "entry", "--may-sign", "delivery"], &["key", "export", "signer", "--format", "wai-keys", "--may-sign", "delivery"]] { run(args, &mut all); all.push_str(&ok(&d, args).to_string()); } assert!(!all.contains(&seed), "a command printed the secret: {all}"); // A second key has a seed of its own; the two are listed. create(&d, "other"); assert_ne!(seed_of(&d, "other"), seed); let l = ok(&d, &["key", "list"]); let names: Vec<&str> = l["keys"].as_array().unwrap().iter().map(|k| k["name"].as_str().unwrap()).collect(); assert_eq!(names, ["other", "signer"]); assert_eq!(l["keys"][1]["key"].as_str().unwrap(), key); } #[cfg(unix)] #[test] fn the_keystore_and_its_files_are_the_owners_alone() { use std::os::unix::fs::PermissionsExt; let d = scratch("modes"); create(&d, "k"); ok(&d, &["key", "rotate", "k", "--store", "file", "--yes"]); let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; assert_eq!(mode(&d.join("ks")), 0o700); assert_eq!(mode(&d.join("ks/k.json")), 0o600); assert_eq!(mode(&d.join("ks/k.2.seed")), 0o600); // A seed file another user may read does not sign: exit 3, with what to do. std::fs::set_permissions(d.join("ks/k.2.seed"), std::fs::Permissions::from_mode(0o640)).unwrap(); let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe"]); assert_eq!(o.status.code(), Some(3), "{}", text(&o)); assert!(text(&o).contains("chmod 600"), "{}", text(&o)); assert_eq!(refused(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe"]).1, "key-file-exposed"); assert!(!d.join("n.wfe").exists()); } #[test] fn the_keystore_is_found_by_flag_then_environment_then_the_users_configuration() { let d = scratch("where"); let home = d.join("home"); let list = |envs: &[(&str, &Path)], args: &[&str]| -> String { let mut c = Command::new(env!("CARGO_BIN_EXE_wai")); c.current_dir(&d).args(["key", "list", "--json"]).args(args).stdin(Stdio::null()).env_remove("WAI_KEYSTORE").env_remove("XDG_CONFIG_HOME").env_remove("APPDATA").env("HOME", &home); for (k, v) in envs { c.env(k, v); } let o = c.output().unwrap(); assert!(o.status.success(), "{}", text(&o)); let v: Value = serde_json::from_slice(&o.stdout).unwrap(); assert_eq!(v["data"]["keys"].as_array().unwrap().len(), 0, "{v}"); v["data"]["keystore"].as_str().unwrap().to_string() }; let under = |x: &Path, first: &str| x.join(first).join("wai").join("keys").display().to_string(); #[cfg(windows)] assert_eq!(list(&[("APPDATA", &d.join("roaming"))], &[]), d.join("roaming").join("wai").join("keys").display().to_string()); assert_eq!(list(&[], &[]), under(&home, ".config")); assert_eq!(list(&[("XDG_CONFIG_HOME", &d.join("xdg"))], &[]), d.join("xdg").join("wai").join("keys").display().to_string()); assert_eq!(list(&[("XDG_CONFIG_HOME", &d.join("xdg")), ("WAI_KEYSTORE", &d.join("env"))], &[]), d.join("env").display().to_string()); assert_eq!(list(&[("WAI_KEYSTORE", &d.join("env"))], &["--keystore", "flag"]), "flag"); // Listing a keystore that is not there makes nothing. assert!(!home.exists()); } #[test] fn every_signing_operation_of_this_build_takes_a_keys_name() { let d = scratch("sign"); let key = create(&d, "org")["key"].as_str().unwrap().to_string(); // An enrolment: a name, and a file as before. assert_eq!(signer_of_enrolment(&d, "org"), key); let seed = seed_of(&d, "org"); std::fs::write(d.join("org.hex"), &seed).unwrap(); assert_eq!(signer_of_enrolment(&d, "org.hex"), key); std::fs::write(d.join("org.raw"), unhex(&seed)).unwrap(); assert_eq!(signer_of_enrolment(&d, "org.raw"), key); // A file of that name comes first: what ran before still runs the same. let other: String = (1..=32u8).map(|b| format!("{b:02x}")).collect(); std::fs::write(d.join("org"), &other).unwrap(); assert_ne!(signer_of_enrolment(&d, "org"), key); std::fs::remove_file(d.join("org")).unwrap(); // Not a file and not a key: exit 5, saying where it looked. for missing in ["absent", "absent.key"] { let a = ["enrolment", "create", NODE, "--role", "relay", "--key", missing, "--out", "n.wfe", "--yes"]; assert_eq!(refused(&d, &a), (5, "key-not-found".to_string()), "{missing}"); } // A conformance record, through its own reader of --key. let wai_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); // A command line of the system's shell: single quotes quote nothing to cmd. let vectors = env!("CARGO_BIN_EXE_wai_jwp_vectors"); let imp = if cfg!(windows) { format!("\"{vectors}\" verdicts {{corpus}}") } else { format!("'{vectors}' verdicts {{corpus}}") }; ok(&d, &["conform", "verifier", "--suite", "jwp", "--impl", &imp, "--root", wai_dir.to_str().unwrap(), "--key", "org", "--out", "jwp.wai-conform.json"]); let rec: Value = serde_json::from_slice(&std::fs::read(d.join("jwp.wai-conform.json")).unwrap()).unwrap(); assert_eq!(rec["signer_pubkey"].as_str().unwrap(), key, "{rec}"); // A channel's receipt (the sample channel of extensions/channel-lineup.md §11). let c = wai_dir.join("live-conformance/channels"); let f = |n: &str| c.join(n).to_str().unwrap().to_string(); let (x, a, b, p, art) = (f("sample.xmltv.xml"), f("sample-a.mrss.xml"), f("sample-b.mrss.xml"), f("sample.m3u8"), f("")); ok(&d, &["channel", "import", "--channel", "sample-1", "--xmltv", &x, "--mrss", &a, "--mrss-owner", "owner-a", "--mrss", &b, "--mrss-owner", "owner-b", "--owner", "owner-c", "--playlist", &p, "--artwork", &art, "--key", "org", "--out-dir", "channel"]); let receipt = std::fs::read_to_string(d.join("channel/channel.json")).unwrap(); assert!(receipt.contains(&key), "the receipt names the key that signed it: {receipt}"); } #[cfg(feature = "video_manifest")] #[test] fn a_playlist_is_sealed_with_a_keys_name_or_a_seed_given_as_before() { let d = scratch("seal"); let key = create(&d, "pub")["key"].as_str().unwrap().to_string(); let wai_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); let obj = |id: &str| -> String { let c = wai_dir.join("http-conformance"); let listing: Value = serde_json::from_slice(&std::fs::read(c.join("objects.json")).unwrap()).unwrap(); c.join(listing[id]["path"].as_str().unwrap()).to_str().unwrap().to_string() }; let objs: Vec = ["O9a", "O9b", "O9c"].iter().map(|i| obj(i)).collect(); std::fs::write(d.join("classical.m3u8"), "#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:1\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:0.640,\nc0.m4s\n#EXTINF:0.640,\nc1.m4s\n#EXTINF:0.640,\nc2.m4s\n#EXT-X-ENDLIST\n").unwrap(); let list = serde_json::json!({ "uri_prefix": "objects/", "variants": [{"uri": "wai-180.m3u8", "classical": "classical.m3u8", "resolution": [320, 180], "frame_rate_milli": 25000, "segments": objs.iter().map(|o| serde_json::json!({"object": o})).collect::>()}], }); std::fs::write(d.join("list.json"), list.to_string()).unwrap(); assert!(wai(&d, &["http", "hls", "--objects", "list.json", "--out", "."]).status.success()); let sealed_by = |k: &str, out: &str| -> String { let o = wai(&d, &["http", "seal-playlist", "wai-180.m3u8", "--key", k, "--out", out]); assert!(o.status.success(), "{}", text(&o)); let mut found = String::new(); for e in std::fs::read_dir(d.join(out).join("mr")).unwrap() { found.push_str(&std::fs::read_to_string(e.unwrap().path()).unwrap()); } found }; assert!(sealed_by("pub", "a").contains(&key)); let seed = seed_of(&d, "pub"); assert!(sealed_by(&seed, "b").contains(&key), "64 hexadecimal digits, as this command took before"); } /// A share contract, through the revenue share's and the opportunity report's reader of `--key`. #[cfg(feature = "revenue_share")] #[test] fn a_contract_is_signed_with_a_keys_name() { let d = scratch("contract"); let key = create(&d, "operator")["key"].as_str().unwrap().to_string(); let owner = "33".repeat(32); let terms = serde_json::json!({ "start": 0, "end": 10, "currency": "EUR", "channel": "fast-7", "channel_signer": "22".repeat(32), "rule": "preceding", "between": 0, "carriers": [{"room": "11".repeat(32)}], "owners": [owner], "splits": [{"operator": 500_000, "owners": {owner.clone(): 500_000}}], "assets": {"a/ep-1": 0}, }); std::fs::write(d.join("terms.json"), terms.to_string()).unwrap(); let c = ok(&d, &["contract", "create", "terms.json", "--key", "operator", "--out", "c.wrc"]); assert_eq!(c["operator"].as_str().unwrap(), key, "{c}"); assert_eq!(refused(&d, &["contract", "create", "terms.json", "--key", "absent", "--out", "c2.wrc"]), (5, "key-not-found".to_string())); } /// An upscale's signed record, through the derivations' reader of `--key`, which takes the seed /// itself as well, as it did. #[cfg(feature = "exact_upscale")] #[test] fn an_upscale_record_is_signed_with_a_keys_name() { let d = scratch("upscale"); let key = create(&d, "edge")["key"].as_str().unwrap().to_string(); let prior = Path::new(env!("CARGO_MANIFEST_DIR")).join("../live-conformance/upscale/priors/generic.wsr"); std::fs::write(d.join("in.png"), wai::png_still::encode(&[9u8; 3 * 8 * 6], 8, 6)).unwrap(); let signer = |k: &str, rec: &str| -> String { let o = wai(&d, &["upscale", "run", "in.png", "--prior", prior.to_str().unwrap(), "-o", "out.png", "--record", rec, "--envelope", "in.wai", "--key", k, "--yes"]); assert!(o.status.success(), "{}", text(&o)); std::fs::read_to_string(d.join(rec)).unwrap() }; assert!(signer("edge", "a.json").contains(&key)); assert!(signer(&seed_of(&d, "edge"), "b.json").contains(&key)); } #[test] fn a_rotation_waits_for_yes_then_retires_the_old_key_and_destroys_its_secret() { let d = scratch("rotate"); let first = create(&d, "k")["key"].as_str().unwrap().to_string(); let before = std::fs::read(d.join("ks/k.json")).unwrap(); let old_seed = seed_of(&d, "k"); // Off a terminal, without --yes: exit 4, the proposal and the command, nothing changed. let o = wai(&d, &["key", "rotate", "k", "--store", "file", "--json"]); assert_eq!(o.status.code(), Some(4), "{}", text(&o)); let v: Value = serde_json::from_slice(&o.stdout).unwrap(); assert_eq!(v["error"]["code"], "confirmation-required", "{v}"); assert_eq!(v["proposed"][0]["new"], "k.2", "{v}"); assert_eq!(v["proposed"][0]["retire"], "k", "{v}"); assert_eq!(v["proposed"][0]["destroy_secret"], "k", "{v}"); assert!(v["confirm"].as_str().unwrap().ends_with("--yes"), "{v}"); // --no-input never asks either. assert_eq!(wai(&d, &["key", "rotate", "k", "--store", "file", "--no-input"]).status.code(), Some(4)); assert_eq!(std::fs::read(d.join("ks/k.json")).unwrap(), before); assert!(d.join("ks/k.seed").is_file() && !d.join("ks/k.2.seed").exists()); let r = ok(&d, &["key", "rotate", "k", "--store", "file", "--yes"]); assert_eq!((r["previous"].as_str(), r["id"].as_str(), r["secret_destroyed"].as_bool()), (Some("k"), Some("k.2"), Some(true)), "{r}"); let until = r["until"].as_str().unwrap(); let second = r["key"].as_str().unwrap(); assert_ne!(second, first); assert!(!d.join("ks/k.seed").exists(), "the old secret is gone"); let new_seed = seed_of(&d, "k.2"); assert_ne!(new_seed, old_seed); // The record states both versions as a key log does: the old retired with until, the new active. let s = ok(&d, &["key", "show", "k"]); assert_eq!(s["signs_with"], "k.2"); let (v0, v1) = (&s["versions"][0], &s["versions"][1]); assert_eq!((v0["id"].as_str(), v0["key"].as_str(), v0["status"].as_str(), v0["until"].as_str(), v0["compromised_since"].as_str(), v0["store"].as_str()), (Some("k"), Some(first.as_str()), Some("retired"), Some(until), None, None)); assert_eq!((v1["id"].as_str(), v1["key"].as_str(), v1["status"].as_str(), v1["until"].as_str(), v1["store"].as_str()), (Some("k.2"), Some(second), Some("active"), None, Some("file"))); // --key signs with the successor; a further rotation numbers on. assert_eq!(signer_of_enrolment(&d, "k"), second); assert_eq!(ok(&d, &["key", "rotate", "k", "--store", "file", "--yes"])["id"], "k.3"); // The export names every version; the line for a log names the newest. let e = ok(&d, &["key", "export", "k", "--format", "entry", "--may-sign", "delivery,energy/Estimated"]); let ids: Vec<&str> = e["entries"].as_array().unwrap().iter().map(|x| x["id"].as_str().unwrap()).collect(); assert_eq!(ids, ["k", "k.2", "k.3"]); assert_eq!(e["entries"][0]["until"].as_str(), Some(until)); assert_eq!(e["entries"][0]["may_sign"], serde_json::json!(["delivery", "energy/Estimated"])); let l = ok(&d, &["key", "export", "k", "--format", "wai-keys", "--may-sign", "delivery,energy/Estimated"]); assert!(l["text"].as_str().unwrap().starts_with("id=k.3,key="), "{l}"); assert!(l["text"].as_str().unwrap().trim_end().ends_with(",may_sign=delivery|energy/Estimated"), "{l}"); } #[test] fn a_revoked_key_signs_nothing_and_its_time_never_changes() { let d = scratch("revoke"); let first = create(&d, "k")["key"].as_str().unwrap().to_string(); let args = ["key", "revoke", "k", "--compromised-since", "2026-11-01T00:00:00Z"]; assert_eq!(refused(&d, &args), (4, "confirmation-required".to_string())); assert!(d.join("ks/k.seed").is_file(), "nothing changed"); assert_eq!(refused(&d, &["key", "revoke", "k", "--compromised-since", "yesterday", "--yes"]).0, 2); assert_eq!(refused(&d, &["key", "revoke", "k", "--id", "k.9", "--yes"]), (5, "key-not-found".to_string())); let mut yes = args.to_vec(); yes.push("--yes"); let r = ok(&d, &yes); assert_eq!((r["id"].as_str(), r["compromised_since"].as_str(), r["secret_destroyed"].as_bool()), (Some("k"), Some("2026-11-01T00:00:00Z"), Some(true)), "{r}"); assert!(!d.join("ks/k.seed").exists()); // It signs nothing, and says so; the log's line refuses it; the record stays. let a = ["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe"]; assert_eq!(refused(&d, &a), (2, "key-revoked".to_string())); assert!(!d.join("n.wfe").exists()); assert_eq!(refused(&d, &["key", "export", "k", "--format", "wai-keys", "--may-sign", "delivery"]).1, "key-revoked"); let s = ok(&d, &["key", "show", "k"]); assert_eq!((s["signs_with"].as_str(), s["versions"][0]["status"].as_str(), s["versions"][0]["key"].as_str(), s["versions"][0]["store"].as_str()), (None, Some("revoked"), Some(first.as_str()), None)); // Its time, once set, does not change (jwp-receipts §7.2 rule 6). assert_eq!(refused(&d, &["key", "revoke", "k", "--yes"]), (2, "key-already-revoked".to_string())); // A successor signs again; the revoked version stays revoked, with no until. let n = ok(&d, &["key", "rotate", "k", "--store", "file", "--yes"]); assert_eq!((n["id"].as_str(), n["secret_destroyed"].as_bool()), (Some("k.2"), Some(false))); assert_eq!(signer_of_enrolment(&d, "k"), n["key"].as_str().unwrap()); let s = ok(&d, &["key", "show", "k"]); assert_eq!((s["versions"][0]["status"].as_str(), s["versions"][0]["until"].as_str()), (Some("revoked"), None)); // An earlier version revoked keeps the time it was retired at; the newest still signs. let d = scratch("revoke-old"); create(&d, "k"); let until = ok(&d, &["key", "rotate", "k", "--store", "file", "--yes"])["until"].as_str().unwrap().to_string(); let newest = ok(&d, &["key", "show", "k"])["versions"][1]["key"].as_str().unwrap().to_string(); let r = ok(&d, &["key", "revoke", "k", "--id", "k", "--yes"]); assert_eq!(r["secret_destroyed"], false, "a retired version's secret is gone already"); let s = ok(&d, &["key", "show", "k"]); assert_eq!((s["versions"][0]["status"].as_str(), s["versions"][0]["until"].as_str()), (Some("revoked"), Some(until.as_str()))); assert_eq!(signer_of_enrolment(&d, "k"), newest); } #[test] fn a_seed_file_is_imported_and_a_published_one_refused() { let d = scratch("import"); let seed: [u8; 32] = core::array::from_fn(|i| 100 + i as u8); let want = hex(&ed25519_dalek::SigningKey::from_bytes(&seed).verifying_key().to_bytes()); std::fs::write(d.join("seed.hex"), format!("{}\n", hex(&seed))).unwrap(); let args = ["key", "import", "old", "--for", "audit", "--from", "seed.hex", "--store", "file"]; let r = ok(&d, &args); assert_eq!((r["key"].as_str(), r["role"].as_str(), r["published_test_seed"].as_bool()), (Some(want.as_str()), Some("audit"), Some(false)), "{r}"); assert_eq!(seed_of(&d, "old"), hex(&seed)); assert!(text(&wai(&d, &["key", "show", "old"])).contains("old")); assert!(d.join("seed.hex").is_file(), "the file it came from is the person's to remove"); assert_eq!(signer_of_enrolment(&d, "old"), want); // The name is taken; the key is held already, under any name; each exits 2 with its code. assert_eq!(refused(&d, &args), (2, "key-exists".to_string())); assert_eq!(refused(&d, &["key", "create", "old", "--for", "dev", "--store", "file"]), (2, "key-exists".to_string())); assert_eq!(refused(&d, &["key", "import", "again", "--for", "dev", "--from", "seed.hex", "--store", "file"]), (2, "key-duplicate".to_string())); assert!(!d.join("ks/again.json").exists() && !d.join("ks/again.seed").exists()); // 32 raw bytes read as well. let raw: [u8; 32] = core::array::from_fn(|i| 200 - i as u8); std::fs::write(d.join("seed.raw"), raw).unwrap(); assert_eq!(ok(&d, &["key", "import", "raw", "--for", "dev", "--from", "seed.raw", "--store", "file"])["key"].as_str().unwrap(), hex(&ed25519_dalek::SigningKey::from_bytes(&raw).verifying_key().to_bytes())); // A file that holds neither, or is not there. std::fs::write(d.join("bad.hex"), "not a seed").unwrap(); assert_eq!(refused(&d, &["key", "import", "bad", "--for", "dev", "--from", "bad.hex", "--store", "file"]), (2, "key-file-malformed".to_string())); assert_eq!(refused(&d, &["key", "import", "bad", "--for", "dev", "--from", "absent.hex", "--store", "file"]).0, 5); // A published test seed (one byte repeated) is refused unless named one, and the record says so. std::fs::write(d.join("published.hex"), "07".repeat(32)).unwrap(); assert_eq!(refused(&d, &["key", "import", "t", "--for", "dev", "--from", "published.hex", "--store", "file"]), (2, "published-test-seed".to_string())); assert!(!d.join("ks/t.json").exists()); let r = ok(&d, &["key", "import", "t", "--for", "dev", "--from", "published.hex", "--store", "file", "--published-test-seed"]); assert_eq!(r["published_test_seed"], true); assert_eq!(ok(&d, &["key", "show", "t"])["versions"][0]["published_test_seed"], true); assert!(text(&wai(&d, &["key", "show", "t"])).contains("a published test seed")); } #[test] fn a_dry_run_makes_no_key() { let d = scratch("dry"); let r = ok(&d, &["key", "create", "k", "--for", "dev", "--store", "file", "--dry-run"]); assert!(r["key"].is_null(), "{r}"); assert!(!d.join("ks").exists()); let o = wai(&d, &["key", "create", "k", "--for", "dev", "--store", "file", "--dry-run", "--json"]); let v: Value = serde_json::from_slice(&o.stdout).unwrap(); let plan = v["plan"].to_string(); assert!(plan.contains("k.json") && plan.contains("k.seed"), "{plan}"); create(&d, "k"); let before = std::fs::read(d.join("ks/k.json")).unwrap(); let r = ok(&d, &["key", "rotate", "k", "--store", "file", "--dry-run"]); assert_eq!((r["id"].as_str(), r["key"].is_null()), (Some("k.2"), true), "{r}"); let o = wai(&d, &["key", "revoke", "k", "--dry-run", "--json"]); let v: Value = serde_json::from_slice(&o.stdout).unwrap(); assert!(v["plan"].to_string().contains("k.seed"), "{v}"); assert_eq!(std::fs::read(d.join("ks/k.json")).unwrap(), before); assert!(d.join("ks/k.seed").is_file()); } #[test] fn what_the_command_line_gets_wrong_is_refused_by_exit_code_and_stable_code() { let d = scratch("usage"); for args in [ &["key", "create"][..], &["key", "create", "k"], &["key", "create", "K", "--for", "dev"], &["key", "create", "a.b", "--for", "dev"], &["key", "create", "k", "--for", "boss"], &["key", "create", "k", "--for", "dev", "--store", "cloud"], &["key", "create", "k", "--for", "dev", "--nope"], &["key", "import", "k", "--for", "dev"], &["key", "list", "--for", "boss"], &["key", "show"], &["key", "export"], ] { assert_eq!(wai(&d, args).status.code(), Some(2), "{args:?}"); } assert!(!d.join("ks").exists(), "nothing was made"); assert_eq!(refused(&d, &["key", "show", "absent"]), (5, "key-not-found".to_string())); assert_eq!(refused(&d, &["key", "rotate", "absent", "--yes"]), (5, "key-not-found".to_string())); assert_eq!(refused(&d, &["key", "revoke", "absent", "--yes"]), (5, "key-not-found".to_string())); assert_eq!(refused(&d, &["key", "export", "absent"]), (5, "key-not-found".to_string())); create(&d, "k"); for a in [&["key", "export", "k", "--format", "entry"][..], &["key", "export", "k", "--format", "wai-keys"], &["key", "export", "k", "--may-sign", "delivery"], &["key", "export", "k", "--format", "pem"], &["key", "export", "k", "--format", "entry", "--may-sign", "a b"]] { assert_eq!(refused(&d, a).0, 2, "{a:?}"); } // Replacing a file the export would write waits for --yes. std::fs::write(d.join("k.pub"), "there").unwrap(); assert_eq!(refused(&d, &["key", "export", "k", "--out", "k.pub"]), (4, "confirmation-required".to_string())); assert_eq!(std::fs::read(d.join("k.pub")).unwrap(), b"there"); let key = ok(&d, &["key", "export", "k", "--out", "k.pub", "--yes"])["text"].as_str().unwrap().to_string(); assert_eq!(std::fs::read_to_string(d.join("k.pub")).unwrap(), key); #[cfg(not(target_os = "macos"))] assert_eq!(refused(&d, &["key", "create", "c", "--for", "dev", "--store", "keychain"]), (2, "keychain-unavailable".to_string())); } #[test] fn a_record_that_does_not_read_is_refused_and_listed_as_such() { let d = scratch("malformed"); create(&d, "good"); create(&d, "broken"); let p = d.join("ks/broken.json"); let t = std::fs::read_to_string(&p).unwrap(); std::fs::write(&p, t.replace("\"role\": \"signer\"", "\"role\": \"signer\",\n \"extra\": 1")).unwrap(); assert_eq!(refused(&d, &["key", "show", "broken"]), (2, "key-record-malformed".to_string())); let l = ok(&d, &["key", "list"]); assert_eq!(l["keys"][0]["error"]["code"], "key-record-malformed", "{l}"); assert_eq!(l["keys"][1]["name"], "good", "{l}"); let a = ["enrolment", "create", NODE, "--role", "relay", "--key", "broken", "--out", "n.wfe"]; assert_eq!(refused(&d, &a), (2, "key-record-malformed".to_string())); // A record and a secret changed apart do not sign. let other: String = (1..=32u8).map(|b| format!("{b:02x}")).collect(); let seed = d.join("ks/good.seed"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&seed, std::fs::Permissions::from_mode(0o600)).unwrap(); } std::fs::write(&seed, other).unwrap(); let a = ["enrolment", "create", NODE, "--role", "relay", "--key", "good", "--out", "n.wfe"]; assert_eq!(refused(&d, &a), (2, "key-mismatch".to_string())); } /// An origin's key log lists what `wai key` exports, as it is: a rotation and a revocation carried /// over by `wai-keys`, and the log verified (jwp-receipts §7.2, §7.3). #[cfg(feature = "key_discovery")] #[test] fn wai_keys_reads_what_wai_key_exports() { let d = scratch("log"); let wk = |args: &[&str]| -> Output { Command::new(env!("CARGO_BIN_EXE_wai-keys")).current_dir(&d).args(args).output().expect("wai-keys runs") }; create(&d, "origin-root"); create(&d, "next-root"); create(&d, "delivery"); let root_file = d.join("ks/origin-root.seed"); let root_file = root_file.to_str().unwrap(); let next_key = ok(&d, &["key", "export", "next-root"])["text"].as_str().unwrap().trim().to_string(); let line = ok(&d, &["key", "export", "delivery", "--format", "wai-keys", "--may-sign", "delivery"])["text"].as_str().unwrap().trim().to_string(); let o = wk(&["init", "--origin", "https://example.test", "--root-seed-file", root_file, "--next-root-key", &next_key, "--issued-at", "2026-11-05T00:00:00Z", "--key", &line]); assert!(o.status.success(), "{}", text(&o)); std::fs::write(d.join("log.json"), &o.stdout).unwrap(); // A rotation: the successor's line and the old id's until, as `wai key rotate` states them. let r = ok(&d, &["key", "rotate", "delivery", "--store", "file", "--yes"]); let retire = format!("{}@{}", r["previous"].as_str().unwrap(), r["until"].as_str().unwrap()); let line = ok(&d, &["key", "export", "delivery", "--format", "wai-keys", "--may-sign", "delivery"])["text"].as_str().unwrap().trim().to_string(); let until = r["until"].as_str().unwrap(); let o = wk(&["append", "log.json", "--root-seed-file", root_file, "--issued-at", until, "--add", &line, "--retire", &retire]); assert!(o.status.success(), "{}", text(&o)); std::fs::write(d.join("log.json"), &o.stdout).unwrap(); // A revocation, at the time `wai key revoke` states. let v = ok(&d, &["key", "revoke", "delivery", "--id", "delivery", "--compromised-since", "2026-11-05T00:00:00Z", "--yes"]); let revoke = format!("{}@{}", v["id"].as_str().unwrap(), v["compromised_since"].as_str().unwrap()); let o = wk(&["append", "log.json", "--root-seed-file", root_file, "--issued-at", until, "--revoke", &revoke]); assert!(o.status.success(), "{}", text(&o)); std::fs::write(d.join("log.json"), &o.stdout).unwrap(); let o = wk(&["verify", "log.json", "--origin", "https://example.test"]); assert!(o.status.success(), "{}", text(&o)); assert!(String::from_utf8_lossy(&o.stdout).starts_with("anchored"), "{}", text(&o)); // The log states what the keystore states, entry for entry. let log: Value = serde_json::from_slice(&std::fs::read(d.join("log.json")).unwrap()).unwrap(); let head = log["revisions"].as_array().unwrap().last().unwrap(); let e = ok(&d, &["key", "export", "delivery", "--format", "entry", "--may-sign", "delivery"]); assert_eq!(head["keys"], e["entries"], "the head's entries are the export's"); } /// Windows' access lists, read here as the system's own descriptor language, which names accounts /// by SID in every locale, and widened by SID. #[cfg(windows)] mod access_lists { use super::*; fn powershell(command: &str) -> String { // Without PSModulePath: a parent PowerShell 7's would keep this one from loading Get-Acl. let o = Command::new("powershell").args(["-NoProfile", "-NonInteractive", "-Command", command]).env_remove("PSModulePath").stdin(Stdio::null()).output().expect("powershell runs"); assert!(o.status.success(), "{command}: {}", text(&o)); String::from_utf8_lossy(&o.stdout).trim().to_string() } fn me() -> String { powershell("[Security.Principal.WindowsIdentity]::GetCurrent().User.Value") } fn descriptor(p: &Path) -> String { powershell(&format!("(Get-Acl -LiteralPath '{}').Sddl", p.display())) } /// `p`'s owner is `me`, its inheritance is off, and its one entry is `me`'s, `entry` being that /// entry's flags. fn holds_only_me(p: &Path, me: &str, flags: &str) { let s = descriptor(p); assert!(s.starts_with(&format!("O:{me}G:")), "{}: its owner: {s}", p.display()); let (_, dacl) = s.split_once("D:").unwrap_or_else(|| panic!("{}: no list: {s}", p.display())); let (control, entries) = dacl.split_once('(').unwrap_or_else(|| panic!("{}: no entry: {s}", p.display())); assert!(control.contains('P'), "{}: inheritance is on: {s}", p.display()); assert_eq!(entries, format!("A;{flags};FA;;;{me})"), "{}: its entries: {s}", p.display()); } fn icacls(p: &Path, args: &[&str]) { let o = Command::new("icacls").arg(p).args(args).output().expect("icacls runs"); assert!(o.status.success(), "icacls {args:?}: {}", text(&o)); } /// A directory in the folder every user of the machine shares, removed in every case. struct Shared(PathBuf); impl Shared { fn new(name: &str) -> Shared { let d = PathBuf::from(std::env::var_os("PUBLIC").expect("%PUBLIC%")).join(format!("wai-key-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); Shared(d) } } impl Drop for Shared { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn wai_in(cwd: &Path, keystore: &Path, args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(cwd).args(args).env("WAI_KEYSTORE", keystore).stdin(Stdio::null()).output().expect("wai runs") } #[test] fn a_keystore_in_a_folder_every_user_shares_gets_what_one_in_a_profile_gets() { let me = me(); let d = scratch("win-shared"); std::fs::write(d.join("seed.hex"), (100..132u8).map(|b| format!("{b:02x}")).collect::()).unwrap(); // One the toolchain makes; and one that exists already, under the folder's wide list, which // stays as it is while what wai makes inside does not take it. let (made, there) = (Shared::new("made"), Shared::new("there")); std::fs::create_dir_all(&there.0).unwrap(); for ks in [&made.0, &there.0] { let store = ks.to_str().unwrap(); for args in [&["key", "create", "k", "--for", "signer", "--keystore", store][..], &["key", "import", "held", "--for", "signer", "--from", "seed.hex", "--keystore", store]] { let o = wai(&d, args); assert!(o.status.success(), "{args:?}: {}", text(&o)); } for f in ["k.seed", "k.json", "held.seed", "held.json"] { holds_only_me(&ks.join(f), &me, ""); } // A successor's seed, and the record replaced beside it. let o = wai(&d, &["key", "rotate", "k", "--keystore", store, "--yes"]); assert!(o.status.success(), "{}", text(&o)); holds_only_me(&ks.join("k.2.seed"), &me, ""); holds_only_me(&ks.join("k.json"), &me, ""); // It signs: this account's list is enough for this account. let o = wai_in(&d, ks, &["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe", "--yes"]); assert!(o.status.success(), "{}", text(&o)); } holds_only_me(&made.0, &me, "OICI"); assert!(!descriptor(&there.0).contains(&format!("D:P(A;OICI;FA;;;{me})")), "a directory that was there stays as it was"); } #[test] fn a_seed_another_account_may_read_signs_nothing_until_the_fix_it_names() { let d = scratch("win-widened"); let ks = d.join("ks"); create(&d, "k"); let seed = ks.join("k.seed"); let sign = ["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe", "--yes"]; let good = signer_of_enrolment(&d, "k"); std::fs::remove_file(d.join("n.wfe")).unwrap(); // Everyone, SYSTEM and Administrators alike, each by its SID. for sid in ["S-1-1-0", "S-1-5-18", "S-1-5-32-544"] { icacls(&seed, &["/grant", &format!("*{sid}:R")]); let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe", "--yes", "--json"]); assert_eq!(o.status.code(), Some(3), "{sid}: {}", text(&o)); assert!(!d.join("n.wfe").exists(), "{sid}: it signed"); let v: Value = serde_json::from_slice(&o.stdout).unwrap(); assert_eq!(v["error"]["code"], "key-file-exposed", "{v}"); let message = v["error"]["message"].as_str().unwrap(); assert!(message.contains(seed.to_str().unwrap()) && message.contains(sid), "{message}"); // The command it names is the whole fix. let hint = v["error"]["hint"].as_str().unwrap(); assert!(hint.contains(&format!("*{sid}")), "{hint}"); use std::os::windows::process::CommandExt; let fixed = Command::new("cmd").raw_arg(format!("/S /C \"{hint}\"")).stdin(Stdio::null()).output().unwrap(); assert!(fixed.status.success(), "{hint}: {}", text(&fixed)); assert_eq!(signer_of_enrolment(&d, "k"), good, "{sid}: fixed, it signs again"); std::fs::remove_file(d.join("n.wfe")).unwrap(); } // A list wider than any one grant: inheritance on again, under the wide list of this folder. std::fs::rename(ks.join("k.seed"), d.join("k.seed")).unwrap(); std::fs::rename(ks.join("k.json"), d.join("k.json")).unwrap(); let o = wai_in(&d, &d, &sign); assert!(o.status.success(), "moved with its list, it is still its owner's: {}", text(&o)); std::fs::remove_file(d.join("n.wfe")).unwrap(); icacls(&d.join("k.seed"), &["/inheritance:e"]); let o = wai_in(&d, &d, &sign); assert_eq!(o.status.code(), Some(3), "inheritance on: {}", text(&o)); // Another owner may change the list, where this account may hand a file over. let handed = Command::new("icacls").arg(d.join("k.seed")).args(["/setowner", "*S-1-5-32-544"]).output().unwrap(); if handed.status.success() { icacls(&d.join("k.seed"), &["/inheritance:r", "/remove", "*S-1-5-32-544", "*S-1-5-18"]); let o = wai_in(&d, &d, &sign); assert_eq!(o.status.code(), Some(3), "another owner: {}", text(&o)); } } #[test] fn the_default_keystore_under_appdata_works_end_to_end() { let me = me(); let d = scratch("win-appdata"); let run = |args: &[&str]| -> Output { Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(&d).args(args).env_remove("WAI_KEYSTORE").env_remove("XDG_CONFIG_HOME").env("APPDATA", d.join("roaming")).stdin(Stdio::null()).output().unwrap() }; let ok = |args: &[&str]| { let o = run(args); assert!(o.status.success(), "{args:?}: {}", text(&o)); String::from_utf8_lossy(&o.stdout).into_owned() }; let ks = d.join("roaming").join("wai").join("keys"); assert!(ok(&["key", "create", "k", "--for", "signer"]).contains("an access list for this account alone")); holds_only_me(&ks, &me, "OICI"); holds_only_me(&ks.join("k.seed"), &me, ""); holds_only_me(&ks.join("k.json"), &me, ""); assert!(ok(&["key", "list"]).contains("k")); ok(&["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe"]); ok(&["key", "rotate", "k", "--yes"]); assert!(!ks.join("k.seed").exists() && ks.join("k.2.seed").is_file()); ok(&["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n2.wfe"]); ok(&["key", "revoke", "k", "--yes"]); assert!(!ks.join("k.2.seed").exists()); assert_eq!(run(&["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n3.wfe"]).status.code(), Some(2)); } } fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } fn unhex(s: &str) -> Vec { (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() }