chio-did 0.1.2

Self-certifying did:chio documents and resolution
Documentation
//! Self-certifying `did:chio` identifiers and DID Document resolution.
//!
//! `did:chio` is intentionally narrow: the method-specific identifier is the
//! lowercase hex form of an Ed25519 public key already used by Chio agents and
//! operators. Basic resolution is self-certifying and requires no registry
//! lookup. Optional receipt-log service endpoints can be attached by the
//! resolving environment.

#![forbid(unsafe_code)]
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]

#[cfg(feature = "fuzz")]
pub mod fuzz;

use std::fmt;
use std::str::FromStr;

use chio_core_types::{crypto::SigningAlgorithm, PublicKey};
use serde::{Deserialize, Serialize};
use url::Url;

const DID_CHIO_PREFIX: &str = "did:chio:";
const DID_CONTEXT_V1: &str = "https://www.w3.org/ns/did/v1";
const ED25519_VERIFICATION_KEY_2020: &str = "Ed25519VerificationKey2020";
const ED25519_PUB_MULTICODEC_PREFIX: [u8; 2] = [0xed, 0x01];
pub const RECEIPT_LOG_SERVICE_TYPE: &str = "ChioReceiptLogService";
pub const PASSPORT_STATUS_SERVICE_TYPE: &str = "ChioPassportStatusService";

#[derive(Debug, thiserror::Error)]
pub enum DidError {
    #[error("did:chio identifiers must start with did:chio:")]
    InvalidPrefix,

    #[error("did:chio method-specific identifier must be exactly 64 lowercase hex characters")]
    InvalidMethodSpecificId,

    #[error("invalid did:chio public key: {0}")]
    InvalidPublicKey(String),

    #[error("did:chio only supports ed25519 public keys, got {0}")]
    UnsupportedKeyAlgorithm(String),

    #[error("invalid service endpoint URL: {0}")]
    InvalidServiceEndpoint(String),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DidChio {
    public_key: PublicKey,
}

impl DidChio {
    pub fn from_public_key(public_key: PublicKey) -> Result<Self, DidError> {
        if public_key.algorithm() != SigningAlgorithm::Ed25519 {
            return Err(DidError::UnsupportedKeyAlgorithm(
                public_key.algorithm().prefix().to_string(),
            ));
        }
        Ok(Self { public_key })
    }

    pub fn try_from_public_key(public_key: PublicKey) -> Result<Self, DidError> {
        Self::from_public_key(public_key)
    }

    #[must_use]
    pub fn public_key(&self) -> &PublicKey {
        &self.public_key
    }

    #[must_use]
    pub fn as_str(&self) -> String {
        format!("{DID_CHIO_PREFIX}{}", self.public_key.to_hex())
    }

    #[must_use]
    pub fn verification_method_id(&self) -> String {
        format!("{}#key-1", self.as_str())
    }

    #[must_use]
    pub fn public_key_multibase(&self) -> String {
        let mut value = Vec::with_capacity(ED25519_PUB_MULTICODEC_PREFIX.len() + 32);
        value.extend_from_slice(&ED25519_PUB_MULTICODEC_PREFIX);
        value.extend_from_slice(self.public_key.as_bytes());
        format!("z{}", bs58::encode(value).into_string())
    }

    #[must_use]
    pub fn resolve(&self) -> DidDocument {
        self.resolve_with_options(&ResolveOptions::default())
    }

    #[must_use]
    pub fn resolve_with_options(&self, options: &ResolveOptions) -> DidDocument {
        let did = self.as_str();
        let verification_method_id = self.verification_method_id();
        DidDocument {
            context: DID_CONTEXT_V1.to_string(),
            id: did.clone(),
            verification_method: vec![DidVerificationMethod {
                id: verification_method_id.clone(),
                verification_type: ED25519_VERIFICATION_KEY_2020.to_string(),
                controller: did.clone(),
                public_key_multibase: self.public_key_multibase(),
            }],
            authentication: vec![verification_method_id.clone()],
            assertion_method: vec![verification_method_id],
            service: options.services.clone(),
        }
    }
}

impl fmt::Display for DidChio {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.as_str())
    }
}

impl TryFrom<PublicKey> for DidChio {
    type Error = DidError;

    fn try_from(value: PublicKey) -> Result<Self, Self::Error> {
        Self::from_public_key(value)
    }
}

impl FromStr for DidChio {
    type Err = DidError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        let suffix = value
            .strip_prefix(DID_CHIO_PREFIX)
            .ok_or(DidError::InvalidPrefix)?;
        if suffix.len() != 64 || !suffix.bytes().all(is_lower_hex_byte) {
            return Err(DidError::InvalidMethodSpecificId);
        }
        let public_key = PublicKey::from_hex(suffix)
            .map_err(|error| DidError::InvalidPublicKey(error.to_string()))?;
        Self::from_public_key(public_key)
    }
}

fn is_lower_hex_byte(byte: u8) -> bool {
    byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResolveOptions {
    services: Vec<DidService>,
}

impl ResolveOptions {
    #[must_use]
    pub fn with_service(mut self, service: DidService) -> Self {
        self.services.push(service);
        self
    }

    #[must_use]
    pub fn services(&self) -> &[DidService] {
        &self.services
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DidDocument {
    #[serde(rename = "@context")]
    pub context: String,
    pub id: String,
    #[serde(rename = "verificationMethod")]
    pub verification_method: Vec<DidVerificationMethod>,
    pub authentication: Vec<String>,
    #[serde(rename = "assertionMethod")]
    pub assertion_method: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub service: Vec<DidService>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DidVerificationMethod {
    pub id: String,
    #[serde(rename = "type")]
    pub verification_type: String,
    pub controller: String,
    #[serde(rename = "publicKeyMultibase")]
    pub public_key_multibase: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DidService {
    pub id: String,
    #[serde(rename = "type")]
    pub service_type: String,
    #[serde(rename = "serviceEndpoint")]
    pub service_endpoint: String,
}

impl DidService {
    pub fn new(
        id: impl Into<String>,
        service_type: impl Into<String>,
        service_endpoint: impl Into<String>,
    ) -> Result<Self, DidError> {
        let service_endpoint = service_endpoint.into();
        let parsed = Url::parse(&service_endpoint)
            .map_err(|error| DidError::InvalidServiceEndpoint(error.to_string()))?;
        if parsed.scheme() != "https" {
            return Err(DidError::InvalidServiceEndpoint(
                "service endpoint must use https".to_string(),
            ));
        }
        Ok(Self {
            id: id.into(),
            service_type: service_type.into(),
            service_endpoint,
        })
    }

    pub fn receipt_log(
        did: &DidChio,
        ordinal: usize,
        service_endpoint: impl Into<String>,
    ) -> Result<Self, DidError> {
        let fragment = service_fragment("receipt-log", ordinal);
        Self::new(
            format!("{}#{fragment}", did.as_str()),
            RECEIPT_LOG_SERVICE_TYPE,
            service_endpoint,
        )
    }

    pub fn passport_status(
        did: &DidChio,
        ordinal: usize,
        service_endpoint: impl Into<String>,
    ) -> Result<Self, DidError> {
        let fragment = service_fragment("passport-status", ordinal);
        Self::new(
            format!("{}#{fragment}", did.as_str()),
            PASSPORT_STATUS_SERVICE_TYPE,
            service_endpoint,
        )
    }
}

fn service_fragment(base: &str, ordinal: usize) -> String {
    if ordinal == 0 {
        base.to_string()
    } else {
        format!("{base}-{}", ordinal + 1)
    }
}

pub fn resolve_did_arc(value: &str, options: &ResolveOptions) -> Result<DidDocument, DidError> {
    DidChio::from_str(value).map(|did| did.resolve_with_options(options))
}

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

    fn fixed_did() -> DidChio {
        let seed = [7u8; 32];
        DidChio::from_public_key(Keypair::from_seed(&seed).public_key()).expect("ed25519 key")
    }

    #[test]
    fn parses_canonical_did_chio_identifier() {
        let canonical = fixed_did().to_string();
        let parsed = DidChio::from_str(&canonical).expect("did");
        assert_eq!(parsed.to_string(), canonical);
    }

    #[test]
    fn rejects_invalid_method_specific_id() {
        let error = DidChio::from_str("did:chio:not-hex").expect_err("invalid did");
        assert!(matches!(error, DidError::InvalidMethodSpecificId));
    }

    #[test]
    fn rejects_uppercase_method_specific_id() {
        let canonical = fixed_did().to_string();
        let uppercase_suffix = canonical[DID_CHIO_PREFIX.len()..].to_uppercase();
        let did = format!("{DID_CHIO_PREFIX}{uppercase_suffix}");

        let error = DidChio::from_str(&did).expect_err("uppercase hex must reject");
        assert!(matches!(error, DidError::InvalidMethodSpecificId));
    }

    #[test]
    fn rejects_non_ed25519_public_keys() {
        let p256_generator = PublicKey::from_hex(
            "p256:046b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c2964fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5",
        )
        .expect("valid p256 public key");

        let error =
            DidChio::try_from_public_key(p256_generator).expect_err("unsupported algorithm");
        assert!(matches!(error, DidError::UnsupportedKeyAlgorithm(_)));
    }

    #[test]
    fn resolves_document_with_ed25519_multibase_key() {
        let did = fixed_did();
        let document = did.resolve();

        assert_eq!(document.id, did.to_string());
        assert_eq!(document.authentication, vec![did.verification_method_id()]);
        assert_eq!(
            document.assertion_method,
            vec![did.verification_method_id()]
        );

        let encoded = document.verification_method[0]
            .public_key_multibase
            .strip_prefix('z')
            .expect("base58btc prefix");
        let decoded = bs58::decode(encoded).into_vec().expect("decode multibase");
        assert_eq!(&decoded[..2], &ED25519_PUB_MULTICODEC_PREFIX);
        assert_eq!(&decoded[2..], did.public_key().as_bytes());
    }

    #[test]
    fn service_fragment_uses_unsuffixed_first_entry_and_one_based_suffixes() {
        assert_eq!(service_fragment("receipt-log", 0), "receipt-log");
        assert_eq!(service_fragment("receipt-log", 1), "receipt-log-2");
        assert_eq!(service_fragment("passport-status", 9), "passport-status-10");
    }

    #[test]
    fn attaches_validated_receipt_log_services_deterministically() {
        let did = fixed_did();
        let options = ResolveOptions::default()
            .with_service(
                DidService::receipt_log(&did, 0, "https://trust.example.com/v1/receipts")
                    .expect("receipt log"),
            )
            .with_service(
                DidService::receipt_log(&did, 1, "https://mirror.example.com/v1/receipts")
                    .expect("receipt log"),
            );

        let document = did.resolve_with_options(&options);
        assert_eq!(document.service.len(), 2);
        assert_eq!(document.service[0].id, format!("{did}#receipt-log"));
        assert_eq!(document.service[1].id, format!("{did}#receipt-log-2"));
        assert_eq!(document.service[0].service_type, RECEIPT_LOG_SERVICE_TYPE);
    }

    #[test]
    fn rejects_invalid_receipt_log_service_endpoint() {
        let did = fixed_did();
        let error =
            DidService::receipt_log(&did, 0, "not-a-url").expect_err("invalid service endpoint");
        assert!(matches!(error, DidError::InvalidServiceEndpoint(_)));
    }

    #[test]
    fn rejects_non_https_service_endpoints() {
        let did = fixed_did();

        for endpoint in [
            "http://trust.example.com/v1/receipts",
            "file:///var/lib/chio/receipts.sqlite3",
            "ftp://trust.example.com/receipts",
        ] {
            let error = DidService::receipt_log(&did, 0, endpoint)
                .expect_err("non-https service endpoint must reject");
            assert!(
                matches!(error, DidError::InvalidServiceEndpoint(_)),
                "endpoint {endpoint:?} returned {error:?}"
            );
        }
    }
}