Skip to main content

aex_identity/
native.rs

1use std::collections::HashMap;
2use std::sync::{Arc, RwLock};
3
4use async_trait::async_trait;
5use ed25519_dalek::{
6    Signature as DalekSignature, Signer, SigningKey, Verifier, VerifyingKey, PUBLIC_KEY_LENGTH,
7    SECRET_KEY_LENGTH, SIGNATURE_LENGTH,
8};
9use rand::rngs::OsRng;
10use sha2::{Digest, Sha256};
11
12use aex_core::{AgentId, Error, IdentityProvider, Result, Signature, SignatureAlgorithm};
13
14/// In-memory peer public-key registry.
15///
16/// This is the dev-tier stand-in for the control plane's Identity Registry.
17/// When the control plane is wired up, this will be replaced by an async
18/// client that talks to `/v1/agents/{id}` and caches results.
19///
20/// Shared across providers so Alice and Bob can mutually verify each other
21/// in tests and local demos without a server.
22#[derive(Default)]
23pub struct PeerRegistry {
24    peers: RwLock<HashMap<AgentId, VerifyingKey>>,
25}
26
27impl PeerRegistry {
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    pub fn register(&self, agent_id: AgentId, public_key: VerifyingKey) {
33        self.peers.write().unwrap().insert(agent_id, public_key);
34    }
35
36    pub fn lookup(&self, agent_id: &AgentId) -> Option<VerifyingKey> {
37        self.peers.read().unwrap().get(agent_id).copied()
38    }
39
40    pub fn len(&self) -> usize {
41        self.peers.read().unwrap().len()
42    }
43
44    pub fn is_empty(&self) -> bool {
45        self.peers.read().unwrap().is_empty()
46    }
47}
48
49/// Spize native identity provider backed by Ed25519.
50///
51/// The agent_id takes the canonical form `spize:{org}/{name}:{fingerprint}`
52/// where `fingerprint` is the first 6 hex chars of SHA-256 over the public
53/// key. This means the agent_id is DERIVED from the key — you cannot forge
54/// an agent_id without holding the matching private key, which gives strong
55/// binding at the naming layer on top of the signature verification layer.
56pub struct SpizeNativeProvider {
57    agent_id: AgentId,
58    signing_key: SigningKey,
59    peer_registry: Arc<PeerRegistry>,
60}
61
62impl SpizeNativeProvider {
63    /// Generate a fresh keypair with a new random secret.
64    pub fn generate(org: &str, name: &str, peer_registry: Arc<PeerRegistry>) -> Result<Self> {
65        let signing_key = SigningKey::generate(&mut OsRng);
66        Self::from_signing_key(org, name, signing_key, peer_registry)
67    }
68
69    /// Load a provider from an existing raw secret key (e.g., from disk).
70    pub fn from_secret_bytes(
71        org: &str,
72        name: &str,
73        secret: [u8; SECRET_KEY_LENGTH],
74        peer_registry: Arc<PeerRegistry>,
75    ) -> Result<Self> {
76        let signing_key = SigningKey::from_bytes(&secret);
77        Self::from_signing_key(org, name, signing_key, peer_registry)
78    }
79
80    fn from_signing_key(
81        org: &str,
82        name: &str,
83        signing_key: SigningKey,
84        peer_registry: Arc<PeerRegistry>,
85    ) -> Result<Self> {
86        validate_label(org, "org")?;
87        validate_label(name, "name")?;
88        let verifying_key = signing_key.verifying_key();
89        let fingerprint = compute_fingerprint(&verifying_key);
90        let id_str = format!("spize:{}/{}:{}", org, name, fingerprint);
91        let agent_id = AgentId::new(id_str)?;
92        Ok(Self {
93            agent_id,
94            signing_key,
95            peer_registry,
96        })
97    }
98
99    /// The public key bytes. Share these to let peers verify this agent's
100    /// signatures (in a real deployment, via registration at
101    /// `POST /v1/agents/register`).
102    pub fn public_key_bytes(&self) -> [u8; PUBLIC_KEY_LENGTH] {
103        self.signing_key.verifying_key().to_bytes()
104    }
105
106    /// The verifying key struct (for tests and direct registry insertion).
107    pub fn verifying_key(&self) -> VerifyingKey {
108        self.signing_key.verifying_key()
109    }
110
111    /// Raw secret key bytes (32). Used by platforms that own their own
112    /// identity file — the desktop app, for example, must persist this
113    /// to a 0600 file. NEVER transmit these over the wire.
114    pub fn secret_key_bytes(&self) -> [u8; SECRET_KEY_LENGTH] {
115        self.signing_key.to_bytes()
116    }
117}
118
119/// Org and name labels must be ASCII alphanumeric plus `-` / `_`, 1-64 chars.
120/// This is stricter than AgentId's overall parser because we control the
121/// format at creation time.
122fn validate_label(s: &str, field: &str) -> Result<()> {
123    if s.is_empty() {
124        return Err(Error::InvalidAgentId(format!("{} is empty", field)));
125    }
126    if s.len() > 64 {
127        return Err(Error::InvalidAgentId(format!(
128            "{} too long: {}",
129            field,
130            s.len()
131        )));
132    }
133    for (i, c) in s.chars().enumerate() {
134        let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_';
135        if !ok {
136            return Err(Error::InvalidAgentId(format!(
137                "{} char at {}: {:?} (allowed: a-z 0-9 - _)",
138                field, i, c
139            )));
140        }
141    }
142    Ok(())
143}
144
145/// Compute the 6-hex-char fingerprint (first 3 bytes of SHA-256 over the
146/// public key). Collisions at this length are acceptable because the org/name
147/// tuple disambiguates; the fingerprint is a tie-breaker and integrity check
148/// when copying agent_ids manually.
149fn compute_fingerprint(key: &VerifyingKey) -> String {
150    let hash = Sha256::digest(key.as_bytes());
151    hex::encode(&hash[..3])
152}
153
154#[async_trait]
155impl IdentityProvider for SpizeNativeProvider {
156    fn agent_id(&self) -> &AgentId {
157        &self.agent_id
158    }
159
160    async fn sign(&self, message: &[u8]) -> Result<Signature> {
161        let sig = self.signing_key.sign(message);
162        Ok(Signature {
163            algorithm: SignatureAlgorithm::Ed25519,
164            bytes: sig.to_bytes().to_vec(),
165        })
166    }
167
168    async fn verify_peer(
169        &self,
170        peer_id: &AgentId,
171        message: &[u8],
172        signature: &Signature,
173    ) -> Result<()> {
174        if signature.algorithm != SignatureAlgorithm::Ed25519 {
175            return Err(Error::SignatureFormat(format!(
176                "SpizeNative only accepts Ed25519, got {:?}",
177                signature.algorithm
178            )));
179        }
180        if signature.bytes.len() != SIGNATURE_LENGTH {
181            return Err(Error::SignatureFormat(format!(
182                "expected {} bytes, got {}",
183                SIGNATURE_LENGTH,
184                signature.bytes.len()
185            )));
186        }
187
188        let verifying_key = self
189            .peer_registry
190            .lookup(peer_id)
191            .ok_or_else(|| Error::NotFound(format!("peer {} not in registry", peer_id)))?;
192
193        let sig_bytes: [u8; SIGNATURE_LENGTH] = signature
194            .bytes
195            .as_slice()
196            .try_into()
197            .map_err(|_| Error::SignatureFormat("length mismatch".into()))?;
198        let dalek_sig = DalekSignature::from_bytes(&sig_bytes);
199
200        verifying_key
201            .verify(message, &dalek_sig)
202            .map_err(|_| Error::SignatureInvalid)
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    fn setup_pair() -> (Arc<PeerRegistry>, SpizeNativeProvider, SpizeNativeProvider) {
211        let reg = Arc::new(PeerRegistry::new());
212        let alice = SpizeNativeProvider::generate("acme", "alice", reg.clone()).unwrap();
213        let bob = SpizeNativeProvider::generate("acme", "bob", reg.clone()).unwrap();
214        reg.register(alice.agent_id().clone(), alice.verifying_key());
215        reg.register(bob.agent_id().clone(), bob.verifying_key());
216        (reg, alice, bob)
217    }
218
219    #[tokio::test]
220    async fn sign_and_verify_roundtrip() {
221        let (_reg, alice, bob) = setup_pair();
222        let msg = b"hello bob, from alice";
223        let sig = alice.sign(msg).await.unwrap();
224        bob.verify_peer(alice.agent_id(), msg, &sig).await.unwrap();
225    }
226
227    #[tokio::test]
228    async fn tampered_message_rejected() {
229        let (_reg, alice, bob) = setup_pair();
230        let msg = b"hello";
231        let sig = alice.sign(msg).await.unwrap();
232        let err = bob
233            .verify_peer(alice.agent_id(), b"hxllo", &sig)
234            .await
235            .unwrap_err();
236        assert!(matches!(err, Error::SignatureInvalid));
237    }
238
239    #[tokio::test]
240    async fn tampered_signature_rejected() {
241        let (_reg, alice, bob) = setup_pair();
242        let msg = b"hello";
243        let mut sig = alice.sign(msg).await.unwrap();
244        sig.bytes[0] ^= 0xff;
245        let err = bob
246            .verify_peer(alice.agent_id(), msg, &sig)
247            .await
248            .unwrap_err();
249        assert!(matches!(err, Error::SignatureInvalid));
250    }
251
252    #[tokio::test]
253    async fn unknown_peer_rejected() {
254        let reg = Arc::new(PeerRegistry::new());
255        let alice = SpizeNativeProvider::generate("acme", "alice", reg.clone()).unwrap();
256        let bob = SpizeNativeProvider::generate("acme", "bob", reg.clone()).unwrap();
257        // Alice is NOT registered
258        let sig = alice.sign(b"hi").await.unwrap();
259        let err = bob
260            .verify_peer(alice.agent_id(), b"hi", &sig)
261            .await
262            .unwrap_err();
263        assert!(matches!(err, Error::NotFound(_)));
264    }
265
266    #[tokio::test]
267    async fn wrong_algorithm_rejected() {
268        let (_reg, alice, bob) = setup_pair();
269        let wrong = Signature {
270            algorithm: SignatureAlgorithm::EcdsaSecp256k1,
271            bytes: vec![0u8; SIGNATURE_LENGTH],
272        };
273        let err = bob
274            .verify_peer(alice.agent_id(), b"hi", &wrong)
275            .await
276            .unwrap_err();
277        assert!(matches!(err, Error::SignatureFormat(_)));
278    }
279
280    #[tokio::test]
281    async fn wrong_signature_length_rejected() {
282        let (_reg, alice, bob) = setup_pair();
283        let wrong = Signature {
284            algorithm: SignatureAlgorithm::Ed25519,
285            bytes: vec![0u8; 32], // too short
286        };
287        let err = bob
288            .verify_peer(alice.agent_id(), b"hi", &wrong)
289            .await
290            .unwrap_err();
291        assert!(matches!(err, Error::SignatureFormat(_)));
292    }
293
294    #[test]
295    fn generate_produces_expected_agent_id_format() {
296        let reg = Arc::new(PeerRegistry::new());
297        let p = SpizeNativeProvider::generate("acme", "alice", reg).unwrap();
298        let id = p.agent_id().as_str();
299        assert!(id.starts_with("spize:acme/alice:"));
300        let fingerprint = id.rsplit(':').next().unwrap();
301        assert_eq!(fingerprint.len(), 6);
302        assert!(fingerprint.chars().all(|c| c.is_ascii_hexdigit()));
303    }
304
305    #[test]
306    fn deterministic_id_from_same_secret() {
307        let reg = Arc::new(PeerRegistry::new());
308        let secret = [7u8; SECRET_KEY_LENGTH];
309        let p1 =
310            SpizeNativeProvider::from_secret_bytes("acme", "alice", secret, reg.clone()).unwrap();
311        let p2 = SpizeNativeProvider::from_secret_bytes("acme", "alice", secret, reg).unwrap();
312        assert_eq!(p1.agent_id(), p2.agent_id());
313        assert_eq!(p1.public_key_bytes(), p2.public_key_bytes());
314    }
315
316    #[test]
317    fn different_secrets_yield_different_ids() {
318        let reg = Arc::new(PeerRegistry::new());
319        let a = SpizeNativeProvider::from_secret_bytes(
320            "acme",
321            "alice",
322            [1u8; SECRET_KEY_LENGTH],
323            reg.clone(),
324        )
325        .unwrap();
326        let b =
327            SpizeNativeProvider::from_secret_bytes("acme", "alice", [2u8; SECRET_KEY_LENGTH], reg)
328                .unwrap();
329        assert_ne!(a.agent_id(), b.agent_id());
330    }
331
332    #[test]
333    fn empty_org_rejected() {
334        let reg = Arc::new(PeerRegistry::new());
335        assert!(matches!(
336            SpizeNativeProvider::generate("", "alice", reg),
337            Err(Error::InvalidAgentId(_))
338        ));
339    }
340
341    #[test]
342    fn empty_name_rejected() {
343        let reg = Arc::new(PeerRegistry::new());
344        assert!(matches!(
345            SpizeNativeProvider::generate("acme", "", reg),
346            Err(Error::InvalidAgentId(_))
347        ));
348    }
349
350    #[test]
351    fn bad_label_chars_rejected() {
352        let reg = Arc::new(PeerRegistry::new());
353        assert!(matches!(
354            SpizeNativeProvider::generate("acme corp", "alice", reg),
355            Err(Error::InvalidAgentId(_))
356        ));
357    }
358
359    #[tokio::test]
360    async fn cross_verification_between_many_peers() {
361        let reg = Arc::new(PeerRegistry::new());
362        let agents: Vec<SpizeNativeProvider> = (0..10)
363            .map(|i| {
364                let p = SpizeNativeProvider::generate("acme", &format!("agent-{}", i), reg.clone())
365                    .unwrap();
366                reg.register(p.agent_id().clone(), p.verifying_key());
367                p
368            })
369            .collect();
370
371        // Every agent signs the same message; every other agent verifies.
372        let msg = b"broadcast";
373        for signer in &agents {
374            let sig = signer.sign(msg).await.unwrap();
375            for verifier in &agents {
376                verifier
377                    .verify_peer(signer.agent_id(), msg, &sig)
378                    .await
379                    .expect("cross-verification failed");
380            }
381        }
382    }
383}