101 lines
7.9 KiB
JavaScript
101 lines
7.9 KiB
JavaScript
'use strict';
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const crypto = require('node:crypto');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { canonical, createPairingService, pairingProof, signEnvelope, createEnvelopeVerifier, redact, createStagingService } = require('../../src/services/etahen-link-agent');
|
|
|
|
test('pairing requires expiring proof-of-possession and cannot be replayed', () => {
|
|
let clock = 1000; let seed = 0;
|
|
const randomBytes = size => Buffer.alloc(size, ++seed);
|
|
const service = createPairingService({ now: () => clock, randomBytes, ttlMs: 100 });
|
|
const challenge = service.begin({ consoleId: 'console_12345678', runtimeEpoch: 'runtime_12345678' });
|
|
assert.ok(challenge.code.length >= 22);
|
|
const peer = { peerId: 'server_12345678', peerPublicKeySha256: 'a'.repeat(64) };
|
|
assert.throws(() => service.confirm({ challengeId: challenge.challengeId, ...peer, proof: 'b'.repeat(64) }), /proof/i);
|
|
const paired = service.confirm({ challengeId: challenge.challengeId, ...peer, proof: pairingProof(challenge, peer) });
|
|
assert.equal(paired.consoleId, 'console_12345678'); assert.equal(paired.sessionKey.length, 64);
|
|
assert.equal(service.peer(peer.peerId).sessionKey, undefined);
|
|
assert.throws(() => service.confirm({ challengeId: challenge.challengeId, ...peer, proof: pairingProof(challenge, peer) }), /unavailable/i);
|
|
clock = 2000; const expired = service.begin({ consoleId: 'console_12345678', runtimeEpoch: 'runtime_12345678' }); clock += 101;
|
|
assert.throws(() => service.confirm({ challengeId: expired.challengeId, ...peer, proof: pairingProof(expired, peer) }), /unavailable/i);
|
|
});
|
|
|
|
test('signed envelopes bind console, runtime, expiry and one-time nonce', () => {
|
|
const key = 'c'.repeat(64); const now = 100000;
|
|
const base = { jobId: 'job_12345678', nonce: 'nonce_12345678', consoleId: 'console_12345678', runtimeEpoch: 'runtime_12345678', issuedAt: now, expiresAt: now + 1000, job: { type: 'inventory.scan', input: {} } };
|
|
const verify = createEnvelopeVerifier({ consoleId: base.consoleId, runtimeEpoch: base.runtimeEpoch, sessionKey: key, now: () => now });
|
|
const signed = signEnvelope(base, key); assert.equal(verify(signed).jobId, base.jobId);
|
|
assert.throws(() => verify(signed), /already used/);
|
|
const wrong = signEnvelope({ ...base, nonce: 'nonce_22345678', consoleId: 'console_87654321' }, key);
|
|
assert.throws(() => verify(wrong), /another console/);
|
|
});
|
|
|
|
test('staging is resumable, hash-bound, atomic and never installed evidence', t => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-etahen-agent-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
const bytes = Buffer.from('bounded staged artifact'); const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
const staging = createStagingService({ root }); const session = staging.create({ artifactId: 'artifact_12345678', size: bytes.length, sha256 });
|
|
const first = bytes.subarray(0, 7); staging.append({ sessionId: session.sessionId, offset: 0, bytes: first, chunkSha256: crypto.createHash('sha256').update(first).digest('hex') });
|
|
assert.throws(() => staging.append({ sessionId: session.sessionId, offset: 0, bytes: Buffer.from('x'), chunkSha256: crypto.createHash('sha256').update('x').digest('hex') }), /chunk/);
|
|
const second = bytes.subarray(7); staging.append({ sessionId: session.sessionId, offset: 7, bytes: second, chunkSha256: crypto.createHash('sha256').update(second).digest('hex') });
|
|
const result = staging.finalize({ sessionId: session.sessionId }); assert.equal(result.status, 'staged'); assert.equal(result.installed, false); assert.deepEqual(fs.readFileSync(result.finalPath), bytes);
|
|
});
|
|
|
|
test('recovery trusts file evidence, not stale persisted offset', t => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-etahen-recover-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
const staging = createStagingService({ root }); const session = staging.create({ artifactId: 'artifact_87654321', size: 10, sha256: 'a'.repeat(64) });
|
|
fs.appendFileSync(session.partialPath, Buffer.from('abc'));
|
|
assert.deepEqual(staging.recover().find(item => item.sessionId === session.sessionId), { ...session, offset: 3, status: 'interrupted' });
|
|
const resumed = staging.resume({ sessionId: session.sessionId });
|
|
assert.equal(resumed.status, 'receiving'); assert.equal(resumed.offset, 3);
|
|
const remainder = Buffer.from('defghij');
|
|
staging.append({ sessionId: session.sessionId, offset: 3, bytes: remainder, chunkSha256: crypto.createHash('sha256').update(remainder).digest('hex') });
|
|
});
|
|
|
|
test('recovery closes the crash window after atomic publication', t => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-etahen-publish-recover-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
const bytes = Buffer.from('published before state persisted'); const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
const staging = createStagingService({ root, now: () => 12345 });
|
|
const session = staging.create({ artifactId: 'artifact_publish1', size: bytes.length, sha256 });
|
|
staging.append({ sessionId: session.sessionId, offset: 0, bytes, chunkSha256: sha256 });
|
|
fs.renameSync(session.partialPath, session.finalPath);
|
|
const recovered = staging.recover().find(item => item.sessionId === session.sessionId);
|
|
assert.equal(recovered.status, 'staged'); assert.equal(recovered.offset, bytes.length); assert.equal(recovered.stagedAt, 12345);
|
|
});
|
|
|
|
test('audit redaction removes nested secrets without altering evidence fields', () => {
|
|
assert.deepEqual(redact({ jobId: 'job_1', auth: { token: 'secret', sessionKey: 'key' }, result: 'staged' }), { jobId: 'job_1', auth: { token: '[REDACTED]', sessionKey: '[REDACTED]' }, result: 'staged' });
|
|
});
|
|
|
|
test('canonical envelopes reject cycles, excessive nesting and non-JSON values', () => {
|
|
const cyclic = {}; cyclic.self = cyclic;
|
|
assert.throws(() => canonical(cyclic), /cycle/i);
|
|
let deep = {}; let cursor = deep;
|
|
for (let index = 0; index < 34; index += 1) { cursor.child = {}; cursor = cursor.child; }
|
|
assert.throws(() => canonical(deep), /nesting/i);
|
|
assert.throws(() => canonical({ value: undefined }), /non-JSON/i);
|
|
});
|
|
|
|
test('staging rejects oversized chunks, external state and symlink swaps', t => {
|
|
const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-etahen-hostile-')); t.after(() => fs.rmSync(parent, { recursive: true, force: true }));
|
|
const root = path.join(parent, 'staging'); fs.mkdirSync(root);
|
|
assert.throws(() => createStagingService({ root, stateFile: path.join(parent, 'outside.json') }), /managed root/);
|
|
const service = createStagingService({ root, maxChunkBytes: 2 }); const session = service.create({ artifactId: 'artifact_hostile1', size: 3, sha256: 'a'.repeat(64) });
|
|
assert.throws(() => service.append({ sessionId: session.sessionId, offset: 0, bytes: Buffer.from('abc'), chunkSha256: crypto.createHash('sha256').update('abc').digest('hex') }), /configured bound/);
|
|
fs.rmSync(session.partialPath);
|
|
try {
|
|
fs.symlinkSync(path.join(parent, 'outside.bin'), session.partialPath);
|
|
assert.throws(() => service.append({ sessionId: session.sessionId, offset: 0, bytes: Buffer.from('a'), chunkSha256: crypto.createHash('sha256').update('a').digest('hex') }), /escaped/);
|
|
} catch (error) {
|
|
if (error.code !== 'EPERM') throw error;
|
|
fs.writeFileSync(session.partialPath, Buffer.alloc(0));
|
|
const statePath = path.join(root, 'sessions.json'); const persisted = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
persisted.sessions[0].partialPath = path.join(parent, 'outside.bin'); fs.writeFileSync(statePath, JSON.stringify(persisted));
|
|
const reloaded = createStagingService({ root, maxChunkBytes: 2 });
|
|
assert.throws(() => reloaded.append({ sessionId: session.sessionId, offset: 0, bytes: Buffer.from('a'), chunkSha256: crypto.createHash('sha256').update('a').digest('hex') }), /identity/);
|
|
}
|
|
});
|