mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 02:27:44 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -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,
|
||||
),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user