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/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ jobs:

unit-tests:
runs-on: ubuntu-latest
env:
ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY: '1'
steps:
- uses: actions/checkout@v4

Expand All @@ -46,6 +48,9 @@ jobs:

integration-tests:
runs-on: ubuntu-latest
env:
# CI lacks safeStorage (no Electron); peers tests opt in to plaintext identity.
ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY: '1'
steps:
- uses: actions/checkout@v4

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "adc",
"version": "0.3.2",
"version": "0.4.0",
"type": "module",
"description": "ADC — Desktop UI for multi-project management with agent team orchestration, automated QA loops, and agentic local software testing",
"main": "./out/main/index.cjs",
Expand Down
16 changes: 16 additions & 0 deletions src/main/features/peers/outbound-dialer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ export interface OutboundDialer {
* connecting / open / permanently_failed / closed.
*/
start: () => void;
/**
* Caller signals that an `'open'` socket has closed and the dialer should
* arm the next attempt. Transitions `open` → `backoff` (with the current
* backoff schedule). No-op in any other state. Caller MUST call this when
* the underlying socket disconnects after a successful open, otherwise the
* dialer stays stuck in `open`.
*/
notifyDisconnected: () => void;
/** Cancel any pending timer and transition to `closed`. Idempotent. */
close: () => void;
/** Current state. */
Expand Down Expand Up @@ -155,6 +163,14 @@ export function createOutboundDialer(opts: OutboundDialerOpts): OutboundDialer {
clearPending();
runDial();
},
notifyDisconnected(): void {
// Only act on a previously-open socket. Closed/permanently_failed are
// terminal; idle/backoff/connecting already handle their own transitions.
if (current !== 'open') return;
// Re-enter the backoff schedule. attempts is 0 (reset on OK) so the next
// delay starts from the base; transient peer disconnects retry quickly.
scheduleBackoff();
},
close(): void {
if (current === 'closed') return;
clearPending();
Expand Down
38 changes: 34 additions & 4 deletions src/main/features/peers/peer-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
* surfaces as a request `'error'` (not a post-`'end'` check).
*/

import { createHash, timingSafeEqual } from 'node:crypto';
import { Agent as HttpsAgent, request as httpsRequest } from 'node:https';

import { pinnedCheckServerIdentity } from './peer-tls-pin';

import type { ClientRequest, IncomingMessage, RequestOptions } from 'node:http';
import type { TLSSocket } from 'node:tls';

export interface PostJsonPinnedOpts {
/**
Expand All @@ -35,11 +37,15 @@ export async function postJsonPinned<T>(
): Promise<T> {
const reqFn = opts.requestImpl ?? httpsRequest;
const u = new URL(url);
// Pin the peer's leaf cert at TLS handshake time. With this in place we can
// (and must) keep `rejectUnauthorized: true` — fingerprint mismatch surfaces
// as a handshake error on the request, not a post-`'end'` check.
// Self-signed peer certs cannot be CA-validated. We use rejectUnauthorized:
// false so the handshake completes, then enforce the fingerprint pin on the
// socket's secureConnect event BEFORE writing any application bytes.
// checkServerIdentity is also wired (defense-in-depth) but Node only acts on
// its Error return when rejectUnauthorized:true, so the post-secureConnect
// check is the actual enforcement gate. Audit ref: 01-security.md (TLS pin).
const expectedFp = Buffer.from(fingerprintHex, 'hex');
const agent = new HttpsAgent({
rejectUnauthorized: true,
rejectUnauthorized: false,
checkServerIdentity: pinnedCheckServerIdentity(fingerprintHex),
});
const payload = Buffer.from(JSON.stringify(body), 'utf8');
Expand Down Expand Up @@ -92,6 +98,30 @@ export async function postJsonPinned<T>(
req.on('error', (err: Error) => {
settle(() => { reject(err); });
});
// Enforce fingerprint pin at TLS handshake time (post-handshake but
// pre-application-write). Real https.request emits 'socket' synchronously
// with a TLSSocket; the test injection seam doesn't, so we guard with
// typeof checks.
req.on('socket', (socket) => {
const tls = socket as TLSSocket;
const verify = (): void => {
const cert = typeof tls.getPeerCertificate === 'function'
? tls.getPeerCertificate(true)
: undefined;
const raw = cert && (cert as { raw?: Buffer }).raw;
if (!raw || raw.length === 0) return; // mock socket — skip
const actual = createHash('sha256').update(raw).digest();
if (actual.length !== expectedFp.length || !timingSafeEqual(actual, expectedFp)) {
settle(() => { reject(new Error('peer fingerprint mismatch')); });
tls.destroy(new Error('peer fingerprint mismatch'));
}
};
if ((tls as { encrypted?: boolean }).encrypted === true) {
verify();
} else if (typeof tls.once === 'function') {
tls.once('secureConnect', verify);
}
});
req.write(payload);
req.end();
});
Expand Down
6 changes: 4 additions & 2 deletions src/main/features/peers/peer-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,15 @@ export function getOrCreatePeerIdentity(
}

const canEncrypt = safeStorage.isEncryptionAvailable();
if (!canEncrypt && !opts.allowPlaintext) {
const envOptIn = process.env.ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY === '1';
const allowPlaintext = opts.allowPlaintext === true || envOptIn;
if (!canEncrypt && !allowPlaintext) {
// Refuse to leak entropy on the failure path — throw before generating the keypair.
throw new Error(
'peer-identity: safeStorage unavailable. Set ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY=1 to opt in.',
);
}
if (!canEncrypt && opts.allowPlaintext === true) {
if (!canEncrypt && allowPlaintext) {
serviceLogger.warn(
{ dataDir },
'peers.peerIdentity writing private key in plaintext (safeStorage unavailable, opt-in via ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY)',
Expand Down
41 changes: 34 additions & 7 deletions src/main/features/peers/ws-transport.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { randomBytes } from 'node:crypto';
import { createHash, randomBytes } from 'node:crypto';
import { createServer as createHttpsServer, type Server as HttpsServer } from 'node:https';

import { WebSocket, WebSocketServer, type RawData, type ClientOptions } from 'ws';
Expand Down Expand Up @@ -30,6 +30,7 @@ import {
} from '@main/features/peers/wire-schema';
import { serviceLogger } from '@main/lib/logger';

import type { TLSSocket } from 'node:tls';

export interface WsTransportTlsOpts {
cert: string;
Expand Down Expand Up @@ -334,14 +335,20 @@ export async function createWsTransport(deps: WsTransportDeps): Promise<WsTransp
// real `PeerCertificate` and accepts an `Error | undefined` return —
// matching `https.RequestOptions.checkServerIdentity`. We cast through
// `ClientOptions` to satisfy the looser package typing.
// Self-signed peer certs cannot be CA-validated. Use
// rejectUnauthorized:false to let the handshake complete, then enforce
// the fingerprint pin on the WebSocket 'open' event BEFORE sending any
// application bytes (HELLO/OPS). checkServerIdentity is wired as
// defense-in-depth but Node only acts on its Error return when
// rejectUnauthorized:true, so the post-'open' check is the gate.
const wssOpts: ClientOptions = remotePeer
? {
rejectUnauthorized: true,
rejectUnauthorized: false,
checkServerIdentity: pinnedCheckServerIdentity(
remotePeer.fingerprint,
) as unknown as ClientOptions['checkServerIdentity'],
}
: { rejectUnauthorized: true };
: { rejectUnauthorized: false };
const ws = isWss
? new WebSocket(remoteUrl, wssOpts)
: new WebSocket(remoteUrl);
Expand All @@ -351,6 +358,27 @@ export async function createWsTransport(deps: WsTransportDeps): Promise<WsTransp
let permanent = false;

ws.on('open', () => {
// Enforce TLS fingerprint pin BEFORE sending any application bytes.
// Node ignores checkServerIdentity Error when rejectUnauthorized:false,
// so the actual gate runs here on the underlying TLS socket.
if (remotePeer) {
const sock = (ws as unknown as { _socket?: TLSSocket })._socket;
const cert = sock && typeof sock.getPeerCertificate === 'function'
? sock.getPeerCertificate(true)
: undefined;
const raw = cert && (cert as { raw?: Buffer }).raw;
if (!raw || raw.length === 0) {
permanent = true;
ws.close(WS_CLOSE_CODES.FINGERPRINT_MISMATCH, 'fingerprint mismatch');
return;
}
const actual = createHash('sha256').update(raw).digest('hex');
if (actual !== remotePeer.fingerprint) {
permanent = true;
ws.close(WS_CLOSE_CODES.FINGERPRINT_MISMATCH, 'fingerprint mismatch');
return;
}
}
if (selfIdentity) {
// Sign the outbound HELLO so the inbound peer can authenticate us
// (Task 6). Signature is over nonce_bytes || schemaHash_utf8 || peerId_utf8.
Expand Down Expand Up @@ -403,11 +431,10 @@ export async function createWsTransport(deps: WsTransportDeps): Promise<WsTransp
return;
}
// The socket previously emitted 'open' (we resolved 'OK' already).
// Treat the close as a request to re-arm the dialer for the next
// attempt — single-flight guard inside the dialer makes re-entrant
// start() calls a no-op when not in idle/backoff.
// The dialer is in `open` state — call notifyDisconnected so it can
// re-enter backoff and retry. start() alone would be a no-op from `open`.
if (!shuttingDown) {
dialer?.start();
dialer?.notifyDisconnected();
}
});
});
Expand Down
6 changes: 3 additions & 3 deletions tests/integration/peers/migration-tags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { loadMigrationTags } from '@main/db/migration-tags';
describe('loadMigrationTags', () => {
it('reads drizzle/meta/_journal.json and returns tags in idx order', () => {
const tags = loadMigrationTags();
// Phase 3a adds migration 0028_peer_state on top of the Phase 2 baseline.
expect(tags.length).toBeGreaterThanOrEqual(29);
// Audit-fix sprint adds 0030_op_log_hlc_index on top of the Phase 2/3 baseline.
expect(tags.length).toBeGreaterThanOrEqual(30);
expect(tags[0]).toMatch(/^0000_/);
expect(tags.at(-1)).toMatch(/^0029_projects$/);
expect(tags.at(-1)).toMatch(/^0030_op_log_hlc_index$/);
});

it('tags are strictly increasing by idx prefix', () => {
Expand Down
14 changes: 12 additions & 2 deletions tests/integration/peers/pair-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,11 @@ describe('pair-flow end-to-end', () => {
remotePeer: { peerId: identityA.peerIdFull, fingerprint: tlsA.fingerprint },
});

// 5. Wait for the TLS-pinned WS connection to come up both ways.
await waitFor(() => (serverA.ws.isConnected() && serverB.ws.isConnected() ? true : undefined));
// 5. PAIR FIRST. T6 added signed-HELLO inbound auth, so the WS dialer's
// initial attempt fails until both sides have the other peer in their
// peer-store. The dialer retries with backoff, so once pairing populates
// both stores the next dial attempt authenticates and connects.
// (Connection waitFor moved to step 9 below, after stores are populated.)

// 6. PAIR: B is initiator, A is receiver. B POSTs /pair/init to A's TLS port.
const initRes = await postJson(
Expand Down Expand Up @@ -243,6 +246,13 @@ describe('pair-flow end-to-end', () => {
expect(storeA.listActive().map((p) => p.peerId)).toContain(identityB.peerIdFull);
expect(storeB.listActive().map((p) => p.peerId)).toContain(identityA.peerIdFull);

// 9. Now the WS dialer should authenticate and connect on its next retry.
// Wait up to a few backoff cycles for both directions to come up.
await waitFor(
() => (serverA.ws.isConnected() && serverB.ws.isConnected() ? true : undefined),
8000,
);

// 10. SYNC: write a task on A, expect it to land on B over the TLS-pinned WS.
sqliteA
.prepare(
Expand Down
8 changes: 8 additions & 0 deletions tests/integration/peers/peer-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,20 @@ vi.mock('electron', () => ({
const { getOrCreatePeerIdentity } = await import('@main/features/peers/peer-identity');

let dir: string;
let savedEnv: string | undefined;

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'peer-identity-'));
// Isolate from CI's ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY=1 so the
// "throws when allowPlaintext not set" assertion is meaningful.
savedEnv = process.env.ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY;
delete process.env.ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY;
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
if (savedEnv !== undefined) {
process.env.ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY = savedEnv;
}
});

describe('peer-identity', () => {
Expand Down
5 changes: 4 additions & 1 deletion tests/unit/peers/peer-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,10 @@ describe('postJsonPinned', () => {
| { options?: { checkServerIdentity?: unknown; rejectUnauthorized?: boolean } };
expect(agent).toBeDefined();
expect(typeof agent?.options?.checkServerIdentity).toBe('function');
expect(agent?.options?.rejectUnauthorized).toBe(true);
// rejectUnauthorized:false is intentional — self-signed peer certs cannot
// be CA-validated. Pin enforcement happens post-secureConnect via the
// socket-cert fingerprint check (see peer-http.ts).
expect(agent?.options?.rejectUnauthorized).toBe(false);
});

it('rejects on non-2xx with status code in error message', async () => {
Expand Down
7 changes: 7 additions & 0 deletions tests/unit/peers/peer-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,21 @@ function setEncryptionAvailable(value: boolean): void {

describe('getOrCreatePeerIdentity — safeStorage unavailable', () => {
let tmpDir: string;
let savedEnv: string | undefined;

beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'pid-'));
setEncryptionAvailable(false);
// Isolate from CI's ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY=1
savedEnv = process.env.ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY;
delete process.env.ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY;
});

afterEach(() => {
setEncryptionAvailable(true);
if (savedEnv !== undefined) {
process.env.ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY = savedEnv;
}
});

it('throws when safeStorage is unavailable and allowPlaintext is not set', () => {
Expand Down
Loading