Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion dapps/pos-app/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"android.permission.BLUETOOTH_ADVERTISE",
"android.permission.USB_PERMISSION"
],
"versionCode": 24
"versionCode": 25
},
"web": {
"output": "static",
Expand Down Expand Up @@ -128,6 +128,12 @@
"faceIDPermission": "Allow $(PRODUCT_NAME) to access your Face ID biometric data."
}
],
[
"expo-camera",
{
"cameraPermission": "$(PRODUCT_NAME) needs camera access to scan QR codes."
}
],
"@sentry/react-native",
"expo-image"
],
Expand Down
6 changes: 6 additions & 0 deletions dapps/pos-app/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ export default Sentry.wrap(function RootLayout() {
}}
/>
<Stack.Screen name="settings" />
<Stack.Screen name="update-keys" />
<Stack.Screen name="export-keys" />
<Stack.Screen
name="scan-customer-key"
options={{ headerShown: false }}
/>
<Stack.Screen name="activity" />
<Stack.Screen name="logs" />
</Stack>
Expand Down
211 changes: 211 additions & 0 deletions dapps/pos-app/app/export-keys.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { Button } from "@/components/button";
import { PinModal } from "@/components/pin-modal";
import QRCode from "@/components/qr-code";
import { ThemedText } from "@/components/themed-text";
import { BorderRadius, Spacing } from "@/constants/spacing";
import { useBiometricAuth } from "@/hooks/use-biometric-auth";
import { usePinGate } from "@/hooks/use-pin-gate";
import { useTheme } from "@/hooks/use-theme-color";
import { useSettingsStore } from "@/store/useSettingsStore";
import { encodeDeviceSetupQr } from "@/utils/device-setup-qr";
import { router } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { StyleSheet, View } from "react-native";

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 951994d. export-keys.tsx now bails out before requireAuth when Platform.OS !== "web" (and also when on the bundled default keys), navigating back via leaveScreen() — so a native deep link never reaches the QR.

import { ScrollView } from "react-native-gesture-handler";

export default function ExportKeysScreen() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Auto Review Issue: export-keys screen accessible on native via deep link — credentials can leak

Severity: HIGH
Category: security
Tool: Claude Auto Review

Context:

  • Pattern: ExportKeysScreen renders unconditionally on any platform; no Platform.OS guard exists. The update-keys.tsx correctly hides the "Export keys" button with {isWeb && ...}, but the route itself is registered and reachable on native via router.push('/export-keys') or a deep link.
  • Risk: The PR's stated security model ("native builds have no export path") is only one navigational hop away from being violated. Any deep link or programmatic navigation on a native device reaches the export screen and shows the credentials QR after PIN.
  • Impact: Credentials exfiltration from a native POS device.
  • Trigger: Any call to router.push('/export-keys') on a native build.

Recommendation:

export default function ExportKeysScreen() {
  // Web-only: native builds must never expose an export path.
  useEffect(() => {
    if (Platform.OS !== 'web') {
      router.replace('/update-keys');
    }
  }, []);

  if (Platform.OS !== 'web') return null;
  // ...rest of component

Fix this →

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 951994d. export-keys.tsx now bails out before requireAuth when Platform.OS !== "web" (and also when on the bundled default keys), navigating back via leaveScreen() — so a native deep link never reaches the QR.

const theme = useTheme();
const storedMerchantId = useSettingsStore((state) => state.merchantId);
const getCustomerApiKey = useSettingsStore(
(state) => state.getCustomerApiKey,
);

const {
activeModal,
pinError,
requireAuth,
handlePinVerifyComplete,
handlePinSetupComplete,
handleBiometricSuccess,
handleBiometricFailure,
cancel,
} = usePinGate();
const { biometricLabel, canUseBiometric, authenticate } = useBiometricAuth();

const [unlocked, setUnlocked] = useState(false);
const [qrValue, setQrValue] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const startedRef = useRef(false);

// Require a PIN once, before anything sensitive is read or shown.
useEffect(() => {
if (startedRef.current) return;
startedRef.current = true;
requireAuth(() => setUnlocked(true));
}, [requireAuth]);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 951994d. export-keys.tsx now bails out before requireAuth when Platform.OS !== "web" (and also when on the bundled default keys), navigating back via leaveScreen() — so a native deep link never reaches the QR.


// Read the customer key only after the user has unlocked.
useEffect(() => {
if (!unlocked) return;
let active = true;
(async () => {
const customerApiKey = await getCustomerApiKey();
if (!active) return;
if (storedMerchantId && customerApiKey) {
setQrValue(
encodeDeviceSetupQr({
merchantId: storedMerchantId,
customerApiKey,
}),
);
} else {
setQrValue(null);
}
setIsLoading(false);
})();
return () => {
active = false;
};
}, [unlocked, storedMerchantId, getCustomerApiKey]);

const handleBiometricAuth = useCallback(async () => {
const success = await authenticate(`Use ${biometricLabel} to export keys`);
if (success) {
handleBiometricSuccess();
} else {
handleBiometricFailure();
}
}, [
authenticate,
biometricLabel,
handleBiometricSuccess,
handleBiometricFailure,
]);

// Cancelling the PIN prompt leaves the screen entirely.
const handleCancel = useCallback(() => {
cancel();
router.back();
}, [cancel]);

const goToUpdateKeys = useCallback(() => {
if (router.canGoBack()) {
router.back();
} else {
router.replace("/update-keys");
}
}, []);

return (
<View style={styles.container}>
{unlocked && (
<ScrollView
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{!isLoading && !qrValue ? (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Auto Review Issue: QR renders with undefined uri during async key load

Severity: LOW
Category: code_quality
Tool: Claude Auto Review

Context:

  • Pattern: The render logic is {!isLoading && !qrValue ? <error> : <QRCode uri={qrValue ?? undefined} />}. When isLoading is true, qrValue is null, so the QR component renders with uri={undefined} — the loading/error branch is not shown, the QR branch is shown with no data.
  • Risk: The QR component may render a broken/empty state before the key loads.
  • Trigger: Brief window between unlock and key retrieval.

Recommendation:

{isLoading ? (
  <ActivityIndicator />
) : !qrValue ? (
  // error state
) : (
  <QRCode size={280} uri={qrValue} arenaClear />
)}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't fix — not a broken state. The QRCode component renders a Shimmer placeholder whenever uri is empty, so the brief load window shows a shimmer rather than a broken QR. Happy to swap it for an explicit spinner later, but there's no defect here.

<>
<ThemedText
fontSize={16}
lineHeight={22}
color="text-secondary"
style={styles.centerText}
>
Set a merchant ID and customer API key first.
</ThemedText>
<Button
onPress={goToUpdateKeys}
style={[
styles.cta,
{ backgroundColor: theme["bg-accent-primary"] },
]}
>
<ThemedText fontSize={16} lineHeight={18} color="text-invert">
Go to Update keys
</ThemedText>
</Button>
</>
) : (
<>
<ThemedText
fontSize={16}
lineHeight={22}
color="text-secondary"
style={styles.centerText}
>
On the new device, open Settings → Update keys → Import keys,
then scan this setup code.
</ThemedText>

<QRCode size={280} uri={qrValue ?? undefined} arenaClear />

<View
style={[
styles.warning,
{
backgroundColor: theme["foreground-primary"],
borderColor: theme["border-primary"],
},
]}
>
<ThemedText fontSize={14} lineHeight={20} color="text-tertiary">
Anyone who scans this gets full access to your merchant
account. Only show it to devices you trust, and don&apos;t
share a photo of it.
</ThemedText>
</View>
</>
)}
</ScrollView>
)}

<PinModal
visible={activeModal !== "none"}
title={activeModal === "pin-verify" ? "Enter PIN" : "Create PIN"}
subtitle={
activeModal === "pin-verify"
? "Enter your PIN to export keys."
: "Set a 4-digit PIN to protect your settings."
}
onComplete={
activeModal === "pin-verify"
? handlePinVerifyComplete
: handlePinSetupComplete
}
onCancel={handleCancel}
error={pinError}
showBiometric={activeModal === "pin-verify" && !!canUseBiometric}
onBiometricPress={handleBiometricAuth}
/>
</View>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: Spacing["spacing-5"],
paddingVertical: Spacing["spacing-6"],
gap: Spacing["spacing-6"],
},
centerText: {
textAlign: "center",
},
cta: {
height: 56,
borderRadius: BorderRadius["4"],
paddingHorizontal: Spacing["spacing-6"],
justifyContent: "center",
alignItems: "center",
},
warning: {
borderWidth: 1,
borderRadius: 16,
padding: Spacing["spacing-4"],
},
});
Loading