mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): prove first usable automation journey
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
||||
readPrivateLocalCommandFile,
|
||||
} from '@qinglong/local-command-file';
|
||||
import {
|
||||
installLocalOwnerCredentialPresentation,
|
||||
LocalOwnerSecretDeliveryError,
|
||||
openLocalOwnerConsole,
|
||||
type ClaimLocalOwnerFromDeliveriesRequest,
|
||||
@@ -71,6 +72,16 @@ export interface AcknowledgeLocalOwnerDeliveryCommand {
|
||||
};
|
||||
}
|
||||
|
||||
export interface InstallLocalOwnerCredentialPresentationCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'owner.credential-presentation.install-from-delivery';
|
||||
readonly options: OpenLocalOwnerConsoleOptions;
|
||||
readonly request: {
|
||||
readonly credentialMutationId: string;
|
||||
readonly destinationFilePath: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IssueLocalOwnerCredentialRecoveryCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'owner.credential-recovery.issue';
|
||||
@@ -101,6 +112,7 @@ export type LocalOwnerCommand =
|
||||
| ClaimLocalOwnerCommand
|
||||
| InspectLocalOwnerDeliveryCommand
|
||||
| AcknowledgeLocalOwnerDeliveryCommand
|
||||
| InstallLocalOwnerCredentialPresentationCommand
|
||||
| IssueLocalOwnerCredentialRecoveryCommand
|
||||
| CompleteLocalOwnerCredentialRecoveryCommand;
|
||||
|
||||
@@ -153,6 +165,7 @@ function normalizeCommand(value: unknown): Readonly<LocalOwnerCommand> {
|
||||
'owner.claim.from-deliveries',
|
||||
'owner.delivery.inspect',
|
||||
'owner.delivery.acknowledge',
|
||||
'owner.credential-presentation.install-from-delivery',
|
||||
'owner.credential-recovery.issue',
|
||||
'owner.credential-recovery.complete',
|
||||
];
|
||||
@@ -222,6 +235,20 @@ function validateAcknowledgementRequest(
|
||||
}
|
||||
}
|
||||
|
||||
function validatePresentationInstallRequest(
|
||||
value: InstallLocalOwnerCredentialPresentationCommand['request'],
|
||||
): void {
|
||||
if (
|
||||
!exactKeys(value, ['credentialMutationId', 'destinationFilePath']) ||
|
||||
!UUID_V4_PATTERN.test(value.credentialMutationId) ||
|
||||
typeof value.destinationFilePath !== 'string'
|
||||
) {
|
||||
throw new LocalOwnerCliConfigurationError(
|
||||
'credential presentation install request is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function missing(error: unknown): boolean {
|
||||
let current = error;
|
||||
for (let depth = 0; depth < 4; depth += 1) {
|
||||
@@ -355,6 +382,22 @@ async function execute(
|
||||
ttlMs: acknowledgement.ttlMs,
|
||||
});
|
||||
}
|
||||
case 'owner.credential-presentation.install-from-delivery': {
|
||||
validatePresentationInstallRequest(command.request);
|
||||
const result = installLocalOwnerCredentialPresentation({
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
deliveryFilePath: console.credentialDeliveryPath(
|
||||
command.request.credentialMutationId,
|
||||
),
|
||||
destinationFilePath: command.request.destinationFilePath,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
credentialMutationId: result.credentialMutationId,
|
||||
});
|
||||
}
|
||||
case 'owner.credential-recovery.issue': {
|
||||
const result = await console.credentialRecovery.issue(command.request);
|
||||
const delivery = deliveryAfterMutation(result.status, () =>
|
||||
|
||||
@@ -145,6 +145,64 @@ test('completes fresh Owner and credential recovery ceremonies without returning
|
||||
assert.equal(claimed.status, 'inserted');
|
||||
assert.equal(claimed.role, 'owner');
|
||||
assert.equal(JSON.stringify(claimed).includes('secret'), false);
|
||||
const credentialFilePath = path.join(
|
||||
state.options.deploymentRoot,
|
||||
'owner-credential.json',
|
||||
);
|
||||
const installed = await runner.run(
|
||||
commandFile(
|
||||
state,
|
||||
'owner.credential-presentation.install-from-delivery',
|
||||
{ credentialMutationId, destinationFilePath: credentialFilePath },
|
||||
'04b-install-presentation',
|
||||
),
|
||||
);
|
||||
assert.deepEqual(installed, {
|
||||
schemaVersion: 1,
|
||||
operation: 'owner.credential-presentation.install-from-delivery',
|
||||
status: 'installed',
|
||||
credentialMutationId,
|
||||
});
|
||||
const presentation = JSON.parse(fs.readFileSync(credentialFilePath, 'utf8'));
|
||||
assert.equal(
|
||||
presentation.kind,
|
||||
'qinglong3-local-identity-credential-presentation',
|
||||
);
|
||||
assert.match(
|
||||
presentation.token,
|
||||
/^ql3c_own_[A-Za-z0-9_-]{22}_[A-Za-z0-9_-]{43}$/,
|
||||
);
|
||||
assert.equal(fs.statSync(credentialFilePath).mode & 0o777, 0o600);
|
||||
const replayedInstall = await runner.run(
|
||||
commandFile(
|
||||
state,
|
||||
'owner.credential-presentation.install-from-delivery',
|
||||
{ credentialMutationId, destinationFilePath: credentialFilePath },
|
||||
'04c-replay-presentation',
|
||||
),
|
||||
);
|
||||
assert.equal(replayedInstall.status, 'existing');
|
||||
assertNoSecretFields(installed);
|
||||
assertNoSecretFields(replayedInstall);
|
||||
const escapedDestination = path.join(
|
||||
os.tmpdir(),
|
||||
`ql3-owner-credential-escape-${process.pid}.json`,
|
||||
);
|
||||
await assert.rejects(
|
||||
runner.run(
|
||||
commandFile(
|
||||
state,
|
||||
'owner.credential-presentation.install-from-delivery',
|
||||
{
|
||||
credentialMutationId,
|
||||
destinationFilePath: escapedDestination,
|
||||
},
|
||||
'04d-reject-escaped-presentation',
|
||||
),
|
||||
),
|
||||
/destinationFilePath must be a descendant of deploymentRoot/,
|
||||
);
|
||||
assert.equal(fs.existsSync(escapedDestination), false);
|
||||
for (const [purpose, mutationId, digest, suffix] of [
|
||||
[
|
||||
'credential-provisioning',
|
||||
|
||||
@@ -46,6 +46,12 @@ export {
|
||||
type LocalOwnerSecretDeliverySummary,
|
||||
type LocalOwnerSecretRecoverySummary,
|
||||
} from '../delivery/secretDelivery';
|
||||
export {
|
||||
LocalOwnerCredentialPresentationInstallError,
|
||||
installLocalOwnerCredentialPresentation,
|
||||
type InstallLocalOwnerCredentialPresentationOptions,
|
||||
type LocalOwnerCredentialPresentationInstallSummary,
|
||||
} from '../delivery/credentialPresentationInstaller';
|
||||
export {
|
||||
LocalOwnerPepperConfigurationError,
|
||||
LocalOwnerPepperConflictError,
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { normalizeLocalOwnerBootstrapSecretDeliveryRecord } from './secret-delivery/ceremonyContracts';
|
||||
import { formatApiCredentialToken } from '@qinglong/runtime-core/api-credential-token';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const MAX_DELIVERY_BYTES = 4 * 1024;
|
||||
const MAX_PRESENTATION_BYTES = 1024;
|
||||
|
||||
export interface InstallLocalOwnerCredentialPresentationOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly deliveryFilePath: string;
|
||||
readonly destinationFilePath: string;
|
||||
}
|
||||
|
||||
export interface LocalOwnerCredentialPresentationInstallSummary {
|
||||
readonly status: 'installed' | 'existing';
|
||||
readonly credentialMutationId: string;
|
||||
}
|
||||
|
||||
export class LocalOwnerCredentialPresentationInstallError extends Error {
|
||||
readonly code = 'LOCAL_OWNER_CREDENTIAL_PRESENTATION_INSTALL_FAILED';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Local Owner credential presentation install failed: ${message}`);
|
||||
this.name = 'LocalOwnerCredentialPresentationInstallError';
|
||||
}
|
||||
}
|
||||
|
||||
interface PrivateFile {
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
readonly size: bigint;
|
||||
}
|
||||
|
||||
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 LocalOwnerCredentialPresentationInstallError(
|
||||
`${label} must be a bounded normalized absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function uid(): number {
|
||||
if (
|
||||
typeof process.getuid !== 'function' ||
|
||||
typeof process.geteuid !== 'function' ||
|
||||
process.getuid() !== process.geteuid()
|
||||
) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'a stable POSIX user is required',
|
||||
);
|
||||
}
|
||||
return process.getuid();
|
||||
}
|
||||
|
||||
function descendant(root: string, candidate: string, label: string): void {
|
||||
const relative = path.relative(root, candidate);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
`${label} must be a descendant of deploymentRoot`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function privateDirectory(
|
||||
directoryPath: string,
|
||||
ownerUid: number,
|
||||
): PrivateFile {
|
||||
const stat = fs.lstatSync(directoryPath, { bigint: true });
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== ownerUid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o700
|
||||
) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'authority directories must be private owned real directories',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ device: stat.dev, inode: stat.ino, size: stat.size });
|
||||
}
|
||||
|
||||
function privateFile(
|
||||
filePath: string,
|
||||
ownerUid: number,
|
||||
maximumBytes: number,
|
||||
): PrivateFile {
|
||||
const stat = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== ownerUid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o600 ||
|
||||
stat.size < 1n ||
|
||||
stat.size > BigInt(maximumBytes)
|
||||
) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'authority files must be bounded private owned regular files',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ device: stat.dev, inode: stat.ino, size: stat.size });
|
||||
}
|
||||
|
||||
function sameDirectory(
|
||||
directoryPath: string,
|
||||
ownerUid: number,
|
||||
expected: PrivateFile,
|
||||
): void {
|
||||
const current = privateDirectory(directoryPath, ownerUid);
|
||||
if (current.device !== expected.device || current.inode !== expected.inode) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'destination directory identity changed during installation',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readPrivateJson(
|
||||
filePath: string,
|
||||
ownerUid: number,
|
||||
maximumBytes: number,
|
||||
): unknown {
|
||||
const expected = privateFile(filePath, ownerUid, maximumBytes);
|
||||
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 !== expected.device ||
|
||||
opened.ino !== expected.inode ||
|
||||
opened.size !== expected.size
|
||||
) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'authority file identity changed while opening',
|
||||
);
|
||||
}
|
||||
material = fs.readFileSync(descriptor);
|
||||
return JSON.parse(material.toString('utf8')) as unknown;
|
||||
} catch (error) {
|
||||
if (error instanceof LocalOwnerCredentialPresentationInstallError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'authority file is invalid',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
material?.fill(0);
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function presentation(value: unknown): Readonly<{
|
||||
schemaVersion: 1;
|
||||
kind: 'qinglong3-local-identity-credential-presentation';
|
||||
token: string;
|
||||
}> {
|
||||
const record = normalizeLocalOwnerBootstrapSecretDeliveryRecord(value);
|
||||
if (record.kind !== 'credential') {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'delivery is not a credential record',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-identity-credential-presentation',
|
||||
token: formatApiCredentialToken(record.credentialId, record.secret),
|
||||
});
|
||||
}
|
||||
|
||||
function existingPresentation(
|
||||
filePath: string,
|
||||
ownerUid: number,
|
||||
expected: ReturnType<typeof presentation>,
|
||||
): boolean {
|
||||
const current = readPrivateJson(filePath, ownerUid, MAX_PRESENTATION_BYTES);
|
||||
return JSON.stringify(current) === JSON.stringify(expected);
|
||||
}
|
||||
|
||||
function syncDirectory(directoryPath: string): void {
|
||||
const descriptor = fs.openSync(directoryPath, 'r');
|
||||
try {
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function installLocalOwnerCredentialPresentation(
|
||||
options: InstallLocalOwnerCredentialPresentationOptions,
|
||||
): Readonly<LocalOwnerCredentialPresentationInstallSummary> {
|
||||
try {
|
||||
const deploymentRoot = boundedPath(
|
||||
options?.deploymentRoot,
|
||||
'deploymentRoot',
|
||||
);
|
||||
const deliveryFilePath = boundedPath(
|
||||
options?.deliveryFilePath,
|
||||
'deliveryFilePath',
|
||||
);
|
||||
const destinationFilePath = boundedPath(
|
||||
options?.destinationFilePath,
|
||||
'destinationFilePath',
|
||||
);
|
||||
descendant(deploymentRoot, deliveryFilePath, 'deliveryFilePath');
|
||||
descendant(deploymentRoot, destinationFilePath, 'destinationFilePath');
|
||||
if (deliveryFilePath === destinationFilePath) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'delivery and destination files must be distinct',
|
||||
);
|
||||
}
|
||||
const ownerUid = uid();
|
||||
const rootIdentity = privateDirectory(deploymentRoot, ownerUid);
|
||||
const destinationDirectory = path.dirname(destinationFilePath);
|
||||
const destinationDirectoryIdentity = privateDirectory(
|
||||
destinationDirectory,
|
||||
ownerUid,
|
||||
);
|
||||
const canonicalRoot = fs.realpathSync(deploymentRoot);
|
||||
const canonicalDelivery = fs.realpathSync(deliveryFilePath);
|
||||
const canonicalDestination = path.join(
|
||||
fs.realpathSync(destinationDirectory),
|
||||
path.basename(destinationFilePath),
|
||||
);
|
||||
descendant(canonicalRoot, canonicalDelivery, 'deliveryFilePath');
|
||||
descendant(canonicalRoot, canonicalDestination, 'destinationFilePath');
|
||||
const delivery = normalizeLocalOwnerBootstrapSecretDeliveryRecord(
|
||||
readPrivateJson(deliveryFilePath, ownerUid, MAX_DELIVERY_BYTES),
|
||||
);
|
||||
if (delivery.kind !== 'credential') {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'delivery is not a credential record',
|
||||
);
|
||||
}
|
||||
const expected = presentation(delivery);
|
||||
if (fs.existsSync(destinationFilePath)) {
|
||||
if (!existingPresentation(destinationFilePath, ownerUid, expected)) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'destination conflicts with the delivered credential',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
credentialMutationId: delivery.mutationId,
|
||||
});
|
||||
}
|
||||
const temporaryPath = path.join(
|
||||
destinationDirectory,
|
||||
`.owner-credential-presentation-${randomUUID()}.tmp`,
|
||||
);
|
||||
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(expected)}\n`;
|
||||
if (Buffer.byteLength(serialized, 'utf8') > MAX_PRESENTATION_BYTES) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'credential presentation exceeds its byte budget',
|
||||
);
|
||||
}
|
||||
fs.writeFileSync(descriptor, serialized, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
sameDirectory(
|
||||
destinationDirectory,
|
||||
ownerUid,
|
||||
destinationDirectoryIdentity,
|
||||
);
|
||||
fs.linkSync(temporaryPath, destinationFilePath);
|
||||
syncDirectory(destinationDirectory);
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'EEXIST' &&
|
||||
existingPresentation(destinationFilePath, ownerUid, expected)
|
||||
) {
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
credentialMutationId: delivery.mutationId,
|
||||
});
|
||||
}
|
||||
if (error instanceof LocalOwnerCredentialPresentationInstallError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'credential presentation could not be published',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
try {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
syncDirectory(destinationDirectory);
|
||||
} catch (error) {
|
||||
if (
|
||||
!error ||
|
||||
typeof error !== 'object' ||
|
||||
!('code' in error) ||
|
||||
error.code !== 'ENOENT'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
sameDirectory(deploymentRoot, ownerUid, rootIdentity);
|
||||
sameDirectory(destinationDirectory, ownerUid, destinationDirectoryIdentity);
|
||||
if (!existingPresentation(destinationFilePath, ownerUid, expected)) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'published credential presentation is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'installed',
|
||||
credentialMutationId: delivery.mutationId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LocalOwnerCredentialPresentationInstallError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'credential presentation installation failed closed',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user