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 index 4594152..d1a7497 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/bundles/[jobId]/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/bundles/[jobId]/route.ts @@ -11,7 +11,7 @@ export async function GET( 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))) + const bindings = (await Promise.all(candidates.map((binding) => verifyBundleBinding(binding)))) .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 ca5d99c..458f7d7 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/register/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/register/route.ts @@ -31,7 +31,7 @@ export async function POST(req: NextRequest) { // 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)); + const verified = await Promise.all(body.bundleBindings.map((binding) => verifyBundleBinding(binding))); if (verified.some((binding) => binding === null)) { return NextResponse.json({ error: "one or more bundleBindings fail DACS-5 BB-4" }, { status: 400 }); } diff --git a/reference-implementations/dacs-directory/src/catalog/bundleBinding.ts b/reference-implementations/dacs-directory/src/catalog/bundleBinding.ts index 1cd4e56..87053cd 100644 --- a/reference-implementations/dacs-directory/src/catalog/bundleBinding.ts +++ b/reference-implementations/dacs-directory/src/catalog/bundleBinding.ts @@ -1,7 +1,13 @@ import { createHash } from "node:crypto"; import { contentHash } from "@kynesyslabs/dacs/canonical"; -import { ed25519Verify, publicKeyFromRaw } from "@kynesyslabs/dacs/crypto"; import { canonicalDemosAgentClaim } from "./claimRef.js"; +import { + resolvePrimaryClaimKey, + resolveDemosPrimaryClaimKey, + sameResolvedPrimaryClaim, + verifyResolvedPrimaryClaimSignature, + type ResolvePrimaryClaimKey, +} from "./primaryClaimKey.js"; import type { BundleBinding } from "./types.js"; const BINDING_DOMAIN = "dacs-bundle-binding:v1:"; @@ -43,7 +49,10 @@ function strictSignatureBytes(value: unknown): Uint8Array | 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 { +export async function verifyBundleBinding( + value: unknown, + resolveKey: ResolvePrimaryClaimKey = resolveDemosPrimaryClaimKey, +): Promise { const raw = record(value); const signature = record(raw?.signature); if ( @@ -59,27 +68,21 @@ export async function verifyBundleBinding(value: unknown): Promise diff --git a/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts b/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts index 40da033..d528fc0 100644 --- a/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts +++ b/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts @@ -1,10 +1,15 @@ import { contentHash, stripSignature } from "@kynesyslabs/dacs/canonical"; -import { ed25519Verify, publicKeyFromRaw } from "@kynesyslabs/dacs/crypto"; import type { AttestationBundle } from "@kynesyslabs/dacs/artifacts"; import type { BundleVerification } from "../../vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; import { bundleSignerPolicy, demosSigningIdentity } from "./bundleSignerPolicy.js"; import { verifyListing } from "./listingVerification.js"; +import { + resolvePrimaryClaimKey, + resolveDemosPrimaryClaimKey, + verifyPrimaryClaimSignature, + type ResolvePrimaryClaimKey, +} from "./primaryClaimKey.js"; import type { DealRecord, RegisteredDeal } from "./types.js"; // This is the legacy SDK compatibility path. Current-profile agreement artifacts, @@ -28,16 +33,6 @@ function decode(value: string): Uint8Array | null { } } -function keyFor(claim: string): ReturnType | null { - const hex = claim.match(/([0-9a-fA-F]{64})$/)?.[1]; - if (!hex) return null; - try { - return publicKeyFromRaw(Uint8Array.from(Buffer.from(hex, "hex"))); - } catch { - return null; - } -} - export function hasRequiredBundleSignatures(result: BundleVerification, rawBundle: unknown): boolean { const bundle = result.bundle; if (!bundle || !rawBundle || typeof rawBundle !== "object" || Array.isArray(rawBundle)) return false; @@ -54,7 +49,8 @@ export function hasRequiredBundleSignatures(result: BundleVerification, rawBundl } const valid = new Set(result.signatures.filter((s) => s.verdict === "valid").map((s) => s.party)); return bundleSignerPolicy(bundle, valid, result.signatures.length > 0 && - result.signatures.every((signature) => signature.verdict === "valid")); + result.signatures.every((signature) => signature.verdict === "valid"), + { legacyDemosAliases: true }); } /** Return the role registered for this exact copy address, if the directory knows it. */ @@ -175,8 +171,9 @@ function expectedArtifacts(verification: BundleVerification): ExpectedArtifact[] export async function verifyReferencedArtifactSignature( artifact: ResolvedArtifact, partyClaims: Set = new Set(), + resolveKey: ResolvePrimaryClaimKey = resolveDemosPrimaryClaimKey, ): Promise { - if (artifact.kind === "dacs-1-listing") return (await verifyListing(artifact.raw)) !== null; + if (artifact.kind === "dacs-1-listing") return (await verifyListing(artifact.raw, resolveKey)) !== null; const separator = SEPARATORS[artifact.kind]; if (!separator) return false; const raw = artifact.raw; @@ -197,19 +194,28 @@ export async function verifyReferencedArtifactSignature( const s = entry as Record; const signer = typeof s.signer === "string" ? s.signer : typeof s.party === "string" ? s.party : null; if (s.algorithm !== "ed25519" || !signer || typeof s.value !== "string") return false; - const key = keyFor(signer); const signature = decode(s.value); - if (!key || !signature || !(await ed25519Verify(message, signature, key))) return false; - validSigners.add(signer); + if (!signature) return false; + const verified = await verifyPrimaryClaimSignature( + message, signature, signer, s.algorithm, resolveKey, + ); + if (!verified) return false; + validSigners.add(verified.canonicalClaim); } if (artifact.kind === "dacs-3-agreement") { const buyer = scope.buyer; const seller = scope.seller; - return typeof buyer === "string" && typeof seller === "string" && - validSigners.has(buyer) && validSigners.has(seller); + if (typeof buyer !== "string" || typeof seller !== "string") return false; + const resolved = await Promise.all([ + resolvePrimaryClaimKey(buyer, "ed25519", resolveKey), + resolvePrimaryClaimKey(seller, "ed25519", resolveKey), + ]); + return resolved.every((claim) => claim && validSigners.has(claim.canonicalClaim)); } if (artifact.kind === "dacs-4-evidence") { - return [...validSigners].some((s) => partyClaims.has(s)); + const parties = await Promise.all([...partyClaims].map((claim) => + resolvePrimaryClaimKey(claim, "ed25519", resolveKey))); + return parties.some((claim) => claim && validSigners.has(claim.canonicalClaim)); } return true; } @@ -217,6 +223,7 @@ export async function verifyReferencedArtifactSignature( export async function refsPassStrictPolicy( verification: BundleVerification, artifacts: ResolvedArtifact[], + resolveKey: ResolvePrimaryClaimKey = resolveDemosPrimaryClaimKey, ): Promise { if (!verification.ok || verification.refs.some((r) => r.verdict !== "ok")) return false; const expected = expectedArtifacts(verification); @@ -242,7 +249,7 @@ export async function refsPassStrictPolicy( } const partyClaims = new Set((verification.bundle?.parties ?? []).map((p) => p.primaryClaim)); const checks = await Promise.all( - artifacts.map((a) => verifyReferencedArtifactSignature(a, partyClaims)), + artifacts.map((a) => verifyReferencedArtifactSignature(a, partyClaims, resolveKey)), ); return checks.every(Boolean); } diff --git a/reference-implementations/dacs-directory/src/catalog/bundleSignerPolicy.ts b/reference-implementations/dacs-directory/src/catalog/bundleSignerPolicy.ts index 3491960..77c5163 100644 --- a/reference-implementations/dacs-directory/src/catalog/bundleSignerPolicy.ts +++ b/reference-implementations/dacs-directory/src/catalog/bundleSignerPolicy.ts @@ -1,3 +1,5 @@ +import { canonicalSigningIdentity } from "./primaryClaimKey.js"; + const ROLES = new Set(["buyer", "seller", "orchestrator"] as const); const OUTCOMES = new Set([ "completed", "failed-perm", "failed-counterparty", "failed-substrate", "aborted-by-self", "aborted-by-other", @@ -11,7 +13,7 @@ interface BundleLike { parties?: unknown; } -/** Collapse the accepted Demos claim spellings to one key identity. */ +/** Explicit compatibility identity used only by the legacy SDK reader. */ export function demosSigningIdentity(claim: string): string { const key = claim.match(/^(?:did:demos:agent:|0x)?([0-9a-f]{64})$/i)?.[1]; return key ? key.toLowerCase() : claim; @@ -25,6 +27,7 @@ export function bundleSignerPolicy( bundle: BundleLike, validSigners: Iterable, allPresentedSignaturesValid: boolean, + options: { legacyDemosAliases?: boolean } = {}, ): boolean { if (!allPresentedSignaturesValid || !Array.isArray(bundle.parties)) return false; if (typeof bundle.outcome !== "string" || !OUTCOMES.has(bundle.outcome)) return false; @@ -40,18 +43,19 @@ export function bundleSignerPolicy( byRole.set(role as BundleRole, claim); } if (!byRole.has("buyer") || !byRole.has("seller")) return false; - if (demosSigningIdentity(byRole.get("buyer")!) === demosSigningIdentity(byRole.get("seller")!)) return false; + const identity = options.legacyDemosAliases ? demosSigningIdentity : canonicalSigningIdentity; + if (identity(byRole.get("buyer")!) === identity(byRole.get("seller")!)) return false; const anchoredByRole = bundle.anchoredByRole; if (typeof anchoredByRole !== "string" || !ROLES.has(anchoredByRole as BundleRole)) return false; const anchorClaim = byRole.get(anchoredByRole as BundleRole); if (!anchorClaim) return false; - const partyClaims = new Set([...byRole.values()].map(demosSigningIdentity)); - const valid = new Set([...validSigners].map(demosSigningIdentity)); + const partyClaims = new Set([...byRole.values()].map(identity)); + const valid = new Set([...validSigners].map(identity)); if (valid.size === 0 || [...valid].some((claim) => !partyClaims.has(claim))) return false; const allRequired = valid.size === partyClaims.size && [...partyClaims].every((claim) => valid.has(claim)); const abort = bundle.outcome === "aborted-by-self" || bundle.outcome === "aborted-by-other"; - return abort ? (valid.size === 1 && valid.has(demosSigningIdentity(anchorClaim))) || allRequired : allRequired; + return abort ? (valid.size === 1 && valid.has(identity(anchorClaim))) || allRequired : allRequired; } diff --git a/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts b/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts index 670bf12..a536ad6 100644 --- a/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts +++ b/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts @@ -1,7 +1,12 @@ import { contentHash } from "@kynesyslabs/dacs/canonical"; -import { ed25519Verify, publicKeyFromRaw } from "@kynesyslabs/dacs/crypto"; import { bundleSignerPolicy } from "./bundleSignerPolicy.js"; import { verifyListing } from "./listingVerification.js"; +import { + canonicalSigningIdentity, + resolveDemosPrimaryClaimKey, + verifyPrimaryClaimSignature, + type ResolvePrimaryClaimKey, +} from "./primaryClaimKey.js"; export type ArtifactKind = "agreement" | "evidence" | "verify-result" | "composite" | "rating" | "listing" | "bundle" | "attestation"; export interface CurrentRef { anchor: { kind: string; locator: string }; contentHash: string; [key: string]: unknown } @@ -22,12 +27,16 @@ export interface EvidenceGraph { export interface EvidenceGraphDeps { read: (locator: string) => Promise | null>; resolveListing: (ref: Record) => Promise<{ locator: string; raw: Record } | null>; + resolvePrimaryClaimKey?: ResolvePrimaryClaimKey; } const rec = (value: unknown): Record | null => value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; const arr = (value: unknown): Record[] => Array.isArray(value) ? value.map(rec).filter(Boolean) as Record[] : []; +const sameSigningIdentity = (left: unknown, right: unknown): boolean => + typeof left === "string" && typeof right === "string" && + canonicalSigningIdentity(left) === canonicalSigningIdentity(right); const OUTCOMES = new Set(["completed", "failed-perm", "failed-counterparty", "failed-substrate", "aborted-by-self", "aborted-by-other"]); const PHASE_OUTCOMES = new Set(["ok", "fail"]); const ERROR_CLASSES = new Set(["permanent", "transient", "counterparty", "substrate", "settlement-atomicity"]); @@ -117,18 +126,24 @@ function decodeSignature(value: unknown): Uint8Array | null { if (/^[0-9a-fA-F]{128}$/.test(hex)) return Uint8Array.from(Buffer.from(hex, "hex")); try { const bytes = Buffer.from(value, "base64url"); return bytes.length === 64 ? Uint8Array.from(bytes) : null; } catch { return null; } } -export function claimKey(claim: unknown): ReturnType | null { - const hex = typeof claim === "string" ? claim.match(/([0-9a-fA-F]{64})$/)?.[1] : undefined; - if (!hex) return null; - try { return publicKeyFromRaw(Uint8Array.from(Buffer.from(hex, "hex"))); } catch { return null; } -} -export function verifyComponentSignature(raw: Record, kind: ArtifactKind, signature: Record): boolean { +export async function verifyComponentSignature( + raw: Record, + kind: ArtifactKind, + signature: Record, + resolveKey: ResolvePrimaryClaimKey = resolveDemosPrimaryClaimKey, +): Promise { if (kind === "attestation") return false; if (signature.algorithm !== "ed25519" || typeof signature.signer !== "string") return false; - const key = claimKey(signature.signer); const value = decodeSignature(signature.value); + const value = decodeSignature(signature.value); const separator = separatorFor(raw, kind); - if (!key || !value || !separator) return false; - return ed25519Verify(Buffer.from(separator + artifactHash(raw, kind), "utf8"), value, key); + if (!value || !separator) return false; + return Boolean(await verifyPrimaryClaimSignature( + Buffer.from(separator + artifactHash(raw, kind), "utf8"), + value, + signature.signer, + signature.algorithm, + resolveKey, + )); } type BundleProfile = "legacy" | "fault"; @@ -140,14 +155,24 @@ function bundleProfile(raw: Record): BundleProfile | null { return raw.faultBundleVersion === "1" ? "fault" : null; } -function verifyBundleSignature(raw: Record, signature: Record): boolean { +async function verifyBundleSignature( + raw: Record, + signature: Record, + resolveKey: ResolvePrimaryClaimKey, +): Promise { const party = signature.party; if (signature.algorithm !== "ed25519" || typeof party !== "string") return false; - const key = claimKey(party); const value = decodeSignature(signature.value); + const value = decodeSignature(signature.value); const profile = bundleProfile(raw); - if (!key || !value || !profile) return false; + if (!value || !profile) return false; const separator = profile === "fault" ? FAULT_BUNDLE_SEPARATOR : SEPARATORS.bundle; - return ed25519Verify(Buffer.from(separator + artifactHash(raw, "bundle"), "utf8"), value, key); + return Boolean(await verifyPrimaryClaimSignature( + Buffer.from(separator + artifactHash(raw, "bundle"), "utf8"), + value, + party, + signature.algorithm, + resolveKey, + )); } function faultAttributionOk(raw: Record, parties: Record[]): boolean { @@ -186,21 +211,33 @@ function shapeOk(raw: Record, kind: ArtifactKind): boolean { if (kind === "rating") return raw.ratingVersion === "1" && typeof raw.jobId === "string" && typeof raw.rater === "string" && typeof raw.target === "string" && (raw.targetRole === "buyer" || raw.targetRole === "seller") && Number.isInteger(raw.value) && Number(raw.value) >= 1 && Number(raw.value) <= 5 && typeof raw.ratedAt === "number" && (typeof raw.freeText !== "string" || raw.freeText.length <= 1000); return true; } -function signatureOk(raw: Record, kind: ArtifactKind): boolean { +async function signatureOk( + raw: Record, + kind: ArtifactKind, + resolveKey: ResolvePrimaryClaimKey, +): Promise { if (kind === "agreement") { const signatures = arr(raw.signatures); - if (raw.signature !== undefined || !Array.isArray(raw.signatures) || signatures.length !== raw.signatures.length || - !signatures.every((signature) => verifyComponentSignature(raw, kind, - { ...signature, signer: signature.signer ?? signature.party }))) return false; + if (raw.signature !== undefined || !Array.isArray(raw.signatures) || signatures.length !== raw.signatures.length) return false; + const checks = await Promise.all(signatures.map((signature) => verifyComponentSignature( + raw, kind, { ...signature, signer: signature.signer ?? signature.party }, resolveKey, + ))); + if (!checks.every(Boolean)) return false; const parties = arr(raw.parties); - const required = parties.filter((party) => party.role === "buyer" || party.role === "seller").map((party) => party.primaryClaim); - const valid = new Set(signatures.map((signature) => signature.signer ?? signature.party)); - return required.length === 2 && required.every((claim) => valid.has(claim)); + const required = parties.filter((party) => party.role === "buyer" || party.role === "seller") + .map((party) => typeof party.primaryClaim === "string" ? canonicalSigningIdentity(party.primaryClaim) : null); + const valid = new Set(signatures.map((signature) => signature.signer ?? signature.party) + .filter((claim): claim is string => typeof claim === "string") + .map(canonicalSigningIdentity)); + return required.length === 2 && required.every((claim) => claim !== null && valid.has(claim)); } if (raw.signatures !== undefined) return false; const signature = rec(raw.signature); - if (kind === "rating" && signature?.signer !== raw.rater) return false; - return Boolean(signature && verifyComponentSignature(raw, kind, signature)); + if (kind === "rating" && ( + typeof signature?.signer !== "string" || typeof raw.rater !== "string" || + canonicalSigningIdentity(signature.signer) !== canonicalSigningIdentity(raw.rater) + )) return false; + return Boolean(signature && await verifyComponentSignature(raw, kind, signature, resolveKey)); } async function resolveRef(kind: ArtifactKind, ref: unknown, deps: EvidenceGraphDeps): Promise { @@ -213,11 +250,14 @@ async function resolveRef(kind: ArtifactKind, ref: unknown, deps: EvidenceGraphD } if (!isCurrentRef(ref)) return null; const hash = normalizedHash(ref.contentHash); - const raw = await deps.read(ref.anchor.locator); if (!raw || !withinArtifactLimit(raw) || !shapeOk(raw, kind) || artifactHash(raw, kind) !== hash || !signatureOk(raw, kind)) return null; + const raw = await deps.read(ref.anchor.locator); + if (!raw || !withinArtifactLimit(raw) || !shapeOk(raw, kind) || artifactHash(raw, kind) !== hash || + !(await signatureOk(raw, kind, deps.resolvePrimaryClaimKey ?? resolveDemosPrimaryClaimKey))) return null; return { kind, ref, locator: ref.anchor.locator, raw, contentHash: hash }; } export async function buildCurrentEvidenceGraph(bundleLocator: string, deps: EvidenceGraphDeps): Promise { + const resolveKey = deps.resolvePrimaryClaimKey ?? resolveDemosPrimaryClaimKey; const raw = await deps.read(bundleLocator); 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); @@ -231,10 +271,10 @@ export async function buildCurrentEvidenceGraph(bundleLocator: string, deps: Evi if (!Array.isArray(raw.signatures) || signatures.length !== raw.signatures.length) { return fail("bundle signatures contain malformed entries"); } - const signatureChecks = signatures.map((signature) => ({ + const signatureChecks = await Promise.all(signatures.map(async (signature) => ({ party: signature.party, - valid: verifyBundleSignature(raw, signature), - })); + valid: await verifyBundleSignature(raw, signature, resolveKey), + }))); const validParties = signatureChecks.filter((check) => check.valid && typeof check.party === "string") .map((check) => check.party as string); const signaturesVerified = bundleSignerPolicy(raw, validParties, @@ -276,13 +316,16 @@ export async function buildCurrentEvidenceGraph(bundleLocator: string, deps: Evi const resolved = await resolveRef(kind, ref, deps); if (!resolved) return { ...fail(`${kind} reference failed`), signaturesVerified }; if ((kind === "evidence" || kind === "rating" || kind === "composite") && resolved.raw.jobId !== raw.jobId) return { ...fail(`${kind} belongs to another job`), signaturesVerified }; if (kind === "rating") { - const target = parties.find((party) => party.primaryClaim === resolved.raw.target); - if (!parties.some((party) => party.primaryClaim === resolved.raw.rater) || + const target = parties.find((party) => sameSigningIdentity(party.primaryClaim, resolved.raw.target)); + if (!parties.some((party) => sameSigningIdentity(party.primaryClaim, resolved.raw.rater)) || !target || target.role !== resolved.raw.targetRole) { return { ...fail("rating party binding failed"), signaturesVerified }; } } - if (kind === "evidence" && !parties.some((party) => party.primaryClaim === rec(resolved.raw.signature)?.signer)) return { ...fail("evidence signer is not a session party"), signaturesVerified }; + if (kind === "evidence" && !parties.some((party) => + sameSigningIdentity(party.primaryClaim, rec(resolved.raw.signature)?.signer))) { + return { ...fail("evidence signer is not a session party"), signaturesVerified }; + } artifacts.push(resolved); } } @@ -301,7 +344,8 @@ export async function buildCurrentEvidenceGraph(bundleLocator: string, deps: Evi if (artifacts.length >= MAX_GRAPH_ARTIFACTS) { graphLimitExceeded = true; return false; } const nested = await resolveRef(kind, value, deps); if (!nested) return false; if ((kind === "evidence" || kind === "composite") && nested.raw.jobId !== raw.jobId) return false; - if (kind === "evidence" && !parties.some((party) => party.primaryClaim === rec(nested.raw.signature)?.signer)) return false; + if (kind === "evidence" && !parties.some((party) => + sameSigningIdentity(party.primaryClaim, rec(nested.raw.signature)?.signer))) return false; seen.set(key, nested.contentHash); artifacts.push(nested); return true; }; for (let index = 0; index < artifacts.length; index++) { @@ -343,11 +387,14 @@ export async function buildCurrentEvidenceGraph(bundleLocator: string, deps: Evi const listingRef = rec(raw.listingRef)!; const listed = await deps.resolveListing(listingRef); const listingHash = normalizedHash(listingRef.contentHash); - if (!listed || artifactHash(listed.raw, "listing") !== listingHash || !(await verifyListing(listed.raw))) return { ...fail("listing reference failed"), signaturesVerified }; + if (!listed || artifactHash(listed.raw, "listing") !== listingHash || + !(await verifyListing(listed.raw, resolveKey))) return { ...fail("listing reference failed"), signaturesVerified }; if (agreement && JSON.stringify(agreement.listingRef) !== JSON.stringify(raw.listingRef)) return { ...fail("agreement listing binding failed"), signaturesVerified }; if (agreement) { const agreementParties = arr(agreement.parties); - if (!["buyer", "seller"].every((role) => agreementParties.some((candidate) => candidate.role === role && parties.some((party) => party.role === role && party.primaryClaim === candidate.primaryClaim)))) { + if (!["buyer", "seller"].every((role) => agreementParties.some((candidate) => + candidate.role === role && parties.some((party) => + party.role === role && sameSigningIdentity(party.primaryClaim, candidate.primaryClaim))))) { return { ...fail("agreement party binding failed"), signaturesVerified }; } if (!agreementMatchesListing(agreement, listed.raw)) { diff --git a/reference-implementations/dacs-directory/src/catalog/identityVerification.ts b/reference-implementations/dacs-directory/src/catalog/identityVerification.ts index 484bfd9..895a3b0 100644 --- a/reference-implementations/dacs-directory/src/catalog/identityVerification.ts +++ b/reference-implementations/dacs-directory/src/catalog/identityVerification.ts @@ -1,6 +1,12 @@ import { contentHash } from "@kynesyslabs/dacs/canonical"; import { readAnchor } from "./chain.js"; import { artifactHash, isCurrentRef, verifyComponentSignature } from "./evidenceGraph.js"; +import { + resolveDemosPrimaryClaimKey, + resolvePrimaryClaimKey, + sameResolvedPrimaryClaim, + type ResolvePrimaryClaimKey, +} from "./primaryClaimKey.js"; import type { IdentityTier } from "./types.js"; type Obj = Record; @@ -36,17 +42,28 @@ function claimParts(ref: unknown): { scheme: string; identifier: string } | null ? { scheme: value.scheme, identifier: value.identifier } : null; } -async function verifiedClaim(claim: Obj, now: number, resolveRecipe: ResolveRecipe, read: typeof readAnchor): Promise<{ scheme: string } | null> { +async function verifiedClaim( + claim: Obj, + now: number, + resolveRecipe: ResolveRecipe, + read: typeof readAnchor, + resolveKey: ResolvePrimaryClaimKey, +): Promise<{ scheme: string } | null> { const parts = claimParts(claim.ref); const ref = obj(claim.verifiedBy); if (!parts || !ref || !isCurrentRef(ref) || !Number.isSafeInteger(ref.recipeVersion)) return null; const raw = await read(ref.anchor.locator); if (!raw) return null; if (artifactHash(raw, "verify-result") !== normalizeHash(ref.contentHash)) return null; if (raw.resultVersion !== "1" || raw.scheme !== parts.scheme || raw.identifier !== parts.identifier || raw.decision !== "pass" || raw.recipeVersion !== ref.recipeVersion || typeof raw.verifiedAt !== "number") return null; - const signature = obj(raw.signature); if (!signature || !verifyComponentSignature(raw, "verify-result", signature)) return null; + const signature = obj(raw.signature); + if (!signature || !(await verifyComponentSignature(raw, "verify-result", signature, resolveKey))) return null; + const verifiedSigner = await resolvePrimaryClaimKey(signature.signer, signature.algorithm, resolveKey); + if (!verifiedSigner) return null; const recipe = await resolveRecipe(parts.scheme, Number(ref.recipeVersion)); - if (!recipe || !recipe.methods.includes(String(raw.method)) || ["mocked", "disabled", "failed"].includes(recipe.availability) || - !recipe.trustedResultSigners.includes(String(signature.signer))) return null; + if (!recipe || !recipe.methods.includes(String(raw.method)) || ["mocked", "disabled", "failed"].includes(recipe.availability)) return null; + const trustedSigners = await Promise.all(recipe.trustedResultSigners.map((claim) => + resolvePrimaryClaimKey(claim, signature.algorithm, resolveKey))); + if (!trustedSigners.some((claim) => claim && sameResolvedPrimaryClaim(claim, verifiedSigner))) return null; const authorityExpiry = typeof raw.validUntil === "number" ? raw.validUntil : raw.verifiedAt + recipe.defaultMaxAgeSec * 1000; const wrapperExpiry = typeof claim.expiresAt === "number" ? claim.expiresAt : Infinity; if (authorityExpiry < raw.verifiedAt || now > Math.min(authorityExpiry, wrapperExpiry)) return null; @@ -63,9 +80,11 @@ export async function deriveIdentityTier( resolveRecipe: ResolveRecipe = resolveConfiguredRecipe, now = Date.now(), read: typeof readAnchor = readAnchor, + resolveKey: ResolvePrimaryClaimKey = resolveDemosPrimaryClaimKey, ): Promise { const claims = Array.isArray(identityBundle?.claims) ? identityBundle.claims.map(obj).filter(Boolean) as Obj[] : []; - const verified = (await Promise.all(claims.map((claim) => verifiedClaim(claim, now, resolveRecipe, read)))).filter(Boolean) as Array<{ scheme: string }>; + const verified = (await Promise.all(claims.map((claim) => + verifiedClaim(claim, now, resolveRecipe, read, resolveKey)))).filter(Boolean) as Array<{ scheme: string }>; if (verified.some((claim) => INSTITUTIONAL.has(claim.scheme))) return "institutional"; return verified.length ? "verified" : "self-declared"; } diff --git a/reference-implementations/dacs-directory/src/catalog/indexer.ts b/reference-implementations/dacs-directory/src/catalog/indexer.ts index a495034..d3995a4 100644 --- a/reference-implementations/dacs-directory/src/catalog/indexer.ts +++ b/reference-implementations/dacs-directory/src/catalog/indexer.ts @@ -27,6 +27,7 @@ import { deriveAnchorAddress, readAnchor, readAnchorRecord } from "./chain.js"; import { gcrGetIdentities } from "./gcr.js"; import { findValidListingRevocation, ownerClaim, verifyListing } from "./listingVerification.js"; import { canonicalDemosAgentClaim } from "./claimRef.js"; +import { resolveDemosPrimaryClaimKey } from "./primaryClaimKey.js"; import { listingPresentation } from "./listingMetadata.js"; import { verifyOwnerSignature } from "./registrationSig.js"; import { @@ -67,10 +68,6 @@ import type { SellerRecord, } from "./types.js"; -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; -}; const verify = (b: Uint8Array, s: Uint8Array, p: Uint8Array): boolean => ed25519Verify(b, s, publicKeyFromRaw(p)); @@ -108,7 +105,7 @@ export async function indexRegistration( const now = Date.now(); // ── CCI badges: from the on-chain GCR, never from the payload ──────────── - const hex = reg.primaryClaim.match(/([0-9a-fA-F]{64})$/)?.[1] ?? reg.primaryClaim; + const hex = canonicalDemosAgentClaim(reg.primaryClaim)?.slice(-64) ?? reg.primaryClaim; let cci: CciBadge[] = prior?.cci ?? []; try { const resolved = await resolveIdentities(hex); @@ -225,7 +222,7 @@ export async function indexRegistration( ...[...relevantJobs].flatMap((jobId) => scanState.bundleBindings?.[jobId] ?? []), ...(reg.bundleBindings ?? []).filter((binding) => relevantJobs.has(binding.jobId)), ]; - const verifiedBindings = (await Promise.all(rawBindings.map(verifyBundleBinding))) + const verifiedBindings = (await Promise.all(rawBindings.map((binding) => verifyBundleBinding(binding)))) .filter((binding): binding is BundleBinding => binding !== null); for (const deal of reg.deals ?? []) { const jobBindings = verifiedBindings.filter((binding) => binding.jobId === deal.jobId); @@ -381,7 +378,8 @@ export async function indexRegistration( if (raw) resolvedArtifacts.push({ kind, raw }); return raw; }, - resolvePublicKey: async (did) => keyFromDid(did), + resolvePublicKey: async (claim) => + (await resolveDemosPrimaryClaimKey(claim, "ed25519"))?.publicKey ?? null, verify, }).catch(() => null); const bundle = verification?.bundle; diff --git a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts index efb65e8..dd56f1d 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts @@ -1,7 +1,15 @@ import { contentHash, stripSignature } from "@kynesyslabs/dacs/canonical"; -import { ed25519Verify, publicKeyFromRaw } from "@kynesyslabs/dacs/crypto"; import { isListing, type Listing } from "@kynesyslabs/dacs/artifacts"; import { safePublicEndpoint } from "./publicEndpoint.js"; +import { + canonicalSigningIdentity, + resolvePrimaryClaimKey, + resolveDemosPrimaryClaimKey, + sameResolvedPrimaryClaim, + verifyPrimaryClaimSignature, + type ResolvedPrimaryClaimKey, + type ResolvePrimaryClaimKey, +} from "./primaryClaimKey.js"; import type { RevocationBinding } from "./types.js"; const SEPARATOR = "dacs-listing:v1:"; @@ -92,12 +100,17 @@ function currentListing(scope: Record): { : 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) => claim.ref === sellerClaim) || !claims.some((claim) => claim.ref === signer) || + !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)) || @@ -108,31 +121,40 @@ function currentListing(scope: Record): { return { signer, sellerClaim, signature }; } -async function verifyEd25519(message: Uint8Array, signer: string, value: unknown): Promise { - const keyHex = signer.match(/([0-9a-fA-F]{64})$/)?.[1]; +async function verifyEd25519( + message: Uint8Array, + signer: string, + value: unknown, + resolveKey: ResolvePrimaryClaimKey, +): Promise { const sig = typeof value === "string" ? decodeSignature(value) : null; - if (!keyHex || !sig) return false; - try { - const key = publicKeyFromRaw(Uint8Array.from(Buffer.from(keyHex, "hex"))); - return await ed25519Verify(message, sig, key); - } catch { - return false; - } + return sig + ? verifyPrimaryClaimSignature(message, sig, signer, "ed25519", resolveKey) + : null; } -async function verifyIdentityPresentation(identity: Record, sellerClaim: string): Promise { +async function verifyIdentityPresentation( + identity: Record, + sellerClaim: string, + resolveKey: ResolvePrimaryClaimKey, +): Promise { const presentation = record(identity.presentation); - if (presentation?.kind !== "per-claim" || !Array.isArray(presentation.signatures)) return false; - const signature = presentation.signatures.map(record).find((item) => item?.ref === sellerClaim); - if (!signature) return false; + if (presentation?.kind !== "per-claim" || !Array.isArray(presentation.signatures)) return null; + const sellerIdentity = canonicalSigningIdentity(sellerClaim); + const signature = presentation.signatures.map(record).find((item) => + typeof item?.ref === "string" && canonicalSigningIdentity(item.ref) === sellerIdentity); + if (!signature) return null; const bundleScope = { ...identity }; delete bundleScope.presentation; const message = Buffer.from(`dacs-bundle-presentation:v1:${contentHash(bundleScope)}`, "utf8"); - return verifyEd25519(message, sellerClaim, signature.signature); + return verifyEd25519(message, sellerClaim, signature.signature, resolveKey); } /** Verify either the current normative Listing or the pinned SDK compatibility profile. */ -export async function verifyListing(raw: Record): Promise { +export async function verifyListing( + raw: Record, + resolveKey: ResolvePrimaryClaimKey = resolveDemosPrimaryClaimKey, +): Promise { if (raw.signatures !== undefined) return null; const current = currentListing(raw); if (current) { @@ -141,18 +163,21 @@ export async function verifyListing(raw: Record): Promise 16_384) return null; if (current.signature.algorithm !== "ed25519" || typeof current.signature.value !== "string") return null; const hash = contentHash(scope); - const signatureOk = await verifyEd25519( - Buffer.from(SEPARATOR + hash, "utf8"), current.signer, current.signature.value, + const verifiedSigner = await verifyEd25519( + Buffer.from(SEPARATOR + hash, "utf8"), current.signer, current.signature.value, resolveKey, ); const seller = record(raw.seller); const identity = record(seller?.identity); - if (!signatureOk || !identity || !(await verifyIdentityPresentation(identity, current.sellerClaim))) return null; + const verifiedSeller = identity + ? await verifyIdentityPresentation(identity, current.sellerClaim, resolveKey) + : null; + if (!verifiedSigner || !verifiedSeller) return null; return { listing: raw, scope, contentHash: hash, - signer: current.signer, - sellerClaim: current.sellerClaim, + signer: verifiedSigner.canonicalClaim, + sellerClaim: verifiedSeller.canonicalClaim, profile: "dacs-v0.1", }; } @@ -172,26 +197,23 @@ export async function verifyListing(raw: Record): Promise Promise | null>, + resolveKey: ResolvePrimaryClaimKey = resolveDemosPrimaryClaimKey, ): Promise { return Boolean(await findValidListingRevocation( candidateRefs, listing, expectedVersion, readCandidate, + resolveKey, )); } @@ -233,10 +257,13 @@ export async function findValidListingRevocation( listing: VerifiedListing, expectedVersion: number, readCandidate: (ref: string) => Promise | null>, + resolveKey: ResolvePrimaryClaimKey = resolveDemosPrimaryClaimKey, ): Promise { for (const ref of candidateRefs) { const candidate = await readCandidate(ref); - if (!candidate || !(await verifyListingRevocation(candidate, listing, expectedVersion))) continue; + if (!candidate || !(await verifyListingRevocation( + candidate, listing, expectedVersion, resolveKey, + ))) continue; const scope = stripSignature(candidate); return { sellerPrimaryClaim: listing.sellerClaim, @@ -260,6 +287,7 @@ export async function verifyListingRevocation( raw: Record, listing: VerifiedListing, expectedVersion: number, + resolveKey: ResolvePrimaryClaimKey = resolveDemosPrimaryClaimKey, ): Promise { const scope = stripSignature(raw); if ( @@ -273,17 +301,18 @@ export async function verifyListingRevocation( if (!signature || typeof signature !== "object" || Array.isArray(signature)) return false; const s = signature as Record; if ( - s.algorithm !== "ed25519" || s.signer !== listing.signer || + s.algorithm !== "ed25519" || typeof s.signer !== "string" || typeof s.value !== "string" ) return false; - const keyHex = listing.signer.match(/([0-9a-fA-F]{64})$/)?.[1]; const sig = decodeSignature(s.value); - if (!keyHex || !sig) return false; - try { - const key = publicKeyFromRaw(Uint8Array.from(Buffer.from(keyHex, "hex"))); - const message = Buffer.from(`dacs-revocation:v1:${contentHash(scope)}`, "utf8"); - return await ed25519Verify(message, sig, key); - } catch { - return false; - } + if (!sig) return false; + const message = Buffer.from(`dacs-revocation:v1:${contentHash(scope)}`, "utf8"); + const verifiedSigner = await verifyPrimaryClaimSignature( + message, sig, listing.signer, s.algorithm, resolveKey, + ); + const verifiedEnvelopeSigner = await resolvePrimaryClaimKey(s.signer, s.algorithm, resolveKey); + return Boolean( + verifiedSigner && verifiedEnvelopeSigner && + sameResolvedPrimaryClaim(verifiedSigner, verifiedEnvelopeSigner), + ); } diff --git a/reference-implementations/dacs-directory/src/catalog/primaryClaimKey.ts b/reference-implementations/dacs-directory/src/catalog/primaryClaimKey.ts new file mode 100644 index 0000000..edd4e2d --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/primaryClaimKey.ts @@ -0,0 +1,96 @@ +import { ed25519Verify, publicKeyFromRaw } from "@kynesyslabs/dacs/crypto"; + +import { canonicalDemosAgentClaim } from "./claimRef.js"; + +export interface ResolvedPrimaryClaimKey { + canonicalClaim: string; + algorithm: "ed25519"; + publicKey: Uint8Array; +} + +/** + * Deployment-supplied key resolution boundary for registered ClaimReference + * schemes. Unknown, unavailable, stale, or unsupported claims return null. + */ +export type ResolvePrimaryClaimKey = ( + claim: string, + algorithm: string, +) => Promise; + +/** The only intrinsic resolver this Demos-focused directory enables today. */ +export const resolveDemosPrimaryClaimKey: ResolvePrimaryClaimKey = async (claim, algorithm) => { + if (algorithm !== "ed25519") return null; + const canonicalClaim = canonicalDemosAgentClaim(claim); + if (!canonicalClaim) return null; + return { + canonicalClaim, + algorithm, + publicKey: Uint8Array.from(Buffer.from(canonicalClaim.slice(-64), "hex")), + }; +}; + +/** Validate even an injected resolver's result before it reaches crypto. */ +export async function resolvePrimaryClaimKey( + claim: unknown, + algorithm: unknown, + resolver: ResolvePrimaryClaimKey = resolveDemosPrimaryClaimKey, +): Promise { + if (typeof claim !== "string" || typeof algorithm !== "string") return null; + try { + const resolved = await resolver(claim, algorithm); + if ( + !resolved || resolved.algorithm !== "ed25519" || resolved.algorithm !== algorithm || + typeof resolved.canonicalClaim !== "string" || resolved.canonicalClaim.length === 0 || + !(resolved.publicKey instanceof Uint8Array) || resolved.publicKey.length !== 32 + ) return null; + // Constructing the SDK key here catches malformed points/encodings before + // a caller mistakes a resolver response for a verified identity. + publicKeyFromRaw(resolved.publicKey); + return resolved; + } catch { + return null; + } +} + +/** Verify and return the authenticated canonical identity, or null. */ +export async function verifyPrimaryClaimSignature( + message: Uint8Array, + signature: Uint8Array, + claim: unknown, + algorithm: unknown, + resolver: ResolvePrimaryClaimKey = resolveDemosPrimaryClaimKey, +): Promise { + const resolved = await resolvePrimaryClaimKey(claim, algorithm, resolver); + if (!resolved) return null; + return verifyResolvedPrimaryClaimSignature(message, signature, resolved) + ? resolved + : null; +} + +export function verifyResolvedPrimaryClaimSignature( + message: Uint8Array, + signature: Uint8Array, + resolved: ResolvedPrimaryClaimKey, +): boolean { + try { + return ed25519Verify(message, signature, publicKeyFromRaw(resolved.publicKey)); + } catch { + return false; + } +} + +/** Compare both canonical identity and authenticated key material. */ +export function sameResolvedPrimaryClaim( + left: ResolvedPrimaryClaimKey, + right: ResolvedPrimaryClaimKey, +): boolean { + return left.algorithm === right.algorithm && + left.canonicalClaim === right.canonicalClaim && + left.publicKey.length === right.publicKey.length && + left.publicKey.every((byte, index) => byte === right.publicKey[index]); +} + +/** Strict current-profile comparison: Demos scheme canonicalisation, no aliases. */ +export function canonicalSigningIdentity(claim: string): string { + return canonicalDemosAgentClaim(claim) ?? claim; +} diff --git a/reference-implementations/dacs-directory/src/catalog/registrationSig.ts b/reference-implementations/dacs-directory/src/catalog/registrationSig.ts index 5987d0e..8457a87 100644 --- a/reference-implementations/dacs-directory/src/catalog/registrationSig.ts +++ b/reference-implementations/dacs-directory/src/catalog/registrationSig.ts @@ -6,10 +6,12 @@ * self-describing primaryClaim. Formats accepted: 0x-hex or base64url * signatures (wallet builds vary); message must be fresh (±10 min). */ -// Pure subpaths ONLY — the package barrel re-exports createAgent, whose -// substrate import chain webpack traces into demosdk/rubic (bundler-hostile). -import { ed25519Verify, publicKeyFromRaw } from "@kynesyslabs/dacs/crypto"; import { canonicalize, sha256Hex } from "@kynesyslabs/dacs/canonical"; +import { + resolveDemosPrimaryClaimKey, + verifyPrimaryClaimSignature, + type ResolvePrimaryClaimKey, +} from "./primaryClaimKey.js"; import type { Registration } from "./types.js"; export function registrationMessage( @@ -47,7 +49,7 @@ function sigBytes(signature: string): Uint8Array | null { export async function verifyOwnerSignature( reg: Registration, - opts: { ignoreFreshness?: boolean } = {}, + opts: { ignoreFreshness?: boolean; resolvePrimaryClaimKey?: ResolvePrimaryClaimKey } = {}, ): Promise { const os = reg.ownerSignature; if (!os) return false; @@ -55,13 +57,13 @@ export async function verifyOwnerSignature( // stored signature without it (the content binding + signature still hold). if (!opts.ignoreFreshness && Math.abs(Date.now() - os.signedAt) > 10 * 60_000) return false; if (os.message !== registrationMessage(reg, os.signedAt)) return false; // bound content - const keyHex = reg.primaryClaim.match(/([0-9a-fA-F]{64})$/)?.[1]; const sig = sigBytes(os.signature); - if (!keyHex || !sig) return false; - try { - const key = publicKeyFromRaw(Uint8Array.from(Buffer.from(keyHex, "hex"))); - return await ed25519Verify(Buffer.from(os.message, "utf8"), sig, key); - } catch { - return false; - } + if (!sig) return false; + return Boolean(await verifyPrimaryClaimSignature( + Buffer.from(os.message, "utf8"), + sig, + reg.primaryClaim, + "ed25519", + opts.resolvePrimaryClaimKey ?? resolveDemosPrimaryClaimKey, + )); } diff --git a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts index 6b5b068..1e7862b 100644 --- a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts +++ b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts @@ -109,7 +109,7 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise verifyBundleBinding(binding)))) .filter((binding) => binding !== null); for (const binding of verified) { const bounded = boundedBundleBindings([...(state.bundleBindings[binding.jobId] ?? []), binding]); diff --git a/reference-implementations/dacs-directory/src/catalog/wellknown.ts b/reference-implementations/dacs-directory/src/catalog/wellknown.ts index 2a5887a..8fdf135 100644 --- a/reference-implementations/dacs-directory/src/catalog/wellknown.ts +++ b/reference-implementations/dacs-directory/src/catalog/wellknown.ts @@ -134,7 +134,7 @@ 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)); + const verified = await Promise.all(carried.map((binding) => verifyBundleBinding(binding))); for (const binding of verified) if (binding) bundleBindings.push(binding); } return { domain, seller, displayName, listingAnchors, contentHashes, bundleBindings }; diff --git a/reference-implementations/dacs-directory/test/bundle-binding.test.ts b/reference-implementations/dacs-directory/test/bundle-binding.test.ts index cc3b58b..615308d 100644 --- a/reference-implementations/dacs-directory/test/bundle-binding.test.ts +++ b/reference-implementations/dacs-directory/test/bundle-binding.test.ts @@ -23,6 +23,7 @@ async function binding(options: { native?: string; hash?: string; logical?: string; + signerClaim?: string; } = {}): Promise { const jobId = options.jobId ?? "job-binding-1"; const role = options.role ?? "buyer"; @@ -34,15 +35,28 @@ async function binding(options: { logicalAddress: options.logical ?? logicalBundleAddress(jobId, role), nativeAddress: options.native ?? native(1), bundleContentHash: options.hash ?? "a".repeat(64), - signer: dids[signer], + signer: options.signerClaim ?? 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 } }; + return { + ...scope, + signature: { algorithm: "ed25519", signer: options.signerClaim ?? dids[signer], value }, + }; } +test("BB-4 accepts scheme-case normalization but rejects suffix-shaped aliases", async () => { + const key = dids[0].slice(-64); + assert.ok(await verifyBundleBinding(await binding({ signerClaim: `DID:demos:agent:${key}` }))); + assert.equal(await verifyBundleBinding(await binding({ signerClaim: `domain:${key}` })), null); + assert.equal(await verifyBundleBinding(await binding({ signerClaim: `demos:0x${key}` })), null); + assert.equal(await verifyBundleBinding(await binding({ + signerClaim: `did:demos:agent:${key.toUpperCase()}`, + })), null); +}); + 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); diff --git a/reference-implementations/dacs-directory/test/current-indexer.test.ts b/reference-implementations/dacs-directory/test/current-indexer.test.ts index 10160ec..28d7574 100644 --- a/reference-implementations/dacs-directory/test/current-indexer.test.ts +++ b/reference-implementations/dacs-directory/test/current-indexer.test.ts @@ -982,5 +982,13 @@ test("identity tier requires a fresh passing version-pinned VerifyResult and its const policy: RecipePolicy = { scheme: "lei", recipeVersion: 2, methods: ["consensus-backed-proxy"], defaultMaxAgeSec: 60, availability: "live", trustedResultSigners: [dids[0]] }; const tier = await deriveIdentityTier(identity, async () => policy, 100, async (at) => maps.get(at) ?? null); assert.equal(tier, "institutional"); + const mixedScheme = structuredClone(result); + (mixedScheme.signature as Obj).signer = `DID:demos:agent:${dids[0].slice(-64)}`; + maps.set(locator(9), mixedScheme); + assert.equal( + await deriveIdentityTier(identity, async () => policy, 100, async (at) => maps.get(at) ?? null), + "institutional", + "trusted signer comparison uses its resolved canonical identity", + ); assert.equal(await deriveIdentityTier(identity, async () => policy, 201, async (at) => maps.get(at) ?? null), "self-declared"); }); diff --git a/reference-implementations/dacs-directory/test/listing-verification.test.ts b/reference-implementations/dacs-directory/test/listing-verification.test.ts index 6bc4ba8..70a04e9 100644 --- a/reference-implementations/dacs-directory/test/listing-verification.test.ts +++ b/reference-implementations/dacs-directory/test/listing-verification.test.ts @@ -14,15 +14,18 @@ function signMessage(message: string): string { return Buffer.from(ed25519Sign(Buffer.from(message, "utf8"), privateKey)).toString("base64url"); } -function signedCurrentListing(extra: Record = {}): Record { +function signedCurrentListing( + extra: Record = {}, + signingClaim = claim, +): Record { const identity: Record = { - presentedBy: claim, - claims: [{ ref: claim, kind: "signing-key" }], + presentedBy: signingClaim, + claims: [{ ref: signingClaim, kind: "signing-key" }], }; identity.presentation = { kind: "per-claim", signatures: [{ - ref: claim, + ref: signingClaim, signature: signMessage(`dacs-bundle-presentation:v1:${contentHash(identity)}`), }], }; @@ -60,12 +63,30 @@ function signedCurrentListing(extra: Record = {}): Record { + const mixedCaseClaim = `DID:demos:agent:${publicKeyHex}`; + const verified = await verifyListing(signedCurrentListing({}, mixedCaseClaim)); + + assert.equal(verified?.signer, claim); + assert.equal(verified?.sellerClaim, claim); +}); + +test("verifyListing rejects suffix-shaped aliases even when their signatures are valid", async () => { + for (const rejected of [ + `domain:${publicKeyHex}`, + `demos:0x${publicKeyHex}`, + `did:demos:agent:${publicKeyHex.toUpperCase()}`, + ]) { + assert.equal(await verifyListing(signedCurrentListing({}, rejected)), null, rejected); + } +}); + test("verifyListing preserves inert unknown top-level fields in the signed scope", async () => { const listing = signedCurrentListing({ futureOptionalMetadata: { fixture: "preserve", color: "blue" }, diff --git a/reference-implementations/dacs-directory/test/primary-claim-key.test.ts b/reference-implementations/dacs-directory/test/primary-claim-key.test.ts new file mode 100644 index 0000000..5f1c117 --- /dev/null +++ b/reference-implementations/dacs-directory/test/primary-claim-key.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + ed25519Sign, + privateKeyFromSeed, + publicKeyFromSeed, + rawPublicKey, +} from "@kynesyslabs/dacs/crypto"; + +import { + resolveDemosPrimaryClaimKey, + resolvePrimaryClaimKey, + verifyPrimaryClaimSignature, + type ResolvePrimaryClaimKey, +} from "../src/catalog/primaryClaimKey.js"; +import { registrationMessage, verifyOwnerSignature } from "../src/catalog/registrationSig.js"; +import type { Registration } from "../src/catalog/types.js"; + +const seed = Uint8Array.from(Buffer.alloc(32, 41)); +const privateKey = privateKeyFromSeed(seed); +const publicKey = rawPublicKey(publicKeyFromSeed(seed)); +const publicKeyHex = Buffer.from(publicKey).toString("hex"); +const canonicalClaim = `did:demos:agent:${publicKeyHex}`; + +test("the default key resolver accepts only the registered Demos ClaimReference profile", async () => { + const canonical = await resolveDemosPrimaryClaimKey(canonicalClaim, "ed25519"); + assert.equal(canonical?.canonicalClaim, canonicalClaim); + assert.deepEqual(canonical?.publicKey, Uint8Array.from(publicKey)); + + for (const readable of [ + `DID:demos:agent:${publicKeyHex}`, + `DiD:demos:agent:${publicKeyHex}`, + ]) { + assert.equal((await resolveDemosPrimaryClaimKey(readable, "ed25519"))?.canonicalClaim, canonicalClaim); + } + + for (const rejected of [ + `did:demos:agent:${publicKeyHex.toUpperCase()}`, + `demos:0x${publicKeyHex}`, + `0x${publicKeyHex}`, + publicKeyHex, + `domain:${publicKeyHex}`, + `did:other:agent:${publicKeyHex}`, + ]) { + assert.equal(await resolveDemosPrimaryClaimKey(rejected, "ed25519"), null, rejected); + } + assert.equal(await resolveDemosPrimaryClaimKey(canonicalClaim, "secp256k1"), null); +}); + +test("an injected resolver enables a future scheme without weakening the default", async () => { + const customClaim = "did:web:agent.example"; + const customResolver: ResolvePrimaryClaimKey = async (claim, algorithm) => + claim === customClaim && algorithm === "ed25519" + ? { canonicalClaim: customClaim, algorithm, publicKey } + : null; + const message = Buffer.from("future ClaimReference resolver boundary", "utf8"); + const signature = await ed25519Sign(message, privateKey); + + assert.equal(await verifyPrimaryClaimSignature( + message, signature, customClaim, "ed25519", + ), null, "the default resolver must remain closed"); + assert.equal((await verifyPrimaryClaimSignature( + message, signature, customClaim, "ed25519", customResolver, + ))?.canonicalClaim, customClaim); + + const malformedKey: ResolvePrimaryClaimKey = async () => ({ + canonicalClaim: customClaim, + algorithm: "ed25519", + publicKey: new Uint8Array(31), + }); + assert.equal(await resolvePrimaryClaimKey(customClaim, "ed25519", malformedKey), null); +}); + +test("registration owner verification rejects a valid signature from an unknown suffix-shaped claim", async () => { + const registration: Registration = { + primaryClaim: `domain:${publicKeyHex}`, + displayName: "Unknown claim fixture", + listingAnchors: [], + }; + const signedAt = Date.now(); + const message = registrationMessage(registration, signedAt); + registration.ownerSignature = { + signedAt, + message, + signature: Buffer.from(await ed25519Sign(Buffer.from(message, "utf8"), privateKey)).toString("base64url"), + }; + + assert.equal(await verifyOwnerSignature(registration), false); +}); diff --git a/reference-implementations/dacs-directory/test/verification.test.ts b/reference-implementations/dacs-directory/test/verification.test.ts index 079393e..91e0a49 100644 --- a/reference-implementations/dacs-directory/test/verification.test.ts +++ b/reference-implementations/dacs-directory/test/verification.test.ts @@ -60,6 +60,10 @@ test("listing verification requires a valid signer-bound envelope", async () => 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, name: "tampered" }), null); assert.equal(await verifyListing({ ...listing, signature: "deadbeef" }), null); assert.equal(await verifyListing({ ...signed, signatures: [null] }), null); @@ -520,6 +524,18 @@ test("evidence ref must be signed by a bundle party", async () => { new Set([`did:demos:agent:${"9".repeat(64)}`, "seller"])), false, ); + const suffixAlias = `domain:${did.slice(-64)}`; + assert.equal( + await verifyReferencedArtifactSignature({ + kind: "dacs-4-evidence", + raw: { + ...evidenceScope, + signatures: [{ algorithm: "ed25519", signer: suffixAlias, value }], + }, + }, new Set([suffixAlias, "seller"])), + false, + "matching key-shaped suffixes are not an implicit ClaimReference resolver", + ); }); test("any valid revocation candidate wins and scanner candidates deduplicate", async () => {