mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
establishAuthenticatedLocalCommand,
|
||||
} = require('@qinglong/local-owner-console/authenticated-command');
|
||||
const {
|
||||
provisionLocalOwnerPepperKey,
|
||||
} = require('@qinglong/local-owner-console/pepper-custody');
|
||||
const {
|
||||
apiCredentialSecretDigest,
|
||||
formatApiCredentialToken,
|
||||
} = require('@qinglong/runtime-core/api-credential-token');
|
||||
|
||||
const CREDENTIAL_ID = 'package-owner';
|
||||
const PEPPER_KEY_ID = 'package-owner-v1';
|
||||
const PEPPER = Buffer.alloc(32, 71).toString('base64url');
|
||||
const SECRET = Buffer.alloc(32, 72).toString('base64url');
|
||||
const TOKEN = formatApiCredentialToken(CREDENTIAL_ID, SECRET);
|
||||
|
||||
function fixture(t) {
|
||||
const deploymentRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-authenticated-command-'),
|
||||
);
|
||||
fs.chmodSync(deploymentRoot, 0o700);
|
||||
t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true }));
|
||||
const ownerPepperKeyringDirectory = path.join(deploymentRoot, 'owner-keys');
|
||||
fs.mkdirSync(ownerPepperKeyringDirectory, { mode: 0o700 });
|
||||
const summary = provisionLocalOwnerPepperKey({
|
||||
keyringDirectory: ownerPepperKeyringDirectory,
|
||||
pepperKeyId: PEPPER_KEY_ID,
|
||||
randomBytes: () => Buffer.alloc(32, 71),
|
||||
});
|
||||
const databasePath = path.join(deploymentRoot, 'qinglong3.sqlite');
|
||||
const credentialFilePath = path.join(deploymentRoot, 'credential.json');
|
||||
fs.writeFileSync(databasePath, 'database', { mode: 0o600 });
|
||||
fs.writeFileSync(
|
||||
credentialFilePath,
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-identity-credential-presentation',
|
||||
token: TOKEN,
|
||||
})}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
let now = 10_000;
|
||||
let credential = {
|
||||
credentialId: CREDENTIAL_ID,
|
||||
version: 1,
|
||||
pepperKeyId: PEPPER_KEY_ID,
|
||||
state: 'active',
|
||||
subject: { type: 'user', id: 'owner-user' },
|
||||
subjectStatus: 'active',
|
||||
secretDigest: apiCredentialSecretDigest(
|
||||
PEPPER,
|
||||
CREDENTIAL_ID,
|
||||
SECRET,
|
||||
),
|
||||
createdAtMs: 1,
|
||||
notBeforeAtMs: 1,
|
||||
expiresAtMs: 1_000_000,
|
||||
};
|
||||
const database = {
|
||||
apiCredentials: {
|
||||
async resolve(credentialId) {
|
||||
return credentialId === CREDENTIAL_ID ? credential : null;
|
||||
},
|
||||
},
|
||||
ownerPepper: {
|
||||
async resolveKey(pepperKeyId) {
|
||||
return pepperKeyId === PEPPER_KEY_ID
|
||||
? {
|
||||
pepperKeyId,
|
||||
materialDigest: summary.digest,
|
||||
backupDigest: 'b'.repeat(64),
|
||||
state: 'active',
|
||||
version: 2,
|
||||
registeredAtMs: 1,
|
||||
activatedAtMs: 2,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
database,
|
||||
options: {
|
||||
deploymentRoot,
|
||||
databasePath,
|
||||
ownerPepperKeyringDirectory,
|
||||
credentialFilePath,
|
||||
authenticationNamespace: 'local_package',
|
||||
now: () => now,
|
||||
},
|
||||
credentialFilePath,
|
||||
setNow(value) {
|
||||
now = value;
|
||||
},
|
||||
revoke() {
|
||||
credential = { ...credential, state: 'revoked', version: 2 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('binds a User credential to a short-lived POSIX local-console principal', async (t) => {
|
||||
const value = fixture(t);
|
||||
const authenticated = await establishAuthenticatedLocalCommand(
|
||||
value.database,
|
||||
value.options,
|
||||
);
|
||||
assert.equal(authenticated.principal.subject.type, 'user');
|
||||
assert.equal(authenticated.principal.subject.id, 'owner-user');
|
||||
assert.equal(authenticated.principal.assurance, 'local_console');
|
||||
assert.match(
|
||||
authenticated.principal.authenticationId,
|
||||
/^local_package:[0-9a-f]{64}$/,
|
||||
);
|
||||
await authenticated.confirm();
|
||||
assert.equal(JSON.stringify(authenticated).includes(TOKEN), false);
|
||||
});
|
||||
|
||||
test('fails closed when the credential file identity or credential fence changes', async (t) => {
|
||||
const value = fixture(t);
|
||||
const authenticated = await establishAuthenticatedLocalCommand(
|
||||
value.database,
|
||||
value.options,
|
||||
);
|
||||
const replacement = `${value.credentialFilePath}.replacement`;
|
||||
fs.writeFileSync(replacement, fs.readFileSync(value.credentialFilePath), {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.renameSync(replacement, value.credentialFilePath);
|
||||
await assert.rejects(authenticated.confirm, {
|
||||
code: 'AUTHENTICATED_LOCAL_COMMAND_AUTHENTICATION_FAILED',
|
||||
});
|
||||
|
||||
const second = fixture(t);
|
||||
const fenced = await establishAuthenticatedLocalCommand(
|
||||
second.database,
|
||||
second.options,
|
||||
);
|
||||
second.revoke();
|
||||
await assert.rejects(fenced.confirm, {
|
||||
code: 'AUTHENTICATED_LOCAL_COMMAND_AUTHENTICATION_FAILED',
|
||||
});
|
||||
});
|
||||
|
||||
test('expires without timers and rejects non-private authority files', async (t) => {
|
||||
const value = fixture(t);
|
||||
const authenticated = await establishAuthenticatedLocalCommand(
|
||||
value.database,
|
||||
value.options,
|
||||
);
|
||||
value.setNow(70_000);
|
||||
await assert.rejects(authenticated.confirm, {
|
||||
code: 'AUTHENTICATED_LOCAL_COMMAND_AUTHENTICATION_FAILED',
|
||||
});
|
||||
|
||||
const second = fixture(t);
|
||||
fs.chmodSync(second.credentialFilePath, 0o644);
|
||||
await assert.rejects(
|
||||
establishAuthenticatedLocalCommand(second.database, second.options),
|
||||
{ code: 'AUTHENTICATED_LOCAL_COMMAND_CONFIGURATION_INVALID' },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,579 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
test('keeps the two reviewed Owner ceremony modules internal to console', () => {
|
||||
const manifest = require('../package.json');
|
||||
assert.deepEqual(Object.keys(manifest.exports).sort(), [
|
||||
'.',
|
||||
'./authenticated-command',
|
||||
'./credential-administration-delivery',
|
||||
'./identity-authentication',
|
||||
'./pepper-custody',
|
||||
'./pepper-custody/destructive',
|
||||
'./secret-delivery',
|
||||
]);
|
||||
assert.throws(
|
||||
() => require('@qinglong/local-owner-console/bootstrap'),
|
||||
(error) => error?.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED',
|
||||
);
|
||||
assert.equal(
|
||||
typeof require('../dist/bootstrap').createLocalOwnerBootstrapService,
|
||||
'function',
|
||||
);
|
||||
assert.equal(
|
||||
typeof require('../dist/credential-recovery')
|
||||
.createLocalOwnerCredentialRecoveryService,
|
||||
'function',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,810 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash, randomUUID } = 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 { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
openLocalSqliteBootstrapDatabase,
|
||||
} = require('@qinglong/local-sqlite/bootstrap');
|
||||
const {
|
||||
createLocalOwnerBootstrapService,
|
||||
LOCAL_IDENTITY_BOOTSTRAP_DEFAULT_TTL_MS,
|
||||
LocalOwnerBootstrapServiceUnavailableError,
|
||||
} = require('../dist/bootstrap');
|
||||
const {
|
||||
formatApiCredentialToken,
|
||||
} = require('@qinglong/runtime-core/api-credential-token');
|
||||
const {
|
||||
FileLocalOwnerBootstrapSecretDelivery,
|
||||
LocalOwnerConsoleConfigurationError,
|
||||
LocalOwnerSecretDeliveryError,
|
||||
openLocalOwnerConsole,
|
||||
} = require('@qinglong/local-owner-console');
|
||||
|
||||
function fixture(t) {
|
||||
const deploymentRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-owner-console-'),
|
||||
);
|
||||
fs.chmodSync(deploymentRoot, 0o700);
|
||||
t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true }));
|
||||
const databasePath = path.join(deploymentRoot, 'qinglong3.sqlite');
|
||||
const pepperPath = path.join(deploymentRoot, 'owner.pepper');
|
||||
const secretDeliveryDirectory = path.join(deploymentRoot, 'secrets');
|
||||
fs.mkdirSync(secretDeliveryDirectory, { mode: 0o700 });
|
||||
fs.writeFileSync(pepperPath, Buffer.alloc(32, 73).toString('base64url'), {
|
||||
mode: 0o600,
|
||||
});
|
||||
return {
|
||||
deploymentRoot,
|
||||
databasePath,
|
||||
pepperPath,
|
||||
secretDeliveryDirectory,
|
||||
profile: 'edge',
|
||||
};
|
||||
}
|
||||
|
||||
async function ready(t) {
|
||||
const options = fixture(t);
|
||||
await migrateLocalSqlitePath({
|
||||
databasePath: options.databasePath,
|
||||
profile: options.profile,
|
||||
});
|
||||
const material = fs.readFileSync(options.pepperPath);
|
||||
const materialDigest = createHash('sha256')
|
||||
.update('qinglong.local-owner-pepper.summary.v1\0', 'utf8')
|
||||
.update(material)
|
||||
.digest('hex');
|
||||
material.fill(0);
|
||||
const database = await openLocalSqliteBootstrapDatabase({
|
||||
databasePath: options.databasePath,
|
||||
profile: options.profile,
|
||||
});
|
||||
await database.ownerPepper.register({
|
||||
mutationId: '00000000-0000-4000-8000-000000000191',
|
||||
pepperKeyId: 'legacy-v1',
|
||||
materialDigest,
|
||||
backupDigest: 'b'.repeat(64),
|
||||
registeredAtMs: 1,
|
||||
});
|
||||
await database.ownerPepper.activate({
|
||||
mutationId: '00000000-0000-4000-8000-000000000192',
|
||||
pepperKeyId: 'legacy-v1',
|
||||
expectedGeneration: 0,
|
||||
activatedAtMs: 2,
|
||||
});
|
||||
await database.close();
|
||||
return options;
|
||||
}
|
||||
|
||||
function authorityFor(options) {
|
||||
const root = fs.lstatSync(options.deploymentRoot, { bigint: true });
|
||||
const uid = process.getuid();
|
||||
const proofDigest = createHash('sha256')
|
||||
.update('qinglong.local-owner-console.proof.v1\0', 'utf8')
|
||||
.update(process.platform, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(String(uid), 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(root.dev.toString(), 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(root.ino.toString(), 'utf8')
|
||||
.digest('hex');
|
||||
const authenticatedAtMs = Date.now();
|
||||
return {
|
||||
subject: { type: 'system', id: 'owner-bootstrap' },
|
||||
authenticationId: `local-console:${proofDigest}`,
|
||||
authenticatedAtMs,
|
||||
expiresAtMs: authenticatedAtMs + 60_000,
|
||||
assurance: 'local_console',
|
||||
};
|
||||
}
|
||||
|
||||
test('proves one bounded delivery crash bridge is clear', (t) => {
|
||||
const options = fixture(t);
|
||||
const delivery = new FileLocalOwnerBootstrapSecretDelivery(
|
||||
options.secretDeliveryDirectory,
|
||||
);
|
||||
const mutationId = '00000000-0000-4000-8000-000000000b01';
|
||||
const evidence = delivery.inspectBridgeClear('credential', mutationId);
|
||||
assert.equal(evidence.kind, 'credential');
|
||||
assert.equal(evidence.acknowledgementMutationId, mutationId);
|
||||
assert.match(evidence.evidenceDigest, /^[0-9a-f]{64}$/);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(
|
||||
options.secretDeliveryDirectory,
|
||||
`credential-${mutationId}.pending.json`,
|
||||
),
|
||||
'{}',
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
assert.throws(
|
||||
() => delivery.inspectBridgeClear('credential', mutationId),
|
||||
/crash bridge is not clear/,
|
||||
);
|
||||
});
|
||||
|
||||
async function directService(options, secretDelivery) {
|
||||
const database = await openLocalSqliteBootstrapDatabase({
|
||||
databasePath: options.databasePath,
|
||||
profile: options.profile,
|
||||
});
|
||||
const service = createLocalOwnerBootstrapService(
|
||||
database.ownerBootstrap,
|
||||
database.apiCredentials,
|
||||
fs.readFileSync(options.pepperPath, 'utf8'),
|
||||
authorityFor(options),
|
||||
{ secretDelivery },
|
||||
);
|
||||
return { database, service };
|
||||
}
|
||||
|
||||
test('binds POSIX proof at composition time and removes issuer from requests', async (t) => {
|
||||
const options = await ready(t);
|
||||
const console = await openLocalOwnerConsole(options);
|
||||
t.after(() => console.close());
|
||||
assert.deepEqual(console.recovery, {
|
||||
inspectedPendingRecords: 0,
|
||||
publishedRecords: 0,
|
||||
retainedUncommittedRecords: 0,
|
||||
orphanTemporaryRecords: 0,
|
||||
});
|
||||
const provisioned = await console.service.provision({
|
||||
mutationId: '00000000-0000-4000-8000-000000000201',
|
||||
requestId: 'console-provision-201',
|
||||
});
|
||||
assert.equal(provisioned.status, 'inserted');
|
||||
assert.equal(provisioned.credentialToken, null);
|
||||
const credentialPath = console.credentialDeliveryPath(
|
||||
'00000000-0000-4000-8000-000000000201',
|
||||
);
|
||||
assert.equal(fs.statSync(credentialPath).mode & 0o777, 0o600);
|
||||
const credential = JSON.parse(fs.readFileSync(credentialPath, 'utf8'));
|
||||
assert.equal(credential.kind, 'credential');
|
||||
assert.equal(credential.credentialId, provisioned.credentialId);
|
||||
|
||||
const issued = await console.service.issue({
|
||||
projectId: 'default',
|
||||
mutationId: '00000000-0000-4000-8000-000000000202',
|
||||
requestId: 'console-issue-202',
|
||||
});
|
||||
assert.equal(issued.status, 'inserted');
|
||||
assert.equal(issued.challengeToken, null);
|
||||
const challengePath = console.challengeDeliveryPath(
|
||||
'00000000-0000-4000-8000-000000000202',
|
||||
);
|
||||
assert.equal(fs.statSync(challengePath).mode & 0o777, 0o600);
|
||||
const challenge = JSON.parse(fs.readFileSync(challengePath, 'utf8'));
|
||||
assert.equal(challenge.kind, 'challenge');
|
||||
assert.equal(challenge.challengeId, issued.challengeId);
|
||||
|
||||
const claimed = await console.service.claim({
|
||||
projectId: 'default',
|
||||
mutationId: '00000000-0000-4000-8000-000000000205',
|
||||
requestId: 'console-claim-205',
|
||||
challengeId: challenge.challengeId,
|
||||
challengeToken: challenge.secret,
|
||||
credentialToken: formatApiCredentialToken(
|
||||
credential.credentialId,
|
||||
credential.secret,
|
||||
),
|
||||
});
|
||||
assert.equal(claimed.status, 'inserted');
|
||||
const databaseBytes = fs.readFileSync(options.databasePath);
|
||||
assert.equal(databaseBytes.includes(credential.secret), false);
|
||||
assert.equal(databaseBytes.includes(challenge.secret), false);
|
||||
await assert.rejects(
|
||||
console.service.issue({
|
||||
projectId: 'default',
|
||||
mutationId: '00000000-0000-4000-8000-000000000206',
|
||||
requestId: 'console-issue-206',
|
||||
issuer: {
|
||||
subject: { type: 'system', id: 'owner-bootstrap' },
|
||||
authenticationId: 'forged',
|
||||
authenticatedAtMs: Date.now(),
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('claims the first Owner from staged deliveries without crossing the transport with secrets', async (t) => {
|
||||
const options = await ready(t);
|
||||
const console = await openLocalOwnerConsole(options);
|
||||
t.after(() => console.close());
|
||||
const credentialMutationId = '00000000-0000-4000-8000-000000000711';
|
||||
const challengeMutationId = '00000000-0000-4000-8000-000000000712';
|
||||
const claimMutationId = '00000000-0000-4000-8000-000000000713';
|
||||
await console.service.provision({
|
||||
mutationId: credentialMutationId,
|
||||
requestId: 'console-provision-711',
|
||||
});
|
||||
await console.service.issue({
|
||||
projectId: 'default',
|
||||
mutationId: challengeMutationId,
|
||||
requestId: 'console-issue-712',
|
||||
});
|
||||
const credentialDelivery =
|
||||
console.inspectCredentialDelivery(credentialMutationId);
|
||||
const challengeDelivery =
|
||||
console.inspectChallengeDelivery(challengeMutationId);
|
||||
const claimed = await console.claimOwnerFromDeliveries({
|
||||
projectId: 'default',
|
||||
mutationId: claimMutationId,
|
||||
requestId: 'console-claim-713',
|
||||
credentialMutationId,
|
||||
challengeMutationId,
|
||||
});
|
||||
assert.equal(claimed.status, 'inserted');
|
||||
assert.equal(claimed.binding.role, 'owner');
|
||||
assert.equal(JSON.stringify(claimed).includes('secret'), false);
|
||||
await console.acknowledgeCredentialDelivery(
|
||||
credentialMutationId,
|
||||
credentialDelivery.deliveryDigest,
|
||||
);
|
||||
await console.acknowledgeChallengeDelivery(
|
||||
challengeMutationId,
|
||||
challengeDelivery.deliveryDigest,
|
||||
);
|
||||
const replay = await console.claimOwnerFromDeliveries({
|
||||
projectId: 'default',
|
||||
mutationId: claimMutationId,
|
||||
requestId: 'console-claim-713',
|
||||
credentialMutationId,
|
||||
challengeMutationId,
|
||||
});
|
||||
assert.equal(replay.status, 'existing');
|
||||
await assert.rejects(
|
||||
console.claimOwnerFromDeliveries({
|
||||
projectId: 'default',
|
||||
mutationId: '00000000-0000-4000-8000-000000000714',
|
||||
requestId: 'console-claim-714',
|
||||
credentialMutationId,
|
||||
challengeMutationId,
|
||||
challengeToken: 'forbidden',
|
||||
}),
|
||||
LocalOwnerSecretDeliveryError,
|
||||
);
|
||||
});
|
||||
|
||||
test('recovers one credential without revoking the old token before delivery acknowledgement', async (t) => {
|
||||
const options = await ready(t);
|
||||
const first = await openLocalOwnerConsole(options);
|
||||
const provisionMutationId = '00000000-0000-4000-8000-000000000701';
|
||||
const provisioned = await first.service.provision({
|
||||
mutationId: provisionMutationId,
|
||||
requestId: 'console-provision-701',
|
||||
});
|
||||
const provisionDelivery =
|
||||
first.inspectCredentialDelivery(provisionMutationId);
|
||||
await first.acknowledgeCredentialDelivery(
|
||||
provisionMutationId,
|
||||
provisionDelivery.deliveryDigest,
|
||||
);
|
||||
|
||||
const issueMutationId = '00000000-0000-4000-8000-000000000702';
|
||||
const issued = await first.credentialRecovery.issue({
|
||||
mutationId: issueMutationId,
|
||||
requestId: 'console-recover-issue-702',
|
||||
previousCredentialId: provisioned.credentialId,
|
||||
expectedPreviousVersion: 1,
|
||||
});
|
||||
assert.equal(issued.status, 'inserted');
|
||||
assert.equal(issued.state, 'issued');
|
||||
assert.equal(issued.replacementCredentialToken, null);
|
||||
const recoveryDelivery = first.inspectCredentialDelivery(issueMutationId);
|
||||
await assert.rejects(
|
||||
first.credentialRecovery.complete({
|
||||
issueMutationId,
|
||||
mutationId: '00000000-0000-4000-8000-000000000703',
|
||||
requestId: 'console-recover-complete-703',
|
||||
}),
|
||||
);
|
||||
const beforeAcknowledgement = await openLocalSqliteBootstrapDatabase({
|
||||
databasePath: options.databasePath,
|
||||
profile: options.profile,
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await beforeAcknowledgement.apiCredentials.resolve(
|
||||
provisioned.credentialId,
|
||||
)
|
||||
).state,
|
||||
'active',
|
||||
);
|
||||
await beforeAcknowledgement.close();
|
||||
|
||||
await first.close();
|
||||
const restarted = await openLocalOwnerConsole(options);
|
||||
t.after(() => restarted.close());
|
||||
assert.equal(fs.existsSync(recoveryDelivery.path), true);
|
||||
await restarted.acknowledgeCredentialRecoveryDelivery(
|
||||
issueMutationId,
|
||||
recoveryDelivery.deliveryDigest,
|
||||
);
|
||||
const completed = await restarted.credentialRecovery.complete({
|
||||
issueMutationId,
|
||||
mutationId: '00000000-0000-4000-8000-000000000703',
|
||||
requestId: 'console-recover-complete-703',
|
||||
});
|
||||
assert.equal(completed.state, 'completed');
|
||||
assert.equal(fs.existsSync(recoveryDelivery.path), false);
|
||||
|
||||
const database = await openLocalSqliteBootstrapDatabase({
|
||||
databasePath: options.databasePath,
|
||||
profile: options.profile,
|
||||
});
|
||||
assert.equal(
|
||||
(await database.apiCredentials.resolve(provisioned.credentialId)).state,
|
||||
'revoked',
|
||||
);
|
||||
assert.equal(
|
||||
(await database.apiCredentials.resolve(issued.replacementCredentialId))
|
||||
.state,
|
||||
'active',
|
||||
);
|
||||
await database.close();
|
||||
const replay = await restarted.credentialRecovery.issue({
|
||||
mutationId: issueMutationId,
|
||||
requestId: 'console-recover-issue-702',
|
||||
previousCredentialId: provisioned.credentialId,
|
||||
expectedPreviousVersion: 1,
|
||||
});
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.equal(replay.state, 'completed');
|
||||
assert.equal(replay.replacementCredentialToken, null);
|
||||
});
|
||||
|
||||
test('acknowledges exact ready records and replays without regenerating secrets', async (t) => {
|
||||
const options = await ready(t);
|
||||
const console = await openLocalOwnerConsole(options);
|
||||
const credentialMutationId = '00000000-0000-4000-8000-000000000207';
|
||||
const provisionRequestId = 'console-provision-207';
|
||||
const provisioned = await console.service.provision({
|
||||
mutationId: credentialMutationId,
|
||||
requestId: provisionRequestId,
|
||||
});
|
||||
const credentialSummary =
|
||||
console.inspectCredentialDelivery(credentialMutationId);
|
||||
const credentialReady = fs.readFileSync(credentialSummary.path);
|
||||
await assert.rejects(
|
||||
console.acknowledgeCredentialDelivery(credentialMutationId, '0'.repeat(64)),
|
||||
LocalOwnerSecretDeliveryError,
|
||||
);
|
||||
assert.equal(fs.existsSync(credentialSummary.path), true);
|
||||
const acknowledgementDatabaseA = await openLocalSqliteBootstrapDatabase({
|
||||
databasePath: options.databasePath,
|
||||
profile: options.profile,
|
||||
});
|
||||
const acknowledgementDatabaseB = await openLocalSqliteBootstrapDatabase({
|
||||
databasePath: options.databasePath,
|
||||
profile: options.profile,
|
||||
});
|
||||
const deliveryA = new FileLocalOwnerBootstrapSecretDelivery(
|
||||
options.secretDeliveryDirectory,
|
||||
);
|
||||
const deliveryB = new FileLocalOwnerBootstrapSecretDelivery(
|
||||
options.secretDeliveryDirectory,
|
||||
);
|
||||
const pepper = fs.readFileSync(options.pepperPath, 'utf8');
|
||||
const concurrentAcknowledgements = await Promise.all([
|
||||
deliveryA.acknowledge(
|
||||
acknowledgementDatabaseA.ownerBootstrap,
|
||||
pepper,
|
||||
'credential',
|
||||
credentialMutationId,
|
||||
credentialSummary.deliveryDigest,
|
||||
1,
|
||||
),
|
||||
deliveryB.acknowledge(
|
||||
acknowledgementDatabaseB.ownerBootstrap,
|
||||
pepper,
|
||||
'credential',
|
||||
credentialMutationId,
|
||||
credentialSummary.deliveryDigest,
|
||||
2,
|
||||
),
|
||||
]);
|
||||
await Promise.all([
|
||||
acknowledgementDatabaseA.close(),
|
||||
acknowledgementDatabaseB.close(),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
concurrentAcknowledgements[0],
|
||||
concurrentAcknowledgements[1],
|
||||
);
|
||||
const credentialAcknowledgement = await console.acknowledgeCredentialDelivery(
|
||||
credentialMutationId,
|
||||
credentialSummary.deliveryDigest,
|
||||
);
|
||||
assert.deepEqual(credentialAcknowledgement, {
|
||||
state: 'acknowledged',
|
||||
kind: 'credential',
|
||||
mutationId: credentialMutationId,
|
||||
requestId: provisionRequestId,
|
||||
ttlMs: LOCAL_IDENTITY_BOOTSTRAP_DEFAULT_TTL_MS,
|
||||
});
|
||||
assert.equal(fs.existsSync(credentialSummary.path), false);
|
||||
const credentialAcknowledgementPath = path.join(
|
||||
options.secretDeliveryDirectory,
|
||||
`credential-${credentialMutationId}.acknowledged.json`,
|
||||
);
|
||||
assert.equal(fs.existsSync(credentialAcknowledgementPath), false);
|
||||
const ledgerDatabase = new DatabaseSync(options.databasePath);
|
||||
const credentialTombstone = ledgerDatabase
|
||||
.prepare(
|
||||
`SELECT * FROM "QingLong3LocalOwnerDeliveryAcknowledgements"
|
||||
WHERE "mutation_id" = ?`,
|
||||
)
|
||||
.get(credentialMutationId);
|
||||
ledgerDatabase.close();
|
||||
assert.equal(
|
||||
credentialTombstone.delivery_digest,
|
||||
credentialSummary.deliveryDigest,
|
||||
);
|
||||
assert.equal([1, 2].includes(credentialTombstone.acknowledged_at_ms), true);
|
||||
assert.equal(Object.keys(credentialTombstone).includes('secret'), false);
|
||||
const replayedProvision = await console.service.provision({
|
||||
mutationId: credentialMutationId,
|
||||
requestId: provisionRequestId,
|
||||
});
|
||||
assert.equal(replayedProvision.status, 'existing');
|
||||
assert.equal(replayedProvision.subjectId, provisioned.subjectId);
|
||||
assert.equal(replayedProvision.credentialId, provisioned.credentialId);
|
||||
assert.equal(replayedProvision.credentialToken, null);
|
||||
|
||||
const challengeMutationId = '00000000-0000-4000-8000-000000000208';
|
||||
const issueRequestId = 'console-issue-208';
|
||||
const issued = await console.service.issue({
|
||||
projectId: 'default',
|
||||
mutationId: challengeMutationId,
|
||||
requestId: issueRequestId,
|
||||
});
|
||||
const challengeSummary =
|
||||
console.inspectChallengeDelivery(challengeMutationId);
|
||||
const challengeAcknowledgement = await console.acknowledgeChallengeDelivery(
|
||||
challengeMutationId,
|
||||
challengeSummary.deliveryDigest,
|
||||
);
|
||||
assert.deepEqual(challengeAcknowledgement, {
|
||||
state: 'acknowledged',
|
||||
kind: 'challenge',
|
||||
projectId: 'default',
|
||||
mutationId: challengeMutationId,
|
||||
requestId: issueRequestId,
|
||||
ttlMs: 600_000,
|
||||
});
|
||||
const replayedIssue = await console.service.issue({
|
||||
projectId: 'default',
|
||||
mutationId: challengeMutationId,
|
||||
requestId: issueRequestId,
|
||||
});
|
||||
assert.equal(replayedIssue.status, 'existing');
|
||||
assert.equal(replayedIssue.challengeId, issued.challengeId);
|
||||
assert.equal(replayedIssue.challengeToken, null);
|
||||
|
||||
fs.writeFileSync(credentialSummary.path, credentialReady, { mode: 0o600 });
|
||||
await console.close();
|
||||
const recovered = await openLocalOwnerConsole(options);
|
||||
t.after(() => recovered.close());
|
||||
assert.equal(fs.existsSync(credentialSummary.path), false);
|
||||
assert.deepEqual(recovered.recovery, {
|
||||
inspectedPendingRecords: 0,
|
||||
publishedRecords: 0,
|
||||
retainedUncommittedRecords: 0,
|
||||
orphanTemporaryRecords: 0,
|
||||
});
|
||||
const recoveredReplay = await recovered.service.provision({
|
||||
mutationId: credentialMutationId,
|
||||
requestId: provisionRequestId,
|
||||
});
|
||||
assert.equal(recoveredReplay.status, 'existing');
|
||||
assert.equal(recoveredReplay.credentialToken, null);
|
||||
});
|
||||
|
||||
test('retains a pre-commit secret and publishes it after the matching commit', async (t) => {
|
||||
const options = await ready(t);
|
||||
const delivery = new FileLocalOwnerBootstrapSecretDelivery(
|
||||
options.secretDeliveryDirectory,
|
||||
);
|
||||
const mutationId = '00000000-0000-4000-8000-000000000211';
|
||||
const staged = await delivery.prepare({
|
||||
kind: 'credential',
|
||||
mutationId,
|
||||
requestId: 'console-provision-211',
|
||||
subjectId: `usr_${Buffer.alloc(16, 11).toString('base64url')}`,
|
||||
credentialId: `own_${Buffer.alloc(16, 12).toString('base64url')}`,
|
||||
secret: Buffer.alloc(32, 13).toString('base64url'),
|
||||
ttlMs: LOCAL_IDENTITY_BOOTSTRAP_DEFAULT_TTL_MS,
|
||||
});
|
||||
const pendingPath = delivery
|
||||
.readyPath('credential', mutationId)
|
||||
.replace('.ready.json', '.pending.json');
|
||||
assert.equal(fs.existsSync(pendingPath), true);
|
||||
|
||||
const console = await openLocalOwnerConsole(options);
|
||||
t.after(() => console.close());
|
||||
assert.deepEqual(console.recovery, {
|
||||
inspectedPendingRecords: 1,
|
||||
publishedRecords: 0,
|
||||
retainedUncommittedRecords: 1,
|
||||
orphanTemporaryRecords: 0,
|
||||
});
|
||||
const provisioned = await console.service.provision({
|
||||
mutationId,
|
||||
requestId: staged.requestId,
|
||||
});
|
||||
assert.equal(provisioned.status, 'inserted');
|
||||
assert.equal(provisioned.subjectId, staged.subjectId);
|
||||
assert.equal(provisioned.credentialId, staged.credentialId);
|
||||
assert.equal(provisioned.credentialToken, null);
|
||||
assert.equal(fs.existsSync(pendingPath), false);
|
||||
assert.equal(fs.existsSync(console.credentialDeliveryPath(mutationId)), true);
|
||||
});
|
||||
|
||||
test('recovers database-committed credential and challenge after publish failure', async (t) => {
|
||||
const options = await ready(t);
|
||||
const delivery = new FileLocalOwnerBootstrapSecretDelivery(
|
||||
options.secretDeliveryDirectory,
|
||||
);
|
||||
const failingDelivery = {
|
||||
prepare(candidate) {
|
||||
return delivery.prepare(candidate);
|
||||
},
|
||||
async publish() {
|
||||
throw new Error('injected publish failure');
|
||||
},
|
||||
};
|
||||
|
||||
const provisionMutationId = '00000000-0000-4000-8000-000000000221';
|
||||
const first = await directService(options, failingDelivery);
|
||||
await assert.rejects(
|
||||
first.service.provision({
|
||||
mutationId: provisionMutationId,
|
||||
requestId: 'console-provision-221',
|
||||
}),
|
||||
LocalOwnerBootstrapServiceUnavailableError,
|
||||
);
|
||||
await first.database.close();
|
||||
|
||||
const recoveredCredential = await openLocalOwnerConsole(options);
|
||||
assert.deepEqual(recoveredCredential.recovery, {
|
||||
inspectedPendingRecords: 1,
|
||||
publishedRecords: 1,
|
||||
retainedUncommittedRecords: 0,
|
||||
orphanTemporaryRecords: 0,
|
||||
});
|
||||
const replayedProvision = await recoveredCredential.service.provision({
|
||||
mutationId: provisionMutationId,
|
||||
requestId: 'console-provision-221',
|
||||
});
|
||||
assert.equal(replayedProvision.status, 'existing');
|
||||
assert.equal(replayedProvision.credentialToken, null);
|
||||
await recoveredCredential.close();
|
||||
|
||||
const challengeMutationId = '00000000-0000-4000-8000-000000000222';
|
||||
const second = await directService(options, failingDelivery);
|
||||
await assert.rejects(
|
||||
second.service.issue({
|
||||
projectId: 'default',
|
||||
mutationId: challengeMutationId,
|
||||
requestId: 'console-issue-222',
|
||||
}),
|
||||
LocalOwnerBootstrapServiceUnavailableError,
|
||||
);
|
||||
await second.database.close();
|
||||
|
||||
const recoveredChallenge = await openLocalOwnerConsole(options);
|
||||
t.after(() => recoveredChallenge.close());
|
||||
assert.deepEqual(recoveredChallenge.recovery, {
|
||||
inspectedPendingRecords: 1,
|
||||
publishedRecords: 1,
|
||||
retainedUncommittedRecords: 0,
|
||||
orphanTemporaryRecords: 0,
|
||||
});
|
||||
const replayedIssue = await recoveredChallenge.service.issue({
|
||||
projectId: 'default',
|
||||
mutationId: challengeMutationId,
|
||||
requestId: 'console-issue-222',
|
||||
});
|
||||
assert.equal(replayedIssue.status, 'existing');
|
||||
assert.equal(replayedIssue.challengeToken, null);
|
||||
});
|
||||
|
||||
test('fails closed on tampered delivery records and bounded-directory overflow', async (t) => {
|
||||
await t.test('private record mode', async (t) => {
|
||||
const options = await ready(t);
|
||||
const console = await openLocalOwnerConsole(options);
|
||||
const mutationId = '00000000-0000-4000-8000-000000000231';
|
||||
await console.service.provision({
|
||||
mutationId,
|
||||
requestId: 'console-provision-231',
|
||||
});
|
||||
const recordPath = console.credentialDeliveryPath(mutationId);
|
||||
await console.close();
|
||||
fs.chmodSync(recordPath, 0o644);
|
||||
await assert.rejects(
|
||||
openLocalOwnerConsole(options),
|
||||
LocalOwnerSecretDeliveryError,
|
||||
);
|
||||
});
|
||||
|
||||
await t.test('database digest mismatch', async (t) => {
|
||||
const options = await ready(t);
|
||||
const console = await openLocalOwnerConsole(options);
|
||||
const mutationId = '00000000-0000-4000-8000-000000000232';
|
||||
await console.service.provision({
|
||||
mutationId,
|
||||
requestId: 'console-provision-232',
|
||||
});
|
||||
const recordPath = console.credentialDeliveryPath(mutationId);
|
||||
const record = JSON.parse(fs.readFileSync(recordPath, 'utf8'));
|
||||
await console.close();
|
||||
record.secret = Buffer.alloc(32, 99).toString('base64url');
|
||||
fs.writeFileSync(recordPath, `${JSON.stringify(record)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
await assert.rejects(
|
||||
openLocalOwnerConsole(options),
|
||||
LocalOwnerSecretDeliveryError,
|
||||
);
|
||||
});
|
||||
|
||||
await t.test('entry budget', async (t) => {
|
||||
const options = await ready(t);
|
||||
for (let index = 0; index < 65; index += 1) {
|
||||
const name = `.credential-${randomUUID()}.${randomUUID()}.tmp`;
|
||||
fs.writeFileSync(path.join(options.secretDeliveryDirectory, name), 'x', {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
await assert.rejects(
|
||||
openLocalOwnerConsole(options),
|
||||
LocalOwnerSecretDeliveryError,
|
||||
);
|
||||
});
|
||||
|
||||
await t.test('tampered acknowledgement fact', async (t) => {
|
||||
const options = await ready(t);
|
||||
const console = await openLocalOwnerConsole(options);
|
||||
const mutationId = '00000000-0000-4000-8000-000000000233';
|
||||
await console.service.provision({
|
||||
mutationId,
|
||||
requestId: 'console-provision-233',
|
||||
});
|
||||
const summary = console.inspectCredentialDelivery(mutationId);
|
||||
await console.acknowledgeCredentialDelivery(
|
||||
mutationId,
|
||||
summary.deliveryDigest,
|
||||
);
|
||||
await console.close();
|
||||
const database = new DatabaseSync(options.databasePath);
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE "QingLong3LocalOwnerDeliveryAcknowledgements"
|
||||
SET "fact_digest" = ? WHERE "mutation_id" = ?`,
|
||||
)
|
||||
.run('0'.repeat(64), mutationId);
|
||||
database.close();
|
||||
const reopened = await openLocalOwnerConsole(options);
|
||||
t.after(() => reopened.close());
|
||||
await assert.rejects(
|
||||
reopened.service.provision({
|
||||
mutationId,
|
||||
requestId: 'console-provision-233',
|
||||
}),
|
||||
LocalOwnerBootstrapServiceUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
await t.test('acknowledged mutation with pending record', async (t) => {
|
||||
const options = await ready(t);
|
||||
const console = await openLocalOwnerConsole(options);
|
||||
const mutationId = '00000000-0000-4000-8000-000000000234';
|
||||
await console.service.provision({
|
||||
mutationId,
|
||||
requestId: 'console-provision-234',
|
||||
});
|
||||
const summary = console.inspectCredentialDelivery(mutationId);
|
||||
const readyMaterial = fs.readFileSync(summary.path);
|
||||
await console.acknowledgeCredentialDelivery(
|
||||
mutationId,
|
||||
summary.deliveryDigest,
|
||||
);
|
||||
await console.close();
|
||||
fs.writeFileSync(
|
||||
summary.path.replace('.ready.json', '.pending.json'),
|
||||
readyMaterial,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await assert.rejects(
|
||||
openLocalOwnerConsole(options),
|
||||
LocalOwnerSecretDeliveryError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('cleans staged files after ENOSPC and read-only delivery failures', async (t) => {
|
||||
for (const [index, code] of ['ENOSPC', 'EROFS'].entries()) {
|
||||
await t.test(code, async (t) => {
|
||||
const options = await ready(t);
|
||||
const console = await openLocalOwnerConsole(options);
|
||||
t.after(() => console.close());
|
||||
const originalWriteFileSync = fs.writeFileSync;
|
||||
fs.writeFileSync = function injectedWriteFailure(target, ...args) {
|
||||
if (typeof target === 'number') {
|
||||
throw Object.assign(new Error(`injected ${code}`), { code });
|
||||
}
|
||||
return originalWriteFileSync.call(this, target, ...args);
|
||||
};
|
||||
try {
|
||||
await assert.rejects(
|
||||
console.service.provision({
|
||||
mutationId: `00000000-0000-4000-8000-00000000024${index}`,
|
||||
requestId: `console-provision-24${index}`,
|
||||
}),
|
||||
LocalOwnerBootstrapServiceUnavailableError,
|
||||
);
|
||||
} finally {
|
||||
fs.writeFileSync = originalWriteFileSync;
|
||||
}
|
||||
assert.deepEqual(fs.readdirSync(options.secretDeliveryDirectory), []);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects broad deployment permissions and pepper symlinks', async (t) => {
|
||||
const broad = await ready(t);
|
||||
fs.chmodSync(broad.deploymentRoot, 0o755);
|
||||
await assert.rejects(
|
||||
openLocalOwnerConsole(broad),
|
||||
LocalOwnerConsoleConfigurationError,
|
||||
);
|
||||
|
||||
const linked = await ready(t);
|
||||
const actualPepper = path.join(linked.deploymentRoot, 'actual.pepper');
|
||||
fs.renameSync(linked.pepperPath, actualPepper);
|
||||
fs.symlinkSync(actualPepper, linked.pepperPath);
|
||||
await assert.rejects(
|
||||
openLocalOwnerConsole(linked),
|
||||
LocalOwnerConsoleConfigurationError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rechecks database identity before every authority operation', async (t) => {
|
||||
const options = await ready(t);
|
||||
const console = await openLocalOwnerConsole(options);
|
||||
t.after(() => console.close());
|
||||
const moved = path.join(options.deploymentRoot, 'moved.sqlite');
|
||||
fs.renameSync(options.databasePath, moved);
|
||||
fs.copyFileSync(moved, options.databasePath);
|
||||
fs.chmodSync(options.databasePath, 0o600);
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
console.service.provision({
|
||||
mutationId: '00000000-0000-4000-8000-000000000203',
|
||||
requestId: 'console-provision-203',
|
||||
}),
|
||||
LocalOwnerConsoleConfigurationError,
|
||||
);
|
||||
});
|
||||
|
||||
test('close is idempotent and no CLI or default-runtime authority is exported', async (t) => {
|
||||
const options = await ready(t);
|
||||
const console = await openLocalOwnerConsole(options);
|
||||
await Promise.all([console.close(), console.close()]);
|
||||
await assert.rejects(
|
||||
console.service.provision({
|
||||
mutationId: '00000000-0000-4000-8000-000000000204',
|
||||
requestId: 'console-provision-204',
|
||||
}),
|
||||
);
|
||||
const manifest = require('../package.json');
|
||||
assert.equal('bin' in manifest, false);
|
||||
const localRuntime = require('@qinglong/local-sqlite/runtime');
|
||||
assert.equal('openLocalOwnerConsole' in localRuntime, false);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
FileLocalCredentialAdministrationDelivery,
|
||||
LocalCredentialAdministrationDeliveryError,
|
||||
} = require('@qinglong/local-owner-console/credential-administration-delivery');
|
||||
|
||||
const MUTATION_ID = '81000000-0000-4000-8000-000000000001';
|
||||
const RECOVERY_MUTATION_ID = '81000000-0000-4000-8000-000000000002';
|
||||
const SECRET = Buffer.alloc(32, 81).toString('base64url');
|
||||
|
||||
function fixture(t) {
|
||||
const root = fs.mkdtempSync(
|
||||
path.join(fs.realpathSync(os.tmpdir()), 'ql3-managed-credential-'),
|
||||
);
|
||||
fs.chmodSync(root, 0o700);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
|
||||
function record(overrides = {}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-managed-credential-delivery',
|
||||
mutationId: MUTATION_ID,
|
||||
requestId: 'managed-credential-issue',
|
||||
projectId: 'default',
|
||||
subject: { type: 'agent', id: 'agent-planner' },
|
||||
credentialId: 'agent-planner-primary',
|
||||
secret: SECRET,
|
||||
notBeforeAtMs: 1_000,
|
||||
expiresAtMs: 61_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('stages, replays, publishes and acknowledges one private credential', (t) => {
|
||||
const directory = fixture(t);
|
||||
const delivery = new FileLocalCredentialAdministrationDelivery(directory);
|
||||
const first = delivery.prepare(record());
|
||||
const digest = delivery.digest(first);
|
||||
|
||||
const replay = delivery.prepare(
|
||||
record({
|
||||
secret: Buffer.alloc(32, 82).toString('base64url'),
|
||||
notBeforeAtMs: 11_000,
|
||||
expiresAtMs: 71_000,
|
||||
}),
|
||||
);
|
||||
assert.equal(replay.secret, SECRET);
|
||||
assert.equal(replay.notBeforeAtMs, 1_000);
|
||||
assert.equal(delivery.digest(replay), digest);
|
||||
|
||||
const published = delivery.publish(replay, digest);
|
||||
assert.equal(
|
||||
path.basename(published.path),
|
||||
`managed-credential-${MUTATION_ID}.ready.json`,
|
||||
);
|
||||
assert.equal(fs.statSync(published.path).mode & 0o777, 0o600);
|
||||
assert.equal(delivery.inspect(MUTATION_ID).deliveryDigest, digest);
|
||||
const presentation = JSON.parse(fs.readFileSync(published.path, 'utf8'));
|
||||
assert.equal(
|
||||
presentation.kind,
|
||||
'qinglong3-local-identity-credential-presentation',
|
||||
);
|
||||
assert.match(presentation.token, /^ql3c_agent-planner-primary_/);
|
||||
|
||||
assert.equal(delivery.removeAcknowledged(MUTATION_ID, digest), 'removed');
|
||||
assert.equal(delivery.removeAcknowledged(MUTATION_ID, digest), 'absent');
|
||||
|
||||
const recovery = delivery.prepare(
|
||||
record({
|
||||
mutationId: RECOVERY_MUTATION_ID,
|
||||
requestId: 'managed-credential-cleanup-recovery',
|
||||
}),
|
||||
);
|
||||
const recoveryDigest = delivery.digest(recovery);
|
||||
const recoveryReady = delivery.publish(recovery, recoveryDigest);
|
||||
fs.unlinkSync(recoveryReady.path);
|
||||
assert.equal(
|
||||
delivery.removeAcknowledged(RECOVERY_MUTATION_ID, recoveryDigest),
|
||||
'removed',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects semantic replay drift and a symlinked delivery directory', (t) => {
|
||||
const directory = fixture(t);
|
||||
const delivery = new FileLocalCredentialAdministrationDelivery(directory);
|
||||
delivery.prepare(record());
|
||||
assert.throws(
|
||||
() => delivery.prepare(record({ expiresAtMs: 62_000 })),
|
||||
LocalCredentialAdministrationDeliveryError,
|
||||
);
|
||||
|
||||
const link = `${directory}-link`;
|
||||
fs.symlinkSync(directory, link);
|
||||
t.after(() => fs.rmSync(link, { force: true }));
|
||||
assert.throws(
|
||||
() => new FileLocalCredentialAdministrationDelivery(link),
|
||||
LocalCredentialAdministrationDeliveryError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
createLocalOwnerCredentialRecoveryService,
|
||||
} = require('../dist/credential-recovery');
|
||||
|
||||
test('issues a distinct credential and replays acknowledged completion', async () => {
|
||||
const previous = {
|
||||
credentialId: `own_${'a'.repeat(22)}`,
|
||||
version: 1,
|
||||
pepperKeyId: 'owner-key-1',
|
||||
state: 'active',
|
||||
subject: { type: 'user', id: `usr_${'b'.repeat(22)}` },
|
||||
subjectStatus: 'active',
|
||||
secretDigest: '1'.repeat(64),
|
||||
createdAtMs: 100,
|
||||
notBeforeAtMs: 100,
|
||||
expiresAtMs: 100000000,
|
||||
};
|
||||
let recovery = null;
|
||||
const credentials = {
|
||||
async resolve(credentialId) {
|
||||
return credentialId === previous.credentialId ? previous : null;
|
||||
},
|
||||
};
|
||||
const repository = {
|
||||
async resolve() {
|
||||
return recovery;
|
||||
},
|
||||
async issue(command) {
|
||||
recovery = {
|
||||
issueMutationId: command.mutationId,
|
||||
issueRequestId: command.requestId,
|
||||
subjectId: command.replacementCredential.subject.id,
|
||||
previousCredentialId: command.previousCredentialId,
|
||||
previousCredentialVersion: command.expectedPreviousVersion,
|
||||
replacementCredential: command.replacementCredential,
|
||||
state: 'issued',
|
||||
issuedAtMs: command.replacementCredential.createdAtMs,
|
||||
};
|
||||
return { status: 'inserted', recovery };
|
||||
},
|
||||
async acknowledge() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async complete(command) {
|
||||
assert.equal(recovery.state, 'acknowledged');
|
||||
recovery = {
|
||||
...recovery,
|
||||
state: 'completed',
|
||||
completeMutationId: command.mutationId,
|
||||
completeRequestId: command.requestId,
|
||||
revokedCredentialVersion: command.revokedCredential.version,
|
||||
completedAtMs: command.revokedCredential.createdAtMs,
|
||||
};
|
||||
return { status: 'inserted', recovery };
|
||||
},
|
||||
};
|
||||
let nowMs = 1000;
|
||||
const service = createLocalOwnerCredentialRecoveryService(
|
||||
repository,
|
||||
credentials,
|
||||
Buffer.alloc(32, 7).toString('base64url'),
|
||||
{
|
||||
pepperKeyId: 'owner-key-1',
|
||||
now: () => nowMs,
|
||||
randomBytes: (size) => Buffer.alloc(size, size),
|
||||
},
|
||||
);
|
||||
const issueMutationId = '00000000-0000-4000-8000-000000000801';
|
||||
const issued = await service.issue({
|
||||
mutationId: issueMutationId,
|
||||
requestId: 'recover-issue-801',
|
||||
previousCredentialId: previous.credentialId,
|
||||
expectedPreviousVersion: 1,
|
||||
});
|
||||
assert.equal(issued.status, 'inserted');
|
||||
assert.notEqual(issued.replacementCredentialId, previous.credentialId);
|
||||
assert.match(issued.replacementCredentialToken, /^ql3c_/);
|
||||
|
||||
recovery = {
|
||||
...recovery,
|
||||
state: 'acknowledged',
|
||||
deliveryDigest: '2'.repeat(64),
|
||||
acknowledgedAtMs: 1100,
|
||||
};
|
||||
nowMs = 1200;
|
||||
const completion = {
|
||||
issueMutationId,
|
||||
mutationId: '00000000-0000-4000-8000-000000000802',
|
||||
requestId: 'recover-complete-802',
|
||||
};
|
||||
assert.equal((await service.complete(completion)).status, 'inserted');
|
||||
assert.equal((await service.complete(completion)).status, 'existing');
|
||||
});
|
||||
@@ -0,0 +1,517 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
LocalOwnerPepperUnavailableError,
|
||||
localOwnerPepperKeyPath,
|
||||
provisionLocalOwnerPepperKey,
|
||||
} = require('../dist/pepper-custody');
|
||||
const { destroyLocalOwnerPepperKey } = require(
|
||||
'../dist/pepper-custody/destructive',
|
||||
);
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-pepper-gc-'));
|
||||
fs.chmodSync(directory, 0o700);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return directory;
|
||||
}
|
||||
|
||||
test('destroys one exact key durably and replays the same absence proof', (t) => {
|
||||
const keyringDirectory = fixture(t);
|
||||
const pepperKeyId = 'owner-key-retired';
|
||||
const material = provisionLocalOwnerPepperKey({
|
||||
keyringDirectory,
|
||||
pepperKeyId,
|
||||
randomBytes: () => Buffer.alloc(32, 23),
|
||||
});
|
||||
const options = {
|
||||
keyringDirectory,
|
||||
pepperKeyId,
|
||||
materialRole: 'runtime',
|
||||
expectedMaterialDigest: material.digest,
|
||||
prepareMutationId: '00000000-0000-4000-8000-000000000501',
|
||||
};
|
||||
const destroyed = destroyLocalOwnerPepperKey(options);
|
||||
assert.equal(destroyed.status, 'destroyed');
|
||||
assert.equal(
|
||||
fs.existsSync(localOwnerPepperKeyPath(keyringDirectory, pepperKeyId)),
|
||||
false,
|
||||
);
|
||||
const replay = destroyLocalOwnerPepperKey(options);
|
||||
assert.equal(replay.status, 'absent');
|
||||
assert.equal(replay.destructionProofDigest, destroyed.destructionProofDigest);
|
||||
});
|
||||
|
||||
test('refuses digest drift without deleting the material', (t) => {
|
||||
const keyringDirectory = fixture(t);
|
||||
const pepperKeyId = 'owner-key-retired';
|
||||
provisionLocalOwnerPepperKey({
|
||||
keyringDirectory,
|
||||
pepperKeyId,
|
||||
randomBytes: () => Buffer.alloc(32, 29),
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
destroyLocalOwnerPepperKey({
|
||||
keyringDirectory,
|
||||
pepperKeyId,
|
||||
materialRole: 'runtime',
|
||||
expectedMaterialDigest: '0'.repeat(64),
|
||||
prepareMutationId: '00000000-0000-4000-8000-000000000502',
|
||||
}),
|
||||
LocalOwnerPepperUnavailableError,
|
||||
);
|
||||
assert.equal(
|
||||
fs.existsSync(localOwnerPepperKeyPath(keyringDirectory, pepperKeyId)),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
LocalOwnerPepperConfigurationError,
|
||||
LocalOwnerPepperConflictError,
|
||||
LocalOwnerPepperUnavailableError,
|
||||
backupLocalOwnerPepper,
|
||||
inspectLocalOwnerPepper,
|
||||
provisionLocalOwnerPepper,
|
||||
restoreLocalOwnerPepper,
|
||||
} = require('../dist/pepper-custody');
|
||||
|
||||
function fixture(t) {
|
||||
const deploymentRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-owner-pepper-'),
|
||||
);
|
||||
fs.chmodSync(deploymentRoot, 0o700);
|
||||
const backupRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-owner-pepper-backup-'),
|
||||
);
|
||||
fs.chmodSync(backupRoot, 0o700);
|
||||
t.after(() => {
|
||||
fs.rmSync(deploymentRoot, { recursive: true, force: true });
|
||||
fs.rmSync(backupRoot, { recursive: true, force: true });
|
||||
});
|
||||
return {
|
||||
deploymentRoot,
|
||||
backupRoot,
|
||||
pepperPath: path.join(deploymentRoot, 'owner.pepper'),
|
||||
backupPath: path.join(backupRoot, 'owner.pepper.backup'),
|
||||
};
|
||||
}
|
||||
|
||||
test('provisions one canonical private pepper without replacement', (t) => {
|
||||
const value = fixture(t);
|
||||
const entropy = Buffer.alloc(32, 41);
|
||||
const result = provisionLocalOwnerPepper({
|
||||
deploymentRoot: value.deploymentRoot,
|
||||
pepperPath: value.pepperPath,
|
||||
randomBytes() {
|
||||
return entropy;
|
||||
},
|
||||
});
|
||||
assert.equal(result.version, 1);
|
||||
assert.equal(result.byteLength, 43);
|
||||
assert.match(result.digest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(fs.statSync(value.pepperPath).mode & 0o777, 0o600);
|
||||
assert.equal(
|
||||
fs.readFileSync(value.pepperPath, 'utf8'),
|
||||
Buffer.alloc(32, 41).toString('base64url'),
|
||||
);
|
||||
assert.equal(entropy.equals(Buffer.alloc(32)), true);
|
||||
assert.deepEqual(
|
||||
inspectLocalOwnerPepper({
|
||||
deploymentRoot: value.deploymentRoot,
|
||||
pepperPath: value.pepperPath,
|
||||
}),
|
||||
result,
|
||||
);
|
||||
|
||||
const before = fs.readFileSync(value.pepperPath);
|
||||
assert.throws(
|
||||
() =>
|
||||
provisionLocalOwnerPepper({
|
||||
deploymentRoot: value.deploymentRoot,
|
||||
pepperPath: value.pepperPath,
|
||||
}),
|
||||
LocalOwnerPepperConflictError,
|
||||
);
|
||||
assert.deepEqual(fs.readFileSync(value.pepperPath), before);
|
||||
});
|
||||
|
||||
test('creates an independent no-replace backup and restores only to absence', (t) => {
|
||||
const value = fixture(t);
|
||||
const provisioned = provisionLocalOwnerPepper({
|
||||
deploymentRoot: value.deploymentRoot,
|
||||
pepperPath: value.pepperPath,
|
||||
randomBytes: () => Buffer.alloc(32, 42),
|
||||
});
|
||||
const backedUp = backupLocalOwnerPepper(value);
|
||||
assert.deepEqual(backedUp, provisioned);
|
||||
const pepperStat = fs.statSync(value.pepperPath, { bigint: true });
|
||||
const backupStat = fs.statSync(value.backupPath, { bigint: true });
|
||||
assert.equal(backupStat.mode & 0o777n, 0o600n);
|
||||
assert.notEqual(backupStat.ino, pepperStat.ino);
|
||||
assert.deepEqual(
|
||||
fs.readFileSync(value.backupPath),
|
||||
fs.readFileSync(value.pepperPath),
|
||||
);
|
||||
|
||||
fs.unlinkSync(value.pepperPath);
|
||||
const restored = restoreLocalOwnerPepper(value);
|
||||
assert.deepEqual(restored, provisioned);
|
||||
assert.deepEqual(
|
||||
fs.readFileSync(value.pepperPath),
|
||||
fs.readFileSync(value.backupPath),
|
||||
);
|
||||
assert.throws(
|
||||
() => restoreLocalOwnerPepper(value),
|
||||
LocalOwnerPepperConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('never overwrites a pre-existing backup', (t) => {
|
||||
const value = fixture(t);
|
||||
provisionLocalOwnerPepper({
|
||||
deploymentRoot: value.deploymentRoot,
|
||||
pepperPath: value.pepperPath,
|
||||
randomBytes: () => Buffer.alloc(32, 43),
|
||||
});
|
||||
fs.writeFileSync(value.backupPath, 'reserved', { mode: 0o600 });
|
||||
assert.throws(
|
||||
() => backupLocalOwnerPepper(value),
|
||||
LocalOwnerPepperConflictError,
|
||||
);
|
||||
assert.equal(fs.readFileSync(value.backupPath, 'utf8'), 'reserved');
|
||||
});
|
||||
|
||||
test('fails closed for broad roots, symlink parents and tampered pepper files', (t) => {
|
||||
const broad = fixture(t);
|
||||
fs.chmodSync(broad.deploymentRoot, 0o755);
|
||||
assert.throws(
|
||||
() =>
|
||||
provisionLocalOwnerPepper({
|
||||
deploymentRoot: broad.deploymentRoot,
|
||||
pepperPath: broad.pepperPath,
|
||||
}),
|
||||
LocalOwnerPepperUnavailableError,
|
||||
);
|
||||
|
||||
const linked = fixture(t);
|
||||
const actual = path.join(linked.deploymentRoot, 'actual');
|
||||
fs.mkdirSync(actual, { mode: 0o700 });
|
||||
const alias = path.join(linked.deploymentRoot, 'alias');
|
||||
fs.symlinkSync(actual, alias);
|
||||
assert.throws(
|
||||
() =>
|
||||
provisionLocalOwnerPepper({
|
||||
deploymentRoot: linked.deploymentRoot,
|
||||
pepperPath: path.join(alias, 'owner.pepper'),
|
||||
}),
|
||||
LocalOwnerPepperUnavailableError,
|
||||
);
|
||||
|
||||
const tampered = fixture(t);
|
||||
provisionLocalOwnerPepper({
|
||||
deploymentRoot: tampered.deploymentRoot,
|
||||
pepperPath: tampered.pepperPath,
|
||||
});
|
||||
fs.chmodSync(tampered.pepperPath, 0o644);
|
||||
assert.throws(
|
||||
() =>
|
||||
inspectLocalOwnerPepper({
|
||||
deploymentRoot: tampered.deploymentRoot,
|
||||
pepperPath: tampered.pepperPath,
|
||||
}),
|
||||
LocalOwnerPepperUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects invalid entropy and widened options before publishing', (t) => {
|
||||
const value = fixture(t);
|
||||
assert.throws(
|
||||
() =>
|
||||
provisionLocalOwnerPepper({
|
||||
deploymentRoot: value.deploymentRoot,
|
||||
pepperPath: value.pepperPath,
|
||||
randomBytes: () => Buffer.alloc(31),
|
||||
}),
|
||||
LocalOwnerPepperConfigurationError,
|
||||
);
|
||||
assert.equal(fs.existsSync(value.pepperPath), false);
|
||||
assert.throws(
|
||||
() =>
|
||||
provisionLocalOwnerPepper({
|
||||
deploymentRoot: value.deploymentRoot,
|
||||
pepperPath: value.pepperPath,
|
||||
extra: true,
|
||||
}),
|
||||
LocalOwnerPepperConfigurationError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
provisionLocalOwnerPepper({
|
||||
deploymentRoot: value.deploymentRoot,
|
||||
pepperPath: value.pepperPath,
|
||||
randomBytes() {
|
||||
throw new Error('sensitive entropy provider detail');
|
||||
},
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof LocalOwnerPepperUnavailableError &&
|
||||
error.message === 'Local Owner pepper operation is unavailable',
|
||||
);
|
||||
assert.equal(fs.existsSync(value.pepperPath), false);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
LocalOwnerPepperKeyringFileProvider,
|
||||
LocalOwnerPepperUnavailableError,
|
||||
backupLocalOwnerPepperKey,
|
||||
localOwnerPepperKeyPath,
|
||||
provisionLocalOwnerPepperKey,
|
||||
restoreLocalOwnerPepperKey,
|
||||
} = require('../dist/pepper-custody');
|
||||
|
||||
function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-owner-keyring-'));
|
||||
fs.chmodSync(root, 0o700);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
|
||||
test('loads one exact key without exposing an active filesystem pointer', (t) => {
|
||||
const keyringDirectory = fixture(t);
|
||||
const summaries = ['owner-key-2', 'owner-key-1'].map((pepperKeyId, index) =>
|
||||
provisionLocalOwnerPepperKey({
|
||||
keyringDirectory,
|
||||
pepperKeyId,
|
||||
randomBytes: () => Buffer.alloc(32, index + 1),
|
||||
}),
|
||||
);
|
||||
const provider = new LocalOwnerPepperKeyringFileProvider(keyringDirectory);
|
||||
assert.deepEqual(provider.inspect(), {
|
||||
version: 1,
|
||||
keyIds: ['owner-key-1', 'owner-key-2'],
|
||||
});
|
||||
assert.equal(provider.resolve('missing-key'), null);
|
||||
assert.deepEqual(provider.resolve('owner-key-1').summary, summaries[1]);
|
||||
assert.equal(
|
||||
path.basename(localOwnerPepperKeyPath(keyringDirectory, 'owner-key-1')),
|
||||
`${Buffer.from('owner-key-1').toString('base64url')}.pepper`,
|
||||
);
|
||||
});
|
||||
|
||||
test('backs up and restores one exact key without replacement or inode reuse', (t) => {
|
||||
const keyringDirectory = fixture(t);
|
||||
const backupDirectory = fixture(t);
|
||||
const pepperKeyId = 'owner-key-recovery';
|
||||
const provisioned = provisionLocalOwnerPepperKey({
|
||||
keyringDirectory,
|
||||
pepperKeyId,
|
||||
randomBytes: () => Buffer.alloc(32, 31),
|
||||
});
|
||||
assert.deepEqual(
|
||||
backupLocalOwnerPepperKey({
|
||||
keyringDirectory,
|
||||
backupDirectory,
|
||||
pepperKeyId,
|
||||
}),
|
||||
provisioned,
|
||||
);
|
||||
const sourcePath = localOwnerPepperKeyPath(keyringDirectory, pepperKeyId);
|
||||
const backupPath = localOwnerPepperKeyPath(backupDirectory, pepperKeyId);
|
||||
assert.notEqual(
|
||||
fs.statSync(sourcePath, { bigint: true }).ino,
|
||||
fs.statSync(backupPath, { bigint: true }).ino,
|
||||
);
|
||||
fs.unlinkSync(sourcePath);
|
||||
assert.deepEqual(
|
||||
restoreLocalOwnerPepperKey({
|
||||
keyringDirectory,
|
||||
backupDirectory,
|
||||
pepperKeyId,
|
||||
}),
|
||||
provisioned,
|
||||
);
|
||||
assert.deepEqual(
|
||||
new LocalOwnerPepperKeyringFileProvider(keyringDirectory).resolve(
|
||||
pepperKeyId,
|
||||
).summary,
|
||||
provisioned,
|
||||
);
|
||||
});
|
||||
|
||||
test('hard-caps the directory and rejects symlinks or unknown entries', (t) => {
|
||||
const keyringDirectory = fixture(t);
|
||||
for (let index = 1; index <= 8; index += 1) {
|
||||
provisionLocalOwnerPepperKey({
|
||||
keyringDirectory,
|
||||
pepperKeyId: `owner-key-${index}`,
|
||||
randomBytes: () => Buffer.alloc(32, index),
|
||||
});
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
provisionLocalOwnerPepperKey({
|
||||
keyringDirectory,
|
||||
pepperKeyId: 'owner-key-9',
|
||||
}),
|
||||
LocalOwnerPepperUnavailableError,
|
||||
);
|
||||
|
||||
const unsafe = fixture(t);
|
||||
fs.symlinkSync(
|
||||
localOwnerPepperKeyPath(keyringDirectory, 'owner-key-1'),
|
||||
localOwnerPepperKeyPath(unsafe, 'owner-key-1'),
|
||||
);
|
||||
assert.throws(
|
||||
() => new LocalOwnerPepperKeyringFileProvider(unsafe),
|
||||
LocalOwnerPepperUnavailableError,
|
||||
);
|
||||
fs.unlinkSync(localOwnerPepperKeyPath(unsafe, 'owner-key-1'));
|
||||
fs.writeFileSync(path.join(unsafe, 'active'), 'owner-key-1', { mode: 0o600 });
|
||||
assert.throws(
|
||||
() => new LocalOwnerPepperKeyringFileProvider(unsafe),
|
||||
LocalOwnerPepperUnavailableError,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user