From ea8f3d47ed0ae90ace2e823299ef80921d167b83 Mon Sep 17 00:00:00 2001 From: random block Date: Mon, 10 Aug 2026 12:02:36 +0100 Subject: [PATCH 1/2] feat(indexer): resolve DACS-5 bundle bindings --- .../dacs-directory/README.md | 19 +- .../app/api/dacs/build-listing/route.ts | 4 +- .../app/api/dacs/bundles/[jobId]/route.ts | 17 ++ .../app/api/dacs/register/route.ts | 12 ++ .../src/catalog/bundleBinding.ts | 188 ++++++++++++++++++ .../dacs-directory/src/catalog/contracts.ts | 8 + .../src/catalog/currentReconciliation.ts | 24 ++- .../src/catalog/evidenceGraph.ts | 33 ++- .../dacs-directory/src/catalog/indexer.ts | 102 +++++++++- .../src/catalog/registration.ts | 11 +- .../src/catalog/registrationSig.ts | 14 +- .../dacs-directory/src/catalog/reindexCore.ts | 71 ++++++- .../dacs-directory/src/catalog/scan.ts | 27 ++- .../dacs-directory/src/catalog/types.ts | 29 +++ .../dacs-directory/src/catalog/wellknown.ts | 23 ++- .../test/bundle-binding.test.ts | 168 ++++++++++++++++ .../test/current-indexer.test.ts | 117 ++++++++++- .../test/discovery-contract.test.ts | 2 + .../dacs-directory/test/security.test.ts | 5 + 19 files changed, 833 insertions(+), 41 deletions(-) create mode 100644 reference-implementations/dacs-directory/app/api/dacs/bundles/[jobId]/route.ts create mode 100644 reference-implementations/dacs-directory/src/catalog/bundleBinding.ts create mode 100644 reference-implementations/dacs-directory/test/bundle-binding.test.ts diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 02687de..c4a573a 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -19,10 +19,10 @@ checks in-browser, while chain inclusion still depends on the disclosed proxy/RP | Surface | Spec | How | |---|---|---| -| Catalog API | DACS-1 §6.3.6 | Full normative listing filters plus `q`, profile and identity-tier extensions; canonical current listings and explicitly labelled legacy SDK artifacts | -| Registration | — (catalog-side) | `POST /api/dacs/register` with a **pointer set** (primary claim + anchor addresses). Nothing in the payload is trusted: listings are read from chain and shape-validated, CCI badges resolved from the on-chain GCR, every offered bundle dereferenced and cryptographically verified before it counts | +| Catalog API | DACS-1 §6.3.6 | Full normative listing filters plus `q`, profile and identity-tier extensions; canonical current listings, explicitly labelled legacy SDK artifacts, and unauthenticated BB-4-verified `GET /api/dacs/bundles/{jobId}` candidates | +| Registration | — (catalog-side) | `POST /api/dacs/register` with bounded discovery hints. Nothing in the payload is trusted: listings are read from chain, BundleBindings are independently BB-4 verified, CCI badges are resolved from the on-chain GCR, and every offered bundle is cryptographically verified before it counts | | Identity links | DACS-1 / DACS-2 / CCI | GCR links remain informational; identity tiers elevate only from hash/signature/identifier/method/version/freshness-verified `verifiedBy` evidence under an explicit recipe policy | -| Reputation derivation | DACS-5 §10.5 | strict evidence-graph validation, two-sided reconciliation, seller perspective, fault metrics, ratings, exact-decimal volume, settlement uniqueness, SR-2 windows and deterministic receipts | +| Reputation derivation | DACS-5 §10.4–§10.5 | logical bundle-address derivation and bounded BB-4/BB-5/BB-6 resolution, strict two-sided evidence graphs, legacy and v0.3 absolute-fault bundles, seller perspective, ratings, exact-decimal volume, settlement uniqueness, SR-2 windows and deterministic receipts | | Index persistence | Operational | SQLite WAL repository, one-time JSON migration, cross-process leases, artifact retry/dead-letter queue and scan-run diagnostics | | In-browser verify | DACS-5 §10.4 | strict buyer/seller bundle-signature coverage plus referenced-artifact signature/hash checks run in the visitor's browser. Because the server ferries RPC bytes, this proves internal cryptographic consistency but is not an independent chain-inclusion proof; the UI states that boundary explicitly | @@ -170,8 +170,9 @@ payloads, internal URLs and stack traces are never returned. ## Discovery — three channels 1. **Registration** (`/register` UI or `POST /api/dacs/register`): bounded pointer sets, - verified from chain. Third parties may submit a new candidate, but only the owner - key can replace an existing registration. + plus self-authenticating BundleBinding carriage, all independently verified. Third + parties may submit a new candidate, but only the owner key can replace an existing + registration. 2. **Chain scanning** (passive): the reindex pass walks the node's transaction history (`nodeCall getTransactions`, plain fetch), spots storage-program writes, classifies anchored DACS artifacts by their self-describing program names, and attributes deals @@ -185,7 +186,13 @@ payloads, internal URLs and stack traces are never returned. outside the retained window is not evaluated. Its publisher can anchor a fresh marker to re-enter discovery, but continued overflow can exclude that marker again. After one marker verifies, later pruning cannot make it disappear. -3. **Evidence graph**: current bundles recursively resolve and validate listings, + BB-4-valid BundleBindings are also classified and accumulated under a deterministic + per-job/role total-work ceiling; any overflow is sticky and makes that side + `indeterminate`, never absent. +3. **Evidence graph and federation**: current bundle copies are reached only after + deriving the role-specific logical address and resolving a signed BundleBinding. + The optional DACS-1 well-known bundle-binding index is hash-bound and SSRF-bounded. + Resolved bundles recursively validate listings, agreements, settlement evidence and amendment chains, composite/VerifyResult vet records, and ratings. Legacy SDK artifacts remain on an explicitly-labelled compatibility path. diff --git a/reference-implementations/dacs-directory/app/api/dacs/build-listing/route.ts b/reference-implementations/dacs-directory/app/api/dacs/build-listing/route.ts index 2402da6..2a73fa7 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/build-listing/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/build-listing/route.ts @@ -272,11 +272,13 @@ export async function POST(req: NextRequest) { blockNumber: null, }; + const priorRegistration = loadRegistrations().find((r) => r.primaryClaim === did); const registration = { primaryClaim: did, displayName: knownSeller?.displayName ?? body.name.trim(), listingAnchors: [...new Set([...(knownSeller?.listings.map((l) => l.anchor.locator) ?? []), anchorAddress])], - deals: loadRegistrations().find((r) => r.primaryClaim === did)?.deals ?? [], + deals: priorRegistration?.deals ?? [], + ...(priorRegistration?.bundleBindings ? { bundleBindings: priorRegistration.bundleBindings } : {}), }; const signedAt = Date.now(); diff --git a/reference-implementations/dacs-directory/app/api/dacs/bundles/[jobId]/route.ts b/reference-implementations/dacs-directory/app/api/dacs/bundles/[jobId]/route.ts new file mode 100644 index 0000000..4594152 --- /dev/null +++ b/reference-implementations/dacs-directory/app/api/dacs/bundles/[jobId]/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { verifyBundleBinding } from "@/src/catalog/bundleBinding"; +import { loadScanState } from "@/src/catalog/store"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ jobId: string }> }, +) { + const { jobId } = await params; + if (jobId.length < 1 || jobId.length > 160) { + return NextResponse.json({ error: "jobId must be 1-160 characters" }, { status: 400 }); + } + const candidates = loadScanState().bundleBindings?.[jobId] ?? []; + const bindings = (await Promise.all(candidates.map(verifyBundleBinding))) + .filter((binding) => binding !== null); + return NextResponse.json({ bindings }); +} diff --git a/reference-implementations/dacs-directory/app/api/dacs/register/route.ts b/reference-implementations/dacs-directory/app/api/dacs/register/route.ts index 545a724..ca5d99c 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/register/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/register/route.ts @@ -9,6 +9,7 @@ import { verifyOwnerSignature } from "@/src/catalog/registrationSig"; import { parseRegistration } from "@/src/catalog/registration"; import { rateLimit, rejectOversizeRequest } from "@/src/catalog/security"; import { loadRegistrations, saveRegistrations, withDataLock } from "@/src/catalog/store"; +import { verifyBundleBinding } from "@/src/catalog/bundleBinding"; // Hard caps bound the reindex cost: every stored registration is re-read and // re-verified from chain each pass (up to 32 anchors + 200 deals apiece), so @@ -26,6 +27,17 @@ export async function POST(req: NextRequest) { if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 }); const body = parsed.value; + // A catalog that carries BundleBindings must serve only BB-4-valid records. + // Verify at ingress as well as at use so invalid carrier data never enters + // persistent registration state. + if (body.bundleBindings) { + const verified = await Promise.all(body.bundleBindings.map(verifyBundleBinding)); + if (verified.some((binding) => binding === null)) { + return NextResponse.json({ error: "one or more bundleBindings fail DACS-5 BB-4" }, { status: 400 }); + } + body.bundleBindings = verified.filter((binding) => binding !== null); + } + // Owner signature (optional): verified NOW so the submitter gets immediate // feedback; a bad signature rejects rather than silently downgrading. let ownerVerified = false; diff --git a/reference-implementations/dacs-directory/src/catalog/bundleBinding.ts b/reference-implementations/dacs-directory/src/catalog/bundleBinding.ts new file mode 100644 index 0000000..1cd4e56 --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/bundleBinding.ts @@ -0,0 +1,188 @@ +import { createHash } from "node:crypto"; +import { contentHash } from "@kynesyslabs/dacs/canonical"; +import { ed25519Verify, publicKeyFromRaw } from "@kynesyslabs/dacs/crypto"; +import { canonicalDemosAgentClaim } from "./claimRef.js"; +import type { BundleBinding } from "./types.js"; + +const BINDING_DOMAIN = "dacs-bundle-binding:v1:"; +const NATIVE_ADDRESS = /^stor-[0-9a-f]{40}$/; +const LOGICAL_ADDRESS = /^stor-[0-9a-f]{64}$/; +const HASH = /^[0-9a-f]{64}$/; +const ROLES = new Set(["buyer", "seller", "orchestrator"]); + +export const MAX_BUNDLE_BINDING_CANDIDATES_PER_SIGNER = 8; +export const MAX_BUNDLE_BINDINGS_PER_JOB_ROLE = 32; + +const record = (value: unknown): Record | null => + value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; + +/** DACS-5 §10.4.2 logical address, derived without any Demos write inputs. */ +export function logicalBundleAddress(jobId: string, role: BundleBinding["role"]): string { + return `stor-${createHash("sha256").update(`${jobId}-bundle-${role}`, "utf8").digest("hex")}`; +} + +export function bundleBindingRoleKey(jobId: string, role: BundleBinding["role"]): string { + return `${jobId}\n${role}`; +} + +function strictSignatureBytes(value: unknown): Uint8Array | null { + if (typeof value !== "string" || !/^[A-Za-z0-9_-]{86}$/.test(value)) return null; + try { + const bytes = Buffer.from(value, "base64url"); + return bytes.length === 64 && bytes.toString("base64url") === value + ? Uint8Array.from(bytes) + : null; + } catch { + return null; + } +} + +/** + * BB-4 plus structural ingress. Unknown top-level members remain in the + * signed scope so a newer-minor field can never be silently stripped. + */ +export async function verifyBundleBinding(value: unknown): Promise { + const raw = record(value); + const signature = record(raw?.signature); + if ( + !raw || Buffer.byteLength(JSON.stringify(raw), "utf8") > 16_384 || + raw.bindingVersion !== "1" || typeof raw.jobId !== "string" || + raw.jobId.length < 1 || raw.jobId.length > 160 || + typeof raw.role !== "string" || !ROLES.has(raw.role) || + typeof raw.logicalAddress !== "string" || !LOGICAL_ADDRESS.test(raw.logicalAddress) || + typeof raw.nativeAddress !== "string" || !NATIVE_ADDRESS.test(raw.nativeAddress) || + typeof raw.bundleContentHash !== "string" || !HASH.test(raw.bundleContentHash) || + (raw.anchorTx !== undefined && (typeof raw.anchorTx !== "string" || raw.anchorTx.length > 256)) || + typeof raw.signer !== "string" || !signature || signature.algorithm !== "ed25519" || + typeof signature.signer !== "string" || typeof signature.value !== "string" + ) return null; + + const signer = canonicalDemosAgentClaim(raw.signer); + const signatureSigner = canonicalDemosAgentClaim(signature.signer); + if (!signer || !signatureSigner || signer !== signatureSigner) return null; + const keyHex = signer.slice(-64); + const sig = strictSignatureBytes(signature.value); + if (!sig) return null; + + const scope = { ...raw }; + delete scope.signature; + const hash = contentHash(scope); + let ok = false; + try { + ok = await ed25519Verify( + Buffer.from(BINDING_DOMAIN + hash, "utf8"), + sig, + publicKeyFromRaw(Uint8Array.from(Buffer.from(keyHex, "hex"))), + ); + } catch { + return null; + } + return ok ? raw as BundleBinding : null; +} + +const bindingOrder = (left: BundleBinding, right: BundleBinding): number => + left.signer.localeCompare(right.signer) || + left.bundleContentHash.localeCompare(right.bundleContentHash) || + left.nativeAddress.localeCompare(right.nativeAddress) || + contentHash(left).localeCompare(contentHash(right)); + +/** + * Deterministic total-work ceiling. Overflow is sticky in ScanState and must + * make that side indeterminate; truncation can never manufacture absence. + */ +export function boundedBundleBindings( + bindings: Iterable, + limit = MAX_BUNDLE_BINDINGS_PER_JOB_ROLE, +): { bindings: BundleBinding[]; overflowKeys: string[] } { + const unique = new Map(); + for (const binding of bindings) unique.set(contentHash(binding), binding); + const byRole = new Map(); + for (const binding of unique.values()) { + const key = bundleBindingRoleKey(binding.jobId, binding.role); + const values = byRole.get(key) ?? []; + values.push(binding); + byRole.set(key, values); + } + const kept: BundleBinding[] = []; + const overflowKeys: string[] = []; + for (const [key, values] of [...byRole].sort(([a], [b]) => a.localeCompare(b))) { + values.sort(bindingOrder); + kept.push(...values.slice(0, limit)); + if (values.length > limit) overflowKeys.push(key); + } + return { bindings: kept, overflowKeys }; +} + +export interface InspectedBundle { + value: T; + bundleContentHash: string; + /** True when every declared buyer/seller/distinct-orchestrator signed. */ + fullSignatureStanding: boolean; +} + +export type BundleSideResolution = + | { disposition: "present"; binding: BundleBinding; inspected: InspectedBundle } + | { disposition: "indeterminate"; reason: string }; + +/** + * BB-5/BB-6 selection for a reputation derivation, where the authenticated + * role holder is already known and outsider signers must be pruned pre-fetch. + */ +export async function resolveBundleSide(options: { + jobId: string; + role: BundleBinding["role"]; + expectedSigner: string; + bindings: readonly BundleBinding[]; + overflow?: boolean; + inspect: (binding: BundleBinding) => Promise | null>; + budget?: number; +}): Promise> { + const expectedSigner = canonicalDemosAgentClaim(options.expectedSigner); + if (!expectedSigner) return { disposition: "indeterminate", reason: "role holder is not a supported canonical claim" }; + if (options.overflow) return { disposition: "indeterminate", reason: "bundle-binding discovery cap exhausted" }; + const logicalAddress = logicalBundleAddress(options.jobId, options.role); + const candidates = options.bindings.filter((binding) => + binding.jobId === options.jobId && binding.role === options.role && + binding.logicalAddress === logicalAddress && + canonicalDemosAgentClaim(binding.signer) === expectedSigner, + ).sort(bindingOrder); + if (candidates.length === 0) { + return { disposition: "indeterminate", reason: "no verified BundleBinding for role" }; + } + + const budget = options.budget ?? MAX_BUNDLE_BINDING_CANDIDATES_PER_SIGNER; + const distinctNative = new Set(candidates.map((binding) => binding.nativeAddress)); + if (distinctNative.size > budget) { + return { disposition: "indeterminate", reason: "BB-6 per-signer fetch budget exhausted" }; + } + + const accepted: Array<{ binding: BundleBinding; inspected: InspectedBundle }> = []; + for (const binding of candidates) { + const inspected = await options.inspect(binding); + if (inspected?.bundleContentHash === binding.bundleContentHash) { + accepted.push({ binding, inspected }); + } + } + if (accepted.length === 0) { + return { disposition: "indeterminate", reason: "every BundleBinding failed BB-5 post-fetch checks" }; + } + + // Canonically equal copies collapse. Prefer a fully-signed representative + // inside a group, then apply the full-over-lesser standing ladder to groups. + const groups = new Map(); + for (const candidate of accepted) { + const values = groups.get(candidate.inspected.bundleContentHash) ?? []; + values.push(candidate); + groups.set(candidate.inspected.bundleContentHash, values); + } + const representatives = [...groups.values()].map((values) => + values.find((candidate) => candidate.inspected.fullSignatureStanding) ?? values[0]); + if (representatives.length === 1) { + return { disposition: "present", ...representatives[0] }; + } + const full = representatives.filter((candidate) => candidate.inspected.fullSignatureStanding); + if (full.length === 1) return { disposition: "present", ...full[0] }; + return { disposition: "indeterminate", reason: "authorized equal-standing bundle copies diverge" }; +} diff --git a/reference-implementations/dacs-directory/src/catalog/contracts.ts b/reference-implementations/dacs-directory/src/catalog/contracts.ts index f970580..4b6d057 100644 --- a/reference-implementations/dacs-directory/src/catalog/contracts.ts +++ b/reference-implementations/dacs-directory/src/catalog/contracts.ts @@ -219,6 +219,7 @@ export const directoryManifest = (origin: string, demosRpc = publicDemosRpcUrl() agentCard: `${origin}/.well-known/agent.json`, api: `${origin}/api/dacs`, catalog: `${origin}/api/dacs/listings`, + bundleBindings: `${origin}/api/dacs/bundles/{jobId}`, openapi: `${origin}/openapi.json`, schemas: { listingSummary: `${origin}/schemas/listing-summary.schema.json` }, substrates: { @@ -303,6 +304,13 @@ export const openApiDocument = (origin: string) => ({ responses: { "200": { description: "Signed listing artifact" }, "404": { description: "Listing not found" }, "502": { description: "Anchor verification failed" } }, }, }, + "/api/dacs/bundles/{jobId}": { + get: { + summary: "Retrieve BB-4-verified DACS-5 BundleBindings known to this catalog", + parameters: [{ name: "jobId", in: "path", required: true, schema: { type: "string", minLength: 1, maxLength: 160 } }], + responses: { "200": { description: "Signed BundleBinding candidates" }, "400": { description: "Invalid jobId" } }, + }, + }, "/api/dacs/inspect-service/{listingId}/{version}": { get: { summary: "Retrieve a verifier-ready Directory service profile envelope", diff --git a/reference-implementations/dacs-directory/src/catalog/currentReconciliation.ts b/reference-implementations/dacs-directory/src/catalog/currentReconciliation.ts index 507478c..a01e7c5 100644 --- a/reference-implementations/dacs-directory/src/catalog/currentReconciliation.ts +++ b/reference-implementations/dacs-directory/src/catalog/currentReconciliation.ts @@ -38,10 +38,28 @@ export function currentBundleCopiesDiverge( buyerBundle: Record, sellerBundle: Record, ): boolean { - return flipOutcome(String(buyerBundle.outcome)) !== String(sellerBundle.outcome) || + const buyerType = buyerBundle.faultBundleVersion === "1" ? "fault" : buyerBundle.bundleVersion === "1" ? "legacy" : "unknown"; + const sellerType = sellerBundle.faultBundleVersion === "1" ? "fault" : sellerBundle.bundleVersion === "1" ? "legacy" : "unknown"; + return buyerType !== sellerType || + (buyerType === "fault" && buyerBundle.faultedParty !== sellerBundle.faultedParty) || + flipOutcome(String(buyerBundle.outcome)) !== String(sellerBundle.outcome) || phaseSummariesDiverge(buyerBundle.phaseSummary, sellerBundle.phaseSummary); } +function sellerRelativeOutcome(graph: EvidenceGraph, sellerClaim: string): string { + const outcome = String(graph.bundle.outcome ?? ""); + if (graph.bundle.faultBundleVersion !== "1") { + return graph.bundle.anchoredByRole === "seller" ? outcome : (flipOutcome(outcome) ?? ""); + } + if (outcome === "completed" || outcome === "failed-substrate") return outcome; + const sellerRole = roleOf(graph, sellerClaim); + const sellerFaulted = graph.bundle.faultedParty === sellerRole; + const abort = outcome === "aborted-by-self" || outcome === "aborted-by-other"; + return abort + ? sellerFaulted ? "aborted-by-self" : "aborted-by-other" + : sellerFaulted ? "failed-perm" : "failed-counterparty"; +} + const roleOf = (graph: EvidenceGraph | null, claim: string) => { const parties = records(graph?.bundle.parties); return parties.find((party) => String(party.primaryClaim).toLowerCase() === claim.toLowerCase())?.role; @@ -65,9 +83,7 @@ export function reconcileCurrentCopies( ); const authoritative = sellerOk ? sellerGraph! : buyerGraph; const refsVerified = Boolean(sellerOk && buyerOk && !divergent && authoritative.refsVerified); - const sellerOutcome = authoritative === sellerGraph - ? String(authoritative.bundle.outcome ?? "") - : flipOutcome(String(authoritative.bundle.outcome ?? "")); + const sellerOutcome = sellerRelativeOutcome(authoritative, sellerClaim); const selectedLocator = authoritative === sellerGraph ? deal.sellerBundleRef! : deal.buyerBundleRef; return { authoritative, diff --git a/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts b/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts index 25de236..670bf12 100644 --- a/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts +++ b/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts @@ -43,6 +43,7 @@ const SEPARATORS: Record, string> = { composite: "dacs-composite:v1:", rating: "dacs-rating:v1:", listing: "dacs-listing:v1:", bundle: "dacs-bundle:v1:", }; const PAYEE_BOUND_AGREEMENT_SEPARATOR = "dacs-payee-bound-agreement:v1:"; +const FAULT_BUNDLE_SEPARATOR = "dacs-fault-bundle:v1:"; type AgreementProfile = "legacy" | "payee-bound"; @@ -129,12 +130,37 @@ export function verifyComponentSignature(raw: Record, kind: Art if (!key || !value || !separator) return false; return ed25519Verify(Buffer.from(separator + artifactHash(raw, kind), "utf8"), value, key); } +type BundleProfile = "legacy" | "fault"; + +function bundleProfile(raw: Record): BundleProfile | null { + const legacy = Object.prototype.hasOwnProperty.call(raw, "bundleVersion"); + const fault = Object.prototype.hasOwnProperty.call(raw, "faultBundleVersion"); + if (legacy === fault) return null; + if (legacy) return raw.bundleVersion === "1" && raw.faultedParty === undefined ? "legacy" : null; + return raw.faultBundleVersion === "1" ? "fault" : null; +} + function verifyBundleSignature(raw: Record, signature: Record): boolean { const party = signature.party; if (signature.algorithm !== "ed25519" || typeof party !== "string") return false; const key = claimKey(party); const value = decodeSignature(signature.value); - if (!key || !value) return false; - return ed25519Verify(Buffer.from(SEPARATORS.bundle + artifactHash(raw, "bundle"), "utf8"), value, key); + const profile = bundleProfile(raw); + if (!key || !value || !profile) return false; + const separator = profile === "fault" ? FAULT_BUNDLE_SEPARATOR : SEPARATORS.bundle; + return ed25519Verify(Buffer.from(separator + artifactHash(raw, "bundle"), "utf8"), value, key); +} + +function faultAttributionOk(raw: Record, parties: Record[]): boolean { + if (bundleProfile(raw) !== "fault") return true; + const anchored = raw.anchoredByRole; + const faulted = raw.faultedParty; + const roles = new Set(parties.map((party) => party.role)); + if (raw.outcome === "completed" || raw.outcome === "failed-substrate") return faulted === "none"; + if (raw.outcome === "failed-perm" || raw.outcome === "aborted-by-self") return faulted === anchored; + if (raw.outcome === "failed-counterparty" || raw.outcome === "aborted-by-other") { + return typeof faulted === "string" && roles.has(faulted) && faulted !== anchored; + } + return false; } export function isCurrentRef(value: unknown): value is CurrentRef { @@ -196,11 +222,12 @@ export async function buildCurrentEvidenceGraph(bundleLocator: string, deps: Evi const fail = (reason: string): EvidenceGraph => ({ profile: "dacs-v0.1", ok: false, reason, bundle: raw ?? {}, bundleContentHash: raw ? artifactHash(raw, "bundle") : "", signaturesVerified: false, refsVerified: false, artifacts: [], ratings: [] }); const phases = arr(raw?.phaseSummary); const phaseIndexes = phases.map((phase) => phase.index); - if (!raw || !withinArtifactLimit(raw) || raw.signature !== undefined || raw.bundleVersion !== "1" || typeof raw.jobId !== "string" || !OUTCOMES.has(String(raw.outcome)) || !["buyer", "seller", "orchestrator"].includes(String(raw.anchoredByRole)) || !rec(raw.listingRef) || arr(raw.parties).length < 2 || !Array.isArray(raw.phaseSummary) || phases.length !== raw.phaseSummary.length || phases.some((phase) => !Number.isSafeInteger(phase.index) || Number(phase.index) < 0 || typeof phase.kind !== "string" || !PHASE_OUTCOMES.has(String(phase.outcome)) || (phase.errorClass !== undefined && !ERROR_CLASSES.has(String(phase.errorClass)))) || new Set(phaseIndexes).size !== phaseIndexes.length || !Array.isArray(raw.vetRecords) || !Array.isArray(raw.settlementEvidence) || (raw.ratingRefs !== undefined && !Array.isArray(raw.ratingRefs)) || (raw.amendments !== undefined && (!Array.isArray(raw.amendments) || raw.amendments.length > 0)) || !Number.isSafeInteger(raw.recipeRegistryVersion) || Number(raw.recipeRegistryVersion) < 1 || !Number.isSafeInteger(raw.railRegistryVersion) || Number(raw.railRegistryVersion) < 1 || typeof raw.finalisedAt !== "number") return fail("invalid current DACS-5 bundle shape"); + if (!raw || !withinArtifactLimit(raw) || raw.signature !== undefined || !bundleProfile(raw) || typeof raw.jobId !== "string" || !OUTCOMES.has(String(raw.outcome)) || !["buyer", "seller", "orchestrator"].includes(String(raw.anchoredByRole)) || !rec(raw.listingRef) || arr(raw.parties).length < 2 || !Array.isArray(raw.phaseSummary) || phases.length !== raw.phaseSummary.length || phases.some((phase) => !Number.isSafeInteger(phase.index) || Number(phase.index) < 0 || typeof phase.kind !== "string" || !PHASE_OUTCOMES.has(String(phase.outcome)) || (phase.errorClass !== undefined && !ERROR_CLASSES.has(String(phase.errorClass)))) || new Set(phaseIndexes).size !== phaseIndexes.length || !Array.isArray(raw.vetRecords) || !Array.isArray(raw.settlementEvidence) || (raw.ratingRefs !== undefined && !Array.isArray(raw.ratingRefs)) || (raw.amendments !== undefined && (!Array.isArray(raw.amendments) || raw.amendments.length > 0)) || !Number.isSafeInteger(raw.recipeRegistryVersion) || Number(raw.recipeRegistryVersion) < 1 || !Number.isSafeInteger(raw.railRegistryVersion) || Number(raw.railRegistryVersion) < 1 || typeof raw.finalisedAt !== "number") return fail("invalid current DACS-5 bundle shape"); const signatures = arr(raw.signatures); const parties = arr(raw.parties); if (new Set(parties.map((party) => party.role)).size !== parties.length || !parties.some((party) => party.role === "buyer") || !parties.some((party) => party.role === "seller") || parties.some((party) => !["buyer", "seller", "orchestrator"].includes(String(party.role)) || typeof party.primaryClaim !== "string" || !/^[0-9a-f]{64}$/.test(normalizedHash(party.bundleHash)))) return fail("invalid or duplicate bundle parties"); + if (!faultAttributionOk(raw, parties)) return fail("invalid fault attribution"); if (!Array.isArray(raw.signatures) || signatures.length !== raw.signatures.length) { return fail("bundle signatures contain malformed entries"); } diff --git a/reference-implementations/dacs-directory/src/catalog/indexer.ts b/reference-implementations/dacs-directory/src/catalog/indexer.ts index 9643d1b..a495034 100644 --- a/reference-implementations/dacs-directory/src/catalog/indexer.ts +++ b/reference-implementations/dacs-directory/src/catalog/indexer.ts @@ -41,6 +41,11 @@ import { deriveSellerReputation, flipOutcome, isNeutralCancellation } from "./re import { agreementPrice, buildCurrentEvidenceGraph, type EvidenceGraph } from "./evidenceGraph.js"; import { agreementRail } from "./agreementMetadata.js"; import { currentBundleCopiesDiverge, reconcileCurrentCopies } from "./currentReconciliation.js"; +import { + bundleBindingRoleKey, + resolveBundleSide, + verifyBundleBinding, +} from "./bundleBinding.js"; import { safePublicEndpoint } from "./publicEndpoint.js"; import { deriveIdentityTier, type ResolveRecipe } from "./identityVerification.js"; import { @@ -56,6 +61,7 @@ import { import type { CciBadge, DealRecord, + BundleBinding, ListingSummary, Registration, SellerRecord, @@ -212,7 +218,17 @@ export async function indexRegistration( // ── Deals: dereference + verify each bundle from chain ──────────────────── const dealCandidates: DealRecord[] = []; const categoriesByListing = new Map(listings.map((l) => [l.listingId, l.offering.category])); + const scanState = loadScanState(); + const overflowBindings = new Set(scanState.bundleBindingOverflow ?? []); + const relevantJobs = new Set((reg.deals ?? []).map((deal) => deal.jobId)); + const rawBindings = [ + ...[...relevantJobs].flatMap((jobId) => scanState.bundleBindings?.[jobId] ?? []), + ...(reg.bundleBindings ?? []).filter((binding) => relevantJobs.has(binding.jobId)), + ]; + const verifiedBindings = (await Promise.all(rawBindings.map(verifyBundleBinding))) + .filter((binding): binding is BundleBinding => binding !== null); for (const deal of reg.deals ?? []) { + const jobBindings = verifiedBindings.filter((binding) => binding.jobId === deal.jobId); const buyerInitial = await readAnchor(deal.buyerBundleRef); const resolveListing = async (ref: Record) => { const id = String(ref.listingId ?? ""); @@ -221,17 +237,91 @@ export async function indexRegistration( return listingArtifacts.get(`${id}\n${version}\n${hash}`) ?? null; }; const graphFor = (locator: string) => buildCurrentEvidenceGraph(locator, { read: readAnchor, resolveListing }); - const sellerCurrentProbe = buyerInitial?.bundleVersion === "1" || !deal.sellerBundleRef + const buyerIsCurrent = buyerInitial?.bundleVersion === "1" || buyerInitial?.faultBundleVersion === "1"; + const sellerCurrentProbe = buyerIsCurrent || jobBindings.length > 0 || !deal.sellerBundleRef ? null : await graphFor(deal.sellerBundleRef); // Profile selection is based on the signed format discriminator, not on // whether verification succeeds. A malformed current bundle must fail in // the current verifier instead of being retried by the legacy verifier. - if (buyerInitial?.bundleVersion === "1" || sellerCurrentProbe?.bundle.bundleVersion === "1") { - const buyerGraph = await graphFor(deal.buyerBundleRef); - const sellerGraph = sellerCurrentProbe ?? (deal.sellerBundleRef ? await graphFor(deal.sellerBundleRef) : null); + if (jobBindings.length > 0 || buyerIsCurrent || sellerCurrentProbe?.bundle.bundleVersion === "1" || sellerCurrentProbe?.bundle.faultBundleVersion === "1") { + const graphCache = new Map>(); + if (sellerCurrentProbe && deal.sellerBundleRef) { + graphCache.set(deal.sellerBundleRef, Promise.resolve(sellerCurrentProbe)); + } + const inspect = async (binding: BundleBinding) => { + let pending = graphCache.get(binding.nativeAddress); + if (!pending) { + pending = graphFor(binding.nativeAddress); + graphCache.set(binding.nativeAddress, pending); + } + const graph = await pending; + const parties = Array.isArray(graph.bundle.parties) + ? graph.bundle.parties.filter((party): party is Record => + Boolean(party && typeof party === "object" && !Array.isArray(party))) + : []; + const roleHolder = parties.find((party) => party.role === binding.role)?.primaryClaim; + if ( + !graph.ok || graph.bundle.jobId !== binding.jobId || + graph.bundle.anchoredByRole !== binding.role || + typeof roleHolder !== "string" || + canonicalDemosAgentClaim(roleHolder) !== canonicalDemosAgentClaim(binding.signer) + ) return null; + const signed = new Set( + (Array.isArray(graph.bundle.signatures) ? graph.bundle.signatures : []) + .map((signature) => signature && typeof signature === "object" && !Array.isArray(signature) + ? (signature as Record).party : undefined) + .filter((party): party is string => typeof party === "string") + .map(canonicalDemosAgentClaim) + .filter((party): party is string => party !== null), + ); + const required = parties.filter((party) => + party.role === "buyer" || party.role === "seller" || party.role === "orchestrator") + .map((party) => party.primaryClaim) + .filter((claim): claim is string => typeof claim === "string") + .map(canonicalDemosAgentClaim) + .filter((claim): claim is string => claim !== null); + return { + value: graph, + bundleContentHash: graph.bundleContentHash, + fullSignatureStanding: required.length >= 2 && required.every((claim) => signed.has(claim)), + }; + }; + const buyerResolution = await resolveBundleSide({ + jobId: deal.jobId, + role: "buyer", + expectedSigner: deal.owners.buyer, + bindings: jobBindings, + overflow: overflowBindings.has(bundleBindingRoleKey(deal.jobId, "buyer")), + inspect, + }); + const sellerResolution = await resolveBundleSide({ + jobId: deal.jobId, + role: "seller", + expectedSigner: deal.owners.seller, + bindings: jobBindings, + overflow: overflowBindings.has(bundleBindingRoleKey(deal.jobId, "seller")), + inspect, + }); + if (buyerResolution.disposition !== "present" || sellerResolution.disposition !== "present") { + dealCandidates.push({ + ...deal, + signatureVerified: false, + refsVerified: false, + reputationEligible: false, + verifiedAt: now, + }); + continue; + } + const resolvedDeal = { + ...deal, + buyerBundleRef: buyerResolution.binding.nativeAddress, + sellerBundleRef: sellerResolution.binding.nativeAddress, + }; + const buyerGraph = buyerResolution.inspected.value; + const sellerGraph = sellerResolution.inspected.value; const { authoritative, buyerOk, sellerOk, refsVerified, sellerOutcome, selectedLocator } = - reconcileCurrentCopies(deal, reg.primaryClaim, buyerGraph, sellerGraph); + reconcileCurrentCopies(resolvedDeal, reg.primaryClaim, buyerGraph, sellerGraph); const parties = Array.isArray(authoritative?.bundle.parties) ? authoritative.bundle.parties as Array> : []; const ratings = (authoritative?.ratings ?? []).filter((rating) => rating.jobId === deal.jobId && parties.some((party) => party.primaryClaim === rating.rater), @@ -254,7 +344,7 @@ export async function indexRegistration( sellerOutcome, cancellation, authoritative?.listing?.terms, authoritative?.bundle.phaseSummary, ); dealCandidates.push({ - ...deal, signatureVerified: Boolean(authoritative?.signaturesVerified), refsVerified, + ...resolvedDeal, signatureVerified: Boolean(authoritative?.signaturesVerified), refsVerified, rail: agreementRail(authoritative?.agreement) ?? "unknown", outcome: String(authoritative?.bundle.outcome ?? "") || undefined, sellerOutcome, anchoredByRole: authoritative?.bundle.anchoredByRole as DealRecord["anchoredByRole"], diff --git a/reference-implementations/dacs-directory/src/catalog/registration.ts b/reference-implementations/dacs-directory/src/catalog/registration.ts index 8cc0315..269e10e 100644 --- a/reference-implementations/dacs-directory/src/catalog/registration.ts +++ b/reference-implementations/dacs-directory/src/catalog/registration.ts @@ -1,4 +1,4 @@ -import type { RegisteredDeal, Registration } from "./types.js"; +import type { BundleBinding, RegisteredDeal, Registration } from "./types.js"; import { isCanonicalDemosAgentClaim } from "./claimRef.js"; const ANCHOR = /^stor-[0-9a-f]{40}$/; @@ -60,6 +60,14 @@ export function parseRegistration(v: unknown): RegistrationParseResult { if (deals?.some((d) => d === null)) { return { ok: false, error: "one or more deal entries are malformed" }; } + if ( + b.bundleBindings !== undefined && + (!Array.isArray(b.bundleBindings) || b.bundleBindings.length > 256 || + b.bundleBindings.some((binding) => !binding || typeof binding !== "object" || Array.isArray(binding) || + Buffer.byteLength(JSON.stringify(binding), "utf8") > 16_384)) + ) { + return { ok: false, error: "bundleBindings must contain at most 256 bounded JSON objects" }; + } let ownerSignature: Registration["ownerSignature"]; if (b.ownerSignature !== undefined) { @@ -81,6 +89,7 @@ export function parseRegistration(v: unknown): RegistrationParseResult { displayName: b.displayName.trim(), listingAnchors: [...new Set(b.listingAnchors as string[])], ...(deals ? { deals: deals as RegisteredDeal[] } : {}), + ...(Array.isArray(b.bundleBindings) ? { bundleBindings: b.bundleBindings as BundleBinding[] } : {}), ...(ownerSignature ? { ownerSignature } : {}), }, }; diff --git a/reference-implementations/dacs-directory/src/catalog/registrationSig.ts b/reference-implementations/dacs-directory/src/catalog/registrationSig.ts index 292361d..5987d0e 100644 --- a/reference-implementations/dacs-directory/src/catalog/registrationSig.ts +++ b/reference-implementations/dacs-directory/src/catalog/registrationSig.ts @@ -13,18 +13,24 @@ import { canonicalize, sha256Hex } from "@kynesyslabs/dacs/canonical"; import type { Registration } from "./types.js"; export function registrationMessage( - reg: Pick, + reg: Pick, signedAt: number, ): string { // Stable, human-inspectable signing payload (what the wallet shows). - return [ + const lines = [ "dacs-directory registration", `claim:${reg.primaryClaim}`, `name:${reg.displayName}`, `anchors:${sha256Hex(JSON.stringify([...reg.listingAnchors].sort()))}`, `deals:${sha256Hex(canonicalize(reg.deals ?? []))}`, - `at:${signedAt}`, - ].join("\n"); + ]; + // Preserve existing signed registrations byte-for-byte when no bindings + // were carried; new registrations bind the self-authenticating carrier set. + if (reg.bundleBindings !== undefined) { + lines.push(`bundle-bindings:${sha256Hex(canonicalize(reg.bundleBindings))}`); + } + lines.push(`at:${signedAt}`); + return lines.join("\n"); } function sigBytes(signature: string): Uint8Array | null { diff --git a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts index b5b0cf9..29e3135 100644 --- a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts +++ b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts @@ -6,6 +6,7 @@ */ import { createHash } from "node:crypto"; import { indexRegistration, type ResolveIdentities } from "./indexer"; +import { boundedBundleBindings, verifyBundleBinding } from "./bundleBinding"; import { boundedRevocationCandidates, readChainTip, @@ -75,7 +76,7 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise 0 ? configuredMax : 100000; const configuredOverlap = Number(process.env.DACS_SCAN_REPLAY_DEPTH ?? 2); const overlap = Number.isSafeInteger(configuredOverlap) && configuredOverlap >= 0 ? configuredOverlap : 2; const sinceTxId = needsBindingBackfill ? 0 : Math.max(0, state.lastSeenTxId - overlap); state.verifiedRevocations ??= {}; + state.bundleBindings ??= {}; + state.bundleBindingOverflow ??= []; + // Registrations are untrusted carriage (BB-3). Re-verify on every ingest so + // hand-edited or legacy persisted JSON cannot bypass the BB-4 gate. + for (const reg of regs) { + const verified = (await Promise.all((reg.bundleBindings ?? []).map(verifyBundleBinding))) + .filter((binding) => binding !== null); + for (const binding of verified) { + const bounded = boundedBundleBindings([...(state.bundleBindings[binding.jobId] ?? []), binding]); + state.bundleBindings[binding.jobId] = bounded.bindings; + state.bundleBindingOverflow = [...new Set([ + ...state.bundleBindingOverflow, + ...bounded.overflowKeys, + ])].sort(); + } + } for (const seller of prior.sellers) for (const listing of seller.listings) { const locator = listing.revocationBinding?.markerAnchor.locator; if (!locator) continue; @@ -147,6 +165,18 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise d.jobId === deal.jobId)) reg.deals.push(deal); + const bindings = state.bundleBindings[deal.jobId] ?? []; + if (bindings.length > 0) { + reg.bundleBindings ??= []; + const knownBindings = new Set(reg.bundleBindings.map((binding) => JSON.stringify(binding))); + for (const binding of bindings) { + const key = JSON.stringify(binding); + if (!knownBindings.has(key)) { + reg.bundleBindings.push(binding); + knownBindings.add(key); + } + } + } } // ── Channel 3: §6.3.5 well-known crawl (hash-bound per-agent indexes) ── const domains = loadDomains(); @@ -268,7 +310,22 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise 0) { + reg.bundleBindings ??= []; + const carried = boundedBundleBindings([...reg.bundleBindings, ...agent.bundleBindings]); + reg.bundleBindings = carried.bindings; + for (const binding of carried.bindings) { + const accumulated = boundedBundleBindings([...(state.bundleBindings[binding.jobId] ?? []), binding]); + state.bundleBindings[binding.jobId] = accumulated.bindings; + state.bundleBindingOverflow = [...new Set([ + ...state.bundleBindingOverflow, + ...carried.overflowKeys, + ...accumulated.overflowKeys, + ])].sort(); + } + } + log(`well-known: ${agent.domain} → ${agent.seller.slice(0, 30)}… (+${agent.listingAnchors.length} anchor(s), ` + + `+${agent.bundleBindings.length} bundle binding(s), index hash ✓)`); } } const allRegs = [...regs, ...discovered.values()]; diff --git a/reference-implementations/dacs-directory/src/catalog/scan.ts b/reference-implementations/dacs-directory/src/catalog/scan.ts index fda9ffb..859c587 100644 --- a/reference-implementations/dacs-directory/src/catalog/scan.ts +++ b/reference-implementations/dacs-directory/src/catalog/scan.ts @@ -17,7 +17,11 @@ */ import { programBindingKey } from "./store.js"; import { agreementRail } from "./agreementMetadata.js"; -import type { RegisteredDeal } from "./types.js"; +import { + boundedBundleBindings, + verifyBundleBinding, +} from "./bundleBinding.js"; +import type { BundleBinding, RegisteredDeal } from "./types.js"; import { contentHash } from "@kynesyslabs/dacs/canonical"; const RPC = (process.env.DEMOS_RPC ?? "https://demosnode.discus.sh/").replace(/\/$/, ""); @@ -36,6 +40,10 @@ export interface ScannedArtifacts { revocations: Map; /** Candidate locators discarded by the per-listing resource bound. */ revocationCandidatesTruncated: number; + /** jobId → BB-4-verified BundleBindings discovered in this scan window. */ + bundleBindings: Map; + /** jobId + role keys whose deterministic total-work cap was exhausted. */ + bundleBindingOverflow: Set; txsScanned: number; /** Highest tx id observed — the next pass's cursor. */ highestTxId: number; @@ -399,6 +407,8 @@ export async function scanChain( const programs = new Map(); const revocations = new Map(); let revocationCandidatesTruncated = 0; + const bundleBindings = new Map(); + const bundleBindingOverflow = new Set(); const observations: ScannedArtifacts["observations"] = []; const failures: ScannedArtifacts["failures"] = []; const bundleOwners = new Map(); // jobId → buyer bundle @@ -416,9 +426,19 @@ export async function scanChain( programs.set(programBindingKey(read.owner, name), address); const data = read.data as Record | undefined; const currentListing = data?.dacsVersion === "1" && typeof data.listingId === "string" && typeof data.listingVersion === "number"; - const currentBundle = data?.bundleVersion === "1" && typeof data.jobId === "string" && Array.isArray(data.parties); + const currentBundle = (data?.bundleVersion === "1" || data?.faultBundleVersion === "1") && + typeof data.jobId === "string" && Array.isArray(data.parties); let artifactKind = "other"; - if (isListingRevocationCandidate(data)) { + const verifiedBundleBinding = data?.bindingVersion === "1" + ? await verifyBundleBinding(data) + : null; + if (verifiedBundleBinding) { + artifactKind = "bundle-binding"; + const prior = bundleBindings.get(verifiedBundleBinding.jobId) ?? []; + const bounded = boundedBundleBindings([...prior, verifiedBundleBinding]); + bundleBindings.set(verifiedBundleBinding.jobId, bounded.bindings); + for (const key of bounded.overflowKeys) bundleBindingOverflow.add(key); + } else if (isListingRevocationCandidate(data)) { artifactKind = "listing-revocation"; const listingHash = String(data!.listingContentHash).toLowerCase(); revocationCandidatesTruncated += addRevocationCandidate( @@ -488,5 +508,6 @@ export async function scanChain( } return { listings, deals, programs, revocations, revocationCandidatesTruncated, + bundleBindings, bundleBindingOverflow, txsScanned: scanned, highestTxId, complete, chainTip, observations, failures, scanError }; } diff --git a/reference-implementations/dacs-directory/src/catalog/types.ts b/reference-implementations/dacs-directory/src/catalog/types.ts index 7980733..81cce82 100644 --- a/reference-implementations/dacs-directory/src/catalog/types.ts +++ b/reference-implementations/dacs-directory/src/catalog/types.ts @@ -21,6 +21,12 @@ export interface Registration { * before counting it, and the UI re-verifies in the visitor's browser. */ deals?: RegisteredDeal[]; + /** + * Signed DACS-5 logical-to-native bundle mappings carried by this catalog + * registration. The carrier is not trusted: every record is independently + * verified under BB-4 before it can be persisted, served, or used. + */ + bundleBindings?: BundleBinding[]; /** * Optional owner signature: ed25519 over the canonical registration message * (see register route) by the primaryClaim's key, produced by the Demos @@ -31,6 +37,25 @@ export interface Registration { ownerSignature?: { message: string; signature: string; signedAt: number }; } +/** DACS-5 §10.4.2 signed logical-to-native mapping for one anchored copy. */ +export interface BundleBinding { + bindingVersion: "1"; + jobId: string; + role: "buyer" | "seller" | "orchestrator"; + logicalAddress: string; + nativeAddress: string; + bundleContentHash: string; + anchorTx?: string; + signer: string; + signature: { + algorithm: "ed25519"; + signer: string; + value: string; + }; + /** Preserve signed additive fields even when this reader does not interpret them. */ + [key: string]: unknown; +} + export interface RegisteredDeal { jobId: string; rail: string; @@ -254,6 +279,10 @@ export interface ScanState { revocations?: Record; /** RB-4-verified marker locators that candidate pruning must preserve. */ verifiedRevocations?: Record; + /** jobId → BB-4-verified BundleBindings known to the catalog. */ + bundleBindings?: Record; + /** jobId + role keys whose total discovery cap was exhausted (BB-7 indeterminate). */ + bundleBindingOverflow?: string[]; /** listing anchor address → owner address */ listings: Record; /** jobId → discovered deal */ diff --git a/reference-implementations/dacs-directory/src/catalog/wellknown.ts b/reference-implementations/dacs-directory/src/catalog/wellknown.ts index 6be5368..2a5887a 100644 --- a/reference-implementations/dacs-directory/src/catalog/wellknown.ts +++ b/reference-implementations/dacs-directory/src/catalog/wellknown.ts @@ -14,6 +14,8 @@ */ import { sha256Hex } from "@kynesyslabs/dacs/canonical"; import { boundedPublicHttpsRequest, isPrivateAddress, validatePublicHttpsUrl } from "./boundedHttps.js"; +import { verifyBundleBinding } from "./bundleBinding.js"; +import type { BundleBinding } from "./types.js"; export { isPrivateAddress } from "./boundedHttps.js"; @@ -24,11 +26,14 @@ export interface WellKnownAgent { listingAnchors: string[]; /** Per-anchor content hash asserted by the index (checked by the indexer). */ contentHashes: Record; + /** BB-4-verified records from the optional DACS-1 bundle-binding index. */ + bundleBindings: BundleBinding[]; } interface DacsBlock { dacsVersion?: string; listings?: { indexUrl?: string; indexHash?: string }; + bundleBindings?: { indexUrl?: string; indexHash?: string }; } interface ListingIndex { indexVersion?: string; @@ -116,7 +121,23 @@ export async function crawlDomain(domain: string): Promise 256) { + return { domain, error: "bundle-binding index must contain at most 256 records" }; + } + const verified = await Promise.all(carried.map(verifyBundleBinding)); + for (const binding of verified) if (binding) bundleBindings.push(binding); + } + return { domain, seller, displayName, listingAnchors, contentHashes, bundleBindings }; } export async function crawlDomains(domains: string[]): Promise<{ diff --git a/reference-implementations/dacs-directory/test/bundle-binding.test.ts b/reference-implementations/dacs-directory/test/bundle-binding.test.ts new file mode 100644 index 0000000..cc3b58b --- /dev/null +++ b/reference-implementations/dacs-directory/test/bundle-binding.test.ts @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { contentHash } from "@kynesyslabs/dacs/canonical"; +import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; +import { + boundedBundleBindings, + bundleBindingRoleKey, + logicalBundleAddress, + resolveBundleSide, + verifyBundleBinding, +} from "../src/catalog/bundleBinding.js"; +import type { BundleBinding } from "../src/catalog/types.js"; + +const seeds = [31, 32].map((byte) => Uint8Array.from(Buffer.alloc(32, byte))); +const dids = seeds.map((seed) => + `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex")}`); +const native = (n: number) => `stor-${n.toString(16).padStart(40, "0")}`; + +async function binding(options: { + jobId?: string; + role?: BundleBinding["role"]; + signer?: number; + native?: string; + hash?: string; + logical?: string; +} = {}): Promise { + const jobId = options.jobId ?? "job-binding-1"; + const role = options.role ?? "buyer"; + const signer = options.signer ?? 0; + const scope = { + bindingVersion: "1" as const, + jobId, + role, + logicalAddress: options.logical ?? logicalBundleAddress(jobId, role), + nativeAddress: options.native ?? native(1), + bundleContentHash: options.hash ?? "a".repeat(64), + signer: dids[signer], + }; + const value = Buffer.from(await ed25519Sign( + Buffer.from(`dacs-bundle-binding:v1:${contentHash(scope)}`, "utf8"), + privateKeyFromSeed(seeds[signer]), + )).toString("base64url"); + return { ...scope, signature: { algorithm: "ed25519", signer: dids[signer], value } }; +} + +test("BB-4 verifies the signed scope and derives the normative logical address", async () => { + const candidate = await binding(); + assert.equal(logicalBundleAddress(candidate.jobId, candidate.role), candidate.logicalAddress); + assert.deepEqual(await verifyBundleBinding(candidate), candidate); + assert.equal(await verifyBundleBinding({ ...candidate, nativeAddress: native(2) }), null); + assert.equal(await verifyBundleBinding({ + ...candidate, + signature: { ...candidate.signature, signer: dids[1] }, + }), null); + assert.equal(await verifyBundleBinding({ + ...candidate, + signature: { ...candidate.signature, value: `${candidate.signature.value}==` }, + }), null, "SIG-6 rejects padded base64url"); +}); + +test("BB-5 request matching rejects a validly signed inconsistent logical mapping", async () => { + const wrong = await binding({ logical: `stor-${"f".repeat(64)}` }); + assert.ok(await verifyBundleBinding(wrong), "BB-4 alone is valid"); + const resolution = await resolveBundleSide({ + jobId: wrong.jobId, + role: "buyer", + expectedSigner: dids[0], + bindings: [wrong], + inspect: async () => ({ value: "unused", bundleContentHash: wrong.bundleContentHash, fullSignatureStanding: true }), + }); + assert.deepEqual(resolution, { disposition: "indeterminate", reason: "no verified BundleBinding for role" }); +}); + +test("BB-6 prefers one fully signed canonical group and makes equal standing divergence indeterminate", async () => { + const lesser = await binding({ native: native(1), hash: "a".repeat(64) }); + const full = await binding({ native: native(2), hash: "b".repeat(64) }); + const selected = await resolveBundleSide({ + jobId: full.jobId, + role: "buyer", + expectedSigner: dids[0], + bindings: [lesser, full], + inspect: async (candidate) => ({ + value: candidate.nativeAddress, + bundleContentHash: candidate.bundleContentHash, + fullSignatureStanding: candidate.nativeAddress === full.nativeAddress, + }), + }); + assert.equal(selected.disposition, "present"); + if (selected.disposition === "present") assert.equal(selected.binding.nativeAddress, full.nativeAddress); + + const divergent = await resolveBundleSide({ + jobId: full.jobId, + role: "buyer", + expectedSigner: dids[0], + bindings: [lesser, full], + inspect: async (candidate) => ({ value: candidate.nativeAddress, bundleContentHash: candidate.bundleContentHash, fullSignatureStanding: true }), + }); + assert.deepEqual(divergent, { disposition: "indeterminate", reason: "authorized equal-standing bundle copies diverge" }); +}); + +test("BB-6 budget and the total discovery ceiling fail closed", async () => { + const candidates = await Promise.all(Array.from({ length: 9 }, (_, index) => + binding({ native: native(index + 1), hash: (index + 1).toString(16).repeat(64).slice(0, 64) }))); + let inspected = 0; + const exhausted = await resolveBundleSide({ + jobId: candidates[0].jobId, + role: "buyer", + expectedSigner: dids[0], + bindings: candidates, + inspect: async (candidate) => { + inspected++; + return { value: candidate, bundleContentHash: candidate.bundleContentHash, fullSignatureStanding: true }; + }, + }); + assert.deepEqual(exhausted, { disposition: "indeterminate", reason: "BB-6 per-signer fetch budget exhausted" }); + assert.equal(inspected, 0, "budget exhaustion does no partial authoritative read"); + + const bounded = boundedBundleBindings(candidates, 8); + assert.equal(bounded.bindings.length, 8); + assert.deepEqual(bounded.overflowKeys, [bundleBindingRoleKey(candidates[0].jobId, "buyer")]); + const overflow = await resolveBundleSide({ + jobId: candidates[0].jobId, + role: "buyer", + expectedSigner: dids[0], + bindings: bounded.bindings, + overflow: true, + inspect: async () => { throw new Error("must not fetch"); }, + }); + assert.deepEqual(overflow, { disposition: "indeterminate", reason: "bundle-binding discovery cap exhausted" }); +}); + +test("BB-5 poisoned content hashes are inert and cannot win selection", async () => { + const poisoned = await binding({ native: native(1), hash: "a".repeat(64) }); + const honest = await binding({ native: native(2), hash: "b".repeat(64) }); + const resolution = await resolveBundleSide({ + jobId: honest.jobId, + role: "buyer", + expectedSigner: dids[0], + bindings: [poisoned, honest], + inspect: async (candidate) => ({ + value: candidate.nativeAddress, + bundleContentHash: candidate === poisoned ? "c".repeat(64) : candidate.bundleContentHash, + fullSignatureStanding: true, + }), + }); + assert.equal(resolution.disposition, "present"); + if (resolution.disposition === "present") assert.equal(resolution.binding.nativeAddress, honest.nativeAddress); +}); + +test("BB-6 prunes outsider signers before fetch and orders authorized work by hash then address", async () => { + const later = await binding({ native: native(1), hash: "b".repeat(64) }); + const earlier = await binding({ native: native(2), hash: "a".repeat(64) }); + const outsider = await binding({ signer: 1, native: native(3), hash: "0".repeat(64) }); + const inspected: string[] = []; + const resolution = await resolveBundleSide({ + jobId: earlier.jobId, + role: "buyer", + expectedSigner: dids[0], + bindings: [later, outsider, earlier], + inspect: async (candidate) => { + inspected.push(candidate.nativeAddress); + return { value: candidate, bundleContentHash: candidate.bundleContentHash, fullSignatureStanding: true }; + }, + }); + assert.deepEqual(inspected, [earlier.nativeAddress, later.nativeAddress]); + assert.equal(resolution.disposition, "indeterminate", "two authorized full-standing hashes equivocate"); + assert.ok(!inspected.includes(outsider.nativeAddress)); +}); diff --git a/reference-implementations/dacs-directory/test/current-indexer.test.ts b/reference-implementations/dacs-directory/test/current-indexer.test.ts index 37679cd..10160ec 100644 --- a/reference-implementations/dacs-directory/test/current-indexer.test.ts +++ b/reference-implementations/dacs-directory/test/current-indexer.test.ts @@ -4,12 +4,13 @@ import { contentHash } from "@kynesyslabs/dacs/canonical"; import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; import { artifactHash, buildCurrentEvidenceGraph, signedScope } from "../src/catalog/evidenceGraph.js"; import { agreementRail } from "../src/catalog/agreementMetadata.js"; -import { reconcileCurrentCopies } from "../src/catalog/currentReconciliation.js"; +import { currentBundleCopiesDiverge, reconcileCurrentCopies } from "../src/catalog/currentReconciliation.js"; import { deriveIdentityTier, type RecipePolicy } from "../src/catalog/identityVerification.js"; import { indexRegistration, listingBindingRejection } from "../src/catalog/indexer.js"; import { deriveSellerReputation, isNeutralCancellation } from "../src/catalog/reputation.js"; import { verifyListing } from "../src/catalog/listingVerification.js"; -import type { DealRecord, RegisteredDeal } from "../src/catalog/types.js"; +import { logicalBundleAddress } from "../src/catalog/bundleBinding.js"; +import type { BundleBinding, DealRecord, RegisteredDeal } from "../src/catalog/types.js"; type Obj = Record; const seeds = [11, 12].map((byte) => Uint8Array.from(Buffer.alloc(32, byte))); @@ -20,7 +21,10 @@ const sign = async (raw: Obj, kind: "listing" | "agreement" | "evidence" | "rati const hash = artifactHash(raw, kind); const prefixes = { listing: "dacs-listing:v1:", agreement: "dacs-agreement:v1:", evidence: "dacs-evidence:v1:", rating: "dacs-rating:v1:", bundle: "dacs-bundle:v1:", "verify-result": "dacs-verifyresult:v1:", composite: "dacs-composite:v1:" }; const prefix = kind === "agreement" && raw.payeeBoundAgreementVersion === "1" - ? "dacs-payee-bound-agreement:v1:" : prefixes[kind]; + ? "dacs-payee-bound-agreement:v1:" + : kind === "bundle" && raw.faultBundleVersion === "1" + ? "dacs-fault-bundle:v1:" + : prefixes[kind]; const value = Buffer.from(await ed25519Sign(Buffer.from(prefix + hash), privateKeyFromSeed(seeds[signer]))).toString("hex"); return party ? { party: dids[signer], algorithm: "ed25519", value } : { signer: dids[signer], algorithm: "ed25519", value }; }; @@ -74,6 +78,29 @@ async function vector(jobId = "job-1", offset = 0) { }; } +async function bindingFor( + jobId: string, + role: "buyer" | "seller", + nativeAddress: string, + rawBundle: Obj, + signer: number, +): Promise { + const scope = { + bindingVersion: "1" as const, + jobId, + role, + logicalAddress: logicalBundleAddress(jobId, role), + nativeAddress, + bundleContentHash: artifactHash(rawBundle, "bundle"), + signer: dids[signer], + }; + const value = Buffer.from(await ed25519Sign( + Buffer.from(`dacs-bundle-binding:v1:${contentHash(scope)}`), + privateKeyFromSeed(seeds[signer]), + )).toString("base64url"); + return { ...scope, signature: { algorithm: "ed25519", signer: dids[signer], value } }; +} + async function installAgreement( fixture: Awaited>, agreementScope: Obj, @@ -429,6 +456,36 @@ test("current evidence graph verifies payee-bound agreements under their distinc assert.equal(graph.agreement?.agreementVersion, undefined); }); +test("current evidence graph verifies v0.3 FaultAttestationBundle attribution and domain", async () => { + const fixture = await vector("fault-bundle-job", 20); + const install = async (raw: Obj, role: "buyer" | "seller") => { + const scope = signedScope(raw, "bundle"); + delete scope.bundleVersion; + scope.faultBundleVersion = "1"; + scope.faultedParty = "none"; + const signatures = [await sign(scope, "bundle", 0, true), await sign(scope, "bundle", 1, true)]; + const fault = { ...scope, signatures, anchoredByRole: role }; + maps.set(role === "buyer" ? fixture.locators.buyer : fixture.locators.seller, fault); + return fault; + }; + const buyer = await install(fixture.buyerBundle, "buyer"); + const seller = await install(fixture.sellerBundle, "seller"); + assert.equal((await graphAt(fixture.locators.buyer, fixture.listing, fixture.locators.listing)).ok, true); + assert.equal((await graphAt(fixture.locators.seller, fixture.listing, fixture.locators.listing)).ok, true); + assert.equal(currentBundleCopiesDiverge(buyer, seller), false); + + const invalidScope = signedScope(buyer, "bundle"); + invalidScope.faultedParty = "buyer"; + maps.set(fixture.locators.buyer, { + ...invalidScope, + signatures: [await sign(invalidScope, "bundle", 0, true), await sign(invalidScope, "bundle", 1, true)], + anchoredByRole: "buyer", + }); + const invalid = await graphAt(fixture.locators.buyer, fixture.listing, fixture.locators.listing); + assert.equal(invalid.ok, false); + assert.equal(invalid.reason, "invalid fault attribution"); +}); + test("payee-bound agreements fail closed on discriminator, domain and payout coverage", async () => { const cases: Array<{ name: string; @@ -716,6 +773,46 @@ test("advisory skew stays unified and an invalid seller copy receipts the verifi assert.deepEqual(reputation.bundleRefs, []); }); +test("indexer resolves current copies through BundleBindings rather than submitted native pointers", async () => { + const fixture = await vector("binding-resolved-job", 70); + const submittedBuyerPointer = locator(998); + const submittedSellerPointer = locator(999); + const deal = registeredDeal("binding-resolved-job", submittedBuyerPointer, submittedSellerPointer); + const bundleBindings = [ + await bindingFor(deal.jobId, "buyer", fixture.locators.buyer, fixture.buyerBundle, 0), + await bindingFor(deal.jobId, "seller", fixture.locators.seller, fixture.sellerBundle, 1), + ]; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const locatorValue = String(input).split("/").pop() ?? ""; + const data = maps.get(locatorValue); + return new Response(JSON.stringify(data + ? { success: true, owner: `0x${dids[1].slice(-64)}`, programName: "dacs:test", data } + : { success: false }), { + status: data ? 200 : 404, + headers: { "content-type": "application/json" }, + }); + }; + try { + const record = await indexRegistration({ + primaryClaim: dids[1], + displayName: "seller", + listingAnchors: [fixture.locators.listing], + deals: [deal], + bundleBindings, + }, undefined, async () => { throw new Error("identity unavailable"); }); + assert.equal(record.deals[0].refsVerified, true); + assert.equal(record.deals[0].buyerBundleRef, fixture.locators.buyer); + assert.notEqual(record.deals[0].buyerBundleRef, submittedBuyerPointer); + assert.equal(record.deals[0].sellerBundleRef, fixture.locators.seller); + assert.notEqual(record.deals[0].sellerBundleRef, submittedSellerPointer); + assert.equal(record.deals[0].rail, "pay-dem"); + assert.equal(record.reputation.bundleCount, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("indexer excludes a valid current seller copy when the buyer anchor is unreadable", async () => { const fixture = await vector("seller-fallback-job", 80); maps.delete(fixture.locators.buyer); @@ -735,16 +832,21 @@ test("indexer excludes a valid current seller copy when the buyer anchor is unre ...registeredDeal("seller-fallback-job", fixture.locators.buyer, fixture.locators.seller), rail: "unknown", }; + const bundleBindings = [ + await bindingFor(deal.jobId, "buyer", fixture.locators.buyer, fixture.buyerBundle, 0), + await bindingFor(deal.jobId, "seller", fixture.locators.seller, fixture.sellerBundle, 1), + ]; const record = await indexRegistration({ primaryClaim: dids[1], displayName: "seller", listingAnchors: [fixture.locators.listing], deals: [deal], + bundleBindings, }, undefined, async () => { throw new Error("identity unavailable"); }); assert.equal(record.deals.length, 1); assert.equal(record.deals[0].refsVerified, false); - assert.equal(record.deals[0].anchoredByRole, "seller"); - assert.equal(record.deals[0].rail, "pay-dem"); + assert.equal(record.deals[0].anchoredByRole, undefined, "an unresolved side makes the lookup indeterminate"); + assert.equal(record.deals[0].rail, "unknown"); assert.equal(record.reputation.bundleCount, 0); assert.deepEqual(record.reputation.bundleRefs, []); } finally { @@ -777,11 +879,16 @@ test("indexer never retries a malformed current seller copy as legacy", async () }; try { const deal = registeredDeal("malformed-current-job", fixture.locators.buyer, fixture.locators.seller); + const bundleBindings = [ + await bindingFor(deal.jobId, "buyer", fixture.locators.buyer, fixture.buyerBundle, 0), + await bindingFor(deal.jobId, "seller", fixture.locators.seller, maps.get(fixture.locators.seller)!, 1), + ]; const record = await indexRegistration({ primaryClaim: dids[1], displayName: "seller", listingAnchors: [fixture.locators.listing], deals: [deal], + bundleBindings, }, undefined, async () => { throw new Error("identity unavailable"); }); assert.equal(record.deals.length, 1); assert.equal(record.deals[0].refsVerified, false); diff --git a/reference-implementations/dacs-directory/test/discovery-contract.test.ts b/reference-implementations/dacs-directory/test/discovery-contract.test.ts index db33e7b..2b355b6 100644 --- a/reference-implementations/dacs-directory/test/discovery-contract.test.ts +++ b/reference-implementations/dacs-directory/test/discovery-contract.test.ts @@ -38,6 +38,7 @@ test("directory manifest lets an agent discover every contract from the origin", assert.equal(manifest.humanUrl, origin); assert.equal(manifest.agentCard, `${origin}/.well-known/agent.json`); assert.equal(manifest.catalog, `${origin}/api/dacs/listings`); + assert.equal(manifest.bundleBindings, `${origin}/api/dacs/bundles/{jobId}`); assert.equal(manifest.inspectService, `${origin}/api/dacs/inspect-service/{listingId}/{version}?seller={primaryClaim}`); assert.equal(manifest.openapi, `${origin}/openapi.json`); assert.equal(manifest.schemas.listingSummary, `${origin}/schemas/listing-summary.schema.json`); @@ -76,6 +77,7 @@ test("OpenAPI and JSON Schema describe the listing discovery surface", () => { assert.equal(document.openapi, "3.1.0"); assert.ok(document.paths["/api/dacs/listings"]); assert.ok(document.paths["/api/dacs/listings/{listingId}/{version}"]); + assert.ok(document.paths["/api/dacs/bundles/{jobId}"]); assert.ok(document.paths["/api/dacs/inspect-service/{listingId}/{version}"]); const inspectSeller = document.paths["/api/dacs/inspect-service/{listingId}/{version}"].get.parameters .find((parameter) => parameter.name === "seller"); diff --git a/reference-implementations/dacs-directory/test/security.test.ts b/reference-implementations/dacs-directory/test/security.test.ts index 89c63fa..65632c8 100644 --- a/reference-implementations/dacs-directory/test/security.test.ts +++ b/reference-implementations/dacs-directory/test/security.test.ts @@ -88,6 +88,11 @@ test("registration owner message binds offered reputation deals", () => { }], }, 1); assert.notEqual(withoutDeal, withDeal); + const withBindings = registrationMessage({ + ...base, + bundleBindings: [], + }, 1); + assert.notEqual(withoutDeal, withBindings); }); test("pagination rejects negative, fractional, and oversized inputs", () => { From 51198675f69f77056d9f200e30b572b1502c123e Mon Sep 17 00:00:00 2001 From: random block Date: Mon, 10 Aug 2026 12:11:00 +0100 Subject: [PATCH 2/2] docs(directory): state binding claim scope --- reference-implementations/dacs-directory/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index c4a573a..362baac 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -221,6 +221,11 @@ client (browser: @noble-shimmed `node:crypto`, base64url-patched Buffer). - **DACS-2 recipe governance is deployment policy.** `verifiedBy` evidence cannot elevate a tier unless its exact recipe version/method/availability/max-age policy is present in `DACS_RECIPE_POLICIES`; missing policy fails closed. +- **BundleBinding key resolution currently implements the directory's canonical Demos + agent profile.** BB-4 accepts self-describing `did:demos:agent:<64hex>` claims. A + binding signed through another ClaimReference/key-resolution method is not carried or + used until that resolver is configured; it fails closed to `indeterminate` rather + than being relabelled as verified. - **Listing versions are allocated from observed catalog state**, without a mutable in-process lock. Publishers must serialize writes for one `seller + listingId` until the substrate or SDK provides an atomic version allocator; concurrent publishers can