Signed, beneficiary-bount Monero subaddress enrollment
This commit is contained in:
@@ -3,6 +3,7 @@ export const CUSTODY_DB_ACTION_ARGUMENT_COUNTS = {
|
||||
'migrate-mainnet-schema': 0,
|
||||
'grant-runtime': 1,
|
||||
'grant-monero-observer-runtime': 1,
|
||||
'grant-monero-deposit-allocator-runtime': 1,
|
||||
'grant-monero-credit-runtime': 1,
|
||||
'import-sqlite': 1,
|
||||
audit: 0,
|
||||
@@ -23,8 +24,9 @@ export function assertCustodyDbActionAllowedOnNetwork(action: CustodyDbAction, i
|
||||
if (action === 'migrate-mainnet-schema' && !isMainnet) {
|
||||
throw new Error('Mainnet schema migration action requires the mainnet profile');
|
||||
}
|
||||
if (isMainnet && action !== 'grant-monero-observer-runtime' && action !== 'migrate-mainnet-schema') {
|
||||
throw new Error('Only the separately scoped Monero observation-role grant is allowed on mainnet');
|
||||
if (isMainnet && action !== 'grant-monero-observer-runtime' && action !== 'grant-monero-deposit-allocator-runtime' &&
|
||||
action !== 'migrate-mainnet-schema') {
|
||||
throw new Error('Only explicitly scoped Monero infrastructure-role grants and schema migration are allowed on mainnet');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { address, verifySignature } from '../testnet/consensus.ts';
|
||||
import { moneroDepositAddressRequestSchema, moneroDepositAddressMessage } from '../../shared/monero-deposit.ts';
|
||||
import { createOrRecoverMoneroDepositSubaddress, reserveMoneroDepositAllocation } from './monero-deposit-allocation-store.ts';
|
||||
import type { MoneroDepositAddressRpc } from './monero-observer.ts';
|
||||
|
||||
const MAX_REQUEST_LIFETIME_MS = 5 * 60_000;
|
||||
|
||||
type MainnetEnrollmentContext = {
|
||||
custodianId: string;
|
||||
chainId: string;
|
||||
walletGenerationId: string;
|
||||
walletFingerprint: string;
|
||||
accountIndex: number;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
export type MoneroDepositAddressResult = {
|
||||
network: 'mainnet';
|
||||
allocationId: string;
|
||||
walletGenerationId: string;
|
||||
addressIndex: number;
|
||||
address: string;
|
||||
replayed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify an expiring customer-wallet signature and durably allocate that customer's one mainnet
|
||||
* subaddress. This only enrolls an address; it never credits a balance, starts a sale, or moves XMR.
|
||||
* Do not expose a public route until the matching credit, withdrawal, and recovery paths are ready.
|
||||
*/
|
||||
export async function enrollMoneroDepositAddress(pool: Pool, rpc: MoneroDepositAddressRpc,
|
||||
context: MainnetEnrollmentContext, rawRequest: unknown): Promise<MoneroDepositAddressResult> {
|
||||
const request = moneroDepositAddressRequestSchema.parse(rawRequest);
|
||||
if (request.network !== 'wikideck-randomx-mainnet-1' || request.custodianId !== context.custodianId ||
|
||||
request.chainId !== context.chainId || request.walletGenerationId !== context.walletGenerationId ||
|
||||
request.walletFingerprint !== context.walletFingerprint || request.accountIndex !== context.accountIndex) {
|
||||
throw new Error('Deposit address request does not match this mainnet custodian and wallet generation');
|
||||
}
|
||||
if (address(request.publicKey) !== request.actor) throw new Error('Deposit address signer does not own the requested beneficiary');
|
||||
const { signature, ...unsigned } = request;
|
||||
const message = moneroDepositAddressMessage(unsigned);
|
||||
if (!verifySignature(request.publicKey, request.actor, message, signature)) {
|
||||
throw new Error('Deposit address request signature is invalid');
|
||||
}
|
||||
const now = context.now?.() ?? Date.now();
|
||||
if (!Number.isSafeInteger(now) || now < 0 || request.expiresAt < now ||
|
||||
request.expiresAt > now + MAX_REQUEST_LIFETIME_MS) {
|
||||
throw new Error('Deposit address request is expired or outside the allowed window');
|
||||
}
|
||||
|
||||
const { allocation } = await reserveMoneroDepositAllocation(pool, {
|
||||
requestId: request.requestId,
|
||||
custodianId: context.custodianId,
|
||||
marketNetwork: 'wikideck-randomx-mainnet-1',
|
||||
marketChainId: context.chainId,
|
||||
beneficiary: request.actor,
|
||||
walletGenerationId: context.walletGenerationId,
|
||||
walletFingerprint: context.walletFingerprint,
|
||||
accountIndex: context.accountIndex,
|
||||
reservedAt: now,
|
||||
});
|
||||
const registered = await createOrRecoverMoneroDepositSubaddress(pool, rpc, allocation.allocationId,
|
||||
context.walletGenerationId, now);
|
||||
if (registered.allocation.beneficiary !== request.actor || registered.allocation.custodianId !== context.custodianId ||
|
||||
registered.allocation.marketChainId !== context.chainId || registered.allocation.moneroNetwork !== 'mainnet' ||
|
||||
registered.allocation.status !== 'registered' || !registered.allocation.moneroAddress) {
|
||||
throw new Error('Registered Monero deposit address does not match the signed beneficiary allocation');
|
||||
}
|
||||
return { network: 'mainnet', allocationId: registered.allocation.allocationId,
|
||||
walletGenerationId: registered.allocation.walletGenerationId,
|
||||
addressIndex: registered.allocation.addressIndex, address: registered.allocation.moneroAddress,
|
||||
replayed: registered.replayed };
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user