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