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