/*
RFC-1213: the verifier boundary for verifiable presentations — the ONLY place
presentation bytes/objects are parsed inside the gate. A capability declaring
`requires.verifiablePresentation` makes the caller attach a W3C VC DM 2.0
verifiable presentation (OIDC4VP-shaped, carried as the
`X-Agent-Verifiable-Presentation` base64url header on HTTP and the
`gogol.dev/verifiablePresentation` params._meta key on MCP/A2A). The boundary
splits duties: an injected `presentationVerifier` port owns cryptographic
proof verification (a maintained VC/OIDC4VP library plugs in there — none is
bundled with v1 readiness), while THIS module owns policy — validity window,
issuer allowlist, holder binding to the verified agentId (replay protection),
and claim-path presence. Downstream code sees a `PresentationVerdict` only;
the journal records a hash, never presentation contents.
- Do not verify cryptographic proofs inline — that is the injected
verifier port's job; this boundary trusts its verdict.
- Do not persist claim values — journal records carry
{requirementId, verified, issuer, failureReason, presentationHash}
only (AC-5, schema-enforced).
- Do not resolve "@name" issuer refs at runtime — serialized
capabilities carry resolved inline issuers (generation resolves
against trusted-issuers.yaml; AGP-05 gates the declaration).
- Do not check credentialStatus revocation — out of scope for v1
(RFC-1213 nonGoal).
- RFC-1213: initial verifier boundary — port-based proof verification + policy (window, issuers, verified-agent binding, claims) + hash-only journaling.
*/
import { byteHash } from "@warpgogol/werkstatt-shared/fingerprint";
import {
buildAgentProblem,
PRESENTATION_VERDICT_SCHEMA_ID,
presentationJournalRecordSchema,
type AgentProblemDetails,
type PresentationAcceptEntry,
type PresentationJournalRecord,
type PresentationVerdict,
type VerifiablePresentationRequirement,
} from "@warpgogol/werkstatt-shared/agent";
import type { CapabilityRecord } from "@warpgogol/werkstatt-shared/ontology";
import type { AgentGatePorts } from "./ports.ts";
/** RFC-1213: HTTP transport — base64url-encoded JSON verifiable presentation. */
export const VP_HEADER = "x-agent-verifiable-presentation";
/** RFC-1213: MCP/A2A transport — params._meta key carrying the VP object. */
export const PRESENTATION_META_KEY = "gogol.dev/verifiablePresentation";
/**
* Cryptography-facing facts the injected verifier extracts AFTER proof
* verification succeeds. `holder` is the VP-level holder binding (for
* bindTo "verified-agent" it must equal the calling verified agentId);
* per-credential facts drive issuer/window/type/claim policy checks.
*/
export interface VerifiedPresentationFacts {
holder?: string;
credentials: Array<{
types: string[];
issuer?: string;
/** The credential subject object — used for claim-path presence only. */
subject?: Record;
validFrom?: string;
validUntil?: string;
}>;
}
/**
* The injected cryptographic verification port. Implementations verify the
* VP proof + embedded VC proofs and return extracted facts; null means the
* cryptographic layer failed. Absent port = no verifier surface (AGP-02
* blocks such declarations at author time; runtime fails closed).
*/
export type PresentationVerifier = (
presentation: unknown,
) => Promise;
/** Extract the VP from an HTTP request — base64url JSON in the header. */
export function extractHttpPresentation(request: Request): unknown {
const raw = request.headers.get(VP_HEADER);
if (!raw) return undefined;
try {
return JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
} catch {
return { __malformedPresentation: true };
}
}
/** Claim-path presence check — "credentialSubject.ageOver" walks the subject. */
function claimPathPresent(subject: Record | undefined, path: string): boolean {
if (!subject) return false;
// Paths are "credentialSubject." — the subject object IS credentialSubject.
const rel = path.startsWith("credentialSubject.")
? path.slice("credentialSubject.".length)
: path;
let cursor: unknown = subject;
for (const seg of rel.split(".")) {
if (typeof cursor !== "object" || cursor === null || !(seg in cursor)) return false;
cursor = (cursor as Record)[seg];
}
return cursor !== undefined && cursor !== null;
}
function windowOk(
validFrom: string | undefined,
validUntil: string | undefined,
now: Date,
): boolean {
if (validFrom !== undefined) {
const from = Date.parse(validFrom);
if (!Number.isNaN(from) && now.getTime() < from) return false;
}
if (validUntil !== undefined) {
const until = Date.parse(validUntil);
if (!Number.isNaN(until) && now.getTime() > until) return false;
}
return true;
}
/** Emit the journaled verdict — hash only, fire-and-forget like usage/telemetry. */
function journalVerdict(
ports: AgentGatePorts,
input: {
siteId: string;
capabilityId: string;
requirementId: string;
presentation: unknown;
verdict: PresentationVerdict;
},
): void {
try {
if (!ports.presentationJournal) return;
const record: PresentationJournalRecord = presentationJournalRecordSchema.parse({
schema: PRESENTATION_VERDICT_SCHEMA_ID,
siteId: input.siteId,
capability: input.capabilityId,
requirementId: input.requirementId,
verified: input.verdict.verified,
...(input.verdict.issuer ? { issuer: input.verdict.issuer } : {}),
...(input.verdict.failureReason ? { failureReason: input.verdict.failureReason } : {}),
presentationHash: byteHash(JSON.stringify(input.presentation)),
occurredAt: ports.now().toISOString(),
});
ports.presentationJournal(record);
} catch {
// Silent drop — presentation journaling never breaks the request path.
}
}
export interface PresentationCheckResult {
verdict: PresentationVerdict;
problem?: AgentProblemDetails;
}
/**
* The requires-checks boundary: returns a verdict plus an RFC 9457 problem
* when the requirement is unsatisfied. Anonymous callers on a requiring
* capability are `presentation-required` (bindTo "verified-agent" is
* unsatisfiable without verified identity — AUDIT-RFC-1213-01 replay guard).
*/
export async function checkPresentationRequirement(input: {
ports: AgentGatePorts;
siteId: string;
capability: CapabilityRecord;
presentation: unknown;
verifiedAgentId?: string;
}): Promise {
const { ports, capability, presentation, verifiedAgentId } = input;
const requirement: VerifiablePresentationRequirement | undefined =
capability.requires.verifiablePresentation;
if (!requirement) return { verdict: { verified: false } };
const journal = (verdict: PresentationVerdict): PresentationCheckResult => {
if (presentation !== undefined) {
journalVerdict(ports, {
siteId: input.siteId,
capabilityId: capability.id,
requirementId: requirement.accept.map((a) => a.type).join(","),
presentation,
verdict,
});
}
return verdict.verified
? { verdict }
: {
verdict,
problem: buildAgentProblem(
verdict.failureReason?.startsWith("absent") ||
verdict.failureReason?.startsWith("unauthenticated")
? "presentation-required"
: "presentation-invalid",
{ detail: verdict.failureReason },
),
};
};
if (!verifiedAgentId) {
return journal({
verified: false,
failureReason:
"unauthenticated: verifiablePresentation requires a verified agent identity (bindTo verified-agent)",
});
}
if (presentation === undefined || presentation === null) {
return journal({ verified: false, failureReason: "absent: no presentation attached" });
}
if (typeof presentation !== "object") {
return journal({ verified: false, failureReason: "malformed: presentation is not an object" });
}
if (!ports.presentationVerifier) {
return journal({
verified: false,
failureReason: "verifier-unavailable: no presentation verifier configured",
});
}
let facts: VerifiedPresentationFacts | null;
try {
facts = await ports.presentationVerifier(presentation);
} catch {
facts = null;
}
if (!facts) {
return journal({
verified: false,
failureReason: "crypto: presentation proofs did not verify",
});
}
// Replay protection (AC-8): the VP holder binding MUST equal the calling
// verified agentId — a cryptographically valid VP bound to somebody else
// is a captured-token replay.
if (facts.holder !== verifiedAgentId) {
return journal({
verified: false,
failureReason: "holder-binding: presentation does not bind to the calling agent",
});
}
const now = ports.now();
for (const entry of requirement.accept) {
const match = matchAcceptEntry(entry, facts, now);
if (match) {
return journal({
verified: true,
matchedRequirement: entry.type,
subjectBinding: verifiedAgentId,
issuer: match.issuer,
});
}
}
return journal({
verified: false,
failureReason: "no-accept-match: no credential satisfied the declared requirement",
});
}
/** Find a credential satisfying one accept entry: type + claims + issuer + window. */
function matchAcceptEntry(
entry: PresentationAcceptEntry,
facts: VerifiedPresentationFacts,
now: Date,
): { issuer?: string } | null {
for (const cred of facts.credentials) {
if (!cred.types.includes(entry.type)) continue;
if (!(entry.claims ?? []).every((p) => claimPathPresent(cred.subject, p))) continue;
// Serialized records carry resolved inline issuer ids. An unresolved
// "@name" ref that survived to runtime stays in the allowlist as a
// literal — it can never equal a real did:/https: issuer id, so the
// entry fails closed instead of degrading to "any issuer".
const allowed = entry.issuers ?? [];
if (allowed.length > 0 && (cred.issuer === undefined || !allowed.includes(cred.issuer)))
continue;
if (!windowOk(cred.validFrom, cred.validUntil, now)) continue;
return { issuer: cred.issuer };
}
return null;
}
/** Production journal sink — structured JSON line, drained like usage records. */
export function workerLogPresentationVerdict(record: PresentationJournalRecord): void {
console.log(JSON.stringify(record));
}