Skip to main content

aph_core/
credential_access_envelope.rs

1//! `CredentialAccessNotarizationEnvelope` — APH envelope variant for
2//! agent-credential-access authorization.
3//!
4//! Sibling to [`crate::envelope::NotarizationEnvelope`]: shares the W3C
5//! VC 2.0 + JSON-LD wire shape, but the credential subject describes a
6//! CREDENTIAL ACCESS request (agent reading a credential) instead of a
7//! MESSAGE SEND request.
8//!
9//! Reuses shared types from [`crate::envelope`]: `HumanPrincipalRef`,
10//! `AgentRef`, `PolicyDescriptor`, `NotarizationMetadata`, `LinkedMandate`,
11//! `EnvelopeProof`. This envelope type is additive — it does NOT touch the
12//! existing `NotarizationEnvelope` shape.
13//!
14//! The two access-specific field types — `CredentialRef` (opaque
15//! identifier for the credential being accessed) and `AccessIntent`
16//! (OneShot / Session(ttl) / Persistent(mandate_id)) — are vendored
17//! into this module; their serde output must match the originating
18//! implementation exactly.
19
20/// Opaque identifier for the credential being accessed. The shape is a
21/// newtype around `String` (serializes as a bare JSON string).
22#[derive(
23  ::std::clone::Clone,
24  ::std::fmt::Debug,
25  ::std::cmp::PartialEq,
26  ::std::cmp::Eq,
27  ::std::hash::Hash,
28  ::serde::Serialize,
29  ::serde::Deserialize,
30)]
31pub struct CredentialRef(::std::string::String);
32
33impl CredentialRef {
34  /// Wraps an existing credential reference string.
35  pub fn new(s: ::std::string::String) -> Self {
36    Self(s)
37  }
38
39  /// Borrows the reference as it appears on the wire (a bare string).
40  pub fn as_str(&self) -> &str {
41    &self.0
42  }
43}
44
45/// Duration / shape of the access the agent is requesting.
46///
47/// `OneShot` — single read, grant expires after first fetch.
48/// `Session { ttl_seconds }` — multi-read over a time-bounded window.
49/// `Persistent { mandate_id }` — long-lived, governed by an external
50/// AP2 IntentMandate that the user has signed; the engine consults the
51/// mandate on each fetch.
52#[derive(
53  ::std::clone::Clone,
54  ::std::fmt::Debug,
55  ::std::cmp::PartialEq,
56  ::std::cmp::Eq,
57  ::serde::Serialize,
58  ::serde::Deserialize,
59)]
60#[serde(tag = "kind", rename_all = "snake_case")]
61pub enum AccessIntent {
62  /// Single read; the grant expires after the first fetch.
63  OneShot,
64  /// Multi-read over a time-bounded window.
65  Session {
66    /// Lifetime of the session grant, in seconds.
67    ttl_seconds: u64,
68  },
69  /// Long-lived access governed by an external signed mandate.
70  Persistent {
71    /// Identifier of the governing AP2 IntentMandate.
72    mandate_id: ::std::string::String,
73  },
74}
75
76/// Top-level envelope for agent-credential-access authorization. Mirrors
77/// the JSON-LD + W3C VC 2.0 shape of [`crate::envelope::NotarizationEnvelope`]
78/// but with a credential-access subject in place of the message-send
79/// subject.
80///
81/// The `type` array MUST include `"VerifiableCredential"` and
82/// `"AgentCredentialAccessAuthorization"`.
83#[derive(
84  ::std::fmt::Debug,
85  ::std::clone::Clone,
86  ::std::cmp::PartialEq,
87  ::std::cmp::Eq,
88  ::serde::Serialize,
89  ::serde::Deserialize,
90)]
91#[serde(rename_all = "camelCase", deny_unknown_fields)]
92pub struct CredentialAccessNotarizationEnvelope {
93  /// APH version pin (`"0.1"`).
94  pub aph_version: ::std::string::String,
95  /// JSON-LD `@context` array. Always begins with W3C VC 2.0 context.
96  #[serde(rename = "@context")]
97  pub context: ::std::vec::Vec<::std::string::String>,
98  /// JSON-LD `type` array; MUST include `"VerifiableCredential"` and
99  /// `"AgentCredentialAccessAuthorization"`.
100  #[serde(rename = "type")]
101  pub r#type: ::std::vec::Vec<::std::string::String>,
102  /// `urn:uuid:...` envelope identifier.
103  pub id: ::std::string::String,
104  /// DID of the notary service.
105  pub issuer: ::std::string::String,
106  /// RFC 3339 issuance timestamp.
107  pub valid_from: ::std::string::String,
108  /// RFC 3339 expiry timestamp.
109  pub valid_until: ::std::string::String,
110  /// Inner credential-access subject (the notarized claim).
111  pub credential_access_subject: CredentialAccessSubject,
112  /// Optional link to an AP2 IntentMandate (for cross-protocol mandates).
113  #[serde(default)]
114  pub linked_mandate: ::std::option::Option<crate::envelope::LinkedMandate>,
115  /// Cryptographic proof block (reuses the canonical APH proof shape).
116  ///
117  /// Deliberately a SINGLE [`crate::envelope::EnvelopeProof`], not the
118  /// [`crate::envelope::EnvelopeProofs`] object-or-array union: §7.1.11's
119  /// proof-chain rules govern `NotarizationEnvelope` only, and this variant
120  /// is a spec v0.2 candidate. Widening it here would invent wire shapes no
121  /// normative text defines.
122  pub proof: crate::envelope::EnvelopeProof,
123}
124
125/// The credential subject for an agent-credential-access envelope.
126///
127/// Reuses `HumanPrincipalRef` (delegator) + `AgentRef` (subject) +
128/// `PolicyDescriptor` (governing policy) + `NotarizationMetadata`
129/// (decision metadata) from the message-send envelope. Adds two NEW
130/// access-specific fields: `credential_ref` (which credential) and
131/// `access_intent` (how the agent intends to use it).
132#[derive(
133  ::std::fmt::Debug,
134  ::std::clone::Clone,
135  ::std::cmp::PartialEq,
136  ::std::cmp::Eq,
137  ::serde::Serialize,
138  ::serde::Deserialize,
139)]
140#[serde(rename_all = "camelCase", deny_unknown_fields)]
141pub struct CredentialAccessSubject {
142  /// The human principal who delegates access (= the user signing the
143  /// consent).
144  pub human_principal: crate::envelope::HumanPrincipalRef,
145  /// The agent requesting access.
146  pub agent: crate::envelope::AgentRef,
147  /// Opaque identifier for the credential being accessed
148  /// (vendored [`CredentialRef`] above).
149  pub credential_ref: CredentialRef,
150  /// Intent / duration of the access grant (OneShot / Session(ttl) /
151  /// Persistent(mandate_id)) — vendored [`AccessIntent`] above.
152  pub access_intent: AccessIntent,
153  /// Governing policy descriptor.
154  pub policy: crate::envelope::PolicyDescriptor,
155  /// Notarization decision metadata.
156  pub notarization: crate::envelope::NotarizationMetadata,
157}
158
159#[cfg(test)]
160mod tests {
161  // Pure-fn round-trip tests. No async runtime needed.
162
163  // Test fixture — single source of envelope construction used by all 3
164  // round-trip tests below. Keeps the cross-test shape identical so a
165  // breaking change to any reused type surfaces consistently.
166  fn make_envelope() -> super::CredentialAccessNotarizationEnvelope {
167    super::CredentialAccessNotarizationEnvelope {
168      aph_version: ::std::string::String::from("0.1"),
169      context: ::std::vec![::std::string::String::from(
170        "https://www.w3.org/ns/credentials/v2",
171      )],
172      r#type: ::std::vec![
173        ::std::string::String::from("VerifiableCredential"),
174        ::std::string::String::from("AgentCredentialAccessAuthorization"),
175      ],
176      id: ::std::string::String::from("urn:uuid:00000000-0000-4000-8000-000000000000"),
177      issuer: ::std::string::String::from("did:web:notary.example"),
178      valid_from: ::std::string::String::from("2026-05-29T00:00:00Z"),
179      valid_until: ::std::string::String::from("2026-05-29T01:00:00Z"),
180      credential_access_subject: super::CredentialAccessSubject {
181        human_principal: crate::envelope::HumanPrincipalRef {
182          id: ::std::string::String::from("did:key:zUser"),
183          display_name: ::std::string::String::from("Scott"),
184        },
185        agent: crate::envelope::AgentRef {
186          id: ::std::string::String::from("did:web:agent.example"),
187          agent_card_uri: ::std::option::Option::None,
188          display_name: ::std::string::String::from("TestAgent"),
189          version: ::std::string::String::from("0.1.0"),
190        },
191        credential_ref: super::CredentialRef::new(
192          ::std::string::String::from("cred-id-1"),
193        ),
194        access_intent: super::AccessIntent::OneShot,
195        policy: crate::envelope::PolicyDescriptor {
196          decision: crate::envelope::PolicyDecision::AlwaysAllow,
197          matched_scope: ::std::string::String::from("per-credential"),
198          delegation_mandate_id: ::std::option::Option::None,
199          act_chain: ::std::vec::Vec::new(),
200          attestation_mode: ::std::option::Option::None,
201          delegation_mandate: ::std::option::Option::None,
202        },
203        notarization: crate::envelope::NotarizationMetadata {
204          notary_service: crate::envelope::NotaryServiceRef {
205            id: ::std::string::String::from("did:web:notary.example"),
206            name: ::std::string::String::from("Squillo Notary Service"),
207            version: ::std::string::String::from("0.1.0"),
208            attested_digest: ::std::option::Option::None,
209            attestation_uri: ::std::option::Option::None,
210          },
211          decision_timestamp: ::std::string::String::from("2026-05-29T00:00:00Z"),
212          decision_latency_ms: 0,
213        },
214      },
215      linked_mandate: ::std::option::Option::None,
216      proof: crate::envelope::EnvelopeProof {
217        r#type: ::std::string::String::from("DataIntegrityProof"),
218        cryptosuite: ::std::option::Option::Some(::std::string::String::from(
219          "eddsa-jcs-2022",
220        )),
221        verification_method: ::std::string::String::from("did:key:zNotary#zNotary"),
222        created: ::std::string::String::from("2026-05-29T00:00:00Z"),
223        proof_purpose: ::std::string::String::from("assertionMethod"),
224        proof_value: ::std::string::String::from("placeholder"),
225        id: ::std::option::Option::None,
226        previous_proof: ::std::option::Option::None,
227      },
228    }
229  }
230
231  #[test]
232  fn type_array_contains_credential_access_marker() {
233    // The type array is how a verifier tells a credential-access envelope
234    // from an ordinary send authorization. Losing the marker would let a
235    // credential-access grant be processed as a plain message envelope.
236    let env = make_envelope();
237    assert_eq!(env.r#type.len(), 2);
238    assert!(env
239      .r#type
240      .iter()
241      .any(|t| t == "AgentCredentialAccessAuthorization"));
242  }
243
244  #[test]
245  fn envelope_round_trip_json_serde() {
246    // Round-trips the variant end to end, including the vendored
247    // CredentialRef and AccessIntent types — their serde shapes must match
248    // the originals byte-for-byte or cross-system grants stop parsing.
249    let env = make_envelope();
250    let s = ::serde_json::to_string(&env).expect("serialize");
251    let env2: super::CredentialAccessNotarizationEnvelope =
252      ::serde_json::from_str(&s).expect("deserialize");
253    assert_eq!(env, env2);
254
255    // Confirm wire shape carries the required JSON-LD + VC 2.0 markers.
256    assert!(s.contains("@context"));
257    assert!(s.contains("VerifiableCredential"));
258    assert!(s.contains("AgentCredentialAccessAuthorization"));
259    // The credential_access_subject MUST carry the camelCase wire form.
260    assert!(s.contains("credentialAccessSubject"));
261    // CredentialRef + AccessIntent (vendored types) MUST be serialized
262    // inline as part of the subject — verify both surface.
263    assert!(s.contains("credentialRef"));
264    assert!(s.contains("accessIntent"));
265  }
266
267  #[test]
268  fn envelope_rejects_unknown_fields() {
269    // `deny_unknown_fields` on the struct attribute must reject extra
270    // JSON fields. This is a wire-stability gate: if a peer ships a
271    // newer envelope with extra fields we want a hard deserialize error
272    // rather than silent drop.
273    let env = make_envelope();
274    let mut value = ::serde_json::to_value(&env).expect("serialize");
275    if let ::serde_json::Value::Object(map) = &mut value {
276      map.insert(
277        ::std::string::String::from("bogusFieldNotInSchema"),
278        ::serde_json::Value::String(::std::string::String::from("should-fail")),
279      );
280    } else {
281      panic!("envelope serializes to a JSON object");
282    }
283    let s = ::serde_json::to_string(&value).expect("re-serialize");
284    let result: ::std::result::Result<
285      super::CredentialAccessNotarizationEnvelope,
286      ::serde_json::Error,
287    > = ::serde_json::from_str(&s);
288    assert!(
289      result.is_err(),
290      "deny_unknown_fields MUST reject bogus extra field"
291    );
292  }
293
294  #[test]
295  fn cross_crate_credential_ref_in_envelope_round_trips() {
296    // Verify the vendored CredentialRef + AccessIntent types embedded
297    // inside the credential_access_subject round-trip correctly through
298    // the envelope's serde implementation. This is the vendored-type
299    // wire-stability gate.
300    let env = make_envelope();
301    let s = ::serde_json::to_string(&env).expect("serialize");
302    let env2: super::CredentialAccessNotarizationEnvelope =
303      ::serde_json::from_str(&s).expect("deserialize");
304    assert_eq!(
305      env.credential_access_subject.credential_ref,
306      env2.credential_access_subject.credential_ref,
307    );
308    assert_eq!(
309      env.credential_access_subject.access_intent,
310      env2.credential_access_subject.access_intent,
311    );
312  }
313}