update-verifier.mjs
81 lines 3.3 KB
Raw
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ breaking 25 days ago
1 import crypto from 'node:crypto';
2
3 const HEX_256 = /^[0-9a-f]{64}$/;
4
5 function reject(reason) {
6 const error = new Error(reason);
7 error.code = reason;
8 throw error;
9 }
10
11 export function verifyUpdateSignature({ manifestBytes, signature, publicKey }) {
12 if (!Buffer.isBuffer(manifestBytes) || !Buffer.isBuffer(signature)) {
13 return false;
14 }
15 try {
16 return crypto.verify(null, manifestBytes, publicKey, signature);
17 } catch {
18 return false;
19 }
20 }
21
22 export function parseUpdateManifest(manifestBytes) {
23 if (!Buffer.isBuffer(manifestBytes) || manifestBytes.length === 0 || manifestBytes.length > 131_072) {
24 reject('update_manifest_malformed');
25 }
26 let value;
27 try {
28 value = JSON.parse(manifestBytes.toString('utf8'));
29 } catch {
30 reject('update_manifest_malformed');
31 }
32 if (!value || typeof value !== 'object' || Array.isArray(value)) {
33 reject('update_manifest_malformed');
34 }
35 return value;
36 }
37
38 export function evaluateUpdateManifest(manifest, context) {
39 if (manifest?.schema !== 1) reject('update_schema_unsupported');
40 if (manifest.channel !== context.channel) reject('update_channel_mismatch');
41 if (manifest.platform !== context.platform) reject('update_platform_mismatch');
42 if (manifest.architecture !== context.architecture) reject('update_architecture_mismatch');
43 if (manifest.keyID !== context.keyID) reject('update_key_mismatch');
44 if (!Number.isSafeInteger(manifest.issuedAt) || manifest.issuedAt > context.now) reject('update_not_yet_valid');
45 if (!Number.isSafeInteger(manifest.expiresAt) || manifest.expiresAt < context.now || manifest.expiresAt <= manifest.issuedAt) {
46 reject('update_expired');
47 }
48 if (!Number.isSafeInteger(manifest.source?.minimumBuild) ||
49 !Number.isSafeInteger(manifest.source?.maximumBuild) ||
50 context.currentBuild < manifest.source.minimumBuild ||
51 context.currentBuild > manifest.source.maximumBuild) {
52 reject('update_source_mismatch');
53 }
54 if (!Number.isSafeInteger(manifest.target?.build) || manifest.target.build <= context.currentBuild) {
55 reject('update_target_not_newer');
56 }
57 const floor = Math.max(context.persistedFloor, manifest.minimumAcceptedBuild);
58 if (!Number.isSafeInteger(floor) || manifest.target.build < floor) reject('update_rollback_rejected');
59 if (typeof manifest.target.version !== 'string' || !manifest.target.version ||
60 typeof manifest.target.packageURL !== 'string' ||
61 !manifest.target.packageURL.startsWith('https://') ||
62 !HEX_256.test(manifest.target.packageSHA256 ?? '') ||
63 !Number.isSafeInteger(manifest.target.packageBytes) || manifest.target.packageBytes <= 0) {
64 reject('update_package_invalid');
65 }
66 return Object.freeze({ ...manifest.target, acceptedFloor: floor });
67 }
68
69 export async function readPersistedUpdateFloor(custody) {
70 const raw = await custody.get('knowtation.companion.updateFloor');
71 if (raw === null) return 0;
72 if (!/^[0-9]+$/.test(raw)) reject('update_floor_invalid');
73 const floor = Number(raw);
74 if (!Number.isSafeInteger(floor)) reject('update_floor_invalid');
75 return floor;
76 }
77
78 export async function persistUpdateFloor(custody, build, previousFloor = 0) {
79 if (!Number.isSafeInteger(build) || build < previousFloor) reject('update_rollback_rejected');
80 await custody.set('knowtation.companion.updateFloor', String(build));
81 }
File History 1 commit
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ 25 days ago