Files
qinglong/packages/ql3-local-secret/test/localSecret.test.cjs
T

366 lines
11 KiB
JavaScript

const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { DatabaseSync } = require('node:sqlite');
const { test } = require('node:test');
const {
LocalSecretMutationConflictError,
LocalSecretUnavailableError,
LocalSecretVersionConflictError,
createLocalSecretRef,
parseLocalSecretRef,
} = require('@qinglong/runtime-core/local-secret');
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
const {
openLocalSqliteRuntimeDatabase,
} = require('@qinglong/local-sqlite/runtime');
const {
EncryptedLocalSecretService,
LocalSecretKeyringConflictError,
LocalSecretKeyringFileProvider,
decryptLocalSecretEnvelopeToBuffer,
encryptLocalSecretEnvelope,
provisionLocalSecretKeyring,
rotateLocalSecretKeyring,
} = require('../dist');
const KEY = Buffer.alloc(32, 0x11);
function fixture(t, profile = 'edge') {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-secret-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
return {
directory,
profile,
databasePath: path.join(directory, 'qinglong3.sqlite'),
keyringPath: path.join(directory, 'secret-keyring.json'),
};
}
function candidate(projectId = 'default') {
return {
runId: 'run-secret',
attemptId: 'attempt-secret',
projectId,
taskId: 'task-secret',
taskRevision: 'revision-secret',
attemptNumber: 1,
executorType: 'local_process',
priority: 0,
queuedAtMs: 1_760_000_000_000,
attemptCreatedAtMs: 1_760_000_000_000,
};
}
async function store(t, profile = 'edge') {
const value = fixture(t, profile);
await migrateLocalSqlitePath(value);
await provisionLocalSecretKeyring(value.keyringPath);
const runtime = await openLocalSqliteRuntimeDatabase(value);
t.after(() => runtime.close());
const keys = new LocalSecretKeyringFileProvider(value.keyringPath);
return {
...value,
runtime,
keys,
service: new EncryptedLocalSecretService(runtime.localSecrets, keys),
};
}
test('uses canonical Project-bound refs and metadata-authenticated AES-GCM', () => {
const ref = createLocalSecretRef({
projectId: 'default',
name: 'TOKEN',
version: 2,
});
assert.deepEqual(parseLocalSecretRef(ref), {
projectId: 'default',
name: 'TOKEN',
version: 2,
});
const unknown = Buffer.from(
JSON.stringify({ projectId: 'default', name: 'TOKEN', extra: true }),
).toString('base64url');
assert.throws(() => parseLocalSecretRef(`qlsecret:v1:${unknown}`));
const envelope = encryptLocalSecretEnvelope(
{
projectId: 'default',
name: 'TOKEN',
version: 1,
mutationId: 'create-token',
keyId: 'edge-key-1',
algorithm: 'aes-256-gcm',
createdAtMs: 100,
},
'fixed-secret',
KEY,
() => Buffer.alloc(12, 0x22),
);
assert.deepEqual(
{
nonce: envelope.nonce,
ciphertext: envelope.ciphertext,
authTag: envelope.authTag,
},
{
nonce: 'IiIiIiIiIiIiIiIi',
ciphertext: 'cZ5_LKTi7DqGTbtI',
authTag: '2af0_G7xHiOEBvMlsBSr0Q',
},
);
const plaintext = decryptLocalSecretEnvelopeToBuffer(envelope, KEY);
assert.equal(plaintext.toString('utf8'), 'fixed-secret');
plaintext.fill(0);
assert.throws(
() =>
decryptLocalSecretEnvelopeToBuffer(
{ ...envelope, projectId: 'another-project' },
KEY,
),
LocalSecretUnavailableError,
);
});
test('provisions once, reloads active rotation and keeps historical keys', async (t) => {
const value = fixture(t);
const first = await provisionLocalSecretKeyring(value.keyringPath);
assert.equal(first.keyIds.length, 1);
assert.equal(fs.statSync(value.keyringPath).mode & 0o777, 0o600);
await assert.rejects(
provisionLocalSecretKeyring(value.keyringPath),
LocalSecretUnavailableError,
);
const provider = new LocalSecretKeyringFileProvider(value.keyringPath);
const firstMaterial = await provider.active();
assert.equal(firstMaterial.keyId, first.activeKeyId);
firstMaterial.key.fill(0);
const second = await rotateLocalSecretKeyring({
filePath: value.keyringPath,
expectedActiveKeyId: first.activeKeyId,
});
assert.equal(second.keyIds.length, 2);
assert.notEqual(second.activeKeyId, first.activeKeyId);
const activeMaterial = await provider.active();
assert.equal(activeMaterial.keyId, second.activeKeyId);
activeMaterial.key.fill(0);
const historicalMaterial = await provider.resolve(first.activeKeyId);
assert.equal(historicalMaterial.key.length, 32);
historicalMaterial.key.fill(0);
await assert.rejects(
rotateLocalSecretKeyring({
filePath: value.keyringPath,
expectedActiveKeyId: first.activeKeyId,
}),
LocalSecretKeyringConflictError,
);
fs.chmodSync(value.keyringPath, 0o644);
await assert.rejects(provider.active(), LocalSecretUnavailableError);
});
test('rejects keyring symlinks and preserves an existing rotation lock', async (t) => {
const value = fixture(t);
const first = await provisionLocalSecretKeyring(value.keyringPath);
const symlinkPath = path.join(value.directory, 'secret-keyring-link.json');
fs.symlinkSync(value.keyringPath, symlinkPath);
const symlinkProvider = new LocalSecretKeyringFileProvider(symlinkPath);
await assert.rejects(symlinkProvider.active(), LocalSecretUnavailableError);
await assert.rejects(
rotateLocalSecretKeyring({
filePath: symlinkPath,
expectedActiveKeyId: first.activeKeyId,
}),
LocalSecretUnavailableError,
);
const lockPath = `${value.keyringPath}.lock`;
fs.writeFileSync(lockPath, 'external-manager\n', { mode: 0o600, flag: 'wx' });
await assert.rejects(
rotateLocalSecretKeyring({
filePath: value.keyringPath,
expectedActiveKeyId: first.activeKeyId,
}),
LocalSecretUnavailableError,
);
assert.equal(fs.readFileSync(lockPath, 'utf8'), 'external-manager\n');
assert.equal(
(await new LocalSecretKeyringFileProvider(value.keyringPath).inspect())
.activeKeyId,
first.activeKeyId,
);
});
test('creates and rotates append-only ciphertext through the Node 24 SQLite authority', async (t) => {
const value = await store(t);
const firstPlaintext = 'first-plaintext-never-persisted';
const secondPlaintext = 'second-plaintext-never-persisted';
const first = await value.service.put({
projectId: 'default',
name: 'TOKEN',
plaintext: firstPlaintext,
mutationId: 'create-token',
expectedCurrentVersion: 0,
createdAtMs: 100,
});
const rotatedKeyring = await rotateLocalSecretKeyring({
filePath: value.keyringPath,
expectedActiveKeyId: (await value.keys.inspect()).activeKeyId,
});
const second = await value.service.put({
projectId: 'default',
name: 'TOKEN',
plaintext: secondPlaintext,
mutationId: 'rotate-token',
expectedCurrentVersion: 1,
createdAtMs: 200,
});
assert.equal(first.version, 1);
assert.equal(second.version, 2);
assert.equal(
(await value.keys.inspect()).activeKeyId,
rotatedKeyring.activeKeyId,
);
assert.deepEqual(
await value.service.resolveLocalSecretEnvironment({
candidate: candidate(),
secretRefs: [
createLocalSecretRef({ projectId: 'default', name: 'TOKEN' }),
first.secretRef,
second.secretRef,
],
}),
[secondPlaintext, firstPlaintext, secondPlaintext],
);
const material = await value.service.resolveProjectSecretMaterial({
projectId: 'default',
secretRef: createLocalSecretRef({
projectId: 'default',
name: 'TOKEN',
}),
});
assert.ok(material);
assert.equal(material.secretRef.includes(secondPlaintext), false);
assert.equal(Buffer.from(material.bytes).toString('utf8'), secondPlaintext);
const ownedBytes = material.bytes;
await material.dispose();
assert.deepEqual([...ownedBytes], new Array(ownedBytes.length).fill(0));
await material.dispose();
const database = new DatabaseSync(value.databasePath, { readOnly: true });
try {
const rows = database
.prepare(
`SELECT ciphertext, key_id FROM "QingLong3LocalSecretEnvelopes"
ORDER BY version`,
)
.all();
assert.equal(rows.length, 2);
assert.notEqual(rows[0].key_id, rows[1].key_id);
assert.equal(JSON.stringify(rows).includes(firstPlaintext), false);
assert.equal(JSON.stringify(rows).includes(secondPlaintext), false);
} finally {
database.close();
}
});
test('replays semantic mutations, fences stale versions and checks Project before storage', async (t) => {
const value = await store(t, 'standalone');
const command = {
projectId: 'default',
name: 'TOKEN',
plaintext: 'same-value',
mutationId: 'create-token',
expectedCurrentVersion: 0,
createdAtMs: 100,
};
assert.equal((await value.service.put(command)).status, 'inserted');
assert.equal(
(await value.service.put({ ...command, createdAtMs: 999 })).status,
'existing',
);
await assert.rejects(
value.service.put({ ...command, plaintext: 'different-value' }),
LocalSecretMutationConflictError,
);
await assert.rejects(
value.service.put({ ...command, mutationId: 'stale-create' }),
LocalSecretVersionConflictError,
);
await assert.rejects(
value.service.resolveLocalSecretEnvironment({
candidate: candidate('default'),
secretRefs: [
createLocalSecretRef({ projectId: 'another', name: 'TOKEN' }),
],
}),
LocalSecretUnavailableError,
);
await assert.rejects(
value.service.resolveProjectSecretMaterial({
projectId: 'default',
secretRef: createLocalSecretRef({
projectId: 'another',
name: 'TOKEN',
}),
}),
LocalSecretUnavailableError,
);
});
test('two SQLite authorities allow exactly one rotation for one expected version', async (t) => {
const value = fixture(t, 'standalone');
await migrateLocalSqlitePath(value);
await provisionLocalSecretKeyring(value.keyringPath);
const firstRuntime = await openLocalSqliteRuntimeDatabase(value);
const secondRuntime = await openLocalSqliteRuntimeDatabase(value);
t.after(() => Promise.all([firstRuntime.close(), secondRuntime.close()]));
const keys = new LocalSecretKeyringFileProvider(value.keyringPath);
const first = new EncryptedLocalSecretService(
firstRuntime.localSecrets,
keys,
);
const second = new EncryptedLocalSecretService(
secondRuntime.localSecrets,
keys,
);
await first.put({
projectId: 'default',
name: 'TOKEN',
plaintext: 'initial',
mutationId: 'initial',
expectedCurrentVersion: 0,
createdAtMs: 1,
});
const results = await Promise.allSettled([
first.put({
projectId: 'default',
name: 'TOKEN',
plaintext: 'winner-a',
mutationId: 'rotate-a',
expectedCurrentVersion: 1,
createdAtMs: 2,
}),
second.put({
projectId: 'default',
name: 'TOKEN',
plaintext: 'winner-b',
mutationId: 'rotate-b',
expectedCurrentVersion: 1,
createdAtMs: 2,
}),
]);
assert.equal(
results.filter(({ status }) => status === 'fulfilled').length,
1,
);
assert.equal(results.filter(({ status }) => status === 'rejected').length, 1);
assert.equal(
results.find(({ status }) => status === 'rejected').reason.constructor,
LocalSecretVersionConflictError,
);
});