mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
580 lines
19 KiB
JavaScript
580 lines
19 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 {
|
|
LocalOwnerBootstrapMutationConflictError,
|
|
} = require('@qinglong/runtime-core/local-owner-bootstrap');
|
|
const {
|
|
MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_AUDIT_RETENTION_MS,
|
|
MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_REPLAY_RETENTION_MS,
|
|
} = require('@qinglong/runtime-core/local-owner-delivery-acknowledgement-gc');
|
|
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
|
const {
|
|
openLocalSqliteAcknowledgementGcDatabase,
|
|
} = require('@qinglong/local-sqlite/acknowledgement-gc');
|
|
const {
|
|
openLocalSqliteBootstrapDatabase,
|
|
} = require('@qinglong/local-sqlite/bootstrap');
|
|
const {
|
|
LocalOwnerBootstrapConfigurationError,
|
|
LocalOwnerBootstrapRejectedError,
|
|
LocalOwnerBootstrapServiceUnavailableError,
|
|
createLocalOwnerBootstrapService,
|
|
} = require('../dist/bootstrap');
|
|
|
|
const NOW = 1_760_000_000_000;
|
|
const PEPPER = Buffer.alloc(32, 91).toString('base64url');
|
|
|
|
function fixture(t) {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-owner-'));
|
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
return {
|
|
databasePath: path.join(directory, 'qinglong3.sqlite'),
|
|
profile: 'edge',
|
|
};
|
|
}
|
|
|
|
function issuer() {
|
|
return {
|
|
subject: { type: 'system', id: 'owner-bootstrap' },
|
|
authenticationId: 'local-console-test',
|
|
authenticatedAtMs: NOW - 1_000,
|
|
expiresAtMs: NOW + 60_000,
|
|
assurance: 'local_console',
|
|
};
|
|
}
|
|
|
|
function restartedIssuer() {
|
|
return {
|
|
...issuer(),
|
|
authenticatedAtMs: NOW + 1_000,
|
|
expiresAtMs: NOW + 61_000,
|
|
};
|
|
}
|
|
|
|
function entropy(start = 1) {
|
|
let value = start;
|
|
return (size) => Buffer.alloc(size, value++);
|
|
}
|
|
|
|
async function opened(t, start = 1) {
|
|
const options = fixture(t);
|
|
await migrateLocalSqlitePath(options);
|
|
const database = await openLocalSqliteBootstrapDatabase(options);
|
|
const materialDigest = createHash('sha256')
|
|
.update('qinglong.local-owner-pepper.summary.v1\0', 'utf8')
|
|
.update(PEPPER, 'utf8')
|
|
.digest('hex');
|
|
await database.ownerPepper.register({
|
|
mutationId: '00000000-0000-4000-8000-000000000091',
|
|
pepperKeyId: 'legacy-v1',
|
|
materialDigest,
|
|
backupDigest: 'b'.repeat(64),
|
|
registeredAtMs: NOW - 2_000,
|
|
});
|
|
await database.ownerPepper.activate({
|
|
mutationId: '00000000-0000-4000-8000-000000000092',
|
|
pepperKeyId: 'legacy-v1',
|
|
expectedGeneration: 0,
|
|
activatedAtMs: NOW - 1_500,
|
|
});
|
|
t.after(() => database.close());
|
|
return {
|
|
options,
|
|
database,
|
|
service: createLocalOwnerBootstrapService(
|
|
database.ownerBootstrap,
|
|
database.apiCredentials,
|
|
PEPPER,
|
|
issuer(),
|
|
{ now: () => NOW, randomBytes: entropy(start) },
|
|
),
|
|
};
|
|
}
|
|
|
|
function provisionRequest(overrides = {}) {
|
|
return {
|
|
mutationId: '00000000-0000-4000-8000-000000000101',
|
|
requestId: 'provision-101',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function issueRequest(overrides = {}) {
|
|
return {
|
|
projectId: 'default',
|
|
mutationId: '00000000-0000-4000-8000-000000000102',
|
|
requestId: 'issue-102',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function claimRequest(provisioned, challenge, overrides = {}) {
|
|
return {
|
|
projectId: 'default',
|
|
mutationId: '00000000-0000-4000-8000-000000000103',
|
|
requestId: 'claim-103',
|
|
challengeId: challenge.challengeId,
|
|
challengeToken: challenge.challengeToken,
|
|
credentialToken: provisioned.credentialToken,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test('provisions stable identity and claims one Owner without persisting plaintext', async (t) => {
|
|
const value = await opened(t);
|
|
const provisioned = await value.service.provision(provisionRequest());
|
|
const challenge = await value.service.issue(issueRequest());
|
|
const claimed = await value.service.claim(
|
|
claimRequest(provisioned, challenge),
|
|
);
|
|
assert.equal(provisioned.status, 'inserted');
|
|
assert.match(provisioned.credentialToken, /^ql3c_/);
|
|
assert.equal(challenge.status, 'inserted');
|
|
assert.equal(challenge.challengeToken.length, 43);
|
|
assert.equal(claimed.status, 'inserted');
|
|
assert.equal(claimed.binding.role, 'owner');
|
|
|
|
const client = new DatabaseSync(value.options.databasePath, {
|
|
readOnly: true,
|
|
});
|
|
try {
|
|
const credential = client
|
|
.prepare('SELECT secret_digest FROM "QingLong3ApiCredentials" LIMIT 1')
|
|
.get();
|
|
const storedChallenge = client
|
|
.prepare(
|
|
'SELECT token_digest, consumed_at_ms FROM "QingLong3LocalOwnerBootstrapChallenges" LIMIT 1',
|
|
)
|
|
.get();
|
|
assert.match(credential.secret_digest, /^[0-9a-f]{64}$/);
|
|
assert.match(storedChallenge.token_digest, /^[0-9a-f]{64}$/);
|
|
assert.equal(storedChallenge.consumed_at_ms, NOW);
|
|
const bytes = fs.readFileSync(value.options.databasePath);
|
|
assert.equal(
|
|
bytes.includes(Buffer.from(provisioned.credentialToken)),
|
|
false,
|
|
);
|
|
assert.equal(bytes.includes(Buffer.from(challenge.challengeToken)), false);
|
|
} finally {
|
|
client.close();
|
|
}
|
|
|
|
const replayProvision = await value.service.provision(provisionRequest());
|
|
const replayIssue = await value.service.issue(issueRequest());
|
|
const replayClaim = await value.service.claim(
|
|
claimRequest(provisioned, challenge),
|
|
);
|
|
assert.equal(replayProvision.status, 'existing');
|
|
assert.equal(replayProvision.credentialToken, null);
|
|
assert.equal(replayIssue.status, 'existing');
|
|
assert.equal(replayIssue.challengeToken, null);
|
|
assert.equal(replayClaim.status, 'existing');
|
|
});
|
|
|
|
test('replays provisioning and issue across a fresh console authentication', async (t) => {
|
|
const value = await opened(t);
|
|
const provisioned = await value.service.provision(provisionRequest());
|
|
const challenge = await value.service.issue(issueRequest());
|
|
const restarted = createLocalOwnerBootstrapService(
|
|
value.database.ownerBootstrap,
|
|
value.database.apiCredentials,
|
|
PEPPER,
|
|
restartedIssuer(),
|
|
{ now: () => NOW + 2_000, randomBytes: entropy(90) },
|
|
);
|
|
const replayProvision = await restarted.provision(provisionRequest());
|
|
const replayIssue = await restarted.issue(issueRequest());
|
|
assert.equal(replayProvision.status, 'existing');
|
|
assert.equal(replayProvision.subjectId, provisioned.subjectId);
|
|
assert.equal(replayProvision.credentialToken, null);
|
|
assert.equal(replayIssue.status, 'existing');
|
|
assert.equal(replayIssue.challengeId, challenge.challengeId);
|
|
assert.equal(replayIssue.challengeToken, null);
|
|
|
|
const foreignProof = createLocalOwnerBootstrapService(
|
|
value.database.ownerBootstrap,
|
|
value.database.apiCredentials,
|
|
PEPPER,
|
|
{ ...restartedIssuer(), authenticationId: 'different-local-console' },
|
|
{ now: () => NOW + 2_000, randomBytes: entropy(100) },
|
|
);
|
|
await assert.rejects(
|
|
foreignProof.provision(provisionRequest()),
|
|
LocalOwnerBootstrapMutationConflictError,
|
|
);
|
|
await assert.rejects(
|
|
foreignProof.issue(issueRequest()),
|
|
LocalOwnerBootstrapMutationConflictError,
|
|
);
|
|
});
|
|
|
|
test('replays a compacted acknowledgement without regenerating entropy', async (t) => {
|
|
const value = await opened(t);
|
|
const request = provisionRequest();
|
|
const provisioned = await value.service.provision(request);
|
|
const source = await value.database.ownerBootstrap.resolveProvisioning(
|
|
request.mutationId,
|
|
);
|
|
const acknowledgement = {
|
|
kind: 'credential',
|
|
mutationId: request.mutationId,
|
|
requestId: request.requestId,
|
|
subjectId: provisioned.subjectId,
|
|
credentialId: provisioned.credentialId,
|
|
factDigest: source.credential.secretDigest,
|
|
ttlMs: source.credential.expiresAtMs - source.credential.notBeforeAtMs,
|
|
deliveryDigest: 'd'.repeat(64),
|
|
acknowledgedAtMs: NOW + 1,
|
|
};
|
|
await value.database.ownerBootstrap.recordDeliveryAcknowledgement(
|
|
acknowledgement,
|
|
);
|
|
const compactedAtMs = Math.max(
|
|
source.credential.expiresAtMs,
|
|
NOW + MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_AUDIT_RETENTION_MS,
|
|
acknowledgement.acknowledgedAtMs +
|
|
MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_REPLAY_RETENTION_MS,
|
|
);
|
|
const gc = await openLocalSqliteAcknowledgementGcDatabase(value.options);
|
|
const gcMutationId = '00000000-0000-4000-8000-0000000001f1';
|
|
const gcRequestId = 'ack-gc-1f1';
|
|
await gc.acknowledgementGc.compact({
|
|
mutationId: gcMutationId,
|
|
requestId: gcRequestId,
|
|
acknowledgementMutationId: request.mutationId,
|
|
expectedKind: 'credential',
|
|
expectedDeliveryDigest: acknowledgement.deliveryDigest,
|
|
bridgeClearEvidence: {
|
|
kind: 'credential',
|
|
acknowledgementMutationId: request.mutationId,
|
|
inspectedAtMs: compactedAtMs,
|
|
evidenceDigest: 'e'.repeat(64),
|
|
},
|
|
retentionPolicy: {
|
|
version: 1,
|
|
replayRetentionMs: MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_REPLAY_RETENTION_MS,
|
|
auditRetentionMs: MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_AUDIT_RETENTION_MS,
|
|
},
|
|
compactedAtMs,
|
|
audit: {
|
|
eventId: gcMutationId,
|
|
requestId: gcRequestId,
|
|
operationId: 'owner.delivery_acknowledgement.gc',
|
|
projectId: null,
|
|
subject: { type: 'system', id: 'owner-acknowledgement-gc' },
|
|
authenticationId: 'local-owner-console',
|
|
outcome: 'allowed',
|
|
reasons: ['delivery_acknowledgement_gc'],
|
|
fence: null,
|
|
occurredAtMs: compactedAtMs,
|
|
},
|
|
});
|
|
await gc.close();
|
|
|
|
const restarted = createLocalOwnerBootstrapService(
|
|
value.database.ownerBootstrap,
|
|
value.database.apiCredentials,
|
|
PEPPER,
|
|
restartedIssuer(),
|
|
{
|
|
now: () => NOW + 2_000,
|
|
randomBytes: () => {
|
|
throw new Error('entropy must not be requested for a tombstone replay');
|
|
},
|
|
},
|
|
);
|
|
assert.deepEqual(await restarted.provision(request), {
|
|
status: 'existing',
|
|
subjectId: provisioned.subjectId,
|
|
credentialId: provisioned.credentialId,
|
|
credentialToken: null,
|
|
expiresAtMs: provisioned.expiresAtMs,
|
|
});
|
|
});
|
|
|
|
test('replays a compacted challenge acknowledgement without regenerating entropy', async (t) => {
|
|
const value = await opened(t);
|
|
await value.service.provision(provisionRequest());
|
|
const request = issueRequest();
|
|
const issued = await value.service.issue(request);
|
|
const source = await value.database.ownerBootstrap.resolveIssuedChallenge(
|
|
request.mutationId,
|
|
);
|
|
const acknowledgement = {
|
|
kind: 'challenge',
|
|
mutationId: request.mutationId,
|
|
requestId: request.requestId,
|
|
projectId: request.projectId,
|
|
challengeId: issued.challengeId,
|
|
factDigest: source.tokenDigest,
|
|
ttlMs: source.expiresAtMs - source.issuedAtMs,
|
|
deliveryDigest: 'f'.repeat(64),
|
|
acknowledgedAtMs: NOW + 1,
|
|
};
|
|
await value.database.ownerBootstrap.recordDeliveryAcknowledgement(
|
|
acknowledgement,
|
|
);
|
|
const compactedAtMs = Math.max(
|
|
source.expiresAtMs,
|
|
NOW + MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_AUDIT_RETENTION_MS,
|
|
acknowledgement.acknowledgedAtMs +
|
|
MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_REPLAY_RETENTION_MS,
|
|
);
|
|
const gc = await openLocalSqliteAcknowledgementGcDatabase(value.options);
|
|
const gcMutationId = '00000000-0000-4000-8000-0000000001f2';
|
|
const gcRequestId = 'ack-gc-1f2';
|
|
await gc.acknowledgementGc.compact({
|
|
mutationId: gcMutationId,
|
|
requestId: gcRequestId,
|
|
acknowledgementMutationId: request.mutationId,
|
|
expectedKind: 'challenge',
|
|
expectedDeliveryDigest: acknowledgement.deliveryDigest,
|
|
bridgeClearEvidence: {
|
|
kind: 'challenge',
|
|
acknowledgementMutationId: request.mutationId,
|
|
inspectedAtMs: compactedAtMs,
|
|
evidenceDigest: '1'.repeat(64),
|
|
},
|
|
retentionPolicy: {
|
|
version: 1,
|
|
replayRetentionMs: MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_REPLAY_RETENTION_MS,
|
|
auditRetentionMs: MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_AUDIT_RETENTION_MS,
|
|
},
|
|
compactedAtMs,
|
|
audit: {
|
|
eventId: gcMutationId,
|
|
requestId: gcRequestId,
|
|
operationId: 'owner.delivery_acknowledgement.gc',
|
|
projectId: null,
|
|
subject: { type: 'system', id: 'owner-acknowledgement-gc' },
|
|
authenticationId: 'local-owner-console',
|
|
outcome: 'allowed',
|
|
reasons: ['delivery_acknowledgement_gc'],
|
|
fence: null,
|
|
occurredAtMs: compactedAtMs,
|
|
},
|
|
});
|
|
await gc.close();
|
|
|
|
const restarted = createLocalOwnerBootstrapService(
|
|
value.database.ownerBootstrap,
|
|
value.database.apiCredentials,
|
|
PEPPER,
|
|
restartedIssuer(),
|
|
{
|
|
now: () => NOW + 2_000,
|
|
randomBytes: () => {
|
|
throw new Error('entropy must not be requested for a tombstone replay');
|
|
},
|
|
},
|
|
);
|
|
assert.deepEqual(await restarted.issue(request), {
|
|
status: 'existing',
|
|
challengeId: issued.challengeId,
|
|
challengeToken: null,
|
|
expiresAtMs: issued.expiresAtMs,
|
|
});
|
|
});
|
|
|
|
test('public requests reject caller-supplied identity fields', async (t) => {
|
|
const value = await opened(t);
|
|
await assert.rejects(
|
|
value.service.provision(
|
|
provisionRequest({
|
|
issuer: issuer(),
|
|
userId: 'chosen-user',
|
|
credentialId: 'chosen-key',
|
|
}),
|
|
),
|
|
LocalOwnerBootstrapConfigurationError,
|
|
);
|
|
await assert.rejects(
|
|
value.service.claim({
|
|
...claimRequest(
|
|
{ credentialToken: 'x' },
|
|
{ challengeId: 'A'.repeat(22), challengeToken: 'B'.repeat(43) },
|
|
),
|
|
principal: issuer(),
|
|
}),
|
|
LocalOwnerBootstrapConfigurationError,
|
|
);
|
|
});
|
|
|
|
test('authentication rejection is audited and consumes the mutation identity', async (t) => {
|
|
const value = await opened(t);
|
|
const provisioned = await value.service.provision(provisionRequest());
|
|
const challenge = await value.service.issue(issueRequest());
|
|
const request = claimRequest(provisioned, challenge, {
|
|
credentialToken: `${provisioned.credentialToken.slice(0, -1)}A`,
|
|
});
|
|
await assert.rejects(
|
|
value.service.claim(request),
|
|
LocalOwnerBootstrapRejectedError,
|
|
);
|
|
await assert.rejects(
|
|
value.service.claim({
|
|
...request,
|
|
credentialToken: provisioned.credentialToken,
|
|
}),
|
|
LocalOwnerBootstrapMutationConflictError,
|
|
);
|
|
const client = new DatabaseSync(value.options.databasePath, {
|
|
readOnly: true,
|
|
});
|
|
try {
|
|
const event = client
|
|
.prepare(
|
|
'SELECT outcome, reasons_json FROM "QingLong3SecurityAuditEvents" WHERE event_id = ?',
|
|
)
|
|
.get(request.mutationId);
|
|
assert.equal(event.outcome, 'authentication_rejected');
|
|
assert.equal(event.reasons_json, '["credential_rejected"]');
|
|
assert.equal(
|
|
client
|
|
.prepare('SELECT COUNT(*) AS count FROM "QingLong3ProjectRoleBindings"')
|
|
.get().count,
|
|
0,
|
|
);
|
|
} finally {
|
|
client.close();
|
|
}
|
|
});
|
|
|
|
test('two independent connections have exactly one claim winner', async (t) => {
|
|
const value = await opened(t);
|
|
const provisioned = await value.service.provision(provisionRequest());
|
|
const challenge = await value.service.issue(issueRequest());
|
|
const secondDatabase = await openLocalSqliteBootstrapDatabase(value.options);
|
|
t.after(() => secondDatabase.close());
|
|
const secondService = createLocalOwnerBootstrapService(
|
|
secondDatabase.ownerBootstrap,
|
|
secondDatabase.apiCredentials,
|
|
PEPPER,
|
|
issuer(),
|
|
{ now: () => NOW, randomBytes: entropy(40) },
|
|
);
|
|
const results = await Promise.allSettled([
|
|
value.service.claim(claimRequest(provisioned, challenge)),
|
|
secondService.claim(
|
|
claimRequest(provisioned, challenge, {
|
|
mutationId: '00000000-0000-4000-8000-000000000104',
|
|
requestId: 'claim-104',
|
|
}),
|
|
),
|
|
]);
|
|
assert.equal(
|
|
results.filter(({ status }) => status === 'fulfilled').length,
|
|
1,
|
|
);
|
|
const client = new DatabaseSync(value.options.databasePath, {
|
|
readOnly: true,
|
|
});
|
|
try {
|
|
assert.equal(
|
|
client
|
|
.prepare('SELECT COUNT(*) AS count FROM "QingLong3ProjectRoleBindings"')
|
|
.get().count,
|
|
1,
|
|
);
|
|
} finally {
|
|
client.close();
|
|
}
|
|
});
|
|
|
|
test('any historical binding permanently closes the bootstrap bypass', async (t) => {
|
|
const value = await opened(t);
|
|
const provisioned = await value.service.provision(provisionRequest());
|
|
const challenge = await value.service.issue(issueRequest());
|
|
await value.service.claim(claimRequest(provisioned, challenge));
|
|
const client = new DatabaseSync(value.options.databasePath);
|
|
try {
|
|
client
|
|
.prepare(
|
|
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
|
project_id, subject_type, subject_id, version, state, role,
|
|
mutation_id, changed_by_type, changed_by_id, created_at_ms
|
|
) VALUES ('default', 'user', ?, 2, 'revoked', NULL,
|
|
'binding-revoke-2', 'system', 'owner-bootstrap', ?)`,
|
|
)
|
|
.run(provisioned.subjectId, NOW + 1);
|
|
} finally {
|
|
client.close();
|
|
}
|
|
await assert.rejects(
|
|
value.service.issue(
|
|
issueRequest({
|
|
mutationId: '00000000-0000-4000-8000-000000000105',
|
|
requestId: 'issue-105',
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('claim audit failure rolls back binding and challenge consumption', async (t) => {
|
|
const value = await opened(t);
|
|
const provisioned = await value.service.provision(provisionRequest());
|
|
const challenge = await value.service.issue(issueRequest());
|
|
const trigger = new DatabaseSync(value.options.databasePath);
|
|
trigger.exec(`
|
|
CREATE TRIGGER fail_owner_claim_audit
|
|
BEFORE INSERT ON "QingLong3SecurityAuditEvents"
|
|
WHEN NEW."operation_id" = 'project.owner_bootstrap_claim'
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'injected audit failure');
|
|
END
|
|
`);
|
|
trigger.close();
|
|
await assert.rejects(
|
|
value.service.claim(claimRequest(provisioned, challenge)),
|
|
LocalOwnerBootstrapServiceUnavailableError,
|
|
);
|
|
const client = new DatabaseSync(value.options.databasePath, {
|
|
readOnly: true,
|
|
});
|
|
try {
|
|
assert.equal(
|
|
client
|
|
.prepare('SELECT COUNT(*) AS count FROM "QingLong3ProjectRoleBindings"')
|
|
.get().count,
|
|
0,
|
|
);
|
|
assert.equal(
|
|
client
|
|
.prepare(
|
|
'SELECT consumed_at_ms FROM "QingLong3LocalOwnerBootstrapChallenges" LIMIT 1',
|
|
)
|
|
.get().consumed_at_ms,
|
|
null,
|
|
);
|
|
assert.equal(
|
|
client
|
|
.prepare(
|
|
`SELECT COUNT(*) AS count FROM "QingLong3SecurityAuditEvents"
|
|
WHERE event_id = '00000000-0000-4000-8000-000000000103'`,
|
|
)
|
|
.get().count,
|
|
0,
|
|
);
|
|
} finally {
|
|
client.close();
|
|
}
|
|
});
|
|
|
|
test('bootstrap authority closes once and rejects later work', async (t) => {
|
|
const value = await opened(t);
|
|
await Promise.all([value.database.close(), value.database.close()]);
|
|
await assert.rejects(
|
|
value.database.ownerBootstrap.resolveProjectVersion('default'),
|
|
);
|
|
await assert.rejects(value.database.apiCredentials.resolve('missing'));
|
|
const root = require('@qinglong/local-sqlite');
|
|
const runtime = require('@qinglong/local-sqlite/runtime');
|
|
assert.equal('openLocalSqliteBootstrapDatabase' in root, false);
|
|
assert.equal('openLocalSqliteBootstrapDatabase' in runtime, false);
|
|
});
|