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;
17pub const DEFAULT_NONCE_TTL_MS: i64 = 300_000;
18pub const DEFAULT_REQUEST_JWT_TTL_SECS: i64 = 300;
19pub const MAX_NONCE_HEADER: &str = "Max-Seen-Nonce";
20pub const MAX_SAFE_NONCE: u64 = 0x1FFFFFFFFFFFFF;
21
22#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
23pub struct AgentId(String);
24
25impl AgentId {
26 pub fn from_public_key(public_key: &[u8; 32]) -> Self {
27 Self(format!(
28 "{}{}",
29 AGENT_ID_PREFIX,
30 URL_SAFE_NO_PAD.encode(public_key)
31 ))
32 }
33
34 pub fn as_str(&self) -> &str {
35 &self.0
36 }
37
38 pub fn public_key_bytes(&self) -> Result<[u8; 32]> {
39 let encoded = self
40 .0
41 .strip_prefix(AGENT_ID_PREFIX)
42 .ok_or(SdkError::InvalidAgentIdPrefix)?;
43 let bytes = URL_SAFE_NO_PAD.decode(encoded)?;
46 bytes
47 .try_into()
48 .map_err(|bytes: Vec<u8>| SdkError::InvalidPublicKeyLength(bytes.len()))
49 }
50
51 pub fn verifying_key(&self) -> Result<VerifyingKey> {
52 Ok(VerifyingKey::from_bytes(&self.public_key_bytes()?)?)
53 }
54}
55
56impl fmt::Debug for AgentId {
57 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
58 formatter.debug_tuple("AgentId").field(&self.0).finish()
59 }
60}
61
62impl fmt::Display for AgentId {
63 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
64 formatter.write_str(&self.0)
65 }
66}
67
68impl FromStr for AgentId {
69 type Err = SdkError;
70
71 fn from_str(value: &str) -> Result<Self> {
72 if !value.starts_with(AGENT_ID_PREFIX) {
73 return Err(SdkError::InvalidAgentIdPrefix);
74 }
75 let agent_id = Self(value.to_owned());
76 agent_id.public_key_bytes()?;
77 Ok(agent_id)
78 }
79}
80
81impl TryFrom<String> for AgentId {
82 type Error = SdkError;
83
84 fn try_from(value: String) -> Result<Self> {
85 Self::from_str(&value)
86 }
87}
88
89impl Serialize for AgentId {
90 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
91 where
92 S: Serializer,
93 {
94 serializer.serialize_str(&self.0)
95 }
96}
97
98impl<'de> Deserialize<'de> for AgentId {
99 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
100 where
101 D: Deserializer<'de>,
102 {
103 let value = String::deserialize(deserializer)?;
104 Self::from_str(&value).map_err(D::Error::custom)
105 }
106}
107
108pub struct AgentSigner {
109 signing_key: SigningKey,
110}
111
112impl AgentSigner {
113 pub fn generate() -> Self {
114 let mut seed = [0_u8; 32];
115 rand::fill(&mut seed);
116 Self::from_seed(seed)
117 }
118
119 pub fn from_seed(seed: [u8; 32]) -> Self {
120 Self {
121 signing_key: SigningKey::from_bytes(&seed),
122 }
123 }
124
125 pub fn agent_id(&self) -> AgentId {
126 AgentId::from_public_key(&self.signing_key.verifying_key().to_bytes())
127 }
128
129 pub fn verifying_key(&self) -> VerifyingKey {
130 self.signing_key.verifying_key()
131 }
132
133 pub fn sign_event<P>(&self, event: Event<P>) -> Result<Envelope<P>>
134 where
135 P: Serialize,
136 {
137 let event_hash = event_hash_bytes(&event)?;
138 let hash = URL_SAFE_NO_PAD.encode(&event_hash);
139 let signature = sign_event_hash(&self.signing_key, &event_hash)
140 .expect("event hashes are always 32 bytes");
141 Ok(Envelope {
142 hash,
143 event,
144 signature,
145 })
146 }
147
148 pub fn sign_request_jwt(&self, claims: &RequestJwtClaims) -> Result<String> {
149 let agent_id = self.agent_id();
150 if claims.iss != agent_id || claims.sub != agent_id {
151 return Err(SdkError::InvalidJwtClaim("iss/sub"));
152 }
153
154 let header = RequestJwtHeader {
155 alg: "EdDSA".to_owned(),
156 typ: "JWT".to_owned(),
157 kid: agent_id,
158 };
159 let header =
160 URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("JWT header is JSON-safe"));
161 let payload =
162 URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).expect("JWT claims are JSON-safe"));
163 let signing_input = format!("{header}.{payload}");
164 let signature = self.signing_key.sign(signing_input.as_bytes());
165 Ok(format!(
166 "{signing_input}.{}",
167 URL_SAFE_NO_PAD.encode(signature.to_bytes())
168 ))
169 }
170}
171
172#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
173pub struct Event<P = Value> {
174 pub protocol: String,
175 #[serde(rename = "type")]
176 pub kind: String,
177 pub actor: AgentId,
178 pub created_at: i64,
179 pub nonce: u64,
180 #[serde(skip_serializing_if = "Option::is_none")]
181 pub room_id: Option<String>,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 pub base_seq: Option<u64>,
184 #[serde(skip_serializing_if = "Option::is_none")]
185 pub base_hash: Option<String>,
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub mentions: Vec<AgentId>,
188 pub payload: P,
189 #[serde(default, flatten)]
190 pub extra: BTreeMap<String, Value>,
191}
192
193impl<P> Event<P> {
194 pub fn new(
195 protocol: impl Into<String>,
196 kind: impl Into<String>,
197 actor: AgentId,
198 created_at: i64,
199 nonce: u64,
200 payload: P,
201 ) -> Self {
202 Self {
203 protocol: protocol.into(),
204 kind: kind.into(),
205 actor,
206 created_at,
207 nonce,
208 room_id: None,
209 base_seq: None,
210 base_hash: None,
211 mentions: Vec::new(),
212 payload,
213 extra: BTreeMap::new(),
214 }
215 }
216
217 pub fn with_room_id(mut self, room_id: impl Into<String>) -> Self {
218 self.room_id = Some(room_id.into());
219 self
220 }
221
222 pub fn with_room_head(mut self, base_seq: u64, base_hash: impl Into<String>) -> Self {
223 self.base_seq = Some(base_seq);
224 self.base_hash = Some(base_hash.into());
225 self
226 }
227
228 pub fn with_mentions(mut self, mentions: Vec<AgentId>) -> Self {
229 self.mentions = mentions;
230 self
231 }
232
233 pub fn with_mention(mut self, agent_id: AgentId) -> Self {
234 self.mentions.push(agent_id);
235 self
236 }
237}
238
239#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
240pub struct Envelope<P = Value> {
241 pub hash: String,
242 pub event: Event<P>,
243 pub signature: String,
244}
245
246pub fn canonical_event_bytes<P>(event: &Event<P>) -> Result<Vec<u8>>
247where
248 P: Serialize,
249{
250 serde_jcs::to_vec(event).map_err(|err| SdkError::CanonicalJson(err.to_string()))
251}
252
253pub fn event_hash<P>(event: &Event<P>) -> Result<String>
254where
255 P: Serialize,
256{
257 Ok(URL_SAFE_NO_PAD.encode(event_hash_bytes(event)?))
258}
259
260pub fn event_hash_bytes<P>(event: &Event<P>) -> Result<[u8; 32]>
261where
262 P: Serialize,
263{
264 validate_nonce(event.nonce)?;
265 let digest = Sha3_256::digest(canonical_event_bytes(event)?);
266 let mut bytes = [0_u8; 32];
267 bytes.copy_from_slice(&digest);
268 Ok(bytes)
269}
270
271pub fn sign_event<P>(signing_key: &SigningKey, event: &Event<P>) -> Result<String>
272where
273 P: Serialize,
274{
275 let event_hash = event_hash_bytes(event)?;
276 Ok(sign_event_hash(signing_key, &event_hash).expect("event hashes are always 32 bytes"))
277}
278
279pub fn sign_event_hash(signing_key: &SigningKey, event_hash: &[u8]) -> Result<String> {
285 let signature = signing_key.sign(valid_event_hash_bytes(event_hash)?);
286 Ok(URL_SAFE_NO_PAD.encode(signature.to_bytes()))
287}
288
289pub fn verify_event_hash<P>(envelope: &Envelope<P>) -> Result<()>
290where
291 P: Serialize,
292{
293 let expected = event_hash(&envelope.event)?;
294 if expected == envelope.hash {
295 Ok(())
296 } else {
297 Err(SdkError::InvalidEventHash {
298 expected,
299 actual: envelope.hash.clone(),
300 })
301 }
302}
303
304pub fn verify_signature<P>(envelope: &Envelope<P>) -> Result<()>
305where
306 P: Serialize,
307{
308 let event_hash = event_hash_bytes(&envelope.event)?;
309 let verifying_key = envelope.event.actor.verifying_key()?;
310 verify_event_hash_signature(&verifying_key, &event_hash, &envelope.signature)
311}
312
313pub fn verify_event_hash_signature(
314 verifying_key: &VerifyingKey,
315 event_hash: &[u8],
316 encoded_signature: &str,
317) -> Result<()> {
318 let signature_bytes = URL_SAFE_NO_PAD.decode(encoded_signature)?;
319 let signature_bytes: [u8; 64] = signature_bytes
320 .try_into()
321 .map_err(|bytes: Vec<u8>| SdkError::InvalidSignatureLength(bytes.len()))?;
322 let signature = Signature::from_bytes(&signature_bytes);
323 verifying_key.verify_strict(valid_event_hash_bytes(event_hash)?, &signature)?;
324 Ok(())
325}
326
327fn valid_event_hash_bytes(event_hash: &[u8]) -> Result<&[u8; 32]> {
328 if event_hash.len() != 32 {
329 return Err(SdkError::InvalidEventHashLength(event_hash.len()));
330 }
331 Ok(event_hash
332 .try_into()
333 .expect("event hash length was checked above"))
334}
335
336pub fn verify_envelope<P>(envelope: &Envelope<P>) -> Result<()>
337where
338 P: Serialize,
339{
340 verify_event_hash(envelope)?;
341 verify_signature(envelope)
342}
343
344pub fn verify_timestamp(created_at: i64, now_ms: i64, window_ms: i64) -> Result<()> {
345 if window_ms < 0 || created_at.abs_diff(now_ms) > window_ms as u64 {
346 Err(SdkError::TimestampOutOfWindow)
347 } else {
348 Ok(())
349 }
350}
351
352#[derive(Clone, Copy, Debug, Eq, PartialEq)]
353pub struct NonceRecord {
354 pub max_nonce: u64,
355 pub expires_at: i64,
356}
357
358pub trait NonceStore {
359 fn check_and_update(
360 &mut self,
361 actor: &AgentId,
362 nonce: u64,
363 now_ms: i64,
364 ttl_ms: i64,
365 ) -> Result<u64>;
366
367 fn max_nonce(&self, actor: &AgentId, now_ms: i64) -> Option<u64>;
368}
369
370#[derive(Clone, Debug, Default)]
371pub struct MemoryNonceStore {
372 records: HashMap<AgentId, NonceRecord>,
373}
374
375impl MemoryNonceStore {
376 pub fn new() -> Self {
377 Self::default()
378 }
379}
380
381impl NonceStore for MemoryNonceStore {
382 fn check_and_update(
383 &mut self,
384 actor: &AgentId,
385 nonce: u64,
386 now_ms: i64,
387 ttl_ms: i64,
388 ) -> Result<u64> {
389 validate_nonce(nonce)?;
390 if ttl_ms < 0 {
391 return Err(SdkError::InvalidNonce(
392 "nonce cache ttl must be non-negative".to_owned(),
393 ));
394 }
395
396 if let Some(record) = self.records.get(actor) {
397 if record.expires_at > now_ms && nonce <= record.max_nonce {
398 return Err(SdkError::NonceNotGreater {
399 max_nonce: record.max_nonce,
400 });
401 }
402 }
403
404 let record = NonceRecord {
405 max_nonce: nonce,
406 expires_at: now_ms.saturating_add(ttl_ms),
407 };
408 self.records.insert(actor.clone(), record);
409 Ok(record.max_nonce)
410 }
411
412 fn max_nonce(&self, actor: &AgentId, now_ms: i64) -> Option<u64> {
413 self.records
414 .get(actor)
415 .filter(|record| record.expires_at > now_ms)
416 .map(|record| record.max_nonce)
417 }
418}
419
420#[derive(Clone, Debug, Eq, PartialEq)]
421pub struct ClientNonceManager {
422 next_nonce: u64,
423}
424
425impl Default for ClientNonceManager {
426 fn default() -> Self {
427 Self { next_nonce: 1 }
428 }
429}
430
431impl ClientNonceManager {
432 pub fn new() -> Self {
433 Self::default()
434 }
435
436 pub fn with_next(next_nonce: u64) -> Result<Self> {
437 validate_nonce(next_nonce)?;
438 Ok(Self { next_nonce })
439 }
440
441 pub fn peek(&self) -> u64 {
442 self.next_nonce
443 }
444
445 pub fn next_nonce(&mut self) -> Result<u64> {
446 let nonce = self.next_nonce;
447 validate_nonce(nonce)?;
448 self.next_nonce += 1;
449 Ok(nonce)
450 }
451
452 pub fn observe_max_nonce(&mut self, max_nonce: u64) {
453 if max_nonce >= self.next_nonce {
454 self.next_nonce = max_nonce.saturating_add(1);
455 }
456 }
457
458 pub fn observe_max_nonce_header(&mut self, value: &str) -> Result<()> {
459 let max_nonce = value
460 .parse::<u64>()
461 .map_err(|_| SdkError::InvalidNonce("invalid max nonce header".to_owned()))?;
462 validate_nonce(max_nonce)?;
463 self.observe_max_nonce(max_nonce);
464 Ok(())
465 }
466}
467
468pub fn validate_nonce(nonce: u64) -> Result<()> {
469 if nonce == 0 || nonce > MAX_SAFE_NONCE {
470 Err(SdkError::InvalidNonce(
472 "nonce must be a positive integer less than or equal to 9007199254740991".to_owned(),
473 ))
474 } else {
475 Ok(())
476 }
477}
478
479#[derive(Clone, Debug)]
480pub struct LiveWriteOptions {
481 pub now_ms: i64,
482 pub window_ms: i64,
483 pub nonce_ttl_ms: i64,
484}
485
486impl Default for LiveWriteOptions {
487 fn default() -> Self {
488 Self {
489 now_ms: unix_ms(),
490 window_ms: DEFAULT_LIVE_WRITE_WINDOW_MS,
491 nonce_ttl_ms: DEFAULT_NONCE_TTL_MS,
492 }
493 }
494}
495
496pub fn verify_live_envelope<P, S>(
497 envelope: &Envelope<P>,
498 options: &LiveWriteOptions,
499 nonce_store: &mut S,
500) -> Result<u64>
501where
502 P: Serialize,
503 S: NonceStore,
504{
505 verify_envelope(envelope)?;
506 verify_timestamp(envelope.event.created_at, options.now_ms, options.window_ms)?;
507 nonce_store.check_and_update(
508 &envelope.event.actor,
509 envelope.event.nonce,
510 options.now_ms,
511 options.nonce_ttl_ms,
512 )
513}
514
515#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
516pub struct RequestJwtHeader {
517 pub alg: String,
518 pub typ: String,
519 pub kid: AgentId,
520}
521
522#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
523pub struct RequestJwtClaims {
524 pub iss: AgentId,
525 pub sub: AgentId,
526 pub aud: String,
530 pub iat: i64,
531 pub exp: i64,
532}
533
534#[derive(Clone, Debug, PartialEq, Eq)]
535pub struct RequestBinding {
536 pub audience: String,
537}
538
539impl RequestBinding {
540 pub fn new(audience: impl Into<String>) -> Self {
541 Self {
542 audience: audience.into(),
543 }
544 }
545}
546
547impl RequestJwtClaims {
548 pub fn new(agent_id: AgentId, binding: RequestBinding, issued_at: i64, ttl_secs: i64) -> Self {
549 Self {
550 iss: agent_id.clone(),
551 sub: agent_id,
552 aud: binding.audience,
553 iat: issued_at,
554 exp: issued_at + ttl_secs,
555 }
556 }
557}
558
559#[derive(Clone, Debug)]
560pub struct RequestAuthContext {
561 pub audience: String,
562 pub now_secs: i64,
563 pub max_ttl_secs: i64,
564}
565
566impl RequestAuthContext {
567 pub fn new(audience: impl Into<String>) -> Self {
568 let binding = RequestBinding::new(audience);
569 Self {
570 audience: binding.audience,
571 now_secs: unix_secs(),
572 max_ttl_secs: DEFAULT_REQUEST_JWT_TTL_SECS,
573 }
574 }
575}
576
577pub fn verify_request_jwt(token: &str, context: &RequestAuthContext) -> Result<RequestJwtClaims> {
578 let parts: Vec<&str> = token.split('.').collect();
579 if parts.len() != 3 {
580 return Err(SdkError::InvalidJwt(
581 "expected three compact JWS parts".into(),
582 ));
583 }
584
585 let header: RequestJwtHeader = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[0])?)?;
586 let claims: RequestJwtClaims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1])?)?;
587 let signature_bytes = URL_SAFE_NO_PAD.decode(parts[2])?;
588 let signature_bytes: [u8; 64] = signature_bytes
589 .try_into()
590 .map_err(|bytes: Vec<u8>| SdkError::InvalidSignatureLength(bytes.len()))?;
591 let signature = Signature::from_bytes(&signature_bytes);
592 let signing_input = format!("{}.{}", parts[0], parts[1]);
593
594 if header.alg != "EdDSA" {
595 return Err(SdkError::InvalidJwtClaim("alg"));
596 }
597 if header.typ != "JWT" {
598 return Err(SdkError::InvalidJwtClaim("typ"));
599 }
600 if header.kid != claims.iss || claims.iss != claims.sub {
601 return Err(SdkError::InvalidJwtClaim("kid/iss/sub"));
602 }
603
604 header
605 .kid
606 .verifying_key()?
607 .verify_strict(signing_input.as_bytes(), &signature)?;
608
609 if claims.aud != context.audience {
610 return Err(SdkError::InvalidJwtClaim("aud"));
611 }
612 if claims.exp <= claims.iat {
613 return Err(SdkError::InvalidJwtClaim("exp/iat"));
614 }
615 if claims.iat > context.now_secs || claims.exp < context.now_secs {
616 return Err(SdkError::InvalidJwtClaim("iat/exp"));
617 }
618 if claims.exp - claims.iat > context.max_ttl_secs {
619 return Err(SdkError::InvalidJwtClaim("ttl"));
620 }
621
622 Ok(claims)
623}
624
625pub fn service_origin(url: &str) -> Result<String> {
628 let parsed = url::Url::parse(url)
629 .map_err(|_| SdkError::InvalidPayload(format!("not a valid URL: {url}")))?;
630 if parsed.scheme() != "https" && parsed.scheme() != "http" {
631 return Err(SdkError::InvalidPayload(format!(
632 "not an HTTP(S) URL: {url}"
633 )));
634 }
635 Ok(parsed.origin().ascii_serialization())
636}
637
638pub fn unix_ms() -> i64 {
639 let duration = SystemTime::now()
640 .duration_since(UNIX_EPOCH)
641 .unwrap_or_default();
642 duration.as_millis().min(i64::MAX as u128) as i64
643}
644
645pub fn unix_secs() -> i64 {
646 unix_ms() / 1000
647}
648
649#[cfg(test)]
650mod tests {
651 use serde::ser;
652 use serde_json::json;
653
654 use super::*;
655
656 struct FailingPayload;
657
658 impl Serialize for FailingPayload {
659 fn serialize<S>(&self, _serializer: S) -> std::result::Result<S::Ok, S::Error>
660 where
661 S: Serializer,
662 {
663 Err(ser::Error::custom("payload cannot be serialized"))
664 }
665 }
666
667 #[test]
668 fn signs_and_verifies_event_envelope() {
669 let signer = AgentSigner::from_seed([7; 32]);
670 let event = Event::new(
671 "agent-profile/1.0",
672 "profile.update",
673 signer.agent_id(),
674 1_779_753_600_000,
675 1,
676 json!({"id": signer.agent_id(), "name": "ResearchAgent"}),
677 );
678
679 let envelope = signer.sign_event(event).unwrap();
680
681 assert!(!envelope.hash.starts_with("evt_"));
682 assert_eq!(envelope.hash.len(), 43);
683 verify_envelope(&envelope).unwrap();
684 }
685
686 #[test]
687 fn signs_and_verifies_raw_event_hash_bytes() {
688 let seed = [18; 32];
689 let signer = AgentSigner::from_seed(seed);
690 let signing_key = SigningKey::from_bytes(&seed);
691 let event = Event::new(
692 "agent-profile/1.0",
693 "profile.update",
694 signer.agent_id(),
695 1_779_753_600_000,
696 1,
697 json!({"id": signer.agent_id(), "name": "ResearchAgent"}),
698 );
699
700 let digest = event_hash_bytes(&event).unwrap();
701 let signature = sign_event_hash(&signing_key, &digest).unwrap();
702 let envelope = signer.sign_event(event).unwrap();
703
704 assert_eq!(envelope.signature, signature);
705 verify_event_hash_signature(&signing_key.verifying_key(), &digest, &signature).unwrap();
706 }
707
708 #[test]
709 fn rejects_tampered_event_payload() {
710 let signer = AgentSigner::from_seed([8; 32]);
711 let event = Event::new(
712 "agent-profile/1.0",
713 "profile.update",
714 signer.agent_id(),
715 1_779_753_600_000,
716 1,
717 json!({"name": "before"}),
718 );
719 let mut envelope = signer.sign_event(event).unwrap();
720 envelope.event.payload = json!({"name": "after"});
721
722 assert!(verify_envelope(&envelope).is_err());
723 }
724
725 #[test]
726 fn rejects_non_canonical_event_payloads() {
727 let signer = AgentSigner::from_seed([52; 32]);
728 let event = Event::new(
729 "agent-profile/1.0",
730 "profile.update",
731 signer.agent_id(),
732 1000,
733 1,
734 FailingPayload,
735 );
736 assert!(matches!(
737 canonical_event_bytes(&event),
738 Err(SdkError::CanonicalJson(_))
739 ));
740 assert!(matches!(
741 event_hash(&event),
742 Err(SdkError::CanonicalJson(_))
743 ));
744 let envelope = Envelope {
745 hash: "h".to_owned(),
746 event,
747 signature: "s".to_owned(),
748 };
749 assert!(matches!(
750 verify_event_hash(&envelope),
751 Err(SdkError::CanonicalJson(_))
752 ));
753 assert!(matches!(
754 verify_signature(&envelope),
755 Err(SdkError::CanonicalJson(_))
756 ));
757 assert!(matches!(
758 verify_envelope(&envelope),
759 Err(SdkError::CanonicalJson(_))
760 ));
761 }
762
763 #[test]
764 fn rejects_nonce_reuse() {
765 let signer = AgentSigner::from_seed([9; 32]);
766 let options = LiveWriteOptions {
767 now_ms: 1000,
768 window_ms: 1000,
769 nonce_ttl_ms: 1000,
770 };
771 let event = Event::new(
772 "agent-profile/1.0",
773 "profile.update",
774 signer.agent_id(),
775 1000,
776 1,
777 json!({"name": "ResearchAgent"}),
778 );
779 let envelope = signer.sign_event(event).unwrap();
780 let mut store = MemoryNonceStore::new();
781
782 let max_nonce = verify_live_envelope(&envelope, &options, &mut store).unwrap();
783 assert_eq!(max_nonce, 1);
784 assert!(matches!(
785 verify_live_envelope(&envelope, &options, &mut store),
786 Err(SdkError::NonceNotGreater { max_nonce: 1 })
787 ));
788 }
789
790 #[test]
791 fn client_nonce_manager_observes_server_max() {
792 let mut manager = ClientNonceManager::new();
793
794 assert_eq!(manager.next_nonce().unwrap(), 1);
795 manager.observe_max_nonce(5);
796
797 assert_eq!(manager.peek(), 6);
798 manager.observe_max_nonce(4);
799 assert_eq!(manager.peek(), 6);
800 assert_eq!(manager.next_nonce().unwrap(), 6);
801
802 let mut invalid = ClientNonceManager {
803 next_nonce: MAX_SAFE_NONCE + 1,
804 };
805 assert!(matches!(
806 invalid.next_nonce(),
807 Err(SdkError::InvalidNonce(_))
808 ));
809 }
810
811 #[test]
812 fn rejects_nonce_values_outside_safe_json_integer_range() {
813 let signer = AgentSigner::from_seed([16; 32]);
814 let event = Event::new(
815 "agent-profile/1.0",
816 "profile.update",
817 signer.agent_id(),
818 1000,
819 MAX_SAFE_NONCE + 1,
820 json!({"id": signer.agent_id(), "name": "ResearchAgent"}),
821 );
822
823 assert!(matches!(
824 signer.sign_event(event),
825 Err(SdkError::InvalidNonce(_))
826 ));
827 }
828
829 #[test]
830 fn signs_and_verifies_request_jwt() {
831 let signer = AgentSigner::from_seed([10; 32]);
832 let claims = RequestJwtClaims::new(
833 signer.agent_id(),
834 RequestBinding::new("https://api.example.com"),
835 100,
836 300,
837 );
838 let token = signer.sign_request_jwt(&claims).unwrap();
839 let context = RequestAuthContext {
840 audience: "https://api.example.com".to_owned(),
841 now_secs: 120,
842 max_ttl_secs: 300,
843 };
844 let verified = verify_request_jwt(&token, &context).unwrap();
845
846 assert_eq!(verified.iss, signer.agent_id());
847 }
848
849 #[test]
850 fn rejects_request_jwts_with_non_positive_ttl() {
851 let signer = AgentSigner::from_seed([17; 32]);
852 let claims = RequestJwtClaims::new(
853 signer.agent_id(),
854 RequestBinding::new("https://api.example.com"),
855 100,
856 0,
857 );
858 let token = signer.sign_request_jwt(&claims).unwrap();
859 let context = RequestAuthContext {
860 audience: "https://api.example.com".to_owned(),
861 now_secs: 100,
862 max_ttl_secs: 300,
863 };
864
865 assert!(matches!(
866 verify_request_jwt(&token, &context),
867 Err(SdkError::InvalidJwtClaim("exp/iat"))
868 ));
869 }
870
871 #[test]
872 fn agent_id_string_conversions() {
873 let signer = AgentSigner::from_seed([20; 32]);
874 let agent_id = signer.agent_id();
875 let text = agent_id.to_string();
876
877 assert_eq!(agent_id.as_str(), text);
878 assert!(format!("{agent_id:?}").starts_with("AgentId("));
879 assert_eq!(AgentId::try_from(text.clone()).unwrap(), agent_id);
880 assert_eq!(text.parse::<AgentId>().unwrap(), agent_id);
881 assert!(matches!(
882 "not-a-did".parse::<AgentId>(),
883 Err(SdkError::InvalidAgentIdPrefix)
884 ));
885 assert!(matches!(
886 AgentId("not-a-did".to_owned()).public_key_bytes(),
887 Err(SdkError::InvalidAgentIdPrefix)
888 ));
889 assert!(AgentId(format!("{AGENT_ID_PREFIX}%"))
890 .public_key_bytes()
891 .is_err());
892 let short = AgentId(format!(
893 "{}{}",
894 AGENT_ID_PREFIX,
895 URL_SAFE_NO_PAD.encode([1_u8; 31])
896 ));
897 assert!(matches!(
898 short.public_key_bytes(),
899 Err(SdkError::InvalidPublicKeyLength(31))
900 ));
901 assert!(short.verifying_key().is_err());
902 assert!(format!("{}%", AGENT_ID_PREFIX).parse::<AgentId>().is_err());
903 }
904
905 #[test]
906 fn agent_signer_exposes_keys_and_generates() {
907 let signer = AgentSigner::from_seed([21; 32]);
908 assert_eq!(
909 signer.verifying_key().to_bytes(),
910 signer.agent_id().public_key_bytes().unwrap()
911 );
912
913 let generated = AgentSigner::generate();
914 let event = Event::new(
915 "agent-profile/1.0",
916 "profile.update",
917 generated.agent_id(),
918 1000,
919 1,
920 json!({"name": "Generated"}),
921 );
922 verify_envelope(&generated.sign_event(event).unwrap()).unwrap();
923 }
924
925 #[test]
926 fn sign_request_jwt_rejects_foreign_claims() {
927 let signer = AgentSigner::from_seed([22; 32]);
928 let other = AgentSigner::from_seed([23; 32]);
929 let claims = RequestJwtClaims {
930 iss: other.agent_id(),
931 sub: other.agent_id(),
932 aud: "https://api.example.com".to_owned(),
933 iat: 100,
934 exp: 400,
935 };
936 assert!(matches!(
937 signer.sign_request_jwt(&claims),
938 Err(SdkError::InvalidJwtClaim("iss/sub"))
939 ));
940 }
941
942 #[test]
943 fn free_sign_event_matches_signer() {
944 let seed = [24; 32];
945 let signer = AgentSigner::from_seed(seed);
946 let signing_key = SigningKey::from_bytes(&seed);
947 let event = Event::new(
948 "agent-profile/1.0",
949 "profile.update",
950 signer.agent_id(),
951 1000,
952 1,
953 json!({"name": "Agent"}),
954 );
955
956 let signature = sign_event(&signing_key, &event).unwrap();
957 assert_eq!(signer.sign_event(event).unwrap().signature, signature);
958 }
959
960 #[test]
961 fn rejects_event_hash_with_wrong_length() {
962 let signing_key = SigningKey::from_bytes(&[25; 32]);
963 assert!(matches!(
964 sign_event_hash(&signing_key, &[0_u8; 10]),
965 Err(SdkError::InvalidEventHashLength(10))
966 ));
967 assert!(matches!(
968 verify_event_hash_signature(&signing_key.verifying_key(), &[0_u8; 10], "AAAA"),
969 Err(SdkError::InvalidSignatureLength(_))
970 ));
971 let signature = URL_SAFE_NO_PAD.encode([0_u8; 64]);
972 assert!(matches!(
973 verify_event_hash_signature(&signing_key.verifying_key(), &[0_u8; 10], &signature),
974 Err(SdkError::InvalidEventHashLength(10))
975 ));
976 }
977
978 #[test]
979 fn verify_timestamp_rejects_drift_and_negative_window() {
980 verify_timestamp(1000, 1200, 1000).unwrap();
981 assert!(matches!(
982 verify_timestamp(0, 1_000_000, 1000),
983 Err(SdkError::TimestampOutOfWindow)
984 ));
985 assert!(matches!(
986 verify_timestamp(100, 100, -1),
987 Err(SdkError::TimestampOutOfWindow)
988 ));
989 }
990
991 #[test]
992 fn verify_live_envelope_propagates_envelope_and_timestamp_errors() {
993 let signer = AgentSigner::from_seed([53; 32]);
994 let event = Event::new(
995 "agent-profile/1.0",
996 "profile.update",
997 signer.agent_id(),
998 1000,
999 1,
1000 json!({"name": "ResearchAgent"}),
1001 );
1002 let mut envelope = signer.sign_event(event).unwrap();
1003 let mut store = MemoryNonceStore::new();
1004 let options = LiveWriteOptions {
1005 now_ms: 1000,
1006 window_ms: 1000,
1007 nonce_ttl_ms: 1000,
1008 };
1009
1010 envelope.signature = "invalid".to_owned();
1011 assert!(verify_live_envelope(&envelope, &options, &mut store).is_err());
1012
1013 let event = Event::new(
1014 "agent-profile/1.0",
1015 "profile.update",
1016 signer.agent_id(),
1017 0,
1018 2,
1019 json!({"name": "ResearchAgent"}),
1020 );
1021 let envelope = signer.sign_event(event).unwrap();
1022 let expired_options = LiveWriteOptions {
1023 now_ms: 3000,
1024 ..options
1025 };
1026 assert!(matches!(
1027 verify_live_envelope(&envelope, &expired_options, &mut store),
1028 Err(SdkError::TimestampOutOfWindow)
1029 ));
1030 }
1031
1032 #[test]
1033 fn memory_nonce_store_rejects_negative_ttl_and_expires() {
1034 let actor = AgentSigner::from_seed([26; 32]).agent_id();
1035 let mut store = MemoryNonceStore::new();
1036 assert!(matches!(
1037 store.check_and_update(&actor, 1, 1000, -1),
1038 Err(SdkError::InvalidNonce(_))
1039 ));
1040
1041 store.check_and_update(&actor, 4, 1000, 1000).unwrap();
1042 assert_eq!(store.check_and_update(&actor, 5, 1100, 1000).unwrap(), 5);
1044 assert_eq!(store.max_nonce(&actor, 1500), Some(5));
1045 assert_eq!(store.max_nonce(&actor, 5000), None);
1047 assert_eq!(store.max_nonce(&actor, 1500), Some(5));
1048 let stranger = AgentSigner::from_seed([27; 32]).agent_id();
1050 assert_eq!(store.max_nonce(&stranger, 1500), None);
1051 }
1052
1053 #[test]
1054 fn client_nonce_manager_with_next_and_header() {
1055 assert!(ClientNonceManager::with_next(0).is_err());
1056 let mut manager = ClientNonceManager::with_next(5).unwrap();
1057 assert_eq!(manager.peek(), 5);
1058
1059 manager.observe_max_nonce_header("10").unwrap();
1060 assert_eq!(manager.peek(), 11);
1061 assert!(matches!(
1062 manager.observe_max_nonce_header("not-a-number"),
1063 Err(SdkError::InvalidNonce(_))
1064 ));
1065 assert!(matches!(
1066 manager.observe_max_nonce_header("9007199254740992"),
1067 Err(SdkError::InvalidNonce(_))
1068 ));
1069 }
1070
1071 #[test]
1072 fn default_options_and_context_use_real_clock() {
1073 let options = LiveWriteOptions::default();
1074 assert_eq!(options.window_ms, DEFAULT_LIVE_WRITE_WINDOW_MS);
1075 assert_eq!(options.nonce_ttl_ms, DEFAULT_NONCE_TTL_MS);
1076 assert!(options.now_ms > 0);
1077
1078 let context = RequestAuthContext::new("https://api.example.com");
1079 assert_eq!(context.audience, "https://api.example.com");
1080 assert_eq!(context.max_ttl_secs, DEFAULT_REQUEST_JWT_TTL_SECS);
1081 assert_eq!(context.now_secs, unix_secs());
1082 assert!(unix_ms() >= context.now_secs * 1000);
1083 }
1084
1085 fn encode_token(header: Value, claims: Value) -> String {
1086 let header = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
1087 let claims = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap());
1088 let signature = URL_SAFE_NO_PAD.encode([0_u8; 64]);
1089 format!("{header}.{claims}.{signature}")
1090 }
1091
1092 #[test]
1093 fn verify_request_jwt_rejects_malformed_tokens() {
1094 let signer = AgentSigner::from_seed([28; 32]);
1095 let agent_id = signer.agent_id();
1096 let other = AgentSigner::from_seed([29; 32]).agent_id();
1097 let context = RequestAuthContext {
1098 audience: "https://api.example.com".to_owned(),
1099 now_secs: 200,
1100 max_ttl_secs: 300,
1101 };
1102 let valid_claims = json!({
1103 "iss": agent_id,
1104 "sub": agent_id,
1105 "aud": "https://api.example.com",
1106 "iat": 100,
1107 "exp": 400,
1108 });
1109
1110 assert!(matches!(
1111 verify_request_jwt("only.two", &context),
1112 Err(SdkError::InvalidJwt(_))
1113 ));
1114 assert!(verify_request_jwt("%.e30.AAAA", &context).is_err());
1115 let invalid_header = format!(
1116 "{}.{}.{}",
1117 URL_SAFE_NO_PAD.encode(b"not-json"),
1118 URL_SAFE_NO_PAD.encode(serde_json::to_vec(&valid_claims).unwrap()),
1119 URL_SAFE_NO_PAD.encode([0_u8; 64])
1120 );
1121 assert!(verify_request_jwt(&invalid_header, &context).is_err());
1122 let header = URL_SAFE_NO_PAD.encode(
1123 serde_json::to_vec(&json!({"alg": "EdDSA", "typ": "JWT", "kid": agent_id})).unwrap(),
1124 );
1125 let claims = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&valid_claims).unwrap());
1126 assert!(verify_request_jwt(&format!("{header}.%.AAAA"), &context).is_err());
1127 assert!(verify_request_jwt(&format!("{header}.{claims}.%"), &context).is_err());
1128 assert!(matches!(
1129 verify_request_jwt(&format!("{header}.{claims}.AAAA"), &context),
1130 Err(SdkError::InvalidSignatureLength(_))
1131 ));
1132 assert!(verify_request_jwt(
1133 &format!("{header}.{claims}.{}", URL_SAFE_NO_PAD.encode([0_u8; 64])),
1134 &context
1135 )
1136 .is_err());
1137 let invalid_key = AgentId::from_public_key(&[255_u8; 32]);
1138 assert!(verify_request_jwt(
1139 &encode_token(
1140 json!({"alg": "EdDSA", "typ": "JWT", "kid": invalid_key}),
1141 json!({
1142 "iss": invalid_key,
1143 "sub": invalid_key,
1144 "aud": "https://api.example.com",
1145 "iat": 100,
1146 "exp": 400,
1147 }),
1148 ),
1149 &context,
1150 )
1151 .is_err());
1152 assert!(matches!(
1153 verify_request_jwt(
1154 &encode_token(
1155 json!({"alg": "HS256", "typ": "JWT", "kid": agent_id}),
1156 valid_claims.clone(),
1157 ),
1158 &context,
1159 ),
1160 Err(SdkError::InvalidJwtClaim("alg"))
1161 ));
1162 assert!(matches!(
1163 verify_request_jwt(
1164 &encode_token(
1165 json!({"alg": "EdDSA", "typ": "JWS", "kid": agent_id}),
1166 valid_claims.clone(),
1167 ),
1168 &context,
1169 ),
1170 Err(SdkError::InvalidJwtClaim("typ"))
1171 ));
1172 assert!(matches!(
1173 verify_request_jwt(
1174 &encode_token(
1175 json!({"alg": "EdDSA", "typ": "JWT", "kid": other}),
1176 valid_claims,
1177 ),
1178 &context,
1179 ),
1180 Err(SdkError::InvalidJwtClaim("kid/iss/sub"))
1181 ));
1182 }
1183
1184 #[test]
1185 fn rejects_non_canonical_base64url_encodings() {
1186 let signer = AgentSigner::from_seed([62; 32]);
1187 let agent_id = signer.agent_id();
1188 let suffix = agent_id
1189 .as_str()
1190 .strip_prefix(AGENT_ID_PREFIX)
1191 .unwrap()
1192 .to_owned();
1193
1194 let padded = AgentId(format!("{AGENT_ID_PREFIX}{suffix}=="));
1196 assert!(padded.public_key_bytes().is_err());
1197
1198 const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1203 let last = suffix.as_bytes()[suffix.len() - 1];
1204 let index = ALPHABET.iter().position(|&c| c == last).unwrap();
1205 let non_canonical = ALPHABET[(index & !3) | ((index & 3) ^ 1)] as char;
1206 let mut tweaked = suffix[..suffix.len() - 1].to_owned();
1207 tweaked.push(non_canonical);
1208 let tweaked = AgentId(format!("{AGENT_ID_PREFIX}{tweaked}"));
1209 assert!(tweaked.public_key_bytes().is_err());
1210
1211 let event = Event::new(
1213 "agent-profile/1.0",
1214 "profile.update",
1215 signer.agent_id(),
1216 1000,
1217 1,
1218 json!({"name": "Agent"}),
1219 );
1220 let mut envelope = signer.sign_event(event).unwrap();
1221 envelope.signature.push('=');
1222 assert!(verify_signature(&envelope).is_err());
1223 }
1224
1225 #[test]
1226 fn service_origin_derives_request_jwt_audience() {
1227 assert_eq!(
1228 service_origin("https://api.example.com/v1/rooms/room1").unwrap(),
1229 "https://api.example.com"
1230 );
1231 assert_eq!(
1232 service_origin("https://api.example.com:8443/path?q=1").unwrap(),
1233 "https://api.example.com:8443"
1234 );
1235 assert_eq!(
1236 service_origin("https://API.Example.com:443/").unwrap(),
1237 "https://api.example.com"
1238 );
1239 assert!(service_origin("not a url").is_err());
1240 assert!(service_origin("ftp://example.com").is_err());
1241 }
1242
1243 #[test]
1244 fn verify_request_jwt_enforces_audience_and_window() {
1245 let signer = AgentSigner::from_seed([30; 32]);
1246
1247 let wrong_audience = signer
1248 .sign_request_jwt(&RequestJwtClaims::new(
1249 signer.agent_id(),
1250 RequestBinding::new("https://api.example.com"),
1251 100,
1252 300,
1253 ))
1254 .unwrap();
1255 let context = RequestAuthContext {
1256 audience: "https://other.example.com".to_owned(),
1257 now_secs: 200,
1258 max_ttl_secs: 300,
1259 };
1260 assert!(matches!(
1261 verify_request_jwt(&wrong_audience, &context),
1262 Err(SdkError::InvalidJwtClaim("aud"))
1263 ));
1264
1265 let expired = signer
1266 .sign_request_jwt(&RequestJwtClaims::new(
1267 signer.agent_id(),
1268 RequestBinding::new("https://api.example.com"),
1269 1000,
1270 300,
1271 ))
1272 .unwrap();
1273 let later = RequestAuthContext {
1274 audience: "https://api.example.com".to_owned(),
1275 now_secs: 5000,
1276 max_ttl_secs: 300,
1277 };
1278 assert!(matches!(
1279 verify_request_jwt(&expired, &later),
1280 Err(SdkError::InvalidJwtClaim("iat/exp"))
1281 ));
1282
1283 let long_ttl = signer
1284 .sign_request_jwt(&RequestJwtClaims::new(
1285 signer.agent_id(),
1286 RequestBinding::new("https://api.example.com"),
1287 100,
1288 400,
1289 ))
1290 .unwrap();
1291 let strict = RequestAuthContext {
1292 audience: "https://api.example.com".to_owned(),
1293 now_secs: 200,
1294 max_ttl_secs: 300,
1295 };
1296 assert!(matches!(
1297 verify_request_jwt(&long_ttl, &strict),
1298 Err(SdkError::InvalidJwtClaim("ttl"))
1299 ));
1300 }
1301}