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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@
"@swc/wasm": "~1.15.47"
},
"dependencies": {
"@openvtc/trust-tasks": "^0.27.1"
"@openvtc/trust-tasks": "^0.27.2"
}
}
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
57 changes: 57 additions & 0 deletions packages/core/src/vault/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -198,6 +204,57 @@ export async function credVaultGet(
return res.credential as Record<string, unknown>;
}

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<VaultCredentialsPresentResponsePayload> {
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<VaultCredentialsPresentResponsePayload>(envelope, {
expectedResponseType: CRED_PRESENT_RESPONSE,
operationLabel: "vault/credentials/present/0.1",
});
}

export interface CredVaultReceiveParams extends CredVaultCallerParams {
/** The verifiable credential. */
credential: Record<string, unknown>;
Expand Down
122 changes: 122 additions & 0 deletions packages/core/src/vtc/directory-present.ts
Original file line number Diff line number Diff line change
@@ -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, unknown> | string,
): OobDocument<CardPresentPayload> {
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<string, VerifiedValue>; 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<string, VerifiedValue>, revision: r.revision };
}
1 change: 1 addition & 0 deletions packages/core/src/vtc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
6 changes: 6 additions & 0 deletions packages/core/task-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
5 changes: 4 additions & 1 deletion packages/core/tests/task-surface.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
100 changes: 100 additions & 0 deletions packages/core/tests/vtc.directory-present.mjs
Original file line number Diff line number Diff line change
@@ -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,
});
});
Loading
Loading