oas-document 1.1.0

OAS Identity Document types and validation for the Open Agent Specification
Documentation
// OAS Spec §5.5 — Document Proof Format (Ed25519Signature2020)
//! Document proof generation and verification.
//!
//! Every OAS Identity Document MUST include a cryptographic proof demonstrating
//! integrity and authenticity using the Ed25519Signature2020 suite.

use serde::{Deserialize, Serialize};

use oas_crypto::encoding;
use oas_crypto::keypair::OasKeyPair;
use oas_crypto::CryptoError;

use crate::error::DocumentError;

/// The fixed proof type for OAS document proofs.
pub const DOCUMENT_PROOF_TYPE: &str = "Ed25519Signature2020";

/// An Ed25519Signature2020 document proof.
///
/// See OAS Specification §5.5 for the complete format.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentProof {
    /// Fixed: `"Ed25519Signature2020"`
    #[serde(rename = "type")]
    pub proof_type: String,

    /// ISO 8601 timestamp of when the proof was created.
    pub created: String,

    /// Reference to the verification method used (e.g., `did:oas:ns:hmr:a#key-1`).
    pub verification_method: String,

    /// The purpose of this proof (e.g., `"assertionMethod"`).
    pub proof_purpose: String,

    /// The multibase-encoded Ed25519 signature (base58btc with `z` prefix).
    pub proof_value: String,
}

impl DocumentProof {
    /// Generates a document proof by signing the canonical document bytes.
    ///
    /// Implements OAS Specification §5.5:
    /// 1. Canonicalize the document (without proof) via JCS
    /// 2. Sign the canonical bytes with the signing key
    /// 3. Encode as multibase base58btc
    ///
    /// # Arguments
    ///
    /// * `document_json` - The document as a JSON value (proof field MUST be absent/null).
    /// * `keypair` - The signing keypair.
    /// * `verification_method_id` - Full ID of the verification method (e.g., `did:oas:ns:hmr:a#key-1`).
    /// * `created` - ISO 8601 timestamp for the proof.
    ///
    /// # Returns
    ///
    /// A [`DocumentProof`] containing the signature.
    ///
    /// # Errors
    ///
    /// Returns [`DocumentError::ProofGenerationFailed`] if canonicalization or signing fails.
    pub fn generate(
        document_json: &serde_json::Value,
        keypair: &OasKeyPair,
        verification_method_id: &str,
        created: &str,
    ) -> Result<Self, DocumentError> {
        let canonical_bytes = oas_crypto::jcs::canonicalize(document_json).map_err(|e| {
            DocumentError::ProofGenerationFailed {
                reason: format!("JCS canonicalization failed: {e}"),
            }
        })?;

        let signature_bytes = keypair.sign(&canonical_bytes);
        let proof_value = encoding::multibase_encode(&signature_bytes);

        Ok(Self {
            proof_type: DOCUMENT_PROOF_TYPE.to_string(),
            created: created.to_string(),
            verification_method: verification_method_id.to_string(),
            proof_purpose: "assertionMethod".to_string(),
            proof_value,
        })
    }

    /// Verifies this document proof against the canonical document bytes.
    ///
    /// Implements OAS Specification §5.5 verification:
    /// 1. Canonicalize the document (without proof) via JCS
    /// 2. Decode the proof value from multibase
    /// 3. Verify the Ed25519 signature
    ///
    /// # Arguments
    ///
    /// * `document_json` - The document as a JSON value (proof field MUST be absent/null).
    /// * `public_key_bytes` - The 32-byte Ed25519 public key.
    ///
    /// # Returns
    ///
    /// `Ok(())` if the proof is valid.
    ///
    /// # Errors
    ///
    /// Returns [`DocumentError::ProofVerificationFailed`] if verification fails.
    pub fn verify(
        &self,
        document_json: &serde_json::Value,
        public_key_bytes: &[u8],
    ) -> Result<(), DocumentError> {
        let canonical_bytes = oas_crypto::jcs::canonicalize(document_json).map_err(|e| {
            DocumentError::ProofVerificationFailed {
                reason: format!("JCS canonicalization failed: {e}"),
            }
        })?;

        let signature_bytes = encoding::multibase_decode(&self.proof_value).map_err(|e| {
            DocumentError::ProofVerificationFailed {
                reason: format!("multibase decode failed: {e}"),
            }
        })?;

        OasKeyPair::verify_with_key(public_key_bytes, &canonical_bytes, &signature_bytes).map_err(
            |e: CryptoError| DocumentError::ProofVerificationFailed {
                reason: format!("Ed25519 signature verification failed: {e}"),
            },
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn generate_and_verify_round_trip() {
        let keypair = OasKeyPair::generate();
        let doc_json = serde_json::json!({
            "id": "did:oas:test:hmr:alice",
            "kind": "hmr",
            "sequence": 1
        });

        let proof = DocumentProof::generate(
            &doc_json,
            &keypair,
            "did:oas:test:hmr:alice#key-1",
            "2026-01-15T00:00:00Z",
        );
        assert!(proof.is_ok());
        let proof = proof.unwrap_or_else(|_| DocumentProof {
            proof_type: String::new(),
            created: String::new(),
            verification_method: String::new(),
            proof_purpose: String::new(),
            proof_value: "z".to_string(),
        });

        assert_eq!(proof.proof_type, DOCUMENT_PROOF_TYPE);
        assert_eq!(proof.proof_purpose, "assertionMethod");

        let result = proof.verify(&doc_json, &keypair.verifying_key_bytes());
        assert!(result.is_ok());
    }

    #[test]
    fn verify_tampered_document_fails() {
        let keypair = OasKeyPair::generate();
        let doc_json = serde_json::json!({"id": "did:oas:test:hmr:alice", "sequence": 1});

        let proof = DocumentProof::generate(
            &doc_json,
            &keypair,
            "did:oas:test:hmr:alice#key-1",
            "2026-01-15T00:00:00Z",
        )
        .unwrap_or_else(|_| DocumentProof {
            proof_type: String::new(),
            created: String::new(),
            verification_method: String::new(),
            proof_purpose: String::new(),
            proof_value: "z".to_string(),
        });

        let tampered = serde_json::json!({"id": "did:oas:test:hmr:alice", "sequence": 999});
        let result = proof.verify(&tampered, &keypair.verifying_key_bytes());
        assert!(result.is_err());
    }

    #[test]
    fn verify_wrong_key_fails() {
        let keypair = OasKeyPair::generate();
        let wrong_key = OasKeyPair::generate();
        let doc_json = serde_json::json!({"id": "test", "sequence": 1});

        let proof =
            DocumentProof::generate(&doc_json, &keypair, "test#key-1", "2026-01-15T00:00:00Z")
                .unwrap_or_else(|_| DocumentProof {
                    proof_type: String::new(),
                    created: String::new(),
                    verification_method: String::new(),
                    proof_purpose: String::new(),
                    proof_value: "z".to_string(),
                });

        let result = proof.verify(&doc_json, &wrong_key.verifying_key_bytes());
        assert!(result.is_err());
    }

    #[test]
    fn serde_round_trip() {
        let keypair = OasKeyPair::generate();
        let doc_json = serde_json::json!({"test": true});
        let proof =
            DocumentProof::generate(&doc_json, &keypair, "test#key-1", "2026-01-15T00:00:00Z")
                .unwrap_or_else(|_| DocumentProof {
                    proof_type: String::new(),
                    created: String::new(),
                    verification_method: String::new(),
                    proof_purpose: String::new(),
                    proof_value: "z".to_string(),
                });

        let json = serde_json::to_string(&proof).unwrap_or_default();
        let parsed: DocumentProof = serde_json::from_str(&json).unwrap_or_else(|_| proof.clone());
        assert_eq!(proof, parsed);
    }
}