diff --git a/src/documentEnvelopeRevision.ts b/src/documentEnvelopeRevision.ts index 3731d541..af9abdf3 100644 --- a/src/documentEnvelopeRevision.ts +++ b/src/documentEnvelopeRevision.ts @@ -10,6 +10,8 @@ const ARRAY_BUFFER_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor( ArrayBuffer.prototype, 'byteLength', )!.get!; +const DIGEST_CREATION_FAILURE_MESSAGE = + 'Document envelope SHA-256 digest could not be created'; /** SHA-256 provider compatible with the Web Cryptography `SubtleCrypto` API. */ export interface DocumentEnvelopeDigestProvider { @@ -17,6 +19,14 @@ export interface DocumentEnvelopeDigestProvider { digest(algorithm: 'SHA-256', source: BufferSource): Promise; } +/** Package-internal snapshot of one provider capability and its receiver. */ +export interface ResolvedDocumentEnvelopeDigestProvider { + /** Original provider receiver required by Web Crypto-compatible methods. */ + readonly provider: DocumentEnvelopeDigestProvider; + /** Exact digest callable captured once at the operation boundary. */ + readonly digest: DocumentEnvelopeDigestProvider['digest']; +} + /** Portable strong validator derived from one canonical document envelope. */ export interface CwlEditorDocumentRevision { /** Cryptographic digest algorithm used for the revision validator. */ @@ -39,7 +49,8 @@ export class DocumentEnvelopeRevisionError extends Error { /** * Create a SHA-256 revision validator from an envelope object or JSON text. * - * The source is parsed through Inkspan's strict envelope boundary and then + * The digest capability is captured before the caller-controlled source is + * parsed. The source then passes Inkspan's strict envelope boundary and is * canonicalized before hashing, so equivalent supported envelopes produce the * same validator regardless of object-property or insignificant-whitespace * order. The optional provider exists for dependency injection; omitting it @@ -50,25 +61,34 @@ export async function createDocumentEnvelopeRevision( limits?: DocumentEnvelopeLimits, digestProvider?: DocumentEnvelopeDigestProvider | null, ): Promise { + const resolvedProvider = resolveDocumentEnvelopeDigestProvider(digestProvider); const envelope = parseDocumentEnvelope(source, limits); - return createValidatedDocumentEnvelopeRevision(envelope, digestProvider); + return createValidatedDocumentEnvelopeRevisionWithResolvedProvider( + envelope, + resolvedProvider, + ); } /** * Create a SHA-256 revision validator from strict UTF-8 envelope bytes. * - * Noncanonical but otherwise valid input is parsed and reserialized to the RFC - * 8785 representation before hashing. Byte-order marks, malformed UTF-8, - * duplicate names, unsupported versions, and resource-limit violations fail - * before the digest provider runs. + * The digest capability is captured before byte-source processing. Noncanonical + * but otherwise valid input is parsed and reserialized to the RFC 8785 + * representation before hashing. Byte-order marks, malformed UTF-8, duplicate + * names, unsupported versions, and resource-limit violations fail before the + * digest callable runs. */ export async function createDocumentEnvelopeRevisionBytes( source: unknown, limits?: DocumentEnvelopeLimits, digestProvider?: DocumentEnvelopeDigestProvider | null, ): Promise { + const resolvedProvider = resolveDocumentEnvelopeDigestProvider(digestProvider); const envelope = parseDocumentEnvelopeBytes(source, limits); - return createValidatedDocumentEnvelopeRevision(envelope, digestProvider); + return createValidatedDocumentEnvelopeRevisionWithResolvedProvider( + envelope, + resolvedProvider, + ); } /** @@ -82,15 +102,32 @@ export async function createValidatedDocumentEnvelopeRevision( envelope: CwlEditorDocumentEnvelope, digestProvider?: DocumentEnvelopeDigestProvider | null, ): Promise { - const provider = resolveDigestProvider(digestProvider); + return createValidatedDocumentEnvelopeRevisionWithResolvedProvider( + envelope, + resolveDocumentEnvelopeDigestProvider(digestProvider), + ); +} + +/** + * Hash one validated envelope with an already captured provider capability. + * + * Multi-revision operations use this package-internal helper so one hostile or + * mutable provider property cannot change meaning between related revisions. + */ +export async function createValidatedDocumentEnvelopeRevisionWithResolvedProvider( + envelope: CwlEditorDocumentEnvelope, + resolvedProvider: ResolvedDocumentEnvelopeDigestProvider, +): Promise { const canonicalBytes = encodeValidatedDocumentEnvelope(envelope); let digestResult: ArrayBuffer; try { - digestResult = await provider.digest('SHA-256', canonicalBytes); - } catch { - throw new DocumentEnvelopeRevisionError( - 'Document envelope SHA-256 digest could not be created', + digestResult = await resolvedProvider.digest.call( + resolvedProvider.provider, + 'SHA-256', + canonicalBytes, ); + } catch { + throw new DocumentEnvelopeRevisionError(DIGEST_CREATION_FAILURE_MESSAGE); } let digestBytes: Uint8Array; @@ -113,30 +150,51 @@ export async function createValidatedDocumentEnvelopeRevision( }); } -function resolveDigestProvider( +/** + * Capture one usable SHA-256 capability before expensive operation work begins. + * + * The returned object keeps the exact callable together with its original + * receiver. Provider-property reflection failures are converted into the same + * payload-redacted revision error as invocation failures. + */ +export function resolveDocumentEnvelopeDigestProvider( digestProvider: DocumentEnvelopeDigestProvider | null | undefined, -): DocumentEnvelopeDigestProvider { +): ResolvedDocumentEnvelopeDigestProvider { if (digestProvider === null) { throw new DocumentEnvelopeRevisionError( 'A SHA-256 digest provider is unavailable', ); } - if (digestProvider !== undefined) return digestProvider; - let platformProvider: SubtleCrypto | undefined; + let provider: DocumentEnvelopeDigestProvider; + if (digestProvider !== undefined) { + provider = digestProvider; + } else { + let platformProvider: SubtleCrypto | undefined; + try { + platformProvider = globalThis.crypto?.subtle; + } catch { + throw new DocumentEnvelopeRevisionError( + 'A SHA-256 digest provider is unavailable', + ); + } + if (platformProvider === undefined) { + throw new DocumentEnvelopeRevisionError( + 'A SHA-256 digest provider is unavailable', + ); + } + provider = platformProvider; + } + try { - platformProvider = globalThis.crypto?.subtle; + const digest = provider.digest; + if (typeof digest !== 'function') { + throw new TypeError('invalid digest provider'); + } + return Object.freeze({ provider, digest }); } catch { - throw new DocumentEnvelopeRevisionError( - 'A SHA-256 digest provider is unavailable', - ); - } - if (platformProvider === undefined) { - throw new DocumentEnvelopeRevisionError( - 'A SHA-256 digest provider is unavailable', - ); + throw new DocumentEnvelopeRevisionError(DIGEST_CREATION_FAILURE_MESSAGE); } - return platformProvider; } function bytesToLowercaseHex(bytes: Uint8Array): string { diff --git a/src/documentEnvelopeRevisionProviderBoundary.test.ts b/src/documentEnvelopeRevisionProviderBoundary.test.ts new file mode 100644 index 00000000..e5d74b01 --- /dev/null +++ b/src/documentEnvelopeRevisionProviderBoundary.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createDocumentEnvelope } from './documentEnvelope.js'; +import { + createDocumentEnvelopeRevision, + createDocumentEnvelopeRevisionBytes, + type DocumentEnvelopeDigestProvider, +} from './documentEnvelopeRevision.js'; +import { + createDocumentEnvelopeRevisionEvidence, + createDocumentEnvelopeRevisionEvidenceBytes, +} from './documentRevisionEvidence.js'; + +const ENVELOPE = createDocumentEnvelope({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Digest provider boundary' }], + }, + ], +}); + +const DIGEST_FAILURE = 'Document envelope SHA-256 digest could not be created'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('document revision digest-provider capability boundary', () => { + it('rejects a non-callable injected digest before canonical byte encoding', async () => { + const encode = vi.spyOn(TextEncoder.prototype, 'encode'); + const provider = { digest: 7 } as unknown as DocumentEnvelopeDigestProvider; + + await expect( + createDocumentEnvelopeRevision(ENVELOPE, undefined, provider), + ).rejects.toThrow(DIGEST_FAILURE); + + expect(encode).not.toHaveBeenCalled(); + }); + + it('redacts a hostile digest capability lookup before canonical byte encoding', async () => { + const encode = vi.spyOn(TextEncoder.prototype, 'encode'); + let digestReads = 0; + const provider = {} as DocumentEnvelopeDigestProvider; + Object.defineProperty(provider, 'digest', { + get() { + digestReads += 1; + throw new Error('private digest capability detail'); + }, + }); + + await expect( + createDocumentEnvelopeRevision(ENVELOPE, undefined, provider), + ).rejects.toThrow(DIGEST_FAILURE); + + expect(digestReads).toBe(1); + expect(encode).not.toHaveBeenCalled(); + }); + + it('rejects an unusable provider before object and byte source processing', async () => { + let sourceReads = 0; + const source = new Proxy(ENVELOPE, { + ownKeys(target) { + sourceReads += 1; + return Reflect.ownKeys(target); + }, + getOwnPropertyDescriptor(target, property) { + sourceReads += 1; + return Reflect.getOwnPropertyDescriptor(target, property); + }, + get(target, property, receiver) { + sourceReads += 1; + return Reflect.get(target, property, receiver); + }, + }); + const provider = { digest: 7 } as unknown as DocumentEnvelopeDigestProvider; + + await expect( + createDocumentEnvelopeRevision(source, undefined, provider), + ).rejects.toThrow(DIGEST_FAILURE); + await expect( + createDocumentEnvelopeRevisionEvidence(source, undefined, provider), + ).rejects.toThrow(DIGEST_FAILURE); + expect(sourceReads).toBe(0); + + await expect( + createDocumentEnvelopeRevisionBytes('invalid byte source', undefined, provider), + ).rejects.toThrow(DIGEST_FAILURE); + await expect( + createDocumentEnvelopeRevisionEvidenceBytes( + 'invalid byte source', + undefined, + provider, + ), + ).rejects.toThrow(DIGEST_FAILURE); + }); + + it('resolves an accessor-backed callable once and preserves its receiver', async () => { + let digestReads = 0; + const digestResult = new ArrayBuffer(32); + const provider = {} as DocumentEnvelopeDigestProvider; + Object.defineProperty(provider, 'digest', { + get() { + digestReads += 1; + return function digest( + this: DocumentEnvelopeDigestProvider, + algorithm: 'SHA-256', + source: BufferSource, + ): Promise { + expect(this).toBe(provider); + expect(algorithm).toBe('SHA-256'); + expect(ArrayBuffer.isView(source)).toBe(true); + return Promise.resolve(digestResult); + }; + }, + }); + + const revision = await createDocumentEnvelopeRevision( + ENVELOPE, + undefined, + provider, + ); + + expect(digestReads).toBe(1); + expect(revision.digestHex).toBe('00'.repeat(32)); + }); +}); diff --git a/src/documentRevisionEvidence.ts b/src/documentRevisionEvidence.ts index ac3cb2c3..7a2cc448 100644 --- a/src/documentRevisionEvidence.ts +++ b/src/documentRevisionEvidence.ts @@ -5,9 +5,11 @@ import { type DocumentEnvelopeLimits, } from './documentEnvelope.js'; import { - createValidatedDocumentEnvelopeRevision, + createValidatedDocumentEnvelopeRevisionWithResolvedProvider, + resolveDocumentEnvelopeDigestProvider, type CwlEditorDocumentRevision, type DocumentEnvelopeDigestProvider, + type ResolvedDocumentEnvelopeDigestProvider, } from './documentEnvelopeRevision.js'; /** @@ -28,7 +30,8 @@ export interface CwlEditorDocumentRevisionEvidence { /** * Create frozen revision evidence from an envelope object or JSON text. * - * The source is parsed once through Inkspan's strict versioned-envelope + * The digest capability is captured before caller-controlled source processing. + * The source is then parsed once through Inkspan's strict versioned-envelope * boundary. The returned envelope is the exact normalized frozen payload whose * RFC 8785 canonical UTF-8 bytes produced the paired SHA-256 revision. */ @@ -37,30 +40,32 @@ export async function createDocumentEnvelopeRevisionEvidence( limits?: DocumentEnvelopeLimits, digestProvider?: DocumentEnvelopeDigestProvider | null, ): Promise { + const resolvedProvider = resolveDocumentEnvelopeDigestProvider(digestProvider); const envelope = parseDocumentEnvelope(source, limits); - return createValidatedDocumentEnvelopeRevisionEvidence( + return createValidatedDocumentEnvelopeRevisionEvidenceWithResolvedProvider( envelope, - digestProvider, + resolvedProvider, ); } /** * Create frozen revision evidence from strict UTF-8 envelope bytes. * - * Noncanonical but valid JSON is normalized through the existing strict byte - * parser before hashing. Malformed UTF-8, byte-order marks, duplicate names, - * unsupported versions, and resource-limit violations fail before the digest - * provider runs. + * The digest capability is captured before byte-source processing. Noncanonical + * but valid JSON is normalized through the existing strict byte parser before + * hashing. Malformed UTF-8, byte-order marks, duplicate names, unsupported + * versions, and resource-limit violations fail before the digest callable runs. */ export async function createDocumentEnvelopeRevisionEvidenceBytes( source: unknown, limits?: DocumentEnvelopeLimits, digestProvider?: DocumentEnvelopeDigestProvider | null, ): Promise { + const resolvedProvider = resolveDocumentEnvelopeDigestProvider(digestProvider); const envelope = parseDocumentEnvelopeBytes(source, limits); - return createValidatedDocumentEnvelopeRevisionEvidence( + return createValidatedDocumentEnvelopeRevisionEvidenceWithResolvedProvider( envelope, - digestProvider, + resolvedProvider, ); } @@ -75,9 +80,21 @@ export async function createValidatedDocumentEnvelopeRevisionEvidence( envelope: CwlEditorDocumentEnvelope, digestProvider?: DocumentEnvelopeDigestProvider | null, ): Promise { - const revision = await createValidatedDocumentEnvelopeRevision( + return createValidatedDocumentEnvelopeRevisionEvidenceWithResolvedProvider( envelope, - digestProvider, + resolveDocumentEnvelopeDigestProvider(digestProvider), ); +} + +/** Pair a validated envelope with a revision using one captured digest capability. */ +async function createValidatedDocumentEnvelopeRevisionEvidenceWithResolvedProvider( + envelope: CwlEditorDocumentEnvelope, + resolvedProvider: ResolvedDocumentEnvelopeDigestProvider, +): Promise { + const revision = + await createValidatedDocumentEnvelopeRevisionWithResolvedProvider( + envelope, + resolvedProvider, + ); return Object.freeze({ envelope, revision }); } diff --git a/src/documentTransitionEvidence.test.ts b/src/documentTransitionEvidence.test.ts index 46791338..b44409a7 100644 --- a/src/documentTransitionEvidence.test.ts +++ b/src/documentTransitionEvidence.test.ts @@ -5,7 +5,10 @@ import { type CwlEditorDocumentEnvelope, } from './documentEnvelope.js'; import { encodeDocumentEnvelope } from './documentEnvelopeCanonical.js'; -import type { DocumentEnvelopeDigestProvider } from './documentEnvelopeRevision.js'; +import { + DocumentEnvelopeRevisionError, + type DocumentEnvelopeDigestProvider, +} from './documentEnvelopeRevision.js'; import { DOCUMENT_TRANSITION_EVIDENCE_SCHEMA_ID, DOCUMENT_TRANSITION_EVIDENCE_SCHEMA_VERSION, @@ -160,6 +163,70 @@ describe('document transition evidence', () => { expect(digestProvider.digest).not.toHaveBeenCalled(); }); + it('rejects an unusable provider before object or byte source processing', async () => { + let objectSourceReads = 0; + const objectSource = new Proxy(createDocumentEnvelope(PREVIOUS_DOCUMENT), { + ownKeys(target) { + objectSourceReads += 1; + return Reflect.ownKeys(target); + }, + }); + const malformedProvider = { + digest: 42, + } as unknown as DocumentEnvelopeDigestProvider; + + await expect( + createDocumentEnvelopeTransitionEvidence( + objectSource, + createDocumentEnvelope(RESULTING_DOCUMENT), + undefined, + malformedProvider, + ), + ).rejects.toBeInstanceOf(DocumentEnvelopeRevisionError); + expect(objectSourceReads).toBe(0); + + await expect( + createDocumentEnvelopeTransitionEvidenceBytes( + 'invalid byte source', + 'invalid byte source', + undefined, + malformedProvider, + ), + ).rejects.toBeInstanceOf(DocumentEnvelopeRevisionError); + }); + + it('resolves one provider capability for both transition revisions', async () => { + const previousEnvelope = createDocumentEnvelope(PREVIOUS_DOCUMENT); + const resultingEnvelope = createDocumentEnvelope(RESULTING_DOCUMENT); + let digestReads = 0; + const calls: unknown[] = []; + const provider = { + get digest() { + digestReads += 1; + return async function ( + this: unknown, + algorithm: 'SHA-256', + source: BufferSource, + ): Promise { + expect(algorithm).toBe('SHA-256'); + calls.push(this); + return sha256(source); + }; + }, + } as DocumentEnvelopeDigestProvider; + + const evidence = await createDocumentEnvelopeTransitionEvidence( + previousEnvelope, + resultingEnvelope, + undefined, + provider, + ); + + expect(evidence.changed).toBe(true); + expect(digestReads).toBe(1); + expect(calls).toEqual([provider, provider]); + }); + it('hashes previous then resulting canonical bytes without overlapping provider calls', async () => { const previousEnvelope = createDocumentEnvelope(PREVIOUS_DOCUMENT); const resultingEnvelope = createDocumentEnvelope(RESULTING_DOCUMENT); diff --git a/src/documentTransitionEvidence.ts b/src/documentTransitionEvidence.ts index bb19ed2e..82075de7 100644 --- a/src/documentTransitionEvidence.ts +++ b/src/documentTransitionEvidence.ts @@ -4,11 +4,12 @@ import { type CwlEditorDocumentEnvelope, type DocumentEnvelopeLimits, } from './documentEnvelope.js'; -import type { - CwlEditorDocumentRevision, - DocumentEnvelopeDigestProvider, +import { + createValidatedDocumentEnvelopeRevisionWithResolvedProvider, + resolveDocumentEnvelopeDigestProvider, + type CwlEditorDocumentRevision, + type DocumentEnvelopeDigestProvider, } from './documentEnvelopeRevision.js'; -import { createValidatedDocumentEnvelopeRevisionEvidence } from './documentRevisionEvidence.js'; /** Canonical identifier for Inkspan's first compact transition-evidence schema. */ export const DOCUMENT_TRANSITION_EVIDENCE_SCHEMA_ID = @@ -45,10 +46,11 @@ type DocumentEnvelopeParser = ( /** * Derive compact transition evidence from two envelope objects or JSON texts. * - * Both inputs pass the strict versioned-envelope boundary before either digest - * begins. Successful operations hash the previous canonical envelope first and - * the resulting canonical envelope second, then return frozen revision-only - * evidence without retaining either complete document in the result. + * The SHA-256 capability is captured before either caller-controlled source is + * reflected or parsed. Both inputs then pass the strict versioned-envelope + * boundary before either digest begins. Successful operations hash the previous + * canonical envelope first and the resulting canonical envelope second with + * that same captured capability, then return frozen revision-only evidence. */ export function createDocumentEnvelopeTransitionEvidence( previousSource: unknown, @@ -68,9 +70,10 @@ export function createDocumentEnvelopeTransitionEvidence( /** * Derive compact transition evidence from two strict UTF-8 envelope byte views. * - * Malformed UTF-8, byte-order marks, duplicate object names, unsupported - * versions, and resource-limit violations fail before hashing. Equivalent - * noncanonical JSON encodings normalize to the same revision pair. + * Provider capability failure precedes byte-source processing. After provider + * preflight, malformed UTF-8, byte-order marks, duplicate object names, + * unsupported versions, and resource-limit violations still fail before + * hashing. Equivalent noncanonical JSON encodings normalize to the same pair. */ export function createDocumentEnvelopeTransitionEvidenceBytes( previousSource: unknown, @@ -87,7 +90,7 @@ export function createDocumentEnvelopeTransitionEvidenceBytes( ); } -/** Parse both documents, hash them sequentially, and expose revisions only. */ +/** Preflight one provider, parse both documents, then hash them sequentially. */ async function createTransitionEvidence( previousSource: unknown, resultingSource: unknown, @@ -95,27 +98,26 @@ async function createTransitionEvidence( digestProvider: DocumentEnvelopeDigestProvider | null | undefined, parse: DocumentEnvelopeParser, ): Promise { + const resolvedProvider = resolveDocumentEnvelopeDigestProvider(digestProvider); const previousEnvelope = parse(previousSource, limits); const resultingEnvelope = parse(resultingSource, limits); - const previousEvidence = - await createValidatedDocumentEnvelopeRevisionEvidence( + const previousRevision = + await createValidatedDocumentEnvelopeRevisionWithResolvedProvider( previousEnvelope, - digestProvider, + resolvedProvider, ); - const resultingEvidence = - await createValidatedDocumentEnvelopeRevisionEvidence( + const resultingRevision = + await createValidatedDocumentEnvelopeRevisionWithResolvedProvider( resultingEnvelope, - digestProvider, + resolvedProvider, ); return Object.freeze({ schemaId: DOCUMENT_TRANSITION_EVIDENCE_SCHEMA_ID, schemaVersion: DOCUMENT_TRANSITION_EVIDENCE_SCHEMA_VERSION, - previousRevision: previousEvidence.revision, - resultingRevision: resultingEvidence.revision, - changed: - previousEvidence.revision.digestHex !== - resultingEvidence.revision.digestHex, + previousRevision, + resultingRevision, + changed: previousRevision.digestHex !== resultingRevision.digestHex, }); }