diff --git a/.github/workflows/android-engine.yml b/.github/workflows/android-engine.yml index 7e9c267..2bf2cdb 100644 --- a/.github/workflows/android-engine.yml +++ b/.github/workflows/android-engine.yml @@ -16,6 +16,10 @@ on: - '.github/workflows/android-engine.yml' workflow_dispatch: inputs: + verify_live: + description: 'Read GitHub API and verify the recorded Cromite release facts' + type: boolean + default: false real_build: description: 'Run the REAL Chromium build (needs a large/self-hosted Linux runner)' type: boolean @@ -67,6 +71,10 @@ jobs: node scripts/audit-candidate.mjs --mode record --json | tee "$report" jq -e '.status == "ready" or .status == "blocked"' "$report" > /dev/null + - name: Live-verify recorded Cromite release facts + if: github.event_name == 'workflow_dispatch' && inputs.verify_live + run: node scripts/audit-candidate.mjs --mode record --live --explain + - name: Android engine checks working-directory: . run: | diff --git a/apps/android-engine/README.md b/apps/android-engine/README.md index 80c590e..cd483ed 100644 --- a/apps/android-engine/README.md +++ b/apps/android-engine/README.md @@ -20,6 +20,8 @@ path toward an Ungoogled Chromium engine and optional bundled Tor, built as an - ✅ A pinned Cromite candidate snapshot and offline adoption gate record license, release-lag, freshness, and extension-support decisions without treating external attestations as a verified build. +- ✅ An opt-in, read-only GitHub verification mode for the pinned release tag, + commit, LICENSE blob, extension patch blob, and release freshness. - ⬜ Patch bodies (`chromium/patches/tronbrowser-android/*.patch`), real Android branding assets, and pinned/checksummed Tor artifacts. - ⬜ Current Chromium security pin and first real compile (Linux x64, at least @@ -35,6 +37,7 @@ cd apps/android-engine/chromium node scripts/preflight.mjs --mode scaffold # Validate the recorded source snapshot without claiming it is adoptable. node scripts/audit-candidate.mjs --mode record +node scripts/audit-candidate.mjs --mode record --live --explain node scripts/audit-candidate.mjs --mode adopt # fails while decisions are open # Checkout mode checks the host and reports release blockers before downloading. node scripts/preflight.mjs --mode checkout diff --git a/apps/android-engine/chromium/README.md b/apps/android-engine/chromium/README.md index 13527f6..572ce2d 100644 --- a/apps/android-engine/chromium/README.md +++ b/apps/android-engine/chromium/README.md @@ -31,6 +31,7 @@ node scripts/preflight.mjs --mode scaffold node scripts/preflight.mjs --mode checkout node scripts/preflight.mjs --mode release node scripts/audit-candidate.mjs --mode record +node scripts/audit-candidate.mjs --mode record --live --explain node scripts/audit-candidate.mjs --mode adopt ``` @@ -43,15 +44,23 @@ patches, branding assets, or pinned Tor integration are unapproved or missing. Candidate `record` mode validates the pinned downstream snapshot and reports licensing, release-lag, freshness, security-SLA, and extension-support blockers without failing CI merely because a product decision remains open. `adopt` mode -fails closed until every blocker is resolved. Both modes are offline: repository -tags, commits, releases, and patch metadata are recorded attestations, not live -network verification, and must be refreshed from primary upstream sources before -an adoption decision. A stale or malformed attestation fails both modes. +fails closed until every blocker is resolved. Both modes are offline by default: +repository tags, commits, releases, and patch metadata remain recorded +attestations unless `--live` is explicitly supplied. Live mode reads only the +canonical GitHub API, resolves lightweight or annotated release tags, and checks +the recorded commit and release timestamp, the LICENSE and extension patch blobs +read at that resolved release commit, and locally computed release age. It never +changes the record. `--explain` prints +the exact recorded and observed values when they differ. A stale, malformed, or +live-mismatched attestation fails closed. Refresh the record at least every `policy.maximumRecordAgeDays` (currently 30): re-check the candidate tag, commit, version, and release date; the recorded stable -version and source; and the extension patch URL, blob SHA, reviewer, and date. +version and source; the license blob SHA; and the extension patch URL, blob SHA, +reviewer, and date. -Use `--json` for machine-readable output. `--as-of YYYY-MM-DD` is available only +Use `--json` for machine-readable output. The manual Android-engine workflow can +run the same read-only check with its `verify_live` input; push and pull-request +workflows never opt into network verification. `--as-of YYYY-MM-DD` is available only in `record` mode for reproducing a historical snapshot; `adopt` always evaluates against the current UTC date. The audit exits with status 0 for a valid record (including unresolved product blockers in `record` mode), 1 for invalid evidence diff --git a/apps/android-engine/chromium/config/cromite-candidate.json b/apps/android-engine/chromium/config/cromite-candidate.json index f7808d4..9a23f5c 100644 --- a/apps/android-engine/chromium/config/cromite-candidate.json +++ b/apps/android-engine/chromium/config/cromite-candidate.json @@ -26,6 +26,7 @@ "license": { "spdxId": "GPL-3.0", "sourceUrl": "https://github.com/uazo/cromite/blob/cdf415cc86c8aa17faa26edf51e12f2fd49a274f/LICENSE", + "blobSha": "f288702d2fa16d3cdf0035b15a9fcbc552cd88e7", "decision": "pending", "decidedBy": null, "decidedOn": null diff --git a/apps/android-engine/chromium/scripts/audit-candidate.mjs b/apps/android-engine/chromium/scripts/audit-candidate.mjs index c3d23e9..5845c4b 100644 --- a/apps/android-engine/chromium/scripts/audit-candidate.mjs +++ b/apps/android-engine/chromium/scripts/audit-candidate.mjs @@ -7,6 +7,9 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const DEFAULT_MANIFEST = resolve(SCRIPT_DIR, '..', 'config', 'cromite-candidate.json'); const DAY_MS = 24 * 60 * 60 * 1000; +const GITHUB_API_ROOT = 'https://api.github.com'; +const MAX_TAG_DEPTH = 8; +const FULL_SHA = /^[a-f0-9]{40}$/i; const LICENSE_DECISIONS = new Set(['pending', 'accepted', 'rejected']); const CROMITE_REPOSITORY = 'https://github.com/uazo/cromite'; const CROMITE_REPOSITORY_PATH = '/uazo/cromite'; @@ -176,6 +179,225 @@ function ageInDays(later, earlier) { return Math.floor((utcDay(later) - utcDay(earlier)) / DAY_MS); } +function encodeRepositoryPath(path) { + return path + .split('/') + .map((part) => encodeURIComponent(part)) + .join('/'); +} + +async function fetchGitHubJson(fetchImpl, path, label) { + let response; + try { + response = await fetchImpl(`${GITHUB_API_ROOT}${path}`, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'tronbrowser-cromite-audit', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + } catch (error) { + throw new Error(`${label}: request failed: ${error?.message ?? String(error)}`); + } + + if (!response || response.ok !== true) { + const status = Number.isInteger(response?.status) ? ` (${response.status})` : ''; + throw new Error(`${label}: GitHub API request failed${status}`); + } + + let payload; + try { + payload = await response.json(); + } catch (error) { + throw new Error(`${label}: invalid JSON response: ${error?.message ?? String(error)}`); + } + if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error(`${label}: GitHub API response must be an object`); + } + return payload; +} + +async function resolveTagCommit(fetchImpl, tag) { + const ref = await fetchGitHubJson( + fetchImpl, + `/repos/uazo/cromite/git/ref/tags/${encodeURIComponent(tag)}`, + `tag ref ${tag}`, + ); + let target = ref.object; + const visited = new Set(); + + for (let depth = 0; depth <= MAX_TAG_DEPTH; depth += 1) { + if ( + target === null || + typeof target !== 'object' || + !FULL_SHA.test(target.sha ?? '') + ) { + throw new Error(`tag ref ${tag}: invalid target object`); + } + if (target.type === 'commit') return target.sha; + if (target.type !== 'tag') { + throw new Error(`tag ref ${tag}: unsupported target type ${target.type ?? 'missing'}`); + } + if (depth === MAX_TAG_DEPTH) { + throw new Error(`tag ref ${tag}: annotated tag depth exceeds ${MAX_TAG_DEPTH}`); + } + if (visited.has(target.sha)) { + throw new Error(`tag ref ${tag}: annotated tag cycle detected`); + } + visited.add(target.sha); + const annotated = await fetchGitHubJson( + fetchImpl, + `/repos/uazo/cromite/git/tags/${target.sha}`, + `annotated tag ${target.sha}`, + ); + target = annotated.object; + } + + throw new Error(`tag ref ${tag}: could not resolve a commit`); +} + +function addDifference(differences, field, recorded, observed, matches) { + if (!matches) differences.push({ field, recorded, observed }); +} + +export async function verifyLiveCandidate( + manifest, + { + asOf = new Date().toISOString().slice(0, 10), + fetchImpl = globalThis.fetch, + } = {}, +) { + const errors = []; + const differences = []; + const observed = { + tag: null, + commitSha: null, + publishedAt: null, + licenseBlobSha: null, + patchBlobSha: null, + releaseAgeDays: null, + }; + const auditDate = parseDate(asOf, 'asOf', errors, { dateOnly: true }); + + if (typeof fetchImpl !== 'function') { + errors.push('live verification requires a fetch implementation'); + } + if (!isSafePatchPath(manifest?.extensionSupport?.patchPath)) { + errors.push('live verification requires a safe extension patch path'); + } + if (errors.length > 0) { + return { status: 'failed', asOf, errors, differences, observed }; + } + + try { + const release = await fetchGitHubJson( + fetchImpl, + '/repos/uazo/cromite/releases/latest', + 'latest release', + ); + if (typeof release.tag_name !== 'string' || !release.tag_name) { + throw new Error('latest release: tag_name is missing'); + } + const publishedAt = parseDate( + release.published_at, + 'latest release published_at', + errors, + ); + if (!publishedAt) { + return { status: 'failed', asOf, errors, differences, observed }; + } + + observed.tag = release.tag_name; + observed.publishedAt = release.published_at; + observed.commitSha = await resolveTagCommit(fetchImpl, observed.tag); + + const license = await fetchGitHubJson( + fetchImpl, + `/repos/uazo/cromite/contents/LICENSE?ref=${encodeURIComponent(observed.commitSha)}`, + 'LICENSE blob', + ); + if (license.type !== 'file' || !FULL_SHA.test(license.sha ?? '')) { + throw new Error('LICENSE blob: response does not describe a pinned file blob'); + } + observed.licenseBlobSha = license.sha; + + const patchPath = encodeRepositoryPath(manifest.extensionSupport.patchPath); + const patch = await fetchGitHubJson( + fetchImpl, + `/repos/uazo/cromite/contents/${patchPath}?ref=${encodeURIComponent(observed.commitSha)}`, + 'extension patch blob', + ); + if (patch.type !== 'file' || !FULL_SHA.test(patch.sha ?? '')) { + throw new Error( + 'extension patch blob: response does not describe a pinned file blob', + ); + } + observed.patchBlobSha = patch.sha; + observed.releaseAgeDays = ageInDays(auditDate, publishedAt); + + const recordedPublishedAt = parseDate( + manifest?.candidate?.publishedAt, + 'candidate.publishedAt', + errors, + ); + addDifference( + differences, + 'candidate.tag', + manifest?.candidate?.tag ?? null, + observed.tag, + manifest?.candidate?.tag === observed.tag, + ); + addDifference( + differences, + 'candidate.commitSha', + manifest?.candidate?.commitSha ?? null, + observed.commitSha, + manifest?.candidate?.commitSha === observed.commitSha, + ); + addDifference( + differences, + 'candidate.publishedAt', + manifest?.candidate?.publishedAt ?? null, + observed.publishedAt, + recordedPublishedAt?.getTime() === publishedAt.getTime(), + ); + addDifference( + differences, + 'license.blobSha', + manifest?.license?.blobSha ?? null, + observed.licenseBlobSha, + manifest?.license?.blobSha === observed.licenseBlobSha, + ); + addDifference( + differences, + 'extensionSupport.patchBlobSha', + manifest?.extensionSupport?.patchBlobSha ?? null, + observed.patchBlobSha, + manifest?.extensionSupport?.patchBlobSha === observed.patchBlobSha, + ); + const recordedReleaseAgeDays = recordedPublishedAt + ? ageInDays(auditDate, recordedPublishedAt) + : null; + addDifference( + differences, + 'metrics.releaseAgeDays', + recordedReleaseAgeDays, + observed.releaseAgeDays, + recordedReleaseAgeDays === observed.releaseAgeDays, + ); + } catch (error) { + errors.push(error?.message ?? String(error)); + } + + return { + status: errors.length === 0 && differences.length === 0 ? 'verified' : 'failed', + asOf, + errors, + differences, + observed, + }; +} + export function inspectCandidate( manifestPath = DEFAULT_MANIFEST, { asOf = new Date().toISOString().slice(0, 10) } = {}, @@ -297,6 +519,9 @@ export function inspectCandidate( 'license.spdxId must be GPL-3.0, GPL-3.0-only, or GPL-3.0-or-later', ); } + if (!FULL_SHA.test(license.blobSha ?? '')) { + errors.push('license.blobSha must be a full 40-character blob SHA'); + } let decidedOn = null; if (!LICENSE_DECISIONS.has(license.decision)) { errors.push('license.decision must be pending, accepted, or rejected'); @@ -342,7 +567,7 @@ export function inspectCandidate( ) { errors.push('extensionSupport.patchUrl must pin patchPath at candidate.tag'); } - if (!/^[a-f0-9]{40}$/i.test(extension.patchBlobSha ?? '')) { + if (!FULL_SHA.test(extension.patchBlobSha ?? '')) { errors.push('extensionSupport.patchBlobSha must be a full 40-character blob SHA'); } if (typeof extension.attestedBy !== 'string' || !extension.attestedBy.trim()) { @@ -431,6 +656,8 @@ function parseArgs(args, defaultManifestPath, defaultAsOf) { let asOf = defaultAsOf; let asOfProvided = false; let json = false; + let live = false; + let explain = false; const nextValue = (index, name) => { const value = args[index + 1]; @@ -441,6 +668,8 @@ function parseArgs(args, defaultManifestPath, defaultAsOf) { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === '--json') json = true; + else if (arg === '--live') live = true; + else if (arg === '--explain') explain = true; else if (arg === '--mode') mode = nextValue(index++, '--mode'); else if (arg.startsWith('--mode=')) mode = arg.slice('--mode='.length); else if (arg === '--manifest') manifestPath = nextValue(index++, '--manifest'); @@ -463,20 +692,29 @@ function parseArgs(args, defaultManifestPath, defaultAsOf) { if (mode === 'adopt' && asOfProvided) { throw new Error('--as-of is only available in record mode'); } - return { mode, manifestPath, asOf, json }; + if (explain && !live) { + throw new Error('--explain requires --live'); + } + return { mode, manifestPath, asOf, json, live, explain }; } -function reportFor(result, mode) { +function reportFor(result, mode, liveVerification = null) { return { status: result.errors.length > 0 ? 'invalid' + : liveVerification && liveVerification.status !== 'verified' + ? 'mismatch' : result.blockers.length > 0 ? 'blocked' : 'ready', mode, asOf: result.asOf, - provenance: result.provenance, + provenance: liveVerification + ? liveVerification.status === 'verified' + ? 'live-verified' + : 'live-verification-failed' + : result.provenance, candidate: result.manifest.candidate ?? null, metrics: result.metrics, policy: result.manifest.policy ?? null, @@ -484,14 +722,60 @@ function reportFor(result, mode) { extensionSupport: result.manifest.extensionSupport ?? null, errors: result.errors, blockers: result.blockers, + liveVerification, }; } +function writeHumanReport(result, options, liveVerification, writeOut, writeError) { + writeOut(`Audit date (UTC): ${result.asOf}.`); + if (result.errors.length > 0) { + writeError('Cromite candidate record is invalid:'); + for (const error of result.errors) writeError(` - ${error}`); + return; + } + + const candidate = result.manifest.candidate; + writeOut( + `Cromite candidate record is valid: ${candidate.tag} @ ${candidate.commitSha}.`, + ); + if (liveVerification) { + if (liveVerification.status === 'verified') { + writeOut('Live GitHub verification matched every recorded upstream fact.'); + } else { + writeError('Live GitHub verification failed:'); + for (const error of liveVerification.errors) writeError(` - ${error}`); + if (options.explain) { + for (const difference of liveVerification.differences) { + writeError( + ` - ${difference.field}: recorded=${JSON.stringify(difference.recorded)} observed=${JSON.stringify(difference.observed)}`, + ); + } + } else if (liveVerification.differences.length > 0) { + writeError( + ` - ${liveVerification.differences.length} recorded value(s) differ; rerun with --explain`, + ); + } + } + } else { + writeOut('Upstream facts are attested, not verified by this offline audit.'); + } + writeOut( + `Recorded lag: ${result.metrics.chromiumMajorLag} Chromium majors; release age: ${result.metrics.releaseAgeDays} days.`, + ); + if (result.blockers.length > 0) { + writeOut(`Adoption blockers (${result.blockers.length}):`); + for (const blocker of result.blockers) writeOut(` - ${blocker}`); + } else { + writeOut('No adoption blockers found.'); + } +} + export function runCli( args = process.argv.slice(2), { defaultManifestPath = DEFAULT_MANIFEST, defaultAsOf = new Date().toISOString().slice(0, 10), + fetchImpl = globalThis.fetch, writeOut = (line) => console.log(line), writeError = (line) => console.error(line), } = {}, @@ -510,41 +794,35 @@ export function runCli( } const result = inspectCandidate(options.manifestPath, { asOf: options.asOf }); - const report = reportFor(result, options.mode); - if (options.json) { - writeOut(JSON.stringify(report, null, 2)); - } else { - writeOut(`Audit date (UTC): ${result.asOf}.`); - if (result.errors.length > 0) { - writeError('Cromite candidate record is invalid:'); - for (const error of result.errors) writeError(` - ${error}`); + const finish = (liveVerification = null) => { + const report = reportFor(result, options.mode, liveVerification); + if (options.json) { + writeOut(JSON.stringify(report, null, 2)); } else { - const candidate = result.manifest.candidate; - writeOut( - `Cromite candidate record is valid: ${candidate.tag} @ ${candidate.commitSha}.`, - ); - writeOut('Upstream facts are attested, not verified by this offline audit.'); - writeOut( - `Recorded lag: ${result.metrics.chromiumMajorLag} Chromium majors; release age: ${result.metrics.releaseAgeDays} days.`, - ); - if (result.blockers.length > 0) { - writeOut(`Adoption blockers (${result.blockers.length}):`); - for (const blocker of result.blockers) writeOut(` - ${blocker}`); - } else { - writeOut('No adoption blockers found.'); - } + writeHumanReport(result, options, liveVerification, writeOut, writeError); } - } - if (result.errors.length > 0) return 1; - if (options.mode === 'adopt' && result.blockers.length > 0) { - if (!options.json) { - writeError('candidate audit: refusing adoption until blockers are resolved'); + if (result.errors.length > 0) return 1; + if (liveVerification && liveVerification.status !== 'verified') return 1; + if (options.mode === 'adopt' && result.blockers.length > 0) { + if (!options.json) { + writeError('candidate audit: refusing adoption until blockers are resolved'); + } + return 1; } - return 1; - } - return 0; + return 0; + }; + + if (!options.live || result.errors.length > 0) return finish(); + return verifyLiveCandidate(result.manifest, { + asOf: options.asOf, + fetchImpl, + }).then(finish); } const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ''; -if (import.meta.url === invokedPath) process.exitCode = runCli(); +if (import.meta.url === invokedPath) { + Promise.resolve(runCli()).then((code) => { + process.exitCode = code; + }); +} diff --git a/apps/android-engine/chromium/scripts/audit-candidate.test.mjs b/apps/android-engine/chromium/scripts/audit-candidate.test.mjs index fc7559e..2f81a5e 100644 --- a/apps/android-engine/chromium/scripts/audit-candidate.test.mjs +++ b/apps/android-engine/chromium/scripts/audit-candidate.test.mjs @@ -1,10 +1,14 @@ import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, test } from 'vitest'; -import { inspectCandidate, runCli } from './audit-candidate.mjs'; +import { + inspectCandidate, + runCli, + verifyLiveCandidate, +} from './audit-candidate.mjs'; const fixtures = []; afterEach(() => { @@ -16,8 +20,54 @@ afterEach(() => { const COMMIT_SHA = 'a'.repeat(40); const BUILD_IDENTIFIER = 'c'.repeat(40); const PATCH_BLOB_SHA = 'b'.repeat(40); +const LICENSE_BLOB_SHA = 'd'.repeat(40); const CURRENT_VERSION = '151.0.7922.71'; const DEFAULT_PATCH_PATH = 'build/patches/extensions.patch'; +const TAG_OBJECT_SHA = 'e'.repeat(40); + +function readManifest(path) { + return JSON.parse(readFileSync(path, 'utf8')); +} + +function githubFixture({ annotated = false, cycle = false } = {}) { + const requests = []; + const fetchImpl = async (input) => { + const url = new URL(input); + requests.push(`${url.pathname}${url.search}`); + let body; + + if (url.pathname.endsWith('/releases/latest')) { + body = { + tag_name: `v${CURRENT_VERSION}-${BUILD_IDENTIFIER}`, + published_at: '2026-08-10T00:00:00Z', + }; + } else if (url.pathname.includes('/git/ref/tags/')) { + body = { + object: annotated + ? { type: 'tag', sha: TAG_OBJECT_SHA } + : { type: 'commit', sha: COMMIT_SHA }, + }; + } else if (url.pathname.includes('/git/tags/')) { + body = { + object: cycle + ? { type: 'tag', sha: TAG_OBJECT_SHA } + : { type: 'commit', sha: COMMIT_SHA }, + }; + } else if (url.pathname.endsWith('/contents/LICENSE')) { + body = { type: 'file', sha: LICENSE_BLOB_SHA }; + } else if (url.pathname.includes('/contents/build/patches/extensions.patch')) { + body = { type: 'file', sha: PATCH_BLOB_SHA }; + } else { + return new Response('{}', { status: 404 }); + } + + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + return { fetchImpl, requests }; +} function makeManifest(overrides = {}) { const candidate = { @@ -54,6 +104,7 @@ function makeManifest(overrides = {}) { license: { spdxId: 'GPL-3.0', sourceUrl: `https://github.com/uazo/cromite/blob/${candidate.commitSha}/LICENSE`, + blobSha: LICENSE_BLOB_SHA, decision: 'accepted', decidedBy: 'maintainer', decidedOn: '2026-08-11', @@ -153,6 +204,7 @@ test('rejects unsafe or unverifiable provenance fields', () => { license: { spdxId: 'MIT', sourceUrl: 'https://attacker.example/LICENSE', + blobSha: 'short', }, extensionSupport: { patchPath: '../extension.txt', @@ -181,6 +233,7 @@ test('rejects unsafe or unverifiable provenance fields', () => { ), ); assert(result.errors.some((item) => item.includes('spdxId'))); + assert(result.errors.some((item) => item.includes('license.blobSha'))); assert(result.errors.some((item) => item.includes('patchBlobSha'))); assert(result.errors.some((item) => item.includes('attestedBy'))); }); @@ -460,3 +513,140 @@ test('invalid CLI arguments return usage status 2 as JSON', () => { assert.equal(report.status, 'invalid'); assert(report.errors[0].includes('record or adopt')); }); + +test('live verification matches a lightweight release tag and pinned blobs', async () => { + const manifest = readManifest(makeManifest()); + const fixture = githubFixture(); + + const result = await verifyLiveCandidate(manifest, { + asOf: '2026-08-17', + fetchImpl: fixture.fetchImpl, + }); + + assert.equal(result.status, 'verified'); + assert.deepEqual(result.errors, []); + assert.deepEqual(result.differences, []); + assert.deepEqual(result.observed, { + tag: `v${CURRENT_VERSION}-${BUILD_IDENTIFIER}`, + commitSha: COMMIT_SHA, + publishedAt: '2026-08-10T00:00:00Z', + licenseBlobSha: LICENSE_BLOB_SHA, + patchBlobSha: PATCH_BLOB_SHA, + releaseAgeDays: 7, + }); + assert(fixture.requests.some((request) => request.includes('/git/ref/tags/'))); + assert(!fixture.requests.some((request) => request.includes('/git/tags/'))); + assert( + fixture.requests.includes( + `/repos/uazo/cromite/contents/LICENSE?ref=${COMMIT_SHA}`, + ), + ); + assert( + fixture.requests.includes( + `/repos/uazo/cromite/contents/${DEFAULT_PATCH_PATH}?ref=${COMMIT_SHA}`, + ), + ); +}); + +test('live verification dereferences annotated tags before reading blobs', async () => { + const fixture = githubFixture({ annotated: true }); + + const result = await verifyLiveCandidate(readManifest(makeManifest()), { + asOf: '2026-08-17', + fetchImpl: fixture.fetchImpl, + }); + + assert.equal(result.status, 'verified'); + assert( + fixture.requests.includes(`/repos/uazo/cromite/git/tags/${TAG_OBJECT_SHA}`), + ); +}); + +test('live verification fails closed on an annotated tag cycle', async () => { + const fixture = githubFixture({ annotated: true, cycle: true }); + + const result = await verifyLiveCandidate(readManifest(makeManifest()), { + asOf: '2026-08-17', + fetchImpl: fixture.fetchImpl, + }); + + assert.equal(result.status, 'failed'); + assert(result.errors.some((error) => error.includes('cycle detected'))); +}); + +test('live CLI explains exact recorded and observed differences', async () => { + const path = makeManifest({ candidate: { commitSha: 'f'.repeat(40) } }); + const manifest = readManifest(path); + manifest.license.sourceUrl = + 'https://github.com/uazo/cromite/blob/ffffffffffffffffffffffffffffffffffffffff/LICENSE'; + writeFileSync(path, JSON.stringify(manifest)); + const fixture = githubFixture(); + const output = []; + const errors = []; + + const code = await runCli(['--live', '--explain'], { + defaultManifestPath: path, + defaultAsOf: '2026-08-17', + fetchImpl: fixture.fetchImpl, + writeOut: (line) => output.push(line), + writeError: (line) => errors.push(line), + }); + + assert.equal(code, 1); + assert( + errors.includes( + ` - candidate.commitSha: recorded=${JSON.stringify('f'.repeat(40))} observed=${JSON.stringify(COMMIT_SHA)}`, + ), + ); + assert(output.includes('Audit date (UTC): 2026-08-17.')); +}); + +test('offline CLI remains the default and never calls fetch', () => { + const path = makeManifest(); + let fetchCalls = 0; + + const code = runCli(['--json'], { + defaultManifestPath: path, + defaultAsOf: '2026-08-17', + fetchImpl: async () => { + fetchCalls += 1; + throw new Error('offline mode must not fetch'); + }, + writeOut: () => {}, + writeError: () => {}, + }); + + assert.equal(code, 0); + assert.equal(fetchCalls, 0); +}); + +test('live CLI emits structured failure for an unavailable GitHub API', async () => { + const output = []; + + const code = await runCli(['--live', '--json'], { + defaultManifestPath: makeManifest(), + defaultAsOf: '2026-08-17', + fetchImpl: async () => { + throw new Error('offline'); + }, + writeOut: (line) => output.push(line), + writeError: () => {}, + }); + + assert.equal(code, 1); + const report = JSON.parse(output[0]); + assert.equal(report.status, 'mismatch'); + assert.equal(report.provenance, 'live-verification-failed'); + assert(report.liveVerification.errors[0].includes('request failed: offline')); +}); + +test('--explain is rejected unless live verification is enabled', () => { + const output = []; + const code = runCli(['--explain', '--json'], { + writeOut: (line) => output.push(line), + writeError: () => {}, + }); + + assert.equal(code, 2); + assert.equal(JSON.parse(output[0]).errors[0], '--explain requires --live'); +}); diff --git a/docs/adr/0002-android-engine-source-strategy.md b/docs/adr/0002-android-engine-source-strategy.md index e193cdc..1216f00 100644 --- a/docs/adr/0002-android-engine-source-strategy.md +++ b/docs/adr/0002-android-engine-source-strategy.md @@ -93,8 +93,10 @@ extension requirement are undecided, the candidate is three majors behind the recorded stable major and its release is 88 UTC calendar days old, and no emergency security-update SLA has been accepted. -This audit is offline. Its upstream facts are attestations, not network or build -verification, and they must be refreshed from primary sources before adoption. +This audit is offline by default; its upstream facts are attestations, not build +verification. An opt-in `--live` mode performs read-only GitHub API verification +of the recorded release tag, commit, LICENSE blob, and extension patch blob. +Records must still be refreshed from primary sources before adoption. No Cromite or other GPL source is copied into this repository by this milestone. ## Build-readiness requirements diff --git a/docs/mobile-architecture.md b/docs/mobile-architecture.md index 77e17b1..0b2f6b7 100644 --- a/docs/mobile-architecture.md +++ b/docs/mobile-architecture.md @@ -95,10 +95,11 @@ Landed (scaffold, CI-validated): patch/assets configuration. - **Source recommendation**: [ADR 0002](adr/0002-android-engine-source-strategy.md) documents the conditional custom-overlay versus maintained-downstream choice. -- **Candidate gate**: a pinned Cromite snapshot is checked offline for evidence - freshness, release lag, licensing, security SLA, and extension requirements. - CI records the unresolved blockers; adoption fails closed until maintainers - explicitly resolve them. +- **Candidate gate**: a pinned Cromite snapshot is checked offline by default for + evidence freshness, release lag, licensing, security SLA, and extension + requirements. A manual workflow can opt into read-only GitHub verification of + the recorded release facts. CI records the unresolved blockers; adoption fails + closed until maintainers explicitly resolve them. Still open: update the historical Chromium pin; fill the Android patch bodies; add real branding and pinned Tor assets; first real compile on a runner with at