Files
TREK/server/tests/unit/plugins/verify-signature.test.ts
T
Maurice 711f6e9881 feat(plugins): sandbox hardening — OS permission model, egress choke point, bound acting user, author signatures
Closes the four gaps the security review surfaced:

- OS permission model on the prod plugin child (Node --permission with
  fs-read scoped to the compiled server dir + the plugin's own code dir, no
  fs-write/child_process/worker/native). A plugin can no longer read trek.db
  or the .jwt_secret/.encryption_key files, nor shell out — the direct-fs and
  RCE escapes that bypassed the RPC layer. Opt-out via TREK_PLUGIN_PERMISSIONS=off.
- Egress guard extended from fetch to the net.Socket connect choke point, so
  node:http/https/net/tls obey the declared-egress allowlist too (no declared
  egress = no outbound). Under the permission model there is no clean escape to
  an unwrapped runtime. Kernel/network-namespace containment remains the
  container step.
- Trip reads are membership-checked against the acting user the HOST binds from
  the authenticated invocation, not an asUserId the plugin supplies; a job/onLoad
  (no user) can't read user-scoped trips.
- Optional minisign (Ed25519) author signatures verified offline, TOFU-pinned
  (migration 158). Unsigned plugins install on sha256 alone; a signed plugin
  can't silently drop its signature or swap its author key.

Server suite green (permission-model activation verified against the Koffi
reference plugin on dev1).
2026-07-04 00:31:01 +02:00

82 lines
3.6 KiB
TypeScript

/**
* Author-signature verification (#plugins, #4). Ed25519 via node:crypto, in the
* minisign shapes TREK accepts: bare raw key+sig, minisign legacy ('Ed', signs
* the file), and minisign prehashed ('ED', signs BLAKE2b-512 of the file).
*/
import { describe, it, expect } from 'vitest';
import crypto from 'node:crypto';
import { verifyAuthorSignature, SignatureError } from '../../../src/nest/plugins/install/verify-signature';
// A raw Ed25519 keypair, exported as the 32-byte raw public key + a signer.
function makeKeypair() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
const rawPub = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32); // last 32 bytes = the key
return {
rawPub,
sign: (msg: Buffer) => crypto.sign(null, msg, privateKey),
};
}
const KEY_ID = Buffer.from('0011223344556677', 'hex');
const b64 = (b: Buffer) => b.toString('base64');
describe('verifyAuthorSignature', () => {
const bytes = Buffer.from('the exact plugin.zip bytes');
it('accepts a bare raw Ed25519 key + 64-byte signature over the file', () => {
const { rawPub, sign } = makeKeypair();
expect(verifyAuthorSignature(bytes, b64(sign(bytes)), b64(rawPub))).toBe(true);
});
it('rejects a valid signature from a different key', () => {
const a = makeKeypair();
const b = makeKeypair();
expect(verifyAuthorSignature(bytes, b64(a.sign(bytes)), b64(b.rawPub))).toBe(false);
});
it('rejects a signature over tampered bytes', () => {
const { rawPub, sign } = makeKeypair();
const sig = b64(sign(bytes));
expect(verifyAuthorSignature(Buffer.from('tampered'), sig, b64(rawPub))).toBe(false);
});
it('verifies the minisign legacy format (Ed — signs the raw file)', () => {
const { rawPub, sign } = makeKeypair();
const pub = b64(Buffer.concat([Buffer.from('Ed'), KEY_ID, rawPub]));
const sig = b64(Buffer.concat([Buffer.from('Ed'), KEY_ID, sign(bytes)]));
expect(verifyAuthorSignature(bytes, sig, pub)).toBe(true);
});
it('verifies the minisign prehashed format (ED — signs BLAKE2b-512 of the file)', () => {
const { rawPub, sign } = makeKeypair();
const hash = crypto.createHash('blake2b512').update(bytes).digest();
const pub = b64(Buffer.concat([Buffer.from('Ed'), KEY_ID, rawPub]));
const sig = b64(Buffer.concat([Buffer.from('ED'), KEY_ID, sign(hash)]));
expect(verifyAuthorSignature(bytes, sig, pub)).toBe(true);
});
it('rejects when the key ids in key and signature disagree', () => {
const { rawPub, sign } = makeKeypair();
const pub = b64(Buffer.concat([Buffer.from('Ed'), KEY_ID, rawPub]));
const otherId = Buffer.from('8899aabbccddeeff', 'hex');
const sig = b64(Buffer.concat([Buffer.from('Ed'), otherId, sign(bytes)]));
expect(verifyAuthorSignature(bytes, sig, pub)).toBe(false);
});
it('parses a two-line minisign key (comment + base64)', () => {
const { rawPub, sign } = makeKeypair();
const pub = `untrusted comment: minisign public key ABC\n${b64(Buffer.concat([Buffer.from('Ed'), KEY_ID, rawPub]))}`;
const sig = `untrusted comment: signature\n${b64(Buffer.concat([Buffer.from('Ed'), KEY_ID, sign(bytes)]))}`;
expect(verifyAuthorSignature(bytes, sig, pub)).toBe(true);
});
it('throws SignatureError on a malformed key', () => {
expect(() => verifyAuthorSignature(bytes, b64(Buffer.alloc(64)), b64(Buffer.alloc(7)))).toThrow(SignatureError);
});
it('throws SignatureError on a malformed signature', () => {
const { rawPub } = makeKeypair();
expect(() => verifyAuthorSignature(bytes, b64(Buffer.alloc(10)), b64(rawPub))).toThrow(SignatureError);
});
});