Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,57 @@ mod tests {
short.signing_public_key.pop();
assert!(refusal(&code_of(&short)).contains("signing key is 63 bytes"));
}

/// Where the guided tour's practice contact code is kept in the frontend.
const PRACTICE_CONTACT_FILE: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../frontend/src/components/tour/practiceContact.ts"
);

/// The guided tour's practice contact, bob: a card on the beta network,
/// written as this module writes a contact code. The tour has the user
/// paste the code into Add Contact, where Rust reads it for real (a read
/// the practice sandbox lets through) and practice mode adds bob. The
/// frontend's file must hold exactly this code, one Rust reads back as the
/// card; DSM_WRITE_FRONTEND_FIXTURES=1 rewrites it.
#[test]
#[serial_test::serial]
fn the_tour_practice_contact_code_is_one_rust_reads() {
identity();
let card = generated::ContactQrV3 {
device_id: vec![0xB0; 32],
network: String::from_utf8(dsm::economic::register::BETA_NETWORK_ID.to_vec())
.expect("the beta network id is UTF-8"),
genesis_hash: vec![0xB1; 32],
signing_public_key: vec![0xB2; 64],
preferred_alias: "bob".into(),
};
let code = code_of(&card);
assert_eq!(
read_contact_code(&code).expect("Rust reads the practice code"),
card
);

let file = format!(
"// SPDX-License-Identifier: Apache-2.0\n\
//\n\
// The guided tour's practice contact, bob: his contact code, as Rust writes\n\
// one. Written by the dsm_sdk test\n\
// `the_tour_practice_contact_code_is_one_rust_reads` (handlers/identity_routes.rs),\n\
// which fails when this file and Rust's encoding differ. Rust reads it when\n\
// it is pasted into Add Contact; adding bob is practice mode's.\n\
export const PRACTICE_CONTACT_CODE =\n '{code}';\n"
);
match std::env::var_os("DSM_WRITE_FRONTEND_FIXTURES") {
Some(_) => std::fs::write(PRACTICE_CONTACT_FILE, &file)
.expect("write the frontend's practice contact"),
None => assert_eq!(
std::fs::read_to_string(PRACTICE_CONTACT_FILE)
.expect("the frontend's committed practice contact"),
file,
"the frontend's practice contact code differs from Rust's encoding; \
rewrite it with DSM_WRITE_FRONTEND_FIXTURES=1"
),
}
}
}
9 changes: 8 additions & 1 deletion dsm_client/frontend/src/bridge/BridgeRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
// SPDX-License-Identifier: MIT OR Apache-2.0

import type { AndroidBridgeV3 } from '../dsm/bridgeTypes';
import { inPracticeSandbox, practiceView } from './practiceGate';

let currentBridge: AndroidBridgeV3 | undefined;

export function setBridgeInstance(bridge: AndroidBridgeV3 | undefined) {
currentBridge = bridge;
}

/**
* The bridge every call to native code goes through. While the guided tour's
* practice wallet stands in, it is the practice view, which lets only reads
* cross (bridge/practiceGate.ts).
*/
export function getBridgeInstance(): AndroidBridgeV3 | undefined {
return currentBridge;
if (currentBridge === undefined) return undefined;
return inPracticeSandbox() ? practiceView(currentBridge) : currentBridge;
}
120 changes: 120 additions & 0 deletions dsm_client/frontend/src/bridge/__tests__/practiceGate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// SPDX-License-Identifier: Apache-2.0
//! While the tour's practice wallet stands in, the bridge lets only reads reach
//! native code, whatever module makes the call. Practice mode used to patch the
//! dsmClient object and nothing else, so a screen that imported a write
//! directly (token creation, burn, forget, every SoFi action, the lock, NFC)
//! reached the real wallet during the tour. These tests drive such direct
//! imports. Reads are answered from Rust's own record of the ingress, and every
//! request that reaches the bridge is logged, so "blocked" means the bridge
//! never saw it.

import { join } from 'path';
import * as pb from '../../proto/dsm_app_pb';
import { answerFromRustRecord } from '../../tests/helpers/rustIngressRecord';
import type { Arrival } from '../../tests/helpers/rustIngressRecord';
import { enterPracticeSandbox, inPracticeSandbox, leavePracticeSandbox, PRACTICE_BLOCKED_MESSAGE } from '../practiceGate';
import { burnToken } from '../../dsm/policies';
import * as sofi from '../../dsm/sofi';
import { walletAmount } from '../../dsm/amount';
import { callBin, setPreference } from '../../dsm/WebViewBridge';
import { startNativeQrScan, writeNfcTagPayloadHost } from '../../dsm/NativeHostBridge';
import { dsmClient } from '../../services/dsmClient';
import { practiceMode, PRACTICE_CONTACT_DEVICE_ID } from '../../components/tour/practiceMode';

const RECORD = join(__dirname, '../../components/tour/__tests__/fixtures/wallet_amount.ingress.bin');
const carried = (arrivals: Arrival[]): string[] => arrivals.map((a) => a.carried);
const client = dsmClient as unknown as Record<string, (...args: any[]) => Promise<any>>;

const VAULT = new Uint8Array(32).fill(0x51);
const TOKEN_IN = new Uint8Array(32).fill(0x52);
const TOKEN_OUT = new Uint8Array(32).fill(0x53);

describe('the practice sandbox at the bridge', () => {
let arrivals: Arrival[];
beforeEach(() => {
arrivals = answerFromRustRecord(RECORD);
enterPracticeSandbox();
});
afterEach(() => leavePracticeSandbox());

it('blocks a write a screen imports directly, past dsmClient, before it reaches native code', async () => {
const burned = await burnToken({ tokenId: 'ERA', amount: '1' });
expect(burned).toEqual(expect.objectContaining({ message: expect.stringContaining(PRACTICE_BLOCKED_MESSAGE) }));
await expect(
sofi.trade({ vaultId: VAULT, tokenIn: TOKEN_IN, tokenOut: TOKEN_OUT, amountIn: '1', minAmountOut: '1' }),
).rejects.toThrow(PRACTICE_BLOCKED_MESSAGE);
await expect(sofi.resolve()).rejects.toThrow(PRACTICE_BLOCKED_MESSAGE);
expect(arrivals).toEqual([]);
});

it('blocks a raw frame that carries a write to the ingress', async () => {
const frame = new pb.IngressRequest({
operation: { case: 'routerInvoke', value: new pb.RouterInvokeOp({ method: 'token.create' }) },
});
await expect(callBin('nativeBoundaryIngress', frame.toBinary())).rejects.toThrow(PRACTICE_BLOCKED_MESSAGE);
expect(arrivals).toEqual([]);
});

it('lets a read through, and Rust answers it', async () => {
await expect(walletAmount({ tokenId: 'ERA' }, { entered: '1000' })).resolves.toEqual({
baseUnits: 100000n,
displayAmount: '1000.00',
decimals: 2,
});
expect(carried(arrivals)).toEqual(['wallet.amount']);
});

it('keeps the preferences that change how the app looks and sounds, and no others', async () => {
await setPreference('ui_theme', 'dark');
await setPreference('lock_enabled', '1');
await setPreference('diagnostics_consent', '1');
expect(carried(arrivals)).toEqual(['ui_theme']);
});

it('lets the camera open for a contact code, and blocks a write to an NFC ring', async () => {
await expect(startNativeQrScan()).rejects.toThrow(/no recorded answer/);
await expect(writeNfcTagPayloadHost(new Uint8Array([7]))).rejects.toThrow(PRACTICE_BLOCKED_MESSAGE);
expect(carried(arrivals)).toEqual(['HOST_CONTROL_QR_START_SCAN']);
});
});

describe('outside practice', () => {
it('the same writes reach native code', async () => {
const arrivals = answerFromRustRecord(RECORD);
await burnToken({ tokenId: 'ERA', amount: '1' });
await expect(sofi.resolve()).rejects.toThrow(/no recorded answer/);
await setPreference('lock_enabled', '1');
expect(carried(arrivals)).toEqual(['token.burn', 'sofi.resolve', 'lock_enabled']);
});
});

describe('practice mode puts the bridge in its sandbox', () => {
let arrivals: Arrival[];
beforeEach(() => {
arrivals = answerFromRustRecord(RECORD);
practiceMode.enter();
});
afterEach(() => practiceMode.leave());

it('blocks a dsmClient write that practice does not answer', async () => {
const burned = await client.burnToken({ tokenId: 'ERA', amount: '1' });
expect(burned).toEqual(expect.objectContaining({ message: expect.stringContaining(PRACTICE_BLOCKED_MESSAGE) }));
await expect(client.forgetToken('PLAY')).rejects.toThrow(PRACTICE_BLOCKED_MESSAGE);
expect(arrivals).toEqual([]);
});

it('answers a write it simulates with only Rust reads crossing the bridge', async () => {
const sent = await client.sendOnlineTransferSmart(PRACTICE_CONTACT_DEVICE_ID, '25', undefined, 'ERA');
expect(sent).toEqual(expect.objectContaining({ newBalance: 97500n }));
// Rust renders the amounts (wallet.amount) and says whether practice ERA
// is protocol-defined (balance.list, as main reads it since #1098): both
// reads, and nothing else crosses.
expect(new Set(carried(arrivals))).toEqual(new Set(['wallet.amount', 'balance.list']));
});

it('takes the bridge out of its sandbox when it leaves', () => {
expect(inPracticeSandbox()).toBeTruthy();
practiceMode.leave();
expect(inPracticeSandbox()).toBeFalsy();
});
});
162 changes: 162 additions & 0 deletions dsm_client/frontend/src/bridge/practiceGate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// SPDX-License-Identifier: Apache-2.0
//
// The guided tour's sandbox, at the bridge. While the tour's practice wallet
// stands in for the device's, every call the WebView makes to native code is
// read here before it leaves, whichever module made it and however it was
// imported, and only reads cross: each one named below. Everything else is
// answered "blocked in practice mode" in its channel's own wire shape and never
// reaches Rust or the host. The practice wallet's own answers
// (components/tour/practiceMode.ts) are made before any call is sent; this is
// what keeps the real wallet untouched while they are.

import {
BridgeRpcRequest,
BridgeRpcResponse,
Error as ProtoError,
ErrorResponse,
IngressRequest,
IngressResponse,
NativeHostRequest,
NativeHostRequestKind,
NativeHostResponse,
PreferencePayload,
} from '../proto/dsm_app_pb';
import type { AndroidBridgeV3 } from '../dsm/bridgeTypes';

export const PRACTICE_BLOCKED_MESSAGE =
'Practice mode: this is switched off until the tour ends. Your real wallet is untouched.';

/** Bridge methods that only read. */
const READ_METHODS = new Set([
'getAllBalancesStrict',
'getTransportHeadersV3Bin',
'getPreference',
'getArchitectureInfo',
'getDiagnosticsLog',
]);

/** The preferences the tour's shell lessons change: how the app looks and sounds. */
const DISPLAY_PREFERENCES = new Set(['ui_theme', 'sfx_enabled']);

/**
* Router routes that only read, whether the router takes them as a query or an
* invoke: the screens the tour visits read these. A route that writes anything,
* a query path included (`tokens.addByAnchor`, `storage.sync`, `prefs.set`), is
* not here.
*/
const READ_ROUTES = new Set([
'balance.list',
'wallet.history',
'wallet.amount',
'contacts.list',
'contacts.readContactCode',
'identity.contact_code',
'inbox.pull',
'storage.status',
'tokens.getFeeSchedule',
'token.adoptionQr',
'bilateral.pending_list',
'recovery.status',
'recovery.capsulePreview',
'recovery.phase',
'recovery.syncStatus',
'sofi.findRoute',
'sofi.vaults',
'bitcoin.balance',
'bitcoin.vault.list',
]);

/** Host requests that change nothing: what the host can do, and the camera a contact code is scanned with. */
const HOST_READS = new Set<NativeHostRequestKind>([
NativeHostRequestKind.HOST_CONTROL_CAPABILITIES_GET,
NativeHostRequestKind.HOST_CONTROL_QR_START_SCAN,
NativeHostRequestKind.HOST_CONTROL_QR_STOP_SCAN,
]);

let sandbox: 'real' | 'practice' = 'real';

/** The tour's practice wallet is standing in: only reads reach native code. */
export function enterPracticeSandbox(): void {
sandbox = 'practice';
}

export function leavePracticeSandbox(): void {
sandbox = 'real';
}

export function inPracticeSandbox(): boolean {
return sandbox === 'practice';
}

/** What a request is, when it may not cross: undefined when it is a read. */
type Refusal = string | undefined;

function ingressRefusal(bytes: Uint8Array): Refusal {
const operation = IngressRequest.fromBinary(bytes).operation;
if (operation.case === 'routerQuery' || operation.case === 'routerInvoke') {
return READ_ROUTES.has(operation.value.method) ? undefined : operation.value.method;
}
return `ingress ${String(operation.case)}`;
}

function hostRefusal(bytes: Uint8Array): Refusal {
const kind = NativeHostRequest.fromBinary(bytes).kind;
return HOST_READS.has(kind) ? undefined : `host request ${NativeHostRequestKind[kind]}`;
}

function rpcRefusal(bytes: Uint8Array): Refusal {
const call = BridgeRpcRequest.fromBinary(bytes);
const payload = call.payload.case === 'bytes' ? call.payload.value.data : new Uint8Array(0);
if (call.method === 'nativeBoundaryIngress') return ingressRefusal(payload);
if (call.method === 'nativeHostRequest') return hostRefusal(payload);
if (call.method === 'setPreference') {
const key = PreferencePayload.fromBinary(payload).key;
return DISPLAY_PREFERENCES.has(key) ? undefined : `setPreference ${key}`;
}
return READ_METHODS.has(call.method) ? undefined : call.method;
}

/** A request that cannot be read is refused, never let through. */
function refusalOf(read: (bytes: Uint8Array) => Refusal, bytes: Uint8Array): Refusal {
try {
return read(bytes);
} catch (e) {
return `an unreadable request (${e instanceof Error ? e.message : String(e)})`;
}
}

const blocked = (what: string): string => `${PRACTICE_BLOCKED_MESSAGE} (${what})`;

/**
* The bridge as the WebView sees it while the tour runs: the same object, with
* every request read first. Reads go to the real bridge; anything else is
* answered here, refused, in the shape that channel answers a refusal in.
*/
export function practiceView(bridge: AndroidBridgeV3): AndroidBridgeV3 {
return {
__binary: bridge.__binary,
isAvailable: () => bridge.isAvailable(),
getBridgeStatus: () => bridge.getBridgeStatus(),
sendMessageBin: async (bytes: Uint8Array) => {
const what = refusalOf(rpcRefusal, bytes);
if (what === undefined) return bridge.sendMessageBin(bytes);
return new BridgeRpcResponse({
result: { case: 'error', value: new ErrorResponse({ message: blocked(what) }) },
}).toBinary();
},
ingress: async (bytes: Uint8Array) => {
const what = refusalOf(ingressRefusal, bytes);
if (what === undefined) return bridge.ingress(bytes);
return new IngressResponse({
result: { case: 'error', value: new ProtoError({ message: blocked(what) }) },
}).toBinary();
},
hostRequest: async (bytes: Uint8Array) => {
const what = refusalOf(hostRefusal, bytes);
if (what === undefined) return bridge.hostRequest(bytes);
return new NativeHostResponse({
result: { case: 'error', value: new ProtoError({ message: blocked(what) }) },
}).toBinary();
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ export default function QRCodeScannerPanel(props: QRCodeScannerProps = {}): Reac
: 'Enter the contact code shown with the QR, or use the camera.'}
</p>

<section className="sb-card">
<section className="sb-card" data-tour="contact-code">
<div className="sb-card__title">Enter Contact Code</div>
<div className="sb-field">
<textarea
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ const AccountsScreen: React.FC<{ eraTokenSrc?: string; btcLogoSrc?: string }> =
<button
type="button"
className="sb-btn"
data-tour="add-token"
onClick={() => { setAddingAnchor(''); setError(null); setSuccessMsg(null); }}
>
+ Add Token (CPTA)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ const ContactsTabScreen: React.FC<Props> = () => {
</button>
</div>
) : (
<section className="sb-card">
<section className="sb-card" data-tour="contact-list">
{contacts.map((c, i) => {
const isOpen = selected === i;
const toggle = () => setSelected(isOpen ? null : i);
Expand Down
Loading
Loading