Bounded test succeeded; native RandomX miner accepted one block
This commit is contained in:
@@ -7,6 +7,8 @@ export const CUSTODY_DB_ACTION_ARGUMENT_COUNTS = {
|
||||
'grant-pilot-deposit-reservation-runtime': 1,
|
||||
'grant-pilot-deposit-address-runtime': 1,
|
||||
'grant-pilot-monero-observer-runtime': 1,
|
||||
'grant-pilot-monero-review-runtime': 1,
|
||||
'pilot-deposit-prepare-review': 2,
|
||||
'grant-runtime': 1,
|
||||
'grant-monero-observer-runtime': 1,
|
||||
'grant-monero-deposit-allocator-runtime': 1,
|
||||
@@ -57,10 +59,11 @@ export function assertMainnetCustodyOperationAcknowledged(action: CustodyDbActio
|
||||
|
||||
export function assertPilotCustodyActionAllowed(action: CustodyDbAction, isPilot: boolean): void {
|
||||
const pilotActions = ['migrate-pilot-schema', 'initialize-pilot-custody-identity',
|
||||
'grant-pilot-deposit-reservation-runtime', 'grant-pilot-deposit-address-runtime', 'grant-pilot-monero-observer-runtime'];
|
||||
'grant-pilot-deposit-reservation-runtime', 'grant-pilot-deposit-address-runtime', 'grant-pilot-monero-observer-runtime',
|
||||
'grant-pilot-monero-review-runtime', 'pilot-deposit-prepare-review'];
|
||||
if ((isPilot && !pilotActions.includes(action)) || (!isPilot && pilotActions.includes(action))) {
|
||||
throw new Error(isPilot
|
||||
? 'Pilot profile permits only pilot schema migration and identity-only initialization through this CLI'
|
||||
? 'Pilot profile permits only pilot-specific schema, identity, scoped-role, and review-preparation operations through this CLI'
|
||||
: 'Pilot custody operation requires the pilot profile');
|
||||
}
|
||||
}
|
||||
@@ -83,6 +86,15 @@ export function assertPilotObserverGrantAcknowledged(action: CustodyDbAction, ac
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPilotDepositReviewAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void {
|
||||
if (action === 'grant-pilot-monero-review-runtime' && acknowledgement !== 'read-only-pilot-review-role-only') {
|
||||
throw new Error('Pilot review role grant requires the exact read-only-role acknowledgement');
|
||||
}
|
||||
if (action === 'pilot-deposit-prepare-review' && acknowledgement !== 'pilot-deposit-review-preparation-only') {
|
||||
throw new Error('Pilot deposit review preparation requires the exact preparation-only acknowledgement');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPilotSchemaMigrationAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void {
|
||||
if (action === 'migrate-pilot-schema' && acknowledgement !== 'explicit-pilot-schema-migration-only') {
|
||||
throw new Error('Pilot schema migration requires explicit pilot schema-migration acknowledgement');
|
||||
|
||||
@@ -26,7 +26,7 @@ function requirePilot(custodianId: string, initializedAt: number): void {
|
||||
if (!Number.isSafeInteger(initializedAt) || initializedAt < 0) throw new Error('Invalid pilot identity initialization time');
|
||||
}
|
||||
|
||||
function identityFromRow(row: Record<string, unknown>): PilotCustodyIdentity {
|
||||
export function pilotCustodyIdentityFromRow(row: Record<string, unknown>): PilotCustodyIdentity {
|
||||
const identity = {
|
||||
version: Number(row.version), mode: row.mode, policy: row.policy,
|
||||
marketNetwork: row.market_network, marketChainId: row.market_chain_id,
|
||||
@@ -78,7 +78,7 @@ export async function initializePilotCustodyIdentity(pool: Pool, custodianId: st
|
||||
await verifyCustodySchema(client);
|
||||
const current = await client.query('SELECT * FROM wk_custody.pilot_custody_identity WHERE singleton=1 FOR UPDATE');
|
||||
if (current.rowCount) {
|
||||
const identity = identityFromRow(current.rows[0]);
|
||||
const identity = pilotCustodyIdentityFromRow(current.rows[0]);
|
||||
if (identity.version !== expected.version || identity.mode !== expected.mode || identity.policy !== expected.policy ||
|
||||
identity.marketNetwork !== expected.marketNetwork || identity.marketChainId !== expected.marketChainId ||
|
||||
identity.moneroNetwork !== expected.moneroNetwork || identity.custodianId !== expected.custodianId) {
|
||||
@@ -103,7 +103,7 @@ export async function verifyPilotCustodyIdentity(pool: Pool, custodianId: string
|
||||
requirePilot(custodianId, 0);
|
||||
const result = await pool.query('SELECT * FROM wk_custody.pilot_custody_identity WHERE singleton=1');
|
||||
if (result.rowCount !== 1) throw new Error('Pilot custody identity is not initialized');
|
||||
const identity = identityFromRow(result.rows[0]);
|
||||
const identity = pilotCustodyIdentityFromRow(result.rows[0]);
|
||||
if (identity.custodianId !== custodianId.toLowerCase()) throw new Error('Pilot custody identity does not match configured custodian');
|
||||
return identity;
|
||||
}
|
||||
|
||||
Executable
+265
@@ -0,0 +1,265 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { XMR_RPC_UINT64_MAX } from '../../shared/xmr-amount.ts';
|
||||
import { CHAIN_ID } from '../testnet/consensus.ts';
|
||||
import { IS_PILOT, NETWORK } from '../../shared/network.ts';
|
||||
import { verifyCustodySchema } from './postgres-schema.ts';
|
||||
import { pilotCustodyIdentityFromRow } from './pilot-custody-identity.ts';
|
||||
import { validateXmrSubaddress } from './xmr.ts';
|
||||
|
||||
const HEX64 = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const UUID = z.string().uuid();
|
||||
const OBSERVATION_ID = /^[a-f0-9]{64}:[a-f0-9]{64}$/;
|
||||
const UINT64 = /^(0|[1-9]\d{0,19})$/;
|
||||
const MAX_EVENT_HISTORY = 256;
|
||||
const MAX_BLOCK_UNLOCK = 500_000_000n;
|
||||
|
||||
export type PilotDepositReviewPolicy = {
|
||||
minimumConfirmations: number;
|
||||
maximumObservationAgeMs: number;
|
||||
};
|
||||
|
||||
const subjectWithoutDigestSchema = z.object({
|
||||
policyVersion: z.literal('pilot-monero-deposit-review-v1'),
|
||||
preparationOnly: z.literal(true),
|
||||
crediting: z.literal('disabled'),
|
||||
marketNetwork: z.literal('wikideck-randomx-pilot-1'),
|
||||
marketChainId: HEX64,
|
||||
moneroNetwork: z.literal('mainnet'),
|
||||
custodianId: UUID,
|
||||
activationStatus: z.literal('identity-only'),
|
||||
walletGenerationId: UUID,
|
||||
walletFingerprint: HEX64,
|
||||
allocationId: UUID,
|
||||
beneficiary: HEX64,
|
||||
accountIndex: z.number().int().nonnegative().max(0xffff_ffff).safe(),
|
||||
addressIndex: z.number().int().nonnegative().max(0xffff_ffff).safe(),
|
||||
moneroAddress: z.string().length(95),
|
||||
observationId: z.string().regex(OBSERVATION_ID),
|
||||
transactionId: HEX64,
|
||||
outputPublicKey: HEX64,
|
||||
amountAtomicXmr: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
blockHeight: z.number().int().positive().safe(),
|
||||
walletHeight: z.number().int().positive().safe(),
|
||||
confirmations: z.number().int().positive().safe(),
|
||||
minimumConfirmations: z.number().int().min(10).max(100_000).safe(),
|
||||
unlockTime: z.string().regex(UINT64),
|
||||
locked: z.literal(false),
|
||||
doubleSpendSeen: z.literal(false),
|
||||
currentlyPresent: z.literal(true),
|
||||
globalOutputIndex: z.number().int().nonnegative().safe(),
|
||||
observationDigest: HEX64,
|
||||
scanId: UUID,
|
||||
scanFromHeight: z.number().int().nonnegative().safe(),
|
||||
scanThroughHeight: z.number().int().nonnegative().safe(),
|
||||
observerScanHeight: z.number().int().nonnegative().safe(),
|
||||
scanCompletedAt: z.number().int().nonnegative().safe(),
|
||||
observerUpdatedAt: z.number().int().nonnegative().safe(),
|
||||
lastObservedAt: z.number().int().nonnegative().safe(),
|
||||
scanFreshnessMs: z.number().int().nonnegative().safe(),
|
||||
maximumObservationAgeMs: z.number().int().min(10_000).max(15 * 60_000).safe(),
|
||||
eventHistorySequence: z.number().int().nonnegative().safe(),
|
||||
eventHistoryCount: z.number().int().positive().max(MAX_EVENT_HISTORY).safe(),
|
||||
eventHistoryDigest: HEX64,
|
||||
preparedAt: z.number().int().nonnegative().safe(),
|
||||
}).strict();
|
||||
|
||||
export type PilotDepositReviewSubject = z.infer<typeof subjectWithoutDigestSchema> & { subjectDigest: string };
|
||||
|
||||
const reviewSubjectSchema = subjectWithoutDigestSchema.extend({ subjectDigest: HEX64 }).strict();
|
||||
const inputSchema = z.object({
|
||||
walletGenerationId: UUID,
|
||||
observationId: z.string().regex(OBSERVATION_ID),
|
||||
}).strict();
|
||||
|
||||
function digest(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function safeInteger(value: unknown, name: string, minimum = 0): number {
|
||||
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'bigint') {
|
||||
throw new Error(`Invalid pilot review ${name}`);
|
||||
}
|
||||
const number = Number(value);
|
||||
if (!Number.isSafeInteger(number) || number < minimum) throw new Error(`Invalid pilot review ${name}`);
|
||||
return number;
|
||||
}
|
||||
|
||||
function validatePolicy(policy: PilotDepositReviewPolicy): void {
|
||||
if (!Number.isSafeInteger(policy.minimumConfirmations) || policy.minimumConfirmations < 10 ||
|
||||
policy.minimumConfirmations > 100_000 || !Number.isSafeInteger(policy.maximumObservationAgeMs) ||
|
||||
policy.maximumObservationAgeMs < 10_000 || policy.maximumObservationAgeMs > 15 * 60_000) {
|
||||
throw new Error('Invalid pilot deposit review policy');
|
||||
}
|
||||
}
|
||||
|
||||
function assertUnlockMature(unlockTime: string, walletHeight: number, scanCompletedAt: number): void {
|
||||
if (!UINT64.test(unlockTime)) throw new Error('Pilot deposit unlock time is malformed');
|
||||
const unlock = BigInt(unlockTime);
|
||||
if (unlock === 0n) return;
|
||||
const mature = unlock < MAX_BLOCK_UNLOCK
|
||||
? BigInt(walletHeight) >= unlock
|
||||
: BigInt(Math.floor(scanCompletedAt / 1000)) >= unlock;
|
||||
if (!mature) throw new Error('Pilot deposit output has a future unlock time');
|
||||
}
|
||||
|
||||
function validateEvents(rows: Record<string, unknown>[], observationDigest: string) {
|
||||
if (!rows.length || rows.length > MAX_EVENT_HISTORY) {
|
||||
throw new Error('Pilot observation event history is missing or exceeds the review bound');
|
||||
}
|
||||
const events = rows.map(row => {
|
||||
const sequence = safeInteger(row.sequence, 'event sequence');
|
||||
const observedAt = safeInteger(row.observed_at, 'event timestamp');
|
||||
const walletHeight = safeInteger(row.wallet_height, 'event wallet height');
|
||||
const payloadDigest = HEX64.parse(row.payload_digest);
|
||||
const eventType = z.enum(['seen', 'changed', 'reappeared', 'not-seen']).parse(row.event_type);
|
||||
const scanId = UUID.parse(row.scan_id);
|
||||
return { sequence, observedAt, walletHeight, payloadDigest, eventType, scanId };
|
||||
});
|
||||
if (events.some(event => event.eventType !== 'seen' || event.payloadDigest !== observationDigest)) {
|
||||
throw new Error('Pilot observation history contains change, disappearance, or reappearance evidence and needs separate review');
|
||||
}
|
||||
const last = events.at(-1)!;
|
||||
const historyDigest = digest(JSON.stringify(events.map(event => [event.sequence, event.eventType,
|
||||
event.payloadDigest, event.walletHeight, event.observedAt, event.scanId])));
|
||||
return { sequence: last.sequence, count: events.length, digest: historyDigest };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an operator-facing pilot deposit review snapshot from persisted observations only.
|
||||
* This function is read-only and does not approve, stage, post, release, or credit funds.
|
||||
*/
|
||||
export async function preparePilotMoneroDepositReview(pool: Pool, inputValue: unknown,
|
||||
policy: PilotDepositReviewPolicy, now = Date.now()): Promise<PilotDepositReviewSubject> {
|
||||
if (!IS_PILOT || NETWORK !== 'wikideck-randomx-pilot-1') {
|
||||
throw new Error('Pilot deposit review preparation requires the pilot profile');
|
||||
}
|
||||
const input = inputSchema.parse(inputValue);
|
||||
validatePolicy(policy);
|
||||
if (!Number.isSafeInteger(now) || now < 0) throw new Error('Invalid pilot review time');
|
||||
|
||||
const client: PoolClient = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'");
|
||||
await verifyCustodySchema(client);
|
||||
|
||||
const identityResult = await client.query('SELECT * FROM wk_custody.pilot_custody_identity WHERE singleton=1');
|
||||
if (identityResult.rowCount !== 1) throw new Error('Pilot custody identity is not initialized');
|
||||
const identity = pilotCustodyIdentityFromRow(identityResult.rows[0]);
|
||||
if (identity.marketChainId !== CHAIN_ID || identity.marketNetwork !== NETWORK ||
|
||||
identity.moneroNetwork !== 'mainnet' || identity.activationStatus !== 'identity-only') {
|
||||
throw new Error('Pilot custody identity does not permit review preparation');
|
||||
}
|
||||
|
||||
const observations = await client.query(`SELECT
|
||||
o.wallet_generation_id::text,o.observation_id,o.allocation_id::text,o.classification,o.txid,o.monero_address,
|
||||
o.amount_atomic_xmr::text AS amount_atomic_xmr,o.block_height::text AS block_height,
|
||||
o.wallet_height::text AS wallet_height,o.confirmations::text AS confirmations,o.unlock_time,
|
||||
o.account_index::text AS account_index,o.address_index::text AS address_index,o.output_public_key,
|
||||
o.global_output_index::text AS global_output_index,o.locked,o.double_spend_seen,o.payload_digest,
|
||||
o.currently_present,o.last_seen_scan_id::text AS scan_id,o.last_observed_at::text AS last_observed_at,
|
||||
s.wallet_fingerprint,s.network,s.scan_height::text AS observer_scan_height,s.updated_at::text AS observer_updated_at,
|
||||
sc.from_height::text AS scan_from_height,sc.through_height::text AS scan_through_height,
|
||||
sc.completed_at::text AS scan_completed_at
|
||||
FROM wk_custody.pilot_monero_observations o
|
||||
JOIN wk_custody.pilot_monero_observer_state s USING(wallet_generation_id)
|
||||
JOIN wk_custody.pilot_monero_observation_scans sc
|
||||
ON sc.scan_id=o.last_seen_scan_id AND sc.wallet_generation_id=o.wallet_generation_id
|
||||
WHERE o.wallet_generation_id=$1 AND o.observation_id=$2`, [input.walletGenerationId, input.observationId]);
|
||||
if (observations.rowCount !== 1) throw new Error('Pilot deposit observation was not found for this wallet generation');
|
||||
const row = observations.rows[0];
|
||||
if (row.wallet_generation_id !== input.walletGenerationId || row.observation_id !== input.observationId ||
|
||||
row.network !== identity.moneroNetwork || row.classification !== 'assigned' || row.allocation_id === null ||
|
||||
row.currently_present !== true || row.double_spend_seen !== false || row.locked !== false) {
|
||||
throw new Error('Pilot deposit observation is not an assigned, present, unlocked, non-double-spent output');
|
||||
}
|
||||
|
||||
const txid = HEX64.parse(row.txid);
|
||||
const outputPublicKey = HEX64.parse(row.output_public_key);
|
||||
if (row.observation_id !== `${txid}:${outputPublicKey}`) throw new Error('Pilot observation ID does not match its output identity');
|
||||
const amountAtomicXmr = z.string().regex(/^[1-9]\d{0,19}$/).parse(row.amount_atomic_xmr);
|
||||
const amount = BigInt(amountAtomicXmr);
|
||||
if (amount > XMR_RPC_UINT64_MAX) throw new Error('Pilot deposit amount exceeds the Monero RPC uint64 range');
|
||||
|
||||
const blockHeight = safeInteger(row.block_height, 'block height', 1);
|
||||
const walletHeight = safeInteger(row.wallet_height, 'wallet height', 1);
|
||||
const confirmations = safeInteger(row.confirmations, 'confirmation count', 1);
|
||||
const observerScanHeight = safeInteger(row.observer_scan_height, 'observer scan height');
|
||||
const scanFromHeight = safeInteger(row.scan_from_height, 'scan start height');
|
||||
const scanThroughHeight = safeInteger(row.scan_through_height, 'scan end height');
|
||||
const scanCompletedAt = safeInteger(row.scan_completed_at, 'scan completion time');
|
||||
const observerUpdatedAt = safeInteger(row.observer_updated_at, 'observer update time');
|
||||
const lastObservedAt = safeInteger(row.last_observed_at, 'last observation time');
|
||||
const accountIndex = safeInteger(row.account_index, 'account index');
|
||||
const addressIndex = safeInteger(row.address_index, 'address index');
|
||||
const globalOutputIndex = safeInteger(row.global_output_index, 'global output index');
|
||||
if (confirmations !== walletHeight - blockHeight + 1 || confirmations < policy.minimumConfirmations) {
|
||||
throw new Error('Pilot deposit output is below the configured confirmation floor');
|
||||
}
|
||||
if (observerScanHeight < walletHeight || scanThroughHeight < walletHeight ||
|
||||
scanFromHeight > blockHeight || scanThroughHeight < blockHeight || lastObservedAt !== scanCompletedAt ||
|
||||
observerUpdatedAt < scanCompletedAt) {
|
||||
throw new Error('Pilot observation scan is not caught up or its persisted scan context is inconsistent');
|
||||
}
|
||||
const scanFreshnessMs = now - observerUpdatedAt;
|
||||
if (scanFreshnessMs < 0 || now - scanCompletedAt < 0 || now - lastObservedAt < 0 ||
|
||||
scanFreshnessMs > policy.maximumObservationAgeMs || now - scanCompletedAt > policy.maximumObservationAgeMs ||
|
||||
now - lastObservedAt > policy.maximumObservationAgeMs) {
|
||||
throw new Error('Pilot Monero observation scan is stale or has a future timestamp');
|
||||
}
|
||||
assertUnlockMature(row.unlock_time, walletHeight, scanCompletedAt);
|
||||
|
||||
const allocations = await client.query(`SELECT allocation_id::text,custodian_id::text,market_network,market_chain_id,
|
||||
monero_network,beneficiary,wallet_generation_id::text,wallet_fingerprint,account_index::text AS account_index,
|
||||
address_index::text AS address_index,status,monero_address
|
||||
FROM wk_custody.pilot_monero_deposit_reservations WHERE allocation_id=$1`, [row.allocation_id]);
|
||||
if (allocations.rowCount !== 1) throw new Error('Pilot assigned output has no unique durable reservation');
|
||||
const allocation = allocations.rows[0];
|
||||
const beneficiary = HEX64.parse(allocation.beneficiary);
|
||||
if (allocation.custodian_id !== identity.custodianId || allocation.market_network !== identity.marketNetwork ||
|
||||
allocation.market_chain_id !== identity.marketChainId || allocation.monero_network !== identity.moneroNetwork ||
|
||||
allocation.wallet_generation_id !== input.walletGenerationId || allocation.wallet_fingerprint !== row.wallet_fingerprint ||
|
||||
safeInteger(allocation.account_index, 'reserved account index') !== accountIndex ||
|
||||
safeInteger(allocation.address_index, 'reserved address index') !== addressIndex ||
|
||||
allocation.status !== 'registered' || allocation.monero_address !== row.monero_address) {
|
||||
throw new Error('Pilot output does not exactly match its immutable registered reservation and identity');
|
||||
}
|
||||
const moneroAddress = await validateXmrSubaddress(z.string().length(95).parse(row.monero_address));
|
||||
const walletFingerprint = HEX64.parse(row.wallet_fingerprint);
|
||||
const observationDigest = HEX64.parse(row.payload_digest);
|
||||
const scanId = UUID.parse(row.scan_id);
|
||||
|
||||
const history = await client.query(`SELECT sequence::text,event_type,payload_digest,wallet_height::text AS wallet_height,
|
||||
observed_at::text AS observed_at,scan_id::text
|
||||
FROM wk_custody.pilot_monero_observation_events
|
||||
WHERE wallet_generation_id=$1 AND observation_id=$2 ORDER BY sequence LIMIT $3`,
|
||||
[input.walletGenerationId, input.observationId, MAX_EVENT_HISTORY + 1]);
|
||||
if (history.rows.length > MAX_EVENT_HISTORY) throw new Error('Pilot observation event history exceeds the review bound');
|
||||
const eventHistory = validateEvents(history.rows, observationDigest);
|
||||
|
||||
const unsigned = subjectWithoutDigestSchema.parse({
|
||||
policyVersion: 'pilot-monero-deposit-review-v1', preparationOnly: true, crediting: 'disabled',
|
||||
marketNetwork: identity.marketNetwork, marketChainId: identity.marketChainId, moneroNetwork: identity.moneroNetwork,
|
||||
custodianId: identity.custodianId, activationStatus: identity.activationStatus,
|
||||
walletGenerationId: input.walletGenerationId, walletFingerprint, allocationId: allocation.allocation_id,
|
||||
beneficiary, accountIndex, addressIndex, moneroAddress, observationId: input.observationId,
|
||||
transactionId: txid, outputPublicKey, amountAtomicXmr, blockHeight, walletHeight, confirmations,
|
||||
minimumConfirmations: policy.minimumConfirmations, unlockTime: z.string().regex(UINT64).parse(row.unlock_time),
|
||||
locked: false, doubleSpendSeen: false, currentlyPresent: true, globalOutputIndex, observationDigest,
|
||||
scanId, scanFromHeight, scanThroughHeight, observerScanHeight, scanCompletedAt, observerUpdatedAt,
|
||||
lastObservedAt, scanFreshnessMs, maximumObservationAgeMs: policy.maximumObservationAgeMs,
|
||||
eventHistorySequence: eventHistory.sequence, eventHistoryCount: eventHistory.count,
|
||||
eventHistoryDigest: eventHistory.digest, preparedAt: now,
|
||||
});
|
||||
const subject = reviewSubjectSchema.parse({ ...unsigned, subjectDigest: digest(JSON.stringify(unsigned)) });
|
||||
await client.query('COMMIT');
|
||||
return subject;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { lstat, readFile } from 'node:fs/promises';
|
||||
import { constants } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
import type { PoolConfig } from 'pg';
|
||||
|
||||
export function custodyStorage(env: NodeJS.ProcessEnv = process.env): 'sqlite' | 'postgres' {
|
||||
@@ -13,6 +16,60 @@ async function secretFile(path: string | undefined): Promise<string> {
|
||||
if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error('PostgreSQL secret file permissions are too broad');
|
||||
return (await readFile(path, 'utf8')).trim();
|
||||
}
|
||||
function derLength(bytes: Buffer, offset: number): { length: number; next: number } {
|
||||
if (offset >= bytes.length) throw new Error('Invalid certificate DER length');
|
||||
const first = bytes[offset];
|
||||
if (first < 0x80) return { length: first, next: offset + 1 };
|
||||
const count = first & 0x7f;
|
||||
if (count === 0 || count > 4 || offset + count >= bytes.length) throw new Error('Invalid certificate DER length');
|
||||
let length = 0;
|
||||
for (let i = 1; i <= count; i++) length = length * 256 + bytes[offset + i];
|
||||
return { length, next: offset + count + 1 };
|
||||
}
|
||||
function derElement(bytes: Buffer, offset: number): { tag: number; start: number; end: number; next: number } {
|
||||
if (offset >= bytes.length) throw new Error('Invalid certificate DER');
|
||||
const tag = bytes[offset], size = derLength(bytes, offset + 1), end = size.next + size.length;
|
||||
if (end > bytes.length) throw new Error('Invalid certificate DER');
|
||||
return { tag, start: size.next, end, next: end };
|
||||
}
|
||||
function validateCaCertificate(pem: Buffer): string {
|
||||
const certificate = new X509Certificate(pem);
|
||||
const now = Date.now();
|
||||
if (!Number.isFinite(Date.parse(certificate.validFrom)) || !Number.isFinite(Date.parse(certificate.validTo)) ||
|
||||
now < Date.parse(certificate.validFrom) || now > Date.parse(certificate.validTo)) {
|
||||
throw new Error('PostgreSQL CA certificate is not currently valid');
|
||||
}
|
||||
if (certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) {
|
||||
throw new Error('PostgreSQL CA certificate must be a valid self-signed trust anchor');
|
||||
}
|
||||
const key = certificate.publicKey;
|
||||
if (key.asymmetricKeyType !== 'rsa' || (key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) {
|
||||
throw new Error('PostgreSQL CA certificate key must be RSA-2048 or stronger');
|
||||
}
|
||||
const der = certificate.raw;
|
||||
const outer = derElement(der, 0);
|
||||
if (outer.tag !== 0x30) throw new Error('Invalid PostgreSQL CA certificate DER');
|
||||
const tbs = derElement(der, outer.start);
|
||||
const signatureAlgorithm = derElement(der, tbs.next);
|
||||
if (signatureAlgorithm.tag !== 0x30) throw new Error('Invalid PostgreSQL CA signature algorithm');
|
||||
const oid = derElement(der, signatureAlgorithm.start);
|
||||
if (oid.tag !== 0x06) throw new Error('Invalid PostgreSQL CA signature algorithm');
|
||||
const signatureOid = der.subarray(oid.start, oid.end).toString('hex');
|
||||
const weakSignatureOids = new Set(['2a864886f70d010104', '2b0e03021a', '2b0e03021b']);
|
||||
if (weakSignatureOids.has(signatureOid)) throw new Error('PostgreSQL CA certificate uses a weak signature algorithm');
|
||||
return pem.toString('utf8');
|
||||
}
|
||||
async function publicCaFile(path: string | undefined): Promise<string | undefined> {
|
||||
if (!path) return undefined;
|
||||
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const info = await file.stat();
|
||||
if (!info.isFile() || info.size < 1 || info.size > 64 * 1024) throw new Error('Invalid PostgreSQL CA file');
|
||||
// A CA certificate is public trust material; mode 0644 is expected. It is not a database credential.
|
||||
const bytes = await file.readFile();
|
||||
return validateCaCertificate(bytes);
|
||||
} finally { await file.close(); }
|
||||
}
|
||||
/** No URL logging, no TLS options in a connection string overriding verified transport. */
|
||||
export async function postgresConfig(env: NodeJS.ProcessEnv = process.env): Promise<PoolConfig> {
|
||||
try {
|
||||
@@ -33,7 +90,7 @@ export async function postgresConfig(env: NodeJS.ProcessEnv = process.env): Prom
|
||||
if (insecure && !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) throw new Error('Non-TLS test connections must be loopback');
|
||||
if (!insecure && (!url.username || !url.password)) throw new Error('PostgreSQL credentials required');
|
||||
if (url.port && (!/^\d+$/.test(url.port) || Number(url.port) < 1 || Number(url.port) > 65535)) throw new Error('Invalid PostgreSQL port');
|
||||
const ca = env.WIKIDECK_CUSTODY_PG_CA_FILE ? await secretFile(env.WIKIDECK_CUSTODY_PG_CA_FILE) : undefined;
|
||||
const ca = await publicCaFile(env.WIKIDECK_CUSTODY_PG_CA_FILE);
|
||||
return { host: url.hostname.replace(/^\[|\]$/g, ''), port: url.port ? Number(url.port) : 5432,
|
||||
database: url.pathname.slice(1), user: decodeURIComponent(url.username), password: decodeURIComponent(url.password),
|
||||
// Infrastructure mode is explicit plaintext on the driver's hop. It does NOT configure or
|
||||
|
||||
@@ -964,6 +964,42 @@ export async function grantPilotMoneroObserverRuntime(pool: Pool, role: string,
|
||||
finally { c.release(); }
|
||||
}
|
||||
|
||||
/** Read-only pilot review role. It can inspect persisted evidence, but not observer or financial state. */
|
||||
export async function grantPilotMoneroDepositReviewRuntime(pool: Pool, role: string,
|
||||
addressRole = 'pilot_deposit_address', reservationRole = 'pilot_deposit_reservation',
|
||||
observerRole = 'pilot_monero_observer', custodyRuntimeRole = 'custody_runtime',
|
||||
creditRole = 'monero_deposit_credit'): Promise<void> {
|
||||
if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid pilot deposit review role identifier');
|
||||
if ([addressRole, reservationRole, observerRole, custodyRuntimeRole, creditRole].includes(role)) {
|
||||
throw new Error('Pilot deposit review role must be separate from observer, allocator, custody, and credit roles');
|
||||
}
|
||||
const name = `"${role}"`;
|
||||
const c = await pool.connect();
|
||||
try {
|
||||
await c.query('BEGIN'); await verifyCustodySchema(c);
|
||||
const r = await c.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolreplication,rolbypassrls FROM pg_roles WHERE rolname=$1', [role]);
|
||||
if (!r.rowCount || Object.values(r.rows[0]).some(Boolean)) throw new Error('Pilot deposit review role must exist without administrative privileges');
|
||||
const memberships = await c.query('SELECT 1 FROM pg_auth_members WHERE member=(SELECT oid FROM pg_roles WHERE rolname=$1) LIMIT 1', [role]);
|
||||
if (memberships.rowCount) throw new Error('Pilot deposit review role must not inherit privileges through role membership');
|
||||
const owner = await c.query("SELECT pg_get_userbyid(nspowner) AS owner FROM pg_namespace WHERE nspname='wk_custody'");
|
||||
if (owner.rows[0].owner === role) throw new Error('Pilot deposit review role must not own the custody schema');
|
||||
const owned = await c.query(`SELECT 1 FROM pg_class rel JOIN pg_namespace n ON n.oid=rel.relnamespace
|
||||
WHERE n.nspname='wk_custody' AND rel.relowner=(SELECT oid FROM pg_roles WHERE rolname=$1) LIMIT 1`, [role]);
|
||||
if (owned.rowCount) throw new Error('Pilot deposit review role must not own custody tables or sequences');
|
||||
const databaseCreate = await c.query('SELECT has_database_privilege($1,current_database(),\'CREATE\') AS allowed', [role]);
|
||||
if (databaseCreate.rows[0].allowed) throw new Error('Pilot deposit review role must not create database objects');
|
||||
await c.query(`GRANT USAGE ON SCHEMA wk_custody TO ${name};
|
||||
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA wk_custody FROM ${name};
|
||||
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA wk_custody FROM ${name};
|
||||
GRANT SELECT ON wk_custody.schema_versions,wk_custody.pilot_custody_identity,
|
||||
wk_custody.pilot_monero_deposit_reservations,wk_custody.pilot_monero_observer_state,
|
||||
wk_custody.pilot_monero_observation_scans,wk_custody.pilot_monero_observations,
|
||||
wk_custody.pilot_monero_observation_events TO ${name};`);
|
||||
await c.query('COMMIT');
|
||||
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { c.release(); }
|
||||
}
|
||||
|
||||
/** Mainnet customer withdrawal-intent role. It can reserve/cancel liabilities but has no signing or relay authority. */
|
||||
export async function grantMoneroWithdrawalRuntime(pool: Pool, role: string,
|
||||
custodyRuntimeRole = 'custody_runtime', observerRole = 'monero_observer',
|
||||
|
||||
Reference in New Issue
Block a user