Skip to main content

wire/
identity.rs

1//! RFC-001 §1: operator / organization identity certs.
2//!
3//! Two cert kinds, both Ed25519 signatures over UTF-8 bytes of a DID:
4//!
5//! - **`op_cert`** — operator's root key signs the session DID,
6//!   binding the session under the operator. Carried on the session's
7//!   agent card alongside `op_did`.
8//! - **`member_cert`** — org's root key signs an operator's `op_did`,
9//!   binding the operator into the org. Carried on the session's
10//!   agent card alongside the operator's `op_did`, as an entry in
11//!   `org_memberships[]`.
12//!
13//! Both certs are leaf-level signatures: a single key-check verifies
14//! one link. The trust chain `session_did → op_did → org_did` is two
15//! independent verifications, not a chained walk. This matches the
16//! NATS / OIDF / Keybase convergence noted in the RFC's prior-art
17//! analysis (§Prior art): *membership = signed statement, not roster
18//! lookup*.
19//!
20//! Verification is *cryptographic only*. Whether a pinned-and-verified
21//! `op_did` or `org_did` actually grants `ORG_VERIFIED` is a separate
22//! policy decision in `trust.rs` — gated on attestation status (DNS-TXT
23//! or SSO, see amendments) and per-org operator opt-in (filtering
24//! amendment §3). The split keeps the cryptographic floor honest:
25//! "the cert verifies" is a fact about bytes; "we accept this cert as
26//! authority" is a fact about operator policy.
27
28use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
29use thiserror::Error;
30
31use crate::signing::{b64decode, b64encode};
32
33#[derive(Debug, Error, PartialEq, Eq)]
34pub enum CertError {
35    #[error("certificate base64 decode failed")]
36    BadEncoding,
37    #[error("certificate length is not 64 bytes")]
38    BadLength,
39    #[error("public key length is not 32 bytes")]
40    BadKey,
41    #[error("signature did not verify")]
42    Rejected,
43}
44
45/// Sign `payload_did` with `signing_key`. Returns the base64 cert ready
46/// to drop into `op_cert` or `member_cert`.
47///
48/// `signing_key` must be a 32-byte Ed25519 secret seed (same shape
49/// `signing::generate_keypair` returns and `sign_agent_card` accepts).
50pub fn sign_did_cert(signing_key: &[u8], payload_did: &str) -> Result<String, CertError> {
51    if signing_key.len() < 32 {
52        return Err(CertError::BadKey);
53    }
54    let mut sk_bytes = [0u8; 32];
55    sk_bytes.copy_from_slice(&signing_key[..32]);
56    let sk = SigningKey::from_bytes(&sk_bytes);
57    let sig = sk.sign(payload_did.as_bytes());
58    Ok(b64encode(&sig.to_bytes()))
59}
60
61/// Verify `op_cert` (b64 Ed25519 signature) was produced by `op_pubkey`
62/// over the UTF-8 bytes of `session_did`. Caller must independently
63/// ensure `op_pubkey` is the correct key for the claimed `op_did`
64/// (typically by looking it up in a pinned operator record or by
65/// pulling it from the wireup registry's `GET /v1/op/<op_did>` endpoint).
66pub fn verify_op_cert(
67    op_pubkey: &[u8],
68    op_cert_b64: &str,
69    session_did: &str,
70) -> Result<(), CertError> {
71    verify_did_cert(op_pubkey, op_cert_b64, session_did)
72}
73
74/// Verify `member_cert` was produced by `org_pubkey` over the UTF-8
75/// bytes of `op_did`. Caller must independently ensure `org_pubkey`
76/// is the correct key for the claimed `org_did` (typically by checking
77/// the wireup-registered org attestation, RFC-001 §2).
78pub fn verify_member_cert(
79    org_pubkey: &[u8],
80    member_cert_b64: &str,
81    op_did: &str,
82) -> Result<(), CertError> {
83    verify_did_cert(org_pubkey, member_cert_b64, op_did)
84}
85
86/// Canonical payload an op/org **succession** cert signs. Distinct from the
87/// bare-DID payload `op_cert`/`member_cert` sign, so a succession cert can never
88/// be replayed as — or mistaken for — an op/member cert (different signed bytes
89/// → different signature domain). `kind` is `"op"` or `"org"`.
90fn succession_payload(kind: &str, old_did: &str, new_did: &str) -> String {
91    format!("wire-succession-v1|{kind}|{old_did}|{new_did}")
92}
93
94/// Sign a key-rotation **succession** statement (RFC-001 §T19/§T20): the OLD
95/// key attests "`old_did` hands off to `new_did`". Because a wire DID commits
96/// to its key, rotating the key mints a *new* DID — this cert is what lets a
97/// peer who pinned `old_did` follow the handoff to `new_did`. The new key is
98/// not part of this signature; the verifier separately checks that
99/// `new_pubkey` commits to `new_did`.
100pub fn sign_succession_cert(
101    old_signing_key: &[u8],
102    kind: &str,
103    old_did: &str,
104    new_did: &str,
105) -> Result<String, CertError> {
106    sign_did_cert(old_signing_key, &succession_payload(kind, old_did, new_did))
107}
108
109/// Verify a succession cert: `old_pubkey` (which the caller must independently
110/// confirm commits to `old_did`) signed the canonical `old_did → new_did`
111/// handoff for `kind`. A cert for a different `new_did`, `kind`, or signer
112/// fails.
113pub fn verify_succession_cert(
114    old_pubkey: &[u8],
115    cert_b64: &str,
116    kind: &str,
117    old_did: &str,
118    new_did: &str,
119) -> Result<(), CertError> {
120    verify_did_cert(
121        old_pubkey,
122        cert_b64,
123        &succession_payload(kind, old_did, new_did),
124    )
125}
126
127/// Verify an Ed25519 signature (base64) by `pubkey` over an arbitrary UTF-8
128/// `payload`. This is the generic primitive under `op_cert` / `member_cert` /
129/// succession-cert verification; exposed for the same-machine attestation
130/// (RFC-001 amendment #182), which signs a domain-separated payload *string*
131/// (`wire-same-machine-v1|<fp_hex>|<session_did>`) rather than a bare DID. The
132/// caller must independently confirm `pubkey` is the right key (here: the
133/// inline `op_pubkey` already verified to commit to `op_did`).
134pub fn verify_payload_sig(pubkey: &[u8], sig_b64: &str, payload: &str) -> Result<(), CertError> {
135    verify_did_cert(pubkey, sig_b64, payload)
136}
137
138fn verify_did_cert(pubkey: &[u8], cert_b64: &str, payload_did: &str) -> Result<(), CertError> {
139    if pubkey.len() != 32 {
140        return Err(CertError::BadKey);
141    }
142    let mut pk_arr = [0u8; 32];
143    pk_arr.copy_from_slice(pubkey);
144    let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|_| CertError::BadKey)?;
145
146    let sig_bytes = b64decode(cert_b64).map_err(|_| CertError::BadEncoding)?;
147    if sig_bytes.len() != 64 {
148        return Err(CertError::BadLength);
149    }
150    let mut sig_arr = [0u8; 64];
151    sig_arr.copy_from_slice(&sig_bytes);
152    let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
153
154    vk.verify(payload_did.as_bytes(), &sig)
155        .map_err(|_| CertError::Rejected)
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::agent_card::{did_for_op, did_for_org, did_for_with_key};
162    use crate::signing::generate_keypair;
163
164    #[test]
165    fn sign_verify_op_cert_roundtrip() {
166        let (op_sk, op_pk) = generate_keypair();
167        let (_, session_pk) = generate_keypair();
168        let session_did = did_for_with_key("vesper-valley", &session_pk);
169        let cert = sign_did_cert(&op_sk, &session_did).unwrap();
170        verify_op_cert(&op_pk, &cert, &session_did).unwrap();
171    }
172
173    #[test]
174    fn sign_verify_member_cert_roundtrip() {
175        let (org_sk, org_pk) = generate_keypair();
176        let (_, op_pk) = generate_keypair();
177        let op_did = did_for_op("darby", &op_pk);
178        let cert = sign_did_cert(&org_sk, &op_did).unwrap();
179        verify_member_cert(&org_pk, &cert, &op_did).unwrap();
180    }
181
182    #[test]
183    fn verify_op_cert_rejects_wrong_session_did() {
184        // Cert binds session_a; presenting it for session_b must fail —
185        // protects against an attacker re-using a leaked op_cert on a
186        // session under their own keypair.
187        let (op_sk, op_pk) = generate_keypair();
188        let (_, sk_a) = generate_keypair();
189        let (_, sk_b) = generate_keypair();
190        let did_a = did_for_with_key("session-a", &sk_a);
191        let did_b = did_for_with_key("session-b", &sk_b);
192        let cert = sign_did_cert(&op_sk, &did_a).unwrap();
193        assert_eq!(
194            verify_op_cert(&op_pk, &cert, &did_b),
195            Err(CertError::Rejected)
196        );
197    }
198
199    #[test]
200    fn verify_member_cert_rejects_wrong_op_did() {
201        // Same shape, one tier up: a cert signed for op_a must not
202        // verify for op_b. Protects against admin-mistake or rolled-back
203        // membership replay.
204        let (org_sk, org_pk) = generate_keypair();
205        let (_, op_a_pk) = generate_keypair();
206        let (_, op_b_pk) = generate_keypair();
207        let op_a = did_for_op("darby", &op_a_pk);
208        let op_b = did_for_op("willard", &op_b_pk);
209        let cert = sign_did_cert(&org_sk, &op_a).unwrap();
210        assert_eq!(
211            verify_member_cert(&org_pk, &cert, &op_b),
212            Err(CertError::Rejected)
213        );
214    }
215
216    #[test]
217    fn verify_op_cert_rejects_wrong_op_key() {
218        // Cert was signed by op_alice; verifying against op_bob's
219        // public key must fail.
220        let (alice_sk, _) = generate_keypair();
221        let (_, bob_pk) = generate_keypair();
222        let (_, session_pk) = generate_keypair();
223        let session_did = did_for_with_key("s", &session_pk);
224        let cert = sign_did_cert(&alice_sk, &session_did).unwrap();
225        assert_eq!(
226            verify_op_cert(&bob_pk, &cert, &session_did),
227            Err(CertError::Rejected)
228        );
229    }
230
231    #[test]
232    fn verify_op_cert_rejects_bad_base64() {
233        let (_, pk) = generate_keypair();
234        assert_eq!(
235            verify_op_cert(&pk, "not-base64!", "did:wire:s"),
236            Err(CertError::BadEncoding)
237        );
238    }
239
240    #[test]
241    fn verify_op_cert_rejects_short_cert() {
242        let (_, pk) = generate_keypair();
243        let short = b64encode(&[0u8; 32]);
244        assert_eq!(
245            verify_op_cert(&pk, &short, "did:wire:s"),
246            Err(CertError::BadLength)
247        );
248    }
249
250    #[test]
251    fn verify_op_cert_rejects_short_pubkey() {
252        let (sk, _) = generate_keypair();
253        let cert = sign_did_cert(&sk, "did:wire:s").unwrap();
254        let short_pk = vec![0u8; 16];
255        assert_eq!(
256            verify_op_cert(&short_pk, &cert, "did:wire:s"),
257            Err(CertError::BadKey)
258        );
259    }
260
261    #[test]
262    fn sign_did_cert_rejects_short_signing_key() {
263        let short_sk = vec![0u8; 16];
264        assert_eq!(
265            sign_did_cert(&short_sk, "did:wire:s"),
266            Err(CertError::BadKey)
267        );
268    }
269
270    #[test]
271    fn op_and_org_cert_signing_are_indistinguishable_at_byte_level() {
272        // Same primitive (ed25519 over UTF-8 DID bytes) — the op/org
273        // distinction is purely semantic, encoded in which DID is being
274        // signed and which field on the card the cert lands in. Documents
275        // the invariant so future cert kinds can reuse `sign_did_cert`
276        // without inventing a new primitive.
277        let (op_sk, _op_pk) = generate_keypair();
278        let (_, session_pk) = generate_keypair();
279        let session_did = did_for_with_key("s", &session_pk);
280
281        let (org_sk, _org_pk) = generate_keypair();
282        let (_, op_pk) = generate_keypair();
283        let op_did = did_for_op("darby", &op_pk);
284
285        let op_cert = sign_did_cert(&op_sk, &session_did).unwrap();
286        let member_cert = sign_did_cert(&org_sk, &op_did).unwrap();
287
288        // Both are 64-byte ed25519 sigs, base64 encoded.
289        assert_eq!(b64decode(&op_cert).unwrap().len(), 64);
290        assert_eq!(b64decode(&member_cert).unwrap().len(), 64);
291    }
292
293    #[test]
294    fn succession_cert_roundtrip_and_binding() {
295        // Old op key signs the handoff to a new op_did; verifies under the old
296        // pubkey for exactly that (kind, old_did, new_did) triple.
297        let (old_sk, old_pk) = generate_keypair();
298        let (_, new_pk) = generate_keypair();
299        let old_did = did_for_op("darby", &old_pk);
300        let new_did = did_for_op("darby", &new_pk);
301        let cert = sign_succession_cert(&old_sk, "op", &old_did, &new_did).unwrap();
302        verify_succession_cert(&old_pk, &cert, "op", &old_did, &new_did).unwrap();
303
304        // Wrong new_did → reject (an attacker can't redirect the handoff).
305        let (_, attacker_pk) = generate_keypair();
306        let attacker_did = did_for_op("darby", &attacker_pk);
307        assert_eq!(
308            verify_succession_cert(&old_pk, &cert, "op", &old_did, &attacker_did),
309            Err(CertError::Rejected)
310        );
311        // Wrong kind → reject (op handoff isn't an org handoff).
312        assert_eq!(
313            verify_succession_cert(&old_pk, &cert, "org", &old_did, &new_did),
314            Err(CertError::Rejected)
315        );
316        // Wrong signer → reject.
317        assert_eq!(
318            verify_succession_cert(&new_pk, &cert, "op", &old_did, &new_did),
319            Err(CertError::Rejected)
320        );
321    }
322
323    #[test]
324    fn succession_cert_is_domain_separated_from_op_cert() {
325        // A succession cert (signs the tagged triple) must NOT verify as an
326        // op_cert (signs a bare session DID), and vice versa — different signed
327        // bytes mean the two cert kinds can't be confused/replayed across paths.
328        let (old_sk, old_pk) = generate_keypair();
329        let (_, new_pk) = generate_keypair();
330        let old_did = did_for_op("darby", &old_pk);
331        let new_did = did_for_op("darby", &new_pk);
332
333        let succ = sign_succession_cert(&old_sk, "op", &old_did, &new_did).unwrap();
334        // The succession cert is over `wire-succession-v1|op|old|new`, NOT over
335        // `new_did` alone — so verify_op_cert(new_did) must reject it.
336        assert_eq!(
337            verify_op_cert(&old_pk, &succ, &new_did),
338            Err(CertError::Rejected)
339        );
340
341        // And a real op_cert over new_did is not a valid succession cert.
342        let op_cert = sign_did_cert(&old_sk, &new_did).unwrap();
343        assert_eq!(
344            verify_succession_cert(&old_pk, &op_cert, "op", &old_did, &new_did),
345            Err(CertError::Rejected)
346        );
347    }
348
349    #[test]
350    fn org_did_payload_is_not_confused_with_member_cert_subject() {
351        // Sanity: a cert signed over an org_did UTF-8 string is NOT
352        // accepted as a member_cert binding that org_did — member_cert
353        // binds op_did, not org_did. Catches a likely future-misuse
354        // pattern.
355        let (org_sk, org_pk) = generate_keypair();
356        let (_, org_pk_for_did) = generate_keypair();
357        let org_did = did_for_org("slanchaai", &org_pk_for_did);
358        let (_, op_pk) = generate_keypair();
359        let op_did = did_for_op("darby", &op_pk);
360
361        // Attacker signs the org_did (wrong subject) and presents it as
362        // a member_cert binding op_did.
363        let bogus = sign_did_cert(&org_sk, &org_did).unwrap();
364        assert_eq!(
365            verify_member_cert(&org_pk, &bogus, &op_did),
366            Err(CertError::Rejected)
367        );
368    }
369}