Implement fail-closed node keystore bridge

This commit is contained in:
冰朔 2026-08-10 09:35:31 +08:00
commit b4d6e7a4f9
5 changed files with 345 additions and 5 deletions

View file

@ -0,0 +1,131 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
import { EncryptedNodeKeyStoreProofSource, type NodeKeyStoreAdapter } from './node-keystore-bridge.js';
import { createNodePossessionChallenge, verifyNodePossessionResponse } from './node-possession-proof.js';
import {
nodeRegistrationClaimSigningBytes,
parseNodeRegistrationEndpointRegistry,
resolveNodeRegistrationEndpoint,
verifyNodeRegistrationClaim,
} from './node-registration.js';
const NOW = 1_786_291_200_000;
const DOMAIN_ID = 'DOM-FIFTH-0001';
const NODE_ID = 'LOCAL-001';
const registrationKeys = generateKeyPairSync('ed25519');
const nodeKeys = generateKeyPairSync('ed25519');
const nodePublicKeyPem = nodeKeys.publicKey.export({ format: 'pem', type: 'spki' }).toString();
const nodePrivateKeyPkcs8 = nodeKeys.privateKey.export({ format: 'der', type: 'pkcs8' });
const fingerprint = createHash('sha256')
.update(nodeKeys.publicKey.export({ format: 'der', type: 'spki' }))
.digest('hex');
function verifiedRegistration() {
const registry = parseNodeRegistrationEndpointRegistry({
endpoints: [{
algorithm: 'Ed25519',
domainIds: [DOMAIN_ID],
endpointId: 'GH-NODE-REG-001',
nodeTypes: ['local-terminal'],
publicKeyPem: registrationKeys.publicKey.export({ format: 'pem', type: 'spki' }).toString(),
signerId: 'GH-NODE-REG-SIGNER-001',
status: 'ACTIVE',
url: 'https://guanghulab.com/api/ai/v1/node-registrations/claims',
}],
registryId: 'GH-AIOS-NODE-REGISTRATION-ENDPOINTS-001',
schema: 'gh-aios.node-registration-endpoints/v1',
state: 'CURRENT',
version: '1.0.0',
}, {
repositoryId: 'REPO-012',
sourceCommit: 'a'.repeat(40),
sourceUrl: `https://guanghulab.com/code/bingshuo/guanghu-ice-heart/raw/commit/${'a'.repeat(40)}/routing/node-registration-endpoints.json`,
});
const endpoint = resolveNodeRegistrationEndpoint(registry, { domainId: DOMAIN_ID, nodeType: 'local-terminal' });
assert.ok(endpoint);
const payload = {
accountId: 'bingshuo',
claimId: 'NODE-CLAIM-001',
domainId: DOMAIN_ID,
endpointId: endpoint.endpointId,
expiresAt: NOW + 120_000,
issuedAt: NOW - 1_000,
nodeId: NODE_ID,
nodeKeyFingerprint: fingerprint,
nodeType: 'local-terminal' as const,
schema: 'gh-aios.node-registration-claim/v1' as const,
signerId: endpoint.signerId,
};
return verifyNodeRegistrationClaim({
...payload,
signature: sign(null, nodeRegistrationClaimSigningBytes(payload), registrationKeys.privateKey).toString('base64'),
}, {
accountId: payload.accountId,
domainId: DOMAIN_ID,
nodeId: NODE_ID,
nodeType: 'local-terminal',
}, endpoint, NOW);
}
function keyRecord(overrides: Record<string, unknown> = {}) {
return {
encryptedPrivateKeyPkcs8: Buffer.from('encrypted-test-record').toString('base64'),
nodeId: NODE_ID,
nodeKeyFingerprint: fingerprint,
nodeType: 'local-terminal',
publicKeyPem: nodePublicKeyPem,
schema: 'gh-aios.node-keystore-record/v1',
...overrides,
};
}
function adapter(overrides: Partial<NodeKeyStoreAdapter> = {}): NodeKeyStoreAdapter {
return {
async decryptPrivateKeyPkcs8() { return Buffer.from(nodePrivateKeyPkcs8); },
isAvailable() { return true; },
async readRecord() { return keyRecord(); },
...overrides,
};
}
function challenge(id = 'NODE-CHALLENGE-KEYSTORE-001') {
return createNodePossessionChallenge(verifiedRegistration(), NOW, id, 'A'.repeat(43));
}
test('signs only an active challenge with the enrolled matching Ed25519 key', async () => {
const issued = challenge();
const source = new EncryptedNodeKeyStoreProofSource(adapter(), () => NOW + 1);
const response = await source.prove(issued);
assert.ok(response);
assert.deepEqual(Object.keys(response).sort(), ['challengeId', 'publicKeyPem', 'schema', 'signature']);
assert.ok(verifyNodePossessionResponse(response, issued, verifiedRegistration(), NOW + 2));
});
test('returns no proof when storage is unavailable or no enrollment exists', async () => {
const issued = challenge('NODE-CHALLENGE-KEYSTORE-002');
assert.equal(await new EncryptedNodeKeyStoreProofSource(adapter({ isAvailable: () => false }), () => NOW + 1).prove(issued), null);
assert.equal(await new EncryptedNodeKeyStoreProofSource(adapter({ readRecord: async () => null }), () => NOW + 1).prove(issued), null);
});
test('rejects copied challenges, unknown record fields and node binding mismatches', async () => {
const issued = challenge('NODE-CHALLENGE-KEYSTORE-003');
assert.equal(await new EncryptedNodeKeyStoreProofSource(adapter(), () => NOW + 1).prove({ ...issued }), null);
assert.equal(await new EncryptedNodeKeyStoreProofSource(adapter({ readRecord: async () => keyRecord({ extra: true }) }), () => NOW + 1).prove(issued), null);
assert.equal(await new EncryptedNodeKeyStoreProofSource(adapter({ readRecord: async () => keyRecord({ nodeId: 'OTHER-NODE' }) }), () => NOW + 1).prove(issued), null);
});
test('rejects malformed encryption payloads and mismatched private keys', async () => {
const issued = challenge('NODE-CHALLENGE-KEYSTORE-004');
assert.equal(await new EncryptedNodeKeyStoreProofSource(adapter({ readRecord: async () => keyRecord({ encryptedPrivateKeyPkcs8: '***' }) }), () => NOW + 1).prove(issued), null);
const other = generateKeyPairSync('ed25519').privateKey.export({ format: 'der', type: 'pkcs8' });
assert.equal(await new EncryptedNodeKeyStoreProofSource(adapter({ decryptPrivateKeyPkcs8: async () => Buffer.from(other) }), () => NOW + 1).prove(issued), null);
});
test('wipes the decrypted temporary key bytes after signing', async () => {
const issued = challenge('NODE-CHALLENGE-KEYSTORE-005');
const decrypted = Buffer.from(nodePrivateKeyPkcs8);
const source = new EncryptedNodeKeyStoreProofSource(adapter({ decryptPrivateKeyPkcs8: async () => decrypted }), () => NOW + 1);
assert.ok(await source.prove(issued));
assert.ok(decrypted.every(byte => byte === 0));
});

View file

@ -0,0 +1,124 @@
import {
createHash,
createPrivateKey,
createPublicKey,
sign,
} from 'node:crypto';
import {
assertActiveNodePossessionChallenge,
nodePossessionChallengeSigningBytes,
type NodePossessionChallenge,
type NodePossessionProofSource,
type NodePossessionResponse,
} from './node-possession-proof.js';
const RECORD_SCHEMA = 'gh-aios.node-keystore-record/v1' as const;
const RESPONSE_SCHEMA = 'gh-aios.node-possession-response/v1' as const;
const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/;
const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
interface NodeKeyRecord {
encryptedPrivateKeyPkcs8: string;
nodeId: string;
nodeKeyFingerprint: string;
nodeType: 'local-terminal' | 'cloud-resident';
publicKeyPem: string;
schema: typeof RECORD_SCHEMA;
}
export interface NodeKeyStoreAdapter {
decryptPrivateKeyPkcs8(encrypted: string): Promise<Buffer | null>;
isAvailable(): boolean;
readRecord(): Promise<unknown | null>;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function hasExactKeys(record: Record<string, unknown>, keys: string[]): boolean {
const expected = [...keys].sort();
const actual = Object.keys(record).sort();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}
function parseRecord(input: unknown): NodeKeyRecord | null {
if (!isRecord(input)
|| !hasExactKeys(input, [
'encryptedPrivateKeyPkcs8',
'nodeId',
'nodeKeyFingerprint',
'nodeType',
'publicKeyPem',
'schema',
])
|| input.schema !== RECORD_SCHEMA
|| typeof input.nodeId !== 'string'
|| !IDENTIFIER_PATTERN.test(input.nodeId)
|| (input.nodeType !== 'local-terminal' && input.nodeType !== 'cloud-resident')
|| typeof input.nodeKeyFingerprint !== 'string'
|| !FINGERPRINT_PATTERN.test(input.nodeKeyFingerprint)
|| typeof input.publicKeyPem !== 'string'
|| input.publicKeyPem.length > 4096
|| input.publicKeyPem.includes('PRIVATE KEY')
|| typeof input.encryptedPrivateKeyPkcs8 !== 'string'
|| input.encryptedPrivateKeyPkcs8.length < 4
|| input.encryptedPrivateKeyPkcs8.length > 65_536
|| !BASE64_PATTERN.test(input.encryptedPrivateKeyPkcs8)) return null;
return input as unknown as NodeKeyRecord;
}
function publicKeyFingerprint(publicKeyPem: string): string | null {
try {
const publicKey = createPublicKey({ key: publicKeyPem, format: 'pem' });
if (publicKey.asymmetricKeyType !== 'ed25519') return null;
return createHash('sha256')
.update(publicKey.export({ format: 'der', type: 'spki' }))
.digest('hex');
} catch {
return null;
}
}
export class EncryptedNodeKeyStoreProofSource implements NodePossessionProofSource {
constructor(
private readonly adapter: NodeKeyStoreAdapter,
private readonly clock: () => number = Date.now,
) {}
async prove(challenge: Readonly<NodePossessionChallenge>): Promise<Readonly<NodePossessionResponse> | null> {
let privateKeyPkcs8: Buffer | null = null;
try {
assertActiveNodePossessionChallenge(challenge, this.clock());
if (!this.adapter.isAvailable()) return null;
const record = parseRecord(await this.adapter.readRecord());
if (!record
|| record.nodeId !== challenge.nodeId
|| record.nodeType !== challenge.nodeType
|| record.nodeKeyFingerprint !== challenge.nodeKeyFingerprint
|| publicKeyFingerprint(record.publicKeyPem) !== record.nodeKeyFingerprint) return null;
privateKeyPkcs8 = await this.adapter.decryptPrivateKeyPkcs8(record.encryptedPrivateKeyPkcs8);
if (!privateKeyPkcs8 || privateKeyPkcs8.length === 0 || privateKeyPkcs8.length > 16_384) return null;
const privateKey = createPrivateKey({ key: privateKeyPkcs8, format: 'der', type: 'pkcs8' });
if (privateKey.asymmetricKeyType !== 'ed25519') return null;
const derivedPublicKey = createPublicKey(privateKey);
const derivedFingerprint = createHash('sha256')
.update(derivedPublicKey.export({ format: 'der', type: 'spki' }))
.digest('hex');
if (derivedFingerprint !== record.nodeKeyFingerprint) return null;
return Object.freeze({
challengeId: challenge.challengeId,
publicKeyPem: record.publicKeyPem,
schema: RESPONSE_SCHEMA,
signature: sign(null, nodePossessionChallengeSigningBytes(challenge), privateKey).toString('base64'),
});
} catch {
return null;
} finally {
privateKeyPkcs8?.fill(0);
}
}
}

View file

@ -81,6 +81,18 @@ export function nodePossessionChallengeSigningBytes(challenge: NodePossessionCha
}), 'utf8');
}
export function assertActiveNodePossessionChallenge(
challenge: NodePossessionChallenge,
now = Date.now(),
): NodePossessionChallenge {
if (!issuedChallenges.has(challenge)
|| consumedChallenges.has(challenge)
|| !Number.isSafeInteger(now)
|| now < challenge.issuedAt
|| now >= challenge.expiresAt) invalidProof();
return challenge;
}
export function createNodePossessionChallenge(
registration: VerifiedNodeRegistration,
now = Date.now(),