diff --git a/package-lock.json b/package-lock.json index bb83f1b..5fea300 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "packages/reviewer-demo" ], "dependencies": { - "@openvtc/trust-tasks": "^0.27.1" + "@openvtc/trust-tasks": "^0.27.2" }, "engines": { "node": ">=24" @@ -2301,9 +2301,9 @@ "link": true }, "node_modules/@openvtc/trust-tasks": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@openvtc/trust-tasks/-/trust-tasks-0.27.1.tgz", - "integrity": "sha512-7J30y9Om8HU1CrXfbd8zz7UdS4kEDCy5YnAdVmibAF1DFX+TDPFr3CAKqVeRq7NyDtfP79HoZeIAqdw8hy5EuQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@openvtc/trust-tasks/-/trust-tasks-0.27.2.tgz", + "integrity": "sha512-gL9XKiONXyv0za00dJR0nSiErXD6r8v38bO8d6ACHpatUVbx8dJOzKkpQxiTzi/eo5kxXXM7EhB7IGFDUI5kZg==", "license": "Apache-2.0" }, "node_modules/@openvtc/vti-didcomm-js": { @@ -7979,7 +7979,7 @@ "dependencies": { "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", - "@openvtc/trust-tasks": "^0.27.1", + "@openvtc/trust-tasks": "^0.27.2", "@openvtc/vti-didcomm-js": "^0.12.0", "@openvtc/vti-tsp-js": "^0.3.0", "@scure/base": "^2.2.0", diff --git a/package.json b/package.json index 73b398c..8bdc0f2 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,6 @@ "@swc/wasm": "~1.15.47" }, "dependencies": { - "@openvtc/trust-tasks": "^0.27.1" + "@openvtc/trust-tasks": "^0.27.2" } } diff --git a/packages/core/package.json b/packages/core/package.json index e1441b7..0dada1c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -142,7 +142,7 @@ "dependencies": { "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", - "@openvtc/trust-tasks": "^0.27.1", + "@openvtc/trust-tasks": "^0.27.2", "@openvtc/vti-didcomm-js": "^0.12.0", "@openvtc/vti-tsp-js": "^0.3.0", "@scure/base": "^2.2.0", diff --git a/packages/core/src/vault/credentials.ts b/packages/core/src/vault/credentials.ts index 4953b9a..b07da8b 100644 --- a/packages/core/src/vault/credentials.ts +++ b/packages/core/src/vault/credentials.ts @@ -47,6 +47,12 @@ import { RESPONSE_TYPE_URI as CRED_GET_RESPONSE, type VaultCredentialsGetResponsePayload, } from "@openvtc/trust-tasks/vault/credentials/get/0.1/payload"; +import { + TYPE_URI as CRED_PRESENT, + RESPONSE_TYPE_URI as CRED_PRESENT_RESPONSE, + type Payload as VaultCredentialsPresentPayload, + type VaultCredentialsPresentResponsePayload, +} from "@openvtc/trust-tasks/vault/credentials/present/0.1/payload"; import { TYPE_URI as CRED_RECEIVE, RESPONSE_TYPE_URI as CRED_RECEIVE_RESPONSE, @@ -198,6 +204,57 @@ export async function credVaultGet( return res.credential as Record; } +export interface CredVaultPresentParams extends CredVaultCallerParams { + /** Handle from a {@link CredentialDescriptor}. */ + id: string; + /** The verifier the presentation is for — its DID, for a community. */ + audience: string; + /** The verifier's nonce. For a task that carries the presentation in its + * own request, that request document's `id`. Never reused. */ + nonce: string; + /** The subject claims to disclose, by name. */ + claims: string[]; + /** Why, in the holder's words; kept with the agent's record of it. */ + purpose?: string; +} + +/** Error code: every presentation of the credential discloses claims the + * request does not name; `details.undisclosed` lists them. */ +export const CRED_PRESENT_WOULD_OVER_DISCLOSE = "vault/credentials/present:wouldOverDisclose"; + +/** + * Have the agent present one stored credential to a verifier the caller + * names, bound to that verifier and its nonce, disclosing only `claims`. + * + * The agent signs as the credential's subject, so this is a disclosure to a + * third party under the subject's signature: the agent records it before it + * answers. The caller carries `presentation` to the verifier unchanged. + * + * A credential that cannot hide a claim `claims` leaves out is refused with + * {@link CRED_PRESENT_WOULD_OVER_DISCLOSE} rather than presented, so asking + * the person about the rest is the caller's to do. + */ +export async function credVaultPresent( + sender: TrustTaskSender, + params: CredVaultPresentParams, +): Promise { + const payload = { + id: params.id, + audience: params.audience, + nonce: params.nonce, + claims: params.claims, + ...(params.purpose ? { purpose: params.purpose } : {}), + } as VaultCredentialsPresentPayload; + const envelope = buildTrustTask(CRED_PRESENT, payload, { + issuer: params.holder.did, + recipient: params.service.did, + }); + return sender.send(envelope, { + expectedResponseType: CRED_PRESENT_RESPONSE, + operationLabel: "vault/credentials/present/0.1", + }); +} + export interface CredVaultReceiveParams extends CredVaultCallerParams { /** The verifiable credential. */ credential: Record; diff --git a/packages/core/src/vtc/directory-present.ts b/packages/core/src/vtc/directory-present.ts new file mode 100644 index 0000000..b660315 --- /dev/null +++ b/packages/core/src/vtc/directory-present.ts @@ -0,0 +1,122 @@ +// Verified directory fields (`vtc/directory/card/present/0.1`): a member fills +// `credential`-sourced fields on their own card — a verified employer, a +// professional registration — from a credential their agent holds. The +// community verifies the presentation, keeps only the claims the fields read, +// badged with their issuer, and discards the presentation. +// +// The presentation is the agent's to make (`vault/credentials/present/0.1`): +// it holds the credential and its subject's key. The wallet reads which +// credential the fields accept from the community's schema, asks the agent for +// a presentation bound to this community and to the `card/present` document +// that will carry it, and sends that document as the member. So the document's +// `id` exists before the presentation does — it is the presentation's nonce — +// and {@link buildCardPresent} takes it rather than minting one. + +import type { ClaimSelector, FieldDefinition } from "@openvtc/trust-tasks/vtc/directory/fields/list/0.1/payload"; +import { + TYPE_URI as CARD_PRESENT, + type Payload as CardPresentPayload, + type VerifiedValue, +} from "@openvtc/trust-tasks/vtc/directory/card/present/0.1/payload"; +import { OobReplyInvalidError, type OobDocument } from "./oob-sign-in.js"; + +export type { ClaimSelector, VerifiedValue }; +export { CARD_PRESENT }; + +/** What the named fields ask of one credential. */ +export interface CredentialAsk { + /** The type every named field's selector names. */ + credentialType: string; + /** Issuers every named field accepts; absent when none of them lists any. + * Where several do, only issuers all of them accept. */ + issuers?: string[]; + /** The subject claims to disclose, by name, in field order. */ + claims: string[]; + /** The named fields, with the community's labels. */ + fields: Array<{ key: string; label: string }>; +} + +/** + * The name of the subject claim a `claimPath` reads: its first segment, as RFC + * 6901 unescapes it. A presentation discloses whole claims, so a path into a + * claim (`/address/country`) discloses the claim it starts in. + */ +export function claimNameOf(claimPath: string): string | null { + if (!claimPath.startsWith("/")) return null; + const first = claimPath.slice(1).split("/")[0] ?? ""; + const name = first.replace(/~1/g, "/").replace(/~0/g, "~"); + return name.length > 0 ? name : null; +} + +/** + * What a presentation for `named` must carry, from the community's schema — + * or why there can be none, in the person's words. + * + * Every named field must be active, `credential`-sourced and a `claim` field + * with a selector, and they must all read one credential type: one + * presentation answers them all. + */ +export function credentialAskFor( + schema: readonly FieldDefinition[], + named: readonly string[], +): { ok: true; ask: CredentialAsk } | { ok: false; error: string } { + const fields: CredentialAsk["fields"] = []; + const selectors: ClaimSelector[] = []; + for (const key of named) { + const f = schema.find((d) => d.key === key && d.state === "active"); + if (!f) return { ok: false, error: `The community's directory has no field \`${key}\`.` }; + const selector = (f.constraints as { claim?: ClaimSelector } | undefined)?.claim; + if (f.source !== "credential" || f.type !== "claim" || !selector) { + return { ok: false, error: `${f.label} isn't a field a credential fills.` }; + } + fields.push({ key: f.key, label: f.label }); + selectors.push(selector); + } + const credentialType = selectors[0]!.credentialType; + if (selectors.some((s) => s.credentialType !== credentialType)) { + return { ok: false, error: "Those fields are filled from different credentials: verify them one credential at a time." }; + } + const claims: string[] = []; + for (const s of selectors) { + const name = claimNameOf(s.claimPath); + if (!name) return { ok: false, error: `The community's directory names a claim the wallet can't read (${s.claimPath}).` }; + if (!claims.includes(name)) claims.push(name); + } + let issuers: string[] | undefined; + for (const s of selectors) { + if (!s.issuers) continue; + issuers = issuers ? issuers.filter((i) => s.issuers!.includes(i)) : [...s.issuers]; + } + if (issuers && issuers.length === 0) { + return { ok: false, error: "No issuer is accepted for all of those fields: verify them one at a time." }; + } + return { ok: true, ask: { credentialType, ...(issuers ? { issuers } : {}), claims, fields } }; +} + +/** `vtc/directory/card/present`, unsigned, as the member. `id` is the nonce the + * presentation is bound to. */ +export function buildCardPresent( + id: string, + memberDid: string, + vtcDid: string, + fields: readonly string[], + presentation: Record | string, +): OobDocument { + return { + id, + type: CARD_PRESENT, + issuer: memberDid, + recipient: vtcDid, + issuedAt: new Date().toISOString(), + payload: { fields: [...fields], presentation } as CardPresentPayload, + }; +} + +/** The values now on the card, from `card/present`'s answer. */ +export function verifiedValuesOf(res: unknown): { verified: Record; revision: number } { + const r = res as { verified?: unknown; revision?: unknown } | undefined; + if (!r || typeof r.verified !== "object" || r.verified === null || typeof r.revision !== "number") { + throw new OobReplyInvalidError("card/present: no verified values"); + } + return { verified: r.verified as Record, revision: r.revision }; +} diff --git a/packages/core/src/vtc/index.ts b/packages/core/src/vtc/index.ts index 098819c..0911701 100644 --- a/packages/core/src/vtc/index.ts +++ b/packages/core/src/vtc/index.ts @@ -11,4 +11,5 @@ export * from "./domain-linkage.js"; export * from "./join.js"; export * from "./directory-disclosure.js"; export * from "./directory-card.js"; +export * from "./directory-present.js"; export * from "./forge-account.js"; diff --git a/packages/core/task-surface.json b/packages/core/task-surface.json index e25c13a..97fdc42 100644 --- a/packages/core/task-surface.json +++ b/packages/core/task-surface.json @@ -1318,6 +1318,12 @@ "TASK_VAULT_CREDENTIALS_GET_0_1" ] }, + { + "uri": "https://trusttasks.org/spec/vault/credentials/present/0.1", + "consts": [ + "TASK_VAULT_CREDENTIALS_PRESENT_0_1" + ] + }, { "uri": "https://trusttasks.org/spec/vault/credentials/purge/0.1", "consts": [ diff --git a/packages/core/tests/task-surface.mjs b/packages/core/tests/task-surface.mjs index 709808f..bcdd699 100644 --- a/packages/core/tests/task-surface.mjs +++ b/packages/core/tests/task-surface.mjs @@ -545,7 +545,10 @@ test("coverage against the agent's surface is recorded, not discovered", () => { // external directory card as their DID (vtc/directory-card.ts). // 227 → 230: `persona/contact/lists/{put,list,delete}/1.0`, the member's // own lists, behind the portal's marks (`persona/annotations.ts`). - const expected = 230; + // 230 → 231: `vault/credentials/present/0.1`, the member's agent presenting a + // credential to the community to verify directory fields + // (vtc/directory-present.ts). + const expected = 231; assert.equal( implemented.size, expected, diff --git a/packages/core/tests/vtc.directory-present.mjs b/packages/core/tests/vtc.directory-present.mjs new file mode 100644 index 0000000..2af2295 --- /dev/null +++ b/packages/core/tests/vtc.directory-present.mjs @@ -0,0 +1,100 @@ +// Verified directory fields (`vtc/directory/card/present/0.1`), as a wallet +// reads what they ask of a credential before its agent presents one. +// +// Pinned: only active `credential`-sourced claim fields are answered, and all of +// one credential type; the claims disclosed are the claims the paths start in, +// RFC 6901-unescaped; issuers narrow to those every field accepts; and the +// document carries the id the presentation was bound to, not a fresh one. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { buildCardPresent, claimNameOf, credentialAskFor, verifiedValuesOf, CARD_PRESENT } from "../dist/vtc/index.js"; + +const claimField = (key, label, claim, extra = {}) => ({ + key, + label, + type: "claim", + source: "credential", + base: false, + required: false, + ceiling: "community", + defaultVisibility: "members", + searchable: false, + facet: false, + order: 1, + state: "active", + constraints: { claim }, + ...extra, +}); + +const EMPLOYER = claimField("org.example.employer", "Employer", { + credentialType: "EmploymentCredential", + claimPath: "/employer", + issuers: ["did:example:hr", "did:example:payroll"], +}); +const TITLE = claimField("org.example.title", "Job title", { + credentialType: "EmploymentCredential", + claimPath: "/role/title", + issuers: ["did:example:hr"], +}); +const REGISTRATION = claimField("org.example.registration", "Registration", { + credentialType: "ProfessionalRegistration", + claimPath: "/number", +}); + +test("claimNameOf reads the first segment, unescaped", () => { + assert.equal(claimNameOf("/employer"), "employer"); + assert.equal(claimNameOf("/role/title"), "role"); + assert.equal(claimNameOf("/a~1b/c"), "a/b"); + assert.equal(claimNameOf("/a~0b"), "a~b"); + assert.equal(claimNameOf("employer"), null); + assert.equal(claimNameOf("/"), null); +}); + +test("fields of one type ask for one credential, issuers narrowed to the common ones", () => { + const got = credentialAskFor([EMPLOYER, TITLE, REGISTRATION], ["org.example.employer", "org.example.title"]); + assert.ok(got.ok); + assert.deepEqual(got.ask, { + credentialType: "EmploymentCredential", + issuers: ["did:example:hr"], + claims: ["employer", "role"], + fields: [ + { key: "org.example.employer", label: "Employer" }, + { key: "org.example.title", label: "Job title" }, + ], + }); +}); + +test("a field without issuers leaves the community's policy to decide", () => { + const got = credentialAskFor([REGISTRATION], ["org.example.registration"]); + assert.ok(got.ok); + assert.equal(got.ask.issuers, undefined); + assert.deepEqual(got.ask.claims, ["number"]); +}); + +test("refused: two credential types, an unknown field, a member-sourced field, a retired one", () => { + assert.equal(credentialAskFor([EMPLOYER, REGISTRATION], ["org.example.employer", "org.example.registration"]).ok, false); + assert.equal(credentialAskFor([EMPLOYER], ["org.example.nope"]).ok, false); + const memberField = { ...EMPLOYER, source: "member" }; + assert.match(credentialAskFor([memberField], ["org.example.employer"]).error, /isn't a field a credential fills/); + assert.equal(credentialAskFor([{ ...EMPLOYER, state: "retired" }], ["org.example.employer"]).ok, false); +}); + +test("the document carries the presentation's nonce as its id", () => { + const doc = buildCardPresent("urn:uuid:nonce-1", "did:example:carol", "did:example:community", ["org.example.employer"], "sd~jwt~kb"); + assert.equal(doc.id, "urn:uuid:nonce-1"); + assert.equal(doc.type, CARD_PRESENT); + assert.equal(doc.issuer, "did:example:carol"); + assert.equal(doc.recipient, "did:example:community"); + assert.deepEqual(doc.payload, { fields: ["org.example.employer"], presentation: "sd~jwt~kb" }); +}); + +test("verifiedValuesOf refuses an answer without values", () => { + assert.throws(() => verifiedValuesOf({ revision: 1 })); + const v = { value: "Acme", issuer: "did:example:hr", credentialType: "EmploymentCredential", verifiedAt: "2026-10-22T09:00:00Z" }; + assert.deepEqual(verifiedValuesOf({ verified: { "org.example.employer": v }, revision: 3 }), { + verified: { "org.example.employer": v }, + revision: 3, + }); +}); diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 2096179..ed3deeb 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -53,9 +53,8 @@ import { DIRECTORY_CONSENT_PREFIX, DirectoryPageGates, MARKS_GRANT_PREFIX, - type DirectoryMarksConsentView, + type DirectoryConsentView, type MarksGrant, - type DirectoryPublishConsentView, } from "./directory-page.js"; import { GIT_CONSENT_PREFIX, GitPageGates, type GitConsentView } from "./git-page.js"; import { RoomOffers, RoomOffersRefusal } from "./room-offers-background.js"; @@ -970,25 +969,27 @@ const roomPageGates = new RoomPageGates({ // one of the wallet's identities there, and the person approves every value in // the wallet's own window — are `directory-page.ts`; this wires them up. -/** Raise the publish — or marks-access — prompt and wait. Closed or unopened - * is a denial. */ +/** Raise a directory prompt — publish, marks access, a credential to present — + * and wait. Closed or unopened is a denial. */ async function requestDirectoryConsent( - view: DirectoryPublishConsentView | DirectoryMarksConsentView, -): Promise<{ approved: boolean }> { + view: DirectoryConsentView, +): Promise<{ approved: boolean; selectedCredential?: string }> { const consentId = crypto.randomUUID(); await chrome.storage.session.set({ [`${DIRECTORY_CONSENT_PREFIX}${consentId}`]: view }); const url = `${chrome.runtime.getURL("confirm.html")}?cid=${consentId}&kind=directory`; const bounds = await consentWindowBounds(620); return new Promise((resolve) => { let settled = false; - const settle = (approved: boolean) => { + const settle = (approved: boolean, selectedCredential?: string) => { if (settled) return; settled = true; pendingConsents.delete(consentId); void chrome.storage.session.remove(`${DIRECTORY_CONSENT_PREFIX}${consentId}`); - resolve({ approved }); + resolve(approved ? { approved: true, ...(selectedCredential ? { selectedCredential } : {}) } : { approved: false }); }; - pendingConsents.set(consentId, (approved) => settle(approved)); + pendingConsents.set(consentId, (approved, _remember, _prf, _did, _rooms, selectedCredential) => + settle(approved, selectedCredential), + ); openConsentWindow({ url, bounds, deny: () => settle(false), tag: "[pnm directory]", what: "directory window" }); }); } diff --git a/packages/extension/src/bridge-protocol.ts b/packages/extension/src/bridge-protocol.ts index c1ae00e..70e0910 100644 --- a/packages/extension/src/bridge-protocol.ts +++ b/packages/extension/src/bridge-protocol.ts @@ -636,6 +636,9 @@ export interface RuntimeConsentResult { * page's origin (`data-rooms-files.md` §8.5), each `heldRoomKey` — the room * and the member it is shared as. Ignored on a denial. */ selectedRooms?: string[]; + /** Directory credential prompt only: the credential the member picked to + * present, one of those the prompt offered. Ignored on a denial. */ + selectedCredential?: string; } /** confirm popup → background: resolve + verify an RP DID. */ @@ -2635,7 +2638,12 @@ export type DirectoryPageOp = lists?: string[]; } | { op: "putList"; hostDid: string; member?: string; listId?: string; name?: string; colour?: string } - | { op: "deleteList"; hostDid: string; member?: string; listId: string }; + | { op: "deleteList"; hostDid: string; member?: string; listId: string } + /** Fill `credential`-sourced fields from a credential the member's agent + * holds (`vtc/directory/card/present/0.1`): the wallet finds the + * credentials the fields accept, the person picks one and sees every claim + * it discloses, and the agent presents it. */ + | { op: "presentCredential"; hostDid: string; member?: string; fields: string[] }; /** The member's marks on one directory entry. */ export interface PageEntryMark { @@ -2673,8 +2681,34 @@ export interface PagePublishedCardView { publishedAt?: string; } +/** One value a presentation put on the member's card. */ +export interface PageVerifiedValue { + value: unknown; + /** The credential's issuer: the value's badge. */ + issuer: string; + credentialType: string; + verifiedAt: string; + /** When the community withdraws it: the credential's expiry. */ + validUntil?: string; +} + +/** The verified values now on the member's card, by field. */ +export interface PageVerifiedFieldsView { + verified: Record; + revision: number; +} + export type RuntimeDirectoryPageResponse = - | { ok: true; result: PagePublishedCardView | PageMarksView | PageEntryMark | PageMarkList | { deleted: boolean } } + | { + ok: true; + result: + | PagePublishedCardView + | PageMarksView + | PageEntryMark + | PageMarkList + | { deleted: boolean } + | PageVerifiedFieldsView; + } | { ok: false; error: string }; // ─── A member's forge account for a page: `window.vtaWallet.git` ──────────── @@ -3257,7 +3291,31 @@ export type OffscreenDirectoryPageOp = lists?: string[]; } | { op: "putList"; hostDid: string; member: string; listId?: string; name?: string; colour?: string } - | { op: "deleteList"; hostDid: string; member: string; listId: string }; + | { op: "deleteList"; hostDid: string; member: string; listId: string } + /** Read the schema as the member: what the named fields ask of a + * credential, and the member's credentials that answer it. */ + | { op: "preparePresent"; hostDid: string; member: string; fields: string[] } + /** Have the agent present `credentialId` disclosing `claims`, and send it as + * the member. Answers `{ undisclosed }`, presenting nothing, when the + * credential cannot hide a claim `claims` leaves out. */ + | { op: "present"; hostDid: string; member: string; fields: string[]; credentialId: string; claims: string[] }; + +/** A credential the member's agent holds that the named fields accept. */ +export interface PresentCandidate { + /** The agent's handle for it. */ + id: string; + issuerDid?: string; + types: string[]; + validUntil?: string; +} + +/** What `preparePresent` found. */ +export interface PreparedPresent { + communityName: string; + fields: Array<{ key: string; label: string }>; + claims: string[]; + candidates: PresentCandidate[]; +} export interface OffscreenDirectoryPageRequest { target: typeof OFFSCREEN_TARGET; diff --git a/packages/extension/src/confirm.tsx b/packages/extension/src/confirm.tsx index 28647be..5148b7d 100644 --- a/packages/extension/src/confirm.tsx +++ b/packages/extension/src/confirm.tsx @@ -1458,7 +1458,16 @@ if (root) { ) : isGitConsent ? ( void sendConsentResult({ approved: ok, remember: false })} /> ) : isDirectoryConsent ? ( - void sendConsentResult({ approved: ok, remember: false })} /> + + void sendConsentResult({ + approved: ok, + remember: false, + ...(ok && credential ? { selectedCredential: credential } : {}), + }) + } + /> ) : isRoomConsent ? ( void; /** The subset of `chrome.windows` this file drives — narrow so a test can @@ -160,6 +161,7 @@ export function deliverConsentResult( prfOutputB64u?: string; selectedDid?: string; selectedRooms?: string[]; + selectedCredential?: string; }, ): boolean { const decision = pending.get(result.consentId); @@ -167,6 +169,7 @@ export function deliverConsentResult( const rooms = Array.isArray(result.selectedRooms) ? result.selectedRooms.filter((r): r is string => typeof r === "string") : undefined; - decision(result.approved, !!result.remember, result.prfOutputB64u, result.selectedDid, rooms); + const credential = typeof result.selectedCredential === "string" ? result.selectedCredential : undefined; + decision(result.approved, !!result.remember, result.prfOutputB64u, result.selectedDid, rooms, credential); return true; } diff --git a/packages/extension/src/directory-consent.tsx b/packages/extension/src/directory-consent.tsx index 503432a..9e49cf1 100644 --- a/packages/extension/src/directory-consent.tsx +++ b/packages/extension/src/directory-consent.tsx @@ -12,13 +12,21 @@ // and change the member's own marks on other members — stars, petnames, lists // (`directory-page.ts`). That answer is remembered until the member withdraws // it, because asking on every star would make the marks unusable. +// +// And it is where the member chooses a credential to verify directory fields +// with: which credential, the fields it fills and every claim it discloses to +// the community, and — when the credential cannot hide the rest — those claims +// too, before anything is presented. Nothing is remembered: every presentation +// is asked for afresh. import { useEffect, useState } from "react"; import { DIRECTORY_CONSENT_PREFIX, + type DirectoryConsentView, type DirectoryMarksConsentView, - type DirectoryPublishConsentView, + type DirectoryOverDiscloseConsentView, + type DirectoryPresentConsentView, } from "./directory-page.js"; import { Did } from "./ui.js"; @@ -51,18 +59,20 @@ export function DirectoryConsent({ decide, }: { consentId: string; - decide: (approved: boolean) => void; + decide: (approved: boolean, selectedCredential?: string) => void; }) { - const [view, setView] = useState(null); + const [view, setView] = useState(null); useEffect(() => { const key = `${DIRECTORY_CONSENT_PREFIX}${consentId}`; void chrome.storage.session.get(key).then((v: Record) => { - setView((v[key] as DirectoryPublishConsentView | DirectoryMarksConsentView) ?? null); + setView((v[key] as DirectoryConsentView) ?? null); }); }, [consentId]); if (!view) return
Loading request…
; if (view.kind === "marksAccess") return ; + if (view.kind === "presentCredential") return ; + if (view.kind === "presentOverDisclose") return ; return (
Publish your card from {view.communityName}? @@ -164,3 +174,138 @@ function MarksAccess({
); } + +const primary: React.CSSProperties = { + padding: "8px 18px", + fontSize: 13, + fontWeight: 700, + border: "none", + borderRadius: 8, + color: "var(--w-accent-ink)", + background: "var(--w-accent)", + cursor: "pointer", +}; + +function dateOnly(iso: string | undefined): string | null { + if (!iso) return null; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); +} + +/** Which credential to present, what it fills and what it discloses. */ +function PresentCredential({ + view, + decide, +}: { + view: DirectoryPresentConsentView; + decide: (approved: boolean, selectedCredential?: string) => void; +}) { + const [chosen, setChosen] = useState(view.candidates.length === 1 ? view.candidates[0]!.id : null); + return ( +
+ Verify your card in {view.communityName} with a credential? +
+

+ Your agent presents the credential you choose to {view.communityName}, signed as you, for this one + request. {view.communityName} keeps only what the fields read, shown with the credential’s + issuer, and not the credential. +

+

Asked for by {originHost(view.origin)}.

+
+
+
Fields it fills
+
+ {view.fields.map((f) => ( +
{f.label}
+ ))} +
+
+
+
What {view.communityName} is shown
+
+ {view.claims.map((c) => ( +
{c}
+ ))} +
+
+
+
Credential
+
+ {view.candidates.map((c) => ( + + ))} +
+
+
+
Signed as
+
+ +
+
+
+ + +
+
+ ); +} + +/** The chosen credential cannot hide claims the fields do not read. */ +function PresentOverDisclose({ + view, + decide, +}: { + view: DirectoryOverDiscloseConsentView; + decide: (approved: boolean) => void; +}) { + return ( +
+ This credential shows more than the fields ask for +
+

+ This credential cannot hide what it says beyond the fields, so presenting it also shows these to{" "} + {view.communityName}. {view.communityName} keeps only what the fields read. +

+

Asked for by {originHost(view.origin)}.

+
+
+
Also shown
+
+ {view.undisclosed.map((c) => ( +
{c}
+ ))} +
+
+
+ + +
+
+ ); +} diff --git a/packages/extension/src/directory-page-offscreen.ts b/packages/extension/src/directory-page-offscreen.ts index 51d5fcf..2866c5c 100644 --- a/packages/extension/src/directory-page-offscreen.ts +++ b/packages/extension/src/directory-page-offscreen.ts @@ -1,7 +1,7 @@ /// -// The work behind a page's `window.vtaWallet.directory.publishCard` — the -// offscreen document's half. +// The work behind a page's `window.vtaWallet.directory.publishCard` and +// `presentCredential` — the offscreen document's half. // // The background has already checked that the page is the community's portal // and which of the member's identities it is for (`directory-page.ts`). This @@ -10,6 +10,12 @@ // approved it in the wallet's window — has their agent sign it as their DID // (`keys/sign-document`) and sends it to the community (`card/publish`). // +// For `presentCredential` it reads what the named fields ask of a credential +// (`fields/list`, as the member) and the member's credentials that answer it +// (`vault/credentials/query`), and — once the person chose one — has their agent +// present it to the community, bound to the `card/present` document that +// carries it (`vault/credentials/present`), and sends that document. +// // Every document to the community is the member's, signed by their agent as // the vault entry they sign in with; every reply is checked against the // community's verified DID document. Nothing the agent returns is sent on @@ -17,7 +23,10 @@ import { CONTACT_METHODS, + CRED_PRESENT_WOULD_OVER_DISCLOSE, annotateContact, + credVaultPresent, + credVaultQuery, deleteList, linkContact, listAllAnnotated, @@ -30,16 +39,19 @@ import { OobRefusedError, agentSignCard, assertSignedAsSent, + buildCardPresent, buildCardPublish, buildCardShow, buildExternalCard, buildFieldsList, + credentialAskFor, describeExternalCard, memberSigningKey, postCommunityTask, publishedStateOf, resolveCommunityDocument, selectSignInServices, + verifiedValuesOf, verifyDomainLinkage, type DirectoryCard, type FieldDefinition, @@ -52,6 +64,8 @@ import type { PageMarkList, PageMarksView, PagePublishedCardView, + PageVerifiedFieldsView, + PreparedPresent, } from "./bridge-protocol.js"; /** A `didSelfIssued` vault entry, as much of one as this needs. */ @@ -145,6 +159,47 @@ function refusalWords(e: unknown, community: string): Error { return new Error(said[local] ?? `${community} didn't publish your card (${e.code}).`); } +/** The trust-task-error code and details an agent or community refusal carries. */ +function refusalCode(e: unknown): { code: string; details?: Record } | null { + if (e instanceof OobRefusedError) return { code: e.code }; + const payload = (e as { details?: unknown } | null)?.details as + | { code?: unknown; details?: Record } + | undefined; + return typeof payload?.code === "string" + ? { code: payload.code, ...(payload.details ? { details: payload.details } : {}) } + : null; +} + +/** What the agent said about a presentation, in the person's words. */ +function presentRefusalWords(e: unknown): Error { + const code = refusalCode(e)?.code; + const said: Record = { + "vault/credentials/present:notFound": + "Your agent no longer holds that credential, or not the key it is presented with. Nothing was presented.", + "vault/credentials/present:notPresentable": + "That credential has expired, been revoked or been put away, so it can't be presented.", + "vault/credentials/present:claimNotHeld": "That credential doesn't carry what these fields ask for.", + "vault/credentials/present:formatUnsupported": "Your agent can't present a credential of that kind here.", + }; + if (code && said[code]) return new Error(said[code]); + return e instanceof Error ? e : new Error(String(e)); +} + +/** What the community said about a presentation, in the person's words. */ +function cardPresentRefusalWords(e: unknown, community: string): Error { + if (!(e instanceof OobRefusedError)) return e instanceof Error ? e : new Error(String(e)); + const local = e.code.slice(e.code.lastIndexOf(":") + 1); + const said: Record = { + notMember: `${community} doesn't count you as an active member.`, + fieldUnknown: `${community}'s directory no longer has one of those fields.`, + fieldNotWritable: `${community}'s directory doesn't fill one of those fields from a credential.`, + presentationInvalid: `${community} couldn't verify the credential as yours. Nothing was added to your card.`, + issuerNotAccepted: `${community} doesn't accept that credential's issuer for one of those fields.`, + claimMissing: `The credential doesn't show what one of those fields reads.`, + }; + return new Error(said[local] ?? `${community} didn't add the credential to your card (${e.code}).`); +} + /** Run one directory operation. Throws with a message fit for the page. */ export async function runDirectoryPageOp( deps: DirectoryPageDeps, @@ -190,6 +245,78 @@ export async function runDirectoryPageOp( return reply.doc.payload as P; }; + if (op.op === "preparePresent") { + let fields: FieldDefinition[]; + try { + ({ fields } = await asMember<{ fields: FieldDefinition[] }>( + buildFieldsList(op.member, op.hostDid), + "vtc/directory/fields/list", + )); + } catch (e) { + throw refusalWords(e, name); + } + const asked = credentialAskFor(fields, op.fields); + if (!asked.ok) throw new Error(asked.error); + const { ask } = asked; + const held = await credVaultQuery(sender, { holder, service, type: ask.credentialType, status: "valid" }); + const candidates = held + .filter((c) => !c.lifecycle || c.lifecycle === "active") + .filter((c) => !ask.issuers || (c.issuerDid !== undefined && ask.issuers.includes(c.issuerDid))) + .map((c) => ({ + id: c.id, + ...(c.issuerDid ? { issuerDid: c.issuerDid } : {}), + types: [...c.types], + ...(c.validUntil ? { validUntil: c.validUntil } : {}), + })); + if (candidates.length === 0) { + throw new Error( + ask.issuers + ? `Your wallet holds no ${ask.credentialType} from an issuer ${name} accepts for ${ask.fields.map((f) => f.label).join(", ")}.` + : `Your wallet holds no ${ask.credentialType} to verify ${ask.fields.map((f) => f.label).join(", ")} with.`, + ); + } + const prepared: PreparedPresent = { communityName: name, fields: ask.fields, claims: ask.claims, candidates }; + return prepared; + } + + if (op.op === "present") { + // The document's id is the presentation's nonce, so it exists first; a + // retry after a refusal is a fresh document and a fresh nonce. + const id = globalThis.crypto.randomUUID(); + let presentation: Record | string; + try { + const res = await credVaultPresent(sender, { + holder, + service, + id: op.credentialId, + audience: op.hostDid, + nonce: id, + claims: op.claims, + purpose: `Verified fields on my card in ${name}'s member directory`, + }); + presentation = res.presentation as Record | string; + } catch (e) { + const refused = refusalCode(e); + if (refused?.code === CRED_PRESENT_WOULD_OVER_DISCLOSE) { + const undisclosed = refused.details?.undisclosed; + if (Array.isArray(undisclosed) && undisclosed.every((c) => typeof c === "string") && undisclosed.length > 0) { + return { undisclosed: undisclosed as string[] }; + } + } + throw presentRefusalWords(e); + } + try { + const answer = await asMember( + buildCardPresent(id, op.member, op.hostDid, op.fields, presentation), + "vtc/directory/card/present", + ); + const view: PageVerifiedFieldsView = verifiedValuesOf(answer) as PageVerifiedFieldsView; + return view; + } catch (e) { + throw cardPresentRefusalWords(e, name); + } + } + if (op.op === "prepare") { try { const { card } = await asMember<{ card: DirectoryCard }>( diff --git a/packages/extension/src/directory-page.ts b/packages/extension/src/directory-page.ts index 5127872..8a98f30 100644 --- a/packages/extension/src/directory-page.ts +++ b/packages/extension/src/directory-page.ts @@ -28,12 +28,24 @@ // community and that portal origin until they withdraw it in the wallet. Only // contacts linked to that community's directory are ever handed over. // +// `presentCredential` fills the member's `credential`-sourced fields — a +// verified employer, a registration — from a credential their agent holds +// (`vtc/directory/card/present/0.1`). Gates 1 – 3 are the same; then the wallet +// reads what the fields ask of a credential and finds the member's credentials +// that answer it, and gate 4 is the person choosing one, seeing the community, +// the fields and every claim it will disclose. A credential that cannot hide a +// claim the fields do not read is asked about again, naming those claims, +// before anything is presented: a presentation discloses to the community +// whatever it carries, and the agent records every one it makes. +// // Written against injected dependencies, not `chrome.*`, so the gates are // tested as they run. import type { DirectoryPageOp, PageMarksView, + PageVerifiedFieldsView, + PreparedPresent, OffscreenDirectoryPageOp, OffscreenDirectoryPageResponse, PagePublishedCardView, @@ -64,6 +76,41 @@ export interface DirectoryMarksConsentView { communityName: string; } +/** What the person is shown before a credential is presented to fill fields. */ +export interface DirectoryPresentConsentView { + kind: "presentCredential"; + origin: string; + hostDid: string; + communityName: string; + /** The DID the presentation is made for, and signed as. */ + member: string; + /** The fields it fills, with the community's labels. */ + fields: Array<{ key: string; label: string }>; + /** Every claim it discloses. */ + claims: string[]; + /** The credentials the person may choose among; one is chosen. */ + candidates: Array<{ id: string; issuerDid?: string; types: string[]; validUntil?: string }>; +} + +/** What the person is shown when the chosen credential cannot hide claims the + * fields do not read. */ +export interface DirectoryOverDiscloseConsentView { + kind: "presentOverDisclose"; + origin: string; + hostDid: string; + communityName: string; + /** The claims it would disclose beyond those the fields read. */ + undisclosed: string[]; + claims: string[]; +} + +/** Every directory prompt. */ +export type DirectoryConsentView = + | DirectoryPublishConsentView + | DirectoryMarksConsentView + | DirectoryPresentConsentView + | DirectoryOverDiscloseConsentView; + /** What the person is shown before their card is published. */ export interface DirectoryPublishConsentView { kind: "publishCard"; @@ -92,8 +139,9 @@ export interface DirectoryPageDeps { offscreen(vta: ActiveVta, op: OffscreenDirectoryPageOp): Promise; /** Whether the wallet holds a host permission for `url`'s origin. */ canReach(url: string): Promise; - /** Raise the prompt and wait. A closed window is a denial. */ - prompt(view: DirectoryPublishConsentView | DirectoryMarksConsentView): Promise<{ approved: boolean }>; + /** Raise the prompt and wait. A closed window is a denial. A credential + * prompt answers with the credential chosen. */ + prompt(view: DirectoryConsentView): Promise<{ approved: boolean; selectedCredential?: string }>; /** The member's leave for `hostDid`'s portal to show their marks, if any. */ marksGrant(hostDid: string): Promise; saveMarksGrant(grant: MarksGrant): Promise; @@ -102,6 +150,9 @@ export interface DirectoryPageDeps { const MAX_DID = 2048; +/** A field key, as `vtc/directory/_shared`'s `FieldKey`. */ +const FIELD_KEY = /^([a-z][a-z0-9-]*\.)*[a-z][a-zA-Z0-9]{0,63}$/; + function isDid(v: unknown): v is string { return typeof v === "string" && v.startsWith("did:") && v.length <= MAX_DID; } @@ -117,6 +168,7 @@ const MEMBERS: Record = { mark: ["op", "hostDid", "member", "entryId", "starred", "petname", "removePetname", "lists"], putList: ["op", "hostDid", "member", "listId", "name", "colour"], deleteList: ["op", "hostDid", "member", "listId"], + presentCredential: ["op", "hostDid", "member", "fields"], }; /** Why `raw` is not a call this channel serves, or `null`. Members other than @@ -164,6 +216,17 @@ export function directoryPageParamsRefusal(raw: unknown): string | null { case "deleteList": if (typeof p.listId !== "string" || !ID.test(p.listId)) return "listId must be a list id"; return null; + case "presentCredential": + if ( + !Array.isArray(p.fields) || + p.fields.length < 1 || + p.fields.length > 16 || + !p.fields.every((f) => typeof f === "string" && f.length <= 128 && FIELD_KEY.test(f)) || + new Set(p.fields).size !== p.fields.length + ) { + return "fields must be 1 to 16 distinct directory field keys"; + } + return null; default: return null; } @@ -210,6 +273,7 @@ export class DirectoryPageGates { if (malformed) return { ok: false, error: malformed }; const p = raw as DirectoryPageOp; if (p.op === "publishCard") return { ok: true, result: await this.publishCard(origin, p) }; + if (p.op === "presentCredential") return { ok: true, result: await this.presentCredential(origin, p) }; return { ok: true, result: (await this.marks(origin, p)) as PageMarksView }; } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) }; @@ -259,7 +323,10 @@ export class DirectoryPageGates { /** The member's own marks (module docs): gates 1 – 3, then the community's * leave, asked for once. */ - private async marks(origin: string, p: Exclude): Promise { + private async marks( + origin: string, + p: Exclude, + ): Promise { const { vta, member, portalOrigin } = await this.admit(origin, p); const grant = await this.deps.marksGrant(p.hostDid); if (!grant || grant.portalOrigin !== portalOrigin) { @@ -337,4 +404,62 @@ export class DirectoryPageGates { revision: prepared.revision, })) as PagePublishedCardView; } + /** Fill credential-sourced fields (module docs): gates 1 – 3, then the + * person's choice of credential, and again for anything it cannot hide. */ + private async presentCredential( + origin: string, + p: Extract, + ): Promise { + const { vta, member } = await this.admit(origin, p); + const prepared = (await this.offscreen(vta, { + op: "preparePresent", + hostDid: p.hostDid, + member, + fields: [...p.fields], + })) as PreparedPresent; + + // Gate 4: the person picks one of the credentials offered, and sees every + // claim it discloses. An answer naming any other is a refusal. + const decided = await this.deps.prompt({ + kind: "presentCredential", + origin, + hostDid: p.hostDid, + communityName: prepared.communityName, + member, + fields: prepared.fields, + claims: prepared.claims, + candidates: prepared.candidates, + }); + const chosen = prepared.candidates.find((c) => c.id === decided.selectedCredential); + if (!decided.approved || !chosen) throw new DirectoryPageRefusal("You did not present a credential."); + + const present = (claims: string[]) => + this.offscreen(vta, { + op: "present", + hostDid: p.hostDid, + member, + fields: [...p.fields], + credentialId: chosen.id, + claims, + }) as Promise; + let answer = await present(prepared.claims); + if ("undisclosed" in answer) { + // The credential cannot hide these: presenting it shows them too. + const undisclosed = answer.undisclosed; + const again = await this.deps.prompt({ + kind: "presentOverDisclose", + origin, + hostDid: p.hostDid, + communityName: prepared.communityName, + undisclosed, + claims: prepared.claims, + }); + if (!again.approved) throw new DirectoryPageRefusal("You did not present the credential."); + answer = await present([...prepared.claims, ...undisclosed.filter((c) => !prepared.claims.includes(c))]); + if ("undisclosed" in answer) { + throw new DirectoryPageRefusal("The credential still discloses more than you agreed to. Nothing was presented."); + } + } + return answer; + } } diff --git a/packages/extension/src/page-task-policy.ts b/packages/extension/src/page-task-policy.ts index 830a942..ab54c62 100644 --- a/packages/extension/src/page-task-policy.ts +++ b/packages/extension/src/page-task-policy.ts @@ -124,6 +124,18 @@ const REFUSED: { prefix: string; why: string }[] = [ "member every value and that anyone will be able to read it before anything " + "is signed.", }, + // A presentation of one of the holder's credentials, signed as its subject, + // to a verifier the caller names (`directory-page.ts`). A generic task prompt + // cannot show what it discloses or to whom. + { + prefix: "https://trusttasks.org/spec/vault/credentials/present/", + why: + "vault/credentials/present has the agent sign a presentation of one of the " + + "holder's credentials, as its subject, for a verifier the caller names. A page " + + "cannot ask for one: to verify directory fields with a credential, use " + + "window.vtaWallet.directory.presentCredential, which shows the member the " + + "community and every claim it discloses first.", + }, // A Data Integrity proof made as one of the holder's DIDs over a document the // caller chose: a credential, a published card. A generic task prompt cannot // show what the holder would be asserting. diff --git a/packages/extension/src/provider.ts b/packages/extension/src/provider.ts index 4f7e194..a5e3649 100644 --- a/packages/extension/src/provider.ts +++ b/packages/extension/src/provider.ts @@ -35,6 +35,7 @@ import type { PageEntryMark, PageMarkList, PageMarksView, + PageVerifiedFieldsView, GitPageOp, PageGitLinkView, PageGitUnlinkView, @@ -249,6 +250,12 @@ export interface VtaWalletDirectory { putList(p: { hostDid: string; member?: string; listId?: string; name?: string; colour?: string }): Promise; /** Delete one of the member's lists; contacts on it stay. */ deleteList(p: { hostDid: string; member?: string; listId: string }): Promise<{ deleted: boolean }>; + /** Fill the member's `credential`-sourced fields from a credential their + * agent holds. The wallet finds the credentials the fields accept, and the + * member picks one and sees every claim it discloses to the community; a + * refusal rejects. All the fields must read one credential type. Resolves + * with the verified values now on the card. */ + presentCredential(p: { hostDid: string; member?: string; fields: string[] }): Promise; } /** Progress of a file transfer, for a progress bar. */ @@ -639,6 +646,13 @@ if (!window.vtaWallet) { listId: p.listId, ...(p.member !== undefined ? { member: p.member } : {}), }), + presentCredential: (p) => + call("directory", { + op: "presentCredential", + hostDid: p.hostDid, + fields: Array.isArray(p.fields) ? [...p.fields] : p.fields, + ...(p.member !== undefined ? { member: p.member } : {}), + }), }, git: { linkAccount: (p) => diff --git a/packages/extension/tests/directory-page.test.mts b/packages/extension/tests/directory-page.test.mts index 3fadfe9..f2d6f28 100644 --- a/packages/extension/tests/directory-page.test.mts +++ b/packages/extension/tests/directory-page.test.mts @@ -5,7 +5,10 @@ // by its DID document, the member must be one of the wallet's own identities // there, and nothing is signed until the person approves every value in the // wallet's window. Then the offscreen half: whose card it reads, and that it -// refuses to sign when the agent holds no key for the member. +// refuses to sign when the agent holds no key for the member. And +// `presentCredential`: the person picks the credential from those offered and +// sees every claim, and is asked again before a credential that cannot hide the +// rest is presented. import test from "node:test"; import assert from "node:assert/strict"; @@ -13,8 +16,9 @@ import assert from "node:assert/strict"; import { DirectoryPageGates, directoryPageParamsRefusal, - type DirectoryMarksConsentView, + type DirectoryConsentView, type DirectoryPageDeps, + type DirectoryPresentConsentView, type DirectoryPublishConsentView, type MarksGrant, } from "../src/directory-page.ts"; @@ -30,9 +34,29 @@ const VTA = { vtaDid: "did:webvh:QmVta:vta.example" }; const CARD = { "@type": "Card", version: "1.0", uid: "urn:vtc-directory:dir_x", issuer: MEMBER, name: { full: "C" } }; -function harness(opts: { members?: string[]; approve?: boolean; reach?: boolean; grant?: MarksGrant } = {}) { +const FIELD = "org.example.employer"; +const CRED = "cred-7f3a91c2"; +const VERIFIED = { + verified: { + [FIELD]: { value: "Acme", issuer: "did:example:hr", credentialType: "EmploymentCredential", verifiedAt: "2026-10-22T09:00:00Z" }, + }, + revision: 5, +}; + +function harness( + opts: { + members?: string[]; + approve?: boolean; + reach?: boolean; + grant?: MarksGrant; + /** Per prompt, in order: what the person answers. */ + answers?: Array<{ approved: boolean; selectedCredential?: string }>; + /** Claims the credential cannot hide unless named. */ + undisclosed?: string[]; + } = {}, +) { const asked: OffscreenDirectoryPageOp[] = []; - const prompts: (DirectoryPublishConsentView | DirectoryMarksConsentView)[] = []; + const prompts: DirectoryConsentView[] = []; const grants = new Map(opts.grant ? [[opts.grant.hostDid, opts.grant]] : []); const deps: DirectoryPageDeps = { async active() { @@ -67,6 +91,20 @@ function harness(opts: { members?: string[]; approve?: boolean; reach?: boolean; return { ok: true, result: { listId: "L1", name: op.name ?? "", order: 0, contactCount: 0 } }; case "deleteList": return { ok: true, result: { deleted: true } }; + case "preparePresent": + return { + ok: true, + result: { + communityName: "Acme Guild", + fields: [{ key: FIELD, label: "Employer" }], + claims: ["employer"], + candidates: [{ id: CRED, issuerDid: "did:example:hr", types: ["VerifiableCredential", "EmploymentCredential"] }], + }, + }; + case "present": { + const hidden = (opts.undisclosed ?? []).filter((c) => !op.claims.includes(c)); + return { ok: true, result: hidden.length > 0 ? { undisclosed: hidden } : VERIFIED }; + } } }, async marksGrant(hostDid) { @@ -80,7 +118,8 @@ function harness(opts: { members?: string[]; approve?: boolean; reach?: boolean; }, async prompt(view) { prompts.push(view); - return { approved: opts.approve ?? true }; + const answer = opts.answers?.[prompts.length - 1]; + return answer ?? { approved: opts.approve ?? true }; }, now: () => 0, }; @@ -321,3 +360,124 @@ test("with no context for the member's identity, nothing is asked of the agent", ); assert.equal(a.sent.length, 0); }); + +// ─── Verifying fields with a credential ──────────────────────────────────── + +test("a presentCredential call names 1 to 16 distinct field keys and nothing else", () => { + assert.equal(directoryPageParamsRefusal({ op: "presentCredential", hostDid: HOST, fields: [FIELD] }), null); + assert.ok(directoryPageParamsRefusal({ op: "presentCredential", hostDid: HOST, fields: [] })); + assert.ok(directoryPageParamsRefusal({ op: "presentCredential", hostDid: HOST })); + assert.ok(directoryPageParamsRefusal({ op: "presentCredential", hostDid: HOST, fields: [FIELD, FIELD] })); + assert.ok(directoryPageParamsRefusal({ op: "presentCredential", hostDid: HOST, fields: ["Not A Key"] })); + assert.ok(directoryPageParamsRefusal({ op: "presentCredential", hostDid: HOST, fields: Array.from({ length: 17 }, (_, i) => `org.x.f${i}`) })); + assert.ok( + directoryPageParamsRefusal({ op: "presentCredential", hostDid: HOST, fields: [FIELD], claims: ["ssn"] }), + "the page never names the claims", + ); + assert.ok(directoryPageParamsRefusal({ op: "presentCredential", hostDid: HOST, fields: [FIELD], credentialId: CRED })); +}); + +test("a page that is not the portal finds out nothing about the member's credentials", async () => { + const h = harness({ answers: [{ approved: true, selectedCredential: CRED }] }); + const res = await h.gates.handle("https://evil.example", { op: "presentCredential", hostDid: HOST, fields: [FIELD] }); + assert.equal(res.ok, false); + assert.ok(!h.asked.some((o) => o.op === "preparePresent")); + assert.equal(h.prompts.length, 0); +}); + +test("the person picks the credential, sees every claim, and only then is it presented", async () => { + const h = harness({ answers: [{ approved: true, selectedCredential: CRED }] }); + const res = await h.gates.handle(PORTAL, { op: "presentCredential", hostDid: HOST, fields: [FIELD] }); + assert.deepEqual(res, { ok: true, result: VERIFIED }); + const prompt = h.prompts[0] as DirectoryPresentConsentView; + assert.equal(prompt.kind, "presentCredential"); + assert.equal(prompt.member, MEMBER); + assert.deepEqual(prompt.claims, ["employer"]); + assert.deepEqual(prompt.candidates.map((c) => c.id), [CRED]); + assert.deepEqual(h.asked.find((o) => o.op === "present"), { + op: "present", + hostDid: HOST, + member: MEMBER, + fields: [FIELD], + credentialId: CRED, + claims: ["employer"], + }); +}); + +test("declined, or answered with a credential the prompt did not offer, nothing is presented", async () => { + for (const answer of [{ approved: false }, { approved: true }, { approved: true, selectedCredential: "cred-other" }]) { + const h = harness({ answers: [answer] }); + const res = await h.gates.handle(PORTAL, { op: "presentCredential", hostDid: HOST, fields: [FIELD] }); + assert.equal(res.ok, false); + assert.ok(!h.asked.some((o) => o.op === "present"), JSON.stringify(answer)); + } +}); + +test("a credential that cannot hide the rest is asked about again, naming what else it shows", async () => { + const h = harness({ answers: [{ approved: true, selectedCredential: CRED }, { approved: true }], undisclosed: ["salary"] }); + const res = await h.gates.handle(PORTAL, { op: "presentCredential", hostDid: HOST, fields: [FIELD] }); + assert.deepEqual(res, { ok: true, result: VERIFIED }); + assert.deepEqual(h.prompts[1], { + kind: "presentOverDisclose", + origin: PORTAL, + hostDid: HOST, + communityName: "Acme Guild", + undisclosed: ["salary"], + claims: ["employer"], + }); + const presents = h.asked.filter((o) => o.op === "present") as Array<{ claims: string[] }>; + assert.deepEqual(presents.map((p) => p.claims), [["employer"], ["employer", "salary"]]); +}); + +test("declining the second question presents nothing more", async () => { + const h = harness({ answers: [{ approved: true, selectedCredential: CRED }, { approved: false }], undisclosed: ["salary"] }); + const res = await h.gates.handle(PORTAL, { op: "presentCredential", hostDid: HOST, fields: [FIELD] }); + assert.equal(res.ok, false); + assert.equal(h.asked.filter((o) => o.op === "present").length, 1, "only the refused attempt"); +}); + +test("the offscreen half hands back what the credential cannot hide, matched on the agent's code", async () => { + const sent: Array<{ type: string; payload: Record }> = []; + const refusal = (code: string, details?: Record) => + Object.assign(new Error("refused"), { details: { code, message: "refused", ...(details ? { details } : {}) } }); + const deps = (err: Error) => ({ + agent: async () => ({ + sender: { + async send(envelope: { type: string; payload: Record }) { + sent.push(envelope); + throw err; + }, + } as never, + holder: { did: "did:key:zHolder" } as never, + service: { did: VTA.vtaDid } as never, + }), + identities: async () => [{ id: "e1", label: "Acme Guild", principalDid: MEMBER, targets: [{ kind: "did", did: HOST }] }], + community: async () => ({ portalOrigin: PORTAL, endpoint: ENDPOINT, document: {} }), + }); + const op = { op: "present" as const, hostDid: HOST, member: MEMBER, fields: [FIELD], credentialId: CRED, claims: ["employer"] }; + + const over = await runDirectoryPageOp( + deps(refusal("vault/credentials/present:wouldOverDisclose", { undisclosed: ["salary"] })), + VTA, + op, + ); + assert.deepEqual(over, { undisclosed: ["salary"] }); + const p = sent[0]!.payload; + assert.equal(sent[0]!.type, "https://trusttasks.org/spec/vault/credentials/present/0.1"); + assert.equal(p.audience, HOST, "bound to the community"); + assert.equal(p.id, CRED); + assert.deepEqual(p.claims, ["employer"]); + assert.equal(typeof p.nonce, "string"); + + await assert.rejects( + runDirectoryPageOp(deps(refusal("vault/credentials/present:notPresentable")), VTA, op), + /expired, been revoked/, + ); + await assert.rejects( + runDirectoryPageOp(deps(Object.assign(new Error("wouldOverDisclose"), {})), VTA, op), + /wouldOverDisclose/, + "a message is never read as the code", + ); + const nonces = sent.map((e) => e.payload.nonce); + assert.equal(new Set(nonces).size, nonces.length, "every attempt is a fresh nonce"); +});