anvilsign in

collin/anvil

main / crates / anvil-web / tests / js_interop.rs
1//! The browser and the CLI must produce and consume the same envelopes, and
2//! the only way to know that is to run both halves.
3//!
4//! This test executes the *actual* `SEAL_JS` string served to browsers under
5//! node's WebCrypto, then opens the resulting envelope with the Rust
6//! implementation the CLI uses. A drift in either direction — a changed HKDF
7//! salt, a different associated-data string, a byte-order slip in the
8//! Edwards → Montgomery map — fails here rather than in production.
9//!
10//! node is a test fixture only: nothing in the server or the CLI depends on it.
11
12use anvil_core::secrets::{
13 Envelope,
14 Identity,
15 Recipient,
16 body_aad,
17 seal,
18};
19use ssh_key::{
20 PrivateKey,
21 private::Ed25519Keypair,
22};
23
24/// Generate a throwaway ssh-ed25519 key.
25fn keypair() -> (PrivateKey, String) {
26 let mut seed = [0u8; 32];
27 getrandom(&mut seed);
28 let key = PrivateKey::from(Ed25519Keypair::from_seed(&seed));
29 let line = key.public_key().to_openssh().unwrap();
30 (key, line)
31}
32
33fn getrandom(buf: &mut [u8]) {
34 use rand::{
35 Rng,
36 rand_core::UnwrapErr,
37 rngs::SysRng,
38 };
39 UnwrapErr(SysRng).fill_bytes(buf);
40}
41
42fn node_available() -> bool {
43 std::process::Command::new("node")
44 .arg("--version")
45 .output()
46 .map(|o| o.status.success())
47 .unwrap_or(false)
48}
49
50/// Run `SEAL_JS` under node and return the envelope it produces.
51fn seal_in_node(repo: &str, name: &str, value: &str, recipients: &serde_json::Value) -> String {
52 let dir = tempfile::tempdir().unwrap();
53 let script = dir.path().join("seal.mjs");
54 std::fs::write(
55 &script,
56 format!(
57 "{seal}\n\
58 const envelope = await anvilSealSecret({repo}, {name}, {value}, {recipients});\n\
59 process.stdout.write(JSON.stringify(envelope));\n",
60 seal = anvil_web::secrets::SEAL_JS,
61 repo = serde_json::to_string(repo).unwrap(),
62 name = serde_json::to_string(name).unwrap(),
63 value = serde_json::to_string(value).unwrap(),
64 recipients = recipients,
65 ),
66 )
67 .unwrap();
68
69 let output = std::process::Command::new("node")
70 .arg(&script)
71 .output()
72 .expect("running node");
73 assert!(
74 output.status.success(),
75 "node failed: {}",
76 String::from_utf8_lossy(&output.stderr)
77 );
78 String::from_utf8(output.stdout).unwrap()
79}
80
81#[test]
82fn rust_opens_what_the_browser_seals() {
83 if !node_available() {
84 eprintln!("skipping: node is not installed");
85 return;
86 }
87 let (a_key, a_line) = keypair();
88 let (b_key, b_line) = keypair();
89 let recipients = serde_json::json!([
90 { "fingerprint": Recipient::from_openssh(&a_line).unwrap().fingerprint, "key": a_line },
91 { "fingerprint": Recipient::from_openssh(&b_line).unwrap().fingerprint, "key": b_line },
92 ]);
93
94 let value = "s3cr3t-värde-🔐"; // non-ASCII: the encoders must agree too
95 let json = seal_in_node("collin/anvil", "DEPLOY_TOKEN", value, &recipients);
96 let envelope = Envelope::parse(&json).expect("browser envelope parses");
97 let aad = body_aad("collin", "anvil", "DEPLOY_TOKEN");
98
99 // Every registered key opens it, which is the promise the UI makes.
100 for key in [&a_key, &b_key] {
101 let identity = Identity::from_private_key(key).unwrap();
102 let opened = envelope.open(&aad, &identity).expect("opens with this key");
103 assert_eq!(String::from_utf8(opened).unwrap(), value);
104 }
105}
106
107#[test]
108fn the_browsers_associated_data_binds_name_and_repo() {
109 if !node_available() {
110 eprintln!("skipping: node is not installed");
111 return;
112 }
113 let (key, line) = keypair();
114 let recipients = serde_json::json!([
115 { "fingerprint": Recipient::from_openssh(&line).unwrap().fingerprint, "key": line },
116 ]);
117 let json = seal_in_node("collin/anvil", "TOKEN", "hunter2", &recipients);
118 let envelope = Envelope::parse(&json).unwrap();
119 let identity = Identity::from_private_key(&key).unwrap();
120
121 assert!(
122 envelope
123 .open(&body_aad("collin", "anvil", "TOKEN"), &identity)
124 .is_ok()
125 );
126 assert!(
127 envelope
128 .open(&body_aad("collin", "anvil", "OTHER"), &identity)
129 .is_err()
130 );
131 assert!(
132 envelope
133 .open(&body_aad("mallory", "anvil", "TOKEN"), &identity)
134 .is_err()
135 );
136}
137
138/// The reverse direction: an envelope the CLI wrote must be shaped exactly like
139/// the browser's, so a value set from the terminal shows up as readable in the
140/// UI's key-coverage display (and re-seals cleanly).
141#[test]
142fn browser_and_cli_envelopes_have_the_same_shape() {
143 if !node_available() {
144 eprintln!("skipping: node is not installed");
145 return;
146 }
147 let (_, line) = keypair();
148 let recipient = Recipient::from_openssh(&line).unwrap();
149 let recipients = serde_json::json!([
150 { "fingerprint": recipient.fingerprint, "key": line },
151 ]);
152 let from_browser: serde_json::Value = serde_json::from_str(&seal_in_node(
153 "collin/anvil",
154 "TOKEN",
155 "hunter2",
156 &recipients,
157 ))
158 .unwrap();
159 let from_cli = serde_json::to_value(
160 seal(
161 b"hunter2",
162 &body_aad("collin", "anvil", "TOKEN"),
163 &[recipient],
164 )
165 .unwrap(),
166 )
167 .unwrap();
168
169 let shape = |v: &serde_json::Value| {
170 let object = v.as_object().unwrap();
171 let mut keys: Vec<String> = object.keys().cloned().collect();
172 keys.sort();
173 let stanza = v["recipients"][0].as_object().unwrap();
174 let mut stanza_keys: Vec<String> = stanza.keys().cloned().collect();
175 stanza_keys.sort();
176 (
177 keys,
178 stanza_keys,
179 v["v"].clone(),
180 v["alg"].clone(),
181 // Field lengths are fixed by the format; base64 lengths follow.
182 v["nonce"].as_str().unwrap().len(),
183 v["recipients"][0]["epk"].as_str().unwrap().len(),
184 v["recipients"][0]["wrap"].as_str().unwrap().len(),
185 )
186 };
187 assert_eq!(shape(&from_browser), shape(&from_cli));
188}