Skip to main content

holographic_memory/core/provenance/
did.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use anyhow::{anyhow, Result};
5use base64::engine::{general_purpose::GeneralPurpose, DecodePaddingMode, GeneralPurposeConfig};
6use base64::{alphabet, Engine};
7
8const ED25519_MULTICODEC: [u8; 2] = [0xed, 0x01];
9
10/// base64url engine matching c2pa-rs: URL-safe alphabet, padding ignored on decode.
11const B64URL: GeneralPurpose = GeneralPurpose::new(
12    &alphabet::URL_SAFE,
13    GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
14);
15
16/// Generate a did:key URI from an Ed25519 public key (32 bytes).
17pub fn did_key_from_ed25519(public_key: &[u8; 32]) -> String {
18    let mut prefixed = Vec::with_capacity(2 + 32);
19    prefixed.extend_from_slice(&ED25519_MULTICODEC);
20    prefixed.extend_from_slice(public_key);
21    let encoded = multibase::encode(multibase::Base::Base58Btc, &prefixed);
22    format!("did:key:{encoded}")
23}
24
25/// Extract the Ed25519 public key bytes from a did:key URI.
26pub fn ed25519_from_did_key(did: &str) -> Result<[u8; 32]> {
27    let multibase_part = did
28        .strip_prefix("did:key:")
29        .ok_or_else(|| anyhow!("not a did:key URI"))?;
30    let (_, decoded) =
31        multibase::decode(multibase_part).map_err(|e| anyhow!("multibase decode failed: {e}"))?;
32    if decoded.len() != 34
33        || decoded[0] != ED25519_MULTICODEC[0]
34        || decoded[1] != ED25519_MULTICODEC[1]
35    {
36        return Err(anyhow!("not an Ed25519 did:key (wrong prefix or length)"));
37    }
38    let mut key = [0u8; 32];
39    key.copy_from_slice(&decoded[2..34]);
40    Ok(key)
41}
42
43/// Build a did:jwk URI from an Ed25519 public key (32 bytes), the inverse of
44/// [`ed25519_from_did_jwk`]. The JWK JSON is compact with SORTED keys
45/// (`{"crv":"Ed25519","kty":"OKP","x":"<base64url-nopad pubkey>"}`); the method id is
46/// that JSON base64url-encoded WITH canonical '=' padding, matching cogmem's
47/// `agent_did_jwk` so the two emit byte-identical issuer DIDs.
48pub fn did_jwk_from_ed25519(public_key: &[u8; 32]) -> String {
49    // base64url, no padding, for the JWK `x` parameter.
50    let b64url_nopad = GeneralPurpose::new(
51        &alphabet::URL_SAFE,
52        GeneralPurposeConfig::new().with_encode_padding(false),
53    );
54    let x = b64url_nopad.encode(public_key);
55    // Compact JWK with sorted keys: crv, kty, x.
56    let jwk_json = format!("{{\"crv\":\"Ed25519\",\"kty\":\"OKP\",\"x\":\"{x}\"}}");
57    // Canonical '=' padding on the method-specific id (c2pa-rs / cogmem requirement).
58    let b64url_pad = GeneralPurpose::new(&alphabet::URL_SAFE, GeneralPurposeConfig::new());
59    let enc = b64url_pad.encode(jwk_json.as_bytes());
60    format!("did:jwk:{enc}")
61}
62
63/// Extract the Ed25519 public key bytes from a did:jwk URI. The method id is a
64/// base64url-encoded JWK; decoding tolerates the canonical padding c2pa-rs emits.
65pub fn ed25519_from_did_jwk(did: &str) -> Result<[u8; 32]> {
66    let encoded = did
67        .strip_prefix("did:jwk:")
68        .ok_or_else(|| anyhow!("not a did:jwk URI"))?;
69    let json = B64URL
70        .decode(encoded)
71        .map_err(|e| anyhow!("did:jwk base64url decode failed: {e}"))?;
72    let jwk: serde_json::Value =
73        serde_json::from_slice(&json).map_err(|e| anyhow!("did:jwk JWK parse failed: {e}"))?;
74    if jwk.get("kty").and_then(|v| v.as_str()) != Some("OKP") {
75        return Err(anyhow!("did:jwk is not an OKP key"));
76    }
77    if jwk.get("crv").and_then(|v| v.as_str()) != Some("Ed25519") {
78        return Err(anyhow!("did:jwk is not an Ed25519 key"));
79    }
80    let x = jwk
81        .get("x")
82        .and_then(|v| v.as_str())
83        .ok_or_else(|| anyhow!("did:jwk JWK has no x parameter"))?;
84    let key_bytes = B64URL
85        .decode(x)
86        .map_err(|e| anyhow!("did:jwk x base64url decode failed: {e}"))?;
87    let key: [u8; 32] = key_bytes
88        .as_slice()
89        .try_into()
90        .map_err(|_| anyhow!("did:jwk x is not a 32-byte Ed25519 key"))?;
91    Ok(key)
92}
93
94/// Create a did:web URI from a domain and optional path segments.
95/// did:web encodes `:` as domain separators and `path` segments after the domain.
96/// Example: did:web:example.com or did:web:example.com:users:alice
97pub fn did_web_from_domain(domain: &str, path: Option<&[&str]>) -> String {
98    let encoded_domain = domain.replace(':', "%3A");
99    match path {
100        Some(segments) if !segments.is_empty() => {
101            let path_str = segments.join(":");
102            format!("did:web:{encoded_domain}:{path_str}")
103        }
104        _ => format!("did:web:{encoded_domain}"),
105    }
106}
107
108/// Convert a did:web URI to the HTTPS URL where its DID document should be hosted.
109/// Per the did:web spec:
110///   did:web:example.com → <https://example.com/.well-known/did.json>
111///   did:web:example.com:path:to → <https://example.com/path/to/did.json>
112pub fn did_web_to_url(did: &str) -> Result<String> {
113    let domain_path = did
114        .strip_prefix("did:web:")
115        .ok_or_else(|| anyhow!("not a did:web URI"))?;
116    let parts: Vec<&str> = domain_path.split(':').collect();
117    let domain = parts[0].replace("%3A", ":");
118    if parts.len() == 1 {
119        Ok(format!("https://{domain}/.well-known/did.json"))
120    } else {
121        let path = parts[1..].join("/");
122        Ok(format!("https://{domain}/{path}/did.json"))
123    }
124}
125
126/// Create a DID document for a did:web or did:key identity with an Ed25519 verification key.
127pub fn create_did_document(did: &str, public_key: &[u8; 32]) -> serde_json::Value {
128    let key_multibase = {
129        let mut prefixed = Vec::with_capacity(34);
130        prefixed.extend_from_slice(&ED25519_MULTICODEC);
131        prefixed.extend_from_slice(public_key);
132        multibase::encode(multibase::Base::Base58Btc, &prefixed)
133    };
134    let key_id = format!("{did}#key-0");
135    serde_json::json!({
136        "@context": [
137            "https://www.w3.org/ns/did/v1",
138            "https://w3id.org/security/multikey/v1"
139        ],
140        "id": did,
141        "verificationMethod": [{
142            "id": &key_id,
143            "type": "Multikey",
144            "controller": did,
145            "publicKeyMultibase": key_multibase
146        }],
147        "authentication": [&key_id],
148        "assertionMethod": [&key_id]
149    })
150}
151
152/// Extract the Ed25519 public key from a DID (supports both did:key and did:web documents).
153/// For did:key, extracts directly from the URI.
154/// For did:web, requires the DID document JSON to be passed in.
155pub fn ed25519_from_did_document(doc: &serde_json::Value) -> Result<[u8; 32]> {
156    let methods = doc
157        .get("verificationMethod")
158        .and_then(|v| v.as_array())
159        .ok_or_else(|| anyhow!("no verificationMethod in DID document"))?;
160    let method = methods
161        .first()
162        .ok_or_else(|| anyhow!("empty verificationMethod array"))?;
163    let multibase_key = method
164        .get("publicKeyMultibase")
165        .and_then(|v| v.as_str())
166        .ok_or_else(|| anyhow!("no publicKeyMultibase in verification method"))?;
167    let (_, decoded) =
168        multibase::decode(multibase_key).map_err(|e| anyhow!("multibase decode failed: {e}"))?;
169    if decoded.len() != 34
170        || decoded[0] != ED25519_MULTICODEC[0]
171        || decoded[1] != ED25519_MULTICODEC[1]
172    {
173        return Err(anyhow!("not an Ed25519 key (wrong multicodec prefix)"));
174    }
175    let mut key = [0u8; 32];
176    key.copy_from_slice(&decoded[2..34]);
177    Ok(key)
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn did_key_roundtrip() {
186        let pk = [0xABu8; 32];
187        let did = did_key_from_ed25519(&pk);
188        assert!(did.starts_with("did:key:z"));
189        let recovered = ed25519_from_did_key(&did).unwrap();
190        assert_eq!(recovered, pk);
191    }
192
193    #[test]
194    fn reject_bad_prefix() {
195        assert!(ed25519_from_did_key("did:web:example.com").is_err());
196    }
197
198    #[test]
199    fn did_jwk_from_ed25519_roundtrips() {
200        let pk = [0x42u8; 32];
201        let did = did_jwk_from_ed25519(&pk);
202        assert!(did.starts_with("did:jwk:"));
203        let recovered = ed25519_from_did_jwk(&did).unwrap();
204        assert_eq!(recovered, pk);
205    }
206
207    #[test]
208    fn did_jwk_resolves_vector_issuer() {
209        let issuer = "did:jwk:eyJjcnYiOiJFZDI1NTE5Iiwia3R5IjoiT0tQIiwieCI6IkE2RUh2X1BPRUw0ZGNOMFk1MHZBbVdmazFqQ2JwUTFmSGR5R1pCSlZNYmcifQ==";
210        let expected = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8";
211        let key = ed25519_from_did_jwk(issuer).unwrap();
212        let hex: String = key.iter().map(|b| format!("{b:02x}")).collect();
213        assert_eq!(hex, expected);
214    }
215
216    #[test]
217    fn did_web_simple() {
218        let did = did_web_from_domain("example.com", None);
219        assert_eq!(did, "did:web:example.com");
220        let url = did_web_to_url(&did).unwrap();
221        assert_eq!(url, "https://example.com/.well-known/did.json");
222    }
223
224    #[test]
225    fn did_web_with_path() {
226        let did = did_web_from_domain("example.com", Some(&["users", "alice"]));
227        assert_eq!(did, "did:web:example.com:users:alice");
228        let url = did_web_to_url(&did).unwrap();
229        assert_eq!(url, "https://example.com/users/alice/did.json");
230    }
231
232    #[test]
233    fn did_web_with_port() {
234        let did = did_web_from_domain("localhost%3A8080", None);
235        assert_eq!(did, "did:web:localhost%3A8080");
236        let url = did_web_to_url(&did).unwrap();
237        assert_eq!(url, "https://localhost:8080/.well-known/did.json");
238    }
239
240    #[test]
241    fn did_document_roundtrip() {
242        let pk = [0xCDu8; 32];
243        let did = did_web_from_domain("example.com", None);
244        let doc = create_did_document(&did, &pk);
245        let recovered = ed25519_from_did_document(&doc).unwrap();
246        assert_eq!(recovered, pk);
247        assert_eq!(doc["id"], "did:web:example.com");
248    }
249}