anvilsign in

collin/anvil

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 argon2::password_hash::rand_core::{
35 OsRng,
36 RngCore,
37 };
38 OsRng.fill_bytes(buf);
39}
40
41fn node_available() -> bool {
42 std::process::Command::new("node")
43 .arg("--version")
44 .output()
45 .map(|o| o.status.success())
46 .unwrap_or(false)
47}
48
49/// Run `SEAL_JS` under node and return the envelope it produces.
50fn seal_in_node(repo: &str, name: &str, value: &str, recipients: &serde_json::Value) -> String {
51 let dir = tempfile::tempdir().unwrap();
52 let script = dir.path().join("seal.mjs");
53 std::fs::write(
54 &script,
55 format!(
56 "{seal}\n\
57 const envelope = await anvilSealSecret({repo}, {name}, {value}, {recipients});\n\
58 process.stdout.write(JSON.stringify(envelope));\n",
59 seal = anvil_web::secrets::SEAL_JS,
60 repo = serde_json::to_string(repo).unwrap(),
61 name = serde_json::to_string(name).unwrap(),
62 value = serde_json::to_string(value).unwrap(),
63 recipients = recipients,
64 ),
65 )
66 .unwrap();
67
68 let output = std::process::Command::new("node")
69 .arg(&script)
70 .output()
71 .expect("running node");
72 assert!(
73 output.status.success(),
74 "node failed: {}",
75 String::from_utf8_lossy(&output.stderr)
76 );
77 String::from_utf8(output.stdout).unwrap()
78}
79
80#[test]
81fn rust_opens_what_the_browser_seals() {
82 if !node_available() {
83 eprintln!("skipping: node is not installed");
84 return;
85 }
86 let (a_key, a_line) = keypair();
87 let (b_key, b_line) = keypair();
88 let recipients = serde_json::json!([
89 { "fingerprint": Recipient::from_openssh(&a_line).unwrap().fingerprint, "key": a_line },
90 { "fingerprint": Recipient::from_openssh(&b_line).unwrap().fingerprint, "key": b_line },
91 ]);
92
93 let value = "s3cr3t-värde-🔐"; // non-ASCII: the encoders must agree too
94 let json = seal_in_node("collin/anvil", "DEPLOY_TOKEN", value, &recipients);
95 let envelope = Envelope::parse(&json).expect("browser envelope parses");
96 let aad = body_aad("collin", "anvil", "DEPLOY_TOKEN");
97
98 // Every registered key opens it, which is the promise the UI makes.
99 for key in [&a_key, &b_key] {
100 let identity = Identity::from_private_key(key).unwrap();
101 let opened = envelope.open(&aad, &identity).expect("opens with this key");
102 assert_eq!(String::from_utf8(opened).unwrap(), value);
103 }
104}
105
106#[test]
107fn the_browsers_associated_data_binds_name_and_repo() {
108 if !node_available() {
109 eprintln!("skipping: node is not installed");
110 return;
111 }
112 let (key, line) = keypair();
113 let recipients = serde_json::json!([
114 { "fingerprint": Recipient::from_openssh(&line).unwrap().fingerprint, "key": line },
115 ]);
116 let json = seal_in_node("collin/anvil", "TOKEN", "hunter2", &recipients);
117 let envelope = Envelope::parse(&json).unwrap();
118 let identity = Identity::from_private_key(&key).unwrap();
119
120 assert!(
121 envelope
122 .open(&body_aad("collin", "anvil", "TOKEN"), &identity)
123 .is_ok()
124 );
125 assert!(
126 envelope
127 .open(&body_aad("collin", "anvil", "OTHER"), &identity)
128 .is_err()
129 );
130 assert!(
131 envelope
132 .open(&body_aad("mallory", "anvil", "TOKEN"), &identity)
133 .is_err()
134 );
135}
136
137/// The reverse direction: an envelope the CLI wrote must be shaped exactly like
138/// the browser's, so a value set from the terminal shows up as readable in the
139/// UI's key-coverage display (and re-seals cleanly).
140#[test]
141fn browser_and_cli_envelopes_have_the_same_shape() {
142 if !node_available() {
143 eprintln!("skipping: node is not installed");
144 return;
145 }
146 let (_, line) = keypair();
147 let recipient = Recipient::from_openssh(&line).unwrap();
148 let recipients = serde_json::json!([
149 { "fingerprint": recipient.fingerprint, "key": line },
150 ]);
151 let from_browser: serde_json::Value = serde_json::from_str(&seal_in_node(
152 "collin/anvil",
153 "TOKEN",
154 "hunter2",
155 &recipients,
156 ))
157 .unwrap();
158 let from_cli = serde_json::to_value(
159 seal(
160 b"hunter2",
161 &body_aad("collin", "anvil", "TOKEN"),
162 &[recipient],
163 )
164 .unwrap(),
165 )
166 .unwrap();
167
168 let shape = |v: &serde_json::Value| {
169 let object = v.as_object().unwrap();
170 let mut keys: Vec<String> = object.keys().cloned().collect();
171 keys.sort();
172 let stanza = v["recipients"][0].as_object().unwrap();
173 let mut stanza_keys: Vec<String> = stanza.keys().cloned().collect();
174 stanza_keys.sort();
175 (
176 keys,
177 stanza_keys,
178 v["v"].clone(),
179 v["alg"].clone(),
180 // Field lengths are fixed by the format; base64 lengths follow.
181 v["nonce"].as_str().unwrap().len(),
182 v["recipients"][0]["epk"].as_str().unwrap().len(),
183 v["recipients"][0]["wrap"].as_str().unwrap().len(),
184 )
185 };
186 assert_eq!(shape(&from_browser), shape(&from_cli));
187}