Skip to main content

agent_protocols/
identity.rs

1use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2use base64::Engine as _;
3use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
4use serde::de::Error as DeError;
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6use serde_json::Value;
7use sha3::{Digest, Sha3_256};
8use std::collections::{BTreeMap, HashMap};
9use std::fmt;
10use std::str::FromStr;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use crate::error::{Result, SdkError};
14
15pub const AGENT_ID_PREFIX: &str = "did:agent:";
16pub const DEFAULT_LIVE_WRITE_WINDOW_MS: i64 = 300_000;
17/// Nonce cache validity (Agent Identity Section 6.2): at least twice the
18/// live-write window, because an envelope signed up to one window ahead of the
19/// receiver's clock stays inside the window for two windows after acceptance.
20pub const DEFAULT_NONCE_TTL_MS: i64 = 2 * DEFAULT_LIVE_WRITE_WINDOW_MS;
21pub const DEFAULT_REQUEST_JWT_TTL_SECS: i64 = 300;
22pub const MAX_NONCE_HEADER: &str = "Max-Seen-Nonce";
23pub const MAX_SAFE_NONCE: u64 = 0x1FFFFFFFFFFFFF;
24/// Largest jump a single `Max-Seen-Nonce` header may cause beyond
25/// `max(next_nonce, now)` (Agent Identity Section 6.2). The nonce sequence is
26/// shared by every service an agent uses, so one hostile service must not be
27/// able to exhaust it.
28pub const MAX_NONCE_JUMP: u64 = 1 << 32;
29
30/// The six Agent Identity event fields; protocols add their own on top.
31pub const IDENTITY_EVENT_FIELDS: [&str; 6] = [
32    "protocol",
33    "type",
34    "actor",
35    "created_at",
36    "nonce",
37    "payload",
38];
39
40/// Error codes shared by every Agent Protocols service (Section 8.1).
41pub const SHARED_ERROR_CODES: [&str; 12] = [
42    "invalid_request",
43    "invalid_event",
44    "invalid_event_hash",
45    "invalid_signature",
46    "invalid_actor",
47    "timestamp_out_of_window",
48    "nonce_not_greater",
49    "invalid_token",
50    "permission_denied",
51    "not_found",
52    "rate_limited",
53    "payload_too_large",
54];
55
56#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
57pub struct AgentId(String);
58
59impl AgentId {
60    pub fn from_public_key(public_key: &[u8; 32]) -> Self {
61        Self(format!(
62            "{}{}",
63            AGENT_ID_PREFIX,
64            URL_SAFE_NO_PAD.encode(public_key)
65        ))
66    }
67
68    pub fn as_str(&self) -> &str {
69        &self.0
70    }
71
72    pub fn public_key_bytes(&self) -> Result<[u8; 32]> {
73        let encoded = self
74            .0
75            .strip_prefix(AGENT_ID_PREFIX)
76            .ok_or(SdkError::InvalidAgentIdPrefix)?;
77        // URL_SAFE_NO_PAD rejects padding characters and non-zero trailing
78        // bits, so non-canonical encodings of the same key are rejected.
79        let bytes = URL_SAFE_NO_PAD.decode(encoded)?;
80        bytes
81            .try_into()
82            .map_err(|bytes: Vec<u8>| SdkError::InvalidPublicKeyLength(bytes.len()))
83    }
84
85    pub fn verifying_key(&self) -> Result<VerifyingKey> {
86        Ok(VerifyingKey::from_bytes(&self.public_key_bytes()?)?)
87    }
88}
89
90impl fmt::Debug for AgentId {
91    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
92        formatter.debug_tuple("AgentId").field(&self.0).finish()
93    }
94}
95
96impl fmt::Display for AgentId {
97    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
98        formatter.write_str(&self.0)
99    }
100}
101
102impl FromStr for AgentId {
103    type Err = SdkError;
104
105    fn from_str(value: &str) -> Result<Self> {
106        if !value.starts_with(AGENT_ID_PREFIX) {
107            return Err(SdkError::InvalidAgentIdPrefix);
108        }
109        let agent_id = Self(value.to_owned());
110        agent_id.public_key_bytes()?;
111        Ok(agent_id)
112    }
113}
114
115impl TryFrom<String> for AgentId {
116    type Error = SdkError;
117
118    fn try_from(value: String) -> Result<Self> {
119        Self::from_str(&value)
120    }
121}
122
123impl Serialize for AgentId {
124    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
125    where
126        S: Serializer,
127    {
128        serializer.serialize_str(&self.0)
129    }
130}
131
132impl<'de> Deserialize<'de> for AgentId {
133    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
134    where
135        D: Deserializer<'de>,
136    {
137        let value = String::deserialize(deserializer)?;
138        Self::from_str(&value).map_err(D::Error::custom)
139    }
140}
141
142pub struct AgentSigner {
143    signing_key: SigningKey,
144}
145
146impl AgentSigner {
147    pub fn generate() -> Self {
148        let mut seed = [0_u8; 32];
149        rand::fill(&mut seed);
150        Self::from_seed(seed)
151    }
152
153    pub fn from_seed(seed: [u8; 32]) -> Self {
154        Self {
155            signing_key: SigningKey::from_bytes(&seed),
156        }
157    }
158
159    pub fn agent_id(&self) -> AgentId {
160        AgentId::from_public_key(&self.signing_key.verifying_key().to_bytes())
161    }
162
163    pub fn verifying_key(&self) -> VerifyingKey {
164        self.signing_key.verifying_key()
165    }
166
167    pub fn sign_event<P>(&self, event: Event<P>) -> Result<Envelope<P>>
168    where
169        P: Serialize,
170    {
171        let event_hash = event_hash_bytes(&event)?;
172        let hash = URL_SAFE_NO_PAD.encode(event_hash);
173        let signature = sign_event_hash(&self.signing_key, &event_hash)
174            .expect("event hashes are always 32 bytes");
175        Ok(Envelope {
176            hash,
177            event,
178            signature,
179        })
180    }
181
182    pub fn sign_request_jwt(&self, claims: &RequestJwtClaims) -> Result<String> {
183        let agent_id = self.agent_id();
184        if claims.iss != agent_id || claims.sub != agent_id {
185            return Err(SdkError::InvalidJwtClaim("iss/sub"));
186        }
187
188        let header = RequestJwtHeader {
189            alg: "EdDSA".to_owned(),
190            typ: "JWT".to_owned(),
191            kid: agent_id,
192        };
193        let header =
194            URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("JWT header is JSON-safe"));
195        let payload =
196            URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).expect("JWT claims are JSON-safe"));
197        let signing_input = format!("{header}.{payload}");
198        let signature = self.signing_key.sign(signing_input.as_bytes());
199        Ok(format!(
200            "{signing_input}.{}",
201            URL_SAFE_NO_PAD.encode(signature.to_bytes())
202        ))
203    }
204
205    /// Signs an Agent Delegation controller registration challenge (Section
206    /// 4.3) over its exact UTF-8 bytes. Any other string is refused, so the
207    /// signer never becomes an arbitrary-message signing oracle.
208    pub fn sign_controller_challenge(&self, challenge: &str) -> Result<String> {
209        validate_controller_challenge(challenge)?;
210        let signature = self.signing_key.sign(challenge.as_bytes());
211        Ok(URL_SAFE_NO_PAD.encode(signature.to_bytes()))
212    }
213}
214
215/// Prefix of an Agent Delegation controller registration challenge (Section 4.3).
216pub const CONTROLLER_CHALLENGE_PREFIX: &str = "agent-delegation/1.0:controller-registration:";
217
218/// The prefix followed by 43 opaque base64url characters, 88 in all.
219pub fn is_controller_challenge(value: &str) -> bool {
220    value
221        .strip_prefix(CONTROLLER_CHALLENGE_PREFIX)
222        .is_some_and(|suffix| {
223            suffix.len() == 43
224                && suffix
225                    .bytes()
226                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
227        })
228}
229
230fn validate_controller_challenge(value: &str) -> Result<()> {
231    if is_controller_challenge(value) {
232        Ok(())
233    } else {
234        Err(SdkError::protocol(
235            "invalid_request",
236            "not an agent-delegation/1.0 controller registration challenge",
237        ))
238    }
239}
240
241/// Verifies a controller registration proof (Agent Delegation Section 4.3):
242/// `signature` over the challenge's exact UTF-8 bytes under the strict rules of
243/// Section 3.1. Matching the challenge to what the provider issued, and its
244/// expiry, remain the provider's checks.
245pub fn verify_controller_challenge(
246    agent_id: &AgentId,
247    challenge: &str,
248    signature: &str,
249) -> Result<()> {
250    validate_controller_challenge(challenge)?;
251    let signature: [u8; 64] = URL_SAFE_NO_PAD
252        .decode(signature)?
253        .try_into()
254        .map_err(|bytes: Vec<u8>| SdkError::InvalidSignatureLength(bytes.len()))?;
255    verify_ed25519_strict_bytes(
256        &agent_id.public_key_bytes()?,
257        challenge.as_bytes(),
258        &signature,
259    )
260}
261
262#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
263pub struct Event<P = Value> {
264    pub protocol: String,
265    #[serde(rename = "type")]
266    pub kind: String,
267    pub actor: AgentId,
268    pub created_at: i64,
269    pub nonce: u64,
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub room_id: Option<String>,
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub base_seq: Option<u64>,
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub base_hash: Option<String>,
276    /// `None` when the field is absent. An explicit empty list is kept, so a
277    /// deserialized event re-serializes to the bytes that were signed.
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub mentions: Option<Vec<AgentId>>,
280    pub payload: P,
281    /// Unknown top-level fields, kept so the event hashes as signed. Protocol
282    /// validators reject them (the closed event object, Section 5.1).
283    #[serde(default, flatten)]
284    pub extra: BTreeMap<String, Value>,
285}
286
287impl<P> Event<P> {
288    pub fn new(
289        protocol: impl Into<String>,
290        kind: impl Into<String>,
291        actor: AgentId,
292        created_at: i64,
293        nonce: u64,
294        payload: P,
295    ) -> Self {
296        Self {
297            protocol: protocol.into(),
298            kind: kind.into(),
299            actor,
300            created_at,
301            nonce,
302            room_id: None,
303            base_seq: None,
304            base_hash: None,
305            mentions: None,
306            payload,
307            extra: BTreeMap::new(),
308        }
309    }
310
311    pub fn with_room_id(mut self, room_id: impl Into<String>) -> Self {
312        self.room_id = Some(room_id.into());
313        self
314    }
315
316    pub fn with_room_head(mut self, base_seq: u64, base_hash: impl Into<String>) -> Self {
317        self.base_seq = Some(base_seq);
318        self.base_hash = Some(base_hash.into());
319        self
320    }
321
322    pub fn with_mentions(mut self, mentions: Vec<AgentId>) -> Self {
323        self.mentions = Some(mentions);
324        self
325    }
326
327    pub fn with_mention(mut self, agent_id: AgentId) -> Self {
328        self.mentions.get_or_insert_with(Vec::new).push(agent_id);
329        self
330    }
331
332    /// The event's mentions, empty when the field is absent.
333    pub fn mentions(&self) -> &[AgentId] {
334        self.mentions.as_deref().unwrap_or(&[])
335    }
336}
337
338/// Enforces the closed event object (Section 5.1): the event may carry only
339/// the six Agent Identity fields plus `extra_fields` the protocol defines.
340pub fn validate_event_fields<P>(event: &Event<P>, extra_fields: &[&str]) -> Result<()> {
341    let present = [
342        ("room_id", event.room_id.is_some()),
343        ("base_seq", event.base_seq.is_some()),
344        ("base_hash", event.base_hash.is_some()),
345        ("mentions", event.mentions.is_some()),
346    ];
347    for (field, is_present) in present {
348        if is_present && !extra_fields.contains(&field) {
349            return Err(SdkError::protocol(
350                "invalid_event",
351                format!("unknown event field: {field}"),
352            ));
353        }
354    }
355    if let Some(field) = event
356        .extra
357        .keys()
358        .find(|key| !extra_fields.contains(&key.as_str()))
359    {
360        return Err(SdkError::protocol(
361            "invalid_event",
362            format!("unknown event field: {field}"),
363        ));
364    }
365    Ok(())
366}
367
368/// Error response body shared by every Agent Protocols service (Section 8.1).
369#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
370pub struct ErrorResponse {
371    pub error: ErrorBody,
372}
373
374#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
375pub struct ErrorBody {
376    pub code: String,
377    pub message: String,
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub data: Option<Value>,
380}
381
382/// List response shape (Section 8.2). `next_cursor` is present exactly when
383/// more items follow.
384#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
385pub struct ListResponse<T> {
386    pub result: Vec<T>,
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub next_cursor: Option<String>,
389}
390
391/// An accepted envelope with the service's acceptance time (Section 8.3).
392#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
393pub struct AcceptedRecord<P = Value> {
394    pub envelope: Envelope<P>,
395    pub accepted_at: i64,
396}
397
398/// Discovery document skeleton served at `/.well-known/{protocol-name}`
399/// (Section 8.4). Protocols add members, kept in `extra`.
400#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
401pub struct DiscoveryDocument {
402    pub protocol: String,
403    pub service: String,
404    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
405    pub endpoints: BTreeMap<String, String>,
406    #[serde(default, skip_serializing_if = "Vec::is_empty")]
407    pub features: Vec<String>,
408    #[serde(default, flatten)]
409    pub extra: BTreeMap<String, Value>,
410}
411
412#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
413pub struct Envelope<P = Value> {
414    pub hash: String,
415    pub event: Event<P>,
416    pub signature: String,
417}
418
419pub fn canonical_event_bytes<P>(event: &Event<P>) -> Result<Vec<u8>>
420where
421    P: Serialize,
422{
423    serde_jcs::to_vec(event).map_err(|err| SdkError::CanonicalJson(err.to_string()))
424}
425
426pub fn event_hash<P>(event: &Event<P>) -> Result<String>
427where
428    P: Serialize,
429{
430    Ok(URL_SAFE_NO_PAD.encode(event_hash_bytes(event)?))
431}
432
433pub fn event_hash_bytes<P>(event: &Event<P>) -> Result<[u8; 32]>
434where
435    P: Serialize,
436{
437    validate_nonce(event.nonce)?;
438    let digest = Sha3_256::digest(canonical_event_bytes(event)?);
439    let mut bytes = [0_u8; 32];
440    bytes.copy_from_slice(&digest);
441    Ok(bytes)
442}
443
444pub fn sign_event<P>(signing_key: &SigningKey, event: &Event<P>) -> Result<String>
445where
446    P: Serialize,
447{
448    let event_hash = event_hash_bytes(event)?;
449    Ok(sign_event_hash(signing_key, &event_hash).expect("event hashes are always 32 bytes"))
450}
451
452/// Signs a precomputed 32-byte event hash. Signing a digest supplied by
453/// another component without seeing the event it commits to (blind signing) is
454/// NOT RECOMMENDED: `actor` and all event content are inside the digest, so a
455/// blind signer can be tricked into signing arbitrary events attributed to its
456/// key. Prefer [`sign_event`], which canonicalizes and hashes the event.
457pub fn sign_event_hash(signing_key: &SigningKey, event_hash: &[u8]) -> Result<String> {
458    let signature = signing_key.sign(valid_event_hash_bytes(event_hash)?);
459    Ok(URL_SAFE_NO_PAD.encode(signature.to_bytes()))
460}
461
462pub fn verify_event_hash<P>(envelope: &Envelope<P>) -> Result<()>
463where
464    P: Serialize,
465{
466    let expected = event_hash(&envelope.event)?;
467    if expected == envelope.hash {
468        Ok(())
469    } else {
470        Err(SdkError::InvalidEventHash {
471            expected,
472            actual: envelope.hash.clone(),
473        })
474    }
475}
476
477pub fn verify_signature<P>(envelope: &Envelope<P>) -> Result<()>
478where
479    P: Serialize,
480{
481    let event_hash = event_hash_bytes(&envelope.event)?;
482    let verifying_key = envelope.event.actor.verifying_key()?;
483    verify_event_hash_signature(&verifying_key, &event_hash, &envelope.signature)
484}
485
486pub fn verify_event_hash_signature(
487    verifying_key: &VerifyingKey,
488    event_hash: &[u8],
489    encoded_signature: &str,
490) -> Result<()> {
491    let signature_bytes = URL_SAFE_NO_PAD.decode(encoded_signature)?;
492    let signature_bytes: [u8; 64] = signature_bytes
493        .try_into()
494        .map_err(|bytes: Vec<u8>| SdkError::InvalidSignatureLength(bytes.len()))?;
495    verify_ed25519_strict(
496        verifying_key,
497        valid_event_hash_bytes(event_hash)?,
498        &signature_bytes,
499    )
500}
501
502/// p = 2^255 - 19, little-endian.
503const FIELD_P_LE: [u8; 32] = [
504    0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
505    0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f,
506];
507/// L = 2^252 + 27742317777372353535851937790883648493, little-endian.
508const GROUP_L_LE: [u8; 32] = [
509    0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14,
510    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10,
511];
512/// y-coordinates (sign bit cleared) of every small-order point (Appendix B).
513const SMALL_ORDER_Y: [[u8; 32]; 5] = [
514    [0; 32],
515    [
516        0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
517        0, 0, 0,
518    ],
519    [
520        0xec, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
521        0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
522        0xff, 0x7f,
523    ],
524    [
525        0x26, 0xe8, 0x95, 0x8f, 0xc2, 0xb2, 0x27, 0xb0, 0x45, 0xc3, 0xf4, 0x89, 0xf2, 0xef, 0x98,
526        0xf0, 0xd5, 0xdf, 0xac, 0x05, 0xd3, 0xc6, 0x33, 0x39, 0xb1, 0x38, 0x02, 0x88, 0x6d, 0x53,
527        0xfc, 0x05,
528    ],
529    [
530        0xc7, 0x17, 0x6a, 0x70, 0x3d, 0x4d, 0xd8, 0x4f, 0xba, 0x3c, 0x0b, 0x76, 0x0d, 0x10, 0x67,
531        0x0f, 0x2a, 0x20, 0x53, 0xfa, 0x2c, 0x39, 0xcc, 0xc6, 0x4e, 0xc7, 0xfd, 0x77, 0x92, 0xac,
532        0x03, 0x7a,
533    ],
534];
535
536/// Little-endian `a < b`.
537fn less_than_le(a: &[u8; 32], b: &[u8; 32]) -> bool {
538    for i in (0..32).rev() {
539        if a[i] != b[i] {
540            return a[i] < b[i];
541        }
542    }
543    false
544}
545
546/// Canonical encoding (y < p) of a point that is not of small order.
547fn is_strict_point(encoding: &[u8; 32]) -> bool {
548    let mut y = *encoding;
549    y[31] &= 0x7f;
550    less_than_le(&y, &FIELD_P_LE) && !SMALL_ORDER_Y.contains(&y)
551}
552
553/// Ed25519 verification under the deterministic rules of Agent Identity
554/// Section 3.1: canonical, non-small-order `A` and `R`, reduced `S`, and the
555/// cofactorless equation (`verify_strict`).
556pub fn verify_ed25519_strict(
557    verifying_key: &VerifyingKey,
558    message: &[u8],
559    signature: &[u8; 64],
560) -> Result<()> {
561    let failed = || SdkError::protocol("invalid_signature", "signature verification failed");
562    let r: [u8; 32] = signature[..32].try_into().expect("64-byte signature");
563    let s: [u8; 32] = signature[32..].try_into().expect("64-byte signature");
564    if !is_strict_point(verifying_key.as_bytes()) || !is_strict_point(&r) {
565        return Err(failed());
566    }
567    if !less_than_le(&s, &GROUP_L_LE) {
568        return Err(failed());
569    }
570    verifying_key
571        .verify_strict(message, &Signature::from_bytes(signature))
572        .map_err(|_| failed())
573}
574
575/// [`verify_ed25519_strict`] over a raw 32-byte public key, which may fail to
576/// decode as a point at all.
577pub fn verify_ed25519_strict_bytes(
578    public_key: &[u8; 32],
579    message: &[u8],
580    signature: &[u8; 64],
581) -> Result<()> {
582    let failed = || SdkError::protocol("invalid_signature", "signature verification failed");
583    if !is_strict_point(public_key) {
584        return Err(failed());
585    }
586    let key = VerifyingKey::from_bytes(public_key).map_err(|_| failed())?;
587    verify_ed25519_strict(&key, message, signature)
588}
589
590/// Parses signed JSON strictly (Agent Identity Section 4.1): rejects
591/// duplicate member names, unpaired surrogates, and integers outside the safe
592/// range. `serde_json::Value` silently keeps the last of two duplicate names,
593/// so parse raw request bodies with this before hashing.
594pub fn parse_strict_json(text: &str) -> Result<Value> {
595    let mut deserializer = serde_json::Deserializer::from_str(text);
596    let value = StrictJson::deserialize(&mut deserializer)
597        .map_err(|err| SdkError::protocol("invalid_event", format!("invalid JSON: {err}")))?;
598    deserializer
599        .end()
600        .map_err(|err| SdkError::protocol("invalid_event", format!("invalid JSON: {err}")))?;
601    Ok(value.0)
602}
603
604/// [`parse_strict_json`] for a signed envelope, checking its outer shape.
605pub fn parse_envelope_json<P>(text: &str) -> Result<Envelope<P>>
606where
607    P: serde::de::DeserializeOwned,
608{
609    let value = parse_strict_json(text)?;
610    let object = value.as_object().ok_or_else(|| {
611        SdkError::protocol(
612            "invalid_event",
613            "envelope must contain hash, event, and signature",
614        )
615    })?;
616    if let Some(key) = object
617        .keys()
618        .find(|key| !matches!(key.as_str(), "hash" | "event" | "signature"))
619    {
620        return Err(SdkError::protocol(
621            "invalid_event",
622            format!("unknown envelope field: {key}"),
623        ));
624    }
625    serde_json::from_value(value)
626        .map_err(|err| SdkError::protocol("invalid_event", format!("invalid envelope: {err}")))
627}
628
629struct StrictJson(Value);
630
631impl<'de> Deserialize<'de> for StrictJson {
632    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
633    where
634        D: Deserializer<'de>,
635    {
636        deserializer
637            .deserialize_any(StrictJsonVisitor)
638            .map(StrictJson)
639    }
640}
641
642struct StrictJsonVisitor;
643
644impl<'de> serde::de::Visitor<'de> for StrictJsonVisitor {
645    type Value = Value;
646
647    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
648        formatter.write_str("I-JSON")
649    }
650
651    fn visit_bool<E>(self, value: bool) -> std::result::Result<Value, E> {
652        Ok(Value::Bool(value))
653    }
654
655    fn visit_i64<E: DeError>(self, value: i64) -> std::result::Result<Value, E> {
656        if value.unsigned_abs() > MAX_SAFE_NONCE {
657            return Err(E::custom("integer outside the safe range"));
658        }
659        Ok(Value::from(value))
660    }
661
662    fn visit_u64<E: DeError>(self, value: u64) -> std::result::Result<Value, E> {
663        if value > MAX_SAFE_NONCE {
664            return Err(E::custom("integer outside the safe range"));
665        }
666        Ok(Value::from(value))
667    }
668
669    fn visit_f64<E: DeError>(self, value: f64) -> std::result::Result<Value, E> {
670        if value.fract() == 0.0 && value.abs() > MAX_SAFE_NONCE as f64 {
671            return Err(E::custom("integer outside the safe range"));
672        }
673        serde_json::Number::from_f64(value)
674            .map(Value::Number)
675            .ok_or_else(|| E::custom("number out of range"))
676    }
677
678    fn visit_str<E>(self, value: &str) -> std::result::Result<Value, E> {
679        Ok(Value::String(value.to_owned()))
680    }
681
682    fn visit_string<E>(self, value: String) -> std::result::Result<Value, E> {
683        Ok(Value::String(value))
684    }
685
686    fn visit_unit<E>(self) -> std::result::Result<Value, E> {
687        Ok(Value::Null)
688    }
689
690    fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Value, A::Error>
691    where
692        A: serde::de::SeqAccess<'de>,
693    {
694        let mut values = Vec::new();
695        while let Some(StrictJson(value)) = seq.next_element()? {
696            values.push(value);
697        }
698        Ok(Value::Array(values))
699    }
700
701    fn visit_map<A>(self, mut map: A) -> std::result::Result<Value, A::Error>
702    where
703        A: serde::de::MapAccess<'de>,
704    {
705        let mut object = serde_json::Map::new();
706        while let Some(key) = map.next_key::<String>()? {
707            if object.contains_key(&key) {
708                return Err(A::Error::custom(format!("duplicate member name {key:?}")));
709            }
710            let StrictJson(value) = map.next_value()?;
711            object.insert(key, value);
712        }
713        Ok(Value::Object(object))
714    }
715}
716
717fn valid_event_hash_bytes(event_hash: &[u8]) -> Result<&[u8; 32]> {
718    if event_hash.len() != 32 {
719        return Err(SdkError::InvalidEventHashLength(event_hash.len()));
720    }
721    Ok(event_hash
722        .try_into()
723        .expect("event hash length was checked above"))
724}
725
726pub fn verify_envelope<P>(envelope: &Envelope<P>) -> Result<()>
727where
728    P: Serialize,
729{
730    verify_event_hash(envelope)?;
731    verify_signature(envelope)
732}
733
734pub fn verify_timestamp(created_at: i64, now_ms: i64, window_ms: i64) -> Result<()> {
735    if window_ms < 0 || created_at.abs_diff(now_ms) > window_ms as u64 {
736        Err(SdkError::TimestampOutOfWindow)
737    } else {
738        Ok(())
739    }
740}
741
742#[derive(Clone, Copy, Debug, Eq, PartialEq)]
743pub struct NonceRecord {
744    pub max_nonce: u64,
745    pub expires_at: i64,
746}
747
748pub trait NonceStore {
749    fn check_and_update(
750        &mut self,
751        actor: &AgentId,
752        nonce: u64,
753        now_ms: i64,
754        ttl_ms: i64,
755    ) -> Result<u64>;
756
757    fn max_nonce(&self, actor: &AgentId, now_ms: i64) -> Option<u64>;
758}
759
760#[derive(Clone, Debug, Default)]
761pub struct MemoryNonceStore {
762    records: HashMap<AgentId, NonceRecord>,
763}
764
765impl MemoryNonceStore {
766    pub fn new() -> Self {
767        Self::default()
768    }
769}
770
771impl NonceStore for MemoryNonceStore {
772    fn check_and_update(
773        &mut self,
774        actor: &AgentId,
775        nonce: u64,
776        now_ms: i64,
777        ttl_ms: i64,
778    ) -> Result<u64> {
779        validate_nonce(nonce)?;
780        if ttl_ms < 0 {
781            return Err(SdkError::InvalidNonce(
782                "nonce cache ttl must be non-negative".to_owned(),
783            ));
784        }
785
786        if let Some(record) = self.records.get(actor) {
787            if record.expires_at > now_ms && nonce <= record.max_nonce {
788                return Err(SdkError::NonceNotGreater {
789                    max_nonce: record.max_nonce,
790                });
791            }
792        }
793
794        let record = NonceRecord {
795            max_nonce: nonce,
796            expires_at: now_ms.saturating_add(ttl_ms),
797        };
798        self.records.insert(actor.clone(), record);
799        Ok(record.max_nonce)
800    }
801
802    fn max_nonce(&self, actor: &AgentId, now_ms: i64) -> Option<u64> {
803        self.records
804            .get(actor)
805            .filter(|record| record.expires_at > now_ms)
806            .map(|record| record.max_nonce)
807    }
808}
809
810#[derive(Clone, Debug, Eq, PartialEq)]
811pub struct ClientNonceManager {
812    next_nonce: u64,
813}
814
815impl Default for ClientNonceManager {
816    fn default() -> Self {
817        Self { next_nonce: 1 }
818    }
819}
820
821impl ClientNonceManager {
822    pub fn new() -> Self {
823        Self::default()
824    }
825
826    pub fn with_next(next_nonce: u64) -> Result<Self> {
827        validate_nonce(next_nonce)?;
828        Ok(Self { next_nonce })
829    }
830
831    pub fn peek(&self) -> u64 {
832        self.next_nonce
833    }
834
835    pub fn next_nonce(&mut self) -> Result<u64> {
836        let nonce = self.next_nonce;
837        validate_nonce(nonce)?;
838        self.next_nonce += 1;
839        Ok(nonce)
840    }
841
842    /// Clock-derived nonce, `max(last + 1, created_at)` (Section 6.2): it
843    /// stays monotonic across restarts, restores, and devices sharing a key.
844    pub fn next_nonce_at(&mut self, created_at: i64) -> Result<u64> {
845        let clock = u64::try_from(created_at).unwrap_or(0);
846        if clock > self.next_nonce {
847            self.next_nonce = clock;
848        }
849        self.next_nonce()
850    }
851
852    /// Applies a `Max-Seen-Nonce` value. A value more than [`MAX_NONCE_JUMP`]
853    /// beyond `max(next_nonce, now_ms)` is rejected rather than applied.
854    pub fn observe_max_nonce(&mut self, max_nonce: u64, now_ms: i64) -> Result<()> {
855        validate_nonce(max_nonce)?;
856        let floor = self.next_nonce.max(u64::try_from(now_ms).unwrap_or(0));
857        if max_nonce > floor.saturating_add(MAX_NONCE_JUMP) {
858            return Err(SdkError::InvalidNonce(format!(
859                "Max-Seen-Nonce {max_nonce} jumps too far beyond the local sequence"
860            )));
861        }
862        if max_nonce >= self.next_nonce {
863            self.next_nonce = max_nonce.saturating_add(1);
864        }
865        Ok(())
866    }
867
868    pub fn observe_max_nonce_header(&mut self, value: &str, now_ms: i64) -> Result<()> {
869        let max_nonce = value
870            .parse::<u64>()
871            .map_err(|_| SdkError::InvalidNonce("invalid max nonce header".to_owned()))?;
872        self.observe_max_nonce(max_nonce, now_ms)
873    }
874}
875
876pub fn validate_nonce(nonce: u64) -> Result<()> {
877    if nonce == 0 || nonce > MAX_SAFE_NONCE {
878        // Number.MAX_SAFE_INTEGER in JavaScript, to prevent interoperability issues with JS clients using Number for nonces
879        Err(SdkError::InvalidNonce(
880            "nonce must be a positive integer less than or equal to 9007199254740991".to_owned(),
881        ))
882    } else {
883        Ok(())
884    }
885}
886
887#[derive(Clone, Debug)]
888pub struct LiveWriteOptions {
889    pub now_ms: i64,
890    pub window_ms: i64,
891    pub nonce_ttl_ms: i64,
892}
893
894impl Default for LiveWriteOptions {
895    fn default() -> Self {
896        Self {
897            now_ms: unix_ms(),
898            window_ms: DEFAULT_LIVE_WRITE_WINDOW_MS,
899            nonce_ttl_ms: DEFAULT_NONCE_TTL_MS,
900        }
901    }
902}
903
904/// Outcome of [`verify_submission`]: an exact resubmission of an accepted
905/// envelope, or a new live write with the nonce now recorded.
906#[derive(Clone, Copy, Debug, Eq, PartialEq)]
907pub enum SubmissionResult {
908    Resubmission,
909    Accepted { max_nonce: u64 },
910}
911
912/// Agent Identity Section 6.1 for one submission: verifies the envelope,
913/// answers an exact resubmission of an accepted envelope before the time
914/// window and nonce checks (Section 6.3), and otherwise enforces both.
915pub fn verify_submission<P, S, F>(
916    envelope: &Envelope<P>,
917    options: &LiveWriteOptions,
918    nonce_store: &mut S,
919    is_accepted: F,
920) -> Result<SubmissionResult>
921where
922    P: Serialize,
923    S: NonceStore,
924    F: Fn(&str) -> bool,
925{
926    verify_envelope(envelope)?;
927    if is_accepted(&envelope.hash) {
928        return Ok(SubmissionResult::Resubmission);
929    }
930    verify_timestamp(envelope.event.created_at, options.now_ms, options.window_ms)?;
931    let max_nonce = nonce_store.check_and_update(
932        &envelope.event.actor,
933        envelope.event.nonce,
934        options.now_ms,
935        options.nonce_ttl_ms,
936    )?;
937    Ok(SubmissionResult::Accepted { max_nonce })
938}
939
940pub fn verify_live_envelope<P, S>(
941    envelope: &Envelope<P>,
942    options: &LiveWriteOptions,
943    nonce_store: &mut S,
944) -> Result<u64>
945where
946    P: Serialize,
947    S: NonceStore,
948{
949    verify_envelope(envelope)?;
950    verify_timestamp(envelope.event.created_at, options.now_ms, options.window_ms)?;
951    nonce_store.check_and_update(
952        &envelope.event.actor,
953        envelope.event.nonce,
954        options.now_ms,
955        options.nonce_ttl_ms,
956    )
957}
958
959#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
960pub struct RequestJwtHeader {
961    pub alg: String,
962    pub typ: String,
963    pub kid: AgentId,
964}
965
966#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
967pub struct RequestJwtClaims {
968    pub iss: AgentId,
969    pub sub: AgentId,
970    /// Origin of the receiving service: scheme, host, and non-default port
971    /// with no path. All Agent Protocols endpoints on one origin share this
972    /// value; derive it with [`service_origin`].
973    pub aud: String,
974    pub iat: i64,
975    pub exp: i64,
976}
977
978#[derive(Clone, Debug, PartialEq, Eq)]
979pub struct RequestBinding {
980    pub audience: String,
981}
982
983impl RequestBinding {
984    pub fn new(audience: impl Into<String>) -> Self {
985        Self {
986            audience: audience.into(),
987        }
988    }
989}
990
991impl RequestJwtClaims {
992    pub fn new(agent_id: AgentId, binding: RequestBinding, issued_at: i64, ttl_secs: i64) -> Self {
993        Self {
994            iss: agent_id.clone(),
995            sub: agent_id,
996            aud: binding.audience,
997            iat: issued_at,
998            exp: issued_at + ttl_secs,
999        }
1000    }
1001}
1002
1003#[derive(Clone, Debug)]
1004pub struct RequestAuthContext {
1005    pub audience: String,
1006    pub now_secs: i64,
1007    pub max_ttl_secs: i64,
1008}
1009
1010impl RequestAuthContext {
1011    pub fn new(audience: impl Into<String>) -> Self {
1012        let binding = RequestBinding::new(audience);
1013        Self {
1014            audience: binding.audience,
1015            now_secs: unix_secs(),
1016            max_ttl_secs: DEFAULT_REQUEST_JWT_TTL_SECS,
1017        }
1018    }
1019}
1020
1021pub fn verify_request_jwt(token: &str, context: &RequestAuthContext) -> Result<RequestJwtClaims> {
1022    let parts: Vec<&str> = token.split('.').collect();
1023    if parts.len() != 3 {
1024        return Err(SdkError::InvalidJwt(
1025            "expected three compact JWS parts".into(),
1026        ));
1027    }
1028
1029    let header: RequestJwtHeader = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[0])?)?;
1030    let claims: RequestJwtClaims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1])?)?;
1031    let signature_bytes = URL_SAFE_NO_PAD.decode(parts[2])?;
1032    let signature_bytes: [u8; 64] = signature_bytes
1033        .try_into()
1034        .map_err(|bytes: Vec<u8>| SdkError::InvalidSignatureLength(bytes.len()))?;
1035    let signature = Signature::from_bytes(&signature_bytes);
1036    let signing_input = format!("{}.{}", parts[0], parts[1]);
1037
1038    if header.alg != "EdDSA" {
1039        return Err(SdkError::InvalidJwtClaim("alg"));
1040    }
1041    if header.typ != "JWT" {
1042        return Err(SdkError::InvalidJwtClaim("typ"));
1043    }
1044    if header.kid != claims.iss || claims.iss != claims.sub {
1045        return Err(SdkError::InvalidJwtClaim("kid/iss/sub"));
1046    }
1047
1048    verify_ed25519_strict(
1049        &header.kid.verifying_key()?,
1050        signing_input.as_bytes(),
1051        &signature.to_bytes(),
1052    )?;
1053
1054    if claims.aud != context.audience {
1055        return Err(SdkError::InvalidJwtClaim("aud"));
1056    }
1057    if claims.exp <= claims.iat {
1058        return Err(SdkError::InvalidJwtClaim("exp/iat"));
1059    }
1060    if claims.iat > context.now_secs || claims.exp < context.now_secs {
1061        return Err(SdkError::InvalidJwtClaim("iat/exp"));
1062    }
1063    if claims.exp - claims.iat > context.max_ttl_secs {
1064        return Err(SdkError::InvalidJwtClaim("ttl"));
1065    }
1066
1067    Ok(claims)
1068}
1069
1070/// Checks that `value` is a serialized HTTPS origin (Agent Identity Section
1071/// 4.4): WHATWG URL parsing and origin serialization must reproduce it exactly.
1072pub fn validate_origin(value: &str) -> Result<()> {
1073    let parsed = url::Url::parse(value)
1074        .map_err(|_| SdkError::InvalidPayload(format!("not a valid origin: {value}")))?;
1075    if parsed.scheme() == "https"
1076        && parsed.host_str().is_some()
1077        && parsed.origin().ascii_serialization() == value
1078    {
1079        Ok(())
1080    } else {
1081        Err(SdkError::InvalidPayload(format!(
1082            "origin must be a serialized HTTPS origin: {value}"
1083        )))
1084    }
1085}
1086
1087/// Derives the request JWT `aud` from a request URL: the service origin —
1088/// scheme, host, and non-default port, with no path (Agent Identity Section 7).
1089pub fn service_origin(url: &str) -> Result<String> {
1090    let parsed = url::Url::parse(url)
1091        .map_err(|_| SdkError::InvalidPayload(format!("not a valid URL: {url}")))?;
1092    if parsed.scheme() != "https" && parsed.scheme() != "http" {
1093        return Err(SdkError::InvalidPayload(format!(
1094            "not an HTTP(S) URL: {url}"
1095        )));
1096    }
1097    Ok(parsed.origin().ascii_serialization())
1098}
1099
1100pub fn unix_ms() -> i64 {
1101    let duration = SystemTime::now()
1102        .duration_since(UNIX_EPOCH)
1103        .unwrap_or_default();
1104    duration.as_millis().min(i64::MAX as u128) as i64
1105}
1106
1107pub fn unix_secs() -> i64 {
1108    unix_ms() / 1000
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113    use serde::ser;
1114    use serde_json::json;
1115
1116    use super::*;
1117
1118    struct FailingPayload;
1119
1120    impl Serialize for FailingPayload {
1121        fn serialize<S>(&self, _serializer: S) -> std::result::Result<S::Ok, S::Error>
1122        where
1123            S: Serializer,
1124        {
1125            Err(ser::Error::custom("payload cannot be serialized"))
1126        }
1127    }
1128
1129    #[test]
1130    fn signs_and_verifies_event_envelope() {
1131        let signer = AgentSigner::from_seed([7; 32]);
1132        let event = Event::new(
1133            "agent-profile/1.0",
1134            "profile.update",
1135            signer.agent_id(),
1136            1_779_753_600_000,
1137            1,
1138            json!({"id": signer.agent_id(), "name": "ResearchAgent"}),
1139        );
1140
1141        let envelope = signer.sign_event(event).unwrap();
1142
1143        assert!(!envelope.hash.starts_with("evt_"));
1144        assert_eq!(envelope.hash.len(), 43);
1145        verify_envelope(&envelope).unwrap();
1146    }
1147
1148    #[test]
1149    fn signs_and_verifies_raw_event_hash_bytes() {
1150        let seed = [18; 32];
1151        let signer = AgentSigner::from_seed(seed);
1152        let signing_key = SigningKey::from_bytes(&seed);
1153        let event = Event::new(
1154            "agent-profile/1.0",
1155            "profile.update",
1156            signer.agent_id(),
1157            1_779_753_600_000,
1158            1,
1159            json!({"id": signer.agent_id(), "name": "ResearchAgent"}),
1160        );
1161
1162        let digest = event_hash_bytes(&event).unwrap();
1163        let signature = sign_event_hash(&signing_key, &digest).unwrap();
1164        let envelope = signer.sign_event(event).unwrap();
1165
1166        assert_eq!(envelope.signature, signature);
1167        verify_event_hash_signature(&signing_key.verifying_key(), &digest, &signature).unwrap();
1168    }
1169
1170    #[test]
1171    fn rejects_tampered_event_payload() {
1172        let signer = AgentSigner::from_seed([8; 32]);
1173        let event = Event::new(
1174            "agent-profile/1.0",
1175            "profile.update",
1176            signer.agent_id(),
1177            1_779_753_600_000,
1178            1,
1179            json!({"name": "before"}),
1180        );
1181        let mut envelope = signer.sign_event(event).unwrap();
1182        envelope.event.payload = json!({"name": "after"});
1183
1184        assert!(verify_envelope(&envelope).is_err());
1185    }
1186
1187    #[test]
1188    fn rejects_non_canonical_event_payloads() {
1189        let signer = AgentSigner::from_seed([52; 32]);
1190        let event = Event::new(
1191            "agent-profile/1.0",
1192            "profile.update",
1193            signer.agent_id(),
1194            1000,
1195            1,
1196            FailingPayload,
1197        );
1198        assert!(matches!(
1199            canonical_event_bytes(&event),
1200            Err(SdkError::CanonicalJson(_))
1201        ));
1202        assert!(matches!(
1203            event_hash(&event),
1204            Err(SdkError::CanonicalJson(_))
1205        ));
1206        let envelope = Envelope {
1207            hash: "h".to_owned(),
1208            event,
1209            signature: "s".to_owned(),
1210        };
1211        assert!(matches!(
1212            verify_event_hash(&envelope),
1213            Err(SdkError::CanonicalJson(_))
1214        ));
1215        assert!(matches!(
1216            verify_signature(&envelope),
1217            Err(SdkError::CanonicalJson(_))
1218        ));
1219        assert!(matches!(
1220            verify_envelope(&envelope),
1221            Err(SdkError::CanonicalJson(_))
1222        ));
1223    }
1224
1225    #[test]
1226    fn rejects_nonce_reuse() {
1227        let signer = AgentSigner::from_seed([9; 32]);
1228        let options = LiveWriteOptions {
1229            now_ms: 1000,
1230            window_ms: 1000,
1231            nonce_ttl_ms: 1000,
1232        };
1233        let event = Event::new(
1234            "agent-profile/1.0",
1235            "profile.update",
1236            signer.agent_id(),
1237            1000,
1238            1,
1239            json!({"name": "ResearchAgent"}),
1240        );
1241        let envelope = signer.sign_event(event).unwrap();
1242        let mut store = MemoryNonceStore::new();
1243
1244        let max_nonce = verify_live_envelope(&envelope, &options, &mut store).unwrap();
1245        assert_eq!(max_nonce, 1);
1246        assert!(matches!(
1247            verify_live_envelope(&envelope, &options, &mut store),
1248            Err(SdkError::NonceNotGreater { max_nonce: 1 })
1249        ));
1250    }
1251
1252    #[test]
1253    fn client_nonce_manager_observes_server_max() {
1254        let mut manager = ClientNonceManager::new();
1255
1256        assert_eq!(manager.next_nonce().unwrap(), 1);
1257        manager.observe_max_nonce(5, 0).unwrap();
1258
1259        assert_eq!(manager.peek(), 6);
1260        manager.observe_max_nonce(4, 0).unwrap();
1261        assert_eq!(manager.peek(), 6);
1262        assert_eq!(manager.next_nonce().unwrap(), 6);
1263
1264        let mut invalid = ClientNonceManager {
1265            next_nonce: MAX_SAFE_NONCE + 1,
1266        };
1267        assert!(matches!(
1268            invalid.next_nonce(),
1269            Err(SdkError::InvalidNonce(_))
1270        ));
1271    }
1272
1273    #[test]
1274    fn rejects_nonce_values_outside_safe_json_integer_range() {
1275        let signer = AgentSigner::from_seed([16; 32]);
1276        let event = Event::new(
1277            "agent-profile/1.0",
1278            "profile.update",
1279            signer.agent_id(),
1280            1000,
1281            MAX_SAFE_NONCE + 1,
1282            json!({"id": signer.agent_id(), "name": "ResearchAgent"}),
1283        );
1284
1285        assert!(matches!(
1286            signer.sign_event(event),
1287            Err(SdkError::InvalidNonce(_))
1288        ));
1289    }
1290
1291    #[test]
1292    fn signs_and_verifies_request_jwt() {
1293        let signer = AgentSigner::from_seed([10; 32]);
1294        let claims = RequestJwtClaims::new(
1295            signer.agent_id(),
1296            RequestBinding::new("https://api.example.com"),
1297            100,
1298            300,
1299        );
1300        let token = signer.sign_request_jwt(&claims).unwrap();
1301        let context = RequestAuthContext {
1302            audience: "https://api.example.com".to_owned(),
1303            now_secs: 120,
1304            max_ttl_secs: 300,
1305        };
1306        let verified = verify_request_jwt(&token, &context).unwrap();
1307
1308        assert_eq!(verified.iss, signer.agent_id());
1309    }
1310
1311    #[test]
1312    fn rejects_request_jwts_with_non_positive_ttl() {
1313        let signer = AgentSigner::from_seed([17; 32]);
1314        let claims = RequestJwtClaims::new(
1315            signer.agent_id(),
1316            RequestBinding::new("https://api.example.com"),
1317            100,
1318            0,
1319        );
1320        let token = signer.sign_request_jwt(&claims).unwrap();
1321        let context = RequestAuthContext {
1322            audience: "https://api.example.com".to_owned(),
1323            now_secs: 100,
1324            max_ttl_secs: 300,
1325        };
1326
1327        assert!(matches!(
1328            verify_request_jwt(&token, &context),
1329            Err(SdkError::InvalidJwtClaim("exp/iat"))
1330        ));
1331    }
1332
1333    #[test]
1334    fn agent_id_string_conversions() {
1335        let signer = AgentSigner::from_seed([20; 32]);
1336        let agent_id = signer.agent_id();
1337        let text = agent_id.to_string();
1338
1339        assert_eq!(agent_id.as_str(), text);
1340        assert!(format!("{agent_id:?}").starts_with("AgentId("));
1341        assert_eq!(AgentId::try_from(text.clone()).unwrap(), agent_id);
1342        assert_eq!(text.parse::<AgentId>().unwrap(), agent_id);
1343        assert!(matches!(
1344            "not-a-did".parse::<AgentId>(),
1345            Err(SdkError::InvalidAgentIdPrefix)
1346        ));
1347        assert!(matches!(
1348            AgentId("not-a-did".to_owned()).public_key_bytes(),
1349            Err(SdkError::InvalidAgentIdPrefix)
1350        ));
1351        assert!(AgentId(format!("{AGENT_ID_PREFIX}%"))
1352            .public_key_bytes()
1353            .is_err());
1354        let short = AgentId(format!(
1355            "{}{}",
1356            AGENT_ID_PREFIX,
1357            URL_SAFE_NO_PAD.encode([1_u8; 31])
1358        ));
1359        assert!(matches!(
1360            short.public_key_bytes(),
1361            Err(SdkError::InvalidPublicKeyLength(31))
1362        ));
1363        assert!(short.verifying_key().is_err());
1364        assert!(format!("{}%", AGENT_ID_PREFIX).parse::<AgentId>().is_err());
1365    }
1366
1367    #[test]
1368    fn agent_signer_exposes_keys_and_generates() {
1369        let signer = AgentSigner::from_seed([21; 32]);
1370        assert_eq!(
1371            signer.verifying_key().to_bytes(),
1372            signer.agent_id().public_key_bytes().unwrap()
1373        );
1374
1375        let generated = AgentSigner::generate();
1376        let event = Event::new(
1377            "agent-profile/1.0",
1378            "profile.update",
1379            generated.agent_id(),
1380            1000,
1381            1,
1382            json!({"name": "Generated"}),
1383        );
1384        verify_envelope(&generated.sign_event(event).unwrap()).unwrap();
1385    }
1386
1387    #[test]
1388    fn sign_request_jwt_rejects_foreign_claims() {
1389        let signer = AgentSigner::from_seed([22; 32]);
1390        let other = AgentSigner::from_seed([23; 32]);
1391        let claims = RequestJwtClaims {
1392            iss: other.agent_id(),
1393            sub: other.agent_id(),
1394            aud: "https://api.example.com".to_owned(),
1395            iat: 100,
1396            exp: 400,
1397        };
1398        assert!(matches!(
1399            signer.sign_request_jwt(&claims),
1400            Err(SdkError::InvalidJwtClaim("iss/sub"))
1401        ));
1402    }
1403
1404    #[test]
1405    fn free_sign_event_matches_signer() {
1406        let seed = [24; 32];
1407        let signer = AgentSigner::from_seed(seed);
1408        let signing_key = SigningKey::from_bytes(&seed);
1409        let event = Event::new(
1410            "agent-profile/1.0",
1411            "profile.update",
1412            signer.agent_id(),
1413            1000,
1414            1,
1415            json!({"name": "Agent"}),
1416        );
1417
1418        let signature = sign_event(&signing_key, &event).unwrap();
1419        assert_eq!(signer.sign_event(event).unwrap().signature, signature);
1420    }
1421
1422    #[test]
1423    fn rejects_event_hash_with_wrong_length() {
1424        let signing_key = SigningKey::from_bytes(&[25; 32]);
1425        assert!(matches!(
1426            sign_event_hash(&signing_key, &[0_u8; 10]),
1427            Err(SdkError::InvalidEventHashLength(10))
1428        ));
1429        assert!(matches!(
1430            verify_event_hash_signature(&signing_key.verifying_key(), &[0_u8; 10], "AAAA"),
1431            Err(SdkError::InvalidSignatureLength(_))
1432        ));
1433        let signature = URL_SAFE_NO_PAD.encode([0_u8; 64]);
1434        assert!(matches!(
1435            verify_event_hash_signature(&signing_key.verifying_key(), &[0_u8; 10], &signature),
1436            Err(SdkError::InvalidEventHashLength(10))
1437        ));
1438    }
1439
1440    #[test]
1441    fn verify_timestamp_rejects_drift_and_negative_window() {
1442        verify_timestamp(1000, 1200, 1000).unwrap();
1443        assert!(matches!(
1444            verify_timestamp(0, 1_000_000, 1000),
1445            Err(SdkError::TimestampOutOfWindow)
1446        ));
1447        assert!(matches!(
1448            verify_timestamp(100, 100, -1),
1449            Err(SdkError::TimestampOutOfWindow)
1450        ));
1451    }
1452
1453    #[test]
1454    fn verify_live_envelope_propagates_envelope_and_timestamp_errors() {
1455        let signer = AgentSigner::from_seed([53; 32]);
1456        let event = Event::new(
1457            "agent-profile/1.0",
1458            "profile.update",
1459            signer.agent_id(),
1460            1000,
1461            1,
1462            json!({"name": "ResearchAgent"}),
1463        );
1464        let mut envelope = signer.sign_event(event).unwrap();
1465        let mut store = MemoryNonceStore::new();
1466        let options = LiveWriteOptions {
1467            now_ms: 1000,
1468            window_ms: 1000,
1469            nonce_ttl_ms: 1000,
1470        };
1471
1472        envelope.signature = "invalid".to_owned();
1473        assert!(verify_live_envelope(&envelope, &options, &mut store).is_err());
1474
1475        let event = Event::new(
1476            "agent-profile/1.0",
1477            "profile.update",
1478            signer.agent_id(),
1479            0,
1480            2,
1481            json!({"name": "ResearchAgent"}),
1482        );
1483        let envelope = signer.sign_event(event).unwrap();
1484        let expired_options = LiveWriteOptions {
1485            now_ms: 3000,
1486            ..options
1487        };
1488        assert!(matches!(
1489            verify_live_envelope(&envelope, &expired_options, &mut store),
1490            Err(SdkError::TimestampOutOfWindow)
1491        ));
1492    }
1493
1494    #[test]
1495    fn memory_nonce_store_rejects_negative_ttl_and_expires() {
1496        let actor = AgentSigner::from_seed([26; 32]).agent_id();
1497        let mut store = MemoryNonceStore::new();
1498        assert!(matches!(
1499            store.check_and_update(&actor, 1, 1000, -1),
1500            Err(SdkError::InvalidNonce(_))
1501        ));
1502
1503        store.check_and_update(&actor, 4, 1000, 1000).unwrap();
1504        // a strictly greater nonce is accepted while the record is still live
1505        assert_eq!(store.check_and_update(&actor, 5, 1100, 1000).unwrap(), 5);
1506        assert_eq!(store.max_nonce(&actor, 1500), Some(5));
1507        // record has expired by now_ms past expires_at
1508        assert_eq!(store.max_nonce(&actor, 5000), None);
1509        assert_eq!(store.max_nonce(&actor, 1500), Some(5));
1510        // a fresh actor has no record at all
1511        let stranger = AgentSigner::from_seed([27; 32]).agent_id();
1512        assert_eq!(store.max_nonce(&stranger, 1500), None);
1513    }
1514
1515    #[test]
1516    fn client_nonce_manager_with_next_and_header() {
1517        assert!(ClientNonceManager::with_next(0).is_err());
1518        let mut manager = ClientNonceManager::with_next(5).unwrap();
1519        assert_eq!(manager.peek(), 5);
1520
1521        manager.observe_max_nonce_header("10", 0).unwrap();
1522        assert_eq!(manager.peek(), 11);
1523        assert!(matches!(
1524            manager.observe_max_nonce_header("not-a-number", 0),
1525            Err(SdkError::InvalidNonce(_))
1526        ));
1527        assert!(matches!(
1528            manager.observe_max_nonce_header("9007199254740992", 0),
1529            Err(SdkError::InvalidNonce(_))
1530        ));
1531    }
1532
1533    #[test]
1534    fn default_options_and_context_use_real_clock() {
1535        let options = LiveWriteOptions::default();
1536        assert_eq!(options.window_ms, DEFAULT_LIVE_WRITE_WINDOW_MS);
1537        assert_eq!(options.nonce_ttl_ms, DEFAULT_NONCE_TTL_MS);
1538        assert!(options.now_ms > 0);
1539
1540        let context = RequestAuthContext::new("https://api.example.com");
1541        assert_eq!(context.audience, "https://api.example.com");
1542        assert_eq!(context.max_ttl_secs, DEFAULT_REQUEST_JWT_TTL_SECS);
1543        assert_eq!(context.now_secs, unix_secs());
1544        assert!(unix_ms() >= context.now_secs * 1000);
1545    }
1546
1547    fn encode_token(header: Value, claims: Value) -> String {
1548        let header = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
1549        let claims = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap());
1550        let signature = URL_SAFE_NO_PAD.encode([0_u8; 64]);
1551        format!("{header}.{claims}.{signature}")
1552    }
1553
1554    #[test]
1555    fn verify_request_jwt_rejects_malformed_tokens() {
1556        let signer = AgentSigner::from_seed([28; 32]);
1557        let agent_id = signer.agent_id();
1558        let other = AgentSigner::from_seed([29; 32]).agent_id();
1559        let context = RequestAuthContext {
1560            audience: "https://api.example.com".to_owned(),
1561            now_secs: 200,
1562            max_ttl_secs: 300,
1563        };
1564        let valid_claims = json!({
1565            "iss": agent_id,
1566            "sub": agent_id,
1567            "aud": "https://api.example.com",
1568            "iat": 100,
1569            "exp": 400,
1570        });
1571
1572        assert!(matches!(
1573            verify_request_jwt("only.two", &context),
1574            Err(SdkError::InvalidJwt(_))
1575        ));
1576        assert!(verify_request_jwt("%.e30.AAAA", &context).is_err());
1577        let invalid_header = format!(
1578            "{}.{}.{}",
1579            URL_SAFE_NO_PAD.encode(b"not-json"),
1580            URL_SAFE_NO_PAD.encode(serde_json::to_vec(&valid_claims).unwrap()),
1581            URL_SAFE_NO_PAD.encode([0_u8; 64])
1582        );
1583        assert!(verify_request_jwt(&invalid_header, &context).is_err());
1584        let header = URL_SAFE_NO_PAD.encode(
1585            serde_json::to_vec(&json!({"alg": "EdDSA", "typ": "JWT", "kid": agent_id})).unwrap(),
1586        );
1587        let claims = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&valid_claims).unwrap());
1588        assert!(verify_request_jwt(&format!("{header}.%.AAAA"), &context).is_err());
1589        assert!(verify_request_jwt(&format!("{header}.{claims}.%"), &context).is_err());
1590        assert!(matches!(
1591            verify_request_jwt(&format!("{header}.{claims}.AAAA"), &context),
1592            Err(SdkError::InvalidSignatureLength(_))
1593        ));
1594        assert!(verify_request_jwt(
1595            &format!("{header}.{claims}.{}", URL_SAFE_NO_PAD.encode([0_u8; 64])),
1596            &context
1597        )
1598        .is_err());
1599        let invalid_key = AgentId::from_public_key(&[255_u8; 32]);
1600        assert!(verify_request_jwt(
1601            &encode_token(
1602                json!({"alg": "EdDSA", "typ": "JWT", "kid": invalid_key}),
1603                json!({
1604                    "iss": invalid_key,
1605                    "sub": invalid_key,
1606                    "aud": "https://api.example.com",
1607                    "iat": 100,
1608                    "exp": 400,
1609                }),
1610            ),
1611            &context,
1612        )
1613        .is_err());
1614        assert!(matches!(
1615            verify_request_jwt(
1616                &encode_token(
1617                    json!({"alg": "HS256", "typ": "JWT", "kid": agent_id}),
1618                    valid_claims.clone(),
1619                ),
1620                &context,
1621            ),
1622            Err(SdkError::InvalidJwtClaim("alg"))
1623        ));
1624        assert!(matches!(
1625            verify_request_jwt(
1626                &encode_token(
1627                    json!({"alg": "EdDSA", "typ": "JWS", "kid": agent_id}),
1628                    valid_claims.clone(),
1629                ),
1630                &context,
1631            ),
1632            Err(SdkError::InvalidJwtClaim("typ"))
1633        ));
1634        assert!(matches!(
1635            verify_request_jwt(
1636                &encode_token(
1637                    json!({"alg": "EdDSA", "typ": "JWT", "kid": other}),
1638                    valid_claims,
1639                ),
1640                &context,
1641            ),
1642            Err(SdkError::InvalidJwtClaim("kid/iss/sub"))
1643        ));
1644    }
1645
1646    #[test]
1647    fn rejects_non_canonical_base64url_encodings() {
1648        let signer = AgentSigner::from_seed([62; 32]);
1649        let agent_id = signer.agent_id();
1650        let suffix = agent_id
1651            .as_str()
1652            .strip_prefix(AGENT_ID_PREFIX)
1653            .unwrap()
1654            .to_owned();
1655
1656        // Padding characters are rejected even when the decoded bytes match.
1657        let padded = AgentId(format!("{AGENT_ID_PREFIX}{suffix}=="));
1658        assert!(padded.public_key_bytes().is_err());
1659
1660        // Non-zero trailing bits give the same key a second string form:
1661        // replace the final character with one sharing its used bits but
1662        // different trailing bits (the last char of a 32-byte value uses 4 of
1663        // its 6 bits).
1664        const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1665        let last = suffix.as_bytes()[suffix.len() - 1];
1666        let index = ALPHABET.iter().position(|&c| c == last).unwrap();
1667        let non_canonical = ALPHABET[(index & !3) | ((index & 3) ^ 1)] as char;
1668        let mut tweaked = suffix[..suffix.len() - 1].to_owned();
1669        tweaked.push(non_canonical);
1670        let tweaked = AgentId(format!("{AGENT_ID_PREFIX}{tweaked}"));
1671        assert!(tweaked.public_key_bytes().is_err());
1672
1673        // Signatures must be canonical base64url too.
1674        let event = Event::new(
1675            "agent-profile/1.0",
1676            "profile.update",
1677            signer.agent_id(),
1678            1000,
1679            1,
1680            json!({"name": "Agent"}),
1681        );
1682        let mut envelope = signer.sign_event(event).unwrap();
1683        envelope.signature.push('=');
1684        assert!(verify_signature(&envelope).is_err());
1685    }
1686
1687    #[test]
1688    fn service_origin_derives_request_jwt_audience() {
1689        assert_eq!(
1690            service_origin("https://api.example.com/v1/rooms/room1").unwrap(),
1691            "https://api.example.com"
1692        );
1693        assert_eq!(
1694            service_origin("https://api.example.com:8443/path?q=1").unwrap(),
1695            "https://api.example.com:8443"
1696        );
1697        assert_eq!(
1698            service_origin("https://API.Example.com:443/").unwrap(),
1699            "https://api.example.com"
1700        );
1701        assert!(service_origin("not a url").is_err());
1702        assert!(service_origin("ftp://example.com").is_err());
1703    }
1704
1705    #[test]
1706    fn verify_request_jwt_enforces_audience_and_window() {
1707        let signer = AgentSigner::from_seed([30; 32]);
1708
1709        let wrong_audience = signer
1710            .sign_request_jwt(&RequestJwtClaims::new(
1711                signer.agent_id(),
1712                RequestBinding::new("https://api.example.com"),
1713                100,
1714                300,
1715            ))
1716            .unwrap();
1717        let context = RequestAuthContext {
1718            audience: "https://other.example.com".to_owned(),
1719            now_secs: 200,
1720            max_ttl_secs: 300,
1721        };
1722        assert!(matches!(
1723            verify_request_jwt(&wrong_audience, &context),
1724            Err(SdkError::InvalidJwtClaim("aud"))
1725        ));
1726
1727        let expired = signer
1728            .sign_request_jwt(&RequestJwtClaims::new(
1729                signer.agent_id(),
1730                RequestBinding::new("https://api.example.com"),
1731                1000,
1732                300,
1733            ))
1734            .unwrap();
1735        let later = RequestAuthContext {
1736            audience: "https://api.example.com".to_owned(),
1737            now_secs: 5000,
1738            max_ttl_secs: 300,
1739        };
1740        assert!(matches!(
1741            verify_request_jwt(&expired, &later),
1742            Err(SdkError::InvalidJwtClaim("iat/exp"))
1743        ));
1744
1745        let long_ttl = signer
1746            .sign_request_jwt(&RequestJwtClaims::new(
1747                signer.agent_id(),
1748                RequestBinding::new("https://api.example.com"),
1749                100,
1750                400,
1751            ))
1752            .unwrap();
1753        let strict = RequestAuthContext {
1754            audience: "https://api.example.com".to_owned(),
1755            now_secs: 200,
1756            max_ttl_secs: 300,
1757        };
1758        assert!(matches!(
1759            verify_request_jwt(&long_ttl, &strict),
1760            Err(SdkError::InvalidJwtClaim("ttl"))
1761        ));
1762    }
1763}