release-macos-update-policy.test.mjs
147 lines 5.7 KB
Raw
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ breaking 24 days ago
1 import assert from 'node:assert/strict';
2 import crypto from 'node:crypto';
3 import fs from 'node:fs';
4 import os from 'node:os';
5 import path from 'node:path';
6 import { spawnSync } from 'node:child_process';
7 import test from 'node:test';
8
9 import {
10 evaluateUpdateManifest,
11 parseUpdateManifest,
12 persistUpdateFloor,
13 readPersistedUpdateFloor,
14 verifyUpdateSignature,
15 } from '../companion/runtime/update-verifier.mjs';
16
17 function manifest(overrides = {}) {
18 return {
19 schema: 1,
20 channel: 'stable',
21 platform: 'macos',
22 architecture: 'arm64',
23 keyID: 'release-key-1',
24 issuedAt: 1000,
25 expiresAt: 2000,
26 source: { minimumBuild: 100, maximumBuild: 100 },
27 target: {
28 version: '0.1.1',
29 build: 101,
30 packageURL: 'https://releases.knowtation.store/stable/Knowtation.pkg',
31 packageSHA256: 'a'.repeat(64),
32 packageBytes: 1024,
33 },
34 minimumAcceptedBuild: 100,
35 ...overrides,
36 };
37 }
38
39 const context = {
40 channel: 'stable',
41 platform: 'macos',
42 architecture: 'arm64',
43 keyID: 'release-key-1',
44 now: 1500,
45 currentBuild: 100,
46 persistedFloor: 100,
47 };
48
49 test('accepts a signed forward update and rejects a tampered signature', () => {
50 const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
51 const bytes = Buffer.from(JSON.stringify(manifest()));
52 const signature = crypto.sign(null, bytes, privateKey);
53 assert.equal(verifyUpdateSignature({ manifestBytes: bytes, signature, publicKey }), true);
54 assert.equal(verifyUpdateSignature({ manifestBytes: Buffer.from(`${bytes} `), signature, publicKey }), false);
55 assert.equal(evaluateUpdateManifest(parseUpdateManifest(bytes), context).build, 101);
56 });
57
58 test('rejects rollback, replay, source substitution, stale, wrong-key, and wrong-platform manifests', () => {
59 const cases = [
60 [manifest({ target: { ...manifest().target, build: 99 } }), 'update_target_not_newer'],
61 [manifest({ target: { ...manifest().target, build: 100 } }), 'update_target_not_newer'],
62 [manifest({ source: { minimumBuild: 99, maximumBuild: 99 } }), 'update_source_mismatch'],
63 [manifest({ expiresAt: 1400 }), 'update_expired'],
64 [manifest({ keyID: 'substituted-key' }), 'update_key_mismatch'],
65 [manifest({ platform: 'linux' }), 'update_platform_mismatch'],
66 [manifest({ architecture: 'x64' }), 'update_architecture_mismatch'],
67 [manifest({ minimumAcceptedBuild: 102 }), 'update_rollback_rejected'],
68 ];
69 for (const [candidate, expected] of cases) {
70 assert.throws(() => evaluateUpdateManifest(candidate, context), { code: expected });
71 }
72 });
73
74 test('persists a monotonic rollback floor through custody', async () => {
75 const store = new Map();
76 const custody = {
77 get: async (key) => store.get(key) ?? null,
78 set: async (key, value) => { store.set(key, value); },
79 };
80 assert.equal(await readPersistedUpdateFloor(custody), 0);
81 await persistUpdateFloor(custody, 101, 100);
82 assert.equal(await readPersistedUpdateFloor(custody), 101);
83 await assert.rejects(() => persistUpdateFloor(custody, 100, 101), { code: 'update_rollback_rejected' });
84 });
85
86 test('release verifier binds signature, public trust, source revision, and package bytes', () => {
87 const root = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-update-release-'));
88 try {
89 const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
90 const packageFile = path.join(root, 'Knowtation-0.1.0-100-macos-arm64.pkg');
91 const packageBytes = Buffer.from('signed package fixture');
92 fs.writeFileSync(packageFile, packageBytes);
93 const candidate = manifest({
94 target: {
95 version: '0.1.0',
96 build: 100,
97 packageURL: 'https://releases.knowtation.store/stable/Knowtation-0.1.0-100-macos-arm64.pkg',
98 packageSHA256: crypto.createHash('sha256').update(packageBytes).digest('hex'),
99 packageBytes: packageBytes.length,
100 },
101 sourceRevision: {
102 status: 'committed',
103 muse: `sha256:${'a'.repeat(64)}`,
104 github: 'b'.repeat(40),
105 },
106 artifacts: [{
107 path: path.basename(packageFile),
108 sha256: crypto.createHash('sha256').update(packageBytes).digest('hex'),
109 bytes: packageBytes.length,
110 executable: false,
111 }],
112 });
113 const manifestFile = path.join(root, 'release-manifest.json');
114 const signatureFile = path.join(root, 'release-manifest.sig');
115 const publicFile = path.join(root, 'public.pem');
116 const trustFile = path.join(root, 'update-trust.json');
117 const manifestBytes = Buffer.from(JSON.stringify(candidate));
118 fs.writeFileSync(manifestFile, manifestBytes);
119 fs.writeFileSync(signatureFile, crypto.sign(null, manifestBytes, privateKey));
120 fs.writeFileSync(publicFile, publicKey.export({ type: 'spki', format: 'pem' }));
121 fs.writeFileSync(trustFile, JSON.stringify({
122 schema: 1,
123 algorithm: 'Ed25519',
124 activeKey: {
125 keyID: 'release-key-1',
126 publicKey: Buffer.from(publicKey.export({ format: 'jwk' }).x, 'base64url').toString('base64'),
127 },
128 revokedKeyIDs: [],
129 minimumAcceptedBuild: 100,
130 }));
131 const args = [
132 'scripts/release/macos/verify-update-manifest.mjs',
133 '--manifest', manifestFile,
134 '--signature', signatureFile,
135 '--public-key-file', publicFile,
136 '--trust', trustFile,
137 '--package', packageFile,
138 ];
139 assert.equal(spawnSync(process.execPath, args, { encoding: 'utf8' }).status, 0);
140 fs.writeFileSync(packageFile, 'substituted package');
141 const substituted = spawnSync(process.execPath, args, { encoding: 'utf8' });
142 assert.notEqual(substituted.status, 0);
143 assert.match(substituted.stderr, /package does not match signed manifest|artifact does not match/);
144 } finally {
145 fs.rmSync(root, { recursive: true, force: true });
146 }
147 });
File History 1 commit
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ 24 days ago