use exo_core::{
Did, PublicKey, SecretKey, Signature, Timestamp,
crypto::{generate_keypair, sign as core_sign, verify as core_verify},
};
use exo_identity::did::DidDocument;
use crate::error::{ExoError, ExoResult};
const KEYPAIR_PROOF_MESSAGE: &[u8] = b"exo.sdk.identity.keypair.v1";
pub struct Identity {
did: Did,
public: PublicKey,
secret: SecretKey,
label: String,
}
impl Identity {
#[must_use]
pub fn generate(label: &str) -> Self {
let (public, secret) = generate_keypair();
let did = derive_did(&public).unwrap_or_else(|_| fallback_did());
Self {
did,
public,
secret,
label: label.to_owned(),
}
}
pub fn from_keypair(label: &str, public: PublicKey, secret: SecretKey) -> ExoResult<Self> {
let did = derive_did(&public)?;
Self::from_resolved_keypair(label, did, public, secret)
}
pub fn from_resolved_keypair(
label: &str,
did: Did,
public: PublicKey,
secret: SecretKey,
) -> ExoResult<Self> {
verify_keypair_match(&public, &secret)?;
Ok(Self {
did,
public,
secret,
label: label.to_owned(),
})
}
#[must_use]
pub fn did(&self) -> &Did {
&self.did
}
#[must_use]
pub fn public_key(&self) -> &PublicKey {
&self.public
}
#[must_use]
pub fn label(&self) -> &str {
&self.label
}
#[must_use]
pub fn sign(&self, message: &[u8]) -> Signature {
core_sign(message, &self.secret)
}
#[must_use]
pub fn verify(&self, message: &[u8], signature: &Signature) -> bool {
core_verify(message, signature, &self.public)
}
#[must_use]
pub fn did_document(&self) -> DidDocument {
DidDocument {
id: self.did.clone(),
public_keys: vec![self.public],
authentication: Vec::new(),
verification_methods: Vec::new(),
hybrid_verification_methods: Vec::new(),
service_endpoints: Vec::new(),
created: Timestamp::ZERO,
updated: Timestamp::ZERO,
revoked: false,
}
}
}
impl core::fmt::Debug for Identity {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("Identity")
.field("did", &self.did)
.field("label", &self.label)
.field("public", &self.public)
.field("secret", &"***")
.finish()
}
}
fn derive_did(public: &PublicKey) -> ExoResult<Did> {
let digest = blake3::hash(public.as_bytes());
let bytes = digest.as_bytes();
let mut hex = String::with_capacity(16);
for byte in bytes.iter().take(8) {
hex.push_str(&format!("{byte:02x}"));
}
let did_str = format!("did:exo:{hex}");
Did::new(&did_str).map_err(|e| ExoError::InvalidDid(e.to_string()))
}
fn verify_keypair_match(public: &PublicKey, secret: &SecretKey) -> ExoResult<()> {
let signature = core_sign(KEYPAIR_PROOF_MESSAGE, secret);
if core_verify(KEYPAIR_PROOF_MESSAGE, &signature, public) {
Ok(())
} else {
Err(ExoError::Identity(
"secret key does not match public key".to_owned(),
))
}
}
#[allow(clippy::expect_used)] fn fallback_did() -> Did {
Did::new("did:exo:sdk-fallback").expect("did:exo:sdk-fallback is a well-formed DID")
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use serde::Deserialize;
use super::*;
use crate::error::ExoError;
#[derive(Debug, Deserialize)]
struct DidDerivationFixtures {
vectors: Vec<DidDerivationVector>,
}
#[derive(Debug, Deserialize)]
struct DidDerivationVector {
name: String,
public_key_hex: String,
expected_did: String,
}
fn decode_public_key_hex(hex: &str) -> [u8; 32] {
assert_eq!(hex.len(), 64, "fixture public key must be 32 bytes");
let mut bytes = [0u8; 32];
for (index, chunk) in hex.as_bytes().chunks_exact(2).enumerate() {
let hex_byte = core::str::from_utf8(chunk).expect("fixture hex is UTF-8");
bytes[index] = u8::from_str_radix(hex_byte, 16).expect("fixture hex is valid");
}
bytes
}
#[test]
fn generate_produces_valid_did() {
let id = Identity::generate("alice");
assert!(id.did().as_str().starts_with("did:exo:"));
assert_eq!(id.did().as_str().len(), "did:exo:".len() + 16);
}
#[test]
fn generate_stores_label() {
let id = Identity::generate("alice");
assert_eq!(id.label(), "alice");
}
#[test]
fn sign_verify_roundtrip() {
let id = Identity::generate("signer");
let sig = id.sign(b"hello");
assert!(id.verify(b"hello", &sig));
}
#[test]
fn verify_rejects_wrong_message() {
let id = Identity::generate("signer");
let sig = id.sign(b"original");
assert!(!id.verify(b"tampered", &sig));
}
#[test]
fn different_identities_produce_different_dids() {
let a = Identity::generate("a");
let b = Identity::generate("b");
assert_ne!(a.did(), b.did());
}
#[test]
fn from_keypair_derives_same_did_as_generate() {
let id = Identity::generate("first");
let rebuilt = Identity::from_keypair(
"rebuilt",
*id.public_key(),
SecretKey::from_bytes(*id.secret.as_bytes()),
)
.expect("ok");
assert_eq!(id.did(), rebuilt.did());
assert_eq!(rebuilt.label(), "rebuilt");
}
#[test]
fn did_derivation_matches_cross_language_vectors() {
let fixtures: DidDerivationFixtures =
serde_json::from_str(include_str!("../../../tests/fixtures/did-derivation.json"))
.expect("DID derivation fixtures parse");
for vector in fixtures.vectors {
let public = PublicKey::from_bytes(decode_public_key_hex(&vector.public_key_hex));
let did = derive_did(&public).expect("fixture DID derives");
assert_eq!(
did.as_str(),
vector.expected_did,
"fixture {} must match canonical BLAKE3 derivation",
vector.name
);
}
}
#[test]
fn from_resolved_keypair_preserves_fabric_resolved_did() {
let id = Identity::generate("local");
let fabric_did = Did::new("did:exo:fabric-resolved").unwrap();
let rebuilt = Identity::from_resolved_keypair(
"fabric",
fabric_did.clone(),
*id.public_key(),
SecretKey::from_bytes(*id.secret.as_bytes()),
)
.expect("resolved identity");
assert_eq!(rebuilt.did(), &fabric_did);
assert_ne!(rebuilt.did(), id.did());
assert_eq!(rebuilt.label(), "fabric");
let sig = rebuilt.sign(b"resolved fabric DID");
assert!(rebuilt.verify(b"resolved fabric DID", &sig));
}
#[test]
fn from_resolved_keypair_rejects_mismatched_public_and_secret_keys() {
let public_source = Identity::generate("public");
let secret_source = Identity::generate("secret");
let fabric_did = Did::new("did:exo:fabric-mismatch").unwrap();
let err = Identity::from_resolved_keypair(
"fabric",
fabric_did,
*public_source.public_key(),
SecretKey::from_bytes(*secret_source.secret.as_bytes()),
)
.unwrap_err();
assert!(matches!(
err,
ExoError::Identity(msg) if msg.contains("does not match public key")
));
}
#[test]
fn did_document_contains_identity_fields() {
let id = Identity::generate("doc");
let doc = id.did_document();
assert_eq!(&doc.id, id.did());
assert_eq!(doc.public_keys.len(), 1);
assert_eq!(&doc.public_keys[0], id.public_key());
assert!(!doc.revoked);
assert!(doc.authentication.is_empty());
assert!(doc.verification_methods.is_empty());
assert!(doc.hybrid_verification_methods.is_empty());
assert!(doc.service_endpoints.is_empty());
}
#[test]
fn debug_redacts_secret() {
let id = Identity::generate("secret-test");
let dbg = format!("{id:?}");
assert!(dbg.contains("***"));
assert!(dbg.contains("Identity"));
}
#[test]
fn public_key_accessor() {
let id = Identity::generate("pk");
let pk = *id.public_key();
assert_eq!(&pk, id.public_key());
}
}