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