diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 48b8e3a..2e509a4 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -6,7 +6,8 @@ discovery layer (DACS-1 §6.3.6 catalog API), with a browsable directory UI and Agents do NOT need to register to appear here: the indexer **crawls the chain** (see *Discovery — three channels* below) and picks up current structured listings and -the pinned SDK's legacy artifacts through program-name and content-shape detection. Registration adds a display +the pinned SDK's explicit legacy read profile through program-name and content-shape detection. Current +listings must pass the pinned SDK's normative `isListing()` gate before admission. Registration adds a display name and (when owner-signed) the "owner-registered" badge — it is never a gate. Live thesis: a Web2 marketplace *asks you to trust its database*. This directory is a @@ -175,6 +176,13 @@ and response failures retain bounded retries. `STORAGE_NOT_FOUND` is operational diagnostic evidence only: under the current Demos mapping it is never authoritative DACS-5 absence evidence and cannot satisfy BB-8. +The same status response exposes `listingRejectionDiagnostics` with scope +`listing-admission`. It reports stable public-safe classes for normative shape, +verification-method, signature, identity-presentation, owner/seller binding and +declared-hash failures. For example, string-valued deliverable verification methods +are excluded as `VERIFICATION_METHOD_INVALID`; the Directory never aliases them to a +registered structured verification-method variant. + ## Discovery — three channels 1. **Registration** (`/register` UI or `POST /api/dacs/register`): bounded pointer sets, @@ -211,8 +219,11 @@ The Next app and the indexer speak to the node over **plain HTTP** (storage read unauthenticated GETs; `gcr_routine` uses hand-rolled timestamp-bound auth headers signed with the SDK's pure ed25519). demosdk is NOT a runtime dependency — its dependency tree (rubic bridge → pancakeswap/cetus/…) has unresolvable optionals in consumer installs and -is bundler-hostile. The SDK's pure barrel does all cryptography, on both server and -client (browser: @noble-shimmed `node:crypto`, base64url-patched Buffer). +is bundler-hostile. SDK verification names resolve through one compatibility seam because +the current top-level SDK barrel statically re-exports those optional rail modules; replace +that seam when the SDK publishes a browser-safe verification subpath. The same verification +code runs on server and client (browser: @noble-shimmed `node:crypto`, a narrow `node:util` +shim over JSON-owned values, and base64url-patched Buffer). ## Honest limitations (MVP) @@ -247,8 +258,9 @@ client (browser: @noble-shimmed `node:crypto`, base64url-patched Buffer). `exercises-spec`: DACS-1 §6.3.4 current Listing publication and dual-profile reading, §6.3.5 well-known generation/crawling, and §6.3.6 catalog discovery. Current artifacts -use directory-native, current-contract evidence-graph validation; the pinned SDK verifier -is retained only for labelled legacy artifacts. DACS-2 tier derivation fails closed on +pass the pinned SDK's normative Listing and component-signature APIs; current-contract +evidence graphs use directory-native validation, while the SDK bundle verifier is retained +for labelled legacy artifacts. DACS-2 tier derivation fails closed on unresolved recipe/evidence/freshness, and DACS-5 derivation includes ratings, volume, settlement uniqueness, anchor-time windowing, and deterministic receipts. Catalog computations remain advisory and independently reproducible from their refs. 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 e94052f..d9335f6 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 @@ -203,7 +203,11 @@ export async function POST(req: NextRequest) { ? { kind: "storage-program", accessModel: "public" } : deliverableKind === "entitlement" ? { kind: "entitlement", durationSec: 2_592_000, renewable: false } - : { kind: "attested-payload", payloadFormat: "application/json" }; + : { + kind: "attested-payload", + payloadFormat: "application/json", + verificationMethod: { kind: "self-signed" }, + }; const auctionDeadline = identityPresentedAt + 7 * 24 * 60 * 60 * 1000; const negotiationKind = negotiationPhaseForPricing(pricingKind); const negotiationStep = negotiationKind === "negotiate-rfq" @@ -277,7 +281,7 @@ export async function POST(req: NextRequest) { // Listing versions are immutable. Recover the already-signed artifact and // continue registration; never overwrite it with newly generated bytes. anchorAddress = resolution.address; - publishedListing = verified.listing as Record; + publishedListing = verified.listing as unknown as Record; publishedHash = verified.contentHash; } else { const nonce = await accountNonce(hex); diff --git a/reference-implementations/dacs-directory/app/api/dacs/derive/route.ts b/reference-implementations/dacs-directory/app/api/dacs/derive/route.ts index fca3095..65bd9d9 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/derive/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/derive/route.ts @@ -32,6 +32,8 @@ export async function GET(req: NextRequest) { found: !!anchored, valid, ownedByClaim: valid, - title: verified?.listing.name ?? null, + title: verified?.profile === "dacs-v0.1" + ? verified.listing.offering.title + : verified?.listing.name ?? null, }); } diff --git a/reference-implementations/dacs-directory/app/api/dacs/lookup/route.ts b/reference-implementations/dacs-directory/app/api/dacs/lookup/route.ts index 6cc6832..46c3d8c 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/lookup/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/lookup/route.ts @@ -5,11 +5,10 @@ * known deals. Registration becomes "confirm what we found", not data entry. */ import { NextRequest, NextResponse } from "next/server"; -import { isListing } from "@kynesyslabs/dacs/artifacts"; -import { stripSignature } from "@kynesyslabs/dacs/canonical"; import { parseCciRecord } from "@kynesyslabs/dacs/identity"; import { readAnchor } from "@/src/catalog/chain"; import { gcrGetIdentities } from "@/src/catalog/gcr"; +import { verifyListing } from "@/src/catalog/listingVerification"; import { loadScanState } from "@/src/catalog/store"; export async function GET(req: NextRequest) { @@ -26,9 +25,14 @@ export async function GET(req: NextRequest) { for (const [address, o] of Object.entries(state.listings)) { if (o.toLowerCase() !== owner.toLowerCase()) continue; const raw = await readAnchor(address); - const scope = raw ? stripSignature(raw) : null; - if (scope && isListing(scope)) { - listings.push({ address, title: (scope as { name?: string }).name ?? address }); + const verified = raw ? await verifyListing(raw) : null; + if (verified) { + listings.push({ + address, + title: verified.profile === "dacs-v0.1" + ? verified.listing.offering.title + : verified.listing.name, + }); } } diff --git a/reference-implementations/dacs-directory/next.config.mjs b/reference-implementations/dacs-directory/next.config.mjs index 5e12040..817d651 100644 --- a/reference-implementations/dacs-directory/next.config.mjs +++ b/reference-implementations/dacs-directory/next.config.mjs @@ -23,6 +23,11 @@ const nextConfig = { resource.request = new URL("./src/shims/node-crypto.ts", import.meta.url).pathname; }), ); + config.plugins.push( + new webpack.NormalModuleReplacementPlugin(/^node:util$/, (resource) => { + resource.request = new URL("./src/shims/node-util.ts", import.meta.url).pathname; + }), + ); config.plugins.push( new webpack.ProvidePlugin({ Buffer: ["buffer", "Buffer"] }), ); diff --git a/reference-implementations/dacs-directory/package-lock.json b/reference-implementations/dacs-directory/package-lock.json index ffb52af..29968c0 100644 --- a/reference-implementations/dacs-directory/package-lock.json +++ b/reference-implementations/dacs-directory/package-lock.json @@ -1862,27 +1862,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "vendor/dacs-sdk": { - "name": "@kynesyslabs/dacs", - "version": "0.1.0-alpha.0", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@kynesyslabs/demosdk": "^4.0.12", - "@x402/core": "^2.15.0", - "@x402/evm": "^2.15.0", - "@x402/fetch": "^2.15.0", - "viem": "^2.52.2" - }, - "devDependencies": { - "@types/node": "^20", - "typescript": "^5", - "vitest": "^4.0.18" - }, - "engines": { - "node": ">=20" - } } } } diff --git a/reference-implementations/dacs-directory/scripts/setup-sdk.sh b/reference-implementations/dacs-directory/scripts/setup-sdk.sh index ef9bdeb..b8fa425 100755 --- a/reference-implementations/dacs-directory/scripts/setup-sdk.sh +++ b/reference-implementations/dacs-directory/scripts/setup-sdk.sh @@ -2,7 +2,7 @@ # Vendors + builds the dacs-sdk (not yet on npm) and installs the app. set -euo pipefail cd "$(dirname "$0")/.." -SDK_REV="44d8ff2a07df8c951b94619d20b957b4bb5ce140" +SDK_REV="2d53f03778189b8f36573720e68d8743a94e4f2b" # Railway's GitHub integration can check out this repository, but it does not # pass its credentials through to nested private-repository clones. Supply a @@ -31,10 +31,10 @@ else git_with_sdk_auth clone --filter=blob:none https://github.com/DACS-Agent-commerce/dacs-sdk.git vendor/dacs-sdk fi (cd vendor/dacs-sdk && git_with_sdk_auth fetch --depth 1 origin "$SDK_REV" && git_with_sdk_auth checkout --detach "$SDK_REV") - (cd vendor/dacs-sdk && npm install --no-audit --no-fund && npm run build) + (cd vendor/dacs-sdk && npm ci --no-audit --no-fund && npm run build) fi if [ "${DACS_SKIP_APP_INSTALL:-0}" != "1" ]; then - npm install --no-audit --no-fund + npm ci --no-audit --no-fund fi # Seed the (gitignored, runtime-mutated) registrations file from the example # so a fresh clone has demo data without the file churning in git. diff --git a/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts b/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts index d528fc0..12aab7a 100644 --- a/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts +++ b/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts @@ -1,6 +1,8 @@ import { contentHash, stripSignature } from "@kynesyslabs/dacs/canonical"; -import type { AttestationBundle } from "@kynesyslabs/dacs/artifacts"; -import type { BundleVerification } from "../../vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; +import { + isLegacyMvpAttestationBundle, +} from "@kynesyslabs/dacs/artifacts"; +import type { BundleVerification } from "@kynesyslabs/dacs"; import { bundleSignerPolicy, demosSigningIdentity } from "./bundleSignerPolicy.js"; import { verifyListing } from "./listingVerification.js"; @@ -84,11 +86,11 @@ export function bundleMatchesRegisteredAnchor( * they must never be allowed to reassign somebody else's bundle/reputation. */ export function bundleMatchesRegisteredDeal( - bundle: AttestationBundle | undefined, + bundle: BundleVerification["bundle"], deal: RegisteredDeal, catalogSeller: string, ): boolean { - if (!bundle || bundle.jobId !== deal.jobId) return false; + if (!isLegacyMvpAttestationBundle(bundle) || bundle.jobId !== deal.jobId) return false; const buyers = bundle.parties.filter((p) => p.role === "buyer"); const sellers = bundle.parties.filter((p) => p.role === "seller"); return buyers.length === 1 && sellers.length === 1 && @@ -129,7 +131,8 @@ function expectedArtifacts(verification: BundleVerification): ExpectedArtifact[] // The pinned compatibility SDK does not resolve or report amendments/ratings. // A nonempty set must fail closed here instead of receiving a partial "strict" // verdict. The current-profile evidence graph resolves ratingRefs separately. - if (!bundle || bundle.agreementRef.kind !== "dacs-3-agreement" || + if (!isLegacyMvpAttestationBundle(bundle) || + !bundle.agreementRef || bundle.agreementRef.kind !== "dacs-3-agreement" || bundle.settlementEvidence.some((ref) => ref.kind !== "dacs-4-evidence") || bundle.vetRecords.some((ref) => ref.kind !== "dacs-2-verifyresult") || [extended?.amendments, extended?.ratingRefs].some((refs) => refs !== undefined && @@ -285,7 +288,7 @@ export function verifiedListingTerms( } export function bundleCategory( - bundle: AttestationBundle | undefined, + bundle: { listingRef: { listingId: string } } | undefined, categoriesByListing: Map, ): string | undefined { return bundle ? categoriesByListing.get(String(bundle.listingRef.listingId)) : undefined; diff --git a/reference-implementations/dacs-directory/src/catalog/contracts.ts b/reference-implementations/dacs-directory/src/catalog/contracts.ts index 4b6d057..ae1e64d 100644 --- a/reference-implementations/dacs-directory/src/catalog/contracts.ts +++ b/reference-implementations/dacs-directory/src/catalog/contracts.ts @@ -190,7 +190,7 @@ export const catalogStatusSchema = { type: "object", required: ["scope", "total", "byCode", "query", "returned", "hasMore", "items"], properties: { - scope: { const: "listing-registration-binding" }, + scope: { const: "listing-admission" }, total: { type: "integer", minimum: 0 }, byCode: { type: "object", additionalProperties: { type: "integer", minimum: 0 } }, query: { diff --git a/reference-implementations/dacs-directory/src/catalog/indexer.ts b/reference-implementations/dacs-directory/src/catalog/indexer.ts index d3995a4..483a2fd 100644 --- a/reference-implementations/dacs-directory/src/catalog/indexer.ts +++ b/reference-implementations/dacs-directory/src/catalog/indexer.ts @@ -18,14 +18,11 @@ import { ed25519Verify, publicKeyFromRaw } from "@kynesyslabs/dacs/crypto"; import { contentHash } from "@kynesyslabs/dacs/canonical"; import { parseCciRecord } from "@kynesyslabs/dacs/identity"; -// verifyBundleCore has no pure subpath export (dacs-sdk#14) — vendor path. -import { verifyBundleCore } from "../../vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; -// The SDK doesn't export sessionAnchorName from its public barrel -// (dacs-sdk#14) — reach into the vendored build. -import { sessionAnchorName } from "../../vendor/dacs-sdk/dist/agent/runSessionCore.js"; +import { isLegacyMvpAttestationBundle } from "@kynesyslabs/dacs/artifacts"; +import { verifyBundleCore } from "@kynesyslabs/dacs"; import { deriveAnchorAddress, readAnchor, readAnchorRecord } from "./chain.js"; import { gcrGetIdentities } from "./gcr.js"; -import { findValidListingRevocation, ownerClaim, verifyListing } from "./listingVerification.js"; +import { findValidListingRevocation, ownerClaim, verifyListingResult } from "./listingVerification.js"; import { canonicalDemosAgentClaim } from "./claimRef.js"; import { resolveDemosPrimaryClaimKey } from "./primaryClaimKey.js"; import { listingPresentation } from "./listingMetadata.js"; @@ -48,6 +45,7 @@ import { verifyBundleBinding, } from "./bundleBinding.js"; import { safePublicEndpoint } from "./publicEndpoint.js"; +import { legacySessionAnchorName } from "./legacySessionAnchorName.js"; import { deriveIdentityTier, type ResolveRecipe } from "./identityVerification.js"; import { bundleMatchesRegisteredDeal, @@ -121,7 +119,7 @@ export async function indexRegistration( const explorerFor = (chainType: string, address: string): string | undefined => chainType === "evm" ? `https://etherscan.io/address/${address}` : chainType === "solana" ? `https://solscan.io/account/${address}` : undefined; - cci = record.claims.map((c) => c.kind === "web2" + cci = record.claims.filter((c) => c.kind === "web2" || c.kind === "wallet").map((c) => c.kind === "web2" ? { kind: c.kind, platform: c.platform, handle: c.handle, ref: c.ref, proofUrl: proofFor(c.platform, c.handle), linkUrl: profileFor(c.platform, c.handle) } : { kind: c.kind, platform: c.chainType, handle: c.address, ref: c.ref, @@ -139,8 +137,12 @@ export async function indexRegistration( for (const anchor of reg.listingAnchors) { const anchored = await readAnchorRecord(anchor); if (!anchored) continue; - const verified = await verifyListing(anchored.data); - if (!verified) continue; + const verification = await verifyListingResult(anchored.data); + if (!verification.ok) { + recordListingRejection(anchor, reg.primaryClaim, verification.code); + continue; + } + const verified = verification.value; const { scope } = verified; const bindingRejection = listingBindingRejection( verified.sellerClaim, @@ -151,9 +153,12 @@ export async function indexRegistration( recordListingRejection(anchor, reg.primaryClaim, bindingRejection); continue; } - clearListingRejection(anchor, reg.primaryClaim); const declaredHash = reg.listingContentHashes?.[anchor]?.replace(/^sha256-/, "").toLowerCase(); - if (declaredHash && declaredHash !== verified.contentHash) continue; + if (declaredHash && declaredHash !== verified.contentHash) { + recordListingRejection(anchor, reg.primaryClaim, "DECLARED_CONTENT_HASH_MISMATCH"); + continue; + } + clearListingRejection(anchor, reg.primaryClaim); const listingId = typeof scope.listingId === "string" ? scope.listingId : typeof scope.serviceId === "string" ? scope.serviceId : ""; if (!listingId) continue; @@ -369,9 +374,9 @@ export async function indexRegistration( return raw; }, resolveRef: async (kind, jobId) => { - const name = kind === "dacs-3-agreement" ? sessionAnchorName.agreement(jobId) - : kind === "dacs-4-evidence" ? sessionAnchorName.evidence(jobId) - : kind === "dacs-2-verifyresult" ? sessionAnchorName.vet(jobId) : null; + const name = kind === "dacs-3-agreement" ? legacySessionAnchorName.agreement(jobId) + : kind === "dacs-4-evidence" ? legacySessionAnchorName.evidence(jobId) + : kind === "dacs-2-verifyresult" ? legacySessionAnchorName.vet(jobId) : null; if (!name) return null; const address = findProgramAddress(deal.owners.buyer, name) ?? deriveAnchorAddress(deal.owners.buyer, name); const raw = await readAnchor(address); @@ -382,7 +387,9 @@ export async function indexRegistration( (await resolveDemosPrimaryClaimKey(claim, "ed25519"))?.publicKey ?? null, verify, }).catch(() => null); - const bundle = verification?.bundle; + const bundle = verification && isLegacyMvpAttestationBundle(verification.bundle) + ? verification.bundle + : undefined; const signaturesOk = verification ? hasRequiredBundleSignatures( verification, rawBundle, diff --git a/reference-implementations/dacs-directory/src/catalog/legacySessionAnchorName.ts b/reference-implementations/dacs-directory/src/catalog/legacySessionAnchorName.ts new file mode 100644 index 0000000..9967dd6 --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/legacySessionAnchorName.ts @@ -0,0 +1,9 @@ +/** + * Historical SDK-MVP program names used only while reading explicitly + * labelled legacy bundles. These strings are not a current normative SDK API. + */ +export const legacySessionAnchorName = { + agreement: (jobId: string): string => `dacs3:agreement:${jobId}`, + evidence: (jobId: string): string => `dacs4:evidence:${jobId}`, + vet: (jobId: string): string => `dacs2:verifyrecord:${jobId}`, +}; diff --git a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts index dd56f1d..2ec4cc0 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts @@ -1,12 +1,21 @@ import { contentHash, stripSignature } from "@kynesyslabs/dacs/canonical"; -import { isListing, type Listing } from "@kynesyslabs/dacs/artifacts"; -import { safePublicEndpoint } from "./publicEndpoint.js"; +import { + ARTIFACT_SEPARATORS, + isListing, + isVerificationMethod, + readListingArtifact, + verifyComponentSignature, + type LegacyMvpListing, + type Listing, +} from "@kynesyslabs/dacs/artifacts"; +import { canonicalDemosAgentClaim } from "./claimRef.js"; import { canonicalSigningIdentity, resolvePrimaryClaimKey, resolveDemosPrimaryClaimKey, sameResolvedPrimaryClaim, verifyPrimaryClaimSignature, + verifyResolvedPrimaryClaimSignature, type ResolvedPrimaryClaimKey, type ResolvePrimaryClaimKey, } from "./primaryClaimKey.js"; @@ -25,100 +34,81 @@ function decodeSignature(value: string): Uint8Array | null { } } -export interface VerifiedListing { - listing: Listing | Record; +interface VerifiedListingBase { scope: Record; contentHash: string; signer: string; sellerClaim: string; - profile: "dacs-v0.1" | "legacy-sdk-v0.1"; } +export type VerifiedListing = + | VerifiedListingBase & { listing: Listing; profile: "dacs-v0.1" } + | VerifiedListingBase & { listing: LegacyMvpListing; profile: "legacy-sdk-v0.1" }; + +export type ListingVerificationFailureCode = + | "NORMATIVE_LISTING_INVALID" + | "VERIFICATION_METHOD_INVALID" + | "LISTING_SIGNATURE_INVALID" + | "IDENTITY_PRESENTATION_INVALID" + | "LEGACY_LISTING_INVALID"; + +export type ListingVerificationResult = + | { ok: true; value: VerifiedListing } + | { ok: false; code: ListingVerificationFailureCode }; + const record = (value: unknown): Record | null => value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; -const PHASES = new Set([ - "vet-credentials", "negotiate-fixed-price", "negotiate-rfq", "negotiate-sealed-envelope", "commit-agreement", "commit-payee-bound-agreement", - "pay-evm-erc20", "pay-solana-spl", "pay-cross-chain-htlc", "pay-cross-chain-liquidity-tank", "pay-ap2", "pay-x402", "pay-dem", - "deliver-storage-program", "deliver-entitlement", "deliver-attested-payload", "rate", -]); -// NOTE (open-world listing shape): the directory does NOT reject a listing that carries -// unknown top-level fields. §11.1.2 (additivity) lets a later minor add top-level listing -// fields, and the older-reads-newer contract (CORE.md) requires an older reader to consume a -// newer-minor artifact correctly; SIG-5 (preserve-unknown) is the mechanism that makes that -// safe — a verifier MAY ignore the *meaning* of unknown fields but MUST preserve them (it must -// not strip them from the signed content). This validator only reads the recognised fields -// below, so unknown fields are neither indexed nor interpreted, and verifyListing() hashes the -// whole scope (contentHash) so they remain signature-bound — a field injected after signing -// still fails the hash. A closed top-level -// allowlist here would reject every listing produced under the first minor that adds a -// top-level field, breaking forward compatibility. -function validPriceTerm(value: unknown): boolean { - const term = record(value); - return Boolean(term && typeof term.amount === "string" && /^(?:0|[1-9]\d*)(?:\.\d*[1-9])?$/.test(term.amount) && - Number.isFinite(Number(term.amount)) && Number(term.amount) > 0 && typeof term.currency === "string" && term.currency.length > 0 && term.currency.length <= 64 && - (term.unit === undefined || (typeof term.unit === "string" && term.unit.length > 0 && term.unit.length <= 64))); +function verificationMethodFailure(raw: Record): boolean { + const offering = record(raw.offering); + const deliverable = record(offering?.deliverable); + const method = deliverable?.verificationMethod; + if (method !== undefined && !isVerificationMethod(method)) return true; + const requiresMethod = Array.isArray(raw.pipeline) && raw.pipeline.some((step) => + record(step)?.kind === "deliver-attested-payload"); + return requiresMethod && ( + deliverable?.kind !== "attested-payload" || !isVerificationMethod(method) + ); } -function currentListing(scope: Record): { - signer: string; sellerClaim: string; signature: Record; -} | null { - const seller = record(scope.seller); +/** + * DACS-1 CF-2 makes only the leading `did` scheme token case-insensitive on + * read. The current SDK validator still requires a lowercase claim scheme, so + * validate a scheme-canonical projection while preserving and verifying the + * exact received bytes. Method text and key casing are never rewritten. + */ +function listingValidationView(raw: Record): Record { + const view = structuredClone(raw); + const normalize = (value: unknown): unknown => typeof value === "string" + ? canonicalDemosAgentClaim(value) ?? value + : value; + const seller = record(view.seller); const identity = record(seller?.identity); - const offering = record(scope.offering); - const pricing = record(scope.pricing); - const validity = record(scope.validity); - const signature = record((scope as Record).signature); - const claims = Array.isArray(identity?.claims) ? identity.claims.map(record).filter(Boolean) as Record[] : []; - const pipeline = Array.isArray(scope.pipeline) ? scope.pipeline.map(record).filter(Boolean) as Record[] : []; - const tags = Array.isArray(offering?.tags) ? offering.tags : []; - const pricingOk = pricing?.kind === "fixed" ? validPriceTerm(pricing.price) - : pricing?.kind === "negotiable" ? validPriceTerm(pricing.bandCenter) && typeof pricing.minPct === "number" && pricing.minPct >= 0 && pricing.minPct < 100 && typeof pricing.maxPct === "number" && pricing.maxPct >= 0 - : pricing?.kind === "auction" ? (!pricing.reservePrice || validPriceTerm(pricing.reservePrice)) && typeof pricing.selectionRule === "string" - : pricing?.kind === "metered" ? validPriceTerm(pricing.unitPrice) && - typeof pricing.unit === "string" && pricing.unit.length > 0 && pricing.unit.length <= 64 && - (pricing.minTotal === undefined || ( - validPriceTerm(pricing.minTotal) && - record(pricing.minTotal)?.currency === record(pricing.unitPrice)?.currency - )) : false; - const hasPayPhase = pipeline.some((step) => typeof step.kind === "string" && step.kind.startsWith("pay-")); - const rails = Array.isArray(scope.acceptedRails) ? scope.acceptedRails.map(record).filter(Boolean) : []; - const railIds = new Set(rails.map((rail) => rail?.railId).filter((rail): rail is string => typeof rail === "string")); - const payBindingsOk = pipeline.filter((step) => typeof step.kind === "string" && step.kind.startsWith("pay-")) - .every((step) => { const parameters = record(step.parameters); return typeof parameters?.rail === "string" && railIds.has(parameters.rail); }); - const negotiationKinds = pipeline.map((step) => step.kind).filter((kind) => typeof kind === "string" && kind.startsWith("negotiate-")); - const negotiationIndex = pipeline.findIndex((step) => typeof step.kind === "string" && step.kind.startsWith("negotiate-")); - const commitmentIndexes = pipeline.flatMap((step, index) => - step.kind === "commit-agreement" || step.kind === "commit-payee-bound-agreement" ? [index] : []); - const commitmentOk = commitmentIndexes.length === 1 && commitmentIndexes[0] === negotiationIndex + 1; - const expectedNegotiation = pricing?.kind === "fixed" ? "negotiate-fixed-price" - : pricing?.kind === "negotiable" ? "negotiate-rfq" : pricing?.kind === "auction" ? "negotiate-sealed-envelope" : ""; - const negotiationOk = pricing?.kind === "metered" - ? negotiationKinds.length === 1 && ( - negotiationKinds[0] === "negotiate-fixed-price" || negotiationKinds[0] === "negotiate-rfq" - ) - : negotiationKinds.length === 1 && negotiationKinds[0] === expectedNegotiation; - const signer = typeof signature?.signer === "string" ? signature.signer : ""; - const sellerClaim = typeof identity?.presentedBy === "string" ? identity.presentedBy : ""; - const signerIdentity = canonicalSigningIdentity(signer); - const sellerIdentity = canonicalSigningIdentity(sellerClaim); - if ( - scope.dacsVersion !== "1" || !Number.isSafeInteger(scope.listingVersion) || Number(scope.listingVersion) < 1 || - typeof scope.listingId !== "string" || !/^[A-Za-z0-9._~-]{1,128}$/.test(scope.listingId) || - typeof seller?.displayName !== "string" || seller.displayName.length > 200 || - (seller.publicEndpoint !== undefined && !safePublicEndpoint(seller.publicEndpoint)) || !sellerClaim || claims.length === 0 || - !claims.some((claim) => typeof claim.ref === "string" && - canonicalSigningIdentity(claim.ref) === sellerIdentity) || - !claims.some((claim) => typeof claim.ref === "string" && - canonicalSigningIdentity(claim.ref) === signerIdentity) || - typeof offering?.title !== "string" || offering.title.length > 200 || typeof offering.description !== "string" || offering.description.length > 2000 || - typeof offering.category !== "string" || !/^[a-z0-9.-]{1,64}$/.test(offering.category) || tags.length > 16 || tags.some((tag) => typeof tag !== "string" || tag.length > 32) || !record(offering.deliverable) || - !record(scope.buyerRequirement) || pipeline.length === 0 || pipeline.some((step) => typeof step.kind !== "string" || !PHASES.has(step.kind)) || - !pricingOk || !negotiationOk || !commitmentOk || - (hasPayPhase && (rails.length === 0 || !payBindingsOk)) || !record(scope.terms) || typeof validity?.notBefore !== "number" || - (typeof validity.notAfter === "number" && validity.notAfter < validity.notBefore) || !signature - ) return null; - return { signer, sellerClaim, signature }; + if (identity) { + identity.presentedBy = normalize(identity.presentedBy); + if (Array.isArray(identity.claims)) { + identity.claims = identity.claims.map((claim) => { + const item = record(claim); + return item ? { ...item, ref: normalize(item.ref) } : claim; + }); + } + const presentation = record(identity.presentation); + if (presentation && Array.isArray(presentation.signatures)) { + presentation.signatures = presentation.signatures.map((signature) => { + const item = record(signature); + return item ? { ...item, ref: normalize(item.ref) } : signature; + }); + } + } + const signature = record(view.signature); + if (signature) signature.signer = normalize(signature.signer); + return view; +} + +function invalid( + code: ListingVerificationFailureCode, +): ListingVerificationResult { + return { ok: false, code }; } async function verifyEd25519( @@ -150,41 +140,94 @@ async function verifyIdentityPresentation( return verifyEd25519(message, sellerClaim, signature.signature, resolveKey); } -/** Verify either the current normative Listing or the pinned SDK compatibility profile. */ -export async function verifyListing( +/** + * Verify either a current normative Listing or the SDK's explicit legacy-MVP + * read profile. Current artifacts never fall back to legacy interpretation. + */ +export async function verifyListingResult( raw: Record, resolveKey: ResolvePrimaryClaimKey = resolveDemosPrimaryClaimKey, -): Promise { - if (raw.signatures !== undefined) return null; - const current = currentListing(raw); - if (current) { - const scope = { ...raw }; - delete scope.signature; - if (Buffer.byteLength(JSON.stringify(raw), "utf8") > 16_384) return null; - if (current.signature.algorithm !== "ed25519" || typeof current.signature.value !== "string") return null; - const hash = contentHash(scope); - const verifiedSigner = await verifyEd25519( - Buffer.from(SEPARATOR + hash, "utf8"), current.signer, current.signature.value, resolveKey, +): Promise { + if (raw.signatures !== undefined) return invalid("LISTING_SIGNATURE_INVALID"); + if (raw.dacsVersion === "1") { + let validationView: Record; + try { + validationView = listingValidationView(raw); + } catch { + return invalid("NORMATIVE_LISTING_INVALID"); + } + if (!isListing(validationView)) { + return invalid(verificationMethodFailure(raw) + ? "VERIFICATION_METHOD_INVALID" + : "NORMATIVE_LISTING_INVALID"); + } + const listing = raw as unknown as Listing; + const signatureVerdict = await verifyComponentSignature( + raw, + ARTIFACT_SEPARATORS.Listing, + { + isSignerAuthorized: (_artifact, signature) => + listing.seller.identity.claims.some((claim) => + canonicalSigningIdentity(claim.ref) === canonicalSigningIdentity(signature.signer)), + resolvePublicKey: async (signature) => { + const resolved = await resolvePrimaryClaimKey( + signature.signer, + signature.algorithm, + resolveKey, + ); + return resolved; + }, + verify: ({ signedBytes, signature, publicKey }) => { + const decoded = decodeSignature(signature.value); + return Boolean( + decoded && verifyResolvedPrimaryClaimSignature( + signedBytes, + decoded, + publicKey, + ), + ); + }, + }, + ); + if (signatureVerdict.status !== "valid") { + return invalid("LISTING_SIGNATURE_INVALID"); + } + const verifiedSigner = await resolvePrimaryClaimKey( + signatureVerdict.signature.signer, + signatureVerdict.signature.algorithm, + resolveKey, ); - const seller = record(raw.seller); - const identity = record(seller?.identity); - const verifiedSeller = identity - ? await verifyIdentityPresentation(identity, current.sellerClaim, resolveKey) - : null; - if (!verifiedSigner || !verifiedSeller) return null; + if (!verifiedSigner) return invalid("LISTING_SIGNATURE_INVALID"); + const identity = listing.seller.identity as unknown as Record; + const verifiedSeller = await verifyIdentityPresentation( + identity, + listing.seller.identity.presentedBy, + resolveKey, + ); + if (!verifiedSeller) return invalid("IDENTITY_PRESENTATION_INVALID"); + const scope = stripSignature(raw) as Record; return { - listing: raw, - scope, - contentHash: hash, - signer: verifiedSigner.canonicalClaim, - sellerClaim: verifiedSeller.canonicalClaim, - profile: "dacs-v0.1", + ok: true, + value: { + listing, + scope, + contentHash: contentHash(scope), + signer: verifiedSigner.canonicalClaim, + sellerClaim: verifiedSeller.canonicalClaim, + profile: "dacs-v0.1", + }, }; } + if (Buffer.byteLength(JSON.stringify(raw), "utf8") > 16_384) { + return invalid("LEGACY_LISTING_INVALID"); + } + const readable = readListingArtifact(raw); + if (!readable || readable.compatibility !== "legacy-mvp") { + return invalid("LEGACY_LISTING_INVALID"); + } const scope = stripSignature(raw); - if (!isListing(scope)) return null; - const listing = scope as unknown as Listing; + const listing = readable.listing; const signature = raw.signature; // Early SDK listings stored only the Ed25519 value. Their signer is still // unambiguous because agentId is inside the signed scope and is also checked @@ -198,26 +241,39 @@ export async function verifyListing( s.algorithm !== "ed25519" || typeof s.signer !== "string" || typeof s.value !== "string" - ) return null; + ) return invalid("LISTING_SIGNATURE_INVALID"); const sig = decodeSignature(s.value); - if (!sig) return null; + if (!sig) return invalid("LISTING_SIGNATURE_INVALID"); const hash = contentHash(scope); const message = Buffer.from(SEPARATOR + hash, "utf8"); const verifiedSigner = await verifyPrimaryClaimSignature( message, sig, s.signer, s.algorithm, resolveKey, ); const verifiedAgent = await resolvePrimaryClaimKey(listing.agentId, s.algorithm, resolveKey); - if (!verifiedSigner || !verifiedAgent || !sameResolvedPrimaryClaim(verifiedSigner, verifiedAgent)) return null; + if (!verifiedSigner || !verifiedAgent || !sameResolvedPrimaryClaim(verifiedSigner, verifiedAgent)) { + return invalid("LISTING_SIGNATURE_INVALID"); + } return { - listing, - scope, - contentHash: hash, - signer: verifiedSigner.canonicalClaim, - sellerClaim: verifiedSigner.canonicalClaim, - profile: "legacy-sdk-v0.1", + ok: true, + value: { + listing, + scope, + contentHash: hash, + signer: verifiedSigner.canonicalClaim, + sellerClaim: verifiedSigner.canonicalClaim, + profile: "legacy-sdk-v0.1", + }, }; } +export async function verifyListing( + raw: Record, + resolveKey: ResolvePrimaryClaimKey = resolveDemosPrimaryClaimKey, +): Promise { + const result = await verifyListingResult(raw, resolveKey); + return result.ok ? result.value : null; +} + /** A bogus candidate must never shadow another valid owner-signed marker. */ export async function hasValidListingRevocation( candidateRefs: string[], @@ -291,7 +347,7 @@ export async function verifyListingRevocation( ): Promise { const scope = stripSignature(raw); if ( - scope.listingId !== (listing.scope.listingId ?? (listing.listing as Listing).serviceId) || + scope.listingId !== (listing.scope.listingId ?? listing.scope.serviceId) || scope.listingVersion !== expectedVersion || typeof scope.listingContentHash !== "string" || scope.listingContentHash.toLowerCase() !== listing.contentHash || diff --git a/reference-implementations/dacs-directory/src/catalog/store.ts b/reference-implementations/dacs-directory/src/catalog/store.ts index faa4ea8..7dd431e 100644 --- a/reference-implementations/dacs-directory/src/catalog/store.ts +++ b/reference-implementations/dacs-directory/src/catalog/store.ts @@ -386,7 +386,15 @@ export function pruneFailureHistory(now = Date.now(), batch = 500): number { export const failureHistorySize = (): number => (db.prepare("SELECT COUNT(*) count FROM artifact_failure_history").get() as { count: number }).count; -export type ListingRejectionCode = "SELLER_CLAIM_BINDING" | "OWNER_CLAIM_BINDING"; +export type ListingRejectionCode = + | "SELLER_CLAIM_BINDING" + | "OWNER_CLAIM_BINDING" + | "NORMATIVE_LISTING_INVALID" + | "VERIFICATION_METHOD_INVALID" + | "LISTING_SIGNATURE_INVALID" + | "IDENTITY_PRESENTATION_INVALID" + | "LEGACY_LISTING_INVALID" + | "DECLARED_CONTENT_HASH_MISMATCH"; export function recordListingRejection( locator: string, @@ -484,7 +492,7 @@ export interface IndexerDiagnostics { items: PublicDeadLetterDiagnostic[]; }; listingRejectionDiagnostics: { - scope: "listing-registration-binding"; + scope: "listing-admission"; total: number; byCode: Record; query: { locator: string | null; limit: number }; @@ -511,6 +519,12 @@ interface ListingRejectionRow { const LISTING_REJECTION_MESSAGES: Record = { SELLER_CLAIM_BINDING: "The verified listing seller does not match the registration claim.", OWNER_CLAIM_BINDING: "The listing anchor owner does not match the registration claim.", + NORMATIVE_LISTING_INVALID: "The current listing does not satisfy the pinned SDK's normative Listing validator.", + VERIFICATION_METHOD_INVALID: "The listing deliverable verification method is missing or is not a registered structured variant.", + LISTING_SIGNATURE_INVALID: "The listing signature is malformed, unsupported, unresolved, or cryptographically invalid.", + IDENTITY_PRESENTATION_INVALID: "The listing seller identity presentation could not be authenticated.", + LEGACY_LISTING_INVALID: "The artifact does not satisfy the SDK's explicit legacy Listing read profile.", + DECLARED_CONTENT_HASH_MISMATCH: "The discovery channel's declared listing content hash does not match the verified artifact.", }; const publicFailure = (code: string): { code: string; message: string } => @@ -595,7 +609,7 @@ const readIndexerDiagnostics = db.transaction((options: IndexerDiagnosticsOption query: { locator: locator ?? null, limit }, returned: items.length, hasMore, items, }, listingRejectionDiagnostics: { - scope: "listing-registration-binding", + scope: "listing-admission", total: listingRejectionTotal, byCode: Object.fromEntries(listingRejectionCounts.map((row) => [row.reason_code, row.count])), query: { locator: locator ?? null, limit }, diff --git a/reference-implementations/dacs-directory/src/components/VerifyAttestation.tsx b/reference-implementations/dacs-directory/src/components/VerifyAttestation.tsx index abdc6e7..abee4d3 100644 --- a/reference-implementations/dacs-directory/src/components/VerifyAttestation.tsx +++ b/reference-implementations/dacs-directory/src/components/VerifyAttestation.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import "@/src/shims/buffer"; import { ed25519Verify, publicKeyFromRaw, dacsXSeparator } from "@kynesyslabs/dacs/crypto"; // Pure module — safe for client bundles (no substrate/demosdk in its chain). -import { verifySignedArtifact } from "@/vendor/dacs-sdk/dist/agent/signedArtifact.js"; +import { verifySignedArtifact } from "@kynesyslabs/dacs"; const keyFromDid = (did: string): Uint8Array | null => { const hex = did.match(/(?:^|:)(?:0x)?([0-9a-fA-F]{64})$/)?.[1]; diff --git a/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx b/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx index 298d77c..8075961 100644 --- a/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx +++ b/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx @@ -11,15 +11,11 @@ import { useState } from "react"; // Side-effect: patches the browser Buffer polyfill with base64url support // (the SDK decodes signature bytes with Buffer.from(x, "base64url")). import "@/src/shims/buffer"; -// Import ONLY pure modules: the package barrel re-exports createAgent, whose -// lazy `import("../substrate")` gets statically traced by Next's bundler and -// drags demosdk (node-only) into the client bundle. (SDK finding: a pure -// "./verify" subpath export would fix this properly — see dacs-sdk#14.) import { ed25519Verify, publicKeyFromRaw } from "@kynesyslabs/dacs/crypto"; import { verifyBundleCore, type BundleVerification, -} from "@/vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; +} from "@kynesyslabs/dacs"; import { bundleMatchesRegisteredAnchor, hasRequiredBundleSignatures, @@ -27,17 +23,17 @@ import { refsPassStrictPolicy, type ResolvedArtifact, } from "@/src/catalog/bundlePolicy"; +import { legacySessionAnchorName } from "@/src/catalog/legacySessionAnchorName"; const keyFromDid = (did: string): Uint8Array | null => { const hex = did.match(/(?:^|:)(?:0x)?([0-9a-fA-F]{64})$/)?.[1]; return hex ? Uint8Array.from(Buffer.from(hex, "hex")) : null; }; -// Mirrors the SDK's sessionAnchorName (not exported publicly — dacs-sdk#14). const anchorName: Record string> = { - "dacs-3-agreement": (j) => `dacs3:agreement:${j}`, - "dacs-4-evidence": (j) => `dacs4:evidence:${j}`, - "dacs-2-verifyresult": (j) => `dacs2:verifyrecord:${j}`, + "dacs-3-agreement": legacySessionAnchorName.agreement, + "dacs-4-evidence": legacySessionAnchorName.evidence, + "dacs-2-verifyresult": legacySessionAnchorName.vet, }; async function fetchArtifact(params: string): Promise | null> { diff --git a/reference-implementations/dacs-directory/src/sdkVerification.ts b/reference-implementations/dacs-directory/src/sdkVerification.ts new file mode 100644 index 0000000..f39ac93 --- /dev/null +++ b/reference-implementations/dacs-directory/src/sdkVerification.ts @@ -0,0 +1,15 @@ +/** + * One compatibility seam for the SDK's public verification exports. + * + * The current SDK exposes these names from its top-level barrel, but that + * barrel also statically re-exports optional Node/multi-chain modules. Resolve + * the public names through their pure implementations until the SDK ships a + * browser-safe public verification subpath. + */ +export { + verifyBundleCore, + type BundleVerification, +} from "../vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; +export { + verifySignedArtifact, +} from "../vendor/dacs-sdk/dist/agent/signedArtifact.js"; diff --git a/reference-implementations/dacs-directory/src/shims/node-util.ts b/reference-implementations/dacs-directory/src/shims/node-util.ts new file mode 100644 index 0000000..74d6172 --- /dev/null +++ b/reference-implementations/dacs-directory/src/shims/node-util.ts @@ -0,0 +1,8 @@ +/** Browser subset used by the SDK's pure verification modules. */ +export const types = { + // Artifacts arrive through Response.json(), and dependency objects are + // constructed inside the component, so no caller-owned Proxy crosses this + // browser verification boundary. + isProxy: (_value: unknown): boolean => false, + isUint8Array: (value: unknown): value is Uint8Array => value instanceof Uint8Array, +}; diff --git a/reference-implementations/dacs-directory/test/build-listing.test.ts b/reference-implementations/dacs-directory/test/build-listing.test.ts index 12b8293..218ed9e 100644 --- a/reference-implementations/dacs-directory/test/build-listing.test.ts +++ b/reference-implementations/dacs-directory/test/build-listing.test.ts @@ -96,6 +96,14 @@ test("publisher builds a verifiable metered listing with the AP2 rail/phase bind minTotal: { amount: "1", currency: "USD" }, }); assert.deepEqual(built.listing.acceptedRails, [{ railId: "ap2:stripe-paymentintents" }]); + assert.deepEqual( + (built.listing.offering as Record).deliverable, + { + kind: "attested-payload", + payloadFormat: "application/json", + verificationMethod: { kind: "self-signed" }, + }, + ); assert.deepEqual(built.listing.pipeline, [ { kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, diff --git a/reference-implementations/dacs-directory/test/confirm-listing.test.ts b/reference-implementations/dacs-directory/test/confirm-listing.test.ts index 37882e2..06eb966 100644 --- a/reference-implementations/dacs-directory/test/confirm-listing.test.ts +++ b/reference-implementations/dacs-directory/test/confirm-listing.test.ts @@ -40,7 +40,11 @@ const scope = { description: "A deterministic verified service.", category: "services.other", tags: [], - deliverable: { kind: "attested-payload", payloadFormat: "application/json" }, + deliverable: { + kind: "attested-payload", + payloadFormat: "application/json", + verificationMethod: { kind: "self-signed" }, + }, }, buyerRequirement: { requirementVersion: "1", required: [], preferredPresentation: "any" }, pipeline: [ diff --git a/reference-implementations/dacs-directory/test/current-indexer.test.ts b/reference-implementations/dacs-directory/test/current-indexer.test.ts index 28d7574..3acf812 100644 --- a/reference-implementations/dacs-directory/test/current-indexer.test.ts +++ b/reference-implementations/dacs-directory/test/current-indexer.test.ts @@ -38,7 +38,7 @@ async function vector(jobId = "job-1", offset = 0) { const listingScope: Obj = { dacsVersion: "1", listingVersion: 1, listingId: "svc", requiredCapabilities: ["SR-2"], seller: { identity: { bundleVersion: "1", presentedBy: dids[1], presentedAt: 1, claims: [{ ref: dids[1] }], presentation: { kind: "per-claim", signatures: [] } }, displayName: "seller" }, - offering: { title: "test", description: "test service", category: "services.test", tags: [], deliverable: { kind: "attested-payload", payloadFormat: "application/json" } }, + offering: { title: "test", description: "test service", category: "services.test", tags: [], deliverable: { kind: "attested-payload", payloadFormat: "application/json", verificationMethod: { kind: "self-signed" } } }, buyerRequirement: { requirementVersion: "1", required: [], preferredPresentation: "any" }, pipeline: [{ kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, { kind: "pay-dem", parameters: { rail: "pay-dem" } }, { kind: "deliver-attested-payload" }], pricing: { kind: "fixed", price: { amount: "1.25", currency: "DEM", unit: "job" } }, acceptedRails: [{ railId: "pay-dem" }], terms: {}, validity: { notBefore: 1 }, diff --git a/reference-implementations/dacs-directory/test/dead-letter-diagnostics.test.ts b/reference-implementations/dacs-directory/test/dead-letter-diagnostics.test.ts index 52a30be..9c80139 100644 --- a/reference-implementations/dacs-directory/test/dead-letter-diagnostics.test.ts +++ b/reference-implementations/dacs-directory/test/dead-letter-diagnostics.test.ts @@ -115,6 +115,7 @@ test("listing binding rejections are persistent, public-safe, filterable, and re store.recordListingRejection(target, claim, "OWNER_CLAIM_BINDING"); const diagnostics = store.indexerDiagnostics({ deadLetterLocator: target }); + assert.equal(diagnostics.listingRejectionDiagnostics.scope, "listing-admission"); assert.equal(diagnostics.listingRejectionDiagnostics.total, 1); assert.equal(diagnostics.listingRejectionDiagnostics.returned, 1); assert.equal(diagnostics.listingRejectionDiagnostics.byCode.OWNER_CLAIM_BINDING, 1); @@ -132,6 +133,20 @@ test("listing binding rejections are persistent, public-safe, filterable, and re assert.equal(store.indexerDiagnostics({ deadLetterLocator: target }).listingRejectionDiagnostics.returned, 0); }); +test("normative listing admission failures expose stable public-safe diagnostics", () => { + const target = locator("8"); + const claim = `did:demos:agent:${"8".repeat(64)}`; + store.recordListingRejection(target, claim, "VERIFICATION_METHOD_INVALID"); + + const diagnostics = store.indexerDiagnostics({ deadLetterLocator: target }) + .listingRejectionDiagnostics; + assert.equal(diagnostics.scope, "listing-admission"); + assert.equal(diagnostics.byCode.VERIFICATION_METHOD_INVALID, 1); + assert.equal(diagnostics.items[0].code, "VERIFICATION_METHOD_INVALID"); + assert.match(diagnostics.items[0].message, /registered structured variant/); + assert.doesNotMatch(JSON.stringify(diagnostics), new RegExp(claim)); +}); + test("cursor progress diagnostics distinguish caught-up, stalled, and unknown cursors", () => { const now = 1_000_000; assert.equal(cursorStallThresholdSeconds("60"), 60); diff --git a/reference-implementations/dacs-directory/test/fixtures/live-invalid-verification-methods.json b/reference-implementations/dacs-directory/test/fixtures/live-invalid-verification-methods.json new file mode 100644 index 0000000..5f64438 --- /dev/null +++ b/reference-implementations/dacs-directory/test/fixtures/live-invalid-verification-methods.json @@ -0,0 +1,20 @@ +[ + { + "listingId": "audit-negotiator-x402", + "listingVersion": 3, + "locator": "stor-77bbb76304a3809858786ac816f693db4ebdc238", + "verificationMethod": "self-signed" + }, + { + "listingId": "oracle-data-x402", + "listingVersion": 3, + "locator": "stor-4b7765d9077b5be93b4538541245e95f9bfff4fc", + "verificationMethod": "seller-signature-source-attestation-and-request-hash" + }, + { + "listingId": "dd-research-x402", + "listingVersion": 2, + "locator": "stor-624fae262d7ad2b809b1fc1ec56b421e4e46d35e", + "verificationMethod": "seller-signature-cited-source-attestations-and-request-hash" + } +] diff --git a/reference-implementations/dacs-directory/test/listing-verification.test.ts b/reference-implementations/dacs-directory/test/listing-verification.test.ts index 70a04e9..5ce29f1 100644 --- a/reference-implementations/dacs-directory/test/listing-verification.test.ts +++ b/reference-implementations/dacs-directory/test/listing-verification.test.ts @@ -1,14 +1,25 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; +import { isListing } from "@kynesyslabs/dacs/artifacts"; import { contentHash } from "@kynesyslabs/dacs/canonical"; import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; -import { verifyListing } from "../src/catalog/listingVerification.js"; +import { verifyListing, verifyListingResult } from "../src/catalog/listingVerification.js"; const seed = Uint8Array.from(Buffer.from("11".repeat(32), "hex")); const privateKey = privateKeyFromSeed(seed); const publicKeyHex = Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex"); const claim = `did:demos:agent:${publicKeyHex}`; +const liveInvalidMethods = JSON.parse(readFileSync( + new URL("./fixtures/live-invalid-verification-methods.json", import.meta.url), + "utf8", +)) as Array<{ + listingId: string; + listingVersion: number; + locator: string; + verificationMethod: string; +}>; function signMessage(message: string): string { return Buffer.from(ed25519Sign(Buffer.from(message, "utf8"), privateKey)).toString("base64url"); @@ -19,7 +30,9 @@ function signedCurrentListing( signingClaim = claim, ): Record { const identity: Record = { + bundleVersion: "1", presentedBy: signingClaim, + presentedAt: 1, claims: [{ ref: signingClaim, kind: "signing-key" }], }; identity.presentation = { @@ -45,7 +58,7 @@ function signedCurrentListing( tags: ["sig5"], deliverable: { kind: "storage-program" }, }, - buyerRequirement: { kind: "none" }, + buyerRequirement: { requirementVersion: "1", required: [] }, pipeline: [ { kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, @@ -120,6 +133,61 @@ test("verifyListing refuses unknown executable phase kinds even with a valid sig assert.equal(await verifyListing(listing), null); }); +test("live x402 string verification methods fail closed under the normative SDK validator", async () => { + for (const fixture of liveInvalidMethods) { + const listing = signedCurrentListing({ + listingId: fixture.listingId, + listingVersion: fixture.listingVersion, + offering: { + title: fixture.listingId, + description: `Regression fixture for ${fixture.locator}.`, + category: "services.test", + tags: ["x402"], + deliverable: { + kind: "attested-payload", + payloadFormat: "application/json", + verificationMethod: fixture.verificationMethod, + }, + }, + pipeline: [ + { kind: "negotiate-fixed-price" }, + { kind: "commit-agreement" }, + { kind: "pay-x402", parameters: { rail: "pay-x402" } }, + { kind: "deliver-attested-payload" }, + ], + }); + + assert.equal(isListing(listing), false, fixture.listingId); + assert.deepEqual(await verifyListingResult(listing), { + ok: false, + code: "VERIFICATION_METHOD_INVALID", + }); + assert.equal(await verifyListing(listing), null); + } + + const structured = signedCurrentListing({ + offering: { + title: "Structured verification method", + description: "A registered DACS-2 verification-method variant.", + category: "services.test", + tags: ["x402"], + deliverable: { + kind: "attested-payload", + payloadFormat: "application/json", + verificationMethod: { kind: "self-signed" }, + }, + }, + pipeline: [ + { kind: "negotiate-fixed-price" }, + { kind: "commit-agreement" }, + { kind: "pay-x402", parameters: { rail: "pay-x402" } }, + { kind: "deliver-attested-payload" }, + ], + }); + assert.equal(isListing(structured), true); + assert.equal((await verifyListingResult(structured)).ok, true); +}); + test("verifyListing requires exactly one adjacent supported commitment phase", async () => { const payeeBound = signedCurrentListing({ pipeline: [ @@ -179,7 +247,10 @@ test("verifyListing accepts normative metered pricing bound to an AP2 rail", asy assert.ok(await verifyListing(listing)); assert.ok(await verifyListing(signedCurrentListing({ pricing: metered, - pipeline: [{ kind: "negotiate-rfq" }, ...pipeline.slice(1)], + pipeline: [{ + kind: "negotiate-rfq", + parameters: { maxTurns: 2, timeoutSec: 60 }, + }, ...pipeline.slice(1)], acceptedRails: [{ railId: "ap2:stripe-paymentintents" }], }))); assert.equal(await verifyListing(signedCurrentListing({ diff --git a/reference-implementations/dacs-directory/test/verification.test.ts b/reference-implementations/dacs-directory/test/verification.test.ts index 91e0a49..7941bd7 100644 --- a/reference-implementations/dacs-directory/test/verification.test.ts +++ b/reference-implementations/dacs-directory/test/verification.test.ts @@ -35,7 +35,8 @@ import { } from "../src/catalog/scan.js"; import { deriveSellerReputation, flipOutcome } from "../src/catalog/reputation.js"; import type { Catalog, DealRecord, SellerRecord } from "../src/catalog/types.js"; -import type { BundleVerification } from "../vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; +import type { BundleVerification } from "@kynesyslabs/dacs"; +import type { LegacyMvpAttestationBundle } from "@kynesyslabs/dacs/artifacts"; const seed = Uint8Array.from(Buffer.alloc(32, 7)); const did = `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex")}`; @@ -55,17 +56,12 @@ const listing = { supportedDelivery: ["deliver-attested-payload"], }; -test("listing verification requires a valid signer-bound envelope", async () => { +test("legacy listing reads do not invent a structured signature profile outside the SDK boundary", async () => { const message = Buffer.from(`dacs-listing:v1:${contentHash(listing)}`, "utf8"); const value = Buffer.from(await ed25519Sign(message, privateKeyFromSeed(seed))).toString("hex"); const signed = { ...listing, signature: { algorithm: "ed25519", signer: did, value } }; - assert.ok(await verifyListing(signed)); - assert.equal((await verifyListing({ - ...listing, - signature: { ...signed.signature, signer: `DID:demos:agent:${did.slice(-64)}` }, - }))?.signer, did, "scheme casing is canonicalized when comparing envelope and scope identities"); + assert.equal(await verifyListing(signed), null); assert.equal(await verifyListing({ ...signed, name: "tampered" }), null); - assert.equal(await verifyListing({ ...listing, signature: "deadbeef" }), null); assert.equal(await verifyListing({ ...signed, signatures: [null] }), null); assert.equal(ownerClaim(`0x${did.slice(-64)}`), did); }); @@ -106,7 +102,11 @@ test("listing verification accepts a current structured listing and verifies its description: "Description", category: "services.test", tags: ["test"], - deliverable: { kind: "attested-payload", payloadFormat: "application/json" }, + deliverable: { + kind: "attested-payload", + payloadFormat: "application/json", + verificationMethod: { kind: "self-signed" }, + }, }, buyerRequirement: { requirementVersion: "1", required: [], preferredPresentation: "any" }, pipeline: [ @@ -137,7 +137,14 @@ test("listing verification accepts a current structured listing and verifies its assert.equal(await verifyListing({ ...unsafeScope, signature: { algorithm: "ed25519", signer: did, value: unsafeValue } }), null); }); -function result(outcome: string, signatures: BundleVerification["signatures"]): BundleVerification { +type LegacyBundleVerification = Omit & { + bundle: LegacyMvpAttestationBundle; +}; + +function result( + outcome: string, + signatures: BundleVerification["signatures"], +): LegacyBundleVerification { return { ok: true, fullyVerified: signatures.every((s) => s.verdict === "valid"), @@ -372,10 +379,8 @@ test("strict ref policy binds positional kinds, hashes, and unique references", ...evidenceScope, signature: await signature("dacs-evidence:v1:", evidenceScope, did, seed), }; - const signedListing = { - ...listing, - signature: await signature("dacs-listing:v1:", listing, did, seed), - }; + const listingSignature = await signature("dacs-listing:v1:", listing, did, seed); + const signedListing = { ...listing, signature: listingSignature.value }; const verification = result("completed", [ { party: buyerDid, verdict: "valid" }, { party: did, verdict: "valid" }, @@ -410,7 +415,7 @@ test("strict ref policy binds positional kinds, hashes, and unique references", assert.equal(await refsPassStrictPolicy(verification, artifacts), true); const substituted = structuredClone(verification); - substituted.bundle!.agreementRef.kind = "dacs-2-verifyresult"; + substituted.bundle.agreementRef!.kind = "dacs-2-verifyresult"; substituted.refs[0].kind = "dacs-2-verifyresult"; assert.equal(await refsPassStrictPolicy(substituted, [ { ...artifacts[0], kind: "dacs-2-verifyresult" }, @@ -426,8 +431,8 @@ test("strict ref policy binds positional kinds, hashes, and unique references", artifacts[0], artifacts[1], artifacts[1], artifacts[2], ]), false); - const unsupported = structuredClone(verification) as BundleVerification & { - bundle: NonNullable & { ratingRefs: unknown[] }; + const unsupported = structuredClone(verification) as LegacyBundleVerification & { + bundle: LegacyMvpAttestationBundle & { ratingRefs: unknown[] }; }; unsupported.bundle.ratingRefs = [{ kind: "dacs-5-rating", id: "rating-j", contentHash: "f".repeat(64) }]; assert.equal(await refsPassStrictPolicy(unsupported, artifacts), false); @@ -545,7 +550,7 @@ test("any valid revocation candidate wins and scanner candidates deduplicate", a ).toString("hex"); const verified = await verifyListing({ ...listing, - signature: { algorithm: "ed25519", signer: did, value: listingSignature }, + signature: listingSignature, }); assert.ok(verified); if (!verified) return; @@ -695,7 +700,7 @@ test("listing verification is open-world: an unknown additive top-level field is identity: { ...identityScope, presentation: { kind: "per-claim", signatures: [{ ref: did, signature: identitySignature }] } }, displayName: "Service agent", publicEndpoint: "https://agent.example/a2a", }, - offering: { title: "Service", description: "Description", category: "services.test", tags: ["test"], deliverable: { kind: "attested-payload", payloadFormat: "application/json" } }, + offering: { title: "Service", description: "Description", category: "services.test", tags: ["test"], deliverable: { kind: "attested-payload", payloadFormat: "application/json", verificationMethod: { kind: "self-signed" } } }, buyerRequirement: { requirementVersion: "1", required: [], preferredPresentation: "any" }, pipeline: [ { kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, diff --git a/reference-implementations/dacs-directory/tsconfig.json b/reference-implementations/dacs-directory/tsconfig.json index e4e91f0..cf5d54a 100644 --- a/reference-implementations/dacs-directory/tsconfig.json +++ b/reference-implementations/dacs-directory/tsconfig.json @@ -23,6 +23,9 @@ "@/*": [ "./*" ], + "@kynesyslabs/dacs": [ + "./src/sdkVerification" + ], "@kynesyslabs/dacs/*": [ "./vendor/dacs-sdk/dist/*/index" ]