diff --git a/packages/agent/src/system-records/receiver-v1.ts b/packages/agent/src/system-records/receiver-v1.ts new file mode 100644 index 0000000000..a1758c2a3b --- /dev/null +++ b/packages/agent/src/system-records/receiver-v1.ts @@ -0,0 +1,503 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + decodeOpaqueKaBundleV1, + parseCanonicalGraphlessProjectionStorageQuadsV1, +} from '@origintrail-official/dkg-core'; +import { + assertAgentProfileProjectionIdentityV1, + assertAgentProfileProjectionSchemaV1, + buildAgentProfileVerificationClosureV1, + copyBoundedSystemRecordBytesV1, + computeOwnedSubjectTableDigestV1, + computeSystemRecordStableKeyHashV1, + decodeSystemRecordInventoryRowV1, + encodeSystemRecordInventoryRowV1, + parseCanonicalOwnedSubjectTableObjectV1, + parseCanonicalSignedAgentProfileHeadEnvelopeV1, + SYSTEM_RECORD_OBJECT_CAPS_V1, + verifySignedSystemRecordEnvelopeV1, + type AgentProfileActiveHeadObjectV1, + type AgentProfileAuthorityTransitionV1, + type AgentProfileForkResolutionV1, + type AgentProfileHeadObjectV1, + type AgentProfileVerifiedAuthoritySummaryV1, + type Digest32V1, + type NetworkIdV1, + type OwnedSubjectTableObjectV1, + type SignedAgentProfileAuthorityTransitionEnvelopeV1, + type SignedAgentProfileForkResolutionEnvelopeV1, + type SignedAgentProfileHeadEnvelopeV1, + type SystemRecordInventoryRowV1, + type SystemRecordObjectKindV1, + type SystemRecordVerificationClosureV1, +} from '@origintrail-official/dkg-core/system-record-v1'; +import type { + Quad, + SystemRecordApplyOutcomeV1, +} from '@origintrail-official/dkg-storage'; + +import { + type SystemRecordArtifactRepositoryV1, + type SystemRecordArtifactV1, +} from './artifact-v1.js'; + +export type SignedAgentProfileActiveHeadEnvelopeV1 = SignedAgentProfileHeadEnvelopeV1 & { + readonly object: AgentProfileActiveHeadObjectV1; +}; + +/** Verified active-profile facts handed to the lifecycle-owned materializer bridge. */ +export interface AgentProfileReceiverCandidateV1 { + readonly head: AgentProfileActiveHeadObjectV1; + readonly envelope: SignedAgentProfileActiveHeadEnvelopeV1; + readonly canonicalProjectionBytes: Uint8Array; + readonly projectionQuads: readonly Readonly[]; + readonly ownedSubjectTable: OwnedSubjectTableObjectV1; + readonly verifiedAuthoritySummary: AgentProfileVerifiedAuthoritySummaryV1; +} + +export interface AgentProfileReceiverPreparedApplyV1 { + /** Existing authenticated Storage deadline in the bridge's monotonic clock domain. */ + readonly existingMonotonicDeadlineMs: number; + /** `Math.floor(performance.now())` captured after bridge waits. */ + readonly monotonicNowMs: number; + /** + * Begin lifecycle proof issuance and atomic apply synchronously with the + * receiver-admitted deadline. No Unix timestamp crosses this boundary. + */ + readonly apply: ( + admittedDeadlineMs: number, + ) => SystemRecordApplyOutcomeV1 | Promise; +} + +export interface CreateAgentProfileReceiverOptionsV1 { + readonly networkId: NetworkIdV1; + readonly artifacts: SystemRecordArtifactRepositoryV1; + /** + * Final authority verification, including bounded EIP-1271 handling when the + * envelope requests it. The receiver never owns a chain client or RPC queue. + */ + readonly verifyAuthorityEnvelope?: ( + envelope: + | SignedAgentProfileHeadEnvelopeV1 + | SignedAgentProfileAuthorityTransitionEnvelopeV1 + | SignedAgentProfileForkResolutionEnvelopeV1, + signal: AbortSignal, + ) => boolean | Promise; + /** Final graph-scoped publication/seal acceptance of the exact supplied bundle. */ + readonly verifyCurrentBundle: ( + head: AgentProfileActiveHeadObjectV1, + canonicalBundleBytes: Uint8Array, + signal: AbortSignal, + ) => boolean | Promise; + /** + * Lifecycle-owned preparation for storage apply. After all asynchronous + * preparation, it returns authenticated monotonic timing and the apply entry. + * The receiver owns final freshness, deadline clamping, and the sole call to + * apply; no replacement proof or prior apply outcome crosses this boundary. + */ + readonly prepareCandidateApply: ( + input: AgentProfileReceiverCandidateV1, + signal: AbortSignal, + ) => AgentProfileReceiverPreparedApplyV1 + | Promise; + /** Unix wall-clock milliseconds, injectable for deterministic verification. */ + readonly nowMs?: () => number; +} + +export interface AgentProfileReceiverV1 { + /** + * Verify and apply one active inventory row. Inventory traversal, admission, + * continuation, caching, and retries remain owned by the caller. + */ + receiveActive( + row: SystemRecordInventoryRowV1, + signal: AbortSignal, + ): Promise; +} + +/** + * Default-unused exact active-record receiver. The only mutable state is scoped + * to one call, so abort and shutdown cannot strand a background operation. + */ +export function createAgentProfileReceiverV1( + options: CreateAgentProfileReceiverOptionsV1, +): AgentProfileReceiverV1 { + const networkId = options.networkId; + const resolveArtifact = options.artifacts.resolve.bind(options.artifacts); + const verifyCurrentBundle = options.verifyCurrentBundle; + const prepareCandidateApply = options.prepareCandidateApply; + const nowMs = options.nowMs; + const verifyAuthorityEnvelope = options.verifyAuthorityEnvelope + ?? ((envelope: SignedAgentProfileHeadEnvelopeV1 + | SignedAgentProfileAuthorityTransitionEnvelopeV1 + | SignedAgentProfileForkResolutionEnvelopeV1) => + verifySignedSystemRecordEnvelopeV1< + AgentProfileHeadObjectV1 + | AgentProfileAuthorityTransitionV1 + | AgentProfileForkResolutionV1 + >(envelope)); + + return Object.freeze({ + async receiveActive( + inputRow: SystemRecordInventoryRowV1, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted(); + const row = canonicalInventoryRow(networkId, inputRow); + if (row.tombstone || row.quarantined || row.conflictEvidenceDigest !== undefined) { + throw new Error('active profile receiver requires an ordinary active inventory row'); + } + + const verificationNowMs = receiverNowMs(nowMs?.() ?? Date.now()); + const candidate = await buildVerifiedActiveCandidateFactsV1({ + networkId, + row, + signal, + nowMs: verificationNowMs, + resolveArtifact, + verifyAuthorityEnvelope, + verifyCurrentBundle, + }); + signal.throwIfAborted(); + const validUntilUnixMs = Date.parse(candidate.head.validUntil); + assertActiveDeadlineFreshV1( + validUntilUnixMs, + receiverNowMs(nowMs?.() ?? Date.now()), + ); + const prepared = await prepareCandidateApply(candidate, signal); + signal.throwIfAborted(); + const apply = readPreparedApplyV1(prepared); + signal.throwIfAborted(); + const remainingMs = assertActiveDeadlineFreshV1( + validUntilUnixMs, + receiverNowMs(nowMs?.() ?? Date.now()), + ); + const translatedDeadlineMs = apply.monotonicNowMs + remainingMs; + if (!Number.isSafeInteger(translatedDeadlineMs)) { + throw new Error('agent-profile translated apply deadline is invalid'); + } + const admittedDeadlineMs = Math.min( + apply.existingMonotonicDeadlineMs, + translatedDeadlineMs, + ); + if (admittedDeadlineMs <= apply.monotonicNowMs) { + throw new Error('agent-profile monotonic apply admission is expired'); + } + // Atomic apply is the point of no return. A cancellation that arrives + // after the storage closure returns must not hide a committed outcome and + // make the caller retry it as if nothing happened. + return await apply.invoke(admittedDeadlineMs); + }, + }); +} + +interface BuildVerifiedActiveCandidateFactsOptionsV1 { + readonly networkId: NetworkIdV1; + readonly row: SystemRecordInventoryRowV1; + readonly signal: AbortSignal; + readonly nowMs: number; + readonly resolveArtifact: SystemRecordArtifactRepositoryV1['resolve']; + readonly verifyAuthorityEnvelope: NonNullable< + CreateAgentProfileReceiverOptionsV1['verifyAuthorityEnvelope'] + >; + readonly verifyCurrentBundle: CreateAgentProfileReceiverOptionsV1['verifyCurrentBundle']; +} + +async function buildVerifiedActiveCandidateFactsV1( + options: BuildVerifiedActiveCandidateFactsOptionsV1, +): Promise { + const { + networkId, + row, + signal, + nowMs, + resolveArtifact, + verifyAuthorityEnvelope, + verifyCurrentBundle, + } = options; + signal.throwIfAborted(); + const resolvedCurrentHeadArtifact = await resolveArtifact({ + type: 'object', + objectKind: 'agent-profile-head', + objectDigest: row.headDigest, + }, signal); + signal.throwIfAborted(); + if (resolvedCurrentHeadArtifact === null) { + throw new Error('verification closure is missing its current agent-profile head'); + } + const currentHeadArtifact = snapshotExpectedArtifactV1( + resolvedCurrentHeadArtifact, + 'agent-profile-head', + row.headDigest, + 'closure artifact', + ); + const envelope = parseCanonicalSignedAgentProfileHeadEnvelopeV1( + currentHeadArtifact.canonicalBytes, + ); + assertRowBindsHead(networkId, row, envelope); + assertActiveHeadEnvelopeV1(envelope); + assertActiveHeadFreshV1(envelope.object, nowMs); + + const closure = await verifyActiveProfileClosureForRowV1({ + row, + signal, + nowMs, + currentHeadArtifact, + resolveArtifact, + verifyAuthorityEnvelope, + verifyCurrentBundle, + }); + signal.throwIfAborted(); + const head = envelope.object; + requiredClosureArtifactV1( + closure, + 'agent-profile-head', + row.headDigest, + 'current agent-profile head', + ); + const bundleArtifact = requiredClosureArtifactV1( + closure, + 'profile-bundle', + head.bundleDigest, + 'current profile bundle', + ); + const decodedBundle = decodeOpaqueKaBundleV1(bundleArtifact.canonicalBytes); + const canonicalProjectionBytes = Uint8Array.from(decodedBundle.projectionBytes); + const projectionQuads = parseCanonicalGraphlessProjectionStorageQuadsV1( + canonicalProjectionBytes, + ); + if (BigInt(canonicalProjectionBytes.byteLength) !== BigInt(head.projectionBytes)) { + throw new Error('profile bundle projection byte count does not bind the verified head'); + } + if (BigInt(projectionQuads.length) !== BigInt(head.projectionQuads)) { + throw new Error('profile bundle projection quad count does not bind the verified head'); + } + const resolvedSubjectTableArtifact = await resolveArtifact({ + type: 'object', + objectKind: 'owned-subject-table', + objectDigest: head.ownedSubjectTableDigest, + }, signal); + signal.throwIfAborted(); + if (resolvedSubjectTableArtifact === null) { + throw new Error('active profile receiver is missing its exact owned-subject table'); + } + const subjectTableArtifact = snapshotExpectedArtifactV1( + resolvedSubjectTableArtifact, + 'owned-subject-table', + head.ownedSubjectTableDigest, + 'owned-subject table', + ); + const ownedSubjectTable = parseCanonicalOwnedSubjectTableObjectV1( + head.rootSubject, + subjectTableArtifact.canonicalBytes, + ); + if (computeOwnedSubjectTableDigestV1(head.rootSubject, ownedSubjectTable) + !== head.ownedSubjectTableDigest + || BigInt(ownedSubjectTable.length) !== BigInt(head.ownedSubjectCount)) { + throw new Error('active profile owned-subject table does not bind the verified head'); + } + assertAgentProfileProjectionSchemaV1( + head.rootSubject, + ownedSubjectTable, + projectionQuads, + ); + assertAgentProfileProjectionIdentityV1(head, projectionQuads); + + return Object.freeze({ + head, + envelope, + canonicalProjectionBytes, + projectionQuads, + ownedSubjectTable, + verifiedAuthoritySummary: closure.authoritySummary, + }); +} + +interface VerifyActiveProfileClosureOptionsV1 + extends Omit { + readonly currentHeadArtifact: SystemRecordArtifactV1; +} + +async function verifyActiveProfileClosureForRowV1( + options: VerifyActiveProfileClosureOptionsV1, +): Promise { + const { + row, + signal, + nowMs, + currentHeadArtifact, + resolveArtifact, + verifyAuthorityEnvelope, + verifyCurrentBundle, + } = options; + return buildAgentProfileVerificationClosureV1(row.headDigest, { + nowMs, + resolve: async (reference) => { + signal.throwIfAborted(); + const owned = reference.objectKind === 'agent-profile-head' + && reference.digest === row.headDigest + ? currentHeadArtifact + : await resolveClosureArtifactV1(reference, resolveArtifact, signal); + signal.throwIfAborted(); + if (owned === undefined) return undefined; + return Object.freeze({ + objectKind: owned.objectKind, + digest: owned.objectDigest, + canonicalBytes: owned.canonicalBytes, + }); + }, + verifyAuthorityEnvelope: async (envelope) => { + signal.throwIfAborted(); + const verified = await verifyAuthorityEnvelope(envelope, signal); + signal.throwIfAborted(); + return verified === true; + }, + verifyCurrentBundle: async (head, canonicalBundleBytes) => { + signal.throwIfAborted(); + const verified = await verifyCurrentBundle( + head, + Uint8Array.from(canonicalBundleBytes), + signal, + ); + signal.throwIfAborted(); + return verified === true; + }, + }); +} + +async function resolveClosureArtifactV1( + reference: Readonly<{ objectKind: SystemRecordObjectKindV1; digest: Digest32V1 }>, + resolveArtifact: SystemRecordArtifactRepositoryV1['resolve'], + signal: AbortSignal, +): Promise { + const artifact = await resolveArtifact({ + type: 'object', + objectKind: reference.objectKind, + objectDigest: reference.digest, + }, signal); + if (artifact === null) return undefined; + return snapshotExpectedArtifactV1( + artifact, + reference.objectKind, + reference.digest, + 'closure artifact', + ); +} + +function assertActiveHeadEnvelopeV1( + envelope: SignedAgentProfileHeadEnvelopeV1, +): asserts envelope is SignedAgentProfileActiveHeadEnvelopeV1 { + if (envelope.object.state !== 'active') { + throw new Error('active profile receiver resolved a non-active verification closure'); + } +} + +function canonicalInventoryRow( + networkId: NetworkIdV1, + row: SystemRecordInventoryRowV1, +): SystemRecordInventoryRowV1 { + return decodeSystemRecordInventoryRowV1( + networkId, + encodeSystemRecordInventoryRowV1(networkId, row), + ); +} + +function assertRowBindsHead( + networkId: NetworkIdV1, + row: SystemRecordInventoryRowV1, + envelope: SignedAgentProfileHeadEnvelopeV1, +): void { + const head = envelope.object; + if (head.networkId !== networkId + || head.peerId !== row.peerId + || computeSystemRecordStableKeyHashV1(networkId, row.peerId) !== row.stableKeyHash + || head.authoritySequence !== row.authoritySequence + || head.version !== row.version + || envelope.objectDigest !== row.headDigest + || (head.state === 'tombstone') !== row.tombstone) { + throw new Error('inventory row does not bind the verified agent-profile head'); + } +} + +function snapshotExpectedArtifactV1( + input: SystemRecordArtifactV1, + expectedKind: SystemRecordObjectKindV1, + expectedDigest: Digest32V1, + label: string, +): SystemRecordArtifactV1 { + const objectKind = input.objectKind; + const objectDigest = input.objectDigest; + if (objectKind !== expectedKind || objectDigest !== expectedDigest) { + throw new Error(`system-record repository returned a different ${label}`); + } + return Object.freeze({ + objectKind, + objectDigest, + canonicalBytes: copyBoundedSystemRecordBytesV1( + input.canonicalBytes, + SYSTEM_RECORD_OBJECT_CAPS_V1[expectedKind], + label, + ), + }); +} + +function requiredClosureArtifactV1( + closure: SystemRecordVerificationClosureV1, + objectKind: SystemRecordObjectKindV1, + digest: Digest32V1, + label: string, +): SystemRecordVerificationClosureV1['objects'][number] { + const artifact = closure.objects.find((candidate) => + candidate.objectKind === objectKind && candidate.digest === digest); + if (artifact === undefined) { + throw new Error(`verification closure did not retain its ${label}`); + } + return artifact; +} + +function readPreparedApplyV1(value: AgentProfileReceiverPreparedApplyV1): { + readonly existingMonotonicDeadlineMs: number; + readonly monotonicNowMs: number; + readonly invoke: AgentProfileReceiverPreparedApplyV1['apply']; +} { + if (value === null || typeof value !== 'object') { + throw new Error('lifecycle bridge did not return prepared apply state'); + } + const existingMonotonicDeadlineMs = monotonicApplyMsV1( + value.existingMonotonicDeadlineMs, + 'existing deadline', + ); + const monotonicNowMs = monotonicApplyMsV1(value.monotonicNowMs, 'current time'); + const invoke = value.apply; + if (typeof invoke !== 'function') { + throw new Error('agent-profile prepared apply callback is invalid'); + } + return Object.freeze({ existingMonotonicDeadlineMs, monotonicNowMs, invoke }); +} + +function monotonicApplyMsV1(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`agent-profile monotonic apply ${label} is invalid`); + } + return value; +} + +function assertActiveHeadFreshV1(head: AgentProfileActiveHeadObjectV1, nowMs: number): void { + assertActiveDeadlineFreshV1(Date.parse(head.validUntil), nowMs); +} + +function assertActiveDeadlineFreshV1(validUntilUnixMs: number, nowUnixMs: number): number { + const remainingMs = validUntilUnixMs - nowUnixMs; + if (remainingMs <= 0) { + throw new Error('active profile receiver resolved an expired agent-profile head'); + } + return remainingMs; +} + +function receiverNowMs(value: number): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error('agent-profile receiver clock returned an invalid value'); + } + return value; +} diff --git a/packages/agent/test/support/agent-profile-receiver-v1-fixture.ts b/packages/agent/test/support/agent-profile-receiver-v1-fixture.ts new file mode 100644 index 0000000000..a31571f08a --- /dev/null +++ b/packages/agent/test/support/agent-profile-receiver-v1-fixture.ts @@ -0,0 +1,378 @@ +import { vi } from 'vitest'; + +import { + decodeOpaqueKaBundleV1, + encodeOpaqueKaBundleV1, +} from '@origintrail-official/dkg-core'; +import { + computeSystemRecordStableKeyHashV1, + digestSystemRecordBytesV1, + SYSTEM_RECORD_DIGEST_DOMAINS_V1, + type AgentProfileActiveHeadObjectV1, + type AgentProfileAuthorityTransitionV1, + type SystemRecordInventoryRowV1, +} from '@origintrail-official/dkg-core/system-record-v1'; +import { ethers } from 'ethers'; + +import { createEvmPersonalMessageSignerV1 } from '../../src/evm-message-signer-v1.js'; +import { prepareAgentProfileV1 } from '../../src/profile.js'; +import type { SystemRecordArtifactLookupV1 } from '../../src/system-records/artifact-v1.js'; +import { createInMemoryAgentProfilePublicationStoreV1 } from '../../src/system-records/in-memory-agent-profile-publication-store-v1.js'; +import type { AgentProfileReceiverPreparedApplyV1 } from '../../src/system-records/receiver-v1.js'; +import { + createFixtureAgentProfileProducerV1, + DEPLOYMENT, + envelopeArtifact, + makePrepared, + NETWORK, + OTHER_PRIVATE_KEY, + produce, + producerFixture, + publicationFor, + signHeadEnvelope, + signTransitionEnvelope, +} from './agent-profile-producer-v1-fixture.js'; + +export const DEFAULT_MONOTONIC_APPLY_TIMING = Object.freeze({ + existingMonotonicDeadlineMs: 10_000, + monotonicNowMs: 1_000, +}); + +export function preparedFixtureApply( + stateRevision: string, + digestCharacter: string, +): AgentProfileReceiverPreparedApplyV1 { + return Object.freeze({ + ...DEFAULT_MONOTONIC_APPLY_TIMING, + apply: () => Object.freeze({ + outcome: 'applied' as const, + stateRevision, + appliedStateDigest: `0x${digestCharacter.repeat(64)}`, + }), + }); +} + +export async function publishedReceiverFixture(withDerivedSubjects = false) { + const fixture = await producerFixture(); + const prepared = withDerivedSubjects + ? prepareAgentProfileV1({ + peerId: fixture.peerSigner.peerId, + publicKey: Buffer.from(fixture.peerSigner.publicKey, 'base64url').toString('base64'), + agentAddress: fixture.evmSigner.address, + name: 'Receiver multi-subject fixture', + nodeRole: 'edge', + lastSeen: '2026-08-07T12:00:00.000Z', + skills: [{ + skillType: 'GraphQuery', + pricePerCall: 1, + currency: 'TRAC', + successRate: 0.99, + pricingModel: 'PerInvocation', + }], + contextGraphsServed: ['receiver-test-graph'], + }) + : fixture.prepared; + const publication = withDerivedSubjects + ? await publicationFor(prepared, fixture.evmSigner.address, '2026-08-07T12:00:00Z') + : fixture.publication; + const producer = createFixtureAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => undefined, + install: () => undefined, + }); + await produce(producer, prepared, publication); + const envelope = fixture.store.snapshot().currentHead; + if (envelope === null) throw new Error('fixture producer did not publish a head'); + const head = envelope.object; + const row: SystemRecordInventoryRowV1 = Object.freeze({ + stableKeyHash: computeSystemRecordStableKeyHashV1(head.networkId, head.peerId), + peerId: head.peerId, + authoritySequence: head.authoritySequence, + version: head.version, + headDigest: envelope.objectDigest, + tombstone: false, + quarantined: false, + }); + return { ...fixture, prepared, publication, envelope, row }; +} + +export async function rotatedPublishedReceiverFixture() { + const prior = await publishedReceiverFixture(); + const nextSigner = createEvmPersonalMessageSignerV1({ + mode: 'custodial', + address: new ethers.Wallet(OTHER_PRIVATE_KEY).address, + privateKey: OTHER_PRIVATE_KEY, + purpose: 'receiver post-transition test', + }); + const prepared = makePrepared( + prior.peerSigner, + nextSigner.address, + '2026-08-07T12:20:00.000Z', + ); + const publication = await publicationFor( + prepared, + nextSigner.address, + '2026-08-07T12:20:00Z', + OTHER_PRIVATE_KEY, + ); + const currentStore = createInMemoryAgentProfilePublicationStoreV1(); + await produce(createFixtureAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: prior.peerSigner, + evmSigner: nextSigner, + store: currentStore, + fence: () => undefined, + install: () => undefined, + }), prepared, publication); + const bootstrapEnvelope = currentStore.snapshot().currentHead; + if (bootstrapEnvelope === null) throw new Error('rotated fixture did not publish a head'); + const transition: AgentProfileAuthorityTransitionV1 = Object.freeze({ + objectType: 'authority-transition', + kind: 'agents', + mode: 'co-signed', + networkId: NETWORK, + peerId: prior.peerSigner.peerId, + peerPublicKey: prior.peerSigner.publicKey, + priorAuthoritySequence: '0', + nextAuthoritySequence: '1', + priorHeadDigest: prior.envelope.objectDigest, + priorEvmIssuer: prior.evmSigner.address, + nextEvmIssuer: nextSigner.address, + nextRoot: prepared.rootEntity, + issuedAt: '2026-08-07T12:10:00Z', + }); + const transitionEnvelope = await signTransitionEnvelope( + transition, + prior.peerSigner, + prior.evmSigner, + nextSigner, + ); + const envelope = await signHeadEnvelope(Object.freeze({ + ...bootstrapEnvelope.object, + authoritySequence: '1', + acceptedTransitionDigest: transitionEnvelope.objectDigest, + }), prior.peerSigner, nextSigner); + const currentHeadArtifact = envelopeArtifact('agent-profile-head', envelope); + const transitionArtifact = envelopeArtifact('authority-transition', transitionEnvelope); + const priorHeadArtifact = envelopeArtifact('agent-profile-head', prior.envelope); + const resolve = vi.fn(async (lookup: SystemRecordArtifactLookupV1, signal: AbortSignal) => { + if (lookup.type === 'object') { + if (lookup.objectKind === currentHeadArtifact.objectKind + && lookup.objectDigest === currentHeadArtifact.objectDigest) return currentHeadArtifact; + if (lookup.objectKind === transitionArtifact.objectKind + && lookup.objectDigest === transitionArtifact.objectDigest) return transitionArtifact; + if (lookup.objectKind === priorHeadArtifact.objectKind + && lookup.objectDigest === priorHeadArtifact.objectDigest) return priorHeadArtifact; + } + return currentStore.resolve(lookup, signal); + }); + const head = envelope.object; + const row: SystemRecordInventoryRowV1 = Object.freeze({ + stableKeyHash: computeSystemRecordStableKeyHashV1(head.networkId, head.peerId), + peerId: head.peerId, + authoritySequence: head.authoritySequence, + version: head.version, + headDigest: envelope.objectDigest, + tombstone: false, + quarantined: false, + }); + return { prior, prepared, envelope, transitionEnvelope, resolve, row }; +} + +export async function publishedReceiverFixtureWithProjectionBytes( + transform: (canonicalProjectionBytes: Uint8Array) => Uint8Array, +) { + const fixture = await publishedReceiverFixture(); + const originalBundle = await fixture.store.resolve({ + type: 'object', + objectKind: 'profile-bundle', + objectDigest: fixture.envelope.object.bundleDigest, + }, new AbortController().signal); + if (originalBundle === null) throw new Error('fixture bundle was not retained'); + const decoded = decodeOpaqueKaBundleV1(originalBundle.canonicalBytes); + const projectionBytes = transform(Uint8Array.from(decoded.projectionBytes)); + const encoded = encodeOpaqueKaBundleV1(projectionBytes, decoded.sealBytes); + const bundleDigest = digestSystemRecordBytesV1( + SYSTEM_RECORD_DIGEST_DOMAINS_V1.profileBundle, + encoded.bundleBytes, + ); + const projectionQuadCount = String(projectionBytes.reduce( + (count, byte) => count + (byte === 0x0a ? 1 : 0), + 0, + )); + const envelope = await signHeadEnvelope(Object.freeze({ + ...fixture.envelope.object, + bundleDigest, + projectionBytes: String(projectionBytes.byteLength), + projectionQuads: projectionQuadCount, + graphScopedAuthorSeal: Object.freeze({ + ...fixture.envelope.object.graphScopedAuthorSeal, + publicTripleCount: projectionQuadCount, + }), + }), fixture.peerSigner, fixture.evmSigner); + const headArtifact = envelopeArtifact('agent-profile-head', envelope); + const bundleArtifact = Object.freeze({ + objectKind: 'profile-bundle' as const, + objectDigest: bundleDigest, + canonicalBytes: encoded.bundleBytes, + }); + const resolve = vi.fn(async (lookup: SystemRecordArtifactLookupV1, signal: AbortSignal) => { + if (lookup.type === 'object') { + if (lookup.objectKind === headArtifact.objectKind + && lookup.objectDigest === headArtifact.objectDigest) return headArtifact; + if (lookup.objectKind === bundleArtifact.objectKind + && lookup.objectDigest === bundleArtifact.objectDigest) return bundleArtifact; + } + return fixture.store.resolve(lookup, signal); + }); + return Object.freeze({ + ...fixture, + envelope, + projectionBytes, + artifacts: Object.freeze({ resolve }), + row: Object.freeze({ ...fixture.row, headDigest: envelope.objectDigest }), + }); +} + +export async function publishedReceiverFixtureWithHeadPatch( + patch: Partial + | ((head: AgentProfileActiveHeadObjectV1) => Partial), +) { + const fixture = await publishedReceiverFixture(); + const resolvedPatch = typeof patch === 'function' ? patch(fixture.envelope.object) : patch; + const envelope = await signHeadEnvelope(Object.freeze({ + ...fixture.envelope.object, + ...resolvedPatch, + }), fixture.peerSigner, fixture.evmSigner); + const headArtifact = envelopeArtifact('agent-profile-head', envelope); + const resolve = vi.fn(async (lookup: SystemRecordArtifactLookupV1, signal: AbortSignal) => { + if (lookup.type === 'object' + && lookup.objectKind === headArtifact.objectKind + && lookup.objectDigest === headArtifact.objectDigest) return headArtifact; + return fixture.store.resolve(lookup, signal); + }); + return Object.freeze({ + ...fixture, + envelope, + artifacts: Object.freeze({ resolve }), + row: Object.freeze({ ...fixture.row, headDigest: envelope.objectDigest }), + }); +} + +export function appendCanonicalProjectionLine( + projectionBytes: Uint8Array, + line: string, +): Uint8Array { + const lines = new TextDecoder().decode(projectionBytes).split('\n').filter(Boolean); + lines.push(line); + lines.sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right))); + return new TextEncoder().encode(`${lines.join('\n')}\n`); +} + +export const RECEIVER_HEAD_COUNT_MISMATCH_CASES = Object.freeze([ + Object.freeze({ + field: 'projectionBytes', + patch: () => ({ projectionBytes: '1' as const }), + error: /byte count/, + }), + Object.freeze({ + field: 'projectionQuads', + patch: (head: AgentProfileActiveHeadObjectV1) => ({ + projectionQuads: '1' as const, + graphScopedAuthorSeal: Object.freeze({ + ...head.graphScopedAuthorSeal, + publicTripleCount: '1' as const, + }), + }), + error: /quad count/, + }), +]); + +export const RECEIVER_CANONICAL_PROJECTION_FAILURE_CASES = Object.freeze([ + Object.freeze({ + label: 'graphful projection', + transform: (projectionBytes: Uint8Array) => new TextEncoder().encode( + new TextDecoder().decode(projectionBytes).replace( + ' .\n', + ' .\n', + ), + ), + error: /projection-iri/, + }), + Object.freeze({ + label: 'noncanonical projection order', + transform: (projectionBytes: Uint8Array) => new TextEncoder().encode( + `${new TextDecoder().decode(projectionBytes) + .split('\n').filter(Boolean).reverse().join('\n')}\n`, + ), + error: /projection-order/, + }), + Object.freeze({ + label: 'invalid UTF-8 projection', + transform: (projectionBytes: Uint8Array) => { + const altered = Uint8Array.from(projectionBytes); + const literalStart = altered.indexOf(0x22); + if (literalStart < 0) throw new Error('fixture projection has no literal'); + altered[literalStart + 1] = 0xff; + return altered; + }, + error: /projection-utf8/, + }), +]); + +export const RECEIVER_PROFILE_PROJECTION_FAILURE_CASES = Object.freeze([ + Object.freeze({ + label: 'unowned projection subject', + transform: (projectionBytes: Uint8Array) => appendCanonicalProjectionLine( + projectionBytes, + ' "intruder" .', + ), + error: /unowned subject/, + }), + Object.freeze({ + label: 'disallowed profile predicate', + transform: (projectionBytes: Uint8Array) => { + const projection = new TextDecoder().decode(projectionBytes); + const rootLine = projection.split('\n').find((line) => + line.includes('')); + const rootSubject = rootLine?.slice(0, rootLine.indexOf(' ')); + if (rootSubject === undefined || !rootSubject.startsWith('<')) { + throw new Error('fixture projection has no root identity line'); + } + return appendCanonicalProjectionLine( + projectionBytes, + `${rootSubject} "x" .`, + ); + }, + error: /disallowed profile predicate/, + }), + Object.freeze({ + label: 'mismatched peer identity', + transform: (projectionBytes: Uint8Array) => new TextEncoder().encode( + new TextDecoder().decode(projectionBytes).split('\n').map((line) => + line.includes('') + ? line.replace(/ "[^"]+" \.$/, ' "different-peer" .') + : line).join('\n'), + ), + error: /signed peerId/, + }), +]); + +export function compareReceiverQuad( + left: { subject: string; predicate: string; object: string; graph: string }, + right: { subject: string; predicate: string; object: string; graph: string }, +): number { + return left.subject.localeCompare(right.subject) + || left.predicate.localeCompare(right.predicate) + || left.object.localeCompare(right.object) + || left.graph.localeCompare(right.graph); +} + +export function compareReceiverUtf8(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')); +} diff --git a/packages/agent/test/system-record-receiver-v1.test.ts b/packages/agent/test/system-record-receiver-v1.test.ts new file mode 100644 index 0000000000..93588c1cd9 --- /dev/null +++ b/packages/agent/test/system-record-receiver-v1.test.ts @@ -0,0 +1,899 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { decodeOpaqueKaBundleV1 } from '@origintrail-official/dkg-core'; + +import { + canonicalizeOwnedSubjectTableObjectV1, + deriveAgentProfileOwnedSubjectV1, + EMPTY_OWNED_SUBJECT_TABLE_DIGEST_V1, + SYSTEM_RECORD_OBJECT_CAPS_V1, + type AgentProfileHeadObjectV1, + type OwnedSubjectTableObjectV1, + type SystemRecordInventoryRowV1, +} from '@origintrail-official/dkg-core/system-record-v1'; + +import { + createAgentProfileReceiverV1, + type AgentProfileReceiverCandidateV1, +} from '../src/system-records/receiver-v1.js'; +import { + envelopeArtifact, + NETWORK, + PRODUCER_FIXTURE_NOW_MS, + signHeadEnvelope, +} from './support/agent-profile-producer-v1-fixture.js'; +import { + compareReceiverQuad as compareQuad, + compareReceiverUtf8 as compareUtf8, + DEFAULT_MONOTONIC_APPLY_TIMING, + preparedFixtureApply, + publishedReceiverFixture as publishedFixture, + publishedReceiverFixtureWithHeadPatch, + publishedReceiverFixtureWithProjectionBytes, + RECEIVER_CANONICAL_PROJECTION_FAILURE_CASES, + RECEIVER_HEAD_COUNT_MISMATCH_CASES, + RECEIVER_PROFILE_PROJECTION_FAILURE_CASES, + rotatedPublishedReceiverFixture as rotatedPublishedFixture, +} from './support/agent-profile-receiver-v1-fixture.js'; + +describe('agent-profile system-record active receiver', () => { + it('verifies the exact closure and submits one immutable active candidate', async () => { + const fixture = await publishedFixture(); + const signal = new AbortController().signal; + const bundleArtifact = await fixture.store.resolve({ + type: 'object', + objectKind: 'profile-bundle', + objectDigest: fixture.envelope.object.bundleDigest, + }, signal); + if (bundleArtifact === null) throw new Error('fixture bundle was not retained'); + const verifyCurrentBundle = vi.fn((head, bundleBytes: Uint8Array, receivedSignal) => { + expect(head).toEqual(fixture.envelope.object); + expect(bundleBytes).toEqual(bundleArtifact.canonicalBytes); + expect(receivedSignal).toBe(signal); + return true; + }); + const prepareCandidateApply = vi.fn(( + _candidate: AgentProfileReceiverCandidateV1, + _signal: AbortSignal, + ) => preparedFixtureApply('1', 'a')); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(fixture.row, signal)) + .resolves.toMatchObject({ outcome: 'applied' }); + expect(verifyCurrentBundle).toHaveBeenCalledTimes(1); + expect(prepareCandidateApply).toHaveBeenCalledTimes(1); + const candidate = prepareCandidateApply.mock.calls[0]![0]; + expect(candidate.head).toEqual(fixture.envelope.object); + expect(candidate.envelope).toEqual(fixture.envelope); + expect([...candidate.projectionQuads].sort(compareQuad)) + .toEqual([...fixture.prepared.projectionQuads].sort(compareQuad)); + expect(candidate.ownedSubjectTable).toContain(fixture.prepared.rootEntity); + expect(candidate.canonicalProjectionBytes).toEqual( + decodeOpaqueKaBundleV1(bundleArtifact.canonicalBytes).projectionBytes, + ); + expect(candidate).not.toHaveProperty('signal'); + expect(prepareCandidateApply.mock.calls[0]![1]).toBe(signal); + expect(prepareCandidateApply.mock.calls[0]).toHaveLength(2); + }); + + it('does not invoke active bundle verification for a non-active current head', async () => { + const fixture = await publishedFixture(); + const active = fixture.envelope.object; + const tombstone = { + objectType: 'agent-profile-head', + kind: 'agents', + state: 'tombstone', + networkId: active.networkId, + peerId: active.peerId, + peerPublicKey: active.peerPublicKey, + authoritySequence: active.authoritySequence, + version: '1', + previousHeadDigest: fixture.envelope.objectDigest, + evmIssuer: active.evmIssuer, + rootSubject: active.rootSubject, + projectionSchemaDigest: active.projectionSchemaDigest, + issuedAt: '2026-08-07T12:10:00Z', + ownedSubjectTableDigest: EMPTY_OWNED_SUBJECT_TABLE_DIGEST_V1, + ownedSubjectCount: '0', + projectionBytes: '0', + projectionQuads: '0', + } as AgentProfileHeadObjectV1; + const tombstoneEnvelope = await signHeadEnvelope( + tombstone, + fixture.peerSigner, + fixture.evmSigner, + ); + const tombstoneArtifact = envelopeArtifact('agent-profile-head', tombstoneEnvelope); + const resolve = vi.fn(async (lookup, signal) => lookup.type === 'object' + && lookup.objectKind === 'agent-profile-head' + && lookup.objectDigest === tombstoneEnvelope.objectDigest + ? tombstoneArtifact + : fixture.store.resolve(lookup, signal)); + const verifyCurrentBundle = vi.fn(); + const prepareCandidateApply = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: { resolve }, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(Object.freeze({ + ...fixture.row, + version: tombstone.version, + headDigest: tombstoneEnvelope.objectDigest, + }), new AbortController().signal)).rejects.toThrow(/inventory row does not bind/); + expect(resolve).toHaveBeenCalledTimes(1); + expect(verifyCurrentBundle).not.toHaveBeenCalled(); + expect(prepareCandidateApply).not.toHaveBeenCalled(); + }); + + it('rejects an expired active head before bundle verification or materialization', async () => { + const fixture = await publishedFixture(); + const expiredHead = Object.freeze({ + ...fixture.envelope.object, + validUntil: '2026-08-07T12:20:00Z', + }) as AgentProfileHeadObjectV1; + const expiredEnvelope = await signHeadEnvelope( + expiredHead, + fixture.peerSigner, + fixture.evmSigner, + ); + const expiredArtifact = envelopeArtifact('agent-profile-head', expiredEnvelope); + const resolve = vi.fn(async (lookup, signal) => lookup.type === 'object' + && lookup.objectKind === 'agent-profile-head' + && lookup.objectDigest === expiredEnvelope.objectDigest + ? expiredArtifact + : fixture.store.resolve(lookup, signal)); + const verifyCurrentBundle = vi.fn(); + const prepareCandidateApply = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: { resolve }, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(Object.freeze({ + ...fixture.row, + headDigest: expiredEnvelope.objectDigest, + }), new AbortController().signal)).rejects.toThrow(/expired agent-profile head/); + expect(resolve).toHaveBeenCalledTimes(1); + expect(verifyCurrentBundle).not.toHaveBeenCalled(); + expect(prepareCandidateApply).not.toHaveBeenCalled(); + }); + + it('rechecks freshness immediately before the materialization point of no return', async () => { + const fixture = await publishedFixture(); + const validUntilMs = Date.parse(fixture.envelope.object.validUntil); + const nowMs = vi.fn() + .mockReturnValueOnce(validUntilMs - 1) + .mockReturnValue(validUntilMs); + const verifyCurrentBundle = vi.fn( + () => true, + ); + const prepareCandidateApply = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs, + verifyCurrentBundle, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(/expired agent-profile head/); + expect(nowMs).toHaveBeenCalledTimes(2); + expect(verifyCurrentBundle).toHaveBeenCalledTimes(1); + expect(prepareCandidateApply).not.toHaveBeenCalled(); + }); + + it('clamps signed wall-clock expiry onto the bridge monotonic deadline', async () => { + const fixture = await publishedFixture(); + const validUntilUnixMs = Date.parse(fixture.envelope.object.validUntil); + const nowMs = vi.fn() + .mockReturnValueOnce(validUntilUnixMs - 100) + .mockReturnValueOnce(validUntilUnixMs - 80) + .mockReturnValue(validUntilUnixMs - 60); + const existingMonotonicDeadlineMs = 5_200; + const monotonicNowMs = 5_000; + let admittedDeadlineMs: number | undefined; + const prepareCandidateApply = vi.fn(( + _candidate: AgentProfileReceiverCandidateV1, + _signal: AbortSignal, + ) => Object.freeze({ + existingMonotonicDeadlineMs, + monotonicNowMs, + apply: (deadline: number) => { + admittedDeadlineMs = deadline; + return { + outcome: 'applied' as const, + stateRevision: '6', + appliedStateDigest: `0x${'8'.repeat(64)}`, + }; + }, + })); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs, + verifyCurrentBundle: () => true, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .resolves.toMatchObject({ outcome: 'applied' }); + expect(nowMs).toHaveBeenCalledTimes(3); + expect(admittedDeadlineMs).toBe(5_060); + expect(admittedDeadlineMs).not.toBe(validUntilUnixMs); + }); + + it('preserves an authenticated existing deadline when it is tighter', async () => { + const fixture = await publishedFixture(); + const validUntilUnixMs = Date.parse(fixture.envelope.object.validUntil); + const nowMs = vi.fn() + .mockReturnValueOnce(validUntilUnixMs - 100) + .mockReturnValueOnce(validUntilUnixMs - 80) + .mockReturnValue(validUntilUnixMs - 60); + const apply = vi.fn(() => ({ + outcome: 'applied' as const, + stateRevision: '6', + appliedStateDigest: `0x${'8'.repeat(64)}`, + })); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs, + verifyCurrentBundle: () => true, + prepareCandidateApply: () => Object.freeze({ + existingMonotonicDeadlineMs: 5_025, + monotonicNowMs: 5_000, + apply, + }), + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .resolves.toMatchObject({ outcome: 'applied' }); + expect(apply).toHaveBeenCalledOnce(); + expect(apply).toHaveBeenCalledWith(5_025); + }); + + it('rejects an already-expired authenticated monotonic deadline', async () => { + const fixture = await publishedFixture(); + const apply = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: () => true, + prepareCandidateApply: () => Object.freeze({ + existingMonotonicDeadlineMs: 5_000, + monotonicNowMs: 5_000, + apply, + }), + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(/monotonic apply admission is expired/); + expect(apply).not.toHaveBeenCalled(); + }); + + it('invokes the prepared lifecycle apply entry exactly once', async () => { + const fixture = await publishedFixture(); + const apply = vi.fn(() => ({ + outcome: 'applied' as const, + stateRevision: '6', + appliedStateDigest: `0x${'8'.repeat(64)}`, + })); + const prepareCandidateApply = vi.fn(() => Object.freeze({ + ...DEFAULT_MONOTONIC_APPLY_TIMING, + apply, + })); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: () => true, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .resolves.toMatchObject({ outcome: 'applied' }); + expect(apply).toHaveBeenCalledTimes(1); + }); + + it('does not acknowledge an apply outcome returned instead of prepared state', async () => { + const fixture = await publishedFixture(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: () => true, + prepareCandidateApply: vi.fn(async () => Object.freeze({ + outcome: 'applied', + stateRevision: '6', + appliedStateDigest: `0x${'8'.repeat(64)}`, + }) as never), + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(/monotonic apply existing deadline is invalid/); + }); + + it('lets the lifecycle bridge reject expiry after its own asynchronous admission work', async () => { + const fixture = await publishedFixture(); + const validUntilMs = Date.parse(fixture.envelope.object.validUntil); + const nowMs = vi.fn() + .mockReturnValueOnce(validUntilMs - 2) + .mockReturnValueOnce(validUntilMs - 1) + .mockReturnValue(validUntilMs); + const apply = vi.fn(() => ({ + outcome: 'applied' as const, + stateRevision: '6', + appliedStateDigest: `0x${'8'.repeat(64)}`, + })); + const prepareCandidateApply = vi.fn(async ( + _candidate: AgentProfileReceiverCandidateV1, + _signal: AbortSignal, + ) => { + await Promise.resolve(); + return Object.freeze({ ...DEFAULT_MONOTONIC_APPLY_TIMING, apply }); + }); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs, + verifyCurrentBundle: () => true, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(/expired agent-profile head/); + expect(nowMs).toHaveBeenCalledTimes(3); + expect(prepareCandidateApply).toHaveBeenCalledTimes(1); + expect(apply).not.toHaveBeenCalled(); + }); + + it('hands every derived owned subject to the materializer candidate', async () => { + const fixture = await publishedFixture(true); + const prepareCandidateApply = vi.fn(( + _candidate: AgentProfileReceiverCandidateV1, + _signal: AbortSignal, + ) => preparedFixtureApply('1', 'a')); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: () => true, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive( + fixture.row, + new AbortController().signal, + )).resolves.toMatchObject({ outcome: 'applied' }); + const candidate = prepareCandidateApply.mock.calls[0]![0]; + const expectedOwnedSubjects = [...new Set( + fixture.prepared.projectionQuads.map(({ subject }) => subject), + )].sort(compareUtf8); + expect(expectedOwnedSubjects.length).toBeGreaterThan(1); + expect(candidate.ownedSubjectTable).toEqual(expectedOwnedSubjects); + expect(candidate.head.ownedSubjectCount).toBe(String(expectedOwnedSubjects.length)); + expect(candidate.envelope.object.state).toBe('active'); + }); + + it('traverses post-transition authority history and hands off its verified lineage', async () => { + const fixture = await rotatedPublishedFixture(); + const verifyAuthorityEnvelope = vi.fn(() => true); + const prepareCandidateApply = vi.fn(( + _candidate: AgentProfileReceiverCandidateV1, + _signal: AbortSignal, + ) => preparedFixtureApply('5', '9')); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: { resolve: fixture.resolve }, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyAuthorityEnvelope, + verifyCurrentBundle: () => true, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .resolves.toMatchObject({ outcome: 'applied' }); + const resolvedKinds = fixture.resolve.mock.calls + .map(([lookup]) => lookup.type === 'object' ? lookup.objectKind : lookup.type); + expect(resolvedKinds).toEqual(expect.arrayContaining([ + 'agent-profile-head', + 'profile-bundle', + 'authority-transition', + 'owned-subject-table', + ])); + expect(verifyAuthorityEnvelope.mock.calls.map(([candidate]) => candidate.object.objectType)) + .toEqual([ + 'agent-profile-head', + 'authority-transition', + 'agent-profile-head', + ]); + const candidate = prepareCandidateApply.mock.calls[0]![0]; + expect(candidate.verifiedAuthoritySummary).toMatchObject({ + candidateHeadDigest: fixture.envelope.objectDigest, + transitionLineage: [{ + priorAuthoritySequence: '0', + nextAuthoritySequence: '1', + transitionDigest: fixture.transitionEnvelope.objectDigest, + }], + historicalRoots: [fixture.prior.envelope.object.rootSubject], + lastAuthorityTransitionPriorHeadDigest: fixture.prior.envelope.objectDigest, + }); + }); + + it.each(['missing', 'refused'] as const)( + 'fails closed when post-transition authority evidence is $condition', + async (condition) => { + const fixture = await rotatedPublishedFixture(); + const prepareCandidateApply = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: { + resolve: (lookup, signal) => condition === 'missing' + && lookup.type === 'object' + && lookup.objectKind === 'authority-transition' + ? Promise.resolve(null) + : fixture.resolve(lookup, signal), + }, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyAuthorityEnvelope: (candidate) => condition !== 'refused' + || candidate.object.objectType !== 'authority-transition', + verifyCurrentBundle: () => true, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(condition === 'missing' ? /missing/ : /authority-transition verification/); + expect(prepareCandidateApply).not.toHaveBeenCalled(); + }, + ); + + it('fails closed when the exact owned-subject table is unavailable', async () => { + const fixture = await publishedFixture(); + const prepareCandidateApply = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: Object.freeze({ + resolve: (lookup, signal) => lookup.type === 'object' + && lookup.objectKind === 'owned-subject-table' + ? Promise.resolve(null) + : fixture.store.resolve(lookup, signal), + }), + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: () => true, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(/owned-subject table/); + expect(prepareCandidateApply).not.toHaveBeenCalled(); + }); + + it('fails closed when the owned-subject table bytes do not bind the verified head', async () => { + const fixture = await publishedFixture(); + const prepareCandidateApply = vi.fn(); + const alteredTable = Object.freeze([ + fixture.envelope.object.rootSubject, + deriveAgentProfileOwnedSubjectV1(fixture.envelope.object.rootSubject, 'capability', 1), + ].sort()) as OwnedSubjectTableObjectV1; + const alteredBytes = canonicalizeOwnedSubjectTableObjectV1( + fixture.envelope.object.rootSubject, + alteredTable, + ); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: Object.freeze({ + resolve: async (lookup, signal) => { + const artifact = await fixture.store.resolve(lookup, signal); + if (artifact === null || lookup.type !== 'object' + || lookup.objectKind !== 'owned-subject-table') return artifact; + return Object.freeze({ ...artifact, canonicalBytes: alteredBytes }); + }, + }), + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: () => true, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(/does not bind the verified head/); + expect(prepareCandidateApply).not.toHaveBeenCalled(); + }); + + it('fails closed when final bundle verification refuses the closure', async () => { + const fixture = await publishedFixture(); + const prepareCandidateApply = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: () => false, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(/bundle verification failed/); + expect(prepareCandidateApply).not.toHaveBeenCalled(); + }); + + it.each(RECEIVER_HEAD_COUNT_MISMATCH_CASES)( + 'rejects when signed $field does not match the retained projection', + async ({ patch, error }) => { + const fixture = await publishedReceiverFixtureWithHeadPatch(patch); + const verifyCurrentBundle = vi.fn(() => true); + const prepareCandidateApply = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.artifacts, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(error); + expect(verifyCurrentBundle).toHaveBeenCalledOnce(); + expect(prepareCandidateApply).not.toHaveBeenCalled(); + }, + ); + + it.each(RECEIVER_CANONICAL_PROJECTION_FAILURE_CASES)( + 'rejects a signed $label after boolean bundle verification', async ({ + transform, + error, + }) => { + const fixture = await publishedReceiverFixtureWithProjectionBytes(transform); + const verifyCurrentBundle = vi.fn(() => true); + const prepareCandidateApply = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.artifacts, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(error); + expect(verifyCurrentBundle).toHaveBeenCalledTimes(1); + expect(prepareCandidateApply).not.toHaveBeenCalled(); + }, + ); + + it.each(RECEIVER_PROFILE_PROJECTION_FAILURE_CASES)( + 'rejects a signed $label before lifecycle preparation', async ({ + transform, + error, + }) => { + const fixture = await publishedReceiverFixtureWithProjectionBytes(transform); + const verifyCurrentBundle = vi.fn(() => true); + const prepareCandidateApply = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.artifacts, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(error); + expect(verifyCurrentBundle).toHaveBeenCalledOnce(); + expect(prepareCandidateApply).not.toHaveBeenCalled(); + }, + ); + + it('isolates signed bundle bytes from mutations by the injected verifier', async () => { + const fixture = await publishedFixture(); + const signal = new AbortController().signal; + const bundleArtifact = await fixture.store.resolve({ + type: 'object', + objectKind: 'profile-bundle', + objectDigest: fixture.envelope.object.bundleDigest, + }, signal); + if (bundleArtifact === null) throw new Error('fixture bundle was not retained'); + const expectedProjectionBytes = Uint8Array.from( + decodeOpaqueKaBundleV1(bundleArtifact.canonicalBytes).projectionBytes, + ); + const verifyCurrentBundle = vi.fn((_head, bundleBytes: Uint8Array) => { + bundleBytes.fill(0); + return true; + }); + const prepareCandidateApply = vi.fn(( + _candidate: AgentProfileReceiverCandidateV1, + _signal: AbortSignal, + ) => preparedFixtureApply('4', 'f')); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(fixture.row, signal)) + .resolves.toMatchObject({ outcome: 'applied' }); + expect(verifyCurrentBundle).toHaveBeenCalledTimes(1); + const candidate = prepareCandidateApply.mock.calls[0]![0]; + expect(candidate.canonicalProjectionBytes).toEqual(expectedProjectionBytes); + expect([...candidate.projectionQuads].sort(compareQuad)) + .toEqual([...fixture.prepared.projectionQuads].sort(compareQuad)); + }); + + it('returns a committed apply outcome when cancellation arrives at the point of no return', async () => { + const fixture = await publishedFixture(); + const controller = new AbortController(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: () => true, + prepareCandidateApply: () => Object.freeze({ + ...DEFAULT_MONOTONIC_APPLY_TIMING, + apply: () => { + controller.abort(new Error('late stop')); + return { + outcome: 'applied', + stateRevision: '2', + appliedStateDigest: `0x${'c'.repeat(64)}`, + }; + }, + }), + }); + + await expect(receiver.receiveActive(fixture.row, controller.signal)).resolves.toEqual({ + outcome: 'applied', + stateRevision: '2', + appliedStateDigest: `0x${'c'.repeat(64)}`, + }); + }); + + it('honors cancellation raised during lifecycle apply preparation', async () => { + const fixture = await publishedFixture(); + const controller = new AbortController(); + const apply = vi.fn(() => ({ + outcome: 'applied' as const, + stateRevision: '2', + appliedStateDigest: `0x${'c'.repeat(64)}`, + })); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: () => true, + prepareCandidateApply: async () => { + await Promise.resolve(); + controller.abort(new Error('pre-apply stop')); + return Object.freeze({ ...DEFAULT_MONOTONIC_APPLY_TIMING, apply }); + }, + }); + + await expect(receiver.receiveActive(fixture.row, controller.signal)) + .rejects.toThrow('pre-apply stop'); + expect(apply).not.toHaveBeenCalled(); + }); + + it('honors a caller abort before resolving any artifact', async () => { + const resolve = vi.fn(); + const controller = new AbortController(); + controller.abort(new Error('test stop')); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: { resolve }, + verifyCurrentBundle: vi.fn(), + prepareCandidateApply: vi.fn(), + }); + const row: SystemRecordInventoryRowV1 = { + stableKeyHash: `0x${'a'.repeat(64)}`, + peerId: 'unused', + authoritySequence: '0', + version: '0', + headDigest: `0x${'b'.repeat(64)}`, + tombstone: false, + quarantined: false, + }; + + await expect(receiver.receiveActive(row, controller.signal)).rejects.toThrow('test stop'); + expect(resolve).not.toHaveBeenCalled(); + }); + + it.each([ + { + label: 'tombstone', + patch: { tombstone: true }, + error: /ordinary active inventory row/, + }, + { + label: 'quarantined', + patch: { + quarantined: true, + conflictEvidenceDigest: `0x${'d'.repeat(64)}`, + }, + error: /ordinary active inventory row/, + }, + { + label: 'conflict evidence', + patch: { conflictEvidenceDigest: `0x${'d'.repeat(64)}` }, + error: /conflict evidence may appear only on quarantined rows|ordinary active inventory row/, + }, + ])('rejects a $label row before fetching closure artifacts', async ({ patch, error }) => { + const fixture = await publishedFixture(); + const resolve = vi.fn(fixture.store.resolve.bind(fixture.store)); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: { resolve }, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: () => true, + prepareCandidateApply: vi.fn(), + }); + + await expect(receiver.receiveActive( + Object.freeze({ ...fixture.row, ...patch }), + new AbortController().signal, + )).rejects.toThrow(error); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('fails closed when the verified head does not bind the inventory version', async () => { + const fixture = await publishedFixture(); + const prepareCandidateApply = vi.fn(); + const resolve = vi.fn(fixture.store.resolve.bind(fixture.store)); + const verifyCurrentBundle = vi.fn( + () => true, + ); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: { resolve }, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive( + Object.freeze({ ...fixture.row, version: '1' }), + new AbortController().signal, + )).rejects.toThrow(/inventory row does not bind/); + expect(prepareCandidateApply).not.toHaveBeenCalled(); + expect(resolve).toHaveBeenCalledTimes(1); + expect(verifyCurrentBundle).not.toHaveBeenCalled(); + }); + + it('fails closed when final authority verification refuses the closure', async () => { + const fixture = await publishedFixture(); + const prepareCandidateApply = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyAuthorityEnvelope: () => false, + verifyCurrentBundle: () => true, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive( + fixture.row, + new AbortController().signal, + )).rejects.toThrow(/authority verification failed/); + expect(prepareCandidateApply).not.toHaveBeenCalled(); + }); + + it('fails closed when the default authority verifier sees a corrupted head signature', async () => { + const fixture = await publishedFixture(); + const signature = fixture.envelope.signatures[0]!.signature; + const corruptedEnvelope = Object.freeze({ + ...fixture.envelope, + signatures: Object.freeze(fixture.envelope.signatures.map((entry, index) => index === 0 + ? Object.freeze({ + ...entry, + signature: `${signature.startsWith('A') ? 'B' : 'A'}${signature.slice(1)}`, + }) + : entry)), + }) as typeof fixture.envelope; + const corruptedArtifact = envelopeArtifact('agent-profile-head', corruptedEnvelope); + const prepareCandidateApply = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: { + resolve: (lookup, signal) => lookup.type === 'object' + && lookup.objectKind === 'agent-profile-head' + && lookup.objectDigest === fixture.envelope.objectDigest + ? Promise.resolve(corruptedArtifact) + : fixture.store.resolve(lookup, signal), + }, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: () => true, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(/authority verification failed/); + expect(prepareCandidateApply).not.toHaveBeenCalled(); + }); + + it('rejects an oversized artifact before invoking typed-array copy hooks', async () => { + const fixture = await publishedFixture(); + const prepareCandidateApply = vi.fn(); + class CopyTrapBytes extends Uint8Array { + override *[Symbol.iterator](): ArrayIterator { + throw new Error('unbounded artifact copy ran before the cap'); + } + } + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: { + resolve: async (lookup, signal) => { + const artifact = await fixture.store.resolve(lookup, signal); + if (artifact === null || lookup.type !== 'object' + || lookup.objectKind !== 'agent-profile-head') return artifact; + return Object.freeze({ + ...artifact, + canonicalBytes: new CopyTrapBytes( + SYSTEM_RECORD_OBJECT_CAPS_V1['agent-profile-head'] + 1, + ), + }); + }, + }, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: () => true, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive( + fixture.row, + new AbortController().signal, + )).rejects.toThrow(/closure artifact exceeds/); + expect(prepareCandidateApply).not.toHaveBeenCalled(); + }); + + it('captures lifecycle dependencies once instead of rereading mutable options', async () => { + const fixture = await publishedFixture(); + const verifyCurrentBundle = vi.fn( + () => true, + ); + const prepareCandidateApply = vi.fn(( + _candidate: AgentProfileReceiverCandidateV1, + _signal: AbortSignal, + ) => preparedFixtureApply('3', 'e')); + const resolveArtifact = vi.fn(fixture.store.resolve.bind(fixture.store)); + const repository = { resolve: resolveArtifact }; + const mutable = { + networkId: NETWORK, + artifacts: repository, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle, + prepareCandidateApply, + }; + const receiver = createAgentProfileReceiverV1(mutable); + mutable.verifyCurrentBundle = vi.fn(() => { + throw new Error('mutated verifier was observed'); + }); + mutable.prepareCandidateApply = vi.fn(() => { + throw new Error('mutated materializer was observed'); + }); + repository.resolve = vi.fn(() => { + throw new Error('mutated repository was observed'); + }); + + await expect(receiver.receiveActive( + fixture.row, + new AbortController().signal, + )).resolves.toMatchObject({ outcome: 'applied', stateRevision: '3' }); + expect(verifyCurrentBundle).toHaveBeenCalledTimes(1); + expect(prepareCandidateApply).toHaveBeenCalledTimes(1); + expect(resolveArtifact).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/cg-shared-projection.ts b/packages/core/src/cg-shared-projection.ts index f8bde6f78c..99bc1f4e65 100644 --- a/packages/core/src/cg-shared-projection.ts +++ b/packages/core/src/cg-shared-projection.ts @@ -77,6 +77,15 @@ export interface CgSharedPublicRootProjectionTripleV1 { readonly object: string; } +/** A storage-ready graphless quad parsed from canonical V10 projection bytes. */ +export interface CanonicalGraphlessProjectionStorageQuadV1 { + readonly subject: string; + readonly predicate: string; + /** IRI value without angle brackets, or the exact canonical literal term. */ + readonly object: string; + readonly graph: ''; +} + /** * Encode public-root triples into the exact canonical bytes consumed by the * cg-shared-v1 verifier: V10-canonical terms, one LF per line, and raw UTF-8 @@ -103,6 +112,36 @@ export function encodeCanonicalCgSharedPublicRootProjectionV1( return projection; } +/** + * Parse exact canonical V10 projection lines into storage-ready graphless + * quads under the same bounded syntactic rules as cg-shared-v1 verification. + * Subject, predicate, and IRI object terms are returned without angle brackets; + * literal objects remain exact canonical lexical terms. This provides no + * authority, digest, signed-count, seal, or projection-schema proof; callers + * must establish those bindings separately. + */ +export function parseCanonicalGraphlessProjectionStorageQuadsV1( + projectionBytes: Uint8Array, + limits: CgSharedProjectionVerificationLimitsV1 = + DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1, +): readonly Readonly[] { + const quads: Readonly[] = []; + walkCanonicalProjectionLinesV1( + projectionBytes, + normalizeVerificationLimits(limits), + undefined, + ({ subject, predicate, object }) => { + quads.push(Object.freeze({ + subject, + predicate, + object: object.startsWith('<') ? object.slice(1, -1) : object, + graph: '', + })); + }, + ); + return Object.freeze(quads); +} + /** * Process-local proof that one structurally verified transferred bundle carries * the exact canonical `cg-shared-v1` projection committed by its author seal. @@ -176,7 +215,6 @@ interface CanonicalProjectionTripleV1 { readonly subject: string; readonly predicate: string; readonly object: string; - readonly leaf: Uint8Array; } interface VerifiedCgSharedProjectionStateV1 { @@ -407,8 +445,88 @@ function verifyCanonicalProjectionBytes( readonly privateDataHash: Digest32V1; readonly assertionMerkleRoot: Digest32V1; } { + const leaves: Uint8Array[] = []; + let anchor: CanonicalProjectionTripleV1 | undefined; + let privateHash: CanonicalProjectionTripleV1 | undefined; + const signedPublicTripleCount = BigInt(seal.publicTripleCount); + const reservedCommitmentSubject = + `${seal.kaUal}${CG_SHARED_PRIVATE_COMMITMENT_SUFFIX_V1}`; + const lineCount = walkCanonicalProjectionLinesV1( + projectionBytes, + limits, + signedPublicTripleCount, + (triple, lineNumber) => { + if ( + triple.subject === reservedCommitmentSubject + && triple.predicate !== CG_SHARED_PRIVATE_ANCHOR_PREDICATE_V1 + && triple.predicate !== CG_SHARED_PRIVATE_HASH_PREDICATE_V1 + ) { + fail( + 'projection-private-subject', + `projection line ${lineNumber} reuses the reserved commitment subject`, + ); + } + if (triple.predicate === CG_SHARED_PRIVATE_ANCHOR_PREDICATE_V1) { + if (anchor !== undefined) { + fail('projection-private-cardinality', 'projection contains duplicate private anchors'); + } + anchor = triple; + } else if (triple.predicate === CG_SHARED_PRIVATE_HASH_PREDICATE_V1) { + if (privateHash !== undefined) { + fail('projection-private-cardinality', 'projection contains duplicate private hashes'); + } + privateHash = triple; + } else if (triple.predicate.startsWith(PRIVATE_COMMITMENT_PREDICATE_PREFIX)) { + fail( + 'projection-private-predicate', + `projection line ${lineNumber} uses an unknown reserved private-data predicate`, + ); + } + leaves.push(hashTripleV10( + triple.subject, + triple.predicate, + triple.object, + )); + }, + ); + if (BigInt(lineCount) !== signedPublicTripleCount) { + fail( + 'projection-public-count', + 'canonical projection line count differs from seal publicTripleCount', + ); + } + assertPrivateCommitment(anchor, privateHash, seal); + + const publicTree = new V10MerkleTree(leaves); + const publicRoot = bytesToDigest(publicTree.root); + const privateDataHash = seal.privateMerkleRoot ?? SENTINEL_NO_PRIVATE_DIGEST_V10; + const assertionMerkleRoot = bytesToDigest(V10MerkleTree.computeKARoot( + publicTree.root, + digestToBytes(privateDataHash), + )); + if (assertionMerkleRoot !== seal.assertionMerkleRoot) { + fail( + 'projection-structured-root', + 'recomputed structured assertion root differs from the author seal', + ); + } + return Object.freeze({ publicRoot, privateDataHash, assertionMerkleRoot }); +} + +function walkCanonicalProjectionLinesV1( + projectionBytes: Uint8Array, + limits: Readonly, + expectedTripleCount: bigint | undefined, + visit: (triple: CanonicalProjectionTripleV1, lineNumber: number) => void, +): number { + if (!(projectionBytes instanceof Uint8Array)) { + fail('projection-input', 'canonical projection bytes must be a Uint8Array'); + } if (projectionBytes.byteLength === 0) { - fail('projection-empty', 'cg-shared-v1 projection must not be empty'); + fail('projection-empty', 'canonical projection must not be empty'); + } + if (projectionBytes.byteLength > limits.maxProjectionBytes) { + fail('projection-resource-refused', 'projection exceeds the local in-memory byte limit'); } if ( projectionBytes.byteLength >= 3 @@ -416,30 +534,27 @@ function verifyCanonicalProjectionBytes( && projectionBytes[1] === 0xbb && projectionBytes[2] === 0xbf ) { - fail('projection-utf8', 'cg-shared-v1 projection must not start with a UTF-8 BOM'); + fail('projection-utf8', 'canonical projection must not start with a UTF-8 BOM'); } if (projectionBytes[projectionBytes.byteLength - 1] !== 0x0a) { - fail('projection-line-ending', 'cg-shared-v1 projection must end with one LF'); + fail('projection-line-ending', 'canonical projection must end with one LF'); } - const leaves: Uint8Array[] = []; - let anchor: CanonicalProjectionTripleV1 | undefined; - let privateHash: CanonicalProjectionTripleV1 | undefined; + let lineCount = 0; let previousLine: Uint8Array | undefined; let lineStart = 0; - let lineNumber = 0; - const signedPublicTripleCount = BigInt(seal.publicTripleCount); - const reservedCommitmentSubject = - `${seal.kaUal}${CG_SHARED_PRIVATE_COMMITMENT_SUFFIX_V1}`; for (let cursor = 0; cursor < projectionBytes.byteLength; cursor += 1) { const byte = projectionBytes[cursor]; if (byte === 0x0d) { - fail('projection-line-ending', 'raw CR is forbidden in cg-shared-v1 bytes'); + fail('projection-line-ending', 'raw CR is forbidden in canonical projection bytes'); } if (byte !== 0x0a) continue; const line = projectionBytes.subarray(lineStart, cursor); - lineNumber += 1; - if (BigInt(lineNumber) > signedPublicTripleCount) { + const lineNumber = lineCount + 1; + if (lineNumber > limits.maxPublicTriples) { + fail('projection-resource-refused', 'projection exceeds the local in-memory triple limit'); + } + if (expectedTripleCount !== undefined && BigInt(lineNumber) > expectedTripleCount) { fail( 'projection-public-count', 'projection contains more lines than the signed publicTripleCount', @@ -463,63 +578,15 @@ function verifyCanonicalProjectionBytes( fail('projection-order', `projection line ${lineNumber} is not in raw UTF-8 order`); } } - const triple = parseCanonicalProjectionLine(line, lineNumber); - if ( - triple.subject === reservedCommitmentSubject - && triple.predicate !== CG_SHARED_PRIVATE_ANCHOR_PREDICATE_V1 - && triple.predicate !== CG_SHARED_PRIVATE_HASH_PREDICATE_V1 - ) { - fail( - 'projection-private-subject', - `projection line ${lineNumber} reuses the reserved commitment subject`, - ); - } - if (triple.predicate === CG_SHARED_PRIVATE_ANCHOR_PREDICATE_V1) { - if (anchor !== undefined) { - fail('projection-private-cardinality', 'projection contains duplicate private anchors'); - } - anchor = triple; - } else if (triple.predicate === CG_SHARED_PRIVATE_HASH_PREDICATE_V1) { - if (privateHash !== undefined) { - fail('projection-private-cardinality', 'projection contains duplicate private hashes'); - } - privateHash = triple; - } else if (triple.predicate.startsWith(PRIVATE_COMMITMENT_PREDICATE_PREFIX)) { - fail( - 'projection-private-predicate', - `projection line ${lineNumber} uses an unknown reserved private-data predicate`, - ); - } - leaves.push(triple.leaf); + visit(parseCanonicalProjectionLine(line, lineNumber), lineNumber); + lineCount = lineNumber; previousLine = line; lineStart = cursor + 1; } if (lineStart !== projectionBytes.byteLength) { fail('projection-line-ending', 'projection contains bytes after its final complete line'); } - - if (BigInt(lineNumber) !== BigInt(seal.publicTripleCount)) { - fail( - 'projection-public-count', - 'canonical projection line count differs from seal publicTripleCount', - ); - } - assertPrivateCommitment(anchor, privateHash, seal); - - const publicTree = new V10MerkleTree(leaves); - const publicRoot = bytesToDigest(publicTree.root); - const privateDataHash = seal.privateMerkleRoot ?? SENTINEL_NO_PRIVATE_DIGEST_V10; - const assertionMerkleRoot = bytesToDigest(V10MerkleTree.computeKARoot( - publicTree.root, - digestToBytes(privateDataHash), - )); - if (assertionMerkleRoot !== seal.assertionMerkleRoot) { - fail( - 'projection-structured-root', - 'recomputed structured assertion root differs from the author seal', - ); - } - return Object.freeze({ publicRoot, privateDataHash, assertionMerkleRoot }); + return lineCount; } function assertPrivateCommitment( @@ -620,7 +687,6 @@ function parseCanonicalProjectionLine( subject: subject.value, predicate: predicate.value, object, - leaf: hashTripleV10(subject.value, predicate.value, object), }); } diff --git a/packages/core/test/cg-shared-projection.test.ts b/packages/core/test/cg-shared-projection.test.ts index f2b4abae18..33330a75f8 100644 --- a/packages/core/test/cg-shared-projection.test.ts +++ b/packages/core/test/cg-shared-projection.test.ts @@ -17,6 +17,7 @@ import { CgSharedProjectionError, assertVerifiedCgSharedProjectionForTransferV1, assertVerifiedCgSharedProjectionV1, + parseCanonicalGraphlessProjectionStorageQuadsV1, readVerifiedCgSharedProjectionBytesV1, readVerifiedCgSharedProjectionMetadataV1, readVerifiedCgSharedProjectionV1, @@ -75,6 +76,68 @@ const FULLY_WITHHELD = + `<${COMMITMENT}> "034349e1ac2b108ba81720c55dff02bcae22762921f5c8354db83e687015872c"^^ .\n`; describe('RFC-64 canonical cg-shared-v1 projection verification', () => { + it('parses exact canonical lines into storage-ready graphless quads', () => { + expect(parseCanonicalGraphlessProjectionStorageQuadsV1(UTF8.encode(PUBLIC))).toEqual([ + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/age', + object: '"42"^^', + graph: '', + }, + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/name', + object: '"Alice"', + graph: '', + }, + ]); + expect(parseCanonicalGraphlessProjectionStorageQuadsV1(UTF8.encode( + ' .\n', + ))).toEqual([{ + subject: 'https://example.org/s', + predicate: 'https://example.org/p', + object: 'https://example.org/o', + graph: '', + }]); + }); + + it.each([ + { + name: 'graph term', + bytes: UTF8.encode(' .\n'), + code: 'projection-iri', + }, + { + name: 'raw byte disorder', + bytes: UTF8.encode(PUBLIC.split('\n').filter(Boolean).reverse().join('\n') + '\n'), + code: 'projection-order', + }, + { + name: 'invalid UTF-8', + bytes: new Uint8Array([ + ...UTF8.encode(' "'), + 0xc3, + 0x28, + ...UTF8.encode('" .\n'), + ]), + code: 'projection-utf8', + }, + ])('rejects $name before returning storage quads', ({ bytes, code }) => { + expectFailure( + () => parseCanonicalGraphlessProjectionStorageQuadsV1(bytes), + code as CgSharedProjectionErrorCode, + ); + }); + + it('rejects the first line beyond the signed count before parsing it', () => { + const firstLine = PUBLIC.split('\n')[0] + '\n'; + const fixture = makeFixtureBytes( + new Uint8Array([...UTF8.encode(firstLine), 0xff, 0x0a]), + sealForProjection(firstLine, '0', null), + ); + expectFailure(() => verifyProjection(fixture), 'projection-public-count'); + }); + it.each([ { name: 'public',