A seed in a shared folder got the folder's list, and wai signed with it. Now the seed, the record and a keystore wai makes have this account as owner and as their one entry, with inheritance off, wherever they are. The call that makes the file carries the list (CreateFileW with its security descriptor), so the secret never exists on disk under anything wider, and no handle can be held open from a moment when it did. Unix was already so: open with O_CREAT|O_EXCL and mode 0600. A test now holds that too. A seed whose list grants any other account anything, or that another account owns, is refused with the same exit 3 and key-file-exposed as a wide mode on Unix, naming the file, the accounts and the icacls command that fixes it. Everything is by SID, read through the system's calls and never from icacls' output, which is localised. SYSTEM and Administrators are not listed: a backup or a scanner reads through its privileges rather than the list, an administrator can still take ownership, and the account itself reads the file elevated or not. All the unsafe code is in win_acl.rs, behind cfg(windows). What decides is ordinary code over plain values, so its tests run on every system. windows-sys 0.61 was already in all sixteen lockfiles that hold this crate; each gains one line. Co-Authored-By: Claude Opus 5.5 <[email protected]>
722 lines
41 KiB
Rust
722 lines
41 KiB
Rust
//! `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 <name>` 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<String> = ["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::<Vec<_>>()}],
|
|
});
|
|
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::<String>()).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<u8> {
|
|
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
|
|
}
|