wire/same_machine.rs
1//! RFC-001 amendment (#182): same-owner same-machine signed attestation.
2//!
3//! Wire already auto-pins *sister sessions* by reading their card off local
4//! disk (`pull::maybe_autopin_local_sister`) — a filesystem witness. Coral's
5//! #182 review flagged that witness as too weak on its own: anything that can
6//! write the data-dir tree could mint a sibling. This module is the
7//! *cryptographic* hardening: an operator-signed claim, carried in the agent
8//! card, that a receiver verifies against ITS OWN machine before auto-pinning
9//! the sender at `ORG_VERIFIED`.
10//!
11//! ## The claim
12//!
13//! `same_machine_attestation = { machine_fingerprint, signature }` where the
14//! signature is the operator root key (`op_sk`) over the domain-separated
15//! canonical message
16//!
17//! ```text
18//! wire-same-machine-v1|<fingerprint_hex>|<session_did>
19//! ```
20//!
21//! Signing with `op_sk` (not the session key) is the point: it proves *the
22//! operator who owns this session says all my sessions on machine X share this
23//! fingerprint*, which is the trust-model claim the receiver acts on.
24//!
25//! ## Receiver safety (the two checks that make it sound)
26//!
27//! 1. **Fingerprint strict-equality** — the receiver recomputes its own
28//! `machine_fingerprint` from its local `(machine_id, os_user_id)` and
29//! refuses unless the attestation's fingerprint byte-equals it. A remote
30//! sender cannot know the receiver's fingerprint without already being on
31//! the receiver's machine.
32//! 2. **Signature over the canonical bytes** — verified under the same inline
33//! `op_pubkey` the op-chain already validated. A card that publishes the
34//! receiver's fingerprint but signs a *different* one (the hostile-forge
35//! case, AC-SM3) fails here.
36//!
37//! ## Deviations from the amendment doc (deliberate, equivalent)
38//!
39//! - **sha256, not blake2b.** A one-way 32-byte commitment; sha2 is already a
40//! dependency, blake2 is not. Domain tag `wire-same-machine-v1` is unchanged.
41//! - **canonical message is a domain-separated string** (mirroring
42//! `identity::succession_payload`) rather than raw byte concatenation, so it
43//! reuses the audited `sign_did_cert` / `verify_payload_sig` path and can
44//! never be replayed as an op/member/succession cert.
45
46use crate::identity::{CertError, sign_did_cert, verify_payload_sig};
47use crate::signing::{b64decode, b64encode};
48use sha2::{Digest, Sha256};
49
50/// Domain-separation tag. The `v1` lets a future fingerprint construction ship
51/// as `v2` without renaming the card field. Protects against cross-protocol
52/// collision on the shared `machine_id` identifier.
53pub const FINGERPRINT_DOMAIN: &str = "wire-same-machine-v1";
54
55/// Errors verifying a received same-machine attestation. Every variant is a
56/// fall-through (the receiver drops the same-machine fast-path and proceeds
57/// with standard pairing), never a hard failure of the pull.
58#[derive(Debug, PartialEq, Eq)]
59pub enum VerifyError {
60 /// `machine_fingerprint` field is not valid base64 or not 32 bytes.
61 BadFingerprint,
62 /// The attestation's fingerprint does not match the receiver's own machine
63 /// — i.e. the sender is not actually on this `(machine, OS user)`. The
64 /// hostile-forge mitigation (§C step 5) and the legitimate different-uid
65 /// case (AC-SM2) both land here.
66 FingerprintMismatch,
67 /// Signature did not verify under the inline `op_pubkey` over the canonical
68 /// message (tampered field, wrong key, or signed-vs-published mismatch —
69 /// AC-SM3).
70 Signature(CertError),
71}
72
73impl std::fmt::Display for VerifyError {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 match self {
76 VerifyError::BadFingerprint => write!(f, "malformed machine_fingerprint"),
77 VerifyError::FingerprintMismatch => {
78 write!(f, "attestation fingerprint is not this machine")
79 }
80 VerifyError::Signature(e) => write!(f, "attestation signature: {e}"),
81 }
82 }
83}
84
85/// Compute the 32-byte machine fingerprint from raw platform inputs.
86///
87/// Pure: the caller supplies the raw `machine_id` bytes and the per-OS-user id
88/// bytes (read by `platform::machine_id_raw` / `platform::os_user_id_bytes`).
89/// The OS-user component is what stops two different users on one shared host
90/// (same `/etc/machine-id`) from cross-pairing — different uid → different
91/// fingerprint → receiver's strict-equality check fails.
92pub fn machine_fingerprint(machine_id: &[u8], os_user_id: &[u8]) -> [u8; 32] {
93 let mut h = Sha256::new();
94 h.update(machine_id);
95 h.update(os_user_id);
96 h.update(FINGERPRINT_DOMAIN.as_bytes());
97 let digest = h.finalize();
98 let mut fp = [0u8; 32];
99 fp.copy_from_slice(&digest);
100 fp
101}
102
103/// The canonical message the operator key signs / a receiver verifies.
104/// Domain-separated; the fingerprint is lowercase hex so the message is a plain
105/// printable string on the same `sign_did_cert` path as the other certs.
106pub fn attestation_payload(fingerprint: &[u8; 32], session_did: &str) -> String {
107 format!(
108 "{FINGERPRINT_DOMAIN}|{}|{session_did}",
109 hex::encode(fingerprint)
110 )
111}
112
113/// Build the attestation: `op_sk` signs the canonical message over
114/// `(fingerprint, session_did)`. Returns `(machine_fingerprint_b64,
115/// signature_b64)` ready to drop into the card's `same_machine_attestation`.
116pub fn build_attestation(
117 op_sk: &[u8],
118 fingerprint: &[u8; 32],
119 session_did: &str,
120) -> Result<(String, String), CertError> {
121 let payload = attestation_payload(fingerprint, session_did);
122 let sig = sign_did_cert(op_sk, &payload)?;
123 Ok((b64encode(fingerprint), sig))
124}
125
126/// Verify a received attestation (amendment §C steps 4–6). **Fail-closed.**
127///
128/// - `op_pubkey` — the inline operator pubkey the op-chain already verified to
129/// commit to the sender's `op_did` (and to have the same `op_did` as the
130/// receiver; that same-operator check is the caller's, done before this).
131/// - `attest_fingerprint_b64` / `attest_sig_b64` — the card's two fields.
132/// - `sender_session_did` — the `did` of the card being evaluated.
133/// - `local_fingerprint` — the receiver's OWN fingerprint, recomputed from its
134/// local platform sources. The source of truth for "what is my machine".
135///
136/// Strict byte-equality on the fingerprint — no prefix / no "or-better".
137pub fn verify_attestation(
138 op_pubkey: &[u8],
139 attest_fingerprint_b64: &str,
140 attest_sig_b64: &str,
141 sender_session_did: &str,
142 local_fingerprint: &[u8; 32],
143) -> Result<(), VerifyError> {
144 let fp_bytes = b64decode(attest_fingerprint_b64).map_err(|_| VerifyError::BadFingerprint)?;
145 if fp_bytes.len() != 32 {
146 return Err(VerifyError::BadFingerprint);
147 }
148 // §C step 5: strict equality against the receiver's own machine. A remote
149 // sender can't produce a fingerprint that matches without being here.
150 if fp_bytes.as_slice() != local_fingerprint.as_slice() {
151 return Err(VerifyError::FingerprintMismatch);
152 }
153 let mut fp = [0u8; 32];
154 fp.copy_from_slice(&fp_bytes);
155 // §C step 6: signature verifies under op_pubkey over the canonical message
156 // reconstructed from the *published* fingerprint. AC-SM3 (published == ours
157 // but signed over a different one) fails right here.
158 let payload = attestation_payload(&fp, sender_session_did);
159 verify_payload_sig(op_pubkey, attest_sig_b64, &payload).map_err(VerifyError::Signature)
160}
161
162/// Read this machine's local fingerprint from platform sources. `None` when
163/// either source can't be read (the session still functions; it just can't
164/// participate in the same-machine lane — fail-closed per §A).
165pub fn local_fingerprint() -> Option<[u8; 32]> {
166 let machine_id = crate::platform::machine_id_raw()?;
167 let os_user_id = crate::platform::os_user_id_bytes()?;
168 Some(machine_fingerprint(&machine_id, &os_user_id))
169}
170
171/// This session's own `op_did`, read from its on-disk agent card. `None` when
172/// not enrolled / no card. Used to gate the same-machine lane on "same
173/// operator as me" (§C step 2).
174fn my_op_did() -> Option<String> {
175 let card = crate::config::read_agent_card().ok()?;
176 crate::agent_card::card_op_did(&card).map(str::to_string)
177}
178
179/// Receiver decision (amendment §C, all 7 steps): should a received `peer_card`
180/// be auto-pinned at `ORG_VERIFIED` because it proves it is on THIS machine,
181/// owned by the SAME operator? Returns `Some(peer_op_did)` when every check
182/// passes, `None` to fall through to standard pairing. Fully offline.
183///
184/// Because a wire `op_did` is a hash commitment to the operator key, "peer's
185/// op_did == my op_did" already forces "peer's inline op_pubkey == my op_pubkey"
186/// — i.e. genuinely the same operator, not a look-alike. The fingerprint match
187/// then forces "same (machine, OS user)", and the signature forces "this exact
188/// session, not a replay".
189pub fn auto_pin_decision(peer_card: &serde_json::Value) -> Option<String> {
190 // §C step 1: the peer's op-chain (op_did ⟵ op_pubkey, op_cert over session)
191 // must verify. A broken / absent claim → no same-machine consideration.
192 let anchor = crate::org_membership::verify_op_anchor(peer_card)
193 .ok()
194 .flatten()?;
195 // §C step 2: same operator — the peer's op_did must equal mine.
196 if anchor.op_did != my_op_did()? {
197 return None;
198 }
199 // §C step 3: the attestation field must be present + shaped.
200 let att = peer_card.get("same_machine_attestation")?;
201 let fp_b64 = att.get("machine_fingerprint").and_then(|v| v.as_str())?;
202 let sig_b64 = att.get("signature").and_then(|v| v.as_str())?;
203 let sender_did = peer_card.get("did").and_then(|v| v.as_str())?;
204 // §C step 4: recompute MY local fingerprint.
205 let local_fp = local_fingerprint()?;
206 // §C steps 5–6: strict fingerprint equality + signature over the canonical
207 // message under the (already op-chain-verified) op_pubkey.
208 verify_attestation(&anchor.op_pubkey, fp_b64, sig_b64, sender_did, &local_fp).ok()?;
209 // §C step 7: caller pins ORG_VERIFIED.
210 Some(anchor.op_did)
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use crate::signing::generate_keypair;
217
218 #[test]
219 fn fingerprint_is_deterministic_and_user_salted() {
220 let m = b"machine-uuid-aaaa";
221 let fp_u1000 = machine_fingerprint(m, b"1000");
222 // Same inputs → same fingerprint.
223 assert_eq!(fp_u1000, machine_fingerprint(m, b"1000"));
224 // Different OS user on the SAME machine → different fingerprint. This is
225 // the multi-user-host isolation (Security §S1).
226 assert_ne!(fp_u1000, machine_fingerprint(m, b"1001"));
227 // Different machine, same user → different fingerprint.
228 assert_ne!(fp_u1000, machine_fingerprint(b"machine-uuid-bbbb", b"1000"));
229 }
230
231 #[test]
232 fn payload_is_domain_separated() {
233 let fp = machine_fingerprint(b"m", b"1000");
234 let p = attestation_payload(&fp, "did:wire:slate-lotus-88232017");
235 assert!(p.starts_with("wire-same-machine-v1|"));
236 assert!(p.ends_with("|did:wire:slate-lotus-88232017"));
237 // The succession-cert / op-cert domains are distinct prefixes, so an
238 // attestation can never be replayed as one of those certs.
239 assert!(!p.starts_with("wire-succession-v1"));
240 }
241
242 /// AC-SM1 core: a genuine same-op + same-machine attestation verifies.
243 #[test]
244 fn roundtrip_same_machine_verifies() {
245 let (op_sk, op_pk) = generate_keypair();
246 let fp = machine_fingerprint(b"machine-X", b"1000");
247 let did = "did:wire:slate-lotus-88232017";
248 let (fp_b64, sig) = build_attestation(&op_sk, &fp, did).unwrap();
249 // Receiver recomputes the SAME local fingerprint (same machine + uid).
250 assert_eq!(verify_attestation(&op_pk, &fp_b64, &sig, did, &fp), Ok(()));
251 }
252
253 /// AC-SM2: same machine, same op, but the receiver's uid differs → the
254 /// receiver's local fingerprint differs → strict-equality rejects.
255 #[test]
256 fn different_uid_rejected() {
257 let (op_sk, op_pk) = generate_keypair();
258 let sender_fp = machine_fingerprint(b"machine-X", b"1000");
259 let did = "did:wire:slate-lotus-88232017";
260 let (fp_b64, sig) = build_attestation(&op_sk, &sender_fp, did).unwrap();
261 // Receiver is uid 1001 on the same box.
262 let receiver_fp = machine_fingerprint(b"machine-X", b"1001");
263 assert_eq!(
264 verify_attestation(&op_pk, &fp_b64, &sig, did, &receiver_fp),
265 Err(VerifyError::FingerprintMismatch)
266 );
267 }
268
269 /// AC-SM3: hostile forge — the published `machine_fingerprint` equals the
270 /// receiver's (so step 5 passes) but the signature was made over a
271 /// *different* fingerprint. Signature verification (step 6) must reject.
272 #[test]
273 fn hostile_forge_signed_over_other_fingerprint_rejected() {
274 let (op_sk, op_pk) = generate_keypair();
275 let receiver_fp = machine_fingerprint(b"machine-victim", b"1000");
276 let attacker_fp = machine_fingerprint(b"machine-attacker", b"1000");
277 let did = "did:wire:evil-1234";
278 // Sign over the attacker's real fingerprint...
279 let sig = sign_did_cert(&op_sk, &attestation_payload(&attacker_fp, did)).unwrap();
280 // ...but PUBLISH the victim's fingerprint to slip past step 5 (fp match).
281 let published_fp_b64 = b64encode(&receiver_fp);
282 // Step 5 passes (published == receiver), but step 6 reconstructs the
283 // canonical message from the PUBLISHED fingerprint and the signature was
284 // made over the attacker's — so it fails to verify. Either way: rejected.
285 assert_eq!(
286 verify_attestation(&op_pk, &published_fp_b64, &sig, did, &receiver_fp),
287 Err(VerifyError::Signature(CertError::Rejected))
288 );
289 }
290
291 /// AC-SM3 variant where the published fingerprint genuinely matches the
292 /// receiver (step 5 passes) but the signature is over a different message.
293 #[test]
294 fn published_matches_but_signature_mismatched_rejected() {
295 let (op_sk, op_pk) = generate_keypair();
296 let receiver_fp = machine_fingerprint(b"machine-victim", b"1000");
297 let did = "did:wire:evil-1234";
298 // Signature is over a DIFFERENT session_did than the one presented.
299 let sig = sign_did_cert(
300 &op_sk,
301 &attestation_payload(&receiver_fp, "did:wire:some-other-9999"),
302 )
303 .unwrap();
304 let published_fp_b64 = b64encode(&receiver_fp);
305 assert_eq!(
306 verify_attestation(&op_pk, &published_fp_b64, &sig, did, &receiver_fp),
307 Err(VerifyError::Signature(CertError::Rejected))
308 );
309 }
310
311 #[test]
312 fn wrong_op_key_rejected() {
313 let (op_sk, _op_pk) = generate_keypair();
314 let (_other_sk, other_pk) = generate_keypair();
315 let fp = machine_fingerprint(b"machine-X", b"1000");
316 let did = "did:wire:slate-lotus-1";
317 let (fp_b64, sig) = build_attestation(&op_sk, &fp, did).unwrap();
318 // Verifying under a different op pubkey fails.
319 assert_eq!(
320 verify_attestation(&other_pk, &fp_b64, &sig, did, &fp),
321 Err(VerifyError::Signature(CertError::Rejected))
322 );
323 }
324
325 #[test]
326 fn malformed_fingerprint_rejected() {
327 let (_sk, pk) = generate_keypair();
328 let fp = machine_fingerprint(b"m", b"1000");
329 assert_eq!(
330 verify_attestation(&pk, "!!!not-base64", "sig", "did:wire:x", &fp),
331 Err(VerifyError::BadFingerprint)
332 );
333 // Valid base64 but wrong length.
334 assert_eq!(
335 verify_attestation(&pk, &b64encode(b"too-short"), "sig", "did:wire:x", &fp),
336 Err(VerifyError::BadFingerprint)
337 );
338 }
339
340 /// Idempotency substrate for AC-SM4: the canonical message is deterministic,
341 /// so re-signing the same fleet produces the byte-identical attestation.
342 #[test]
343 fn build_is_deterministic() {
344 let (op_sk, _pk) = generate_keypair();
345 let fp = machine_fingerprint(b"machine-X", b"1000");
346 let did = "did:wire:slate-lotus-1";
347 assert_eq!(
348 build_attestation(&op_sk, &fp, did).unwrap(),
349 build_attestation(&op_sk, &fp, did).unwrap()
350 );
351 }
352
353 /// Build a peer card for the SAME operator (`op_sk`/`op_pk`), with an
354 /// attestation over `attest_fp`. Returns the unsigned card Value.
355 #[cfg(test)]
356 fn peer_card_for(
357 op_sk: &[u8; 32],
358 op_pk: &[u8; 32],
359 op_handle: &str,
360 attest_fp: &[u8; 32],
361 ) -> serde_json::Value {
362 let (_peer_sk, peer_pk) = generate_keypair();
363 let base = crate::agent_card::build_agent_card("sister", &peer_pk, None, None, None);
364 let session_did = base
365 .get("did")
366 .and_then(|v| v.as_str())
367 .unwrap()
368 .to_string();
369 let op_did = crate::agent_card::did_for_op(op_handle, op_pk);
370 let op_cert = crate::identity::sign_did_cert(op_sk, &session_did).unwrap();
371 let (fp_b64, sig) = build_attestation(op_sk, attest_fp, &session_did).unwrap();
372 let claims = crate::agent_card::IdentityClaims {
373 op_did: Some(op_did),
374 op_cert: Some(op_cert),
375 op_pubkey: Some(b64encode(op_pk)),
376 org_memberships: vec![],
377 project: None,
378 same_machine_attestation: Some((fp_b64, sig)),
379 };
380 crate::agent_card::with_identity_claims(&base, &claims).unwrap()
381 }
382
383 /// Enroll a self-session under `op_sk` and write its card so `my_op_did()`
384 /// resolves. Returns the operator did.
385 #[cfg(test)]
386 fn enroll_self(op_sk: &[u8; 32], op_handle: &str) {
387 crate::config::write_op_key(op_sk).unwrap();
388 crate::config::write_op_handle(op_handle).unwrap();
389 let (my_sk, my_pk) = generate_keypair();
390 let base = crate::agent_card::build_agent_card("me", &my_pk, None, None, None);
391 let card = crate::enroll::with_op_claims_if_enrolled(base).unwrap();
392 crate::config::write_agent_card(&crate::agent_card::sign_agent_card(&card, &my_sk))
393 .unwrap();
394 }
395
396 /// AC-SM1 end-to-end through the real platform fingerprint: a same-op card
397 /// attesting THIS machine's actual fingerprint is accepted.
398 #[test]
399 fn auto_pin_decision_accepts_same_op_same_machine() {
400 crate::config::test_support::with_temp_home(|| {
401 let Some(local_fp) = local_fingerprint() else {
402 return; // platform can't read machine-id/uid → lane unavailable; skip.
403 };
404 let (op_sk, op_pk) = generate_keypair();
405 enroll_self(&op_sk, "darby");
406 let op_did = crate::agent_card::did_for_op("darby", &op_pk);
407 let peer = peer_card_for(&op_sk, &op_pk, "darby", &local_fp);
408 assert_eq!(auto_pin_decision(&peer), Some(op_did));
409 });
410 }
411
412 /// A card from a DIFFERENT operator (different op key) is not same-machine
413 /// eligible even if it attests this machine's fingerprint.
414 #[test]
415 fn auto_pin_decision_rejects_different_operator() {
416 crate::config::test_support::with_temp_home(|| {
417 let Some(local_fp) = local_fingerprint() else {
418 return;
419 };
420 let (my_op_sk, _my_op_pk) = generate_keypair();
421 enroll_self(&my_op_sk, "darby");
422 // Peer is a different operator.
423 let (other_sk, other_pk) = generate_keypair();
424 let peer = peer_card_for(&other_sk, &other_pk, "mallory", &local_fp);
425 assert_eq!(auto_pin_decision(&peer), None);
426 });
427 }
428
429 /// A same-op card attesting a DIFFERENT machine's fingerprint is rejected
430 /// (the fingerprint won't match this receiver's local one).
431 #[test]
432 fn auto_pin_decision_rejects_other_machine_fingerprint() {
433 crate::config::test_support::with_temp_home(|| {
434 if local_fingerprint().is_none() {
435 return;
436 }
437 let (op_sk, op_pk) = generate_keypair();
438 enroll_self(&op_sk, "darby");
439 let other_machine_fp = machine_fingerprint(b"some-other-box", b"31337");
440 let peer = peer_card_for(&op_sk, &op_pk, "darby", &other_machine_fp);
441 assert_eq!(auto_pin_decision(&peer), None);
442 });
443 }
444}