use affinidi_data_integrity::{DataIntegrityProof, SignOptions};
use affinidi_secrets_resolver::secrets::Secret;
use chrono::Utc;
use serde_json::Value;
use trust_tasks_rs::specs::auth::authenticate::v0_1 as authenticate;
use trust_tasks_rs::specs::auth::refresh::v0_1 as refresh;
use trust_tasks_rs::{Proof, TrustTask};
use crate::did_key::decode_private_key_multibase;
use crate::protocols::auth::AuthenticateResponse;
use crate::trust_tasks::{TASK_AUTH_AUTHENTICATE_0_1, TASK_AUTH_REFRESH_0_1};
#[derive(Debug)]
pub enum AuthDiError {
NotDidKey(String),
BadPrivateKey(String),
Payload(String),
Sign(String),
TypeUri(String),
Response(String),
}
impl std::fmt::Display for AuthDiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotDidKey(did) => write!(
f,
"DI-signed REST authentication requires a did:key holder; got {did}"
),
Self::BadPrivateKey(e) => write!(f, "decode holder private key: {e}"),
Self::Payload(e) => write!(f, "invalid auth payload: {e}"),
Self::Sign(e) => write!(f, "sign authenticate Trust Task: {e}"),
Self::TypeUri(e) => write!(f, "Trust Task type URI parse: {e}"),
Self::Response(e) => write!(f, "unexpected auth response from VTA: {e}"),
}
}
}
impl std::error::Error for AuthDiError {}
fn did_key_to_vm(did: &str) -> Option<String> {
let mb = did.strip_prefix("did:key:")?;
Some(format!("{did}#{mb}"))
}
pub async fn sign_authenticate_doc(
client_did: &str,
private_key_multibase: &str,
vta_did: &str,
challenge: &str,
session_id: &str,
) -> Result<String, AuthDiError> {
let payload = authenticate::Payload {
challenge: authenticate::PayloadChallenge::try_from(challenge.to_string())
.map_err(|e| AuthDiError::Payload(format!("challenge: {e}")))?,
session_id: authenticate::PayloadSessionId::try_from(session_id.to_string())
.map_err(|e| AuthDiError::Payload(format!("sessionId: {e}")))?,
scope: Vec::new(),
ext: None,
};
let mut doc = new_doc(
TASK_AUTH_AUTHENTICATE_0_1,
serde_json::to_value(&payload).map_err(|e| AuthDiError::Payload(e.to_string()))?,
client_did,
vta_did,
)?;
let vm_id =
did_key_to_vm(client_did).ok_or_else(|| AuthDiError::NotDidKey(client_did.into()))?;
let seed = decode_private_key_multibase(private_key_multibase)
.map_err(|e| AuthDiError::BadPrivateKey(e.to_string()))?;
let mut signer = Secret::generate_ed25519(Some(&vm_id), Some(&seed));
signer.id = vm_id;
let mut signing_doc =
serde_json::to_value(&doc).map_err(|e| AuthDiError::Sign(e.to_string()))?;
if let Some(obj) = signing_doc.as_object_mut() {
obj.remove("proof");
}
let di_proof = DataIntegrityProof::sign(
&signing_doc,
&signer,
SignOptions::new()
.with_proof_purpose("assertionMethod")
.with_created(Utc::now()),
)
.await
.map_err(|e| AuthDiError::Sign(e.to_string()))?;
let proof_json =
serde_json::to_value(&di_proof).map_err(|e| AuthDiError::Sign(e.to_string()))?;
doc.proof = Some(
serde_json::from_value::<Proof>(proof_json)
.map_err(|e| AuthDiError::Sign(e.to_string()))?,
);
serde_json::to_string(&doc).map_err(|e| AuthDiError::Sign(e.to_string()))
}
pub fn build_refresh_doc(
client_did: &str,
vta_did: &str,
refresh_token: &str,
) -> Result<String, AuthDiError> {
let payload = refresh::Payload {
refresh_token: refresh::PayloadRefreshToken::try_from(refresh_token.to_string())
.map_err(|e| AuthDiError::Payload(format!("refreshToken: {e}")))?,
scope: Vec::new(),
ext: None,
};
let doc = new_doc(
TASK_AUTH_REFRESH_0_1,
serde_json::to_value(&payload).map_err(|e| AuthDiError::Payload(e.to_string()))?,
client_did,
vta_did,
)?;
serde_json::to_string(&doc).map_err(|e| AuthDiError::Sign(e.to_string()))
}
pub fn parse_auth_response(body: &str) -> Result<AuthenticateResponse, AuthDiError> {
if let Ok(flat) = serde_json::from_str::<AuthenticateResponse>(body) {
return Ok(flat);
}
let doc: TrustTask<Value> = serde_json::from_str(body)
.map_err(|e| AuthDiError::Response(format!("{e} (is this a VTA?)")))?;
serde_json::from_value(doc.payload)
.map_err(|e| AuthDiError::Response(format!("payload is not an AuthenticateResponse: {e}")))
}
fn new_doc(
type_uri: &str,
payload: Value,
client_did: &str,
vta_did: &str,
) -> Result<TrustTask<Value>, AuthDiError> {
let mut doc: TrustTask<Value> = TrustTask::new(
format!("urn:uuid:{}", uuid::Uuid::new_v4()),
type_uri
.parse()
.map_err(|e| AuthDiError::TypeUri(format!("{e}")))?,
payload,
);
doc.issuer = Some(client_did.to_string());
doc.recipient = Some(vta_did.to_string());
doc.issued_at = Some(Utc::now());
Ok(doc)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const CHALLENGE: &str = "a3f1c09b7e2d4856a3f1c09b7e2d4856";
fn did_key_from_seed(seed_byte: u8) -> (String, String) {
let seed = [seed_byte; 32];
let sk = ed25519_dalek::SigningKey::from_bytes(&seed);
let did = format!(
"did:key:{}",
crate::did_key::ed25519_multibase_pubkey(&sk.verifying_key().to_bytes())
);
let mut buf = vec![0x80, 0x26];
buf.extend_from_slice(&seed);
(did, multibase::encode(multibase::Base::Base58Btc, &buf))
}
#[tokio::test]
async fn authenticate_doc_is_signed_and_camel_cased() {
let (did, pk) = did_key_from_seed(0x11);
let body = sign_authenticate_doc(&did, &pk, "did:key:z6MkVta", CHALLENGE, "sess-1")
.await
.expect("sign");
let v: Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["type"], TASK_AUTH_AUTHENTICATE_0_1);
assert_eq!(v["payload"]["challenge"], CHALLENGE);
assert_eq!(v["payload"]["sessionId"], "sess-1");
assert_eq!(v["issuer"], did);
assert_eq!(v["recipient"], "did:key:z6MkVta");
assert_eq!(v["proof"]["cryptosuite"], "eddsa-jcs-2022");
assert_eq!(
v["proof"]["verificationMethod"],
format!("{did}#{}", did.strip_prefix("did:key:").unwrap())
);
assert!(
v["proof"]["proofValue"]
.as_str()
.is_some_and(|s| !s.is_empty()),
"proof carries no proofValue"
);
}
#[tokio::test]
async fn signed_doc_verifies_under_did_key_resolver() {
use affinidi_data_integrity::{DidKeyResolver, VerifyOptions};
let (did, pk) = did_key_from_seed(0x22);
let body = sign_authenticate_doc(&did, &pk, "did:key:z6MkVta", CHALLENGE, "sess-1")
.await
.expect("sign");
let doc: TrustTask<Value> = serde_json::from_str(&body).unwrap();
let proof: DataIntegrityProof =
serde_json::from_value(serde_json::to_value(doc.proof.as_ref().unwrap()).unwrap())
.expect("proof round-trips into a DataIntegrityProof");
let mut unsigned = doc.clone();
unsigned.proof = None;
proof
.verify(&unsigned, &DidKeyResolver, VerifyOptions::new())
.await
.expect("server-side verification must succeed");
}
#[tokio::test]
async fn tampered_payload_fails_verification() {
use affinidi_data_integrity::{DidKeyResolver, VerifyOptions};
let (did, pk) = did_key_from_seed(0x33);
let body = sign_authenticate_doc(&did, &pk, "did:key:z6MkVta", CHALLENGE, "sess-1")
.await
.expect("sign");
let mut doc: TrustTask<Value> = serde_json::from_str(&body).unwrap();
doc.payload = json!({ "challenge": "attacker-nonce-0000000000", "sessionId": "sess-1" });
let proof: DataIntegrityProof =
serde_json::from_value(serde_json::to_value(doc.proof.as_ref().unwrap()).unwrap())
.unwrap();
let mut unsigned = doc.clone();
unsigned.proof = None;
assert!(
proof
.verify(&unsigned, &DidKeyResolver, VerifyOptions::new())
.await
.is_err(),
"a tampered challenge must not verify"
);
}
#[tokio::test]
async fn non_did_key_holder_is_refused() {
let (_, pk) = did_key_from_seed(0x44);
let err = sign_authenticate_doc(
"did:web:example.com",
&pk,
"did:key:z6MkVta",
CHALLENGE,
"sess-1",
)
.await
.expect_err("did:web holder must be refused");
assert!(matches!(err, AuthDiError::NotDidKey(_)), "got {err:?}");
}
#[test]
fn refresh_doc_is_unsigned_and_camel_cased() {
let body = build_refresh_doc("did:key:z6MkHolder", "did:key:z6MkVta", "tok-1").unwrap();
let v: Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["type"], TASK_AUTH_REFRESH_0_1);
assert_eq!(v["payload"]["refreshToken"], "tok-1");
assert!(
v.get("proof").is_none_or(Value::is_null),
"refresh must not be signed"
);
}
#[test]
fn parses_flat_and_trust_task_responses() {
let tokens = json!({
"session": {
"id": "sess", "subject": "did:key:z6MkHolder",
"issuedAt": "1970-01-01T00:00:00Z", "expiresAt": "2099-12-31T23:59:59Z",
"amr": ["did"], "acr": "aal1"
},
"tokens": {
"accessToken": "acc", "tokenType": "Bearer", "expiresIn": 900_u64
}
});
let flat = parse_auth_response(&tokens.to_string()).expect("flat");
assert_eq!(flat.tokens.access_token, "acc");
let wrapped = json!({
"id": "urn:uuid:1",
"type": "https://trusttasks.org/spec/auth/authenticate/0.1#response",
"payload": tokens,
});
let unwrapped = parse_auth_response(&wrapped.to_string()).expect("trust-task");
assert_eq!(unwrapped.tokens.access_token, "acc");
}
#[test]
fn rejects_unrecognisable_response() {
assert!(matches!(
parse_auth_response("<html>not a VTA</html>"),
Err(AuthDiError::Response(_))
));
}
}