diff --git a/mobile_app/app/dev/arcium-beacon.tsx b/mobile_app/app/dev/arcium-beacon.tsx new file mode 100644 index 00000000..2a072863 --- /dev/null +++ b/mobile_app/app/dev/arcium-beacon.tsx @@ -0,0 +1,18 @@ +/** + * Dev-only route for the Arcium beacon-privacy operator screen. + * + * The screen + its arcium-service imports are loaded via a require() gated on + * __DEV__ (a build-time constant). In production builds __DEV__ is statically + * false, so the branch — and therefore the require — is dead-code-eliminated: + * the dev screen never imports, initializes, renders, or ships behind a live + * code path. Reachable in dev via `anonmesh://dev/arcium-beacon`. + */ +import React from 'react'; +import { Redirect } from 'expo-router'; + +export default function ArciumBeaconRoute() { + if (!__DEV__) return ; + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require + const ArciumBeaconScreen = require('@/components/dev/ArciumBeaconScreen').default; + return ; +} diff --git a/mobile_app/components/dev/ArciumBeaconScreen.tsx b/mobile_app/components/dev/ArciumBeaconScreen.tsx new file mode 100644 index 00000000..6eb70b31 --- /dev/null +++ b/mobile_app/components/dev/ArciumBeaconScreen.tsx @@ -0,0 +1,185 @@ +/** + * Arcium beacon-privacy operator screen (dev only). + * Drives register -> bind -> init stats -> record relay -> decrypt count against + * the live anonbeta1 program on devnet, showing each phase. + * + * This component lives outside app/ and is loaded only by the __DEV__-gated + * require() in app/dev/arcium-beacon.tsx, so it (and its arcium-service imports) + * never execute in production builds. + */ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Stack } from 'expo-router'; + +import { fontFamily, fontSize, useTheme } from '@/theme'; +import { useWallet } from '@/context/WalletContext'; +import { useNetworkMode } from '@/src/hooks/useNetworkMode'; +import { + registerBeacon, waitForBindingVerified, initRelayStats, + recordRelay, waitForRelayRecorded, getBeaconStatus, getDecryptedRelayCount, + type BeaconStatus, +} from '@/src/services/arcium'; +import { runCryptoSelfTest, type SelfTestResult } from '@/src/services/arcium/selfTest'; + +export default function ArciumBeaconScreen() { + const { colors } = useTheme(); + const { wallet, publicKey, isConnected } = useWallet(); + const { adapter: rpcAdapter } = useNetworkMode(); + + const [status, setStatus] = useState(null); + const [count, setCount] = useState(null); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const [lastTx, setLastTx] = useState(null); + const [selfTest, setSelfTest] = useState(null); + + const onSelfTest = useCallback(() => { + const result = runCryptoSelfTest(); + setSelfTest(result); + // logged so it can be captured via logcat during automated on-device runs + console.log('[arcium self-test]', result.pass ? 'PASS' : 'FAIL', JSON.stringify(result.lines)); + }, []); + + const ctx = useMemo( + () => (wallet && rpcAdapter ? { walletAdapter: wallet, rpcAdapter } : null), + [wallet, rpcAdapter], + ); + + const refresh = useCallback(async () => { + if (!ctx) return; + try { + setStatus(await getBeaconStatus(ctx)); + setCount(await getDecryptedRelayCount(ctx)); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, [ctx]); + + useEffect(() => { + if (ctx && isConnected) void refresh(); + }, [ctx, isConnected, refresh]); + + const run = useCallback( + (phase: string, fn: () => Promise) => async () => { + if (!ctx) return; + setError(null); + setBusy(phase); + try { + await fn(); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(null); + } + }, + [ctx, refresh], + ); + + const onRegister = run('Registering beacon + Arcium binding (MPC)…', async () => { + const res = await registerBeacon(ctx!); + setLastTx(res.signature); + await waitForBindingVerified(ctx!); + }); + const onInitStats = run('Initializing encrypted relay stats…', async () => { + const res = await initRelayStats(ctx!); + setLastTx(res.signature); + }); + const onRecordRelay = run('Recording relay + Arcium increment (MPC)…', async () => { + const res = await recordRelay(ctx!); + setLastTx(res.signature); + await waitForRelayRecorded(ctx!); + }); + + const s = StyleSheet.create({ + body: { padding: 16, gap: 14 }, + title: { fontFamily: fontFamily.sansSb, fontSize: fontSize.lg, color: colors.textPrimary }, + blurb: { fontFamily: fontFamily.sans, fontSize: fontSize.sm, color: colors.textSecondary, lineHeight: 19 }, + card: { backgroundColor: colors.surface1, borderColor: colors.borderSubtle, borderWidth: 1, borderRadius: 12, padding: 14, gap: 8 }, + row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + label: { fontFamily: fontFamily.sans, fontSize: fontSize.sm, color: colors.textTertiary }, + value: { fontFamily: fontFamily.sansMd, fontSize: fontSize.sm, color: colors.textPrimary }, + btn: { backgroundColor: colors.primary, borderRadius: 10, paddingVertical: 13, alignItems: 'center' }, + btnGhost: { backgroundColor: 'transparent', borderColor: colors.borderSubtle, borderWidth: 1 }, + btnText: { fontFamily: fontFamily.sansSb, fontSize: fontSize.md, color: colors.background }, + btnTextGhost: { color: colors.textPrimary }, + disabled: { opacity: 0.4 }, + busy: { flexDirection: 'row', gap: 10, alignItems: 'center' }, + busyText: { fontFamily: fontFamily.sans, fontSize: fontSize.sm, color: colors.textSecondary, flex: 1 }, + err: { fontFamily: fontFamily.sansMd, fontSize: fontSize.sm, color: '#dc2626' }, + mono: { fontFamily: fontFamily.sans, fontSize: fontSize.xs, color: colors.textTertiary }, + }); + + const Btn = ({ label, onPress, disabled, ghost }: { label: string; onPress: () => void; disabled?: boolean; ghost?: boolean }) => ( + + {label} + + ); + + const StatusRow = ({ label, value }: { label: string; value: string }) => ( + {label}{value} + ); + + return ( + + + + Beacon privacy + + Register this wallet as a relay beacon. The RNS destination is encrypted and bound via + Arcium MPC (never public), and relay activity is counted in an encrypted on-chain + counter only you can decrypt. Runs against anonbeta1 on devnet. + + + + {selfTest && ( + + + {selfTest.pass ? 'SELF-TEST PASS ✓' : 'SELF-TEST FAIL ✗'} + + {selfTest.lines.map((l) => ( + + {l.name} + {l.ok ? '✓' : '✗'} + + ))} + + )} + + {!isConnected || !ctx ? ( + Connect a wallet first. + ) : ( + <> + + + + + + + + + + {busy && ( + + + {busy} + + )} + {error && {error}} + {lastTx && last tx: {lastTx.slice(0, 16)}…} + + + + + void refresh()} ghost /> + + )} + + + ); +} diff --git a/mobile_app/src/services/arcium/README.md b/mobile_app/src/services/arcium/README.md new file mode 100644 index 00000000..dba7c946 --- /dev/null +++ b/mobile_app/src/services/arcium/README.md @@ -0,0 +1,56 @@ +# Arcium beacon-privacy + +Integrates the **anonbeta1** Arcium program (Solana devnet) into the app for the +**beacon-operator privacy flow**. An operator registers a relay beacon whose RNS +destination is encrypted + bound under Arcium MPC (never public), and whose relay +throughput is tracked in an encrypted on-chain counter only the operator can decrypt. + +> Scope: operator privacy flow only. The public payment instruction +> (`execute_cosigned_transfer`) is intentionally out of scope here. + +## Layout + +| File | Role | +|------|------| +| `constants.ts` | program id, cluster offset (456), comp-def offsets, decode offsets | +| `vendor/arciumCrypto.ts` | **vendored** RescueCipher + Arcium PDA derivations (from `@arcium-hq/client@0.9.3`, node/anchor stripped, `@noble` v2). `@ts-nocheck`. Validated byte-for-byte vs the SDK. Do not hand-edit. | +| `beaconInstructions.ts` | raw `@solana/web3.js` instruction builders + account decoders (framework-agnostic) | +| `beaconKeys.ts` | operator x25519 keypair, persisted in the OS keystore | +| `beaconClient.ts` | service: register / waitForBindingVerified / initRelayStats / recordRelay / waitForRelayRecorded / getBeaconStatus / getDecryptedRelayCount | +| `__tests__/` | `crypto.test.mjs` (deterministic) + `integration.devnet.mjs` (live round-trip) | + +## Usage + +```ts +import { registerBeacon, waitForBindingVerified, initRelayStats, + recordRelay, waitForRelayRecorded, getDecryptedRelayCount } from '@/src/services/arcium'; + +const ctx = { walletAdapter, rpcAdapter }; // from useWallet().wallet + useNetworkMode().adapter +await registerBeacon(ctx); // signs register_beacon_private +await waitForBindingVerified(ctx); // polls beacon_bind MPC callback +await initRelayStats(ctx); +await recordRelay(ctx); // signs record_relay +await waitForRelayRecorded(ctx); // polls relay_increment MPC callback +const count = await getDecryptedRelayCount(ctx); // bigint, decrypted locally +``` + +## Tests + +```bash +# deterministic (no network): crypto golden vectors + instruction encoding +npx tsx src/services/arcium/__tests__/crypto.test.mjs +# live devnet round-trip of the real modules (funds a fresh operator from the CLI keypair) +npx tsx src/services/arcium/__tests__/integration.devnet.mjs +``` + +Both pass against devnet. Metro bundles the modules for Android cleanly. + +## On-device smoke (Seeker) + +1. Serve this branch over Metro and load the dev client. +2. Deep link: `anonmesh://dev/arcium-beacon`. +3. Connect a wallet, then tap **1 Register → 2 Init relay stats → 3 Record relay**. + Each MPC step shows a busy indicator (seconds–minutes); the status card shows + `binding verified ✓` and a non-zero **decrypted relay count** when done. + +Verified on devnet: program `anon7uu8UtVoFgS8GCSfw2RqyphJhkN3xEjgPwznYDe`. diff --git a/mobile_app/src/services/arcium/__tests__/crypto.test.mjs b/mobile_app/src/services/arcium/__tests__/crypto.test.mjs new file mode 100644 index 00000000..f05df1ee --- /dev/null +++ b/mobile_app/src/services/arcium/__tests__/crypto.test.mjs @@ -0,0 +1,53 @@ +/** + * Deterministic unit checks for the vendored crypto + instruction encoding. + * No network. Run: npx tsx src/services/arcium/__tests__/crypto.test.mjs + * Proves the RN-bundled TS modules match the SDK-validated golden vectors. + */ +import { PublicKey } from '@solana/web3.js'; +import { RescueCipher } from '../vendor/arciumCrypto.ts'; +import { + compDefOffset, getMxeX25519Pubkey, beaconPda, + buildRegisterBeaconInstruction, buildRecordRelayInstruction, leBytes, +} from '../beaconInstructions.ts'; +import { getMXEAccAddress } from '../vendor/arciumCrypto.ts'; +import { ANONBETA1_PROGRAM_ID, DISCRIMINATORS } from '../constants.ts'; + +let fail = 0; +const check = (name, cond) => { if (!cond) fail++; console.log(`${cond ? 'OK ' : 'FAIL'} ${name}`); }; + +// 1) RescueCipher matches the v1 SDK golden vector (fixed inputs) +const shared = new Uint8Array(32); for (let i = 0; i < 32; i++) shared[i] = i + 1; +const nonce = new Uint8Array(16); for (let i = 0; i < 16; i++) nonce[i] = (i * 7 + 3) & 0xff; +const ct = new RescueCipher(shared).encrypt([123456789n, 987654321n], nonce); +const golden = [[30,126,244,5,116,130,238,81,247,144,198,42,200,9,6,102,169,63,166,133,163,175,3,2,62,166,145,124,107,53,237,54],[128,83,228,133,167,71,3,11,26,45,215,234,234,24,189,137,132,225,9,95,8,92,233,33,195,102,78,177,39,73,239,103]]; +check('RescueCipher == SDK golden vector', JSON.stringify(ct) === JSON.stringify(golden)); +const dec = new RescueCipher(shared).decrypt(ct, nonce); +check('RescueCipher decrypt round-trip', dec[0] === 123456789n && dec[1] === 987654321n); + +// 2) derivations match known on-chain values +check('getMXEAccAddress', getMXEAccAddress(ANONBETA1_PROGRAM_ID).toBase58() === '6EiE6YSJ99qhq3bTEM8CtBqmdmBZHsMm56NZtRJ5shJL'); +check('beacon_bind comp-def offset', compDefOffset('beacon_bind') === 287562432); +check('relay_increment comp-def offset', compDefOffset('relay_increment') === 1084638176); + +// 3) instruction encoding sanity (discriminator + arg sizes) +const op = new PublicKey('96pAGQK9Fa4dD17oDH9qDw6n38aNteLEEKKakNgsYUWw'); +const reg = buildRegisterBeaconInstruction({ + operator: op, computationOffset: 1n, + encryptedRnsDestHash: new Uint8Array(32), encryptedRegionCode: new Uint8Array(32), + nonce: 1n, x25519Pubkey: new Uint8Array(32), regionCode: Uint8Array.from([0x55,0x53,0x20,0x20]), capabilitiesBitmap: 0, +}); +check('register discriminator', Buffer.from(reg.data.subarray(0, 8)).equals(Buffer.from(DISCRIMINATORS.registerBeaconPrivate))); +check('register data length (8+8+32+32+16+32+4+4=136)', reg.data.length === 136); +check('register account count = 14', reg.keys.length === 14); +check('register operator is signer+writable', reg.keys[0].pubkey.equals(op) && reg.keys[0].isSigner && reg.keys[0].isWritable); + +const rec = buildRecordRelayInstruction({ operator: op, computationOffset: 2n, relayEventHash: new Uint8Array(32).fill(1), x25519Pubkey: new Uint8Array(32) }); +check('record discriminator', Buffer.from(rec.data.subarray(0, 8)).equals(Buffer.from(DISCRIMINATORS.recordRelay))); +check('record data length (8+8+32+32=80)', rec.data.length === 80); +check('record account count = 15', rec.keys.length === 15); + +// 4) leBytes correctness +check('leBytes u64', Buffer.from(leBytes(0x0102030405060708n, 8)).toString('hex') === '0807060504030201'); + +console.log(fail === 0 ? '\n✅ crypto/encoding unit checks PASS' : `\n❌ ${fail} failure(s)`); +process.exit(fail === 0 ? 0 : 1); diff --git a/mobile_app/src/services/arcium/__tests__/integration.devnet.mjs b/mobile_app/src/services/arcium/__tests__/integration.devnet.mjs new file mode 100644 index 00000000..c45ea6af --- /dev/null +++ b/mobile_app/src/services/arcium/__tests__/integration.devnet.mjs @@ -0,0 +1,75 @@ +/** + * Devnet integration test for the ACTUAL mobile TS modules (beaconInstructions + + * vendored crypto). Signing/RPC use Node Keypair/Connection here; in the app they + * are IWalletAdapter/IRpcAdapter. Proves the shipped code path on-chain. + * + * Run: npx tsx src/services/arcium/__tests__/integration.devnet.mjs + * Needs devnet SOL — funds a fresh operator from ~/.config/solana/id.json. + */ +import * as fs from 'fs'; +import { Connection, Keypair, Transaction, SystemProgram, sendAndConfirmTransaction } from '@solana/web3.js'; +import { x25519 } from '@noble/curves/ed25519.js'; +import { randomBytes } from '@noble/hashes/utils.js'; +import { RescueCipher, deserializeLE, serializeLE } from '../vendor/arciumCrypto.ts'; +import { + buildRegisterBeaconInstruction, buildInitRelayStatsInstruction, buildRecordRelayInstruction, + getMxeX25519Pubkey, beaconPda, relayStatsPda, decodeBindingVerified, decodeRelayStats, +} from '../beaconInstructions.ts'; + +const RPC = process.env.RPC_URL || 'https://api.devnet.solana.com'; +const log = (...a) => console.log(...a); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const randU64 = () => deserializeLE(randomBytes(8)); + +async function poll(conn, addr, check, label, timeoutMs = 180000) { + const t0 = Date.now(); + while (Date.now() - t0 < timeoutMs) { + const info = await conn.getAccountInfo(addr); + if (info && check(info.data)) return; + await sleep(3000); + } + throw new Error(`timeout: ${label}`); +} + +async function main() { + const conn = new Connection(RPC, 'confirmed'); + const cli = Keypair.fromSecretKey(new Uint8Array(JSON.parse(fs.readFileSync(process.env.HOME + '/.config/solana/id.json')))); + const operator = Keypair.generate(); + log('fresh operator:', operator.publicKey.toBase58()); + await sendAndConfirmTransaction(conn, new Transaction().add(SystemProgram.transfer({ fromPubkey: cli.publicKey, toPubkey: operator.publicKey, lamports: 1.5e9 })), [cli]); + + const xpriv = x25519.utils.randomSecretKey(); + const xpub = x25519.getPublicKey(xpriv); + const mxePub = await getMxeX25519Pubkey((pk) => conn.getAccountInfo(pk)); + const cipher = new RescueCipher(x25519.getSharedSecret(xpriv, mxePub)); + + // register_beacon_private + const rnsNonce = randomBytes(16); + const ct = cipher.encrypt([deserializeLE(randomBytes(16)) % (2n ** 128n), BigInt(0x53550000)], rnsNonce); + const regIx = buildRegisterBeaconInstruction({ + operator: operator.publicKey, computationOffset: randU64(), + encryptedRnsDestHash: Uint8Array.from(ct[0]), encryptedRegionCode: Uint8Array.from(ct[1]), + nonce: deserializeLE(rnsNonce), x25519Pubkey: xpub, regionCode: Uint8Array.from([0x55, 0x53, 0x20, 0x20]), capabilitiesBitmap: 0, + }); + log('register tx:', await sendAndConfirmTransaction(conn, new Transaction().add(regIx), [operator])); + await poll(conn, beaconPda(operator.publicKey), decodeBindingVerified, 'binding_verified'); + log(' binding_verified ✓'); + + // init_relay_stats + const initNonce = randomBytes(16); + const initCt = cipher.encrypt([0n], initNonce); + const initIx = buildInitRelayStatsInstruction({ operator: operator.publicKey, initialCiphertext: Uint8Array.from(initCt[0]), initialNonce: deserializeLE(initNonce), x25519Pubkey: xpub }); + log('init_relay_stats tx:', await sendAndConfirmTransaction(conn, new Transaction().add(initIx), [operator])); + + // record_relay + const recIx = buildRecordRelayInstruction({ operator: operator.publicKey, computationOffset: randU64(), relayEventHash: randomBytes(32), x25519Pubkey: xpub }); + log('record_relay tx:', await sendAndConfirmTransaction(conn, new Transaction().add(recIx), [operator])); + await poll(conn, relayStatsPda(operator.publicKey), (d) => !decodeRelayStats(d).hasPending, 'relay_increment'); + + const stats = decodeRelayStats((await conn.getAccountInfo(relayStatsPda(operator.publicKey))).data); + const count = cipher.decrypt([stats.encryptedCount], serializeLE(stats.nonce, 16)); + log('decrypted relay count =', count[0].toString()); + if (count[0] !== 1n) throw new Error(`expected 1, got ${count[0]}`); + log('\n✅ MOBILE TS MODULES — devnet round-trip OK'); +} +main().catch((e) => { console.error('FATAL:', e.message); if (e.logs) e.logs.forEach((l) => console.error(' ', l)); process.exit(1); }); diff --git a/mobile_app/src/services/arcium/beaconClient.ts b/mobile_app/src/services/arcium/beaconClient.ts new file mode 100644 index 00000000..b8971d46 --- /dev/null +++ b/mobile_app/src/services/arcium/beaconClient.ts @@ -0,0 +1,189 @@ +/** + * Arcium beacon-operator service. Drives the privacy flow end-to-end using the + * app's wallet + RPC adapters: + * registerBeacon -> waitForBindingVerified -> initRelayStats + * -> recordRelay -> waitForRelayRecorded -> getDecryptedRelayCount + * + * All instruction-building + crypto is the devnet-proven code in + * beaconInstructions.ts + vendor/arciumCrypto.ts. Signing reuses + * signAndSubmitTransaction (local Keystore or MWA). + */ +import { PublicKey, Transaction } from '@solana/web3.js'; +import type { IWalletAdapter } from '@/src/infrastructure/wallet'; +import type { IRpcAdapter } from '@/src/infrastructure/network'; +import { signAndSubmitTransaction } from '@/src/services/sendTransaction'; +// eslint-disable-next-line import/extensions +import { RescueCipher, deserializeLE, serializeLE, randomBytes, x25519 } from './vendor/arciumCrypto'; +import { + buildRegisterBeaconInstruction, buildInitRelayStatsInstruction, buildRecordRelayInstruction, + getMxeX25519Pubkey, beaconPda, relayStatsPda, decodeBindingVerified, decodeRelayStats, +} from './beaconInstructions'; +import { BEACON_REGISTRY_OFFSETS } from './constants'; +import { getOrCreateBeaconX25519Key } from './beaconKeys'; + +const randU64 = (): bigint => deserializeLE(randomBytes(8)); +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +interface Ctx { + walletAdapter: IWalletAdapter; + rpcAdapter: IRpcAdapter; +} + +function requireOperator(walletAdapter: IWalletAdapter): PublicKey { + const pk = walletAdapter.getPublicKey(); + if (!pk) throw new Error('Wallet not connected'); + return pk; +} + +/** + * Build a RescueCipher bound to (operator x25519 secret, live MXE pubkey). + * Zeroes the operator secret + derived shared secret once the cipher has + * absorbed the key material — mirrors the secret-key hygiene in + * sendTransaction.ts (the cipher keeps its own derived key, not these buffers). + */ +async function buildCipher(rpcAdapter: IRpcAdapter, secret: Uint8Array): Promise { + const mxePub = await getMxeX25519Pubkey((pk) => rpcAdapter.getAccountInfo(pk)); + const shared = x25519.getSharedSecret(secret, mxePub); + const cipher = new RescueCipher(shared); + secret.fill(0); + shared.fill(0); + return cipher; +} + +async function submit(ctx: Ctx, ix: ReturnType, operator: PublicKey) { + const tx = new Transaction().add(ix); + return signAndSubmitTransaction({ + walletAdapter: ctx.walletAdapter, + rpcAdapter: ctx.rpcAdapter, + tx, + expectedPubkey: operator, + }); +} + +export interface RegisterOptions { + /** 16-byte mesh/RNS destination hash to bind privately. Random if omitted. */ + rnsDestHash?: Uint8Array; + /** 4-byte printable-ASCII region code. Defaults to "US ". */ + regionCode?: Uint8Array; + capabilitiesBitmap?: number; +} + +/** register_beacon_private — encrypts the destination + queues the beacon_bind MPC. */ +export async function registerBeacon(ctx: Ctx, opts: RegisterOptions = {}) { + const operator = requireOperator(ctx.walletAdapter); + const { secret, publicKey } = await getOrCreateBeaconX25519Key(); + const cipher = await buildCipher(ctx.rpcAdapter, secret); + + // TODO(product): in a real deployment, pass the operator's actual Reticulum/ + // LXMF destination hash so the Arcium binding commits to the real mesh + // identity. The random fallback below only exercises the flow (dev/devnet); + // it does NOT bind a meaningful destination. + const rns = opts.rnsDestHash ? deserializeLE(opts.rnsDestHash) % (2n ** 128n) : deserializeLE(randomBytes(16)) % (2n ** 128n); + const region = opts.regionCode ?? Uint8Array.from([0x55, 0x53, 0x20, 0x20]); // "US " + const regionU32 = BigInt(region[0] | (region[1] << 8) | (region[2] << 16) | (region[3] << 24)) & 0xffffffffn; + const nonce = randomBytes(16); + const ct = cipher.encrypt([rns, regionU32], nonce); + + const ix = buildRegisterBeaconInstruction({ + operator, + computationOffset: randU64(), + encryptedRnsDestHash: Uint8Array.from(ct[0]), + encryptedRegionCode: Uint8Array.from(ct[1]), + nonce: deserializeLE(nonce), + x25519Pubkey: publicKey, + regionCode: region, + capabilitiesBitmap: opts.capabilitiesBitmap ?? 0, + }); + return submit(ctx, ix, operator); +} + +/** Poll until the beacon_bind MPC callback flips binding_verified. */ +export async function waitForBindingVerified(ctx: Ctx, timeoutMs = 180_000): Promise { + const operator = requireOperator(ctx.walletAdapter); + const addr = beaconPda(operator); + const t0 = Date.now(); + while (Date.now() - t0 < timeoutMs) { + const info = await ctx.rpcAdapter.getAccountInfo(addr); + if (info && decodeBindingVerified(info.data)) return; + await sleep(3000); + } + throw new Error('Timed out waiting for Arcium beacon binding to verify'); +} + +/** init_relay_stats — creates the encrypted relay counter at 0. */ +export async function initRelayStats(ctx: Ctx) { + const operator = requireOperator(ctx.walletAdapter); + const { secret, publicKey } = await getOrCreateBeaconX25519Key(); + const cipher = await buildCipher(ctx.rpcAdapter, secret); + const nonce = randomBytes(16); + const ct = cipher.encrypt([0n], nonce); + const ix = buildInitRelayStatsInstruction({ + operator, + initialCiphertext: Uint8Array.from(ct[0]), + initialNonce: deserializeLE(nonce), + x25519Pubkey: publicKey, + }); + return submit(ctx, ix, operator); +} + +/** record_relay — queues the relay_increment MPC for one relay event. */ +export async function recordRelay(ctx: Ctx, relayEventHash?: Uint8Array) { + const operator = requireOperator(ctx.walletAdapter); + const { publicKey } = await getOrCreateBeaconX25519Key(); + const hash = relayEventHash ?? randomBytes(32); + const ix = buildRecordRelayInstruction({ + operator, + computationOffset: randU64(), + relayEventHash: hash, + x25519Pubkey: publicKey, + }); + return submit(ctx, ix, operator); +} + +/** Poll until the relay_increment MPC callback clears pending_relay_hash. */ +export async function waitForRelayRecorded(ctx: Ctx, timeoutMs = 180_000): Promise { + const operator = requireOperator(ctx.walletAdapter); + const addr = relayStatsPda(operator); + const t0 = Date.now(); + while (Date.now() - t0 < timeoutMs) { + const info = await ctx.rpcAdapter.getAccountInfo(addr); + if (info && !decodeRelayStats(info.data).hasPending) return; + await sleep(3000); + } + throw new Error('Timed out waiting for Arcium relay increment'); +} + +export interface BeaconStatus { + registered: boolean; + bindingVerified: boolean; + settlementCount: number; + relayStatsInitialized: boolean; +} + +/** Read public beacon state (no decryption needed). */ +export async function getBeaconStatus(ctx: Ctx): Promise { + const operator = requireOperator(ctx.walletAdapter); + const beaconInfo = await ctx.rpcAdapter.getAccountInfo(beaconPda(operator)); + const statsInfo = await ctx.rpcAdapter.getAccountInfo(relayStatsPda(operator)); + if (!beaconInfo) { + return { registered: false, bindingVerified: false, settlementCount: 0, relayStatsInitialized: false }; + } + const data = Buffer.from(beaconInfo.data); + return { + registered: true, + bindingVerified: decodeBindingVerified(data), + settlementCount: Number(data.readBigUInt64LE(BEACON_REGISTRY_OFFSETS.settlementCount)), + relayStatsInitialized: !!statsInfo, + }; +} + +/** Decrypt the operator's private relay count (only the operator can). */ +export async function getDecryptedRelayCount(ctx: Ctx): Promise { + const operator = requireOperator(ctx.walletAdapter); + const info = await ctx.rpcAdapter.getAccountInfo(relayStatsPda(operator)); + if (!info) return null; + const stats = decodeRelayStats(info.data); + const { secret } = await getOrCreateBeaconX25519Key(); + const cipher = await buildCipher(ctx.rpcAdapter, secret); + return cipher.decrypt([stats.encryptedCount], serializeLE(stats.nonce, 16))[0]; +} diff --git a/mobile_app/src/services/arcium/beaconInstructions.ts b/mobile_app/src/services/arcium/beaconInstructions.ts new file mode 100644 index 00000000..8d378029 --- /dev/null +++ b/mobile_app/src/services/arcium/beaconInstructions.ts @@ -0,0 +1,218 @@ +/** + * Raw @solana/web3.js instruction builders + account decoders for the anonbeta1 + * beacon-operator flow. Framework-agnostic (no expo/RN imports) so it is unit- + * testable in Node. The exact logic here was proven end-to-end on devnet via + * contract/scripts/beacon-roundtrip-raw.mjs. + */ +import { + PublicKey, + SystemProgram, + TransactionInstruction, + type AccountInfo, +} from '@solana/web3.js'; +// eslint-disable-next-line import/extensions +import { + getArciumProgramId, + getMXEAccAddress, + getCompDefAccAddress, + getCompDefAccOffset, + getComputationAccAddress, + getMempoolAccAddress, + getExecutingPoolAccAddress, + getClusterAccAddress, +} from './vendor/arciumCrypto'; +import { + ANONBETA1_PROGRAM_ID, + MXE_CLOCK, + MXE_FEE_POOL, + BEACON_REGISTRY_OFFSETS, + CIRCUITS, + CLUSTER_OFFSET, + DISCRIMINATORS, + MXE_X25519_PUBKEY_OFFSET, + RELAY_STATS_OFFSETS, +} from './constants'; + +const PID = ANONBETA1_PROGRAM_ID; + +// ── little-endian encoders (BigInt, no bn.js) ────────────────────────────────── +export function leBytes(value: bigint, len: number): Uint8Array { + const out = new Uint8Array(len); + let v = value; + for (let i = 0; i < len; i++) { + out[i] = Number(v & 0xffn); + v >>= 8n; + } + return out; +} +const u32le = (n: number): Uint8Array => leBytes(BigInt(n >>> 0), 4); + +// ── PDAs ─────────────────────────────────────────────────────────────────────── +const findPda = (seeds: (Buffer | Uint8Array)[]) => + PublicKey.findProgramAddressSync(seeds.map((s) => Buffer.from(s)), PID)[0]; + +export const beaconPda = (operator: PublicKey) => findPda([Buffer.from('beacon'), operator.toBuffer()]); +export const privateBindingPda = (operator: PublicKey) => findPda([Buffer.from('private_beacon'), operator.toBuffer()]); +export const relayStatsPda = (operator: PublicKey) => findPda([Buffer.from('relay_stats'), operator.toBuffer()]); +export const arciumSignerPda = () => findPda([Buffer.from('ArciumSignerAccount')]); + +export const compDefOffset = (circuit: string): number => + Buffer.from(getCompDefAccOffset(circuit)).readUInt32LE(0); + +/** + * Read the MXE x25519 encryption pubkey straight from the MXE account data. + * Replaces the SDK's getMXEPublicKey (which pulls anchor) with a raw read. + */ +export async function getMxeX25519Pubkey( + getAccountInfo: (pubkey: PublicKey) => Promise | null>, +): Promise { + const info = await getAccountInfo(getMXEAccAddress(PID)); + if (!info) throw new Error('anonbeta1 MXE account not found on this cluster'); + if (info.data.length < MXE_X25519_PUBKEY_OFFSET + 32) { + throw new Error('MXE account smaller than expected — Arcium layout may have changed'); + } + const key = new Uint8Array( + info.data.subarray(MXE_X25519_PUBKEY_OFFSET, MXE_X25519_PUBKEY_OFFSET + 32), + ); + // Offset is verified against the deployed MXE; guard against a silent wrong + // read (all-zero) that would otherwise surface as an opaque MPC decrypt + // failure far downstream. + if (key.every((b) => b === 0)) { + throw new Error( + `MXE x25519 pubkey read as all-zero at offset ${MXE_X25519_PUBKEY_OFFSET} — Arcium MXE account layout likely changed`, + ); + } + return key; +} + +type Meta = { pubkey: PublicKey; isSigner: boolean; isWritable: boolean }; +const m = (pubkey: PublicKey, isSigner: boolean, isWritable: boolean): Meta => ({ pubkey, isSigner, isWritable }); + +/** The shared Arcium account set used by queue_computation instructions. */ +function arciumAccounts(compDefOff: number, computationOffsetLe8: Uint8Array) { + return { + mxe: getMXEAccAddress(PID), + mempool: getMempoolAccAddress(CLUSTER_OFFSET), + execPool: getExecutingPoolAccAddress(CLUSTER_OFFSET), + computation: getComputationAccAddress(CLUSTER_OFFSET, computationOffsetLe8), + compDef: getCompDefAccAddress(PID, compDefOff), + cluster: getClusterAccAddress(CLUSTER_OFFSET), + }; +} + +export interface RegisterBeaconArgs { + operator: PublicKey; + computationOffset: bigint; + encryptedRnsDestHash: Uint8Array; // [u8;32] + encryptedRegionCode: Uint8Array; // [u8;32] + nonce: bigint; // u128 + x25519Pubkey: Uint8Array; // [u8;32] + regionCode: Uint8Array; // [u8;4], all printable ASCII + capabilitiesBitmap: number; // u32 +} + +export function buildRegisterBeaconInstruction(args: RegisterBeaconArgs): TransactionInstruction { + const compOffLe8 = leBytes(args.computationOffset, 8); + const a = arciumAccounts(compDefOffset(CIRCUITS.beaconBind), compOffLe8); + const data = Buffer.concat([ + DISCRIMINATORS.registerBeaconPrivate, + compOffLe8, + args.encryptedRnsDestHash, + args.encryptedRegionCode, + leBytes(args.nonce, 16), + args.x25519Pubkey, + args.regionCode, + u32le(args.capabilitiesBitmap), + ].map((x) => Buffer.from(x))); + const keys: Meta[] = [ + m(args.operator, true, true), + m(beaconPda(args.operator), false, true), + m(privateBindingPda(args.operator), false, true), + m(arciumSignerPda(), false, true), + m(a.mxe, false, false), m(a.mempool, false, true), m(a.execPool, false, true), + m(a.computation, false, true), m(a.compDef, false, false), m(a.cluster, false, true), + m(MXE_FEE_POOL, false, true), m(MXE_CLOCK, false, true), + m(SystemProgram.programId, false, false), m(getArciumProgramId(), false, false), + ]; + return new TransactionInstruction({ programId: PID, keys, data }); +} + +export interface InitRelayStatsArgs { + operator: PublicKey; + initialCiphertext: Uint8Array; // [u8;32] + initialNonce: bigint; // u128 + x25519Pubkey: Uint8Array; // [u8;32] +} + +export function buildInitRelayStatsInstruction(args: InitRelayStatsArgs): TransactionInstruction { + const data = Buffer.concat([ + DISCRIMINATORS.initRelayStats, + Buffer.from(args.initialCiphertext), + Buffer.from(leBytes(args.initialNonce, 16)), + Buffer.from(args.x25519Pubkey), + ]); + const keys: Meta[] = [ + m(args.operator, true, true), + m(beaconPda(args.operator), false, false), + m(privateBindingPda(args.operator), false, false), + m(relayStatsPda(args.operator), false, true), + m(SystemProgram.programId, false, false), + ]; + return new TransactionInstruction({ programId: PID, keys, data }); +} + +export interface RecordRelayArgs { + operator: PublicKey; + computationOffset: bigint; + relayEventHash: Uint8Array; // [u8;32], non-zero + x25519Pubkey: Uint8Array; // [u8;32] +} + +export function buildRecordRelayInstruction(args: RecordRelayArgs): TransactionInstruction { + const compOffLe8 = leBytes(args.computationOffset, 8); + const a = arciumAccounts(compDefOffset(CIRCUITS.relayIncrement), compOffLe8); + const data = Buffer.concat([ + DISCRIMINATORS.recordRelay, + compOffLe8, + Buffer.from(args.relayEventHash), + Buffer.from(args.x25519Pubkey), + ]); + const keys: Meta[] = [ + m(args.operator, true, true), + m(beaconPda(args.operator), false, true), + m(args.operator, false, false), // operator (address == payer) + m(relayStatsPda(args.operator), false, true), + m(arciumSignerPda(), false, true), + m(a.mxe, false, false), m(a.mempool, false, true), m(a.execPool, false, true), + m(a.computation, false, true), m(a.compDef, false, false), m(a.cluster, false, true), + m(MXE_FEE_POOL, false, true), m(MXE_CLOCK, false, true), + m(SystemProgram.programId, false, false), m(getArciumProgramId(), false, false), + ]; + return new TransactionInstruction({ programId: PID, keys, data }); +} + +// ── account decoders ──────────────────────────────────────────────────────────── +export const decodeBindingVerified = (data: Buffer | Uint8Array): boolean => + data[BEACON_REGISTRY_OFFSETS.bindingVerified] === 1; + +export interface DecodedRelayStats { + encryptedCount: number[]; // [u8;32] + nonce: bigint; // u128 + pendingRelayHash: Uint8Array; // [u8;32] + hasPending: boolean; +} + +export function decodeRelayStats(data: Buffer | Uint8Array): DecodedRelayStats { + const b = Buffer.from(data); + const o = RELAY_STATS_OFFSETS; + const nonceBytes = b.subarray(o.nonce, o.nonce + 16); + let nonce = 0n; + for (let i = 15; i >= 0; i--) nonce = (nonce << 8n) | BigInt(nonceBytes[i]); + const pending = b.subarray(o.pendingRelayHash, o.pendingRelayHash + 32); + return { + encryptedCount: Array.from(b.subarray(o.encryptedCount, o.encryptedCount + 32)), + nonce, + pendingRelayHash: new Uint8Array(pending), + hasPending: pending.some((x) => x !== 0), + }; +} diff --git a/mobile_app/src/services/arcium/beaconKeys.ts b/mobile_app/src/services/arcium/beaconKeys.ts new file mode 100644 index 00000000..99169283 --- /dev/null +++ b/mobile_app/src/services/arcium/beaconKeys.ts @@ -0,0 +1,43 @@ +/** + * Operator x25519 keypair for Arcium beacon privacy. The secret derives the + * shared secret used to encrypt the RNS destination and decrypt relay stats — + * it never leaves the device (stored in the OS keystore via SecureStore). + */ +// eslint-disable-next-line import/extensions +import { x25519 } from './vendor/arciumCrypto'; +import { SecureKeys, secureGet, secureSet, secureDelete } from '@/src/storage'; + +const toHex = (b: Uint8Array): string => Buffer.from(b).toString('hex'); +const fromHex = (h: string): Uint8Array => Uint8Array.from(Buffer.from(h, 'hex')); + +export interface BeaconX25519Key { + secret: Uint8Array; // 32 bytes — keep in memory only as long as needed + publicKey: Uint8Array; // 32 bytes — safe to expose +} + +/** + * Returns the operator's persisted x25519 keypair, generating + storing one on + * first use. The same key must be reused across register / init / record so the + * MPC re-encrypts results the operator can later decrypt. + */ +export async function getOrCreateBeaconX25519Key(): Promise { + const existing = await secureGet(SecureKeys.BEACON_X25519_SECRET); + if (existing) { + const secret = fromHex(existing); + return { secret, publicKey: x25519.getPublicKey(secret) }; + } + const secret = x25519.utils.randomSecretKey(); + await secureSet(SecureKeys.BEACON_X25519_SECRET, toHex(secret)); + return { secret, publicKey: x25519.getPublicKey(secret) }; +} + +/** Public key only — for status displays that don't need the secret. */ +export async function getBeaconX25519PublicKey(): Promise { + const existing = await secureGet(SecureKeys.BEACON_X25519_SECRET); + return existing ? x25519.getPublicKey(fromHex(existing)) : null; +} + +/** Wipe the operator key (e.g. on wallet reset). */ +export async function deleteBeaconX25519Key(): Promise { + await secureDelete(SecureKeys.BEACON_X25519_SECRET); +} diff --git a/mobile_app/src/services/arcium/constants.ts b/mobile_app/src/services/arcium/constants.ts new file mode 100644 index 00000000..a21a720f --- /dev/null +++ b/mobile_app/src/services/arcium/constants.ts @@ -0,0 +1,47 @@ +import { PublicKey } from '@solana/web3.js'; + +/** + * anonbeta1 Arcium program (devnet). Values verified on-chain — see + * the arcium integration notes (LOCAL_NOTES) and the proven harness in contract/scripts. + */ +export const ANONBETA1_PROGRAM_ID = new PublicKey( + 'anon7uu8UtVoFgS8GCSfw2RqyphJhkN3xEjgPwznYDe', +); + +/** Arcium cluster offset for this MXE (read from MXE.cluster on devnet). */ +export const CLUSTER_OFFSET = 456; + +/** Fixed Arcium accounts (constant addresses from the program IDL). */ +export const MXE_FEE_POOL = new PublicKey('G2sRWJvi3xoyh5k2gY49eG9L8YhAEWQPtNb1zb1GXTtC'); +export const MXE_CLOCK = new PublicKey('7EbMUTLo5DjdzbN7s8BXeZwXzEwNQb1hScfRvWg8a6ot'); + +/** + * Byte offset of the MXE x25519 encryption pubkey inside the MXE account. + * Verified against getMXEPublicKey() output on devnet. + */ +export const MXE_X25519_PUBKEY_OFFSET = 95; + +/** Anchor instruction discriminators (from target/idl/anonbeta1.json). */ +export const DISCRIMINATORS = { + registerBeaconPrivate: Uint8Array.from([124, 235, 154, 5, 22, 208, 60, 219]), + initRelayStats: Uint8Array.from([217, 238, 139, 15, 155, 33, 60, 120]), + recordRelay: Uint8Array.from([215, 191, 71, 143, 57, 225, 37, 128]), +} as const; + +/** Circuit names for comp-def offset derivation. */ +export const CIRCUITS = { + beaconBind: 'beacon_bind', + relayIncrement: 'relay_increment', +} as const; + +/** Account-data byte offsets for manual decode (post 8-byte discriminator). */ +export const BEACON_REGISTRY_OFFSETS = { + bindingVerified: 121, // bool (1) + settlementCount: 138, // u64 (8) +} as const; + +export const RELAY_STATS_OFFSETS = { + encryptedCount: 73, // [u8; 32] + nonce: 105, // u128 (16, LE) + pendingRelayHash: 193, // [u8; 32] +} as const; diff --git a/mobile_app/src/services/arcium/index.ts b/mobile_app/src/services/arcium/index.ts new file mode 100644 index 00000000..c590a4ab --- /dev/null +++ b/mobile_app/src/services/arcium/index.ts @@ -0,0 +1,4 @@ +export * from './beaconClient'; +export * from './beaconKeys'; +export * as beaconInstructions from './beaconInstructions'; +export { ANONBETA1_PROGRAM_ID, CLUSTER_OFFSET } from './constants'; diff --git a/mobile_app/src/services/arcium/selfTest.ts b/mobile_app/src/services/arcium/selfTest.ts new file mode 100644 index 00000000..7b42fd6e --- /dev/null +++ b/mobile_app/src/services/arcium/selfTest.ts @@ -0,0 +1,63 @@ +/** + * In-app crypto + encoding self-test. Runs the same assertions as + * __tests__/crypto.test.mjs but inside the React Native (Hermes) runtime, so we + * can confirm on-device that the vendored RescueCipher + instruction builders + * behave identically to Node/the SDK — the one thing off-device tests can't cover. + * Pure: no wallet, no network. Safe to run without a connected wallet. + */ +import { PublicKey } from '@solana/web3.js'; +// eslint-disable-next-line import/extensions +import { RescueCipher, getMXEAccAddress } from './vendor/arciumCrypto'; +import { + buildRegisterBeaconInstruction, buildRecordRelayInstruction, compDefOffset, leBytes, +} from './beaconInstructions'; +import { ANONBETA1_PROGRAM_ID, DISCRIMINATORS } from './constants'; + +export interface SelfTestResult { + pass: boolean; + lines: { name: string; ok: boolean }[]; +} + +const GOLDEN = [ + [30,126,244,5,116,130,238,81,247,144,198,42,200,9,6,102,169,63,166,133,163,175,3,2,62,166,145,124,107,53,237,54], + [128,83,228,133,167,71,3,11,26,45,215,234,234,24,189,137,132,225,9,95,8,92,233,33,195,102,78,177,39,73,239,103], +]; + +export function runCryptoSelfTest(): SelfTestResult { + const lines: { name: string; ok: boolean }[] = []; + const add = (name: string, ok: boolean) => lines.push({ name, ok }); + + try { + const shared = new Uint8Array(32); for (let i = 0; i < 32; i++) shared[i] = i + 1; + const nonce = new Uint8Array(16); for (let i = 0; i < 16; i++) nonce[i] = (i * 7 + 3) & 0xff; + const ct = new RescueCipher(shared).encrypt([123456789n, 987654321n], nonce); + add('RescueCipher == golden vector', JSON.stringify(ct) === JSON.stringify(GOLDEN)); + const dec = new RescueCipher(shared).decrypt(ct, nonce); + add('RescueCipher decrypt round-trip', dec[0] === 123456789n && dec[1] === 987654321n); + + add('getMXEAccAddress', getMXEAccAddress(ANONBETA1_PROGRAM_ID).toBase58() === '6EiE6YSJ99qhq3bTEM8CtBqmdmBZHsMm56NZtRJ5shJL'); + add('beacon_bind offset', compDefOffset('beacon_bind') === 287562432); + add('relay_increment offset', compDefOffset('relay_increment') === 1084638176); + + const op = new PublicKey('96pAGQK9Fa4dD17oDH9qDw6n38aNteLEEKKakNgsYUWw'); + const reg = buildRegisterBeaconInstruction({ + operator: op, computationOffset: 1n, + encryptedRnsDestHash: new Uint8Array(32), encryptedRegionCode: new Uint8Array(32), + nonce: 1n, x25519Pubkey: new Uint8Array(32), regionCode: Uint8Array.from([0x55, 0x53, 0x20, 0x20]), capabilitiesBitmap: 0, + }); + add('register discriminator', Buffer.from(reg.data.subarray(0, 8)).equals(Buffer.from(DISCRIMINATORS.registerBeaconPrivate))); + add('register data length 136', reg.data.length === 136); + add('register account count 14', reg.keys.length === 14); + + const rec = buildRecordRelayInstruction({ operator: op, computationOffset: 2n, relayEventHash: new Uint8Array(32).fill(1), x25519Pubkey: new Uint8Array(32) }); + add('record discriminator', Buffer.from(rec.data.subarray(0, 8)).equals(Buffer.from(DISCRIMINATORS.recordRelay))); + add('record data length 80', rec.data.length === 80); + add('record account count 15', rec.keys.length === 15); + + add('leBytes u64', Buffer.from(leBytes(0x0102030405060708n, 8)).toString('hex') === '0807060504030201'); + } catch (e) { + add(`threw: ${e instanceof Error ? e.message : String(e)}`, false); + } + + return { pass: lines.every((l) => l.ok), lines }; +} diff --git a/mobile_app/src/services/arcium/vendor/arciumCrypto.ts b/mobile_app/src/services/arcium/vendor/arciumCrypto.ts new file mode 100644 index 00000000..81a7dd6e --- /dev/null +++ b/mobile_app/src/services/arcium/vendor/arciumCrypto.ts @@ -0,0 +1,1146 @@ +// @ts-nocheck +/* eslint-disable */ +// VENDORED from @arcium-hq/client@0.9.3 — RescueCipher + Arcium PDA derivations only. +// node:crypto/fs/anchor stripped; @noble paths adapted for v2; validated byte-for-byte +// vs the real SDK (see contract/scripts/vendor/validate-vendor.mjs + validate-v2.test.mjs). +// DO NOT hand-edit. Re-vendor from the proven module if @arcium-hq/client changes. +/* VENDORED from @arcium-hq/client@0.9.3 (readable build/index.mjs). + * Cipher + PDA derivations only. node:crypto/fs/anchor removed; sha256 -> @noble. + * Validated byte-for-byte against the real SDK (scripts/validate-vendor.mjs). + * x25519 is used directly from @noble/curves by callers. */ +import { ed25519 } from '@noble/curves/ed25519.js'; +export { x25519 } from '@noble/curves/ed25519.js'; +export { randomBytes } from '@noble/hashes/utils.js'; +import { invert, mod, isNegativeLE, pow2 } from '@noble/curves/abstract/modular.js'; +import { shake256, sha3_512 } from '@noble/hashes/sha3.js'; +import { sha256 as _nobleSha256 } from '@noble/hashes/sha2.js'; +import { randomBytes } from '@noble/hashes/utils.js'; +import { PublicKey } from '@solana/web3.js'; + +function sha256(byteArrays) { + const h = _nobleSha256.create(); + for (const b of byteArrays) h.update(Uint8Array.from(b)); + return Buffer.from(h.digest()); +} +const CURVE25519_SCALAR_FIELD_MODULUS = ed25519.Point.Fn.ORDER; +/** + * Generate a random value within the field bound by q. + * @param q - Upper bound (exclusive) for the random value. + * @returns Random bigint value between 0 and q-1. + */ +function generateRandomFieldElem(q) { + const byteLength = (q.toString(2).length + 7) >> 3; + let r; + do { + const randomBuffer = randomBytes(byteLength); + r = BigInt(`0x${randomBuffer.toString('hex')}`); + } while (r >= q); + return r; +} +/** + * Compute the positive modulo of a over m. + * @param a - Dividend. + * @param m - Modulus. + * @returns Positive remainder of a mod m. + */ +function positiveModulo(a, m) { + return ((a % m) + m) % m; +} +/** + * Serialize a bigint to a little-endian Uint8Array of the specified length. + * @param val - Bigint value to serialize. + * @param lengthInBytes - Desired length of the output array. + * @returns Serialized value as a Uint8Array. + * @throws Error if the value is too large for the specified length. + */ +function serializeLE(val, lengthInBytes) { + const result = new Uint8Array(lengthInBytes); + let tempVal = val; + for (let i = 0; i < lengthInBytes; i++) { + result[i] = Number(tempVal & BigInt(255)); + tempVal >>= BigInt(8); + } + if (tempVal > BigInt(0)) { + throw new Error(`Value ${val} is too large for the byte length ${lengthInBytes}`); + } + return result; +} +/** + * Deserialize a little-endian Uint8Array to a bigint. + * @param bytes - Uint8Array to deserialize. + * @returns Deserialized bigint value. + */ +function deserializeLE(bytes) { + let result = BigInt(0); + for (let i = 0; i < bytes.length; i++) { + result |= BigInt(bytes[i]) << (BigInt(i) * BigInt(8)); + } + return result; +} +// GENERAL +/** + * Compute the SHA-256 hash of an array of Uint8Arrays. + * @param byteArrays - Arrays to hash. + * @returns SHA-256 hash as a Buffer. + */ +function toBinLE(x, binSize) { + const res = []; + for (let i = 0; i < binSize; ++i) { + res.push(ctSignBit(x, BigInt(i))); + } + return res; +} +/** + * Convert an array of bits (least significant to most significant, in 2's complement representation) to a bigint. + * @param xBin - Array of bits to convert. + * @returns Bigint represented by the bit array. + */ +function fromBinLE(xBin) { + let res = 0n; + for (let i = 0; i < xBin.length - 1; ++i) { + res |= BigInt(xBin[i]) << BigInt(i); + } + return res - (BigInt(xBin[xBin.length - 1]) << BigInt(xBin.length - 1)); +} +/** + * Binary adder between x and y (assumes xBin and yBin are of the same length and large enough to represent the sum). + * @param xBin - First operand as a bit array. + * @param yBin - Second operand as a bit array. + * @param carryIn - Initial carry-in value. + * @param binSize - Number of bits to use in the operation. + * @returns Sum as a bit array. + */ +function adder(xBin, yBin, carryIn, binSize) { + const res = []; + let carry = carryIn; + for (let i = 0; i < binSize; ++i) { + // res[i] = xBin[i] XOR yBin[i] XOR carry + const yXorCarry = yBin[i] !== carry; + res.push(xBin[i] !== yXorCarry); + // newCarry = (xBin[i] AND yBin[i]) XOR (xBin[i] AND carry) XOR (yBin[i] AND carry) + // = (yBin[i] XOR carry) ? xBin[i] : yBin[i] + const newCarry = yBin[i] !== (yXorCarry && (xBin[i] !== yBin[i])); + carry = newCarry; + } + return res; +} +/** + * Constant-time addition of two bigints, using 2's complement representation. + * @param x - First operand. + * @param y - Second operand. + * @param binSize - Number of bits to use in the operation. + * @returns Sum as a bigint. + */ +function ctAdd(x, y, binSize) { + const resBin = adder(toBinLE(x, binSize), toBinLE(y, binSize), false, binSize); + return fromBinLE(resBin); +} +/** + * Constant-time subtraction of two bigints, using 2's complement representation. + * @param x - First operand. + * @param y - Second operand. + * @param binSize - Number of bits to use in the operation. + * @returns Difference as a bigint. + */ +function ctSub(x, y, binSize) { + const yBin = toBinLE(y, binSize); + const yBinNot = []; + for (let i = 0; i < binSize; ++i) { + yBinNot.push(yBin[i] === false); + } + const resBin = adder(toBinLE(x, binSize), yBinNot, true, binSize); + return fromBinLE(resBin); +} +/** + * Return the sign bit of a bigint in constant time. + * @param x - Bigint to check. + * @param binSize - Bit position to check (typically the highest bit). + * @returns True if the sign bit is set, false otherwise. + */ +function ctSignBit(x, binSize) { + return ((x >> binSize) & 1n) === 1n; +} +/** + * Constant-time less-than comparison for two bigints. + * @param x - First operand. + * @param y - Second operand. + * @param binSize - Number of bits to use in the operation. + * @returns True if x < y, false otherwise. + */ +function ctLt(x, y, binSize) { + return ctSignBit(ctSub(x, y, binSize), binSize); +} +/** + * Constant-time select between two bigints based on a boolean condition. + * @param b - Condition; if true, select x, otherwise select y. + * @param x - Value to select if b is true. + * @param y - Value to select if b is false. + * @param binSize - Number of bits to use in the operation. + * @returns Selected bigint. + */ +function ctSelect(b, x, y, binSize) { + return ctAdd(y, BigInt(b) * (ctSub(x, y, binSize)), binSize); +} +/** + * Check if a bigint fits in the range -2^binSize <= x < 2^binSize. + * Not constant-time for arbitrary x, but is constant-time for all inputs for which the function returns true. + * If you assert your inputs satisfy verifyBinSize(x, binSize), you need not care about the non constant-timeness of this function. + * @param x - Bigint to check. + * @param binSize - Number of bits to use in the check. + * @returns True if x fits in the range, false otherwise. + */ +function verifyBinSize(x, binSize) { + const bin = (x >> binSize).toString(2); + return bin === '0' || bin === '-1'; +} + +/** + * Check if code is running in a browser environment. + * @returns true if window object exists, false otherwise. + */ +function isBrowser() { + return ( + // eslint-disable-next-line no-prototype-builtins + typeof window !== 'undefined' && !window.process?.hasOwnProperty('type')); +} +/** + * Conditionally logs a message if logging is enabled. + * @param log - Whether to output the log. + * @param args - Arguments to pass to console.log. + */ +function optionalLog(log, ...args) { + if (log) { + // eslint-disable-next-line no-console + console.log(...args); + } +} +/** + * Calculate the minimum number of bits needed to represent a value. + * Formula: floor(log2(max)) + 1 for unsigned, +1 for signed, +1 for diff of two negatives. + * @param max - Bigint value to measure. + * @returns Number of bits required. + */ +function getBinSize(max) { + // floor(log2(max)) + 1 to represent unsigned elements, a +1 for signed elements + // and another +1 to account for the diff of two negative elements + return BigInt(Math.floor(Math.log2(Number(max)))) + 3n; +} +/** + * Number of mantissa bits for double-precision floating point values. + */ +const DOUBLE_PRECISION_MANTISSA = 52; +/** + * Encode a value as a bigint suitable for Rescue encryption, handling booleans, bigints, and numbers. + * The encoding is performed in constant-time to avoid leaking information through timing side-channels. + * Throws if the value is out of the supported range for the field. + * @param v - Value to encode (bigint, number, or boolean). + * @returns Encoded value as a bigint. + */ +function encodeAsRescueEncryptable(v) { + if (typeof v === 'boolean') { + return v ? 1n : 0n; + } + if (typeof v === 'bigint') { + const binSize = getBinSize(CURVE25519_BASE_FIELD.ORDER - 1n); + if (!verifyBinSize(v, binSize - 1n) || ctLt(v, -(CURVE25519_BASE_FIELD.ORDER - 1n), binSize) || !ctLt(v, CURVE25519_BASE_FIELD.ORDER, binSize)) { + throw Error(`v must be in the range [${CURVE25519_BASE_FIELD.ORDER - 1n}, ${CURVE25519_BASE_FIELD.ORDER - 1n}]`); + } + return ctSelect(ctSignBit(v, binSize), ctAdd(v, CURVE25519_BASE_FIELD.ORDER, binSize), v, binSize); + } + if (typeof v === 'number') { + if (v < -3777893186295716e7 || v >= 2 ** 75) { + throw new Error('Inputs only supported in the range [-2**75, 2**75)'); + } + const vBigInt = BigInt(Math.round(v * 2 ** DOUBLE_PRECISION_MANTISSA)); + const binSize = getBinSize(CURVE25519_BASE_FIELD.ORDER - 1n); + return ctSelect(ctSignBit(vBigInt, binSize), ctAdd(vBigInt, CURVE25519_BASE_FIELD.ORDER, binSize), vBigInt, binSize); + } + throw new Error('Invalid type to convert from number to bigint'); +} +/** + * Decode a Rescue-decrypted value back to a signed bigint. + * Handle the conversion from field element representation to signed integer. + * @param v - Decrypted field element value. + * @returns Decoded signed bigint value. + */ +function decodeRescueDecryptedToBigInt(v) { + const twoInv = (CURVE25519_BASE_FIELD.ORDER + 1n) / 2n; + const binSize = getBinSize(CURVE25519_BASE_FIELD.ORDER - 1n); + const isLtTwoInv = ctLt(v, twoInv, binSize); + return ctSelect(isLtTwoInv, v, ctSub(v, CURVE25519_BASE_FIELD.ORDER, binSize), binSize); +} +/** + * Decode a Rescue-decrypted value back to a JavaScript number. + * Convert from field element representation to a floating-point number. + * @param v - Decrypted field element value. + * @returns Decoded number value. + */ +function decodeRescueDecryptedToNumber(v) { + const vSigned = decodeRescueDecryptedToBigInt(v); + return Number(vSigned) * 2 ** -DOUBLE_PRECISION_MANTISSA; +} +/** + * Check if a computation reference is null (all zeros). + * @param ref - Computation reference to check. + * @returns true if the reference is null, false otherwise. + */ +function isNullRef(ref) { + const bigZero = new anchor.BN(0); + return (ref.computationOffset === bigZero + && ref.priorityFee === bigZero); +} + +/** + * Matrix operations for MPC field arithmetic. + * Used internally by Rescue cipher. Not part of public API. + * @internal + */ +class Matrix { + field; + data; + constructor(field, data) { + this.field = field; + const nrows = data.length; + const ncols = data[0].length; + for (let i = 1; i < nrows; ++i) { + if (data[i].length !== ncols) { + throw Error('All rows must have same number of columns.'); + } + } + this.data = data.map((row) => row.map((c) => field.create(c))); + } + /** + * Matrix multiplication between `this` and `rhs`. + */ + matMul(rhs) { + const thisNrows = this.data.length; + const thisNcols = this.data[0].length; + const rhsNrows = rhs.data.length; + const rhsNcols = rhs.data[0].length; + if (thisNcols !== rhsNrows) { + throw Error(`this.ncols must be equal to rhs.nrows (found ${thisNcols} and ${rhsNrows})`); + } + const data = []; + for (let i = 0; i < thisNrows; ++i) { + const row = []; + for (let j = 0; j < rhsNcols; ++j) { + let c = this.field.ZERO; + for (let k = 0; k < thisNcols; ++k) { + c = this.field.add(c, this.field.mul(this.data[i][k], rhs.data[k][j])); + } + row.push(c); + } + data.push(row); + } + return new Matrix(this.field, data); + } + /** + * Element-wise addition between `this` and `rhs`. + */ + add(rhs, ct = false) { + const thisNrows = this.data.length; + const thisNcols = this.data[0].length; + const rhsNrows = rhs.data.length; + const rhsNcols = rhs.data[0].length; + if (thisNrows !== rhsNrows) { + throw Error(`this.nrows must be equal to rhs.nrows (found ${thisNrows} and ${rhsNrows})`); + } + if (thisNcols !== rhsNcols) { + throw Error(`this.ncols must be equal to rhs.ncols (found ${thisNcols} and ${rhsNcols})`); + } + const binSize = getBinSize(this.field.ORDER - 1n); + const data = []; + for (let i = 0; i < thisNrows; ++i) { + const row = []; + for (let j = 0; j < thisNcols; ++j) { + if (ct) { + const sum = ctAdd(this.data[i][j], rhs.data[i][j], binSize); + row.push(ctSelect(ctLt(sum, this.field.ORDER, binSize), sum, ctSub(sum, this.field.ORDER, binSize), binSize)); + } + else { + row.push(this.field.add(this.data[i][j], rhs.data[i][j])); + } + } + data.push(row); + } + return new Matrix(this.field, data); + } + /** + * Element-wise subtraction between `this` and `rhs`. + */ + sub(rhs, ct = false) { + const thisNrows = this.data.length; + const thisNcols = this.data[0].length; + const rhsNrows = rhs.data.length; + const rhsNcols = rhs.data[0].length; + if (thisNrows !== rhsNrows) { + throw Error(`this.nrows must be equal to rhs.nrows (found ${thisNrows} and ${rhsNrows})`); + } + if (thisNcols !== rhsNcols) { + throw Error(`this.ncols must be equal to rhs.ncols (found ${thisNcols} and ${rhsNcols})`); + } + const binSize = getBinSize(this.field.ORDER - 1n); + const data = []; + for (let i = 0; i < thisNrows; ++i) { + const row = []; + for (let j = 0; j < thisNcols; ++j) { + if (ct) { + const diff = ctSub(this.data[i][j], rhs.data[i][j], binSize); + row.push(ctSelect(ctSignBit(diff, binSize), ctAdd(diff, this.field.ORDER, binSize), diff, binSize)); + } + else { + row.push(this.field.sub(this.data[i][j], rhs.data[i][j])); + } + } + data.push(row); + } + return new Matrix(this.field, data); + } + /** + * Raises each element of `this` to the power `e`. + */ + pow(e) { + const data = []; + for (let i = 0; i < this.data.length; ++i) { + const row = []; + for (let j = 0; j < this.data[0].length; ++j) { + row.push(this.field.pow(this.data[i][j], e)); + } + data.push(row); + } + return new Matrix(this.field, data); + } + /** + * Compute the determinant using Gauss elimination. + * Match the determinant implementation in Arcis. + */ + det() { + // Ensure the matrix is square + const n = this.data.length; + if (n === 0 || !this.is_square()) { + throw Error('Matrix must be square and non-empty to compute the determinant.'); + } + let det = this.field.ONE; + // Clone the data to avoid mutating the original matrix + let rows = this.data.map((row) => [...row]); + for (let i = 0; i < n; ++i) { + // we partition into rows that have a leading zero and rows that don't + const lzRows = rows.filter((r) => this.field.is0(r[0])); + const nlzRows = rows.filter((r) => !this.field.is0(r[0])); + // take pivot element + const pivotRow = nlzRows.shift(); + if (pivotRow === undefined) { + // no pivot row implies the rank is less than n i.e. the determinant is zero + return this.field.ZERO; + } + const pivot = pivotRow[0]; + // multiply pivot onto the determinant + det = this.field.mul(det, pivot); + // subtract all leading non zero values with the pivot element (forward elimination). + const pivotInverse = this.field.inv(pivot); + // precomputing pivot row such that the leading value is one. This reduces the number of + // multiplications in the forward elimination multiplications by 50% + const normalizedPivotRow = pivotRow.map((v) => this.field.mul(pivotInverse, v)); + // forward elimination with normalized pivot row + const nlzRowsProcessed = nlzRows.map((row) => { + const lead = row[0]; + return row.map((value, index) => this.field.sub(value, this.field.mul(lead, normalizedPivotRow[index]))); + }); + // concat the reamining rows (without pivot row) and remove the pivot column (all first + // elements (i.e. zeros) from the remaining rows). + rows = nlzRowsProcessed.concat(lzRows).map((row) => row.slice(1)); + } + return det; + } + is_square() { + const n = this.data.length; + for (let i = 1; i < n; ++i) { + if (this.data[i].length !== n) { + return false; + } + } + return true; + } +} +/** + * Generate random matrix for testing. + * @internal + */ +function randMatrix(field, nrows, ncols) { + const data = []; + for (let i = 0; i < nrows; ++i) { + const row = []; + for (let j = 0; j < ncols; ++j) { + row.push(generateRandomFieldElem(field.ORDER)); + } + data.push(row); + } + return new Matrix(field, data); +} + +/** + * Curve25519 base field as an IField instance. + */ +const CURVE25519_BASE_FIELD = ed25519.Point.Fp; +/** + * Curve25519 scalar field as an IField instance. + */ +const CURVE25519_SCALAR_FIELD = ed25519.Point.Fn; +// Security level for the block cipher. +const SECURITY_LEVEL_BLOCK_CIPHER = 128; +// Security level for the hash function. +const SECURITY_LEVEL_HASH_FUNCTION = 256; +// We refer to https://tosc.iacr.org/index.php/ToSC/article/view/8695/8287 for more details. +/** + * Description and parameters for the Rescue cipher or hash function, including round constants, MDS matrix, and key schedule. + * See: https://tosc.iacr.org/index.php/ToSC/article/view/8695/8287 + */ +class RescueDesc { + mode; + field; + // The smallest prime that does not divide p-1. + alpha; + // The inverse of alpha modulo p-1. + alphaInverse; + nRounds; + m; + // A Maximum Distance Separable matrix. + mdsMat; + // Its inverse. + mdsMatInverse; + // The round keys, needed for encryption and decryption. + roundKeys; + /** + * Construct a RescueDesc for a given field and mode (cipher or hash). + * Initialize round constants, MDS matrix, and key schedule. + * @param field - Field to use (e.g., CURVE25519_BASE_FIELD). + * @param mode - Mode: block cipher or hash function. + */ + constructor(field, mode) { + this.field = field; + this.mode = mode; + switch (this.mode.kind) { + case 'cipher': { + this.m = this.mode.key.length; + if (this.m < 2) { + throw Error(`parameter m must be at least 2 (found ${this.m})`); + } + break; + } + case 'hash': { + this.m = this.mode.m; + break; + } + default: { + this.m = 0; + break; + } + } + const alphaAndInverse = getAlphaAndInverse(this.field.ORDER); + this.alpha = alphaAndInverse[0]; + this.alphaInverse = alphaAndInverse[1]; + this.nRounds = getNRounds(this.field.ORDER, this.mode, this.alpha, this.m); + const mdsMatrixAndInverse = getMdsMatrixAndInverse(this.field, this.m); + this.mdsMat = mdsMatrixAndInverse[0]; + this.mdsMatInverse = mdsMatrixAndInverse[1]; + // generate the round constants using SHAKE256 + const roundConstants = this.sampleConstants(this.nRounds); + switch (this.mode.kind) { + case 'cipher': { + // do the key schedule + this.roundKeys = rescuePermutation(this.mode, this.alpha, this.alphaInverse, this.mdsMat, roundConstants, new Matrix(this.field, toVec(this.mode.key))); + break; + } + case 'hash': { + this.roundKeys = roundConstants; + break; + } + default: { + this.roundKeys = []; + break; + } + } + } + /** + * Sample round constants for the Rescue permutation, using SHAKE256. + * @param nRounds - Number of rounds. + * @returns Array of round constant matrices. + */ + sampleConstants(nRounds) { + const field = this.field; + const m = this.m; + // setup randomness + // dkLen is the output length from the Keccak instance behind shake. + // this is irrelevant for our extendable output function (xof), but still we use + // the default value from one-time shake256 hashing, as defined in shake256's definition + // in noble-hashes-sha3. + const hasher = shake256.create({ dkLen: 256 / 8 }); + // buffer to create field elements from bytes + // we add 16 bytes to get a distribution statistically close to uniform + const bufferLen = Math.ceil(field.BITS / 8) + 16; + switch (this.mode.kind) { + case 'cipher': { + hasher.update(new TextEncoder().encode('encrypt everything, compute anything')); + const rFieldArray = Array.from({ length: m * m + 2 * m }, () => { + // create field element from the shake hash + const randomness = hasher.xof(bufferLen); + // we need not check whether the obtained field element f is in any subgroup, + // because we use only prime fields (i.e. there are no subgroups) + return field.create(deserializeLE(randomness)); + }); + // create matrix and vectors + const matData = Array.from({ length: m }, () => rFieldArray.splice(0, m)); + let roundConstantMat = new Matrix(field, matData); + const initData = Array.from({ length: m }, () => rFieldArray.splice(0, 1)); + const initialRoundConstant = new Matrix(field, initData); + const roundData = Array.from({ length: m }, () => rFieldArray.splice(0, 1)); + const roundConstantAffineTerm = new Matrix(field, roundData); + // check for inversability + while (field.is0(roundConstantMat.det())) { + // resample matrix + const resampleArray = Array.from({ length: m * m }, () => { + const randomness = hasher.xof(bufferLen); + return field.create(deserializeLE(randomness)); + }); + const resampleData = Array.from({ length: m }, () => resampleArray.splice(0, m)); + roundConstantMat = new Matrix(field, resampleData); + } + const roundConstants = [initialRoundConstant]; + for (let r = 0; r < 2 * this.nRounds; ++r) { + roundConstants.push(roundConstantMat.matMul(roundConstants[r]).add(roundConstantAffineTerm)); + } + return roundConstants; + } + case 'hash': { + hasher.update(new TextEncoder().encode(`Rescue-XLIX(${this.field.ORDER},${m},${this.mode.capacity},${SECURITY_LEVEL_HASH_FUNCTION})`)); + // this.permute requires an odd number of round keys + // prepending a 0 matrix makes it equivalent to Algorithm 3 from https://eprint.iacr.org/2020/1143.pdf + const zeros = []; + for (let i = 0; i < m; ++i) { + zeros.push([0n]); + } + const roundConstants = [new Matrix(field, zeros)]; + const rFieldArray = Array.from({ length: 2 * m * nRounds }, () => { + // create field element from the shake hash + const randomness = hasher.xof(bufferLen); + // we need not check whether the obtained field element f is in any subgroup, + // because we use only prime fields (i.e. there are no subgroups) + return field.create(deserializeLE(randomness)); + }); + for (let r = 0; r < 2 * nRounds; ++r) { + const data = []; + for (let i = 0; i < m; ++i) { + data.push([rFieldArray[r * m + i]]); + } + roundConstants.push(new Matrix(field, data)); + } + return roundConstants; + } + default: return []; + } + } + /** + * Apply the Rescue permutation to a state matrix. + * @param state - Input state matrix. + * @returns Permuted state matrix. + */ + permute(state) { + return rescuePermutation(this.mode, this.alpha, this.alphaInverse, this.mdsMat, this.roundKeys, state)[2 * this.nRounds]; + } + /** + * Apply the inverse Rescue permutation to a state matrix. + * @param state - Input state matrix. + * @returns Inverse-permuted state matrix. + */ + permuteInverse(state) { + return rescuePermutationInverse(this.mode, this.alpha, this.alphaInverse, this.mdsMatInverse, this.roundKeys, state)[2 * this.nRounds]; + } +} +/** + * Find the smallest prime alpha that does not divide p-1, and compute its inverse modulo p-1. + * The alpha parameter is used in the Rescue permutation for exponentiation operations. + * @param p - Field modulus (prime number). + * @returns Tuple [alpha, alphaInverse] where alpha is the prime and alphaInverse is its modular inverse. + * @throws Error if no suitable prime alpha is found. + */ +function getAlphaAndInverse(p) { + const pMinusOne = p - 1n; + let alpha = 0n; + for (const a of [2n, 3n, 5n, 7n, 11n, 13n, 17n, 19n, 23n, 29n, 31n, 37n, 41n, 43n, 47n]) { + if (pMinusOne % a !== 0n) { + alpha = a; + break; + } + } + if (alpha === 0n) { + throw Error('Could not find prime alpha that does not divide p-1.'); + } + const alphaInverse = invert(alpha, pMinusOne); + return [alpha, alphaInverse]; +} +/** + * Calculate the number of rounds required for the Rescue permutation based on security analysis. + * The number of rounds is determined by analyzing resistance to differential and algebraic attacks. + * See: https://tosc.iacr.org/index.php/ToSC/article/view/8695/8287 for the security analysis. + * @param p - Field modulus. + * @param mode - Rescue mode (cipher or hash). + * @param alpha - Prime alpha parameter. + * @param m - State size (block size for cipher, total size for hash). + * @returns Number of rounds (will be doubled for the full permutation). + */ +function getNRounds(p, mode, alpha, m) { + function dcon(n) { + return Math.floor(0.5 * (Number(alpha) - 1) * m * (n - 1) + 2.0); + } + function v(n, rate) { + return m * (n - 1) + rate; + } + function binomial(n, k) { + function factorial(x) { + if (x === 0n || x === 1n) { + return 1n; + } + return x * factorial(x - 1n); + } + return factorial(BigInt(n)) / (factorial(BigInt(n - k)) * factorial(BigInt(k))); + } + switch (mode.kind) { + case 'cipher': { + const l0 = Math.ceil((2 * SECURITY_LEVEL_BLOCK_CIPHER) / ((m + 1) * (Math.log2(Number(p)) - Math.log2(Number(alpha) - 1)))); + let l1 = 0; + if (alpha === 3n) { + l1 = Math.ceil((SECURITY_LEVEL_BLOCK_CIPHER + 2) / (4 * m)); + } + else { + l1 = Math.ceil((SECURITY_LEVEL_BLOCK_CIPHER + 3) / (5.5 * m)); + } + return 2 * Math.max(l0, l1, 5); + } + case 'hash': { + // get number of rounds for Groebner basis attack + const rate = m - mode.capacity; + const target = 1n << BigInt(SECURITY_LEVEL_HASH_FUNCTION); + let l1 = 1; + let tmp = binomial(v(l1, rate) + dcon(l1), v(l1, rate)); + while (tmp * tmp <= target && l1 <= 23) { + l1 += 1; + tmp = binomial(v(l1, rate) + dcon(l1), v(l1, rate)); + } + // set a minimum value for sanity and add 50% + return Math.ceil(1.5 * Math.max(5, l1)); + } + default: return 0; + } +} +/** + * Build a Cauchy matrix for use as an MDS (Maximum Distance Separable) matrix. + * A Cauchy matrix is guaranteed to be invertible and provides optimal diffusion properties. + * The matrix is constructed using the formula: M[i][j] = 1/(i + j) for i, j in [1, size]. + * @param field - Finite field over which to construct the matrix. + * @param size - Size of the square matrix. + * @returns Cauchy matrix of the specified size. + */ +function buildCauchy(field, size) { + const data = []; + for (let i = 1n; i <= size; ++i) { + const row = []; + for (let j = 1n; j <= size; ++j) { + row.push(field.inv(i + j)); + } + data.push(row); + } + return new Matrix(field, data); +} +/** + * Build the inverse of a Cauchy matrix for use as the inverse MDS matrix. + * The inverse is computed using a closed-form formula for Cauchy matrix inversion. + * @param field - Finite field over which to construct the matrix. + * @param size - Size of the square matrix. + * @returns Inverse of the Cauchy matrix. + */ +function buildInverseCauchy(field, size) { + function product(arr) { + return arr.reduce((acc, curr) => field.mul(acc, field.create(curr)), field.ONE); + } + function prime(arr, val) { + return product(arr.map((u) => { + if (u !== val) { + return val - u; + } + return 1n; + })); + } + const data = []; + for (let i = 1n; i <= size; ++i) { + const row = []; + for (let j = 1n; j <= size; ++j) { + const a = product(Array.from({ length: size }, (_, key) => -i - BigInt(1 + key))); + const aPrime = prime(Array.from({ length: size }, (_, key) => BigInt(1 + key)), j); + const b = product(Array.from({ length: size }, (_, key) => j + BigInt(1 + key))); + const bPrime = prime(Array.from({ length: size }, (_, key) => -BigInt(1 + key)), -i); + row.push(field.mul(a, field.mul(b, field.mul(field.inv(aPrime), field.mul(field.inv(bPrime), field.inv(-i - j)))))); + } + data.push(row); + } + return new Matrix(field, data); +} +function getMdsMatrixAndInverse(field, m) { + const mdsMat = buildCauchy(field, m); + const mdsMatInverse = buildInverseCauchy(field, m); + return [mdsMat, mdsMatInverse]; +} +function exponentForEven(mode, alpha, alphaInverse) { + switch (mode.kind) { + case 'cipher': { + return alphaInverse; + } + case 'hash': { + return alpha; + } + default: return 0n; + } +} +function exponentForOdd(mode, alpha, alphaInverse) { + switch (mode.kind) { + case 'cipher': { + return alpha; + } + case 'hash': { + return alphaInverse; + } + default: return 0n; + } +} +/** + * Core Rescue permutation function implementing the cryptographic primitive. + * Apply alternating rounds of exponentiation and MDS matrix multiplication with round keys. + * The permutation alternates between using alpha and alphaInverse as exponents based on round parity. + * This is the fundamental building block for both Rescue cipher and Rescue-Prime hash. + * @param mode - Rescue mode (cipher or hash) determining exponent selection. + * @param alpha - Prime exponent for even rounds. + * @param alphaInverse - Inverse exponent for odd rounds. + * @param mdsMat - Maximum Distance Separable matrix for diffusion. + * @param subkeys - Array of round key matrices. + * @param state - Initial state matrix to permute. + * @returns Array of all intermediate states during the permutation. + */ +function rescuePermutation(mode, alpha, alphaInverse, mdsMat, subkeys, state) { + const exponentEven = exponentForEven(mode, alpha, alphaInverse); + const exponentOdd = exponentForOdd(mode, alpha, alphaInverse); + const states = [state.add(subkeys[0])]; + for (let r = 0; r < subkeys.length - 1; ++r) { + let s = states[r]; + if (r % 2 === 0) { + s = s.pow(exponentEven); + } + else { + s = s.pow(exponentOdd); + } + states.push(mdsMat.matMul(s).add(subkeys[r + 1])); + } + return states; +} +function rescuePermutationInverse(mode, alpha, alphaInverse, mdsMatInverse, subkeys, state) { + const exponentEven = exponentForEven(mode, alpha, alphaInverse); + const exponentOdd = exponentForOdd(mode, alpha, alphaInverse); + // the initial state will need to be removed afterwards + const states = [state]; + for (let r = 0; r < subkeys.length - 1; ++r) { + let s = states[r]; + s = mdsMatInverse.matMul(s.sub(subkeys[subkeys.length - 1 - r])); + if (r % 2 === 0) { + s = s.pow(exponentEven); + } + else { + s = s.pow(exponentOdd); + } + states.push(s); + } + states.push(states[states.length - 1].sub(subkeys[0])); + states.shift(); + return states; +} +function toVec(data) { + const dataVec = []; + for (let i = 0; i < data.length; ++i) { + dataVec.push([data[i]]); + } + return dataVec; +} + +/** + * The Rescue-Prime hash function, as described in https://eprint.iacr.org/2020/1143.pdf, offering 256 bits + * of security against collision, preimage and second-preimage attacks for any field of size at least 102 bits. + * We use the sponge construction with fixed rate = 7 and capacity = 5 (i.e., m = 12), and truncate the + * output to 5 field elements. + */ +class RescuePrimeHash { + desc; + rate; + digestLength; + /** + * Construct a RescuePrimeHash instance with rate = 7 and capacity = 5. + */ + constructor(field) { + this.desc = new RescueDesc(field, { kind: 'hash', m: 12, capacity: 5 }); + this.rate = 7; + this.digestLength = 5; + } + // This is Algorithm 1 from https://eprint.iacr.org/2020/1143.pdf, though with the padding (see Algorithm 2). + // The hash is truncated to digestLength elements. + // According to Section 2.2, this offers min(log2(CURVE25519_BASE_FIELD.ORDER) / 2 * min(digestLength, capacity), s) + // bits of security against collision, preimage and second-preimage attacks. + // The security level is thus of the order of 256 bits for any field of size at least 102 bits. + // The rate and capacity are chosen to achieve minimal number of rounds 8. + /** + * Compute the Rescue-Prime hash of a message, with padding as described in Algorithm 2 of the paper. + * @param message - Input message as an array of bigints. + * @returns Hash output as an array of bigints (length = digestLength). + */ + digest(message) { + // Create a copy and pad message to avoid mutating input parameter + const paddedMessage = [...message, 1n]; + while (paddedMessage.length % this.rate !== 0) { + paddedMessage.push(0n); + } + const zeros = []; + for (let i = 0; i < this.desc.m; ++i) { + zeros.push([0n]); + } + let state = new Matrix(this.desc.field, zeros); + for (let r = 0; r < paddedMessage.length / this.rate; ++r) { + const data = []; + for (let i = 0; i < this.rate; ++i) { + data[i] = [paddedMessage[r * this.rate + i]]; + } + for (let i = this.rate; i < this.desc.m; ++i) { + data[i] = [0n]; + } + const s = new Matrix(this.desc.field, data); + state = this.desc.permute(state.add(s, true)); + } + const res = []; + for (let i = 0; i < this.digestLength; ++i) { + res.push(state.data[i][0]); + } + return res; + } +} + +/** + * Block size m for Rescue cipher operations. + * Rescue operates on 5-element blocks of field elements. + */ +const RESCUE_CIPHER_BLOCK_SIZE = 5; +/** + * The Rescue cipher in Counter (CTR) mode, with a fixed block size m = 5. + * See: https://tosc.iacr.org/index.php/ToSC/article/view/8695/8287 + */ +class RescueCipherCommon { + desc; + /** + * Construct a RescueCipherCommon instance using a shared secret. + * The key is derived using RescuePrimeHash and used to initialize the RescueDesc. + * @param sharedSecret - Shared secret to derive the cipher key from. + */ + constructor(sharedSecret, field) { + if (sharedSecret.length != 32) { + throw Error(`sharedSecret must be of length 32 (found ${sharedSecret.length})`); + } + const hasher = new RescuePrimeHash(field); + // In case `field` is different from CURVE25519_BASE_FIELD we need to injectively map sharedSecret + // to a vector of elements over `field`. + const converted = []; + if (field === CURVE25519_BASE_FIELD) { + converted.push(deserializeLE(sharedSecret)); + } + else { + // We chunk sharedSecret by field.BYTES - 1 and convert. + const chunkSize = field.BYTES - 1; + const nChunks = Math.ceil(sharedSecret.length / chunkSize); + for (let i = 0; i < nChunks; ++i) { + converted.push(deserializeLE(sharedSecret.slice(i * chunkSize, (i + 1) * chunkSize))); + } + } + // We follow [Section 4, Option 1.](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-56Cr2.pdf). + // For our choice of hash function, we have: + // - H_outputBits = hasher.digestLength = RESCUE_CIPHER_BLOCK_SIZE + // - max_H_inputBits = arbitrarily long, as the Rescue-Prime hash function is built upon the + // sponge construction + // - L = RESCUE_CIPHER_BLOCK_SIZE. + // Build the vector `counter || Z || FixedInfo` (we only have i = 1, since reps = 1). + // For the FixedInfo we simply take L. + const counter = [1n, ...converted, BigInt(RESCUE_CIPHER_BLOCK_SIZE)]; + const rescueKey = hasher.digest(counter); + this.desc = new RescueDesc(field, { kind: 'cipher', key: rescueKey }); + } + /** + * Encrypt the plaintext vector in Counter (CTR) mode (raw, returns bigints). + * @param plaintext - Array of plaintext bigints to encrypt. + * @param nonce - 16-byte nonce for CTR mode. + * @returns Ciphertext as an array of bigints. + * @throws Error if the nonce is not 16 bytes long. + */ + encrypt_raw(plaintext, nonce) { + if (nonce.length !== 16) { + throw Error(`nonce must be of length 16 (found ${nonce.length})`); + } + const binSize = getBinSize(this.desc.field.ORDER - 1n); + function encryptBatch(desc, ptxt, cntr) { + if (cntr.length !== RESCUE_CIPHER_BLOCK_SIZE) { + throw Error(`counter must be of length ${RESCUE_CIPHER_BLOCK_SIZE} (found ${cntr.length})`); + } + const encryptedCounter = desc.permute(new Matrix(desc.field, toVec(cntr))); + const ciphertext = []; + for (let i = 0; i < ptxt.length; ++i) { + if (!verifyBinSize(ptxt[i], binSize - 1n) || ctSignBit(ptxt[i], binSize) || !ctLt(ptxt[i], desc.field.ORDER, binSize)) { + throw Error(`plaintext must be non-negative and less than ${desc.field.ORDER}`); + } + const sum = ctAdd(ptxt[i], encryptedCounter.data[i][0], binSize); + ciphertext.push(ctSelect(ctLt(sum, desc.field.ORDER, binSize), sum, ctSub(sum, desc.field.ORDER, binSize), binSize)); + } + return ciphertext; + } + const nBlocks = Math.ceil(plaintext.length / RESCUE_CIPHER_BLOCK_SIZE); + const counter = getCounter(deserializeLE(nonce), nBlocks); + const ciphertext = []; + for (let i = 0; i < nBlocks; ++i) { + const cnt = RESCUE_CIPHER_BLOCK_SIZE * i; + const newCiphertext = encryptBatch(this.desc, plaintext.slice(cnt, Math.min(cnt + RESCUE_CIPHER_BLOCK_SIZE, plaintext.length)), counter.slice(cnt, cnt + RESCUE_CIPHER_BLOCK_SIZE)); + for (let j = 0; j < newCiphertext.length; ++j) { + ciphertext.push(newCiphertext[j]); + } + } + return ciphertext; + } + /** + * Encrypt the plaintext vector in Counter (CTR) mode and serialize each block. + * @param plaintext - Array of plaintext bigints to encrypt. + * @param nonce - 16-byte nonce for CTR mode. + * @returns Ciphertext as an array of arrays of numbers (each 32 bytes). + */ + encrypt(plaintext, nonce) { + return this.encrypt_raw(plaintext, nonce).map((c) => Array.from(serializeLE(c, 32))); + } + /** + * Decrypt the ciphertext vector in Counter (CTR) mode (raw, expects bigints). + * @param ciphertext - Array of ciphertext bigints to decrypt. + * @param nonce - 16-byte nonce for CTR mode. + * @returns Decrypted plaintext as an array of bigints. + * @throws Error if the nonce is not 16 bytes long. + */ + decrypt_raw(ciphertext, nonce) { + if (nonce.length !== 16) { + throw Error(`nonce must be of length 16 (found ${nonce.length})`); + } + const binSize = getBinSize(this.desc.field.ORDER - 1n); + function decryptBatch(desc, ctxt, cntr) { + if (cntr.length !== RESCUE_CIPHER_BLOCK_SIZE) { + throw Error(`counter must be of length ${RESCUE_CIPHER_BLOCK_SIZE} (found ${cntr.length})`); + } + const encryptedCounter = desc.permute(new Matrix(desc.field, toVec(cntr))); + const decrypted = []; + for (let i = 0; i < ctxt.length; ++i) { + const diff = ctSub(ctxt[i], encryptedCounter.data[i][0], binSize); + decrypted.push(ctSelect(ctSignBit(diff, binSize), ctAdd(diff, desc.field.ORDER, binSize), diff, binSize)); + } + return decrypted; + } + const nBlocks = Math.ceil(ciphertext.length / RESCUE_CIPHER_BLOCK_SIZE); + const counter = getCounter(deserializeLE(nonce), nBlocks); + const decrypted = []; + for (let i = 0; i < nBlocks; ++i) { + const cnt = RESCUE_CIPHER_BLOCK_SIZE * i; + const newDecrypted = decryptBatch(this.desc, ciphertext.slice(cnt, Math.min(cnt + RESCUE_CIPHER_BLOCK_SIZE, ciphertext.length)), counter.slice(cnt, cnt + RESCUE_CIPHER_BLOCK_SIZE)); + for (let j = 0; j < newDecrypted.length; ++j) { + decrypted.push(newDecrypted[j]); + } + } + return decrypted; + } + /** + * Deserialize and decrypt the ciphertext vector in Counter (CTR) mode. + * @param ciphertext - Array of arrays of numbers (each 32 bytes) to decrypt. + * @param nonce - 16-byte nonce for CTR mode. + * @returns Decrypted plaintext as an array of bigints. + */ + decrypt(ciphertext, nonce) { + return this.decrypt_raw(ciphertext.map((c) => { + if (c.length !== 32) { + throw Error(`ciphertext must be of length 32 (found ${c.length})`); + } + return deserializeLE(Uint8Array.from(c)); + }), nonce); + } +} +/** + * Generate the counter values for Rescue cipher CTR mode. + * @param nonce - Initial nonce as a bigint. + * @param nBlocks - Number of blocks to generate counters for. + * @returns Array of counter values as bigints. + */ +function getCounter(nonce, nBlocks) { + const counter = []; + for (let i = 0n; i < nBlocks; ++i) { + counter.push(nonce); + counter.push(i); + // Pad to RESCUE_CIPHER_BLOCK_SIZE elements per counter block + for (let j = 2; j < RESCUE_CIPHER_BLOCK_SIZE; ++j) { + counter.push(0n); + } + } + return counter; +} + +/** + * The Rescue cipher over Curve25519's base field in Counter (CTR) mode, with a fixed block size m = 5. + * See: https://tosc.iacr.org/index.php/ToSC/article/view/8695/8287 + */ +class RescueCipher { + cipher; + /** + * Construct a RescueCipher instance using a shared secret. + * The key is derived using RescuePrimeHash and used to initialize the RescueDesc. + * @param sharedSecret - Shared secret to derive the cipher key from. + */ + constructor(sharedSecret) { + this.cipher = new RescueCipherCommon(sharedSecret, CURVE25519_BASE_FIELD); + } + /** + * Encrypt the plaintext vector in Counter (CTR) mode and serialize each block. + * @param plaintext - Array of plaintext bigints to encrypt. + * @param nonce - 16-byte nonce for CTR mode. + * @returns Ciphertext as an array of arrays of numbers (each 32 bytes). + */ + encrypt(plaintext, nonce) { + return this.cipher.encrypt(plaintext, nonce); + } + /** + * Deserialize and decrypt the ciphertext vector in Counter (CTR) mode. + * @param ciphertext - Array of arrays of numbers (each 32 bytes) to decrypt. + * @param nonce - 16-byte nonce for CTR mode. + * @returns Decrypted plaintext as an array of bigints. + */ + decrypt(ciphertext, nonce) { + return this.cipher.decrypt(ciphertext, nonce); + } +} + + +// ── Arcium account derivations (vendored) ────────────────────────────────── +const ARX_PROGRAM_ADDR = 'Arcj82pX7HxYKLR92qvgZUAd7vGS1k4hQvAFcPATFdEQ'; +const OFFSET_BUFFER_SIZE = 4, COMP_DEF_OFFSET_SIZE = 4; +const COMPUTATION_ACC_SEED='ComputationAccount', MEMPOOL_ACC_SEED='Mempool', EXEC_POOL_ACC_SEED='Execpool'; +const CLUSTER_ACC_SEED='Cluster', MXE_ACCOUNT_SEED='MXEAccount', COMP_DEF_ACC_SEED='ComputationDefinitionAccount'; +export function getArciumProgramId(){ return new PublicKey(ARX_PROGRAM_ADDR); } +function pda(seeds){ return PublicKey.findProgramAddressSync(seeds, getArciumProgramId())[0]; } +export function getArciumAccountBaseSeed(name){ return Buffer.from(name,'utf-8'); } +export function getCompDefAccOffset(circuitName){ return sha256([Buffer.from(circuitName,'utf-8')]).slice(0, COMP_DEF_OFFSET_SIZE); } +function off4(n){ const b=Buffer.alloc(OFFSET_BUFFER_SIZE); b.writeUInt32LE(n,0); return b; } +export function getMXEAccAddress(p){ return pda([Buffer.from(MXE_ACCOUNT_SEED), p.toBuffer()]); } +export function getCompDefAccAddress(p,o){ return pda([Buffer.from(COMP_DEF_ACC_SEED), p.toBuffer(), off4(o)]); } +export function getComputationAccAddress(cl,coLe8){ return pda([Buffer.from(COMPUTATION_ACC_SEED), off4(cl), Buffer.from(coLe8)]); } +export function getMempoolAccAddress(cl){ return pda([Buffer.from(MEMPOOL_ACC_SEED), off4(cl)]); } +export function getExecutingPoolAccAddress(cl){ return pda([Buffer.from(EXEC_POOL_ACC_SEED), off4(cl)]); } +export function getClusterAccAddress(cl){ return pda([Buffer.from(CLUSTER_ACC_SEED), off4(cl)]); } +export { RescueCipher, deserializeLE, serializeLE }; diff --git a/mobile_app/src/services/sendTransaction.ts b/mobile_app/src/services/sendTransaction.ts index 85132316..cdfc3456 100644 --- a/mobile_app/src/services/sendTransaction.ts +++ b/mobile_app/src/services/sendTransaction.ts @@ -350,7 +350,7 @@ async function buildSplTransferTransaction({ return tx; } -async function signAndSubmitTransaction({ +export async function signAndSubmitTransaction({ walletAdapter, rpcAdapter, tx, diff --git a/mobile_app/src/storage/index.ts b/mobile_app/src/storage/index.ts index b4b0e883..e4cb25ba 100644 --- a/mobile_app/src/storage/index.ts +++ b/mobile_app/src/storage/index.ts @@ -36,6 +36,9 @@ export const SecureKeys = { BEACON_KEYPAIR_HEX: 'beacon_keypair_hex', // Beacon ed25519 public key (hex) — safe to expose in state/UI. BEACON_PUBKEY_HEX: 'beacon_pubkey_hex', + // Arcium beacon-operator x25519 secret key (hex, 32 bytes) — derives the + // shared secret for encrypting/decrypting private beacon binding + relay stats. + BEACON_X25519_SECRET: 'arcium_x25519_secret_v1', } as const; export const PrefKeys = {