Skip to main content

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}