1use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::{BTreeMap, BTreeSet};
9
10use crate::error::{Result, SdkError};
11use crate::identity::{
12 validate_event_fields, validate_origin, verify_envelope, AcceptedRecord, AgentId, Envelope,
13 Event, ListResponse,
14};
15use url::Url;
16
17pub const PROTOCOL: &str = "agent-delegation/1.0";
18pub const DELEGATION_GRANT: &str = "delegation.grant";
19pub const DELEGATION_REVOKE: &str = "delegation.revoke";
20
21pub const DELEGATION_ERROR_CODES: [&str; 8] = [
23 "principal_unresolvable",
24 "principal_not_canonical",
25 "controller_not_current",
26 "delegation_not_permitted",
27 "delegation_ceiling_exceeded",
28 "credential_not_found",
29 "credential_identity_mismatch",
30 "grant_expired",
31];
32
33#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
34#[serde(rename_all = "snake_case")]
35pub enum DelegationStatus {
36 Active,
37 Suspended,
38 Expired,
39 Revoked,
40}
41
42#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
43pub struct PrincipalLink {
44 pub name: String,
45 pub url: String,
46 pub rel: String,
47}
48
49#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
53pub struct PrincipalDescriptor {
54 pub id: String,
55 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
56 pub kind: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub name: Option<String>,
59 #[serde(default, flatten)]
60 pub extra: BTreeMap<String, Value>,
61}
62
63impl PrincipalDescriptor {
64 pub fn new(id: impl Into<String>) -> Self {
65 Self {
66 id: id.into(),
67 kind: None,
68 name: None,
69 extra: BTreeMap::new(),
70 }
71 }
72}
73
74#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
76pub struct Controller {
77 pub id: AgentId,
78 pub source: String,
79 pub valid_from: i64,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub name: Option<String>,
82 #[serde(
83 default,
84 deserialize_with = "non_null_option",
85 skip_serializing_if = "Option::is_none"
86 )]
87 pub delegation: Option<DelegationAuthority>,
88 #[serde(
89 default,
90 deserialize_with = "non_null_option",
91 skip_serializing_if = "Option::is_none"
92 )]
93 pub retired_at: Option<i64>,
94 #[serde(
95 default,
96 deserialize_with = "non_null_option",
97 skip_serializing_if = "Option::is_none"
98 )]
99 pub invalid_from: Option<i64>,
100}
101
102impl Controller {
103 pub fn new(id: AgentId, source: impl Into<String>, valid_from: i64) -> Self {
105 Self {
106 id,
107 source: source.into(),
108 valid_from,
109 name: None,
110 delegation: None,
111 retired_at: None,
112 invalid_from: None,
113 }
114 }
115}
116
117#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
118#[serde(untagged)]
119pub enum DelegationAuthority {
120 Unrestricted(String),
122 Restricted(DelegationPolicy),
123}
124
125#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
126#[serde(deny_unknown_fields)]
127pub struct DelegationPolicy {
128 pub scopes: Vec<String>,
129 pub audiences: Vec<String>,
130}
131
132fn non_null_option<'de, D, T>(deserializer: D) -> std::result::Result<Option<T>, D::Error>
133where
134 D: serde::Deserializer<'de>,
135 T: Deserialize<'de>,
136{
137 T::deserialize(deserializer).map(Some)
138}
139
140#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
141pub struct PrincipalDocument {
142 pub id: String,
143 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
144 pub kind: Option<String>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub name: Option<String>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub description: Option<String>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub avatar_url: Option<String>,
151 #[serde(default, skip_serializing_if = "Vec::is_empty")]
153 pub aliases: Vec<String>,
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
155 pub links: Vec<PrincipalLink>,
156 pub protocol: String,
157 pub controllers: Vec<Controller>,
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
159 pub retired_controllers: Vec<Controller>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub delegation_service: Option<String>,
164 pub updated_at: i64,
165 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
166 pub extra: BTreeMap<String, Value>,
167}
168
169#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
170pub struct DelegationGrantPayload {
171 pub id: String,
172 pub principal_id: String,
173 pub subject: AgentId,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub relationship: Option<String>,
176 pub scopes: Vec<String>,
177 pub audiences: Vec<String>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub constraints: Option<BTreeMap<String, Value>>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub not_before: Option<i64>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub expires_at: Option<i64>,
184}
185
186impl DelegationGrantPayload {
187 pub fn new(
188 id: impl Into<String>,
189 principal_id: impl Into<String>,
190 subject: AgentId,
191 scopes: Vec<String>,
192 audiences: Vec<String>,
193 ) -> Self {
194 Self {
195 id: id.into(),
196 principal_id: principal_id.into(),
197 subject,
198 relationship: None,
199 scopes,
200 audiences,
201 constraints: None,
202 not_before: None,
203 expires_at: None,
204 }
205 }
206}
207
208#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
209pub struct DelegationRevokePayload {
210 pub id: String,
211 pub principal_id: String,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub reason: Option<String>,
214}
215
216#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
217#[serde(untagged)]
218pub enum DelegationPayload {
219 Grant(DelegationGrantPayload),
220 Revoke(DelegationRevokePayload),
221}
222
223#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
224pub struct DelegationCredential {
225 pub id: String,
226 pub protocol: String,
227 pub principal_id: String,
228 pub subject: AgentId,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub relationship: Option<String>,
231 pub scopes: Vec<String>,
232 pub audiences: Vec<String>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub constraints: Option<BTreeMap<String, Value>>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub not_before: Option<i64>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub expires_at: Option<i64>,
239 pub status: DelegationStatus,
240 pub controller: AgentId,
241 pub grant_event_id: String,
242 pub event_id: String,
243 pub accepted_at: i64,
244 pub checked_at: i64,
245}
246
247pub type DelegationRecord = AcceptedRecord<DelegationPayload>;
248
249#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
250pub struct DelegationServiceDiscovery {
251 pub protocol: String,
252 pub service: String,
253 pub endpoints: DelegationServiceEndpoints,
254 #[serde(default, skip_serializing_if = "Vec::is_empty")]
255 pub features: Vec<String>,
256}
257
258#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
259pub struct DelegationServiceEndpoints {
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub delegations: Option<String>,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub query: Option<String>,
264}
265
266#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
267pub struct DelegationQueryRequest {
268 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub subject: Option<AgentId>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub principal_id: Option<String>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub id: Option<String>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub status: Option<DelegationStatus>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub limit: Option<u32>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub cursor: Option<String>,
280}
281
282pub type DelegationQueryResponse = ListResponse<DelegationCredential>;
284
285pub type DelegationEventsResponse = ListResponse<DelegationRecord>;
287
288#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
290pub struct DelegationVerdict {
291 pub credential: DelegationCredential,
292 pub verified: bool,
294 pub usable: bool,
296 pub reasons: Vec<String>,
298}
299
300pub fn delegation_grant_event(
301 actor: AgentId,
302 created_at: i64,
303 nonce: u64,
304 payload: DelegationGrantPayload,
305) -> Event<DelegationGrantPayload> {
306 Event::new(
307 PROTOCOL,
308 DELEGATION_GRANT,
309 actor,
310 created_at,
311 nonce,
312 payload,
313 )
314}
315
316pub fn delegation_revoke_event(
317 actor: AgentId,
318 created_at: i64,
319 nonce: u64,
320 payload: DelegationRevokePayload,
321) -> Event<DelegationRevokePayload> {
322 Event::new(
323 PROTOCOL,
324 DELEGATION_REVOKE,
325 actor,
326 created_at,
327 nonce,
328 payload,
329 )
330}
331
332pub fn validate_controller(controller: &Controller, retired: bool) -> Result<()> {
333 controller.id.public_key_bytes()?;
334 if controller.source != "local" {
335 validate_origin(&controller.source)?;
336 }
337 timestamp(controller.valid_from, "valid_from")?;
338 if let Some(name) = &controller.name {
339 validate_non_empty(name, "name")?;
340 }
341 match &controller.delegation {
342 Some(DelegationAuthority::Unrestricted(value)) if value != "*" => {
343 return fail("invalid delegation policy")
344 }
345 Some(DelegationAuthority::Restricted(policy)) => {
346 strings(&policy.scopes, "scopes", false)?;
347 strings(&policy.audiences, "audiences", false)?;
348 for audience in &policy.audiences {
349 validate_audience(audience)?;
350 }
351 }
352 _ => {}
353 }
354 if retired {
355 let end = controller
356 .retired_at
357 .ok_or_else(|| SdkError::InvalidPayload("retired_at required".into()))?;
358 timestamp(end, "retired_at")?;
359 if end < controller.valid_from {
360 return fail("retired_at precedes valid_from");
361 }
362 if let Some(cutoff) = controller.invalid_from {
363 timestamp(cutoff, "invalid_from")?;
364 if cutoff < controller.valid_from || cutoff > end {
365 return fail("invalid compromise interval");
366 }
367 }
368 } else if controller.retired_at.is_some() || controller.invalid_from.is_some() {
369 return fail("current controller has retirement fields");
370 }
371 Ok(())
372}
373
374pub fn validate_principal_document(document: &PrincipalDocument) -> Result<()> {
375 validate_https_url(&document.id, "principal.id")?;
376 if document.protocol != PROTOCOL {
377 return fail("invalid principal protocol");
378 }
379 timestamp(document.updated_at, "updated_at")?;
380 let mut records: BTreeMap<String, &Controller> = BTreeMap::new();
381 let mut delegates = false;
382 for (list, retired) in [
383 (&document.controllers, false),
384 (&document.retired_controllers, true),
385 ] {
386 for record in list {
387 validate_controller(record, retired)?;
388 if records.insert(record.id.to_string(), record).is_some() {
389 return fail("duplicate controller key");
390 }
391 delegates |= record.delegation.is_some();
392 if record.valid_from > document.updated_at
393 || record.retired_at.is_some_and(|v| v > document.updated_at)
394 {
395 return fail("controller timestamp exceeds document update");
396 }
397 }
398 }
399 strings(&document.aliases, "aliases", true)?;
400 for alias in &document.aliases {
401 validate_https_url(alias, "alias")?;
402 }
403 if let Some(url) = &document.avatar_url {
404 validate_https_url(url, "avatar_url")?;
405 }
406 match &document.delegation_service {
407 Some(origin) => validate_origin(origin)?,
408 None if delegates => {
409 return fail("delegation_service is required when a controller carries delegation")
410 }
411 None => {}
412 }
413 Ok(())
414}
415
416pub fn validate_principal_resolution(
421 document: &PrincipalDocument,
422 resolved_url: &str,
423) -> Result<()> {
424 if document.id == resolved_url {
425 Ok(())
426 } else {
427 Err(SdkError::InvalidPayload(format!(
428 "principal document id {} was served at {resolved_url}",
429 document.id
430 )))
431 }
432}
433
434pub fn is_principal_alias(document: &PrincipalDocument, url: &str) -> bool {
438 document.aliases.iter().any(|alias| alias == url)
439}
440
441pub fn controller_covers_credential(
446 controller: &Controller,
447 credential: &DelegationCredential,
448) -> bool {
449 within_ceiling(controller, &credential.scopes, &credential.audiences)
450}
451
452fn within_ceiling(controller: &Controller, scopes: &[String], audiences: &[String]) -> bool {
454 match &controller.delegation {
455 None => false,
456 Some(DelegationAuthority::Restricted(policy)) => {
457 scopes.iter().all(|x| policy.scopes.contains(x))
458 && audiences.iter().all(|x| policy.audiences.contains(x))
459 }
460 Some(_) => unrestricted(controller),
461 }
462}
463
464pub fn validate_delegation_grant_payload(
465 payload: &DelegationGrantPayload,
466 created_at: Option<i64>,
467) -> Result<()> {
468 validate_delegation_id(&payload.id)?;
469 validate_https_url(&payload.principal_id, "principal_id")?;
470 payload.subject.public_key_bytes()?;
471 strings(&payload.scopes, "scopes", false)?;
472 strings(&payload.audiences, "audiences", false)?;
473 for audience in &payload.audiences {
474 validate_audience(audience)?;
475 }
476 if let Some(time) = created_at {
477 timestamp(time, "created_at")?;
478 }
479 if let Some(time) = payload.not_before {
480 timestamp(time, "not_before")?;
481 }
482 if let Some(time) = payload.expires_at {
483 timestamp(time, "expires_at")?;
484 }
485 if let Some(expires_at) = payload.expires_at {
486 if matches!(payload.not_before, Some(not_before) if expires_at <= not_before) {
487 return Err(SdkError::protocol(
488 "grant_expired",
489 "expires_at must be greater than not_before",
490 ));
491 }
492 if matches!(created_at, Some(created_at) if expires_at <= created_at) {
493 return Err(SdkError::protocol(
494 "grant_expired",
495 "expires_at must be greater than created_at",
496 ));
497 }
498 }
499 Ok(())
500}
501
502pub fn validate_delegation_query_request(
508 request: &DelegationQueryRequest,
509 allow_enumeration: bool,
510) -> Result<()> {
511 if allow_enumeration {
512 if request.subject.is_none() && request.principal_id.is_none() {
513 return Err(SdkError::protocol(
514 "invalid_request",
515 "query must include at least one of subject or principal_id",
516 ));
517 }
518 } else if request.subject.is_none() || request.principal_id.is_none() {
519 return Err(SdkError::protocol(
520 "invalid_request",
521 "public query must include both subject and principal_id",
522 ));
523 }
524 if let Some(subject) = &request.subject {
525 subject.public_key_bytes()?;
526 }
527 if let Some(principal_id) = &request.principal_id {
528 validate_https_url(principal_id, "principal_id")?;
529 }
530 if let Some(id) = &request.id {
531 validate_delegation_id(id)?;
532 }
533 if matches!(request.limit, Some(0)) {
534 return Err(SdkError::protocol(
535 "invalid_request",
536 "limit must be a positive integer",
537 ));
538 }
539 Ok(())
540}
541
542pub fn validate_delegation_revoke_payload(payload: &DelegationRevokePayload) -> Result<()> {
543 validate_delegation_id(&payload.id)?;
544 validate_https_url(&payload.principal_id, "principal_id")
545}
546
547pub fn validate_delegation_id(value: &str) -> Result<()> {
550 let valid = (1..=128).contains(&value.len())
551 && value
552 .bytes()
553 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'~' | b'-'))
554 && !matches!(value, "." | "..");
555 if valid {
556 Ok(())
557 } else {
558 fail("delegation id must match [A-Za-z0-9._~-]{1,128} and not be a dot segment")
559 }
560}
561
562pub fn validate_delegation_envelope(envelope: &Envelope<DelegationPayload>) -> Result<()> {
563 verify_envelope(envelope)?;
564 validate_event_fields(&envelope.event, &[])?;
566 if envelope.event.protocol != PROTOCOL {
567 return Err(SdkError::InvalidEventProtocol {
568 expected: PROTOCOL.to_owned(),
569 actual: envelope.event.protocol.clone(),
570 });
571 }
572 match (&envelope.event.kind[..], &envelope.event.payload) {
573 (DELEGATION_GRANT, DelegationPayload::Grant(payload)) => {
574 validate_delegation_grant_payload(payload, Some(envelope.event.created_at))
575 }
576 (DELEGATION_REVOKE, DelegationPayload::Revoke(payload)) => {
577 validate_delegation_revoke_payload(payload)
578 }
579 (DELEGATION_GRANT, _) | (DELEGATION_REVOKE, _) => Err(SdkError::InvalidPayload(
580 "delegation event type does not match payload shape".to_owned(),
581 )),
582 _ => Err(SdkError::InvalidEventType {
583 expected: format!("{DELEGATION_GRANT} or {DELEGATION_REVOKE}"),
584 actual: envelope.event.kind.clone(),
585 }),
586 }
587}
588
589pub fn materialize_delegation_credential(
592 envelope: &Envelope<DelegationPayload>,
593 status: DelegationStatus,
594 accepted_at: i64,
595 previous: Option<&DelegationCredential>,
596) -> Result<DelegationCredential> {
597 validate_delegation_envelope(envelope)?;
598 timestamp(accepted_at, "accepted_at")?;
599 check_previous(&envelope.event, previous)?;
600 if previous.is_some_and(|p| accepted_at < p.accepted_at) {
601 return fail("acceptance order reversed");
602 }
603 match &envelope.event.payload {
604 DelegationPayload::Revoke(_) => {
605 let mut result = previous
606 .ok_or_else(|| {
607 SdkError::protocol(
608 "credential_not_found",
609 "revocation requires previous credential",
610 )
611 })?
612 .clone();
613 result.controller = envelope.event.actor.clone();
614 result.status = DelegationStatus::Revoked;
615 result.event_id = envelope.hash.clone();
616 result.accepted_at = accepted_at;
617 result.checked_at = accepted_at;
618 Ok(result)
619 }
620 DelegationPayload::Grant(payload) => {
621 if payload.expires_at.is_some_and(|t| t <= accepted_at) {
622 return Err(SdkError::protocol(
623 "grant_expired",
624 "grant expired at acceptance",
625 ));
626 }
627 Ok(DelegationCredential {
628 id: payload.id.clone(),
629 protocol: PROTOCOL.into(),
630 principal_id: payload.principal_id.clone(),
631 subject: payload.subject.clone(),
632 relationship: payload.relationship.clone(),
633 scopes: payload.scopes.clone(),
634 audiences: payload.audiences.clone(),
635 constraints: payload.constraints.clone(),
636 not_before: payload.not_before,
637 expires_at: payload.expires_at,
638 status,
639 controller: envelope.event.actor.clone(),
640 grant_event_id: envelope.hash.clone(),
641 event_id: envelope.hash.clone(),
642 accepted_at,
643 checked_at: accepted_at,
644 })
645 }
646 }
647}
648
649pub fn validate_delegation_event_authority(
652 event: &Event<DelegationPayload>,
653 document: &PrincipalDocument,
654 accepted_at: i64,
655 previous: Option<&DelegationCredential>,
656) -> Result<()> {
657 check_authority(event, document, accepted_at, previous, false)
658}
659
660pub fn validate_delegation_acceptance(
662 envelope: &Envelope<DelegationPayload>,
663 document: &PrincipalDocument,
664 resolved_url: &str,
665 accepted_at: i64,
666 previous: Option<&DelegationCredential>,
667) -> Result<()> {
668 validate_delegation_envelope(envelope)?;
669 validate_principal_resolution(document, resolved_url)?;
670 check_authority(&envelope.event, document, accepted_at, previous, false)
671}
672
673pub fn validate_historical_delegation(
676 record: &DelegationRecord,
677 document: &PrincipalDocument,
678 resolved_url: &str,
679 previous: Option<&DelegationCredential>,
680) -> Result<()> {
681 validate_delegation_envelope(&record.envelope)?;
682 validate_principal_resolution(document, resolved_url)?;
683 check_authority(
684 &record.envelope.event,
685 document,
686 record.accepted_at,
687 previous,
688 true,
689 )
690}
691
692pub fn validate_controller_enumeration(
694 document: &PrincipalDocument,
695 actor: &AgentId,
696 now: i64,
697 credential: &DelegationCredential,
698) -> Result<()> {
699 validate_principal_document(document)?;
700 timestamp(now, "now")?;
701 strings(&credential.scopes, "scopes", false)?;
702 strings(&credential.audiences, "audiences", false)?;
703 let c = document
704 .controllers
705 .iter()
706 .find(|c| &c.id == actor)
707 .filter(|c| now >= c.valid_from)
708 .ok_or_else(|| {
709 SdkError::protocol("controller_not_current", "controller cannot enumerate")
710 })?;
711 if c.delegation.is_none() {
712 return Err(SdkError::protocol(
713 "delegation_not_permitted",
714 "controller cannot enumerate",
715 ));
716 }
717 if !controller_covers_credential(c, credential) {
718 return Err(SdkError::protocol(
719 "delegation_ceiling_exceeded",
720 "credential is outside the controller's ceiling",
721 ));
722 }
723 Ok(())
724}
725
726pub fn validate_delegation_use(
729 credential: &DelegationCredential,
730 audience: &str,
731 now: i64,
732) -> Result<()> {
733 validate_audience(audience)?;
734 timestamp(now, "now")?;
735 validate_delegation_grant_payload(&grant_from_credential(credential), None)?;
736 if credential.protocol != PROTOCOL
737 || credential.status != DelegationStatus::Active
738 || !credential.audiences.iter().any(|a| a == audience)
739 || credential.not_before.is_some_and(|t| now < t)
740 || credential.expires_at.is_some_and(|t| now >= t)
741 {
742 return fail("delegation is not usable");
743 }
744 Ok(())
745}
746
747pub fn verify_delegation_credential(
756 credential: &DelegationCredential,
757 records: &[DelegationRecord],
758 document: &PrincipalDocument,
759 resolved_url: &str,
760 audience: &str,
761 now: i64,
762) -> DelegationVerdict {
763 let mut reasons = Vec::new();
764 if let Err(error) = latest_grant_matches(credential, records, document, resolved_url) {
765 reasons.push(error.to_string());
766 }
767 let verified = reasons.is_empty();
768 if credential.status != DelegationStatus::Active {
769 let status = serde_json::to_value(credential.status)
770 .ok()
771 .and_then(|value| value.as_str().map(str::to_owned))
772 .unwrap_or_default();
773 reasons.push(format!("status is {status}"));
774 }
775 if !credential.audiences.iter().any(|a| a == audience) {
776 reasons.push(format!("audience {audience} is not granted"));
777 }
778 if credential.not_before.is_some_and(|t| now < t) {
779 reasons.push("not yet valid".to_owned());
780 }
781 if credential.expires_at.is_some_and(|t| now >= t) {
782 reasons.push("expired".to_owned());
783 }
784 DelegationVerdict {
785 credential: credential.clone(),
786 verified,
787 usable: reasons.is_empty(),
788 reasons,
789 }
790}
791
792fn latest_grant_matches(
795 credential: &DelegationCredential,
796 records: &[DelegationRecord],
797 document: &PrincipalDocument,
798 resolved_url: &str,
799) -> Result<()> {
800 let record = records
801 .iter()
802 .find(|record| record.envelope.hash == credential.grant_event_id)
803 .ok_or_else(|| SdkError::InvalidPayload("latest grant record is missing".into()))?;
804 validate_historical_delegation(record, document, resolved_url, None)?;
805 let DelegationPayload::Grant(grant) = &record.envelope.event.payload else {
806 return fail("grant_event_id does not name a grant");
807 };
808 if credential.protocol != PROTOCOL
809 || serde_jcs::to_vec(grant)? != serde_jcs::to_vec(&grant_from_credential(credential))?
810 {
811 return fail("credential does not match its latest grant");
812 }
813 if credential.event_id == credential.grant_event_id {
814 if credential.accepted_at != record.accepted_at
815 || credential.controller != record.envelope.event.actor
816 || credential.status == DelegationStatus::Revoked
817 {
818 return fail("credential does not match its latest grant");
819 }
820 } else if credential.status != DelegationStatus::Revoked {
821 return fail("a credential last changed by a revocation must be revoked");
822 }
823 Ok(())
824}
825
826pub fn audit_delegation_history(
832 credential: &DelegationCredential,
833 records: &[DelegationRecord],
834 document: &PrincipalDocument,
835 resolved_url: &str,
836) -> Result<()> {
837 if records.is_empty() {
838 return fail("no accepted records");
839 }
840 let mut replayed: Option<DelegationCredential> = None;
841 for record in records {
842 validate_historical_delegation(record, document, resolved_url, replayed.as_ref())?;
843 replayed = Some(materialize_delegation_credential(
844 &record.envelope,
845 DelegationStatus::Active,
846 record.accepted_at,
847 replayed.as_ref(),
848 )?);
849 }
850 let r = replayed.expect("at least one record");
851 if serde_jcs::to_vec(&grant_from_credential(&r))?
853 != serde_jcs::to_vec(&grant_from_credential(credential))?
854 || r.protocol != credential.protocol
855 || r.accepted_at != credential.accepted_at
856 || r.event_id != credential.event_id
857 || r.grant_event_id != credential.grant_event_id
858 || r.controller != credential.controller
859 || (r.status == DelegationStatus::Revoked)
860 != (credential.status == DelegationStatus::Revoked)
861 {
862 return fail("credential does not match its accepted records");
863 }
864 Ok(())
865}
866
867fn grant_from_credential(credential: &DelegationCredential) -> DelegationGrantPayload {
868 DelegationGrantPayload {
869 id: credential.id.clone(),
870 principal_id: credential.principal_id.clone(),
871 subject: credential.subject.clone(),
872 relationship: credential.relationship.clone(),
873 scopes: credential.scopes.clone(),
874 audiences: credential.audiences.clone(),
875 constraints: credential.constraints.clone(),
876 not_before: credential.not_before,
877 expires_at: credential.expires_at,
878 }
879}
880
881fn event_identity(event: &Event<DelegationPayload>) -> (&str, &str) {
882 match &event.payload {
883 DelegationPayload::Grant(p) => (&p.id, &p.principal_id),
884 DelegationPayload::Revoke(p) => (&p.id, &p.principal_id),
885 }
886}
887fn check_previous(
888 event: &Event<DelegationPayload>,
889 previous: Option<&DelegationCredential>,
890) -> Result<()> {
891 let (id, principal) = event_identity(event);
892 if let Some(previous) = previous {
893 strings(&previous.scopes, "previous.scopes", false)?;
894 strings(&previous.audiences, "previous.audiences", false)?;
895 timestamp(previous.accepted_at, "previous.accepted_at")?;
896 let same_subject = match &event.payload {
897 DelegationPayload::Grant(p) => p.subject == previous.subject,
898 DelegationPayload::Revoke(_) => true,
899 };
900 if previous.id != id
901 || previous.principal_id != principal
902 || previous.protocol != PROTOCOL
903 || !same_subject
904 {
905 return Err(SdkError::protocol(
906 "credential_identity_mismatch",
907 "credential principal, subject, and protocol are immutable",
908 ));
909 }
910 } else if event.kind == DELEGATION_REVOKE {
911 return Err(SdkError::protocol(
912 "credential_not_found",
913 "revocation requires previous credential",
914 ));
915 }
916 Ok(())
917}
918fn unrestricted(c: &Controller) -> bool {
919 matches!(&c.delegation, Some(DelegationAuthority::Unrestricted(v)) if v == "*")
920}
921fn check_authority(
922 event: &Event<DelegationPayload>,
923 document: &PrincipalDocument,
924 accepted_at: i64,
925 previous: Option<&DelegationCredential>,
926 historical: bool,
927) -> Result<()> {
928 validate_principal_document(document)?;
929 timestamp(accepted_at, "accepted_at")?;
930 timestamp(event.created_at, "created_at")?;
931 event.actor.public_key_bytes()?;
932 if event.protocol != PROTOCOL {
933 return fail("invalid event protocol");
934 }
935 match (&event.kind[..], &event.payload) {
936 (DELEGATION_GRANT, DelegationPayload::Grant(p)) => {
937 validate_delegation_grant_payload(p, Some(event.created_at))?
938 }
939 (DELEGATION_REVOKE, DelegationPayload::Revoke(p)) => validate_delegation_revoke_payload(p)?,
940 _ => return fail("event type does not match payload"),
941 }
942 if event_identity(event).1 != document.id {
943 return Err(SdkError::protocol(
944 "principal_not_canonical",
945 "principal mismatch",
946 ));
947 }
948 let c = document
949 .controllers
950 .iter()
951 .chain(document.retired_controllers.iter().filter(|_| historical))
952 .find(|c| c.id == event.actor)
953 .ok_or_else(|| {
954 SdkError::protocol(
955 "controller_not_current",
956 "actor is not a controller of the principal",
957 )
958 })?;
959 if c.delegation.is_none() {
960 return Err(SdkError::protocol(
961 "delegation_not_permitted",
962 "actor has no delegation authority",
963 ));
964 }
965 for time in [event.created_at, accepted_at] {
966 if time < c.valid_from
967 || c.retired_at.is_some_and(|t| time >= t)
968 || c.invalid_from.is_some_and(|t| time >= t)
969 {
970 return Err(SdkError::protocol(
971 "controller_not_current",
972 "outside controller authority interval",
973 ));
974 }
975 }
976 check_previous(event, previous)?;
977 if let Some(previous) = previous {
978 if accepted_at < previous.accepted_at {
979 return fail("acceptance order reversed");
980 }
981 if !controller_covers_credential(c, previous) {
982 return Err(SdkError::protocol(
983 "delegation_ceiling_exceeded",
984 "credential is outside the controller's ceiling",
985 ));
986 }
987 }
988 if let DelegationPayload::Grant(payload) = &event.payload {
989 if payload.expires_at.is_some_and(|t| t <= accepted_at) {
990 return Err(SdkError::protocol(
991 "grant_expired",
992 "grant expired at acceptance",
993 ));
994 }
995 if !within_ceiling(c, &payload.scopes, &payload.audiences) {
996 return Err(SdkError::protocol(
997 "delegation_ceiling_exceeded",
998 "grant exceeds controller delegation policy",
999 ));
1000 }
1001 }
1002 Ok(())
1003}
1004fn fail<T>(message: &str) -> Result<T> {
1005 Err(SdkError::InvalidPayload(message.into()))
1006}
1007fn timestamp(value: i64, field: &str) -> Result<()> {
1008 if !(0..=9_007_199_254_740_991).contains(&value) {
1009 return fail(&format!("{field} must be a non-negative safe integer"));
1010 }
1011 Ok(())
1012}
1013fn strings(values: &[String], field: &str, empty: bool) -> Result<()> {
1014 if !empty && values.is_empty() {
1015 return fail(&format!("{field} must be a non-empty array"));
1016 }
1017 let mut seen = BTreeSet::new();
1018 for value in values {
1019 validate_non_empty(value, field)?;
1020 if value == "*" || !seen.insert(value) {
1021 return fail(&format!("{field} has wildcard or duplicates"));
1022 }
1023 }
1024 Ok(())
1025}
1026pub fn validate_audience(value: &str) -> Result<()> {
1029 if value.starts_with(crate::identity::AGENT_ID_PREFIX) {
1030 value.parse::<AgentId>().map(|_| ())
1031 } else {
1032 validate_origin(value)
1033 }
1034}
1035
1036fn validate_https_url(value: &str, field: &str) -> Result<()> {
1037 let parsed = Url::parse(value)
1038 .map_err(|_| SdkError::InvalidPayload(format!("{field} must be an HTTPS URL")))?;
1039 if parsed.scheme() == "https" && parsed.host_str().is_some() {
1040 Ok(())
1041 } else {
1042 Err(SdkError::InvalidPayload(format!(
1043 "{field} must be an HTTPS URL"
1044 )))
1045 }
1046}
1047
1048fn validate_non_empty(value: &str, field: &str) -> Result<()> {
1049 if value.trim().is_empty() {
1050 Err(SdkError::InvalidPayload(format!(
1051 "{field} must not be empty"
1052 )))
1053 } else {
1054 Ok(())
1055 }
1056}
1057
1058#[cfg(test)]
1059mod tests {
1060 use super::*;
1061 use crate::identity::AgentSigner;
1062
1063 #[test]
1064 fn validates_and_materializes_grant() {
1065 let controller = AgentSigner::from_seed([31; 32]);
1066 let subject = AgentSigner::from_seed([32; 32]);
1067 let mut payload = DelegationGrantPayload::new(
1068 "del_01J8ZM7A3G2T9B4Q6X8R0N1P2Q",
1069 "https://api.al.ink/d9c6a99cne5g00a6scn0",
1070 subject.agent_id(),
1071 vec!["inbox.screen".to_owned(), "meeting.propose".to_owned()],
1072 vec!["https://dmsg.net".to_owned()],
1073 );
1074 payload.relationship = Some("primary_delegate".to_owned());
1075 payload.not_before = Some(1_779_753_600_000);
1076 payload.expires_at = Some(1_790_000_000_000);
1077 let envelope = controller
1078 .sign_event(delegation_grant_event(
1079 controller.agent_id(),
1080 1_779_753_600_000,
1081 1,
1082 payload,
1083 ))
1084 .unwrap();
1085 let envelope_value = serde_json::to_value(&envelope).unwrap();
1086 let envelope_for_validation: Envelope<DelegationPayload> =
1087 serde_json::from_value(envelope_value).unwrap();
1088
1089 validate_delegation_envelope(&envelope_for_validation).unwrap();
1090 let credential = materialize_delegation_credential(
1091 &envelope_for_validation,
1092 DelegationStatus::Active,
1093 1_779_753_600_000,
1094 None,
1095 )
1096 .unwrap();
1097
1098 assert_eq!(credential.protocol, PROTOCOL);
1099 assert_eq!(credential.controller, controller.agent_id());
1100 assert_eq!(credential.subject, subject.agent_id());
1101 assert_eq!(
1102 credential.principal_id,
1103 "https://api.al.ink/d9c6a99cne5g00a6scn0"
1104 );
1105 assert_eq!(credential.event_id, envelope.hash);
1106 }
1107
1108 #[test]
1109 fn validates_revoke_and_rejects_invalid_grants() {
1110 let controller = AgentSigner::from_seed([33; 32]);
1111 let envelope = controller
1112 .sign_event(delegation_revoke_event(
1113 controller.agent_id(),
1114 1_779_753_700_000,
1115 2,
1116 DelegationRevokePayload {
1117 id: "del_01J8ZM7A3G2T9B4Q6X8R0N1P2Q".to_owned(),
1118 principal_id: "https://api.al.ink/d9c6a99cne5g00a6scn0".to_owned(),
1119 reason: Some("rotated_primary_agent".to_owned()),
1120 },
1121 ))
1122 .unwrap();
1123 let envelope: Envelope<DelegationPayload> =
1124 serde_json::from_value(serde_json::to_value(envelope).unwrap()).unwrap();
1125 validate_delegation_envelope(&envelope).unwrap();
1126
1127 let invalid = DelegationGrantPayload::new(
1128 "del",
1129 "http://example.com",
1130 controller.agent_id(),
1131 Vec::new(),
1132 vec!["https://dmsg.net".to_owned()],
1133 );
1134 assert!(validate_delegation_grant_payload(&invalid, None).is_err());
1135 }
1136
1137 #[test]
1138 fn grant_expiry_checked_against_not_before_and_created_at() {
1139 let controller = AgentSigner::from_seed([36; 32]);
1140 let base = DelegationGrantPayload::new(
1141 "del_1",
1142 "https://api.al.ink/d9c6a99cne5g00a6scn0",
1143 controller.agent_id(),
1144 vec!["inbox.screen".to_owned()],
1145 vec!["https://dmsg.net".to_owned()],
1146 );
1147
1148 let mut same_as_not_before = base.clone();
1149 same_as_not_before.not_before = Some(2000);
1150 same_as_not_before.expires_at = Some(2000);
1151 assert!(validate_delegation_grant_payload(&same_as_not_before, Some(1000)).is_err());
1152
1153 let mut before_created_at = base.clone();
1154 before_created_at.not_before = Some(500);
1155 before_created_at.expires_at = Some(800);
1156 assert!(validate_delegation_grant_payload(&before_created_at, Some(1000)).is_err());
1157
1158 let mut valid = base;
1159 valid.not_before = Some(500);
1160 valid.expires_at = Some(1500);
1161 validate_delegation_grant_payload(&valid, Some(1000)).unwrap();
1162 }
1163
1164 #[test]
1165 fn public_delegation_queries_carry_both_subject_and_principal() {
1166 let subject = AgentSigner::from_seed([37; 32]).agent_id();
1167 let principal_id = "https://api.al.ink/d9c6a99cne5g00a6scn0".to_owned();
1168 let public = DelegationQueryRequest {
1169 subject: Some(subject.clone()),
1170 principal_id: Some(principal_id.clone()),
1171 limit: Some(20),
1172 ..DelegationQueryRequest::default()
1173 };
1174 validate_delegation_query_request(&public, false).unwrap();
1175
1176 for one_sided in [
1178 DelegationQueryRequest {
1179 subject: Some(subject.clone()),
1180 ..DelegationQueryRequest::default()
1181 },
1182 DelegationQueryRequest {
1183 principal_id: Some(principal_id),
1184 ..DelegationQueryRequest::default()
1185 },
1186 ] {
1187 assert!(validate_delegation_query_request(&one_sided, false).is_err());
1188 validate_delegation_query_request(&one_sided, true).unwrap();
1189 }
1190
1191 assert!(
1192 validate_delegation_query_request(&DelegationQueryRequest::default(), true).is_err()
1193 );
1194 assert!(validate_delegation_query_request(
1195 &DelegationQueryRequest {
1196 limit: Some(0),
1197 ..public
1198 },
1199 false
1200 )
1201 .is_err());
1202 }
1203
1204 #[test]
1205 fn principal_documents_bind_controllers_only_at_their_own_id() {
1206 let controller = AgentSigner::from_seed([38; 32]);
1207 let document = PrincipalDocument {
1208 id: "https://api.al.ink/d9c6a99cne5g00a6scn0".to_owned(),
1209 kind: None,
1210 name: None,
1211 description: None,
1212 avatar_url: None,
1213 aliases: vec!["https://al.ink/yan".to_owned()],
1214 links: Vec::new(),
1215 protocol: PROTOCOL.into(),
1216 retired_controllers: vec![],
1217 controllers: vec![Controller::new(controller.agent_id(), "local", 0)],
1218 delegation_service: None,
1219 updated_at: 1000,
1220 extra: BTreeMap::new(),
1221 };
1222
1223 validate_principal_resolution(&document, "https://api.al.ink/d9c6a99cne5g00a6scn0")
1224 .unwrap();
1225 assert!(
1227 validate_principal_resolution(&document, "https://impostor.example.com/yan").is_err()
1228 );
1229
1230 assert!(is_principal_alias(&document, "https://al.ink/yan"));
1231 assert!(!is_principal_alias(
1232 &document,
1233 "https://impostor.example.com/yan"
1234 ));
1235 }
1236
1237 #[test]
1238 fn validates_principal_document() {
1239 let controller = AgentSigner::from_seed([34; 32]);
1240 let document = PrincipalDocument {
1241 id: "https://profiles.example.com/org/acme".to_owned(),
1242 kind: None,
1243 name: None,
1244 description: None,
1245 avatar_url: None,
1246 aliases: vec!["https://profiles.example.com/acme".to_owned()],
1247 links: Vec::new(),
1248 protocol: PROTOCOL.into(),
1249 retired_controllers: vec![],
1250 controllers: vec![Controller::new(controller.agent_id(), "local", 0)],
1251 delegation_service: Some("https://profiles.example.com".to_owned()),
1252 updated_at: 1000,
1253 extra: BTreeMap::new(),
1254 };
1255 validate_principal_document(&document).unwrap();
1256
1257 let mut invalid = document.clone();
1258 invalid.protocol = "old-draft".into();
1259 assert!(validate_principal_document(&invalid).is_err());
1260
1261 let mut delegating = document;
1263 delegating.controllers[0].delegation = Some(DelegationAuthority::Unrestricted("*".into()));
1264 validate_principal_document(&delegating).unwrap();
1265 delegating.delegation_service = None;
1266 assert!(validate_principal_document(&delegating).is_err());
1267 }
1268
1269 #[test]
1270 fn rejects_malformed_https_like_urls() {
1271 let signer = AgentSigner::from_seed([35; 32]);
1272 for principal_id in ["https://", "https://[::1", "http://example.com"] {
1273 let payload = DelegationGrantPayload::new(
1274 "del",
1275 principal_id,
1276 signer.agent_id(),
1277 vec!["scope".to_owned()],
1278 vec!["https://dmsg.net".to_owned()],
1279 );
1280 assert!(validate_delegation_grant_payload(&payload, None).is_err());
1281 }
1282 }
1283}