diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 9f334e3a..1408e083 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -203,13 +203,14 @@ jobs: ghcr.io/gitleaks/gitleaks:latest \ detect \ --source=/repo \ - --log-opts=all \ + --log-opts=--all \ --redact \ --report-format=json \ --report-path="$SECURITY_REPORT_DIR/gitleaks.json" \ - --exit-code=0 || true + --exit-code=1 - name: Block critical secret findings + if: always() run: python scripts/security-dashboard.py "$SECURITY_REPORT_DIR" --tool gitleaks --fail-on-critical - name: Upload secret scan reports diff --git a/.gitleaks.toml b/.gitleaks.toml index 364959b8..c1c27772 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -4,7 +4,7 @@ title = "SubTrackr secret scanning configuration" useDefault = true [allowlist] -description = "Allow test fixtures, documentation examples, and lockfiles to avoid noisy findings." +description = "Allow test fixtures, documentation examples, demo UI placeholder credentials, and lockfiles to avoid noisy findings." paths = [ '''\.env\.example$''', '''.*\.test\.ts$''', @@ -18,4 +18,13 @@ paths = [ '''.*pnpm-lock\.yaml$''', '''.*yarn\.lock$''', '''.*Cargo\.lock$''', + # Developer-portal demo screen: mock API-key fixtures rendered in the UI. + # These are fake values in local state, never real credentials. + '''developer-portal/pages/ApiKeysPage\.tsx$''', +] +# Literal placeholder tokens used in docs snippets and demo copy. +stopwords = [ + 'YOUR_API_KEY', + 'YOUR_API_KEY_HERE', + 'sk_test_your_key', ] \ No newline at end of file diff --git a/backend/server.ts b/backend/server.ts index e7c3e9ca..2a168b90 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -1082,6 +1082,69 @@ export async function startServer(options: StartServerOptions = {}): Promise + ({ + headers: { 'user-agent': userAgent, 'accept-language': acceptLanguage }, + socket: { remoteAddress: '203.0.113.7' }, + }) as unknown as IncomingMessage; + +describe('session device binding (issue #1766)', () => { + let service: ServerSessionService; + + beforeEach(() => { + service = new ServerSessionService(); + }); + + describe('binding enforced', () => { + it('validates a session presented from the bound device', () => { + const session = service.createSession({ userId: 'user-1', req: req('iPhone-UA') }); + + const result = service.validateSession(session.token, req('iPhone-UA')); + + expect(result.valid).toBe(true); + expect(result.session?.device.fingerprint).toBe(session.device.fingerprint); + }); + + it('rejects a session replayed from a different device', () => { + const session = service.createSession({ userId: 'user-1', req: req('iPhone-UA') }); + + const result = service.validateSession(session.token, req('Attacker-Curl/8.0')); + + expect(result.valid).toBe(false); + expect(result.reason).toBe('device_binding_mismatch'); + expect(result.session?.id).toBe(session.id); + }); + + it('treats a changed Accept-Language as a different device', () => { + const session = service.createSession({ userId: 'user-1', req: req('iPhone-UA', 'en-US') }); + + expect(service.validateSession(session.token, req('iPhone-UA', 'fr-FR')).valid).toBe(false); + expect(service.validateSession(session.token, req('iPhone-UA', 'en-US')).valid).toBe(true); + }); + + it('records the mismatch in the audit log', () => { + const session = service.createSession({ userId: 'user-1', req: req('iPhone-UA') }); + service.validateSession(session.token, req('Thief-UA')); + + const log = service.getAuditLog({ sessionId: session.id }); + const flagged = log.find((e) => e.detail?.includes('device_binding_mismatch')); + expect(flagged).toBeDefined(); + }); + + it('still enforces the other validation reasons first', () => { + const session = service.createSession({ userId: 'user-1', req: req('iPhone-UA') }); + service.revokeSession(session.token, 'logout'); + + const result = service.validateSession(session.token, req('Thief-UA')); + expect(result.reason).toBe('session_revoked'); + }); + }); + + describe('graceful fallback: warn and off modes', () => { + it('warn mode flags but still validates the request', () => { + service.updateConfig({ deviceBinding: 'warn' }); + const session = service.createSession({ userId: 'user-1', req: req('iPhone-UA') }); + + const result = service.validateSession(session.token, req('Thief-UA')); + + expect(result.valid).toBe(true); + expect(result.session?.isSuspicious).toBe(true); + expect(result.session?.suspiciousReasons).toContain('device_mismatch'); + }); + + it('warn mode does not duplicate the mismatch flag', () => { + service.updateConfig({ deviceBinding: 'warn' }); + const session = service.createSession({ userId: 'user-1', req: req('iPhone-UA') }); + + service.validateSession(session.token, req('Thief-UA')); + service.validateSession(session.token, req('Thief-UA')); + + const flags = (session.suspiciousReasons ?? []).filter((r) => r === 'device_mismatch'); + expect(flags).toHaveLength(1); + }); + + it('off mode skips the check entirely', () => { + service.updateConfig({ deviceBinding: 'off' }); + const session = service.createSession({ userId: 'user-1', req: req('iPhone-UA') }); + + const result = service.validateSession(session.token, req('Thief-UA')); + + expect(result.valid).toBe(true); + expect(result.session?.isSuspicious).toBe(false); + }); + + it('validates without a request (no headers to compare)', () => { + const session = service.createSession({ userId: 'user-1', req: req('iPhone-UA') }); + expect(service.validateSession(session.token).valid).toBe(true); + }); + }); + + describe('rebind flow', () => { + it('refuses to rebind without step-up authentication', () => { + const session = service.createSession({ userId: 'user-1', req: req('iPhone-UA') }); + + const result = service.rebindSession(session.token, req('New-UA')); + + expect(result).toMatchObject({ rebound: false, reason: 'step_up_required' }); + // Binding unchanged — the mismatch still rejects. + expect(service.validateSession(session.token, req('New-UA')).valid).toBe(false); + expect( + service.getAuditLog({ sessionId: session.id }).some((e) => + e.detail?.includes('without step-up') + ) + ).toBe(true); + }); + + it('rebinds after step-up and moves validation to the new device', () => { + const session = service.createSession({ userId: 'user-1', req: req('iPhone-UA') }); + expect(service.validateSession(session.token, req('New-UA')).valid).toBe(false); + + const result = service.rebindSession(session.token, req('New-UA'), { + reauthenticated: true, + }); + + expect(result.rebound).toBe(true); + expect(result.session?.device.userAgent).toBe('New-UA'); + expect(service.validateSession(session.token, req('New-UA')).valid).toBe(true); + // The old device no longer matches either. + expect(service.validateSession(session.token, req('iPhone-UA')).valid).toBe(false); + }); + + it('learns the rebound device for future new-device detection', () => { + service.createSession({ userId: 'user-1', req: req('Device-A') }); + const second = service.createSession({ userId: 'user-1', req: req('Device-B') }); + service.rebindSession(second.token, req('Device-C'), { reauthenticated: true }); + + const third = service.createSession({ userId: 'user-1', req: req('Device-C') }); + expect(third.device.isKnownDevice).toBe(true); + expect(third.suspiciousReasons).not.toContain('new_device'); + }); + + it('clears a pending device_mismatch flag once rebound', () => { + service.updateConfig({ deviceBinding: 'warn' }); + const session = service.createSession({ userId: 'user-1', req: req('iPhone-UA') }); + service.validateSession(session.token, req('New-UA')); + expect(session.suspiciousReasons).toContain('device_mismatch'); + + service.rebindSession(session.token, req('New-UA'), { reauthenticated: true }); + + expect(session.suspiciousReasons).not.toContain('device_mismatch'); + expect(session.isSuspicious).toBe(false); + }); + + it('is a no-op when the device has not changed', () => { + const session = service.createSession({ userId: 'user-1', req: req('iPhone-UA') }); + + const result = service.rebindSession(session.token, req('iPhone-UA'), { + reauthenticated: true, + }); + + expect(result).toMatchObject({ rebound: false, reason: 'same_device' }); + }); + + it('reports missing, revoked and expired sessions distinctly', () => { + expect(service.rebindSession('nope', req('UA'), { reauthenticated: true }).reason).toBe( + 'session_not_found' + ); + + const revoked = service.createSession({ userId: 'user-1', req: req('iPhone-UA') }); + service.revokeSession(revoked.token, 'logout'); + expect( + service.rebindSession(revoked.token, req('UA'), { reauthenticated: true }).reason + ).toBe('session_revoked'); + + const expired = service.createSession({ + userId: 'user-2', + req: req('iPhone-UA'), + ttlMs: -1, + }); + expect( + service.rebindSession(expired.token, req('UA'), { reauthenticated: true }).reason + ).toBe('session_expired'); + }); + + it('writes a rebound audit entry with the device transition', () => { + const session = service.createSession({ userId: 'user-1', req: req('iPhone-UA') }); + const before = session.device.fingerprint; + + service.rebindSession(session.token, req('New-UA'), { reauthenticated: true }); + + const entry = service.getAuditLog({ sessionId: session.id }).find((e) => e.action === 'rebound'); + expect(entry).toBeDefined(); + expect(entry?.detail).toContain(before); + expect(entry?.detail).toContain(session.device.fingerprint); + }); + }); +}); diff --git a/backend/services/auth/serverSessionService.ts b/backend/services/auth/serverSessionService.ts index 7333be36..821a956f 100644 --- a/backend/services/auth/serverSessionService.ts +++ b/backend/services/auth/serverSessionService.ts @@ -25,11 +25,15 @@ import type { IncomingMessage } from 'node:http'; export type SessionStatus = 'active' | 'expired' | 'revoked'; export type SuspiciousReason = | 'new_device' + | 'device_mismatch' | 'concurrent_session_limit' | 'rapid_ip_change' | 'impossible_travel' | 'unusual_activity'; +/** Device binding enforcement mode (issue #1766). */ +export type DeviceBindingMode = 'off' | 'warn' | 'enforce'; + export interface DeviceInfo { /** SHA-256 fingerprint of (userAgent + platform + language) */ fingerprint: string; @@ -96,18 +100,42 @@ export interface SessionServiceConfig { slidingExpiry: boolean; /** Suspicious threshold: new device + N concurrent sessions */ suspiciousConcurrentThreshold: number; + /** + * Bind sessions to the device fingerprint observed at creation time + * (issue #1766): `enforce` rejects requests whose fingerprint differs, + * `warn` only flags them, `off` disables the check. Default: `enforce`. + */ + deviceBinding: DeviceBindingMode; } export interface SessionAuditEntry { sessionId: string; userId: string; - action: 'created' | 'validated' | 'touched' | 'revoked' | 'expired' | 'suspicious_flagged'; + action: + | 'created' + | 'validated' + | 'touched' + | 'revoked' + | 'expired' + | 'suspicious_flagged' + | 'rebound'; ip: string; userAgent: string; timestamp: number; detail?: string; } +export interface RebindResult { + rebound: boolean; + reason?: + | 'session_not_found' + | 'session_revoked' + | 'session_expired' + | 'step_up_required' + | 'same_device'; + session?: SessionRecord; +} + // --------------------------------------------------------------------------- // Device fingerprinting // --------------------------------------------------------------------------- @@ -154,6 +182,13 @@ function buildDeviceInfo(req: IncomingMessage, isKnownDevice: boolean): DeviceIn }; } +/** Fingerprint of the device making `req`, computed the same way as at creation. */ +function currentDeviceFingerprint(req: IncomingMessage): string { + const userAgent = (req.headers['user-agent'] as string) ?? 'unknown'; + const acceptLanguage = (req.headers['accept-language'] as string) ?? ''; + return fingerprintDevice(userAgent, acceptLanguage); +} + // --------------------------------------------------------------------------- // Geo extraction (header-based; replace with MaxMind or ip-api in production) // --------------------------------------------------------------------------- @@ -224,6 +259,7 @@ export class ServerSessionService { maxConcurrentSessions: 5, slidingExpiry: true, suspiciousConcurrentThreshold: 3, + deviceBinding: 'enforce', ...config, }; } @@ -352,6 +388,41 @@ export class ServerSessionService { return { valid: false, session, reason: 'session_expired' }; } + // Device binding (issue #1766): the session is pinned to the fingerprint + // observed at creation (or last rebind). A stolen token replayed from + // another device produces a different fingerprint and is rejected. + if (req && this.config.deviceBinding !== 'off') { + const fingerprint = currentDeviceFingerprint(req); + if (fingerprint !== session.device.fingerprint) { + if (this.config.deviceBinding === 'enforce') { + this.audit({ + sessionId: session.id, + userId: session.userId, + action: 'suspicious_flagged', + ip: session.geo.ip, + userAgent: session.device.userAgent, + timestamp: now, + detail: `device_binding_mismatch bound=${session.device.fingerprint} presented=${fingerprint}`, + }); + return { valid: false, session, reason: 'device_binding_mismatch' }; + } + + if (!session.suspiciousReasons.includes('device_mismatch')) { + session.suspiciousReasons.push('device_mismatch'); + session.isSuspicious = true; + this.audit({ + sessionId: session.id, + userId: session.userId, + action: 'suspicious_flagged', + ip: session.geo.ip, + userAgent: session.device.userAgent, + timestamp: now, + detail: `device_mismatch flagged (warn mode) bound=${session.device.fingerprint} presented=${fingerprint}`, + }); + } + } + } + // Sliding expiry if (this.config.slidingExpiry) { session.lastActiveAt = now; @@ -419,6 +490,75 @@ export class ServerSessionService { return session; } + // ------------------------------------------------------------------------- + // Device rebinding (issue #1766) + // ------------------------------------------------------------------------- + + /** + * Rebind a session to the device making `req` after the user has completed + * step-up authentication (passkey / password re-verification). + * + * The route is responsible for verifying the step-up credential and only + * passes `reauthenticated: true` once it has; without it the rebind is + * refused so a stolen token alone can never move a session to a new device. + */ + rebindSession( + token: string, + req: IncomingMessage, + opts: { reauthenticated?: boolean } = {} + ): RebindResult { + const session = this.sessions.get(token); + if (!session) return { rebound: false, reason: 'session_not_found' }; + + const now = Date.now(); + if (session.status === 'revoked') return { rebound: false, reason: 'session_revoked' }; + if (now > session.expiresAt) { + session.status = 'expired'; + return { rebound: false, reason: 'session_expired' }; + } + + const presented = currentDeviceFingerprint(req); + if (presented === session.device.fingerprint) { + return { rebound: false, reason: 'same_device', session }; + } + + if (!opts.reauthenticated) { + this.audit({ + sessionId: session.id, + userId: session.userId, + action: 'suspicious_flagged', + ip: session.geo.ip, + userAgent: session.device.userAgent, + timestamp: now, + detail: 'rebind attempted without step-up authentication', + }); + return { rebound: false, reason: 'step_up_required' }; + } + + const previous = session.device.fingerprint; + const knownFingerprints = this.knownDevices.get(session.userId) ?? new Set(); + const device = buildDeviceInfo(req, knownFingerprints.size > 0); + knownFingerprints.add(device.fingerprint); + this.knownDevices.set(session.userId, knownFingerprints); + + session.device = device; + session.geo = buildGeoInfo(req); + session.suspiciousReasons = session.suspiciousReasons.filter((r) => r !== 'device_mismatch'); + session.isSuspicious = session.suspiciousReasons.length > 0; + + this.audit({ + sessionId: session.id, + userId: session.userId, + action: 'rebound', + ip: session.geo.ip, + userAgent: device.userAgent, + timestamp: now, + detail: `device ${previous} -> ${device.fingerprint} (step-up verified)`, + }); + + return { rebound: true, session }; + } + // ------------------------------------------------------------------------- // Revocation // ------------------------------------------------------------------------- diff --git a/db/MIGRATION_ROLLBACK.md b/db/MIGRATION_ROLLBACK.md index d1a93c4c..3cb49c6c 100644 --- a/db/MIGRATION_ROLLBACK.md +++ b/db/MIGRATION_ROLLBACK.md @@ -68,6 +68,7 @@ node scripts/db-migrate-dryrun.js --json ``` The dry-run tool reports: + - Destructive changes (DROP / TRUNCATE) - Statements requiring ACCESS EXCLUSIVE locks - NOT NULL columns added without DEFAULT @@ -80,7 +81,7 @@ The dry-run tool reports: `BEGIN/COMMIT` block. A failure rolls back the entire migration. 2. **CONCURRENTLY note**: `CREATE INDEX CONCURRENTLY` and `DROP INDEX - CONCURRENTLY` cannot run inside a transaction. The runner wraps each +CONCURRENTLY` cannot run inside a transaction. The runner wraps each migration in a transaction, so migrations using `CONCURRENTLY` should structure their down section to match. The ledger insert is in the same transaction as the migration body. @@ -95,9 +96,9 @@ The dry-run tool reports: ## Migration directories -| Directory | Contents | -|---|---| -| `db/migrations/` | Core schema migrations (indexes, views, tables) | +| Directory | Contents | +| --------------------- | --------------------------------------------------- | +| `db/migrations/` | Core schema migrations (indexes, views, tables) | | `backend/migrations/` | Backend-specific migrations (cache columns, themes) | Default migration directory for the runner is `backend/migrations/`. Use diff --git a/db/QUERY_OPTIMIZATION.md b/db/QUERY_OPTIMIZATION.md index 846b509e..595226df 100644 --- a/db/QUERY_OPTIMIZATION.md +++ b/db/QUERY_OPTIMIZATION.md @@ -193,6 +193,7 @@ node scripts/db-index-audit.js --json # JSON output for CI ``` The tool: + 1. Parses `CREATE INDEX` statements from `db/migrations/*.sql` and `backend/migrations/*.sql` (excluding `-- @down` sections). 2. Scans `backend/services/`, `backend/graphql/`, and `backend/notification/` @@ -210,18 +211,18 @@ Exit code: 0 = no gaps, 1 = gaps found, 2 = usage error. The initial audit found 10 query patterns with no matching index. Migration `009_index_audit_hot_queries.sql` adds indexes for all of them: -| Table | Column(s) | Query source | Index | -|---|---|---|---| -| users | address | postgres.ts:382 | idx_users_address | -| users | email | postgres.ts:391 | idx_users_email | -| merchants | merchant_address | postgres.ts:470 | idx_merchants_merchant_address | -| merchants | status | postgres.ts:481 | idx_merchants_status | -| loyalty | subscriber_id | postgres.ts:570 | idx_loyalty_subscriber_id | -| loyalty | points DESC | postgres.ts:582 | idx_loyalty_points_desc | -| subscriptions | created_at DESC | resolvers.ts:71 | idx_subscriptions_created_at | -| user_devices | user_id | NotificationServiceImpl.ts | idx_user_devices_user_id | -| usage_alerts | created_at DESC | usageAlertsController.ts:144 | idx_usage_alerts_created_at | -| usage_alert_configs | subscription_id | usageAlertsController.ts | idx_usage_alert_configs_subscription | +| Table | Column(s) | Query source | Index | +| ------------------- | ---------------- | ---------------------------- | ------------------------------------ | +| users | address | postgres.ts:382 | idx_users_address | +| users | email | postgres.ts:391 | idx_users_email | +| merchants | merchant_address | postgres.ts:470 | idx_merchants_merchant_address | +| merchants | status | postgres.ts:481 | idx_merchants_status | +| loyalty | subscriber_id | postgres.ts:570 | idx_loyalty_subscriber_id | +| loyalty | points DESC | postgres.ts:582 | idx_loyalty_points_desc | +| subscriptions | created_at DESC | resolvers.ts:71 | idx_subscriptions_created_at | +| user_devices | user_id | NotificationServiceImpl.ts | idx_user_devices_user_id | +| usage_alerts | created_at DESC | usageAlertsController.ts:144 | idx_usage_alerts_created_at | +| usage_alert_configs | subscription_id | usageAlertsController.ts | idx_usage_alert_configs_subscription | After applying migration 009, re-run the audit to confirm zero gaps: diff --git a/developer-portal/pages/api/revalidate.ts b/developer-portal/pages/api/revalidate.ts index 97c1f8f4..88395eef 100644 --- a/developer-portal/pages/api/revalidate.ts +++ b/developer-portal/pages/api/revalidate.ts @@ -88,7 +88,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< // arbitrary value lets a holder of the secret force revalidation of // unrelated routes, so it is restricted to the documented set. if (path !== undefined && !ALLOWED_PATHS.has(path)) { - return res.status(400).json({ revalidated: false, error: `Path "${path}" is not revalidatable` }); + return res + .status(400) + .json({ revalidated: false, error: `Path "${path}" is not revalidatable` }); } const pathsToRevalidate: string[] = path ? [path] : (TAG_TO_PATHS[tag as string] ?? []); diff --git a/docs/BIOMETRIC_STEP_UP.md b/docs/BIOMETRIC_STEP_UP.md new file mode 100644 index 00000000..c384f9a8 --- /dev/null +++ b/docs/BIOMETRIC_STEP_UP.md @@ -0,0 +1,60 @@ +# Biometric Step-Up for Sensitive Actions + +Issue #1765 — high-risk operations require a fresh biometric check, even +though the app itself is already unlocked. + +## How it works + +[`src/services/auth/sensitiveActionGuard.ts`](../src/services/auth/sensitiveActionGuard.ts) +gates configured actions behind `biometricService` (which supplies the system +prompt, the device-PIN fallback, lockout backoff and failure accounting): + +```ts +await sensitiveActionGuard.withBiometricGuard('transfer_funds', () => + transferCredit(...) +); +``` + +- **Enforced where configured** — a persisted policy + (`@subtrackr/sensitive_action_policy`) lists which actions are gated. + Defaults: `transfer_funds`, `export_private_key`, `confirm_payment`, + `delete_account`. Actions not in the list execute without a prompt. + + ```ts + await sensitiveActionGuard.savePolicy({ + enforced: ['transfer_funds', 'delete_account'], + onUnavailable: 'allow', + }); + ``` + +- **Graceful fallback** — when the device has no biometric hardware or no + enrolled credentials (module missing, tests, Expo Go), the policy decides: + `onUnavailable: 'allow'` (default) lets the user through and records + `fellBack: true` on the result; `onUnavailable: 'block'` denies the action. + A user who cancels the prompt gets a distinct `cancelled` result, and + lockout after repeated failures is enforced by `biometricService` before any + prompt is shown. + +- **Deny-by-default on failure** — when a prompt is required and fails, + `withBiometricGuard` throws `BiometricGateError` *before* the action runs; + the operation is never executed partially. + +## Wired-up actions + +| Action | Call site | +| --- | --- | +| `transfer_funds` | `src/screens/SubscriptionDetailScreen.tsx` — credit transfer | +| `delete_account` | `src/screens/PrivacyCenterScreen.tsx` — GDPR deletion request | + +`export_private_key` and `confirm_payment` are reserved for the wallet flows. + +## Tests + +`src/services/auth/__tests__/sensitiveActionGuard.test.ts` covers prompting +for enforced actions, skipping unconfigured actions, deny-on-failure, +cancellation, lockout, both fallback policies, and policy persistence/recovery +from corrupt storage. + +```bash +npx jest --config jest.backend.config.js --testMatch '**/src/services/auth/__tests__/sensitiveActionGuard.test.ts' +``` diff --git a/docs/CERTIFICATE_PINNING.md b/docs/CERTIFICATE_PINNING.md new file mode 100644 index 00000000..6c7ecc2e --- /dev/null +++ b/docs/CERTIFICATE_PINNING.md @@ -0,0 +1,106 @@ +# Certificate Pinning + +Issue #1764 — MITM protection for the mobile API client on untrusted networks. + +The mobile client pins the API server's TLS public key (SPKI SHA-256). A +certificate that does not contain a pinned key — for example one presented by an +attacker-controlled proxy — is rejected before any request bytes are sent. + +## How it works + +React Native's `fetch` completes the TLS handshake in native code, so pinning is +split in two halves: + +- **Policy** — [`src/services/network/certificatePinning.ts`](../src/services/network/certificatePinning.ts) + holds the configurable pin sets, verification logic and fallback behaviour. + `ApiClient` runs a verification step before every HTTPS request + ([`src/services/network/apiClient.ts`](../src/services/network/apiClient.ts)). +- **Observation** — the native TLS layer (OkHttp on Android, + `NSURLSession` on iOS, or a certificate-transparency hook) registers: + + ```ts + globalThis.__SUBTRACKR_TLS_PEER_CERT__ = async (url: string) => ({ + spkiSha256: '', + }); + ``` + + The default provider reads the observed SPKI hash from that hook. Until the + native half ships (or on platforms without it) the provider returns `null` + and the configured `onUnavailable` fallback decides what happens. + +## Configuration (pins are configurable) + +Pins are configured at build time through environment variables, or in code via +`ApiClient({ certificatePinning: { config } })`. + +| Variable | Description | +| --- | --- | +| `EXPO_PUBLIC_CERT_PIN_SETS` | JSON array of pin sets: `[{ "host": "api.example.com", "pins": ["sha256/…"], "expiresAt": "2026-06-01T00:00:00Z" }]`. `host` supports `*.example.com` wildcards. | +| `EXPO_PUBLIC_CERT_PINS` | Comma-separated shorthand; applied to the host of `EXPO_PUBLIC_API_BASE_URL`. | +| `EXPO_PUBLIC_CERT_PINNING_MODE` | `enforce` (default), `report`, or `disabled`. | +| `EXPO_PUBLIC_CERT_PIN_UNAVAILABLE` | Fallback when no peer certificate / no active pins: `allow` (default) or `block`. | + +A pin is the base64 SHA-256 of the certificate's Subject Public Key Info +(SPKI), written as `sha256/` (bare base64 is accepted and normalised). + +```bash +openssl x509 -in api.pem -pubkey -noout \ + | openssl pkey -pubin -outform DER \ + | openssl dgst -sha256 -binary | openssl base64 +``` + +## Modes and fallback path + +| Situation | `enforce` | `report` | `disabled` | +| --- | --- | --- | --- | +| Peer key matches a pin | request proceeds | request proceeds | request proceeds | +| Peer key does not match | **request aborted** (`CertificatePinningError`) | request proceeds, mismatch recorded on the span | request proceeds | +| Peer certificate unavailable, `onUnavailable: allow` | request proceeds, `unavailable` recorded | request proceeds | request proceeds | +| Peer certificate unavailable, `onUnavailable: block` | **request aborted** | request proceeds | request proceeds | +| No active pin set for the host | same as "unavailable" fallback | same | request proceeds | +| Plain HTTP (dev / E2E mock hosts) | skipped | skipped | skipped | + +Verification results are always written to the client span +(`security.cert_pinning.mode`, `security.cert_pinning.status`), so a rollout in +`report` mode can be measured from trace data before switching to `enforce`. + +**Recommended rollout:** ship in `report` mode → watch span attributes for +`mismatch`/`unavailable` for one release → flip to `enforce` with +`onUnavailable: allow` → optionally tighten to `block` once the native provider +is guaranteed present. + +## Rotation + +Pin rotation is time-boxed so an app update is never a hard cut-over: + +1. **Add the new pin** alongside the old one in the same pin set (or a second + set for the same host) and give the old pin an `expiresAt` roughly one + release cycle in the future. +2. **Ship the app.** During the overlap window both the outgoing and incoming + keys validate, so servers can switch certificates at any point without + breaking older builds. +3. **Verify** from trace data that `security.cert_pinning.status` is `match`. +4. **Expire the old pin.** Once `expiresAt` passes the old pin set is ignored + automatically (`activePinSets()` filters it) — no app release required to + drop it. + +To roll back a bad pin, ship a build with the pin removed or mode `report`. + +## Tests + +`src/services/network/__tests__/certificatePinning.test.ts` covers: + +- pin/host normalisation helpers +- match, mismatch (`enforce` / `report` / `disabled`) +- the fallback path: peer unavailable with `allow` and `block` +- hosts without pin sets +- rotation: expired pins dropped, overlap window accepted, old pin rejected + after expiry +- environment configuration, including invalid JSON +- `ApiClient` end-to-end: request aborted before `fetch` on mismatch, + proceeding in report mode with span attributes, provider failure treated as + "unavailable", and HTTP hosts skipped + +```bash +npx jest --config jest.backend.config.js --testMatch '**/src/services/network/__tests__/certificatePinning.test.ts' +``` diff --git a/docs/SECRET_SCANNING.md b/docs/SECRET_SCANNING.md new file mode 100644 index 00000000..9441dd77 --- /dev/null +++ b/docs/SECRET_SCANNING.md @@ -0,0 +1,75 @@ +# Secret Scanning + +Issue #1767 — prevent credentials from landing in the repository. + +## What runs + +The `Secret Scanning - gitleaks` job in +[`.github/workflows/security-scan.yml`](../.github/workflows/security-scan.yml) +runs [gitleaks](https://github.com/gitleaks/gitleaks) over **every commit** in +the repository (`git log --all`) on every pull request and on pushes to +`main` / `dev` / `develop`, plus a weekly scheduled run: + +```bash +gitleaks detect \ + --source=/repo \ + --log-opts=--all \ + --redact \ + --report-format=json \ + --report-path=security-reports/gitleaks.json \ + --exit-code=1 +``` + +- **Runs on PRs** — the workflow trigger is `pull_request` (and `push` to the + long-lived branches), so every PR gets a dedicated check. +- **Fails on detection** — `--exit-code=1` makes the job go red the moment any + secret is found; the follow-up `security-dashboard.py --fail-on-critical` + step (runs with `if: always()`) re-reports the findings and annotates the run. + Findings are also uploaded as the `security-secret-scan` artifact. +- Secrets in the report are **redacted** (`--redact`), so the raw value never + appears in logs. + +## Configuration + +Rules come from the built-in gitleaks ruleset extended by +[`.gitleaks.toml`](../.gitleaks.toml): + +- **Allowlist** — test/spec files, `__tests__`/`__mocks__` directories, + Markdown, lockfiles, `.env.example`, the developer-portal demo screen + (`developer-portal/pages/ApiKeysPage.tsx`) whose `sk_test_…` values are + render-only fixtures, and the literal placeholder tokens `YOUR_API_KEY` / + `sk_test_your_key` used in documentation snippets. +- Everything else is reported as **Critical** and blocks the check. + +Allowlist entries are for **known-fake values only**. Never allowlist a file +because it contains a real credential — rotate the credential instead. + +## Running locally + +```bash +# Full-history scan (same as CI), needs Docker: +docker run --rm -v "$PWD":/repo -w /repo ghcr.io/gitleaks/gitleaks:latest \ + detect --source=/repo --log-opts=--all --redact --exit-code=1 + +# Or with a local gitleaks install: +gitleaks detect --source=. --redact --verbose +``` + +The scan uses `.gitleaks.toml` automatically from the repository root. + +## If a secret is found + +1. **Rotate it immediately** (API keys, Stripe keys, tokens are all + revocable). Treat the leak as real until proven otherwise — history scans + catch secrets that were committed and later "removed" from the working tree. +2. Purge from history only after rotation (`git filter-repo` / BFG) and force + coordinate with collaborators. +3. Add an allowlist entry **only** if the value is a documented placeholder, + and say so in the entry's description. + +## Related + +- `docs/security-dashboard.md` — how findings from all security tools are + aggregated. +- `.github/workflows/security-scan.yml` — SAST (Semgrep), dependency audit, + Trivy, this secret scan, and DAST (ZAP). diff --git a/docs/SESSION_DEVICE_BINDING.md b/docs/SESSION_DEVICE_BINDING.md new file mode 100644 index 00000000..0ca645f0 --- /dev/null +++ b/docs/SESSION_DEVICE_BINDING.md @@ -0,0 +1,61 @@ +# Session Device Binding + +Issue #1766 — a stolen session token is bound to the device it was issued on +and cannot be replayed from anywhere else. + +## Enforcement + +[`backend/services/auth/serverSessionService.ts`](../backend/services/auth/serverSessionService.ts) +fingerprints the device at session creation (SHA-256 over +`user-agent` + `accept-language`) and re-checks it on every validation: + +| `deviceBinding` mode | Behaviour | +| --- | --- | +| `enforce` (default) | A fingerprint mismatch fails validation with `reason: "device_binding_mismatch"` (HTTP 401 on `/sessions/validate`) | +| `warn` | The request proceeds but the session is flagged suspicious (`device_mismatch`) and audited | +| `off` | Check disabled (legacy / migration window) | + +```bash +# Change mode at runtime +curl -X PATCH /sessions/config -H 'content-type: application/json' \ + -d '{"deviceBinding":"enforce"}' +``` + +Reasons take their usual precedence: `session_not_found` → `session_revoked` → +`session_expired` → `device_binding_mismatch`. + +## Rebind flow + +A legitimate device change (OS upgrade changing the UA string, restored +backup, new phone) is recovered with an explicit rebind that requires +step-up authentication: + +1. The client re-authenticates with a passkey (existing + `POST /api/v1/auth/passkeys/login/verify` issues a fresh session whose + `metadata.authMethod === 'passkey'`). +2. The client calls `POST /sessions/rebind` with: + - `X-Session-Token` — the session to rebind, and + - `X-Reauth-Token` — the fresh passkey session from step 1 (same user, + issued within the last 5 minutes). +3. The server re-binds the session to the fingerprint of the rebind request, + clears any pending `device_mismatch` flag, records the transition in the + audit log (`action: "rebound"`), and the session validates normally from + the new device afterwards. + +Refusals: missing reauth header → `403 step_up_required`; stale/foreign/non- +passkey reauth token → `403 step_up_invalid`; revoked/expired session → +`409`; already-bound device → `200 { rebound: false, reason: "same_device" }`. +`ServerSessionService.rebindSession()` itself also refuses any rebind without +`reauthenticated: true`, so a stolen token alone can never move a session. + +## Tests + +`backend/services/auth/__tests__/deviceBinding.test.ts` covers same-device +validation, cross-device rejection (UA and Accept-Language changes), warn/off +fallbacks, audit entries, step-up-required refusal, successful rebind with +device hand-over, flag clearing, and the missing/revoked/expired/same-device +rebind outcomes. + +```bash +npx jest --config jest.backend.config.js --testMatch '**/backend/services/auth/__tests__/deviceBinding.test.ts' +``` diff --git a/scripts/__tests__/db-index-audit.test.js b/scripts/__tests__/db-index-audit.test.js index 36957b99..31cb23fb 100644 --- a/scripts/__tests__/db-index-audit.test.js +++ b/scripts/__tests__/db-index-audit.test.js @@ -50,7 +50,8 @@ describe('db-index-audit :: parseCreateIndex', () => { }); it('parses CREATE INDEX CONCURRENTLY IF NOT EXISTS with composite columns', () => { - const sql = "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_bar ON subscriptions (user_id, status) WHERE status = 'active';"; + const sql = + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_bar ON subscriptions (user_id, status) WHERE status = 'active';"; const idx = parseCreateIndex(sql); expect(idx.name).toBe('idx_bar'); expect(idx.table).toBe('subscriptions'); @@ -73,10 +74,13 @@ describe('db-index-audit :: parseCreateIndex', () => { describe('db-index-audit :: loadIndexes', () => { it('loads indexes from migration files', () => { - writeMigration('001_init.sql', ` + writeMigration( + '001_init.sql', + ` CREATE INDEX IF NOT EXISTS idx_a ON users (email); CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_b ON subscriptions (user_id, status); - `); + ` + ); const indexes = loadIndexes([migrationsDir]); expect(indexes).toHaveLength(2); expect(indexes[0].name).toBe('idx_a'); @@ -84,12 +88,15 @@ describe('db-index-audit :: loadIndexes', () => { }); it('ignores indexes in -- @down sections', () => { - writeMigration('001_init.sql', ` + writeMigration( + '001_init.sql', + ` CREATE INDEX IF NOT EXISTS idx_up ON users (email); -- @down DROP INDEX IF EXISTS idx_up; - `); + ` + ); const indexes = loadIndexes([migrationsDir]); expect(indexes).toHaveLength(1); expect(indexes[0].name).toBe('idx_up'); @@ -107,15 +114,24 @@ describe('db-index-audit :: isCoveredByIndex', () => { ]; it('covers a single-column query when index first column matches', () => { - expect(isCoveredByIndex({ table: 'users', columns: ['email'], type: 'WHERE' }, indexes)).toBe(true); + expect(isCoveredByIndex({ table: 'users', columns: ['email'], type: 'WHERE' }, indexes)).toBe( + true + ); }); it('covers a composite query when index is a prefix match', () => { - expect(isCoveredByIndex({ table: 'subscriptions', columns: ['user_id', 'status'], type: 'WHERE' }, indexes)).toBe(true); + expect( + isCoveredByIndex( + { table: 'subscriptions', columns: ['user_id', 'status'], type: 'WHERE' }, + indexes + ) + ).toBe(true); }); it('does not cover when no index exists for the table', () => { - expect(isCoveredByIndex({ table: 'orders', columns: ['user_id'], type: 'WHERE' }, indexes)).toBe(false); + expect( + isCoveredByIndex({ table: 'orders', columns: ['user_id'], type: 'WHERE' }, indexes) + ).toBe(false); }); it('always covers id lookups (primary key)', () => { @@ -123,13 +139,15 @@ describe('db-index-audit :: isCoveredByIndex', () => { }); it('does not cover when the column is not the leading column', () => { - expect(isCoveredByIndex({ table: 'subscriptions', columns: ['status'], type: 'WHERE' }, indexes)).toBe(false); + expect( + isCoveredByIndex({ table: 'subscriptions', columns: ['status'], type: 'WHERE' }, indexes) + ).toBe(false); }); }); describe('db-index-audit :: extractQueryPatterns', () => { it('extracts WHERE column = $N patterns', () => { - const content = "`SELECT * FROM users WHERE email = $1`"; + const content = '`SELECT * FROM users WHERE email = $1`'; const patterns = extractQueryPatterns(content, 'test.ts'); expect(patterns).toContainEqual({ table: 'users', @@ -140,7 +158,7 @@ describe('db-index-audit :: extractQueryPatterns', () => { }); it('extracts FROM table WHERE column patterns', () => { - const content = "`SELECT * FROM subscriptions WHERE user_id = $1 ORDER BY created_at DESC`"; + const content = '`SELECT * FROM subscriptions WHERE user_id = $1 ORDER BY created_at DESC`'; const patterns = extractQueryPatterns(content, 'test.ts'); const wherePattern = patterns.find((p) => p.type === 'WHERE'); expect(wherePattern.table).toBe('subscriptions'); @@ -165,7 +183,11 @@ describe('db-index-audit :: run', () => { writeMigration('001.sql', 'CREATE INDEX IF NOT EXISTS idx_email ON users (email);'); const srcDir = path.join(tmpDir, 'src'); fs.mkdirSync(srcDir, { recursive: true }); - fs.writeFileSync(path.join(srcDir, 'repo.ts'), "`SELECT * FROM users WHERE email = $1`", 'utf8'); + fs.writeFileSync( + path.join(srcDir, 'repo.ts'), + '`SELECT * FROM users WHERE email = $1`', + 'utf8' + ); const code = run(['--migrations-dir', migrationsDir, '--src-dir', srcDir]); expect(code).toBe(0); @@ -174,7 +196,11 @@ describe('db-index-audit :: run', () => { it('exits 1 when gaps are found', () => { const srcDir = path.join(tmpDir, 'src'); fs.mkdirSync(srcDir, { recursive: true }); - fs.writeFileSync(path.join(srcDir, 'repo.ts'), "`SELECT * FROM users WHERE email = $1`", 'utf8'); + fs.writeFileSync( + path.join(srcDir, 'repo.ts'), + '`SELECT * FROM users WHERE email = $1`', + 'utf8' + ); const code = run(['--migrations-dir', migrationsDir, '--src-dir', srcDir]); expect(code).toBe(1); @@ -184,7 +210,11 @@ describe('db-index-audit :: run', () => { writeMigration('001.sql', 'CREATE INDEX IF NOT EXISTS idx_email ON users (email);'); const srcDir = path.join(tmpDir, 'src'); fs.mkdirSync(srcDir, { recursive: true }); - fs.writeFileSync(path.join(srcDir, 'repo.ts'), "`SELECT * FROM users WHERE email = $1`", 'utf8'); + fs.writeFileSync( + path.join(srcDir, 'repo.ts'), + '`SELECT * FROM users WHERE email = $1`', + 'utf8' + ); const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); run(['--json', '--migrations-dir', migrationsDir, '--src-dir', srcDir]); diff --git a/scripts/db-index-audit.js b/scripts/db-index-audit.js index e949e3d2..dd8ed8cb 100755 --- a/scripts/db-index-audit.js +++ b/scripts/db-index-audit.js @@ -64,7 +64,10 @@ function parseArgs(argv) { /** Parse a CREATE INDEX statement into a structured index definition. */ function parseCreateIndex(sql) { // Match: CREATE [UNIQUE] INDEX [CONCURRENTLY] IF NOT EXISTS name ON table (cols) [WHERE ...] - const match = /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?IF\s+NOT\s+EXISTS\s+(\w+)\s+ON\s+(\w+)\s*\(([^)]+)\)(?:\s+WHERE\s+(.+?))?;/i.exec(sql); + const match = + /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?IF\s+NOT\s+EXISTS\s+(\w+)\s+ON\s+(\w+)\s*\(([^)]+)\)(?:\s+WHERE\s+(.+?))?;/i.exec( + sql + ); if (!match) return null; return { name: match[1], @@ -76,7 +79,10 @@ function parseCreateIndex(sql) { /** Parse a CREATE INDEX without IF NOT EXISTS (some migrations use plain CREATE INDEX). */ function parseCreateIndexAlt(sql) { - const match = /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(\w+)\s+ON\s+(\w+)\s*\(([^)]+)\)(?:\s+WHERE\s+(.+?))?;/i.exec(sql); + const match = + /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(\w+)\s+ON\s+(\w+)\s*\(([^)]+)\)(?:\s+WHERE\s+(.+?))?;/i.exec( + sql + ); if (!match) return null; return { name: match[1], @@ -118,7 +124,12 @@ function findTsFiles(dir) { const full = path.join(dir, entry.name); if (entry.isDirectory() && entry.name !== '__tests__' && entry.name !== 'node_modules') { results.push(...findTsFiles(full)); - } else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.js')) && !entry.name.endsWith('.test.ts') && !entry.name.endsWith('.test.js')) { + } else if ( + entry.isFile() && + (entry.name.endsWith('.ts') || entry.name.endsWith('.js')) && + !entry.name.endsWith('.test.ts') && + !entry.name.endsWith('.test.js') + ) { results.push(full); } } @@ -258,7 +269,9 @@ function run(argv) { } if (options.help) { - console.log('Usage: node scripts/db-index-audit.js [--json] [--src-dir ] [--migrations-dir ]'); + console.log( + 'Usage: node scripts/db-index-audit.js [--json] [--src-dir ] [--migrations-dir ]' + ); return 0; } @@ -294,9 +307,19 @@ function run(argv) { covered: covered.length, gaps: gaps.length, }, - indexes: indexes.map((i) => ({ name: i.name, table: i.table, columns: i.columns, source: i.source })), + indexes: indexes.map((i) => ({ + name: i.name, + table: i.table, + columns: i.columns, + source: i.source, + })), gaps: gaps.map((g) => ({ table: g.table, columns: g.columns, type: g.type, file: g.file })), - covered: covered.map((c) => ({ table: c.table, columns: c.columns, type: c.type, file: c.file })), + covered: covered.map((c) => ({ + table: c.table, + columns: c.columns, + type: c.type, + file: c.file, + })), }; if (options.json) { diff --git a/scripts/db-partition-manage.js b/scripts/db-partition-manage.js index 554a7e37..b6f37c13 100755 --- a/scripts/db-partition-manage.js +++ b/scripts/db-partition-manage.js @@ -21,9 +21,13 @@ const { spawnSync } = require('child_process'); function psql(databaseUrl, sql) { - const result = spawnSync('psql', ['-v', 'ON_ERROR_STOP=1', '--no-psqlrc', '-tAq', databaseUrl, '-c', sql], { - encoding: 'utf8', - }); + const result = spawnSync( + 'psql', + ['-v', 'ON_ERROR_STOP=1', '--no-psqlrc', '-tAq', databaseUrl, '-c', sql], + { + encoding: 'utf8', + } + ); if (result.error) return { ok: false, output: result.error.message }; return { ok: result.status === 0, output: result.stdout || result.stderr }; } @@ -74,7 +78,9 @@ function createPartitions(databaseUrl, monthsAhead) { const result = psql(databaseUrl, `SELECT create_audit_events_partition(${year}, ${month});`); if (!result.ok) { - console.error(`[partition] Failed to create partition for ${year}-${month}: ${result.output}`); + console.error( + `[partition] Failed to create partition for ${year}-${month}: ${result.output}` + ); continue; } console.log(`[partition] ${result.output.trim()}`); diff --git a/src/components/security/DeviceIntegrityBanner.tsx b/src/components/security/DeviceIntegrityBanner.tsx index 1ca5c685..ecf7abaf 100644 --- a/src/components/security/DeviceIntegrityBanner.tsx +++ b/src/components/security/DeviceIntegrityBanner.tsx @@ -1,11 +1,5 @@ import React, { useState } from 'react'; -import { - View, - Text, - StyleSheet, - TouchableOpacity, - AccessibilityInfo, -} from 'react-native'; +import { View, Text, StyleSheet, TouchableOpacity, AccessibilityInfo } from 'react-native'; import { useDeviceIntegrity } from '../../hooks/useDeviceIntegrity'; import { colors, spacing, typography, borderRadius } from '../../utils/constants'; @@ -13,11 +7,8 @@ export interface DeviceIntegrityBannerProps { onLearnMore?: () => void; } -export const DeviceIntegrityBanner: React.FC = ({ - onLearnMore, -}) => { - const { result, isCompromised, riskLevel, bannerDismissed, dismissBanner } = - useDeviceIntegrity(); +export const DeviceIntegrityBanner: React.FC = ({ onLearnMore }) => { + const { result, isCompromised, riskLevel, bannerDismissed, dismissBanner } = useDeviceIntegrity(); const [showDetails, setShowDetails] = useState(false); // Only render if device is compromised or at high/critical risk and not dismissed @@ -35,10 +26,10 @@ export const DeviceIntegrityBanner: React.FC = ({ const title = result?.isRooted ? 'Rooted Android Device Detected' : result?.isJailbroken - ? 'Jailbroken iOS Device Detected' - : result?.isTampered - ? 'Application Tampering Detected' - : 'Device Integrity Compromised'; + ? 'Jailbroken iOS Device Detected' + : result?.isTampered + ? 'Application Tampering Detected' + : 'Device Integrity Compromised'; const defaultMessage = result?.degradationPolicy?.warningMessage || @@ -105,9 +96,7 @@ export const DeviceIntegrityBanner: React.FC = ({ style={styles.linkButton} accessibilityRole="button" accessibilityLabel="Learn more about device integrity policy"> - - Security policy - + Security policy )} diff --git a/src/hooks/__tests__/useDeviceIntegrity.test.ts b/src/hooks/__tests__/useDeviceIntegrity.test.ts index b82bd69c..05460303 100644 --- a/src/hooks/__tests__/useDeviceIntegrity.test.ts +++ b/src/hooks/__tests__/useDeviceIntegrity.test.ts @@ -94,7 +94,9 @@ describe('useDeviceIntegrity hook', () => { }); it('allows dismissing banner state', async () => { - (deviceAttestationService.getCachedIntegrity as jest.Mock).mockResolvedValue(mockCompromisedResult); + (deviceAttestationService.getCachedIntegrity as jest.Mock).mockResolvedValue( + mockCompromisedResult + ); const { result, waitForNextUpdate } = renderHook(() => useDeviceIntegrity()); await waitForNextUpdate(); diff --git a/src/hooks/useDeviceIntegrity.ts b/src/hooks/useDeviceIntegrity.ts index 7b23bf83..3eedf124 100644 --- a/src/hooks/useDeviceIntegrity.ts +++ b/src/hooks/useDeviceIntegrity.ts @@ -40,10 +40,7 @@ export function useDeviceIntegrity(): DeviceIntegrityState { const isOperationAllowed = useCallback( (operation: SensitiveOperation): boolean => { - return deviceAttestationService.isOperationAllowed( - operation, - result?.degradationPolicy - ); + return deviceAttestationService.isOperationAllowed(operation, result?.degradationPolicy); }, [result] ); @@ -90,7 +87,8 @@ export function useDeviceIntegrity(): DeviceIntegrityState { }, []); const riskLevel = result?.riskLevel ?? 'low'; - const isCompromised = !result?.isIntact && (result?.isRooted || result?.isJailbroken || result?.isTampered || false); + const isCompromised = + !result?.isIntact && (result?.isRooted || result?.isJailbroken || result?.isTampered || false); return { result, diff --git a/src/screens/PrivacyCenterScreen.tsx b/src/screens/PrivacyCenterScreen.tsx index 2409a4b2..77c64f2b 100644 --- a/src/screens/PrivacyCenterScreen.tsx +++ b/src/screens/PrivacyCenterScreen.tsx @@ -15,6 +15,7 @@ import { useThemeColors } from '../hooks/useThemeColors'; import { gdprService } from '../services/gdpr'; import { useUserStore } from '../store/userStore'; import { CONSENT_CATEGORIES } from '../services/consentService'; +import { BiometricGateError, sensitiveActionGuard } from '../services/auth/sensitiveActionGuard'; type Nav = NativeStackNavigationProp; @@ -37,14 +38,20 @@ const PrivacyCenterScreen = () => { onPress: async () => { setLoading(true); try { - const result = await gdprService.requestDeletion(true); + const result = await sensitiveActionGuard.withBiometricGuard('delete_account', () => + gdprService.requestDeletion(true) + ); if (!result.success) { Alert.alert('Blocked', result.message); } else { Alert.alert('Deletion Requested', result.message); } - } catch { - Alert.alert('Error', 'Deletion request failed. Please try again.'); + } catch (err) { + if (err instanceof BiometricGateError) { + Alert.alert('Authentication Required', err.message); + } else { + Alert.alert('Error', 'Deletion request failed. Please try again.'); + } } finally { setLoading(false); } diff --git a/src/screens/SubscriptionDetailScreen.tsx b/src/screens/SubscriptionDetailScreen.tsx index c18da067..f126fd33 100644 --- a/src/screens/SubscriptionDetailScreen.tsx +++ b/src/screens/SubscriptionDetailScreen.tsx @@ -25,6 +25,7 @@ import { CreditPaymentMethod } from '../types/credit'; import { useGroupStore } from '../store/groupStore'; import { shareSubscriptionLink } from '../utils/shareLink'; import { validateSubscriptionId } from '../utils/deepLinkValidator'; +import { BiometricGateError, sensitiveActionGuard } from '../services/auth/sensitiveActionGuard'; // Components import { Button } from '../components/common/Button'; @@ -224,16 +225,26 @@ const SubscriptionDetailScreen: React.FC = () => { return; } - await transferCredit( - { - amount, - currency: subscription?.currency, - reference: `transfer:${subscription?.id ?? 'subscription'}`, - note: `Transfer from ${accountId} to ${transferRecipient.trim()}`, - }, - transferRecipient.trim(), - accountId - ); + try { + await sensitiveActionGuard.withBiometricGuard('transfer_funds', () => + transferCredit( + { + amount, + currency: subscription?.currency, + reference: `transfer:${subscription?.id ?? 'subscription'}`, + note: `Transfer from ${accountId} to ${transferRecipient.trim()}`, + }, + transferRecipient.trim(), + accountId + ) + ); + } catch (err) { + if (err instanceof BiometricGateError) { + Alert.alert('Authentication Required', err.message); + return; + } + throw err; + } Alert.alert('Credit transferred', 'The recipient balance was updated.'); }, [ diff --git a/src/services/__tests__/groupBilling.test.ts b/src/services/__tests__/groupBilling.test.ts index ab7e0d29..05e7ca94 100644 --- a/src/services/__tests__/groupBilling.test.ts +++ b/src/services/__tests__/groupBilling.test.ts @@ -72,11 +72,7 @@ describe('GroupBillingService', () => { const now = Date.now(); // The window is anchored to the period, not to "the last N days from // now" — a period that ended long ago must not pick up today's charges. - const items = service.aggregateCharges( - sampleGroup, - now - 90 * 86400000, - now - 60 * 86400000 - ); + const items = service.aggregateCharges(sampleGroup, now - 90 * 86400000, now - 60 * 86400000); const ownerItem = items.find((i) => i.memberAddress === '0xOWNER'); expect(ownerItem?.amount).toBe(0); }); diff --git a/src/services/__tests__/rpcProvider.test.ts b/src/services/__tests__/rpcProvider.test.ts index 7ff7325f..ac3e04ae 100644 --- a/src/services/__tests__/rpcProvider.test.ts +++ b/src/services/__tests__/rpcProvider.test.ts @@ -163,4 +163,4 @@ describe('provider error types', () => { expect(error.message).toContain(PRIMARY); expect(error.message).toContain(FALLBACK); }); -}); \ No newline at end of file +}); diff --git a/src/services/__tests__/transactionConfirmation.test.ts b/src/services/__tests__/transactionConfirmation.test.ts index 51f5ff7f..4025df5d 100644 --- a/src/services/__tests__/transactionConfirmation.test.ts +++ b/src/services/__tests__/transactionConfirmation.test.ts @@ -258,4 +258,4 @@ describe('bestEffortTransactionHash', () => { await expect(bestEffortTransactionHash(null)).resolves.toBeUndefined(); await expect(bestEffortTransactionHash(undefined)).resolves.toBeUndefined(); }); -}); \ No newline at end of file +}); diff --git a/src/services/auth/__tests__/deviceAttestationService.test.ts b/src/services/auth/__tests__/deviceAttestationService.test.ts index 13ff7b99..6c49e3ec 100644 --- a/src/services/auth/__tests__/deviceAttestationService.test.ts +++ b/src/services/auth/__tests__/deviceAttestationService.test.ts @@ -108,7 +108,9 @@ describe('deviceAttestationService', () => { it('detects root when known root management packages are installed', async () => { const mockProvider: IntegrityProbeProvider = { - getInstalledPackages: jest.fn().mockResolvedValue(['com.topjohnwu.magisk', 'com.other.app']), + getInstalledPackages: jest + .fn() + .mockResolvedValue(['com.topjohnwu.magisk', 'com.other.app']), }; deviceAttestationService.setCustomProbeProvider(mockProvider); @@ -284,19 +286,47 @@ describe('deviceAttestationService', () => { expect(result.riskLevel).toBe('critical'); // Safe / Basic operations are allowed under graceful degradation - expect(deviceAttestationService.isOperationAllowed('VIEW_SUBSCRIPTIONS', result.degradationPolicy)).toBe(true); - expect(deviceAttestationService.isOperationAllowed('VIEW_ANALYTICS', result.degradationPolicy)).toBe(true); - expect(deviceAttestationService.isOperationAllowed('MANAGE_CATEGORIES', result.degradationPolicy)).toBe(true); - expect(deviceAttestationService.isOperationAllowed('CHANGE_SETTINGS', result.degradationPolicy)).toBe(true); - expect(deviceAttestationService.isOperationAllowed('READ_DOCUMENTATION', result.degradationPolicy)).toBe(true); + expect( + deviceAttestationService.isOperationAllowed('VIEW_SUBSCRIPTIONS', result.degradationPolicy) + ).toBe(true); + expect( + deviceAttestationService.isOperationAllowed('VIEW_ANALYTICS', result.degradationPolicy) + ).toBe(true); + expect( + deviceAttestationService.isOperationAllowed('MANAGE_CATEGORIES', result.degradationPolicy) + ).toBe(true); + expect( + deviceAttestationService.isOperationAllowed('CHANGE_SETTINGS', result.degradationPolicy) + ).toBe(true); + expect( + deviceAttestationService.isOperationAllowed('READ_DOCUMENTATION', result.degradationPolicy) + ).toBe(true); // Sensitive operations are RESTRICTED under graceful degradation - expect(deviceAttestationService.isOperationAllowed('EXECUTE_PAYMENT', result.degradationPolicy)).toBe(false); - expect(deviceAttestationService.isOperationAllowed('SIGN_TRANSACTION', result.degradationPolicy)).toBe(false); - expect(deviceAttestationService.isOperationAllowed('EXPORT_PRIVATE_KEYS', result.degradationPolicy)).toBe(false); - expect(deviceAttestationService.isOperationAllowed('BIOMETRIC_AUTH', result.degradationPolicy)).toBe(false); - expect(deviceAttestationService.isOperationAllowed('EXPORT_SENSITIVE_DATA', result.degradationPolicy)).toBe(false); - expect(deviceAttestationService.isOperationAllowed('PERSIST_PLAINTEXT_TOKENS', result.degradationPolicy)).toBe(false); + expect( + deviceAttestationService.isOperationAllowed('EXECUTE_PAYMENT', result.degradationPolicy) + ).toBe(false); + expect( + deviceAttestationService.isOperationAllowed('SIGN_TRANSACTION', result.degradationPolicy) + ).toBe(false); + expect( + deviceAttestationService.isOperationAllowed('EXPORT_PRIVATE_KEYS', result.degradationPolicy) + ).toBe(false); + expect( + deviceAttestationService.isOperationAllowed('BIOMETRIC_AUTH', result.degradationPolicy) + ).toBe(false); + expect( + deviceAttestationService.isOperationAllowed( + 'EXPORT_SENSITIVE_DATA', + result.degradationPolicy + ) + ).toBe(false); + expect( + deviceAttestationService.isOperationAllowed( + 'PERSIST_PLAINTEXT_TOKENS', + result.degradationPolicy + ) + ).toBe(false); }); it('assertOperationAllowed throws DeviceIntegrityError for restricted operations on compromised device', async () => { @@ -349,7 +379,9 @@ describe('deviceAttestationService', () => { ]; for (const op of allOps) { - expect(deviceAttestationService.isOperationAllowed(op, result.degradationPolicy)).toBe(true); + expect(deviceAttestationService.isOperationAllowed(op, result.degradationPolicy)).toBe( + true + ); expect(() => deviceAttestationService.assertOperationAllowed(op, result)).not.toThrow(); } }); diff --git a/src/services/auth/__tests__/sensitiveActionGuard.test.ts b/src/services/auth/__tests__/sensitiveActionGuard.test.ts new file mode 100644 index 00000000..236b6351 --- /dev/null +++ b/src/services/auth/__tests__/sensitiveActionGuard.test.ts @@ -0,0 +1,164 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + BiometricGateError, + DEFAULT_SENSITIVE_ACTION_POLICY, + sensitiveActionGuard, +} from '../sensitiveActionGuard'; +import { biometricService } from '../biometricService'; + +const mockStore: Record = {}; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(async (key: string) => mockStore[key] ?? null), + setItem: jest.fn(async (key: string, value: string) => { + mockStore[key] = value; + }), + removeItem: jest.fn(async (key: string) => { + delete mockStore[key]; + }), +})); + +jest.mock('../biometricService', () => ({ + biometricService: { + checkLockout: jest.fn(async () => ({ locked: false, remainingMs: 0 })), + isAvailable: jest.fn(async () => true), + authenticateHardened: jest.fn(async () => ({ success: true })), + }, +})); + +const mockedAuth = biometricService as jest.Mocked; +const mockedAsyncStorage = AsyncStorage as jest.Mocked; + +describe('sensitiveActionGuard', () => { + beforeEach(() => { + jest.clearAllMocks(); + Object.keys(mockStore).forEach((key) => delete mockStore[key]); + mockedAuth.checkLockout.mockResolvedValue({ locked: false, remainingMs: 0 }); + mockedAuth.isAvailable.mockResolvedValue(true); + mockedAuth.authenticateHardened.mockResolvedValue({ success: true }); + }); + + describe('enforced where configured', () => { + it('prompts and runs the action for an enforced action', async () => { + const fn = jest.fn().mockReturnValue('done'); + + const result = await sensitiveActionGuard.authorize('transfer_funds'); + + expect(result).toMatchObject({ allowed: true, code: 'allowed', required: true }); + expect(mockedAuth.authenticateHardened).toHaveBeenCalledTimes(1); + + await expect(sensitiveActionGuard.withBiometricGuard('transfer_funds', fn)).resolves.toBe( + 'done' + ); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('does not prompt for actions that are not configured', async () => { + await sensitiveActionGuard.savePolicy({ enforced: ['delete_account'] }); + + const fn = jest.fn(); + await expect( + sensitiveActionGuard.withBiometricGuard('transfer_funds', fn) + ).resolves.toBeUndefined(); + + expect(mockedAuth.authenticateHardened).not.toHaveBeenCalled(); + expect(fn).toHaveBeenCalledTimes(1); + expect(await sensitiveActionGuard.isEnforced('delete_account')).toBe(true); + expect(await sensitiveActionGuard.isEnforced('transfer_funds')).toBe(false); + }); + + it('denies and never executes the action when the prompt fails', async () => { + mockedAuth.authenticateHardened.mockResolvedValue({ + success: false, + error: 'Authentication failed.', + }); + + const fn = jest.fn(); + const result = await sensitiveActionGuard.authorize('confirm_payment'); + expect(result).toMatchObject({ allowed: false, code: 'denied' }); + + await expect(sensitiveActionGuard.withBiometricGuard('confirm_payment', fn)).rejects.toThrow( + BiometricGateError + ); + expect(fn).not.toHaveBeenCalled(); + }); + + it('surfaces cancellation distinctly from a hard failure', async () => { + mockedAuth.authenticateHardened.mockResolvedValue({ + success: false, + cancelled: true, + error: 'Authentication cancelled.', + }); + + const result = await sensitiveActionGuard.authorize('transfer_funds'); + expect(result).toMatchObject({ allowed: false, code: 'cancelled' }); + }); + + it('blocks while locked out after repeated failures, without prompting', async () => { + mockedAuth.checkLockout.mockResolvedValue({ locked: true, remainingMs: 120_000 }); + + const result = await sensitiveActionGuard.authorize('transfer_funds'); + expect(result).toMatchObject({ allowed: false, code: 'locked_out' }); + expect(result.message).toContain('2 minute(s)'); + expect(mockedAuth.authenticateHardened).not.toHaveBeenCalled(); + }); + }); + + describe('graceful fallback', () => { + it('allows the action when biometrics are unavailable and policy is fail-open', async () => { + mockedAuth.isAvailable.mockResolvedValue(false); + await sensitiveActionGuard.savePolicy({ onUnavailable: 'allow' }); + + const fn = jest.fn().mockReturnValue('ok'); + const result = await sensitiveActionGuard.authorize('transfer_funds'); + + expect(result).toMatchObject({ + allowed: true, + code: 'unavailable', + fellBack: true, + required: true, + }); + expect(mockedAuth.authenticateHardened).not.toHaveBeenCalled(); + await expect(sensitiveActionGuard.withBiometricGuard('transfer_funds', fn)).resolves.toBe( + 'ok' + ); + }); + + it('blocks the action when biometrics are unavailable and policy is fail-closed', async () => { + mockedAuth.isAvailable.mockResolvedValue(false); + await sensitiveActionGuard.savePolicy({ onUnavailable: 'block' }); + + const fn = jest.fn(); + const result = await sensitiveActionGuard.authorize('delete_account'); + expect(result).toMatchObject({ allowed: false, code: 'unavailable' }); + + await expect(sensitiveActionGuard.withBiometricGuard('delete_account', fn)).rejects.toThrow( + BiometricGateError + ); + expect(fn).not.toHaveBeenCalled(); + }); + }); + + describe('policy persistence', () => { + it('uses the default policy when nothing is stored', async () => { + const policy = await sensitiveActionGuard.getPolicy(); + expect(policy).toEqual(DEFAULT_SENSITIVE_ACTION_POLICY); + }); + + it('recovers from corrupt stored policy', async () => { + mockStore['@subtrackr/sensitive_action_policy'] = '{not json'; + const policy = await sensitiveActionGuard.getPolicy(); + expect(policy.enforced).toEqual(DEFAULT_SENSITIVE_ACTION_POLICY.enforced); + expect(policy.onUnavailable).toBe('allow'); + }); + + it('merges patches into the stored policy', async () => { + const updated = await sensitiveActionGuard.savePolicy({ onUnavailable: 'block' }); + expect(updated.onUnavailable).toBe('block'); + expect(updated.enforced).toEqual(DEFAULT_SENSITIVE_ACTION_POLICY.enforced); + + const reloaded = await sensitiveActionGuard.getPolicy(); + expect(reloaded).toEqual(updated); + }); + }); +}); diff --git a/src/services/auth/deviceAttestationService.ts b/src/services/auth/deviceAttestationService.ts index d563376b..5fbf2498 100644 --- a/src/services/auth/deviceAttestationService.ts +++ b/src/services/auth/deviceAttestationService.ts @@ -128,12 +128,7 @@ export const IOS_JAILBREAK_PATHS = [ /** * Custom URL schemes registered by jailbreak package managers. */ -export const IOS_JAILBREAK_SCHEMES = [ - 'cydia://', - 'sileo://', - 'zbra://', - 'undecimus://', -]; +export const IOS_JAILBREAK_SCHEMES = ['cydia://', 'sileo://', 'zbra://', 'undecimus://']; /** * Pluggable probe provider to allow native module integration or test mocking. @@ -238,7 +233,10 @@ class DeviceAttestationService { parsed.isJailbroken = parsed.isJailbroken ?? false; parsed.isTampered = parsed.isTampered ?? false; parsed.compromiseIndicators = parsed.compromiseIndicators ?? []; - parsed.degradationPolicy = this.computeDegradationPolicy(riskLevel, parsed.compromiseIndicators); + parsed.degradationPolicy = this.computeDegradationPolicy( + riskLevel, + parsed.compromiseIndicators + ); } return parsed; } catch { @@ -401,7 +399,9 @@ class DeviceAttestationService { // 3. Sandbox write test if (this.customProvider?.canWriteRestrictedPath) { try { - const canWrite = await this.customProvider.canWriteRestrictedPath('/private/subtrackr_jailbreak.txt'); + const canWrite = await this.customProvider.canWriteRestrictedPath( + '/private/subtrackr_jailbreak.txt' + ); if (canWrite) { detected = true; indicators.push('ios_sandbox_violation'); @@ -476,8 +476,7 @@ class DeviceAttestationService { if (Platform.OS === 'ios') { const isSim = - (Platform as any).constants?.isSimulator || - process.env.SIMULATOR_DEVICE_NAME !== undefined; + (Platform as any).constants?.isSimulator || process.env.SIMULATOR_DEVICE_NAME !== undefined; if (isSim) { indicators.push('emulator_ios_simulator_detected'); diff --git a/src/services/auth/sensitiveActionGuard.ts b/src/services/auth/sensitiveActionGuard.ts new file mode 100644 index 00000000..51439550 --- /dev/null +++ b/src/services/auth/sensitiveActionGuard.ts @@ -0,0 +1,174 @@ +/** + * SensitiveActionGuard — step-up biometric authentication for high-risk + * actions (Issue #1765). + * + * Certain operations (moving funds, exporting keys, deleting the account, …) + * require a fresh biometric check even though the app itself is already + * unlocked. The guard consults a persisted policy — so enforcement is + * opt-in per action ("enforced where configured") — and delegates the actual + * prompt to `biometricService`, which supplies lockout backoff and the + * device-PIN fallback. + * + * Graceful fallback: devices without enrolled biometrics (or with the native + * module missing, e.g. tests / Expo Go) are not locked out of their own data + * unless the policy opts into `onUnavailable: 'block'`. The fallback decision + * is always surfaced on the result so callers/UI can show why. + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { biometricService } from './biometricService'; + +const POLICY_KEY = '@subtrackr/sensitive_action_policy'; + +export type SensitiveAction = + | 'transfer_funds' + | 'export_private_key' + | 'confirm_payment' + | 'delete_account' + | 'change_security_settings'; + +export interface SensitiveActionPolicy { + /** Actions that require a biometric step-up before executing. */ + enforced: SensitiveAction[]; + /** Fallback when no biometric hardware/enrollment is available. */ + onUnavailable: 'allow' | 'block'; +} + +export type AuthorizationCode = + 'allowed' | 'not_enforced' | 'denied' | 'cancelled' | 'unavailable' | 'locked_out'; + +export interface AuthorizationResult { + allowed: boolean; + code: AuthorizationCode; + /** True when the guard ran but there was nothing to enforce. */ + required: boolean; + /** True when the allow/deny decision came from the unavailable-fallback. */ + fellBack: boolean; + message?: string; +} + +export class BiometricGateError extends Error { + readonly code: AuthorizationCode; + readonly action: SensitiveAction; + + constructor(action: SensitiveAction, result: AuthorizationResult) { + super(result.message ?? `Biometric step-up required for "${action}" (code: ${result.code})`); + this.name = 'BiometricGateError'; + this.code = result.code; + this.action = action; + } +} + +export const DEFAULT_SENSITIVE_ACTION_POLICY: SensitiveActionPolicy = { + enforced: ['transfer_funds', 'export_private_key', 'confirm_payment', 'delete_account'], + onUnavailable: 'allow', +}; + +class SensitiveActionGuard { + async getPolicy(): Promise { + try { + const raw = await AsyncStorage.getItem(POLICY_KEY); + if (raw) { + const parsed = JSON.parse(raw) as Partial; + return { + enforced: Array.isArray(parsed.enforced) + ? parsed.enforced + : DEFAULT_SENSITIVE_ACTION_POLICY.enforced, + onUnavailable: parsed.onUnavailable === 'block' ? 'block' : 'allow', + }; + } + } catch { + // Corrupt policy → defaults + } + return { + ...DEFAULT_SENSITIVE_ACTION_POLICY, + enforced: [...DEFAULT_SENSITIVE_ACTION_POLICY.enforced], + }; + } + + async savePolicy(patch: Partial): Promise { + const current = await this.getPolicy(); + const next: SensitiveActionPolicy = { + enforced: patch.enforced ?? current.enforced, + onUnavailable: patch.onUnavailable ?? current.onUnavailable, + }; + await AsyncStorage.setItem(POLICY_KEY, JSON.stringify(next)); + return next; + } + + /** Whether `action` currently requires a biometric step-up. */ + async isEnforced(action: SensitiveAction): Promise { + const policy = await this.getPolicy(); + return policy.enforced.includes(action); + } + + /** + * Authorize a sensitive action. Never throws — callers decide whether to + * proceed; `withBiometricGuard` is the throwing convenience wrapper. + */ + async authorize(action: SensitiveAction, reason?: string): Promise { + const policy = await this.getPolicy(); + if (!policy.enforced.includes(action)) { + return { allowed: true, code: 'not_enforced', required: false, fellBack: false }; + } + + const lockout = await biometricService.checkLockout(); + if (lockout.locked) { + const mins = Math.ceil(lockout.remainingMs / 60000); + return { + allowed: false, + code: 'locked_out', + required: true, + fellBack: false, + message: `Too many failed attempts. Try again in ${mins} minute(s).`, + }; + } + + const available = await biometricService.isAvailable(); + if (!available) { + const allowed = policy.onUnavailable === 'allow'; + return { + allowed, + code: 'unavailable', + required: true, + fellBack: true, + message: allowed + ? 'Biometrics unavailable on this device; action allowed by fallback policy.' + : 'Biometric authentication is required but unavailable on this device.', + }; + } + + const prompt = reason ?? `Authenticate to ${action.replace(/_/g, ' ')} in SubTrackr`; + const result = await biometricService.authenticateHardened(prompt); + + if (result.success) { + return { allowed: true, code: 'allowed', required: true, fellBack: false }; + } + + return { + allowed: false, + code: result.cancelled ? 'cancelled' : 'denied', + required: true, + fellBack: false, + message: result.error ?? 'Authentication failed.', + }; + } + + /** + * Run `fn` only after a successful step-up. Throws `BiometricGateError` + * (before `fn` runs) when the guard denies the action. + */ + async withBiometricGuard( + action: SensitiveAction, + fn: () => Promise | T, + reason?: string + ): Promise { + const result = await this.authorize(action, reason); + if (!result.allowed) { + throw new BiometricGateError(action, result); + } + return fn(); + } +} + +export const sensitiveActionGuard = new SensitiveActionGuard(); diff --git a/src/services/network/__tests__/certificatePinning.test.ts b/src/services/network/__tests__/certificatePinning.test.ts new file mode 100644 index 00000000..93b9878c --- /dev/null +++ b/src/services/network/__tests__/certificatePinning.test.ts @@ -0,0 +1,353 @@ +import { + CertificatePinner, + CertificatePinningError, + hostMatches, + hostnameOf, + normalizePin, + PeerCertificateProvider, +} from '../certificatePinning'; +import { ApiClient } from '../apiClient'; +import { MobileTracer, MobileSpan } from '../trace'; + +const PIN_A = 'sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; +const PIN_B = 'sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB='; +const OTHER = 'sha256/CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC='; + +function makeSpan(): MobileSpan { + return { + context: { traceId: 'a'.repeat(32), spanId: 'b'.repeat(16), sampled: false }, + name: 'test', + startTime: 0, + attributes: {}, + status: 'unset', + }; +} + +function makeTracer(): MobileTracer { + return { + startClientSpan: jest.fn().mockReturnValue(makeSpan()), + endSpan: jest.fn(), + } as unknown as MobileTracer; +} + +const okResponse = (data: unknown = { ok: true }): Response => + ({ ok: true, status: 200, text: async () => JSON.stringify(data) }) as unknown as Response; + +describe('certificate pinning helpers', () => { + it('normalises pins to a canonical sha256/base64 form', () => { + expect(normalizePin('sha256/AbC=')).toBe('sha256/abc='); + expect(normalizePin('sha256:xyz')).toBe('sha256/xyz'); + expect(normalizePin(' barePinValue ')).toBe('sha256/barepinvalue'); + }); + + it('extracts hostnames from absolute URLs, ignoring port and userinfo', () => { + expect(hostnameOf('https://API.Example.com:8443/v1/subs?x=1')).toBe('api.example.com'); + expect(hostnameOf('https://user:pass@api.example.com')).toBe('api.example.com'); + expect(hostnameOf('api.example.com')).toBe('api.example.com'); + }); + + it('matches exact and wildcard hosts', () => { + expect(hostMatches('api.example.com', 'api.example.com')).toBe(true); + expect(hostMatches('api.example.com', '*.example.com')).toBe(true); + expect(hostMatches('evil.com', '*.example.com')).toBe(false); + expect(hostMatches('example.com', '*.example.com')).toBe(false); + }); +}); + +describe('CertificatePinner', () => { + it('matches a configured pin', () => { + const pinner = new CertificatePinner({ pinSets: [{ host: 'api.example.com', pins: [PIN_A] }] }); + const result = pinner.verify('api.example.com', { spkiSha256: PIN_A }); + expect(result).toMatchObject({ status: 'match', ok: true }); + }); + + it('rejects a mismatch in enforce mode', () => { + const pinner = new CertificatePinner({ + mode: 'enforce', + pinSets: [{ host: 'api.example.com', pins: [PIN_A] }], + }); + const result = pinner.verify('api.example.com', { spkiSha256: OTHER }); + expect(result.status).toBe('mismatch'); + expect(result.ok).toBe(false); + expect(() => pinner.assert('api.example.com', { spkiSha256: OTHER })).toThrow( + CertificatePinningError + ); + }); + + it('allows a mismatch in report mode (fallback while rolling out pins)', () => { + const pinner = new CertificatePinner({ + mode: 'report', + pinSets: [{ host: 'api.example.com', pins: [PIN_A] }], + }); + const result = pinner.verify('api.example.com', { spkiSha256: OTHER }); + expect(result.status).toBe('mismatch'); + expect(result.ok).toBe(true); + expect(pinner.assert('api.example.com', { spkiSha256: OTHER }).ok).toBe(true); + }); + + it('skips verification entirely when disabled', () => { + const pinner = new CertificatePinner({ mode: 'disabled', pinSets: [] }); + expect(pinner.verify('api.example.com', null)).toMatchObject({ + status: 'disabled', + ok: true, + }); + }); + + it('falls back to allow when the peer certificate is unavailable', () => { + const pinner = new CertificatePinner({ pinSets: [{ host: 'api.example.com', pins: [PIN_A] }] }); + expect(pinner.verify('api.example.com', null)).toMatchObject({ + status: 'unavailable', + ok: true, + }); + expect(pinner.verify('api.example.com', { spkiSha256: ' ' })).toMatchObject({ + status: 'unavailable', + ok: true, + }); + }); + + it('falls back to block when configured as fail-closed', () => { + const pinner = new CertificatePinner({ + onUnavailable: 'block', + pinSets: [{ host: 'api.example.com', pins: [PIN_A] }], + }); + expect(pinner.verify('api.example.com', null)).toMatchObject({ + status: 'unavailable', + ok: false, + }); + expect(() => pinner.assert('api.example.com', null)).toThrow(CertificatePinningError); + }); + + it('treats a host with no pin set as unavailable (documented fallback)', () => { + const pinner = new CertificatePinner({ + pinSets: [{ host: 'other.example.com', pins: [PIN_A] }], + }); + expect(pinner.verify('api.example.com', { spkiSha256: PIN_A })).toMatchObject({ + status: 'no_pins', + ok: true, + }); + }); + + describe('rotation', () => { + const past = new Date(Date.now() - 60_000).toISOString(); + const future = new Date(Date.now() + 60_000).toISOString(); + + it('ignores expired pin sets so rotated-out pins drop automatically', () => { + const pinner = new CertificatePinner({ + pinSets: [ + { + host: 'api.example.com', + pins: [PIN_A], + expiresAt: past, // rotated out + }, + ], + }); + expect(pinner.activePinSets('api.example.com')).toHaveLength(0); + expect(pinner.verify('api.example.com', { spkiSha256: PIN_A })).toMatchObject({ + status: 'no_pins', + ok: true, + }); + }); + + it('accepts both the outgoing and incoming pin during the overlap window', () => { + const pinner = new CertificatePinner({ + pinSets: [ + { host: 'api.example.com', pins: [PIN_A], expiresAt: future }, + { host: 'api.example.com', pins: [PIN_B] }, + ], + }); + expect(pinner.verify('api.example.com', { spkiSha256: PIN_A })).toMatchObject({ + status: 'match', + ok: true, + }); + expect(pinner.verify('api.example.com', { spkiSha256: PIN_B })).toMatchObject({ + status: 'match', + ok: true, + }); + expect(pinner.verify('api.example.com', { spkiSha256: OTHER }).status).toBe('mismatch'); + }); + + it('rejects the old pin once its rotation window has closed', () => { + const pinner = new CertificatePinner({ + pinSets: [ + { host: 'api.example.com', pins: [PIN_A], expiresAt: past }, + { host: 'api.example.com', pins: [PIN_B] }, + ], + }); + const after = Date.now() + 60_000; + expect(pinner.activePinSets('api.example.com', after)).toHaveLength(1); + expect(pinner.verify('api.example.com', { spkiSha256: PIN_A }).status).toBe('mismatch'); + expect(pinner.verify('api.example.com', { spkiSha256: PIN_A }).ok).toBe(false); + expect(pinner.verify('api.example.com', { spkiSha256: PIN_B }).status).toBe('match'); + }); + }); + + describe('fromEnv', () => { + it('builds pin sets from the JSON configuration', () => { + const pinner = CertificatePinner.fromEnv({ + EXPO_PUBLIC_CERT_PINNING_MODE: 'report', + EXPO_PUBLIC_CERT_PIN_SETS: JSON.stringify([{ host: '*.example.com', pins: [PIN_A] }]), + }); + expect(pinner.getMode()).toBe('report'); + expect(pinner.verify('api.example.com', { spkiSha256: PIN_A }).status).toBe('match'); + }); + + it('supports the single-host comma-separated shorthand', () => { + const pinner = CertificatePinner.fromEnv({ + EXPO_PUBLIC_CERT_PINS: `${PIN_A}, ${PIN_B}`, + EXPO_PUBLIC_API_BASE_URL: 'https://api.example.com', + }); + expect(pinner.verify('api.example.com', { spkiSha256: PIN_B }).status).toBe('match'); + expect(pinner.verify('api.example.com', { spkiSha256: OTHER }).status).toBe('mismatch'); + }); + + it('falls back to no pins when the JSON configuration is invalid', () => { + const pinner = CertificatePinner.fromEnv({ + EXPO_PUBLIC_CERT_PIN_SETS: 'not-json', + }); + expect(pinner.verify('api.example.com', { spkiSha256: PIN_A })).toMatchObject({ + status: 'no_pins', + ok: true, + }); + }); + + it('honours the fail-closed fallback flag', () => { + const pinner = CertificatePinner.fromEnv({ + EXPO_PUBLIC_CERT_PIN_UNAVAILABLE: 'block', + EXPO_PUBLIC_CERT_PIN_SETS: JSON.stringify([{ host: 'api.example.com', pins: [PIN_A] }]), + }); + expect(pinner.verify('api.example.com', null).ok).toBe(false); + }); + }); +}); + +describe('ApiClient certificate pinning', () => { + const provider = (spki: string | null): PeerCertificateProvider => + jest.fn(async () => (spki ? { spkiSha256: spki } : null)); + + it('performs a pinned request when the peer key matches', async () => { + const fetchImpl = jest.fn().mockResolvedValue(okResponse()); + const client = new ApiClient({ + baseUrl: 'https://api.example.com', + tracer: makeTracer(), + fetchImpl: fetchImpl as unknown as typeof fetch, + certificatePinning: { + config: { pinSets: [{ host: 'api.example.com', pins: [PIN_A] }] }, + peerCertificateProvider: provider(PIN_A), + }, + }); + + const res = await client.get('/v1/subscriptions'); + expect(res.ok).toBe(true); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('aborts the request on a pin mismatch in enforce mode (fetch never called)', async () => { + const fetchImpl = jest.fn().mockResolvedValue(okResponse()); + const client = new ApiClient({ + baseUrl: 'https://api.example.com', + tracer: makeTracer(), + fetchImpl: fetchImpl as unknown as typeof fetch, + certificatePinning: { + config: { mode: 'enforce', pinSets: [{ host: 'api.example.com', pins: [PIN_A] }] }, + peerCertificateProvider: provider(OTHER), + }, + }); + + await expect(client.get('/v1/subscriptions')).rejects.toThrow(CertificatePinningError); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('proceeds on a pin mismatch in report mode and records it on the span', async () => { + const tracer = makeTracer(); + const fetchImpl = jest.fn().mockResolvedValue(okResponse()); + const client = new ApiClient({ + baseUrl: 'https://api.example.com', + tracer, + fetchImpl: fetchImpl as unknown as typeof fetch, + certificatePinning: { + config: { mode: 'report', pinSets: [{ host: 'api.example.com', pins: [PIN_A] }] }, + peerCertificateProvider: provider(OTHER), + }, + }); + + const res = await client.get('/v1/subscriptions'); + expect(res.ok).toBe(true); + expect(fetchImpl).toHaveBeenCalledTimes(1); + + const span = (tracer.startClientSpan as jest.Mock).mock.results[0].value as MobileSpan; + expect(span.attributes['security.cert_pinning.mode']).toBe('report'); + expect(span.attributes['security.cert_pinning.status']).toBe('mismatch'); + }); + + it('falls back to allowing the request when no native peer provider is registered', async () => { + const fetchImpl = jest.fn().mockResolvedValue(okResponse()); + const client = new ApiClient({ + baseUrl: 'https://api.example.com', + tracer: makeTracer(), + fetchImpl: fetchImpl as unknown as typeof fetch, + certificatePinning: { + config: { mode: 'enforce', pinSets: [{ host: 'api.example.com', pins: [PIN_A] }] }, + peerCertificateProvider: async () => null, + }, + }); + + const res = await client.get('/v1/subscriptions'); + expect(res.ok).toBe(true); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('blocks the request when fail-closed fallback is configured', async () => { + const fetchImpl = jest.fn().mockResolvedValue(okResponse()); + const client = new ApiClient({ + baseUrl: 'https://api.example.com', + tracer: makeTracer(), + fetchImpl: fetchImpl as unknown as typeof fetch, + certificatePinning: { + config: { + mode: 'enforce', + onUnavailable: 'block', + pinSets: [{ host: 'api.example.com', pins: [PIN_A] }], + }, + peerCertificateProvider: async () => null, + }, + }); + + await expect(client.get('/v1/subscriptions')).rejects.toThrow(CertificatePinningError); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('treats a provider exception as unavailable rather than crashing', async () => { + const fetchImpl = jest.fn().mockResolvedValue(okResponse()); + const client = new ApiClient({ + baseUrl: 'https://api.example.com', + tracer: makeTracer(), + fetchImpl: fetchImpl as unknown as typeof fetch, + certificatePinning: { + config: { pinSets: [{ host: 'api.example.com', pins: [PIN_A] }] }, + peerCertificateProvider: async () => { + throw new Error('native hook exploded'); + }, + }, + }); + + const res = await client.get('/v1/subscriptions'); + expect(res.ok).toBe(true); + }); + + it('skips pinning for plain HTTP (dev / mock hosts) even when fail-closed', async () => { + const fetchImpl = jest.fn().mockResolvedValue(okResponse()); + const client = new ApiClient({ + baseUrl: 'http://localhost:3000', + tracer: makeTracer(), + fetchImpl: fetchImpl as unknown as typeof fetch, + certificatePinning: { + config: { onUnavailable: 'block', pinSets: [] }, + peerCertificateProvider: provider(null), + }, + }); + + const res = await client.get('/v1/subscriptions'); + expect(res.ok).toBe(true); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/services/network/apiClient.ts b/src/services/network/apiClient.ts index 4eefca05..a7706689 100644 --- a/src/services/network/apiClient.ts +++ b/src/services/network/apiClient.ts @@ -8,8 +8,25 @@ * attributes to the span. Sensitive headers are never recorded. */ -import { formatTraceparent, mobileTracer, MobileTracer } from './trace'; +import { formatTraceparent, mobileTracer, MobileSpan, MobileTracer } from './trace'; import { CircuitBreaker, CircuitBreakerOptions } from './circuitBreaker'; +import { + CertificatePinner, + CertificatePinningConfig, + CertificatePinningError, + defaultPeerCertificateProvider, + hostnameOf, + PeerCertificateProvider, +} from './certificatePinning'; + +export interface ApiCertificatePinningOptions { + /** Pre-built pinner; takes precedence over `config`. */ + pinner?: CertificatePinner; + /** Raw pin configuration (e.g. from build-time env / remote config). */ + config?: CertificatePinningConfig; + /** Source of the peer SPKI hash; defaults to the native TLS hook. */ + peerCertificateProvider?: PeerCertificateProvider; +} export interface ApiClientOptions { baseUrl?: string; @@ -19,6 +36,12 @@ export interface ApiClientOptions { defaultHeaders?: Record; /** Optional circuit breaker configuration */ circuitBreaker?: CircuitBreakerOptions; + /** + * Certificate pinning. Defaults to `CertificatePinner.fromEnv()` with the + * native TLS hook provider — i.e. config-driven, and a no-op until pins or + * the native provider are configured. + */ + certificatePinning?: ApiCertificatePinningOptions; } export interface ApiRequestOptions { @@ -42,6 +65,8 @@ export class ApiClient { private readonly fetchImpl: typeof fetch; private readonly defaultHeaders: Record; private readonly circuitBreaker: CircuitBreaker; + private readonly pinner: CertificatePinner; + private readonly peerCertificateProvider: PeerCertificateProvider; constructor(options: ApiClientOptions = {}) { this.baseUrl = (options.baseUrl ?? process.env.EXPO_PUBLIC_API_BASE_URL ?? '').replace( @@ -56,6 +81,12 @@ export class ApiClient { tracer: this.tracer, ...options.circuitBreaker, }); + const pinning = options.certificatePinning ?? {}; + this.pinner = + pinning.pinner ?? + (pinning.config ? new CertificatePinner(pinning.config) : CertificatePinner.fromEnv()); + this.peerCertificateProvider = + pinning.peerCertificateProvider ?? defaultPeerCertificateProvider; } async request(path: string, options: ApiRequestOptions = {}): Promise> { @@ -74,6 +105,8 @@ export class ApiClient { }; try { + await this.verifyCertificate(url, span); + let response: Response; try { response = await this.circuitBreaker.execute(async () => { @@ -111,6 +144,33 @@ export class ApiClient { } } + /** + * Verify the peer certificate against the configured pins for HTTPS URLs. + * Non-HTTPS requests (local dev, mock E2E hosts) are skipped. Throws + * `CertificatePinningError` when the fallback/mode says the request must not + * proceed; in `report` mode mismatches are only recorded on the span. + */ + private async verifyCertificate(url: string, span: MobileSpan): Promise { + if (!/^https:\/\//i.test(url)) return; + + const hostname = hostnameOf(url); + let peer = null; + try { + peer = await this.peerCertificateProvider(url); + } catch { + peer = null; // provider failure → treated as "unavailable" + } + + const result = this.pinner.verify(hostname, peer); + span.attributes['security.cert_pinning.mode'] = this.pinner.getMode(); + span.attributes['security.cert_pinning.status'] = result.status; + + if (!result.ok) { + // Span attributes above stay attached; the caller's catch ends the span. + throw new CertificatePinningError(result); + } + } + get( path: string, options: Omit = {} diff --git a/src/services/network/certificatePinning.ts b/src/services/network/certificatePinning.ts new file mode 100644 index 00000000..cb543de0 --- /dev/null +++ b/src/services/network/certificatePinning.ts @@ -0,0 +1,264 @@ +/** + * Certificate pinning for the mobile API client. + * + * `fetch` in React Native performs the TLS handshake in native code, so the JS + * layer never sees the peer certificate directly. This module implements the + * *policy* half of pinning: + * + * 1. A configurable set of SPKI SHA-256 pins per host (`CertificatePinner`). + * 2. A verification step the API client runs before every HTTPS request. + * 3. A peer-certificate provider hook — the native TLS layer (OkHttp / + * NSURLSession pinning or a certificate-transparency hook) registers + * `globalThis.__SUBTRACKR_TLS_PEER_CERT__(url)` and the default provider + * picks the observed SPKI hash up from there. + * + * Modes: + * - `enforce` — a pin mismatch aborts the request (`CertificatePinningError`). + * - `report` — mismatches are recorded on the span but the request proceeds + * (safe fallback while pins are rolled out). + * - `disabled`— verification is skipped entirely. + * + * When no peer certificate can be observed (no native hook, provider error, or + * no active pin set for the host) the configurable `onUnavailable` fallback + * decides whether the request is allowed (`allow`, default) or blocked + * (`block`), so deployments can choose fail-open telemetry or fail-closed + * security. + */ + +export type PinningMode = 'enforce' | 'report' | 'disabled'; + +export interface PinSet { + /** Exact host (`api.example.com`) or wildcard (`*.example.com`). */ + host: string; + /** SPKI SHA-256 pins: `sha256/` or bare base64. */ + pins: string[]; + /** ISO-8601 date after which this pin set is ignored (rotation). */ + expiresAt?: string; +} + +export interface CertificatePinningConfig { + mode?: PinningMode; + pinSets?: PinSet[]; + /** Fallback when the peer certificate or a matching pin set is unavailable. */ + onUnavailable?: 'allow' | 'block'; +} + +export interface PeerCertificateInfo { + /** Base64-encoded SHA-256 digest of the certificate's SPKI. */ + spkiSha256: string; +} + +export type PeerCertificateProvider = (url: string) => Promise; + +export type PinVerifyStatus = 'match' | 'mismatch' | 'unavailable' | 'no_pins' | 'disabled'; + +export interface PinVerifyResult { + status: PinVerifyStatus; + /** True when the request may proceed. */ + ok: boolean; + hostname: string; + detail: string; +} + +export class CertificatePinningError extends Error { + readonly status: PinVerifyStatus; + readonly hostname: string; + + constructor(result: PinVerifyResult) { + super(`Certificate pinning ${result.status} for ${result.hostname}: ${result.detail}`); + this.name = 'CertificatePinningError'; + this.status = result.status; + this.hostname = result.hostname; + } +} + +/** Normalises a pin to `sha256/` (lower-case prefix, trimmed). */ +export function normalizePin(raw: string): string { + const value = raw.trim(); + const withoutScheme = value.replace(/^sha256:/i, '').trim(); + const prefixed = /^sha256\//i.test(withoutScheme) + ? withoutScheme.replace(/^sha256\//i, 'sha256/') + : `sha256/${withoutScheme}`; + return prefixed.toLowerCase(); +} + +/** Extracts the lower-case hostname from an absolute URL (or bare host). */ +export function hostnameOf(url: string): string { + const withoutScheme = url.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, ''); + const authority = withoutScheme.split(/[/?#]/)[0]; + const host = authority.replace(/^[^\@]*\@/, ''); // strip userinfo + return host.replace(/:\d+$/, '').toLowerCase(); +} + +/** Matches `hostname` against an exact or `*.` wildcard pattern. */ +export function hostMatches(hostname: string, pattern: string): boolean { + const host = hostname.toLowerCase(); + const pat = pattern.trim().toLowerCase(); + if (pat === host) return true; + if (pat.startsWith('*.')) { + const suffix = pat.slice(1); // ".example.com" + return host.endsWith(suffix) && host.length > suffix.length; + } + return false; +} + +/** + * Default peer-certificate provider. Reads the SPKI hash observed by the + * native TLS layer via `globalThis.__SUBTRACKR_TLS_PEER_CERT__`. Returns + * `null` (→ `unavailable` fallback) when no hook is registered or it errors, + * which keeps the app working before the native half ships. + */ +export const defaultPeerCertificateProvider: PeerCertificateProvider = async (url) => { + const hook = ( + globalThis as unknown as { + __SUBTRACKR_TLS_PEER_CERT__?: (target: string) => unknown; + } + ).__SUBTRACKR_TLS_PEER_CERT__; + if (typeof hook !== 'function') return null; + try { + const info = await hook(url); + if (info && typeof info === 'object' && 'spkiSha256' in info) { + return info as PeerCertificateInfo; + } + return null; + } catch { + return null; + } +}; + +export class CertificatePinner { + private readonly mode: PinningMode; + private readonly pinSets: PinSet[]; + private readonly onUnavailable: 'allow' | 'block'; + + constructor(config: CertificatePinningConfig = {}) { + this.mode = config.mode ?? 'enforce'; + this.pinSets = (config.pinSets ?? []).map((set) => ({ + ...set, + pins: set.pins.map(normalizePin), + })); + this.onUnavailable = config.onUnavailable ?? 'allow'; + } + + /** + * Builds a pinner from environment configuration: + * - `EXPO_PUBLIC_CERT_PINNING_MODE` — enforce (default) | report | disabled + * - `EXPO_PUBLIC_CERT_PIN_SETS` — JSON array of `PinSet` objects + * - `EXPO_PUBLIC_CERT_PINS` — comma-separated pins applied to the host of + * `EXPO_PUBLIC_API_BASE_URL` (convenient single-host setup) + */ + static fromEnv(env: Record = process.env): CertificatePinner { + const mode = env.EXPO_PUBLIC_CERT_PINNING_MODE as PinningMode | undefined; + const onUnavailable = + (env.EXPO_PUBLIC_CERT_PIN_UNAVAILABLE as 'allow' | 'block' | undefined) ?? 'allow'; + + let pinSets: PinSet[] = []; + const rawSets = env.EXPO_PUBLIC_CERT_PIN_SETS; + if (rawSets) { + try { + const parsed = JSON.parse(rawSets) as PinSet[]; + if (Array.isArray(parsed)) pinSets = parsed; + } catch { + // Invalid configuration is treated as "no pins" (documented fallback). + pinSets = []; + } + } + + const rawPins = env.EXPO_PUBLIC_CERT_PINS; + if (pinSets.length === 0 && rawPins && env.EXPO_PUBLIC_API_BASE_URL) { + const host = hostnameOf(env.EXPO_PUBLIC_API_BASE_URL); + if (host) { + pinSets = [ + { + host, + pins: rawPins + .split(',') + .map((p) => p.trim()) + .filter(Boolean), + }, + ]; + } + } + + return new CertificatePinner({ mode, pinSets, onUnavailable }); + } + + getMode(): PinningMode { + return this.mode; + } + + /** Pin sets whose host matches and whose rotation window is still open. */ + activePinSets(hostname: string, now: number = Date.now()): PinSet[] { + return this.pinSets.filter((set) => { + if (!hostMatches(hostname, set.host)) return false; + if (!set.expiresAt) return true; + const expires = Date.parse(set.expiresAt); + return Number.isNaN(expires) || expires > now; + }); + } + + /** + * Verifies a peer certificate against the configured pins. + * Never throws — the caller decides what to do with `ok === false`. + */ + verify(hostname: string, peer: PeerCertificateInfo | null): PinVerifyResult { + const host = hostname.toLowerCase(); + + if (this.mode === 'disabled') { + return { status: 'disabled', ok: true, hostname: host, detail: 'pinning disabled' }; + } + + const active = this.activePinSets(host); + if (active.length === 0) { + const ok = this.onUnavailable === 'allow'; + return { + status: 'no_pins', + ok, + hostname: host, + detail: ok + ? 'no active pin set for host; fallback allows request' + : 'no active pin set for host; fallback blocks request', + }; + } + + if (!peer || typeof peer.spkiSha256 !== 'string' || !peer.spkiSha256.trim()) { + const ok = this.onUnavailable === 'allow'; + return { + status: 'unavailable', + ok, + hostname: host, + detail: ok + ? 'peer certificate unavailable; fallback allows request' + : 'peer certificate unavailable; fallback blocks request', + }; + } + + const presented = normalizePin(peer.spkiSha256); + const matched = active.some((set) => set.pins.includes(presented)); + if (matched) { + return { + status: 'match', + ok: true, + hostname: host, + detail: 'peer SPKI matches a pinned key', + }; + } + + const ok = this.mode === 'report'; + return { + status: 'mismatch', + ok, + hostname: host, + detail: ok + ? 'pin mismatch recorded in report mode; request proceeds' + : 'peer SPKI does not match any configured pin', + }; + } + + /** Verify and throw `CertificatePinningError` when the request must not proceed. */ + assert(hostname: string, peer: PeerCertificateInfo | null): PinVerifyResult { + const result = this.verify(hostname, peer); + if (!result.ok) throw new CertificatePinningError(result); + return result; + } +} diff --git a/src/services/payment/__tests__/paymentStrategies.test.ts b/src/services/payment/__tests__/paymentStrategies.test.ts index def39ca2..837de783 100644 --- a/src/services/payment/__tests__/paymentStrategies.test.ts +++ b/src/services/payment/__tests__/paymentStrategies.test.ts @@ -340,4 +340,4 @@ describe('Payment Strategies & StrategyFactory', () => { expect(UnifiedPaymentTracker.getHistory()).toHaveLength(1); }); }); -}); \ No newline at end of file +}); diff --git a/src/services/payment/paymentStrategies.ts b/src/services/payment/paymentStrategies.ts index 5d8ab3a6..bb955ef6 100644 --- a/src/services/payment/paymentStrategies.ts +++ b/src/services/payment/paymentStrategies.ts @@ -303,4 +303,4 @@ export class UnifiedPaymentTracker { public static getFailed(): PaymentResult[] { return this.history.filter((result) => !result.success); } -} \ No newline at end of file +} diff --git a/src/services/transactionConfirmation.ts b/src/services/transactionConfirmation.ts index af9ce0eb..2c666eb6 100644 --- a/src/services/transactionConfirmation.ts +++ b/src/services/transactionConfirmation.ts @@ -32,9 +32,7 @@ export class TransactionRevertedError extends Error { readonly transactionHash: string; constructor(transactionHash: string, detail?: string) { - super( - `Transaction ${transactionHash} was reverted on-chain${detail ? `: ${detail}` : '.'}` - ); + super(`Transaction ${transactionHash} was reverted on-chain${detail ? `: ${detail}` : '.'}`); this.name = 'TransactionRevertedError'; this.transactionHash = transactionHash; Object.setPrototypeOf(this, new.target.prototype); @@ -284,4 +282,4 @@ export async function bestEffortTransactionHash( ); return txResponse.hash; } -} \ No newline at end of file +} diff --git a/src/store/_tests_/transactionQueueStore.test.ts b/src/store/_tests_/transactionQueueStore.test.ts index 92160592..68131b55 100644 --- a/src/store/_tests_/transactionQueueStore.test.ts +++ b/src/store/_tests_/transactionQueueStore.test.ts @@ -276,10 +276,8 @@ describe('transactionQueueStore', () => { }); it('keeps the connectivity listener alive while another consumer is mounted', async () => { - const unsubscribeFirst = - useTransactionQueueStore.getState().initializeConnectivityListener(); - const unsubscribeSecond = - useTransactionQueueStore.getState().initializeConnectivityListener(); + const unsubscribeFirst = useTransactionQueueStore.getState().initializeConnectivityListener(); + const unsubscribeSecond = useTransactionQueueStore.getState().initializeConnectivityListener(); unsubscribeFirst(); diff --git a/src/store/transactionQueueStore.ts b/src/store/transactionQueueStore.ts index 3e3ada3e..7ec8bf9b 100644 --- a/src/store/transactionQueueStore.ts +++ b/src/store/transactionQueueStore.ts @@ -153,11 +153,7 @@ const isDefinitelyNotBroadcast = (error: unknown): boolean => { const err = error as { code?: unknown; userMessage?: unknown; message?: unknown }; const code = typeof err.code === 'string' ? err.code : ''; - if ( - code === 'USER_REJECTED' || - code === 'WALLET_NOT_CONNECTED' || - code === 'INVALID_PARAMS' - ) { + if (code === 'USER_REJECTED' || code === 'WALLET_NOT_CONNECTED' || code === 'INVALID_PARAMS') { return true; } diff --git a/src/utils/__tests__/billingCycleAccuracy.test.ts b/src/utils/__tests__/billingCycleAccuracy.test.ts index 2f20dc49..e646dc64 100644 --- a/src/utils/__tests__/billingCycleAccuracy.test.ts +++ b/src/utils/__tests__/billingCycleAccuracy.test.ts @@ -260,4 +260,4 @@ describe('invoice money arithmetic', () => { it('keeps the invoice status contract unchanged', () => { expect(InvoiceStatus.DRAFT).toBe('draft'); }); -}); \ No newline at end of file +}); diff --git a/src/utils/__tests__/billingCycleMath.test.ts b/src/utils/__tests__/billingCycleMath.test.ts index 2d23791c..d246f704 100644 --- a/src/utils/__tests__/billingCycleMath.test.ts +++ b/src/utils/__tests__/billingCycleMath.test.ts @@ -100,4 +100,4 @@ describe('isAnnualCycle', () => { expect(isAnnualCycle(BillingCycle.WEEKLY)).toBe(false); expect(isAnnualCycle(undefined)).toBe(false); }); -}); \ No newline at end of file +}); diff --git a/src/utils/__tests__/decimalAmount.test.ts b/src/utils/__tests__/decimalAmount.test.ts index b640c29c..d952eec3 100644 --- a/src/utils/__tests__/decimalAmount.test.ts +++ b/src/utils/__tests__/decimalAmount.test.ts @@ -5,11 +5,7 @@ * @see https://github.com/Smartdevs17/SubTrackr/issues/1683 */ -import { - compareDecimalAmounts, - exceedsLimit, - normalizeDecimal, -} from '../decimalAmount'; +import { compareDecimalAmounts, exceedsLimit, normalizeDecimal } from '../decimalAmount'; describe('normalizeDecimal', () => { it('splits a decimal string into sign, digits and scale', () => { @@ -107,4 +103,4 @@ describe('exceedsLimit', () => { expect(exceedsLimit('0.2', '0.1')).toBe(true); expect(exceedsLimit('0.1', '0.1')).toBe(false); }); -}); \ No newline at end of file +}); diff --git a/src/utils/__tests__/invoiceTotals.test.ts b/src/utils/__tests__/invoiceTotals.test.ts index e7ff924f..a0446017 100644 --- a/src/utils/__tests__/invoiceTotals.test.ts +++ b/src/utils/__tests__/invoiceTotals.test.ts @@ -5,11 +5,7 @@ * @see https://github.com/Smartdevs17/SubTrackr/issues/1684 */ -import { - calculateInvoiceTotals, - resolveLineTotal, - roundMoney, -} from '../invoiceTotals'; +import { calculateInvoiceTotals, resolveLineTotal, roundMoney } from '../invoiceTotals'; describe('roundMoney', () => { it('rounds half away from zero at two decimal places', () => { @@ -137,4 +133,4 @@ describe('calculateInvoiceTotals', () => { expect(updated).toEqual(created); expect(updated.total).toBe(78); }); -}); \ No newline at end of file +}); diff --git a/src/utils/billingCycleMath.ts b/src/utils/billingCycleMath.ts index 887d8866..4de5c1f9 100644 --- a/src/utils/billingCycleMath.ts +++ b/src/utils/billingCycleMath.ts @@ -95,4 +95,4 @@ export function yearlyEquivalent(price: number, cycle: unknown): number { export function isAnnualCycle(cycle: unknown): boolean { const normalized = normalizeBillingCycle(cycle); return normalized === BillingCycle.YEARLY || normalized === BillingCycle.ANNUAL; -} \ No newline at end of file +} diff --git a/src/utils/decimalAmount.ts b/src/utils/decimalAmount.ts index a06a3938..fd57d2e8 100644 --- a/src/utils/decimalAmount.ts +++ b/src/utils/decimalAmount.ts @@ -102,4 +102,4 @@ export function exceedsLimit( limit: string | number | null | undefined ): boolean { return compareDecimalAmounts(amount, limit) > 0; -} \ No newline at end of file +} diff --git a/src/utils/invoiceTotals.ts b/src/utils/invoiceTotals.ts index 2b3ca5f5..b9417500 100644 --- a/src/utils/invoiceTotals.ts +++ b/src/utils/invoiceTotals.ts @@ -34,8 +34,7 @@ export interface LineTotalSource { * and flat (`amount`) shapes. */ export function resolveLineTotal(item: LineTotalSource): number { - const derived = - item.quantity !== undefined ? (item.unitPrice ?? 0) * item.quantity : undefined; + const derived = item.quantity !== undefined ? (item.unitPrice ?? 0) * item.quantity : undefined; const candidates = [item.lineTotal, item.amount, derived]; for (const candidate of candidates) { @@ -89,4 +88,4 @@ export function calculateInvoiceTotals(input: InvoiceTotalsInput): InvoiceTotals const total = roundMoney(Math.max(subtotal + tax - discount, 0), decimals); return { subtotal, tax, discount, total }; -} \ No newline at end of file +}