diff --git a/Dockerfile b/Dockerfile index 3c2b55c..d2965fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ ENV VITE_WIKIDECK_NETWORK=$WIKIDECK_NETWORK RUN npm run build FROM build AS test -RUN apk add --no-cache libstdc++ postgresql-client +RUN apk add --no-cache libstdc++ postgresql-client openssl COPY --from=randomx-build /native/build/wikideck-randomx /app/native/wikideck-randomx ENV RANDOMX_BINARY=/app/native/wikideck-randomx CMD ["npm", "test"] diff --git a/docs/real-xmr-pilot-deployment.md b/docs/real-xmr-pilot-deployment.md index 921a096..115a525 100755 --- a/docs/real-xmr-pilot-deployment.md +++ b/docs/real-xmr-pilot-deployment.md @@ -18,7 +18,7 @@ The independent private chain uses a fixed pilot genesis time and its own determ This stack does **not** connect to the operator's Monero daemon, create real Monero deposit addresses, scan a wallet, approve real credits, construct or relay withdrawals, or settle NFT sales against active consensus. Having a full node supplies only the blockchain daemon; it is not a Wallet-RPC service, watch-only deposit wallet, or spending wallet. Deposit enrollment and withdrawal request flags remain rejected outside the mainnet profile, and mainnet remains separately gated. Do not expose the service publicly or solicit funds. -### Pilot custody identity and deposit reservations (schema v13) +### Pilot custody identity and deposit reservations (schema v14) Schema v10 added the immutable identity binding with separate fields for Wikideck market network/chain (`wikideck-randomx-pilot-1` and pilot chain ID) and Monero network (`mainnet`). Schema v11 adds the separate pilot reservation table; v12 adds one-time address registration; v13 persists a create-intent state before the Wallet-RPC side effect. A signed beneficiary/wallet request reserves a never-reused subaddress index transactionally, with request replay, same-beneficiary recovery, concurrent index allocation, and conflict protection. Mainnet allocation/withdrawal constraints and v1-v12 migration definitions remain unchanged. @@ -71,6 +71,16 @@ Pilot Wallet-RPC output scans are stored in separate pilot tables, not the exist The pilot observer role is separate from reservation/address roles and cannot read custody balances/journals or write credit records. Use `custody:db grant-pilot-monero-observer-runtime ` with `WIKIDECK_PILOT_CUSTODY_OPERATIONS=pilot-observation-only-no-credit` after schema migration and identity initialization. Run one operator-controlled scan with `npm run monero:pilot-observe`; it requires the protected pilot observer PostgreSQL URL/CA, Mainnet Wallet-RPC config, matching mTLS gateway config, and wallet generation/fingerprint settings. `WIKIDECK_PILOT_OBSERVER_MAX_BLOCKS` (default 5000; max 100000) bounds each pass; `WIKIDECK_PILOT_OBSERVER_OVERLAP` (default 100; max 10000) requests a rescan window. Re-run manually to advance the cursor. No background service or HTTP endpoint is enabled. +### Pilot deposit review preparation (read-only, no credit) + +An operator can now prepare a review subject from **already persisted** pilot observations. This does not run Wallet-RPC, scan the daemon, approve the output, stage a pending liability, create a journal, change balances, or expose an HTTP endpoint. It is not authorization to accept or spend funds. The separate `pilot_deposit_review` role reads only schema digests, the pilot identity/reservations, and pilot observer state/scans/observations/events; it cannot write pilot data or read financial tables. Do not reuse the observer, address, reservation, app-runtime, or credit role. The grant refuses roles with administrative powers, role memberships, custody object ownership, or database `CREATE` privilege. + +After creating that role out of band, use the schema-owner connection to run `custody:db grant-pilot-monero-review-runtime ` with `WIKIDECK_NETWORK=pilot`, `WIKIDECK_PILOT_MODE=private-mock-only`, and `WIKIDECK_PILOT_CUSTODY_OPERATIONS=read-only-pilot-review-role-only`. The separate database should already have the pilot schema and immutable identity. The grant does not issue addresses or enable crediting. + +Configure a separate review-role PostgreSQL connection via protected `WIKIDECK_PILOT_REVIEW_PG_URL_FILE` and `WIKIDECK_PILOT_REVIEW_PG_CA_FILE` files; TLS and CA validation are mandatory, and the URL's role must equal `WIKIDECK_PILOT_REVIEW_DB_ROLE`. Then run the operator-only action `custody:db pilot-deposit-prepare-review ` with `WIKIDECK_PILOT_CUSTODY_OPERATIONS=pilot-deposit-review-preparation-only`. The default confirmation floor is 10 and can be set with `WIKIDECK_PILOT_DEPOSIT_REVIEW_MIN_CONFIRMATIONS` (bounded 10–100000). Maximum observation age defaults to 300000 ms and can be set with `WIKIDECK_PILOT_DEPOSIT_REVIEW_MAX_OBSERVATION_AGE_MS` (bounded 10000–900000). The command fails closed for unallocated/mismatched outputs, low confirmations, future unlocks, stale or inconsistent scans, double-spend flags, missing/reorged/changed history, malformed atomic amounts, and identity mismatch. + +The JSON result is an unsigned operator evidence snapshot with an integrity digest—not a credit approval or finality proof. It includes public wallet/allocation identifiers and a Monero address; protect terminal capture and any saved output accordingly. Even a successful preparation proves only what the persisted observer reported at scan time. Current chain canonicality, daemon trust, wallet correctness, and finality still need independent operational checks. No posting/release action is available under the pilot profile, and cash-out/recovery remains a prerequisite before any real deposit can be accepted. + ## Initial checks The network manifest reports `profile: pilot`, `network: wikideck-randomx-pilot-1`, and `launchStatus: private-pilot-mock-only`. Verify startup refuses a missing pilot-mode opt-in and any non-mock payment setting. Deployment tests check loopback binding, distinct profile selection, database secret isolation, and absence of an in-stack Monero daemon. A successful mock deployment is only evidence for this first slice, not for daemon connectivity or real-XMR readiness. diff --git a/docs/real-xmr-pilot-plan.md b/docs/real-xmr-pilot-plan.md index fba6193..67a5c27 100755 --- a/docs/real-xmr-pilot-plan.md +++ b/docs/real-xmr-pilot-plan.md @@ -4,7 +4,7 @@ Updated: 2026-10-22. Implementation plan for a private, operator-supervised func ## Progress checkpoint — 2026-10-22 -- **Step 1 started; mock-only foundation implemented.** Added a distinct `pilot` profile with its own network/genesis identity, explicit private-mock-only startup opt-in, separate local ledger/market/custody paths and browser wallet database, and a loopback-bound PostgreSQL sandbox stack. Setup and limits are in [the pilot deployment guide](real-xmr-pilot-deployment.md). Testnet v6 and mainnet candidate identities/gates were left intact. +- **Step 1 browser smoke verified; acceptance remains partial.** The isolated Compose stack started with healthy PostgreSQL/app and completed migration; the browser showed `PRIVATE PILOT`, `RPC available`, `wikideck-randomx-pilot-1`, and mock-only/no-real-XMR status. One encrypted local pilot wallet was created and unlocked in the same browser origin. A hardened disposable Linux container, capped at one CPU core, one block, and five minutes, used its encrypted backup read-only and mined/accepted block 1 (article: Portlaoise railway station); the UI then showed height 1 and one confirmed wallet NFT. A second browser wallet was not created, and no external Monero daemon or funds path was tested. Therefore Step 1's planned two-wallet flow is not yet complete. - **Operator infrastructure update:** a Monero full node is hosted on the operator's network and is intended to be reused; the pilot does not need to deploy or synchronize another daemon. Its network mode, RPC reachability from the pilot host/container, authentication/transport boundary and sync health still need verification. This does not provide Wallet-RPC, deposit observation, address allocation or a spending wallet. - **Next integration slice implemented:** added an operator-triggered, read-only `monerod get_info` probe, opt-in Compose check service, and mocked regression coverage. The Linux test image builds and focused probe/deployment tests pass (6/6). The probe validates expected Mainnet/Stagenet identity and synchronization, but has not been run against the operator's node because its private endpoint/configuration has not been provided. It does not enable wallet access, addresses, deposits, or credits. See [the probe setup and security boundary](real-xmr-pilot-deployment.md). - **Pilot identity/schema slice implemented:** schema v10 additively records a separate identity binding `wikideck-randomx-pilot-1` + current pilot chain ID to Monero `mainnet`, with an immutable `identity-only` activation status. Schema v11 adds separate immutable pilot reservation rows. Explicit pilot-only migration, identity, and reservation-role CLI actions require exact acknowledgements, reject non-pilot profiles, and refuse reused financial databases. @@ -12,10 +12,11 @@ Updated: 2026-10-22. Implementation plan for a private, operator-supervised func - **Wallet-RPC address create/recover implemented:** the operator/library-only function verifies the configured wallet fingerprint, checks the reserved index and stable allocation label, persists a create intent before `create_address`, then reads back and validates the exact Mainnet subaddress before registering it once. Lost responses recover by the stable label without repeating creation; ambiguous missing responses remain in a durable `creating` state and stop for operator reconciliation. No HTTP enrollment, observer, credit, withdrawal, or sale path is enabled. - **Operator address command implemented:** `monero:pilot-address` loads an owner-only signed request file, requires the pilot profile plus exact operator-action acknowledgement, checks the configured custodian and watch-only wallet generation/fingerprint/account, loads the protected Wallet-RPC and mTLS configs, verifies the wallet fingerprint, and invokes the durable create/recover library. It returns the address to that one-shot operator caller only; it is not wired into server startup, HTTP, or the browser. - **Pilot observation persistence implemented:** schema v14 stores pilot-only wallet cursors, scan records, assigned/unallocated outputs, and append-only `seen`/`changed`/`reappeared`/`not-seen` events. The separate observer role matches outputs only to a registered reservation with the same wallet generation, fingerprint, account/index, and address; unknown outputs remain suspense. A one-shot bounded scanner resumes with overlap through the new `monero:pilot-observe` command. This is observation-only: no journal, credit, HTTP route, or background service is enabled. +- **Pilot deposit review preparation implemented:** an operator-only CLI action reads persisted pilot observations through a dedicated read-only PostgreSQL role and emits a pilot/Mainnet/custodian/allocation-bound review subject. It checks exact reservation and wallet binding, amount range, confirmations, unlock time, freshness, scan context, current presence, double-spend flag, and bounded append-only event history; changed/disappeared/reappeared evidence fails closed. The action is preparation-only: no signature approval, journal, pending/available balance, credit event, HTTP enrollment, or startup worker is involved. See [the deployment guide](real-xmr-pilot-deployment.md) for the separate role and command boundary. - **Validation:** focused schema and observer PostgreSQL tests passed; full disposable PostgreSQL suite passed 46/46 and full serial offline suite passed 191/191. Tests cover assigned versus suspense outputs, replay, disappearance/reappearance evidence, exact large atomic amounts, zero financial posting, and observer role denials. No operator database or live node was accessed. -- **Step 1 is not complete.** The current pilot stack does not connect to that external daemon and still has no Wallet-RPC gateway, observer, real-fund deposit or withdrawal path. It is useful for isolated UI/mock-flow work only and must not receive funds. +- **Step 1 remains incomplete only for the planned second browser wallet.** The external daemon is still not connected and there is no live-fund path. The stack remains private/mock-only and must not receive funds. - **Validation history:** earlier pilot-only checks passed 10/10. An earlier full-suite attempt had a transient RandomX peer-gossip failure; the most recent serial offline suite subsequently exited 0. -- **Steps 2–5:** not started. Existing deposit/withdrawal APIs still require the separately gated mainnet profile; the new pilot record is identity metadata only. Sale-lock transactions remain candidate-only and are not accepted by active consensus. +- **Step 2 is in progress at review-preparation only.** Actual observer setup/scans against operator infrastructure, signed credit approval/staging/release, withdrawal execution/recovery, and sale settlement remain unavailable. Existing deposit/withdrawal APIs still require the separately gated mainnet profile; sale-lock transactions remain candidate-only and are not accepted by active consensus. ## Finish line @@ -28,7 +29,7 @@ Use the existing pooled-custody architecture, PostgreSQL accounting, native Mone ## Reuse what exists - Exact atomic-XMR prices and fee calculations. -- PostgreSQL schema v11 and accounting/reorg records. +- PostgreSQL schema v14, including pilot identity, reservations, persisted observation, and append-only reorg evidence. - Signed deposit enrollment, observation worker and reviewed credit/release commands. - Signed balance reads and withdrawal reservation/cancellation APIs. - Marketplace/custody UI and executable sale-lock/accounting models. @@ -49,9 +50,9 @@ One Linux host, one authoritative market writer, one treasury tool/worker, fixed - Add one pilot deployment definition using existing Docker patterns: app, PostgreSQL, observer and private Wallet-RPC gateway. Reuse the operator-hosted full node over a verified private network connection; do not add a duplicate daemon service. Connect the observer/watch-only wallet and separate spending wallet explicitly. Keep daemon and Wallet-RPC endpoints private, authenticated and unavailable from the public Internet; supply credentials through protected files/secrets, not checked-in Compose values. - Include schema initialization, role grants and required configuration in a short setup procedure. Keep spending keys outside the web app and Wallet RPC off the public Internet. -**Progress:** A separate mock-only pilot network/profile, launch opt-in, profile-isolated local storage, PostgreSQL deployment, and opt-in read-only daemon probe now exist. The probe awaits the operator's protected endpoint config. Additive schema v11 binds the pilot chain to Monero Mainnet and durably reserves signed beneficiary-bound address indexes in a dedicated database/role. The allocator stops before Wallet-RPC: it does not create/register or return addresses, start observation, credit funds, or enable HTTP enrollment. Observer, Wallet-RPC and real-fund integrations remain outstanding. See [pilot deployment status and startup](real-xmr-pilot-deployment.md). +**Progress:** A separate mock-only pilot network/profile, launch opt-in, profile-isolated local storage, PostgreSQL deployment, and opt-in read-only daemon probe now exist. The probe awaits the operator's protected endpoint config. Additive schema v14 binds the pilot chain to Monero Mainnet, durably reserves signed beneficiary-bound address indexes, and stores pilot-only observation/reorg evidence in a dedicated database. A separate read-only CLI review preparer now checks that evidence and emits an unsigned subject without posting. No operator Wallet-RPC address, daemon observation, credit, or real-fund operation has been run. See [pilot deployment status and startup](real-xmr-pilot-deployment.md). -**Result:** start the stack, open the UI, create two wallets and obtain a test card; verify the hosted daemon's intended network, synchronization and private RPC reachability from the pilot runtime. Do not start a duplicate daemon. Prepare the watch-only/observer and separately controlled spending-wallet services while application development proceeds. A Stagenet rehearsal still requires a Stagenet daemon endpoint; an operator's Mainnet daemon cannot substitute for it. +**Observed so far:** the private stack/browser health and first wallet were exercised, and the first native RandomX proof-of-work test card was accepted by the isolated pilot chain. **Still required for this step:** create the second browser wallet and verify the two-wallet UI flow. The hosted daemon's intended network, synchronization and private RPC reachability remain unverified; do not start a duplicate daemon. A Stagenet rehearsal still requires a Stagenet daemon endpoint; an operator's Mainnet daemon cannot substitute for it. ### 2. Connect deposits to spendable balances diff --git a/scripts/custody-db.ts b/scripts/custody-db.ts index f37d6c6..6746155 100755 --- a/scripts/custody-db.ts +++ b/scripts/custody-db.ts @@ -5,7 +5,7 @@ import { postgresConfig } from '../server/market/postgres-config.ts'; import { migrateCustody, grantCustodyRuntime, grantMoneroObserverRuntime, grantMoneroDepositCreditRuntime, grantMoneroDepositAllocatorRuntime, grantMoneroWithdrawalRuntime, grantPilotDepositReservationRuntime, grantPilotDepositAddressRuntime, - grantPilotMoneroObserverRuntime } from '../server/market/postgres-schema.ts'; + grantPilotMoneroObserverRuntime, grantPilotMoneroDepositReviewRuntime } from '../server/market/postgres-schema.ts'; import { initializeMainnetCustodyIdentity, parseMainnetCustodianId } from '../server/market/mainnet-custody-identity.ts'; import { importSqliteCustody } from '../server/market/custody-import.ts'; import { PostgresCustody } from '../server/market/postgres-custody.ts'; @@ -18,21 +18,25 @@ import { assertCustodyDbActionAllowedOnNetwork, assertMainnetSchemaMigrationAckn assertPilotCustodyIdentityAcknowledged, assertPilotReservationGrantAcknowledged, assertPilotAddressGrantAcknowledged, assertPilotObserverGrantAcknowledged, + assertPilotDepositReviewAcknowledged, parseCustodyDbArguments } from '../server/market/custody-db-args.ts'; import { prepareDepositCreditReview, postReviewedDepositPendingCredit, prepareDepositCreditReleaseReview, releaseReviewedDepositCredit, reconcileDepositCreditReorgHolds, prepareDepositReorgResolution, resolveReleasedDepositReorgHold, type DepositCreditApproval, type DepositCreditReleaseApproval, type DepositReorgResolutionApproval } from '../server/market/monero-deposit-credit.ts'; +import { preparePilotMoneroDepositReview } from '../server/market/pilot-monero-deposit-review.ts'; // Explicit operator task, never invoked by node startup. No passwords or database URLs printed. const commandArgs = process.argv.slice(2); const depositActions = ['deposit-prepare-credit', 'deposit-stage-credit', 'deposit-prepare-release', 'deposit-release', 'deposit-reconcile-holds', 'deposit-prepare-resolve-hold', 'deposit-resolve-hold']; +const pilotReviewActions = ['pilot-deposit-prepare-review']; const custodyActions = ['migrate', 'migrate-mainnet-schema', 'initialize-mainnet-custody-identity', 'migrate-pilot-schema', 'initialize-pilot-custody-identity', 'grant-runtime', 'grant-monero-observer-runtime', 'grant-pilot-deposit-reservation-runtime', 'grant-pilot-deposit-address-runtime', 'grant-pilot-monero-observer-runtime', + 'grant-pilot-monero-review-runtime', 'grant-monero-withdrawal-runtime', 'grant-monero-deposit-allocator-runtime', 'grant-monero-credit-runtime', 'import-sqlite', 'audit', 'resolve-integrity']; @@ -47,12 +51,42 @@ assertPilotCustodyIdentityAcknowledged(action, process.env.WIKIDECK_PILOT_CUSTOD assertPilotReservationGrantAcknowledged(action, process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS); assertPilotAddressGrantAcknowledged(action, process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS); assertPilotObserverGrantAcknowledged(action, process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS); +assertPilotDepositReviewAcknowledged(action, process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS); if (action === 'initialize-mainnet-custody-identity' && process.env.WIKIDECK_MAINNET_CUSTODY_IDENTITY !== 'initialize-mainnet-custody-v1') { throw new Error('Mainnet custody identity initialization requires WIKIDECK_MAINNET_CUSTODY_IDENTITY=initialize-mainnet-custody-v1'); } -if (!custodyActions.includes(action) && !depositActions.includes(action)) throw new Error('Unsupported custody operation'); +if (!custodyActions.includes(action) && !depositActions.includes(action) && !pilotReviewActions.includes(action)) { + throw new Error('Unsupported custody operation'); +} + +function boundedPilotReviewSetting(name: string, fallback: number, minimum: number, maximum: number): number { + const value = process.env[name]; + if (value === undefined) return fallback; + if (!/^(0|[1-9]\d{0,8})$/.test(value)) throw new Error(`Invalid ${name}`); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) throw new Error(`Invalid ${name}`); + return parsed; +} + try { - const config = await postgresConfig(); + const reviewRole = pilotReviewActions.includes(action) ? process.env.WIKIDECK_PILOT_REVIEW_DB_ROLE : undefined; + let config; + if (pilotReviewActions.includes(action)) { + const reviewUrlFile = process.env.WIKIDECK_PILOT_REVIEW_PG_URL_FILE; + const reviewCaFile = process.env.WIKIDECK_PILOT_REVIEW_PG_CA_FILE; + if (!IS_PILOT || process.env.WIKIDECK_PILOT_MODE !== 'private-mock-only' || !reviewRole || + !/^[a-z][a-z0-9_]{0,62}$/.test(reviewRole) || !reviewUrlFile || !reviewCaFile) { + throw new Error('Pilot review requires the private mock-only profile, exact review-role name, and dedicated PostgreSQL TLS secret files'); + } + config = await postgresConfig({ ...process.env, + WIKIDECK_CUSTODY_PG_URL_FILE: reviewUrlFile, + WIKIDECK_CUSTODY_PG_CA_FILE: reviewCaFile, + WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls', + WIKIDECK_CUSTODY_PG_LOCAL_TEST: undefined, + WIKIDECK_CUSTODY_PG_INFRASTRUCTURE_ACK: undefined, + }); + if (config.user !== reviewRole) throw new Error('Pilot review PostgreSQL secret does not use the configured read-only review role'); + } else config = await postgresConfig(); if (action === 'audit') { const market = await PostgresCustody.open(config, { feeBps: 250, card: () => undefined }); try { console.log(JSON.stringify(await market.audit())); } finally { await market.close(); } @@ -110,6 +144,16 @@ try { await grantPilotMoneroObserverRuntime(pool, argument ?? ''); console.log('Pilot Monero observation-only role granted; no address allocation, credit, withdrawal, or public enrollment privileges.'); } + if (action === 'grant-pilot-monero-review-runtime') { + if (!IS_PILOT || process.env.WIKIDECK_PILOT_MODE !== 'private-mock-only' || + process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS !== 'read-only-pilot-review-role-only' || extraArgument) { + throw new Error('Pilot review role grant requires the private pilot profile, read-only-role acknowledgement, and one role name'); + } + const identity = await pool.query('SELECT 1 FROM wk_custody.pilot_custody_identity WHERE singleton=1'); + if (!identity.rowCount) throw new Error('Initialize pilot custody identity before granting review access'); + await grantPilotMoneroDepositReviewRuntime(pool, argument ?? ''); + console.log('Pilot deposit review role granted read-only pilot evidence access; financial tables and all writes remain denied.'); + } if (action === 'grant-runtime') { await grantCustodyRuntime(pool, argument ?? ''); console.log('Runtime grants applied; no credentials printed.'); } if (action === 'grant-monero-observer-runtime') { await grantMoneroObserverRuntime(pool, argument ?? ''); @@ -165,6 +209,22 @@ try { console.log(JSON.stringify(await resolveReleasedDepositReorgHold(pool, approval as DepositReorgResolutionApproval, policy))); } } + if (pilotReviewActions.includes(action)) { + if (!IS_PILOT || process.env.WIKIDECK_PILOT_MODE !== 'private-mock-only' || !argument || !extraArgument || + process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS !== 'pilot-deposit-review-preparation-only') { + throw new Error('Pilot review requires the private pilot profile, preparation-only acknowledgement, wallet generation, and observation ID'); + } + const currentUser = await pool.query('SELECT current_user AS role'); + if (currentUser.rows[0]?.role !== reviewRole) throw new Error('Pilot deposit review must use the dedicated read-only review role'); + const subject = await preparePilotMoneroDepositReview(pool, { + walletGenerationId: argument, + observationId: extraArgument, + }, { + minimumConfirmations: boundedPilotReviewSetting('WIKIDECK_PILOT_DEPOSIT_REVIEW_MIN_CONFIRMATIONS', 10, 10, 100_000), + maximumObservationAgeMs: boundedPilotReviewSetting('WIKIDECK_PILOT_DEPOSIT_REVIEW_MAX_OBSERVATION_AGE_MS', 300_000, 10_000, 15 * 60_000), + }); + console.log(JSON.stringify(subject)); + } if (action === 'import-sqlite') { if (!argument) throw new Error('Stopped source node DATA_DIR required'); const directory = resolve(argument); diff --git a/server/market/custody-db-args.ts b/server/market/custody-db-args.ts index b2ae0d5..f164cc1 100755 --- a/server/market/custody-db-args.ts +++ b/server/market/custody-db-args.ts @@ -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'); diff --git a/server/market/pilot-custody-identity.ts b/server/market/pilot-custody-identity.ts index 6745d7a..5eb93a8 100755 --- a/server/market/pilot-custody-identity.ts +++ b/server/market/pilot-custody-identity.ts @@ -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): PilotCustodyIdentity { +export function pilotCustodyIdentityFromRow(row: Record): 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; } diff --git a/server/market/pilot-monero-deposit-review.ts b/server/market/pilot-monero-deposit-review.ts new file mode 100755 index 0000000..f339782 --- /dev/null +++ b/server/market/pilot-monero-deposit-review.ts @@ -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 & { 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[], 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 { + 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(); + } +} diff --git a/server/market/postgres-config.ts b/server/market/postgres-config.ts index eb3abe5..5be2378 100755 --- a/server/market/postgres-config.ts +++ b/server/market/postgres-config.ts @@ -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 { 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 { + 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 { 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 diff --git a/server/market/postgres-schema.ts b/server/market/postgres-schema.ts index 4524ef9..846af35 100755 --- a/server/market/postgres-schema.ts +++ b/server/market/postgres-schema.ts @@ -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 { + 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', diff --git a/tests/custody-db-args.test.ts b/tests/custody-db-args.test.ts index c6f7197..dd8cffe 100755 --- a/tests/custody-db-args.test.ts +++ b/tests/custody-db-args.test.ts @@ -5,6 +5,7 @@ import { assertCustodyDbActionAllowedOnNetwork, assertMainnetSchemaMigrationAckn assertPilotSchemaMigrationAcknowledged, assertPilotCustodyIdentityAcknowledged, assertPilotReservationGrantAcknowledged, assertPilotAddressGrantAcknowledged, assertPilotObserverGrantAcknowledged, + assertPilotDepositReviewAcknowledged, parseCustodyDbArguments } from '../server/market/custody-db-args.ts'; test('custody database CLI parses two-argument deposit operations without shifting arguments', () => { @@ -23,6 +24,14 @@ test('custody database CLI parses two-argument deposit operations without shifti assert.deepEqual(parseCustodyDbArguments(['grant-monero-withdrawal-runtime', 'monero_withdrawal']), { action: 'grant-monero-withdrawal-runtime', argument: 'monero_withdrawal', extraArgument: undefined, }); + assert.deepEqual(parseCustodyDbArguments(['grant-pilot-monero-review-runtime', 'pilot_deposit_review']), { + action: 'grant-pilot-monero-review-runtime', argument: 'pilot_deposit_review', extraArgument: undefined, + }); + assert.deepEqual(parseCustodyDbArguments(['pilot-deposit-prepare-review', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + `${'b'.repeat(64)}:${'c'.repeat(64)}`]), { + action: 'pilot-deposit-prepare-review', argument: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + extraArgument: `${'b'.repeat(64)}:${'c'.repeat(64)}`, + }); assert.deepEqual(parseCustodyDbArguments(['initialize-pilot-custody-identity', 'AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA']), { action: 'initialize-pilot-custody-identity', argument: 'AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA', extraArgument: undefined, }); @@ -60,12 +69,14 @@ test('mainnet reviewed deposit operations require exact credit-only acknowledgem assert.doesNotThrow(() => assertMainnetCustodyOperationAcknowledged('grant-monero-withdrawal-runtime', undefined)); }); -test('pilot custody CLI permits only additive schema migration and identity-only initialization', () => { +test('pilot custody CLI permits only pilot-scoped schema, identity, role, and review-preparation actions', () => { assert.doesNotThrow(() => assertPilotCustodyActionAllowed('migrate-pilot-schema', true)); assert.doesNotThrow(() => assertPilotCustodyActionAllowed('initialize-pilot-custody-identity', true)); assert.doesNotThrow(() => assertPilotCustodyActionAllowed('grant-pilot-deposit-reservation-runtime', true)); - assert.throws(() => assertPilotCustodyActionAllowed('grant-monero-observer-runtime', true), /only pilot schema migration/); - assert.throws(() => assertPilotCustodyActionAllowed('deposit-stage-credit', true), /only pilot schema migration/); + assert.doesNotThrow(() => assertPilotCustodyActionAllowed('grant-pilot-monero-review-runtime', true)); + assert.doesNotThrow(() => assertPilotCustodyActionAllowed('pilot-deposit-prepare-review', true)); + assert.throws(() => assertPilotCustodyActionAllowed('grant-monero-observer-runtime', true), /pilot-specific schema/); + assert.throws(() => assertPilotCustodyActionAllowed('deposit-stage-credit', true), /pilot-specific schema/); assert.throws(() => assertPilotCustodyActionAllowed('initialize-pilot-custody-identity', false), /requires the pilot profile/); assert.throws(() => assertPilotSchemaMigrationAcknowledged('migrate-pilot-schema', undefined), /acknowledgement/); assert.throws(() => assertPilotSchemaMigrationAcknowledged('migrate-pilot-schema', 'yes'), /acknowledgement/); @@ -84,6 +95,13 @@ test('pilot custody CLI permits only additive schema migration and identity-only assert.throws(() => assertPilotObserverGrantAcknowledged('grant-pilot-monero-observer-runtime', undefined), /no-credit acknowledgement/); assert.throws(() => assertPilotObserverGrantAcknowledged('grant-pilot-monero-observer-runtime', 'reviewed-credit-only'), /no-credit acknowledgement/); assert.doesNotThrow(() => assertPilotObserverGrantAcknowledged('grant-pilot-monero-observer-runtime', 'pilot-observation-only-no-credit')); + for (const action of ['grant-pilot-monero-review-runtime', 'pilot-deposit-prepare-review'] as const) { + assert.throws(() => assertPilotDepositReviewAcknowledged(action, undefined), /acknowledgement/); + assert.throws(() => assertPilotDepositReviewAcknowledged(action, 'yes'), /acknowledgement/); + } + assert.doesNotThrow(() => assertPilotDepositReviewAcknowledged('grant-pilot-monero-review-runtime', 'read-only-pilot-review-role-only')); + assert.doesNotThrow(() => assertPilotDepositReviewAcknowledged('pilot-deposit-prepare-review', 'pilot-deposit-review-preparation-only')); + assert.throws(() => assertPilotCustodyActionAllowed('pilot-deposit-prepare-review', false), /requires the pilot profile/); }); test('custody database CLI rejects unknown, missing, empty, and excess arguments', () => { diff --git a/tests/postgres-transport.test.ts b/tests/postgres-transport.test.ts index c4aa386..06abb59 100755 --- a/tests/postgres-transport.test.ts +++ b/tests/postgres-transport.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { mkdtemp, writeFile, chmod, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; import { postgresConfig } from '../server/market/postgres-config.ts'; test('PostgreSQL verified TLS remains the default and cannot be downgraded through URL options', async t => { @@ -27,6 +28,25 @@ test('PostgreSQL verified TLS remains the default and cannot be downgraded throu await assert.rejects(postgresConfig(env), /configuration/); }); +test('PostgreSQL public CA is validated separately from owner-only credential URL files', async t => { + if (process.platform === 'win32') return; + const dir = await mkdtemp(join(tmpdir(), 'wikideck-ca-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const urlFile = join(dir, 'connection'), caFile = join(dir, 'ca.crt'), keyFile = join(dir, 'ca.key'); + await writeFile(urlFile, 'postgresql://fixture:fixture-only@database/custody', { mode: 0o600 }); + execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-sha256', '-nodes', '-days', '2', + '-subj', '/CN=fixture CA', '-keyout', keyFile, '-out', caFile], { stdio: 'ignore' }); + await chmod(urlFile, 0o600); await chmod(caFile, 0o644); await chmod(keyFile, 0o600); + const config = await postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: urlFile, WIKIDECK_CUSTODY_PG_CA_FILE: caFile }); + assert.equal((config.ssl as { rejectUnauthorized: boolean }).rejectUnauthorized, true); + assert.ok((config.ssl as { ca: string }).ca.includes('BEGIN CERTIFICATE')); + await chmod(urlFile, 0o644); + await assert.rejects(postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: urlFile, WIKIDECK_CUSTODY_PG_CA_FILE: caFile }), /configuration/); + await chmod(urlFile, 0o600); + await writeFile(caFile, 'not a certificate', { mode: 0o644 }); await chmod(caFile, 0o644); + await assert.rejects(postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: urlFile, WIKIDECK_CUSTODY_PG_CA_FILE: caFile }), /configuration/); +}); + test('infrastructure-managed transport requires exact opt-in and rejects mixed TLS/test configuration', async t => { const dir = await mkdtemp(join(tmpdir(), 'wikideck-infrastructure-')); t.after(() => rm(dir, { recursive: true, force: true })); diff --git a/tests/postgres/custody.test.ts b/tests/postgres/custody.test.ts index f9f48fe..661e7f9 100755 --- a/tests/postgres/custody.test.ts +++ b/tests/postgres/custody.test.ts @@ -17,6 +17,7 @@ import { CustodyMarket } from '../../server/market/custody.ts'; import { migrateCustody, grantCustodyRuntime, grantMoneroObserverRuntime, CUSTODY_SCHEMA_V1, CUSTODY_SCHEMA_V1_DIGEST, grantMoneroDepositCreditRuntime, grantMoneroDepositAllocatorRuntime, grantMoneroWithdrawalRuntime, grantPilotDepositReservationRuntime, grantPilotDepositAddressRuntime, grantPilotMoneroObserverRuntime, + grantPilotMoneroDepositReviewRuntime, CUSTODY_MIGRATION_V2, CUSTODY_SCHEMA_V2_DIGEST } from '../../server/market/postgres-schema.ts'; import { parseMainnetCustodianId } from '../../server/market/mainnet-custody-identity.ts'; @@ -670,6 +671,113 @@ test('pilot observations classify reserved-wallet outputs, persist replay/reorg } }); +test('pilot review preparation is read-only, identity-bound, and fails closed on ineligible evidence', async t => { + const admin = new Pool(config), database = `pilot_review_${randomUUID().replaceAll('-', '')}`; + const role = `pilot_review_${randomUUID().replaceAll('-', '')}`; + await admin.query(`CREATE DATABASE "${database}"`); + const owner = new Pool({ ...config, database }), run = promisify(execFile); + const custodianId = randomUUID(), generation = randomUUID(), fingerprint = 'c'.repeat(64), now = Date.now(); + const firstAddress = await syntheticSubaddress(31), secondAddress = await syntheticSubaddress(32); + const suspenseAddress = await syntheticSubaddress(33); + const code = `import { Pool } from 'pg'; + import { randomUUID } from 'node:crypto'; + import { CHAIN_ID } from './server/testnet/consensus.ts'; + import { initializePilotCustodyIdentity } from './server/market/pilot-custody-identity.ts'; + import { reservePilotMoneroDepositIndex } from './server/market/pilot-monero-deposit-allocation.ts'; + import { persistPilotMoneroObservationScan } from './server/market/pilot-monero-observation-store.ts'; + import { preparePilotMoneroDepositReview } from './server/market/pilot-monero-deposit-review.ts'; + const config={host:'127.0.0.1',database:process.env.DB,user:'postgres',ssl:false,max:4}; + const owner=new Pool(config), review=new Pool({...config,user:process.env.REVIEW_ROLE}); + const now=Number(process.env.NOW), generation=process.env.GENERATION, fingerprint=process.env.FINGERPRINT; + const policy={minimumConfirmations:10,maximumObservationAgeMs:30000}; + const failed=async (generationId,observationId,at=now+300,selectedPolicy=policy)=>{ + try { await preparePilotMoneroDepositReview(review,{walletGenerationId:generationId,observationId},selectedPolicy,at); return false; } + catch { return true; } + }; + try { + const identity=await initializePilotCustodyIdentity(owner,process.env.CUSTODIAN,now-100); + const reserve=async (beneficiary,requestDigest)=>{ + const intent={requestId:randomUUID(),custodianId:identity.custodianId,marketNetwork:identity.marketNetwork, + marketChainId:CHAIN_ID,moneroNetwork:'mainnet',beneficiary,walletGenerationId:generation,walletFingerprint:fingerprint, + accountIndex:0,requestDigest}; + return (await reservePilotMoneroDepositIndex(owner,identity,intent,now-50)).reservation; + }; + const first=await reserve('a'.repeat(64),'b'.repeat(64)); + const second=await reserve('c'.repeat(64),'d'.repeat(64)); + for (const [reservation,address] of [[first,process.env.FIRST_ADDRESS],[second,process.env.SECOND_ADDRESS]]) { + await owner.query("UPDATE wk_custody.pilot_monero_deposit_reservations SET status='creating',create_started_at=$2 WHERE allocation_id=$1", + [reservation.allocationId,now-40]); + await owner.query("UPDATE wk_custody.pilot_monero_deposit_reservations SET status='registered',monero_address=$2,registered_at=$3 WHERE allocation_id=$1", + [reservation.allocationId,address,now-30]); + } + const assigned={observationId:'e'.repeat(64)+':'+'f'.repeat(64),transactionId:'e'.repeat(64),address:process.env.FIRST_ADDRESS, + amountAtomicXmr:'9007199254740993',height:100,confirmations:11,timestamp:Math.floor(now/1000)-100, + unlockTime:'0',accountIndex:0,addressIndex:0,outputPublicKey:'f'.repeat(64),globalOutputIndex:50, + locked:false,doubleSpendSeen:false}; + const futureUnlock={...assigned,observationId:'1'.repeat(64)+':'+'2'.repeat(64),transactionId:'1'.repeat(64), + address:process.env.SECOND_ADDRESS,height:101,confirmations:10,unlockTime:String(Math.floor((now+60000)/1000)), + addressIndex:1,outputPublicKey:'2'.repeat(64),globalOutputIndex:51}; + const suspense={...assigned,observationId:'3'.repeat(64)+':'+'4'.repeat(64),transactionId:'3'.repeat(64), + address:process.env.SUSPENSE_ADDRESS,amountAtomicXmr:'7',height:102,confirmations:9,unlockTime:'0',addressIndex:2, + outputPublicKey:'4'.repeat(64),globalOutputIndex:52}; + const persist=(scanId,expectedScanHeight,fromHeight,throughHeight,walletHeight,incoming,scannedAt)=>persistPilotMoneroObservationScan(owner, + {scanId,walletGenerationId:generation,walletFingerprint:fingerprint,expectedScanHeight,scannedAt, + page:{network:'mainnet',walletHeight,fromHeight,throughHeight,primaryAddress:process.env.PRIMARY_ADDRESS,incoming}}); + await persist(randomUUID(),0,0,110,110,[assigned,futureUnlock,suspense],now+100); + const subject=await preparePilotMoneroDepositReview(review,{walletGenerationId:generation,observationId:assigned.observationId},policy,now+300); + const stricterFloorRejected=await failed(generation,assigned.observationId,now+300,{...policy,minimumConfirmations:12}); + const futureUnlockRejected=await failed(generation,futureUnlock.observationId); + const unallocatedRejected=await failed(generation,suspense.observationId); + const staleRejected=await failed(generation,assigned.observationId,now+40000); + const before=await owner.query('SELECT (SELECT count(*)::int FROM wk_custody.journals) AS journals, '+ + '(SELECT count(*)::int FROM wk_custody.entries) AS entries, '+ + '(SELECT count(*)::int FROM wk_custody.accounts) AS accounts, '+ + '(SELECT count(*)::int FROM wk_custody.monero_deposit_credit_events) AS credits'); + const changed={...assigned,amountAtomicXmr:'9007199254740994',confirmations:21}; + await persist(randomUUID(),110,100,120,120,[changed,{...futureUnlock,confirmations:20}],now+200); + const changedHistoryRejected=await failed(generation,assigned.observationId,now+300); + let updateDenied=false, financialReadDenied=false; + try { await review.query("UPDATE wk_custody.pilot_monero_observations SET locked=true"); } catch { updateDenied=true; } + try { await review.query('SELECT * FROM wk_custody.journals'); } catch { financialReadDenied=true; } + console.log(JSON.stringify({subject,stricterFloorRejected,futureUnlockRejected,unallocatedRejected,staleRejected, + changedHistoryRejected,before:before.rows[0],updateDenied,financialReadDenied})); + } finally { await review.end(); await owner.end(); }`; + try { + await migrateCustody(owner); + await admin.query(`CREATE ROLE "${role}" LOGIN`); + await grantPilotMoneroDepositReviewRuntime(owner, role); + const privileges = await owner.query(`SELECT + has_table_privilege($1,'wk_custody.pilot_monero_observations','SELECT') AS read_observations, + has_table_privilege($1,'wk_custody.pilot_monero_observations','UPDATE') AS write_observations, + has_table_privilege($1,'wk_custody.pilot_monero_observation_events','INSERT') AS append_events, + has_table_privilege($1,'wk_custody.accounts','SELECT') AS read_accounts, + has_table_privilege($1,'wk_custody.journals','SELECT') AS read_journals`, [role]); + assert.deepEqual(Object.values(privileges.rows[0]), [true, false, false, false, false]); + const result = JSON.parse((await run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], { + env: { ...process.env, WIKIDECK_NETWORK: 'pilot', WIKIDECK_PILOT_MODE: 'private-mock-only', DB: database, + REVIEW_ROLE: role, CUSTODIAN: custodianId, GENERATION: generation, FINGERPRINT: fingerprint, NOW: String(now), + FIRST_ADDRESS: firstAddress, SECOND_ADDRESS: secondAddress, SUSPENSE_ADDRESS: suspenseAddress, + PRIMARY_ADDRESS: ADDITIONAL_MARKET_FEE.address }, + })).stdout.trim()); + assert.equal(result.subject.policyVersion, 'pilot-monero-deposit-review-v1'); + assert.equal(result.subject.preparationOnly, true); assert.equal(result.subject.crediting, 'disabled'); + assert.equal(result.subject.marketNetwork, 'wikideck-randomx-pilot-1'); assert.equal(result.subject.moneroNetwork, 'mainnet'); + assert.equal(result.subject.amountAtomicXmr, '9007199254740993'); + assert.equal(result.subject.confirmations, 11); assert.equal(result.subject.minimumConfirmations, 10); + assert.equal(result.subject.eventHistoryCount, 1); assert.match(result.subject.subjectDigest, /^[a-f0-9]{64}$/); + assert.equal(result.stricterFloorRejected, true); assert.equal(result.futureUnlockRejected, true); + assert.equal(result.unallocatedRejected, true); assert.equal(result.staleRejected, true); + assert.equal(result.changedHistoryRejected, true); assert.equal(result.updateDenied, true); + assert.equal(result.financialReadDenied, true); + assert.deepEqual(result.before, { journals: 0, entries: 0, accounts: 0, credits: 0 }); + } finally { + await owner.end(); + await admin.query(`DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`); + await admin.query(`DROP ROLE IF EXISTS "${role}"`); + await admin.end(); + } +}); + test('mainnet custody identity bootstrap is one-time, chain-bound, and does not open withdrawals', async t => { assert.equal(parseMainnetCustodianId('AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA'), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); assert.throws(() => parseMainnetCustodianId('not-a-uuid'), /Invalid mainnet custodian UUID/);