diff --git a/packages/core/src/rp-login/trust-task.ts b/packages/core/src/rp-login/trust-task.ts index 08bf2f9..4270002 100644 --- a/packages/core/src/rp-login/trust-task.ts +++ b/packages/core/src/rp-login/trust-task.ts @@ -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"; @@ -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. */ @@ -71,29 +77,33 @@ export async function loginViaTrustTask( opts: TrustTaskLoginOptions, ): Promise { 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 } : {}), diff --git a/packages/core/src/siop/login-client.ts b/packages/core/src/siop/login-client.ts index 07b5b27..7a21a02 100644 --- a/packages/core/src/siop/login-client.ts +++ b/packages/core/src/siop/login-client.ts @@ -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; +} + +/** 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; @@ -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( @@ -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, }); @@ -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, diff --git a/packages/core/src/vault/index.ts b/packages/core/src/vault/index.ts index 5ede1c6..74bc0cf 100644 --- a/packages/core/src/vault/index.ts +++ b/packages/core/src/vault/index.ts @@ -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"; diff --git a/packages/core/src/vault/task-signer.ts b/packages/core/src/vault/task-signer.ts new file mode 100644 index 0000000..2e2eb60 --- /dev/null +++ b/packages/core/src/vault/task-signer.ts @@ -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, + }); + 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; + }, + }; +} diff --git a/packages/core/src/vta/auth-tasks.ts b/packages/core/src/vta/auth-tasks.ts index 8bf36ac..2128d0c 100644 --- a/packages/core/src/vta/auth-tasks.ts +++ b/packages/core/src/vta/auth-tasks.ts @@ -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 { @@ -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(envelope, { @@ -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(envelope, { @@ -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(envelope, { @@ -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(envelope, { @@ -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(envelope, { diff --git a/packages/core/src/vta/didcomm.ts b/packages/core/src/vta/didcomm.ts index 4c32b4a..674b366 100644 --- a/packages/core/src/vta/didcomm.ts +++ b/packages/core/src/vta/didcomm.ts @@ -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"; @@ -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; @@ -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; @@ -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, diff --git a/packages/core/src/vta/rest-channel.ts b/packages/core/src/vta/rest-channel.ts index 83f155c..f1f0ecd 100644 --- a/packages/core/src/vta/rest-channel.ts +++ b/packages/core/src/vta/rest-channel.ts @@ -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"; @@ -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 @@ -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, @@ -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 => { diff --git a/packages/core/src/vta/trust-task.ts b/packages/core/src/vta/trust-task.ts index 68589e7..03c9996 100644 --- a/packages/core/src/vta/trust-task.ts +++ b/packages/core/src/vta/trust-task.ts @@ -105,21 +105,79 @@ export function buildTrustTask

( */ export async function signOutboundTask( envelope: TrustTask, - signing: SigningIdentity, + signer: TaskSigner, ): Promise { // SPEC §7.2 item 6 — the in-band issuer must be the party that signed. A // consumer rejects the mismatch, so catching it here turns a remote // `identityMismatch` into a local error naming both DIDs. - if (envelope.issuer !== undefined && envelope.issuer !== signing.did) { + if (envelope.issuer !== undefined && envelope.issuer !== signer.did) { throw new VtaClientError( "e.client.identity", - `${envelope.type}: envelope issuer ${envelope.issuer} is not the signing identity ${signing.did}`, + `${envelope.type}: envelope issuer ${envelope.issuer} is not the signing identity ${signer.did}`, ); } - await signTrustTask({ - envelope: envelope as unknown as Record & { proof?: unknown }, - signing, - }); + await signer.sign(envelope); +} + +/** + * Whatever can put a proof on an outbound document. + * + * An interface rather than a key, because the wallet does not hold every key it + * needs to sign with. A per-site persona's key lives at the VTA and never + * leaves it, so signing as one is a request, not a computation — but from the + * channel's point of view the two are the same operation, and the RP cannot + * tell them apart either: it verifies a proof against `did`, wherever the + * bytes were produced. + * + * **Still REQUIRED, for the reason above.** Widening the type does not weaken + * the rule that a channel cannot be built without one; a channel with no signer + * would still be a channel that silently sends unsigned documents. What it + * changes is only *where the key is*. + * + * `did` must be the DID the proof will verify under. A signer whose `did` + * disagrees with what it actually signs produces documents that fail at the + * consumer with `identityMismatch`, and the issuer check above cannot catch it + * — it compares the envelope against this field, not against the signature. + */ +export interface TaskSigner { + readonly did: string; + sign(envelope: TrustTask): Promise; +} + +/** A signer backed by a key this process holds — the holder's own identity, + * and what every channel used before the persona paths existed. */ +export function localTaskSigner(signing: SigningIdentity): TaskSigner { + return { + did: signing.did, + sign: async (envelope) => { + await signTrustTask({ + envelope: envelope as unknown as Record & { proof?: unknown }, + signing, + }); + }, + }; +} + +/** + * What a channel accepts for its signing input. + * + * A bare {@link SigningIdentity} is still accepted because the deprecated + * `*Rest` helpers thread one straight through their own options types, and + * changing that would rewrite fourteen public interfaces to say something none + * of their callers need to know. It is normalised on the way in, so exactly one + * shape reaches {@link signOutboundTask}. + * + * This is not a compatibility fold: both arms are live, they describe local + * objects rather than a wire format, and neither is a legacy spelling of the + * other. + */ +export type ChannelSigner = SigningIdentity | TaskSigner; + +/** Normalise a channel's signing input. Call once, at construction — a channel + * that re-normalised per send would re-derive the same object on every + * outbound document. */ +export function asTaskSigner(signer: ChannelSigner): TaskSigner { + return "sign" in signer ? signer : localTaskSigner(signer); } export interface ParseTrustTaskReplyOptions { diff --git a/packages/core/src/vta/tsp-channel.ts b/packages/core/src/vta/tsp-channel.ts index 4f5b146..775441a 100644 --- a/packages/core/src/vta/tsp-channel.ts +++ b/packages/core/src/vta/tsp-channel.ts @@ -25,6 +25,7 @@ import type { NotifyOpts, SendOpts, TrustTaskChannel } from "./channel.js"; import { 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"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -122,7 +123,7 @@ export interface TspChannelOptions { * over the document, naming a `verificationMethod` a verifier can resolve — * which the outer signature is not and cannot become. */ - signing: SigningIdentity; + signing: ChannelSigner; /** Per-request timeout (default 30s). */ timeoutMs?: number; } @@ -140,11 +141,11 @@ export class TspChannel implements TrustTaskChannel { private readonly transport: TspTransport; private readonly holder: TspHolderIdentity; private readonly vta: TspRemoteEndpoint; - private readonly signing: SigningIdentity; + private readonly signer: TaskSigner; private readonly timeoutMs: number; constructor(opts: TspChannelOptions) { - this.signing = opts.signing; + this.signer = asTaskSigner(opts.signing); this.transport = opts.transport; this.holder = opts.holder; this.vta = opts.vta; @@ -155,7 +156,7 @@ export class TspChannel implements TrustTaskChannel { private async packForVta(envelope: TrustTask): Promise { // Both `send` and `notify` seal through here, so this is the one place the // proof has to be attached — before the JSON the seal is taken over. - await signOutboundTask(envelope, this.signing); + await signOutboundTask(envelope, this.signer); // TSP plaintext = the Trust-Task envelope JSON (no binding wrapper). const plaintext = utf8.encode(JSON.stringify(envelope)); const packed = await pack(plaintext, this.holder.vid, this.vta.vid, { diff --git a/packages/core/tests/rp-login.trust-task.mjs b/packages/core/tests/rp-login.trust-task.mjs index 8e6af0b..154e1d7 100644 --- a/packages/core/tests/rp-login.trust-task.mjs +++ b/packages/core/tests/rp-login.trust-task.mjs @@ -88,19 +88,36 @@ test("a refresh token is omitted rather than invented when the RP sends none", a assert.ok(!("refreshToken" in s) || s.refreshToken === undefined); }); -test("a signing identity that is not the holder is refused before anything is sent", async () => { - // The proof is the authentication, so a signature by another key - // authenticates nobody — and the RP's answer for that is an opaque - // permissionDenied that says nothing about the cause being local. +test("both auth documents are issued by the holder when no subject is named", async () => { + // The guard this replaces asserted "the signing identity must be the + // holder", checked inside `loginViaTrustTask`. It could not survive a + // channel that signs as a per-site persona, and it was checking the wrong + // thing anyway: `loginViaTrustTask` never signs. `signOutboundTask` compares + // the envelope's issuer against the signer's DID on every outbound document, + // which is the same rule enforced where the signature actually happens. + // + // What still has to hold here is that both steps name ONE identity. The RP + // refuses unless the DID it issued the challenge to is the one that signed + // (`session.did != input.signer_did`), so a challenge for A spent by a + // document issued by B fails remotely with nothing pointing here. const sender = rp(); - await assert.rejects( - () => - loginViaTrustTask( - opts(sender, { signing: { did: "did:key:zSomeoneElse", kid: "x", privateKey: new Uint8Array(32) } }), - ), - /is not the holder/, - ); - assert.equal(sender.sent.length, 0, "nothing may reach the RP"); + await loginViaTrustTask(opts(sender)); + assert.equal(sender.sent.length, 2); + assert.equal(sender.sent[0].envelope.issuer, HOLDER); + assert.equal(sender.sent[1].envelope.issuer, HOLDER); + assert.equal(sender.sent[0].envelope.payload.subject, HOLDER); +}); + +test("a named subject issues BOTH documents, not just the challenge", async () => { + // The half that would break silently: request the challenge for the persona + // but issue the authenticate as the holder, and the RP rejects on the signer + // check — after the operator has already approved the sign-in. + const PERSONA = "did:webvh:zScid:agent.example:contexts:personal"; + const sender = rp(); + await loginViaTrustTask(opts(sender, { subject: PERSONA })); + assert.equal(sender.sent[0].envelope.issuer, PERSONA); + assert.equal(sender.sent[0].envelope.payload.subject, PERSONA); + assert.equal(sender.sent[1].envelope.issuer, PERSONA); }); test("a refused challenge surfaces as itself, not as a login failure", async () => { diff --git a/packages/core/tests/vta.outbound-signing.mjs b/packages/core/tests/vta.outbound-signing.mjs index b16c2fb..a5add7f 100644 --- a/packages/core/tests/vta.outbound-signing.mjs +++ b/packages/core/tests/vta.outbound-signing.mjs @@ -30,6 +30,7 @@ import { TRUST_TASK_ENVELOPE_TYPE, buildTrustTask, generateSigningIdentity, + localTaskSigner, signOutboundTask, verifyTrustTaskProof, } from "../dist/index.js"; @@ -220,7 +221,7 @@ test("an envelope whose issuer is not the signer is refused before it is sent", }); await assert.rejects( - () => signOutboundTask(envelope, signing), + () => signOutboundTask(envelope, localTaskSigner(signing)), (err) => { assert.equal(err.code, "e.client.identity"); return true; @@ -251,7 +252,7 @@ test("re-signing a document that already carries a proof does not sign over it", }); envelope.proof = { type: "DataIntegrityProof", proofValue: "zStaleGarbage" }; - await signOutboundTask(envelope, signing); + await signOutboundTask(envelope, localTaskSigner(signing)); await assertSignedBy(envelope, signing.did); }); diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index d26e605..2691a05 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -18,8 +18,15 @@ import { } from "./active-vta.js"; import { checkOriginPin, pinOrigin } from "./origin-pin.js"; import { isOriginTrusted, trustOrigin } from "./trusted-sites.js"; +import { + forgetSiteIdentity, + HOLDER_IDENTITY, + prefersHolderIdentity, + rememberHolderIdentity, +} from "./site-identity.js"; import { buildProfileEntry, + decideSiteIdentity, matchProfileEntry, PROFILE_SECRET_KIND, } from "./first-use-profile.js"; @@ -676,6 +683,10 @@ async function requestConsent(args: { * so the prompt asks which identity the site should know the user as and * returns the answer in `selectedDid`. */ chooseProfile?: boolean; + /** Offer the wallet's own identity as one of the answers, returned as + * {@link HOLDER_IDENTITY}. Only `login()` sets it — the proxy paths mint + * through a vault entry, and the holder is not one. */ + allowHolder?: boolean; }): Promise<{ approved: boolean; remember: boolean; selectedDid?: string }> { const consentId = crypto.randomUUID(); const url = @@ -688,6 +699,7 @@ async function requestConsent(args: { (args.noRemember ? `&noRemember=1` : "") + (args.stepUp ? `&stepUp=1` : "") + (args.chooseProfile ? `&chooseProfile=1` : "") + + (args.allowHolder ? `&allowHolder=1` : "") + (args.reason ? `&reason=${encodeURIComponent(args.reason)}` : "") + (args.changedFromRpDid ? `&changedFrom=${encodeURIComponent(args.changedFromRpDid)}` @@ -917,20 +929,112 @@ async function handleLogin(req: RuntimeLoginRequest): Promise { + // No attested origin means no site to bind a persona to. The wallet's own + // identity is the only honest answer, and it is what this path already did. + if (!origin) return { ok: true, did: holderDid, bound: false }; + + const listed = await handleVaultList({ + type: RUNTIME_VAULT_LIST, + filter: { secretKind: PROFILE_SECRET_KIND, targetOriginPrefix: origin }, + }); + if (!listed.ok) return { ok: false, error: listed.error }; + + const decision = decideSiteIdentity( + listed.result.entries, + origin, + await prefersHolderIdentity(origin), + ); + + if (decision.kind === "holder") return { ok: true, did: holderDid, bound: false }; + if (decision.kind === "persona") { + const did = await principalDidFor(origin, decision.entryId); + if (!did.ok) return { ok: false, error: did.error }; + return { ok: true, entryId: decision.entryId, did: did.did, bound: false }; + } + + // `requestConsent`, not `gatedConsent` — a remembered origin consented to + // being signed in as an identity already chosen, never to one being chosen + // for it. Same reasoning as the proxy-login path. + const chosen = await requestConsent({ + origin, + rpDid, + holderDid, + chooseProfile: true, + allowHolder: true, + }); + if (!chosen.approved || !chosen.selectedDid) { + return { ok: false, error: "login denied by user" }; + } + if (chosen.remember) await trustOrigin(origin, rpDid); + + if (chosen.selectedDid === HOLDER_IDENTITY) { + await rememberHolderIdentity(origin); + return { ok: true, did: holderDid, bound: false }; + } + + const bound = await bindProfileEntry(origin, chosen.selectedDid, rpDid); + if (!bound.ok) return { ok: false, error: bound.error }; + // A persona now answers for this origin, so a holder record left behind would + // be a second answer that never wins but is read on every sign-in. + await forgetSiteIdentity(origin); + return { ok: true, entryId: bound.entryId, did: chosen.selectedDid, bound: true }; } async function handleLoginDidcomm( @@ -966,16 +1070,35 @@ async function handleLoginDidcomm( await pinOrigin(req.origin, req.params.controlDid); } + // Same question as the REST path, same answer: a per-site persona signs the + // auth documents when this origin has one. The transport stays the wallet's + // own — the RP reads the caller off the document's proof, not off who + // delivered it. + const identity = await resolveLoginIdentity(req.origin, req.params.controlDid, holderDid); + if (!identity.ok) return { ok: false, error: identity.error }; + await ensureOffscreenDocument(); - const activeVtaDid = await readActiveVtaDid(); - if (!activeVtaDid) return { ok: false, error: "no active VTA connection — connect first" }; + const active = await readActiveConnection(); + if (!active.ok) return { ok: false, error: active.error }; const offscreenRequest: OffscreenDidcommLoginRequest = { target: OFFSCREEN_TARGET, type: OFFSCREEN_DIDCOMM_LOGIN, - vtaDid: activeVtaDid, + vtaDid: active.conn.vtaDid, params: req.params, + ...(identity.entryId ? { entryId: identity.entryId } : {}), + ...(active.conn.restBaseUrl ? { restBaseUrl: active.conn.restBaseUrl } : {}), }; - return (await chrome.runtime.sendMessage(offscreenRequest)) as RuntimeLoginResponse; + const result = (await chrome.runtime.sendMessage(offscreenRequest)) as RuntimeLoginResponse; + + if (!result.ok && identity.bound) { + return { + ok: false, + error: + `${result.error} — this was the first sign-in as ${identity.did}. ` + + `If ${req.origin ?? "the site"} refused it, that identity needs to be on its access list.`, + }; + } + return result; } async function handleStepUpVta( diff --git a/packages/extension/src/bridge-protocol.ts b/packages/extension/src/bridge-protocol.ts index 98a935e..8451467 100644 --- a/packages/extension/src/bridge-protocol.ts +++ b/packages/extension/src/bridge-protocol.ts @@ -1607,6 +1607,13 @@ export interface OffscreenOnboardConnectRequest { /** background → offscreen: run a DIDComm login. Reply is a * [`RuntimeLoginResponse`] via `sendResponse`. */ export interface OffscreenDidcommLoginRequest { + /** The vault entry whose persona signs the auth documents, when this origin + * has one bound. Absent means the wallet's own identity. Resolved in the + * background — see `OffscreenRestLoginRequest.entryId`. */ + entryId?: string; + /** REST base for the VTA session the persona's signatures go through. + * Unused for a holder login. */ + restBaseUrl?: string; target: typeof OFFSCREEN_TARGET; type: typeof OFFSCREEN_DIDCOMM_LOGIN; /** Which VTA's holder identity to authenticate as. Multi-VTA: the @@ -1632,6 +1639,16 @@ export interface OffscreenRestLoginRequest { * fills this from the active connection before forwarding. */ vtaDid: string; params: LoginParams; + /** The vault entry whose persona signs in, when this origin has one bound. + * Absent means the wallet's own holder identity — either because the + * operator chose it for this site or because there is no attested origin to + * bind a persona to. Resolved in the background, where the vault and the + * operator's recorded choice live; the offscreen only turns it into the + * matching `id_token` producer. */ + entryId?: string; + /** REST base for the VTA session the persona mint goes through. Unused for + * a holder login, which contacts only the RP. */ + restBaseUrl?: string; } /** background → offscreen: run a VTA-approval step-up. Reply is a diff --git a/packages/extension/src/confirm.tsx b/packages/extension/src/confirm.tsx index 20453b1..6583ccc 100644 --- a/packages/extension/src/confirm.tsx +++ b/packages/extension/src/confirm.tsx @@ -3,6 +3,7 @@ import { StrictMode, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import { collapseDid, splitDid, type DidPart } from "./did-display.js"; +import { HOLDER_IDENTITY } from "./site-identity.js"; import { extractAgentNames, withoutScheme } from "./agent-name.js"; import "./theme.css"; import { @@ -64,6 +65,13 @@ const stepUpReason = params.get("reason"); // the identity a site sees IS the approval, and splitting it into two screens // would only train the operator to click through both. const isChooseProfile = params.get("chooseProfile") === "1"; +// Whether "my wallet's own identity" is one of the answers. +// +// Only `login()` can honour it: it self-issues the id_token from a key the +// browser holds. `proxyLogin` and `walletProfile` cannot — the VTA mints as a +// vault entry's persona, and the holder is not one — so offering it there would +// present a choice that fails after it is made. +const allowsHolder = params.get("allowHolder") === "1"; // M5: when set, the rpDid this origin previously used. Render a // louder warning so the operator sees the swap and decides // whether to approve it. @@ -483,9 +491,11 @@ function Confirm() { return; } setPersonas(reply.result.dids); - // One persona is not a choice. Preselect it so the operator is deciding - // the thing that is actually in question — whether this site gets an - // identity at all — instead of confirming a dropdown with one row. + // Preselect a sole persona as the recommended default — the wallet's + // own identity is always in the list beside it, so this is offering a + // default rather than removing the choice. Approve stays disabled with + // no personas at all, so the operator has to pick the holder option + // deliberately rather than inherit it. if (reply.result.dids.length === 1) setSelectedDid(reply.result.dids[0]!.did); }) .catch((e: unknown) => { @@ -740,7 +750,7 @@ function Confirm() { ) : personas === null ? (

Loading your identities…
- ) : personas.length === 0 ? ( + ) : personas.length === 0 && !allowsHolder ? (
Your agent hosts no identities yet, so there is nothing to sign in as. Create one in the wallet (Vault → identities) and try again. @@ -767,6 +777,18 @@ function Confirm() { {collapsedDidText(d.did)} · {d.contextId} ))} + {/* The wallet's own identity, offered explicitly rather than + used as a silent fallback. Every ACL enrolment made before + per-site personas existed names this DID, so removing the + route would break those sites with an RP refusal as the + only signal. Last in the list, and labelled with what it + costs, because it is the weaker answer — not the default. */} + {allowsHolder && ( + + )} {selectedDid && (
- {selectedDid} + {selectedDid === HOLDER_IDENTITY ? (holderDid ?? "") : selectedDid}
)} {/* The ACL caveat. Stated as a fact about the site, not a wallet @@ -786,21 +808,35 @@ function Confirm() { identities it admits, and nothing this wallet does can add one. Said here rather than after the failure so the operator can copy the DID while it is on screen. */} -

- The site has to allow this identity before it will let you in. If sign-in is - refused, ask{" "} - {originHost ? ( - {originHost} - ) : ( - "the site" - )}{" "} - to add the identity above to its access list, then try again. -

- {personas.length > 1 && ( -

- Using an identity you already use elsewhere lets both sites work out you are the - same person. A fresh one for this site keeps them separate. + {selectedDid === HOLDER_IDENTITY ? ( + // Different warning, because the trade is the opposite one. + // The holder DID already works at every site that enrolled it, + // so there is no ACL step — the cost is that those sites can + // all recognise the same person. +

+ This is the same identity you use everywhere else, so any site that has it can + work out you are the same person. Choose it when this site's access list + already names your wallet's identity.

+ ) : ( + <> +

+ The site has to allow this identity before it will let you in. If sign-in is + refused, ask{" "} + {originHost ? ( + {originHost} + ) : ( + "the site" + )}{" "} + to add the identity above to its access list, then try again. +

+ {personas.length > 1 && ( +

+ Using an identity you already use elsewhere lets both sites work out you are + the same person. A fresh one for this site keeps them separate. +

+ )} + )} )} diff --git a/packages/extension/src/first-use-profile.ts b/packages/extension/src/first-use-profile.ts index e681559..c7c8e76 100644 --- a/packages/extension/src/first-use-profile.ts +++ b/packages/extension/src/first-use-profile.ts @@ -104,3 +104,33 @@ export function buildProfileEntry( }, }; } + +/** What a sign-in at this origin should do about identity. */ +export type SiteIdentityDecision = + | { kind: "persona"; entryId: string } + | { kind: "holder" } + | { kind: "ask" }; + +/** + * Resolve the identity for a sign-in, from the two places the answer can live. + * + * A bound persona is a vault entry; choosing the wallet's own identity is a + * local record (`site-identity.ts`). **A persona always wins**, and the order + * is not arbitrary: a persona is the more specific statement about this site, + * and it is the one the operator can see and revoke in the vault. Reading the + * local record first would let a stale holder choice mask an entry the operator + * later bound through `proxyLogin`, and the sign-in would quietly use a + * different identity than the vault says it does. + * + * `ask` means neither exists — raise the picker. + */ +export function decideSiteIdentity( + entries: readonly VaultEntryView[], + origin: string, + prefersHolder: boolean, +): SiteIdentityDecision { + const match = matchProfileEntry(entries, origin); + if (match) return { kind: "persona", entryId: match.id }; + if (prefersHolder) return { kind: "holder" }; + return { kind: "ask" }; +} diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 5446f8b..e7af568 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -16,6 +16,11 @@ import { IndexedDBKVStore, loginViaTrustTask, loginViaSiop, + selfIssuedMinter, + vaultTaskSigner, + type ChannelSigner, + type SiopIdTokenMinter, + type TaskSigner, claimInboundDocument, type MediatorConnection, MediatorSessionBridge, @@ -539,7 +544,26 @@ interface VtaSessionHandle { * because the holder it is about to mint does not exist yet. */ interface SessionIdentity { holder: Identity; + /** The wallet's own key. This is the **transport** identity — TSP seals from + * it, the mediator authenticates it — and, by default, what signs outbound + * documents too. */ signing: SigningIdentity; + /** + * Signs the documents, when that is not the holder. + * + * Transport sender and document signer are different things, and the RP + * treats them as different things: it establishes the caller from the proof + * on the document (`session.did != input.signer_did` in vti-common's + * `handle_authenticate`), not from who delivered it. A per-site persona + * login rides the wallet's own transport — there is no second mediator + * session, and the persona has no key here to open one with — while the + * documents are issued by, and signed as, the persona. + * + * Kept out of `signing` deliberately: widening that field would have handed + * a keyless signer to `tspHolderIdentityFromSecret`, which needs the actual + * private key and would have failed at a distance from the cause. + */ + documentSigner?: TaskSigner; } /** How an identity reaches a mediator. @@ -921,6 +945,9 @@ async function buildVtaSession( } = {}, ): Promise { const { holder, signing } = who; + // Documents are signed by the persona when there is one; the transport + // stays the wallet's own either way. + const documentSigner: ChannelSigner = who.documentSigner ?? signing; const restBaseUrl = opts.restBaseUrl; const service = await resolveKeyAgreement(vtaDid); const services = opts.services ?? (await resolveVtaServices(vtaDid)); @@ -947,12 +974,14 @@ async function buildVtaSession( new TspChannel({ transport: new MediatorSessionTspTransport({ connection: conn }), holder: tspHolderIdentityFromSecret(holder.did, signing.privateKey), - // The same Ed25519 key signs the outer TSP envelope (above) and the - // Trust-Task document (here). They are not redundant: the outer - // signature authenticates the *sender of the frame*, and SPEC §7.2 - // item 7 admits no transport substitute for a proof over the - // document itself. - signing, + // Outer TSP envelope and inner Trust-Task proof are separate + // signatures, and not redundant: the outer one authenticates the + // *sender of the frame*, and SPEC §7.2 item 7 admits no transport + // substitute for a proof over the document itself. Which is exactly + // why they can be different keys — a persona login is sent by the + // wallet and issued by the persona, and the consumer reads the + // caller off the document. + signing: documentSigner, vta: vtaTsp, }), ); @@ -990,7 +1019,7 @@ async function buildVtaSession( new DidcommVtaTransport({ bridge, holder, - signing, + signing: documentSigner, vta: service, mediator: conn.mediator, }), @@ -999,7 +1028,7 @@ async function buildVtaSession( } const rest = restBaseUrl || services.rest?.baseUrl; if (rest) { - channels.push(new RestChannel({ baseUrl: rest, holder, signing, service })); + channels.push(new RestChannel({ baseUrl: rest, holder, signing: documentSigner, service })); // Deliberately `"unknown"`, not `"up"`. A `RestChannel` is built from a // URL without contacting anything, so construction is not evidence — and // a REST channel that turns out to be unreachable fails the caller's @@ -2794,12 +2823,84 @@ async function doRestLogin( // (challenge → issueIdToken → authenticate), just running in the // context that owns the cache. const { signing } = await loadHolder(req.vtaDid); + + // Which identity signs in was decided in the background, where the vault and + // the operator's choice live. Here it is only the difference between two + // id_token producers: the holder self-issues from a key this document holds, + // while a persona is minted by the VTA — the only place that key exists. + const minter = req.entryId + ? await personaMinter(req.vtaDid, req.restBaseUrl, req.entryId) + : selfIssuedMinter(signing); + const tokens = await loginViaSiop({ baseUrl: req.params.baseUrl, rpDid: req.params.rpDid, - signing, + minter, }); - return { ok: true, result: { ...tokens, holderDid: signing.did } }; + // The DID the RP actually authenticated, not the wallet's own. Reporting + // `signing.did` for a persona login would tell the page it is talking to an + // identity that never signed anything in this flow. + return { ok: true, result: { ...tokens, holderDid: minter.did } }; +} + +/** + * An `id_token` minter backed by `vault/proxy-login/0.2`. + * + * The persona's signing key never leaves the VTA, so the wallet cannot issue + * this token — it asks the VTA to, threading the RP's challenge through as the + * `nonce` so the result passes the RP's exact-match check. The `SessionBlob` + * comes back with the token in an `Authorization` header, which is the shape + * `vault/proxy-login` has always returned for did-self-issued entries. + * + * The DID is read from the entry rather than assumed, because `principalDid` is + * maintainer-derived: an entry whose secret was rotated at the VTA signs as + * something the wallet never chose, and the challenge must be requested for + * whatever actually signs or the RP refuses on `signer_did` mismatch. + */ +/** A {@link TaskSigner} for a vault entry's persona, plus the VTA session it + * signs through. The persona DID is read from the entry rather than assumed, + * for the same reason `personaMinter` reads it: `principalDid` is + * maintainer-derived, and the RP checks the signer against the challenge + * subject. */ +async function personaTaskSigner( + vtaDid: string, + restBaseUrl: string | undefined, + entryId: string, +): Promise { + const { session, holder, service } = await getVtaSession(vtaDid, restBaseUrl); + const listed = await vaultList(session, { holder, service }); + const entry = listed.entries.find((e) => e.id === entryId); + if (!entry?.principalDid) { + throw new Error(`vault entry ${entryId} names no persona DID`); + } + return vaultTaskSigner({ session, holder, service, entryId, did: entry.principalDid }); +} + +async function personaMinter( + vtaDid: string, + restBaseUrl: string | undefined, + entryId: string, +): Promise { + const { session, holder, service } = await getVtaSession(vtaDid, restBaseUrl); + const listed = await vaultList(session, { holder, service }); + const entry = listed.entries.find((e) => e.id === entryId); + if (!entry?.principalDid) { + throw new Error(`vault entry ${entryId} names no persona DID`); + } + return { + did: entry.principalDid, + mint: async ({ nonce }) => { + const res = await vaultProxyLogin(session, { holder, service, entryId, nonce }); + const auth = res.sessionBlob.headers?.find( + (h) => h.name.toLowerCase() === "authorization", + ); + const token = auth ? /^\s*Bearer\s+(.+?)\s*$/i.exec(auth.value)?.[1] : undefined; + if (!token) { + throw new Error("vault/proxy-login: SessionBlob carried no id_token"); + } + return token; + }, + }; } async function doDidcommLogin( @@ -2830,9 +2931,17 @@ async function doDidcommLogin( }; sw.mark("resolve rp services"); + // A persona signs the documents when this origin has one; the transport + // stays the wallet's own either way. The signer talks to the **VTA** over the + // wallet's own session — a different channel from the RP one being built here + // — because that is where the persona's key lives. + const documentSigner = req.entryId + ? await personaTaskSigner(req.vtaDid, req.restBaseUrl, req.entryId) + : undefined; + const { session } = await buildVtaSession( req.params.controlDid, - { holder: identity, signing }, + { holder: identity, signing, ...(documentSigner ? { documentSigner } : {}) }, (mediatorDid) => getWarmSession(mediatorDid, req.vtaDid), { services }, ); @@ -2849,8 +2958,8 @@ async function doDidcommLogin( const rpSession = await loginViaTrustTask({ sender: session, holder: identity, - signing, service, + ...(documentSigner ? { subject: documentSigner.did } : {}), ...(req.params.scope ? { scope: req.params.scope } : {}), }); sw.mark("authenticate (trust-task)"); @@ -2863,7 +2972,8 @@ async function doDidcommLogin( // a fabricated value would. refreshToken: rpSession.refreshToken ?? "", sessionId: rpSession.sessionId, - holderDid: signing.did, + // The DID the RP authenticated, not the wallet's own — see doRestLogin. + holderDid: documentSigner?.did ?? signing.did, timings: sw.marks, }, }; diff --git a/packages/extension/src/site-identity.ts b/packages/extension/src/site-identity.ts new file mode 100644 index 0000000..f57a248 --- /dev/null +++ b/packages/extension/src/site-identity.ts @@ -0,0 +1,77 @@ +/** + * Which identity a site knows the user as, when the answer is "the wallet's + * own". + * + * Persona choices need no store: binding one *is* a vault entry, and + * `matchProfileEntry` reads it back. Choosing the holder DID creates nothing — + * there is no entry to find — so without a record here the operator would be + * asked again on every sign-in, and a prompt that reappears after being + * answered is one people learn to click through (R7.2). + * + * So this stores exactly one fact, per origin: *the operator chose the holder + * for this site.* It is a decision, not a cache — nothing here is derivable + * from the vault, which is why it cannot live there. + * + * ## Why the holder is offered at all + * + * A per-site persona is the better answer and the default. But the RP's ACL is + * checked against whichever DID signs in, and every enrolment made before + * personas existed names the holder DID. Removing that route would break those + * sites on the next sign-in, with a refusal from the RP as the only signal. So + * it stays, as an explicit choice the operator makes with the consequence on + * screen — rather than as a silent fallback, which is the thing this whole + * change set exists to remove. + * + * Storage: `chrome.storage.local`, key prefix `site-identity:`. Revoked by + * clearing extension storage, or by binding a persona — a persona always wins, + * because it is the more specific statement about this site. + */ + +const KEY_PREFIX = "site-identity:"; + +/** The value the consent picker returns when the operator chooses the wallet's + * own identity. Not a DID: it must never be mistaken for one, and a sentinel + * that cannot parse as a DID fails loudly if it ever reaches a place expecting + * one. */ +export const HOLDER_IDENTITY = "holder" as const; + +interface SiteIdentityRecord { + kind: typeof HOLDER_IDENTITY; + chosenAt: number; +} + +function key(origin: string): string { + return `${KEY_PREFIX}${origin}`; +} + +/** Did the operator choose the wallet's own identity for this site? */ +export async function prefersHolderIdentity(origin: string): Promise { + if (!origin) return false; + const k = key(origin); + const got = await chrome.storage.local.get(k); + return (got[k] as SiteIdentityRecord | undefined)?.kind === HOLDER_IDENTITY; +} + +/** Record that this site signs in as the wallet's own identity. */ +export async function rememberHolderIdentity(origin: string): Promise { + if (!origin) return; + const record: SiteIdentityRecord = { kind: HOLDER_IDENTITY, chosenAt: Date.now() }; + await chrome.storage.local.set({ [key(origin)]: record }); +} + +/** Forget the choice, so the next sign-in asks again. Used when a persona is + * bound for the same origin: leaving a stale holder record behind would make + * the answer depend on which of two stores was read first. */ +export async function forgetSiteIdentity(origin: string): Promise { + if (!origin) return; + await chrome.storage.local.remove(key(origin)); +} + +/** Every origin pinned to the wallet's own identity, for the options page. */ +export async function listHolderIdentitySites(): Promise { + const all = await chrome.storage.local.get(null); + return Object.keys(all) + .filter((k) => k.startsWith(KEY_PREFIX)) + .map((k) => k.slice(KEY_PREFIX.length)) + .sort(); +} diff --git a/packages/extension/tests/first-use-profile.test.mts b/packages/extension/tests/first-use-profile.test.mts index 34baf3d..35c49c9 100644 --- a/packages/extension/tests/first-use-profile.test.mts +++ b/packages/extension/tests/first-use-profile.test.mts @@ -5,6 +5,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { buildProfileEntry, + decideSiteIdentity, matchProfileEntry, profileLabelFor, PROFILE_SECRET_KIND, @@ -139,3 +140,39 @@ test("the label is the hostname, and never a fabricated one", () => { // Unparseable input comes back verbatim rather than as a guess. assert.equal(profileLabelFor("not a url"), "not a url"); }); + +// ─── Which identity a login() at this origin uses ─── + +test("a bound persona is used, and beats a stale holder choice", () => { + // The operator once chose the wallet's own identity here, then later bound a + // persona (through proxyLogin, say). The persona is the more specific + // statement and the one visible in the vault, so reading the local record + // first would sign in as an identity the vault contradicts. + const e = entry("01A", [{ kind: "webOrigin", origin }]); + assert.deepEqual(decideSiteIdentity([e], origin, true), { kind: "persona", entryId: "01A" }); + assert.deepEqual(decideSiteIdentity([e], origin, false), { kind: "persona", entryId: "01A" }); +}); + +test("the recorded holder choice is honoured when no persona is bound", () => { + assert.deepEqual(decideSiteIdentity([], origin, true), { kind: "holder" }); +}); + +test("neither recorded means ask — never a silent holder login", () => { + // The whole point of the change: login() used to sign as the holder here, + // unconditionally and with no signal, while the wallet told the operator + // that each site gets its own identity. + assert.deepEqual(decideSiteIdentity([], origin, false), { kind: "ask" }); +}); + +test("another site's persona does not answer for this one", () => { + const other = entry("01A", [{ kind: "webOrigin", origin: "https://other.example" }]); + assert.deepEqual(decideSiteIdentity([other], origin, false), { kind: "ask" }); + assert.deepEqual(decideSiteIdentity([other], origin, true), { kind: "holder" }); +}); + +test("a look-alike origin's entry never satisfies the real one", () => { + const lookalike = entry("01A", [ + { kind: "webOrigin", origin: "https://shop.example.evil.test" }, + ]); + assert.deepEqual(decideSiteIdentity([lookalike], origin, false), { kind: "ask" }); +});