Signed, beneficiary-bount Monero subaddress enrollment

This commit is contained in:
2026-10-22 11:33:38 -04:00
parent 5d607c1521
commit b865807272
9 changed files with 266 additions and 28 deletions
+53 -3
View File
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import type { Pool, PoolClient } from 'pg';
export const CUSTODY_PG_VERSION = 7;
export const CUSTODY_PG_VERSION = 8;
export const CUSTODY_WRITE_LOCK = '7140824361';
export const MONERO_OBSERVER_WORKER_LOCK = '7140824362';
export const CUSTODY_SCHEMA_V1 = `
@@ -307,9 +307,25 @@ ALTER TABLE wk_custody.monero_deposit_reorg_resolutions
`;
export const CUSTODY_SCHEMA_V7_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}\n-- custody migration v6 --\n${CUSTODY_MIGRATION_V6}\n-- custody migration v7 --\n${CUSTODY_MIGRATION_V7}`).digest('hex');
export const CUSTODY_MIGRATION_V8 = `
CREATE FUNCTION wk_custody.monero_deposit_allocation_insert_guard() RETURNS trigger LANGUAGE plpgsql
SET search_path=wk_custody,pg_catalog AS $$
BEGIN
IF NEW.status<>'reserved' OR NEW.monero_address IS NOT NULL OR NEW.registered_at IS NOT NULL THEN
RAISE EXCEPTION 'Monero deposit allocations must be inserted as address-free reservations';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER monero_deposit_allocations_insert_reserved
BEFORE INSERT ON wk_custody.monero_deposit_allocations
FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_deposit_allocation_insert_guard();
REVOKE ALL ON FUNCTION wk_custody.monero_deposit_allocation_insert_guard() FROM PUBLIC;
`;
export const CUSTODY_SCHEMA_V8_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}\n-- custody migration v6 --\n${CUSTODY_MIGRATION_V6}\n-- custody migration v7 --\n${CUSTODY_MIGRATION_V7}\n-- custody migration v8 --\n${CUSTODY_MIGRATION_V8}`).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_V7_DIGEST;
export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V8_DIGEST;
/** Explicit operator migration; the HTTP runtime never creates or upgrades schema. */
export async function migrateCustody(pool: Pool): Promise<void> {
@@ -352,6 +368,11 @@ export async function migrateCustody(pool: Pool): Promise<void> {
if (current === 6) {
await c.query(CUSTODY_MIGRATION_V7);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(7,$1)', [CUSTODY_SCHEMA_V7_DIGEST]);
current = 7;
}
if (current === 7) {
await c.query(CUSTODY_MIGRATION_V8);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(8,$1)', [CUSTODY_SCHEMA_V8_DIGEST]);
}
if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application');
await verifyCustodySchema(c);
@@ -366,7 +387,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, CUSTODY_SCHEMA_V5_DIGEST, CUSTODY_SCHEMA_V6_DIGEST, CUSTODY_SCHEMA_V7_DIGEST];
CUSTODY_SCHEMA_V4_DIGEST, CUSTODY_SCHEMA_V5_DIGEST, CUSTODY_SCHEMA_V6_DIGEST, CUSTODY_SCHEMA_V7_DIGEST,
CUSTODY_SCHEMA_V8_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');
@@ -464,3 +486,31 @@ export async function grantMoneroDepositCreditRuntime(pool: Pool, role: string,
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
finally { c.release(); }
}
/** Separate API role for immutable customer-bound subaddress allocation; it cannot read balances or post journals. */
export async function grantMoneroDepositAllocatorRuntime(pool: Pool, role: string,
custodyRuntimeRole = 'custody_runtime', observerRole = 'monero_observer',
creditRole = 'monero_deposit_credit'): Promise<void> {
if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid deposit-allocator role identifier');
if ([custodyRuntimeRole, observerRole, creditRole].includes(role)) {
throw new Error('Deposit allocator requires a separate role from custody, observer, 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('Deposit allocator 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 allocator 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 allocator 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,INSERT ON wk_custody.monero_deposit_allocations TO ${name};
GRANT UPDATE(status,monero_address,registered_at) ON wk_custody.monero_deposit_allocations TO ${name};`);
await c.query('COMMIT');
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
finally { c.release(); }
}