Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ For history before this file, see `git log` on `packages/core`.

### Changed

- **BREAKING: a relying-party session step-up no longer takes the session's
tokens.** `stepUpVtaStart`, `stepUpVtaFinish` and `performStepUpVta`, which
took the session's access and refresh tokens, sent `auth/step-up/start`,
the approve-response and `auth/refresh` to the RP, and returned new tokens,
are replaced by `verifyRpStepUpRequest` and `performRpStepUp`. The page now
opens the step-up itself with the session key its login bound, and hands
the wallet only the RP's signed `approve-request/0.5`; `performRpStepUp`
verifies it (signed by the RP, addressed to and naming the holder, not
lapsed), asks for consent on the verified reason, and returns the signed
`approve-response/0.7`. The page sends it and renews the session with its
own session key. The wallet makes no request to the RP. In the extension,
`window.vtaWallet.stepUpVta` is replaced by
`approveSessionStepUp({ rpDid, approveRequest })`, which resolves with
`{ approveResponse, holderDid }` and is refused unless `rpDid` is the one
the page's origin is pinned to. The pin no longer needs a base URL for a
step-up, since the wallet contacts no one for it. Matches the did-hosting
console's step-up (affinidi-webvh-service#445).

- **`@openvtc/vti-didcomm-js` 0.16** (from 0.14). The default resolver
resolves did:webs (vti-didcomm-js #38), and DIDComm/TSP key agreement picks
the X25519 `keyAgreement` key past a P-256 one (#39). `did/vm-key.ts` now
Expand Down
303 changes: 65 additions & 238 deletions packages/core/src/rp-login/step-up.ts

Large diffs are not rendered by default.

354 changes: 74 additions & 280 deletions packages/core/tests/rp-login.step-up.mjs

Large diffs are not rendered by default.

6 changes: 0 additions & 6 deletions packages/core/tests/task-surface.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,6 @@ const NOT_IN_SDK = [
"Emitted by the relying party during a step-up and consumed here. The RP side lives " +
"in did-hosting, so vta-sdk carries the approve-*response* half only.",
},
{
prefix: "https://trusttasks.org/spec/auth/step-up/start/",
why:
"Served by the did-hosting control plane, the relying party a session is stepped " +
"up at (rp-login/step-up.ts). The VTA is not the counterparty.",
},
{
prefix: "https://trusttasks.org/spec/auth/step-up/approver/attest/",
why:
Expand Down
27 changes: 11 additions & 16 deletions packages/extension/demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -273,23 +273,18 @@ <h2>RP → wallet · confirm</h2>
});

// ── Step-up · VTA approval ──
// The wallet no longer runs the whole step-up: the page opens it with
// the session key its login bound (`auth/step-up/start/0.2`), hands the
// wallet only the RP's signed approve-request, sends the signed
// approve-response the wallet returns, and renews the session with its
// own session key. This demo holds no session key, so it cannot open
// one; the did-hosting console is the reference caller.
$("su-go").addEventListener("click", () => {
if (!baseSession) { resetLog("su"); log("su", "no base session — log in via REST or DIDComm first", "err"); return; }
const { rpDid, baseUrl, accessToken, refreshToken, sessionId } = baseSession;
return run("su", "su-go",
() => window.vtaWallet.stepUpVta({ baseUrl, rpDid, accessToken, refreshToken, sessionId }),
[
"wallet.stepUpVta({ baseUrl, rpDid, accessToken, refreshToken, sessionId })",
"auth/step-up/start/0.1 → signed approve-request/0.3 (verified)",
"approve-response/0.5 (signed by you) → elevated",
"auth/refresh/0.1 → aal2 tokens",
],
(r) => {
// Replace the stashed base session with the elevated one so the
// sensitive-op check uses the aal2 token.
baseSession = { ...baseSession, accessToken: r.accessToken, refreshToken: r.refreshToken };
},
);
resetLog("su");
log("su", "page: auth/step-up/start/0.2 (signed by the page's session key) → the RP's signed approve-request/0.5");
log("su", "wallet.approveSessionStepUp({ rpDid, approveRequest }) → signed approve-response/0.7");
log("su", "page: sends the approve-response → elevated; auth/refresh/0.2 (session key) → aal2 tokens");
log("su", "This demo has no session key to open a step-up with — use the did-hosting console.", "err");
});

// ── Call a sensitive op (gated on aal2) ──
Expand Down
2 changes: 1 addition & 1 deletion packages/extension/src/approver-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const APPROVE_RESPONSE = APPROVER_RESPONSE_TYPE;
/** Any version of approve-response. Used only to *route* a document claiming
* `approverSigned` evidence to `approverResponseRefusal`, which then refuses a
* version other than {@link APPROVE_RESPONSE} by name — instead of letting it
* fall through to `pageSignRefusal`, whose reason (use `stepUpVta`) is wrong
* fall through to `pageSignRefusal`, whose reason (use `approveSessionStepUp`) is wrong
* for this caller. */
const APPROVE_RESPONSE_FAMILY = "https://trusttasks.org/spec/auth/step-up/approve-response/";

Expand Down
64 changes: 33 additions & 31 deletions packages/extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ import {
OFFSCREEN_ONBOARD_PREPARE,
OFFSCREEN_SIGN_TRUST_TASK,
OFFSCREEN_START_INBOUND,
OFFSCREEN_STEP_UP_VTA,
OFFSCREEN_APPROVE_SESSION_STEP_UP,
OFFSCREEN_TARGET,
OFFSCREEN_VAULT_DELETE,
OFFSCREEN_VAULT_LIST,
Expand Down Expand Up @@ -232,7 +232,7 @@ import {
type RuntimeDisclosureStepUpConsentResponse,
type RelayTaskFailure,
type DiscloseResult,
RUNTIME_STEP_UP_VTA,
RUNTIME_APPROVE_SESSION_STEP_UP,
RUNTIME_APPROVER_STATE,
RUNTIME_RESOLVE_AGENT_NAME,
RUNTIME_VERIFY_RP_DID,
Expand All @@ -243,7 +243,7 @@ import {
type RuntimeRunDiagnosticsResponse,
type OffscreenDidcommLoginRequest,
type OffscreenSetWakeResponse,
type OffscreenStepUpVtaRequest,
type OffscreenApproveSessionStepUpRequest,
type RuntimeApiGetRequest,
type RuntimeApiGetResponse,
type RuntimeApiPostRequest,
Expand Down Expand Up @@ -283,7 +283,8 @@ import {
type RuntimeSignTrustTaskResponse,
type RuntimeStepUpConsentRequest,
type RuntimeStepUpConsentResponse,
type RuntimeStepUpVtaRequest,
type RuntimeApproveSessionStepUpRequest,
type RuntimeApproveSessionStepUpResponse,
type RuntimeVaultDeleteRequest,
type RuntimeVaultDeleteResponse,
type RuntimeVaultListRequest,
Expand Down Expand Up @@ -1553,7 +1554,7 @@ async function handleDisclose(req: RuntimeDiscloseRequest): Promise<RuntimeDiscl
* `import()` in `background.js` is the one thing CI asserts is absent, because a
* service worker cannot load one — so the verify and the signing live where
* they can, and this side contributes the only thing it uniquely can, a window
* for the human. `doStepUpVta` has exactly this shape for the same reason.
* for the human. `doApproveSessionStepUp` has exactly this shape for the same reason.
*/
async function runDisclosureStepUp(
active: { vtaDid: string; restBaseUrl?: string },
Expand Down Expand Up @@ -1798,8 +1799,8 @@ async function handleLogin(req: RuntimeLoginRequest): Promise<RuntimeLoginRespon
// for a *different* rpDid get a louder consent prompt so the
// operator can spot a redirect-to-attacker-RP attempt.
//
// The base URL is pinned beside the rpDid, because it is where `stepUpVta`
// from this origin may later go. A changed base URL is treated like a
// The base URL is pinned beside the rpDid, because it is where the REST
// login from this origin went. A changed base URL is treated like a
// changed rpDid: always prompted, loudly, even for a remembered site.
const pin: OriginPinStatus = req.origin
? await checkOriginPin(req.origin, req.params.rpDid, req.params.baseUrl)
Expand Down Expand Up @@ -1996,48 +1997,49 @@ async function handleLoginDidcomm(
return result;
}

async function handleStepUpVta(
req: RuntimeStepUpVtaRequest,
): Promise<RuntimeLoginResponse> {
async function handleApproveSessionStepUp(
req: RuntimeApproveSessionStepUpRequest,
): Promise<RuntimeApproveSessionStepUpResponse> {
// Fast-fail without spinning up the offscreen document. Display-only DID
// lookup — see handleLoginDidcomm for the background-vs-offscreen rationale.
const holderDid = await readActiveHolderDid();
if (!holderDid) return { ok: false, error: "no active VTA connection — connect first" };

// NO consent prompt here. The step-up prompt fires mid-flow instead: the
// offscreen sends `auth/step-up/start`, verifies the RP's signed reply and
// the approve-request in it (proof + enrolled-executor signer + issuer ==
// rpDid + bound to this session), and
// only then asks back via RUNTIME_STEP_UP_CONSENT — so the prompt can show
// the human the VERIFIED `reason` from inside the signature. Prompting
// before the fetch (the old shape) showed origin/rpDid only and left the
// signed reason unread, which defeated the point of signing it (the spec's
// rule is verify-BEFORE-surfacing, not verify-instead-of-surfacing).
// Nothing is signed or sent unless that prompt approves.
// offscreen verifies the approve-request the page handed in (proof +
// enrolled-executor signer + issuer == rpDid + addressed to and naming this
// holder), and only then asks back via RUNTIME_STEP_UP_CONSENT — so the
// prompt can show the human the VERIFIED `reason` from inside the
// signature (the spec's rule is verify-BEFORE-surfacing). Nothing is signed
// unless that prompt approves.
//
// The signer is the active connection's holder — the identity the base login
// used — never one the page names.
// The page opened the step-up with its own session key and sends the
// decision itself; the wallet is given no session token and contacts no
// one. The signer is the active connection's holder — the identity the base
// login used — never one the page names.
//
// Refused before anything is sent or prompted: the page may only step up at
// the RP DID and base URL its origin is pinned to by an approved login. See
// `pageStepUpBindingRefusal`.
// Refused before anything is verified or prompted: the page may only have a
// step-up approved for the RP DID its origin is pinned to by an approved
// login. See `pageStepUpBindingRefusal`.
const bindingRefusal = pageStepUpBindingRefusal(
req.origin,
await readOriginPin(req.origin),
req.params,
req.params?.rpDid,
);
if (bindingRefusal) return { ok: false, error: bindingRefusal };
const active = await readActiveConnection();
if (!active.ok) return { ok: false, error: active.error };
await ensureOffscreenDocument();
const offscreenRequest: OffscreenStepUpVtaRequest = {
const offscreenRequest: OffscreenApproveSessionStepUpRequest = {
target: OFFSCREEN_TARGET,
type: OFFSCREEN_STEP_UP_VTA,
type: OFFSCREEN_APPROVE_SESSION_STEP_UP,
vtaDid: active.conn.vtaDid,
params: req.params,
origin: req.origin,
};
return (await chrome.runtime.sendMessage(offscreenRequest)) as RuntimeLoginResponse;
return (await chrome.runtime.sendMessage(
offscreenRequest,
)) as RuntimeApproveSessionStepUpResponse;
}

/** Longest RP-authored reason the consent prompt will carry. Anything past
Expand Down Expand Up @@ -2076,7 +2078,7 @@ async function handleStepUpConsent(
// for the prompt, and a pin that changed underneath the flow is a denial,
// never a prompt for a pairing the human has not confirmed.
const pin = await readOriginPin(req.origin);
if (pageStepUpBindingRefusal(req.origin, pin, { rpDid: req.rpDid, baseUrl: req.baseUrl })) {
if (pageStepUpBindingRefusal(req.origin, pin, req.rpDid)) {
return { approved: false };
}
const approved = await gatedConsent({
Expand Down Expand Up @@ -4792,8 +4794,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true; // async sendResponse
}

if ((message as { type?: string })?.type === RUNTIME_STEP_UP_VTA) {
handleStepUpVta(message as RuntimeStepUpVtaRequest)
if ((message as { type?: string })?.type === RUNTIME_APPROVE_SESSION_STEP_UP) {
handleApproveSessionStepUp(message as RuntimeApproveSessionStepUpRequest)
.then(sendResponse)
.catch((e: unknown) =>
sendResponse({ ok: false, error: e instanceof Error ? e.message : String(e) }),
Expand Down
Loading
Loading