-
Notifications
You must be signed in to change notification settings - Fork 784
feat(lab): CL-10 public evidence trust core #1628
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 10 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
a7b1ea6
feat(lab): CL-10 public evidence trust core
Wibias 45a030a
fix(lab): bound private file stage cleanup
Wibias 9c37da1
test(lab): cover expired private file stages
Wibias bd0763b
fix(lab): bound strict public JSON input (#1641)
Wibias bf2c551
fix(lab): harden private stages before publication
Wibias ab92c90
fix(lab): protect publisher key before final link
Wibias 41a8fd9
test(lab): reject publisher key publication on ACL failure
Wibias 15d5752
test(lab): prepare private stages before secret writes
Wibias 2b8eafe
fix(lab): harden private stages before writing secrets
Wibias b5f9b75
fix(lab): harden publisher stage before key bytes
Wibias 8374c6d
fix(lab): address public evidence review findings
Wibias b6808ca
test(lab): tighten review regressions
Wibias File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,207 @@ | ||
| import { jcsStringify } from "../digest"; | ||
| import { publicEvidenceId } from "./ids"; | ||
| import { | ||
| PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION, | ||
| PUBLIC_EXPORT_POLICY_VERSION, | ||
| type PublicArtifactV1, | ||
| type PublicEvidenceBundleUnsignedV1, | ||
| type PublicEvidenceRecordV1, | ||
| type PublicPublisherV1, | ||
| } from "./types"; | ||
| import { PublicEvidenceValidationError, validatePublicEvidenceRecord } from "./validate"; | ||
|
|
||
| export const MAX_PUBLIC_BUNDLE_BYTES = 2 * 1024 * 1024; | ||
| export const MAX_PUBLIC_BUNDLE_RECORDS = 256; | ||
| export const MAX_PUBLIC_BUNDLE_ARTIFACTS = 16; | ||
| export const MAX_PUBLIC_ARTIFACT_BYTES = 256 * 1024; | ||
| export const MAX_PUBLIC_ARTIFACT_BYTES_TOTAL = 1024 * 1024; | ||
|
|
||
| export interface PublicEvidenceContentInput { | ||
| records: PublicEvidenceRecordV1[]; | ||
| artifacts: PublicArtifactV1[]; | ||
| createdDayUtc: string; | ||
| } | ||
|
|
||
| export interface BuildPublicEvidenceBundleInput extends PublicEvidenceContentInput { | ||
| publisher: PublicPublisherV1; | ||
| } | ||
|
|
||
| function utcDay(value: string): string { | ||
| if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { | ||
| throw new PublicEvidenceValidationError("invalid_day", "createdDayUtc must be YYYY-MM-DD"); | ||
| } | ||
| const parsed = new Date(`${value}T00:00:00.000Z`); | ||
| if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) { | ||
| throw new PublicEvidenceValidationError("invalid_day", "createdDayUtc must be a real UTC day"); | ||
| } | ||
| return value; | ||
| } | ||
|
|
||
| function validatePublisher(publisher: PublicPublisherV1): PublicPublisherV1 { | ||
| const raw = publisher as unknown as Record<string, unknown>; | ||
| if (!raw || typeof raw !== "object" || Array.isArray(raw)) { | ||
| throw new PublicEvidenceValidationError("invalid_publisher", "publisher must be an object"); | ||
| } | ||
| const keys = Object.keys(raw); | ||
| if (keys.some((key) => !["algorithm", "keyId", "publicKey"].includes(key))) { | ||
| throw new PublicEvidenceValidationError("unknown_field", "publisher contains unknown fields"); | ||
| } | ||
| if (publisher.algorithm !== "ed25519") { | ||
| throw new PublicEvidenceValidationError("unsupported_algorithm", "publisher must use ed25519"); | ||
| } | ||
| if (!/^[0-9a-f]{64}$/.test(publisher.keyId)) { | ||
| throw new PublicEvidenceValidationError("invalid_publisher", "publisher.keyId must be sha256 hex"); | ||
| } | ||
| if (typeof publisher.publicKey !== "string" || publisher.publicKey.length === 0 || publisher.publicKey.length > 1024) { | ||
| throw new PublicEvidenceValidationError("invalid_publisher", "publisher.publicKey is invalid"); | ||
| } | ||
| const publicKeyBytes = Buffer.from(publisher.publicKey, "base64"); | ||
| if (publicKeyBytes.byteLength === 0 || publicKeyBytes.toString("base64") !== publisher.publicKey) { | ||
| throw new PublicEvidenceValidationError("invalid_publisher", "publisher.publicKey must use canonical base64"); | ||
| } | ||
| const expectedKeyId = publicEvidenceId("publisher_key", { | ||
| algorithm: publisher.algorithm, | ||
| publicKey: publisher.publicKey, | ||
| }); | ||
| if (publisher.keyId !== expectedKeyId) { | ||
| throw new PublicEvidenceValidationError("publisher_key_id_mismatch", "publisher.keyId does not match public key"); | ||
| } | ||
| return { algorithm: "ed25519", keyId: publisher.keyId, publicKey: publisher.publicKey }; | ||
| } | ||
|
|
||
| function validateArtifacts(artifacts: PublicArtifactV1[]): PublicArtifactV1[] { | ||
| if (!Array.isArray(artifacts) || artifacts.length > MAX_PUBLIC_BUNDLE_ARTIFACTS) { | ||
| throw new PublicEvidenceValidationError("array_too_large", `artifacts exceeds ${MAX_PUBLIC_BUNDLE_ARTIFACTS}`); | ||
| } | ||
| let aggregate = 0; | ||
| const ids = new Set<string>(); | ||
| return artifacts.map((artifact, index) => { | ||
| const raw = artifact as unknown as Record<string, unknown>; | ||
| if (!raw || typeof raw !== "object" || Array.isArray(raw)) { | ||
| throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}] must be an object`); | ||
| } | ||
| if (Object.keys(raw).some((key) => !["artifactId", "artifactClass", "mediaType", "byteCount", "contentBase64"].includes(key))) { | ||
| throw new PublicEvidenceValidationError("unknown_field", `artifacts[${index}] contains unknown fields`); | ||
| } | ||
| if (!/^[0-9a-f]{64}$/.test(artifact.artifactId)) { | ||
| throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}].artifactId is invalid`); | ||
| } | ||
| if (typeof artifact.artifactClass !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:+-]{0,255}$/.test(artifact.artifactClass)) { | ||
| throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}].artifactClass is invalid`); | ||
| } | ||
| if (typeof artifact.mediaType !== "string" || artifact.mediaType.length === 0 || artifact.mediaType.length > 256) { | ||
| throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}].mediaType is invalid`); | ||
| } | ||
| if (!Number.isInteger(artifact.byteCount) || artifact.byteCount < 0 || artifact.byteCount > MAX_PUBLIC_ARTIFACT_BYTES) { | ||
| throw new PublicEvidenceValidationError("artifact_too_large", `artifacts[${index}].byteCount is invalid`); | ||
| } | ||
| let bytes: Buffer; | ||
| try { | ||
| bytes = Buffer.from(artifact.contentBase64, "base64"); | ||
| } catch { | ||
| throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}].contentBase64 is invalid`); | ||
| } | ||
| if (bytes.byteLength !== artifact.byteCount || bytes.toString("base64") !== artifact.contentBase64) { | ||
| throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}] byte count or base64 is non-canonical`); | ||
| } | ||
| const expectedArtifactId = publicEvidenceId("artifact", { | ||
| artifactClass: artifact.artifactClass, | ||
| mediaType: artifact.mediaType, | ||
| byteCount: artifact.byteCount, | ||
| contentBase64: artifact.contentBase64, | ||
| }); | ||
| if (artifact.artifactId !== expectedArtifactId) { | ||
| throw new PublicEvidenceValidationError("artifact_id_mismatch", `artifacts[${index}].artifactId mismatch`); | ||
| } | ||
| aggregate += artifact.byteCount; | ||
| if (aggregate > MAX_PUBLIC_ARTIFACT_BYTES_TOTAL) { | ||
| throw new PublicEvidenceValidationError("artifact_aggregate_too_large", "artifact aggregate exceeds 1 MiB"); | ||
| } | ||
| if (ids.has(artifact.artifactId)) { | ||
| throw new PublicEvidenceValidationError("duplicate_id", "artifacts contains duplicate ids"); | ||
| } | ||
| ids.add(artifact.artifactId); | ||
| return { ...artifact }; | ||
| }); | ||
| } | ||
|
|
||
| /** Validate all publisher-independent bundle content before any signing-key state is touched. */ | ||
| export function normalizePublicEvidenceContent(input: PublicEvidenceContentInput): PublicEvidenceContentInput { | ||
| if (!Array.isArray(input.records) || input.records.length > MAX_PUBLIC_BUNDLE_RECORDS) { | ||
| throw new PublicEvidenceValidationError("array_too_large", `records exceeds ${MAX_PUBLIC_BUNDLE_RECORDS}`); | ||
| } | ||
| const records = input.records.map(validatePublicEvidenceRecord).sort((a, b) => a.recordId.localeCompare(b.recordId)); | ||
| if (new Set(records.map((record) => record.recordId)).size !== records.length) { | ||
| throw new PublicEvidenceValidationError("duplicate_id", "records contains duplicate ids"); | ||
| } | ||
| const artifacts = validateArtifacts(input.artifacts).sort((a, b) => a.artifactId.localeCompare(b.artifactId)); | ||
|
Wibias marked this conversation as resolved.
Outdated
|
||
| const artifactIds = new Set(artifacts.map((artifact) => artifact.artifactId)); | ||
| for (const record of records) { | ||
| for (const artifactId of record.artifactRefs ?? []) { | ||
| if (!artifactIds.has(artifactId)) { | ||
| throw new PublicEvidenceValidationError("artifact_ref_missing", `record ${record.recordId} references a missing public artifact`); | ||
| } | ||
| } | ||
| } | ||
| return { records, artifacts, createdDayUtc: utcDay(input.createdDayUtc) }; | ||
| } | ||
|
|
||
| export function canonicalPublicEvidenceContent( | ||
| input: PublicEvidenceContentInput, | ||
| ): { canonical: boolean; normalized: PublicEvidenceContentInput } { | ||
| const normalized = normalizePublicEvidenceContent(input); | ||
| const canonical = input.records.length === normalized.records.length | ||
| && input.artifacts.length === normalized.artifacts.length | ||
| && input.records.every((record, index) => record.recordId === normalized.records[index]!.recordId) | ||
| && input.artifacts.every((artifact, index) => artifact.artifactId === normalized.artifacts[index]!.artifactId); | ||
| return { canonical, normalized }; | ||
| } | ||
|
|
||
| export function hasCanonicalPublicEvidenceOrder(input: PublicEvidenceContentInput): boolean { | ||
| return canonicalPublicEvidenceContent(input).canonical; | ||
| } | ||
|
|
||
| function buildFromNormalizedContent( | ||
| normalized: PublicEvidenceContentInput, | ||
| publisherInput: PublicPublisherV1, | ||
| ): PublicEvidenceBundleUnsignedV1 { | ||
| const publisher = validatePublisher(publisherInput); | ||
| const content = { | ||
| schemaVersion: PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION, | ||
| exportPolicyVersion: PUBLIC_EXPORT_POLICY_VERSION, | ||
| createdDayUtc: normalized.createdDayUtc, | ||
| publisher, | ||
| records: normalized.records, | ||
| artifacts: normalized.artifacts, | ||
| }; | ||
| const bundleId = publicEvidenceId("bundle", content); | ||
| const bundleDigest = publicEvidenceId("bundle_digest", { ...content, bundleId }); | ||
| const bundle: PublicEvidenceBundleUnsignedV1 = { ...content, bundleId, bundleDigest }; | ||
| if (new TextEncoder().encode(jcsStringify(bundle)).byteLength > MAX_PUBLIC_BUNDLE_BYTES) { | ||
| throw new PublicEvidenceValidationError("bundle_too_large", "public bundle exceeds 2 MiB"); | ||
| } | ||
| return bundle; | ||
| } | ||
|
|
||
| export function buildPublicEvidenceBundle(input: BuildPublicEvidenceBundleInput): PublicEvidenceBundleUnsignedV1 { | ||
| return buildFromNormalizedContent(normalizePublicEvidenceContent(input), input.publisher); | ||
| } | ||
|
|
||
| export function expectedPublicBundleIdentityFromNormalized( | ||
| normalized: PublicEvidenceContentInput, | ||
| publisher: PublicPublisherV1, | ||
| ): { bundleId: string; bundleDigest: string } { | ||
| const rebuilt = buildFromNormalizedContent(normalized, publisher); | ||
| return { bundleId: rebuilt.bundleId, bundleDigest: rebuilt.bundleDigest }; | ||
| } | ||
|
|
||
| export function expectedPublicBundleIdentity(bundle: PublicEvidenceBundleUnsignedV1): { bundleId: string; bundleDigest: string } { | ||
| return expectedPublicBundleIdentityFromNormalized( | ||
| normalizePublicEvidenceContent({ | ||
| records: bundle.records, | ||
| artifacts: bundle.artifacts, | ||
| createdDayUtc: bundle.createdDayUtc, | ||
| }), | ||
| bundle.publisher, | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.