diff --git a/CHANGELOG.md b/CHANGELOG.md index 03e06dbd8..d5ea94b22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ ## Unreleased +### vta-service 0.13.18 — step-up approve-request minted as 0.2; inbound stays bilingual + +The deferred follow-up to 0.13.17 (#870): the minted step-up approve-request +moves from `auth/step-up/approve-request/0.1` to `/0.2`. Receivers were +migrated first — vta-mobile-core (#871) and the browser plugin +(OpenVTC/vta-browser-plugin#103) accept both request minors, and the webvh +control plane accepts both approve-response minors +(affinidi/affinidi-webvh-service#147) — so this is the producer-side cutover. + +- `mint_pending_step_up` emits the `/0.2` type URI and the 0.2 camelCase + `acceptableEvidence` spelling (`didSigned`; 0.1 said `did-signed`). That + spelling is the **only** payload difference between the minors — same + required members, same optional hints, same ttl semantics. The signing is + unchanged from #870: `eddsa-jcs-2022`, `assertionMethod`, `{vta_did}#key-0`, + proof last over the complete document including `payload.ext` (the embedded + Cierge `authorizationContext` carriage is unaffected and still covered by + the proof). The DIDComm push type follows the document to `/0.2`. +- **Inbound stays bilingual.** The approve-response dispatcher keeps accepting + 0.1 and 0.2 (approvers in the field answer with either during the + transition), and the DIDComm router's canonical step-up-approve registration + now accepts the `/0.2` request URI beside `/0.1` and the legacy + `vta/step-up/*/1.0`, echoing the caller's own minor in the response type. +- The stored `PendingStepUp.acceptable_evidence` record keeps the internal + kebab canonical form — it is state, not wire — so in-flight pending + step-ups from 0.13.17 remain consumable across the deploy. +- New integration test: the gate's minted 0.2 document verifies end-to-end + (VTA proof via `di_proof`, issuer == proof VM DID) and a 0.1-flavored + signed approve-response completes the 0.2-minted step-up, with the ack + echoing the approver's 0.1 family. + ### vta-service 0.13.17 — the step-up approve-request is signed (spec: proof REQUIRED) Part of the ecosystem-wide "signed request legs" push diff --git a/Cargo.lock b/Cargo.lock index 95e2c4d0d..e733927b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10333,7 +10333,7 @@ dependencies = [ [[package]] name = "vta-service" -version = "0.13.17" +version = "0.13.18" dependencies = [ "aes-gcm", "affinidi-bbs", diff --git a/vta-service/Cargo.toml b/vta-service/Cargo.toml index cdb5b05e3..d5a97cc67 100644 --- a/vta-service/Cargo.toml +++ b/vta-service/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "vta-service" description = "Service for Verifiable Trust Agents operating in Verifiable Trust Communities" -version = "0.13.17" +version = "0.13.18" edition.workspace = true publish.workspace = true authors.workspace = true diff --git a/vta-service/src/messaging/handlers.rs b/vta-service/src/messaging/handlers.rs index be2812ca1..de18891c1 100644 --- a/vta-service/src/messaging/handlers.rs +++ b/vta-service/src/messaging/handlers.rs @@ -1680,16 +1680,30 @@ pub(crate) const STEP_UP_APPROVE_RESPONSE_TYPE: &str = /// registered on the DIDComm router alongside the legacy `vta/step-up/*` URI /// (issue #517). A spec-conformant caller that targets the canonical /// `spec/auth/step-up/approve-request/0.1` is now handled too; the response -/// echoes the request's version family (canonical request → canonical -/// response), so neither the legacy plugin nor a spec-0.2 caller breaks. +/// echoes the request's version family AND minor version (0.1 request → +/// 0.1 response, 0.2 → 0.2), so neither the legacy plugin nor a spec caller +/// on either minor breaks. **Kept for inbound compat** during the 0.1→0.2 +/// transition — approvers in the field still send 0.1. pub(crate) const STEP_UP_APPROVE_REQUEST_CANONICAL: &str = "https://trusttasks.org/spec/auth/step-up/approve-request/0.1"; +/// The 0.2 flavor of [`STEP_UP_APPROVE_REQUEST_CANONICAL`] — same payload +/// shape (this request body carries no renamed enum values), so one handler +/// serves both minors and only the echoed response type differs. +pub(crate) const STEP_UP_APPROVE_REQUEST_CANONICAL_0_2: &str = + "https://trusttasks.org/spec/auth/step-up/approve-request/0.2"; + /// Canonical Trust Task registry URI for the step-up approval response, /// emitted when the request arrived on [`STEP_UP_APPROVE_REQUEST_CANONICAL`]. pub(crate) const STEP_UP_APPROVE_RESPONSE_CANONICAL: &str = "https://trusttasks.org/spec/auth/step-up/approve-response/0.1"; +/// The 0.2 response flavor, emitted when the request arrived on +/// [`STEP_UP_APPROVE_REQUEST_CANONICAL_0_2`]. (The webvh control plane — +/// the relying party for this flow — accepts both response minors.) +pub(crate) const STEP_UP_APPROVE_RESPONSE_CANONICAL_0_2: &str = + "https://trusttasks.org/spec/auth/step-up/approve-response/0.2"; + /// Request body for [`handle_step_up_approve`]. The `rpDid` alias accepts a /// spec-conformant (lowerCamelCase) producer; the legacy `rp_did` keeps the /// existing plugin working (issue #517). @@ -1734,10 +1748,13 @@ pub async fn handle_step_up_approve( } }; - // Echo the version family of the inbound request so a canonical - // (`spec/auth/step-up/…`) caller gets a canonical response and the legacy - // (`vta/step-up/…/1.0`) plugin gets the legacy response. - let response_type = if message.typ == STEP_UP_APPROVE_REQUEST_CANONICAL { + // Echo the version family (and minor) of the inbound request so a + // canonical (`spec/auth/step-up/…`) caller gets the matching canonical + // response and the legacy (`vta/step-up/…/1.0`) plugin gets the legacy + // response. + let response_type = if message.typ == STEP_UP_APPROVE_REQUEST_CANONICAL_0_2 { + STEP_UP_APPROVE_RESPONSE_CANONICAL_0_2 + } else if message.typ == STEP_UP_APPROVE_REQUEST_CANONICAL { STEP_UP_APPROVE_RESPONSE_CANONICAL } else { STEP_UP_APPROVE_RESPONSE_TYPE diff --git a/vta-service/src/messaging/router.rs b/vta-service/src/messaging/router.rs index a91b0c146..30023b5dd 100644 --- a/vta-service/src/messaging/router.rs +++ b/vta-service/src/messaging/router.rs @@ -541,6 +541,7 @@ pub async fn dispatch( // ── Step-up approval (always) ──────────────────────────────────── if t == handlers::STEP_UP_APPROVE_REQUEST_TYPE || t == handlers::STEP_UP_APPROVE_REQUEST_CANONICAL + || t == handlers::STEP_UP_APPROVE_REQUEST_CANONICAL_0_2 { return finish(handlers::handle_step_up_approve(ctx, msg, Extension(vta_state)).await); } diff --git a/vta-service/src/trust_tasks/step_up.rs b/vta-service/src/trust_tasks/step_up.rs index 5b3843ab9..37f8a174a 100644 --- a/vta-service/src/trust_tasks/step_up.rs +++ b/vta-service/src/trust_tasks/step_up.rs @@ -15,7 +15,7 @@ //! pending step-up, dispatches on `evidence.kind`, and elevates the session //! lands alongside it. //! -//! The *request* leg (`auth/step-up/approve-request/0.1`, minted by +//! The *request* leg (`auth/step-up/approve-request/0.2`, minted by //! [`mint_pending_step_up`]) is **signed by this VTA** — `eddsa-jcs-2022`, //! `assertionMethod`, issuer DID == the proof's `verificationMethod` DID — //! the same shape as `task-consent` ([`super::consent_request`]) and the @@ -538,8 +538,11 @@ async fn load_step_up_signing_secret(state: &AppState, vta_did: &str) -> Result< } /// Mint a pending step-up and build the **signed** -/// `auth/step-up/approve-request/0.1` document the AAL1 caller hands to its -/// approver (wallet / VTA). +/// `auth/step-up/approve-request/0.2` document the AAL1 caller hands to its +/// approver (wallet / VTA). 0.2 differs from 0.1 only in the type URI and the +/// `acceptableEvidence` spelling (`did-signed` → `didSigned`); every fielded +/// receiver (vta-mobile-core, the browser plugin) accepts both request +/// flavors, so the mint moved cleanly to 0.2 (#870's deferred follow-up). /// /// A fresh challenge is bound server-side to the caller's /// `{session_id, subject, targetAcr=aal2, acceptableEvidence}` via the @@ -574,7 +577,11 @@ async fn mint_pending_step_up( // the reason-only form. authorization_context: Option<&Value>, ) -> Result { + // The *stored* pending record keeps the kebab canonical form + // (`did-signed`) that `vti_common::auth::step_up` documents — it's internal + // state, not wire. The 0.2 wire spelling is camelCase (`didSigned`). let acceptable = vec!["did-signed".to_string(), "webauthn".to_string()]; + let acceptable_wire = vec!["didSigned".to_string(), "webauthn".to_string()]; // 256 bits of challenge entropy (two UUIDv4s) — comfortably over the spec's // ≥128-bit / ≥16-char minimum, using deps already present. @@ -604,7 +611,7 @@ async fn mint_pending_step_up( let mut doc = json!({ "id": format!("urn:uuid:{}", Uuid::new_v4()), - "type": "https://trusttasks.org/spec/auth/step-up/approve-request/0.1", + "type": "https://trusttasks.org/spec/auth/step-up/approve-request/0.2", "issuer": vta_did, "issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), "payload": { @@ -613,7 +620,7 @@ async fn mint_pending_step_up( "challenge": challenge, "reason": reason, "targetAcr": STEP_UP_TARGET_ACR, - "acceptableEvidence": acceptable, + "acceptableEvidence": acceptable_wire, "ttl": STEP_UP_TTL_SECS, }, }); @@ -734,10 +741,12 @@ fn step_up_denied_response() -> Response { } /// Trust Task `type` of a step-up approve-request (also the DIDComm message -/// `type` used when pushing one to an approver). +/// `type` used when pushing one to an approver). 0.2 — matches the minted +/// document; both fielded approver stacks (vta-mobile-core #871, the browser +/// plugin) accept 0.1 and 0.2 request URIs. #[cfg(feature = "didcomm")] const STEP_UP_APPROVE_REQUEST_TYPE: &str = - "https://trusttasks.org/spec/auth/step-up/approve-request/0.1"; + "https://trusttasks.org/spec/auth/step-up/approve-request/0.2"; /// Pure route selection for a delegated push: given the approver DID and the /// VTA's configured mediator, pick the mediator to forward through. @@ -1392,13 +1401,18 @@ mod tests { assert_eq!(v["requiredAcr"], "aal2"); assert_eq!( v["approveRequest"]["type"], - "https://trusttasks.org/spec/auth/step-up/approve-request/0.1" + "https://trusttasks.org/spec/auth/step-up/approve-request/0.2" ); assert_eq!(v["approveRequest"]["issuer"], vta_did); assert_eq!(v["approveRequest"]["recipient"], "did:key:zHolder"); assert_eq!(v["approveRequest"]["payload"]["sessionId"], "sess-9"); assert_eq!(v["approveRequest"]["payload"]["targetAcr"], "aal2"); assert_eq!(v["approveRequest"]["payload"]["reason"], "rotate keys"); + // 0.2 wire spelling: camelCase `didSigned` (0.1 said `did-signed`). + assert_eq!( + v["approveRequest"]["payload"]["acceptableEvidence"], + json!(["didSigned", "webauthn"]) + ); let challenge = v["approveRequest"]["payload"]["challenge"] .as_str() .expect("challenge string"); @@ -1427,6 +1441,8 @@ mod tests { // self-approval recorded the subject as its own authorized approver. assert_eq!(pending.approver, "did:key:zHolder"); assert_eq!(pending.target_acr, "aal2"); + // The stored record keeps the internal kebab canonical form even + // though the 0.2 wire says `didSigned` (it's state, not wire). assert_eq!( pending.acceptable_evidence, vec!["did-signed".to_string(), "webauthn".to_string()] diff --git a/vta-service/tests/api_integration.rs b/vta-service/tests/api_integration.rs index a2921cd7c..9623b16f7 100644 --- a/vta-service/tests/api_integration.rs +++ b/vta-service/tests/api_integration.rs @@ -742,7 +742,12 @@ async fn acl_mutation_requires_step_up() { let ar = &body["approveRequest"]; assert_eq!( ar["type"], - "https://trusttasks.org/spec/auth/step-up/approve-request/0.1" + "https://trusttasks.org/spec/auth/step-up/approve-request/0.2" + ); + // 0.2 wire spelling of the evidence enum (0.1 said `did-signed`). + assert_eq!( + ar["payload"]["acceptableEvidence"], + json!(["didSigned", "webauthn"]) ); assert_eq!(ar["recipient"], "did:key:z6MkAdmin"); assert_eq!(ar["payload"]["targetAcr"], "aal2"); diff --git a/vta-service/tests/step_up_approve_response.rs b/vta-service/tests/step_up_approve_response.rs index 8a2923ca6..f370f30ad 100644 --- a/vta-service/tests/step_up_approve_response.rs +++ b/vta-service/tests/step_up_approve_response.rs @@ -1,6 +1,8 @@ -//! Integration test for `auth/step-up/approve-response/0.1` — the full -//! HTTP round-trip: an AAL1 session holder POSTs a did-signed approve-response -//! to `/api/trust-tasks` and the VTA elevates their session to AAL2. +//! Integration tests for `auth/step-up/approve-response/0.1` **and** `/0.2` — +//! the full HTTP round-trip: an AAL1 session holder POSTs a did-signed +//! approve-response to `/api/trust-tasks` and the VTA elevates their session +//! to AAL2. The request leg is minted as `/0.2`; both response minors are +//! accepted (mixed-version deployments during the transition). //! //! Exercises the real route → bearer auth → trust-task dispatcher → step-up //! handler → pending-store consume → did-signed gate verification → session @@ -373,7 +375,7 @@ async fn trust_task_acl_mutation_requires_step_up() { ); assert_eq!( details["approveRequest"]["type"], - "https://trusttasks.org/spec/auth/step-up/approve-request/0.1", + "https://trusttasks.org/spec/auth/step-up/approve-request/0.2", "reject must carry the approve-request: {v}" ); assert_eq!(details["approveRequest"]["recipient"], did, "{v}"); @@ -394,6 +396,175 @@ async fn trust_task_acl_mutation_requires_step_up() { ); } +/// Full transition-window round trip: the gate mints a **0.2** approve-request +/// (the migrated wire form), the minted document's VTA proof verifies +/// end-to-end (the #870 pattern — did:key signing, `di_proof` verification), +/// and an approver still speaking **0.1** answers it — kebab `did-signed` +/// evidence discriminator and the `…/0.1` type URI — against the 0.2-minted +/// pending step-up. The session must elevate and the ack must echo the +/// approver's own (0.1) version family. This is exactly the mixed-version +/// deployment the dual-accept inbound exists for. +#[tokio::test] +async fn v0_2_minted_request_completes_with_a_0_1_flavored_response() { + // Signing app: the minted approve-request carries the real VTA proof. + let (router, ctx) = build_provisionable_test_app().await; + + // Opt into step-up enforcement: `*` floor, self-approve. + { + use vti_common::auth::step_up::{StepUpFloor, StepUpMode, StepUpPolicy}; + ctx.config.write().await.auth.step_up = StepUpPolicy { + enabled: true, + floors: vec![StepUpFloor { + operation: "*".into(), + mode: StepUpMode::SelfApprove, + allow_aal1_if_non_escalating: false, + }], + }; + } + + // The subject: a REAL did:key admin at AAL1 (self step-up — it will sign + // its own approve-response). + let sk = SigningKey::from_bytes(&[57u8; 32]); + let (did, mb) = did_key(&sk); + let vm = format!("{did}#{mb}"); + let session_id = "sess-roundtrip-0-2".to_string(); + let session = Session { + session_id: session_id.clone(), + did: did.clone(), + challenge: String::new(), + state: SessionState::Authenticated, + created_at: now_epoch(), + last_seen: now_epoch(), + refresh_token: None, + refresh_expires_at: Some(now_epoch() + 86_400), + tee_attested: false, + amr: vec!["did".to_string()], + acr: "aal1".to_string(), + acr_expires_at: None, + token_id: None, + session_pubkey_b58btc: None, + }; + store_session(&ctx.sessions_ks, &session).await.unwrap(); + let claims = ctx.jwt_keys.new_claims( + did.clone(), + session_id.clone(), + "admin".to_string(), + vec![], + 900, + false, + ); + let token = ctx.jwt_keys.encode(&claims).unwrap(); + + // 1. An AAL2-gated trust-task mutation → rejected with the minted + // approve-request in `details`. + let gated = json!({ + "id": "acl-create-roundtrip-1", + "type": "https://trusttasks.org/spec/acl/grant/0.1", + "issuer": did, + "recipient": ctx.vta_did, + "payload": { + "entry": { + "subject": "did:key:z6MkRoundTripEntry", + "role": "application", + "scopes": ["ctx1"] + } + }, + }); + let req = Request::builder() + .method("POST") + .uri("/api/trust-tasks") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&gated).unwrap())) + .unwrap(); + let resp = router.clone().oneshot(req).await.unwrap(); + assert_ne!(resp.status(), StatusCode::OK, "gate must fire"); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + let ar = v["payload"]["details"]["approveRequest"].clone(); + + // 2. The minted request is 0.2: /0.2 type URI + camelCase evidence enum. + assert_eq!( + ar["type"], "https://trusttasks.org/spec/auth/step-up/approve-request/0.2", + "{v}" + ); + assert_eq!( + ar["payload"]["acceptableEvidence"], + json!(["didSigned", "webauthn"]), + "{v}" + ); + + // 3. …and it verifies end-to-end: the VTA's eddsa-jcs-2022 proof checks + // out over the served 0.2 bytes, attributable to the issuing VTA. + let minted: TrustTask = serde_json::from_value(ar.clone()).unwrap(); + let signer = vta_service::auth::di_proof::verify_trust_task_proof(&minted) + .await + .expect("minted 0.2 approve-request proof verifies"); + assert_eq!(signer, ctx.vta_did, "proof VM DID == issuing VTA"); + + // 4. The approver answers in the OLD (0.1) dialect: kebab `did-signed` + // evidence + the /0.1 type URI, echoing the 0.2 request's challenge. + let challenge = ar["payload"]["challenge"].as_str().unwrap().to_string(); + let doc_json = json!({ + "id": "approve-resp-roundtrip-1", + "type": "https://trusttasks.org/spec/auth/step-up/approve-response/0.1", + "issuer": did, + "recipient": ctx.vta_did, + "payload": { + "subject": did, + "sessionId": session_id, + "challenge": challenge, + "decision": "approved", + "grantedAcr": "aal2", + "evidence": { "kind": "did-signed" }, + }, + }); + let mut doc: TrustTask = serde_json::from_value(doc_json).unwrap(); + let mut di = DataIntegrityProof::new( + CryptoSuite::EddsaJcs2022, + vm, + "assertionMethod".to_string(), + None, + Some("2026-05-31T00:00:00Z".to_string()), + None, + ); + let input = prepare_sign_input(&doc, &di, CryptoSuite::EddsaJcs2022).unwrap(); + di.proof_value = Some(multibase::encode( + Base::Base58Btc, + sk.sign(&input).to_bytes(), + )); + doc.proof = Some(serde_json::from_value::(serde_json::to_value(&di).unwrap()).unwrap()); + + let req = Request::builder() + .method("POST") + .uri("/api/trust-tasks") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&doc).unwrap())) + .unwrap(); + let resp = router.clone().oneshot(req).await.unwrap(); + let status = resp.status(); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + + // 5. The 0.1-flavored answer completes the 0.2-minted step-up… + assert_eq!(status, StatusCode::OK, "expected 200, got {status}: {v}"); + assert_eq!(v["payload"]["status"], "elevated", "{v}"); + assert_eq!(v["payload"]["session"]["acr"], "aal2", "{v}"); + // …and the ack echoes the APPROVER's version family (0.1), not the mint's. + assert_eq!( + v["type"], "https://trusttasks.org/spec/auth/step-up/approve-response/0.1#response", + "0.1 response must yield a 0.1 ack: {v}" + ); + + // 6. The stored session is elevated. + let stored = get_session(&ctx.sessions_ks, &session_id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.acr, "aal2"); +} + /// Delegated step-up: a distinct, authorized approver (`issuer != subject`) /// ratifies and the *subject's* session elevates. Mirrors the VTA's /// `mode: delegated` flow — the approve-request was addressed to the subject's