feat(auth): derive anonymous backend auth keypair (#864)#920
feat(auth): derive anonymous backend auth keypair (#864)#920piyalbasu wants to merge 12 commits into
Conversation
|
iOS Simulator preview build is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-ebeba4f380a074ccc5f7 (SDF collaborators only — install instructions in the release description) |
|
Extension parity: this is the mobile port of stellar/freighter#2876 — derive backend auth keypair from seed (issue stellar/freighter#2769). Same derivation ( |
There was a problem hiding this comment.
Pull request overview
This PR introduces a deterministic, per-user “anonymous backend auth” Ed25519 keypair derived from the wallet mnemonic, intended to authenticate future requests to freighter-backend-v2 without triggering wallet-signing prompts and with cross-platform parity to the Freighter extension.
Changes:
- Added pure synchronous derivation utilities (
HMAC-SHA256(BIP39-seed, "freighter-auth-v1") → Ed25519 keypair) plus shared test vectors. - Added
getAuthKeypair()with an in-memory (non-persisted) cache and explicit cache clearing hooks on logout/lock/import paths. - Added Jest coverage for derivation vectors, determinism, auth-status gating, and cache behavior; added
bip39dependency.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
package.json |
Adds bip39 dependency for mnemonic → seed derivation. |
yarn.lock |
Locks bip39@3.1.0. |
src/services/auth/deriveAuthKeypair.ts |
Implements auth-seed + auth-keypair derivation and hex userId formatting. |
src/services/auth/authKeypairVectors.ts |
Adds cross-platform mnemonic → seed/userId vectors for parity checks. |
src/services/auth/getAuthKeypair.ts |
Adds authenticated-session-only accessor with module-scoped in-memory caching. |
src/ducks/auth.ts |
Clears auth-keypair cache on logout/lock/wipe and exports getActiveMnemonicPhrase(). |
__tests__/services/auth/deriveAuthKeypair.test.ts |
Tests vectors, determinism, formatting, mnemonic validation, and wallet-key independence. |
__tests__/services/auth/getAuthKeypair.test.ts |
Tests auth-status gating, memoization, and cache invalidation behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 342d60a3f7
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…tors (#864) Implements HMAC-SHA256(key=bip39-64-byte-seed, msg="freighter-auth-v1") -> Keypair.fromRawEd25519Seed -> lowercase-hex userId, matching the extension's committed vectors exactly. Adds bip39@3.1.0 as a direct dep (exact pin). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…864) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…expired (#864) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
342d60a to
9f4ec75
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9f4ec75f6e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…live session validity, clear cache on soft-lock (#920) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4d8e481011
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…getActiveMnemonicPhrase (#920)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 994bb73589
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
|
||
| import { Keypair } from "@stellar/stellar-sdk"; | ||
| import { mnemonicToSeedSync, validateMnemonic } from "bip39"; | ||
| import { createHmac } from "crypto"; |
There was a problem hiding this comment.
The crypto import resolves to react-native-crypto only via package.json's browser/react-native field maps, which metro honors but Jest doesn't — jest.config.js has no moduleNameMapper entry for crypto, so under Jest this is Node's built-in crypto. The cross-platform vectors therefore validate Node's createHmac, not the RN runtime path: if react-native-crypto ever diverges on device (e.g. Buffer-key handling under buffer@4.9.2), users derive wrong userIds/keypairs while CI stays green. The line-1 comment ("works in jest & RN") overstates what the tests prove. Suggest a Jest mapping of ^crypto$ → react-native-crypto, or a device-side/E2E assertion of one vector.
There was a problem hiding this comment.
Corrected the overstated comment in 02693b1 — it now states plainly that crypto resolves to Node's built-in under Jest (no moduleNameMapper override) and to react-native-crypto only on device, so the vectors pin the HMAC-SHA256 algorithm, not the RN code path.
I did not add the ^crypto$→react-native-crypto Jest mapping: it's a global remap that reroutes every crypto consumer in the suite (SDK internals included), and react-native-crypto pulls in the react-native-randombytes native module, which won't load under Jest. The honest coverage for the on-device library is a device-side vector assertion, which belongs in #865's gated E2E — I'll add it there. Leaving this open until that lands; say the word if you'd rather gate it differently.
There was a problem hiding this comment.
Follow-up on this one: I over-credited the E2E earlier. On closer look the gated authJwt.integration E2E also runs under Node crypto (jest environment), so it doesn't exercise react-native-crypto either — the on-device derivation/signing path is genuinely unasserted, exactly as you flagged here.
Filed #931 to track adding a real device-runtime assertion covering both the #864 derivation vector and #865 JWT signing (the two paths that share this crypto-fidelity gap). The RN-URL half of the fidelity concern was separately fixed in #925 (signed path no longer depends on any environment-specific primitive). Leaving this thread to #931.
|
One eviction gap in the new keypair cache: it's cleared on |
- getAuthKeypair: wrap body in try/catch so the accessor is total — log and
return null on keychain / corrupt-hash / invalid-mnemonic errors instead of
rejecting the Promise<Keypair | null> contract
- getAuthKeypair: memoize the in-flight derivation so the unlock request burst
(balances/prices/history) runs one PBKDF2, not one per caller
- deriveAuth{Seed,Keypair}: use async mnemonicToSeed so the 2048-round PBKDF2
doesn't block the JS thread; document the crypto jest-vs-RN test gap and the
deliberate direct bip39 dependency
- getAuthStatus: evict the auth-keypair cache on HASH_KEY_EXPIRED transitions
(retention, not just use, must end at hard-expiry — matches softLock)
- getActiveMnemonicPhrase: log in the previously-silent catch
- drop the clearAuthKeypairCache re-export (one canonical import path)
- tests: collapse three identical gate tests into one, add concurrency and
expiry-eviction regressions, update for the async API
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed in 02693b1: |
…920) isSessionAuthValid re-implemented a subset of the lock rules (in-memory authStatus + hash-key TTL) and omitted the auto-lock timer, so in the window after foreground but before the getAuthStatus() funnel runs it could report a session valid after auto-lock should have engaged — letting getAuthKeypair hand out the backend signing key. Delegate to the authoritative getAuthStatus() funnel instead, so the check evaluates account existence, persisted LOCKED, hash-key hard-expiry AND the auto-lock timer from one source of truth, removing the drift that caused the gap. Reworked the isSessionAuthValid tests to drive the real funnel via storage mocks and added an auto-lock-timer regression. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…865) isSessionAuthValid delegates to the getAuthStatus() funnel (~5 keychain reads per run), and the JWT interceptor calls it on every backend request. Add an in-flight promise to collapse the near-simultaneous unlock burst (balances + prices + history) into one run, and a short-TTL (1s) value memo for sequential calls. Tradeoff documented inline: a funnel-only transition (auto-lock timer elapse, hash hard-expiry) is reflected at the gate up to the TTL late — bounded and immaterial (auto-lock windows are minutes+), and it cannot leak key material because explicit lock/logout/wipe paths clear the keypair cache and flip in-memory authStatus. Exposes clearSessionAuthValidMemo() for eviction/tests. Also await the now-async deriveAuthKeypair in the gated JWT e2e test (#920 made it async). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(auth): per-request EdDSA JWT builder for mobile (#865) * feat(auth): attach per-request JWT + retry-once-on-401 to freighterBackendV2 (#865) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(auth): register JWT interceptors before apiFactory normalizer so 401 retry fires (#865) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(auth): gated E2E round-trip vs staging whoami (#865) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(auth): migrate v2 POST call sites to string body + query-in-url for correct JWT signing (#865) * test(auth): read whoami { data } envelope; E2E verified green vs staging (#865) * fix(auth): idempotent error normalizer (preserve 401 on retry) + skip retry for anonymous 401s (#865) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(auth): enforce string-only V2 request bodies at compile time so JWT bodyHash always matches (#865) * fix(auth): fold config.params into the signed JWT path; refresh body/path contract doc (#865) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(auth): strip stale Authorization header on locked/anonymous requests (#865) * fix(auth): handle URLSearchParams when folding params into signed path (#865) * perf(auth): short-TTL memo + in-flight dedup for isSessionAuthValid (#865) isSessionAuthValid delegates to the getAuthStatus() funnel (~5 keychain reads per run), and the JWT interceptor calls it on every backend request. Add an in-flight promise to collapse the near-simultaneous unlock burst (balances + prices + history) into one run, and a short-TTL (1s) value memo for sequential calls. Tradeoff documented inline: a funnel-only transition (auto-lock timer elapse, hash hard-expiry) is reflected at the gate up to the TTL late — bounded and immaterial (auto-lock windows are minutes+), and it cannot leak key material because explicit lock/logout/wipe paths clear the keypair cache and flip in-memory authStatus. Exposes clearSessionAuthValidMemo() for eviction/tests. Also await the now-async deriveAuthKeypair in the gated JWT e2e test (#920 made it async). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): address review — central body serialization, clock-skew fallback (#865) Body handling (aristides): serialize object bodies centrally in the request interceptor before hashing, so bodyHash always matches the wire bytes. Fixes the prod empty-bodyHash bug (non-string body silently signed an empty hash, warned only in __DEV__). Removes the string-only contract: drop the createApiService <string> constraint, the backendV2TypeContract test, the dev warning, and the per-call JSON.stringify + redundant Content-Type overrides (createApiService already defaults application/json). Clock skew (aristides/codex): if the signed 401-retry also 401s (likely device clock skew — re-signing from the same bad clock fails identically), fall back to one anonymous request so these endpoints (anonymous pre-PR) keep working, and log it. The fallback rate is the signal for widening the backend clock-skew leeway; tracked as a follow-up. Also: hoist the constant JWT header segment to module scope (compute once); skip null/undefined params in mergeParamsIntoUrl to match axios; collapse duplicated call-shape assertions in backend.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(api): drop the now-inert TWriteBody generic from createApiService (#865) The TWriteBody generic existed only to support the string-only body contract (V2 was createApiService<string>). Central body serialization removed that contract, leaving the generic always defaulting to unknown. Remove it and type post/put bodies as `unknown` directly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(auth): derive signed JWT path from axios getUri, not hand-rolled helpers (#865) Replace mergeParamsIntoUrl + deriveServerPath (and the fold-then-delete of config.params) with instance.getUri(config): axios's own baseURL-join + param serializer. The signed methodAndPath is now pathname+search of exactly what axios will put on the wire, so signed path == wire path by construction — no manual param serialization that can drift from axios (the state-dependent divergence aristides flagged), and config.params is left untouched. Tests: fake instance gains a getUri stand-in; unit param tests assert the getUri-derived path + params-preserved; the real-serialization specifics (&-join, encoding) are covered by the integration tests against real axios. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * revert(auth): drop the anonymous 401 fallback; match the extension's retry-once (#865) The extension's authedFetch retries once on 401 and returns the second 401 (no anonymous fallback). The mobile fallback was a divergence added for clock-skew resilience; on review it breaks parity, masks genuine auth failures behind a permissive-backend success, and only helps during the permissive window. Remove the __authFellBackAnon path (forced-anon request branch + response-side fallback + the logger.warn) and return the second 401 to the caller. Clock-skew handling is deferred entirely to the backend leeway (#930), same as the extension. Retry-once-then-fail is preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): sign the path without RN's non-spec URL; clear memo on unlock (#865) Fable review (device-vs-jest) findings: BLOCKER — deriveServerPath used `new URL(getUri).pathname`. React Native's built-in URL (0.81, Libraries/Blob/URL.js) is not WHATWG-compliant: it appends a trailing "/" to any URL with no query/hash. So on device a query-less GET (e.g. /protocols) signed "/api/v1/protocols/" while the wire target was "/api/v1/protocols" → guaranteed 401 (no fallback). jest uses Node's compliant URL, so the suite was blind to it. Replace with an origin-strip on getUri's output — environment-independent, preserves the wire target verbatim. Added a query-less regression test. - signIn clears the isSessionAuthValid memo on unlock, so the post-unlock request burst isn't served a stale `false` (would go anonymous for ≤1s). - Central body serialization narrowed to plain objects/arrays, so a future FormData/Blob body isn't corrupted to "{}" on the wire. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(api): redact Authorization in the logRequests debug logger (#865) The logRequests request/response debug logger JSON.stringify'd the full config; on an instance carrying per-request JWT auth (via configureInstance) that would log the Bearer token (request interceptors run LIFO, so the token is attached before the logger runs). Mask any Authorization header at any depth via a stringify replacer. Not reachable today (V2 doesn't set logRequests) — closes the secret-leak foot-gun. Added a redaction test through the real chain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(backend): pass v2 network via { params } now that getUri signs them (#865) The two v2 POST call sites embedded the network query directly in the URL string because the pre-getUri interceptor signed the path before axios serialized config.params — so { params } would have signed a query-less path (≠ wire → 401). The getUri switch removed that constraint: the signed path is derived from instance.getUri(config), which folds params in via axios's own serializer. So migrate both to the idiomatic { params: { network } }; wire + signed output are identical. Tests updated to lock the { params } call shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(api): explain the `unknown` body type on post/put (#865) Document why the body is `unknown` (the TWriteBody string-only contract was removed) and that an auth-wired instance serializes object bodies centrally so the signed bodyHash matches the wire. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
TL;DR
Closes #864. Derives an anonymous, per-user auth keypair from the wallet seed so Freighter Mobile can (in a follow-up) authenticate its calls to
freighter-backend-v2. The derived key is cryptographically independent from the wallet signing key, never triggers a wallet-signing prompt, and produces the same user id as the Freighter extension for the same mnemonic (locked by shared cross-platform test vectors). The keypair is memoized in memory for the unlocked session and dropped on every session-ending path — logout, lock, soft-lock, hash-key hard-expiry, and wallet import — never persisted.This is PR 1 of 2. PR 2 (#865) adds the per-request JWT and wires it into the backend-v2 axios client.
Implementation details (for agents)
What changed:
src/services/auth/deriveAuthKeypair.ts(new) — pure, async (bip39'smnemonicToSeedkeeps the 2048-round PBKDF2 off the JS thread).HMAC-SHA256(key = bip39 64-byte seed, msg = "freighter-auth-v1")→Keypair.fromRawEd25519Seed→ lowercase-hexuserId. Usesbip39(direct dep on purpose — extension parity + its async seed/validate APIs) + thecryptoimport (react-native-crypto on device, Node's built-in under jest) +@stellar/stellar-sdkKeypair(signs via@noble/curves, unaffected by thetweetnacljest mock).validateMnemonicfirst; rejects on invalid input.src/services/auth/authKeypairVectors.ts(new) — the samemnemonic → authSeedHex → userIdvectors the extension committed, asserted in tests to guarantee cross-platform parity.src/ducks/auth.ts— exportsgetActiveMnemonicPhrase(): Promise<string | null>(thin wrapper over the existing privategetTemporaryStore, gated on AUTHENTICATED with a re-check after the decrypt await); callsclearAuthKeypairCache()on both logout paths, inclearAllData(), insoftLock(), and on theHASH_KEY_EXPIREDtransitions ingetAuthStatus()— so no session-ending path leaves key material resident.src/services/auth/getAuthKeypair.ts(new) —getAuthKeypair(): Promise<Keypair | null>, total (never rejects: errors are logged and surfaced asnull), with an in-memory, module-scoped cache (never persisted) plus in-flight-promise memoization so the unlock request burst runs one derivation;clearAuthKeypairCache(): void.selectAccountintentionally does not clear — sub-accounts share one mnemonic and derivation is mnemonic-keyed, so the cached keypair stays correct across sub-account switches; only session-ending paths (logout/lock/soft-lock/hard-expiry/import) clear it.package.json— addsbip39pinned exact (3.1.0).Why this mirrors the extension: HMAC-SHA256 and Ed25519 (RFC 8032) are standardized, so the RN crypto libraries produce byte-identical output for the same mnemonic. The shared vectors pin that output under jest (Node's
crypto); the on-device react-native-crypto path is asserted by PR #865's gated E2E.Verification (post-review):
deriveAuthKeypair7/7,getAuthKeypair7/7,ducks/auth137/137;tsc --noEmitclean;madgeno circular deps; full pre-commit suite green on commit. Reviewed task-by-task (spec + quality) including the import-cycle, cache-eviction, and async-derivation paths.Follow-ups / out of scope: PR 2 (#865) —
buildAuthJwt+ axios interceptor + gated E2E. Contacts UI and the backend permissive→required flip are separate.