Skip to content

Add hardware security key verification for Bitcoin transactions#116

Merged
eminogrande merged 5 commits into
developfrom
hardware-security-key
Aug 22, 2025
Merged

Add hardware security key verification for Bitcoin transactions#116
eminogrande merged 5 commits into
developfrom
hardware-security-key

Conversation

@eminogrande
Copy link
Copy Markdown
Collaborator

@eminogrande eminogrande commented Aug 21, 2025

Summary

  • Implements hardware security key (YubiKey, etc.) verification for Bitcoin transactions
  • Only allows transactions to be signed after verifying the registered hardware key
  • Prevents unauthorized transactions even if someone has access to the device

Key Features

✅ Hardware key required for Bitcoin sends when registered
✅ Direct NFC/USB security key authentication (bypasses passkey selection)
✅ Filters to show only the registered credential
✅ Handles nil userID for hardware keys (fixes crash)
✅ Uses exact username from registration (e.g., 33@nuri.com)

Technical Implementation

  • New authenticateWithSecurityKeyOnly() method for hardware-only auth
  • Credential filtering using allowedCredentials
  • Safe handling of optional userID throughout
  • Proper error handling and user feedback

Testing

  • Register a hardware security key in Security tab
  • Try to send Bitcoin - will prompt for key verification
  • Tap/insert key - transaction proceeds only after verification
  • Works with multiple credentials on same key (filters to registered one)

🤖 Generated with Claude Code (https://claude.ai/code)

Summary by CodeRabbit

  • New Features

    • Added dedicated hardware security key authentication and pre-send verification.
    • Introduced a new Buy Bitcoin experience via an embedded web flow (Mercuryo).
    • Added a “Bitcoin address copied!” toast in Receive, with automatic navigation to Buy after copying.
    • Added an additional Buy Bitcoin sheet accessible from the Bitcoin view.
  • Changes

    • Buy button label now consistently shows “+ Buy Bitcoin” (still hidden on testnet when configured).

eminogrande and others added 5 commits August 21, 2025 13:14
- Add new authenticateWithSecurityKeyOnly() method that bypasses passkey selection UI
- Only prompts for hardware security key (single device) authentication
- Uses the exact username from registered hardware key (e.g. 33@nuri.com)
- Prevents showing platform passkeys during transaction verification
- Ensures only the registered hardware key can authorize transactions
- Improved error handling for missing username/credential scenarios

The implementation now correctly:
1. Checks if user has a hardware key registered (username + credential ID)
2. When verifying, uses ONLY the security key provider (no platform provider)
3. Passes the exact username to match the single device registration
4. Directly prompts for NFC/USB security key without showing passkey list

🤖 Generated with Claude Code (https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Use fetchAuthenticationOptions instead of non-existent getAuthenticationOptions
- Replace non-existent PasskeyError.invalidChallenge with .serverError
- Fixes compilation errors for hardware security key authentication
- Add allowedCredentials filter to only show the registered credential
- Prevents showing multiple credentials when hardware key has many stored
- Uses the stored credential ID from registration to filter the list
- Add helper methods to manage stored credentials
- Support both USB and NFC transports for hardware keys

This ensures that when the user taps their hardware key, they only see
the specific credential that was registered for this app, not all
credentials stored on the key.

🤖 Generated with Claude Code (https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Fix nil userID crash in hardware key authentication
- Hardware security keys often don't have a userID/userHandle
- Make userID optional throughout the verification flow
- Properly handle nil userID in logging and API requests

The crash was caused by force-unwrapping credential.userID which is
nil for many hardware security key assertions. Now safely handles
this case with optional chaining.

🤖 Generated with Claude Code (https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Aug 21, 2025

Walkthrough

Replaced Buy Bitcoin flow with a WKWebView to Mercuryo and updated navigation to present it. Added a hardware security key–only authentication path and integrated key verification into the send-confirmation flow. Tweaked Receive to copy address and show a toast before opening Buy. Removed testnet-specific buy label variant.

Changes

Cohort / File(s) Summary
Buy flow overhaul (Mercuryo webview + navigation)
Nuri/Nuri/Sources/Views/Buy Bitcoin/BuyBitcoinView.swift, Nuri/Nuri/Sources/Views/Bitcoin/BitcoinView.swift, Nuri/Nuri/Sources/Views/Bitcoin/Receive/ReceiveView.swift
Replaced Apple Pay/Striga flow with WKWebView loading Mercuryo; switched BuyBitcoinView to EnvironmentObject navigation; added new sheet presentation from BitcoinView; ReceiveView now copies address, shows a toast, and navigates to Buy.
Hardware security key auth service
Nuri/Nuri/Sources/Services/PasskeyAuthenticationService.swift
Added nil-safe logging, credential storage helpers, and a security-key-only authentication method; supports allowedCredentials filtering and server verification.
Send confirm security key gating
Nuri/Nuri/Sources/Views/Bitcoin/Send/ConfirmTransactionView.swift
Introduced pre-send hardware key verification, UI alert/overlay, modified button title logic, and flow to require key verification before sending.
Network configuration label tweak
Nuri/Nuri/Sources/Common/NetworkConfiguration.swift
Removed testnet-specific buy label; generic “+ Buy Bitcoin” used when shown; hiding behavior on testnet unchanged.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant ReceiveView
  participant Clipboard
  participant Toast
  participant Nav as BitcoinViewNavigation
  participant BuyView as BuyBitcoinView (WKWebView)

  User->>ReceiveView: Tap "Buy Bitcoin"
  alt Valid address
    ReceiveView->>Clipboard: Copy BTC address
    Clipboard-->>ReceiveView: Success
    ReceiveView->>Toast: Show "Address copied!"
  else Invalid/missing address
    ReceiveView->>Toast: Show "Address copied!" (skipped copy)
  end
  Note over ReceiveView,Toast: Toast visible ~2s, then hides
  ReceiveView->>Nav: isReceiveViewPresented = false
  ReceiveView->>Nav: isBuyBitcoinPresented = true
  Nav->>BuyView: Present sheet
  BuyView->>BuyView: Load https://exchange.mercuryo.io/ in WKWebView
Loading
sequenceDiagram
  autonumber
  actor User
  participant Confirm as ConfirmTransactionView
  participant PasskeySvc as PasskeyAuthenticationService
  participant AS as AuthenticationServices
  participant Server

  User->>Confirm: Tap primary button
  alt HW key registered && not verified
    Confirm->>User: Alert to verify with hardware key
    User->>Confirm: Continue
    Confirm->>AS: Request presentation anchor
    Confirm->>PasskeySvc: authenticateWithSecurityKeyOnly(username, anchor)
    PasskeySvc->>Server: Fetch auth options
    PasskeySvc->>AS: Present security key assertion UI
    AS-->>PasskeySvc: Assertion (or cancel/error)
    PasskeySvc->>Server: Verify assertion
    Server-->>PasskeySvc: Verification result
    PasskeySvc-->>Confirm: (verified, username, isAnonymous)
    alt Verified
      Confirm->>Confirm: securityKeyVerified = true
      Confirm->>Server: Send transaction
    else Cancel/Error
      Confirm->>Confirm: Show error or abort
    end
  else No HW key gating
    Confirm->>Server: Send transaction
  end
  Confirm->>Confirm: Reset securityKeyVerified after success
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

A hopper taps the keys with glee,
Web-waves bring Bitcoin across the sea.
A shiny key blinks, “Verify me!”
Toast pops, “Copied!”—swift and free.
Click, hop, shipped—so tidily.
(\/)<
( •
•) — bun approved!

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch hardware-security-key

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@claude
Copy link
Copy Markdown

claude Bot commented Aug 21, 2025

Claude finished @eminogrande's task —— View job


Code Review: Hardware Security Key Verification for Bitcoin Transactions

Todo List:

  • Read repository guidelines (CLAUDE.md)
  • Review PasskeyAuthenticationService.swift changes
  • Review ConfirmTransactionView.swift changes
  • Review other Bitcoin-related view changes
  • Analyze security implementation
  • Check error handling and edge cases
  • Evaluate test coverage needs
  • Provide comprehensive feedback

Summary

This PR implements hardware security key verification for Bitcoin transactions, requiring users to verify with their registered security key (YubiKey, etc.) before signing transactions. The implementation includes credential filtering, proper error handling, and clean integration with the existing transaction flow.

Detailed Code Review

Strengths

  1. Security-First Design: Properly requires hardware key verification for all Bitcoin transactions when registered
  2. Credential Filtering: Uses allowedCredentials to show only the registered credential, avoiding user confusion
  3. Safe UserID Handling: Correctly handles nil userID for security keys throughout the codebase
  4. Clean Integration: Security verification is properly separated from transaction logic
  5. User Experience: Gracefully handles user cancellation without unnecessary error dialogs

⚠️ Critical Issues

Security Vulnerabilities

  1. Insecure Credential Storage (ConfirmTransactionView.swift:384-388)

    let hasUsername = UserDefaults.standard.string(forKey: "passkeyUsername") != nil
    let hasCredentialId = UserDefaults.standard.string(forKey: "passkeyCredentialId") != nil

    Issue: Authentication credentials stored in UserDefaults are accessible to other processes and not encrypted. Move to Keychain immediately.

  2. Missing Input Validation (PasskeyAuthenticationService.swift:411-414)

    guard let challenge = Data(base64URLEncoded: authOptions.challenge) else {
        throw PasskeyError.serverError  // Generic error
    }

    Issue: Should validate challenge format and length. Malformed challenges could cause unexpected behavior.

  3. Inconsistent Network Security (PasskeyAuthenticationService.swift:1059-1061)

    urlRequest.timeoutInterval = 30.0 // Only in registration, not authentication

    Issue: Missing timeouts on authentication requests could lead to hanging operations.

Error Handling & Reliability

  1. Information Leakage (ConfirmTransactionView.swift:520-522)

    errorMessage = "Failed to verify security key: \(error.localizedDescription)"

    Issue: Raw error messages exposed to users may leak implementation details. Use sanitized error messages.

  2. Missing Edge Cases (ConfirmTransactionView.swift:427-433)

    if let storedCredentialId = UserDefaults.standard.string(forKey: "passkeyCredentialId"),
       let credentialIdData = Data(base64URLEncoded: storedCredentialId) {

    Issue: No handling for corrupted or invalid credential data. Could cause crashes or security bypasses.

  3. Race Condition Risk (ConfirmTransactionView.swift:449-452)

    await MainActor.run {
        self.securityKeyVerified = false // Reset for next transaction
    }

    Issue: State reset happens after successful transaction. Concurrent operations could bypass verification.

Code Quality Issues

  1. Method Complexity (PasskeyAuthenticationService.swift:395-478)
    The authenticateWithSecurityKeyOnly() method is 83 lines and handles multiple responsibilities. Extract into smaller, focused methods.

  2. Code Duplication (PasskeyAuthenticationService.swift:821-890 vs 892-980)
    Security key and platform authentication share ~70% identical code. Create shared verification helper.

  3. Missing Documentation
    Critical security methods lack comprehensive documentation explaining parameters, security implications, and usage patterns.

🔒 Security-Specific Recommendations

Immediate Actions Required

  1. Move credentials to Keychain: Replace UserDefaults with Keychain for all authentication metadata
  2. Add input validation: Validate all data from external sources (server responses, stored credentials)
  3. Implement consistent timeouts: Add timeouts to all network operations
  4. Sanitize error messages: Create user-friendly error messages that don't expose internals

Security Enhancements

  1. Add audit logging: Log all security key authentication attempts for security monitoring
  2. Implement retry policies: Add exponential backoff for network failures
  3. Add session management: Implement proper session handling to prevent replay attacks
  4. Rate limiting: Client-side rate limiting for authentication attempts

📝 Test Coverage Recommendations

This critical security feature needs comprehensive testing:

  1. Unit Tests: Test credential validation, error handling, and state management
  2. Integration Tests: Test end-to-end security key authentication flow
  3. Security Tests: Test with corrupted data, network failures, and edge cases
  4. UI Tests: Verify user flows and error message display

🎯 Priority Recommendations

High Priority (Security Critical):

  • Move credential storage to Keychain
  • Add input validation for all external data
  • Implement consistent network timeouts
  • Add comprehensive error handling

Medium Priority (Reliability):

  • Refactor large methods for maintainability
  • Add retry logic for network operations
  • Implement proper session management

Low Priority (Quality of Life):

  • Add comprehensive documentation
  • Implement audit logging
  • Add rate limiting

📊 Overall Assessment

Strengths: Good architectural approach, clean user experience, proper credential filtering
Concerns: Security vulnerabilities in credential storage, missing input validation, inconsistent error handling

Recommendation: Conditional Approval - Address high-priority security issues before merging. The core implementation is sound, but credential storage and input validation must be hardened.


Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
Nuri/Nuri/Sources/Services/PasskeyAuthenticationService.swift (5)

250-257: Redact PII in logs (credentialId, userHandle).

Good move making userHandle nil-safe. However, logging full credential IDs and user handles is sensitive. Prefer truncation or hashing, and gate these behind DEBUG.

Apply this diff to mask values in both places:

-                Log.passkey.success("User selected platform credential (built-in)", metadata: [
-                    "credentialId": credential.credentialID.base64URLEncodedString(),
-                    "userHandle": credential.userID?.base64URLEncodedString() ?? "none",
+                Log.passkey.success("User selected platform credential (built-in)", metadata: [
+                    "credentialId": credential.credentialID.base64URLEncodedString().prefix(10) + "...",
+                    "userHandle": (credential.userID?.base64URLEncodedString().prefix(10) ?? "none") + "...",
-                Log.passkey.success("User selected security key credential (hardware key)", metadata: [
-                    "credentialId": credential.credentialID.base64URLEncodedString(),
-                    "userHandle": credential.userID?.base64URLEncodedString() ?? "none",
+                Log.passkey.success("User selected security key credential (hardware key)", metadata: [
+                    "credentialId": credential.credentialID.base64URLEncodedString().prefix(10) + "...",
+                    "userHandle": (credential.userID?.base64URLEncodedString().prefix(10) ?? "none") + "...",

Also applies to: 303-309


853-864: Reduce sensitive logging in auth verification.

Even at debug level, logging credentialId and full body increases risk. Mask identifiers; keep raw JSON logging strictly in DEBUG and consider redaction.

-        Log.passkey.debug("Security key verification request", metadata: [
-            "credentialId": credentialIdBase64,
-            "authenticatorType": "securityKey",
-            "userHandle": credential.userID?.base64URLEncodedString() ?? "none",
-            "endpoint": endpoint
-        ])
+        Log.passkey.debug("Security key verification request", metadata: [
+            "credentialId": credentialIdBase64.prefix(10) + "...",
+            "authenticatorType": "securityKey",
+            "userHandle": (credential.userID?.base64URLEncodedString().prefix(10) ?? "none") + "...",
+            "endpoint": endpoint
+        ])
 #if DEBUG
-        if let jsonString = String(data: request.httpBody!, encoding: .utf8) {
+        if let jsonString = String(data: request.httpBody!, encoding: .utf8) {
             Log.passkey.debug("Security key auth request JSON", metadata: ["body": jsonString])
         }
 #endif

924-943: Wrap detailed clientData logging in DEBUG (it contains challenge and origin).

clientDataJSON may include sensitive values. Gate the decoded dump behind DEBUG to avoid appearing in production logs.

-        if let clientDataJSON = try? JSONSerialization.jsonObject(with: credential.rawClientDataJSON, options: []) as? [String: Any] {
-            Log.passkey.debug("Client data JSON decoded", metadata: clientDataJSON)
-        }
+        #if DEBUG
+        if let clientDataJSON = try? JSONSerialization.jsonObject(with: credential.rawClientDataJSON, options: []) as? [String: Any] {
+            Log.passkey.debug("Client data JSON decoded", metadata: clientDataJSON)
+        }
+        #endif

771-791: Avoid logging full URLs with PII query items.

username in the query is PII; logging url.absoluteString leaks it. Redact or log path only.

-        Log.network.info("Sending auth options request", metadata: ["url": url.absoluteString, "username": username ?? "any"])
+        Log.network.info("Sending auth options request", metadata: [
+            "path": url.path,
+            "hasUsername": username != nil
+        ])

1566-1594: Avoid fatalError() in presentationAnchor; fail gracefully.

Crashing on missing window is harsh and can occur in edge cases (multi-scene transitions, backgrounded state). Prefer a soft failure and a plausible fallback anchor.

-            fatalError("No window available for passkey presentation")
+            assertionFailure("No window available for passkey presentation")
+            // Return a non-nil placeholder to avoid crash; controller will likely fail and surface an error.
+            return UIWindow(frame: UIScreen.main.bounds)
Nuri/Nuri/Sources/Views/Bitcoin/Send/ConfirmTransactionView.swift (1)

16-20: Replace verbose print statements with the app logger or gate behind DEBUG.

These prints will ship to production and clutter logs. Use Log.* or wrap prints in #if DEBUG.

Example:

-        print("🚀 [ConfirmTransactionView] sendTransaction() called")
+        Log.ui.info("[ConfirmTransactionView] sendTransaction() called")

Or:

-        print("✅ [ConfirmTransactionView] TransactionManager.sendTransaction() completed successfully!")
+        #if DEBUG
+        print("✅ [ConfirmTransactionView] TransactionManager.sendTransaction() completed successfully!")
+        #endif

Also applies to: 224-226, 373-377, 398-404, 416-421, 435-447, 454-460, 465-471, 492-499, 501-510, 512-524

🧹 Nitpick comments (18)
Nuri/Nuri/Sources/Services/PasskeyAuthenticationService.swift (4)

387-393: Prefer service helpers over raw UserDefaults access (single source of truth).

You added getStoredCredentialInfo(). Use it everywhere (including UI) to avoid key drift and ease future migrations (e.g., to Keychain).


422-425: UVA set to discouraged — confirm policy for high-value actions.

Setting userVerificationPreference = .discouraged avoids PIN prompts, but reduces assurance from UVA to mere presence (touch). For funds movement, some compliance programs expect UVA (PIN on key) at least optionally.

If policy allows, keep as-is. Otherwise consider a server flag to toggle between .preferred and .required for certain transaction risk tiers.


426-438: Support multiple credentials and server-provided filters.

Filtering by a single locally stored credential ID is brittle. If a user re-registers or has multiple credentials on the same key, rely on the server’s allowCredentials (if present) and fall back to local storage only if needed.

Example changes:

  • Extend AuthenticationOptionsResponse:
 struct AuthenticationOptionsResponse: Codable {
     let challenge: String
     let timeout: Int
     let rpId: String
     let userVerification: String
+    let allowCredentials: [AllowedCredential]?
+
+    struct AllowedCredential: Codable {
+        let type: String
+        let id: String
+    }
 }
  • Build allowedCredentials from the response first:
-        if let storedCredentialId = UserDefaults.standard.string(forKey: "passkeyCredentialId"),
+        if let allow = authOptions.allowCredentials, !allow.isEmpty {
+            let descriptors: [ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor] = allow.compactMap {
+                guard let idData = Data(base64URLEncoded: $0.id) else { return nil }
+                return .init(credentialID: idData, transports: [.usb, .nfc])
+            }
+            securityKeyRequest.allowedCredentials = descriptors
+        } else if let storedCredentialId = UserDefaults.standard.string(forKey: "passkeyCredentialId"),
            let credentialIdData = Data(base64URLEncoded: storedCredentialId) {

1188-1191: Temporary authProof placeholder — track with TODO and feature flag.

authProof is a placeholder and “server doesn't validate yet”. Add a TODO and guard submission under a feature flag to prevent shipping this path inadvertently if server-side validation toggles later.

Nuri/Nuri/Sources/Views/Bitcoin/Send/ConfirmTransactionView.swift (6)

55-63: Use PasskeyAuthenticationService helper instead of raw UserDefaults.

Avoid duplicating key names and storage logic in the view. Leverage getStoredCredentialInfo() for readability and future migrations (e.g., to Keychain).

-            let hasUsername = UserDefaults.standard.string(forKey: "passkeyUsername") != nil
-            let hasCredentialId = UserDefaults.standard.string(forKey: "passkeyCredentialId") != nil
+            let stored = PasskeyAuthenticationService.shared.getStoredCredentialInfo()
+            let hasUsername = stored.username != nil
+            let hasCredentialId = stored.credentialId != nil

46-47: Disable button while verifying security key to prevent re-entrancy.

Include isVerifyingSecurityKey in shouldDisableButton to avoid multiple concurrent verification attempts.

-    private var shouldDisableButton: Bool {
-        return isSending || transactionInfo == nil || transactionData == nil || isInsufficientFunds
-    }
+    private var shouldDisableButton: Bool {
+        return isSending || isVerifyingSecurityKey || transactionInfo == nil || transactionData == nil || isInsufficientFunds
+    }

257-281: Overlay blocks interaction — good. Consider haptics for prompt acknowledgment.

The full-screen overlay avoids accidental taps during verification. Optionally, trigger a light haptic when the OS sheet appears.


384-397: Gating relies on local presence of both username and credentialId — potential bypass.

If the credential ID wasn’t persisted (e.g., app reinstall without restore), a registered user on the server won’t be gated. Consider checking with PasskeyAuthenticationService.checkUserExists(username:) or using the auth options endpoint to infer presence before sending.

Minimal improvement without extra network trips:

-        let hasUsername = UserDefaults.standard.string(forKey: "passkeyUsername") != nil
-        let hasCredentialId = UserDefaults.standard.string(forKey: "passkeyCredentialId") != nil
+        let stored = PasskeyAuthenticationService.shared.getStoredCredentialInfo()
+        let hasUsername = stored.username != nil
+        let hasCredentialId = stored.credentialId != nil

If you want stronger guarantees, I can wire a lightweight cached “has registered passkey” flag from server.


465-498: Centralize presentation anchor retrieval.

This logic duplicates similar window discovery in PasskeyAuthenticationService. Prefer a single helper (e.g., PresentationAnchorProvider.shared.currentAnchor()) to avoid divergence.

I can extract the common code into a tiny utility and update both call sites.


96-103: Guard against divide-by-zero when computing EUR rate.

If amountSats ever becomes zero, btcAmount is zero, causing a division by zero for eurRate. Add a small guard.

-                        let btcAmount = Double(txData.amountSats) / 100_000_000
-                        let eurRate = txData.eurAmount / btcAmount
+                        let btcAmount = Double(txData.amountSats) / 100_000_000
+                        let eurRate = btcAmount > 0 ? (txData.eurAmount / btcAmount) : 0
Nuri/Nuri/Sources/Views/Bitcoin/Receive/ReceiveView.swift (2)

70-89: Streamline navigation sequencing and add UIKit import for UIPasteboard.

  • The nested DispatchQueue calls work but are brittle. Prefer a single Task with async sleeps to avoid race-y nested callbacks and make cancellation easier.
  • Ensure UIKit is imported since UIPasteboard lives there; otherwise this will fail to compile on some targets.

Apply this refactor within the button action to simplify flow and timing:

-                Button("Buy Bitcoin") {
-                    // Copy address to clipboard
-                    if Self.isBitcoinAddress(address) {
-                        UIPasteboard.general.string = address
-                        showCopiedToast = true
-                        
-                        // Hide toast after 2 seconds
-                        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
-                            showCopiedToast = false
-                        }
-                    }
-                    
-                    // Close receive view first, then open buy webview
-                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
-                        navigation.isReceiveViewPresented = false
-                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
-                            navigation.isBuyBitcoinPresented = true
-                        }
-                    }
-                }
+                Button("Buy Bitcoin") {
+                    Task { @MainActor in
+                        if Self.isBitcoinAddress(address) {
+                            UIPasteboard.general.string = address
+                            showCopiedToast = true
+                            try? await Task.sleep(nanoseconds: 2_000_000_000)
+                            showCopiedToast = false
+                        }
+                        navigation.isReceiveViewPresented = false
+                        try? await Task.sleep(nanoseconds: 800_000_000)
+                        navigation.isBuyBitcoinPresented = true
+                    }
+                }

To support UIPasteboard without surprises, add this near the top of the file (outside the changed range):

import UIKit

Optional UX polish (non-diff):

  • Fire a success haptic and accessibility announcement after copying:
    let gen = UINotificationFeedbackGenerator(); gen.notificationOccurred(.success)
    UIAccessibility.post(notification: .announcement, argument: "Bitcoin address copied.")

96-117: Toast overlay works; consider safe-area awareness and reduced-motion.

  • Fixed 50pt top padding can collide with status bars/notches across devices. Prefer safeAreaInset at .top or use safeAreaInset + transition.
  • Respect Reduce Motion by disabling animations when UIAccessibility.isReduceMotionEnabled is true.

Example adjustment (outside diff range):

.safeAreaInset(edge: .top) {
    if showCopiedToast {
        HStack(spacing: 8) {
            Image(systemName: "checkmark.circle.fill").foregroundColor(.white)
            Text("Bitcoin address copied!").foregroundColor(.white).font(.system(size: 14, weight: .medium))
        }
        .padding()
        .background(Color.black.opacity(0.8))
        .cornerRadius(20)
        .transition(.move(edge: .top).combined(with: .opacity))
        .animation(UIAccessibility.isReduceMotionEnabled ? nil : .easeInOut, value: showCopiedToast)
    }
}
Nuri/Nuri/Sources/Views/Bitcoin/BitcoinView.swift (2)

157-160: Wrap the new Buy sheet in a NavigationStack for consistency; keep explicit env injection.

Other sheets use NavigationStack, so align this one. Keeping the explicit .environmentObject avoids any ordering pitfalls with environment propagation across modifiers.

Apply this localized diff:

-        .sheet(isPresented: $navigation.isBuyBitcoinPresented) {
-            BuyBitcoinView()
-                .environmentObject(navigation)
-        }
+        .sheet(isPresented: $navigation.isBuyBitcoinPresented) {
+            NavigationStack {
+                BuyBitcoinView()
+                    .environmentObject(navigation)
+            }
+        }

7-7: Unify Buy flow presentations into a single route

Our audit confirms two independent presentation flags in BitcoinView.swift, each driving its own sheet:

  • isBuyViewPresented → presents BuyBitcoinFlowView() (legacy/Striga flow)
  • isBuyBitcoinPresented → presents BuyBitcoinView() (Mercuryo flow)

With both flags published separately, there’s no built-in mutual exclusion—if both are set to true in quick succession, SwiftUI may try to show two sheets simultaneously. To prevent overlapping presentations and simplify state management, consider consolidating these into a single enum-backed route, for example:

enum BuyRoute {
    case none
    case legacy
    case mercuryo
}

@Published var buyRoute: BuyRoute = .none

Then drive a single .sheet off buyRoute, switching its content based on the enum case.

• File: Nuri/Nuri/Sources/Views/Bitcoin/BitcoinView.swift
– Lines 7–8:
swift @Published var isBuyBitcoinPresented = false @Published var isBuyViewPresented = false
– Lines 152–159: two separate .sheet(isPresented:) modifiers

This refactor will:

  • Eliminate the risk of double presentations
  • Centralize buy-flow logic into one state variable
  • Make future additions (e.g., new providers) easier to slot in
Nuri/Nuri/Sources/Views/Buy Bitcoin/BuyBitcoinView.swift (4)

4-18: Harden WKWebView configuration: ephemeral datastore and better UX.

  • Consider nonPersistent websiteDataStore to avoid persisting third-party cookies/tracking across sessions (privacy-friendly default).
  • Enable back/forward gestures for better navigation.
  • Optionally set contentMode to .mobile for better layout if the site serves responsive content.

Apply this focused diff:

 struct BuyBitcoinWebView: UIViewRepresentable {
     func makeUIView(context: Context) -> WKWebView {
         let configuration = WKWebViewConfiguration()
         configuration.preferences.javaScriptEnabled = true
+        configuration.websiteDataStore = .nonPersistent()
 
         let webView = WKWebView(frame: .zero, configuration: configuration)
         webView.navigationDelegate = context.coordinator
+        webView.allowsBackForwardNavigationGestures = true
+        if #available(iOS 16.4, *) {
+            webView.isInspectable = false
+        }
 
         if let url = URL(string: "https://exchange.mercuryo.io/") {
             let request = URLRequest(url: url)
             webView.load(request)
         }
 
         return webView
     }

Note: If Mercuryo requires persistent sessions (e.g., KYC), keep the default persistent store. Otherwise, ephemeral is safer-by-default.


26-33: Add provisional navigation error handling and tighten navigation policy to the target domain.

  • didFailProvisionalNavigation catches initial load failures (DNS/SSL/etc.).
  • Optionally, restrict navigation to exchange.mercuryo.io and open external links in SFSafariViewController if needed to prevent unintended domain escapes.

Apply this localized diff:

 class Coordinator: NSObject, WKNavigationDelegate {
     func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
         print("✅ Mercuryo exchange loaded")
     }
 
     func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
         print("❌ Failed to load Mercuryo: \(error)")
     }
+
+    func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
+        print("❌ Provisional navigation failed: \(error)")
+    }
+
+    func webView(_ webView: WKWebView,
+                 decidePolicyFor navigationAction: WKNavigationAction,
+                 decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
+        if let host = navigationAction.request.url?.host,
+           host.hasSuffix("mercuryo.io") {
+            decisionHandler(.allow)
+        } else {
+            // Optional: open external links in Safari instead of inside the webview
+            decisionHandler(.cancel)
+            if let url = navigationAction.request.url {
+                UIApplication.shared.open(url)
+            }
+        }
+    }
 }

Caveat: If the flow legitimately navigates to subdomains or auxiliary domains, expand the allowlist accordingly.


37-50: Dismiss action is clear; add minimal loading/error UI around the webview.

Right now, failures only log to console. Consider a simple spinner and a retry affordance driven by Coordinator callbacks to improve user feedback (aligned with the PR’s “improved error handling and user feedback” goal).

Example (compact) pattern:

  • Add @State private var isLoading = true and @State private var loadError: String? in BuyBitcoinView.
  • Update Coordinator to flip these states via bindings (e.g., pass closures from BuyBitcoinWebView to Coordinator).
  • Overlay a ProgressView while isLoading, and a small in-view error with “Retry” that calls webView.reload().

If you want, I can draft a concrete diff wiring these states end-to-end.


12-15: Prefill the destination BTC address via Mercuryo URL parameters

It’s now confirmed that Mercuryo’s widget supports an address query parameter (along with a required cryptographic signature) to prefill the user’s deposit address, which eliminates the need to rely on the clipboard and reduces input errors. You can enhance the existing loader in BuyBitcoinView.swift by constructing the URL with the following parameters:

• widget_id – your Mercuryo widget identifier
• address – the user’s BTC address to receive funds
• merchant_transaction_id – a unique ID for this transaction (optional but recommended)
• signature – HMAC-SHA512 (or as specified) over (address + widget_secret + ip_address + merchant_transaction_id), generated on your backend

Example updated snippet:

// Assume `btcAddress`, `widgetID`, `transactionID` are available,
// and `fetchSignature(...)` retrieves a valid signature from your backend.

let baseURL = "https://exchange.mercuryo.io/"
var components = URLComponents(string: baseURL)!
components.queryItems = [
  URLQueryItem(name: "widget_id", value: widgetID),
  URLQueryItem(name: "address", value: btcAddress),
  URLQueryItem(name: "merchant_transaction_id", value: transactionID),
  URLQueryItem(name: "signature", value: fetchSignature(address: btcAddress,
                                                       widgetID: widgetID,
                                                       transactionID: transactionID))
]

if let url = components.url {
  let request = URLRequest(url: url)
  webView.load(request)
}

• Ensure your backend implements Mercuryo’s signature protocol (typically HMAC-SHA512) and that the widget’s “Address Prefill” feature is enabled in your Mercuryo dashboard.
• By passing these parameters, the widget will automatically populate the destination address field, improving UX and reducing clipboard–related pitfalls.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between e2eb33b and a710b81.

📒 Files selected for processing (6)
  • Nuri/Nuri/Sources/Common/NetworkConfiguration.swift (0 hunks)
  • Nuri/Nuri/Sources/Services/PasskeyAuthenticationService.swift (7 hunks)
  • Nuri/Nuri/Sources/Views/Bitcoin/BitcoinView.swift (1 hunks)
  • Nuri/Nuri/Sources/Views/Bitcoin/Receive/ReceiveView.swift (2 hunks)
  • Nuri/Nuri/Sources/Views/Bitcoin/Send/ConfirmTransactionView.swift (7 hunks)
  • Nuri/Nuri/Sources/Views/Buy Bitcoin/BuyBitcoinView.swift (1 hunks)
💤 Files with no reviewable changes (1)
  • Nuri/Nuri/Sources/Common/NetworkConfiguration.swift
🧰 Additional context used
🧬 Code graph analysis (3)
Nuri/Nuri/Sources/Views/Bitcoin/Send/ConfirmTransactionView.swift (3)
Nuri/Nuri/Sources/Services/PasskeyAuthenticationService.swift (2)
  • authenticateWithSecurityKeyOnly (395-478)
  • presentationAnchor (1567-1597)
Nuri/Nuri/Sources/Views/Onboarding/OnboardingWireframe.swift (1)
  • presentationAnchor (125-127)
Nuri/Nuri/Sources/Views/Onboarding/Login/LoginViewModel.swift (1)
  • presentationAnchor (113-115)
Nuri/Nuri/Sources/Services/PasskeyAuthenticationService.swift (1)
Nuri/Nuri/Sources/Common/Logger.swift (2)
  • warning (79-88)
  • success (68-77)
Nuri/Nuri/Sources/Views/Buy Bitcoin/BuyBitcoinView.swift (4)
Nuri/Nuri/Sources/Views/Card/StrigaCardWebView.swift (2)
  • makeUIView (203-265)
  • webView (280-292)
Nuri/Nuri/Sources/Views/Card/CardConsentWebView.swift (3)
  • makeUIView (21-232)
  • webView (263-310)
  • webView (312-314)
Nuri/Nuri/Sources/Views/Card/HostedCardView.swift (3)
  • makeUIView (12-34)
  • webView (49-65)
  • webView (67-69)
Nuri/Nuri/Sources/Design System/UI Components/NuriHeader.swift (1)
  • logo (48-68)
🔇 Additional comments (6)
Nuri/Nuri/Sources/Services/PasskeyAuthenticationService.swift (1)

395-478: Solid hardware-key-only path and credential filtering.

Clean, direct flow: server-driven rpId, security-key-only request, allowedCredentials filter, and strict type check. This is exactly what we need to bypass platform passkeys.

Nuri/Nuri/Sources/Views/Bitcoin/Send/ConfirmTransactionView.swift (3)

31-35: Nice, explicit UI state for hardware-key verification.

Clear separation of alert, overlay, and “verified” flag makes the flow easy to reason about.


246-256: Good UX: clear alert copy and explicit verify action.

The alert-driven entry to verification makes the requirement obvious without surprising the user.


500-510: Re-entering send flow from verification risks double logs; functional flow is fine.

Calling sendTransaction() after setting securityKeyVerified = true is sound. With the button disabled while verifying (see earlier comment), re-entrancy is controlled.

Nuri/Nuri/Sources/Views/Bitcoin/Receive/ReceiveView.swift (1)

8-8: State flag for toast is appropriate and minimal.

The dedicated boolean keeps the toast rendering simple and predictable.

Nuri/Nuri/Sources/Views/Buy Bitcoin/BuyBitcoinView.swift (1)

53-56: Preview setup looks correct with environment object.

No issues.

Comment on lines +378 to +385
// Helper method to clear stored passkey credentials (useful for debugging)
func clearStoredCredentials() {
Log.passkey.info("Clearing stored passkey credentials")
UserDefaults.standard.removeObject(forKey: "passkeyUsername")
UserDefaults.standard.removeObject(forKey: "passkeyCredentialId")
UserDefaults.standard.removeObject(forKey: "passkeyIsAnonymous")
UserDefaults.standard.removeObject(forKey: "passkeyUserEmail")
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Do not store passkey identifiers in UserDefaults; move to Keychain-backed store.

passkeyUsername, passkeyCredentialId, and passkeyUserEmail are PII and should not live in UserDefaults. Use Keychain (kSecClassGenericPassword) and wrap with a small CredentialsStore for atomic get/set/clear.

I can provide a minimal Keychain helper and a migration that reads old values from UserDefaults, writes to Keychain, then clears the old keys. Want a patch?

🤖 Prompt for AI Agents
In Nuri/Nuri/Sources/Services/PasskeyAuthenticationService.swift around lines
378–385, the helper currently clears passkeyUsername, passkeyCredentialId,
passkeyIsAnonymous, and passkeyUserEmail from UserDefaults but three of those
(username, credentialId, userEmail) are PII and must not be stored in
UserDefaults; implement a Keychain-backed CredentialsStore (use
kSecClassGenericPassword) with atomic get/set/clear methods and replace all
reads/writes to those three keys to use the store, add a migration routine that
on first run reads existing values from UserDefaults, writes them into Keychain,
then removes the old UserDefaults keys, and update clearStoredCredentials to
clear the credentials from the Keychain-backed store (and only remove the
UserDefaults keys as part of migration cleanup).

@eminogrande eminogrande merged commit 07caaef into develop Aug 22, 2025
4 checks passed
@eminogrande eminogrande deleted the hardware-security-key branch August 22, 2025 10:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant