feat(auth): derive backend auth keypair from seed (#2769)#2876
Conversation
|
PR Preview build is ready: https://github.com/stellar/freighter/releases/tag/untagged-edd362fb6bcffca39753 (SDF collaborators only — install instructions in the release description) |
Design doc for the extension-side derivation primitive: HMAC-SHA256(seedBytes, "freighter-auth-v1") -> Ed25519 keypair, hex pubkey = anonymous backend user ID. Covers scope, threat model, crypto choices (crypto.subtle + stellar-sdk, zero new deps), exact algorithm, session-timeout lifecycle, verified cross-platform test vectors, and a reworded acceptance #2 (cryptographic independence, not "invalid G addr"). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…d/api (#2769) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
#2769) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
6245cdd to
b6e0804
Compare
There was a problem hiding this comment.
Pull request overview
Adds an extension-side crypto primitive (in @shared/api) to deterministically derive an anonymous backend authentication Ed25519 keypair from a wallet recovery phrase, establishing the cross-platform contract (via committed vectors) without any backend calls or UI changes.
Changes:
- Introduces
deriveAuthSeed()(HMAC-SHA256 keyed by the 64-byte BIP39 seed) andderiveAuthKeypair()(Ed25519 keypair + lowercase-hexuserId). - Commits cross-platform derivation vectors (
mnemonic → authSeedHex → userId) to lock down parity with mobile. - Adds Jest coverage asserting vector parity, determinism, format constraints, independence from the wallet key, and invalid-mnemonic rejection.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
yarn.lock |
Locks the new direct bip39@3.1.0 dependency in the workspace resolution. |
@shared/api/package.json |
Promotes bip39@3.1.0 to an explicit dependency for deterministic seed derivation. |
@shared/api/helpers/deriveAuthKeypair.ts |
Implements the HMAC-based auth seed derivation and Ed25519 keypair/userId derivation. |
@shared/api/helpers/authKeypairVectors.ts |
Adds committed cross-platform test vectors to enforce extension/mobile parity. |
@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts |
Adds tests for vector parity, determinism, formatting, independence, and invalid input handling. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
JakeUrban
left a comment
There was a problem hiding this comment.
Looks good but left some suggestions you can take or leave
| @@ -0,0 +1,28 @@ | |||
| // Canonical cross-platform auth-keypair derivation vectors. | |||
There was a problem hiding this comment.
Can we indicate in this file that its used only for tests?
| * Implementation note: stellar-hd-wallet@1.0.2's fromMnemonic() is an ESM | ||
| * module whose internal bip39 default-import hits a Jest CJS interop edge case | ||
| * (bip39 sets __esModule:true without a .default export). We call bip39 named |
There was a problem hiding this comment.
Can we file an issue on bip39 for this?
| /** | ||
| * Derives the Freighter backend auth keypair from the wallet mnemonic. | ||
| * Pure crypto: no logging, no keyManager, no messaging, no persistence. The | ||
| * caller supplies the mnemonic (requires an unlocked session) and handles the | ||
| * locked-session case. | ||
| * | ||
| * @returns userId lowercase hex Ed25519 public key (64 chars) — the anonymous | ||
| * backend user ID and the JWT `sub`. | ||
| * @returns keypair stellar-sdk Keypair; the JWT ticket signs with keypair.sign(). | ||
| */ | ||
| export const deriveAuthKeypair = async ( | ||
| mnemonic: string, | ||
| ): Promise<{ userId: string; keypair: Keypair }> => { | ||
| const authSeed = await deriveAuthSeed(mnemonic); | ||
| const keypair = Keypair.fromRawEd25519Seed(Buffer.from(authSeed)); | ||
| const userId = keypair.rawPublicKey().toString("hex"); | ||
| return { userId, keypair }; | ||
| }; |
There was a problem hiding this comment.
nit: if keypair is only returned for testing purposes, you could mark this function as @internal, create another function deriveUserId(mnemonic: string): string that calls this function, and move the userId derivation line into the new function. That way, real users of this functionality don't need to concern themselves with the underlying keypair.
There was a problem hiding this comment.
Never mind, I realized that keypair is necessary to sign JWTs, proving possession of the userId's associated private key.
* feat(auth): per-request EdDSA JWT builder in @shared/api (#2770) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(auth): authedFetch wrapper with retry-once-on-401 (#2770) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(auth): cover authedFetch Content-Type default + override (#2770) * feat(auth): runnable E2E script for backend JWT round-trip (#2770) * fix(auth): E2E script records per-case failures instead of aborting (#2770) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(auth): uppercase signed method, normalize baseUrl join, pin tsx in script (#2770) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(auth): narrow JWT body to string, drop unused authedFetch headers override (#2770) * test(auth): replace E2E script with gated Playwright integration test (#2770) * test(auth): add gated Playwright auth e2e test (dropped from the replace-script commit) (#2770) * fix(auth): upper-case authedFetch wire method to match signed methodAndPath buildAuthJwt bakes method.toUpperCase() into the methodAndPath claim, but authedFetch sent the raw-case method on the wire. fetch only auto-uppercases the standard verbs (GET/POST/...), not PATCH or custom methods — so a lower-case non-standard method would leave the server's r.Method mismatching the signed claim and yield a silent 401. Normalize the method once and use it for both the JWT and the request. Adds a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): sign the full request target in authedFetch, not the bare path The backend verifies methodAndPath against r.URL.RequestURI() (the full path+query). authedFetch signed the caller's `path` fragment alone, but the backend base URL (INDEXER_V2_URL) carries an "/api/v1" prefix and helpers append the endpoint suffix — so base "<host>/api/v1" + path "/contacts" fetched "/api/v1/contacts" while signing "/contacts", a guaranteed 401 once wired into the real path. Derive the signed target from the final URL's pathname+search so it always matches the wire request regardless of where the prefix lives. Adds prefix + query-string regression tests. Addresses Codex review (P2) on PR #2877. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
Address review feedback on deriveAuthSeed: - Pin validateMnemonic to wordlists.english instead of relying on bip39's implicit require-order default, matching the rest of the wallet's mnemonic paths (StellarHDWallet.validateMnemonic(m, "english")). - Drop the redundant Buffer.from() around mnemonicToSeedSync, which already returns a Buffer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…point (#2879) (#2880) * docs(auth): spec for deriving backend auth keypair from seed (#2769) Design doc for the extension-side derivation primitive: HMAC-SHA256(seedBytes, "freighter-auth-v1") -> Ed25519 keypair, hex pubkey = anonymous backend user ID. Covers scope, threat model, crypto choices (crypto.subtle + stellar-sdk, zero new deps), exact algorithm, session-timeout lifecycle, verified cross-platform test vectors, and a reworded acceptance #2 (cryptographic independence, not "invalid G addr"). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(auth): HMAC auth-seed derivation + cross-platform vectors (#2769) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(auth): declare bip39 dep, drop unused stellar-hd-wallet in @shared/api (#2769) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(auth): align spec/plan with bip39-direct derivation (jest interop) (#2769) * docs(auth): align plan Task 1 with bip39-direct implementation (#2769) * feat(auth): derive Ed25519 auth keypair + userId from seed (#2769) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(auth): mark deriveAuthSeed @internal; clarify purity test comment (#2769) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(auth): move authKeypairVectors fixture out of __tests__ (CI test collection) (#2769) * chore(auth): keep PR code-only — spec moved to wallet-eng-monorepo, plan untracked (#2769) * fix(auth): correct fixture import path after moving it out of __tests__ (#2769) * docs(auth): note authKeypairVectors is a test fixture (PR #2876 review) * feat(auth): per-request EdDSA JWT builder in @shared/api (#2770) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(auth): authedFetch wrapper with retry-once-on-401 (#2770) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(auth): cover authedFetch Content-Type default + override (#2770) * feat(auth): runnable E2E script for backend JWT round-trip (#2770) * fix(auth): E2E script records per-case failures instead of aborting (#2770) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(auth): uppercase signed method, normalize baseUrl join, pin tsx in script (#2770) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(auth): narrow JWT body to string, drop unused authedFetch headers override (#2770) * test(auth): replace E2E script with gated Playwright integration test (#2770) * test(auth): add gated Playwright auth e2e test (dropped from the replace-script commit) (#2770) * fix(auth): upper-case authedFetch wire method to match signed methodAndPath buildAuthJwt bakes method.toUpperCase() into the methodAndPath claim, but authedFetch sent the raw-case method on the wire. fetch only auto-uppercases the standard verbs (GET/POST/...), not PATCH or custom methods — so a lower-case non-standard method would leave the server's r.Method mismatching the signed claim and yield a silent 401. Normalize the method once and use it for both the JWT and the request. Adds a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): sign the full request target in authedFetch, not the bare path The backend verifies methodAndPath against r.URL.RequestURI() (the full path+query). authedFetch signed the caller's `path` fragment alone, but the backend base URL (INDEXER_V2_URL) carries an "/api/v1" prefix and helpers append the endpoint suffix — so base "<host>/api/v1" + path "/contacts" fetched "/api/v1/contacts" while signing "/contacts", a guaranteed 401 once wired into the real path. Derive the signed target from the final URL's pathname+search so it always matches the wire request regardless of where the prefix lives. Adds prefix + query-string regression tests. Addresses Codex review (P2) on PR #2877. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(auth): callBackendV2 chokepoint for backend-v2 requests (#2879) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(auth): cover callBackendV2 authed-POST headers + signed query path (#2879) * feat(auth): route getDiscoverData through callBackendV2 via FETCH_BACKEND_V2 (#2879) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(auth): gate FETCH_BACKEND_V2 behind isFromExtensionPage (#2879) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(auth): route getLedgerKeyAccounts through callBackendV2 (#2879) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(auth): add type param to sendMessageToBackground in getLedgerKeyAccounts Resolves TS2339 errors from the webpack pre-commit hook — the generic defaults to the web Response type; pass { status: number; body: unknown } so TypeScript resolves the destructure correctly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(auth): cover getLedgerKeyAccounts non-200 path + richer error log (#2879) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(auth): route collectibles fetch through FETCH_BACKEND_V2 message (#2879) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(auth): route rpc-health through callBackendV2 (#2879) Thread sessionStore into getIsRpcHealthy / loadBackendSettings and swap the direct fetch for callBackendV2 so the JWT chokepoint covers /rpc-health. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(auth): break circular import exposing sessionSlice; fix account.test types (#2879) Remove the top-level `import { callBackendV2 }` from account.ts and replace it with a deferred dynamic import() inside getIsRpcHealthy. This breaks the module-eval-time cycle: ducks/session → account → callBackendV2 → session.ts → store → ducks/session, which caused sessionSlice to resolve as undefined in ~11 background test suites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): route v2 getTokenPrices through the chokepoint (#2879) getTokenPrices' v2 branch still did a raw fetch to ${INDEXER_V2_URL}/token-prices (added to master by #2870 after this branch diverged), while this branch had already dropped the INDEXER_V2_URL import from internal.ts — breaking the build (TS2552: Cannot find name 'INDEXER_V2_URL') and leaving a backend-v2 call site outside the chokepoint, contrary to the ticket's acceptance criteria. Route the v2 path through the FETCH_BACKEND_V2 message (query-in-path so the signed JWT's methodAndPath matches the server's request-target), matching the other migrated call sites. The v1 (useV2=false) path stays a direct fetch to the legacy indexer. Contract parity with getDiscoverData: treat a 200 without a `data` payload as a failure (throw) rather than returning undefined, and keep the response body in the Sentry message. Rewrite the v2 tests to assert on the background message instead of a fetch spy, and add coverage for the return value and the error path (non-200 and 200-without-data both throw). INDEXER_V2_URL is now referenced only by the chokepoint (callBackendV2). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(auth): stub backend-v2 message calls in AssetDetail test (#2879) AssetDetail.test.tsx rendered an issued asset, whose domain gate awaits getAssetDomains → getLedgerKeyAccounts. That call was migrated to the FETCH_BACKEND_V2 background message earlier in this branch, and the test env has no listener — so the message never resolves *or rejects* and the view is stuck on <Loading /> (a plain fetch failure used to reject and clear the gate). These two failures were latent on the branch, masked by the TS2552 compile error that killed the test job before it ran; fixing the compile error surfaced them. Stub getAssetDomains (and getTokenPrices, same message-hang class) to resolve empty, mirroring Send.test.tsx. Full suite: 137 suites / 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(auth): intercept collectibles e2e fetch on context, not page (#2879) fetchCollectibles was migrated to the FETCH_BACKEND_V2 background message earlier in this branch, so the /collectibles request is now made by the MV3 service worker. The collectibles e2e specs mocked it with page.route, which only intercepts the popup page — so the mock never fired, no collectibles rendered, and the tests timed out (addCollectible failed all 5 CI retries). Switch the /collectibles interception to context.route (intercepts the SW), matching how token-prices and account-history are already mocked. Fixed in the shared stubCollectibles / stubCollectiblesUnsuccessfulMetadata helpers (now take a context param) plus the inline overrides in addCollectible/hideCollectible. tokenMetadata stays on page.route — it's a genuine popup fetch. Like the AssetDetail unit failure, these were latent on the branch, masked by the TS2552 compile error that killed the test job before e2e ran. Verified locally: 19/19 collectible-tagged e2e tests pass (addCollectible, hideCollectible, loadAccount, sendCollectible, reviewTxFees, sendPayment). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(auth): route protocols/token-prices e2e stubs on the SW context (#2879) Same background-service-worker mocking gap as the collectibles fix, for the other endpoints migrated to the FETCH_BACKEND_V2 chokepoint: - /protocols (getDiscoverData): stubDiscoverProtocols / stubDiscoverProtocolsError and discover.test.ts's unroute now use page.context().route/unroute so the SW request is intercepted. Fixes the 3 discover CI failures. - /token-prices (getTokenPrices, migrated in this PR): stubTokenPrices normalizes its Page|BrowserContext arg to the context before routing, and loadAccount's batching test counts calls via context.on("request") instead of page.on — the request is now issued by the service worker, not the popup. Verified locally: discover.test.ts 7/7, loadAccount token-prices batching test, and the full collectibles set all pass. (Unrelated: the freighterApiIntegration signing tests fail only against a real backend URL locally; they pass in CI and don't touch these endpoints.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(auth): skip JWT for rpc-health, it's never auth-gated (#2879) rpc-health went through callBackendV2's authed path, so an unlocked wallet derived the auth keypair (PBKDF2) and signed a JWT on every health check. That endpoint will never be auth-gated, so the token is pointless work. Add a skipAuth option to callBackendV2 that bypasses keypair derivation and always sends an anonymous fetch, and set it on the rpc-health call. Keeps rpc-health flowing through the single chokepoint (per the ticket) while avoiding the JWT and its PBKDF2 cost. Test proves skipAuth never reads the session or derives a keypair even when unlocked. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(auth): correct getTokenPrices comment; fast-fail skipAuth test (#2879) - getTokenPrices: the v2 comment said the chokepoint "sends anonymously otherwise". token-prices is only ever fetched from an unlocked wallet (a locked wallet shows the login screen), so it always carries the JWT — never anonymous. Reword to match. - callBackendV2 skipAuth test: give the deriveAuthKeypair spy a mock impl so a regression fails fast instead of running real PBKDF2 before the assertion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): address backend-v2 chokepoint review (#2879) Resolves review feedback on #2880: - popupMessageListener: DEV_SERVER carve-out on the FETCH_BACKEND_V2 gate. Under the webpack dev server the popup relays through the content script, so isFromExtensionPage is false and every v2 call (Discover, prices, collectibles, ledger-key import) returned Unauthorized in local dev. Gate stays intact in production (DEV_SERVER=false). - callBackendV2: parse the response body on non-2xx too, so a server error payload reaches Sentry instead of being flattened to null. - callBackendV2: captureException on unexpected key-derivation failures (corrupted temporaryStoreExtra / WebCrypto error) instead of silently downgrading to anonymous with no telemetry; corrected the catch comment. - Extract a shared fetchBackendV2() popup helper owning the FETCH_BACKEND_V2 message shape + typed result (incl. the { error } reply, normalized to a 401), replacing the ~10-line block copy-pasted across getDiscoverData, getTokenPrices, fetchCollectibles, and getLedgerKeyAccounts. PBKDF2-per-request (deriveAuthKeypair on the SW thread) is deferred to #2897 as a deliberate follow-up: derivation is deterministic, so a session-scoped in-memory memo would be equally secure — kept out of this routing PR to avoid a rushed keypair-lifecycle change. New tests: JSON error-body preserved, capture-on-derive-failure, and fetchBackendV2 error-reply normalization. Full suite 138 suites / 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): address Copilot review on v2 fetch helpers - fetchBackendV2: only map the sender-rejection error ("Unauthorized") to 401; any other { error } reply (e.g. "Message type not supported") maps to 500 so callers don't misread an unrelated failure as an auth problem. Guard a falsy/undefined message-channel response into a defined 500 result instead of returning it and breaking { status, body } destructuring. - callBackendV2: capture the original exception (preserving the stack) with the context in `extra`, rather than JSON.stringify(e) — stringifying an Error yields "{}" and JSON.stringify can throw on non-serializable values. - Tests: cover non-auth error → 500, missing response → 500, and assert the original Error + extra context reach captureException. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): back crypto.subtle byte inputs with ArrayBuffer TS 6.0 types Uint8Array/Buffer as generic over ArrayBufferLike, which is not assignable to crypto.subtle's BufferSource (ArrayBufferView<ArrayBuffer>). Copy the digest input and the bip39 seed into ArrayBuffer-backed Uint8Arrays, matching the existing session.ts idiom. Fixes the production build (TS2345/TS2769). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TL;DR
Adds the extension-side primitive that turns a wallet's recovery phrase into the user's anonymous backend identity — the first piece of Cross-Platform Contact Sync (#2769). It derives a dedicated auth key from the seed that is cryptographically independent from the user's Stellar wallet key, so the backend can recognize a returning user without ever learning their wallet address and without triggering any signing prompt.
This PR is derivation only — it deliberately does not generate request tokens, call any backend, or touch the contacts UI. Those are follow-up tickets. Nothing in the product changes yet; this is a self-contained, fully-tested building block.
The same recovery phrase must produce the exact same identity on the extension and on mobile, so this ships committed cross-platform test vectors that the mobile app will be held to as well.
Implementation details (for agents/reviewers)
What changed (all new, under
@shared/api/helpers/):deriveAuthKeypair.ts— the primitive:deriveAuthSeed(mnemonic):HMAC-SHA256(viacrypto.subtle) with key = the 64-byte BIP39 seed (bip39.mnemonicToSeedSync(mnemonic)) and message =utf8("freighter-auth-v1"), returning 32 bytes. Marked@internal(returns private-key material; exported only for test assertions).deriveAuthKeypair(mnemonic): feeds that 32-byte seed toKeypair.fromRawEd25519Seed;userId = keypair.rawPublicKey().toString("hex")(lowercase hex — matches the backend's canonicalsub). Pure: no logging, no keyManager, no messaging, no persistence.authKeypairVectors.ts— committed cross-platform vectors (mnemonic → authSeedHex → userId). Kept outside__tests__/on purpose: Jest's defaulttestMatchcollects every file under__tests__/as a suite and fails a fixture with "must contain at least one test." The intermediateauthSeedHexis included so a failing mobile test localizes the divergence (HMAC step vs Ed25519 step). freighter-mobile must mirror these.__tests__/deriveAuthKeypair.test.ts— 10 tests mapped to acceptance criteria: vector parity (Run eslint during build, minor adjustments #1), determinism, lowercase-64-hex format, independence from the wallet key (router fix #2), no messaging side-effects (Piyal dev #3), invalid-mnemonic rejection.@shared/api/package.json/yarn.lock— declaresbip39@3.1.0(exact pin; promoted from a transitive dep). No new library is introduced to the project.Notable decision —
bip39directly instead ofstellar-hd-wallet:stellar-hd-walletcannot run under jest/jsdom (its compiled bip39 import throwsCannot read properties of undefined (reading 'wordlists'); verified that adding it to the transform allowlist does not fix it).bip39.mnemonicToSeedSyncis byte-identical to whatstellar-hd-walletwraps, so cross-platform parity is unaffected.Acceptance #2 wording correction: the ticket says "auth pubkey is not a valid Stellar G address," which is technically false (any 32 bytes StrKey-encode to a format-valid
G…). The true, tested property is cryptographic independence from the wallet keypair. Ticket text should be updated.Verification:
yarn jest @shared/api(full collection) → 8 suites / 63 tests pass underjest-fixed-jsdom(real WebCrypto);tsc -p @shared/api/tsconfig.jsonclean. Test vectors were independently regenerated from the algorithm and matched.Known minor follow-ups (non-blocking, not yet applied):
authKeypairVectors.tsheader comment to state HMAC arg order explicitly (key = seedBytes, message = salt) for mobile implementers.userId(today it only asserts inequality with the wallet key; correctness is already covered by the vector test).Buffer.from(...)wraps and hoistAUTH_SALTbytes to a module constant (micro-cleanups).freighter-mobilemirrors the algorithm + vectors.The two
translation.jsonkeys in the diff ("Auto-lock timer" en/pt) are auto-generated by the repo's huskyi18next-scannerpre-commit hook filling a pre-existing gap onmaster; unrelated to this feature.