Skip to main content

laser_sdk/
sign.rs

1use crate::error::LaserError;
2use ed25519_dalek::{Signer, SigningKey as DalekSigningKey, Verifier, VerifyingKey};
3use laser_wire::agent::{
4    AgentEnvelope, SIGNATURE_DOMAIN, SIGNATURE_SCHEME_ED25519, Signature, SignatureContext,
5};
6use laser_wire::framing::encode_named;
7pub use laser_wire::keys::KeyKind;
8use sha2::{Digest, Sha256};
9use std::collections::HashMap;
10
11/// The key id length: the first 8 bytes of the public key's SHA-256.
12const KEY_ID_BYTES: usize = 8;
13/// The Ed25519 detached-signature length.
14const SIGNATURE_BYTES: usize = 64;
15/// The key-value namespace [`KvKeyRegistry::new`] uses.
16#[cfg(feature = "kv")]
17pub const DEFAULT_KEY_NAMESPACE: &str = "agent.keys";
18
19/// The canonical bytes a signature covers: the domain separator followed by the
20/// named-field encoding of the envelope with its signature cleared. Sign and
21/// verify build the identical input, so a decode-then-re-encode must be
22/// byte-identical (the round-trip property the wire fixtures pin).
23fn signing_input(
24    envelope: &AgentEnvelope,
25    context: Option<&SignatureContext>,
26) -> Result<Vec<u8>, LaserError> {
27    let mut bare = envelope.clone();
28    bare.signature = None;
29    let body =
30        encode_named(&bare).map_err(|error| LaserError::Codec(format!("sign encode: {error}")))?;
31    // domain || [context] || body. The context (agdx.ct / agdx.av) is prepended
32    // so a signed record's interpretation attributes cannot be flipped by an
33    // intermediary. A `None` context reproduces the pre-context preimage exactly,
34    // so an unsigned-context signature verifies unchanged.
35    let context_bytes = match context {
36        Some(context) => encode_named(context)
37            .map_err(|error| LaserError::Codec(format!("sign context encode: {error}")))?,
38        None => Vec::new(),
39    };
40    let mut input = Vec::with_capacity(SIGNATURE_DOMAIN.len() + context_bytes.len() + body.len());
41    input.extend_from_slice(SIGNATURE_DOMAIN);
42    input.extend_from_slice(&context_bytes);
43    input.extend_from_slice(&body);
44    Ok(input)
45}
46
47/// The key id of a public key: the first [`KEY_ID_BYTES`] of its SHA-256.
48fn key_id_of(verifying: &VerifyingKey) -> Vec<u8> {
49    Sha256::digest(verifying.as_bytes())[..KEY_ID_BYTES].to_vec()
50}
51
52/// An Ed25519 signing key. Sign an envelope to produce the detached
53/// [`Signature`] to attach with [`AgentEnvelope::with_signature`].
54#[derive(Clone)]
55pub struct SigningKey {
56    inner: DalekSigningKey,
57    key_id: Vec<u8>,
58}
59
60impl SigningKey {
61    /// Build from a 32-byte Ed25519 secret seed.
62    pub fn from_bytes(secret: &[u8; 32]) -> Self {
63        let inner = DalekSigningKey::from_bytes(secret);
64        let key_id = key_id_of(&inner.verifying_key());
65        Self { inner, key_id }
66    }
67
68    /// This key's 8-byte id, the [`Signature::key_id`] it stamps.
69    pub fn key_id(&self) -> &[u8] {
70        &self.key_id
71    }
72
73    /// The public verifying key, to enroll in a [`KeyRegistry`].
74    pub fn verifying_key(&self) -> VerifyingKey {
75        self.inner.verifying_key()
76    }
77
78    /// Sign `envelope`, returning the detached signature (no interpretation
79    /// context: the preimage covers the envelope body alone).
80    pub fn sign(&self, envelope: &AgentEnvelope) -> Result<Signature, LaserError> {
81        self.sign_inner(envelope, None)
82    }
83
84    /// Sign `envelope` binding `context` (the `agdx.ct` / `agdx.av` interpretation
85    /// attributes) into the preimage, so an intermediary cannot flip the codec or
86    /// wire version on the signed record. The context is stored on the capsule so
87    /// a verifier reconstructs the same preimage.
88    pub fn sign_with_context(
89        &self,
90        envelope: &AgentEnvelope,
91        context: SignatureContext,
92    ) -> Result<Signature, LaserError> {
93        self.sign_inner(envelope, Some(context))
94    }
95
96    fn sign_inner(
97        &self,
98        envelope: &AgentEnvelope,
99        context: Option<SignatureContext>,
100    ) -> Result<Signature, LaserError> {
101        let input = signing_input(envelope, context.as_ref())?;
102        let signature = self.inner.sign(&input);
103        Ok(Signature {
104            scheme: SIGNATURE_SCHEME_ED25519,
105            key_id: self.key_id.clone(),
106            bytes: signature.to_bytes().to_vec(),
107            context,
108        })
109    }
110}
111
112/// Sign a v1.0 A2A card value as a detached JWS (`sign` feature): EdDSA over
113/// `base64url(protected) || "." || base64url(JCS(card without signatures))`,
114/// the RFC 7515 signing input with the RFC 8785 canonical form as the payload.
115#[cfg(feature = "a2a-bridge")]
116pub fn sign_card_value(
117    key: &SigningKey,
118    card: &serde_json::Value,
119) -> Result<crate::a2a::AgentCardSignature, LaserError> {
120    let payload = jcs_bytes(&without_signatures(card))?;
121    let protected = base64url(br#"{"alg":"EdDSA"}"#);
122    let input = jws_signing_input(&protected, &payload);
123    let signature = key.inner.sign(input.as_bytes());
124    Ok(crate::a2a::AgentCardSignature {
125        protected,
126        signature: base64url(&signature.to_bytes()),
127    })
128}
129
130/// Verify one detached JWS signature over a v1.0 A2A card value against
131/// `verifying`. The payload is reconstructed from the card itself (minus
132/// `signatures`), so a tampered field fails, exactly the discovery-integrity
133/// property the signature exists for.
134#[cfg(feature = "a2a-bridge")]
135pub fn verify_card(
136    card: &serde_json::Value,
137    signature: &crate::a2a::AgentCardSignature,
138    verifying: &VerifyingKey,
139) -> Result<(), LaserError> {
140    let payload = jcs_bytes(&without_signatures(card))?;
141    let input = jws_signing_input(&signature.protected, &payload);
142    let payload = base64url_decode(&signature.signature)?;
143    let payload: [u8; SIGNATURE_BYTES] = payload
144        .try_into()
145        .map_err(|_| LaserError::Signature("card signature is not 64 bytes".to_owned()))?;
146    verifying
147        .verify(
148            input.as_bytes(),
149            &ed25519_dalek::Signature::from_bytes(&payload),
150        )
151        .map_err(|_| LaserError::Signature("card signature does not verify".to_owned()))
152}
153
154/// A managed key registry: versioned, lifecycle-aware verifying-key records in
155/// a reserved namespace (default `agent.keys`), keyed by canonical key id. The
156/// kv sibling of the in-memory [`KeyRegistry`]: enroll keys through the
157/// platform, then [`registry`](Self::registry) snapshots them into the same
158/// [`KeyRegistry`] the verifier folds with. Managed: unsupported without the
159/// key-value surface.
160#[cfg(feature = "kv")]
161#[derive(Clone)]
162pub struct KvKeyRegistry {
163    laser: crate::laser::Laser,
164    namespace: String,
165}
166
167#[cfg(feature = "kv")]
168impl KvKeyRegistry {
169    /// A registry over the default `agent.keys` namespace.
170    pub fn new(laser: crate::laser::Laser) -> Self {
171        Self::in_namespace(laser, DEFAULT_KEY_NAMESPACE)
172    }
173
174    /// A registry over `namespace`.
175    pub fn in_namespace(laser: crate::laser::Laser, namespace: impl Into<String>) -> Self {
176        Self {
177            laser,
178            namespace: namespace.into(),
179        }
180    }
181
182    /// Enroll `verifying` as `principal`'s agent key.
183    pub async fn enroll(
184        &self,
185        principal: impl Into<String>,
186        verifying: &VerifyingKey,
187    ) -> Result<(), LaserError> {
188        self.enroll_record(&KeyRecord::agent(principal, *verifying))
189            .await
190            .map(|_| ())
191    }
192
193    /// Enroll or replace a lifecycle-aware key record with compare-and-swap protection.
194    pub async fn enroll_record(&self, record: &KeyRecord) -> Result<u64, LaserError> {
195        let stored = stored_key_record(record);
196        stored
197            .validate()
198            .map_err(|error| LaserError::Invalid(error.to_owned()))?;
199        let payload = encode_named(&stored)?;
200        let kv = self.laser.kv(&self.namespace);
201        let key = key_id_hex(&stored.key_id);
202        let current = kv.get_entry(&key).await?;
203        let write = kv.set(&key).bytes(payload);
204        match current {
205            Some(entry) => write.expect_version(entry.version).commit().await,
206            None => write.expect_absent().commit().await,
207        }
208    }
209
210    /// Revoke an enrolled key with compare-and-swap protection.
211    pub async fn revoke(&self, key_id: impl AsRef<[u8]>) -> Result<u64, LaserError> {
212        let key_id = key_id.as_ref();
213        if key_id.len() != KEY_ID_BYTES {
214            return Err(LaserError::Invalid(
215                "Ed25519 key id must be 8 bytes".to_owned(),
216            ));
217        }
218        let kv = self.laser.kv(&self.namespace);
219        let key = key_id_hex(key_id);
220        let entry = kv
221            .get_entry(&key)
222            .await?
223            .ok_or_else(|| LaserError::Invalid("signing key is not enrolled".to_owned()))?;
224        let mut stored: laser_wire::keys::KeyRecord =
225            laser_wire::framing::decode_named(&entry.value)?;
226        stored
227            .validate()
228            .map_err(|error| LaserError::Invalid(error.to_owned()))?;
229        if stored.key_id != key_id {
230            return Err(LaserError::Invalid(
231                "key record identifier differs from its storage key".to_owned(),
232            ));
233        }
234        if stored.revoked {
235            return Ok(entry.version);
236        }
237        stored.revoked = true;
238        kv.set(&key)
239            .bytes(encode_named(&stored)?)
240            .expect_version(entry.version)
241            .commit()
242            .await
243    }
244
245    /// Snapshot every enrolled key into a [`KeyRegistry`]. An entry whose
246    /// value is not a valid 32-byte Ed25519 key is skipped (a corrupt
247    /// enrollment must not poison every other principal's verification).
248    pub async fn registry(&self) -> Result<KeyRegistry, LaserError> {
249        let entries = self.laser.kv(&self.namespace).scan().entries().await?;
250        let mut registry = KeyRegistry::default();
251        for entry in entries {
252            if let Ok(stored) =
253                laser_wire::framing::decode_named::<laser_wire::keys::KeyRecord>(&entry.value)
254                && stored.validate().is_ok()
255                && entry.key == key_id_hex(&stored.key_id).into_bytes()
256                && let Ok(payload) = <[u8; 32]>::try_from(stored.verifying_key.as_slice())
257                && let Ok(verifying) = VerifyingKey::from_bytes(&payload)
258                && stored.key_id == key_id_of(&verifying)
259            {
260                registry.enroll_record(KeyRecord {
261                    principal: stored.principal,
262                    verifying,
263                    kind: stored.kind,
264                    valid_from_micros: stored.valid_from_micros,
265                    valid_to_micros: stored.valid_to_micros,
266                    revoked: stored.revoked,
267                });
268            }
269        }
270        Ok(registry)
271    }
272}
273
274#[cfg(feature = "kv")]
275fn stored_key_record(record: &KeyRecord) -> laser_wire::keys::KeyRecord {
276    laser_wire::keys::KeyRecord {
277        v: laser_wire::keys::KEY_RECORD_VERSION,
278        principal: record.principal.clone(),
279        key_id: record.key_id(),
280        verifying_key: record.verifying.as_bytes().to_vec(),
281        kind: record.kind,
282        valid_from_micros: record.valid_from_micros,
283        valid_to_micros: record.valid_to_micros,
284        revoked: record.revoked,
285    }
286}
287
288#[cfg(feature = "kv")]
289fn key_id_hex(key_id: &[u8]) -> String {
290    key_id
291        .iter()
292        .copied()
293        .map(|byte| format!("{byte:02x}"))
294        .collect::<Vec<_>>()
295        .join("")
296}
297
298/// RFC 8785 (JCS) canonical bytes of `value` for the card's value domain:
299/// serde_json's plain `Value` already holds objects sorted (BTreeMap) and
300/// serializes compact, which IS the canonical form for a tree of strings,
301/// booleans, integers, arrays, and objects. A non-integer number is refused
302/// rather than canonicalized wrong: the card schema carries none, so hitting
303/// one means the input is not a card.
304#[cfg(feature = "a2a-bridge")]
305fn jcs_bytes(value: &serde_json::Value) -> Result<Vec<u8>, LaserError> {
306    fn assert_no_floats(value: &serde_json::Value) -> Result<(), LaserError> {
307        match value {
308            serde_json::Value::Number(number) if !number.is_i64() && !number.is_u64() => {
309                Err(LaserError::Signature(
310                    "card canonicalization does not cover non-integer numbers".to_owned(),
311                ))
312            }
313            serde_json::Value::Array(items) => items.iter().try_for_each(assert_no_floats),
314            serde_json::Value::Object(fields) => fields.values().try_for_each(assert_no_floats),
315            _ => Ok(()),
316        }
317    }
318    assert_no_floats(value)?;
319    serde_json::to_vec(value).map_err(|error| LaserError::Codec(format!("canonicalize: {error}")))
320}
321
322/// The card value with its `signatures` slot removed: the JWS payload covers
323/// everything else, so a signature never signs itself.
324#[cfg(feature = "a2a-bridge")]
325fn without_signatures(card: &serde_json::Value) -> serde_json::Value {
326    let mut bare = card.clone();
327    if let Some(fields) = bare.as_object_mut() {
328        fields.remove("signatures");
329    }
330    bare
331}
332
333/// The RFC 7515 signing input: `BASE64URL(protected) || '.' || BASE64URL(payload)`.
334#[cfg(feature = "a2a-bridge")]
335fn jws_signing_input(protected_b64: &str, payload: &[u8]) -> String {
336    format!("{protected_b64}.{}", base64url(payload))
337}
338
339/// Unpadded base64url (RFC 4648 section 5), dependency-free.
340#[cfg(feature = "a2a-bridge")]
341fn base64url(payload: &[u8]) -> String {
342    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
343    let mut out = String::with_capacity(payload.len().div_ceil(3) * 4);
344    for chunk in payload.chunks(3) {
345        let buffer = [
346            chunk[0],
347            chunk.get(1).copied().unwrap_or(0),
348            chunk.get(2).copied().unwrap_or(0),
349        ];
350        let index = u32::from_be_bytes([0, buffer[0], buffer[1], buffer[2]]);
351        out.push(ALPHABET[(index >> 18) as usize & 63] as char);
352        out.push(ALPHABET[(index >> 12) as usize & 63] as char);
353        if chunk.len() > 1 {
354            out.push(ALPHABET[(index >> 6) as usize & 63] as char);
355        }
356        if chunk.len() > 2 {
357            out.push(ALPHABET[index as usize & 63] as char);
358        }
359    }
360    out
361}
362
363/// Inverse of [`base64url`], rejecting non-alphabet input.
364#[cfg(feature = "a2a-bridge")]
365fn base64url_decode(text: &str) -> Result<Vec<u8>, LaserError> {
366    fn value_of(byte: u8) -> Result<u32, LaserError> {
367        match byte {
368            b'A'..=b'Z' => Ok(u32::from(byte - b'A')),
369            b'a'..=b'z' => Ok(u32::from(byte - b'a') + 26),
370            b'0'..=b'9' => Ok(u32::from(byte - b'0') + 52),
371            b'-' => Ok(62),
372            b'_' => Ok(63),
373            _ => Err(LaserError::Signature("invalid base64url".to_owned())),
374        }
375    }
376    let payload = text.as_bytes();
377    let mut out = Vec::with_capacity(payload.len() * 3 / 4);
378    for chunk in payload.chunks(4) {
379        if chunk.len() == 1 {
380            return Err(LaserError::Signature("truncated base64url".to_owned()));
381        }
382        let mut index = 0u32;
383        for &byte in chunk {
384            index = (index << 6) | value_of(byte)?;
385        }
386        index <<= 6 * (4 - chunk.len()) as u32;
387        out.push((index >> 16) as u8);
388        if chunk.len() > 2 {
389            out.push((index >> 8) as u8);
390        }
391        if chunk.len() > 3 {
392            out.push(index as u8);
393        }
394    }
395    Ok(out)
396}
397
398/// Whether an enrolled key may sign privileged control facts (`Operator`) or only
399/// its own agent messages (`Agent`). A quarantine/unquarantine fold requires an
400/// `Operator` signer, so an enrolled agent cannot quarantine another agent.
401/// A verified signer: the enrolled principal and the kind of key it holds.
402#[derive(Debug, Clone, PartialEq, Eq)]
403pub struct VerifiedPrincipal {
404    /// The authenticated principal the key was enrolled under.
405    pub principal: String,
406    /// Whether the key is an operator or agent key.
407    pub kind: KeyKind,
408}
409
410/// An enrolled key with its lifecycle. `valid_from`/`valid_to` bound when the key
411/// may sign (epoch micros, `valid_to` `None` is open-ended), and `revoked` hard
412/// disables it regardless of window. A rotation enrolls the new key while the old
413/// one's `valid_to` still covers in-flight records (the overlap window).
414#[derive(Debug, Clone)]
415pub struct KeyRecord {
416    /// The authenticated principal this key is bound to.
417    pub principal: String,
418    /// The public key.
419    pub verifying: VerifyingKey,
420    /// Whether it is an operator or agent key.
421    pub kind: KeyKind,
422    /// Not valid before this epoch-micros time.
423    pub valid_from_micros: u64,
424    /// Not valid at or after this epoch-micros time (`None` is open-ended).
425    pub valid_to_micros: Option<u64>,
426    /// Hard-disabled: never verifies, whatever the window.
427    pub revoked: bool,
428}
429
430impl KeyRecord {
431    /// The 8-byte identifier derived from this record's public key.
432    pub fn key_id(&self) -> Vec<u8> {
433        key_id_of(&self.verifying)
434    }
435
436    /// An always-valid agent key.
437    pub fn agent(principal: impl Into<String>, verifying: VerifyingKey) -> Self {
438        Self::new(principal, verifying, KeyKind::Agent)
439    }
440
441    /// An always-valid operator key (may sign privileged control facts).
442    pub fn operator(principal: impl Into<String>, verifying: VerifyingKey) -> Self {
443        Self::new(principal, verifying, KeyKind::Operator)
444    }
445
446    /// Build a record from a 32-byte Ed25519 public verifying key.
447    pub fn from_verifying_bytes(
448        principal: impl Into<String>,
449        verifying: &[u8],
450        kind: KeyKind,
451    ) -> Result<Self, LaserError> {
452        let bytes: [u8; 32] = verifying.try_into().map_err(|_| {
453            LaserError::Invalid("an Ed25519 public key requires exactly 32 bytes".to_owned())
454        })?;
455        let verifying = VerifyingKey::from_bytes(&bytes)
456            .map_err(|_| LaserError::Invalid("invalid Ed25519 public key".to_owned()))?;
457        Ok(Self::new(principal, verifying, kind))
458    }
459
460    fn new(principal: impl Into<String>, verifying: VerifyingKey, kind: KeyKind) -> Self {
461        Self {
462            principal: principal.into(),
463            verifying,
464            kind,
465            valid_from_micros: 0,
466            valid_to_micros: None,
467            revoked: false,
468        }
469    }
470
471    /// Bound the validity window (epoch micros).
472    #[must_use]
473    pub fn valid_window(mut self, from_micros: u64, to_micros: Option<u64>) -> Self {
474        self.valid_from_micros = from_micros;
475        self.valid_to_micros = to_micros;
476        self
477    }
478
479    /// Mark the key revoked.
480    #[must_use]
481    pub fn revoked(mut self) -> Self {
482        self.revoked = true;
483        self
484    }
485}
486
487/// A registry of enrolled verifying keys, each bound to the authenticated
488/// principal that enrolled it. Verifying a signed envelope returns the bound
489/// principal, so a caller asserts it equals the server-stamped identity (the
490/// authorship binding: a key proves its enrolled principal, never the
491/// self-asserted `source`). A key carries a kind (agent vs operator) and a
492/// lifecycle window, so a privileged-fact verifier can require an operator key
493/// valid at fold time.
494#[derive(Clone, Default)]
495pub struct KeyRegistry {
496    keys: HashMap<Vec<u8>, KeyRecord>,
497}
498
499impl KeyRegistry {
500    /// An empty registry.
501    pub fn new() -> Self {
502        Self::default()
503    }
504
505    /// Enroll `verifying` as an always-valid agent key bound to `principal` (the
506    /// authenticated `user_id`). A later enroll under the same key id replaces it.
507    pub fn enroll(&mut self, principal: impl Into<String>, verifying: VerifyingKey) {
508        self.enroll_record(KeyRecord::agent(principal, verifying));
509    }
510
511    /// Enroll `verifying` as an always-valid operator key (may sign privileged
512    /// control facts).
513    pub fn enroll_operator(&mut self, principal: impl Into<String>, verifying: VerifyingKey) {
514        self.enroll_record(KeyRecord::operator(principal, verifying));
515    }
516
517    /// Enroll a key with an explicit kind and lifecycle.
518    pub fn enroll_record(&mut self, record: KeyRecord) {
519        self.keys.insert(key_id_of(&record.verifying), record);
520    }
521
522    /// Verify the envelope's signature, returning the enrolled principal on
523    /// success. Errors on an unsigned envelope, an unknown or revoked key, a
524    /// non-Ed25519 scheme, a malformed signature, or a failed check. Ignores the
525    /// validity window (no clock): a time-bound check is [`verify_at`](Self::verify_at).
526    pub fn verify(&self, envelope: &AgentEnvelope) -> Result<String, LaserError> {
527        self.check(envelope, None)
528            .map(|verified| verified.principal)
529    }
530
531    /// Verify as of `at_micros`, returning the signer's principal and key kind.
532    /// Errors additionally when the key is outside its validity window at that
533    /// time. The privileged-fact path uses this to require an operator key valid
534    /// at fold time.
535    pub fn verify_at(
536        &self,
537        envelope: &AgentEnvelope,
538        at_micros: u64,
539    ) -> Result<VerifiedPrincipal, LaserError> {
540        self.check(envelope, Some(at_micros))
541    }
542
543    /// Verify a log-resident envelope against the interpretation headers that
544    /// were actually observed on the record, using the record timestamp for the
545    /// key lifecycle check. A signed context that differs from the observed
546    /// headers is rejected before cryptographic verification.
547    pub fn verify_observed_at(
548        &self,
549        envelope: &AgentEnvelope,
550        observed: &SignatureContext,
551        at_micros: u64,
552    ) -> Result<VerifiedPrincipal, LaserError> {
553        let signed = envelope
554            .signature
555            .as_ref()
556            .and_then(|signature| signature.context.as_ref())
557            .ok_or_else(|| {
558                LaserError::Signature("signature does not bind record headers".to_owned())
559            })?;
560        if signed != observed {
561            return Err(LaserError::Signature(
562                "signature context differs from record headers".to_owned(),
563            ));
564        }
565        self.check(envelope, Some(at_micros))
566    }
567
568    fn check(
569        &self,
570        envelope: &AgentEnvelope,
571        at_micros: Option<u64>,
572    ) -> Result<VerifiedPrincipal, LaserError> {
573        let signature = envelope
574            .signature
575            .as_ref()
576            .ok_or_else(|| LaserError::Signature("envelope is not signed".to_owned()))?;
577        if signature.scheme != SIGNATURE_SCHEME_ED25519 {
578            return Err(LaserError::Signature(format!(
579                "unsupported signature scheme {}",
580                signature.scheme
581            )));
582        }
583        let record = self
584            .keys
585            .get(signature.key_id.as_slice())
586            .ok_or_else(|| LaserError::Signature("signing key is not enrolled".to_owned()))?;
587        if record.revoked {
588            return Err(LaserError::Signature("signing key is revoked".to_owned()));
589        }
590        if let Some(now) = at_micros {
591            if now < record.valid_from_micros {
592                return Err(LaserError::Signature(
593                    "signing key is not yet valid".to_owned(),
594                ));
595            }
596            if record.valid_to_micros.is_some_and(|to| now >= to) {
597                return Err(LaserError::Signature("signing key has expired".to_owned()));
598            }
599        }
600        let payload: [u8; SIGNATURE_BYTES] = signature
601            .bytes
602            .as_slice()
603            .try_into()
604            .map_err(|_| LaserError::Signature("malformed Ed25519 signature".to_owned()))?;
605        let signature_bytes = ed25519_dalek::Signature::from_bytes(&payload);
606        let input = signing_input(envelope, signature.context.as_ref())?;
607        record
608            .verifying
609            .verify(&input, &signature_bytes)
610            .map_err(|_| LaserError::Signature("signature verification failed".to_owned()))?;
611        Ok(VerifiedPrincipal {
612            principal: record.principal.clone(),
613            kind: record.kind,
614        })
615    }
616}
617
618/// Verify an on-behalf-of envelope and return `(signer, delegated_user)`. The
619/// signature must verify against an enrolled key (so the signer cannot forge the
620/// claim), and the delegated user is read from the signed metadata span. `None`
621/// when the envelope carries no delegation. The caller then authorizes with
622/// [`laser_wire::authz::delegated_allow`], intersecting the two principals'
623/// grants so the agent can never exceed the user it acts for.
624pub fn verify_delegation(
625    registry: &KeyRegistry,
626    envelope: &AgentEnvelope,
627) -> Result<Option<(String, String)>, LaserError> {
628    let signer = registry.verify(envelope)?;
629    let delegated = envelope
630        .metadata
631        .as_ref()
632        .and_then(|meta| meta.get(laser_wire::agent::METADATA_DELEGATED_BY))
633        .and_then(|value| match value {
634            laser_wire::query::Value::Str(user) => Some(user.clone()),
635            _ => None,
636        });
637    Ok(delegated.map(|user| (signer, user)))
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use laser_wire::agent::{AgentEnvelope, ConversationId, CorrelationId, RecordId};
644
645    fn envelope() -> AgentEnvelope {
646        AgentEnvelope::command(
647            RecordId::from_u128(1),
648            ConversationId::from_u128(2),
649            "planner".parse().expect("valid agent id"),
650            CorrelationId::from_u128(3),
651            b"{}".to_vec(),
652        )
653    }
654
655    #[test]
656    fn given_a_signed_envelope_when_verified_then_should_return_the_enrolled_principal() {
657        let key = SigningKey::from_bytes(&[7u8; 32]);
658        let mut registry = KeyRegistry::new();
659        registry.enroll("user-42", key.verifying_key());
660
661        let signed = envelope().with_signature(key.sign(&envelope()).expect("signs"));
662        assert_eq!(registry.verify(&signed).expect("verifies"), "user-42");
663    }
664
665    #[test]
666    fn given_a_tampered_body_when_verified_then_should_fail() {
667        let key = SigningKey::from_bytes(&[7u8; 32]);
668        let mut registry = KeyRegistry::new();
669        registry.enroll("user-42", key.verifying_key());
670
671        let signature = key.sign(&envelope()).expect("signs");
672        let mut tampered = envelope();
673        tampered.body = b"{\"evil\":true}".to_vec();
674        let tampered = tampered.with_signature(signature);
675        assert!(matches!(
676            registry.verify(&tampered),
677            Err(LaserError::Signature(_))
678        ));
679    }
680
681    #[test]
682    fn given_an_unenrolled_key_when_verified_then_should_fail() {
683        let key = SigningKey::from_bytes(&[7u8; 32]);
684        let signed = envelope().with_signature(key.sign(&envelope()).expect("signs"));
685        assert!(matches!(
686            KeyRegistry::new().verify(&signed),
687            Err(LaserError::Signature(_))
688        ));
689    }
690
691    #[test]
692    fn given_an_unsigned_envelope_when_verified_then_should_fail() {
693        assert!(matches!(
694            KeyRegistry::new().verify(&envelope()),
695            Err(LaserError::Signature(_))
696        ));
697    }
698
699    #[test]
700    fn given_a_signed_delegation_when_verified_then_should_return_the_signer_and_user() {
701        let key = SigningKey::from_bytes(&[9u8; 32]);
702        let mut registry = KeyRegistry::new();
703        registry.enroll("agent-a", key.verifying_key());
704
705        let env = envelope().with_metadata(laser_wire::agent::METADATA_DELEGATED_BY, "user-7");
706        let signed = env.clone().with_signature(key.sign(&env).expect("signs"));
707        assert_eq!(
708            verify_delegation(&registry, &signed).expect("verifies"),
709            Some(("agent-a".to_owned(), "user-7".to_owned()))
710        );
711
712        // No delegation claim: verifies, reports None.
713        let plain = envelope().with_signature(key.sign(&envelope()).expect("signs"));
714        assert_eq!(
715            verify_delegation(&registry, &plain).expect("verifies"),
716            None
717        );
718
719        // A forged claim (unsigned mutation) fails signature verification.
720        let mut forged = envelope().with_signature(key.sign(&envelope()).expect("signs"));
721        forged = forged.with_metadata(laser_wire::agent::METADATA_DELEGATED_BY, "user-999");
722        assert!(matches!(
723            verify_delegation(&registry, &forged),
724            Err(LaserError::Signature(_))
725        ));
726    }
727    #[cfg(feature = "a2a-bridge")]
728    #[test]
729    fn given_a_signed_card_when_verified_then_should_pass_and_fail_on_tamper() {
730        let key = SigningKey::from_bytes(&[7u8; 32]);
731        let card = serde_json::json!({
732            "name": "a2a-bridge",
733            "version": "0.0.1",
734            "supportedInterfaces": [
735                {"url": "/", "protocolBinding": "JSONRPC", "protocolVersion": "1.0"}
736            ],
737            "skills": []
738        });
739        let signature = sign_card_value(&key, &card).expect("signs");
740        verify_card(&card, &signature, &key.verifying_key()).expect("verifies");
741
742        // Any field change breaks the detached payload.
743        let mut tampered = card.clone();
744        tampered["name"] = serde_json::json!("impostor");
745        assert!(verify_card(&tampered, &signature, &key.verifying_key()).is_err());
746
747        // A signature already on the card is excluded from its own payload, so
748        // attaching it does not invalidate verification.
749        let mut signed = card.clone();
750        signed["signatures"] = serde_json::json!([{
751            "protected": signature.protected,
752            "signature": signature.signature,
753        }]);
754        verify_card(&signed, &signature, &key.verifying_key()).expect("self-carrying verifies");
755    }
756
757    #[test]
758    fn given_a_context_signature_when_the_interpretation_flips_then_should_fail() {
759        let key = SigningKey::from_bytes(&[21u8; 32]);
760        let mut registry = KeyRegistry::new();
761        registry.enroll("user-1", key.verifying_key());
762        let context = SignatureContext {
763            content_type: Some(1),
764            agent_version: Some(1),
765        };
766        let signed =
767            envelope().with_signature(key.sign_with_context(&envelope(), context).expect("signs"));
768        assert_eq!(registry.verify(&signed).expect("verifies"), "user-1");
769        // Flip the bound content-type: the preimage no longer matches, so a hop
770        // that reinterprets the codec on the signed record is rejected.
771        let mut flipped = signed.clone();
772        if let Some(signature) = flipped.signature.as_mut() {
773            signature.context = Some(SignatureContext {
774                content_type: Some(3),
775                agent_version: Some(1),
776            });
777        }
778        assert!(registry.verify(&flipped).is_err());
779    }
780
781    #[test]
782    fn given_an_operator_key_when_verified_at_then_should_report_the_operator_kind() {
783        let key = SigningKey::from_bytes(&[11u8; 32]);
784        let mut registry = KeyRegistry::new();
785        registry.enroll_operator("op-1", key.verifying_key());
786        let signed = envelope().with_signature(key.sign(&envelope()).expect("signs"));
787        let verified = registry.verify_at(&signed, 1_000).expect("verifies");
788        assert_eq!(verified.principal, "op-1");
789        assert_eq!(verified.kind, KeyKind::Operator);
790        // A plain enroll is an agent key, distinguishable at fold time.
791        let agent_key = SigningKey::from_bytes(&[12u8; 32]);
792        registry.enroll("agent-x", agent_key.verifying_key());
793        let agent_signed = envelope().with_signature(agent_key.sign(&envelope()).expect("signs"));
794        assert_eq!(
795            registry
796                .verify_at(&agent_signed, 1_000)
797                .expect("verifies")
798                .kind,
799            KeyKind::Agent
800        );
801    }
802
803    #[test]
804    fn given_a_key_outside_its_window_when_verified_at_then_should_fail() {
805        let key = SigningKey::from_bytes(&[13u8; 32]);
806        let mut registry = KeyRegistry::new();
807        registry.enroll_record(
808            KeyRecord::operator("op-2", key.verifying_key()).valid_window(100, Some(200)),
809        );
810        let signed = envelope().with_signature(key.sign(&envelope()).expect("signs"));
811        assert!(
812            registry.verify_at(&signed, 150).is_ok(),
813            "inside the window"
814        );
815        assert!(
816            registry.verify_at(&signed, 50).is_err(),
817            "before valid_from"
818        );
819        assert!(registry.verify_at(&signed, 200).is_err(), "at valid_to");
820        // `verify` (no clock) still passes: the window only binds `verify_at`.
821        assert!(registry.verify(&signed).is_ok());
822    }
823
824    #[test]
825    fn given_a_revoked_key_when_verified_then_should_fail_regardless_of_time() {
826        let key = SigningKey::from_bytes(&[14u8; 32]);
827        let mut registry = KeyRegistry::new();
828        registry.enroll_record(KeyRecord::operator("op-3", key.verifying_key()).revoked());
829        let signed = envelope().with_signature(key.sign(&envelope()).expect("signs"));
830        assert!(registry.verify(&signed).is_err());
831        assert!(registry.verify_at(&signed, 1_000).is_err());
832    }
833
834    #[test]
835    fn given_base64url_when_round_tripped_then_should_be_lossless() {
836        for input in [&b""[..], b"f", b"fo", b"foo", b"foob", &[0xff, 0x00, 0x7f]] {
837            #[cfg(feature = "a2a-bridge")]
838            assert_eq!(
839                base64url_decode(&base64url(input)).expect("decodes"),
840                input.to_vec()
841            );
842        }
843    }
844}