feat(ql3): establish 3.0 incubation baseline

This commit is contained in:
whyour
2026-08-12 00:25:26 +08:00
parent 4bf92dcfeb
commit c699c32461
2817 changed files with 779642 additions and 653 deletions
@@ -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);
}
}
@@ -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"]
}