mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user