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
24 changes: 18 additions & 6 deletions reference-implementations/dacs-directory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -214,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
@@ -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 });
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
188 changes: 188 additions & 0 deletions reference-implementations/dacs-directory/src/catalog/bundleBinding.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null =>
value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: 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<BundleBinding | null> {
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<BundleBinding>,
limit = MAX_BUNDLE_BINDINGS_PER_JOB_ROLE,
): { bindings: BundleBinding[]; overflowKeys: string[] } {
const unique = new Map<string, BundleBinding>();
for (const binding of bindings) unique.set(contentHash(binding), binding);
const byRole = new Map<string, BundleBinding[]>();
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<T> {
value: T;
bundleContentHash: string;
/** True when every declared buyer/seller/distinct-orchestrator signed. */
fullSignatureStanding: boolean;
}

export type BundleSideResolution<T> =
| { disposition: "present"; binding: BundleBinding; inspected: InspectedBundle<T> }
| { 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<T>(options: {
jobId: string;
role: BundleBinding["role"];
expectedSigner: string;
bindings: readonly BundleBinding[];
overflow?: boolean;
inspect: (binding: BundleBinding) => Promise<InspectedBundle<T> | null>;
budget?: number;
}): Promise<BundleSideResolution<T>> {
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<T> }> = [];
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<string, typeof accepted>();
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" };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,28 @@ export function currentBundleCopiesDiverge(
buyerBundle: Record<string, unknown>,
sellerBundle: Record<string, unknown>,
): 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;
Expand All @@ -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,
Expand Down
Loading