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
40 changes: 25 additions & 15 deletions packages/core/src/rp-login/trust-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
// (affinidi-webvh-service #171). Against one that does not, the challenge comes
// back `unsupportedType` and the caller can fall back to `loginViaDidcomm`.

import type { SigningIdentity } from "../siop/self-issued.js";
import { authenticateSession, requestAuthChallenge } from "../vta/auth-tasks.js";
import type { TrustTaskSender } from "../vta/channel.js";
import type { Identity } from "../didcomm/index.js";
Expand All @@ -45,12 +44,19 @@ export interface TrustTaskLoginOptions {
/** Any transport that can carry a Trust Task to the RP. A `VtaSession`
* built against the RP's control DID gives the full chain. */
sender: TrustTaskSender;
/** The wallet's holder identity — the envelope `issuer`, and the VID the
* RP's ACL is checked against. */
/** The wallet's holder identity — the transport identity, and the default
* document `issuer`. */
holder: Identity;
/** Signs the documents. Its DID MUST be the holder's: the proof is what
* authenticates, so a signature by anything else authenticates nobody. */
signing: SigningIdentity;
/**
* DID to log in AS, when that is not the holder — a per-site persona.
*
* The RP checks the challenge subject against the DID that signed
* (`session.did != input.signer_did` in vti-common's `handle_authenticate`),
* so this has to be both: the challenge is requested for it, and the channel
* has to sign as it. Supplying one whose key the channel cannot sign with
* fails at `signOutboundTask`, locally, naming both DIDs.
*/
subject?: string;
/** The RP's control DID + keyAgreement — the envelope `recipient`. */
service: RemoteDidcommEndpoint;
/** Capability tags to request. The RP decides what it grants. */
Expand All @@ -71,29 +77,33 @@ export async function loginViaTrustTask(
opts: TrustTaskLoginOptions,
): Promise<RpSession> {
const { sender, holder, service } = opts;
// Who is signing in. The holder unless a persona was named — and the same
// value has to reach both steps, or the RP refuses on the signer check.
const subject = opts.subject ?? holder.did;

if (opts.signing.did !== holder.did) {
// Refused here rather than at the RP, because the failure the RP returns
// for this is `permissionDenied` with no hint that the cause is local.
throw new Error(
`rp-login: signing identity ${opts.signing.did} is not the holder ${holder.did}; ` +
"the document proof is what authenticates, so it must be the holder's",
);
}
// The guard that used to live here — "the signing identity must be the
// holder" — has moved to where it can actually be checked.
// `loginViaTrustTask` never signs; the channel does, and `signOutboundTask`
// compares the envelope's issuer against the signer's DID on every outbound
// document. Re-asserting it here would only have said the holder is the only
// possible signer, which stopped being true when a channel could sign as a
// persona whose key lives at the VTA.

const challenge = await requestAuthChallenge(sender, {
holder,
service,
issuer: subject,
// The RP binds the challenge to the identity it verified, so naming a
// subject here cannot widen anything — it is a statement of intent that
// lets the RP refuse early if it disagrees.
subject: holder.did,
subject,
purpose: "login",
});

const authed = await authenticateSession(sender, {
holder,
service,
issuer: subject,
challenge: challenge.challenge,
sessionId: challenge.sessionId,
...(opts.scope && opts.scope.length > 0 ? { scope: opts.scope } : {}),
Expand Down
48 changes: 41 additions & 7 deletions packages/core/src/siop/login-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,46 @@ export interface SiopLoginResult {
timings: TimingMark[];
}

/**
* Where the `id_token` comes from.
*
* Two producers, and the difference is not an implementation detail: the
* holder self-issues locally with a key the browser holds, while a per-site
* persona is minted **by the VTA**, which is the only place that persona's
* signing key exists. The RP cannot tell them apart and should not — both are
* an `id_token` signed by `did` — but the caller has to choose, so the choice
* is a parameter rather than a branch buried in here.
*
* `did` is load-bearing beyond the signature: the challenge is requested for
* it, and the RP refuses unless the DID it issued the challenge to is the one
* that signed (`session.did != input.signer_did` in vti-common's
* `handle_authenticate`). So a minter whose `did` disagrees with what it
* actually signs with fails at the RP, not here.
*/
export interface SiopIdTokenMinter {
/** The DID the `id_token` is issued by — its `iss` and `sub`. */
readonly did: string;
mint(input: { audience: string; nonce: string }): Promise<string>;
}

/** The holder self-issuing locally, which is what this module did before the
* source became pluggable. */
export function selfIssuedMinter(signing: SigningIdentity): SiopIdTokenMinter {
return {
did: signing.did,
mint: ({ audience, nonce }) =>
Promise.resolve(issueIdToken({ identity: signing, audience, nonce })),
};
}

export interface SiopLoginOptions {
/** Base URL of the RP's auth API (e.g. `https://hosting.example/api`). */
baseUrl: string;
/** The RP's identifier — its server DID — used as the `id_token` `aud`. */
rpDid: string;
/** The holder's Ed25519 signing identity (from `generateOrLoadHolderIdentity().signing`). */
signing: SigningIdentity;
/** Who signs in, and how the `id_token` is produced. Use
* {@link selfIssuedMinter} for the holder's own identity. */
minter: SiopIdTokenMinter;
/** Optional ephemeral session pubkey (`z6Mk…` Ed25519 multikey) to bind
* for subsequent trust-task proofs. */
sessionPubkeyB58btc?: string;
Expand All @@ -51,7 +84,7 @@ export async function loginViaSiop(
const challengeRes = await fetchFn(`${base}/auth/challenge`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ did: opts.signing.did }),
body: JSON.stringify({ did: opts.minter.did }),
});
if (!challengeRes.ok) {
throw new Error(
Expand All @@ -67,9 +100,10 @@ export async function loginViaSiop(
};
sw.mark("challenge");

// 2. Self-issue the id_token — aud = the RP's DID, nonce = the challenge.
const idToken = issueIdToken({
identity: opts.signing,
// 2. Mint the id_token — aud = the RP's DID, nonce = the challenge. Either
// self-issued here, or minted by the VTA for a per-site persona whose key
// the browser does not hold.
const idToken = await opts.minter.mint({
audience: opts.rpDid,
nonce: challenge.challenge,
});
Expand All @@ -79,7 +113,7 @@ export async function loginViaSiop(
const envelope = {
id: `urn:uuid:${globalThis.crypto.randomUUID()}`,
type: TASK_AUTH_AUTHENTICATE,
issuer: opts.signing.did,
issuer: opts.minter.did,
issuedAt: new Date().toISOString(),
payload: {
id_token: idToken,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/vault/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export * from "./delete.js";
export * from "./release.js";
export * from "./proxy-login.js";
export * from "./sign-trust-task.js";
export * from "./task-signer.js";
export type { VtaAuthInputs } from "../vta/auth.js";
78 changes: 78 additions & 0 deletions packages/core/src/vault/task-signer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// A `TaskSigner` whose key lives at the VTA.
//
// A per-site persona's signing key is generated at the agent and never leaves
// it — that is the security property the whole persona design rests on, and it
// is why the browser cannot put a proof on a document issued by one. So it
// asks: `vault/sign-trust-task/0.2` canonicalises and signs the envelope with
// the entry's key and hands it back.
//
// From the channel's side this is indistinguishable from a local key, which is
// the point of `TaskSigner`. From the RP's side it is indistinguishable too: it
// verifies a Data Integrity proof against the issuer's DID document and never
// learns where the bytes were produced.
//
// ## The signing channel is not the channel being signed for
//
// `vaultSignTrustTask` is itself a Trust Task, sent to the **VTA** over the
// wallet's own holder-signed session. The document it signs is bound for a
// **relying party** over a different channel. Passing the RP channel here would
// send a vault task to a party that has no vault — and there is no recursion in
// the arrangement that is correct, because the VTA channel signs locally.

import type { TrustTaskSender } from "../vta/channel.js";
import type { RemoteDidcommEndpoint } from "../vta/didcomm.js";
import type { TaskSigner } from "../vta/trust-task.js";
import type { Identity } from "../didcomm/index.js";

import { vaultSignTrustTask } from "./sign-trust-task.js";

export interface VaultTaskSignerOptions {
/** Channel to the **VTA** — not to the party the signed document is for. */
session: TrustTaskSender;
/** The wallet's holder identity: the issuer of the `vault/sign-trust-task`
* request itself, which the VTA authenticates in the ordinary way. */
holder: Identity;
/** The VTA's endpoint — the request's `recipient`. */
service: RemoteDidcommEndpoint;
/** The vault entry holding the persona's key. */
entryId: string;
/** The persona DID the proof will verify under. Read from the entry's
* `principalDid`, never assumed: it is maintainer-derived, and an entry
* rotated at the VTA signs as something the wallet never chose. */
did: string;
}

/**
* Sign outbound documents as a vault entry's persona.
*
* The VTA refuses with `envelope_issuer_mismatch` when the envelope's `issuer`
* is not the entry's `principalDid`, so `did` and the envelope must already
* agree — `signOutboundTask` checks that before calling this, which turns a
* remote refusal into a local error naming both DIDs.
*/
export function vaultTaskSigner(opts: VaultTaskSignerOptions): TaskSigner {
return {
did: opts.did,
sign: async (envelope) => {
const { signedEnvelope } = await vaultSignTrustTask(opts.session, {
holder: opts.holder,
service: opts.service,
entryId: opts.entryId,
unsignedEnvelope: envelope as unknown as Record<string, unknown>,
});
const proof = (signedEnvelope as { proof?: unknown }).proof;
if (!proof) {
// The VTA answered without putting a proof on it. Returning quietly
// would send an unsigned document the RP refuses as `proofRequired`,
// with nothing pointing at the step that dropped it.
throw new Error(
`vault/sign-trust-task: the VTA returned an envelope with no proof for ${opts.did}`,
);
}
// Mutate in place: `signOutboundTask` returns void because every channel
// sends the envelope it already holds, so a signer that returned a new
// object would have its signature silently discarded.
(envelope as { proof?: unknown }).proof = proof;
},
};
}
19 changes: 14 additions & 5 deletions packages/core/src/vta/auth-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ export type { TokenBundle };
export interface AuthTaskCallerParams {
holder: Identity;
service: RemoteDidcommEndpoint;
/**
* DID the document is issued by. Defaults to the holder's.
*
* Different only when the channel signs as someone else — a per-site persona,
* whose key lives at the VTA. It MUST match the channel's signer:
* `signOutboundTask` refuses the mismatch locally, which is better than the
* consumer's `identityMismatch` with no hint that the cause is here.
*/
issuer?: string;
}

export interface AuthChallengeParams extends AuthTaskCallerParams {
Expand All @@ -72,7 +81,7 @@ export async function requestAuthChallenge(
...(params.purpose ? { purpose: params.purpose } : {}),
};
const envelope = buildTrustTask(AUTH_CHALLENGE, payload, {
issuer: params.holder.did,
issuer: params.issuer ?? params.holder.did,
recipient: params.service.did,
});
return sender.send<AuthChallengeResponsePayload>(envelope, {
Expand Down Expand Up @@ -117,7 +126,7 @@ export async function authenticateSession(
...(params.scope && params.scope.length > 0 ? { scope: params.scope } : {}),
};
const envelope = buildTrustTask(AUTH_AUTHENTICATE, payload, {
issuer: params.holder.did,
issuer: params.issuer ?? params.holder.did,
recipient: params.service.did,
});
return sender.send<AuthAuthenticateResponsePayload>(envelope, {
Expand Down Expand Up @@ -151,7 +160,7 @@ export async function refreshAuthSession(
...(params.scope ? { scope: params.scope } : {}),
};
const envelope = buildTrustTask(AUTH_REFRESH, payload, {
issuer: params.holder.did,
issuer: params.issuer ?? params.holder.did,
recipient: params.service.did,
});
return sender.send<AuthRefreshResponsePayload>(envelope, {
Expand Down Expand Up @@ -209,7 +218,7 @@ export async function startPasskeyLogin(
...(params.purpose !== undefined ? { purpose: params.purpose } : {}),
};
const envelope = buildTrustTask(AUTH_PASSKEY_LOGIN_START, payload, {
issuer: params.holder.did,
issuer: params.issuer ?? params.holder.did,
recipient: params.service.did,
});
return sender.send<AuthPasskeyLoginStartResponsePayload>(envelope, {
Expand Down Expand Up @@ -246,7 +255,7 @@ export async function finishPasskeyLogin(
credential: params.credential,
};
const envelope = buildTrustTask(AUTH_PASSKEY_LOGIN_FINISH, payload, {
issuer: params.holder.did,
issuer: params.issuer ?? params.holder.did,
recipient: params.service.did,
});
return sender.send<AuthPasskeyLoginFinishResponsePayload>(envelope, {
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/vta/didcomm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type TrustTask,
} from "./protocol.js";
import { buildTrustTask, parseTrustTaskReply, signOutboundTask } from "./trust-task.js";
import { asTaskSigner, type ChannelSigner, type TaskSigner } from "./trust-task.js";
import type { SigningIdentity } from "../siop/self-issued.js";
import type { NotifyOpts, SendOpts, TrustTaskChannel } from "./channel.js";
import type { DidcommMessageBridge, VtaTransport } from "./transport.js";
Expand Down Expand Up @@ -44,7 +45,7 @@ export interface DidcommVtaTransportOptions {
* path forwards whatever it is handed. The proof is what ties the payload to
* the DID named in `issuer`.
*/
signing: SigningIdentity;
signing: ChannelSigner;
/** Optional mediator. When set, every outbound message gets wrapped
* in a routing/2.0/forward envelope and anoncrypt'd to the mediator. */
mediator?: RemoteDidcommEndpoint;
Expand All @@ -71,12 +72,12 @@ export class DidcommVtaTransport implements VtaTransport, TrustTaskChannel {
private readonly bridge: DidcommMessageBridge;
private readonly holder: Identity;
private readonly vta: RemoteDidcommEndpoint;
private readonly signing: SigningIdentity;
private readonly signer: TaskSigner;
private readonly mediator?: RemoteDidcommEndpoint;
private readonly timeoutMs: number;

constructor(opts: DidcommVtaTransportOptions) {
this.signing = opts.signing;
this.signer = asTaskSigner(opts.signing);
this.bridge = opts.bridge;
this.holder = opts.holder;
this.vta = opts.vta;
Expand Down Expand Up @@ -237,7 +238,7 @@ export class DidcommVtaTransport implements VtaTransport, TrustTaskChannel {
// Every outbound path — `send`, `notify`, and the passkey-VM convenience
// surface — packs through here, which is why the proof is attached here
// and not in each of them.
await signOutboundTask(envelope, this.signing);
await signOutboundTask(envelope, this.signer);
const requestId = envelope.id;
const message = {
id: requestId,
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/vta/rest-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { TRUST_TASK_PATH } from "./endpoint.js";
import { errorFromBody, VtaClientError } from "./errors.js";
import type { TrustTask } from "./protocol.js";
import { parseTrustTaskReply, signOutboundTask } from "./trust-task.js";
import { asTaskSigner, type ChannelSigner, type TaskSigner } from "./trust-task.js";
import type { SigningIdentity } from "../siop/self-issued.js";
import { isTrustTaskErrorType } from "./protocol.js";
import { getVtaBearer, makeReauth, type VtaAuthInputs } from "./auth.js";
Expand All @@ -31,7 +32,7 @@ export interface RestChannelOptions extends VtaAuthInputs {
*
* Its `did` must be the envelope's `issuer` — see {@link signOutboundTask}.
*/
signing: SigningIdentity;
signing: ChannelSigner;
/**
* Trust-task dispatcher path, appended to `baseUrl`. Defaults to
* `/trust-tasks`, which the published HTTPS binding fixes — `baseUrl` is
Expand All @@ -55,12 +56,12 @@ export interface RestChannelOptions extends VtaAuthInputs {
export class RestChannel implements TrustTaskChannel {
readonly kind = "rest" as const;
private readonly auth: VtaAuthInputs;
private readonly signing: SigningIdentity;
private readonly signer: TaskSigner;
private readonly path: string;
private readonly fetchImpl: typeof fetch;

constructor(opts: RestChannelOptions) {
this.signing = opts.signing;
this.signer = asTaskSigner(opts.signing);
this.auth = {
baseUrl: opts.baseUrl,
holder: opts.holder,
Expand All @@ -84,7 +85,7 @@ export class RestChannel implements TrustTaskChannel {
// Before serialization, and before the bearer handshake: the proof is part
// of the document, so a body built from an unsigned envelope would be the
// one thing that reaches the VTA.
await signOutboundTask(envelope, this.signing);
await signOutboundTask(envelope, this.signer);
const body = JSON.stringify(envelope);

const once = async (bearer: string): Promise<Response> => {
Expand Down
Loading
Loading