baseid_anoncreds/
credential.rs

1//! AnonCreds credential types with ACA-Py interop and W3C VC mapping.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use baseid_vc::credential::Issuer;
8use baseid_vc::VerifiableCredential;
9
10/// An AnonCreds credential.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct AnonCredsCredential {
13    /// Schema identifier.
14    pub schema_id: String,
15    /// Credential definition identifier.
16    pub cred_def_id: String,
17    /// Optional revocation registry identifier.
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub rev_reg_id: Option<String>,
20    /// Issuer DID (extracted from schema_id or cred_def_id).
21    pub issuer_did: String,
22    /// Credential values (attribute name -> raw/encoded value).
23    pub values: BTreeMap<String, AttributeValue>,
24}
25
26/// An attribute value with both raw and encoded forms.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct AttributeValue {
29    /// The human-readable value.
30    pub raw: String,
31    /// The integer-encoded value (used in CL proofs).
32    pub encoded: String,
33}
34
35impl AnonCredsCredential {
36    /// Parse an ACA-Py credential JSON object into an `AnonCredsCredential`.
37    ///
38    /// ACA-Py format:
39    /// ```json
40    /// {
41    ///   "schema_id": "did:sov:NcYx...:2:degree:1.0",
42    ///   "cred_def_id": "did:sov:NcYx...:3:CL:12:default",
43    ///   "rev_reg_id": null,
44    ///   "values": {
45    ///     "name": {"raw": "Alice", "encoded": "27034..."}
46    ///   }
47    /// }
48    /// ```
49    pub fn from_aca_py_json(json: &str) -> Result<Self, serde_json::Error> {
50        #[derive(Deserialize)]
51        struct AcaPyCredential {
52            schema_id: String,
53            cred_def_id: String,
54            rev_reg_id: Option<String>,
55            values: BTreeMap<String, AttributeValue>,
56        }
57
58        let parsed: AcaPyCredential = serde_json::from_str(json)?;
59
60        // Extract the issuer DID from the cred_def_id.
61        // ACA-Py cred_def_ids look like: `did:sov:NcYx...:3:CL:12:default`
62        // or Sovrin-style: `NcYxiDXkpYi6ov5FcYDi1e:3:CL:12:default`
63        let issuer_did = extract_issuer_did(&parsed.cred_def_id);
64
65        Ok(Self {
66            schema_id: parsed.schema_id,
67            cred_def_id: parsed.cred_def_id,
68            rev_reg_id: parsed.rev_reg_id,
69            issuer_did,
70            values: parsed.values,
71        })
72    }
73
74    /// Map this AnonCreds credential to a W3C Verifiable Credential for unified display.
75    ///
76    /// The resulting VC uses the `AnonCredsCredential` type and places all raw
77    /// attribute values into the `credentialSubject`.
78    pub fn to_w3c_vc(&self) -> VerifiableCredential {
79        let mut subject = serde_json::Map::new();
80        for (name, value) in &self.values {
81            subject.insert(name.clone(), serde_json::Value::String(value.raw.clone()));
82        }
83
84        // Extract a human-readable schema name from the schema_id if possible.
85        // e.g. "did:sov:NcYx...:2:degree:1.0" -> "degree"
86        let schema_name = extract_schema_name(&self.schema_id);
87        let credential_type = schema_name
88            .map(|n| format!("AnonCreds:{n}"))
89            .unwrap_or_else(|| "AnonCredsCredential".to_string());
90
91        VerifiableCredential {
92            context: vec!["https://www.w3.org/ns/credentials/v2".to_string()],
93            id: Some(self.cred_def_id.clone()),
94            r#type: vec!["VerifiableCredential".to_string(), credential_type],
95            issuer: Issuer::Uri(self.issuer_did.clone()),
96            valid_from: None,
97            valid_until: None,
98            credential_subject: serde_json::Value::Object(subject),
99            credential_status: None,
100            proof: None,
101        }
102    }
103
104    /// Return the list of attribute names in this credential.
105    pub fn attribute_names(&self) -> Vec<&str> {
106        self.values.keys().map(|k| k.as_str()).collect()
107    }
108
109    /// Get the raw value of a named attribute.
110    pub fn get_raw_value(&self, name: &str) -> Option<&str> {
111        self.values.get(name).map(|v| v.raw.as_str())
112    }
113}
114
115/// Extract the issuer DID from a cred_def_id or schema_id.
116///
117/// Handles both `did:sov:XYZ:3:CL:...` (DID prefix) and
118/// bare Sovrin `XYZ:3:CL:...` formats.
119fn extract_issuer_did(cred_def_id: &str) -> String {
120    if cred_def_id.starts_with("did:") {
121        // `did:sov:XYZ:3:CL:12:default` — take the first 3 colon-separated parts
122        let parts: Vec<&str> = cred_def_id.splitn(4, ':').collect();
123        if parts.len() >= 3 {
124            return format!("{}:{}:{}", parts[0], parts[1], parts[2]);
125        }
126    }
127    // Bare Sovrin style: `XYZ:3:CL:12:default` — first segment is the DID
128    cred_def_id
129        .split(':')
130        .next()
131        .unwrap_or(cred_def_id)
132        .to_string()
133}
134
135/// Extract the schema name from a schema_id.
136///
137/// e.g. `did:sov:NcYx...:2:degree:1.0` → `Some("degree")`
138/// e.g. `NcYx...:2:degree:1.0` → `Some("degree")`
139fn extract_schema_name(schema_id: &str) -> Option<String> {
140    let parts: Vec<&str> = schema_id.split(':').collect();
141    // Sovrin-style schema_id: `ISSUER:2:NAME:VERSION` (4 parts)
142    // DID-style: `did:sov:ISSUER:2:NAME:VERSION` (6 parts)
143    if parts.len() >= 4 {
144        // The name is the second-to-last part
145        return Some(parts[parts.len() - 2].to_string());
146    }
147    None
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    const ACA_PY_JSON: &str = r#"{
155        "schema_id": "did:sov:NcYxiDXkpYi6ov5FcYDi1e:2:degree:1.0",
156        "cred_def_id": "did:sov:NcYxiDXkpYi6ov5FcYDi1e:3:CL:12:default",
157        "rev_reg_id": null,
158        "values": {
159            "name": {"raw": "Alice", "encoded": "27034640024117331033063128044004318218486816931520886405535659934417438781507"},
160            "degree": {"raw": "Computer Science", "encoded": "123456"},
161            "university": {"raw": "University of Toronto", "encoded": "789012"}
162        }
163    }"#;
164
165    #[test]
166    fn parse_aca_py_json() {
167        let cred = AnonCredsCredential::from_aca_py_json(ACA_PY_JSON).unwrap();
168        assert_eq!(
169            cred.schema_id,
170            "did:sov:NcYxiDXkpYi6ov5FcYDi1e:2:degree:1.0"
171        );
172        assert_eq!(
173            cred.cred_def_id,
174            "did:sov:NcYxiDXkpYi6ov5FcYDi1e:3:CL:12:default"
175        );
176        assert!(cred.rev_reg_id.is_none());
177        assert_eq!(cred.issuer_did, "did:sov:NcYxiDXkpYi6ov5FcYDi1e");
178        assert_eq!(cred.values.len(), 3);
179    }
180
181    #[test]
182    fn attribute_access() {
183        let cred = AnonCredsCredential::from_aca_py_json(ACA_PY_JSON).unwrap();
184        assert_eq!(cred.get_raw_value("name"), Some("Alice"));
185        assert_eq!(cred.get_raw_value("degree"), Some("Computer Science"));
186        assert_eq!(cred.get_raw_value("missing"), None);
187
188        let names = cred.attribute_names();
189        assert!(names.contains(&"name"));
190        assert!(names.contains(&"degree"));
191        assert!(names.contains(&"university"));
192    }
193
194    #[test]
195    fn to_w3c_vc_mapping() {
196        let cred = AnonCredsCredential::from_aca_py_json(ACA_PY_JSON).unwrap();
197        let vc = cred.to_w3c_vc();
198
199        assert_eq!(
200            vc.context,
201            vec!["https://www.w3.org/ns/credentials/v2".to_string()]
202        );
203        assert!(vc.r#type.contains(&"VerifiableCredential".to_string()));
204        assert!(vc.r#type.contains(&"AnonCreds:degree".to_string()));
205        assert_eq!(
206            vc.issuer,
207            Issuer::Uri("did:sov:NcYxiDXkpYi6ov5FcYDi1e".to_string())
208        );
209        assert_eq!(vc.credential_subject["name"], "Alice");
210        assert_eq!(vc.credential_subject["degree"], "Computer Science");
211        assert_eq!(vc.credential_subject["university"], "University of Toronto");
212    }
213
214    #[test]
215    fn serde_roundtrip() {
216        let cred = AnonCredsCredential::from_aca_py_json(ACA_PY_JSON).unwrap();
217        let json = serde_json::to_string(&cred).unwrap();
218        let deserialized: AnonCredsCredential = serde_json::from_str(&json).unwrap();
219        assert_eq!(deserialized.schema_id, cred.schema_id);
220        assert_eq!(deserialized.issuer_did, cred.issuer_did);
221        assert_eq!(deserialized.values.len(), cred.values.len());
222    }
223
224    #[test]
225    fn extract_issuer_did_bare_sovrin() {
226        let issuer = super::extract_issuer_did("NcYxiDXkpYi6ov5FcYDi1e:3:CL:12:default");
227        assert_eq!(issuer, "NcYxiDXkpYi6ov5FcYDi1e");
228    }
229}