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
86fn verify_did_cert(pubkey: &[u8], cert_b64: &str, payload_did: &str) -> Result<(), CertError> {
87    if pubkey.len() != 32 {
88        return Err(CertError::BadKey);
89    }
90    let mut pk_arr = [0u8; 32];
91    pk_arr.copy_from_slice(pubkey);
92    let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|_| CertError::BadKey)?;
93
94    let sig_bytes = b64decode(cert_b64).map_err(|_| CertError::BadEncoding)?;
95    if sig_bytes.len() != 64 {
96        return Err(CertError::BadLength);
97    }
98    let mut sig_arr = [0u8; 64];
99    sig_arr.copy_from_slice(&sig_bytes);
100    let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
101
102    vk.verify(payload_did.as_bytes(), &sig)
103        .map_err(|_| CertError::Rejected)
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::agent_card::{did_for_op, did_for_org, did_for_with_key};
110    use crate::signing::generate_keypair;
111
112    #[test]
113    fn sign_verify_op_cert_roundtrip() {
114        let (op_sk, op_pk) = generate_keypair();
115        let (_, session_pk) = generate_keypair();
116        let session_did = did_for_with_key("vesper-valley", &session_pk);
117        let cert = sign_did_cert(&op_sk, &session_did).unwrap();
118        verify_op_cert(&op_pk, &cert, &session_did).unwrap();
119    }
120
121    #[test]
122    fn sign_verify_member_cert_roundtrip() {
123        let (org_sk, org_pk) = generate_keypair();
124        let (_, op_pk) = generate_keypair();
125        let op_did = did_for_op("darby", &op_pk);
126        let cert = sign_did_cert(&org_sk, &op_did).unwrap();
127        verify_member_cert(&org_pk, &cert, &op_did).unwrap();
128    }
129
130    #[test]
131    fn verify_op_cert_rejects_wrong_session_did() {
132        // Cert binds session_a; presenting it for session_b must fail —
133        // protects against an attacker re-using a leaked op_cert on a
134        // session under their own keypair.
135        let (op_sk, op_pk) = generate_keypair();
136        let (_, sk_a) = generate_keypair();
137        let (_, sk_b) = generate_keypair();
138        let did_a = did_for_with_key("session-a", &sk_a);
139        let did_b = did_for_with_key("session-b", &sk_b);
140        let cert = sign_did_cert(&op_sk, &did_a).unwrap();
141        assert_eq!(
142            verify_op_cert(&op_pk, &cert, &did_b),
143            Err(CertError::Rejected)
144        );
145    }
146
147    #[test]
148    fn verify_member_cert_rejects_wrong_op_did() {
149        // Same shape, one tier up: a cert signed for op_a must not
150        // verify for op_b. Protects against admin-mistake or rolled-back
151        // membership replay.
152        let (org_sk, org_pk) = generate_keypair();
153        let (_, op_a_pk) = generate_keypair();
154        let (_, op_b_pk) = generate_keypair();
155        let op_a = did_for_op("darby", &op_a_pk);
156        let op_b = did_for_op("willard", &op_b_pk);
157        let cert = sign_did_cert(&org_sk, &op_a).unwrap();
158        assert_eq!(
159            verify_member_cert(&org_pk, &cert, &op_b),
160            Err(CertError::Rejected)
161        );
162    }
163
164    #[test]
165    fn verify_op_cert_rejects_wrong_op_key() {
166        // Cert was signed by op_alice; verifying against op_bob's
167        // public key must fail.
168        let (alice_sk, _) = generate_keypair();
169        let (_, bob_pk) = generate_keypair();
170        let (_, session_pk) = generate_keypair();
171        let session_did = did_for_with_key("s", &session_pk);
172        let cert = sign_did_cert(&alice_sk, &session_did).unwrap();
173        assert_eq!(
174            verify_op_cert(&bob_pk, &cert, &session_did),
175            Err(CertError::Rejected)
176        );
177    }
178
179    #[test]
180    fn verify_op_cert_rejects_bad_base64() {
181        let (_, pk) = generate_keypair();
182        assert_eq!(
183            verify_op_cert(&pk, "not-base64!", "did:wire:s"),
184            Err(CertError::BadEncoding)
185        );
186    }
187
188    #[test]
189    fn verify_op_cert_rejects_short_cert() {
190        let (_, pk) = generate_keypair();
191        let short = b64encode(&[0u8; 32]);
192        assert_eq!(
193            verify_op_cert(&pk, &short, "did:wire:s"),
194            Err(CertError::BadLength)
195        );
196    }
197
198    #[test]
199    fn verify_op_cert_rejects_short_pubkey() {
200        let (sk, _) = generate_keypair();
201        let cert = sign_did_cert(&sk, "did:wire:s").unwrap();
202        let short_pk = vec![0u8; 16];
203        assert_eq!(
204            verify_op_cert(&short_pk, &cert, "did:wire:s"),
205            Err(CertError::BadKey)
206        );
207    }
208
209    #[test]
210    fn sign_did_cert_rejects_short_signing_key() {
211        let short_sk = vec![0u8; 16];
212        assert_eq!(
213            sign_did_cert(&short_sk, "did:wire:s"),
214            Err(CertError::BadKey)
215        );
216    }
217
218    #[test]
219    fn op_and_org_cert_signing_are_indistinguishable_at_byte_level() {
220        // Same primitive (ed25519 over UTF-8 DID bytes) — the op/org
221        // distinction is purely semantic, encoded in which DID is being
222        // signed and which field on the card the cert lands in. Documents
223        // the invariant so future cert kinds can reuse `sign_did_cert`
224        // without inventing a new primitive.
225        let (op_sk, _op_pk) = generate_keypair();
226        let (_, session_pk) = generate_keypair();
227        let session_did = did_for_with_key("s", &session_pk);
228
229        let (org_sk, _org_pk) = generate_keypair();
230        let (_, op_pk) = generate_keypair();
231        let op_did = did_for_op("darby", &op_pk);
232
233        let op_cert = sign_did_cert(&op_sk, &session_did).unwrap();
234        let member_cert = sign_did_cert(&org_sk, &op_did).unwrap();
235
236        // Both are 64-byte ed25519 sigs, base64 encoded.
237        assert_eq!(b64decode(&op_cert).unwrap().len(), 64);
238        assert_eq!(b64decode(&member_cert).unwrap().len(), 64);
239    }
240
241    #[test]
242    fn org_did_payload_is_not_confused_with_member_cert_subject() {
243        // Sanity: a cert signed over an org_did UTF-8 string is NOT
244        // accepted as a member_cert binding that org_did — member_cert
245        // binds op_did, not org_did. Catches a likely future-misuse
246        // pattern.
247        let (org_sk, org_pk) = generate_keypair();
248        let (_, org_pk_for_did) = generate_keypair();
249        let org_did = did_for_org("slanchaai", &org_pk_for_did);
250        let (_, op_pk) = generate_keypair();
251        let op_did = did_for_op("darby", &op_pk);
252
253        // Attacker signs the org_did (wrong subject) and presents it as
254        // a member_cert binding op_did.
255        let bogus = sign_did_cert(&org_sk, &org_did).unwrap();
256        assert_eq!(
257            verify_member_cert(&org_pk, &bogus, &op_did),
258            Err(CertError::Rejected)
259        );
260    }
261}