Skip to main content

oas_document/
proof_format.rs

1// OAS Spec §5.5 — Document Proof Format (Ed25519Signature2020)
2//! Document proof generation and verification.
3//!
4//! Every OAS Identity Document MUST include a cryptographic proof demonstrating
5//! integrity and authenticity using the Ed25519Signature2020 suite.
6
7use serde::{Deserialize, Serialize};
8
9use oas_crypto::encoding;
10use oas_crypto::keypair::OasKeyPair;
11use oas_crypto::CryptoError;
12
13use crate::error::DocumentError;
14
15/// The fixed proof type for OAS document proofs.
16pub const DOCUMENT_PROOF_TYPE: &str = "Ed25519Signature2020";
17
18/// An Ed25519Signature2020 document proof.
19///
20/// See OAS Specification §5.5 for the complete format.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct DocumentProof {
24    /// Fixed: `"Ed25519Signature2020"`
25    #[serde(rename = "type")]
26    pub proof_type: String,
27
28    /// ISO 8601 timestamp of when the proof was created.
29    pub created: String,
30
31    /// Reference to the verification method used (e.g., `did:oas:ns:hmr:a#key-1`).
32    pub verification_method: String,
33
34    /// The purpose of this proof (e.g., `"assertionMethod"`).
35    pub proof_purpose: String,
36
37    /// The multibase-encoded Ed25519 signature (base58btc with `z` prefix).
38    pub proof_value: String,
39}
40
41impl DocumentProof {
42    /// Generates a document proof by signing the canonical document bytes.
43    ///
44    /// Implements OAS Specification §5.5:
45    /// 1. Canonicalize the document (without proof) via JCS
46    /// 2. Sign the canonical bytes with the signing key
47    /// 3. Encode as multibase base58btc
48    ///
49    /// # Arguments
50    ///
51    /// * `document_json` - The document as a JSON value (proof field MUST be absent/null).
52    /// * `keypair` - The signing keypair.
53    /// * `verification_method_id` - Full ID of the verification method (e.g., `did:oas:ns:hmr:a#key-1`).
54    /// * `created` - ISO 8601 timestamp for the proof.
55    ///
56    /// # Returns
57    ///
58    /// A [`DocumentProof`] containing the signature.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`DocumentError::ProofGenerationFailed`] if canonicalization or signing fails.
63    pub fn generate(
64        document_json: &serde_json::Value,
65        keypair: &OasKeyPair,
66        verification_method_id: &str,
67        created: &str,
68    ) -> Result<Self, DocumentError> {
69        let canonical_bytes = oas_crypto::jcs::canonicalize(document_json).map_err(|e| {
70            DocumentError::ProofGenerationFailed {
71                reason: format!("JCS canonicalization failed: {e}"),
72            }
73        })?;
74
75        let signature_bytes = keypair.sign(&canonical_bytes);
76        let proof_value = encoding::multibase_encode(&signature_bytes);
77
78        Ok(Self {
79            proof_type: DOCUMENT_PROOF_TYPE.to_string(),
80            created: created.to_string(),
81            verification_method: verification_method_id.to_string(),
82            proof_purpose: "assertionMethod".to_string(),
83            proof_value,
84        })
85    }
86
87    /// Verifies this document proof against the canonical document bytes.
88    ///
89    /// Implements OAS Specification §5.5 verification:
90    /// 1. Canonicalize the document (without proof) via JCS
91    /// 2. Decode the proof value from multibase
92    /// 3. Verify the Ed25519 signature
93    ///
94    /// # Arguments
95    ///
96    /// * `document_json` - The document as a JSON value (proof field MUST be absent/null).
97    /// * `public_key_bytes` - The 32-byte Ed25519 public key.
98    ///
99    /// # Returns
100    ///
101    /// `Ok(())` if the proof is valid.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`DocumentError::ProofVerificationFailed`] if verification fails.
106    pub fn verify(
107        &self,
108        document_json: &serde_json::Value,
109        public_key_bytes: &[u8],
110    ) -> Result<(), DocumentError> {
111        let canonical_bytes = oas_crypto::jcs::canonicalize(document_json).map_err(|e| {
112            DocumentError::ProofVerificationFailed {
113                reason: format!("JCS canonicalization failed: {e}"),
114            }
115        })?;
116
117        let signature_bytes = encoding::multibase_decode(&self.proof_value).map_err(|e| {
118            DocumentError::ProofVerificationFailed {
119                reason: format!("multibase decode failed: {e}"),
120            }
121        })?;
122
123        OasKeyPair::verify_with_key(public_key_bytes, &canonical_bytes, &signature_bytes).map_err(
124            |e: CryptoError| DocumentError::ProofVerificationFailed {
125                reason: format!("Ed25519 signature verification failed: {e}"),
126            },
127        )
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn generate_and_verify_round_trip() {
137        let keypair = OasKeyPair::generate();
138        let doc_json = serde_json::json!({
139            "id": "did:oas:test:hmr:alice",
140            "kind": "hmr",
141            "sequence": 1
142        });
143
144        let proof = DocumentProof::generate(
145            &doc_json,
146            &keypair,
147            "did:oas:test:hmr:alice#key-1",
148            "2026-01-15T00:00:00Z",
149        );
150        assert!(proof.is_ok());
151        let proof = proof.unwrap_or_else(|_| DocumentProof {
152            proof_type: String::new(),
153            created: String::new(),
154            verification_method: String::new(),
155            proof_purpose: String::new(),
156            proof_value: "z".to_string(),
157        });
158
159        assert_eq!(proof.proof_type, DOCUMENT_PROOF_TYPE);
160        assert_eq!(proof.proof_purpose, "assertionMethod");
161
162        let result = proof.verify(&doc_json, &keypair.verifying_key_bytes());
163        assert!(result.is_ok());
164    }
165
166    #[test]
167    fn verify_tampered_document_fails() {
168        let keypair = OasKeyPair::generate();
169        let doc_json = serde_json::json!({"id": "did:oas:test:hmr:alice", "sequence": 1});
170
171        let proof = DocumentProof::generate(
172            &doc_json,
173            &keypair,
174            "did:oas:test:hmr:alice#key-1",
175            "2026-01-15T00:00:00Z",
176        )
177        .unwrap_or_else(|_| DocumentProof {
178            proof_type: String::new(),
179            created: String::new(),
180            verification_method: String::new(),
181            proof_purpose: String::new(),
182            proof_value: "z".to_string(),
183        });
184
185        let tampered = serde_json::json!({"id": "did:oas:test:hmr:alice", "sequence": 999});
186        let result = proof.verify(&tampered, &keypair.verifying_key_bytes());
187        assert!(result.is_err());
188    }
189
190    #[test]
191    fn verify_wrong_key_fails() {
192        let keypair = OasKeyPair::generate();
193        let wrong_key = OasKeyPair::generate();
194        let doc_json = serde_json::json!({"id": "test", "sequence": 1});
195
196        let proof =
197            DocumentProof::generate(&doc_json, &keypair, "test#key-1", "2026-01-15T00:00:00Z")
198                .unwrap_or_else(|_| DocumentProof {
199                    proof_type: String::new(),
200                    created: String::new(),
201                    verification_method: String::new(),
202                    proof_purpose: String::new(),
203                    proof_value: "z".to_string(),
204                });
205
206        let result = proof.verify(&doc_json, &wrong_key.verifying_key_bytes());
207        assert!(result.is_err());
208    }
209
210    #[test]
211    fn serde_round_trip() {
212        let keypair = OasKeyPair::generate();
213        let doc_json = serde_json::json!({"test": true});
214        let proof =
215            DocumentProof::generate(&doc_json, &keypair, "test#key-1", "2026-01-15T00:00:00Z")
216                .unwrap_or_else(|_| DocumentProof {
217                    proof_type: String::new(),
218                    created: String::new(),
219                    verification_method: String::new(),
220                    proof_purpose: String::new(),
221                    proof_value: "z".to_string(),
222                });
223
224        let json = serde_json::to_string(&proof).unwrap_or_default();
225        let parsed: DocumentProof = serde_json::from_str(&json).unwrap_or_else(|_| proof.clone());
226        assert_eq!(proof, parsed);
227    }
228}