mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
326 lines
9.3 KiB
JavaScript
326 lines
9.3 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 {
|
|
LocalSecretAuthorizationFenceConflictError,
|
|
} = require('@qinglong/runtime-core/local-secret-administration');
|
|
const {
|
|
LocalSecretMutationConflictError,
|
|
} = require('@qinglong/runtime-core/local-secret');
|
|
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
|
const {
|
|
openLocalSqliteRuntimeDatabase,
|
|
} = require('@qinglong/local-sqlite/runtime');
|
|
const {
|
|
LocalSecretKeyringFileProvider,
|
|
provisionLocalSecretKeyring,
|
|
} = require('@qinglong/local-secret');
|
|
const {
|
|
LocalSecretAdministrationAuthenticationError,
|
|
LocalSecretAdministrationAuthorizationError,
|
|
LocalSecretAdministrationUnavailableError,
|
|
createLocalSecretAdministrationService,
|
|
} = require('@qinglong/local-admin/secret-administration');
|
|
|
|
const NOW = 1_760_000_000_000;
|
|
|
|
function fixture(t) {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-secret-admin-'));
|
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
return {
|
|
directory,
|
|
profile: 'edge',
|
|
databasePath: path.join(directory, 'qinglong3.sqlite'),
|
|
keyringPath: path.join(directory, 'secret-keyring.json'),
|
|
};
|
|
}
|
|
|
|
function principal(overrides = {}) {
|
|
return {
|
|
subject: { type: 'user', id: 'user-owner' },
|
|
authenticationId: 'local-console-1',
|
|
authenticatedAtMs: NOW - 1_000,
|
|
expiresAtMs: NOW + 60_000,
|
|
assurance: 'local_console',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function request(mutationId, overrides = {}) {
|
|
return {
|
|
projectId: 'default',
|
|
name: 'TOKEN',
|
|
plaintext: 'never-persist-this-plaintext',
|
|
mutationId,
|
|
requestId: `request-${mutationId.slice(-4)}`,
|
|
expectedCurrentVersion: 0,
|
|
principal: principal(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function openStore(t) {
|
|
const value = fixture(t);
|
|
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 };
|
|
}
|
|
|
|
async function bind(runtime, role, version = 1, state = 'active') {
|
|
return runtime.projectPolicy.append({
|
|
expectedCurrentVersion: version - 1,
|
|
binding: {
|
|
projectId: 'default',
|
|
subject: { type: 'user', id: 'user-owner' },
|
|
version,
|
|
state,
|
|
...(state === 'active' ? { role } : {}),
|
|
mutationId: `binding-${version}`,
|
|
changedBy: { type: 'system', id: 'owner-bootstrap' },
|
|
createdAtMs: NOW + version,
|
|
},
|
|
});
|
|
}
|
|
|
|
function service(value, overrides = {}) {
|
|
return createLocalSecretAdministrationService(
|
|
overrides.projectPolicy ?? value.runtime.projectPolicy,
|
|
overrides.mutations ?? value.runtime.localSecretAdministration,
|
|
overrides.audit ?? value.runtime.securityAudit,
|
|
overrides.keys ?? value.keys,
|
|
{ now: () => NOW, nonceFactory: () => Buffer.alloc(12, 7) },
|
|
);
|
|
}
|
|
|
|
test('owner writes one encrypted envelope and allowed audit atomically', async (t) => {
|
|
const value = await openStore(t);
|
|
assert.equal(
|
|
(await value.runtime.projectPolicy.resolve('default', principal().subject))
|
|
.binding,
|
|
undefined,
|
|
);
|
|
await bind(value.runtime, 'owner');
|
|
const mutationId = '00000000-0000-4000-8000-000000000001';
|
|
const result = await service(value).put(request(mutationId));
|
|
assert.equal(result.status, 'inserted');
|
|
assert.equal(result.version, 1);
|
|
|
|
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
|
try {
|
|
const secret = database
|
|
.prepare(
|
|
`SELECT version, ciphertext FROM "QingLong3LocalSecretEnvelopes"
|
|
WHERE mutation_id = ?`,
|
|
)
|
|
.get(mutationId);
|
|
const audit = database
|
|
.prepare(
|
|
`SELECT operation_id, outcome, subject_id, fence_project_version,
|
|
fence_binding_version
|
|
FROM "QingLong3SecurityAuditEvents" WHERE event_id = ?`,
|
|
)
|
|
.get(mutationId);
|
|
assert.equal(secret.version, 1);
|
|
assert.equal(
|
|
Buffer.from(secret.ciphertext).includes(
|
|
Buffer.from('never-persist-this-plaintext'),
|
|
),
|
|
false,
|
|
);
|
|
assert.deepEqual(
|
|
{ ...audit },
|
|
{
|
|
operation_id: 'secret.create',
|
|
outcome: 'allowed',
|
|
subject_id: 'user-owner',
|
|
fence_project_version: 1,
|
|
fence_binding_version: 1,
|
|
},
|
|
);
|
|
} finally {
|
|
database.close();
|
|
}
|
|
});
|
|
|
|
test('denial is audited before key access and ownerless default stays closed', async (t) => {
|
|
const value = await openStore(t);
|
|
let keyReads = 0;
|
|
const keys = {
|
|
async active() {
|
|
keyReads += 1;
|
|
throw new Error('must not run');
|
|
},
|
|
async resolve() {
|
|
keyReads += 1;
|
|
throw new Error('must not run');
|
|
},
|
|
};
|
|
const mutationId = '00000000-0000-4000-8000-000000000002';
|
|
await assert.rejects(
|
|
service(value, { keys }).put(request(mutationId)),
|
|
LocalSecretAdministrationAuthorizationError,
|
|
);
|
|
assert.equal(keyReads, 0);
|
|
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
|
try {
|
|
assert.equal(
|
|
database
|
|
.prepare(
|
|
`SELECT outcome FROM "QingLong3SecurityAuditEvents"
|
|
WHERE event_id = ?`,
|
|
)
|
|
.get(mutationId).outcome,
|
|
'denied',
|
|
);
|
|
assert.equal(
|
|
database
|
|
.prepare(
|
|
'SELECT COUNT(*) AS count FROM "QingLong3LocalSecretEnvelopes"',
|
|
)
|
|
.get().count,
|
|
0,
|
|
);
|
|
} finally {
|
|
database.close();
|
|
}
|
|
});
|
|
|
|
test('weak authentication is audited without Policy or key access', async (t) => {
|
|
const value = await openStore(t);
|
|
let policyReads = 0;
|
|
let keyReads = 0;
|
|
const projectPolicy = {
|
|
async resolve() {
|
|
policyReads += 1;
|
|
throw new Error('must not run');
|
|
},
|
|
append: value.runtime.projectPolicy.append,
|
|
};
|
|
const keys = {
|
|
async active() {
|
|
keyReads += 1;
|
|
throw new Error('must not run');
|
|
},
|
|
async resolve() {
|
|
keyReads += 1;
|
|
throw new Error('must not run');
|
|
},
|
|
};
|
|
const mutationId = '00000000-0000-4000-8000-000000000003';
|
|
await assert.rejects(
|
|
service(value, { projectPolicy, keys }).put(
|
|
request(mutationId, {
|
|
principal: principal({ assurance: 'single_factor' }),
|
|
}),
|
|
),
|
|
LocalSecretAdministrationAuthenticationError,
|
|
);
|
|
assert.equal(policyReads, 0);
|
|
assert.equal(keyReads, 0);
|
|
});
|
|
|
|
test('revocation after Policy decision is fenced inside the write transaction', async (t) => {
|
|
const value = await openStore(t);
|
|
await bind(value.runtime, 'admin');
|
|
let revoked = false;
|
|
const projectPolicy = {
|
|
async resolve(projectId, subject) {
|
|
const snapshot = await value.runtime.projectPolicy.resolve(
|
|
projectId,
|
|
subject,
|
|
);
|
|
if (!revoked) {
|
|
revoked = true;
|
|
await bind(value.runtime, undefined, 2, 'revoked');
|
|
}
|
|
return snapshot;
|
|
},
|
|
append: (...args) => value.runtime.projectPolicy.append(...args),
|
|
};
|
|
const mutationId = '00000000-0000-4000-8000-000000000004';
|
|
await assert.rejects(
|
|
service(value, { projectPolicy }).put(request(mutationId)),
|
|
LocalSecretAuthorizationFenceConflictError,
|
|
);
|
|
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
|
try {
|
|
assert.equal(
|
|
database
|
|
.prepare(
|
|
'SELECT COUNT(*) AS count FROM "QingLong3LocalSecretEnvelopes"',
|
|
)
|
|
.get().count,
|
|
0,
|
|
);
|
|
assert.equal(
|
|
database
|
|
.prepare(
|
|
`SELECT COUNT(*) AS count FROM "QingLong3SecurityAuditEvents"
|
|
WHERE event_id = ?`,
|
|
)
|
|
.get(mutationId).count,
|
|
0,
|
|
);
|
|
} finally {
|
|
database.close();
|
|
}
|
|
});
|
|
|
|
test('audit insertion failure rolls the encrypted envelope back', async (t) => {
|
|
const value = await openStore(t);
|
|
await bind(value.runtime, 'owner');
|
|
const database = new DatabaseSync(value.databasePath);
|
|
database.exec(`
|
|
CREATE TRIGGER reject_secret_audit
|
|
BEFORE INSERT ON "QingLong3SecurityAuditEvents"
|
|
WHEN NEW.operation_id IN ('secret.create', 'secret.rotate')
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'audit unavailable');
|
|
END
|
|
`);
|
|
database.close();
|
|
|
|
await assert.rejects(
|
|
service(value).put(request('00000000-0000-4000-8000-000000000005')),
|
|
LocalSecretAdministrationUnavailableError,
|
|
);
|
|
const check = new DatabaseSync(value.databasePath, { readOnly: true });
|
|
try {
|
|
assert.equal(
|
|
check
|
|
.prepare(
|
|
'SELECT COUNT(*) AS count FROM "QingLong3LocalSecretEnvelopes"',
|
|
)
|
|
.get().count,
|
|
0,
|
|
);
|
|
assert.equal(
|
|
check
|
|
.prepare('SELECT COUNT(*) AS count FROM "QingLong3SecurityAuditEvents"')
|
|
.get().count,
|
|
0,
|
|
);
|
|
} finally {
|
|
check.close();
|
|
}
|
|
});
|
|
|
|
test('semantic replay returns no plaintext and conflicting replay fails closed', async (t) => {
|
|
const value = await openStore(t);
|
|
await bind(value.runtime, 'owner');
|
|
const mutationId = '00000000-0000-4000-8000-000000000006';
|
|
const admin = service(value);
|
|
assert.equal((await admin.put(request(mutationId))).status, 'inserted');
|
|
assert.equal((await admin.put(request(mutationId))).status, 'existing');
|
|
await assert.rejects(
|
|
admin.put(request(mutationId, { plaintext: 'different' })),
|
|
LocalSecretMutationConflictError,
|
|
);
|
|
});
|