Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ jobs:
- name: Build
run: npm run build

- name: Stamp manifest fingerprint from the packed file set
run: node scripts/stamp-manifest.mjs
env:
ORGX_MANIFEST_SIGNING_KEY: ${{ secrets.ORGX_MANIFEST_SIGNING_KEY }}

- name: Pack release artifact
run: npm run pack

Expand Down
2 changes: 1 addition & 1 deletion plugin.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@
"attention:reply_in_place"
],
"driver_ids": ["opencode"],
"notes": "fingerprint + signature are populated by the npm prepublish step; the dev build ships unsigned and gets 'degraded' status from the server."
"notes": "fingerprint + signature are stamped by scripts/stamp-manifest.mjs in the publish workflow after build and before npm pack; dev builds ship empty and get 'degraded' status from the server."
}
64 changes: 64 additions & 0 deletions scripts/stamp-manifest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* Fill `manifest_fingerprint` (and `signature`, when a key is provided) in
* plugin.manifest.json from the exact files `npm pack` will publish.
*
* The peer sends both fields on every heartbeat, but nothing populated them,
* so every shipped build reported an empty identity and the server could not
* tell one installed release from another (plan v3 §2, §5.4).
*
* fingerprint = sha256 over sorted "<path>\0<sha256(file)>\n" for every
* packed file except plugin.manifest.json itself
* signature = base64(ed25519.sign(fingerprint)) with
* ORGX_MANIFEST_SIGNING_KEY (PEM), matching the server's
* lib/licenses/manifest.ts verifyManifest
*
* Run immediately before `npm pack` in the release workflow.
*/
import { execFileSync } from 'node:child_process';
import { createHash, createPrivateKey, sign } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const MANIFEST = 'plugin.manifest.json';

export function computeManifestFingerprint(files, readFile) {
const lines = files
.filter((path) => path !== MANIFEST)
.sort()
.map((path) => `${path}\0${createHash('sha256').update(readFile(path)).digest('hex')}\n`);
return `sha256:${createHash('sha256').update(lines.join('')).digest('hex')}`;
}

export function signManifestFingerprint(fingerprint, privateKeyPem) {
return sign(null, Buffer.from(fingerprint, 'utf8'), createPrivateKey(privateKeyPem)).toString(
'base64'
);
}

function packedFiles(root) {
const out = execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], {
cwd: root,
encoding: 'utf8',
});
return JSON.parse(out)[0].files.map((file) => file.path);
}

function main() {
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const manifestPath = resolve(root, MANIFEST);
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const fingerprint = computeManifestFingerprint(packedFiles(root), (path) =>
readFileSync(resolve(root, path))
);
const key = process.env.ORGX_MANIFEST_SIGNING_KEY;
manifest.manifest_fingerprint = fingerprint;
manifest.signature = key ? signManifestFingerprint(fingerprint, key) : '';
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
console.log(
`stamp-manifest: ${fingerprint} (${key ? 'signed' : 'unsigned: ORGX_MANIFEST_SIGNING_KEY not set'})`
);
}

if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
34 changes: 34 additions & 0 deletions scripts/stamp-manifest.node-test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import { generateKeyPairSync, verify } from 'node:crypto';
import { test } from 'node:test';

import { computeManifestFingerprint, signManifestFingerprint } from './stamp-manifest.mjs';

const contents = { 'a.mjs': 'a', 'b/c.mjs': 'c', 'plugin.manifest.json': '{"x":1}' };
const read = (path) => Buffer.from(contents[path]);

test('fingerprint is order-independent and excludes the manifest itself', () => {
const one = computeManifestFingerprint(['a.mjs', 'b/c.mjs', 'plugin.manifest.json'], read);
const two = computeManifestFingerprint(['b/c.mjs', 'a.mjs'], read);
assert.equal(one, two);
assert.match(one, /^sha256:[0-9a-f]{64}$/);
});

test('fingerprint changes when any packed byte changes', () => {
const before = computeManifestFingerprint(['a.mjs'], read);
const after = computeManifestFingerprint(['a.mjs'], () => Buffer.from('A'));
assert.notEqual(before, after);
});

test('signature verifies the way the server verifies it', () => {
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
const fingerprint = computeManifestFingerprint(['a.mjs'], read);
const signature = signManifestFingerprint(
fingerprint,
privateKey.export({ type: 'pkcs8', format: 'pem' })
);
assert.equal(
verify(null, Buffer.from(fingerprint, 'utf8'), publicKey, Buffer.from(signature, 'base64')),
true
);
});
Loading