Verified incoming transfers against Monero Wallet PRC

This commit is contained in:
2026-10-21 21:34:46 -04:00
parent af646dcc32
commit 9f82f050b4
13 changed files with 605 additions and 17 deletions
+97 -3
View File
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import type { Pool, PoolClient } from 'pg';
export const CUSTODY_PG_VERSION = 4;
export const CUSTODY_PG_VERSION = 5;
export const CUSTODY_WRITE_LOCK = '7140824361';
export const CUSTODY_SCHEMA_V1 = `
CREATE SCHEMA IF NOT EXISTS wk_custody;
@@ -198,9 +198,65 @@ CREATE UNIQUE INDEX monero_observations_output_key
`;
export const CUSTODY_SCHEMA_V4_DIGEST = createHash('sha256')
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}`).digest('hex');
export const CUSTODY_MIGRATION_V5 = `
-- Deferred balance enforcement runs with the schema owner's read access; the
-- fixed search_path and static table reference keep the trigger safe for the
-- narrowly privileged pending-credit coordinator.
ALTER FUNCTION wk_custody.balanced_journal() SECURITY DEFINER;
CREATE TABLE wk_custody.monero_deposit_credit_events(
credit_id uuid PRIMARY KEY,
wallet_fingerprint text NOT NULL,
observation_id text NOT NULL,
allocation_id uuid NOT NULL REFERENCES wk_custody.monero_deposit_allocations(allocation_id),
beneficiary text NOT NULL CHECK(beneficiary ~ '^[a-f0-9]{64}$'),
amount_atomic_xmr numeric(20,0) NOT NULL CHECK(amount_atomic_xmr>0 AND amount_atomic_xmr<=18446744073709551615),
journal_id text NOT NULL UNIQUE REFERENCES wk_custody.journals(id),
observation_digest text NOT NULL CHECK(observation_digest ~ '^[a-f0-9]{64}$'),
last_observation_event_sequence bigint NOT NULL CHECK(last_observation_event_sequence>=0),
reviewer_owner text NOT NULL CHECK(reviewer_owner ~ '^[a-f0-9]{64}$'),
reviewer_public_key text NOT NULL CHECK(length(reviewer_public_key) BETWEEN 1 AND 256),
review_nonce uuid NOT NULL,
review_expires_at bigint NOT NULL CHECK(review_expires_at>=0),
review_signature text NOT NULL CHECK(length(review_signature) BETWEEN 1 AND 256),
review_subject jsonb NOT NULL,
review_digest text NOT NULL CHECK(review_digest ~ '^[a-f0-9]{64}$'),
credited_at bigint NOT NULL CHECK(credited_at>=0),
minimum_confirmations integer NOT NULL CHECK(minimum_confirmations>=10),
UNIQUE(wallet_fingerprint,observation_id),
UNIQUE(reviewer_owner,review_nonce),
FOREIGN KEY(wallet_fingerprint,observation_id)
REFERENCES wk_custody.monero_transfer_observations(wallet_fingerprint,observation_id)
);
ALTER TABLE wk_custody.monero_observation_events
ADD CONSTRAINT monero_observation_event_identity UNIQUE(sequence,wallet_fingerprint,observation_id,event_type);
CREATE TABLE wk_custody.monero_deposit_reorg_holds(
hold_id uuid PRIMARY KEY,
credit_id uuid NOT NULL REFERENCES wk_custody.monero_deposit_credit_events(credit_id),
wallet_fingerprint text NOT NULL,
observation_id text NOT NULL,
observation_event_sequence bigint NOT NULL REFERENCES wk_custody.monero_observation_events(sequence),
event_type text NOT NULL CHECK(event_type IN ('changed','not-seen','reappeared')),
created_at bigint NOT NULL CHECK(created_at>=0),
UNIQUE(credit_id,observation_event_sequence),
FOREIGN KEY(observation_event_sequence,wallet_fingerprint,observation_id,event_type)
REFERENCES wk_custody.monero_observation_events(sequence,wallet_fingerprint,observation_id,event_type)
);
CREATE INDEX monero_deposit_holds_credit ON wk_custody.monero_deposit_reorg_holds(credit_id,observation_event_sequence);
CREATE FUNCTION wk_custody.monero_deposit_credit_history_immutable() RETURNS trigger LANGUAGE plpgsql
SET search_path=wk_custody,pg_catalog AS $$
BEGIN RAISE EXCEPTION 'Monero deposit credit/reorg history is append-only'; END $$;
CREATE TRIGGER monero_deposit_credit_events_no_update BEFORE UPDATE OR DELETE ON wk_custody.monero_deposit_credit_events
FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_deposit_credit_history_immutable();
CREATE TRIGGER monero_deposit_reorg_holds_no_update BEFORE UPDATE OR DELETE ON wk_custody.monero_deposit_reorg_holds
FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_deposit_credit_history_immutable();
REVOKE ALL ON wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds FROM PUBLIC;
REVOKE ALL ON FUNCTION wk_custody.monero_deposit_credit_history_immutable() FROM PUBLIC;
`;
export const CUSTODY_SCHEMA_V5_DIGEST = createHash('sha256')
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}\n-- custody migration v5 --\n${CUSTODY_MIGRATION_V5}`).digest('hex');
/** Retained alias for callers that need the initial schema definition in disposable fixtures. */
export const CUSTODY_SCHEMA = CUSTODY_SCHEMA_V1;
export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V4_DIGEST;
export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V5_DIGEST;
/** Explicit operator migration; the HTTP runtime never creates or upgrades schema. */
export async function migrateCustody(pool: Pool): Promise<void> {
@@ -228,6 +284,11 @@ export async function migrateCustody(pool: Pool): Promise<void> {
if (current === 3) {
await c.query(CUSTODY_MIGRATION_V4);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(4,$1)', [CUSTODY_SCHEMA_V4_DIGEST]);
current = 4;
}
if (current === 4) {
await c.query(CUSTODY_MIGRATION_V5);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(5,$1)', [CUSTODY_SCHEMA_V5_DIGEST]);
}
if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application');
await verifyCustodySchema(c);
@@ -241,7 +302,8 @@ export async function verifyCustodySchema(c: PoolClient): Promise<void> {
}
async function verifyCustodySchemaHistory(c: PoolClient): Promise<number> {
const rows = await c.query('SELECT version,digest FROM wk_custody.schema_versions ORDER BY version');
const expected = [CUSTODY_SCHEMA_V1_DIGEST, CUSTODY_SCHEMA_V2_DIGEST, CUSTODY_SCHEMA_V3_DIGEST, CUSTODY_SCHEMA_V4_DIGEST];
const expected = [CUSTODY_SCHEMA_V1_DIGEST, CUSTODY_SCHEMA_V2_DIGEST, CUSTODY_SCHEMA_V3_DIGEST,
CUSTODY_SCHEMA_V4_DIGEST, CUSTODY_SCHEMA_V5_DIGEST];
if (!rows.rowCount || rows.rowCount > expected.length || rows.rows.some((row, index) =>
Number(row.version) !== index + 1 || row.digest !== expected[index])) {
throw new Error('Custody PostgreSQL schema version/digest history mismatch; explicit migration required');
@@ -301,3 +363,35 @@ export async function grantMoneroObserverRuntime(pool: Pool, role: string, custo
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
finally { c.release(); }
}
/** Separate least-privilege role for reviewed pending-credit posting and deposit reorg evidence. */
export async function grantMoneroDepositCreditRuntime(pool: Pool, role: string,
custodyRuntimeRole = 'custody_runtime', observerRole = 'monero_observer'): Promise<void> {
if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid deposit-credit role identifier');
if (role === custodyRuntimeRole || role === observerRole) {
throw new Error('Deposit-credit coordinator requires a separate role from custody runtime and observer');
}
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('Deposit-credit 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('Deposit-credit 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('Deposit-credit role must not own the custody schema');
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.custody_meta,wk_custody.monero_observer_state,wk_custody.monero_transfer_observations,
wk_custody.monero_observation_events,wk_custody.monero_deposit_allocations,
wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds TO ${name};
GRANT SELECT(id,normal) ON wk_custody.accounts TO ${name};
GRANT INSERT(id,normal) ON wk_custody.accounts TO ${name};
GRANT INSERT(id,kind,created) ON wk_custody.journals TO ${name};
GRANT INSERT ON wk_custody.entries,wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds TO ${name};`);
await c.query('COMMIT');
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
finally { c.release(); }
}