mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:38:14 +08:00
366 lines
11 KiB
JavaScript
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,
|
|
);
|
|
});
|