vta-sdk 0.18.18

SDK for Verifiable Trust Agents operating in Verifiable Trust Communities
Documentation
//! Ephemeral Ed25519 `did:key` generated by the setup workflow for a single
//! provisioning session.
//!
//! The setup key is held in memory for the lifetime of the setup process. On
//! restart a fresh key is generated and the operator must re-run the
//! `pnm acl create` step for the new DID, *unless* the key was persisted via
//! [`EphemeralSetupKey::persist_to`] and reloaded via
//! [`EphemeralSetupKey::load_from`]. The latter is how a two-phase
//! non-interactive flow bridges the race between ACL provisioning and
//! connection verification.

use std::fs;
use std::path::Path;

#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};

use affinidi_tdk::dids::{DID, KeyType};
use serde::{Deserialize, Serialize};

use super::error::ProvisionError;

/// On-disk representation. Kept deliberately small — only the two strings
/// the auth paths actually consume.
#[derive(Debug, Serialize, Deserialize)]
struct PersistedKey {
    version: u8,
    did: String,
    private_key_multibase: String,
    #[serde(default)]
    note: String,
}

/// An ephemeral Ed25519 `did:key` minted for a single provisioning run.
pub struct EphemeralSetupKey {
    pub did: String,
    private_key_mb: String,
}

impl EphemeralSetupKey {
    /// Generate a fresh Ed25519 `did:key` and capture its private key in
    /// multibase form for re-use by auth code.
    pub fn generate() -> Result<Self, ProvisionError> {
        let (did, secret) = DID::generate_did_key(KeyType::Ed25519)
            .map_err(|e| ProvisionError::DidKeyGeneration(format!("generate did:key: {e}")))?;
        let private_key_mb = secret.get_private_keymultibase().map_err(|e| {
            ProvisionError::DidKeyGeneration(format!("get private key multibase: {e}"))
        })?;
        Ok(Self {
            did,
            private_key_mb,
        })
    }

    /// Multibase-encoded private key. Auth helpers consume this directly.
    pub fn private_key_multibase(&self) -> &str {
        &self.private_key_mb
    }

    /// Persist to a JSON file with owner-only permissions (unix). Used by
    /// non-interactive setup flows to bridge phases between ACL
    /// registration and connection verification.
    pub fn persist_to(&self, path: &Path) -> Result<(), ProvisionError> {
        let payload = PersistedKey {
            version: 1,
            did: self.did.clone(),
            private_key_multibase: self.private_key_mb.clone(),
            note: "Ephemeral setup key for VTA integration provisioning. \
                   Delete once the integration is provisioned."
                .into(),
        };
        let json = serde_json::to_string_pretty(&payload).map_err(ProvisionError::Serialization)?;

        let mut opts = fs::OpenOptions::new();
        opts.create(true).write(true).truncate(true);
        #[cfg(unix)]
        opts.mode(0o600);
        let mut file = opts.open(path).map_err(|e| ProvisionError::SetupKeyIo {
            path: path.to_path_buf(),
            source: e,
        })?;
        use std::io::Write;
        file.write_all(json.as_bytes())
            .map_err(|e| ProvisionError::SetupKeyIo {
                path: path.to_path_buf(),
                source: e,
            })?;
        #[cfg(unix)]
        {
            let mut perm = fs::metadata(path)
                .map_err(|e| ProvisionError::SetupKeyIo {
                    path: path.to_path_buf(),
                    source: e,
                })?
                .permissions();
            perm.set_mode(0o600);
            fs::set_permissions(path, perm).map_err(|e| ProvisionError::SetupKeyIo {
                path: path.to_path_buf(),
                source: e,
            })?;
        }
        Ok(())
    }

    /// Reload a previously-persisted setup key.
    pub fn load_from(path: &Path) -> Result<Self, ProvisionError> {
        let bytes = fs::read(path).map_err(|e| ProvisionError::SetupKeyIo {
            path: path.to_path_buf(),
            source: e,
        })?;
        let p: PersistedKey =
            serde_json::from_slice(&bytes).map_err(|e| ProvisionError::SetupKeyParse {
                path: path.to_path_buf(),
                source: e,
            })?;
        if p.version != 1 {
            return Err(ProvisionError::UnsupportedSetupKeyVersion {
                path: path.to_path_buf(),
                version: p.version,
            });
        }
        Ok(Self {
            did: p.did,
            private_key_mb: p.private_key_multibase,
        })
    }
}

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

    #[test]
    fn generate_returns_did_key_with_private_multibase() {
        let key = EphemeralSetupKey::generate().unwrap();
        assert!(key.did.starts_with("did:key:z6Mk"));
        assert!(!key.private_key_multibase().is_empty());
    }

    #[test]
    fn two_generate_calls_produce_distinct_keys() {
        let a = EphemeralSetupKey::generate().unwrap();
        let b = EphemeralSetupKey::generate().unwrap();
        assert_ne!(a.did, b.did);
    }

    #[test]
    fn persist_roundtrip_preserves_did_and_key() {
        let tmp = NamedTempFile::new().unwrap();
        let original = EphemeralSetupKey::generate().unwrap();
        original.persist_to(tmp.path()).unwrap();
        let reloaded = EphemeralSetupKey::load_from(tmp.path()).unwrap();
        assert_eq!(original.did, reloaded.did);
        assert_eq!(
            original.private_key_multibase(),
            reloaded.private_key_multibase()
        );
    }

    #[cfg(unix)]
    #[test]
    fn persisted_file_has_owner_only_permissions() {
        use std::os::unix::fs::PermissionsExt;
        let tmp = NamedTempFile::new().unwrap();
        let key = EphemeralSetupKey::generate().unwrap();
        key.persist_to(tmp.path()).unwrap();
        let perm = fs::metadata(tmp.path()).unwrap().permissions();
        assert_eq!(perm.mode() & 0o777, 0o600);
    }

    #[test]
    fn load_rejects_unknown_version() {
        let tmp = NamedTempFile::new().unwrap();
        std::fs::write(
            tmp.path(),
            r#"{"version":42,"did":"did:key:z6Mk","private_key_multibase":"z"}"#,
        )
        .unwrap();
        assert!(matches!(
            EphemeralSetupKey::load_from(tmp.path()),
            Err(ProvisionError::UnsupportedSetupKeyVersion { version: 42, .. })
        ));
    }
}