From f525b069664cc34fbac3c00f5101aaabd774fece Mon Sep 17 00:00:00 2001 From: protom Date: Thu, 20 Aug 2026 08:31:59 +0200 Subject: [PATCH] feat: capability layer for clipboard, share and deep links with tests --- src/capabilities/biometricUnlock.ts | 172 ++++++++++++++++++ src/capabilities/clipboard.ts | 25 +++ src/capabilities/deepLinks.test.ts | 71 ++++++++ src/capabilities/deepLinks.ts | 86 +++++++++ src/capabilities/platform.ts | 26 +++ src/capabilities/share.ts | 25 +++ .../receive/ReceiveLightningDialog.vue | 29 +-- src/components/send/PayInvoiceDialog.vue | 14 +- src/components/send/SendTokenDialog.vue | 21 ++- src/pages/BackupPage.vue | 5 +- src/pages/NwcPage.vue | 5 +- 11 files changed, 446 insertions(+), 33 deletions(-) create mode 100644 src/capabilities/biometricUnlock.ts create mode 100644 src/capabilities/clipboard.ts create mode 100644 src/capabilities/deepLinks.test.ts create mode 100644 src/capabilities/deepLinks.ts create mode 100644 src/capabilities/platform.ts create mode 100644 src/capabilities/share.ts diff --git a/src/capabilities/biometricUnlock.ts b/src/capabilities/biometricUnlock.ts new file mode 100644 index 0000000..f911eb1 --- /dev/null +++ b/src/capabilities/biometricUnlock.ts @@ -0,0 +1,172 @@ +// Native biometric unlock: a THIRD wrap of the same linking key, alongside +// the password wrap (keys.ts) and the passkey slots (passkeys.ts). +// +// Why native: Android WebView has no usable WebAuthn platform authenticator +// for this app (passkeys need Play Services / Digital Asset Links wiring we +// cannot rely on), so the biometric path on native is a device-credential +// prompt instead of a passkey ceremony. +// +// Design (mirrors the passkey-slot construction, reusing its wrap +// primitives verbatim): on enrollment a random 32-byte wrap secret is +// generated, the linking key is AES-GCM-wrapped under an HKDF of that +// secret (wrapLinkingKeyWithPrf - the "PRF output" parameter is just 32 +// bytes of IKM), and only the SECRET goes into biometric-gated secure +// storage (Android Keystore-backed AES-GCM via +// @aparajita/capacitor-secure-storage). The wrapped blob stays in +// localStorage as a record shaped like a passkey slot, plus the linking +// pubkey as an identity check: restoring a DIFFERENT seed leaves a stale +// wrap behind, and unlocking with it must fail loudly (never activate the +// old wallet silently), so unlock verifies the unwrapped key against the +// recorded pubkey and tells the holder to re-enroll. +// +// The biometric gate is app-level (a BiometricPrompt before the secure +// read), not a keystore key invalidated on biometric re-enrollment - 04 +// deliberately prefers BIOMETRY_ANY/weak so adding a fingerprint doesn't +// wipe the holder's unlock. allowDeviceCredential keeps PIN/pattern as the +// system fallback. +// +// Web/PWA: every entry point reports unavailable and never touches the +// plugins' web shims (secure-storage's web impl is unencrypted localStorage +// - explicitly not for production secrets). + +import { + AndroidBiometryStrength, + BiometricAuth, + BiometryError, + BiometryErrorType, +} from '@aparajita/capacitor-biometric-auth'; +import { SecureStorage } from '@aparajita/capacitor-secure-storage'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'; + +import { linkingPubKeyHex } from '@/lnurlcash/keys'; +import type { PasskeyWrap } from '@/lnurlcash/passkeys'; +import { unwrapLinkingKeyWithPrf, wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys'; + +import { isNative } from './platform'; + +type BiometricWrapRecord = PasskeyWrap & { + pubkey: string; // linking pubkey the wrap belongs to - detects stale wraps + createdAt: number; +}; + +// the wrapped blob is useless without the secure-storage secret, so (like +// the passkey slots) this record sits in plain localStorage +const WRAP_RECORD_STORAGE_KEY = 'sattle_biometric_wrap'; +const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret'; + +const isValidWrapRecord = (record: unknown): record is BiometricWrapRecord => { + if (typeof record !== 'object' || record === null) return false; + const r = record as Record; + return ( + typeof r.hkdfSalt === 'string' && + /^[0-9a-f]{32}$/i.test(r.hkdfSalt) && + typeof r.iv === 'string' && + /^[0-9a-f]{24}$/i.test(r.iv) && + typeof r.wrappedKey === 'string' && + r.wrappedKey.length > 0 && + r.wrappedKey.length % 2 === 0 && + /^[0-9a-f]+$/i.test(r.wrappedKey) && + typeof r.pubkey === 'string' && + /^[0-9a-f]{66}$/i.test(r.pubkey) && + typeof r.createdAt === 'number' + ); +}; + +const readWrapRecord = (): BiometricWrapRecord | null => { + const raw = localStorage.getItem(WRAP_RECORD_STORAGE_KEY); + if (!raw) return null; + try { + const parsed: unknown = JSON.parse(raw); + return isValidWrapRecord(parsed) ? parsed : null; + } catch { + return null; + } +}; + +// sync on purpose (same convention as hasPasskeySlots): the unlock form and +// the security page ask this during render/setup +export const isBiometricUnlockEnrolled = (): boolean => readWrapRecord() !== null; + +export const biometricUnlockAvailable = async (): Promise => { + if (!isNative() || !isBiometricUnlockEnrolled()) return false; + try { + return (await BiometricAuth.checkBiometry()).isAvailable; + } catch { + return false; + } +}; + +// the shared prompt options: weak biometry + device credential fallback, so +// any screen lock the holder already uses qualifies (see header) +const authenticate = (reason: string): Promise => + BiometricAuth.authenticate({ + reason, + cancelTitle: 'Cancel', + allowDeviceCredential: true, + androidTitle: 'sattle', + androidSubtitle: reason, + androidBiometryStrength: AndroidBiometryStrength.weak, + }); + +// a cancelled prompt must surface as an ordinary failure message, not a +// crash-shaped error - normalize to a plain Error with holder-facing text +const authenticateOrThrow = async (reason: string): Promise => { + try { + await authenticate(reason); + } catch (err) { + if (err instanceof BiometryError && err.code === BiometryErrorType.userCancel) { + throw new Error('Biometric prompt was cancelled.', { cause: err }); + } + throw err; + } +}; + +export const enableBiometricUnlock = async (linkingKey: Uint8Array): Promise => { + if (!isNative()) throw new Error('Biometric unlock is only available in the native app.'); + const biometry = await BiometricAuth.checkBiometry(); + if (!biometry.isAvailable) { + throw new Error(biometry.reason || 'No biometric unlock is set up on this device.'); + } + // prove presence before storing anything under the biometric gate + await authenticateOrThrow('Set up biometric unlock for your wallet'); + const secret = crypto.getRandomValues(new Uint8Array(32)); + const wrap = await wrapLinkingKeyWithPrf(secret, linkingKey); + // secure storage first: if it fails, no record is written and the wallet + // simply stays unenrolled instead of carrying an unwrap-able-nothing + await SecureStorage.set(SECURE_SECRET_KEY, bytesToHex(secret)); + const record: BiometricWrapRecord = { + ...wrap, + pubkey: linkingPubKeyHex(linkingKey), + createdAt: Date.now(), + }; + localStorage.setItem(WRAP_RECORD_STORAGE_KEY, JSON.stringify(record)); +}; + +export const unlockWithBiometrics = async (): Promise => { + const record = readWrapRecord(); + if (!isNative() || !record) { + throw new Error('Biometric unlock is not set up on this device.'); + } + await authenticateOrThrow('Unlock your sattle wallet'); + const secretHex = await SecureStorage.get(SECURE_SECRET_KEY); + if (typeof secretHex !== 'string' || !/^[0-9a-f]{64}$/i.test(secretHex)) { + throw new Error('Biometric unlock data is missing - set it up again in Settings > Security.'); + } + const linkingKey = await unwrapLinkingKeyWithPrf(hexToBytes(secretHex), record); + if (linkingPubKeyHex(linkingKey) !== record.pubkey) { + throw new Error( + 'Biometric unlock belongs to a different wallet - set it up again in Settings > Security.', + ); + } + return linkingKey; +}; + +// the localStorage record goes first and synchronously: even if the caller +// fire-and-forgets this (forgetWallet), a later unlock attempt can never +// reach the secure secret with a stale record +export const disableBiometricUnlock = async (): Promise => { + localStorage.removeItem(WRAP_RECORD_STORAGE_KEY); + if (isNative()) { + await SecureStorage.remove(SECURE_SECRET_KEY); + } +}; diff --git a/src/capabilities/clipboard.ts b/src/capabilities/clipboard.ts new file mode 100644 index 0000000..6de2c62 --- /dev/null +++ b/src/capabilities/clipboard.ts @@ -0,0 +1,25 @@ +// Clipboard capability: WebView clipboard reads are unreliable on Android +// (navigator.clipboard.readText is often denied outside of paste gestures), +// so native goes through @capacitor/clipboard. Web keeps today's behavior: +// quasar's copyToClipboard (with its execCommand fallback) for writes and +// navigator.clipboard for reads. +import { Clipboard } from '@capacitor/clipboard'; +import { copyToClipboard } from 'quasar'; + +import { isNative } from './platform'; + +export const writeClipboard = async (text: string): Promise => { + if (isNative()) { + await Clipboard.write({ string: text }); + return; + } + await copyToClipboard(text); +}; + +export const readClipboard = async (): Promise => { + if (isNative()) { + const { value } = await Clipboard.read(); + return value; + } + return navigator.clipboard.readText(); +}; diff --git a/src/capabilities/deepLinks.test.ts b/src/capabilities/deepLinks.test.ts new file mode 100644 index 0000000..091187a --- /dev/null +++ b/src/capabilities/deepLinks.test.ts @@ -0,0 +1,71 @@ +// Deep-link parsing: the pure classification half of the capability. +// Fixtures mirror what actually arrives via Android intents and the PWA +// protocol handler - scheme-wrapped invoices, lnurlw bearer links, bech32. +import { describe, expect, it } from 'vitest'; +import { toBech32Lnurl } from 'lnurlcash-kit'; + +import { parseExternalInput } from './deepLinks'; + +const K1 = 'ab'.repeat(32); +const NOTE_URL = `lnurlw://mint.example/withdraw?k1=${K1}`; +const INVOICE = 'lnbc210n1pqqqqqqqqqqqqqqqqqqqq'; + +describe('parseExternalInput', () => { + it('routes an lnurlw:// bearer link to receive', () => { + expect(parseExternalInput(NOTE_URL)).toEqual({ kind: 'note', value: expect.any(String) }); + expect(parseExternalInput(NOTE_URL)?.value).toContain('k1='); + }); + + it('routes a bech32 lnurl carrying a k1 to receive', () => { + const bech32 = toBech32Lnurl(`https://mint.example/withdraw?k1=${K1}`); + expect(parseExternalInput(bech32)).toEqual({ kind: 'note', value: expect.any(String) }); + }); + + it('strips the lightning: scheme from invoices and routes to pay', () => { + expect(parseExternalInput(`lightning:${INVOICE}`)).toEqual({ kind: 'pay', value: INVOICE }); + }); + + it('handles the uppercase LIGHTNING: scheme', () => { + expect(parseExternalInput(`LIGHTNING:${INVOICE.toUpperCase()}`)).toEqual({ + kind: 'pay', + value: INVOICE.toUpperCase(), + }); + }); + + it('routes a bare bolt11 invoice to pay', () => { + expect(parseExternalInput(INVOICE)).toEqual({ kind: 'pay', value: INVOICE }); + }); + + it('strips the PWA web+ prefix from protocol-handler launches', () => { + expect(parseExternalInput(`web+lightning:${INVOICE}`)).toEqual({ + kind: 'pay', + value: INVOICE, + }); + }); + + it('routes a Lightning Address to pay', () => { + expect(parseExternalInput('alice@example.com')).toEqual({ + kind: 'pay', + value: 'alice@example.com', + }); + }); + + it('routes an lnurlp:// link to pay', () => { + const link = 'lnurlp://pay.example/.well-known/lnurlp/alice'; + expect(parseExternalInput(link)).toEqual({ kind: 'pay', value: link }); + }); + + it('routes a lightning:-wrapped bearer note to receive, not pay', () => { + const result = parseExternalInput(`lightning:${NOTE_URL}`); + expect(result?.kind).toBe('note'); + }); + + it('rejects empty and unrecognized input', () => { + expect(parseExternalInput('')).toBeNull(); + expect(parseExternalInput(' ')).toBeNull(); + expect(parseExternalInput('hello world')).toBeNull(); + expect(parseExternalInput('https://example.com/page')).toBeNull(); + // an lnurlw link WITHOUT a k1 is neither a note nor payable + expect(parseExternalInput('lnurlw://mint.example/withdraw')).toBeNull(); + }); +}); diff --git a/src/capabilities/deepLinks.ts b/src/capabilities/deepLinks.ts new file mode 100644 index 0000000..4b70b8f --- /dev/null +++ b/src/capabilities/deepLinks.ts @@ -0,0 +1,86 @@ +// Deep-link capability: inbound bearer-note / Lightning URLs from outside +// the app, classified and handed to the home screen, which routes them into +// the existing receive/pay dialogs via their initialInput props. +// +// Sources: +// - native: Android intent filters (lightning:, lnurlw:, lnurlp: - see +// android/app/src/main/AndroidManifest.xml) surface through +// @capacitor/app's appUrlOpen (warm) / getLaunchUrl (cold start). +// - web/PWA: the manifest's protocol_handlers (web+lightning) land the app +// on /?uri= - read once at boot from location.search. +// +// parseExternalInput is the pure, unit-tested half; everything Capacitor is +// confined to initDeepLinks. The pending value is consumed by IndexPage +// once the wallet is unlocked (the receive/pay flows need the keys). + +import { ref } from 'vue'; +import { + isBech32Lnurl, + isBolt11Invoice, + isLightningAddress, + isValidNoteInput, +} from 'lnurlcash-kit'; + +import { isNative } from './platform'; + +export type ExternalInput = { kind: 'note' | 'pay'; value: string }; + +// Classify an inbound link the way the Scan button classifies a scan: a +// bearer note goes to receive, anything payable (bolt11, bech32 LNURL, +// Lightning Address, lnurlp:) goes to pay. The lightning: URI scheme (and +// the PWA's web+lightning variant) is stripped; everything else is passed +// through verbatim for the dialogs' own validation. +export const parseExternalInput = (raw: string): ExternalInput | null => { + const trimmed = raw.trim(); + if (!trimmed) return null; + const value = trimmed.replace(/^web\+/i, ''); + // a bearer note (lnurlw:// or bech32 lnurl carrying a k1) is never a pay + // request - check both the raw and the lightning:-stripped form + if (isValidNoteInput(value)) return { kind: 'note', value }; + const stripped = value.replace(/^lightning:/i, '').trim(); + if (isValidNoteInput(stripped)) return { kind: 'note', value: stripped }; + if ( + isBolt11Invoice(stripped) || + isBech32Lnurl(stripped) || + isLightningAddress(stripped) || + /^lnurlp:\/\//i.test(stripped) + ) { + return { kind: 'pay', value: stripped }; + } + return null; +}; + +// the home screen watches this and opens the matching dialog as soon as the +// wallet is unlocked; until then the value simply waits (a locked wallet +// must not swallow the link) +export const pendingExternalInput = ref(null); + +export const consumePendingExternalInput = (): ExternalInput | null => { + const pending = pendingExternalInput.value; + pendingExternalInput.value = null; + return pending; +}; + +const receive = (rawUrl: string, navigateHome: () => void): void => { + const input = parseExternalInput(rawUrl); + if (!input) return; + pendingExternalInput.value = input; + navigateHome(); +}; + +// Wires the platform sources into the pending value. notify is invoked +// after every accepted link so the caller can route to the home screen. +// Web is a one-shot read (protocol-handler launch); native also subscribes +// for the lifetime of the app. +export const initDeepLinks = async (navigateHome: () => void): Promise => { + if (!isNative()) { + const uri = + typeof window === 'undefined' ? null : new URLSearchParams(window.location.search).get('uri'); + if (uri) receive(uri, navigateHome); + return; + } + const { App } = await import('@capacitor/app'); + const launch = await App.getLaunchUrl(); + if (launch?.url) receive(launch.url, navigateHome); + await App.addListener('appUrlOpen', ({ url }) => receive(url, navigateHome)); +}; diff --git a/src/capabilities/platform.ts b/src/capabilities/platform.ts new file mode 100644 index 0000000..1fb8afb --- /dev/null +++ b/src/capabilities/platform.ts @@ -0,0 +1,26 @@ +// The capability layer: the ONLY place Capacitor plugins are imported. App +// code (components, pages, stores) consumes these modules; each one no-ops +// to today's web behavior in the PWA and calls the plugin on native. +// +// Surface, deliberately minimal - only what the app already does: +// - platform.ts isNative() detection +// - clipboard.ts writeClipboard / readClipboard (@capacitor/clipboard) +// - share.ts canShareText / shareText (@capacitor/share) +// - biometricUnlock.ts biometric-gated wrap of the linking key +// (@aparajita/capacitor-biometric-auth + +// @aparajita/capacitor-secure-storage, per 04) +// - deepLinks.ts lightning:/lnurlw:/lnurlp: inbound URLs (@capacitor/app) +// +// Two documented non-abstractions: +// - QR scan: QrScanner.vue's getUserMedia camera approach works identically +// in the Capacitor WebView (CAMERA permission is declared in the Android +// manifest), so there is no native divergence to hide - no scan plugin. +// - Storage: the wallet's localStorage backend stays as-is this milestone. +// The linking key at rest is AES-GCM under the holder's password exactly +// as on web; the ONLY secret moved into native secure storage is the +// biometric wrap secret (see biometricUnlock.ts). A full storage backend +// migration is a separate, fund-critical change. + +import { Capacitor } from '@capacitor/core'; + +export const isNative = (): boolean => Capacitor.isNativePlatform(); diff --git a/src/capabilities/share.ts b/src/capabilities/share.ts new file mode 100644 index 0000000..9ec1d6e --- /dev/null +++ b/src/capabilities/share.ts @@ -0,0 +1,25 @@ +// Share capability: navigator.share doesn't exist in the Android WebView, +// so native goes through @capacitor/share and the share affordance is +// available there too. Cancellation is normalized to the web's AbortError +// DOMException so callers can treat "user dismissed the sheet" uniformly. +import { Share } from '@capacitor/share'; + +import { isNative } from './platform'; + +export const canShareText = (): boolean => + isNative() || (typeof navigator !== 'undefined' && typeof navigator.share === 'function'); + +export const shareText = async (title: string, text: string): Promise => { + if (isNative()) { + try { + await Share.share({ title, text, dialogTitle: title }); + } catch (err) { + if (err instanceof Error && err.message.toLowerCase().includes('cancel')) { + throw new DOMException(err.message, 'AbortError'); + } + throw err; + } + return; + } + await navigator.share({ title, text }); +}; diff --git a/src/components/receive/ReceiveLightningDialog.vue b/src/components/receive/ReceiveLightningDialog.vue index 0ce85d9..73bb011 100644 --- a/src/components/receive/ReceiveLightningDialog.vue +++ b/src/components/receive/ReceiveLightningDialog.vue @@ -130,8 +130,8 @@
- Not watching right now — if the invoice gets paid, the sats are still - claimed into your wallet automatically. + Not watching right now — if the invoice gets paid, the sats are still claimed into your + wallet automatically.
- The note is in your wallet, but it could not be fully secured yet — the - sender may still hold a copy. You can secure it later. + The note is in your wallet, but it could not be fully secured yet — the sender may still + hold a copy. You can secure it later. ({{ trustNodeAlias }})
- Trusting saves the mint so it is offered next time. You can manage trusted - mints in Settings. + Trusting saves the mint so it is offered next time. You can manage trusted mints in + Settings.
@@ -203,9 +203,10 @@