@@ -41,6 +41,20 @@ impl Capability {
4141 self . constraints = Some ( constraints) ;
4242 self
4343 }
44+
45+ /// Returns `true` if `self` is a valid attenuation of `parent`.
46+ ///
47+ /// A delegated capability is only valid if it is at most as permissive as
48+ /// the parent capability backing it. `"*"` on the **parent**'s resource or
49+ /// action field and `repo/admin` in the parent's action position act as
50+ /// wildcards that cover any delegated value; wildcards on `self` carry no
51+ /// special meaning.
52+ pub fn is_attenuated_by ( & self , parent : & Capability ) -> bool {
53+ let resource_ok = parent. with == self . with || parent. with == "*" ;
54+ let action_ok =
55+ parent. can == self . can || parent. can == "*" || parent. can == caps:: REPO_ADMIN ;
56+ resource_ok && action_ok
57+ }
4458}
4559
4660/// Well-known gitlawb capability strings.
@@ -132,6 +146,26 @@ impl Ucan {
132146 }
133147 }
134148
149+ /// Check if this UCAN's not-before time is in the future (token not yet valid).
150+ pub fn is_before_valid ( & self ) -> bool {
151+ if let Some ( nbf) = self . payload . nbf {
152+ Utc :: now ( ) . timestamp ( ) < nbf
153+ } else {
154+ false
155+ }
156+ }
157+
158+ /// Verify this UCAN's audience matches `expected`.
159+ pub fn verify_audience ( & self , expected : & Did ) -> Result < ( ) > {
160+ if & self . payload . aud != expected {
161+ return Err ( Error :: Ucan ( format ! (
162+ "audience mismatch: expected {expected}, got {}" ,
163+ self . payload. aud
164+ ) ) ) ;
165+ }
166+ Ok ( ( ) )
167+ }
168+
135169 /// Verify the signature on this UCAN.
136170 pub fn verify_signature ( & self ) -> Result < ( ) > {
137171 use crate :: identity:: verify;
@@ -217,6 +251,10 @@ impl Ucan {
217251 return Err ( Error :: Ucan ( "token is expired" . to_string ( ) ) ) ;
218252 }
219253
254+ if self . is_before_valid ( ) {
255+ return Err ( Error :: Ucan ( "token is not yet valid" . to_string ( ) ) ) ;
256+ }
257+
220258 for proof_token in & self . payload . prf {
221259 let proof = Self :: decode ( proof_token)
222260 . map_err ( |e| Error :: Ucan ( format ! ( "failed to decode proof: {e}" ) ) ) ?;
@@ -229,6 +267,17 @@ impl Ucan {
229267 ) ) ) ;
230268 }
231269
270+ // Every delegated capability must be covered by the proof (attenuation).
271+ for cap in & self . payload . att {
272+ let covered = proof. payload . att . iter ( ) . any ( |p| cap. is_attenuated_by ( p) ) ;
273+ if !covered {
274+ return Err ( Error :: Ucan ( format ! (
275+ "capability attenuation violated: '{}' on '{}' not covered by proof" ,
276+ cap. can, cap. with
277+ ) ) ) ;
278+ }
279+ }
280+
232281 // Verify the proof's signature and chain recursively
233282 proof. verify_chain ( ) ?;
234283 }
@@ -399,4 +448,157 @@ mod tests {
399448 let err = delegated. verify_chain ( ) . unwrap_err ( ) ;
400449 assert ! ( err. to_string( ) . contains( "expired" ) ) ;
401450 }
451+
452+ #[ test]
453+ fn is_before_valid_future_nbf ( ) {
454+ let issuer = Keypair :: generate ( ) ;
455+ let audience = Keypair :: generate ( ) . did ( ) ;
456+ let nbf_future = chrono:: Utc :: now ( ) + chrono:: Duration :: hours ( 1 ) ;
457+
458+ let payload = UcanPayload {
459+ ucan : "1.0.0" . to_string ( ) ,
460+ iss : issuer. did ( ) ,
461+ aud : audience,
462+ att : vec ! [ ] ,
463+ exp : None ,
464+ nbf : Some ( nbf_future. timestamp ( ) ) ,
465+ prf : vec ! [ ] ,
466+ } ;
467+ let signing_bytes = serde_json:: to_vec ( & payload) . unwrap ( ) ;
468+ let sig = issuer. sign_b64 ( & signing_bytes) ;
469+ let ucan = Ucan { payload, s : sig } ;
470+
471+ assert ! ( ucan. is_before_valid( ) ) ;
472+ let err = ucan. verify_chain ( ) . unwrap_err ( ) ;
473+ assert ! ( err. to_string( ) . contains( "not yet valid" ) ) ;
474+ }
475+
476+ #[ test]
477+ fn is_before_valid_past_nbf ( ) {
478+ let issuer = Keypair :: generate ( ) ;
479+ let audience = Keypair :: generate ( ) . did ( ) ;
480+ let nbf_past = chrono:: Utc :: now ( ) - chrono:: Duration :: hours ( 1 ) ;
481+
482+ let payload = UcanPayload {
483+ ucan : "1.0.0" . to_string ( ) ,
484+ iss : issuer. did ( ) ,
485+ aud : audience,
486+ att : vec ! [ Capability :: new( "gitlawb://repos/test" , caps:: GIT_PUSH ) ] ,
487+ exp : None ,
488+ nbf : Some ( nbf_past. timestamp ( ) ) ,
489+ prf : vec ! [ ] ,
490+ } ;
491+ let signing_bytes = serde_json:: to_vec ( & payload) . unwrap ( ) ;
492+ let sig = issuer. sign_b64 ( & signing_bytes) ;
493+ let ucan = Ucan { payload, s : sig } ;
494+
495+ assert ! ( !ucan. is_before_valid( ) ) ;
496+ ucan. verify_chain ( ) . unwrap ( ) ;
497+ }
498+
499+ #[ test]
500+ fn verify_audience_matches ( ) {
501+ let issuer = Keypair :: generate ( ) ;
502+ let audience = Keypair :: generate ( ) . did ( ) ;
503+ let ucan = Ucan :: issue ( & issuer, audience. clone ( ) , vec ! [ ] , None ) . unwrap ( ) ;
504+ ucan. verify_audience ( & audience) . unwrap ( ) ;
505+ }
506+
507+ #[ test]
508+ fn verify_audience_mismatch ( ) {
509+ let issuer = Keypair :: generate ( ) ;
510+ let audience = Keypair :: generate ( ) . did ( ) ;
511+ let wrong = Keypair :: generate ( ) . did ( ) ;
512+ let ucan = Ucan :: issue ( & issuer, audience, vec ! [ ] , None ) . unwrap ( ) ;
513+ let err = ucan. verify_audience ( & wrong) . unwrap_err ( ) ;
514+ assert ! ( err. to_string( ) . contains( "audience mismatch" ) ) ;
515+ }
516+
517+ #[ test]
518+ fn attenuation_valid_subset ( ) {
519+ let alice = Keypair :: generate ( ) ;
520+ let bob = Keypair :: generate ( ) ;
521+ let charlie = Keypair :: generate ( ) ;
522+
523+ // Alice grants Bob push on a specific repo
524+ let root = Ucan :: issue (
525+ & alice,
526+ bob. did ( ) ,
527+ vec ! [ Capability :: new( "gitlawb://repos/org/repo" , caps:: GIT_PUSH ) ] ,
528+ None ,
529+ )
530+ . unwrap ( ) ;
531+
532+ // Bob delegates the same capability (exact subset) to Charlie
533+ let delegated = Ucan :: delegate (
534+ & bob,
535+ charlie. did ( ) ,
536+ vec ! [ Capability :: new( "gitlawb://repos/org/repo" , caps:: GIT_PUSH ) ] ,
537+ None ,
538+ & root,
539+ )
540+ . unwrap ( ) ;
541+
542+ delegated. verify_chain ( ) . unwrap ( ) ;
543+ }
544+
545+ #[ test]
546+ fn attenuation_exceeds_parent_is_rejected ( ) {
547+ let alice = Keypair :: generate ( ) ;
548+ let bob = Keypair :: generate ( ) ;
549+ let charlie = Keypair :: generate ( ) ;
550+
551+ // Alice grants Bob push on one repo only
552+ let root = Ucan :: issue (
553+ & alice,
554+ bob. did ( ) ,
555+ vec ! [ Capability :: new( "gitlawb://repos/org/repo" , caps:: GIT_PUSH ) ] ,
556+ None ,
557+ )
558+ . unwrap ( ) ;
559+
560+ // Bob tries to delegate merge (not in the original grant) to Charlie
561+ let delegated = Ucan :: delegate (
562+ & bob,
563+ charlie. did ( ) ,
564+ vec ! [ Capability :: new( "gitlawb://repos/org/repo" , caps:: PR_MERGE ) ] ,
565+ None ,
566+ & root,
567+ )
568+ . unwrap ( ) ;
569+
570+ let err = delegated. verify_chain ( ) . unwrap_err ( ) ;
571+ assert ! ( err. to_string( ) . contains( "attenuation violated" ) ) ;
572+ }
573+
574+ #[ test]
575+ fn attenuation_repo_admin_covers_all ( ) {
576+ let alice = Keypair :: generate ( ) ;
577+ let bob = Keypair :: generate ( ) ;
578+ let charlie = Keypair :: generate ( ) ;
579+
580+ // Alice grants Bob repo/admin (superpower)
581+ let root = Ucan :: issue (
582+ & alice,
583+ bob. did ( ) ,
584+ vec ! [ Capability :: new(
585+ "gitlawb://repos/org/repo" ,
586+ caps:: REPO_ADMIN ,
587+ ) ] ,
588+ None ,
589+ )
590+ . unwrap ( ) ;
591+
592+ // Bob delegates a more specific capability — covered by repo/admin
593+ let delegated = Ucan :: delegate (
594+ & bob,
595+ charlie. did ( ) ,
596+ vec ! [ Capability :: new( "gitlawb://repos/org/repo" , caps:: GIT_PUSH ) ] ,
597+ None ,
598+ & root,
599+ )
600+ . unwrap ( ) ;
601+
602+ delegated. verify_chain ( ) . unwrap ( ) ;
603+ }
402604}
0 commit comments