feat(saas): enforce signed release evidence and verified deployments
This commit is contained in:
@@ -6,7 +6,7 @@ const { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { createHash, generateKeyPairSync } = require('node:crypto');
|
||||
const {
|
||||
startStable, verifyStableEvidence, verifyBootstrapSession, applyExactEnvironment,
|
||||
calculateCompiledArtifactDigest, candidateLockChecksum, fetchReleaseEvidence, sourceCommit
|
||||
@@ -33,7 +33,8 @@ function sessionFor(exact) {
|
||||
releaseId: Number(exact.RPC_RELEASE_ID), releaseVersion: exact.RPC_RELEASE_VERSION, serviceVersion: exact.RPC_SERVICE_VERSION,
|
||||
runtimeMode: exact.SAAS_RUNTIME_MODE, buildRevision: Number(exact.SAAS_BUILD_REVISION), buildChecksum: exact.SAAS_BUILD_CHECKSUM,
|
||||
schemaInputChecksum: exact.SAAS_SCHEMA_INPUT_CHECKSUM, implementationManifestChecksum: exact.RPC_IMPLEMENTATION_MANIFEST_CHECKSUM,
|
||||
artifactDigest: exact.RPC_ARTIFACT_DIGEST, artifactIdentityChecksum: exact.RPC_ARTIFACT_IDENTITY_CHECKSUM
|
||||
artifactDigest: exact.RPC_ARTIFACT_DIGEST, artifactIdentityChecksum: exact.RPC_ARTIFACT_IDENTITY_CHECKSUM,
|
||||
schemaChecksum: exact.RPC_SCHEMA_CHECKSUM, actionManifestChecksum: exact.RPC_ACTION_MANIFEST_CHECKSUM, runtimeContractChecksum: exact.RPC_RUNTIME_CONTRACT_CHECKSUM
|
||||
} };
|
||||
}
|
||||
|
||||
@@ -193,3 +194,56 @@ test('dirty Git checkouts cannot be presented as the frozen source commit', t =>
|
||||
writeFileSync(path.join(root, 'business.js'), 'modified');
|
||||
assert.throws(() => sourceCommit(root, {}), /dirty/);
|
||||
});
|
||||
|
||||
test('V2 frozen and Stable starts reuse signed executable and dependency bytes and reject tampering before generated code loads', async t => {
|
||||
const helper = require('../release-artifact');
|
||||
const parent = mkdtempSync(path.join(os.tmpdir(), 'stable-v2-test-'));
|
||||
t.after(() => rmSync(parent, { recursive:true, force:true }));
|
||||
const root = path.join(parent, 'deployment');
|
||||
for (const name of ['dist/saas/generated', 'scripts', 'node_modules/pkg']) mkdirSync(path.join(root,name), {recursive:true});
|
||||
const data = fixture();
|
||||
for (const name of ['bootstrap.js','release-environment.js','start-stable.js','start-frozen.js','release-artifact.js','dist/configuration.js','dist/saas/generated/index.js']) writeFileSync(path.join(root,name), 'module.exports = {};');
|
||||
writeFileSync(path.join(root,'.cool-saas-candidate.json'),JSON.stringify(data.lock));
|
||||
for (const name of ['.cool-saas-service.json','package.json','scripts/published-packages.json']) writeFileSync(path.join(root,name),'{}');
|
||||
writeFileSync(path.join(root,'pnpm-lock.yaml'),'lockfileVersion: 9\n');
|
||||
writeFileSync(path.join(root,'node_modules/pkg/index.js'),'exports.identity = "frozen";');
|
||||
const bundle = helper.createReleaseArtifactBundle(root);
|
||||
const inspected = helper.inspectReleaseArtifactBundle(bundle);
|
||||
const keys = generateKeyPairSync('ed25519');
|
||||
const privatePem = keys.privateKey.export({type:'pkcs8',format:'pem'}).toString();
|
||||
const publicKey = keys.publicKey.export({type:'spki',format:'pem'}).toString();
|
||||
const trustFile = path.join(parent,'trust.json');
|
||||
writeFileSync(trustFile,JSON.stringify({builders:{'builder-1':{publicKey,moduleIds:[2],repositories:['https://git.example.invalid/demo.git']}}}));
|
||||
const artifact = data.release.frozenArtifact;
|
||||
Object.assign(artifact,{releaseEvidenceVersion:2,sourceRepository:'https://git.example.invalid/demo.git',
|
||||
artifactDigest:inspected.artifactDigest,bundleDigest:inspected.bundleDigest,
|
||||
dependencyLockChecksum:createHash('sha256').update(inspected.files.get('pnpm-lock.yaml')).digest('hex')});
|
||||
const claims = {version:2,taskId:'build-task',taskNonce:'nonce',releaseId:41,moduleId:2,buildRevision:2,buildChecksum:data.lock.buildChecksum};
|
||||
for (const name of ['sourceRepository','sourceBranch','sourceCommit','tagName','generatedLockChecksum','implementationManifestChecksum','artifactDigest','bundleDigest','dependencyLockChecksum']) claims[name] = artifact[name];
|
||||
artifact.buildEvidence = helper.signReleaseEvidence('build','builder-1',claims,privatePem);
|
||||
data.release.releaseEvidenceVersion = 2;
|
||||
data.release.schemaSnapshot.actionManifest.artifactDigest = inspected.artifactDigest;
|
||||
const originalBytes = helper.releaseJsonChecksum(helper.createReleaseArtifactBundle(root));
|
||||
let starts = 0, loads = 0;
|
||||
async function launch(mode, edit) {
|
||||
const release = structuredClone(data.release);
|
||||
if (mode === 'frozen') Object.assign(release,{id:41,releaseType:'rc',candidateStatus:'promoted',targetVersion:'1.0.1',version:data.lock.candidateVersion,serviceVersion:data.lock.candidateVersion,promotedReleaseId:42});
|
||||
edit?.(release);
|
||||
const environment = {NODE_ENV:'local',RPC_RELEASE_ID:String(release.id),BUILD_SHA:data.commit,COOL_SAAS_TOKEN:'short-lived-test',SAAS_RELEASE_TRUST_FILE:trustFile};
|
||||
return startStable({projectDir:root,mode,environment,fetch:async url => ({ok:true,json:async()=>({code:1000,data:url.pathname.endsWith('/artifact-bundle') ? {bundle,...inspected,files:undefined,buildEvidence:artifact.buildEvidence} : release})}),
|
||||
loadGenerated:()=>{loads++;return data.generated;},start:({verifySession})=>{starts++;verifySession(sessionFor(environment));return 'started';}});
|
||||
}
|
||||
assert.equal(await launch('frozen'),'started');
|
||||
assert.equal(await launch('production'),'started');
|
||||
assert.equal(starts,2);
|
||||
assert.equal(helper.releaseJsonChecksum(helper.createReleaseArtifactBundle(root)),originalBytes);
|
||||
for (const name of ['bootstrap.js','node_modules/pkg/index.js','pnpm-lock.yaml']) {
|
||||
const file = path.join(root,name), bytes = readFileSync(file);
|
||||
writeFileSync(file,'tampered');
|
||||
await assert.rejects(launch('production'),/differs|differ/);
|
||||
writeFileSync(file,bytes);
|
||||
}
|
||||
await assert.rejects(launch('production',release=>{release.frozenArtifact.buildEvidence.signature='a'.repeat(86);}),/signature/);
|
||||
await assert.rejects(launch('production',release=>{release.frozenArtifact.sourceCommit='9'.repeat(40);}),/sourceCommit/);
|
||||
assert.equal(loads,2,'tampered bytes must fail before loading generated JavaScript');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user