Skip to main content

holographic_memory/core/provenance/
access.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Credential-gated agent admission.
5//!
6//! An [`AccessCredential`] is a W3C Verifiable Credential in which a trusted
7//! issuer grants an agent `did:key` a set of [`Permission`]s. An
8//! [`AgentRegistry`] admits an agent only after verifying the credential's
9//! eddsa-jcs-2022 proof, its validity window, and that the signing key belongs
10//! to an issuer the host has explicitly trusted.
11//!
12//! The registry decides; it does not authenticate the caller. A host exposing
13//! HMS to remote agents must separately prove that the party presenting a DID
14//! controls its key (for example a signed challenge) before calling
15//! [`AgentRegistry::authorize`].
16
17use std::time::{SystemTime, UNIX_EPOCH};
18
19use anyhow::{anyhow, ensure, Result};
20use ed25519_dalek::SigningKey;
21use fxhash::FxHashMap;
22use serde::{Deserialize, Serialize};
23
24use super::did::{did_key_from_ed25519, ed25519_from_did_key};
25use super::trust::TrustStore;
26use super::vc::{self, DataIntegrityProof};
27
28pub const ACCESS_CREDENTIAL_TYPE: &str = "HmsAccessCredential";
29const VC_TYPE: &str = "VerifiableCredential";
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum Permission {
34    Read,
35    Write,
36}
37
38#[derive(Clone, Debug, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct AccessCredential {
41    #[serde(rename = "@context")]
42    pub context: Vec<String>,
43    #[serde(rename = "type")]
44    pub credential_type: Vec<String>,
45    pub id: String,
46    pub issuer: String,
47    pub valid_from: String,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub valid_until: Option<String>,
50    pub credential_subject: AccessSubject,
51    pub proof: Option<DataIntegrityProof>,
52}
53
54#[derive(Clone, Debug, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct AccessSubject {
57    /// The agent's Ed25519 `did:key`.
58    pub id: String,
59    pub permissions: Vec<Permission>,
60}
61
62/// Issue a signed access credential for `subject_did`. `valid_until` is a UTC
63/// `YYYY-MM-DDTHH:MM:SSZ` timestamp; `None` means the grant does not expire.
64pub fn issue_access_credential(
65    signing_key: &SigningKey,
66    subject_did: &str,
67    permissions: &[Permission],
68    valid_until: Option<&str>,
69) -> Result<AccessCredential> {
70    ed25519_from_did_key(subject_did)?;
71    ensure!(
72        !permissions.is_empty(),
73        "credential must grant a permission"
74    );
75    if let Some(until) = valid_until {
76        vc::parse_iso8601_utc(until)?;
77    }
78    let nonce: [u8; 16] = rand::random();
79    let id: String = nonce.iter().map(|b| format!("{b:02x}")).collect();
80
81    let mut credential = AccessCredential {
82        context: vec![
83            "https://www.w3.org/ns/credentials/v2".to_string(),
84            "https://writerslogic.com/ns/hms/v1".to_string(),
85        ],
86        credential_type: vec![VC_TYPE.to_string(), ACCESS_CREDENTIAL_TYPE.to_string()],
87        id: format!("urn:hms:access:{id}"),
88        issuer: did_key_from_ed25519(&signing_key.verifying_key().to_bytes()),
89        valid_from: vc::chrono_iso8601_now(),
90        valid_until: valid_until.map(str::to_string),
91        credential_subject: AccessSubject {
92            id: subject_did.to_string(),
93            permissions: permissions.to_vec(),
94        },
95        proof: None,
96    };
97    credential.proof = Some(vc::sign_document(signing_key, &credential)?);
98    Ok(credential)
99}
100
101struct Grant {
102    permissions: Vec<Permission>,
103    expires_at: Option<u64>,
104}
105
106/// Agents admitted by credentials from trusted issuers. Trusts no issuer, and
107/// therefore admits no agent, until [`AgentRegistry::trust_issuer`] is called.
108#[derive(Default)]
109pub struct AgentRegistry {
110    issuers: TrustStore,
111    grants: FxHashMap<String, Grant>,
112}
113
114impl AgentRegistry {
115    pub fn new(issuers: TrustStore) -> Self {
116        Self {
117            issuers,
118            grants: FxHashMap::default(),
119        }
120    }
121
122    /// Trust credentials signed by the holder of this Ed25519 `did:key`.
123    pub fn trust_issuer(&mut self, issuer_did: &str) -> Result<()> {
124        self.issuers.trust_did(issuer_did).map(|_| ())
125    }
126
127    /// Verify a credential and record its grant, replacing any earlier grant
128    /// for the same agent.
129    pub fn admit(&mut self, credential: &AccessCredential) -> Result<()> {
130        self.admit_at(credential, unix_now())
131    }
132
133    pub fn admit_at(&mut self, credential: &AccessCredential, now: u64) -> Result<()> {
134        let proof = credential
135            .proof
136            .as_ref()
137            .ok_or_else(|| anyhow!("credential has no proof"))?;
138        ensure!(
139            [VC_TYPE, ACCESS_CREDENTIAL_TYPE]
140                .iter()
141                .all(|t| credential.credential_type.iter().any(|c| c == t)),
142            "credential is not an {ACCESS_CREDENTIAL_TYPE}"
143        );
144        ensure!(
145            !credential.credential_subject.permissions.is_empty(),
146            "credential grants no permission"
147        );
148        ed25519_from_did_key(&credential.credential_subject.id)?;
149
150        let mut unsigned = credential.clone();
151        unsigned.proof = None;
152        let issuer_key = vc::verify_document(&unsigned, &credential.issuer, proof)?;
153        ensure!(
154            self.issuers.is_trusted(&issuer_key),
155            "credential issuer is not trusted"
156        );
157
158        ensure!(
159            vc::parse_iso8601_utc(&credential.valid_from)? <= now,
160            "credential is not yet valid"
161        );
162        let expires_at = credential
163            .valid_until
164            .as_deref()
165            .map(vc::parse_iso8601_utc)
166            .transpose()?;
167        ensure!(expires_at.is_none_or(|t| now < t), "credential has expired");
168
169        self.grants.insert(
170            credential.credential_subject.id.clone(),
171            Grant {
172                permissions: credential.credential_subject.permissions.clone(),
173                expires_at,
174            },
175        );
176        Ok(())
177    }
178
179    /// Remove an agent's grant. Returns whether one existed.
180    pub fn revoke(&mut self, agent_did: &str) -> bool {
181        self.grants.remove(agent_did).is_some()
182    }
183
184    /// Check that `agent_did` currently holds `permission`.
185    pub fn authorize(&self, agent_did: &str, permission: Permission) -> Result<()> {
186        self.authorize_at(agent_did, permission, unix_now())
187    }
188
189    pub fn authorize_at(&self, agent_did: &str, permission: Permission, now: u64) -> Result<()> {
190        let grant = self
191            .grants
192            .get(agent_did)
193            .ok_or_else(|| anyhow!("agent has no admitted credential"))?;
194        ensure!(
195            grant.expires_at.is_none_or(|t| now < t),
196            "agent credential has expired"
197        );
198        ensure!(
199            grant.permissions.contains(&permission),
200            "agent credential does not grant {permission:?}"
201        );
202        Ok(())
203    }
204}
205
206fn unix_now() -> u64 {
207    SystemTime::now()
208        .duration_since(UNIX_EPOCH)
209        .map(|d| d.as_secs())
210        .unwrap_or_default()
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    fn key() -> SigningKey {
218        SigningKey::from_bytes(&rand::random())
219    }
220
221    fn did(key: &SigningKey) -> String {
222        did_key_from_ed25519(&key.verifying_key().to_bytes())
223    }
224
225    fn registry_trusting(issuer: &SigningKey) -> AgentRegistry {
226        let mut registry = AgentRegistry::default();
227        registry.trust_issuer(&did(issuer)).unwrap();
228        registry
229    }
230
231    #[test]
232    fn trusted_credential_grants_only_listed_permissions() {
233        let (issuer, agent) = (key(), did(&key()));
234        let mut registry = registry_trusting(&issuer);
235        let credential =
236            issue_access_credential(&issuer, &agent, &[Permission::Read], None).unwrap();
237
238        assert!(registry.authorize(&agent, Permission::Read).is_err());
239        registry.admit(&credential).unwrap();
240        registry.authorize(&agent, Permission::Read).unwrap();
241        assert!(registry.authorize(&agent, Permission::Write).is_err());
242
243        assert!(registry.revoke(&agent));
244        assert!(registry.authorize(&agent, Permission::Read).is_err());
245    }
246
247    #[test]
248    fn untrusted_or_self_issued_credential_is_rejected() {
249        let (issuer, attacker) = (key(), key());
250        let mut registry = registry_trusting(&issuer);
251        let self_issued =
252            issue_access_credential(&attacker, &did(&attacker), &[Permission::Read], None).unwrap();
253        assert!(registry.admit(&self_issued).is_err());
254        assert!(AgentRegistry::default().admit(&self_issued).is_err());
255    }
256
257    #[test]
258    fn tampered_or_unsigned_credential_is_rejected() {
259        let (issuer, agent) = (key(), did(&key()));
260        let mut registry = registry_trusting(&issuer);
261        let credential =
262            issue_access_credential(&issuer, &agent, &[Permission::Read], None).unwrap();
263
264        let mut escalated = credential.clone();
265        escalated
266            .credential_subject
267            .permissions
268            .push(Permission::Write);
269        assert!(registry.admit(&escalated).is_err());
270
271        let mut retargeted = credential.clone();
272        retargeted.credential_subject.id = did(&key());
273        assert!(registry.admit(&retargeted).is_err());
274
275        // Re-signed by an attacker while still naming the trusted issuer.
276        let mut forged = escalated.clone();
277        forged.proof = None;
278        forged.proof = Some(vc::sign_document(&key(), &forged).unwrap());
279        assert!(registry.admit(&forged).is_err());
280
281        let mut unsigned = credential.clone();
282        unsigned.proof = None;
283        assert!(registry.admit(&unsigned).is_err());
284
285        let mut garbage = credential;
286        garbage.proof.as_mut().unwrap().proof_value = "z1111".to_string();
287        assert!(registry.admit(&garbage).is_err());
288    }
289
290    #[test]
291    fn validity_window_is_enforced_at_its_boundaries() {
292        let (issuer, agent) = (key(), did(&key()));
293        let mut registry = registry_trusting(&issuer);
294        let until = "2100-01-01T00:00:00Z";
295        let expiry = vc::parse_iso8601_utc(until).unwrap();
296        let credential =
297            issue_access_credential(&issuer, &agent, &[Permission::Read], Some(until)).unwrap();
298        let issued = vc::parse_iso8601_utc(&credential.valid_from).unwrap();
299
300        assert!(registry.admit_at(&credential, issued - 1).is_err());
301        assert!(registry.admit_at(&credential, expiry).is_err());
302        registry.admit_at(&credential, issued).unwrap();
303
304        registry
305            .authorize_at(&agent, Permission::Read, expiry - 1)
306            .unwrap();
307        assert!(registry
308            .authorize_at(&agent, Permission::Read, expiry)
309            .is_err());
310        assert!(registry
311            .authorize_at(&agent, Permission::Read, expiry + 1)
312            .is_err());
313    }
314
315    #[test]
316    fn timestamps_round_trip_and_reject_malformed_input() {
317        assert_eq!(vc::parse_iso8601_utc("1970-01-01T00:00:00Z").unwrap(), 0);
318        assert_eq!(
319            vc::parse_iso8601_utc("2024-02-29T12:30:15Z").unwrap(),
320            1_709_209_815
321        );
322        for bad in [
323            "2023-02-29T00:00:00Z",
324            "2024-13-01T00:00:00Z",
325            "2024-01-01T24:00:00Z",
326            "2024-01-01 00:00:00Z",
327            "2024-01-01T00:00:00+00:00",
328            "+024-01-01T00:00:00Z",
329            "",
330        ] {
331            assert!(vc::parse_iso8601_utc(bad).is_err(), "{bad}");
332        }
333    }
334}