mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
518 lines
16 KiB
JavaScript
518 lines
16 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { createHash } = require('node:crypto');
|
|
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 {
|
|
ApiCredentialUnavailableError,
|
|
} = require('@qinglong/runtime-core/api-credential');
|
|
const {
|
|
apiCredentialSecretDigest,
|
|
formatApiCredentialToken,
|
|
} = require('@qinglong/runtime-core/api-credential-token');
|
|
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
|
const {
|
|
openLocalSqliteBootstrapDatabase,
|
|
} = require('@qinglong/local-sqlite/bootstrap');
|
|
const {
|
|
openLocalSqliteRuntimeDatabase,
|
|
} = require('@qinglong/local-sqlite/runtime');
|
|
const {
|
|
LocalOwnerPepperKeyringFileProvider,
|
|
provisionLocalOwnerPepperKey,
|
|
restoreLocalOwnerPepperKey,
|
|
} = require('@qinglong/local-owner-console/pepper-custody');
|
|
const {
|
|
LocalIdentityAuthenticationConfigurationError,
|
|
LocalIdentityAuthenticationUnavailableError,
|
|
createLocalIdentityAuthenticator,
|
|
createLocalIdentityKeyringAuthenticator,
|
|
} = require('@qinglong/local-owner-console/identity-authentication');
|
|
|
|
const NOW = 1_800_000_000_000;
|
|
const PEPPER = Buffer.alloc(32, 7).toString('base64url');
|
|
const SECRET = Buffer.alloc(32, 11).toString('base64url');
|
|
const CREDENTIAL_ID = 'fresh-owner';
|
|
const TOKEN = formatApiCredentialToken(CREDENTIAL_ID, SECRET);
|
|
|
|
function fixture(t) {
|
|
const directory = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ql3-local-identity-'),
|
|
);
|
|
const databasePath = path.join(directory, 'qinglong3.sqlite');
|
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
return databasePath;
|
|
}
|
|
|
|
function seed(databasePath, options = {}) {
|
|
const client = new DatabaseSync(databasePath);
|
|
try {
|
|
const materialDigest = createHash('sha256')
|
|
.update('qinglong.local-owner-pepper.summary.v1\0', 'utf8')
|
|
.update(PEPPER, 'utf8')
|
|
.digest('hex');
|
|
if (options.recoveryRequired) {
|
|
client
|
|
.prepare(
|
|
`INSERT INTO "QingLong3LocalOwnerPepperKeys" (
|
|
"pepper_key_id", "state", "version", "registered_at_ms"
|
|
) VALUES ('legacy-v1', 'recovery_required', 1, 0)`,
|
|
)
|
|
.run();
|
|
} else {
|
|
client
|
|
.prepare(
|
|
`INSERT INTO "QingLong3LocalOwnerPepperKeys" (
|
|
"pepper_key_id", "material_digest", "backup_digest", "state",
|
|
"version", "register_mutation_id", "activate_mutation_id",
|
|
"registered_at_ms", "activated_at_ms"
|
|
) VALUES (
|
|
'legacy-v1', ?, ?, 'active', 2,
|
|
'00000000-0000-4000-8000-000000000091',
|
|
'00000000-0000-4000-8000-000000000092', ?, ?
|
|
)`,
|
|
)
|
|
.run(materialDigest, 'b'.repeat(64), NOW - 2_000, NOW - 1_500);
|
|
client
|
|
.prepare(
|
|
`INSERT INTO "QingLong3LocalOwnerPepperActivations" (
|
|
"generation", "mutation_id", "expected_generation",
|
|
"previous_pepper_key_id", "active_pepper_key_id",
|
|
"material_digest", "backup_digest", "activated_at_ms"
|
|
) VALUES (
|
|
1, '00000000-0000-4000-8000-000000000092', 0,
|
|
NULL, 'legacy-v1', ?, ?, ?
|
|
)`,
|
|
)
|
|
.run(materialDigest, 'b'.repeat(64), NOW - 1_500);
|
|
}
|
|
client
|
|
.prepare(
|
|
`INSERT INTO "QingLong3IdentitySubjects" (
|
|
"subject_type", "subject_id", "status", "version",
|
|
"created_at_ms", "updated_at_ms"
|
|
) VALUES ('user', 'user-01', ?, 1, ?, ?)`,
|
|
)
|
|
.run(options.subjectStatus ?? 'active', NOW - 1_000, NOW - 1_000);
|
|
client
|
|
.prepare(
|
|
`INSERT INTO "QingLong3ApiCredentials" (
|
|
"credential_id", "version", "state", "subject_type",
|
|
"subject_id", "secret_digest", "created_at_ms",
|
|
"not_before_at_ms", "expires_at_ms"
|
|
) VALUES (?, 1, ?, 'user', 'user-01', ?, ?, ?, ?)`,
|
|
)
|
|
.run(
|
|
CREDENTIAL_ID,
|
|
options.state ?? 'active',
|
|
apiCredentialSecretDigest(PEPPER, CREDENTIAL_ID, SECRET),
|
|
NOW - 1_000,
|
|
options.notBeforeAtMs ?? NOW - 1_000,
|
|
options.expiresAtMs ?? NOW + 600_000,
|
|
);
|
|
if (!options.omitPepperBinding) {
|
|
client
|
|
.prepare(
|
|
`INSERT INTO "QingLong3ApiCredentialPepperBindings" (
|
|
"credential_id", "credential_version", "pepper_key_id"
|
|
) VALUES (?, 1, 'legacy-v1')`,
|
|
)
|
|
.run(CREDENTIAL_ID);
|
|
}
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
|
|
test('authenticates one stable local User through the shared SQLite authority', async (t) => {
|
|
const databasePath = fixture(t);
|
|
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
|
seed(databasePath);
|
|
const runtime = await openLocalSqliteRuntimeDatabase({
|
|
databasePath,
|
|
profile: 'edge',
|
|
});
|
|
const authenticator = createLocalIdentityAuthenticator(
|
|
runtime.apiCredentials,
|
|
PEPPER,
|
|
{ now: () => NOW },
|
|
);
|
|
const principal = await authenticator.authenticate(TOKEN);
|
|
assert.deepEqual(principal, {
|
|
subject: { type: 'user', id: 'user-01' },
|
|
authenticationId: 'local_credential:fresh-owner:1',
|
|
authenticatedAtMs: NOW,
|
|
expiresAtMs: NOW + 60_000,
|
|
assurance: 'single_factor',
|
|
});
|
|
const authentication = await authenticator.authenticateCredential(TOKEN);
|
|
assert.deepEqual(authentication, {
|
|
principal,
|
|
credentialId: CREDENTIAL_ID,
|
|
credentialVersion: 1,
|
|
});
|
|
await runtime.close();
|
|
});
|
|
|
|
test('authenticates through the runtime catalog and bounded POSIX keyring', async (t) => {
|
|
const databasePath = fixture(t);
|
|
const keyringDirectory = path.join(path.dirname(databasePath), 'keyring');
|
|
fs.mkdirSync(keyringDirectory, { mode: 0o700 });
|
|
provisionLocalOwnerPepperKey({
|
|
keyringDirectory,
|
|
pepperKeyId: 'legacy-v1',
|
|
randomBytes: () => Buffer.alloc(32, 7),
|
|
});
|
|
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
|
seed(databasePath);
|
|
const runtime = await openLocalSqliteRuntimeDatabase({
|
|
databasePath,
|
|
profile: 'edge',
|
|
});
|
|
const authenticator = createLocalIdentityKeyringAuthenticator(
|
|
runtime.apiCredentials,
|
|
runtime.ownerPepper,
|
|
new LocalOwnerPepperKeyringFileProvider(keyringDirectory),
|
|
{ now: () => NOW },
|
|
);
|
|
assert.equal(
|
|
(await authenticator.authenticate(TOKEN))?.subject.id,
|
|
'user-01',
|
|
);
|
|
await runtime.close();
|
|
await assert.rejects(
|
|
authenticator.authenticate(TOKEN),
|
|
LocalIdentityAuthenticationUnavailableError,
|
|
);
|
|
});
|
|
|
|
test('restores a recovery-required legacy key before explicit activation', async (t) => {
|
|
const databasePath = fixture(t);
|
|
const keyringDirectory = path.join(path.dirname(databasePath), 'keyring');
|
|
const backupDirectory = path.join(path.dirname(databasePath), 'backup');
|
|
fs.mkdirSync(keyringDirectory, { mode: 0o700 });
|
|
fs.mkdirSync(backupDirectory, { mode: 0o700 });
|
|
const backup = provisionLocalOwnerPepperKey({
|
|
keyringDirectory: backupDirectory,
|
|
pepperKeyId: 'legacy-v1',
|
|
randomBytes: () => Buffer.alloc(32, 7),
|
|
});
|
|
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
|
seed(databasePath, { recoveryRequired: true });
|
|
assert.deepEqual(
|
|
restoreLocalOwnerPepperKey({
|
|
keyringDirectory,
|
|
backupDirectory,
|
|
pepperKeyId: 'legacy-v1',
|
|
}),
|
|
backup,
|
|
);
|
|
|
|
const bootstrap = await openLocalSqliteBootstrapDatabase({
|
|
databasePath,
|
|
profile: 'edge',
|
|
});
|
|
assert.equal(
|
|
(await bootstrap.ownerPepper.resolveKey('legacy-v1'))?.state,
|
|
'recovery_required',
|
|
);
|
|
await bootstrap.ownerPepper.register({
|
|
mutationId: '00000000-0000-4000-8000-000000000093',
|
|
pepperKeyId: 'legacy-v1',
|
|
materialDigest: backup.digest,
|
|
backupDigest: backup.digest,
|
|
registeredAtMs: NOW - 900,
|
|
});
|
|
await bootstrap.ownerPepper.activate({
|
|
mutationId: '00000000-0000-4000-8000-000000000094',
|
|
pepperKeyId: 'legacy-v1',
|
|
expectedGeneration: 0,
|
|
activatedAtMs: NOW - 800,
|
|
});
|
|
await bootstrap.close();
|
|
|
|
const runtime = await openLocalSqliteRuntimeDatabase({
|
|
databasePath,
|
|
profile: 'edge',
|
|
});
|
|
const authenticator = createLocalIdentityKeyringAuthenticator(
|
|
runtime.apiCredentials,
|
|
runtime.ownerPepper,
|
|
new LocalOwnerPepperKeyringFileProvider(keyringDirectory),
|
|
{ now: () => NOW },
|
|
);
|
|
assert.equal(
|
|
(await authenticator.authenticate(TOKEN))?.subject.id,
|
|
'user-01',
|
|
);
|
|
await runtime.close();
|
|
});
|
|
|
|
test('resolves active and retired credential keys through the exact catalog identity', async () => {
|
|
const oldPepper = Buffer.alloc(32, 21).toString('base64url');
|
|
const newPepper = Buffer.alloc(32, 22).toString('base64url');
|
|
const newSecret = Buffer.alloc(32, 23).toString('base64url');
|
|
const digest = (pepper) =>
|
|
createHash('sha256')
|
|
.update('qinglong.local-owner-pepper.summary.v1\0', 'utf8')
|
|
.update(pepper, 'utf8')
|
|
.digest('hex');
|
|
const records = new Map([
|
|
[
|
|
'owner-old',
|
|
{
|
|
credentialId: 'owner-old',
|
|
version: 1,
|
|
pepperKeyId: 'owner-key-old',
|
|
state: 'active',
|
|
subject: { type: 'user', id: 'user-old' },
|
|
subjectStatus: 'active',
|
|
secretDigest: apiCredentialSecretDigest(oldPepper, 'owner-old', SECRET),
|
|
createdAtMs: NOW - 1_000,
|
|
notBeforeAtMs: NOW - 1_000,
|
|
expiresAtMs: NOW + 60_000,
|
|
},
|
|
],
|
|
[
|
|
'owner-new',
|
|
{
|
|
credentialId: 'owner-new',
|
|
version: 1,
|
|
pepperKeyId: 'owner-key-new',
|
|
state: 'active',
|
|
subject: { type: 'user', id: 'user-new' },
|
|
subjectStatus: 'active',
|
|
secretDigest: apiCredentialSecretDigest(
|
|
newPepper,
|
|
'owner-new',
|
|
newSecret,
|
|
),
|
|
createdAtMs: NOW - 500,
|
|
notBeforeAtMs: NOW - 500,
|
|
expiresAtMs: NOW + 60_000,
|
|
},
|
|
],
|
|
]);
|
|
const keys = new Map([
|
|
[
|
|
'owner-key-old',
|
|
{
|
|
pepperKeyId: 'owner-key-old',
|
|
materialDigest: digest(oldPepper),
|
|
backupDigest: 'b'.repeat(64),
|
|
state: 'retired',
|
|
version: 3,
|
|
registeredAtMs: NOW - 2_000,
|
|
activatedAtMs: NOW - 1_900,
|
|
retiredAtMs: NOW - 100,
|
|
},
|
|
],
|
|
[
|
|
'owner-key-new',
|
|
{
|
|
pepperKeyId: 'owner-key-new',
|
|
materialDigest: digest(newPepper),
|
|
backupDigest: 'c'.repeat(64),
|
|
state: 'active',
|
|
version: 2,
|
|
registeredAtMs: NOW - 1_000,
|
|
activatedAtMs: NOW - 100,
|
|
},
|
|
],
|
|
]);
|
|
const materials = new Map([
|
|
['owner-key-old', { pepperKeyId: 'owner-key-old', pepper: oldPepper }],
|
|
['owner-key-new', { pepperKeyId: 'owner-key-new', pepper: newPepper }],
|
|
]);
|
|
let materialReads = 0;
|
|
const authenticator = createLocalIdentityKeyringAuthenticator(
|
|
{ resolve: async (credentialId) => records.get(credentialId) ?? null },
|
|
{ resolveKey: async (pepperKeyId) => keys.get(pepperKeyId) ?? null },
|
|
{
|
|
resolve: async (pepperKeyId) => {
|
|
materialReads += 1;
|
|
return materials.get(pepperKeyId) ?? null;
|
|
},
|
|
},
|
|
{ now: () => NOW },
|
|
);
|
|
|
|
assert.equal(
|
|
(
|
|
await authenticator.authenticate(
|
|
formatApiCredentialToken('owner-old', SECRET),
|
|
)
|
|
)?.subject.id,
|
|
'user-old',
|
|
);
|
|
assert.equal(
|
|
(
|
|
await authenticator.authenticate(
|
|
formatApiCredentialToken('owner-new', newSecret),
|
|
)
|
|
)?.subject.id,
|
|
'user-new',
|
|
);
|
|
|
|
keys.get('owner-key-old').state = 'staged';
|
|
await assert.rejects(
|
|
authenticator.authenticate(formatApiCredentialToken('owner-old', SECRET)),
|
|
LocalIdentityAuthenticationUnavailableError,
|
|
);
|
|
assert.equal(materialReads, 2);
|
|
keys.get('owner-key-old').state = 'retired';
|
|
materials.set('owner-key-old', {
|
|
pepperKeyId: 'owner-key-old',
|
|
pepper: Buffer.alloc(32, 24).toString('base64url'),
|
|
});
|
|
await assert.rejects(
|
|
authenticator.authenticate(formatApiCredentialToken('owner-old', SECRET)),
|
|
LocalIdentityAuthenticationUnavailableError,
|
|
);
|
|
});
|
|
|
|
test('rejects malformed, wrong, inactive and expired credentials', async () => {
|
|
const record = {
|
|
credentialId: CREDENTIAL_ID,
|
|
version: 1,
|
|
pepperKeyId: 'legacy-v1',
|
|
state: 'active',
|
|
subject: { type: 'user', id: 'user-01' },
|
|
subjectStatus: 'active',
|
|
secretDigest: apiCredentialSecretDigest(PEPPER, CREDENTIAL_ID, SECRET),
|
|
createdAtMs: NOW - 1_000,
|
|
notBeforeAtMs: NOW - 1_000,
|
|
expiresAtMs: NOW + 60_000,
|
|
};
|
|
const repository = { resolve: async () => record };
|
|
const authenticator = createLocalIdentityAuthenticator(repository, PEPPER, {
|
|
now: () => NOW,
|
|
});
|
|
assert.equal(await authenticator.authenticate('not-a-token'), null);
|
|
assert.equal(
|
|
await authenticator.authenticate(
|
|
formatApiCredentialToken(
|
|
CREDENTIAL_ID,
|
|
Buffer.alloc(32, 12).toString('base64url'),
|
|
),
|
|
),
|
|
null,
|
|
);
|
|
record.state = 'revoked';
|
|
assert.equal(await authenticator.authenticate(TOKEN), null);
|
|
record.state = 'active';
|
|
record.subjectStatus = 'disabled';
|
|
assert.equal(await authenticator.authenticate(TOKEN), null);
|
|
record.subjectStatus = 'active';
|
|
record.expiresAtMs = NOW;
|
|
assert.equal(await authenticator.authenticate(TOKEN), null);
|
|
record.expiresAtMs = NOW + 60_000;
|
|
record.pepperKeyId = 'other-v1';
|
|
await assert.rejects(
|
|
authenticator.authenticate(TOKEN),
|
|
LocalIdentityAuthenticationUnavailableError,
|
|
);
|
|
});
|
|
|
|
test('maps repository and clock failures to unavailable', async () => {
|
|
const unavailable = createLocalIdentityAuthenticator(
|
|
{
|
|
resolve: async () => {
|
|
throw new ApiCredentialUnavailableError();
|
|
},
|
|
},
|
|
PEPPER,
|
|
);
|
|
await assert.rejects(
|
|
unavailable.authenticate(TOKEN),
|
|
LocalIdentityAuthenticationUnavailableError,
|
|
);
|
|
|
|
const badClock = createLocalIdentityAuthenticator(
|
|
{
|
|
resolve: async () => ({
|
|
credentialId: CREDENTIAL_ID,
|
|
version: 1,
|
|
pepperKeyId: 'legacy-v1',
|
|
state: 'active',
|
|
subject: { type: 'user', id: 'user-01' },
|
|
subjectStatus: 'active',
|
|
secretDigest: apiCredentialSecretDigest(PEPPER, CREDENTIAL_ID, SECRET),
|
|
createdAtMs: 0,
|
|
notBeforeAtMs: 0,
|
|
expiresAtMs: NOW + 60_000,
|
|
}),
|
|
},
|
|
PEPPER,
|
|
{ now: () => Number.NaN },
|
|
);
|
|
await assert.rejects(
|
|
badClock.authenticate(TOKEN),
|
|
LocalIdentityAuthenticationUnavailableError,
|
|
);
|
|
});
|
|
|
|
test('shares the runtime close fence and never opens a second connection', async (t) => {
|
|
const databasePath = fixture(t);
|
|
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
|
seed(databasePath);
|
|
const runtime = await openLocalSqliteRuntimeDatabase({
|
|
databasePath,
|
|
profile: 'edge',
|
|
});
|
|
const authenticator = createLocalIdentityAuthenticator(
|
|
runtime.apiCredentials,
|
|
PEPPER,
|
|
{ now: () => NOW },
|
|
);
|
|
await runtime.close();
|
|
await assert.rejects(
|
|
authenticator.authenticate(TOKEN),
|
|
LocalIdentityAuthenticationUnavailableError,
|
|
);
|
|
});
|
|
|
|
test('fails closed when credential pepper provenance is missing', async (t) => {
|
|
const databasePath = fixture(t);
|
|
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
|
seed(databasePath, { omitPepperBinding: true });
|
|
const runtime = await openLocalSqliteRuntimeDatabase({
|
|
databasePath,
|
|
profile: 'edge',
|
|
});
|
|
const authenticator = createLocalIdentityAuthenticator(
|
|
runtime.apiCredentials,
|
|
PEPPER,
|
|
{ now: () => NOW },
|
|
);
|
|
await assert.rejects(
|
|
authenticator.authenticate(TOKEN),
|
|
LocalIdentityAuthenticationUnavailableError,
|
|
);
|
|
await runtime.close();
|
|
});
|
|
|
|
test('rejects weak pepper, widened options and unbounded principal TTL', () => {
|
|
const repository = { resolve: async () => null };
|
|
assert.throws(
|
|
() => createLocalIdentityAuthenticator(repository, 'weak'),
|
|
LocalIdentityAuthenticationConfigurationError,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
createLocalIdentityAuthenticator(repository, PEPPER, {
|
|
principalTtlMs: 300_001,
|
|
}),
|
|
LocalIdentityAuthenticationConfigurationError,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
createLocalIdentityAuthenticator(repository, PEPPER, {
|
|
extra: true,
|
|
}),
|
|
LocalIdentityAuthenticationConfigurationError,
|
|
);
|
|
});
|