mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "@qinglong/local-owner-console",
|
||||
"version": "3.0.0-alpha.0",
|
||||
"private": true,
|
||||
"description": "QingLong 3.0 short-lived POSIX local Owner console and internal ceremonies",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=24.18.0 <25"
|
||||
},
|
||||
"main": "dist/application-runtime/localOwnerConsole.js",
|
||||
"types": "dist/application-runtime/localOwnerConsole.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/application-runtime/localOwnerConsole.d.ts",
|
||||
"require": "./dist/application-runtime/localOwnerConsole.js",
|
||||
"default": "./dist/application-runtime/localOwnerConsole.js"
|
||||
},
|
||||
"./secret-delivery": {
|
||||
"types": "./dist/delivery/secretDelivery.d.ts",
|
||||
"require": "./dist/delivery/secretDelivery.js",
|
||||
"default": "./dist/delivery/secretDelivery.js"
|
||||
},
|
||||
"./authenticated-command": {
|
||||
"types": "./dist/authentication/authenticatedCommand.d.ts",
|
||||
"require": "./dist/authentication/authenticatedCommand.js",
|
||||
"default": "./dist/authentication/authenticatedCommand.js"
|
||||
},
|
||||
"./credential-administration-delivery": {
|
||||
"types": "./dist/delivery/credentialAdministrationDelivery.d.ts",
|
||||
"require": "./dist/delivery/credentialAdministrationDelivery.js",
|
||||
"default": "./dist/delivery/credentialAdministrationDelivery.js"
|
||||
},
|
||||
"./identity-authentication": {
|
||||
"types": "./dist/authentication/identityAuthentication.d.ts",
|
||||
"require": "./dist/authentication/identityAuthentication.js",
|
||||
"default": "./dist/authentication/identityAuthentication.js"
|
||||
},
|
||||
"./pepper-custody": {
|
||||
"types": "./dist/pepper-custody/index.d.ts",
|
||||
"require": "./dist/pepper-custody/index.js",
|
||||
"default": "./dist/pepper-custody/index.js"
|
||||
},
|
||||
"./pepper-custody/destructive": {
|
||||
"types": "./dist/pepper-custody/destructive.d.ts",
|
||||
"require": "./dist/pepper-custody/destructive.js",
|
||||
"default": "./dist/pepper-custody/destructive.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.js",
|
||||
"dist/**/*.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "node ../../scripts/ql3-build-package-closure.cjs && tsc -p tsconfig.json --noEmit",
|
||||
"test": "node ../../scripts/ql3-build-package-closure.cjs && node --test test/*.test.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@qinglong/local-sqlite": "workspace:*",
|
||||
"@qinglong/runtime-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
createLocalOwnerBootstrapService,
|
||||
type ClaimLocalOwnerRequest,
|
||||
type IssueLocalOwnerBootstrapRequest,
|
||||
type LocalOwnerBootstrapSecretDeliveryAcknowledgement,
|
||||
type LocalOwnerBootstrapService,
|
||||
type ProvisionLocalIdentityRequest,
|
||||
} from '../bootstrap';
|
||||
import {
|
||||
createLocalOwnerCredentialRecoveryService,
|
||||
type CompleteLocalOwnerCredentialRecoveryRequest,
|
||||
type IssueLocalOwnerCredentialRecoveryRequest,
|
||||
type LocalOwnerCredentialRecoveryDeliveryAcknowledgement,
|
||||
type LocalOwnerCredentialRecoveryDeliveryRecord,
|
||||
type LocalOwnerCredentialRecoveryService,
|
||||
} from '../credential-recovery';
|
||||
import {
|
||||
openLocalSqliteBootstrapDatabase,
|
||||
type LocalSqliteProfile,
|
||||
type LocalSqliteReadinessEvidence,
|
||||
} from '@qinglong/local-sqlite/bootstrap';
|
||||
import { assertApiCredentialPepper } from '@qinglong/runtime-core/api-credential-token';
|
||||
import {
|
||||
LEGACY_API_CREDENTIAL_PEPPER_KEY_ID,
|
||||
assertApiCredentialPepperKeyId,
|
||||
} from '@qinglong/runtime-core/api-credential';
|
||||
import { LOCAL_OWNER_BOOTSTRAP_SYSTEM_SUBJECT } from '@qinglong/runtime-core/local-owner-bootstrap';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import {
|
||||
FileLocalOwnerBootstrapSecretDelivery,
|
||||
type ClaimLocalOwnerFromDeliveriesRequest,
|
||||
type LocalOwnerSecretRecoverySummary,
|
||||
type LocalOwnerSecretDeliverySummary,
|
||||
} from '../delivery/secretDelivery';
|
||||
|
||||
export {
|
||||
FileLocalOwnerBootstrapSecretDelivery,
|
||||
LocalOwnerSecretDeliveryError,
|
||||
type ClaimLocalOwnerFromDeliveriesRequest,
|
||||
type LocalOwnerSecretDeliverySummary,
|
||||
type LocalOwnerSecretRecoverySummary,
|
||||
} from '../delivery/secretDelivery';
|
||||
export {
|
||||
LocalOwnerPepperConfigurationError,
|
||||
LocalOwnerPepperConflictError,
|
||||
LocalOwnerPepperUnavailableError,
|
||||
backupLocalOwnerPepper,
|
||||
inspectLocalOwnerPepper,
|
||||
provisionLocalOwnerPepper,
|
||||
restoreLocalOwnerPepper,
|
||||
type BackupLocalOwnerPepperOptions,
|
||||
type LocalOwnerPepperPathOptions,
|
||||
type LocalOwnerPepperSummary,
|
||||
type ProvisionLocalOwnerPepperOptions,
|
||||
type RestoreLocalOwnerPepperOptions,
|
||||
LocalOwnerPepperKeyringFileProvider,
|
||||
backupLocalOwnerPepperKey,
|
||||
localOwnerPepperKeyPath,
|
||||
provisionLocalOwnerPepperKey,
|
||||
restoreLocalOwnerPepperKey,
|
||||
type BackupLocalOwnerPepperKeyOptions,
|
||||
type LocalOwnerPepperKeyMaterial,
|
||||
type LocalOwnerPepperKeyringSummary,
|
||||
type ProvisionLocalOwnerPepperKeyOptions,
|
||||
type RestoreLocalOwnerPepperKeyOptions,
|
||||
} from '../pepper-custody';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const MAX_PEPPER_BYTES = 256;
|
||||
const AUTHORITY_TTL_MS = 60_000;
|
||||
|
||||
interface PathIdentity {
|
||||
readonly path: string;
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
readonly uid: number;
|
||||
readonly mode: number;
|
||||
readonly kind: 'directory' | 'file';
|
||||
}
|
||||
|
||||
export interface OpenLocalOwnerConsoleOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly databasePath: string;
|
||||
readonly pepperPath: string;
|
||||
readonly pepperKeyId?: string;
|
||||
readonly secretDeliveryDirectory: string;
|
||||
readonly profile: LocalSqliteProfile;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface LocalOwnerConsole {
|
||||
readonly profile: LocalSqliteProfile;
|
||||
readonly readiness: LocalSqliteReadinessEvidence;
|
||||
readonly recovery: Readonly<LocalOwnerSecretRecoverySummary>;
|
||||
readonly service: LocalOwnerBootstrapService;
|
||||
readonly credentialRecovery: LocalOwnerCredentialRecoveryService;
|
||||
credentialDeliveryPath(mutationId: string): string;
|
||||
challengeDeliveryPath(mutationId: string): string;
|
||||
inspectCredentialDelivery(
|
||||
mutationId: string,
|
||||
): Readonly<LocalOwnerSecretDeliverySummary>;
|
||||
inspectChallengeDelivery(
|
||||
mutationId: string,
|
||||
): Readonly<LocalOwnerSecretDeliverySummary>;
|
||||
claimOwnerFromDeliveries(
|
||||
request: ClaimLocalOwnerFromDeliveriesRequest,
|
||||
): ReturnType<LocalOwnerBootstrapService['claim']>;
|
||||
acknowledgeCredentialDelivery(
|
||||
mutationId: string,
|
||||
expectedDeliveryDigest: string,
|
||||
): Promise<Readonly<LocalOwnerBootstrapSecretDeliveryAcknowledgement>>;
|
||||
acknowledgeCredentialRecoveryDelivery(
|
||||
mutationId: string,
|
||||
expectedDeliveryDigest: string,
|
||||
): Promise<Readonly<LocalOwnerCredentialRecoveryDeliveryAcknowledgement>>;
|
||||
acknowledgeChallengeDelivery(
|
||||
mutationId: string,
|
||||
expectedDeliveryDigest: string,
|
||||
): Promise<Readonly<LocalOwnerBootstrapSecretDeliveryAcknowledgement>>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export class LocalOwnerConsoleConfigurationError extends Error {
|
||||
readonly code = 'LOCAL_OWNER_CONSOLE_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Local Owner console configuration is invalid: ${message}`);
|
||||
this.name = 'LocalOwnerConsoleConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function boundedAbsolutePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
Buffer.byteLength(value) < 1 ||
|
||||
Buffer.byteLength(value) > MAX_PATH_BYTES ||
|
||||
value.includes('\0') ||
|
||||
path.normalize(value) !== value
|
||||
) {
|
||||
throw new LocalOwnerConsoleConfigurationError(
|
||||
`${label} must be a normalized bounded absolute path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function currentUid(): number {
|
||||
if (
|
||||
typeof process.getuid !== 'function' ||
|
||||
typeof process.geteuid !== 'function'
|
||||
) {
|
||||
throw new LocalOwnerConsoleConfigurationError(
|
||||
'POSIX user identity is unavailable',
|
||||
);
|
||||
}
|
||||
const uid = process.getuid();
|
||||
const effectiveUid = process.geteuid();
|
||||
if (
|
||||
!Number.isSafeInteger(uid) ||
|
||||
uid < 0 ||
|
||||
!Number.isSafeInteger(effectiveUid) ||
|
||||
effectiveUid < 0 ||
|
||||
uid !== effectiveUid
|
||||
) {
|
||||
throw new LocalOwnerConsoleConfigurationError(
|
||||
'real and effective POSIX users must match',
|
||||
);
|
||||
}
|
||||
return uid;
|
||||
}
|
||||
|
||||
function identity(
|
||||
targetPath: string,
|
||||
uid: number,
|
||||
kind: PathIdentity['kind'],
|
||||
): PathIdentity {
|
||||
const stat = fs.lstatSync(targetPath, { bigint: true });
|
||||
const expectedKind =
|
||||
kind === 'directory' ? stat.isDirectory() : stat.isFile();
|
||||
if (
|
||||
!expectedKind ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== uid ||
|
||||
(Number(stat.mode) & 0o777) !== (kind === 'directory' ? 0o700 : 0o600)
|
||||
) {
|
||||
throw new LocalOwnerConsoleConfigurationError(
|
||||
`${kind} ownership or private mode is invalid`,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
path: targetPath,
|
||||
device: stat.dev,
|
||||
inode: stat.ino,
|
||||
uid,
|
||||
mode: Number(stat.mode) & 0o777,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
|
||||
function descendants(
|
||||
deploymentRoot: string,
|
||||
targetPath: string,
|
||||
): readonly string[] {
|
||||
const relative = path.relative(deploymentRoot, targetPath);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LocalOwnerConsoleConfigurationError(
|
||||
'authority files must be descendants of deploymentRoot',
|
||||
);
|
||||
}
|
||||
const parts = relative.split(path.sep);
|
||||
const directories: string[] = [];
|
||||
let current = deploymentRoot;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
current = path.join(current, part);
|
||||
directories.push(current);
|
||||
}
|
||||
return directories;
|
||||
}
|
||||
|
||||
function containsPath(container: string, target: string): boolean {
|
||||
const relative = path.relative(container, target);
|
||||
return (
|
||||
relative.length === 0 ||
|
||||
(relative !== '..' &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function sameIdentity(expected: PathIdentity): void {
|
||||
const current = identity(expected.path, expected.uid, expected.kind);
|
||||
if (
|
||||
current.device !== expected.device ||
|
||||
current.inode !== expected.inode ||
|
||||
current.mode !== expected.mode
|
||||
) {
|
||||
throw new LocalOwnerConsoleConfigurationError(
|
||||
`${expected.kind} identity changed during console activation`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readPepper(expected: PathIdentity): {
|
||||
readonly value: string;
|
||||
readonly digest: string;
|
||||
} {
|
||||
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
|
||||
const descriptor = fs.openSync(
|
||||
expected.path,
|
||||
fs.constants.O_RDONLY | noFollow,
|
||||
);
|
||||
let material: Buffer | undefined;
|
||||
try {
|
||||
const stat = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.dev !== expected.device ||
|
||||
stat.ino !== expected.inode ||
|
||||
stat.size < 32n ||
|
||||
stat.size > BigInt(MAX_PEPPER_BYTES)
|
||||
) {
|
||||
throw new LocalOwnerConsoleConfigurationError(
|
||||
'pepper file identity or size is invalid',
|
||||
);
|
||||
}
|
||||
material = fs.readFileSync(descriptor);
|
||||
const pepper = material.toString('utf8');
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(pepper)) {
|
||||
throw new LocalOwnerConsoleConfigurationError(
|
||||
'pepper file must contain one base64url value without whitespace',
|
||||
);
|
||||
}
|
||||
assertApiCredentialPepper(pepper);
|
||||
return Object.freeze({
|
||||
value: pepper,
|
||||
digest: createHash('sha256')
|
||||
.update('qinglong.local-owner-pepper.summary.v1\0', 'utf8')
|
||||
.update(material)
|
||||
.digest('hex'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LocalOwnerConsoleConfigurationError) throw error;
|
||||
throw new LocalOwnerConsoleConfigurationError(
|
||||
'pepper file is invalid',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
material?.fill(0);
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function proof(options: OpenLocalOwnerConsoleOptions): {
|
||||
readonly authority: Readonly<SecurityPrincipal>;
|
||||
readonly pepper: string;
|
||||
readonly pepperDigest: string;
|
||||
readonly pepperKeyId: string;
|
||||
verify(): void;
|
||||
} {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!exactKeys(options, [
|
||||
'deploymentRoot',
|
||||
'databasePath',
|
||||
'pepperPath',
|
||||
...(options.pepperKeyId === undefined ? [] : ['pepperKeyId']),
|
||||
'secretDeliveryDirectory',
|
||||
'profile',
|
||||
...(options.busyTimeoutMs === undefined ? [] : ['busyTimeoutMs']),
|
||||
]) ||
|
||||
(options.profile !== 'edge' && options.profile !== 'standalone')
|
||||
) {
|
||||
throw new LocalOwnerConsoleConfigurationError('options shape is invalid');
|
||||
}
|
||||
const deploymentRoot = boundedAbsolutePath(
|
||||
options.deploymentRoot,
|
||||
'deploymentRoot',
|
||||
);
|
||||
const databasePath = boundedAbsolutePath(
|
||||
options.databasePath,
|
||||
'databasePath',
|
||||
);
|
||||
const pepperPath = boundedAbsolutePath(options.pepperPath, 'pepperPath');
|
||||
const pepperKeyId =
|
||||
options.pepperKeyId ?? LEGACY_API_CREDENTIAL_PEPPER_KEY_ID;
|
||||
try {
|
||||
assertApiCredentialPepperKeyId(pepperKeyId);
|
||||
} catch {
|
||||
throw new LocalOwnerConsoleConfigurationError('pepperKeyId is invalid');
|
||||
}
|
||||
const secretDeliveryDirectory = boundedAbsolutePath(
|
||||
options.secretDeliveryDirectory,
|
||||
'secretDeliveryDirectory',
|
||||
);
|
||||
if (
|
||||
databasePath === pepperPath ||
|
||||
containsPath(secretDeliveryDirectory, databasePath) ||
|
||||
containsPath(secretDeliveryDirectory, pepperPath)
|
||||
) {
|
||||
throw new LocalOwnerConsoleConfigurationError(
|
||||
'database, pepper, and the dedicated delivery directory must be distinct',
|
||||
);
|
||||
}
|
||||
const uid = currentUid();
|
||||
const root = identity(deploymentRoot, uid, 'directory');
|
||||
const nestedDirectories = [
|
||||
...descendants(deploymentRoot, databasePath),
|
||||
...descendants(deploymentRoot, pepperPath),
|
||||
...descendants(deploymentRoot, secretDeliveryDirectory),
|
||||
].map((directory) => identity(directory, uid, 'directory'));
|
||||
const database = identity(databasePath, uid, 'file');
|
||||
const pepperFile = identity(pepperPath, uid, 'file');
|
||||
const secretDelivery = identity(secretDeliveryDirectory, uid, 'directory');
|
||||
if (
|
||||
database.device === pepperFile.device &&
|
||||
database.inode === pepperFile.inode
|
||||
) {
|
||||
throw new LocalOwnerConsoleConfigurationError(
|
||||
'database and pepper files must not share an inode',
|
||||
);
|
||||
}
|
||||
const pepper = readPepper(pepperFile);
|
||||
const authenticatedAtMs = Date.now();
|
||||
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.device.toString(), 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(root.inode.toString(), 'utf8')
|
||||
.digest('hex');
|
||||
const authority = normalizeSecurityPrincipal(
|
||||
{
|
||||
subject: LOCAL_OWNER_BOOTSTRAP_SYSTEM_SUBJECT,
|
||||
authenticationId: `local-console:${proofDigest}`,
|
||||
authenticatedAtMs,
|
||||
expiresAtMs: authenticatedAtMs + AUTHORITY_TTL_MS,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
authenticatedAtMs,
|
||||
);
|
||||
const identities = Object.freeze([
|
||||
root,
|
||||
...nestedDirectories,
|
||||
database,
|
||||
pepperFile,
|
||||
secretDelivery,
|
||||
]);
|
||||
return Object.freeze({
|
||||
authority,
|
||||
pepper: pepper.value,
|
||||
pepperDigest: pepper.digest,
|
||||
pepperKeyId,
|
||||
verify() {
|
||||
if (currentUid() !== uid) {
|
||||
throw new LocalOwnerConsoleConfigurationError(
|
||||
'POSIX user changed during console activation',
|
||||
);
|
||||
}
|
||||
for (const expected of identities) sameIdentity(expected);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function openLocalOwnerConsole(
|
||||
options: OpenLocalOwnerConsoleOptions,
|
||||
): Promise<LocalOwnerConsole> {
|
||||
const localProof = proof(options);
|
||||
const secretDelivery = new FileLocalOwnerBootstrapSecretDelivery(
|
||||
options.secretDeliveryDirectory,
|
||||
);
|
||||
let database: Awaited<
|
||||
ReturnType<typeof openLocalSqliteBootstrapDatabase>
|
||||
> | null = null;
|
||||
try {
|
||||
database = await openLocalSqliteBootstrapDatabase({
|
||||
databasePath: options.databasePath,
|
||||
profile: options.profile,
|
||||
...(options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: options.busyTimeoutMs }),
|
||||
});
|
||||
localProof.verify();
|
||||
const activePepper = await database.ownerPepper.resolveActive();
|
||||
if (
|
||||
!activePepper ||
|
||||
activePepper.activePepperKeyId !== localProof.pepperKeyId ||
|
||||
activePepper.materialDigest !== localProof.pepperDigest
|
||||
) {
|
||||
throw new LocalOwnerConsoleConfigurationError(
|
||||
'pepper file is not the database active key',
|
||||
);
|
||||
}
|
||||
const recovery = await secretDelivery.recover(
|
||||
database.ownerBootstrap,
|
||||
localProof.pepper,
|
||||
database.ownerCredentialRecovery,
|
||||
);
|
||||
localProof.verify();
|
||||
const service = createLocalOwnerBootstrapService(
|
||||
database.ownerBootstrap,
|
||||
database.apiCredentials,
|
||||
localProof.pepper,
|
||||
localProof.authority,
|
||||
{ pepperKeyId: localProof.pepperKeyId, secretDelivery },
|
||||
);
|
||||
const guardedService: LocalOwnerBootstrapService = Object.freeze({
|
||||
provision(request: ProvisionLocalIdentityRequest) {
|
||||
localProof.verify();
|
||||
return service.provision(request);
|
||||
},
|
||||
issue(request: IssueLocalOwnerBootstrapRequest) {
|
||||
localProof.verify();
|
||||
return service.issue(request);
|
||||
},
|
||||
claim(request: ClaimLocalOwnerRequest) {
|
||||
localProof.verify();
|
||||
return service.claim(request);
|
||||
},
|
||||
});
|
||||
const credentialRecovery = createLocalOwnerCredentialRecoveryService(
|
||||
database.ownerCredentialRecovery,
|
||||
database.apiCredentials,
|
||||
localProof.pepper,
|
||||
{
|
||||
pepperKeyId: localProof.pepperKeyId,
|
||||
secretDelivery: Object.freeze({
|
||||
async prepare(
|
||||
candidate: Readonly<LocalOwnerCredentialRecoveryDeliveryRecord>,
|
||||
) {
|
||||
const prepared = await secretDelivery.prepare(candidate);
|
||||
if (prepared.kind !== 'credential') {
|
||||
throw new LocalOwnerConsoleConfigurationError(
|
||||
'credential recovery delivery kind changed',
|
||||
);
|
||||
}
|
||||
return prepared;
|
||||
},
|
||||
publish(
|
||||
prepared: Readonly<LocalOwnerCredentialRecoveryDeliveryRecord>,
|
||||
) {
|
||||
return secretDelivery.publish(prepared);
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const guardedCredentialRecovery: LocalOwnerCredentialRecoveryService =
|
||||
Object.freeze({
|
||||
issue(request: IssueLocalOwnerCredentialRecoveryRequest) {
|
||||
localProof.verify();
|
||||
return credentialRecovery.issue(request);
|
||||
},
|
||||
complete(request: CompleteLocalOwnerCredentialRecoveryRequest) {
|
||||
localProof.verify();
|
||||
return credentialRecovery.complete(request);
|
||||
},
|
||||
});
|
||||
let closePromise: Promise<void> | undefined;
|
||||
const ownedDatabase = database;
|
||||
return Object.freeze({
|
||||
profile: ownedDatabase.profile,
|
||||
readiness: ownedDatabase.readiness,
|
||||
recovery,
|
||||
service: guardedService,
|
||||
credentialRecovery: guardedCredentialRecovery,
|
||||
credentialDeliveryPath(mutationId: string) {
|
||||
localProof.verify();
|
||||
return secretDelivery.readyPath('credential', mutationId);
|
||||
},
|
||||
challengeDeliveryPath(mutationId: string) {
|
||||
localProof.verify();
|
||||
return secretDelivery.readyPath('challenge', mutationId);
|
||||
},
|
||||
inspectCredentialDelivery(mutationId: string) {
|
||||
localProof.verify();
|
||||
return secretDelivery.inspectReady('credential', mutationId);
|
||||
},
|
||||
inspectChallengeDelivery(mutationId: string) {
|
||||
localProof.verify();
|
||||
return secretDelivery.inspectReady('challenge', mutationId);
|
||||
},
|
||||
claimOwnerFromDeliveries(request: ClaimLocalOwnerFromDeliveriesRequest) {
|
||||
localProof.verify();
|
||||
return secretDelivery.claimOwnerFromDeliveries(
|
||||
ownedDatabase.ownerBootstrap,
|
||||
guardedService,
|
||||
request,
|
||||
);
|
||||
},
|
||||
acknowledgeCredentialDelivery(
|
||||
mutationId: string,
|
||||
expectedDeliveryDigest: string,
|
||||
) {
|
||||
localProof.verify();
|
||||
return secretDelivery.acknowledge(
|
||||
ownedDatabase.ownerBootstrap,
|
||||
localProof.pepper,
|
||||
'credential',
|
||||
mutationId,
|
||||
expectedDeliveryDigest,
|
||||
);
|
||||
},
|
||||
acknowledgeCredentialRecoveryDelivery(
|
||||
mutationId: string,
|
||||
expectedDeliveryDigest: string,
|
||||
) {
|
||||
localProof.verify();
|
||||
return secretDelivery.acknowledgeRecovery(
|
||||
ownedDatabase.ownerCredentialRecovery,
|
||||
localProof.pepper,
|
||||
mutationId,
|
||||
expectedDeliveryDigest,
|
||||
);
|
||||
},
|
||||
acknowledgeChallengeDelivery(
|
||||
mutationId: string,
|
||||
expectedDeliveryDigest: string,
|
||||
) {
|
||||
localProof.verify();
|
||||
return secretDelivery.acknowledge(
|
||||
ownedDatabase.ownerBootstrap,
|
||||
localProof.pepper,
|
||||
'challenge',
|
||||
mutationId,
|
||||
expectedDeliveryDigest,
|
||||
);
|
||||
},
|
||||
close() {
|
||||
if (closePromise) return closePromise;
|
||||
closePromise = ownedDatabase.close();
|
||||
return closePromise;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await database?.close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
createLocalIdentityKeyringAuthenticator,
|
||||
type LocalIdentityAuthentication,
|
||||
} from './identityAuthentication';
|
||||
import { LocalOwnerPepperKeyringFileProvider } from '../pepper-custody';
|
||||
import type { LocalSqliteAuthenticatedUserCredentialFence } from '@qinglong/local-sqlite/package-management';
|
||||
import type { ApiCredentialRepository } from '@qinglong/runtime-core/api-credential';
|
||||
import type { LocalOwnerPepperRepository } from '@qinglong/runtime-core/local-owner-pepper';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const MAX_CREDENTIAL_FILE_BYTES = 1024;
|
||||
const LOCAL_COMMAND_PRINCIPAL_TTL_MS = 60_000;
|
||||
|
||||
interface PathIdentity {
|
||||
readonly path: string;
|
||||
readonly kind: 'directory' | 'file';
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
readonly size: bigint;
|
||||
readonly modifiedAtNs: bigint;
|
||||
readonly changedAtNs: bigint;
|
||||
readonly uid: number;
|
||||
readonly mode: number;
|
||||
}
|
||||
|
||||
interface CredentialFence extends LocalSqliteAuthenticatedUserCredentialFence {
|
||||
readonly authenticationId: string;
|
||||
}
|
||||
|
||||
export interface AuthenticatedLocalCommandDatabase {
|
||||
readonly apiCredentials: ApiCredentialRepository;
|
||||
readonly ownerPepper: Pick<LocalOwnerPepperRepository, 'resolveKey'>;
|
||||
}
|
||||
|
||||
export interface EstablishAuthenticatedLocalCommandOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly databasePath: string;
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly authenticationNamespace: string;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export interface AuthenticatedLocalCommand {
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
readonly databaseFence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>;
|
||||
confirm(): Promise<void>;
|
||||
}
|
||||
|
||||
export class AuthenticatedLocalCommandConfigurationError extends TypeError {
|
||||
readonly code = 'AUTHENTICATED_LOCAL_COMMAND_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Authenticated local command configuration is invalid: ${message}`);
|
||||
this.name = 'AuthenticatedLocalCommandConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthenticatedLocalCommandAuthenticationError extends Error {
|
||||
readonly code = 'AUTHENTICATED_LOCAL_COMMAND_AUTHENTICATION_FAILED';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Authenticated local command failed: ${message}`);
|
||||
this.name = 'AuthenticatedLocalCommandAuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function boundedPath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new AuthenticatedLocalCommandConfigurationError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function currentUid(): number {
|
||||
if (
|
||||
typeof process.getuid !== 'function' ||
|
||||
typeof process.geteuid !== 'function'
|
||||
) {
|
||||
throw new AuthenticatedLocalCommandConfigurationError(
|
||||
'POSIX user identity is unavailable',
|
||||
);
|
||||
}
|
||||
const uid = process.getuid();
|
||||
const effectiveUid = process.geteuid();
|
||||
if (
|
||||
!Number.isSafeInteger(uid) ||
|
||||
uid < 0 ||
|
||||
!Number.isSafeInteger(effectiveUid) ||
|
||||
effectiveUid < 0 ||
|
||||
uid !== effectiveUid
|
||||
) {
|
||||
throw new AuthenticatedLocalCommandConfigurationError(
|
||||
'real and effective POSIX users must match',
|
||||
);
|
||||
}
|
||||
return uid;
|
||||
}
|
||||
|
||||
function identity(
|
||||
targetPath: string,
|
||||
kind: PathIdentity['kind'],
|
||||
uid: number,
|
||||
): PathIdentity {
|
||||
let stat: fs.BigIntStats;
|
||||
try {
|
||||
stat = fs.lstatSync(targetPath, { bigint: true });
|
||||
} catch (error) {
|
||||
throw new AuthenticatedLocalCommandConfigurationError(
|
||||
`${kind} is unavailable`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
const expectedKind =
|
||||
kind === 'directory' ? stat.isDirectory() : stat.isFile();
|
||||
const mode = Number(stat.mode) & 0o777;
|
||||
if (
|
||||
!expectedKind ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== uid ||
|
||||
mode !== (kind === 'directory' ? 0o700 : 0o600)
|
||||
) {
|
||||
throw new AuthenticatedLocalCommandConfigurationError(
|
||||
`${kind} ownership or private mode is invalid`,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
path: targetPath,
|
||||
kind,
|
||||
device: stat.dev,
|
||||
inode: stat.ino,
|
||||
size: stat.size,
|
||||
modifiedAtNs: stat.mtimeNs,
|
||||
changedAtNs: stat.ctimeNs,
|
||||
uid,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
|
||||
function descendants(deploymentRoot: string, targetPath: string): string[] {
|
||||
const relative = path.relative(deploymentRoot, targetPath);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new AuthenticatedLocalCommandConfigurationError(
|
||||
'authority paths must be descendants of deploymentRoot',
|
||||
);
|
||||
}
|
||||
const directories: string[] = [];
|
||||
let current = deploymentRoot;
|
||||
for (const part of relative.split(path.sep).slice(0, -1)) {
|
||||
current = path.join(current, part);
|
||||
directories.push(current);
|
||||
}
|
||||
return directories;
|
||||
}
|
||||
|
||||
function sameIdentity(expected: PathIdentity, mutable: boolean): void {
|
||||
const actual = identity(expected.path, expected.kind, expected.uid);
|
||||
if (
|
||||
actual.device !== expected.device ||
|
||||
actual.inode !== expected.inode ||
|
||||
actual.mode !== expected.mode ||
|
||||
(expected.kind === 'file' &&
|
||||
!mutable &&
|
||||
(actual.size !== expected.size ||
|
||||
actual.modifiedAtNs !== expected.modifiedAtNs ||
|
||||
actual.changedAtNs !== expected.changedAtNs))
|
||||
) {
|
||||
throw new AuthenticatedLocalCommandAuthenticationError(
|
||||
'authority path identity changed during command execution',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readCredentialToken(filePath: string, expected: PathIdentity): string {
|
||||
let descriptor: number | undefined;
|
||||
let material: Buffer | undefined;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
opened.dev !== expected.device ||
|
||||
opened.ino !== expected.inode ||
|
||||
opened.size !== expected.size ||
|
||||
opened.size < 1n ||
|
||||
opened.size > BigInt(MAX_CREDENTIAL_FILE_BYTES)
|
||||
) {
|
||||
throw new AuthenticatedLocalCommandAuthenticationError(
|
||||
'credential file identity or size is invalid',
|
||||
);
|
||||
}
|
||||
material = fs.readFileSync(descriptor);
|
||||
const value = JSON.parse(material.toString('utf8')) as unknown;
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['kind', 'schemaVersion', 'token'])
|
||||
) {
|
||||
throw new AuthenticatedLocalCommandAuthenticationError(
|
||||
'credential presentation shape is invalid',
|
||||
);
|
||||
}
|
||||
const presentation = value as Record<string, unknown>;
|
||||
if (
|
||||
presentation.schemaVersion !== 1 ||
|
||||
presentation.kind !==
|
||||
'qinglong3-local-identity-credential-presentation' ||
|
||||
typeof presentation.token !== 'string' ||
|
||||
presentation.token.length > 256
|
||||
) {
|
||||
throw new AuthenticatedLocalCommandAuthenticationError(
|
||||
'credential presentation is invalid',
|
||||
);
|
||||
}
|
||||
return presentation.token;
|
||||
} catch (error) {
|
||||
if (error instanceof AuthenticatedLocalCommandAuthenticationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AuthenticatedLocalCommandAuthenticationError(
|
||||
'credential presentation cannot be read',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
material?.fill(0);
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function commandProof(
|
||||
options: EstablishAuthenticatedLocalCommandOptions & {
|
||||
readonly now: () => number;
|
||||
},
|
||||
): {
|
||||
readonly authenticatedAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
readonly digest: string;
|
||||
readonly credentialFile: PathIdentity;
|
||||
verify(): void;
|
||||
} {
|
||||
const uid = currentUid();
|
||||
const rootPath = boundedPath(options.deploymentRoot, 'deploymentRoot');
|
||||
const databasePath = boundedPath(options.databasePath, 'databasePath');
|
||||
const credentialFilePath = boundedPath(
|
||||
options.credentialFilePath,
|
||||
'credentialFilePath',
|
||||
);
|
||||
const keyringDirectory = boundedPath(
|
||||
options.ownerPepperKeyringDirectory,
|
||||
'ownerPepperKeyringDirectory',
|
||||
);
|
||||
const nestedDirectories = new Set<string>();
|
||||
for (const target of [databasePath, credentialFilePath, keyringDirectory]) {
|
||||
for (const directory of descendants(rootPath, target)) {
|
||||
nestedDirectories.add(directory);
|
||||
}
|
||||
}
|
||||
const root = identity(rootPath, 'directory', uid);
|
||||
const directories = [
|
||||
root,
|
||||
...[...nestedDirectories]
|
||||
.filter(
|
||||
(candidate) => candidate !== rootPath && candidate !== keyringDirectory,
|
||||
)
|
||||
.map((candidate) => identity(candidate, 'directory', uid)),
|
||||
identity(keyringDirectory, 'directory', uid),
|
||||
];
|
||||
const database = identity(databasePath, 'file', uid);
|
||||
const credentialFile = identity(credentialFilePath, 'file', uid);
|
||||
if (
|
||||
database.device === credentialFile.device &&
|
||||
database.inode === credentialFile.inode
|
||||
) {
|
||||
throw new AuthenticatedLocalCommandConfigurationError(
|
||||
'database and credential files must not share an inode',
|
||||
);
|
||||
}
|
||||
const authenticatedAtMs = options.now();
|
||||
if (!Number.isSafeInteger(authenticatedAtMs) || authenticatedAtMs < 0) {
|
||||
throw new AuthenticatedLocalCommandConfigurationError('clock is invalid');
|
||||
}
|
||||
const digest = createHash('sha256')
|
||||
.update('qinglong3.authenticated-local-command-posix-proof.v1\0', 'utf8')
|
||||
.update(process.platform, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(String(uid), 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(root.device.toString(), 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(root.inode.toString(), 'utf8')
|
||||
.digest('hex');
|
||||
return Object.freeze({
|
||||
authenticatedAtMs,
|
||||
expiresAtMs: authenticatedAtMs + LOCAL_COMMAND_PRINCIPAL_TTL_MS,
|
||||
digest,
|
||||
credentialFile,
|
||||
verify() {
|
||||
if (currentUid() !== uid) {
|
||||
throw new AuthenticatedLocalCommandAuthenticationError(
|
||||
'POSIX user changed during command execution',
|
||||
);
|
||||
}
|
||||
for (const expected of directories) sameIdentity(expected, false);
|
||||
sameIdentity(database, true);
|
||||
sameIdentity(credentialFile, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function establishAuthenticatedLocalCommand(
|
||||
database: AuthenticatedLocalCommandDatabase,
|
||||
candidateOptions: EstablishAuthenticatedLocalCommandOptions,
|
||||
): Promise<Readonly<AuthenticatedLocalCommand>> {
|
||||
if (
|
||||
!database ||
|
||||
typeof database !== 'object' ||
|
||||
!database.apiCredentials ||
|
||||
typeof database.apiCredentials.resolve !== 'function' ||
|
||||
!database.ownerPepper ||
|
||||
typeof database.ownerPepper.resolveKey !== 'function'
|
||||
) {
|
||||
throw new AuthenticatedLocalCommandConfigurationError(
|
||||
'database authority is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!candidateOptions ||
|
||||
typeof candidateOptions !== 'object' ||
|
||||
Array.isArray(candidateOptions) ||
|
||||
!exactKeys(candidateOptions, [
|
||||
'deploymentRoot',
|
||||
'databasePath',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'credentialFilePath',
|
||||
'authenticationNamespace',
|
||||
...(candidateOptions.now === undefined ? [] : ['now']),
|
||||
]) ||
|
||||
!/^[a-z][a-z0-9_]{0,31}$/.test(candidateOptions.authenticationNamespace) ||
|
||||
(candidateOptions.now !== undefined &&
|
||||
typeof candidateOptions.now !== 'function')
|
||||
) {
|
||||
throw new AuthenticatedLocalCommandConfigurationError(
|
||||
'options shape is invalid',
|
||||
);
|
||||
}
|
||||
const options = Object.freeze({
|
||||
...candidateOptions,
|
||||
now: candidateOptions.now ?? Date.now,
|
||||
});
|
||||
const proof = commandProof(options);
|
||||
proof.verify();
|
||||
const pepperProvider = new LocalOwnerPepperKeyringFileProvider(
|
||||
options.ownerPepperKeyringDirectory,
|
||||
);
|
||||
const authenticator = createLocalIdentityKeyringAuthenticator(
|
||||
database.apiCredentials,
|
||||
database.ownerPepper,
|
||||
pepperProvider,
|
||||
{
|
||||
principalTtlMs: LOCAL_COMMAND_PRINCIPAL_TTL_MS,
|
||||
now: options.now,
|
||||
},
|
||||
);
|
||||
const token = readCredentialToken(
|
||||
options.credentialFilePath,
|
||||
proof.credentialFile,
|
||||
);
|
||||
const authentication: Readonly<LocalIdentityAuthentication> | null =
|
||||
await authenticator.authenticateCredential(token);
|
||||
if (!authentication || authentication.principal.subject.type !== 'user') {
|
||||
throw new AuthenticatedLocalCommandAuthenticationError(
|
||||
'credential is not an active User identity',
|
||||
);
|
||||
}
|
||||
const credential = await database.apiCredentials.resolve(
|
||||
authentication.credentialId,
|
||||
);
|
||||
if (!credential || credential.version !== authentication.credentialVersion) {
|
||||
throw new AuthenticatedLocalCommandAuthenticationError(
|
||||
'credential fence is unavailable',
|
||||
);
|
||||
}
|
||||
const key = await database.ownerPepper.resolveKey(credential.pepperKeyId);
|
||||
const material = pepperProvider.resolve(credential.pepperKeyId);
|
||||
if (
|
||||
!key?.materialDigest ||
|
||||
!material ||
|
||||
material.summary.digest !== key.materialDigest
|
||||
) {
|
||||
throw new AuthenticatedLocalCommandAuthenticationError(
|
||||
'credential pepper provenance is unavailable',
|
||||
);
|
||||
}
|
||||
const fence: CredentialFence = Object.freeze({
|
||||
credentialId: authentication.credentialId,
|
||||
credentialVersion: authentication.credentialVersion,
|
||||
pepperKeyId: credential.pepperKeyId,
|
||||
materialDigest: key.materialDigest,
|
||||
authenticationId: authentication.principal.authenticationId,
|
||||
subjectType: 'user',
|
||||
subjectId: credential.subject.id,
|
||||
secretDigest: credential.secretDigest,
|
||||
notBeforeAtMs: credential.notBeforeAtMs,
|
||||
expiresAtMs: credential.expiresAtMs,
|
||||
});
|
||||
const authenticatedAtMs = Math.max(
|
||||
proof.authenticatedAtMs,
|
||||
authentication.principal.authenticatedAtMs,
|
||||
);
|
||||
const expiresAtMs = Math.min(
|
||||
proof.expiresAtMs,
|
||||
authentication.principal.expiresAtMs,
|
||||
);
|
||||
const principal = normalizeSecurityPrincipal(
|
||||
{
|
||||
subject: authentication.principal.subject,
|
||||
authenticationId: `${options.authenticationNamespace}:${createHash(
|
||||
'sha256',
|
||||
)
|
||||
.update('qinglong3.authenticated-local-command-principal.v1\0', 'utf8')
|
||||
.update(fence.authenticationId, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(proof.digest, 'utf8')
|
||||
.digest('hex')}`,
|
||||
authenticatedAtMs,
|
||||
expiresAtMs,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
authenticatedAtMs,
|
||||
);
|
||||
return Object.freeze({
|
||||
principal,
|
||||
databaseFence: Object.freeze({
|
||||
credentialId: fence.credentialId,
|
||||
credentialVersion: fence.credentialVersion,
|
||||
pepperKeyId: fence.pepperKeyId,
|
||||
materialDigest: fence.materialDigest,
|
||||
subjectType: fence.subjectType,
|
||||
subjectId: fence.subjectId,
|
||||
secretDigest: fence.secretDigest,
|
||||
notBeforeAtMs: fence.notBeforeAtMs,
|
||||
expiresAtMs: fence.expiresAtMs,
|
||||
}),
|
||||
async confirm() {
|
||||
proof.verify();
|
||||
const nowMs = options.now();
|
||||
const currentAuthentication = await authenticator.authenticateCredential(
|
||||
readCredentialToken(options.credentialFilePath, proof.credentialFile),
|
||||
);
|
||||
const currentCredential = await database.apiCredentials.resolve(
|
||||
fence.credentialId,
|
||||
);
|
||||
const currentKey = await database.ownerPepper.resolveKey(
|
||||
fence.pepperKeyId,
|
||||
);
|
||||
const currentMaterial = pepperProvider.resolve(fence.pepperKeyId);
|
||||
if (
|
||||
!Number.isSafeInteger(nowMs) ||
|
||||
nowMs < principal.authenticatedAtMs ||
|
||||
nowMs >= principal.expiresAtMs ||
|
||||
!currentAuthentication ||
|
||||
currentAuthentication.credentialId !== fence.credentialId ||
|
||||
currentAuthentication.credentialVersion !== fence.credentialVersion ||
|
||||
!currentCredential ||
|
||||
currentCredential.version !== fence.credentialVersion ||
|
||||
currentCredential.state !== 'active' ||
|
||||
currentCredential.subjectStatus !== 'active' ||
|
||||
currentCredential.subject.type !== fence.subjectType ||
|
||||
currentCredential.subject.id !== fence.subjectId ||
|
||||
currentCredential.secretDigest !== fence.secretDigest ||
|
||||
currentCredential.notBeforeAtMs !== fence.notBeforeAtMs ||
|
||||
currentCredential.expiresAtMs !== fence.expiresAtMs ||
|
||||
currentCredential.notBeforeAtMs > nowMs ||
|
||||
currentCredential.expiresAtMs <= nowMs ||
|
||||
currentCredential.pepperKeyId !== fence.pepperKeyId ||
|
||||
!currentKey ||
|
||||
(currentKey.state !== 'active' && currentKey.state !== 'retired') ||
|
||||
currentKey.materialDigest !== fence.materialDigest ||
|
||||
!currentMaterial ||
|
||||
currentMaterial.summary.digest !== fence.materialDigest
|
||||
) {
|
||||
throw new AuthenticatedLocalCommandAuthenticationError(
|
||||
'credential authority changed during command execution',
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import {
|
||||
ApiCredentialUnavailableError,
|
||||
LEGACY_API_CREDENTIAL_PEPPER_KEY_ID,
|
||||
assertApiCredentialPepperKeyId,
|
||||
normalizeApiCredentialRecord,
|
||||
type ApiCredentialRepository,
|
||||
} from '@qinglong/runtime-core/api-credential';
|
||||
import {
|
||||
apiCredentialSecretDigest,
|
||||
assertApiCredentialPepper,
|
||||
} from '@qinglong/runtime-core/api-credential-token';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import type { LocalOwnerPepperRepository } from '@qinglong/runtime-core/local-owner-pepper';
|
||||
|
||||
const TOKEN_PATTERN =
|
||||
/^ql3c_([A-Za-z0-9][A-Za-z0-9._:-]{0,63})_([A-Za-z0-9_-]{43})$/;
|
||||
const DEFAULT_PRINCIPAL_TTL_MS = 60_000;
|
||||
const MAX_PRINCIPAL_TTL_MS = 300_000;
|
||||
|
||||
export interface LocalIdentityAuthenticator {
|
||||
authenticate(token: string): Promise<Readonly<SecurityPrincipal> | null>;
|
||||
authenticateCredential(
|
||||
token: string,
|
||||
): Promise<Readonly<LocalIdentityAuthentication> | null>;
|
||||
}
|
||||
|
||||
export interface LocalIdentityAuthentication {
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
readonly credentialId: string;
|
||||
readonly credentialVersion: number;
|
||||
}
|
||||
|
||||
export interface LocalIdentityAuthenticatorOptions {
|
||||
readonly principalTtlMs?: number;
|
||||
readonly pepperKeyId?: string;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export interface LocalIdentityPepperKeyMaterial {
|
||||
readonly pepperKeyId: string;
|
||||
readonly pepper: string;
|
||||
}
|
||||
|
||||
export interface LocalIdentityPepperKeyProvider {
|
||||
resolve(
|
||||
pepperKeyId: string,
|
||||
):
|
||||
| Readonly<LocalIdentityPepperKeyMaterial>
|
||||
| null
|
||||
| Promise<Readonly<LocalIdentityPepperKeyMaterial> | null>;
|
||||
}
|
||||
|
||||
export interface LocalIdentityKeyringAuthenticatorOptions {
|
||||
readonly principalTtlMs?: number;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export class LocalIdentityAuthenticationConfigurationError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(`Local identity authentication configuration is invalid: ${message}`);
|
||||
this.name = 'LocalIdentityAuthenticationConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalIdentityAuthenticationUnavailableError extends Error {
|
||||
readonly code = 'LOCAL_IDENTITY_AUTHENTICATION_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Local identity authentication is unavailable');
|
||||
this.name = 'LocalIdentityAuthenticationUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function ttl(value: number | undefined): number {
|
||||
const resolved = value ?? DEFAULT_PRINCIPAL_TTL_MS;
|
||||
if (
|
||||
!Number.isSafeInteger(resolved) ||
|
||||
resolved < 1_000 ||
|
||||
resolved > MAX_PRINCIPAL_TTL_MS
|
||||
) {
|
||||
throw new LocalIdentityAuthenticationConfigurationError(
|
||||
'principalTtlMs is invalid',
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function createLocalIdentityAuthenticator(
|
||||
repository: ApiCredentialRepository,
|
||||
pepper: string,
|
||||
options: LocalIdentityAuthenticatorOptions = {},
|
||||
): LocalIdentityAuthenticator {
|
||||
if (!repository || typeof repository.resolve !== 'function') {
|
||||
throw new LocalIdentityAuthenticationConfigurationError(
|
||||
'repository is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertApiCredentialPepper(pepper);
|
||||
} catch {
|
||||
throw new LocalIdentityAuthenticationConfigurationError(
|
||||
'pepper is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
key !== 'principalTtlMs' && key !== 'pepperKeyId' && key !== 'now',
|
||||
) ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new LocalIdentityAuthenticationConfigurationError(
|
||||
'options are invalid',
|
||||
);
|
||||
}
|
||||
const pepperKeyId =
|
||||
options.pepperKeyId ?? LEGACY_API_CREDENTIAL_PEPPER_KEY_ID;
|
||||
try {
|
||||
assertApiCredentialPepperKeyId(pepperKeyId);
|
||||
} catch {
|
||||
throw new LocalIdentityAuthenticationConfigurationError(
|
||||
'pepperKeyId is invalid',
|
||||
);
|
||||
}
|
||||
const principalTtlMs = ttl(options.principalTtlMs);
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
return createResolvedLocalIdentityAuthenticator(
|
||||
repository,
|
||||
async (credentialPepperKeyId) => {
|
||||
if (credentialPepperKeyId !== pepperKeyId) {
|
||||
throw new LocalIdentityAuthenticationUnavailableError();
|
||||
}
|
||||
return pepper;
|
||||
},
|
||||
principalTtlMs,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
export function createLocalIdentityKeyringAuthenticator(
|
||||
repository: ApiCredentialRepository,
|
||||
pepperRepository: Pick<LocalOwnerPepperRepository, 'resolveKey'>,
|
||||
pepperProvider: LocalIdentityPepperKeyProvider,
|
||||
options: LocalIdentityKeyringAuthenticatorOptions = {},
|
||||
): LocalIdentityAuthenticator {
|
||||
if (!repository || typeof repository.resolve !== 'function') {
|
||||
throw new LocalIdentityAuthenticationConfigurationError(
|
||||
'repository is invalid',
|
||||
);
|
||||
}
|
||||
if (!pepperRepository || typeof pepperRepository.resolveKey !== 'function') {
|
||||
throw new LocalIdentityAuthenticationConfigurationError(
|
||||
'pepperRepository is invalid',
|
||||
);
|
||||
}
|
||||
if (!pepperProvider || typeof pepperProvider.resolve !== 'function') {
|
||||
throw new LocalIdentityAuthenticationConfigurationError(
|
||||
'pepperProvider is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) => key !== 'principalTtlMs' && key !== 'now',
|
||||
) ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new LocalIdentityAuthenticationConfigurationError(
|
||||
'options are invalid',
|
||||
);
|
||||
}
|
||||
const principalTtlMs = ttl(options.principalTtlMs);
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
return createResolvedLocalIdentityAuthenticator(
|
||||
repository,
|
||||
async (pepperKeyId) => {
|
||||
let key;
|
||||
try {
|
||||
key = await pepperRepository.resolveKey(pepperKeyId);
|
||||
} catch {
|
||||
throw new LocalIdentityAuthenticationUnavailableError();
|
||||
}
|
||||
if (
|
||||
!key ||
|
||||
(key.state !== 'active' && key.state !== 'retired') ||
|
||||
!key.materialDigest
|
||||
) {
|
||||
throw new LocalIdentityAuthenticationUnavailableError();
|
||||
}
|
||||
let material;
|
||||
try {
|
||||
material = await pepperProvider.resolve(pepperKeyId);
|
||||
} catch {
|
||||
throw new LocalIdentityAuthenticationUnavailableError();
|
||||
}
|
||||
if (!material || material.pepperKeyId !== pepperKeyId) {
|
||||
throw new LocalIdentityAuthenticationUnavailableError();
|
||||
}
|
||||
try {
|
||||
assertApiCredentialPepper(material.pepper);
|
||||
} catch {
|
||||
throw new LocalIdentityAuthenticationUnavailableError();
|
||||
}
|
||||
const materialDigest = createHash('sha256')
|
||||
.update('qinglong.local-owner-pepper.summary.v1\0', 'utf8')
|
||||
.update(material.pepper, 'utf8')
|
||||
.digest('hex');
|
||||
if (materialDigest !== key.materialDigest) {
|
||||
throw new LocalIdentityAuthenticationUnavailableError();
|
||||
}
|
||||
return material.pepper;
|
||||
},
|
||||
principalTtlMs,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
function createResolvedLocalIdentityAuthenticator(
|
||||
repository: ApiCredentialRepository,
|
||||
resolvePepper: (pepperKeyId: string) => Promise<string>,
|
||||
principalTtlMs: number,
|
||||
now: () => number,
|
||||
): LocalIdentityAuthenticator {
|
||||
const authenticateCredential = async (
|
||||
token: string,
|
||||
): Promise<Readonly<LocalIdentityAuthentication> | null> => {
|
||||
if (typeof token !== 'string' || token.length > 256) return null;
|
||||
const match = TOKEN_PATTERN.exec(token);
|
||||
if (!match) return null;
|
||||
const credentialId = match[1]!;
|
||||
const secret = match[2]!;
|
||||
let candidate;
|
||||
try {
|
||||
candidate = await repository.resolve(credentialId);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiCredentialUnavailableError) {
|
||||
throw new LocalIdentityAuthenticationUnavailableError();
|
||||
}
|
||||
throw new LocalIdentityAuthenticationUnavailableError();
|
||||
}
|
||||
if (!candidate) return null;
|
||||
let credential;
|
||||
try {
|
||||
credential = normalizeApiCredentialRecord(candidate);
|
||||
} catch {
|
||||
throw new LocalIdentityAuthenticationUnavailableError();
|
||||
}
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = await resolvePepper(credential.pepperKeyId);
|
||||
} catch {
|
||||
throw new LocalIdentityAuthenticationUnavailableError();
|
||||
}
|
||||
let presented: Buffer | undefined;
|
||||
let stored: Buffer | undefined;
|
||||
try {
|
||||
presented = Buffer.from(
|
||||
apiCredentialSecretDigest(pepper, credentialId, secret),
|
||||
'hex',
|
||||
);
|
||||
stored = Buffer.from(credential.secretDigest, 'hex');
|
||||
if (
|
||||
presented.byteLength !== 32 ||
|
||||
stored.byteLength !== 32 ||
|
||||
!timingSafeEqual(presented, stored)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
presented?.fill(0);
|
||||
stored?.fill(0);
|
||||
}
|
||||
const nowMs = now();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new LocalIdentityAuthenticationUnavailableError();
|
||||
}
|
||||
if (
|
||||
credential.state !== 'active' ||
|
||||
credential.subjectStatus !== 'active' ||
|
||||
credential.notBeforeAtMs > nowMs ||
|
||||
credential.expiresAtMs <= nowMs
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const principal = normalizeSecurityPrincipal(
|
||||
{
|
||||
subject: credential.subject,
|
||||
authenticationId: `local_credential:${credential.credentialId}:${credential.version}`,
|
||||
authenticatedAtMs: nowMs,
|
||||
expiresAtMs: Math.min(credential.expiresAtMs, nowMs + principalTtlMs),
|
||||
assurance:
|
||||
credential.subject.type === 'user' ? 'single_factor' : 'service',
|
||||
},
|
||||
nowMs,
|
||||
);
|
||||
return Object.freeze({
|
||||
principal,
|
||||
credentialId: credential.credentialId,
|
||||
credentialVersion: credential.version,
|
||||
});
|
||||
} catch {
|
||||
throw new LocalIdentityAuthenticationUnavailableError();
|
||||
}
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
async authenticate(token: string) {
|
||||
return (await authenticateCredential(token))?.principal ?? null;
|
||||
},
|
||||
authenticateCredential,
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,571 @@
|
||||
import { randomBytes as cryptoRandomBytes } from 'node:crypto';
|
||||
import {
|
||||
REVOKED_API_CREDENTIAL_DIGEST,
|
||||
type ApiCredentialMutationRecord,
|
||||
} from '@qinglong/runtime-core/api-credential-administration';
|
||||
import {
|
||||
assertApiCredentialId,
|
||||
assertApiCredentialPepperKeyId,
|
||||
type ApiCredentialRecord,
|
||||
type ApiCredentialRepository,
|
||||
} from '@qinglong/runtime-core/api-credential';
|
||||
import {
|
||||
apiCredentialSecretDigest,
|
||||
assertApiCredentialPepper,
|
||||
formatApiCredentialToken,
|
||||
} from '@qinglong/runtime-core/api-credential-token';
|
||||
import {
|
||||
LocalOwnerCredentialRecoveryCredentialUnavailableError,
|
||||
LocalOwnerCredentialRecoveryInProgressError,
|
||||
LocalOwnerCredentialRecoveryMutationConflictError,
|
||||
LocalOwnerCredentialRecoveryNotAcknowledgedError,
|
||||
type LocalOwnerCredentialRecoveryRecord,
|
||||
type LocalOwnerCredentialRecoveryRepository,
|
||||
} from '@qinglong/runtime-core/local-owner-credential-recovery';
|
||||
import {
|
||||
assertLocalOwnerBootstrapMutationId,
|
||||
assertLocalOwnerBootstrapRequestId,
|
||||
} from '@qinglong/runtime-core/local-owner-bootstrap';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
export const LOCAL_OWNER_CREDENTIAL_RECOVERY_DEFAULT_TTL_MS =
|
||||
24 * 60 * 60 * 1000;
|
||||
export const LOCAL_OWNER_CREDENTIAL_RECOVERY_MIN_TTL_MS = 10 * 60 * 1000;
|
||||
export const LOCAL_OWNER_CREDENTIAL_RECOVERY_MAX_TTL_MS =
|
||||
7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
type RandomBytesFactory = (size: number) => Buffer;
|
||||
|
||||
export interface LocalOwnerCredentialRecoveryDeliveryRecord {
|
||||
readonly kind: 'credential';
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly subjectId: string;
|
||||
readonly credentialId: string;
|
||||
readonly secret: string;
|
||||
readonly ttlMs: number;
|
||||
}
|
||||
|
||||
export interface LocalOwnerCredentialRecoveryDeliveryAcknowledgement {
|
||||
readonly state: 'acknowledged';
|
||||
readonly kind: 'credential';
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly ttlMs: number;
|
||||
}
|
||||
|
||||
export type LocalOwnerCredentialRecoveryDeliveryPreparation =
|
||||
| LocalOwnerCredentialRecoveryDeliveryRecord
|
||||
| LocalOwnerCredentialRecoveryDeliveryAcknowledgement;
|
||||
|
||||
export interface LocalOwnerCredentialRecoverySecretDelivery {
|
||||
prepare(
|
||||
candidate: Readonly<LocalOwnerCredentialRecoveryDeliveryRecord>,
|
||||
): Promise<Readonly<LocalOwnerCredentialRecoveryDeliveryPreparation>>;
|
||||
publish(
|
||||
prepared: Readonly<LocalOwnerCredentialRecoveryDeliveryRecord>,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export interface IssueLocalOwnerCredentialRecoveryRequest {
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly previousCredentialId: string;
|
||||
readonly expectedPreviousVersion: number;
|
||||
readonly credentialTtlMs?: number;
|
||||
}
|
||||
|
||||
export interface IssueLocalOwnerCredentialRecoveryResponse {
|
||||
readonly status: 'inserted' | 'existing';
|
||||
readonly subjectId: string;
|
||||
readonly previousCredentialId: string;
|
||||
readonly replacementCredentialId: string;
|
||||
readonly replacementCredentialToken: string | null;
|
||||
readonly expiresAtMs: number;
|
||||
readonly state: LocalOwnerCredentialRecoveryRecord['state'];
|
||||
}
|
||||
|
||||
export interface CompleteLocalOwnerCredentialRecoveryRequest {
|
||||
readonly issueMutationId: string;
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
}
|
||||
|
||||
export interface CompleteLocalOwnerCredentialRecoveryResponse {
|
||||
readonly status: 'inserted' | 'existing';
|
||||
readonly previousCredentialId: string;
|
||||
readonly replacementCredentialId: string;
|
||||
readonly state: 'completed';
|
||||
}
|
||||
|
||||
export interface LocalOwnerCredentialRecoveryService {
|
||||
issue(
|
||||
request: IssueLocalOwnerCredentialRecoveryRequest,
|
||||
): Promise<Readonly<IssueLocalOwnerCredentialRecoveryResponse>>;
|
||||
complete(
|
||||
request: CompleteLocalOwnerCredentialRecoveryRequest,
|
||||
): Promise<Readonly<CompleteLocalOwnerCredentialRecoveryResponse>>;
|
||||
}
|
||||
|
||||
export interface LocalOwnerCredentialRecoveryServiceOptions {
|
||||
readonly now?: () => number;
|
||||
readonly randomBytes?: RandomBytesFactory;
|
||||
readonly pepperKeyId: string;
|
||||
readonly secretDelivery?: LocalOwnerCredentialRecoverySecretDelivery;
|
||||
}
|
||||
|
||||
export class LocalOwnerCredentialRecoveryConfigurationError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Local Owner credential recovery configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'LocalOwnerCredentialRecoveryConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalOwnerCredentialRecoveryServiceUnavailableError extends Error {
|
||||
readonly code = 'LOCAL_OWNER_CREDENTIAL_RECOVERY_SERVICE_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Local Owner credential recovery service is unavailable');
|
||||
this.name = 'LocalOwnerCredentialRecoveryServiceUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function clock(now: () => number): number {
|
||||
const value = now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new LocalOwnerCredentialRecoveryConfigurationError(
|
||||
'clock is invalid',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ttl(value: number | undefined): number {
|
||||
const result = value ?? LOCAL_OWNER_CREDENTIAL_RECOVERY_DEFAULT_TTL_MS;
|
||||
if (
|
||||
!Number.isSafeInteger(result) ||
|
||||
result < LOCAL_OWNER_CREDENTIAL_RECOVERY_MIN_TTL_MS ||
|
||||
result > LOCAL_OWNER_CREDENTIAL_RECOVERY_MAX_TTL_MS
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryConfigurationError(
|
||||
'credentialTtlMs is invalid',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function randomToken(factory: RandomBytesFactory, size: number): string {
|
||||
const bytes = factory(size);
|
||||
if (!Buffer.isBuffer(bytes) || bytes.byteLength !== size) {
|
||||
if (Buffer.isBuffer(bytes)) bytes.fill(0);
|
||||
throw new LocalOwnerCredentialRecoveryConfigurationError(
|
||||
'randomBytes returned invalid material',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return bytes.toString('base64url');
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function issueRequest(request: IssueLocalOwnerCredentialRecoveryRequest): void {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
!exactKeys(request, [
|
||||
'mutationId',
|
||||
'requestId',
|
||||
'previousCredentialId',
|
||||
'expectedPreviousVersion',
|
||||
...(request.credentialTtlMs === undefined ? [] : ['credentialTtlMs']),
|
||||
])
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryConfigurationError(
|
||||
'issue request shape is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertLocalOwnerBootstrapMutationId(request.mutationId);
|
||||
assertLocalOwnerBootstrapRequestId(request.requestId);
|
||||
assertApiCredentialId(request.previousCredentialId);
|
||||
} catch {
|
||||
throw new LocalOwnerCredentialRecoveryConfigurationError(
|
||||
'issue request identity is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(request.expectedPreviousVersion) ||
|
||||
request.expectedPreviousVersion < 1 ||
|
||||
request.expectedPreviousVersion >= 2_147_483_647
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryConfigurationError(
|
||||
'expectedPreviousVersion is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function completeRequest(
|
||||
request: CompleteLocalOwnerCredentialRecoveryRequest,
|
||||
): void {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
!exactKeys(request, ['issueMutationId', 'mutationId', 'requestId'])
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryConfigurationError(
|
||||
'complete request shape is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertLocalOwnerBootstrapMutationId(request.issueMutationId);
|
||||
assertLocalOwnerBootstrapMutationId(request.mutationId);
|
||||
assertLocalOwnerBootstrapRequestId(request.requestId);
|
||||
} catch {
|
||||
throw new LocalOwnerCredentialRecoveryConfigurationError(
|
||||
'complete request identity is invalid',
|
||||
);
|
||||
}
|
||||
if (request.issueMutationId === request.mutationId) {
|
||||
throw new LocalOwnerCredentialRecoveryConfigurationError(
|
||||
'completion mutation must be distinct',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function audit(
|
||||
eventId: string,
|
||||
requestId: string,
|
||||
operation: 'issue' | 'revoke',
|
||||
occurredAtMs: number,
|
||||
): SecurityAuditRecord {
|
||||
return Object.freeze({
|
||||
eventId,
|
||||
requestId,
|
||||
operationId: `credential.${operation}`,
|
||||
projectId: null,
|
||||
subject: Object.freeze({
|
||||
type: 'system' as const,
|
||||
id: 'owner-credential-recovery',
|
||||
}),
|
||||
authenticationId: 'local-owner-console',
|
||||
outcome: 'allowed' as const,
|
||||
reasons: Object.freeze(['credential_recovery']),
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function mutation(
|
||||
mutationId: string,
|
||||
operation: 'issue' | 'revoke',
|
||||
credentialId: string,
|
||||
credentialVersion: number,
|
||||
expectedPreviousVersion: number,
|
||||
createdAtMs: number,
|
||||
): ApiCredentialMutationRecord {
|
||||
return Object.freeze({
|
||||
mutationId,
|
||||
operation,
|
||||
credentialId,
|
||||
credentialVersion,
|
||||
expectedPreviousVersion,
|
||||
changedBy: Object.freeze({
|
||||
type: 'system' as const,
|
||||
id: 'owner-credential-recovery',
|
||||
}),
|
||||
createdAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function response(
|
||||
status: 'inserted' | 'existing',
|
||||
recovery: Readonly<LocalOwnerCredentialRecoveryRecord>,
|
||||
token: string | null,
|
||||
): Readonly<IssueLocalOwnerCredentialRecoveryResponse> {
|
||||
return Object.freeze({
|
||||
status,
|
||||
subjectId: recovery.subjectId,
|
||||
previousCredentialId: recovery.previousCredentialId,
|
||||
replacementCredentialId: recovery.replacementCredential.credentialId,
|
||||
replacementCredentialToken: token,
|
||||
expiresAtMs: recovery.replacementCredential.expiresAtMs,
|
||||
state: recovery.state,
|
||||
});
|
||||
}
|
||||
|
||||
export function createLocalOwnerCredentialRecoveryService(
|
||||
repository: LocalOwnerCredentialRecoveryRepository,
|
||||
credentials: ApiCredentialRepository,
|
||||
pepper: string,
|
||||
options: LocalOwnerCredentialRecoveryServiceOptions,
|
||||
): LocalOwnerCredentialRecoveryService {
|
||||
if (
|
||||
!repository ||
|
||||
typeof repository.resolve !== 'function' ||
|
||||
typeof repository.issue !== 'function' ||
|
||||
typeof repository.complete !== 'function' ||
|
||||
!credentials ||
|
||||
typeof credentials.resolve !== 'function' ||
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!exactKeys(options, [
|
||||
'pepperKeyId',
|
||||
...(options.now === undefined ? [] : ['now']),
|
||||
...(options.randomBytes === undefined ? [] : ['randomBytes']),
|
||||
...(options.secretDelivery === undefined ? [] : ['secretDelivery']),
|
||||
]) ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.randomBytes !== undefined &&
|
||||
typeof options.randomBytes !== 'function') ||
|
||||
(options.secretDelivery !== undefined &&
|
||||
(!options.secretDelivery ||
|
||||
typeof options.secretDelivery.prepare !== 'function' ||
|
||||
typeof options.secretDelivery.publish !== 'function'))
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryConfigurationError(
|
||||
'service dependencies are invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertApiCredentialPepper(pepper);
|
||||
assertApiCredentialPepperKeyId(options.pepperKeyId);
|
||||
} catch {
|
||||
throw new LocalOwnerCredentialRecoveryConfigurationError(
|
||||
'pepper configuration is invalid',
|
||||
);
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const randomBytes = options.randomBytes ?? cryptoRandomBytes;
|
||||
const delivery = options.secretDelivery;
|
||||
|
||||
return Object.freeze({
|
||||
async issue(request: IssueLocalOwnerCredentialRecoveryRequest) {
|
||||
issueRequest(request);
|
||||
const lifetimeMs = ttl(request.credentialTtlMs);
|
||||
try {
|
||||
const existing = await repository.resolve(request.mutationId);
|
||||
if (existing && existing.state !== 'issued') {
|
||||
if (
|
||||
existing.issueRequestId !== request.requestId ||
|
||||
existing.previousCredentialId !== request.previousCredentialId ||
|
||||
existing.previousCredentialVersion !==
|
||||
request.expectedPreviousVersion ||
|
||||
existing.replacementCredential.expiresAtMs -
|
||||
existing.replacementCredential.notBeforeAtMs !==
|
||||
lifetimeMs
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryMutationConflictError();
|
||||
}
|
||||
return response('existing', existing, null);
|
||||
}
|
||||
const previous = await credentials.resolve(
|
||||
request.previousCredentialId,
|
||||
);
|
||||
if (
|
||||
!previous ||
|
||||
previous.version !== request.expectedPreviousVersion ||
|
||||
previous.state !== 'active' ||
|
||||
previous.subject.type !== 'user' ||
|
||||
previous.subjectStatus !== 'active'
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryServiceUnavailableError();
|
||||
}
|
||||
const candidate: LocalOwnerCredentialRecoveryDeliveryRecord =
|
||||
Object.freeze({
|
||||
kind: 'credential',
|
||||
mutationId: request.mutationId,
|
||||
requestId: request.requestId,
|
||||
subjectId: previous.subject.id,
|
||||
credentialId: `own_${randomToken(randomBytes, 16)}`,
|
||||
secret: randomToken(randomBytes, 32),
|
||||
ttlMs: lifetimeMs,
|
||||
});
|
||||
let prepared = candidate;
|
||||
if (delivery) {
|
||||
const value = await delivery.prepare(candidate);
|
||||
if ('state' in value) {
|
||||
const replay = await repository.resolve(request.mutationId);
|
||||
if (!replay || replay.state === 'issued') {
|
||||
throw new LocalOwnerCredentialRecoveryServiceUnavailableError();
|
||||
}
|
||||
return response('existing', replay, null);
|
||||
}
|
||||
if (
|
||||
value.kind !== 'credential' ||
|
||||
value.mutationId !== request.mutationId ||
|
||||
value.requestId !== request.requestId ||
|
||||
value.subjectId !== previous.subject.id ||
|
||||
value.ttlMs !== lifetimeMs
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryServiceUnavailableError();
|
||||
}
|
||||
prepared = value;
|
||||
}
|
||||
const issuedAtMs = existing?.issuedAtMs ?? clock(now);
|
||||
const expiresAtMs = issuedAtMs + lifetimeMs;
|
||||
if (!Number.isSafeInteger(expiresAtMs)) {
|
||||
throw new LocalOwnerCredentialRecoveryConfigurationError(
|
||||
'credential lifetime is invalid',
|
||||
);
|
||||
}
|
||||
const replacementCredential: ApiCredentialRecord = {
|
||||
credentialId: prepared.credentialId,
|
||||
version: 1,
|
||||
pepperKeyId: options.pepperKeyId,
|
||||
state: 'active',
|
||||
subject: previous.subject,
|
||||
subjectStatus: previous.subjectStatus,
|
||||
secretDigest: apiCredentialSecretDigest(
|
||||
pepper,
|
||||
prepared.credentialId,
|
||||
prepared.secret,
|
||||
),
|
||||
createdAtMs: issuedAtMs,
|
||||
notBeforeAtMs: issuedAtMs,
|
||||
expiresAtMs,
|
||||
};
|
||||
const result = await repository.issue({
|
||||
mutationId: request.mutationId,
|
||||
requestId: request.requestId,
|
||||
previousCredentialId: request.previousCredentialId,
|
||||
expectedPreviousVersion: request.expectedPreviousVersion,
|
||||
replacementCredential,
|
||||
mutation: mutation(
|
||||
request.mutationId,
|
||||
'issue',
|
||||
replacementCredential.credentialId,
|
||||
1,
|
||||
0,
|
||||
issuedAtMs,
|
||||
),
|
||||
audit: audit(
|
||||
request.mutationId,
|
||||
request.requestId,
|
||||
'issue',
|
||||
issuedAtMs,
|
||||
),
|
||||
});
|
||||
if (delivery) await delivery.publish(prepared);
|
||||
return response(
|
||||
result.status,
|
||||
result.recovery,
|
||||
!delivery && result.status === 'inserted'
|
||||
? formatApiCredentialToken(prepared.credentialId, prepared.secret)
|
||||
: null,
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof LocalOwnerCredentialRecoveryConfigurationError ||
|
||||
error instanceof LocalOwnerCredentialRecoveryInProgressError ||
|
||||
error instanceof LocalOwnerCredentialRecoveryMutationConflictError ||
|
||||
error instanceof
|
||||
LocalOwnerCredentialRecoveryCredentialUnavailableError ||
|
||||
error instanceof LocalOwnerCredentialRecoveryServiceUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalOwnerCredentialRecoveryServiceUnavailableError();
|
||||
}
|
||||
},
|
||||
|
||||
async complete(request: CompleteLocalOwnerCredentialRecoveryRequest) {
|
||||
completeRequest(request);
|
||||
try {
|
||||
const recovery = await repository.resolve(request.issueMutationId);
|
||||
if (!recovery) {
|
||||
throw new LocalOwnerCredentialRecoveryServiceUnavailableError();
|
||||
}
|
||||
if (recovery.state === 'completed') {
|
||||
if (
|
||||
recovery.completeMutationId !== request.mutationId ||
|
||||
recovery.completeRequestId !== request.requestId
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryMutationConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
previousCredentialId: recovery.previousCredentialId,
|
||||
replacementCredentialId:
|
||||
recovery.replacementCredential.credentialId,
|
||||
state: 'completed' as const,
|
||||
});
|
||||
}
|
||||
const previous = await credentials.resolve(
|
||||
recovery.previousCredentialId,
|
||||
);
|
||||
if (
|
||||
!previous ||
|
||||
previous.version !== recovery.previousCredentialVersion ||
|
||||
previous.state !== 'active' ||
|
||||
previous.subject.id !== recovery.subjectId
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryServiceUnavailableError();
|
||||
}
|
||||
const completedAtMs = clock(now);
|
||||
const revokedCredential: ApiCredentialRecord = {
|
||||
...previous,
|
||||
version: previous.version + 1,
|
||||
state: 'revoked',
|
||||
secretDigest: REVOKED_API_CREDENTIAL_DIGEST,
|
||||
createdAtMs: completedAtMs,
|
||||
notBeforeAtMs: completedAtMs,
|
||||
expiresAtMs: completedAtMs + 1,
|
||||
};
|
||||
const result = await repository.complete({
|
||||
issueMutationId: request.issueMutationId,
|
||||
mutationId: request.mutationId,
|
||||
requestId: request.requestId,
|
||||
expectedPreviousVersion: previous.version,
|
||||
revokedCredential,
|
||||
mutation: mutation(
|
||||
request.mutationId,
|
||||
'revoke',
|
||||
previous.credentialId,
|
||||
previous.version + 1,
|
||||
previous.version,
|
||||
completedAtMs,
|
||||
),
|
||||
audit: audit(
|
||||
request.mutationId,
|
||||
request.requestId,
|
||||
'revoke',
|
||||
completedAtMs,
|
||||
),
|
||||
});
|
||||
return Object.freeze({
|
||||
status: result.status,
|
||||
previousCredentialId: result.recovery.previousCredentialId,
|
||||
replacementCredentialId:
|
||||
result.recovery.replacementCredential.credentialId,
|
||||
state: 'completed' as const,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof LocalOwnerCredentialRecoveryConfigurationError ||
|
||||
error instanceof LocalOwnerCredentialRecoveryMutationConflictError ||
|
||||
error instanceof LocalOwnerCredentialRecoveryNotAcknowledgedError ||
|
||||
error instanceof
|
||||
LocalOwnerCredentialRecoveryCredentialUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalOwnerCredentialRecoveryServiceUnavailableError();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { assertApiCredentialId } from '@qinglong/runtime-core/api-credential';
|
||||
import {
|
||||
assertApiCredentialSecret,
|
||||
formatApiCredentialToken,
|
||||
} from '@qinglong/runtime-core/api-credential-token';
|
||||
import {
|
||||
assertProjectPolicyProjectId,
|
||||
normalizeProjectPolicySubject,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
import type { SecuritySubject } from '@qinglong/runtime-core/security';
|
||||
|
||||
const MAX_DIRECTORY_ENTRIES = 64;
|
||||
const MAX_RECORD_BYTES = 4 * 1024;
|
||||
const MAX_PRESENTATION_BYTES = 1024;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const FILE_NAME_PATTERN =
|
||||
/^managed-credential-([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.(pending|ready)\.json$/;
|
||||
const TEMP_NAME_PATTERN =
|
||||
/^\.managed-credential-([0-9a-f-]{36})\.([0-9a-f-]{36})\.tmp$/;
|
||||
|
||||
export interface LocalCredentialAdministrationDeliveryRecord {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: 'qinglong3-local-managed-credential-delivery';
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly projectId: string;
|
||||
readonly subject: SecuritySubject;
|
||||
readonly credentialId: string;
|
||||
readonly secret: string;
|
||||
readonly notBeforeAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
}
|
||||
|
||||
export interface LocalCredentialAdministrationDeliverySummary {
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly projectId: string;
|
||||
readonly subject: Readonly<SecuritySubject>;
|
||||
readonly credentialId: string;
|
||||
readonly deliveryDigest: string;
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
interface PrivateFile {
|
||||
readonly value: unknown;
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
readonly digest: string;
|
||||
}
|
||||
|
||||
export class LocalCredentialAdministrationDeliveryError extends Error {
|
||||
readonly code = 'LOCAL_CREDENTIAL_ADMINISTRATION_DELIVERY_FAILED';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Local credential administration delivery failed: ${message}`);
|
||||
this.name = 'LocalCredentialAdministrationDeliveryError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function missing(error: unknown): boolean {
|
||||
return (
|
||||
!!error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
);
|
||||
}
|
||||
|
||||
function uid(): number {
|
||||
if (
|
||||
typeof process.getuid !== 'function' ||
|
||||
typeof process.geteuid !== 'function' ||
|
||||
process.getuid() !== process.geteuid()
|
||||
) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'real and effective POSIX users must match',
|
||||
);
|
||||
}
|
||||
return process.getuid();
|
||||
}
|
||||
|
||||
function normalizeRecord(
|
||||
value: LocalCredentialAdministrationDeliveryRecord,
|
||||
): Readonly<LocalCredentialAdministrationDeliveryRecord> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, [
|
||||
'schemaVersion',
|
||||
'kind',
|
||||
'mutationId',
|
||||
'requestId',
|
||||
'projectId',
|
||||
'subject',
|
||||
'credentialId',
|
||||
'secret',
|
||||
'notBeforeAtMs',
|
||||
'expiresAtMs',
|
||||
]) ||
|
||||
value.schemaVersion !== 1 ||
|
||||
value.kind !== 'qinglong3-local-managed-credential-delivery' ||
|
||||
!UUID_V4_PATTERN.test(value.mutationId) ||
|
||||
!REQUEST_ID_PATTERN.test(value.requestId)
|
||||
) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'delivery record shape is invalid',
|
||||
);
|
||||
}
|
||||
let subject: Readonly<SecuritySubject>;
|
||||
try {
|
||||
assertProjectPolicyProjectId(value.projectId);
|
||||
subject = normalizeProjectPolicySubject(value.subject);
|
||||
assertApiCredentialId(value.credentialId);
|
||||
assertApiCredentialSecret(value.secret);
|
||||
} catch (error) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'delivery record identity is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
subject.type === 'system' ||
|
||||
subject.type === 'worker' ||
|
||||
!Number.isSafeInteger(value.notBeforeAtMs) ||
|
||||
value.notBeforeAtMs < 0 ||
|
||||
!Number.isSafeInteger(value.expiresAtMs) ||
|
||||
value.expiresAtMs <= value.notBeforeAtMs
|
||||
) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'delivery record lifetime or subject is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value, subject });
|
||||
}
|
||||
|
||||
function sameSemantic(
|
||||
left: Readonly<LocalCredentialAdministrationDeliveryRecord>,
|
||||
right: Readonly<LocalCredentialAdministrationDeliveryRecord>,
|
||||
): boolean {
|
||||
return (
|
||||
left.mutationId === right.mutationId &&
|
||||
left.requestId === right.requestId &&
|
||||
left.projectId === right.projectId &&
|
||||
left.subject.type === right.subject.type &&
|
||||
left.subject.id === right.subject.id &&
|
||||
left.credentialId === right.credentialId &&
|
||||
left.expiresAtMs - left.notBeforeAtMs ===
|
||||
right.expiresAtMs - right.notBeforeAtMs
|
||||
);
|
||||
}
|
||||
|
||||
function sameRecord(
|
||||
left: Readonly<LocalCredentialAdministrationDeliveryRecord>,
|
||||
right: Readonly<LocalCredentialAdministrationDeliveryRecord>,
|
||||
): boolean {
|
||||
return sameSemantic(left, right) && left.secret === right.secret;
|
||||
}
|
||||
|
||||
function recordDigest(
|
||||
record: Readonly<LocalCredentialAdministrationDeliveryRecord>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update('qinglong3.local-managed-credential-delivery.v1\0', 'utf8')
|
||||
.update(JSON.stringify(record), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function pendingName(mutationId: string): string {
|
||||
return `managed-credential-${mutationId}.pending.json`;
|
||||
}
|
||||
|
||||
function readyName(mutationId: string): string {
|
||||
return `managed-credential-${mutationId}.ready.json`;
|
||||
}
|
||||
|
||||
function presentation(
|
||||
record: Readonly<LocalCredentialAdministrationDeliveryRecord>,
|
||||
): Readonly<{
|
||||
schemaVersion: 1;
|
||||
kind: 'qinglong3-local-identity-credential-presentation';
|
||||
token: string;
|
||||
}> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-identity-credential-presentation',
|
||||
token: formatApiCredentialToken(record.credentialId, record.secret),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePresentation(value: unknown): Readonly<{
|
||||
schemaVersion: 1;
|
||||
kind: 'qinglong3-local-identity-credential-presentation';
|
||||
token: string;
|
||||
}> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['schemaVersion', 'kind', 'token'])
|
||||
) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'credential presentation is invalid',
|
||||
);
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (
|
||||
candidate.schemaVersion !== 1 ||
|
||||
candidate.kind !== 'qinglong3-local-identity-credential-presentation' ||
|
||||
typeof candidate.token !== 'string' ||
|
||||
candidate.token.length > 256
|
||||
) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'credential presentation is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze(
|
||||
candidate as {
|
||||
schemaVersion: 1;
|
||||
kind: 'qinglong3-local-identity-credential-presentation';
|
||||
token: string;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export class FileLocalCredentialAdministrationDelivery {
|
||||
private readonly ownerUid: number;
|
||||
private readonly device: bigint;
|
||||
private readonly inode: bigint;
|
||||
|
||||
constructor(readonly directory: string) {
|
||||
if (
|
||||
typeof directory !== 'string' ||
|
||||
!path.isAbsolute(directory) ||
|
||||
path.parse(directory).root === directory ||
|
||||
path.normalize(directory) !== directory ||
|
||||
directory.includes('\0') ||
|
||||
Buffer.byteLength(directory, 'utf8') > 4096
|
||||
) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'delivery directory path is invalid',
|
||||
);
|
||||
}
|
||||
this.ownerUid = uid();
|
||||
let stat: fs.BigIntStats;
|
||||
try {
|
||||
stat = fs.lstatSync(directory, { bigint: true });
|
||||
} catch (error) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'delivery directory is unavailable',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== this.ownerUid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o700 ||
|
||||
fs.realpathSync(directory) !== directory
|
||||
) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'delivery directory must be a canonical current-UID 0700 directory',
|
||||
);
|
||||
}
|
||||
this.device = stat.dev;
|
||||
this.inode = stat.ino;
|
||||
}
|
||||
|
||||
private verifyDirectory(): void {
|
||||
const stat = fs.lstatSync(this.directory, { bigint: true });
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== this.ownerUid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o700 ||
|
||||
stat.dev !== this.device ||
|
||||
stat.ino !== this.inode
|
||||
) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'delivery directory identity changed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private entries(): readonly string[] {
|
||||
this.verifyDirectory();
|
||||
const entries = fs.readdirSync(this.directory);
|
||||
if (
|
||||
entries.length > MAX_DIRECTORY_ENTRIES ||
|
||||
entries.some(
|
||||
(name) =>
|
||||
!FILE_NAME_PATTERN.test(name) && !TEMP_NAME_PATTERN.test(name),
|
||||
)
|
||||
) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'delivery directory contents are invalid or unbounded',
|
||||
);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private syncDirectory(): void {
|
||||
const descriptor = fs.openSync(this.directory, fs.constants.O_RDONLY);
|
||||
try {
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
private read(name: string, maxBytes: number): PrivateFile {
|
||||
this.verifyDirectory();
|
||||
const filePath = path.join(this.directory, name);
|
||||
let descriptor: number | undefined;
|
||||
let material: Buffer | undefined;
|
||||
try {
|
||||
const before = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
Number(before.uid) !== this.ownerUid ||
|
||||
(Number(before.mode) & 0o777) !== 0o600 ||
|
||||
before.size < 1n ||
|
||||
before.size > BigInt(maxBytes)
|
||||
) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'delivery file is not a bounded private regular file',
|
||||
);
|
||||
}
|
||||
descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.size !== before.size ||
|
||||
Number(opened.uid) !== this.ownerUid ||
|
||||
(Number(opened.mode) & 0o777) !== 0o600
|
||||
) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'delivery file identity changed while opening',
|
||||
);
|
||||
}
|
||||
material = fs.readFileSync(descriptor);
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
after.dev !== opened.dev ||
|
||||
after.ino !== opened.ino ||
|
||||
after.size !== opened.size
|
||||
) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'delivery file identity changed while reading',
|
||||
);
|
||||
}
|
||||
const digest = createHash('sha256').update(material).digest('hex');
|
||||
return Object.freeze({
|
||||
value: JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(material),
|
||||
) as unknown,
|
||||
device: opened.dev,
|
||||
inode: opened.ino,
|
||||
digest,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LocalCredentialAdministrationDeliveryError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'delivery file cannot be read',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
material?.fill(0);
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
private optional(name: string, maxBytes: number): PrivateFile | null {
|
||||
try {
|
||||
return this.read(name, maxBytes);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof LocalCredentialAdministrationDeliveryError &&
|
||||
error.cause &&
|
||||
missing(error.cause)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private writeExclusive(name: string, value: unknown): PrivateFile {
|
||||
if (this.entries().length >= MAX_DIRECTORY_ENTRIES - 1) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'delivery directory lacks capacity',
|
||||
);
|
||||
}
|
||||
const temporaryName = `.managed-credential-${name
|
||||
.split('.')[0]!
|
||||
.slice('managed-credential-'.length)}.${randomUUID()}.tmp`;
|
||||
const temporaryPath = path.join(this.directory, temporaryName);
|
||||
const targetPath = path.join(this.directory, name);
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
temporaryPath,
|
||||
fs.constants.O_WRONLY |
|
||||
fs.constants.O_CREAT |
|
||||
fs.constants.O_EXCL |
|
||||
(fs.constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
const serialized = `${JSON.stringify(value)}\n`;
|
||||
if (Buffer.byteLength(serialized) > MAX_RECORD_BYTES) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'serialized delivery exceeds its byte budget',
|
||||
);
|
||||
}
|
||||
fs.writeFileSync(descriptor, serialized, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
fs.linkSync(temporaryPath, targetPath);
|
||||
this.syncDirectory();
|
||||
return this.read(
|
||||
name,
|
||||
name.endsWith('.ready.json')
|
||||
? MAX_PRESENTATION_BYTES
|
||||
: MAX_RECORD_BYTES,
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'EEXIST'
|
||||
) {
|
||||
return this.read(
|
||||
name,
|
||||
name.endsWith('.ready.json')
|
||||
? MAX_PRESENTATION_BYTES
|
||||
: MAX_RECORD_BYTES,
|
||||
);
|
||||
}
|
||||
if (error instanceof LocalCredentialAdministrationDeliveryError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'delivery file cannot be published',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
try {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
this.syncDirectory();
|
||||
} catch (error) {
|
||||
if (!missing(error)) {
|
||||
// A durable target, if created, remains authoritative.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prepare(
|
||||
candidate: LocalCredentialAdministrationDeliveryRecord,
|
||||
): Readonly<LocalCredentialAdministrationDeliveryRecord> {
|
||||
const normalized = normalizeRecord(candidate);
|
||||
this.entries();
|
||||
const pending = this.optional(
|
||||
pendingName(normalized.mutationId),
|
||||
MAX_RECORD_BYTES,
|
||||
);
|
||||
if (pending) {
|
||||
const existing = normalizeRecord(
|
||||
pending.value as LocalCredentialAdministrationDeliveryRecord,
|
||||
);
|
||||
if (!sameSemantic(existing, normalized)) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'pending delivery conflicts with request',
|
||||
);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
if (
|
||||
this.optional(readyName(normalized.mutationId), MAX_PRESENTATION_BYTES)
|
||||
) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'published presentation is missing its durable pending record',
|
||||
);
|
||||
}
|
||||
const created = this.writeExclusive(
|
||||
pendingName(normalized.mutationId),
|
||||
normalized,
|
||||
);
|
||||
const stored = normalizeRecord(
|
||||
created.value as LocalCredentialAdministrationDeliveryRecord,
|
||||
);
|
||||
if (!sameSemantic(stored, normalized)) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'concurrent pending delivery conflicts with request',
|
||||
);
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
digest(prepared: LocalCredentialAdministrationDeliveryRecord): string {
|
||||
return recordDigest(normalizeRecord(prepared));
|
||||
}
|
||||
|
||||
publish(
|
||||
prepared: LocalCredentialAdministrationDeliveryRecord,
|
||||
expectedDeliveryDigest: string,
|
||||
): Readonly<LocalCredentialAdministrationDeliverySummary> {
|
||||
const normalized = normalizeRecord(prepared);
|
||||
if (
|
||||
!/^[0-9a-f]{64}$/.test(expectedDeliveryDigest) ||
|
||||
recordDigest(normalized) !== expectedDeliveryDigest
|
||||
) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'delivery digest is invalid',
|
||||
);
|
||||
}
|
||||
const pending = this.read(
|
||||
pendingName(normalized.mutationId),
|
||||
MAX_RECORD_BYTES,
|
||||
);
|
||||
const stored = normalizeRecord(
|
||||
pending.value as LocalCredentialAdministrationDeliveryRecord,
|
||||
);
|
||||
if (!sameRecord(stored, normalized)) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'pending delivery changed before publication',
|
||||
);
|
||||
}
|
||||
const expectedPresentation = presentation(stored);
|
||||
const ready = this.writeExclusive(
|
||||
readyName(stored.mutationId),
|
||||
expectedPresentation,
|
||||
);
|
||||
const published = normalizePresentation(ready.value);
|
||||
if (published.token !== expectedPresentation.token) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'published credential conflicts with pending delivery',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
mutationId: stored.mutationId,
|
||||
requestId: stored.requestId,
|
||||
projectId: stored.projectId,
|
||||
subject: stored.subject,
|
||||
credentialId: stored.credentialId,
|
||||
deliveryDigest: expectedDeliveryDigest,
|
||||
path: path.join(this.directory, readyName(stored.mutationId)),
|
||||
});
|
||||
}
|
||||
|
||||
inspect(
|
||||
mutationId: string,
|
||||
): Readonly<LocalCredentialAdministrationDeliverySummary> {
|
||||
if (!UUID_V4_PATTERN.test(mutationId)) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'mutationId is invalid',
|
||||
);
|
||||
}
|
||||
const pending = normalizeRecord(
|
||||
this.read(pendingName(mutationId), MAX_RECORD_BYTES)
|
||||
.value as LocalCredentialAdministrationDeliveryRecord,
|
||||
);
|
||||
const ready = normalizePresentation(
|
||||
this.read(readyName(mutationId), MAX_PRESENTATION_BYTES).value,
|
||||
);
|
||||
if (ready.token !== presentation(pending).token) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'published credential conflicts with pending delivery',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
mutationId,
|
||||
requestId: pending.requestId,
|
||||
projectId: pending.projectId,
|
||||
subject: pending.subject,
|
||||
credentialId: pending.credentialId,
|
||||
deliveryDigest: recordDigest(pending),
|
||||
path: path.join(this.directory, readyName(mutationId)),
|
||||
});
|
||||
}
|
||||
|
||||
removeAcknowledged(
|
||||
mutationId: string,
|
||||
expectedDeliveryDigest: string,
|
||||
): 'removed' | 'absent' {
|
||||
if (
|
||||
!UUID_V4_PATTERN.test(mutationId) ||
|
||||
!/^[0-9a-f]{64}$/.test(expectedDeliveryDigest)
|
||||
) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'acknowledgement input is invalid',
|
||||
);
|
||||
}
|
||||
this.entries();
|
||||
const pendingFile = this.optional(
|
||||
pendingName(mutationId),
|
||||
MAX_RECORD_BYTES,
|
||||
);
|
||||
const readyFile = this.optional(
|
||||
readyName(mutationId),
|
||||
MAX_PRESENTATION_BYTES,
|
||||
);
|
||||
if (!pendingFile && !readyFile) return 'absent';
|
||||
if (!pendingFile && readyFile) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'acknowledged delivery is incomplete',
|
||||
);
|
||||
}
|
||||
const pending = normalizeRecord(
|
||||
pendingFile!.value as LocalCredentialAdministrationDeliveryRecord,
|
||||
);
|
||||
if (recordDigest(pending) !== expectedDeliveryDigest) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'acknowledged delivery changed before cleanup',
|
||||
);
|
||||
}
|
||||
if (!readyFile) {
|
||||
fs.unlinkSync(path.join(this.directory, pendingName(mutationId)));
|
||||
this.syncDirectory();
|
||||
return 'removed';
|
||||
}
|
||||
const ready = normalizePresentation(readyFile.value);
|
||||
if (ready.token !== presentation(pending).token) {
|
||||
throw new LocalCredentialAdministrationDeliveryError(
|
||||
'acknowledged delivery changed before cleanup',
|
||||
);
|
||||
}
|
||||
fs.unlinkSync(path.join(this.directory, readyName(mutationId)));
|
||||
this.syncDirectory();
|
||||
fs.unlinkSync(path.join(this.directory, pendingName(mutationId)));
|
||||
this.syncDirectory();
|
||||
return 'removed';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { apiCredentialSecretDigest } from '@qinglong/runtime-core/api-credential-token';
|
||||
import {
|
||||
assertLocalOwnerBootstrapMutationId,
|
||||
localOwnerBootstrapTokenDigest,
|
||||
type LocalOwnerBootstrapRepository,
|
||||
} from '@qinglong/runtime-core/local-owner-bootstrap';
|
||||
import type {
|
||||
LocalOwnerCredentialRecoveryRecord,
|
||||
LocalOwnerCredentialRecoveryRepository,
|
||||
} from '@qinglong/runtime-core/local-owner-credential-recovery';
|
||||
import type {
|
||||
LocalOwnerBootstrapSecretDeliveryAcknowledgement,
|
||||
LocalOwnerCredentialRecoveryDeliveryAcknowledgement,
|
||||
} from './ceremonyContracts';
|
||||
import { LocalOwnerSecretDeliveryError } from './contracts';
|
||||
import {
|
||||
acknowledgementName,
|
||||
fileAcknowledgement,
|
||||
persistentAcknowledgement,
|
||||
sameAcknowledgementSemantic,
|
||||
type AcknowledgementRecord,
|
||||
type CredentialAcknowledgementRecord,
|
||||
type DeliveryFile,
|
||||
} from './codec';
|
||||
import { SecretDeliveryPrivateFilesystemStore } from './privateFilesystemStore';
|
||||
|
||||
export async function validateAcknowledgement(
|
||||
repository: LocalOwnerBootstrapRepository,
|
||||
pepper: string,
|
||||
acknowledgement: Readonly<AcknowledgementRecord>,
|
||||
ready: DeliveryFile | null,
|
||||
): Promise<void> {
|
||||
if (ready) {
|
||||
if (
|
||||
ready.record.kind !== acknowledgement.kind ||
|
||||
ready.record.mutationId !== acknowledgement.mutationId ||
|
||||
ready.record.requestId !== acknowledgement.requestId ||
|
||||
ready.record.ttlMs !== acknowledgement.ttlMs ||
|
||||
ready.digest !== acknowledgement.deliveryDigest
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'acknowledgement does not bind the published record',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (acknowledgement.kind === 'credential') {
|
||||
const provisioning = await repository.resolveProvisioning(
|
||||
acknowledgement.mutationId,
|
||||
);
|
||||
if (
|
||||
!provisioning ||
|
||||
provisioning.requestId !== acknowledgement.requestId ||
|
||||
provisioning.identity.subject.id !== acknowledgement.subjectId ||
|
||||
provisioning.credential.credentialId !== acknowledgement.credentialId ||
|
||||
provisioning.credential.secretDigest !== acknowledgement.factDigest ||
|
||||
provisioning.credential.expiresAtMs -
|
||||
provisioning.credential.notBeforeAtMs !==
|
||||
acknowledgement.ttlMs ||
|
||||
(ready &&
|
||||
(ready.record.kind !== 'credential' ||
|
||||
ready.record.subjectId !== acknowledgement.subjectId ||
|
||||
ready.record.credentialId !== acknowledgement.credentialId ||
|
||||
apiCredentialSecretDigest(
|
||||
pepper,
|
||||
ready.record.credentialId,
|
||||
ready.record.secret,
|
||||
) !== acknowledgement.factDigest))
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'credential acknowledgement does not match its database fact',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const challenge = await repository.resolveIssuedChallenge(
|
||||
acknowledgement.mutationId,
|
||||
);
|
||||
if (
|
||||
!challenge ||
|
||||
challenge.projectId !== acknowledgement.projectId ||
|
||||
challenge.issueRequestId !== acknowledgement.requestId ||
|
||||
challenge.challengeId !== acknowledgement.challengeId ||
|
||||
challenge.tokenDigest !== acknowledgement.factDigest ||
|
||||
challenge.expiresAtMs - challenge.issuedAtMs !== acknowledgement.ttlMs ||
|
||||
(ready &&
|
||||
(ready.record.kind !== 'challenge' ||
|
||||
ready.record.projectId !== acknowledgement.projectId ||
|
||||
ready.record.challengeId !== acknowledgement.challengeId ||
|
||||
localOwnerBootstrapTokenDigest(
|
||||
ready.record.projectId,
|
||||
ready.record.challengeId,
|
||||
ready.record.secret,
|
||||
) !== acknowledgement.factDigest))
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'challenge acknowledgement does not match its database fact',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateRecoveryAcknowledgement(
|
||||
pepper: string,
|
||||
acknowledgement: Readonly<CredentialAcknowledgementRecord>,
|
||||
recovery: Readonly<LocalOwnerCredentialRecoveryRecord>,
|
||||
ready: DeliveryFile | null,
|
||||
): void {
|
||||
if (
|
||||
recovery.issueMutationId !== acknowledgement.mutationId ||
|
||||
recovery.issueRequestId !== acknowledgement.requestId ||
|
||||
recovery.subjectId !== acknowledgement.subjectId ||
|
||||
recovery.replacementCredential.credentialId !==
|
||||
acknowledgement.credentialId ||
|
||||
recovery.replacementCredential.secretDigest !==
|
||||
acknowledgement.factDigest ||
|
||||
recovery.replacementCredential.expiresAtMs -
|
||||
recovery.replacementCredential.notBeforeAtMs !==
|
||||
acknowledgement.ttlMs ||
|
||||
(ready &&
|
||||
(ready.record.kind !== 'credential' ||
|
||||
ready.record.mutationId !== acknowledgement.mutationId ||
|
||||
ready.record.requestId !== acknowledgement.requestId ||
|
||||
ready.record.subjectId !== acknowledgement.subjectId ||
|
||||
ready.record.credentialId !== acknowledgement.credentialId ||
|
||||
ready.record.ttlMs !== acknowledgement.ttlMs ||
|
||||
ready.digest !== acknowledgement.deliveryDigest ||
|
||||
apiCredentialSecretDigest(
|
||||
pepper,
|
||||
ready.record.credentialId,
|
||||
ready.record.secret,
|
||||
) !== acknowledgement.factDigest))
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'credential recovery acknowledgement does not match its database fact',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function acknowledge(
|
||||
store: SecretDeliveryPrivateFilesystemStore,
|
||||
repository: LocalOwnerBootstrapRepository,
|
||||
pepper: string,
|
||||
kind: 'credential' | 'challenge',
|
||||
mutationId: string,
|
||||
expectedDeliveryDigest: string,
|
||||
acknowledgedAtMs = Date.now(),
|
||||
): Promise<Readonly<LocalOwnerBootstrapSecretDeliveryAcknowledgement>> {
|
||||
try {
|
||||
assertLocalOwnerBootstrapMutationId(mutationId);
|
||||
} catch (error) {
|
||||
throw new LocalOwnerSecretDeliveryError('mutationId is invalid', error);
|
||||
}
|
||||
if (
|
||||
!/^[0-9a-f]{64}$/.test(expectedDeliveryDigest) ||
|
||||
!Number.isSafeInteger(acknowledgedAtMs) ||
|
||||
acknowledgedAtMs < 0
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError('acknowledgement input is invalid');
|
||||
}
|
||||
store.entries();
|
||||
const ackName = acknowledgementName(kind, mutationId);
|
||||
const existing = store.optionalAcknowledgement(ackName);
|
||||
const readyName = `${kind}-${mutationId}.ready.json`;
|
||||
const ready = store.optional(readyName);
|
||||
const persisted = await repository.resolveDeliveryAcknowledgement(mutationId);
|
||||
const persistedFile = persisted ? fileAcknowledgement(persisted) : null;
|
||||
if (
|
||||
persistedFile &&
|
||||
(persistedFile.kind !== kind ||
|
||||
persistedFile.deliveryDigest !== expectedDeliveryDigest)
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'database acknowledgement conflicts with the expected delivery',
|
||||
);
|
||||
}
|
||||
if (
|
||||
existing &&
|
||||
(existing.deliveryDigest !== expectedDeliveryDigest ||
|
||||
(persistedFile && !sameAcknowledgementSemantic(existing, persistedFile)))
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'acknowledgement digest conflicts with the published record',
|
||||
);
|
||||
}
|
||||
if (!existing && !ready && !persistedFile) {
|
||||
throw new LocalOwnerSecretDeliveryError('published record does not exist');
|
||||
}
|
||||
if (ready && ready.digest !== expectedDeliveryDigest) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'published record digest changed before acknowledgement',
|
||||
);
|
||||
}
|
||||
let record = existing ?? persistedFile;
|
||||
if (!record) {
|
||||
const delivery = ready!.record;
|
||||
if (delivery.kind === 'credential') {
|
||||
const provisioning = await repository.resolveProvisioning(mutationId);
|
||||
const factDigest = apiCredentialSecretDigest(
|
||||
pepper,
|
||||
delivery.credentialId,
|
||||
delivery.secret,
|
||||
);
|
||||
if (
|
||||
!provisioning ||
|
||||
provisioning.requestId !== delivery.requestId ||
|
||||
provisioning.identity.subject.id !== delivery.subjectId ||
|
||||
provisioning.credential.credentialId !== delivery.credentialId ||
|
||||
provisioning.credential.secretDigest !== factDigest ||
|
||||
provisioning.credential.expiresAtMs -
|
||||
provisioning.credential.notBeforeAtMs !==
|
||||
delivery.ttlMs
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'published credential does not match its database fact',
|
||||
);
|
||||
}
|
||||
record = Object.freeze({
|
||||
state: 'acknowledged' as const,
|
||||
kind: 'credential' as const,
|
||||
mutationId,
|
||||
requestId: delivery.requestId,
|
||||
subjectId: delivery.subjectId,
|
||||
credentialId: delivery.credentialId,
|
||||
factDigest,
|
||||
ttlMs: delivery.ttlMs,
|
||||
deliveryDigest: expectedDeliveryDigest,
|
||||
acknowledgedAtMs,
|
||||
});
|
||||
} else {
|
||||
const challenge = await repository.resolveIssuedChallenge(mutationId);
|
||||
const factDigest = localOwnerBootstrapTokenDigest(
|
||||
delivery.projectId,
|
||||
delivery.challengeId,
|
||||
delivery.secret,
|
||||
);
|
||||
if (
|
||||
!challenge ||
|
||||
challenge.projectId !== delivery.projectId ||
|
||||
challenge.issueRequestId !== delivery.requestId ||
|
||||
challenge.challengeId !== delivery.challengeId ||
|
||||
challenge.tokenDigest !== factDigest ||
|
||||
challenge.expiresAtMs - challenge.issuedAtMs !== delivery.ttlMs
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'published challenge does not match its database fact',
|
||||
);
|
||||
}
|
||||
record = Object.freeze({
|
||||
state: 'acknowledged' as const,
|
||||
kind: 'challenge' as const,
|
||||
projectId: delivery.projectId,
|
||||
mutationId,
|
||||
requestId: delivery.requestId,
|
||||
challengeId: delivery.challengeId,
|
||||
factDigest,
|
||||
ttlMs: delivery.ttlMs,
|
||||
deliveryDigest: expectedDeliveryDigest,
|
||||
acknowledgedAtMs,
|
||||
});
|
||||
}
|
||||
record = store.writeAcknowledgement(record);
|
||||
}
|
||||
await validateAcknowledgement(repository, pepper, record, ready);
|
||||
const stored = await repository.recordDeliveryAcknowledgement(
|
||||
persistentAcknowledgement(record),
|
||||
);
|
||||
const storedFile = fileAcknowledgement(stored.acknowledgement);
|
||||
if (!sameAcknowledgementSemantic(storedFile, record)) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'database acknowledgement conflicts with the published record',
|
||||
);
|
||||
}
|
||||
record = storedFile;
|
||||
const currentReady = store.optional(readyName);
|
||||
if (currentReady) {
|
||||
if (currentReady.digest !== record.deliveryDigest) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'published record changed during acknowledgement',
|
||||
);
|
||||
}
|
||||
fs.unlinkSync(path.join(store.directory, readyName));
|
||||
store.syncDirectory();
|
||||
}
|
||||
const currentAcknowledgement = store.optionalAcknowledgement(ackName);
|
||||
if (currentAcknowledgement) {
|
||||
if (!sameAcknowledgementSemantic(currentAcknowledgement, record)) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'file acknowledgement changed during cleanup',
|
||||
);
|
||||
}
|
||||
fs.unlinkSync(path.join(store.directory, ackName));
|
||||
store.syncDirectory();
|
||||
}
|
||||
return Object.freeze({
|
||||
state: 'acknowledged' as const,
|
||||
kind: record.kind,
|
||||
...(record.kind === 'challenge' ? { projectId: record.projectId } : {}),
|
||||
mutationId: record.mutationId,
|
||||
requestId: record.requestId,
|
||||
ttlMs: record.ttlMs,
|
||||
}) as Readonly<LocalOwnerBootstrapSecretDeliveryAcknowledgement>;
|
||||
}
|
||||
|
||||
export async function acknowledgeRecovery(
|
||||
store: SecretDeliveryPrivateFilesystemStore,
|
||||
repository: LocalOwnerCredentialRecoveryRepository,
|
||||
pepper: string,
|
||||
mutationId: string,
|
||||
expectedDeliveryDigest: string,
|
||||
acknowledgedAtMs = Date.now(),
|
||||
): Promise<Readonly<LocalOwnerCredentialRecoveryDeliveryAcknowledgement>> {
|
||||
try {
|
||||
assertLocalOwnerBootstrapMutationId(mutationId);
|
||||
} catch (error) {
|
||||
throw new LocalOwnerSecretDeliveryError('mutationId is invalid', error);
|
||||
}
|
||||
if (
|
||||
!/^[0-9a-f]{64}$/.test(expectedDeliveryDigest) ||
|
||||
!Number.isSafeInteger(acknowledgedAtMs) ||
|
||||
acknowledgedAtMs < 0
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError('acknowledgement input is invalid');
|
||||
}
|
||||
store.entries();
|
||||
const ackName = acknowledgementName('credential', mutationId);
|
||||
const existing = store.optionalAcknowledgement(ackName);
|
||||
if (existing && existing.kind !== 'credential') {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'credential recovery acknowledgement kind is invalid',
|
||||
);
|
||||
}
|
||||
const readyName = `credential-${mutationId}.ready.json`;
|
||||
const ready = store.optional(readyName);
|
||||
const persisted = await repository.resolve(mutationId);
|
||||
if (!persisted) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'credential recovery database fact does not exist',
|
||||
);
|
||||
}
|
||||
const persistedFile =
|
||||
persisted.state === 'issued'
|
||||
? null
|
||||
: fileAcknowledgement({
|
||||
kind: 'credential',
|
||||
mutationId: persisted.issueMutationId,
|
||||
requestId: persisted.issueRequestId,
|
||||
subjectId: persisted.subjectId,
|
||||
credentialId: persisted.replacementCredential.credentialId,
|
||||
factDigest: persisted.replacementCredential.secretDigest,
|
||||
ttlMs:
|
||||
persisted.replacementCredential.expiresAtMs -
|
||||
persisted.replacementCredential.notBeforeAtMs,
|
||||
deliveryDigest: persisted.deliveryDigest!,
|
||||
acknowledgedAtMs: persisted.acknowledgedAtMs!,
|
||||
});
|
||||
if (
|
||||
persistedFile &&
|
||||
persistedFile.deliveryDigest !== expectedDeliveryDigest
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'database recovery acknowledgement conflicts with expected delivery',
|
||||
);
|
||||
}
|
||||
if (
|
||||
existing &&
|
||||
(existing.deliveryDigest !== expectedDeliveryDigest ||
|
||||
(persistedFile && !sameAcknowledgementSemantic(existing, persistedFile)))
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'recovery acknowledgement conflicts with the published record',
|
||||
);
|
||||
}
|
||||
if (!existing && !ready && !persistedFile) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'published recovery credential does not exist',
|
||||
);
|
||||
}
|
||||
if (ready && ready.digest !== expectedDeliveryDigest) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'published recovery credential changed before acknowledgement',
|
||||
);
|
||||
}
|
||||
let record = existing ?? persistedFile;
|
||||
if (!record) {
|
||||
const delivery = ready!.record;
|
||||
if (delivery.kind !== 'credential') {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'published recovery credential kind is invalid',
|
||||
);
|
||||
}
|
||||
const factDigest = apiCredentialSecretDigest(
|
||||
pepper,
|
||||
delivery.credentialId,
|
||||
delivery.secret,
|
||||
);
|
||||
record = Object.freeze({
|
||||
state: 'acknowledged' as const,
|
||||
kind: 'credential' as const,
|
||||
mutationId,
|
||||
requestId: delivery.requestId,
|
||||
subjectId: delivery.subjectId,
|
||||
credentialId: delivery.credentialId,
|
||||
factDigest,
|
||||
ttlMs: delivery.ttlMs,
|
||||
deliveryDigest: expectedDeliveryDigest,
|
||||
acknowledgedAtMs,
|
||||
});
|
||||
validateRecoveryAcknowledgement(pepper, record, persisted, ready);
|
||||
record = store.writeAcknowledgement(record);
|
||||
}
|
||||
if (record.kind !== 'credential') {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'credential recovery acknowledgement kind is invalid',
|
||||
);
|
||||
}
|
||||
validateRecoveryAcknowledgement(pepper, record, persisted, ready);
|
||||
const stored = await repository.acknowledge({
|
||||
issueMutationId: record.mutationId,
|
||||
requestId: record.requestId,
|
||||
credentialId: record.credentialId,
|
||||
factDigest: record.factDigest,
|
||||
deliveryDigest: record.deliveryDigest,
|
||||
acknowledgedAtMs: record.acknowledgedAtMs,
|
||||
});
|
||||
if (
|
||||
stored.recovery.state === 'issued' ||
|
||||
stored.recovery.deliveryDigest !== record.deliveryDigest ||
|
||||
stored.recovery.acknowledgedAtMs !== record.acknowledgedAtMs
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'database recovery acknowledgement conflicts with published record',
|
||||
);
|
||||
}
|
||||
const currentReady = store.optional(readyName);
|
||||
if (currentReady) {
|
||||
if (currentReady.digest !== record.deliveryDigest) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'published recovery credential changed during acknowledgement',
|
||||
);
|
||||
}
|
||||
fs.unlinkSync(path.join(store.directory, readyName));
|
||||
store.syncDirectory();
|
||||
}
|
||||
const currentAcknowledgement = store.optionalAcknowledgement(ackName);
|
||||
if (currentAcknowledgement) {
|
||||
if (!sameAcknowledgementSemantic(currentAcknowledgement, record)) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'recovery acknowledgement file changed during cleanup',
|
||||
);
|
||||
}
|
||||
fs.unlinkSync(path.join(store.directory, ackName));
|
||||
store.syncDirectory();
|
||||
}
|
||||
return Object.freeze({
|
||||
state: 'acknowledged' as const,
|
||||
kind: 'credential' as const,
|
||||
mutationId: record.mutationId,
|
||||
requestId: record.requestId,
|
||||
ttlMs: record.ttlMs,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { formatApiCredentialToken } from '@qinglong/runtime-core/api-credential-token';
|
||||
import type {
|
||||
ClaimLocalOwnerResult,
|
||||
LocalOwnerBootstrapRepository,
|
||||
} from '@qinglong/runtime-core/local-owner-bootstrap';
|
||||
import type { LocalOwnerBootstrapService } from './ceremonyContracts';
|
||||
import {
|
||||
LocalOwnerSecretDeliveryError,
|
||||
type ClaimLocalOwnerFromDeliveriesRequest,
|
||||
} from './contracts';
|
||||
import { deliveryClaimRequest } from './codec';
|
||||
import { SecretDeliveryPrivateFilesystemStore } from './privateFilesystemStore';
|
||||
|
||||
export async function claimOwnerFromDeliveries(
|
||||
store: SecretDeliveryPrivateFilesystemStore,
|
||||
repository: LocalOwnerBootstrapRepository,
|
||||
service: Pick<LocalOwnerBootstrapService, 'claim'>,
|
||||
candidate: ClaimLocalOwnerFromDeliveriesRequest,
|
||||
): Promise<Readonly<ClaimLocalOwnerResult>> {
|
||||
if (
|
||||
!repository ||
|
||||
typeof repository.resolveIssuedChallenge !== 'function' ||
|
||||
typeof repository.resolveProvisioning !== 'function' ||
|
||||
!service ||
|
||||
typeof service.claim !== 'function'
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'Owner bootstrap boundary is invalid',
|
||||
);
|
||||
}
|
||||
const command = deliveryClaimRequest(candidate);
|
||||
const existing = await repository.resolveIssuedChallenge(
|
||||
command.challengeMutationId,
|
||||
);
|
||||
if (existing?.consumedAtMs !== undefined) {
|
||||
const provisioning = await repository.resolveProvisioning(
|
||||
command.credentialMutationId,
|
||||
);
|
||||
if (
|
||||
existing.projectId !== command.projectId ||
|
||||
existing.claimMutationId !== command.mutationId ||
|
||||
existing.claimRequestId !== command.requestId ||
|
||||
!existing.binding ||
|
||||
!provisioning ||
|
||||
provisioning.credential.credentialId !== existing.credentialId ||
|
||||
provisioning.credential.version !== existing.credentialVersion
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'delivery claim conflicts with the committed database fact',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
challenge: existing,
|
||||
binding: existing.binding,
|
||||
});
|
||||
}
|
||||
store.entries();
|
||||
const credential = store.read(
|
||||
`credential-${command.credentialMutationId}.ready.json`,
|
||||
).record;
|
||||
const challenge = store.read(
|
||||
`challenge-${command.challengeMutationId}.ready.json`,
|
||||
).record;
|
||||
if (
|
||||
credential.kind !== 'credential' ||
|
||||
challenge.kind !== 'challenge' ||
|
||||
challenge.projectId !== command.projectId
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'delivery claim records do not match the requested Project',
|
||||
);
|
||||
}
|
||||
return service.claim({
|
||||
projectId: command.projectId,
|
||||
mutationId: command.mutationId,
|
||||
requestId: command.requestId,
|
||||
challengeId: challenge.challengeId,
|
||||
challengeToken: challenge.secret,
|
||||
credentialToken: formatApiCredentialToken(
|
||||
credential.credentialId,
|
||||
credential.secret,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { normalizeLocalOwnerBootstrapSecretDeliveryRecord } from '../../bootstrap';
|
||||
export type {
|
||||
LocalOwnerBootstrapSecretDelivery,
|
||||
LocalOwnerBootstrapSecretDeliveryAcknowledgement,
|
||||
LocalOwnerBootstrapSecretDeliveryPreparation,
|
||||
LocalOwnerBootstrapSecretDeliveryRecord,
|
||||
LocalOwnerBootstrapService,
|
||||
} from '../../bootstrap';
|
||||
export type { LocalOwnerCredentialRecoveryDeliveryAcknowledgement } from '../../credential-recovery';
|
||||
@@ -0,0 +1,272 @@
|
||||
import type { LocalOwnerBootstrapSecretDeliveryRecord } from './ceremonyContracts';
|
||||
import {
|
||||
assertLocalOwnerBootstrapChallengeId,
|
||||
assertLocalOwnerBootstrapMutationId,
|
||||
assertLocalOwnerBootstrapRequestId,
|
||||
normalizeLocalOwnerSecretDeliveryAcknowledgementRecord,
|
||||
type LocalOwnerSecretDeliveryAcknowledgementRecord,
|
||||
} from '@qinglong/runtime-core/local-owner-bootstrap';
|
||||
import { assertProjectPolicyProjectId } from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
LocalOwnerSecretDeliveryError,
|
||||
type ClaimLocalOwnerFromDeliveriesRequest,
|
||||
} from './contracts';
|
||||
|
||||
export const MAX_DIRECTORY_ENTRIES = 64;
|
||||
export const MAX_RECORD_BYTES = 4 * 1024;
|
||||
export const RECORD_NAME_PATTERN =
|
||||
/^(credential|challenge)-([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.(pending|ready)\.json$/;
|
||||
export const TEMP_NAME_PATTERN =
|
||||
/^\.(credential|challenge)-([0-9a-f-]{36})\.[0-9a-f-]{36}\.tmp$/;
|
||||
export const ACKNOWLEDGEMENT_NAME_PATTERN =
|
||||
/^(credential|challenge)-([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.acknowledged\.json$/;
|
||||
|
||||
export interface DeliveryFile {
|
||||
readonly record: Readonly<LocalOwnerBootstrapSecretDeliveryRecord>;
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
readonly digest: string;
|
||||
}
|
||||
|
||||
export interface CredentialAcknowledgementRecord {
|
||||
readonly state: 'acknowledged';
|
||||
readonly kind: 'credential';
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly subjectId: string;
|
||||
readonly credentialId: string;
|
||||
readonly factDigest: string;
|
||||
readonly ttlMs: number;
|
||||
readonly deliveryDigest: string;
|
||||
readonly acknowledgedAtMs: number;
|
||||
}
|
||||
|
||||
export interface ChallengeAcknowledgementRecord {
|
||||
readonly state: 'acknowledged';
|
||||
readonly kind: 'challenge';
|
||||
readonly projectId: string;
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly challengeId: string;
|
||||
readonly factDigest: string;
|
||||
readonly ttlMs: number;
|
||||
readonly deliveryDigest: string;
|
||||
readonly acknowledgedAtMs: number;
|
||||
}
|
||||
|
||||
export type AcknowledgementRecord =
|
||||
| CredentialAcknowledgementRecord
|
||||
| ChallengeAcknowledgementRecord;
|
||||
|
||||
export function isMissing(error: unknown): boolean {
|
||||
return (
|
||||
!!error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
);
|
||||
}
|
||||
|
||||
export function recordName(
|
||||
record: Readonly<LocalOwnerBootstrapSecretDeliveryRecord>,
|
||||
state: 'pending' | 'ready',
|
||||
): string {
|
||||
return `${record.kind}-${record.mutationId}.${state}.json`;
|
||||
}
|
||||
|
||||
export function sameRecord(
|
||||
left: Readonly<LocalOwnerBootstrapSecretDeliveryRecord>,
|
||||
right: Readonly<LocalOwnerBootstrapSecretDeliveryRecord>,
|
||||
): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
export function sameRequestSemantic(
|
||||
left: Readonly<LocalOwnerBootstrapSecretDeliveryRecord>,
|
||||
right: Readonly<LocalOwnerBootstrapSecretDeliveryRecord>,
|
||||
): boolean {
|
||||
return (
|
||||
left.kind === right.kind &&
|
||||
left.mutationId === right.mutationId &&
|
||||
left.requestId === right.requestId &&
|
||||
left.ttlMs === right.ttlMs &&
|
||||
(left.kind !== 'challenge' ||
|
||||
(right.kind === 'challenge' && left.projectId === right.projectId))
|
||||
);
|
||||
}
|
||||
|
||||
export function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
export function deliveryClaimRequest(
|
||||
value: ClaimLocalOwnerFromDeliveriesRequest,
|
||||
): Readonly<ClaimLocalOwnerFromDeliveriesRequest> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, [
|
||||
'projectId',
|
||||
'mutationId',
|
||||
'requestId',
|
||||
'credentialMutationId',
|
||||
'challengeMutationId',
|
||||
])
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'delivery claim request shape is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertProjectPolicyProjectId(value.projectId);
|
||||
assertLocalOwnerBootstrapMutationId(value.mutationId);
|
||||
assertLocalOwnerBootstrapRequestId(value.requestId);
|
||||
assertLocalOwnerBootstrapMutationId(value.credentialMutationId);
|
||||
assertLocalOwnerBootstrapMutationId(value.challengeMutationId);
|
||||
} catch (error) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'delivery claim request value is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.mutationId === value.credentialMutationId ||
|
||||
value.mutationId === value.challengeMutationId ||
|
||||
value.credentialMutationId === value.challengeMutationId
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'delivery claim mutations must be distinct',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
export function acknowledgementName(
|
||||
kind: 'credential' | 'challenge',
|
||||
mutationId: string,
|
||||
): string {
|
||||
return `${kind}-${mutationId}.acknowledged.json`;
|
||||
}
|
||||
|
||||
export function sameAcknowledgementSemantic(
|
||||
left: Readonly<AcknowledgementRecord>,
|
||||
right: Readonly<AcknowledgementRecord>,
|
||||
): boolean {
|
||||
return (
|
||||
left.state === right.state &&
|
||||
left.kind === right.kind &&
|
||||
left.mutationId === right.mutationId &&
|
||||
left.requestId === right.requestId &&
|
||||
left.factDigest === right.factDigest &&
|
||||
left.ttlMs === right.ttlMs &&
|
||||
left.deliveryDigest === right.deliveryDigest &&
|
||||
(left.kind === 'credential'
|
||||
? right.kind === 'credential' &&
|
||||
left.subjectId === right.subjectId &&
|
||||
left.credentialId === right.credentialId
|
||||
: right.kind === 'challenge' &&
|
||||
left.projectId === right.projectId &&
|
||||
left.challengeId === right.challengeId)
|
||||
);
|
||||
}
|
||||
|
||||
export function persistentAcknowledgement(
|
||||
acknowledgement: Readonly<AcknowledgementRecord>,
|
||||
): Readonly<LocalOwnerSecretDeliveryAcknowledgementRecord> {
|
||||
const { state: _state, ...record } = acknowledgement;
|
||||
return normalizeLocalOwnerSecretDeliveryAcknowledgementRecord(record);
|
||||
}
|
||||
|
||||
export function fileAcknowledgement(
|
||||
acknowledgement: Readonly<LocalOwnerSecretDeliveryAcknowledgementRecord>,
|
||||
): Readonly<AcknowledgementRecord> {
|
||||
return normalizeAcknowledgement({
|
||||
state: 'acknowledged',
|
||||
...acknowledgement,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeAcknowledgement(raw: unknown): AcknowledgementRecord {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
throw new LocalOwnerSecretDeliveryError('acknowledgement is invalid');
|
||||
}
|
||||
const value = raw as AcknowledgementRecord;
|
||||
try {
|
||||
assertLocalOwnerBootstrapMutationId(value.mutationId);
|
||||
assertLocalOwnerBootstrapRequestId(value.requestId);
|
||||
} catch (error) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'acknowledgement mutation is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.state !== 'acknowledged' ||
|
||||
!/^[0-9a-f]{64}$/.test(value.factDigest) ||
|
||||
!/^[0-9a-f]{64}$/.test(value.deliveryDigest) ||
|
||||
!Number.isSafeInteger(value.ttlMs) ||
|
||||
value.ttlMs < 1 ||
|
||||
!Number.isSafeInteger(value.acknowledgedAtMs) ||
|
||||
value.acknowledgedAtMs < 0
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError('acknowledgement is invalid');
|
||||
}
|
||||
if (value.kind === 'credential') {
|
||||
if (
|
||||
!exactKeys(value, [
|
||||
'state',
|
||||
'kind',
|
||||
'mutationId',
|
||||
'requestId',
|
||||
'subjectId',
|
||||
'credentialId',
|
||||
'factDigest',
|
||||
'ttlMs',
|
||||
'deliveryDigest',
|
||||
'acknowledgedAtMs',
|
||||
]) ||
|
||||
!/^usr_[A-Za-z0-9_-]{22}$/.test(value.subjectId) ||
|
||||
!/^own_[A-Za-z0-9_-]{22}$/.test(value.credentialId)
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'credential acknowledgement is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
if (
|
||||
value.kind !== 'challenge' ||
|
||||
!exactKeys(value, [
|
||||
'state',
|
||||
'kind',
|
||||
'projectId',
|
||||
'mutationId',
|
||||
'requestId',
|
||||
'challengeId',
|
||||
'factDigest',
|
||||
'ttlMs',
|
||||
'deliveryDigest',
|
||||
'acknowledgedAtMs',
|
||||
])
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'challenge acknowledgement is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertProjectPolicyProjectId(value.projectId);
|
||||
assertLocalOwnerBootstrapChallengeId(value.challengeId);
|
||||
} catch (error) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'challenge acknowledgement is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface LocalOwnerSecretDeliverySummary {
|
||||
readonly kind: 'credential' | 'challenge';
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly deliveryDigest: string;
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
export interface LocalOwnerSecretRecoverySummary {
|
||||
readonly inspectedPendingRecords: number;
|
||||
readonly publishedRecords: number;
|
||||
readonly retainedUncommittedRecords: number;
|
||||
readonly orphanTemporaryRecords: number;
|
||||
}
|
||||
|
||||
export interface ClaimLocalOwnerFromDeliveriesRequest {
|
||||
readonly projectId: string;
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly credentialMutationId: string;
|
||||
readonly challengeMutationId: string;
|
||||
}
|
||||
|
||||
export class LocalOwnerSecretDeliveryError extends Error {
|
||||
readonly code = 'LOCAL_OWNER_SECRET_DELIVERY_FAILED';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Local Owner secret delivery failed: ${message}`);
|
||||
this.name = 'LocalOwnerSecretDeliveryError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
LocalOwnerBootstrapSecretDelivery,
|
||||
LocalOwnerBootstrapSecretDeliveryAcknowledgement,
|
||||
LocalOwnerBootstrapSecretDeliveryPreparation,
|
||||
LocalOwnerBootstrapSecretDeliveryRecord,
|
||||
LocalOwnerBootstrapService,
|
||||
LocalOwnerCredentialRecoveryDeliveryAcknowledgement,
|
||||
} from './ceremonyContracts';
|
||||
import type {
|
||||
ClaimLocalOwnerResult,
|
||||
LocalOwnerBootstrapRepository,
|
||||
} from '@qinglong/runtime-core/local-owner-bootstrap';
|
||||
import type { LocalOwnerDeliveryBridgeClearEvidence } from '@qinglong/runtime-core/local-owner-delivery-acknowledgement-gc';
|
||||
import type { LocalOwnerCredentialRecoveryRepository } from '@qinglong/runtime-core/local-owner-credential-recovery';
|
||||
import type {
|
||||
ClaimLocalOwnerFromDeliveriesRequest,
|
||||
LocalOwnerSecretDeliverySummary,
|
||||
LocalOwnerSecretRecoverySummary,
|
||||
} from './contracts';
|
||||
import { acknowledge, acknowledgeRecovery } from './acknowledgement';
|
||||
import { claimOwnerFromDeliveries } from './bootstrapClaim';
|
||||
import { SecretDeliveryPrivateFilesystemStore } from './privateFilesystemStore';
|
||||
import { recover } from './recovery';
|
||||
|
||||
export class FileLocalOwnerBootstrapSecretDelivery
|
||||
implements LocalOwnerBootstrapSecretDelivery
|
||||
{
|
||||
private readonly store: SecretDeliveryPrivateFilesystemStore;
|
||||
|
||||
constructor(readonly directory: string) {
|
||||
this.store = new SecretDeliveryPrivateFilesystemStore(directory);
|
||||
}
|
||||
|
||||
inspectBridgeClear(
|
||||
kind: 'credential' | 'challenge',
|
||||
mutationId: string,
|
||||
): Readonly<LocalOwnerDeliveryBridgeClearEvidence> {
|
||||
return this.store.inspectBridgeClear(kind, mutationId);
|
||||
}
|
||||
|
||||
async prepare(
|
||||
candidate: Readonly<LocalOwnerBootstrapSecretDeliveryRecord>,
|
||||
): Promise<Readonly<LocalOwnerBootstrapSecretDeliveryPreparation>> {
|
||||
return this.store.prepare(candidate);
|
||||
}
|
||||
|
||||
async publish(
|
||||
prepared: Readonly<LocalOwnerBootstrapSecretDeliveryRecord>,
|
||||
): Promise<void> {
|
||||
return this.store.publish(prepared);
|
||||
}
|
||||
|
||||
inspectReady(
|
||||
kind: 'credential' | 'challenge',
|
||||
mutationId: string,
|
||||
): Readonly<LocalOwnerSecretDeliverySummary> {
|
||||
return this.store.inspectReady(kind, mutationId);
|
||||
}
|
||||
|
||||
async claimOwnerFromDeliveries(
|
||||
repository: LocalOwnerBootstrapRepository,
|
||||
service: Pick<LocalOwnerBootstrapService, 'claim'>,
|
||||
candidate: ClaimLocalOwnerFromDeliveriesRequest,
|
||||
): Promise<Readonly<ClaimLocalOwnerResult>> {
|
||||
return claimOwnerFromDeliveries(this.store, repository, service, candidate);
|
||||
}
|
||||
|
||||
async acknowledge(
|
||||
repository: LocalOwnerBootstrapRepository,
|
||||
pepper: string,
|
||||
kind: 'credential' | 'challenge',
|
||||
mutationId: string,
|
||||
expectedDeliveryDigest: string,
|
||||
acknowledgedAtMs = Date.now(),
|
||||
): Promise<Readonly<LocalOwnerBootstrapSecretDeliveryAcknowledgement>> {
|
||||
return acknowledge(
|
||||
this.store,
|
||||
repository,
|
||||
pepper,
|
||||
kind,
|
||||
mutationId,
|
||||
expectedDeliveryDigest,
|
||||
acknowledgedAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
async acknowledgeRecovery(
|
||||
repository: LocalOwnerCredentialRecoveryRepository,
|
||||
pepper: string,
|
||||
mutationId: string,
|
||||
expectedDeliveryDigest: string,
|
||||
acknowledgedAtMs = Date.now(),
|
||||
): Promise<Readonly<LocalOwnerCredentialRecoveryDeliveryAcknowledgement>> {
|
||||
return acknowledgeRecovery(
|
||||
this.store,
|
||||
repository,
|
||||
pepper,
|
||||
mutationId,
|
||||
expectedDeliveryDigest,
|
||||
acknowledgedAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
async recover(
|
||||
repository: LocalOwnerBootstrapRepository,
|
||||
pepper: string,
|
||||
recoveryRepository?: LocalOwnerCredentialRecoveryRepository,
|
||||
): Promise<Readonly<LocalOwnerSecretRecoverySummary>> {
|
||||
return recover(this.store, repository, pepper, recoveryRepository);
|
||||
}
|
||||
|
||||
readyPath(kind: 'credential' | 'challenge', mutationId: string): string {
|
||||
return this.store.readyPath(kind, mutationId);
|
||||
}
|
||||
}
|
||||
+593
@@ -0,0 +1,593 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
normalizeLocalOwnerBootstrapSecretDeliveryRecord,
|
||||
type LocalOwnerBootstrapSecretDeliveryAcknowledgement,
|
||||
type LocalOwnerBootstrapSecretDeliveryPreparation,
|
||||
type LocalOwnerBootstrapSecretDeliveryRecord,
|
||||
} from './ceremonyContracts';
|
||||
import { assertLocalOwnerBootstrapMutationId } from '@qinglong/runtime-core/local-owner-bootstrap';
|
||||
import type { LocalOwnerDeliveryBridgeClearEvidence } from '@qinglong/runtime-core/local-owner-delivery-acknowledgement-gc';
|
||||
import {
|
||||
LocalOwnerSecretDeliveryError,
|
||||
type LocalOwnerSecretDeliverySummary,
|
||||
} from './contracts';
|
||||
import {
|
||||
ACKNOWLEDGEMENT_NAME_PATTERN,
|
||||
MAX_DIRECTORY_ENTRIES,
|
||||
MAX_RECORD_BYTES,
|
||||
RECORD_NAME_PATTERN,
|
||||
TEMP_NAME_PATTERN,
|
||||
acknowledgementName,
|
||||
isMissing,
|
||||
normalizeAcknowledgement,
|
||||
recordName,
|
||||
sameAcknowledgementSemantic,
|
||||
sameRecord,
|
||||
sameRequestSemantic,
|
||||
type AcknowledgementRecord,
|
||||
type DeliveryFile,
|
||||
} from './codec';
|
||||
|
||||
export class SecretDeliveryPrivateFilesystemStore {
|
||||
private readonly uid: number;
|
||||
private readonly device: bigint;
|
||||
private readonly inode: bigint;
|
||||
|
||||
constructor(readonly directory: string) {
|
||||
if (
|
||||
typeof directory !== 'string' ||
|
||||
!path.isAbsolute(directory) ||
|
||||
path.normalize(directory) !== directory ||
|
||||
Buffer.byteLength(directory) < 1 ||
|
||||
Buffer.byteLength(directory) > 4096 ||
|
||||
directory.includes('\0') ||
|
||||
typeof process.getuid !== 'function'
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'directory must be a bounded absolute POSIX path',
|
||||
);
|
||||
}
|
||||
this.uid = process.getuid();
|
||||
const stat = fs.lstatSync(directory, { bigint: true });
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== this.uid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o700
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'directory must be a private owned real directory',
|
||||
);
|
||||
}
|
||||
this.device = stat.dev;
|
||||
this.inode = stat.ino;
|
||||
}
|
||||
|
||||
verifyDirectory(): void {
|
||||
const stat = fs.lstatSync(this.directory, { bigint: true });
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== this.uid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o700 ||
|
||||
stat.dev !== this.device ||
|
||||
stat.ino !== this.inode
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'directory identity changed during delivery',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
syncDirectory(): void {
|
||||
const descriptor = fs.openSync(this.directory, 'r');
|
||||
try {
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
entries(): readonly string[] {
|
||||
this.verifyDirectory();
|
||||
const directory = fs.opendirSync(this.directory);
|
||||
const entries: string[] = [];
|
||||
try {
|
||||
for (;;) {
|
||||
const entry = directory.readSync();
|
||||
if (!entry) break;
|
||||
entries.push(entry.name);
|
||||
if (entries.length > MAX_DIRECTORY_ENTRIES) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'directory entry budget exceeded',
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
directory.closeSync();
|
||||
}
|
||||
for (const name of entries) {
|
||||
if (
|
||||
!RECORD_NAME_PATTERN.test(name) &&
|
||||
!ACKNOWLEDGEMENT_NAME_PATTERN.test(name) &&
|
||||
!TEMP_NAME_PATTERN.test(name)
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'directory contains an unknown entry',
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze(entries);
|
||||
}
|
||||
|
||||
inspectBridgeClear(
|
||||
kind: 'credential' | 'challenge',
|
||||
mutationId: string,
|
||||
): Readonly<LocalOwnerDeliveryBridgeClearEvidence> {
|
||||
try {
|
||||
assertLocalOwnerBootstrapMutationId(mutationId);
|
||||
} catch (error) {
|
||||
throw new LocalOwnerSecretDeliveryError('mutationId is invalid', error);
|
||||
}
|
||||
if (kind !== 'credential' && kind !== 'challenge') {
|
||||
throw new LocalOwnerSecretDeliveryError('kind is invalid');
|
||||
}
|
||||
const entries = new Set(this.entries());
|
||||
if (
|
||||
entries.has(`${kind}-${mutationId}.pending.json`) ||
|
||||
entries.has(`${kind}-${mutationId}.ready.json`) ||
|
||||
entries.has(`${kind}-${mutationId}.acknowledged.json`)
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'delivery crash bridge is not clear',
|
||||
);
|
||||
}
|
||||
const inspectedAtMs = Date.now();
|
||||
if (!Number.isSafeInteger(inspectedAtMs) || inspectedAtMs < 0) {
|
||||
throw new LocalOwnerSecretDeliveryError('trusted clock is invalid');
|
||||
}
|
||||
const evidenceDigest = createHash('sha256')
|
||||
.update('qinglong.local-owner-delivery-bridge-clear.v1\0', 'utf8')
|
||||
.update(kind, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(mutationId, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(this.device.toString(), 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(this.inode.toString(), 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(String(inspectedAtMs), 'utf8')
|
||||
.digest('hex');
|
||||
return Object.freeze({
|
||||
kind,
|
||||
acknowledgementMutationId: mutationId,
|
||||
inspectedAtMs,
|
||||
evidenceDigest,
|
||||
});
|
||||
}
|
||||
|
||||
read(fileName: string): DeliveryFile {
|
||||
const match = RECORD_NAME_PATTERN.exec(fileName);
|
||||
if (!match) {
|
||||
throw new LocalOwnerSecretDeliveryError('record name is invalid');
|
||||
}
|
||||
const filePath = path.join(this.directory, fileName);
|
||||
const before = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
Number(before.uid) !== this.uid ||
|
||||
(Number(before.mode) & 0o777) !== 0o600 ||
|
||||
before.size < 1n ||
|
||||
before.size > BigInt(MAX_RECORD_BYTES)
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'record must be a bounded private regular file',
|
||||
);
|
||||
}
|
||||
const descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
let material: Buffer | undefined;
|
||||
try {
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.size !== before.size
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'record identity changed while opening',
|
||||
);
|
||||
}
|
||||
material = fs.readFileSync(descriptor);
|
||||
const record = normalizeLocalOwnerBootstrapSecretDeliveryRecord(
|
||||
JSON.parse(material.toString('utf8')),
|
||||
);
|
||||
if (record.kind !== match[1] || record.mutationId !== match[2]) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'record content does not match its name',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
record,
|
||||
device: opened.dev,
|
||||
inode: opened.ino,
|
||||
digest: createHash('sha256')
|
||||
.update('qinglong.local-owner-secret-delivery.v1\0', 'utf8')
|
||||
.update(material)
|
||||
.digest('hex'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LocalOwnerSecretDeliveryError) throw error;
|
||||
throw new LocalOwnerSecretDeliveryError('record is invalid', error);
|
||||
} finally {
|
||||
material?.fill(0);
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
optional(fileName: string): DeliveryFile | null {
|
||||
try {
|
||||
return this.read(fileName);
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
readAcknowledgement(fileName: string): Readonly<AcknowledgementRecord> {
|
||||
const match = ACKNOWLEDGEMENT_NAME_PATTERN.exec(fileName);
|
||||
if (!match) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'acknowledgement name is invalid',
|
||||
);
|
||||
}
|
||||
const filePath = path.join(this.directory, fileName);
|
||||
const before = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
Number(before.uid) !== this.uid ||
|
||||
(Number(before.mode) & 0o777) !== 0o600 ||
|
||||
before.size < 1n ||
|
||||
before.size > BigInt(MAX_RECORD_BYTES)
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'acknowledgement must be a bounded private regular file',
|
||||
);
|
||||
}
|
||||
const descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
let material: Buffer | undefined;
|
||||
try {
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.size !== before.size
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'acknowledgement identity changed while opening',
|
||||
);
|
||||
}
|
||||
material = fs.readFileSync(descriptor);
|
||||
const record = normalizeAcknowledgement(
|
||||
JSON.parse(material.toString('utf8')),
|
||||
);
|
||||
if (record.kind !== match[1] || record.mutationId !== match[2]) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'acknowledgement content does not match its name',
|
||||
);
|
||||
}
|
||||
return record;
|
||||
} catch (error) {
|
||||
if (error instanceof LocalOwnerSecretDeliveryError) throw error;
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'acknowledgement is invalid',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
material?.fill(0);
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
optionalAcknowledgement(
|
||||
fileName: string,
|
||||
): Readonly<AcknowledgementRecord> | null {
|
||||
try {
|
||||
return this.readAcknowledgement(fileName);
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
writeAcknowledgement(
|
||||
record: Readonly<AcknowledgementRecord>,
|
||||
): Readonly<AcknowledgementRecord> {
|
||||
const fileName = acknowledgementName(record.kind, record.mutationId);
|
||||
const targetPath = path.join(this.directory, fileName);
|
||||
const temporaryName = `.${record.kind}-${
|
||||
record.mutationId
|
||||
}.${randomUUID()}.tmp`;
|
||||
const temporaryPath = path.join(this.directory, temporaryName);
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
temporaryPath,
|
||||
fs.constants.O_WRONLY |
|
||||
fs.constants.O_CREAT |
|
||||
fs.constants.O_EXCL |
|
||||
(fs.constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
const serialized = `${JSON.stringify(record)}\n`;
|
||||
if (Buffer.byteLength(serialized) > MAX_RECORD_BYTES) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'acknowledgement exceeds its byte budget',
|
||||
);
|
||||
}
|
||||
fs.writeFileSync(descriptor, serialized, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
try {
|
||||
fs.linkSync(temporaryPath, targetPath);
|
||||
this.syncDirectory();
|
||||
} catch (error) {
|
||||
if (
|
||||
!error ||
|
||||
typeof error !== 'object' ||
|
||||
!('code' in error) ||
|
||||
error.code !== 'EEXIST'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const published = this.readAcknowledgement(fileName);
|
||||
if (!sameAcknowledgementSemantic(published, record)) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'acknowledgement conflicts with the published record',
|
||||
);
|
||||
}
|
||||
return published;
|
||||
} catch (error) {
|
||||
if (error instanceof LocalOwnerSecretDeliveryError) throw error;
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'cannot publish acknowledgement',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
try {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
this.syncDirectory();
|
||||
} catch {
|
||||
// A published acknowledgement remains authoritative.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async prepare(
|
||||
candidate: Readonly<LocalOwnerBootstrapSecretDeliveryRecord>,
|
||||
): Promise<Readonly<LocalOwnerBootstrapSecretDeliveryPreparation>> {
|
||||
const normalized =
|
||||
normalizeLocalOwnerBootstrapSecretDeliveryRecord(candidate);
|
||||
const pendingName = recordName(normalized, 'pending');
|
||||
const readyName = recordName(normalized, 'ready');
|
||||
const entries = this.entries();
|
||||
const acknowledged = this.optionalAcknowledgement(
|
||||
acknowledgementName(normalized.kind, normalized.mutationId),
|
||||
);
|
||||
if (acknowledged) {
|
||||
if (
|
||||
acknowledged.requestId !== normalized.requestId ||
|
||||
acknowledged.ttlMs !== normalized.ttlMs ||
|
||||
(acknowledged.kind === 'challenge' &&
|
||||
(normalized.kind !== 'challenge' ||
|
||||
acknowledged.projectId !== normalized.projectId))
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'acknowledged mutation semantic conflicts with request',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
state: 'acknowledged' as const,
|
||||
kind: acknowledged.kind,
|
||||
...(acknowledged.kind === 'challenge'
|
||||
? { projectId: acknowledged.projectId }
|
||||
: {}),
|
||||
mutationId: acknowledged.mutationId,
|
||||
requestId: acknowledged.requestId,
|
||||
ttlMs: acknowledged.ttlMs,
|
||||
}) as Readonly<LocalOwnerBootstrapSecretDeliveryAcknowledgement>;
|
||||
}
|
||||
const ready = this.optional(readyName);
|
||||
if (ready) {
|
||||
if (!sameRequestSemantic(ready.record, normalized)) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'published mutation semantic conflicts with request',
|
||||
);
|
||||
}
|
||||
return ready.record;
|
||||
}
|
||||
const pending = this.optional(pendingName);
|
||||
if (pending) {
|
||||
if (!sameRequestSemantic(pending.record, normalized)) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'pending mutation semantic conflicts with request',
|
||||
);
|
||||
}
|
||||
return pending.record;
|
||||
}
|
||||
if (entries.length > MAX_DIRECTORY_ENTRIES - 2) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'directory lacks capacity for an atomic staged record',
|
||||
);
|
||||
}
|
||||
|
||||
const temporaryName = `.${normalized.kind}-${
|
||||
normalized.mutationId
|
||||
}.${randomUUID()}.tmp`;
|
||||
const temporaryPath = path.join(this.directory, temporaryName);
|
||||
const pendingPath = path.join(this.directory, pendingName);
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
temporaryPath,
|
||||
fs.constants.O_WRONLY |
|
||||
fs.constants.O_CREAT |
|
||||
fs.constants.O_EXCL |
|
||||
(fs.constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
const serialized = `${JSON.stringify(normalized)}\n`;
|
||||
if (Buffer.byteLength(serialized) > MAX_RECORD_BYTES) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'serialized record exceeds its byte budget',
|
||||
);
|
||||
}
|
||||
fs.writeFileSync(descriptor, serialized, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
try {
|
||||
fs.linkSync(temporaryPath, pendingPath);
|
||||
this.syncDirectory();
|
||||
} catch (error) {
|
||||
if (
|
||||
!error ||
|
||||
typeof error !== 'object' ||
|
||||
!('code' in error) ||
|
||||
error.code !== 'EEXIST'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const winner = this.read(pendingName);
|
||||
if (!sameRequestSemantic(winner.record, normalized)) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'concurrent pending mutation conflicts with request',
|
||||
);
|
||||
}
|
||||
return winner.record;
|
||||
} catch (error) {
|
||||
if (error instanceof LocalOwnerSecretDeliveryError) throw error;
|
||||
throw new LocalOwnerSecretDeliveryError('cannot stage secret', error);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
try {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
this.syncDirectory();
|
||||
} catch (error) {
|
||||
if (!isMissing(error)) {
|
||||
// The durable pending record, if any, remains authoritative.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async publish(
|
||||
prepared: Readonly<LocalOwnerBootstrapSecretDeliveryRecord>,
|
||||
): Promise<void> {
|
||||
const normalized =
|
||||
normalizeLocalOwnerBootstrapSecretDeliveryRecord(prepared);
|
||||
this.verifyDirectory();
|
||||
const pendingName = recordName(normalized, 'pending');
|
||||
const readyName = recordName(normalized, 'ready');
|
||||
const pendingPath = path.join(this.directory, pendingName);
|
||||
const readyPath = path.join(this.directory, readyName);
|
||||
const pending = this.optional(pendingName);
|
||||
const ready = this.optional(readyName);
|
||||
if (!pending && !ready) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'neither pending nor published record exists',
|
||||
);
|
||||
}
|
||||
if (pending && !sameRecord(pending.record, normalized)) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'pending record does not match prepared secret',
|
||||
);
|
||||
}
|
||||
if (ready && !sameRecord(ready.record, normalized)) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'published record does not match prepared secret',
|
||||
);
|
||||
}
|
||||
if (!ready) {
|
||||
try {
|
||||
fs.linkSync(pendingPath, readyPath);
|
||||
this.syncDirectory();
|
||||
} catch (error) {
|
||||
if (
|
||||
!error ||
|
||||
typeof error !== 'object' ||
|
||||
!('code' in error) ||
|
||||
error.code !== 'EEXIST'
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'cannot publish staged secret',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const published = this.read(readyName);
|
||||
if (!sameRecord(published.record, normalized)) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'published record changed during delivery',
|
||||
);
|
||||
}
|
||||
const currentPending = this.optional(pendingName);
|
||||
if (currentPending) {
|
||||
if (
|
||||
currentPending.device !== published.device ||
|
||||
currentPending.inode !== published.inode
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'pending and published records do not share one inode',
|
||||
);
|
||||
}
|
||||
fs.unlinkSync(pendingPath);
|
||||
this.syncDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
inspectReady(
|
||||
kind: 'credential' | 'challenge',
|
||||
mutationId: string,
|
||||
): Readonly<LocalOwnerSecretDeliverySummary> {
|
||||
try {
|
||||
assertLocalOwnerBootstrapMutationId(mutationId);
|
||||
} catch (error) {
|
||||
throw new LocalOwnerSecretDeliveryError('mutationId is invalid', error);
|
||||
}
|
||||
this.entries();
|
||||
const ready = this.read(`${kind}-${mutationId}.ready.json`);
|
||||
return Object.freeze({
|
||||
kind,
|
||||
mutationId,
|
||||
requestId: ready.record.requestId,
|
||||
deliveryDigest: ready.digest,
|
||||
path: path.join(this.directory, `${kind}-${mutationId}.ready.json`),
|
||||
});
|
||||
}
|
||||
|
||||
readyPath(kind: 'credential' | 'challenge', mutationId: string): string {
|
||||
try {
|
||||
assertLocalOwnerBootstrapMutationId(mutationId);
|
||||
} catch (error) {
|
||||
throw new LocalOwnerSecretDeliveryError('mutationId is invalid', error);
|
||||
}
|
||||
return path.join(this.directory, `${kind}-${mutationId}.ready.json`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { apiCredentialSecretDigest } from '@qinglong/runtime-core/api-credential-token';
|
||||
import {
|
||||
localOwnerBootstrapTokenDigest,
|
||||
type LocalOwnerBootstrapRepository,
|
||||
} from '@qinglong/runtime-core/local-owner-bootstrap';
|
||||
import type { LocalOwnerCredentialRecoveryRepository } from '@qinglong/runtime-core/local-owner-credential-recovery';
|
||||
import {
|
||||
LocalOwnerSecretDeliveryError,
|
||||
type LocalOwnerSecretRecoverySummary,
|
||||
} from './contracts';
|
||||
import {
|
||||
ACKNOWLEDGEMENT_NAME_PATTERN,
|
||||
RECORD_NAME_PATTERN,
|
||||
TEMP_NAME_PATTERN,
|
||||
fileAcknowledgement,
|
||||
persistentAcknowledgement,
|
||||
sameAcknowledgementSemantic,
|
||||
} from './codec';
|
||||
import {
|
||||
acknowledgeRecovery,
|
||||
validateAcknowledgement,
|
||||
validateRecoveryAcknowledgement,
|
||||
} from './acknowledgement';
|
||||
import { SecretDeliveryPrivateFilesystemStore } from './privateFilesystemStore';
|
||||
|
||||
export async function recover(
|
||||
store: SecretDeliveryPrivateFilesystemStore,
|
||||
repository: LocalOwnerBootstrapRepository,
|
||||
pepper: string,
|
||||
recoveryRepository?: LocalOwnerCredentialRecoveryRepository,
|
||||
): Promise<Readonly<LocalOwnerSecretRecoverySummary>> {
|
||||
const initialEntries = store.entries();
|
||||
let inspectedPendingRecords = 0;
|
||||
let publishedRecords = 0;
|
||||
let retainedUncommittedRecords = 0;
|
||||
let orphanTemporaryRecords = 0;
|
||||
for (const fileName of initialEntries) {
|
||||
const match = ACKNOWLEDGEMENT_NAME_PATTERN.exec(fileName);
|
||||
if (!match) continue;
|
||||
const acknowledgement = store.readAcknowledgement(fileName);
|
||||
if (
|
||||
acknowledgement.kind === 'credential' &&
|
||||
recoveryRepository &&
|
||||
(await recoveryRepository.resolve(acknowledgement.mutationId))
|
||||
) {
|
||||
await acknowledgeRecovery(
|
||||
store,
|
||||
recoveryRepository,
|
||||
pepper,
|
||||
acknowledgement.mutationId,
|
||||
acknowledgement.deliveryDigest,
|
||||
acknowledgement.acknowledgedAtMs,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const pendingName = `${acknowledgement.kind}-${acknowledgement.mutationId}.pending.json`;
|
||||
if (store.optional(pendingName)) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'acknowledged mutation still has a pending record',
|
||||
);
|
||||
}
|
||||
const readyName = `${acknowledgement.kind}-${acknowledgement.mutationId}.ready.json`;
|
||||
const ready = store.optional(readyName);
|
||||
await validateAcknowledgement(repository, pepper, acknowledgement, ready);
|
||||
const stored = await repository.recordDeliveryAcknowledgement(
|
||||
persistentAcknowledgement(acknowledgement),
|
||||
);
|
||||
if (
|
||||
!sameAcknowledgementSemantic(
|
||||
fileAcknowledgement(stored.acknowledgement),
|
||||
acknowledgement,
|
||||
)
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'database acknowledgement conflicts during recovery',
|
||||
);
|
||||
}
|
||||
if (ready) {
|
||||
fs.unlinkSync(path.join(store.directory, readyName));
|
||||
store.syncDirectory();
|
||||
}
|
||||
fs.unlinkSync(path.join(store.directory, fileName));
|
||||
store.syncDirectory();
|
||||
}
|
||||
const entries = store.entries();
|
||||
for (const fileName of entries) {
|
||||
if (TEMP_NAME_PATTERN.test(fileName)) {
|
||||
orphanTemporaryRecords += 1;
|
||||
continue;
|
||||
}
|
||||
const match = RECORD_NAME_PATTERN.exec(fileName);
|
||||
if (!match) continue;
|
||||
const stagedFile = store.read(fileName);
|
||||
const staged = stagedFile.record;
|
||||
const credentialRecovery =
|
||||
staged.kind === 'credential' && recoveryRepository
|
||||
? await recoveryRepository.resolve(staged.mutationId)
|
||||
: null;
|
||||
if (credentialRecovery && credentialRecovery.state !== 'issued') {
|
||||
if (match[3] !== 'ready') {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'database-acknowledged recovery still has a pending record',
|
||||
);
|
||||
}
|
||||
const recoveryAcknowledgement = fileAcknowledgement({
|
||||
kind: 'credential',
|
||||
mutationId: credentialRecovery.issueMutationId,
|
||||
requestId: credentialRecovery.issueRequestId,
|
||||
subjectId: credentialRecovery.subjectId,
|
||||
credentialId: credentialRecovery.replacementCredential.credentialId,
|
||||
factDigest: credentialRecovery.replacementCredential.secretDigest,
|
||||
ttlMs:
|
||||
credentialRecovery.replacementCredential.expiresAtMs -
|
||||
credentialRecovery.replacementCredential.notBeforeAtMs,
|
||||
deliveryDigest: credentialRecovery.deliveryDigest!,
|
||||
acknowledgedAtMs: credentialRecovery.acknowledgedAtMs!,
|
||||
});
|
||||
if (recoveryAcknowledgement.kind !== 'credential') {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'database recovery acknowledgement kind is invalid',
|
||||
);
|
||||
}
|
||||
validateRecoveryAcknowledgement(
|
||||
pepper,
|
||||
recoveryAcknowledgement,
|
||||
credentialRecovery,
|
||||
stagedFile,
|
||||
);
|
||||
fs.unlinkSync(path.join(store.directory, fileName));
|
||||
store.syncDirectory();
|
||||
continue;
|
||||
}
|
||||
const acknowledged = await repository.resolveDeliveryAcknowledgement(
|
||||
staged.mutationId,
|
||||
);
|
||||
if (acknowledged) {
|
||||
if (match[3] !== 'ready') {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'database-acknowledged mutation still has a pending record',
|
||||
);
|
||||
}
|
||||
await validateAcknowledgement(
|
||||
repository,
|
||||
pepper,
|
||||
fileAcknowledgement(acknowledged),
|
||||
stagedFile,
|
||||
);
|
||||
fs.unlinkSync(path.join(store.directory, fileName));
|
||||
store.syncDirectory();
|
||||
continue;
|
||||
}
|
||||
let committed = false;
|
||||
if (staged.kind === 'credential') {
|
||||
const provisioning = await repository.resolveProvisioning(
|
||||
staged.mutationId,
|
||||
);
|
||||
if (provisioning && credentialRecovery) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'credential mutation belongs to multiple database facts',
|
||||
);
|
||||
}
|
||||
if (provisioning) {
|
||||
if (
|
||||
provisioning.requestId !== staged.requestId ||
|
||||
provisioning.identity.subject.id !== staged.subjectId ||
|
||||
provisioning.credential.credentialId !== staged.credentialId ||
|
||||
provisioning.credential.secretDigest !==
|
||||
apiCredentialSecretDigest(
|
||||
pepper,
|
||||
staged.credentialId,
|
||||
staged.secret,
|
||||
) ||
|
||||
provisioning.credential.expiresAtMs -
|
||||
provisioning.credential.notBeforeAtMs !==
|
||||
staged.ttlMs
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'staged credential does not match committed provisioning',
|
||||
);
|
||||
}
|
||||
committed = true;
|
||||
} else if (credentialRecovery) {
|
||||
if (
|
||||
credentialRecovery.issueRequestId !== staged.requestId ||
|
||||
credentialRecovery.subjectId !== staged.subjectId ||
|
||||
credentialRecovery.replacementCredential.credentialId !==
|
||||
staged.credentialId ||
|
||||
credentialRecovery.replacementCredential.secretDigest !==
|
||||
apiCredentialSecretDigest(
|
||||
pepper,
|
||||
staged.credentialId,
|
||||
staged.secret,
|
||||
) ||
|
||||
credentialRecovery.replacementCredential.expiresAtMs -
|
||||
credentialRecovery.replacementCredential.notBeforeAtMs !==
|
||||
staged.ttlMs
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'staged credential does not match committed recovery',
|
||||
);
|
||||
}
|
||||
committed = true;
|
||||
}
|
||||
} else {
|
||||
const challenge = await repository.resolveIssuedChallenge(
|
||||
staged.mutationId,
|
||||
);
|
||||
if (challenge) {
|
||||
if (
|
||||
challenge.projectId !== staged.projectId ||
|
||||
challenge.issueRequestId !== staged.requestId ||
|
||||
challenge.challengeId !== staged.challengeId ||
|
||||
challenge.tokenDigest !==
|
||||
localOwnerBootstrapTokenDigest(
|
||||
staged.projectId,
|
||||
staged.challengeId,
|
||||
staged.secret,
|
||||
) ||
|
||||
challenge.expiresAtMs - challenge.issuedAtMs !== staged.ttlMs
|
||||
) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'staged challenge does not match committed issue',
|
||||
);
|
||||
}
|
||||
committed = true;
|
||||
}
|
||||
}
|
||||
if (match[3] === 'ready') {
|
||||
if (!committed) {
|
||||
throw new LocalOwnerSecretDeliveryError(
|
||||
'published secret has no committed database fact',
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
inspectedPendingRecords += 1;
|
||||
if (!committed) {
|
||||
retainedUncommittedRecords += 1;
|
||||
continue;
|
||||
}
|
||||
await store.publish(staged);
|
||||
publishedRecords += 1;
|
||||
}
|
||||
return Object.freeze({
|
||||
inspectedPendingRecords,
|
||||
publishedRecords,
|
||||
retainedUncommittedRecords,
|
||||
orphanTemporaryRecords,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { LocalOwnerSecretDeliveryError } from './secret-delivery/contracts';
|
||||
export type {
|
||||
ClaimLocalOwnerFromDeliveriesRequest,
|
||||
LocalOwnerSecretDeliverySummary,
|
||||
LocalOwnerSecretRecoverySummary,
|
||||
} from './secret-delivery/contracts';
|
||||
export { FileLocalOwnerBootstrapSecretDelivery } from './secret-delivery/fileSecretDelivery';
|
||||
@@ -0,0 +1,236 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { assertApiCredentialPepperKeyId } from '@qinglong/runtime-core/api-credential';
|
||||
import {
|
||||
LocalOwnerPepperConfigurationError,
|
||||
LocalOwnerPepperUnavailableError,
|
||||
} from './pepperFile';
|
||||
import { localOwnerPepperKeyPath } from './pepperKeyring';
|
||||
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
interface DirectoryIdentity {
|
||||
readonly path: string;
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
readonly uid: number;
|
||||
}
|
||||
|
||||
export interface DestroyLocalOwnerPepperKeyOptions {
|
||||
readonly keyringDirectory: string;
|
||||
readonly pepperKeyId: string;
|
||||
readonly materialRole: 'runtime' | 'backup';
|
||||
readonly expectedMaterialDigest: string;
|
||||
readonly prepareMutationId: string;
|
||||
}
|
||||
|
||||
export interface DestroyLocalOwnerPepperKeyResult {
|
||||
readonly status: 'destroyed' | 'absent';
|
||||
readonly pepperKeyId: string;
|
||||
readonly materialDigest: string;
|
||||
readonly destructionProofDigest: string;
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function currentUid(): number {
|
||||
if (
|
||||
typeof process.getuid !== 'function' ||
|
||||
typeof process.geteuid !== 'function' ||
|
||||
process.getuid() !== process.geteuid()
|
||||
) {
|
||||
throw new LocalOwnerPepperConfigurationError(
|
||||
'real and effective POSIX users must match',
|
||||
);
|
||||
}
|
||||
return process.getuid();
|
||||
}
|
||||
|
||||
function directoryIdentity(directory: string): DirectoryIdentity {
|
||||
const uid = currentUid();
|
||||
let stat: fs.BigIntStats;
|
||||
try {
|
||||
stat = fs.lstatSync(directory, { bigint: true });
|
||||
} catch (error) {
|
||||
throw new LocalOwnerPepperUnavailableError(error);
|
||||
}
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== uid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o700
|
||||
) {
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
path: directory,
|
||||
device: stat.dev,
|
||||
inode: stat.ino,
|
||||
uid,
|
||||
});
|
||||
}
|
||||
|
||||
function verifyDirectory(expected: DirectoryIdentity): void {
|
||||
const current = directoryIdentity(expected.path);
|
||||
if (
|
||||
current.device !== expected.device ||
|
||||
current.inode !== expected.inode ||
|
||||
current.uid !== expected.uid
|
||||
) {
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function proof(
|
||||
prepareMutationId: string,
|
||||
pepperKeyId: string,
|
||||
materialRole: 'runtime' | 'backup',
|
||||
materialDigest: string,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update('qinglong.local-owner-pepper-material-destruction.v1\0', 'utf8')
|
||||
.update(prepareMutationId, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(pepperKeyId, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(materialRole, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(materialDigest, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function missing(error: unknown): boolean {
|
||||
return (
|
||||
!!error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
);
|
||||
}
|
||||
|
||||
export function destroyLocalOwnerPepperKey(
|
||||
options: DestroyLocalOwnerPepperKeyOptions,
|
||||
): Readonly<DestroyLocalOwnerPepperKeyResult> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!exactKeys(options, [
|
||||
'keyringDirectory',
|
||||
'pepperKeyId',
|
||||
'materialRole',
|
||||
'expectedMaterialDigest',
|
||||
'prepareMutationId',
|
||||
]) ||
|
||||
typeof options.expectedMaterialDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(options.expectedMaterialDigest) ||
|
||||
(options.materialRole !== 'runtime' && options.materialRole !== 'backup') ||
|
||||
typeof options.prepareMutationId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(options.prepareMutationId)
|
||||
) {
|
||||
throw new LocalOwnerPepperConfigurationError('options shape is invalid');
|
||||
}
|
||||
try {
|
||||
assertApiCredentialPepperKeyId(options.pepperKeyId);
|
||||
} catch {
|
||||
throw new LocalOwnerPepperConfigurationError('pepperKeyId is invalid');
|
||||
}
|
||||
const target = localOwnerPepperKeyPath(
|
||||
options.keyringDirectory,
|
||||
options.pepperKeyId,
|
||||
);
|
||||
const directory = directoryIdentity(path.dirname(target));
|
||||
const destructionProofDigest = proof(
|
||||
options.prepareMutationId,
|
||||
options.pepperKeyId,
|
||||
options.materialRole,
|
||||
options.expectedMaterialDigest,
|
||||
);
|
||||
let descriptor: number | undefined;
|
||||
let material: Buffer | undefined;
|
||||
try {
|
||||
verifyDirectory(directory);
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
target,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!missing(error)) throw error;
|
||||
verifyDirectory(directory);
|
||||
return Object.freeze({
|
||||
status: 'absent' as const,
|
||||
pepperKeyId: options.pepperKeyId,
|
||||
materialDigest: options.expectedMaterialDigest,
|
||||
destructionProofDigest,
|
||||
});
|
||||
}
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
opened.isSymbolicLink() ||
|
||||
Number(opened.uid) !== directory.uid ||
|
||||
(Number(opened.mode) & 0o777) !== 0o600 ||
|
||||
opened.size < 32n ||
|
||||
opened.size > 256n
|
||||
) {
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
}
|
||||
material = fs.readFileSync(descriptor);
|
||||
const materialDigest = createHash('sha256')
|
||||
.update('qinglong.local-owner-pepper.summary.v1\0', 'utf8')
|
||||
.update(material)
|
||||
.digest('hex');
|
||||
if (materialDigest !== options.expectedMaterialDigest) {
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
}
|
||||
verifyDirectory(directory);
|
||||
const current = fs.lstatSync(target, { bigint: true });
|
||||
if (
|
||||
!current.isFile() ||
|
||||
current.isSymbolicLink() ||
|
||||
current.dev !== opened.dev ||
|
||||
current.ino !== opened.ino ||
|
||||
Number(current.uid) !== directory.uid ||
|
||||
(Number(current.mode) & 0o777) !== 0o600
|
||||
) {
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
}
|
||||
fs.unlinkSync(target);
|
||||
const directoryDescriptor = fs.openSync(directory.path, 'r');
|
||||
try {
|
||||
fs.fsyncSync(directoryDescriptor);
|
||||
} finally {
|
||||
fs.closeSync(directoryDescriptor);
|
||||
}
|
||||
verifyDirectory(directory);
|
||||
try {
|
||||
fs.lstatSync(target);
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
} catch (error) {
|
||||
if (!missing(error)) throw error;
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'destroyed' as const,
|
||||
pepperKeyId: options.pepperKeyId,
|
||||
materialDigest: options.expectedMaterialDigest,
|
||||
destructionProofDigest,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LocalOwnerPepperUnavailableError) throw error;
|
||||
throw new LocalOwnerPepperUnavailableError(error);
|
||||
} finally {
|
||||
material?.fill(0);
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export {
|
||||
LocalOwnerPepperConfigurationError,
|
||||
LocalOwnerPepperConflictError,
|
||||
LocalOwnerPepperUnavailableError,
|
||||
backupLocalOwnerPepper,
|
||||
inspectLocalOwnerPepper,
|
||||
provisionLocalOwnerPepper,
|
||||
restoreLocalOwnerPepper,
|
||||
type BackupLocalOwnerPepperOptions,
|
||||
type LocalOwnerPepperPathOptions,
|
||||
type LocalOwnerPepperSummary,
|
||||
type ProvisionLocalOwnerPepperOptions,
|
||||
type RestoreLocalOwnerPepperOptions,
|
||||
} from './pepperFile';
|
||||
export {
|
||||
LocalOwnerPepperKeyringFileProvider,
|
||||
backupLocalOwnerPepperKey,
|
||||
localOwnerPepperKeyPath,
|
||||
provisionLocalOwnerPepperKey,
|
||||
restoreLocalOwnerPepperKey,
|
||||
type BackupLocalOwnerPepperKeyOptions,
|
||||
type LocalOwnerPepperKeyMaterial,
|
||||
type LocalOwnerPepperKeyringSummary,
|
||||
type ProvisionLocalOwnerPepperKeyOptions,
|
||||
type RestoreLocalOwnerPepperKeyOptions,
|
||||
} from './pepperKeyring';
|
||||
@@ -0,0 +1,468 @@
|
||||
import { createHash, randomBytes as cryptoRandomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { assertApiCredentialPepper } from '@qinglong/runtime-core/api-credential-token';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const PEPPER_BYTES = 32;
|
||||
|
||||
type RandomBytesFactory = (size: number) => Buffer;
|
||||
|
||||
interface PathIdentity {
|
||||
readonly path: string;
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
readonly uid: number;
|
||||
readonly mode: number;
|
||||
readonly kind: 'directory' | 'file';
|
||||
}
|
||||
|
||||
export interface LocalOwnerPepperSummary {
|
||||
readonly version: 1;
|
||||
readonly digest: string;
|
||||
readonly byteLength: number;
|
||||
}
|
||||
|
||||
export interface LocalOwnerPepperPathOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly pepperPath: string;
|
||||
}
|
||||
|
||||
export interface ProvisionLocalOwnerPepperOptions
|
||||
extends LocalOwnerPepperPathOptions {
|
||||
readonly randomBytes?: RandomBytesFactory;
|
||||
}
|
||||
|
||||
export interface BackupLocalOwnerPepperOptions
|
||||
extends LocalOwnerPepperPathOptions {
|
||||
readonly backupRoot: string;
|
||||
readonly backupPath: string;
|
||||
}
|
||||
|
||||
export interface RestoreLocalOwnerPepperOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly backupRoot: string;
|
||||
readonly backupPath: string;
|
||||
readonly pepperPath: string;
|
||||
}
|
||||
|
||||
export class LocalOwnerPepperConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_OWNER_PEPPER_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Local Owner pepper configuration is invalid: ${message}`);
|
||||
this.name = 'LocalOwnerPepperConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalOwnerPepperConflictError extends Error {
|
||||
readonly code = 'LOCAL_OWNER_PEPPER_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Local Owner pepper destination already exists');
|
||||
this.name = 'LocalOwnerPepperConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalOwnerPepperUnavailableError extends Error {
|
||||
readonly code = 'LOCAL_OWNER_PEPPER_UNAVAILABLE';
|
||||
|
||||
constructor(readonly cause?: unknown) {
|
||||
super('Local Owner pepper operation is unavailable');
|
||||
this.name = 'LocalOwnerPepperUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function boundedPath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value) < 1 ||
|
||||
Buffer.byteLength(value) > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalOwnerPepperConfigurationError(
|
||||
`${label} must be a normalized bounded absolute path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function currentUid(): number {
|
||||
if (
|
||||
typeof process.getuid !== 'function' ||
|
||||
typeof process.geteuid !== 'function' ||
|
||||
process.getuid() !== process.geteuid()
|
||||
) {
|
||||
throw new LocalOwnerPepperConfigurationError(
|
||||
'real and effective POSIX users must match',
|
||||
);
|
||||
}
|
||||
return process.getuid();
|
||||
}
|
||||
|
||||
function identity(
|
||||
targetPath: string,
|
||||
uid: number,
|
||||
kind: PathIdentity['kind'],
|
||||
): PathIdentity {
|
||||
let stat: fs.BigIntStats;
|
||||
try {
|
||||
stat = fs.lstatSync(targetPath, { bigint: true });
|
||||
} catch (error) {
|
||||
throw new LocalOwnerPepperUnavailableError(error);
|
||||
}
|
||||
const expected = kind === 'directory' ? stat.isDirectory() : stat.isFile();
|
||||
const mode = Number(stat.mode) & 0o777;
|
||||
if (
|
||||
!expected ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== uid ||
|
||||
mode !== (kind === 'directory' ? 0o700 : 0o600)
|
||||
) {
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
path: targetPath,
|
||||
device: stat.dev,
|
||||
inode: stat.ino,
|
||||
uid,
|
||||
mode,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
|
||||
function sameIdentity(expected: PathIdentity): void {
|
||||
const current = identity(expected.path, expected.uid, expected.kind);
|
||||
if (
|
||||
current.device !== expected.device ||
|
||||
current.inode !== expected.inode ||
|
||||
current.mode !== expected.mode
|
||||
) {
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function authority(
|
||||
deploymentRoot: string,
|
||||
targetPaths: readonly string[],
|
||||
): { readonly uid: number; readonly identities: readonly PathIdentity[] } {
|
||||
const uid = currentUid();
|
||||
const root = identity(deploymentRoot, uid, 'directory');
|
||||
const identities: PathIdentity[] = [root];
|
||||
const seen = new Set([deploymentRoot]);
|
||||
for (const targetPath of targetPaths) {
|
||||
const relative = path.relative(deploymentRoot, targetPath);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LocalOwnerPepperConfigurationError(
|
||||
'pepper paths must be descendants of deploymentRoot',
|
||||
);
|
||||
}
|
||||
let current = deploymentRoot;
|
||||
for (const part of path.dirname(relative).split(path.sep)) {
|
||||
if (part === '.') continue;
|
||||
current = path.join(current, part);
|
||||
if (seen.has(current)) continue;
|
||||
identities.push(identity(current, uid, 'directory'));
|
||||
seen.add(current);
|
||||
}
|
||||
}
|
||||
return Object.freeze({ uid, identities: Object.freeze(identities) });
|
||||
}
|
||||
|
||||
function verifyAuthority(identities: readonly PathIdentity[]): void {
|
||||
for (const expected of identities) sameIdentity(expected);
|
||||
}
|
||||
|
||||
function summary(material: Buffer): Readonly<LocalOwnerPepperSummary> {
|
||||
return Object.freeze({
|
||||
version: 1,
|
||||
digest: createHash('sha256')
|
||||
.update('qinglong.local-owner-pepper.summary.v1\0', 'utf8')
|
||||
.update(material)
|
||||
.digest('hex'),
|
||||
byteLength: material.byteLength,
|
||||
});
|
||||
}
|
||||
|
||||
function readPepper(
|
||||
pepperPath: string,
|
||||
uid: number,
|
||||
): { readonly material: Buffer; readonly identity: PathIdentity } {
|
||||
const expected = identity(pepperPath, uid, 'file');
|
||||
let descriptor: number | undefined;
|
||||
let material: Buffer | undefined;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
pepperPath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
opened.dev !== expected.device ||
|
||||
opened.ino !== expected.inode ||
|
||||
opened.size < 32n ||
|
||||
opened.size > 256n
|
||||
) {
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
}
|
||||
material = fs.readFileSync(descriptor);
|
||||
const value = material.toString('utf8');
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
}
|
||||
assertApiCredentialPepper(value);
|
||||
return Object.freeze({ material, identity: expected });
|
||||
} catch (error) {
|
||||
material?.fill(0);
|
||||
if (error instanceof LocalOwnerPepperUnavailableError) throw error;
|
||||
throw new LocalOwnerPepperUnavailableError(error);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function syncDirectory(directory: string): void {
|
||||
const descriptor = fs.openSync(directory, 'r');
|
||||
try {
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function isConflict(error: unknown): boolean {
|
||||
return (
|
||||
!!error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'EEXIST'
|
||||
);
|
||||
}
|
||||
|
||||
function publishNoReplace(
|
||||
targetPath: string,
|
||||
material: Buffer,
|
||||
identities: readonly PathIdentity[],
|
||||
): void {
|
||||
const directory = path.dirname(targetPath);
|
||||
const temporaryPath = path.join(
|
||||
directory,
|
||||
`.owner-pepper-${cryptoRandomBytes(12).toString('hex')}.tmp`,
|
||||
);
|
||||
let descriptor: number | undefined;
|
||||
let linked = false;
|
||||
try {
|
||||
verifyAuthority(identities);
|
||||
descriptor = fs.openSync(
|
||||
temporaryPath,
|
||||
fs.constants.O_WRONLY |
|
||||
fs.constants.O_CREAT |
|
||||
fs.constants.O_EXCL |
|
||||
(fs.constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
fs.writeFileSync(descriptor, material);
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
fs.linkSync(temporaryPath, targetPath);
|
||||
linked = true;
|
||||
syncDirectory(directory);
|
||||
verifyAuthority(identities);
|
||||
const published = readPepper(targetPath, currentUid());
|
||||
try {
|
||||
if (!published.material.equals(material)) {
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
}
|
||||
} finally {
|
||||
published.material.fill(0);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isConflict(error)) throw new LocalOwnerPepperConflictError();
|
||||
if (error instanceof LocalOwnerPepperConflictError) throw error;
|
||||
if (error instanceof LocalOwnerPepperUnavailableError) throw error;
|
||||
throw new LocalOwnerPepperUnavailableError(error);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
try {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
syncDirectory(directory);
|
||||
} catch {
|
||||
// A linked target is already durable; a bounded orphan is recoverable.
|
||||
}
|
||||
if (!linked) verifyAuthority(identities);
|
||||
}
|
||||
}
|
||||
|
||||
function validatePathOptions(options: LocalOwnerPepperPathOptions): {
|
||||
readonly deploymentRoot: string;
|
||||
readonly pepperPath: string;
|
||||
} {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!exactKeys(options, ['deploymentRoot', 'pepperPath'])
|
||||
) {
|
||||
throw new LocalOwnerPepperConfigurationError('options shape is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
deploymentRoot: boundedPath(options.deploymentRoot, 'deploymentRoot'),
|
||||
pepperPath: boundedPath(options.pepperPath, 'pepperPath'),
|
||||
});
|
||||
}
|
||||
|
||||
export function provisionLocalOwnerPepper(
|
||||
options: ProvisionLocalOwnerPepperOptions,
|
||||
): Readonly<LocalOwnerPepperSummary> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!exactKeys(options, [
|
||||
'deploymentRoot',
|
||||
'pepperPath',
|
||||
...(options.randomBytes === undefined ? [] : ['randomBytes']),
|
||||
]) ||
|
||||
(options.randomBytes !== undefined &&
|
||||
typeof options.randomBytes !== 'function')
|
||||
) {
|
||||
throw new LocalOwnerPepperConfigurationError('options shape is invalid');
|
||||
}
|
||||
const deploymentRoot = boundedPath(options.deploymentRoot, 'deploymentRoot');
|
||||
const pepperPath = boundedPath(options.pepperPath, 'pepperPath');
|
||||
const proof = authority(deploymentRoot, [pepperPath]);
|
||||
let entropy: Buffer | undefined;
|
||||
let material: Buffer | undefined;
|
||||
try {
|
||||
entropy = (options.randomBytes ?? cryptoRandomBytes)(PEPPER_BYTES);
|
||||
if (!Buffer.isBuffer(entropy) || entropy.byteLength !== PEPPER_BYTES) {
|
||||
throw new LocalOwnerPepperConfigurationError(
|
||||
'randomBytes result is invalid',
|
||||
);
|
||||
}
|
||||
material = Buffer.from(entropy.toString('base64url'), 'utf8');
|
||||
publishNoReplace(pepperPath, material, proof.identities);
|
||||
return summary(material);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof LocalOwnerPepperConfigurationError ||
|
||||
error instanceof LocalOwnerPepperConflictError ||
|
||||
error instanceof LocalOwnerPepperUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalOwnerPepperUnavailableError(error);
|
||||
} finally {
|
||||
entropy?.fill(0);
|
||||
material?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function inspectLocalOwnerPepper(
|
||||
options: LocalOwnerPepperPathOptions,
|
||||
): Readonly<LocalOwnerPepperSummary> {
|
||||
const resolved = validatePathOptions(options);
|
||||
const proof = authority(resolved.deploymentRoot, [resolved.pepperPath]);
|
||||
const pepper = readPepper(resolved.pepperPath, proof.uid);
|
||||
try {
|
||||
verifyAuthority(proof.identities);
|
||||
sameIdentity(pepper.identity);
|
||||
return summary(pepper.material);
|
||||
} finally {
|
||||
pepper.material.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function backupLocalOwnerPepper(
|
||||
options: BackupLocalOwnerPepperOptions,
|
||||
): Readonly<LocalOwnerPepperSummary> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!exactKeys(options, [
|
||||
'backupPath',
|
||||
'backupRoot',
|
||||
'deploymentRoot',
|
||||
'pepperPath',
|
||||
])
|
||||
) {
|
||||
throw new LocalOwnerPepperConfigurationError('options shape is invalid');
|
||||
}
|
||||
const deploymentRoot = boundedPath(options.deploymentRoot, 'deploymentRoot');
|
||||
const pepperPath = boundedPath(options.pepperPath, 'pepperPath');
|
||||
const backupRoot = boundedPath(options.backupRoot, 'backupRoot');
|
||||
const backupPath = boundedPath(options.backupPath, 'backupPath');
|
||||
if (pepperPath === backupPath) {
|
||||
throw new LocalOwnerPepperConfigurationError(
|
||||
'pepper and backup paths must be distinct',
|
||||
);
|
||||
}
|
||||
const sourceProof = authority(deploymentRoot, [pepperPath]);
|
||||
const backupProof = authority(backupRoot, [backupPath]);
|
||||
const pepper = readPepper(pepperPath, sourceProof.uid);
|
||||
try {
|
||||
publishNoReplace(backupPath, pepper.material, backupProof.identities);
|
||||
sameIdentity(pepper.identity);
|
||||
verifyAuthority(sourceProof.identities);
|
||||
return summary(pepper.material);
|
||||
} finally {
|
||||
pepper.material.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreLocalOwnerPepper(
|
||||
options: RestoreLocalOwnerPepperOptions,
|
||||
): Readonly<LocalOwnerPepperSummary> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!exactKeys(options, [
|
||||
'backupPath',
|
||||
'backupRoot',
|
||||
'deploymentRoot',
|
||||
'pepperPath',
|
||||
])
|
||||
) {
|
||||
throw new LocalOwnerPepperConfigurationError('options shape is invalid');
|
||||
}
|
||||
const deploymentRoot = boundedPath(options.deploymentRoot, 'deploymentRoot');
|
||||
const backupRoot = boundedPath(options.backupRoot, 'backupRoot');
|
||||
const backupPath = boundedPath(options.backupPath, 'backupPath');
|
||||
const pepperPath = boundedPath(options.pepperPath, 'pepperPath');
|
||||
if (pepperPath === backupPath) {
|
||||
throw new LocalOwnerPepperConfigurationError(
|
||||
'pepper and backup paths must be distinct',
|
||||
);
|
||||
}
|
||||
const backupProof = authority(backupRoot, [backupPath]);
|
||||
const targetProof = authority(deploymentRoot, [pepperPath]);
|
||||
const backup = readPepper(backupPath, backupProof.uid);
|
||||
try {
|
||||
publishNoReplace(pepperPath, backup.material, targetProof.identities);
|
||||
sameIdentity(backup.identity);
|
||||
verifyAuthority(backupProof.identities);
|
||||
return summary(backup.material);
|
||||
} finally {
|
||||
backup.material.fill(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { assertApiCredentialPepperKeyId } from '@qinglong/runtime-core/api-credential';
|
||||
import { assertApiCredentialPepper } from '@qinglong/runtime-core/api-credential-token';
|
||||
import { MAX_LOCAL_OWNER_PEPPER_KEYS } from '@qinglong/runtime-core/local-owner-pepper';
|
||||
import {
|
||||
LocalOwnerPepperConfigurationError,
|
||||
LocalOwnerPepperUnavailableError,
|
||||
backupLocalOwnerPepper,
|
||||
provisionLocalOwnerPepper,
|
||||
restoreLocalOwnerPepper,
|
||||
type LocalOwnerPepperSummary,
|
||||
} from './pepperFile';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const KEY_SUFFIX = '.pepper';
|
||||
|
||||
interface DirectoryIdentity {
|
||||
readonly path: string;
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
readonly uid: number;
|
||||
readonly mode: number;
|
||||
}
|
||||
|
||||
export interface LocalOwnerPepperKeyMaterial {
|
||||
readonly pepperKeyId: string;
|
||||
readonly pepper: string;
|
||||
readonly summary: Readonly<LocalOwnerPepperSummary>;
|
||||
}
|
||||
|
||||
export interface LocalOwnerPepperKeyringSummary {
|
||||
readonly version: 1;
|
||||
readonly keyIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface ProvisionLocalOwnerPepperKeyOptions {
|
||||
readonly keyringDirectory: string;
|
||||
readonly pepperKeyId: string;
|
||||
readonly randomBytes?: (size: number) => Buffer;
|
||||
}
|
||||
|
||||
export interface BackupLocalOwnerPepperKeyOptions {
|
||||
readonly keyringDirectory: string;
|
||||
readonly backupDirectory: string;
|
||||
readonly pepperKeyId: string;
|
||||
}
|
||||
|
||||
export interface RestoreLocalOwnerPepperKeyOptions {
|
||||
readonly keyringDirectory: string;
|
||||
readonly backupDirectory: string;
|
||||
readonly pepperKeyId: string;
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function boundedDirectory(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value) < 1 ||
|
||||
Buffer.byteLength(value) > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalOwnerPepperConfigurationError(
|
||||
'keyringDirectory must be a normalized bounded absolute path',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function keyId(value: unknown): string {
|
||||
try {
|
||||
assertApiCredentialPepperKeyId(value as string);
|
||||
} catch {
|
||||
throw new LocalOwnerPepperConfigurationError('pepperKeyId is invalid');
|
||||
}
|
||||
return value as string;
|
||||
}
|
||||
|
||||
function currentUid(): number {
|
||||
if (
|
||||
typeof process.getuid !== 'function' ||
|
||||
typeof process.geteuid !== 'function' ||
|
||||
process.getuid() !== process.geteuid()
|
||||
) {
|
||||
throw new LocalOwnerPepperConfigurationError(
|
||||
'real and effective POSIX users must match',
|
||||
);
|
||||
}
|
||||
return process.getuid();
|
||||
}
|
||||
|
||||
function directoryIdentity(directory: string): DirectoryIdentity {
|
||||
const uid = currentUid();
|
||||
let stat: fs.BigIntStats;
|
||||
try {
|
||||
stat = fs.lstatSync(directory, { bigint: true });
|
||||
} catch (error) {
|
||||
throw new LocalOwnerPepperUnavailableError(error);
|
||||
}
|
||||
const mode = Number(stat.mode) & 0o777;
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== uid ||
|
||||
mode !== 0o700
|
||||
) {
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
path: directory,
|
||||
device: stat.dev,
|
||||
inode: stat.ino,
|
||||
uid,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
|
||||
function verifyDirectory(expected: DirectoryIdentity): void {
|
||||
const current = directoryIdentity(expected.path);
|
||||
if (
|
||||
current.device !== expected.device ||
|
||||
current.inode !== expected.inode ||
|
||||
current.uid !== expected.uid ||
|
||||
current.mode !== expected.mode
|
||||
) {
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function fileName(pepperKeyId: string): string {
|
||||
return `${Buffer.from(pepperKeyId, 'utf8').toString(
|
||||
'base64url',
|
||||
)}${KEY_SUFFIX}`;
|
||||
}
|
||||
|
||||
function decodeFileName(value: string): string {
|
||||
if (!value.endsWith(KEY_SUFFIX)) {
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
}
|
||||
const encoded = value.slice(0, -KEY_SUFFIX.length);
|
||||
let decoded: Buffer | undefined;
|
||||
try {
|
||||
decoded = Buffer.from(encoded, 'base64url');
|
||||
const result = decoded.toString('utf8');
|
||||
if (
|
||||
encoded.length === 0 ||
|
||||
decoded.toString('base64url') !== encoded ||
|
||||
fileName(keyId(result)) !== value
|
||||
) {
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof LocalOwnerPepperUnavailableError) throw error;
|
||||
throw new LocalOwnerPepperUnavailableError(error);
|
||||
} finally {
|
||||
decoded?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function summary(material: Buffer): Readonly<LocalOwnerPepperSummary> {
|
||||
return Object.freeze({
|
||||
version: 1,
|
||||
digest: createHash('sha256')
|
||||
.update('qinglong.local-owner-pepper.summary.v1\0', 'utf8')
|
||||
.update(material)
|
||||
.digest('hex'),
|
||||
byteLength: material.byteLength,
|
||||
});
|
||||
}
|
||||
|
||||
function readKey(
|
||||
identity: DirectoryIdentity,
|
||||
pepperKeyId: string,
|
||||
): Readonly<LocalOwnerPepperKeyMaterial> | null {
|
||||
verifyDirectory(identity);
|
||||
const target = path.join(identity.path, fileName(pepperKeyId));
|
||||
let descriptor: number | undefined;
|
||||
let material: Buffer | undefined;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
target,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const stat = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== identity.uid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o600 ||
|
||||
stat.size < 32n ||
|
||||
stat.size > 256n
|
||||
) {
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
}
|
||||
material = fs.readFileSync(descriptor);
|
||||
const pepper = material.toString('utf8');
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(pepper)) {
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
}
|
||||
assertApiCredentialPepper(pepper);
|
||||
verifyDirectory(identity);
|
||||
return Object.freeze({ pepperKeyId, pepper, summary: summary(material) });
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
verifyDirectory(identity);
|
||||
return null;
|
||||
}
|
||||
if (error instanceof LocalOwnerPepperUnavailableError) throw error;
|
||||
throw new LocalOwnerPepperUnavailableError(error);
|
||||
} finally {
|
||||
material?.fill(0);
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function audit(identity: DirectoryIdentity): readonly string[] {
|
||||
verifyDirectory(identity);
|
||||
const directory = fs.opendirSync(identity.path);
|
||||
const keys: string[] = [];
|
||||
try {
|
||||
for (let index = 0; index <= MAX_LOCAL_OWNER_PEPPER_KEYS; index += 1) {
|
||||
const entry = directory.readSync();
|
||||
if (!entry) break;
|
||||
if (index === MAX_LOCAL_OWNER_PEPPER_KEYS || !entry.isFile()) {
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
}
|
||||
const id = decodeFileName(entry.name);
|
||||
if (keys.includes(id) || !readKey(identity, id)) {
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
}
|
||||
keys.push(id);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof LocalOwnerPepperUnavailableError) throw error;
|
||||
throw new LocalOwnerPepperUnavailableError(error);
|
||||
} finally {
|
||||
directory.closeSync();
|
||||
}
|
||||
verifyDirectory(identity);
|
||||
return Object.freeze(keys.sort());
|
||||
}
|
||||
|
||||
export function localOwnerPepperKeyPath(
|
||||
keyringDirectory: string,
|
||||
pepperKeyId: string,
|
||||
): string {
|
||||
return path.join(
|
||||
boundedDirectory(keyringDirectory),
|
||||
fileName(keyId(pepperKeyId)),
|
||||
);
|
||||
}
|
||||
|
||||
export class LocalOwnerPepperKeyringFileProvider {
|
||||
private readonly identity: DirectoryIdentity;
|
||||
|
||||
constructor(keyringDirectory: string) {
|
||||
this.identity = directoryIdentity(boundedDirectory(keyringDirectory));
|
||||
audit(this.identity);
|
||||
}
|
||||
|
||||
inspect(): Readonly<LocalOwnerPepperKeyringSummary> {
|
||||
return Object.freeze({ version: 1, keyIds: audit(this.identity) });
|
||||
}
|
||||
|
||||
resolve(pepperKeyId: string): Readonly<LocalOwnerPepperKeyMaterial> | null {
|
||||
return readKey(this.identity, keyId(pepperKeyId));
|
||||
}
|
||||
}
|
||||
|
||||
export function provisionLocalOwnerPepperKey(
|
||||
options: ProvisionLocalOwnerPepperKeyOptions,
|
||||
): Readonly<LocalOwnerPepperSummary> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!exactKeys(options, [
|
||||
'keyringDirectory',
|
||||
'pepperKeyId',
|
||||
...(options.randomBytes === undefined ? [] : ['randomBytes']),
|
||||
]) ||
|
||||
(options.randomBytes !== undefined &&
|
||||
typeof options.randomBytes !== 'function')
|
||||
) {
|
||||
throw new LocalOwnerPepperConfigurationError('options shape is invalid');
|
||||
}
|
||||
const keyringDirectory = boundedDirectory(options.keyringDirectory);
|
||||
const identity = directoryIdentity(keyringDirectory);
|
||||
if (audit(identity).length >= MAX_LOCAL_OWNER_PEPPER_KEYS) {
|
||||
throw new LocalOwnerPepperUnavailableError();
|
||||
}
|
||||
const pepperKeyId = keyId(options.pepperKeyId);
|
||||
return provisionLocalOwnerPepper({
|
||||
deploymentRoot: keyringDirectory,
|
||||
pepperPath: localOwnerPepperKeyPath(keyringDirectory, pepperKeyId),
|
||||
...(options.randomBytes === undefined
|
||||
? {}
|
||||
: { randomBytes: options.randomBytes }),
|
||||
});
|
||||
}
|
||||
|
||||
export function backupLocalOwnerPepperKey(
|
||||
options: BackupLocalOwnerPepperKeyOptions,
|
||||
): Readonly<LocalOwnerPepperSummary> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!exactKeys(options, ['backupDirectory', 'keyringDirectory', 'pepperKeyId'])
|
||||
) {
|
||||
throw new LocalOwnerPepperConfigurationError('options shape is invalid');
|
||||
}
|
||||
const keyringDirectory = boundedDirectory(options.keyringDirectory);
|
||||
const backupDirectory = boundedDirectory(options.backupDirectory);
|
||||
const pepperKeyId = keyId(options.pepperKeyId);
|
||||
return backupLocalOwnerPepper({
|
||||
deploymentRoot: keyringDirectory,
|
||||
pepperPath: localOwnerPepperKeyPath(keyringDirectory, pepperKeyId),
|
||||
backupRoot: backupDirectory,
|
||||
backupPath: localOwnerPepperKeyPath(backupDirectory, pepperKeyId),
|
||||
});
|
||||
}
|
||||
|
||||
export function restoreLocalOwnerPepperKey(
|
||||
options: RestoreLocalOwnerPepperKeyOptions,
|
||||
): Readonly<LocalOwnerPepperSummary> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!exactKeys(options, ['backupDirectory', 'keyringDirectory', 'pepperKeyId'])
|
||||
) {
|
||||
throw new LocalOwnerPepperConfigurationError('options shape is invalid');
|
||||
}
|
||||
const keyringDirectory = boundedDirectory(options.keyringDirectory);
|
||||
const backupDirectory = boundedDirectory(options.backupDirectory);
|
||||
const pepperKeyId = keyId(options.pepperKeyId);
|
||||
return restoreLocalOwnerPepper({
|
||||
deploymentRoot: keyringDirectory,
|
||||
pepperPath: localOwnerPepperKeyPath(keyringDirectory, pepperKeyId),
|
||||
backupRoot: backupDirectory,
|
||||
backupPath: localOwnerPepperKeyPath(backupDirectory, pepperKeyId),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": false
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user