Files
qinglong/test/back/localSecretStore.test.cjs

441 lines
13 KiB
JavaScript

require('ts-node/register/transpile-only');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
const { QueryTypes, Sequelize } = require('sequelize');
const {
defineSchemaMigrationModel,
} = require('../../back/data/schemaMigration');
const {
LOCAL_SECRET_ENVELOPE_TABLE,
localSecretEnvelopeMigration,
} = require('../../back/migrations/0014-local-secret-envelopes');
const { runMigrations } = require('../../back/migrations/runner');
const {
decryptLocalSecretEnvelopeToBuffer,
encryptLocalSecretEnvelope,
} = require('../../back/runtime/adapters/crypto/aes256GcmLocalSecret');
const {
LocalSecretKeyringFileProvider,
} = require('../../back/runtime/adapters/fs/localSecretKeyringFileProvider');
const {
LegacySequelizeLocalSecretEnvelopeRepository,
} = require('../../back/runtime/adapters/legacy-sequelize/localSecretEnvelopeRepository');
const {
EncryptedLocalSecretService,
LocalSecretMutationConflictError,
LocalSecretVersionConflictError,
} = require('../../back/runtime/application/encryptedLocalSecretService');
const {
LOCAL_SECRET_ALGORITHM,
LocalSecretUnavailableError,
createLocalSecretRef,
parseLocalSecretRef,
} = require('../../back/runtime/domain/localSecret');
const KEY = Buffer.alloc(32, 0x11);
function keyProvider(key = KEY) {
return {
async active() {
return { keyId: 'edge-key-1', key: Uint8Array.from(key) };
},
async resolve(keyId) {
return keyId === 'edge-key-1'
? { keyId, key: Uint8Array.from(key) }
: null;
},
};
}
function candidate(projectId = 'default') {
return {
runId: 'run-secret',
attemptId: 'attempt-secret',
projectId,
taskId: 'task-secret',
taskRevision: 'revision-secret',
executorType: 'local_process',
priority: 0,
queuedAtMs: 1_760_000_000_000,
attemptCreatedAtMs: 1_760_000_000_000,
};
}
async function createStore(t, storage = ':memory:') {
const database = new Sequelize({
dialect: 'sqlite',
storage,
logging: false,
});
t.after(() => database.close());
await runMigrations({
database,
migrationModel: defineSchemaMigrationModel(database),
migrations: [localSecretEnvelopeMigration],
logger: { info() {} },
});
let nonce = 0;
const repository = new LegacySequelizeLocalSecretEnvelopeRepository(database);
const service = new EncryptedLocalSecretService(
repository,
keyProvider(),
() => Buffer.alloc(12, nonce++),
);
return { database, repository, service };
}
test('uses a stable AES-256-GCM envelope vector bound to metadata AAD', () => {
const envelope = encryptLocalSecretEnvelope(
{
projectId: 'default',
name: 'TOKEN',
version: 1,
mutationId: 'mutation-1',
keyId: 'edge-key-1',
algorithm: LOCAL_SECRET_ALGORITHM,
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: 'tsA93yEy5RJCD5Q6wngfgA',
},
);
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('SecretRef is canonical, project-bound and optionally pins an integer version', () => {
const current = createLocalSecretRef({ projectId: 'default', name: 'TOKEN' });
const exact = createLocalSecretRef({
projectId: 'default',
name: 'TOKEN',
version: 2,
});
assert.deepEqual(parseLocalSecretRef(current), {
projectId: 'default',
name: 'TOKEN',
});
assert.deepEqual(parseLocalSecretRef(exact), {
projectId: 'default',
name: 'TOKEN',
version: 2,
});
const unknownField = Buffer.from(
JSON.stringify({ projectId: 'default', name: 'TOKEN', extra: true }),
).toString('base64url');
assert.throws(() => parseLocalSecretRef(`qlsecret:v1:${unknownField}`));
assert.throws(() =>
createLocalSecretRef({ projectId: 'default', name: 'TOKEN', version: 0 }),
);
});
test('creates and rotates append-only versions without storing plaintext', async (t) => {
const { database, service } = await createStore(t);
const firstPlaintext = 'first-plaintext-never-persisted';
const secondPlaintext = 'second-plaintext-never-persisted';
const first = await service.put({
projectId: 'default',
name: 'TOKEN',
plaintext: firstPlaintext,
mutationId: 'create-token',
expectedCurrentVersion: 0,
createdAtMs: 100,
});
const second = await service.put({
projectId: 'default',
name: 'TOKEN',
plaintext: secondPlaintext,
mutationId: 'rotate-token',
expectedCurrentVersion: 1,
createdAtMs: 200,
});
assert.deepEqual(
{ status: first.status, version: first.version },
{ status: 'inserted', version: 1 },
);
assert.deepEqual(
{ status: second.status, version: second.version },
{ status: 'inserted', version: 2 },
);
const resolved = await service.resolve({
candidate: candidate(),
secretRefs: [
createLocalSecretRef({ projectId: 'default', name: 'TOKEN' }),
first.secretRef,
second.secretRef,
],
});
assert.deepEqual(resolved, [
secondPlaintext,
firstPlaintext,
secondPlaintext,
]);
const rows = await database.query(
`SELECT * FROM "${LOCAL_SECRET_ENVELOPE_TABLE}" ORDER BY version`,
{ type: QueryTypes.SELECT },
);
assert.equal(rows.length, 2);
for (const row of rows) {
assert.ok(Buffer.isBuffer(row.ciphertext));
assert.equal(row.ciphertext.includes(Buffer.from(firstPlaintext)), false);
assert.equal(row.ciphertext.includes(Buffer.from(secondPlaintext)), false);
assert.equal(JSON.stringify(row).includes(firstPlaintext), false);
assert.equal(JSON.stringify(row).includes(secondPlaintext), false);
}
});
test('replays mutations idempotently and fences stale rotations', async (t) => {
const { service } = await createStore(t);
const command = {
projectId: 'default',
name: 'TOKEN',
plaintext: 'same-value',
mutationId: 'create-token',
expectedCurrentVersion: 0,
createdAtMs: 100,
};
assert.equal((await service.put(command)).status, 'inserted');
assert.equal(
(await service.put({ ...command, createdAtMs: 999 })).status,
'existing',
);
await assert.rejects(
service.put({ ...command, plaintext: 'different-value' }),
LocalSecretMutationConflictError,
);
await assert.rejects(
service.put({
...command,
mutationId: 'stale-create',
plaintext: 'stale-value',
}),
LocalSecretVersionConflictError,
);
});
test('batch resolution preserves position, supports missing, and checks Project first', async (t) => {
const { repository, service } = await createStore(t);
await service.put({
projectId: 'default',
name: 'A',
plaintext: 'value-a',
mutationId: 'create-a',
expectedCurrentVersion: 0,
createdAtMs: 100,
});
await service.put({
projectId: 'default',
name: 'B',
plaintext: 'value-b',
mutationId: 'create-b',
expectedCurrentVersion: 0,
createdAtMs: 100,
});
const refs = [
{ projectId: 'default', name: 'B' },
{ projectId: 'default', name: 'missing' },
{ projectId: 'default', name: 'A', version: 1 },
];
const envelopes = await repository.resolveMany(refs);
assert.deepEqual(
envelopes.map((item) => item && item.name),
['B', null, 'A'],
);
assert.equal(
await service.resolve({
candidate: candidate(),
secretRefs: refs.map(createLocalSecretRef),
}),
null,
);
let databaseCalls = 0;
const isolated = new EncryptedLocalSecretService(
{
async append() {
throw new Error('not used');
},
async findByMutation() {
throw new Error('not used');
},
async resolveMany() {
databaseCalls += 1;
return [];
},
},
keyProvider(),
);
await assert.rejects(
isolated.resolve({
candidate: candidate('default'),
secretRefs: [
createLocalSecretRef({ projectId: 'another', name: 'TOKEN' }),
],
}),
LocalSecretUnavailableError,
);
assert.equal(databaseCalls, 0);
});
test('corrupt ciphertext and wrong keys fail with a generic non-secret error', async (t) => {
const { database, repository, service } = await createStore(t);
const plaintext = 'must-not-appear-in-errors';
await service.put({
projectId: 'default',
name: 'TOKEN',
plaintext,
mutationId: 'create-token',
expectedCurrentVersion: 0,
createdAtMs: 100,
});
await database
.getQueryInterface()
.bulkUpdate(
LOCAL_SECRET_ENVELOPE_TABLE,
{ auth_tag: Buffer.alloc(16) },
{ project_id: 'default', secret_name: 'TOKEN', version: 1 },
);
const request = {
candidate: candidate(),
secretRefs: [createLocalSecretRef({ projectId: 'default', name: 'TOKEN' })],
};
await assert.rejects(service.resolve(request), (error) => {
assert.equal(error.constructor, LocalSecretUnavailableError);
assert.equal(error.message, 'Local Secret is unavailable');
assert.equal(error.message.includes(plaintext), false);
assert.equal(error.message.includes('TOKEN'), false);
return true;
});
const wrongKeyService = new EncryptedLocalSecretService(
repository,
keyProvider(Buffer.alloc(32, 0x33)),
);
await assert.rejects(
wrongKeyService.resolve(request),
LocalSecretUnavailableError,
);
});
test('private keyring reloads rotation and rejects broad modes and symlinks', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql3-secret-keyring-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const keyringPath = path.join(root, 'keyring.json');
const manifest = (activeKeyId, keys) =>
JSON.stringify({ version: 1, activeKeyId, keys });
const first = Buffer.alloc(32, 0x11).toString('base64url');
const second = Buffer.alloc(32, 0x22).toString('base64url');
await fs.writeFile(keyringPath, manifest('key-1', { 'key-1': first }), {
mode: 0o600,
});
const provider = new LocalSecretKeyringFileProvider(keyringPath);
assert.equal((await provider.active()).keyId, 'key-1');
await fs.writeFile(
keyringPath,
manifest('key-2', { 'key-1': first, 'key-2': second }),
);
await fs.chmod(keyringPath, 0o600);
assert.equal((await provider.active()).keyId, 'key-2');
assert.equal(
Buffer.from((await provider.resolve('key-1')).key).equals(KEY),
true,
);
await fs.chmod(keyringPath, 0o644);
await assert.rejects(provider.active(), LocalSecretUnavailableError);
await fs.chmod(keyringPath, 0o600);
const symlinkPath = path.join(root, 'keyring-link.json');
await fs.symlink(keyringPath, symlinkPath);
await assert.rejects(
new LocalSecretKeyringFileProvider(symlinkPath).active(),
LocalSecretUnavailableError,
);
await fs.writeFile(
keyringPath,
JSON.stringify({ version: 1, activeKeyId: 'key-1', keys: {}, extra: true }),
);
await fs.chmod(keyringPath, 0o600);
await assert.rejects(provider.active(), LocalSecretUnavailableError);
});
test('concurrent rotations serialize so exactly one expected version wins', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql3-secret-db-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const storage = path.join(root, 'database.sqlite');
const firstStore = await createStore(t, storage);
await firstStore.service.put({
projectId: 'default',
name: 'TOKEN',
plaintext: 'version-one',
mutationId: 'create-token',
expectedCurrentVersion: 0,
createdAtMs: 100,
});
const secondDatabase = new Sequelize({
dialect: 'sqlite',
storage,
logging: false,
});
t.after(() => secondDatabase.close());
const secondService = new EncryptedLocalSecretService(
new LegacySequelizeLocalSecretEnvelopeRepository(secondDatabase),
keyProvider(),
);
const rotations = await Promise.allSettled([
firstStore.service.put({
projectId: 'default',
name: 'TOKEN',
plaintext: 'rotation-a',
mutationId: 'rotate-a',
expectedCurrentVersion: 1,
createdAtMs: 200,
}),
secondService.put({
projectId: 'default',
name: 'TOKEN',
plaintext: 'rotation-b',
mutationId: 'rotate-b',
expectedCurrentVersion: 1,
createdAtMs: 201,
}),
]);
assert.equal(
rotations.filter((item) => item.status === 'fulfilled').length,
1,
);
assert.equal(
rotations.filter((item) => item.status === 'rejected').length,
1,
);
assert.equal(
rotations.find((item) => item.status === 'rejected').reason.constructor,
LocalSecretVersionConflictError,
);
});