use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use affinidi_tdk::did_common::DocumentExt;
use affinidi_tdk::didcomm::Message;
use affinidi_tdk::didcomm::UnpackResult;
use affinidi_tdk::didcomm::jws::envelope::{Jws, JwsProtectedHeader};
use affinidi_tdk::didcomm::message::unpack;
use base64::Engine;
use super::error::AppError;
pub const FRESHNESS_WINDOW_SECS: u64 = 300;
pub const FUTURE_SKEW_SECS: u64 = 60;
pub const REPLAY_WINDOW_SECS: u64 = FRESHNESS_WINDOW_SECS + FUTURE_SKEW_SECS;
fn extract_signer_kid(jws_str: &str) -> Result<String, AppError> {
let jws: Jws = serde_json::from_str(jws_str)
.map_err(|e| AppError::Authentication(format!("invalid JWS JSON: {e}")))?;
if jws.signatures.is_empty() {
return Err(AppError::Authentication("JWS has no signatures".into()));
}
if jws.signatures.len() > 1 {
return Err(AppError::Authentication(format!(
"JWS has {} signatures; only single-signer envelopes are accepted",
jws.signatures.len()
)));
}
let sig = &jws.signatures[0];
let header_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(&sig.protected)
.map_err(|e| AppError::Authentication(format!("invalid JWS protected header: {e}")))?;
let header: JwsProtectedHeader = serde_json::from_slice(&header_bytes)
.map_err(|e| AppError::Authentication(format!("invalid JWS header JSON: {e}")))?;
header
.kid
.ok_or_else(|| AppError::Authentication("JWS header missing kid".into()))
}
async fn resolve_verifying_key(
did_resolver: &DIDCacheClient,
kid: &str,
) -> Result<[u8; 32], AppError> {
let base_did = kid.split('#').next().unwrap_or(kid);
let resolved = did_resolver
.resolve(base_did)
.await
.map_err(|e| AppError::Authentication(format!("failed to resolve DID {base_did}: {e}")))?;
let fragment = kid.find('#').map(|i| &kid[i..]);
let refers = |id: &str| id == kid || fragment.is_some_and(|f| id == f);
let listed = resolved.doc.authentication.iter().any(|r| {
match r {
affinidi_tdk::did_common::verification_method::VerificationRelationship::Reference(id) => {
refers(id)
}
affinidi_tdk::did_common::verification_method::VerificationRelationship::VerificationMethod(
m,
) => refers(m.id.as_str()),
_ => false,
}
});
if !listed {
return Err(AppError::Authentication(format!(
"verification method {kid} is not an authentication key of {base_did}"
)));
}
let vm = resolved.doc.get_verification_method(kid).ok_or_else(|| {
AppError::Authentication(format!(
"verification method {kid} not found in DID document"
))
})?;
let vm_type = vm.type_.as_str();
if matches!(
vm_type,
"X25519KeyAgreementKey2020" | "X25519KeyAgreementKey2019"
) {
return Err(AppError::Authentication(format!(
"verification method {kid} is an X25519 key-agreement key; expected an Ed25519 signing key"
)));
}
let pk_bytes = vm
.get_public_key_bytes()
.map_err(|e| AppError::Authentication(format!("failed to get public key bytes: {e}")))?;
pk_bytes
.try_into()
.map_err(|_| AppError::Authentication("public key must be 32 bytes".into()))
}
pub fn require_addressed_to(msg: &Message, own_did: Option<&str>) -> Result<(), AppError> {
let own = own_did.ok_or_else(|| {
AppError::Config(
"server_did not configured; a signed sign-in cannot be addressed to this service"
.into(),
)
})?;
match msg.to.as_deref() {
None | Some([]) => Err(AppError::Validation(
"malformedRequest: a signed sign-in message must name this service in `to`".into(),
)),
Some([one]) if one == own => Ok(()),
Some(to) => Err(AppError::Authentication(format!(
"wrongRecipient: this message is addressed to {}, not to this service ({own})",
to.join(", ")
))),
}
}
pub async fn unpack_signed(
input: &str,
did_resolver: &DIDCacheClient,
) -> Result<(Message, String), AppError> {
let kid = extract_signer_kid(input)?;
let verifying_key = resolve_verifying_key(did_resolver, &kid).await?;
let verify_key = affinidi_tdk::didcomm::jws::verify::VerifyKey::Ed25519(verifying_key);
let signer = affinidi_tdk::didcomm::SignerKey::new(&kid, &verify_key);
let result = unpack::unpack_bound(input, None, None, None, Some(signer))
.map_err(|e| AppError::Authentication(format!("failed to unpack message: {e}")))?;
let (message, signer_kid) = match result {
UnpackResult::Signed {
message,
signer_kid,
} => (message, signer_kid),
UnpackResult::Plaintext(_) => {
return Err(AppError::Authentication(
"message is not signed; refusing to authenticate plaintext".into(),
));
}
UnpackResult::Encrypted { .. } => {
return Err(AppError::Authentication(
"message is encrypted-only; expected JWS-signed envelope".into(),
));
}
_ => {
return Err(AppError::Authentication(
"unsupported DIDComm envelope shape; expected JWS-signed message".into(),
));
}
};
let signer_kid = signer_kid.unwrap_or(kid);
let signer_base = signer_kid
.split('#')
.next()
.unwrap_or(&signer_kid)
.to_string();
let claimed_from = message
.from
.as_deref()
.ok_or_else(|| AppError::Authentication("signed message is missing `from` field".into()))?;
let claimed_base = claimed_from.split('#').next().unwrap_or(claimed_from);
if signer_base != claimed_base {
return Err(AppError::Authentication(
"JWS signer does not match message `from` DID".into(),
));
}
let created_time = message.created_time.ok_or_else(|| {
AppError::Authentication(
"signed message is missing `created_time` (required for freshness/replay protection)"
.into(),
)
})?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now.saturating_sub(created_time) > FRESHNESS_WINDOW_SECS {
return Err(AppError::Authentication(
"message too old (created_time exceeds 5-minute window)".into(),
));
}
if created_time > now + FUTURE_SKEW_SECS {
return Err(AppError::Authentication(
"message created_time is in the future".into(),
));
}
Ok((message, signer_base))
}
#[derive(Debug, Clone)]
pub struct VerifiedSiopIdToken {
pub issuer: String,
pub audience: String,
pub nonce: String,
pub issued_at: u64,
pub expires_at: u64,
}
#[derive(serde::Deserialize)]
struct SiopClaims {
iss: Option<String>,
sub: Option<String>,
aud: Option<String>,
nonce: Option<String>,
iat: Option<u64>,
exp: Option<u64>,
}
fn ed25519_pubkey_from_did_key(did: &str) -> Result<[u8; 32], AppError> {
let multibase = did
.strip_prefix("did:key:")
.ok_or_else(|| AppError::Authentication("id_token `iss` is not a did:key".into()))?;
let (_base, bytes) = multibase::decode(multibase)
.map_err(|e| AppError::Authentication(format!("id_token `iss` multibase invalid: {e}")))?;
let key = bytes.strip_prefix(&[0xed, 0x01]).ok_or_else(|| {
AppError::Authentication("id_token `iss` is not an Ed25519 did:key".into())
})?;
key.try_into()
.map_err(|_| AppError::Authentication("id_token `iss` Ed25519 key is not 32 bytes".into()))
}
pub async fn verify_siop_id_token(
id_token: &str,
did_resolver: &DIDCacheClient,
) -> Result<VerifiedSiopIdToken, AppError> {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
let mut parts = id_token.split('.');
let (header_b64, payload_b64, sig_b64) =
match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(h), Some(p), Some(s), None) => (h, p, s),
_ => {
return Err(AppError::Authentication(
"id_token is not a compact JWS (header.payload.signature)".into(),
));
}
};
let payload_bytes = URL_SAFE_NO_PAD
.decode(payload_b64)
.map_err(|e| AppError::Authentication(format!("id_token payload not base64url: {e}")))?;
let claims: SiopClaims = serde_json::from_slice(&payload_bytes)
.map_err(|e| AppError::Authentication(format!("id_token payload not JSON: {e}")))?;
let iss = claims
.iss
.ok_or_else(|| AppError::Authentication("id_token missing `iss`".into()))?;
let sub = claims
.sub
.ok_or_else(|| AppError::Authentication("id_token missing `sub`".into()))?;
if iss != sub {
return Err(AppError::Authentication(
"id_token `iss` does not equal `sub`".into(),
));
}
let kid = extract_signer_kid_compact(header_b64)?;
let kid_base = kid.split('#').next().unwrap_or(&kid);
if kid_base != iss {
return Err(AppError::Authentication(
"id_token header `kid` DID does not match `iss`".into(),
));
}
let resolved_key = resolve_verifying_key(did_resolver, &kid).await?;
if iss.starts_with("did:key:") {
let did_key_pub = ed25519_pubkey_from_did_key(&iss)?;
if resolved_key != did_key_pub {
return Err(AppError::Authentication(
"id_token `iss` did:key does not match its resolved authentication key".into(),
));
}
}
let verifying_key = VerifyingKey::from_bytes(&resolved_key)
.map_err(|e| AppError::Authentication(format!("invalid Ed25519 public key: {e}")))?;
let sig_bytes = URL_SAFE_NO_PAD
.decode(sig_b64)
.map_err(|e| AppError::Authentication(format!("id_token signature not base64url: {e}")))?;
let signature = Signature::from_slice(&sig_bytes)
.map_err(|e| AppError::Authentication(format!("id_token signature malformed: {e}")))?;
let signing_input = format!("{header_b64}.{payload_b64}");
verifying_key
.verify(signing_input.as_bytes(), &signature)
.map_err(|_| AppError::Authentication("id_token signature verification failed".into()))?;
Ok(VerifiedSiopIdToken {
issuer: iss,
audience: claims
.aud
.ok_or_else(|| AppError::Authentication("id_token missing `aud`".into()))?,
nonce: claims
.nonce
.ok_or_else(|| AppError::Authentication("id_token missing `nonce`".into()))?,
issued_at: claims
.iat
.ok_or_else(|| AppError::Authentication("id_token missing `iat`".into()))?,
expires_at: claims
.exp
.ok_or_else(|| AppError::Authentication("id_token missing `exp`".into()))?,
})
}
fn extract_signer_kid_compact(header_b64: &str) -> Result<String, AppError> {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
let header_bytes = URL_SAFE_NO_PAD
.decode(header_b64)
.map_err(|e| AppError::Authentication(format!("id_token header not base64url: {e}")))?;
let header: JwsProtectedHeader = serde_json::from_slice(&header_bytes)
.map_err(|e| AppError::Authentication(format!("id_token header not JSON: {e}")))?;
header
.kid
.ok_or_else(|| AppError::Authentication("id_token header missing kid".into()))
}
#[cfg(test)]
mod addressed_to_tests {
use super::require_addressed_to;
use affinidi_tdk::didcomm::Message;
use serde_json::json;
fn msg(to: Option<Vec<&str>>) -> Message {
let mut m = Message::build("id-1".to_string(), "t".to_string(), json!({})).finalize();
m.to = to.map(|v| v.into_iter().map(String::from).collect());
m
}
#[test]
fn a_signed_sign_in_must_be_addressed_to_this_service_alone() {
let own = Some("did:web:svc.example");
assert!(require_addressed_to(&msg(Some(vec!["did:web:svc.example"])), own).is_ok());
assert!(require_addressed_to(&msg(Some(vec!["did:web:other.example"])), own).is_err());
assert!(
require_addressed_to(
&msg(Some(vec!["did:web:svc.example", "did:web:other.example"])),
own
)
.is_err()
);
assert!(require_addressed_to(&msg(None), own).is_err());
assert!(require_addressed_to(&msg(Some(vec![])), own).is_err());
assert!(require_addressed_to(&msg(Some(vec!["did:web:svc.example "])), own).is_err());
assert!(require_addressed_to(&msg(Some(vec!["did:web:svc.example"])), None).is_err());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_signer_kid_reads_protected_header_kid() {
let header_json = serde_json::json!({
"kid": "did:example:123#key-1",
"alg": "EdDSA",
});
let protected_b64 = base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
serde_json::to_vec(&header_json).unwrap(),
);
let jws = serde_json::json!({
"payload": "ignored",
"signatures": [
{
"protected": protected_b64,
"signature": "ignored",
}
],
});
let kid = extract_signer_kid(&jws.to_string()).unwrap();
assert_eq!(kid, "did:example:123#key-1");
}
#[test]
fn extract_signer_kid_rejects_envelope_without_signatures() {
let jws = serde_json::json!({ "payload": "ignored", "signatures": [] });
assert!(extract_signer_kid(&jws.to_string()).is_err());
}
#[test]
fn extract_signer_kid_rejects_envelope_with_no_kid() {
let header_json = serde_json::json!({ "alg": "EdDSA" });
let protected_b64 = base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
serde_json::to_vec(&header_json).unwrap(),
);
let jws = serde_json::json!({
"payload": "ignored",
"signatures": [{ "protected": protected_b64, "signature": "ignored" }],
});
assert!(extract_signer_kid(&jws.to_string()).is_err());
}
#[test]
fn extract_signer_kid_rejects_invalid_json() {
assert!(extract_signer_kid("not-json").is_err());
}
#[test]
fn extract_signer_kid_rejects_multi_signature_envelope() {
let header_json = serde_json::json!({ "kid": "did:example:123#key-1", "alg": "EdDSA" });
let protected_b64 = base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
serde_json::to_vec(&header_json).unwrap(),
);
let jws = serde_json::json!({
"payload": "ignored",
"signatures": [
{ "protected": protected_b64.clone(), "signature": "ignored" },
{ "protected": protected_b64, "signature": "second" },
],
});
let err = extract_signer_kid(&jws.to_string()).unwrap_err();
match err {
AppError::Authentication(msg) => assert!(
msg.contains("only single-signer"),
"expected multi-sig rejection message, got: {msg}",
),
other => panic!("expected Authentication, got {other:?}"),
}
}
#[test]
fn ed25519_pubkey_from_did_key_round_trips() {
let pk = [7u8; 32];
let mut multicodec = vec![0xed, 0x01];
multicodec.extend_from_slice(&pk);
let multibase = multibase::encode(multibase::Base::Base58Btc, &multicodec);
assert!(multibase.starts_with("z6Mk"), "got {multibase}");
let did = format!("did:key:{multibase}");
let decoded = ed25519_pubkey_from_did_key(&did).unwrap();
assert_eq!(decoded, pk);
}
#[test]
fn ed25519_pubkey_from_did_key_rejects_non_did_key() {
assert!(ed25519_pubkey_from_did_key("did:web:example.com").is_err());
}
#[test]
fn ed25519_pubkey_from_did_key_rejects_x25519_multicodec() {
let mut multicodec = vec![0xec, 0x01];
multicodec.extend_from_slice(&[9u8; 32]);
let multibase = multibase::encode(multibase::Base::Base58Btc, &multicodec);
let did = format!("did:key:{multibase}");
assert!(ed25519_pubkey_from_did_key(&did).is_err());
}
#[test]
fn extract_signer_kid_compact_reads_header_kid() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
let header = serde_json::json!({
"alg": "EdDSA",
"typ": "JWT",
"kid": "did:key:z6MkABC#z6MkABC",
});
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
let kid = extract_signer_kid_compact(&header_b64).unwrap();
assert_eq!(kid, "did:key:z6MkABC#z6MkABC");
}
#[test]
fn extract_signer_kid_compact_rejects_missing_kid() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
let header = serde_json::json!({ "alg": "EdDSA", "typ": "JWT" });
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
assert!(extract_signer_kid_compact(&header_b64).is_err());
}
#[tokio::test]
async fn verify_siop_id_token_accepts_well_formed_token() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ed25519_dalek::{Signer, SigningKey};
let sk = SigningKey::from_bytes(&[42u8; 32]);
let pk = sk.verifying_key().to_bytes();
let mut multicodec = vec![0xed, 0x01];
multicodec.extend_from_slice(&pk);
let multibase = multibase::encode(multibase::Base::Base58Btc, &multicodec);
let did = format!("did:key:{multibase}");
let header = serde_json::json!({
"alg": "EdDSA",
"typ": "JWT",
"kid": format!("{did}#{multibase}"),
});
let payload = serde_json::json!({
"iss": did,
"sub": did,
"aud": "did:key:server-rp",
"nonce": "deadbeef",
"iat": 1_700_000_000u64,
"exp": 1_700_003_600u64,
});
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap());
let signing_input = format!("{header_b64}.{payload_b64}");
let sig = sk.sign(signing_input.as_bytes());
let sig_b64 = URL_SAFE_NO_PAD.encode(sig.to_bytes());
let token = format!("{signing_input}.{sig_b64}");
let resolver = DIDCacheClient::new(
affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder::default().build(),
)
.await
.expect("did:key resolver");
let verified = verify_siop_id_token(&token, &resolver)
.await
.expect("well-formed token verifies");
assert_eq!(verified.issuer, did);
assert_eq!(verified.audience, "did:key:server-rp");
assert_eq!(verified.nonce, "deadbeef");
assert_eq!(verified.issued_at, 1_700_000_000);
assert_eq!(verified.expires_at, 1_700_003_600);
}
#[tokio::test]
async fn verify_siop_id_token_rejects_bad_signature() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ed25519_dalek::{Signer, SigningKey};
let sk = SigningKey::from_bytes(&[1u8; 32]);
let pk = sk.verifying_key().to_bytes();
let mut multicodec = vec![0xed, 0x01];
multicodec.extend_from_slice(&pk);
let multibase = multibase::encode(multibase::Base::Base58Btc, &multicodec);
let did = format!("did:key:{multibase}");
let header = serde_json::json!({ "alg": "EdDSA", "typ": "JWT", "kid": format!("{did}#{multibase}") });
let payload = serde_json::json!({
"iss": did, "sub": did, "aud": "rp", "nonce": "n", "iat": 1u64, "exp": 2u64,
});
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap());
let sig = sk.sign(format!("{header_b64}.{payload_b64}").as_bytes());
let mut sig_bytes = sig.to_bytes();
sig_bytes[0] ^= 0xff; let sig_b64 = URL_SAFE_NO_PAD.encode(sig_bytes);
let token = format!("{header_b64}.{payload_b64}.{sig_b64}");
let resolver = DIDCacheClient::new(
affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder::default().build(),
)
.await
.expect("did:key resolver");
assert!(verify_siop_id_token(&token, &resolver).await.is_err());
}
#[tokio::test]
async fn sign_in_keys_must_be_authentication_keys() {
use affinidi_secrets_resolver::secrets::Secret;
let secret = Secret::generate_ed25519(None, Some(&[4u8; 32]));
let pk = secret.get_public_keymultibase().unwrap();
let mut resolver = DIDCacheClient::new(
affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder::default().build(),
)
.await
.unwrap();
for (did, rel) in [
("did:web:auth.example", "authentication"),
("did:web:assert.example", "assertionMethod"),
] {
let doc: affinidi_tdk::did_common::Document =
serde_json::from_value(serde_json::json!({
"id": did,
"verificationMethod": [{
"id": format!("{did}#key-1"),
"type": "Multikey",
"controller": did,
"publicKeyMultibase": pk,
}],
rel: [format!("{did}#key-1")],
}))
.unwrap();
resolver.add_did_document(did, doc).await;
}
resolve_verifying_key(&resolver, "did:web:auth.example#key-1")
.await
.expect("authentication key accepted");
let err = resolve_verifying_key(&resolver, "did:web:assert.example#key-1")
.await
.unwrap_err();
assert!(
err.to_string().contains("not an authentication key"),
"{err}"
);
}
}