use affinidi_data_integrity::{DataIntegrityProof, SignOptions, VerifyOptions};
use affinidi_secrets_resolver::secrets::Secret;
use affinidi_vc::VerifiableCredential;
use vti_common::error::AppError;
use vti_common::trust_task::envelope::{EnvelopeRole, seal_envelope};
pub const ASSERTION_KEY_FRAGMENT: &str = "key-0";
#[derive(Debug, Clone)]
pub struct LocalSigner {
issuer_did: String,
secrets: Vec<Secret>,
}
impl LocalSigner {
pub fn new(issuer_did: String, secret: Secret) -> Self {
Self {
issuer_did,
secrets: vec![secret],
}
}
#[must_use]
pub fn with_additional_key(mut self, secret: Secret) -> Self {
self.secrets.push(secret);
self
}
pub fn key_count(&self) -> usize {
self.secrets.len()
}
fn primary(&self) -> &Secret {
&self.secrets[0]
}
pub fn from_ed25519_seed(issuer_did: String, seed: &[u8; 32]) -> Self {
let assertion_id = assertion_method_id(&issuer_did);
let secret = Secret::generate_ed25519(Some(&assertion_id), Some(seed));
Self {
issuer_did,
secrets: vec![secret],
}
}
#[cfg(test)]
pub(crate) fn did_key_for_seed(seed: &[u8; 32]) -> Self {
let probe = Secret::generate_ed25519(None, Some(seed));
let mb = probe.get_public_keymultibase().expect("ed25519 multikey");
let did = format!("did:key:{mb}");
let secret = Secret::generate_ed25519(Some(&format!("{did}#{mb}")), Some(seed));
Self::new(did, secret)
}
async fn proof_value_for(
&self,
doc: &(impl serde::Serialize + Sync),
) -> Result<serde_json::Value, AppError> {
self.proof_value_with(doc, SignOptions::new()).await
}
async fn proof_value_with(
&self,
doc: &(impl serde::Serialize + Sync),
options: SignOptions,
) -> Result<serde_json::Value, AppError> {
self.proof_value_from(&self.secrets, doc, options).await
}
async fn proof_value_from(
&self,
secrets: &[Secret],
doc: &(impl serde::Serialize + Sync),
options: SignOptions,
) -> Result<serde_json::Value, AppError> {
let signers: Vec<&dyn affinidi_data_integrity::signer::Signer> = secrets
.iter()
.map(|s| s as &dyn affinidi_data_integrity::signer::Signer)
.collect();
let proofs = DataIntegrityProof::sign_multi(doc, &signers, options)
.await
.map_err(|e| AppError::Internal(format!("sign: {e}")))?;
let value = if proofs.len() == 1 {
serde_json::to_value(&proofs[0])
} else {
serde_json::to_value(&proofs)
};
value.map_err(|e| AppError::Internal(format!("serialize proof: {e}")))
}
pub fn issuer_did(&self) -> &str {
&self.issuer_did
}
pub fn assertion_method_id(&self) -> &str {
&self.primary().id
}
pub fn public_bytes(&self) -> &[u8] {
self.primary().get_public_bytes()
}
pub fn ed25519_signing_key(&self) -> Option<ed25519_dalek::SigningKey> {
let bytes = self.primary().get_private_bytes();
<[u8; 32]>::try_from(bytes)
.ok()
.map(|b| ed25519_dalek::SigningKey::from_bytes(&b))
}
pub async fn sign(&self, vc: &mut VerifiableCredential) -> Result<(), AppError> {
vc.proof = Some(self.proof_value_for(vc).await?);
Ok(())
}
pub async fn sign_operational_doc(&self, doc: &mut serde_json::Value) -> Result<(), AppError> {
self.sign_operational(doc, EnvelopeRole::Request).await
}
pub async fn sign_operational_response(
&self,
doc: &mut serde_json::Value,
) -> Result<(), AppError> {
self.sign_operational(doc, EnvelopeRole::Response).await
}
pub async fn sign_attested_response(
&self,
doc: &mut serde_json::Value,
) -> Result<(), AppError> {
seal_envelope(doc, &self.issuer_did, EnvelopeRole::Response)
.map_err(|e| AppError::Internal(format!("cannot sign as this VTC: {e}")))?;
let obj = doc
.as_object_mut()
.ok_or_else(|| AppError::Internal("response document is not a JSON object".into()))?;
obj.remove("proof");
let proof_value = self
.proof_value_from(
std::slice::from_ref(self.primary()),
&*doc,
SignOptions::new().with_proof_purpose("assertionMethod"),
)
.await?;
doc.as_object_mut()
.expect("checked above")
.insert("proof".into(), proof_value);
Ok(())
}
async fn sign_operational(
&self,
doc: &mut serde_json::Value,
role: EnvelopeRole,
) -> Result<(), AppError> {
seal_envelope(doc, &self.issuer_did, role)
.map_err(|e| AppError::Internal(format!("cannot sign as this VTC: {e}")))?;
let obj = doc
.as_object_mut()
.ok_or_else(|| AppError::Internal("request document is not a JSON object".into()))?;
obj.remove("proof");
let proof_value = self
.proof_value_from(
std::slice::from_ref(self.primary()),
&*doc,
SignOptions::new().with_proof_purpose("authentication"),
)
.await?;
doc.as_object_mut()
.expect("checked above")
.insert("proof".into(), proof_value);
Ok(())
}
pub async fn sign_doc(&self, doc: &mut serde_json::Value) -> Result<(), AppError> {
let obj = doc
.as_object_mut()
.ok_or_else(|| AppError::Internal("credential document is not a JSON object".into()))?;
obj.remove("proof");
let proof_value = self.proof_value_for(&*doc).await?;
doc.as_object_mut()
.expect("checked above")
.insert("proof".into(), proof_value);
Ok(())
}
pub fn verify(&self, vc: &VerifiableCredential) -> Result<(), AppError> {
let proof_value = vc
.proof
.as_ref()
.ok_or_else(|| AppError::Validation("VC has no proof to verify".into()))?;
let proofs = super::proof_set::proof_set(proof_value)
.map_err(|e| AppError::Validation(format!("parse VC proof: {e}")))?;
let mut vc_without_proof = vc.clone();
vc_without_proof.proof = None;
let outcomes: Vec<(String, Result<(), String>)> = proofs
.iter()
.map(|proof| {
let did = super::proof_set::proof_signer_did(proof).to_string();
let r = match self
.secrets
.iter()
.find(|s| s.id == proof.verification_method)
{
None => Err("proof names a key this signer does not hold".to_string()),
Some(secret) => proof
.verify_with_public_key(
&vc_without_proof,
secret.get_public_bytes(),
VerifyOptions::new(),
)
.map_err(|e| e.to_string()),
};
(did, r)
})
.collect();
super::proof_set::accept_all(&outcomes)
.map_err(|e| AppError::Forbidden(format!("verify VC: {e}")))?;
Ok(())
}
}
pub fn assertion_method_id(issuer_did: &str) -> String {
match issuer_did.strip_prefix("did:key:") {
Some(id) => format!("{issuer_did}#{id}"),
None => format!("{issuer_did}#{ASSERTION_KEY_FRAGMENT}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_DID: &str = "did:webvh:vtc.example.com:abc";
#[test]
fn from_seed_constructs_with_canonical_kid() {
let seed = [0xAB; 32];
let signer = LocalSigner::from_ed25519_seed(TEST_DID.into(), &seed);
assert_eq!(signer.issuer_did(), TEST_DID);
assert_eq!(
signer.assertion_method_id(),
format!("{TEST_DID}#{ASSERTION_KEY_FRAGMENT}")
);
let other = LocalSigner::from_ed25519_seed(TEST_DID.into(), &seed);
assert_eq!(signer.public_bytes(), other.public_bytes());
}
#[test]
fn different_seeds_produce_different_public_keys() {
let a = LocalSigner::from_ed25519_seed(TEST_DID.into(), &[0xAB; 32]);
let b = LocalSigner::from_ed25519_seed(TEST_DID.into(), &[0xCD; 32]);
assert_ne!(a.public_bytes(), b.public_bytes());
}
#[test]
fn assertion_method_id_is_did_hash_fragment() {
assert_eq!(
assertion_method_id("did:webvh:scid:vtc.example"),
"did:webvh:scid:vtc.example#key-0".to_string()
);
assert_eq!(
assertion_method_id("did:key:zX"),
"did:key:zX#zX".to_string()
);
}
}
#[cfg(test)]
mod multi_key_tests {
use super::*;
use affinidi_secrets_resolver::secrets::Secret;
const DID: &str = "did:webvh:vtc.example.com:multi";
fn vc() -> serde_json::Value {
serde_json::json!({
"@context": ["https://www.w3.org/ns/credentials/v2"],
"id": "urn:uuid:multi-key-probe",
"type": ["VerifiableCredential"],
"issuer": DID,
"credentialSubject": { "id": "did:example:subject" },
})
}
#[tokio::test]
async fn one_key_emits_a_proof_object_not_an_array() {
let signer = LocalSigner::from_ed25519_seed(DID.into(), &[0x11; 32]);
assert_eq!(signer.key_count(), 1);
let mut doc = vc();
signer.sign_doc(&mut doc).await.expect("signs");
let proof = doc.get("proof").expect("proof present");
assert!(
proof.is_object(),
"a single-key signer must emit the historical shape, not a one-element array: {proof}"
);
}
#[tokio::test]
async fn a_request_is_signed_for_authentication() {
let signer = LocalSigner::from_ed25519_seed(DID.into(), &[0x11; 32]);
let doc = vti_common::capability_client::build_document(
DID,
"did:web:registry.example",
"https://trusttasks.org/spec/registry/record/put/0.1",
serde_json::json!({}),
);
let mut doc = serde_json::to_value(&doc).unwrap();
signer.sign_operational_doc(&mut doc).await.expect("signs");
assert_eq!(doc["proof"]["proofPurpose"], "authentication", "{doc}");
assert_eq!(doc["issuer"], DID);
assert_eq!(doc["recipient"], "did:web:registry.example");
assert!(
doc.get("issuedAt").is_some() && doc.get("id").is_some(),
"{doc}"
);
let mut vc = vc();
signer.sign_doc(&mut vc).await.expect("signs");
assert_ne!(vc["proof"]["proofPurpose"], "authentication");
}
#[tokio::test]
async fn a_hybrid_signer_signs_a_trust_task_with_one_proof() {
let pq = Secret::generate_ml_dsa_44(Some(&format!("{DID}#key-pq")), None);
let signer =
LocalSigner::from_ed25519_seed(DID.into(), &[0x11; 32]).with_additional_key(pq);
assert_eq!(signer.key_count(), 2);
let doc = vti_common::capability_client::build_document(
DID,
"did:web:registry.example",
"https://trusttasks.org/spec/registry/record/put/0.1",
serde_json::json!({}),
);
let mut doc = serde_json::to_value(&doc).unwrap();
signer.sign_operational_doc(&mut doc).await.expect("signs");
assert!(doc["proof"].is_object(), "{doc}");
assert_eq!(doc["proof"]["cryptosuite"], "eddsa-jcs-2022");
serde_json::from_value::<trust_tasks_rs::TrustTask<serde_json::Value>>(doc)
.expect("reparses as a Trust Task");
}
#[tokio::test]
async fn two_keys_emit_one_proof_each() {
let pq = Secret::generate_ml_dsa_44(Some(&format!("{DID}#key-pq")), None);
let signer =
LocalSigner::from_ed25519_seed(DID.into(), &[0x11; 32]).with_additional_key(pq);
assert_eq!(signer.key_count(), 2);
let mut doc = vc();
signer.sign_doc(&mut doc).await.expect("signs with both");
let proofs = doc
.get("proof")
.and_then(|p| p.as_array())
.expect("two keys produce an array");
assert_eq!(proofs.len(), 2);
let suites: Vec<&str> = proofs
.iter()
.filter_map(|p| p.get("cryptosuite").and_then(|s| s.as_str()))
.collect();
assert!(suites.contains(&"eddsa-jcs-2022"), "suites: {suites:?}");
assert!(suites.contains(&"mldsa44-jcs-2024"), "suites: {suites:?}");
}
#[tokio::test]
async fn every_proof_carries_the_same_created() {
let pq = Secret::generate_ml_dsa_44(Some(&format!("{DID}#key-pq")), None);
let signer =
LocalSigner::from_ed25519_seed(DID.into(), &[0x11; 32]).with_additional_key(pq);
let mut doc = vc();
signer.sign_doc(&mut doc).await.expect("signs");
let proofs = doc.get("proof").and_then(|p| p.as_array()).expect("array");
let created: Vec<&str> = proofs
.iter()
.filter_map(|p| p.get("created").and_then(|c| c.as_str()))
.collect();
assert_eq!(created.len(), 2);
assert_eq!(created[0], created[1], "one signing event, one timestamp");
}
#[tokio::test]
async fn a_two_key_credential_verifies() {
let pq = Secret::generate_ml_dsa_44(Some(&format!("{DID}#key-pq")), None);
let signer =
LocalSigner::from_ed25519_seed(DID.into(), &[0x11; 32]).with_additional_key(pq);
let mut vc: VerifiableCredential =
serde_json::from_value(vc()).expect("fixture is a credential");
signer.sign(&mut vc).await.expect("signs");
signer.verify(&vc).expect("the Ed25519 proof verifies");
}
}