Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 84 additions & 26 deletions src/documentEnvelopeRevision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,23 @@ 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 {
/** Produce a SHA-256 digest for one complete canonical byte sequence. */
digest(algorithm: 'SHA-256', source: BufferSource): Promise<ArrayBuffer>;
}

/** 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. */
Expand All @@ -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
Expand All @@ -50,25 +61,34 @@ export async function createDocumentEnvelopeRevision(
limits?: DocumentEnvelopeLimits,
digestProvider?: DocumentEnvelopeDigestProvider | null,
): Promise<CwlEditorDocumentRevision> {
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<CwlEditorDocumentRevision> {
const resolvedProvider = resolveDocumentEnvelopeDigestProvider(digestProvider);
const envelope = parseDocumentEnvelopeBytes(source, limits);
return createValidatedDocumentEnvelopeRevision(envelope, digestProvider);
return createValidatedDocumentEnvelopeRevisionWithResolvedProvider(
envelope,
resolvedProvider,
);
}

/**
Expand All @@ -82,15 +102,32 @@ export async function createValidatedDocumentEnvelopeRevision(
envelope: CwlEditorDocumentEnvelope,
digestProvider?: DocumentEnvelopeDigestProvider | null,
): Promise<CwlEditorDocumentRevision> {
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<CwlEditorDocumentRevision> {
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;
Expand All @@ -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 {
Expand Down
127 changes: 127 additions & 0 deletions src/documentEnvelopeRevisionProviderBoundary.test.ts
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer> {
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));
});
});
41 changes: 29 additions & 12 deletions src/documentRevisionEvidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import {
type DocumentEnvelopeLimits,
} from './documentEnvelope.js';
import {
createValidatedDocumentEnvelopeRevision,
createValidatedDocumentEnvelopeRevisionWithResolvedProvider,
resolveDocumentEnvelopeDigestProvider,
type CwlEditorDocumentRevision,
type DocumentEnvelopeDigestProvider,
type ResolvedDocumentEnvelopeDigestProvider,
} from './documentEnvelopeRevision.js';

/**
Expand All @@ -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.
*/
Expand All @@ -37,30 +40,32 @@ export async function createDocumentEnvelopeRevisionEvidence(
limits?: DocumentEnvelopeLimits,
digestProvider?: DocumentEnvelopeDigestProvider | null,
): Promise<CwlEditorDocumentRevisionEvidence> {
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<CwlEditorDocumentRevisionEvidence> {
const resolvedProvider = resolveDocumentEnvelopeDigestProvider(digestProvider);
const envelope = parseDocumentEnvelopeBytes(source, limits);
return createValidatedDocumentEnvelopeRevisionEvidence(
return createValidatedDocumentEnvelopeRevisionEvidenceWithResolvedProvider(
envelope,
digestProvider,
resolvedProvider,
);
}

Expand All @@ -75,9 +80,21 @@ export async function createValidatedDocumentEnvelopeRevisionEvidence(
envelope: CwlEditorDocumentEnvelope,
digestProvider?: DocumentEnvelopeDigestProvider | null,
): Promise<CwlEditorDocumentRevisionEvidence> {
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<CwlEditorDocumentRevisionEvidence> {
const revision =
await createValidatedDocumentEnvelopeRevisionWithResolvedProvider(
envelope,
resolvedProvider,
);
return Object.freeze({ envelope, revision });
}
Loading
Loading