From 2be6ef9b59618b1867908847142203255ba982b4 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Thu, 22 Oct 2026 14:32:12 -0700 Subject: [PATCH] fix(directory): presentCredential offers only credentials your agent presents as the member The community accepts a verified field only from a presentation its member holds, so a credential bound to another key (an SD-JWT with a separate holder did:key) was offered, chosen, presented and then refused. The wallet now asks the agent only for credentials whose subject is the member's DID (vault/credentials/query subjectDid, trust-tasks-tf #884). Needs a VTA that serves subjectDid: an older one refuses the query as malformed, so this ships after the VTI release that carries it. Signed-off-by: Glenn Gore --- packages/core/src/vault/credentials.ts | 5 +++++ packages/core/tests/vault.ops.mjs | 22 +++++++++++++++++++ .../extension/src/directory-page-offscreen.ts | 12 +++++++++- 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/core/src/vault/credentials.ts b/packages/core/src/vault/credentials.ts index b07da8bd..a7a5ccdb 100644 --- a/packages/core/src/vault/credentials.ts +++ b/packages/core/src/vault/credentials.ts @@ -110,6 +110,10 @@ export interface CredentialFilter { } export interface CredVaultQueryParams extends CredVaultCallerParams, CredentialFilter { + /** Only credentials whose subject — the identity the agent presents them as — + * is this DID. Narrows the filter; does not satisfy it on its own, since one + * identity's credentials can be the whole vault. */ + subjectDid?: string; /** Also return archived credentials. A **modifier, not a filter** — it does * not satisfy the at-least-one-filter requirement. */ includeArchived?: boolean; @@ -160,6 +164,7 @@ export async function credVaultQuery( ...(params.issuerDid ? { issuerDid: params.issuerDid } : {}), ...(params.purpose ? { purpose: params.purpose } : {}), ...(params.status ? { status: params.status } : {}), + ...(params.subjectDid ? { subjectDid: params.subjectDid } : {}), ...(params.includeArchived ? { includeArchived: true } : {}), ...(params.includeDeleted ? { includeDeleted: true } : {}), }, diff --git a/packages/core/tests/vault.ops.mjs b/packages/core/tests/vault.ops.mjs index 9e79572b..4e62c360 100644 --- a/packages/core/tests/vault.ops.mjs +++ b/packages/core/tests/vault.ops.mjs @@ -13,6 +13,7 @@ import { generateSigningIdentity, VtaSession, decodeDigestMultibase, + credVaultQuery, } from "../dist/index.js"; /** A TrustTaskChannel that records the envelope + opts and returns a canned @@ -216,3 +217,24 @@ test("an attachment digest that is not a sha2-256 multihash is refused", () => { // invisible at the one place it matters. assert.throws(() => decodeDigestMultibase("deadbeef"), /unsupported multibase prefix/); }); + +test("credVaultQuery carries subjectDid beside the filter, and it alone is no filter", async () => { + const ch = captureChannel({ credentials: [{ id: "c1", types: ["VerifiableCredential"], status: "valid" }] }); + const res = await credVaultQuery(ch, { + holder, + service, + type: "EmploymentCredential", + status: "valid", + subjectDid: "did:web:member.example", + }); + assert.equal(ch.sent[0].envelope.type, "https://trusttasks.org/spec/vault/credentials/query/0.1"); + assert.deepEqual(ch.sent[0].envelope.payload, { + type: "EmploymentCredential", + status: "valid", + subjectDid: "did:web:member.example", + }); + assert.deepEqual(res.map((c) => c.id), ["c1"]); + + await assert.rejects(credVaultQuery(ch, { holder, service, subjectDid: "did:web:member.example" })); + assert.equal(ch.sent.length, 1, "refused before sending"); +}); diff --git a/packages/extension/src/directory-page-offscreen.ts b/packages/extension/src/directory-page-offscreen.ts index 2866c5c6..6f54fd1b 100644 --- a/packages/extension/src/directory-page-offscreen.ts +++ b/packages/extension/src/directory-page-offscreen.ts @@ -258,7 +258,17 @@ export async function runDirectoryPageOp( 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" }); + // Only credentials the agent would present as the member: the community + // accepts a presentation only from its member, so one bound to another + // key (a separate SD-JWT holder key) would be refused after the member + // chose it. + const held = await credVaultQuery(sender, { + holder, + service, + type: ask.credentialType, + status: "valid", + subjectDid: op.member, + }); const candidates = held .filter((c) => !c.lifecycle || c.lifecycle === "active") .filter((c) => !ask.issuers || (c.issuerDid !== undefined && ask.issuers.includes(c.issuerDid)))