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
127fn verify_did_cert(pubkey: &[u8], cert_b64: &str, payload_did: &str) -> Result<(), CertError> {
128    if pubkey.len() != 32 {
129        return Err(CertError::BadKey);
130    }
131    let mut pk_arr = [0u8; 32];
132    pk_arr.copy_from_slice(pubkey);
133    let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|_| CertError::BadKey)?;
134
135    let sig_bytes = b64decode(cert_b64).map_err(|_| CertError::BadEncoding)?;
136    if sig_bytes.len() != 64 {
137        return Err(CertError::BadLength);
138    }
139    let mut sig_arr = [0u8; 64];
140    sig_arr.copy_from_slice(&sig_bytes);
141    let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
142
143    vk.verify(payload_did.as_bytes(), &sig)
144        .map_err(|_| CertError::Rejected)
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::agent_card::{did_for_op, did_for_org, did_for_with_key};
151    use crate::signing::generate_keypair;
152
153    #[test]
154    fn sign_verify_op_cert_roundtrip() {
155        let (op_sk, op_pk) = generate_keypair();
156        let (_, session_pk) = generate_keypair();
157        let session_did = did_for_with_key("vesper-valley", &session_pk);
158        let cert = sign_did_cert(&op_sk, &session_did).unwrap();
159        verify_op_cert(&op_pk, &cert, &session_did).unwrap();
160    }
161
162    #[test]
163    fn sign_verify_member_cert_roundtrip() {
164        let (org_sk, org_pk) = generate_keypair();
165        let (_, op_pk) = generate_keypair();
166        let op_did = did_for_op("darby", &op_pk);
167        let cert = sign_did_cert(&org_sk, &op_did).unwrap();
168        verify_member_cert(&org_pk, &cert, &op_did).unwrap();
169    }
170
171    #[test]
172    fn verify_op_cert_rejects_wrong_session_did() {
173        // Cert binds session_a; presenting it for session_b must fail —
174        // protects against an attacker re-using a leaked op_cert on a
175        // session under their own keypair.
176        let (op_sk, op_pk) = generate_keypair();
177        let (_, sk_a) = generate_keypair();
178        let (_, sk_b) = generate_keypair();
179        let did_a = did_for_with_key("session-a", &sk_a);
180        let did_b = did_for_with_key("session-b", &sk_b);
181        let cert = sign_did_cert(&op_sk, &did_a).unwrap();
182        assert_eq!(
183            verify_op_cert(&op_pk, &cert, &did_b),
184            Err(CertError::Rejected)
185        );
186    }
187
188    #[test]
189    fn verify_member_cert_rejects_wrong_op_did() {
190        // Same shape, one tier up: a cert signed for op_a must not
191        // verify for op_b. Protects against admin-mistake or rolled-back
192        // membership replay.
193        let (org_sk, org_pk) = generate_keypair();
194        let (_, op_a_pk) = generate_keypair();
195        let (_, op_b_pk) = generate_keypair();
196        let op_a = did_for_op("darby", &op_a_pk);
197        let op_b = did_for_op("willard", &op_b_pk);
198        let cert = sign_did_cert(&org_sk, &op_a).unwrap();
199        assert_eq!(
200            verify_member_cert(&org_pk, &cert, &op_b),
201            Err(CertError::Rejected)
202        );
203    }
204
205    #[test]
206    fn verify_op_cert_rejects_wrong_op_key() {
207        // Cert was signed by op_alice; verifying against op_bob's
208        // public key must fail.
209        let (alice_sk, _) = generate_keypair();
210        let (_, bob_pk) = generate_keypair();
211        let (_, session_pk) = generate_keypair();
212        let session_did = did_for_with_key("s", &session_pk);
213        let cert = sign_did_cert(&alice_sk, &session_did).unwrap();
214        assert_eq!(
215            verify_op_cert(&bob_pk, &cert, &session_did),
216            Err(CertError::Rejected)
217        );
218    }
219
220    #[test]
221    fn verify_op_cert_rejects_bad_base64() {
222        let (_, pk) = generate_keypair();
223        assert_eq!(
224            verify_op_cert(&pk, "not-base64!", "did:wire:s"),
225            Err(CertError::BadEncoding)
226        );
227    }
228
229    #[test]
230    fn verify_op_cert_rejects_short_cert() {
231        let (_, pk) = generate_keypair();
232        let short = b64encode(&[0u8; 32]);
233        assert_eq!(
234            verify_op_cert(&pk, &short, "did:wire:s"),
235            Err(CertError::BadLength)
236        );
237    }
238
239    #[test]
240    fn verify_op_cert_rejects_short_pubkey() {
241        let (sk, _) = generate_keypair();
242        let cert = sign_did_cert(&sk, "did:wire:s").unwrap();
243        let short_pk = vec![0u8; 16];
244        assert_eq!(
245            verify_op_cert(&short_pk, &cert, "did:wire:s"),
246            Err(CertError::BadKey)
247        );
248    }
249
250    #[test]
251    fn sign_did_cert_rejects_short_signing_key() {
252        let short_sk = vec![0u8; 16];
253        assert_eq!(
254            sign_did_cert(&short_sk, "did:wire:s"),
255            Err(CertError::BadKey)
256        );
257    }
258
259    #[test]
260    fn op_and_org_cert_signing_are_indistinguishable_at_byte_level() {
261        // Same primitive (ed25519 over UTF-8 DID bytes) — the op/org
262        // distinction is purely semantic, encoded in which DID is being
263        // signed and which field on the card the cert lands in. Documents
264        // the invariant so future cert kinds can reuse `sign_did_cert`
265        // without inventing a new primitive.
266        let (op_sk, _op_pk) = generate_keypair();
267        let (_, session_pk) = generate_keypair();
268        let session_did = did_for_with_key("s", &session_pk);
269
270        let (org_sk, _org_pk) = generate_keypair();
271        let (_, op_pk) = generate_keypair();
272        let op_did = did_for_op("darby", &op_pk);
273
274        let op_cert = sign_did_cert(&op_sk, &session_did).unwrap();
275        let member_cert = sign_did_cert(&org_sk, &op_did).unwrap();
276
277        // Both are 64-byte ed25519 sigs, base64 encoded.
278        assert_eq!(b64decode(&op_cert).unwrap().len(), 64);
279        assert_eq!(b64decode(&member_cert).unwrap().len(), 64);
280    }
281
282    #[test]
283    fn succession_cert_roundtrip_and_binding() {
284        // Old op key signs the handoff to a new op_did; verifies under the old
285        // pubkey for exactly that (kind, old_did, new_did) triple.
286        let (old_sk, old_pk) = generate_keypair();
287        let (_, new_pk) = generate_keypair();
288        let old_did = did_for_op("darby", &old_pk);
289        let new_did = did_for_op("darby", &new_pk);
290        let cert = sign_succession_cert(&old_sk, "op", &old_did, &new_did).unwrap();
291        verify_succession_cert(&old_pk, &cert, "op", &old_did, &new_did).unwrap();
292
293        // Wrong new_did → reject (an attacker can't redirect the handoff).
294        let (_, attacker_pk) = generate_keypair();
295        let attacker_did = did_for_op("darby", &attacker_pk);
296        assert_eq!(
297            verify_succession_cert(&old_pk, &cert, "op", &old_did, &attacker_did),
298            Err(CertError::Rejected)
299        );
300        // Wrong kind → reject (op handoff isn't an org handoff).
301        assert_eq!(
302            verify_succession_cert(&old_pk, &cert, "org", &old_did, &new_did),
303            Err(CertError::Rejected)
304        );
305        // Wrong signer → reject.
306        assert_eq!(
307            verify_succession_cert(&new_pk, &cert, "op", &old_did, &new_did),
308            Err(CertError::Rejected)
309        );
310    }
311
312    #[test]
313    fn succession_cert_is_domain_separated_from_op_cert() {
314        // A succession cert (signs the tagged triple) must NOT verify as an
315        // op_cert (signs a bare session DID), and vice versa — different signed
316        // bytes mean the two cert kinds can't be confused/replayed across paths.
317        let (old_sk, old_pk) = generate_keypair();
318        let (_, new_pk) = generate_keypair();
319        let old_did = did_for_op("darby", &old_pk);
320        let new_did = did_for_op("darby", &new_pk);
321
322        let succ = sign_succession_cert(&old_sk, "op", &old_did, &new_did).unwrap();
323        // The succession cert is over `wire-succession-v1|op|old|new`, NOT over
324        // `new_did` alone — so verify_op_cert(new_did) must reject it.
325        assert_eq!(
326            verify_op_cert(&old_pk, &succ, &new_did),
327            Err(CertError::Rejected)
328        );
329
330        // And a real op_cert over new_did is not a valid succession cert.
331        let op_cert = sign_did_cert(&old_sk, &new_did).unwrap();
332        assert_eq!(
333            verify_succession_cert(&old_pk, &op_cert, "op", &old_did, &new_did),
334            Err(CertError::Rejected)
335        );
336    }
337
338    #[test]
339    fn org_did_payload_is_not_confused_with_member_cert_subject() {
340        // Sanity: a cert signed over an org_did UTF-8 string is NOT
341        // accepted as a member_cert binding that org_did — member_cert
342        // binds op_did, not org_did. Catches a likely future-misuse
343        // pattern.
344        let (org_sk, org_pk) = generate_keypair();
345        let (_, org_pk_for_did) = generate_keypair();
346        let org_did = did_for_org("slanchaai", &org_pk_for_did);
347        let (_, op_pk) = generate_keypair();
348        let op_did = did_for_op("darby", &op_pk);
349
350        // Attacker signs the org_did (wrong subject) and presents it as
351        // a member_cert binding op_did.
352        let bogus = sign_did_cert(&org_sk, &org_did).unwrap();
353        assert_eq!(
354            verify_member_cert(&org_pk, &bogus, &op_did),
355            Err(CertError::Rejected)
356        );
357    }
358}