use affinidi_tdk::didcomm::Message;
use serde_json::json;
use zeroize::Zeroizing;
use crate::error::AppError;
pub const AUTHENTICATE_TYPE: &str = "https://trusttasks.org/spec/auth/authenticate/0.1";
pub const REFRESH_TYPE: &str = "https://trusttasks.org/spec/auth/refresh/0.1";
#[derive(Debug)]
pub struct VtaSigningIdentity<'a> {
pub vta_did: &'a str,
pub signing_kid: &'a str,
pub private_key: &'a [u8; 32],
}
#[derive(Debug)]
pub struct ChallengeContext<'a> {
pub session_id: &'a str,
pub challenge: &'a str,
pub server_did: &'a str,
}
pub fn build_authenticate_message(
identity: &VtaSigningIdentity<'_>,
ctx: &ChallengeContext<'_>,
now_secs: u64,
) -> Result<String, AppError> {
let msg = Message::new(
AUTHENTICATE_TYPE,
json!({
"session_id": ctx.session_id,
"challenge": ctx.challenge,
}),
)
.from(identity.vta_did.to_string())
.to(vec![ctx.server_did.to_string()])
.created_time(now_secs);
affinidi_tdk::didcomm::message::pack::pack_signed(
&msg,
identity.signing_kid,
identity.private_key,
)
.map_err(|e| AppError::Internal(format!("failed to sign webvh authenticate message: {e}")))
}
pub struct VtaSigningIdentityOwned {
pub vta_did: String,
pub signing_kid: String,
pub private_key: Zeroizing<[u8; 32]>,
}
impl VtaSigningIdentityOwned {
pub fn as_ref(&self) -> VtaSigningIdentity<'_> {
VtaSigningIdentity {
vta_did: &self.vta_did,
signing_kid: &self.signing_kid,
private_key: &self.private_key,
}
}
}
impl std::fmt::Debug for VtaSigningIdentityOwned {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VtaSigningIdentityOwned")
.field("vta_did", &self.vta_did)
.field("signing_kid", &self.signing_kid)
.field("private_key", &"<redacted>")
.finish()
}
}
pub fn build_refresh_message(
identity: &VtaSigningIdentity<'_>,
server_did: &str,
refresh_token: &str,
now_secs: u64,
) -> Result<String, AppError> {
let msg = Message::new(
REFRESH_TYPE,
json!({
"refresh_token": refresh_token,
}),
)
.from(identity.vta_did.to_string())
.to(vec![server_did.to_string()])
.created_time(now_secs);
affinidi_tdk::didcomm::message::pack::pack_signed(
&msg,
identity.signing_kid,
identity.private_key,
)
.map_err(|e| AppError::Internal(format!("failed to sign webvh refresh message: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use affinidi_tdk::didcomm::message::unpack::{UnpackResult, unpack};
use ed25519_dalek::SigningKey;
fn fixture_identity(seed_byte: u8) -> ([u8; 32], [u8; 32], String, String) {
let seed = [seed_byte; 32];
let sk = SigningKey::from_bytes(&seed);
let private = sk.to_bytes();
let public = sk.verifying_key().to_bytes();
let vta_did = "did:webvh:scid123:vta.example".to_string();
let kid = format!("{vta_did}#key-0");
(private, public, vta_did, kid)
}
fn unpack_with(jws: &str, verifying_key: &[u8; 32]) -> Message {
match unpack(jws, None, None, None, Some(verifying_key)).expect("unpack must succeed") {
UnpackResult::Signed { message, .. } => message,
UnpackResult::Plaintext(_) => panic!("expected Signed result, got Plaintext"),
UnpackResult::Encrypted { .. } => panic!("expected Signed result, got Encrypted"),
_ => panic!("expected Signed result, got an unrecognised UnpackResult variant"),
}
}
#[test]
fn authenticate_message_round_trips_via_unpack() {
let (private, public, vta_did, kid) = fixture_identity(7);
let identity = VtaSigningIdentity {
vta_did: &vta_did,
signing_kid: &kid,
private_key: &private,
};
let ctx = ChallengeContext {
session_id: "session-abc",
challenge: "challenge-xyz",
server_did: "did:web:daemon.example",
};
let jws = build_authenticate_message(&identity, &ctx, 1_700_000_000).unwrap();
let msg = unpack_with(&jws, &public);
assert_eq!(msg.typ, AUTHENTICATE_TYPE);
assert_eq!(msg.from.as_deref(), Some(vta_did.as_str()));
assert_eq!(
msg.to.as_deref(),
Some(&vec![ctx.server_did.to_string()][..])
);
assert_eq!(msg.body["session_id"], "session-abc");
assert_eq!(msg.body["challenge"], "challenge-xyz");
assert_eq!(msg.created_time, Some(1_700_000_000));
}
#[test]
fn authenticate_message_binds_audience_via_to_field() {
let (private, public, vta_did, kid) = fixture_identity(7);
let identity = VtaSigningIdentity {
vta_did: &vta_did,
signing_kid: &kid,
private_key: &private,
};
let ctx = ChallengeContext {
session_id: "s",
challenge: "c",
server_did: "did:web:daemon-A.example",
};
let jws = build_authenticate_message(&identity, &ctx, 1).unwrap();
let msg = unpack_with(&jws, &public);
assert_eq!(
msg.to,
Some(vec!["did:web:daemon-A.example".to_string()]),
"to: must carry the daemon's DID for audience binding"
);
}
#[test]
fn authenticate_message_type_is_canonical_spec_uri() {
assert_eq!(
AUTHENTICATE_TYPE,
"https://trusttasks.org/spec/auth/authenticate/0.1",
);
}
#[test]
fn refresh_message_type_is_canonical_spec_uri() {
assert_eq!(REFRESH_TYPE, "https://trusttasks.org/spec/auth/refresh/0.1",);
}
#[test]
fn refresh_message_round_trips() {
let (private, public, vta_did, kid) = fixture_identity(7);
let identity = VtaSigningIdentity {
vta_did: &vta_did,
signing_kid: &kid,
private_key: &private,
};
let jws = build_refresh_message(&identity, "did:web:daemon.example", "rt-abc", 42).unwrap();
let msg = unpack_with(&jws, &public);
assert_eq!(msg.typ, REFRESH_TYPE);
assert_eq!(msg.from.as_deref(), Some(vta_did.as_str()));
assert_eq!(
msg.to.as_deref(),
Some(&vec!["did:web:daemon.example".to_string()][..])
);
assert_eq!(msg.body["refresh_token"], "rt-abc");
assert_eq!(msg.created_time, Some(42));
}
#[test]
fn refresh_message_type_is_distinct_from_authenticate() {
assert_ne!(AUTHENTICATE_TYPE, REFRESH_TYPE);
}
#[test]
fn authenticate_message_signed_with_wrong_key_fails_unpack() {
let (private_a, _public_a, vta_did, kid) = fixture_identity(7);
let (_private_b, public_b, _, _) = fixture_identity(42);
let identity = VtaSigningIdentity {
vta_did: &vta_did,
signing_kid: &kid,
private_key: &private_a,
};
let ctx = ChallengeContext {
session_id: "s",
challenge: "c",
server_did: "did:web:daemon.example",
};
let jws = build_authenticate_message(&identity, &ctx, 1).unwrap();
let result = unpack(&jws, None, None, None, Some(&public_b));
assert!(
result.is_err(),
"JWS signed by A must not verify under B's key"
);
}
#[test]
fn distinct_session_id_or_challenge_produces_distinct_jws() {
let (private, _public, vta_did, kid) = fixture_identity(7);
let identity = VtaSigningIdentity {
vta_did: &vta_did,
signing_kid: &kid,
private_key: &private,
};
let a = build_authenticate_message(
&identity,
&ChallengeContext {
session_id: "s1",
challenge: "c1",
server_did: "did:web:x",
},
1,
)
.unwrap();
let b = build_authenticate_message(
&identity,
&ChallengeContext {
session_id: "s2",
challenge: "c2",
server_did: "did:web:x",
},
1,
)
.unwrap();
assert_ne!(a, b, "session_id+challenge variation must affect the JWS");
}
}