feat(ql3): establish 3.0 incubation baseline

This commit is contained in:
whyour
2026-08-12 00:25:26 +08:00
parent 4bf92dcfeb
commit c699c32461
2817 changed files with 779642 additions and 653 deletions
@@ -0,0 +1,797 @@
import { createHash, timingSafeEqual } from 'node:crypto';
import {
normalizeApiCredentialRecord,
type ApiCredentialRecord,
} from '../security/identity-credential/apiCredential';
import {
normalizeIdentitySubjectRecord,
type IdentitySubjectRecord,
} from '../security/identity-credential/identityAdministration';
import {
assertProjectPolicyProjectId,
normalizeProjectRoleBinding,
type ProjectRoleBindingRecord,
} from '../security/project-policy/projectPolicy';
import { normalizeSecurityPrincipal, type SecurityPrincipal } from '../security/security';
import {
normalizeSecurityAuditRecord,
type SecurityAuditRecord,
} from '../security/audit/securityAudit';
export const LOCAL_OWNER_BOOTSTRAP_TOKEN_BYTES = 32;
export const LOCAL_OWNER_BOOTSTRAP_CHALLENGE_ID_BYTES = 16;
export const LOCAL_OWNER_BOOTSTRAP_DEFAULT_TTL_MS = 10 * 60 * 1000;
export const LOCAL_OWNER_BOOTSTRAP_MIN_TTL_MS = 60 * 1000;
export const LOCAL_OWNER_BOOTSTRAP_MAX_TTL_MS = 30 * 60 * 1000;
export const LOCAL_OWNER_BOOTSTRAP_SYSTEM_SUBJECT = Object.freeze({
type: 'system' as const,
id: 'owner-bootstrap',
});
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 CHALLENGE_ID_PATTERN = /^[A-Za-z0-9_-]{22}$/;
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const DIGEST_DOMAIN = 'qinglong3-local-owner-bootstrap-v1\0';
const MAX_VERSION = 2_147_483_647;
export interface LocalIdentityProvisioningRecord {
readonly mutationId: string;
readonly requestId: string;
readonly identity: IdentitySubjectRecord;
readonly credential: ApiCredentialRecord;
readonly issuer: SecurityPrincipal;
readonly audit: SecurityAuditRecord;
readonly createdAtMs: number;
}
export interface ProvisionLocalIdentityCommand
extends LocalIdentityProvisioningRecord {}
export interface ProvisionLocalIdentityResult {
readonly status: 'inserted' | 'existing';
readonly provisioning: Readonly<LocalIdentityProvisioningRecord>;
}
export interface LocalOwnerBootstrapChallengeRecord {
readonly projectId: string;
readonly version: number;
readonly issueMutationId: string;
readonly issueRequestId: string;
readonly challengeId: string;
readonly tokenDigest: string;
readonly issuer: SecurityPrincipal;
readonly issuedAtMs: number;
readonly expiresAtMs: number;
readonly issueAudit: SecurityAuditRecord;
readonly consumedAtMs?: number;
readonly claimMutationId?: string;
readonly claimRequestId?: string;
readonly claimedPrincipal?: SecurityPrincipal;
readonly credentialId?: string;
readonly credentialVersion?: number;
readonly binding?: ProjectRoleBindingRecord;
readonly claimAudit?: SecurityAuditRecord;
}
export interface IssueLocalOwnerBootstrapChallengeCommand {
readonly projectId: string;
readonly mutationId: string;
readonly requestId: string;
readonly challengeId: string;
readonly tokenDigest: string;
readonly issuer: SecurityPrincipal;
readonly issuedAtMs: number;
readonly expiresAtMs: number;
readonly audit: SecurityAuditRecord;
}
export interface IssueLocalOwnerBootstrapChallengeResult {
readonly status: 'inserted' | 'existing';
readonly challenge: Readonly<LocalOwnerBootstrapChallengeRecord>;
}
export interface ClaimLocalOwnerCommand {
readonly projectId: string;
readonly mutationId: string;
readonly requestId: string;
readonly challengeId: string;
readonly tokenDigest: string;
readonly principal: SecurityPrincipal;
readonly credentialId: string;
readonly credentialVersion: number;
readonly claimedAtMs: number;
readonly audit: SecurityAuditRecord;
}
export interface ClaimLocalOwnerResult {
readonly status: 'inserted' | 'existing';
readonly challenge: Readonly<LocalOwnerBootstrapChallengeRecord>;
readonly binding: Readonly<ProjectRoleBindingRecord>;
}
export interface LocalOwnerCredentialDeliveryAcknowledgementRecord {
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 LocalOwnerChallengeDeliveryAcknowledgementRecord {
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 LocalOwnerSecretDeliveryAcknowledgementRecord =
| LocalOwnerCredentialDeliveryAcknowledgementRecord
| LocalOwnerChallengeDeliveryAcknowledgementRecord;
export interface RecordLocalOwnerSecretDeliveryAcknowledgementResult {
readonly status: 'inserted' | 'existing';
readonly acknowledgement: Readonly<LocalOwnerSecretDeliveryAcknowledgementRecord>;
}
export interface LocalOwnerBootstrapRepository {
resolveProjectVersion(projectId: string): Promise<number | null>;
resolveProvisioning(
mutationId: string,
): Promise<Readonly<LocalIdentityProvisioningRecord> | null>;
resolveIssuedChallenge(
mutationId: string,
): Promise<Readonly<LocalOwnerBootstrapChallengeRecord> | null>;
resolveDeliveryAcknowledgement(
mutationId: string,
): Promise<Readonly<LocalOwnerSecretDeliveryAcknowledgementRecord> | null>;
recordDeliveryAcknowledgement(
acknowledgement: LocalOwnerSecretDeliveryAcknowledgementRecord,
): Promise<RecordLocalOwnerSecretDeliveryAcknowledgementResult>;
provision(
command: ProvisionLocalIdentityCommand,
): Promise<ProvisionLocalIdentityResult>;
issue(
command: IssueLocalOwnerBootstrapChallengeCommand,
): Promise<IssueLocalOwnerBootstrapChallengeResult>;
claim(command: ClaimLocalOwnerCommand): Promise<ClaimLocalOwnerResult>;
recordAudit(audit: SecurityAuditRecord): Promise<void>;
}
export class InvalidLocalOwnerBootstrapValueError extends TypeError {
constructor(message: string) {
super(`Local owner bootstrap value is invalid: ${message}`);
this.name = 'InvalidLocalOwnerBootstrapValueError';
}
}
export class LocalOwnerBootstrapNotPristineError extends Error {
readonly code = 'LOCAL_OWNER_BOOTSTRAP_NOT_PRISTINE';
constructor() {
super('Local owner bootstrap is no longer available');
this.name = 'LocalOwnerBootstrapNotPristineError';
}
}
export class LocalOwnerBootstrapChallengeActiveError extends Error {
readonly code = 'LOCAL_OWNER_BOOTSTRAP_CHALLENGE_ACTIVE';
constructor() {
super('A local owner bootstrap challenge is already active');
this.name = 'LocalOwnerBootstrapChallengeActiveError';
}
}
export class LocalOwnerBootstrapIdentityRequiredError extends Error {
readonly code = 'LOCAL_OWNER_BOOTSTRAP_IDENTITY_REQUIRED';
constructor() {
super('A provisioned local identity is required');
this.name = 'LocalOwnerBootstrapIdentityRequiredError';
}
}
export class LocalOwnerBootstrapClaimRejectedError extends Error {
readonly code = 'LOCAL_OWNER_BOOTSTRAP_CLAIM_REJECTED';
constructor() {
super('Local owner bootstrap claim was rejected');
this.name = 'LocalOwnerBootstrapClaimRejectedError';
}
}
export class LocalOwnerBootstrapMutationConflictError extends Error {
readonly code = 'LOCAL_OWNER_BOOTSTRAP_MUTATION_CONFLICT';
constructor() {
super('Local owner bootstrap mutation conflicts with previous use');
this.name = 'LocalOwnerBootstrapMutationConflictError';
}
}
export class LocalOwnerBootstrapUnavailableError extends Error {
readonly code = 'LOCAL_OWNER_BOOTSTRAP_UNAVAILABLE';
constructor() {
super('Local owner bootstrap is unavailable');
this.name = 'LocalOwnerBootstrapUnavailableError';
}
}
function exactKeys(
value: object,
expected: readonly string[],
name: string,
): void {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
actual.length !== canonical.length ||
actual.some((key, index) => key !== canonical[index])
) {
throw new InvalidLocalOwnerBootstrapValueError(`${name} shape is invalid`);
}
}
function timestamp(value: number, name: string): number {
if (!Number.isSafeInteger(value) || value < 0) {
throw new InvalidLocalOwnerBootstrapValueError(`${name} is invalid`);
}
return value;
}
export function assertLocalOwnerBootstrapMutationId(value: string): void {
if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) {
throw new InvalidLocalOwnerBootstrapValueError('mutationId is invalid');
}
}
export function assertLocalOwnerBootstrapRequestId(value: string): void {
if (typeof value !== 'string' || !REQUEST_ID_PATTERN.test(value)) {
throw new InvalidLocalOwnerBootstrapValueError('requestId is invalid');
}
}
export function assertLocalOwnerBootstrapChallengeId(value: string): void {
if (typeof value !== 'string' || !CHALLENGE_ID_PATTERN.test(value)) {
throw new InvalidLocalOwnerBootstrapValueError('challengeId is invalid');
}
}
export function assertLocalOwnerBootstrapToken(value: string): void {
if (typeof value !== 'string' || !TOKEN_PATTERN.test(value)) {
throw new InvalidLocalOwnerBootstrapValueError('token is invalid');
}
}
export function assertLocalOwnerBootstrapTokenDigest(value: string): void {
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
throw new InvalidLocalOwnerBootstrapValueError('tokenDigest is invalid');
}
}
export function assertLocalOwnerBootstrapTtl(value: number): void {
if (
!Number.isSafeInteger(value) ||
value < LOCAL_OWNER_BOOTSTRAP_MIN_TTL_MS ||
value > LOCAL_OWNER_BOOTSTRAP_MAX_TTL_MS
) {
throw new InvalidLocalOwnerBootstrapValueError('ttlMs is invalid');
}
}
export function normalizeLocalOwnerSecretDeliveryAcknowledgementRecord(
value: LocalOwnerSecretDeliveryAcknowledgementRecord,
): Readonly<LocalOwnerSecretDeliveryAcknowledgementRecord> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidLocalOwnerBootstrapValueError(
'delivery acknowledgement must be an object',
);
}
assertLocalOwnerBootstrapMutationId(value.mutationId);
assertLocalOwnerBootstrapRequestId(value.requestId);
assertLocalOwnerBootstrapTokenDigest(value.factDigest);
assertLocalOwnerBootstrapTokenDigest(value.deliveryDigest);
if (
!Number.isSafeInteger(value.ttlMs) ||
value.ttlMs < 1 ||
!Number.isSafeInteger(value.acknowledgedAtMs) ||
value.acknowledgedAtMs < 0
) {
throw new InvalidLocalOwnerBootstrapValueError(
'delivery acknowledgement lifetime is invalid',
);
}
if (value.kind === 'credential') {
exactKeys(
value,
[
'kind',
'mutationId',
'requestId',
'subjectId',
'credentialId',
'factDigest',
'ttlMs',
'deliveryDigest',
'acknowledgedAtMs',
],
'credential delivery acknowledgement',
);
if (
!/^usr_[A-Za-z0-9_-]{22}$/.test(value.subjectId) ||
!/^own_[A-Za-z0-9_-]{22}$/.test(value.credentialId)
) {
throw new InvalidLocalOwnerBootstrapValueError(
'credential delivery acknowledgement identity is invalid',
);
}
return Object.freeze({ ...value });
}
if (value.kind !== 'challenge') {
throw new InvalidLocalOwnerBootstrapValueError(
'delivery acknowledgement kind is invalid',
);
}
exactKeys(
value,
[
'kind',
'projectId',
'mutationId',
'requestId',
'challengeId',
'factDigest',
'ttlMs',
'deliveryDigest',
'acknowledgedAtMs',
],
'challenge delivery acknowledgement',
);
assertProjectPolicyProjectId(value.projectId);
assertLocalOwnerBootstrapChallengeId(value.challengeId);
return Object.freeze({ ...value });
}
export function localOwnerSecretDeliveryAcknowledgementSemanticDigest(
value: LocalOwnerSecretDeliveryAcknowledgementRecord,
): string {
const record = normalizeLocalOwnerSecretDeliveryAcknowledgementRecord(value);
const hash = createHash('sha256')
.update('qinglong.local-owner-delivery-acknowledgement.v1\0', 'utf8')
.update(record.kind, 'utf8')
.update('\0', 'utf8')
.update(record.mutationId, 'utf8')
.update('\0', 'utf8')
.update(record.requestId, 'utf8')
.update('\0', 'utf8');
if (record.kind === 'credential') {
hash
.update(record.subjectId, 'utf8')
.update('\0', 'utf8')
.update(record.credentialId, 'utf8');
} else {
hash
.update(record.projectId, 'utf8')
.update('\0', 'utf8')
.update(record.challengeId, 'utf8');
}
return hash
.update('\0', 'utf8')
.update(record.factDigest, 'utf8')
.update('\0', 'utf8')
.update(record.deliveryDigest, 'utf8')
.update('\0', 'utf8')
.update(String(record.ttlMs), 'utf8')
.update('\0', 'utf8')
.update(String(record.acknowledgedAtMs), 'utf8')
.digest('hex');
}
export function localOwnerBootstrapTokenDigest(
projectId: string,
challengeId: string,
token: string,
): string {
if (typeof projectId !== 'string' || !REQUEST_ID_PATTERN.test(projectId)) {
throw new InvalidLocalOwnerBootstrapValueError('projectId is invalid');
}
assertLocalOwnerBootstrapChallengeId(challengeId);
assertLocalOwnerBootstrapToken(token);
return createHash('sha256')
.update(DIGEST_DOMAIN, 'utf8')
.update(projectId, 'utf8')
.update('\0', 'utf8')
.update(challengeId, 'utf8')
.update('\0', 'utf8')
.update(token, 'utf8')
.digest('hex');
}
export function localOwnerBootstrapDigestMatches(
expected: string,
actual: string,
): boolean {
assertLocalOwnerBootstrapTokenDigest(expected);
assertLocalOwnerBootstrapTokenDigest(actual);
const left = Buffer.from(expected, 'hex');
const right = Buffer.from(actual, 'hex');
try {
return timingSafeEqual(left, right);
} finally {
left.fill(0);
right.fill(0);
}
}
function localConsolePrincipal(
value: SecurityPrincipal,
nowMs: number,
): Readonly<SecurityPrincipal> {
let principal: Readonly<SecurityPrincipal>;
try {
principal = normalizeSecurityPrincipal(value, nowMs);
} catch {
throw new InvalidLocalOwnerBootstrapValueError('issuer is invalid');
}
if (
principal.subject.type !== LOCAL_OWNER_BOOTSTRAP_SYSTEM_SUBJECT.type ||
principal.subject.id !== LOCAL_OWNER_BOOTSTRAP_SYSTEM_SUBJECT.id ||
principal.assurance !== 'local_console'
) {
throw new InvalidLocalOwnerBootstrapValueError('issuer is invalid');
}
return principal;
}
function userPrincipal(
value: SecurityPrincipal,
nowMs: number,
): Readonly<SecurityPrincipal> {
let principal: Readonly<SecurityPrincipal>;
try {
principal = normalizeSecurityPrincipal(value, nowMs);
} catch {
throw new InvalidLocalOwnerBootstrapValueError('principal is invalid');
}
if (principal.subject.type !== 'user') {
throw new InvalidLocalOwnerBootstrapValueError('principal is invalid');
}
return principal;
}
export function normalizeLocalIdentityProvisioningRecord(
value: LocalIdentityProvisioningRecord,
): Readonly<LocalIdentityProvisioningRecord> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidLocalOwnerBootstrapValueError(
'provisioning must be an object',
);
}
exactKeys(
value,
[
'mutationId',
'requestId',
'identity',
'credential',
'issuer',
'audit',
'createdAtMs',
],
'provisioning',
);
assertLocalOwnerBootstrapMutationId(value.mutationId);
assertLocalOwnerBootstrapRequestId(value.requestId);
const createdAtMs = timestamp(value.createdAtMs, 'createdAtMs');
const identity = normalizeIdentitySubjectRecord(value.identity);
const credential = normalizeApiCredentialRecord(value.credential);
const issuer = localConsolePrincipal(value.issuer, createdAtMs);
const audit = normalizeSecurityAuditRecord(value.audit);
if (
identity.subject.type !== 'user' ||
identity.status !== 'active' ||
identity.version !== 1 ||
identity.createdAtMs !== createdAtMs ||
identity.updatedAtMs !== createdAtMs ||
credential.subject.type !== identity.subject.type ||
credential.subject.id !== identity.subject.id ||
credential.subjectStatus !== 'active' ||
credential.version !== 1 ||
credential.state !== 'active' ||
credential.createdAtMs !== createdAtMs ||
credential.notBeforeAtMs !== createdAtMs ||
audit.eventId !== value.mutationId ||
audit.requestId !== value.requestId ||
audit.operationId !== 'identity.bootstrap_provision' ||
audit.projectId !== null ||
audit.subject?.type !== issuer.subject.type ||
audit.subject.id !== issuer.subject.id ||
audit.authenticationId !== issuer.authenticationId ||
audit.outcome !== 'allowed' ||
audit.reasons.length !== 1 ||
audit.reasons[0] !== 'local_console_provisioning' ||
audit.fence !== null ||
audit.occurredAtMs !== createdAtMs
) {
throw new InvalidLocalOwnerBootstrapValueError(
'provisioning semantics are invalid',
);
}
return Object.freeze({
mutationId: value.mutationId,
requestId: value.requestId,
identity,
credential,
issuer,
audit,
createdAtMs,
});
}
export function normalizeIssueLocalOwnerBootstrapChallengeCommand(
value: IssueLocalOwnerBootstrapChallengeCommand,
): Readonly<IssueLocalOwnerBootstrapChallengeCommand> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidLocalOwnerBootstrapValueError('issue must be an object');
}
exactKeys(
value,
[
'projectId',
'mutationId',
'requestId',
'challengeId',
'tokenDigest',
'issuer',
'issuedAtMs',
'expiresAtMs',
'audit',
],
'issue',
);
if (
typeof value.projectId !== 'string' ||
!REQUEST_ID_PATTERN.test(value.projectId)
) {
throw new InvalidLocalOwnerBootstrapValueError('projectId is invalid');
}
assertLocalOwnerBootstrapMutationId(value.mutationId);
assertLocalOwnerBootstrapRequestId(value.requestId);
assertLocalOwnerBootstrapChallengeId(value.challengeId);
assertLocalOwnerBootstrapTokenDigest(value.tokenDigest);
const issuedAtMs = timestamp(value.issuedAtMs, 'issuedAtMs');
const expiresAtMs = timestamp(value.expiresAtMs, 'expiresAtMs');
assertLocalOwnerBootstrapTtl(expiresAtMs - issuedAtMs);
const issuer = localConsolePrincipal(value.issuer, issuedAtMs);
const audit = normalizeSecurityAuditRecord(value.audit);
if (
audit.eventId !== value.mutationId ||
audit.requestId !== value.requestId ||
audit.operationId !== 'project.owner_bootstrap_issue' ||
audit.projectId !== value.projectId ||
audit.subject?.type !== issuer.subject.type ||
audit.subject.id !== issuer.subject.id ||
audit.authenticationId !== issuer.authenticationId ||
audit.outcome !== 'allowed' ||
audit.reasons.length !== 1 ||
audit.reasons[0] !== 'local_console_challenge' ||
audit.fence !== null ||
audit.occurredAtMs !== issuedAtMs
) {
throw new InvalidLocalOwnerBootstrapValueError('issue audit is invalid');
}
return Object.freeze({ ...value, issuer, audit, issuedAtMs, expiresAtMs });
}
export function normalizeClaimLocalOwnerCommand(
value: ClaimLocalOwnerCommand,
): Readonly<ClaimLocalOwnerCommand> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidLocalOwnerBootstrapValueError('claim must be an object');
}
exactKeys(
value,
[
'projectId',
'mutationId',
'requestId',
'challengeId',
'tokenDigest',
'principal',
'credentialId',
'credentialVersion',
'claimedAtMs',
'audit',
],
'claim',
);
if (
typeof value.projectId !== 'string' ||
!REQUEST_ID_PATTERN.test(value.projectId)
) {
throw new InvalidLocalOwnerBootstrapValueError('projectId is invalid');
}
assertLocalOwnerBootstrapMutationId(value.mutationId);
assertLocalOwnerBootstrapRequestId(value.requestId);
assertLocalOwnerBootstrapChallengeId(value.challengeId);
assertLocalOwnerBootstrapTokenDigest(value.tokenDigest);
const claimedAtMs = timestamp(value.claimedAtMs, 'claimedAtMs');
const principal = userPrincipal(value.principal, claimedAtMs);
if (principal.assurance !== 'single_factor') {
throw new InvalidLocalOwnerBootstrapValueError(
'claim principal assurance is invalid',
);
}
if (
typeof value.credentialId !== 'string' ||
value.credentialId.length < 1 ||
value.credentialId.length > 64 ||
!Number.isSafeInteger(value.credentialVersion) ||
value.credentialVersion < 1 ||
value.credentialVersion > MAX_VERSION
) {
throw new InvalidLocalOwnerBootstrapValueError(
'credential fence is invalid',
);
}
const audit = normalizeSecurityAuditRecord(value.audit);
if (
audit.eventId !== value.mutationId ||
audit.requestId !== value.requestId ||
audit.operationId !== 'project.owner_bootstrap_claim' ||
audit.projectId !== value.projectId ||
audit.subject?.type !== principal.subject.type ||
audit.subject.id !== principal.subject.id ||
audit.authenticationId !== principal.authenticationId ||
audit.outcome !== 'allowed' ||
audit.reasons.length !== 1 ||
audit.reasons[0] !== 'owner_bootstrap_claim' ||
audit.fence?.bindingVersion !== 1 ||
audit.occurredAtMs !== claimedAtMs
) {
throw new InvalidLocalOwnerBootstrapValueError('claim audit is invalid');
}
return Object.freeze({ ...value, principal, audit, claimedAtMs });
}
export function normalizeLocalOwnerBootstrapChallengeRecord(
value: LocalOwnerBootstrapChallengeRecord,
): Readonly<LocalOwnerBootstrapChallengeRecord> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidLocalOwnerBootstrapValueError(
'challenge must be an object',
);
}
const consumed = value.consumedAtMs !== undefined;
exactKeys(
value,
consumed
? [
'projectId',
'version',
'issueMutationId',
'issueRequestId',
'challengeId',
'tokenDigest',
'issuer',
'issuedAtMs',
'expiresAtMs',
'issueAudit',
'consumedAtMs',
'claimMutationId',
'claimRequestId',
'claimedPrincipal',
'credentialId',
'credentialVersion',
'binding',
'claimAudit',
]
: [
'projectId',
'version',
'issueMutationId',
'issueRequestId',
'challengeId',
'tokenDigest',
'issuer',
'issuedAtMs',
'expiresAtMs',
'issueAudit',
],
'challenge',
);
const issue = normalizeIssueLocalOwnerBootstrapChallengeCommand({
projectId: value.projectId,
mutationId: value.issueMutationId,
requestId: value.issueRequestId,
challengeId: value.challengeId,
tokenDigest: value.tokenDigest,
issuer: value.issuer,
issuedAtMs: value.issuedAtMs,
expiresAtMs: value.expiresAtMs,
audit: value.issueAudit,
});
if (
!Number.isSafeInteger(value.version) ||
value.version < 1 ||
value.version > MAX_VERSION
) {
throw new InvalidLocalOwnerBootstrapValueError('version is invalid');
}
if (!consumed) {
return Object.freeze({
projectId: issue.projectId,
version: value.version,
issueMutationId: issue.mutationId,
issueRequestId: issue.requestId,
challengeId: issue.challengeId,
tokenDigest: issue.tokenDigest,
issuer: issue.issuer,
issuedAtMs: issue.issuedAtMs,
expiresAtMs: issue.expiresAtMs,
issueAudit: issue.audit,
});
}
const claim = normalizeClaimLocalOwnerCommand({
projectId: value.projectId,
mutationId: value.claimMutationId!,
requestId: value.claimRequestId!,
challengeId: value.challengeId,
tokenDigest: value.tokenDigest,
principal: value.claimedPrincipal!,
credentialId: value.credentialId!,
credentialVersion: value.credentialVersion!,
claimedAtMs: value.consumedAtMs!,
audit: value.claimAudit!,
});
const binding = normalizeProjectRoleBinding(value.binding!);
if (
binding.projectId !== value.projectId ||
binding.subject.type !== claim.principal.subject.type ||
binding.subject.id !== claim.principal.subject.id ||
binding.version !== 1 ||
binding.state !== 'active' ||
binding.role !== 'owner' ||
binding.mutationId !== claim.mutationId ||
binding.changedBy.type !== LOCAL_OWNER_BOOTSTRAP_SYSTEM_SUBJECT.type ||
binding.changedBy.id !== LOCAL_OWNER_BOOTSTRAP_SYSTEM_SUBJECT.id ||
binding.createdAtMs !== claim.claimedAtMs
) {
throw new InvalidLocalOwnerBootstrapValueError(
'claimed binding is invalid',
);
}
return Object.freeze({
projectId: issue.projectId,
version: value.version,
issueMutationId: issue.mutationId,
issueRequestId: issue.requestId,
challengeId: issue.challengeId,
tokenDigest: issue.tokenDigest,
issuer: issue.issuer,
issuedAtMs: issue.issuedAtMs,
expiresAtMs: issue.expiresAtMs,
issueAudit: issue.audit,
consumedAtMs: claim.claimedAtMs,
claimMutationId: claim.mutationId,
claimRequestId: claim.requestId,
claimedPrincipal: claim.principal,
credentialId: claim.credentialId,
credentialVersion: claim.credentialVersion,
binding,
claimAudit: claim.audit,
});
}
@@ -0,0 +1,441 @@
import {
REVOKED_API_CREDENTIAL_DIGEST,
type ApiCredentialMutationRecord,
} from '../security/identity-credential/apiCredentialAdministration';
import {
assertApiCredentialId,
normalizeApiCredentialRecord,
type ApiCredentialRecord,
} from '../security/identity-credential/apiCredential';
import {
normalizeSecurityAuditRecord,
type SecurityAuditRecord,
} from '../security/audit/securityAudit';
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 DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const MAX_VERSION = 2_147_483_647;
export type LocalOwnerCredentialRecoveryState =
| 'issued'
| 'acknowledged'
| 'completed';
export interface LocalOwnerCredentialRecoveryRecord {
readonly issueMutationId: string;
readonly issueRequestId: string;
readonly subjectId: string;
readonly previousCredentialId: string;
readonly previousCredentialVersion: number;
readonly replacementCredential: Readonly<ApiCredentialRecord>;
readonly state: LocalOwnerCredentialRecoveryState;
readonly issuedAtMs: number;
readonly deliveryDigest?: string;
readonly acknowledgedAtMs?: number;
readonly completeMutationId?: string;
readonly completeRequestId?: string;
readonly revokedCredentialVersion?: number;
readonly completedAtMs?: number;
}
export interface IssueLocalOwnerCredentialRecoveryCommand {
readonly mutationId: string;
readonly requestId: string;
readonly previousCredentialId: string;
readonly expectedPreviousVersion: number;
readonly replacementCredential: ApiCredentialRecord;
readonly mutation: ApiCredentialMutationRecord;
readonly audit: SecurityAuditRecord;
}
export interface AcknowledgeLocalOwnerCredentialRecoveryCommand {
readonly issueMutationId: string;
readonly requestId: string;
readonly credentialId: string;
readonly factDigest: string;
readonly deliveryDigest: string;
readonly acknowledgedAtMs: number;
}
export interface CompleteLocalOwnerCredentialRecoveryCommand {
readonly issueMutationId: string;
readonly mutationId: string;
readonly requestId: string;
readonly expectedPreviousVersion: number;
readonly revokedCredential: ApiCredentialRecord;
readonly mutation: ApiCredentialMutationRecord;
readonly audit: SecurityAuditRecord;
}
export interface LocalOwnerCredentialRecoveryResult {
readonly status: 'inserted' | 'existing';
readonly recovery: Readonly<LocalOwnerCredentialRecoveryRecord>;
}
export interface LocalOwnerCredentialRecoveryRepository {
resolve(
issueMutationId: string,
): Promise<Readonly<LocalOwnerCredentialRecoveryRecord> | null>;
issue(
command: IssueLocalOwnerCredentialRecoveryCommand,
): Promise<LocalOwnerCredentialRecoveryResult>;
acknowledge(
command: AcknowledgeLocalOwnerCredentialRecoveryCommand,
): Promise<LocalOwnerCredentialRecoveryResult>;
complete(
command: CompleteLocalOwnerCredentialRecoveryCommand,
): Promise<LocalOwnerCredentialRecoveryResult>;
}
export class InvalidLocalOwnerCredentialRecoveryValueError extends TypeError {
constructor(message: string) {
super(`Local Owner credential recovery value is invalid: ${message}`);
this.name = 'InvalidLocalOwnerCredentialRecoveryValueError';
}
}
export class LocalOwnerCredentialRecoveryMutationConflictError extends Error {
readonly code = 'LOCAL_OWNER_CREDENTIAL_RECOVERY_MUTATION_CONFLICT';
constructor() {
super(
'Local Owner credential recovery mutation conflicts with previous use',
);
this.name = 'LocalOwnerCredentialRecoveryMutationConflictError';
}
}
export class LocalOwnerCredentialRecoveryInProgressError extends Error {
readonly code = 'LOCAL_OWNER_CREDENTIAL_RECOVERY_IN_PROGRESS';
constructor() {
super('A Local Owner credential recovery is already in progress');
this.name = 'LocalOwnerCredentialRecoveryInProgressError';
}
}
export class LocalOwnerCredentialRecoveryNotAcknowledgedError extends Error {
readonly code = 'LOCAL_OWNER_CREDENTIAL_RECOVERY_NOT_ACKNOWLEDGED';
constructor() {
super('Replacement credential delivery has not been acknowledged');
this.name = 'LocalOwnerCredentialRecoveryNotAcknowledgedError';
}
}
export class LocalOwnerCredentialRecoveryCredentialUnavailableError extends Error {
readonly code = 'LOCAL_OWNER_CREDENTIAL_RECOVERY_CREDENTIAL_UNAVAILABLE';
constructor() {
super('Local Owner credential is unavailable for recovery');
this.name = 'LocalOwnerCredentialRecoveryCredentialUnavailableError';
}
}
export class LocalOwnerCredentialRecoveryRepositoryUnavailableError extends Error {
readonly code = 'LOCAL_OWNER_CREDENTIAL_RECOVERY_REPOSITORY_UNAVAILABLE';
constructor() {
super('Local Owner credential recovery repository is unavailable');
this.name = 'LocalOwnerCredentialRecoveryRepositoryUnavailableError';
}
}
function exactKeys(value: object, expected: readonly string[]): void {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
actual.length !== canonical.length ||
actual.some((key, index) => key !== canonical[index])
) {
throw new InvalidLocalOwnerCredentialRecoveryValueError(
'object shape is invalid',
);
}
}
function mutationId(value: unknown, field: string): string {
if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) {
throw new InvalidLocalOwnerCredentialRecoveryValueError(
`${field} is invalid`,
);
}
return value;
}
function requestId(value: unknown): string {
if (typeof value !== 'string' || !REQUEST_ID_PATTERN.test(value)) {
throw new InvalidLocalOwnerCredentialRecoveryValueError(
'requestId is invalid',
);
}
return value;
}
function version(value: unknown, field: string): number {
if (
typeof value !== 'number' ||
!Number.isSafeInteger(value) ||
value < 1 ||
value > MAX_VERSION
) {
throw new InvalidLocalOwnerCredentialRecoveryValueError(
`${field} is invalid`,
);
}
return value;
}
function timestamp(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
throw new InvalidLocalOwnerCredentialRecoveryValueError(
`${field} is invalid`,
);
}
return value;
}
function digest(value: unknown, field: string): string {
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
throw new InvalidLocalOwnerCredentialRecoveryValueError(
`${field} is invalid`,
);
}
return value;
}
function normalizedMutation(
value: ApiCredentialMutationRecord,
operation: 'issue' | 'revoke',
credential: Readonly<ApiCredentialRecord>,
expectedPreviousVersion: number,
): Readonly<ApiCredentialMutationRecord> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidLocalOwnerCredentialRecoveryValueError(
'mutation is invalid',
);
}
exactKeys(value, [
'mutationId',
'operation',
'credentialId',
'credentialVersion',
'expectedPreviousVersion',
'changedBy',
'createdAtMs',
]);
mutationId(value.mutationId, 'mutation.mutationId');
if (
value.operation !== operation ||
value.credentialId !== credential.credentialId ||
value.credentialVersion !== credential.version ||
value.expectedPreviousVersion !== expectedPreviousVersion ||
value.createdAtMs !== credential.createdAtMs ||
value.changedBy.type !== 'system' ||
value.changedBy.id !== 'owner-credential-recovery'
) {
throw new InvalidLocalOwnerCredentialRecoveryValueError(
'mutation semantic is invalid',
);
}
return Object.freeze({
...value,
changedBy: Object.freeze({ ...value.changedBy }),
});
}
function normalizedAudit(
value: SecurityAuditRecord,
mutation: Readonly<ApiCredentialMutationRecord>,
request: string,
): Readonly<SecurityAuditRecord> {
const audit = normalizeSecurityAuditRecord(value);
if (
audit.eventId !== mutation.mutationId ||
audit.requestId !== request ||
audit.operationId !== `credential.${mutation.operation}` ||
audit.projectId !== null ||
audit.subject?.type !== 'system' ||
audit.subject.id !== 'owner-credential-recovery' ||
audit.authenticationId !== 'local-owner-console' ||
audit.outcome !== 'allowed' ||
audit.reasons.length !== 1 ||
audit.reasons[0] !== 'credential_recovery' ||
audit.fence !== null ||
audit.occurredAtMs !== mutation.createdAtMs
) {
throw new InvalidLocalOwnerCredentialRecoveryValueError(
'audit semantic is invalid',
);
}
return audit;
}
export function normalizeIssueLocalOwnerCredentialRecoveryCommand(
value: IssueLocalOwnerCredentialRecoveryCommand,
): Readonly<IssueLocalOwnerCredentialRecoveryCommand> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidLocalOwnerCredentialRecoveryValueError(
'command is invalid',
);
}
exactKeys(value, [
'mutationId',
'requestId',
'previousCredentialId',
'expectedPreviousVersion',
'replacementCredential',
'mutation',
'audit',
]);
const issueMutationId = mutationId(value.mutationId, 'mutationId');
const issueRequestId = requestId(value.requestId);
try {
assertApiCredentialId(value.previousCredentialId);
} catch {
throw new InvalidLocalOwnerCredentialRecoveryValueError(
'previousCredentialId is invalid',
);
}
const expectedPreviousVersion = version(
value.expectedPreviousVersion,
'expectedPreviousVersion',
);
const replacementCredential = normalizeApiCredentialRecord(
value.replacementCredential,
);
if (
replacementCredential.credentialId === value.previousCredentialId ||
replacementCredential.version !== 1 ||
replacementCredential.state !== 'active' ||
replacementCredential.subject.type !== 'user' ||
replacementCredential.subjectStatus !== 'active'
) {
throw new InvalidLocalOwnerCredentialRecoveryValueError(
'replacement credential is invalid',
);
}
const mutation = normalizedMutation(
value.mutation,
'issue',
replacementCredential,
0,
);
if (mutation.mutationId !== issueMutationId) {
throw new InvalidLocalOwnerCredentialRecoveryValueError(
'issue mutation identity is invalid',
);
}
return Object.freeze({
mutationId: issueMutationId,
requestId: issueRequestId,
previousCredentialId: value.previousCredentialId,
expectedPreviousVersion,
replacementCredential,
mutation,
audit: normalizedAudit(value.audit, mutation, issueRequestId),
});
}
export function normalizeAcknowledgeLocalOwnerCredentialRecoveryCommand(
value: AcknowledgeLocalOwnerCredentialRecoveryCommand,
): Readonly<AcknowledgeLocalOwnerCredentialRecoveryCommand> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidLocalOwnerCredentialRecoveryValueError(
'command is invalid',
);
}
exactKeys(value, [
'issueMutationId',
'requestId',
'credentialId',
'factDigest',
'deliveryDigest',
'acknowledgedAtMs',
]);
const issueMutationId = mutationId(value.issueMutationId, 'issueMutationId');
const normalizedRequestId = requestId(value.requestId);
try {
assertApiCredentialId(value.credentialId);
} catch {
throw new InvalidLocalOwnerCredentialRecoveryValueError(
'credentialId is invalid',
);
}
return Object.freeze({
issueMutationId,
requestId: normalizedRequestId,
credentialId: value.credentialId,
factDigest: digest(value.factDigest, 'factDigest'),
deliveryDigest: digest(value.deliveryDigest, 'deliveryDigest'),
acknowledgedAtMs: timestamp(value.acknowledgedAtMs, 'acknowledgedAtMs'),
});
}
export function normalizeCompleteLocalOwnerCredentialRecoveryCommand(
value: CompleteLocalOwnerCredentialRecoveryCommand,
): Readonly<CompleteLocalOwnerCredentialRecoveryCommand> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidLocalOwnerCredentialRecoveryValueError(
'command is invalid',
);
}
exactKeys(value, [
'issueMutationId',
'mutationId',
'requestId',
'expectedPreviousVersion',
'revokedCredential',
'mutation',
'audit',
]);
const issueMutationId = mutationId(value.issueMutationId, 'issueMutationId');
const completeMutationId = mutationId(value.mutationId, 'mutationId');
if (completeMutationId === issueMutationId) {
throw new InvalidLocalOwnerCredentialRecoveryValueError(
'completion mutation must be distinct',
);
}
const completeRequestId = requestId(value.requestId);
const expectedPreviousVersion = version(
value.expectedPreviousVersion,
'expectedPreviousVersion',
);
const revokedCredential = normalizeApiCredentialRecord(
value.revokedCredential,
);
if (
revokedCredential.state !== 'revoked' ||
revokedCredential.version !== expectedPreviousVersion + 1 ||
revokedCredential.subject.type !== 'user' ||
revokedCredential.secretDigest !== REVOKED_API_CREDENTIAL_DIGEST ||
revokedCredential.notBeforeAtMs !== revokedCredential.createdAtMs ||
revokedCredential.expiresAtMs !== revokedCredential.createdAtMs + 1
) {
throw new InvalidLocalOwnerCredentialRecoveryValueError(
'revoked credential is invalid',
);
}
const mutation = normalizedMutation(
value.mutation,
'revoke',
revokedCredential,
expectedPreviousVersion,
);
if (mutation.mutationId !== completeMutationId) {
throw new InvalidLocalOwnerCredentialRecoveryValueError(
'completion mutation identity is invalid',
);
}
return Object.freeze({
issueMutationId,
mutationId: completeMutationId,
requestId: completeRequestId,
expectedPreviousVersion,
revokedCredential,
mutation,
audit: normalizedAudit(value.audit, mutation, completeRequestId),
});
}
@@ -0,0 +1,336 @@
import { createHash } from 'node:crypto';
import {
assertLocalOwnerBootstrapMutationId,
assertLocalOwnerBootstrapRequestId,
} from './localOwnerBootstrap';
import {
normalizeSecurityAuditRecord,
type SecurityAuditRecord,
} from '../security/audit/securityAudit';
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const DAY_MS = 86_400_000;
export const MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_REPLAY_RETENTION_MS = 30 * DAY_MS;
export const MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_AUDIT_RETENTION_MS = 30 * DAY_MS;
export const MAX_LOCAL_OWNER_ACKNOWLEDGEMENT_RETENTION_MS = 10 * 365 * DAY_MS;
export interface LocalOwnerDeliveryAcknowledgementGcRetentionPolicy {
readonly version: 1;
readonly replayRetentionMs: number;
readonly auditRetentionMs: number;
}
export interface LocalOwnerDeliveryBridgeClearEvidence {
readonly kind: 'credential' | 'challenge';
readonly acknowledgementMutationId: string;
readonly inspectedAtMs: number;
readonly evidenceDigest: string;
}
export interface CompactLocalOwnerDeliveryAcknowledgementCommand {
readonly mutationId: string;
readonly requestId: string;
readonly acknowledgementMutationId: string;
readonly expectedKind: 'credential' | 'challenge';
readonly expectedDeliveryDigest: string;
readonly bridgeClearEvidence: LocalOwnerDeliveryBridgeClearEvidence;
readonly retentionPolicy: LocalOwnerDeliveryAcknowledgementGcRetentionPolicy;
readonly compactedAtMs: number;
readonly audit: SecurityAuditRecord;
}
export interface LocalOwnerDeliveryAcknowledgementGcRecord {
readonly mutationId: string;
readonly requestId: string;
readonly acknowledgementMutationId: string;
readonly acknowledgementKind: 'credential' | 'challenge';
readonly deliveryDigest: string;
readonly acknowledgedAtMs: number;
readonly acknowledgementSemanticDigest: string;
readonly bridgeClearEvidenceDigest: string;
readonly retentionPolicy: Readonly<LocalOwnerDeliveryAcknowledgementGcRetentionPolicy>;
readonly retentionPolicyDigest: string;
readonly retentionEligibleAtMs: number;
readonly compactedAtMs: number;
}
export interface LocalOwnerDeliveryAcknowledgementGcResult {
readonly status: 'inserted' | 'existing';
readonly record: Readonly<LocalOwnerDeliveryAcknowledgementGcRecord>;
}
export interface LocalOwnerDeliveryAcknowledgementGcRepository {
resolveByAcknowledgement(
acknowledgementMutationId: string,
): Promise<Readonly<LocalOwnerDeliveryAcknowledgementGcRecord> | null>;
compact(
command: CompactLocalOwnerDeliveryAcknowledgementCommand,
): Promise<Readonly<LocalOwnerDeliveryAcknowledgementGcResult>>;
}
export class InvalidLocalOwnerDeliveryAcknowledgementGcValueError extends TypeError {
constructor(message: string) {
super(
`Local Owner delivery acknowledgement GC value is invalid: ${message}`,
);
this.name = 'InvalidLocalOwnerDeliveryAcknowledgementGcValueError';
}
}
export class LocalOwnerDeliveryAcknowledgementGcMutationConflictError extends Error {
readonly code = 'LOCAL_OWNER_DELIVERY_ACKNOWLEDGEMENT_GC_MUTATION_CONFLICT';
constructor() {
super('Local Owner delivery acknowledgement GC mutation conflicts');
this.name = 'LocalOwnerDeliveryAcknowledgementGcMutationConflictError';
}
}
export class LocalOwnerDeliveryAcknowledgementGcRetentionPendingError extends Error {
readonly code = 'LOCAL_OWNER_DELIVERY_ACKNOWLEDGEMENT_GC_RETENTION_PENDING';
constructor(readonly eligibleAtMs: number) {
super('Local Owner delivery acknowledgement retention has not elapsed');
this.name = 'LocalOwnerDeliveryAcknowledgementGcRetentionPendingError';
}
}
export class LocalOwnerDeliveryAcknowledgementGcReferenceConflictError extends Error {
readonly code = 'LOCAL_OWNER_DELIVERY_ACKNOWLEDGEMENT_GC_REFERENCE_CONFLICT';
constructor() {
super('Local Owner delivery acknowledgement source is still active');
this.name = 'LocalOwnerDeliveryAcknowledgementGcReferenceConflictError';
}
}
export class LocalOwnerDeliveryAcknowledgementGcRepositoryUnavailableError extends Error {
readonly code =
'LOCAL_OWNER_DELIVERY_ACKNOWLEDGEMENT_GC_REPOSITORY_UNAVAILABLE';
constructor() {
super('Local Owner delivery acknowledgement GC repository is unavailable');
this.name = 'LocalOwnerDeliveryAcknowledgementGcRepositoryUnavailableError';
}
}
function exactKeys(value: object, expected: readonly string[]): void {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
actual.length !== canonical.length ||
actual.some((key, index) => key !== canonical[index])
) {
throw new InvalidLocalOwnerDeliveryAcknowledgementGcValueError(
'object shape is invalid',
);
}
}
function mutationId(value: unknown, field: string): string {
try {
assertLocalOwnerBootstrapMutationId(value as string);
} catch {
throw new InvalidLocalOwnerDeliveryAcknowledgementGcValueError(
`${field} is invalid`,
);
}
return value as string;
}
function requestId(value: unknown): string {
try {
assertLocalOwnerBootstrapRequestId(value as string);
} catch {
throw new InvalidLocalOwnerDeliveryAcknowledgementGcValueError(
'requestId is invalid',
);
}
return value as string;
}
function digest(value: unknown, field: string): string {
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
throw new InvalidLocalOwnerDeliveryAcknowledgementGcValueError(
`${field} is invalid`,
);
}
return value;
}
function timestamp(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
throw new InvalidLocalOwnerDeliveryAcknowledgementGcValueError(
`${field} is invalid`,
);
}
return value;
}
function retention(
value: unknown,
): Readonly<LocalOwnerDeliveryAcknowledgementGcRetentionPolicy> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidLocalOwnerDeliveryAcknowledgementGcValueError(
'retentionPolicy is invalid',
);
}
exactKeys(value, ['version', 'replayRetentionMs', 'auditRetentionMs']);
const policy = value as Record<string, unknown>;
const replayRetentionMs = timestamp(
policy.replayRetentionMs,
'replayRetentionMs',
);
const auditRetentionMs = timestamp(
policy.auditRetentionMs,
'auditRetentionMs',
);
if (
policy.version !== 1 ||
replayRetentionMs < MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_REPLAY_RETENTION_MS ||
auditRetentionMs < MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_AUDIT_RETENTION_MS ||
replayRetentionMs > MAX_LOCAL_OWNER_ACKNOWLEDGEMENT_RETENTION_MS ||
auditRetentionMs > MAX_LOCAL_OWNER_ACKNOWLEDGEMENT_RETENTION_MS
) {
throw new InvalidLocalOwnerDeliveryAcknowledgementGcValueError(
'retentionPolicy is outside the reviewed bounds',
);
}
return Object.freeze({
version: 1 as const,
replayRetentionMs,
auditRetentionMs,
});
}
function bridgeEvidence(
value: LocalOwnerDeliveryBridgeClearEvidence,
): Readonly<LocalOwnerDeliveryBridgeClearEvidence> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidLocalOwnerDeliveryAcknowledgementGcValueError(
'bridgeClearEvidence is invalid',
);
}
exactKeys(value, [
'kind',
'acknowledgementMutationId',
'inspectedAtMs',
'evidenceDigest',
]);
if (value.kind !== 'credential' && value.kind !== 'challenge') {
throw new InvalidLocalOwnerDeliveryAcknowledgementGcValueError(
'bridge kind is invalid',
);
}
return Object.freeze({
kind: value.kind,
acknowledgementMutationId: mutationId(
value.acknowledgementMutationId,
'acknowledgementMutationId',
),
inspectedAtMs: timestamp(value.inspectedAtMs, 'inspectedAtMs'),
evidenceDigest: digest(value.evidenceDigest, 'evidenceDigest'),
});
}
function audit(
value: SecurityAuditRecord,
mutation: string,
request: string,
occurredAtMs: number,
): Readonly<SecurityAuditRecord> {
const normalized = normalizeSecurityAuditRecord(value);
if (
normalized.eventId !== mutation ||
normalized.requestId !== request ||
normalized.operationId !== 'owner.delivery_acknowledgement.gc' ||
normalized.projectId !== null ||
normalized.subject?.type !== 'system' ||
normalized.subject.id !== 'owner-acknowledgement-gc' ||
normalized.authenticationId !== 'local-owner-console' ||
normalized.outcome !== 'allowed' ||
normalized.reasons.length !== 1 ||
normalized.reasons[0] !== 'delivery_acknowledgement_gc' ||
normalized.fence !== null ||
normalized.occurredAtMs !== occurredAtMs
) {
throw new InvalidLocalOwnerDeliveryAcknowledgementGcValueError(
'audit is not bound to the GC mutation',
);
}
return normalized;
}
export function localOwnerDeliveryAcknowledgementGcRetentionPolicyDigest(
value: LocalOwnerDeliveryAcknowledgementGcRetentionPolicy,
): string {
const policy = retention(value);
return createHash('sha256')
.update('qinglong.local-owner-delivery-acknowledgement-gc-policy.v1\0')
.update(String(policy.replayRetentionMs), 'utf8')
.update('\0', 'utf8')
.update(String(policy.auditRetentionMs), 'utf8')
.digest('hex');
}
export function normalizeCompactLocalOwnerDeliveryAcknowledgementCommand(
value: CompactLocalOwnerDeliveryAcknowledgementCommand,
): Readonly<CompactLocalOwnerDeliveryAcknowledgementCommand> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidLocalOwnerDeliveryAcknowledgementGcValueError(
'compact command is invalid',
);
}
exactKeys(value, [
'mutationId',
'requestId',
'acknowledgementMutationId',
'expectedKind',
'expectedDeliveryDigest',
'bridgeClearEvidence',
'retentionPolicy',
'compactedAtMs',
'audit',
]);
const normalizedMutationId = mutationId(value.mutationId, 'mutationId');
const normalizedAcknowledgementMutationId = mutationId(
value.acknowledgementMutationId,
'acknowledgementMutationId',
);
const normalizedRequestId = requestId(value.requestId);
const normalizedBridge = bridgeEvidence(value.bridgeClearEvidence);
const compactedAtMs = timestamp(value.compactedAtMs, 'compactedAtMs');
if (
normalizedMutationId === normalizedAcknowledgementMutationId ||
(value.expectedKind !== 'credential' &&
value.expectedKind !== 'challenge') ||
normalizedBridge.kind !== value.expectedKind ||
normalizedBridge.acknowledgementMutationId !==
normalizedAcknowledgementMutationId ||
normalizedBridge.inspectedAtMs !== compactedAtMs
) {
throw new InvalidLocalOwnerDeliveryAcknowledgementGcValueError(
'GC identity binding is invalid',
);
}
return Object.freeze({
mutationId: normalizedMutationId,
requestId: normalizedRequestId,
acknowledgementMutationId: normalizedAcknowledgementMutationId,
expectedKind: value.expectedKind,
expectedDeliveryDigest: digest(
value.expectedDeliveryDigest,
'expectedDeliveryDigest',
),
bridgeClearEvidence: normalizedBridge,
retentionPolicy: retention(value.retentionPolicy),
compactedAtMs,
audit: audit(
value.audit,
normalizedMutationId,
normalizedRequestId,
compactedAtMs,
),
});
}
@@ -0,0 +1,252 @@
import { assertApiCredentialPepperKeyId } from '../security/identity-credential/apiCredential';
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
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 MAX_VERSION = 2_147_483_647;
export const MAX_LOCAL_OWNER_PEPPER_KEYS = 8;
export type LocalOwnerPepperKeyState =
| 'recovery_required'
| 'staged'
| 'active'
| 'retired';
export interface LocalOwnerPepperKeyRecord {
readonly pepperKeyId: string;
readonly materialDigest?: string;
readonly backupDigest?: string;
readonly state: LocalOwnerPepperKeyState;
readonly version: number;
readonly registerMutationId?: string;
readonly activateMutationId?: string;
readonly retireMutationId?: string;
readonly registeredAtMs: number;
readonly activatedAtMs?: number;
readonly retiredAtMs?: number;
}
export interface LocalOwnerPepperActivationRecord {
readonly generation: number;
readonly mutationId: string;
readonly expectedGeneration: number;
readonly previousPepperKeyId?: string;
readonly activePepperKeyId: string;
readonly materialDigest: string;
readonly backupDigest: string;
readonly activatedAtMs: number;
}
export interface RegisterLocalOwnerPepperKeyCommand {
readonly mutationId: string;
readonly pepperKeyId: string;
readonly materialDigest: string;
readonly backupDigest: string;
readonly registeredAtMs: number;
}
export interface RegisterLocalOwnerPepperKeyResult {
readonly status: 'inserted' | 'existing';
readonly key: Readonly<LocalOwnerPepperKeyRecord>;
}
export interface ActivateLocalOwnerPepperKeyCommand {
readonly mutationId: string;
readonly pepperKeyId: string;
readonly expectedGeneration: number;
readonly expectedActivePepperKeyId?: string;
readonly activatedAtMs: number;
}
export interface ActivateLocalOwnerPepperKeyResult {
readonly status: 'inserted' | 'existing';
readonly activation: Readonly<LocalOwnerPepperActivationRecord>;
}
export interface LocalOwnerPepperReferenceSummary {
readonly pepperKeyId: string;
readonly inspectedAtMs: number;
readonly currentCredentialReferences: number;
readonly inFlightRecoveryReferences: number;
readonly historicalCredentialReferences: number;
readonly runtimeReferencesClear: boolean;
}
export interface LocalOwnerPepperRepository {
resolveKey(
pepperKeyId: string,
): Promise<Readonly<LocalOwnerPepperKeyRecord> | null>;
resolveActive(): Promise<Readonly<LocalOwnerPepperActivationRecord> | null>;
register(
command: RegisterLocalOwnerPepperKeyCommand,
): Promise<RegisterLocalOwnerPepperKeyResult>;
activate(
command: ActivateLocalOwnerPepperKeyCommand,
): Promise<ActivateLocalOwnerPepperKeyResult>;
}
export interface LocalOwnerPepperReferenceRepository
extends LocalOwnerPepperRepository {
inspectReferences(
pepperKeyId: string,
inspectedAtMs: number,
): Promise<Readonly<LocalOwnerPepperReferenceSummary>>;
}
export class InvalidLocalOwnerPepperValueError extends TypeError {
constructor(message: string) {
super(`Local Owner pepper value is invalid: ${message}`);
this.name = 'InvalidLocalOwnerPepperValueError';
}
}
export class LocalOwnerPepperMutationConflictError extends Error {
readonly code = 'LOCAL_OWNER_PEPPER_MUTATION_CONFLICT';
constructor() {
super('Local Owner pepper mutation conflicts with previous use');
this.name = 'LocalOwnerPepperMutationConflictError';
}
}
export class LocalOwnerPepperGenerationConflictError extends Error {
readonly code = 'LOCAL_OWNER_PEPPER_GENERATION_CONFLICT';
constructor() {
super('Local Owner active pepper generation changed');
this.name = 'LocalOwnerPepperGenerationConflictError';
}
}
export class LocalOwnerPepperKeyNotActivatableError extends Error {
readonly code = 'LOCAL_OWNER_PEPPER_KEY_NOT_ACTIVATABLE';
constructor() {
super('Local Owner pepper key is not staged for activation');
this.name = 'LocalOwnerPepperKeyNotActivatableError';
}
}
export class LocalOwnerPepperCatalogFullError extends Error {
readonly code = 'LOCAL_OWNER_PEPPER_CATALOG_FULL';
constructor() {
super('Local Owner pepper catalog reached its hard key limit');
this.name = 'LocalOwnerPepperCatalogFullError';
}
}
export class LocalOwnerPepperRepositoryUnavailableError extends Error {
readonly code = 'LOCAL_OWNER_PEPPER_REPOSITORY_UNAVAILABLE';
constructor() {
super('Local Owner pepper repository is unavailable');
this.name = 'LocalOwnerPepperRepositoryUnavailableError';
}
}
function exactKeys(value: object, expected: readonly string[]): void {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
actual.length !== canonical.length ||
actual.some((key, index) => key !== canonical[index])
) {
throw new InvalidLocalOwnerPepperValueError('object shape is invalid');
}
}
function assertDigest(value: unknown, field: string): asserts value is string {
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
throw new InvalidLocalOwnerPepperValueError(`${field} is invalid`);
}
}
function assertMutationId(value: unknown): asserts value is string {
if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) {
throw new InvalidLocalOwnerPepperValueError('mutationId is invalid');
}
}
function assertInteger(
value: unknown,
field: string,
minimum: number,
maximum = MAX_VERSION,
): asserts value is number {
if (
typeof value !== 'number' ||
!Number.isSafeInteger(value) ||
value < minimum ||
value > maximum
) {
throw new InvalidLocalOwnerPepperValueError(`${field} is invalid`);
}
}
export function normalizeRegisterLocalOwnerPepperKeyCommand(
value: RegisterLocalOwnerPepperKeyCommand,
): Readonly<RegisterLocalOwnerPepperKeyCommand> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidLocalOwnerPepperValueError('command is invalid');
}
exactKeys(value, [
'backupDigest',
'materialDigest',
'mutationId',
'pepperKeyId',
'registeredAtMs',
]);
assertMutationId(value.mutationId);
try {
assertApiCredentialPepperKeyId(value.pepperKeyId);
} catch {
throw new InvalidLocalOwnerPepperValueError('pepperKeyId is invalid');
}
assertDigest(value.materialDigest, 'materialDigest');
assertDigest(value.backupDigest, 'backupDigest');
assertInteger(
value.registeredAtMs,
'registeredAtMs',
0,
Number.MAX_SAFE_INTEGER,
);
return Object.freeze({ ...value });
}
export function normalizeActivateLocalOwnerPepperKeyCommand(
value: ActivateLocalOwnerPepperKeyCommand,
): Readonly<ActivateLocalOwnerPepperKeyCommand> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidLocalOwnerPepperValueError('command is invalid');
}
exactKeys(value, [
'activatedAtMs',
'expectedGeneration',
...(value.expectedActivePepperKeyId === undefined
? []
: ['expectedActivePepperKeyId']),
'mutationId',
'pepperKeyId',
]);
assertMutationId(value.mutationId);
try {
assertApiCredentialPepperKeyId(value.pepperKeyId);
if (value.expectedActivePepperKeyId !== undefined) {
assertApiCredentialPepperKeyId(value.expectedActivePepperKeyId);
}
} catch {
throw new InvalidLocalOwnerPepperValueError(
'pepper key identity is invalid',
);
}
assertInteger(value.expectedGeneration, 'expectedGeneration', 0);
assertInteger(
value.activatedAtMs,
'activatedAtMs',
0,
Number.MAX_SAFE_INTEGER,
);
return Object.freeze({ ...value });
}
@@ -0,0 +1,410 @@
import { createHash } from 'node:crypto';
import { assertApiCredentialPepperKeyId } from '../security/identity-credential/apiCredential';
import {
normalizeSecurityAuditRecord,
type SecurityAuditRecord,
} from '../security/audit/securityAudit';
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 DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const DAY_MS = 86_400_000;
export const MIN_LOCAL_OWNER_PEPPER_ACK_RETENTION_MS = 7 * DAY_MS;
export const MIN_LOCAL_OWNER_PEPPER_AUDIT_RETENTION_MS = 30 * DAY_MS;
export const MIN_LOCAL_OWNER_PEPPER_BACKUP_RETENTION_MS = 30 * DAY_MS;
export const MAX_LOCAL_OWNER_PEPPER_RETENTION_MS = 10 * 365 * DAY_MS;
export interface LocalOwnerPepperMaterialGcRetentionPolicy {
readonly version: 1;
readonly acknowledgementRetentionMs: number;
readonly auditRetentionMs: number;
readonly backupRetentionMs: number;
}
export type LocalOwnerPepperMaterialGcState = 'prepared' | 'completed';
export interface LocalOwnerPepperMaterialGcRecord {
readonly prepareMutationId: string;
readonly prepareRequestId: string;
readonly pepperKeyId: string;
readonly materialDigest: string;
readonly backupMaterialDigest: string;
readonly activePepperKeyId: string;
readonly activeGeneration: number;
readonly activeMaterialDigest: string;
readonly retentionPolicy: Readonly<LocalOwnerPepperMaterialGcRetentionPolicy>;
readonly retentionPolicyDigest: string;
readonly referencesInspectedAtMs: number;
readonly retentionEligibleAtMs: number;
readonly preparedAtMs: number;
readonly state: LocalOwnerPepperMaterialGcState;
readonly completeMutationId?: string;
readonly completeRequestId?: string;
readonly destructionProofDigest?: string;
readonly completedAtMs?: number;
}
export interface PrepareLocalOwnerPepperMaterialGcCommand {
readonly mutationId: string;
readonly requestId: string;
readonly pepperKeyId: string;
readonly expectedMaterialDigest: string;
readonly expectedBackupMaterialDigest: string;
readonly expectedActivePepperKeyId: string;
readonly expectedActiveGeneration: number;
readonly expectedActiveMaterialDigest: string;
readonly retentionPolicy: LocalOwnerPepperMaterialGcRetentionPolicy;
readonly preparedAtMs: number;
readonly audit: SecurityAuditRecord;
}
export interface CompleteLocalOwnerPepperMaterialGcCommand {
readonly prepareMutationId: string;
readonly mutationId: string;
readonly requestId: string;
readonly destructionProofDigest: string;
readonly completedAtMs: number;
readonly audit: SecurityAuditRecord;
}
export interface LocalOwnerPepperMaterialGcResult {
readonly status: 'inserted' | 'existing';
readonly record: Readonly<LocalOwnerPepperMaterialGcRecord>;
}
export interface LocalOwnerPepperMaterialGcRepository {
resolve(
prepareMutationId: string,
): Promise<Readonly<LocalOwnerPepperMaterialGcRecord> | null>;
prepare(
command: PrepareLocalOwnerPepperMaterialGcCommand,
): Promise<LocalOwnerPepperMaterialGcResult>;
complete(
command: CompleteLocalOwnerPepperMaterialGcCommand,
): Promise<LocalOwnerPepperMaterialGcResult>;
}
export class InvalidLocalOwnerPepperMaterialGcValueError extends TypeError {
constructor(message: string) {
super(`Local Owner pepper material GC value is invalid: ${message}`);
this.name = 'InvalidLocalOwnerPepperMaterialGcValueError';
}
}
export class LocalOwnerPepperMaterialGcMutationConflictError extends Error {
readonly code = 'LOCAL_OWNER_PEPPER_MATERIAL_GC_MUTATION_CONFLICT';
constructor() {
super(
'Local Owner pepper material GC mutation conflicts with previous use',
);
this.name = 'LocalOwnerPepperMaterialGcMutationConflictError';
}
}
export class LocalOwnerPepperMaterialGcInProgressError extends Error {
readonly code = 'LOCAL_OWNER_PEPPER_MATERIAL_GC_IN_PROGRESS';
constructor() {
super('A Local Owner pepper material GC is already in progress');
this.name = 'LocalOwnerPepperMaterialGcInProgressError';
}
}
export class LocalOwnerPepperMaterialGcReferenceConflictError extends Error {
readonly code = 'LOCAL_OWNER_PEPPER_MATERIAL_GC_REFERENCE_CONFLICT';
constructor() {
super('Local Owner pepper material still has runtime references');
this.name = 'LocalOwnerPepperMaterialGcReferenceConflictError';
}
}
export class LocalOwnerPepperMaterialGcRetentionPendingError extends Error {
readonly code = 'LOCAL_OWNER_PEPPER_MATERIAL_GC_RETENTION_PENDING';
constructor(readonly eligibleAtMs: number) {
super('Local Owner pepper material retention period has not elapsed');
this.name = 'LocalOwnerPepperMaterialGcRetentionPendingError';
}
}
export class LocalOwnerPepperMaterialGcRepositoryUnavailableError extends Error {
readonly code = 'LOCAL_OWNER_PEPPER_MATERIAL_GC_REPOSITORY_UNAVAILABLE';
constructor() {
super('Local Owner pepper material GC repository is unavailable');
this.name = 'LocalOwnerPepperMaterialGcRepositoryUnavailableError';
}
}
function exactKeys(value: object, expected: readonly string[]): void {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
actual.length !== canonical.length ||
actual.some((key, index) => key !== canonical[index])
) {
throw new InvalidLocalOwnerPepperMaterialGcValueError(
'object shape is invalid',
);
}
}
function mutationId(value: unknown, field: string): string {
if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) {
throw new InvalidLocalOwnerPepperMaterialGcValueError(
`${field} is invalid`,
);
}
return value;
}
function requestId(value: unknown): string {
if (typeof value !== 'string' || !REQUEST_ID_PATTERN.test(value)) {
throw new InvalidLocalOwnerPepperMaterialGcValueError(
'requestId is invalid',
);
}
return value;
}
function digest(value: unknown, field: string): string {
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
throw new InvalidLocalOwnerPepperMaterialGcValueError(
`${field} is invalid`,
);
}
return value;
}
function timestamp(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
throw new InvalidLocalOwnerPepperMaterialGcValueError(
`${field} is invalid`,
);
}
return value;
}
function positiveInteger(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
throw new InvalidLocalOwnerPepperMaterialGcValueError(
`${field} is invalid`,
);
}
return value;
}
function retention(
value: unknown,
): Readonly<LocalOwnerPepperMaterialGcRetentionPolicy> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidLocalOwnerPepperMaterialGcValueError(
'retentionPolicy is invalid',
);
}
exactKeys(value, [
'version',
'acknowledgementRetentionMs',
'auditRetentionMs',
'backupRetentionMs',
]);
const policy = value as Record<string, unknown>;
const acknowledgementRetentionMs = positiveInteger(
policy.acknowledgementRetentionMs,
'acknowledgementRetentionMs',
);
const auditRetentionMs = positiveInteger(
policy.auditRetentionMs,
'auditRetentionMs',
);
const backupRetentionMs = positiveInteger(
policy.backupRetentionMs,
'backupRetentionMs',
);
if (
policy.version !== 1 ||
acknowledgementRetentionMs < MIN_LOCAL_OWNER_PEPPER_ACK_RETENTION_MS ||
auditRetentionMs < MIN_LOCAL_OWNER_PEPPER_AUDIT_RETENTION_MS ||
backupRetentionMs < MIN_LOCAL_OWNER_PEPPER_BACKUP_RETENTION_MS ||
acknowledgementRetentionMs > MAX_LOCAL_OWNER_PEPPER_RETENTION_MS ||
auditRetentionMs > MAX_LOCAL_OWNER_PEPPER_RETENTION_MS ||
backupRetentionMs > MAX_LOCAL_OWNER_PEPPER_RETENTION_MS
) {
throw new InvalidLocalOwnerPepperMaterialGcValueError(
'retentionPolicy is outside the reviewed bounds',
);
}
return Object.freeze({
version: 1 as const,
acknowledgementRetentionMs,
auditRetentionMs,
backupRetentionMs,
});
}
function audit(
value: SecurityAuditRecord,
mutation: string,
request: string,
operation: 'prepare' | 'complete',
occurredAtMs: number,
): Readonly<SecurityAuditRecord> {
const normalized = normalizeSecurityAuditRecord(value);
if (
normalized.eventId !== mutation ||
normalized.requestId !== request ||
normalized.operationId !== `owner.pepper.material_gc.${operation}` ||
normalized.projectId !== null ||
normalized.subject === null ||
normalized.subject.type !== 'system' ||
normalized.subject.id !== 'owner-pepper-gc' ||
normalized.authenticationId !== 'local-owner-console' ||
normalized.outcome !== 'allowed' ||
normalized.reasons.length !== 1 ||
normalized.reasons[0] !== 'pepper_material_gc' ||
normalized.fence !== null ||
normalized.occurredAtMs !== occurredAtMs
) {
throw new InvalidLocalOwnerPepperMaterialGcValueError(
'audit is not bound to the GC mutation',
);
}
return normalized;
}
export function localOwnerPepperMaterialGcRetentionPolicyDigest(
value: LocalOwnerPepperMaterialGcRetentionPolicy,
): string {
const policy = retention(value);
return createHash('sha256')
.update('qinglong.local-owner-pepper-material-gc-policy.v1\0', 'utf8')
.update(String(policy.acknowledgementRetentionMs), 'utf8')
.update('\0', 'utf8')
.update(String(policy.auditRetentionMs), 'utf8')
.update('\0', 'utf8')
.update(String(policy.backupRetentionMs), 'utf8')
.digest('hex');
}
export function normalizePrepareLocalOwnerPepperMaterialGcCommand(
value: PrepareLocalOwnerPepperMaterialGcCommand,
): Readonly<PrepareLocalOwnerPepperMaterialGcCommand> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidLocalOwnerPepperMaterialGcValueError(
'prepare command is invalid',
);
}
exactKeys(value, [
'mutationId',
'requestId',
'pepperKeyId',
'expectedMaterialDigest',
'expectedBackupMaterialDigest',
'expectedActivePepperKeyId',
'expectedActiveGeneration',
'expectedActiveMaterialDigest',
'retentionPolicy',
'preparedAtMs',
'audit',
]);
const normalizedMutationId = mutationId(value.mutationId, 'mutationId');
const normalizedRequestId = requestId(value.requestId);
const normalizedPepperKeyId = value.pepperKeyId;
const normalizedActivePepperKeyId = value.expectedActivePepperKeyId;
try {
assertApiCredentialPepperKeyId(normalizedPepperKeyId);
assertApiCredentialPepperKeyId(normalizedActivePepperKeyId);
} catch {
throw new InvalidLocalOwnerPepperMaterialGcValueError(
'pepper key identity is invalid',
);
}
if (normalizedPepperKeyId === normalizedActivePepperKeyId) {
throw new InvalidLocalOwnerPepperMaterialGcValueError(
'retired and active pepper keys must differ',
);
}
const preparedAtMs = timestamp(value.preparedAtMs, 'preparedAtMs');
return Object.freeze({
mutationId: normalizedMutationId,
requestId: normalizedRequestId,
pepperKeyId: normalizedPepperKeyId,
expectedMaterialDigest: digest(
value.expectedMaterialDigest,
'expectedMaterialDigest',
),
expectedBackupMaterialDigest: digest(
value.expectedBackupMaterialDigest,
'expectedBackupMaterialDigest',
),
expectedActivePepperKeyId: normalizedActivePepperKeyId,
expectedActiveGeneration: positiveInteger(
value.expectedActiveGeneration,
'expectedActiveGeneration',
),
expectedActiveMaterialDigest: digest(
value.expectedActiveMaterialDigest,
'expectedActiveMaterialDigest',
),
retentionPolicy: retention(value.retentionPolicy),
preparedAtMs,
audit: audit(
value.audit,
normalizedMutationId,
normalizedRequestId,
'prepare',
preparedAtMs,
),
});
}
export function normalizeCompleteLocalOwnerPepperMaterialGcCommand(
value: CompleteLocalOwnerPepperMaterialGcCommand,
): Readonly<CompleteLocalOwnerPepperMaterialGcCommand> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidLocalOwnerPepperMaterialGcValueError(
'complete command is invalid',
);
}
exactKeys(value, [
'prepareMutationId',
'mutationId',
'requestId',
'destructionProofDigest',
'completedAtMs',
'audit',
]);
const prepareMutationId = mutationId(
value.prepareMutationId,
'prepareMutationId',
);
const normalizedMutationId = mutationId(value.mutationId, 'mutationId');
if (prepareMutationId === normalizedMutationId) {
throw new InvalidLocalOwnerPepperMaterialGcValueError(
'completion mutation must be distinct',
);
}
const normalizedRequestId = requestId(value.requestId);
const completedAtMs = timestamp(value.completedAtMs, 'completedAtMs');
return Object.freeze({
prepareMutationId,
mutationId: normalizedMutationId,
requestId: normalizedRequestId,
destructionProofDigest: digest(
value.destructionProofDigest,
'destructionProofDigest',
),
completedAtMs,
audit: audit(
value.audit,
normalizedMutationId,
normalizedRequestId,
'complete',
completedAtMs,
),
});
}