feat(companion): add optional signing for release bundle artifacts
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { Command } from 'commander';
|
||||
@@ -90,12 +90,15 @@ const {
|
||||
const writeCompanionReleaseBundle = vi.fn(async (input: {
|
||||
outputDir: string;
|
||||
manifest: unknown;
|
||||
signingKeyPem?: string;
|
||||
signingKeyId?: string;
|
||||
}) => ({
|
||||
outputDir: input.outputDir,
|
||||
manifestPath: `${input.outputDir}/companion.bootstrap.json`,
|
||||
launcherPath: `${input.outputDir}/run-companion.sh`,
|
||||
readmePath: `${input.outputDir}/README.md`,
|
||||
checksumsPath: `${input.outputDir}/CHECKSUMS.sha256`,
|
||||
signaturePath: input.signingKeyPem ? `${input.outputDir}/CHECKSUMS.sha256.sig` : undefined,
|
||||
}));
|
||||
const writeCompanionShellTemplate = vi.fn(async (input: {
|
||||
outputDir: string;
|
||||
@@ -416,6 +419,8 @@ describe('companion command', () => {
|
||||
node: expect.objectContaining({ platform: 'android' }),
|
||||
push: expect.objectContaining({ provider: 'fcm' }),
|
||||
}),
|
||||
signingKeyPem: undefined,
|
||||
signingKeyId: undefined,
|
||||
});
|
||||
expect(mockRuntimeCtorArgs).toEqual([]);
|
||||
expect(mockRuntimeInstances).toEqual([]);
|
||||
@@ -425,6 +430,39 @@ describe('companion command', () => {
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('passes signing key material to release bundle export', async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'flynn-signing-key-'));
|
||||
const keyPath = join(tempDir, 'release-signing-key.pem');
|
||||
await writeFile(keyPath, '---test-private-key---\n', 'utf8');
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const program = new Command();
|
||||
const { registerCompanionCommand } = await import('./companion.js');
|
||||
registerCompanionCommand(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'companion',
|
||||
'--export-release-bundle',
|
||||
'/tmp/flynn-companion-bundle',
|
||||
'--signing-key',
|
||||
keyPath,
|
||||
'--signing-key-id',
|
||||
'test-key',
|
||||
]);
|
||||
|
||||
expect(mockWriteCompanionReleaseBundle).toHaveBeenCalledWith(expect.objectContaining({
|
||||
signingKeyPem: '---test-private-key---\n',
|
||||
signingKeyId: 'test-key',
|
||||
}));
|
||||
expect(errSpy).not.toHaveBeenCalled();
|
||||
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
logSpy.mockRestore();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('exports a platform shell template and exits without runtime connection', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
@@ -565,4 +603,25 @@ describe('companion command', () => {
|
||||
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('sets process exit code when signing-key is provided without release bundle export', async () => {
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const program = new Command();
|
||||
const { registerCompanionCommand } = await import('./companion.js');
|
||||
registerCompanionCommand(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'companion',
|
||||
'--once',
|
||||
'--signing-key',
|
||||
'/tmp/flynn-signing-key.pem',
|
||||
]);
|
||||
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(1);
|
||||
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
+18
-1
@@ -1,6 +1,6 @@
|
||||
import { hostname } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import type { Command } from 'commander';
|
||||
import { CompanionRuntimeClient } from '../companion/index.js';
|
||||
import type { SetNodeLocationInput, SetNodePushTokenInput, SetNodeStatusInput } from '../companion/index.js';
|
||||
@@ -42,6 +42,8 @@ interface CompanionCommandOptions {
|
||||
exportBootstrap?: string;
|
||||
exportReleaseBundle?: string;
|
||||
exportShellTemplate?: string;
|
||||
signingKey?: string;
|
||||
signingKeyId?: string;
|
||||
once?: boolean;
|
||||
}
|
||||
|
||||
@@ -323,12 +325,17 @@ export async function runCompanionSession(options: CompanionCommandOptions): Pro
|
||||
const exportBootstrapPath = options.exportBootstrap?.trim();
|
||||
const exportReleaseBundleDir = options.exportReleaseBundle?.trim();
|
||||
const exportShellTemplateDir = options.exportShellTemplate?.trim();
|
||||
const signingKeyPath = options.signingKey?.trim();
|
||||
const signingKeyId = options.signingKeyId?.trim();
|
||||
|
||||
const exportCount = [exportBootstrapPath, exportReleaseBundleDir, exportShellTemplateDir]
|
||||
.filter((value) => Boolean(value)).length;
|
||||
if (exportCount > 1) {
|
||||
throw new Error('export-bootstrap, export-release-bundle, and export-shell-template are mutually exclusive');
|
||||
}
|
||||
if (signingKeyPath && !exportReleaseBundleDir) {
|
||||
throw new Error('signing-key requires --export-release-bundle');
|
||||
}
|
||||
|
||||
const manifest = createCompanionBootstrapManifest({
|
||||
gatewayUrl,
|
||||
@@ -357,15 +364,23 @@ export async function runCompanionSession(options: CompanionCommandOptions): Pro
|
||||
}
|
||||
|
||||
if (exportReleaseBundleDir) {
|
||||
const signingKeyPem = signingKeyPath
|
||||
? await readFile(signingKeyPath, 'utf8')
|
||||
: undefined;
|
||||
const result = await writeCompanionReleaseBundle({
|
||||
outputDir: exportReleaseBundleDir,
|
||||
manifest,
|
||||
signingKeyPem,
|
||||
signingKeyId: signingKeyId && signingKeyId.length > 0 ? signingKeyId : undefined,
|
||||
});
|
||||
console.log(`Wrote companion release bundle to ${result.outputDir}`);
|
||||
console.log(`- Manifest: ${result.manifestPath}`);
|
||||
console.log(`- Launcher: ${result.launcherPath}`);
|
||||
console.log(`- README: ${result.readmePath}`);
|
||||
console.log(`- Checksums: ${result.checksumsPath}`);
|
||||
if (result.signaturePath) {
|
||||
console.log(`- Signature: ${result.signaturePath}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -571,6 +586,8 @@ export function registerCompanionCommand(program: Command): void {
|
||||
'--export-release-bundle <dir>',
|
||||
'Write a companion release bundle (manifest + launcher + README) and exit',
|
||||
)
|
||||
.option('--signing-key <path>', 'Optional private-key PEM path for release-bundle signature')
|
||||
.option('--signing-key-id <value>', 'Optional signing key identifier to embed in signature metadata')
|
||||
.option(
|
||||
'--export-shell-template <dir>',
|
||||
'Write a platform shell template (bootstrap + native starter file + README) and exit',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { generateKeyPairSync, verify, createPublicKey } from 'node:crypto';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { writeCompanionReleaseBundle } from './releaseBundle.js';
|
||||
|
||||
@@ -66,4 +67,53 @@ describe('writeCompanionReleaseBundle', () => {
|
||||
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes signature artifact when signing key is provided', async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'flynn-companion-release-sign-'));
|
||||
const outputDir = join(tempDir, 'bundle');
|
||||
const keyPair = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const privatePem = keyPair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
||||
const publicPem = keyPair.publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
||||
|
||||
const result = await writeCompanionReleaseBundle({
|
||||
outputDir,
|
||||
signingKeyPem: privatePem,
|
||||
signingKeyId: 'test-key',
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
generatedAt: '2026-02-27T00:00:00.000Z',
|
||||
gateway: { url: 'ws://127.0.0.1:18800' },
|
||||
node: {
|
||||
nodeId: 'macos-node',
|
||||
role: 'companion',
|
||||
platform: 'macos',
|
||||
capabilities: ['ui.canvas'],
|
||||
},
|
||||
runtime: {
|
||||
heartbeatSeconds: 30,
|
||||
handoffTimeoutMs: 120000,
|
||||
autoReconnect: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.signaturePath).toBe(`${outputDir}/CHECKSUMS.sha256.sig`);
|
||||
|
||||
const checksumsRaw = await readFile(result.checksumsPath, 'utf8');
|
||||
const signatureRaw = await readFile(result.signaturePath!, 'utf8');
|
||||
const signatureLine = signatureRaw.split('\n').find((line) => line.startsWith('signature='));
|
||||
expect(signatureLine).toBeTruthy();
|
||||
expect(signatureRaw).toContain('key_id=test-key');
|
||||
|
||||
const signature = Buffer.from(String(signatureLine).replace('signature=', ''), 'base64');
|
||||
const verified = verify(
|
||||
'sha256',
|
||||
Buffer.from(checksumsRaw, 'utf8'),
|
||||
createPublicKey(publicPem),
|
||||
signature,
|
||||
);
|
||||
expect(verified).toBe(true);
|
||||
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { chmod, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createHash, createPrivateKey, sign } from 'node:crypto';
|
||||
import type { CompanionBootstrapManifest } from './bootstrapManifest.js';
|
||||
|
||||
export interface WriteCompanionReleaseBundleInput {
|
||||
outputDir: string;
|
||||
manifest: CompanionBootstrapManifest;
|
||||
signingKeyPem?: string;
|
||||
signingKeyId?: string;
|
||||
}
|
||||
|
||||
export interface WriteCompanionReleaseBundleResult {
|
||||
@@ -13,6 +15,7 @@ export interface WriteCompanionReleaseBundleResult {
|
||||
launcherPath: string;
|
||||
readmePath: string;
|
||||
checksumsPath: string;
|
||||
signaturePath?: string;
|
||||
}
|
||||
|
||||
function shSingleQuote(value: string): string {
|
||||
@@ -158,7 +161,23 @@ export async function writeCompanionReleaseBundle(
|
||||
[createHash('sha256').update(launcherBody, 'utf8').digest('hex'), 'run-companion.sh'],
|
||||
[createHash('sha256').update(readmeBody, 'utf8').digest('hex'), 'README.md'],
|
||||
].map(([hash, name]) => `${hash} ${name}`).join('\n');
|
||||
await writeFile(checksumsPath, `${checksums}\n`, 'utf8');
|
||||
const checksumsPayload = `${checksums}\n`;
|
||||
await writeFile(checksumsPath, checksumsPayload, 'utf8');
|
||||
let signaturePath: string | undefined;
|
||||
if (input.signingKeyPem && input.signingKeyPem.trim().length > 0) {
|
||||
const privateKey = createPrivateKey(input.signingKeyPem);
|
||||
const signature = sign('sha256', Buffer.from(checksumsPayload, 'utf8'), privateKey).toString('base64');
|
||||
signaturePath = `${input.outputDir}/CHECKSUMS.sha256.sig`;
|
||||
const keyIdLine = input.signingKeyId ? `key_id=${input.signingKeyId}\n` : '';
|
||||
const signatureBody = [
|
||||
'# Flynn companion release signature',
|
||||
keyIdLine.trim(),
|
||||
`algorithm=sha256`,
|
||||
`encoding=base64`,
|
||||
`signature=${signature}`,
|
||||
].filter(Boolean).join('\n');
|
||||
await writeFile(signaturePath, `${signatureBody}\n`, 'utf8');
|
||||
}
|
||||
|
||||
return {
|
||||
outputDir: input.outputDir,
|
||||
@@ -166,5 +185,6 @@ export async function writeCompanionReleaseBundle(
|
||||
launcherPath,
|
||||
readmePath,
|
||||
checksumsPath,
|
||||
signaturePath,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user