Skip to content

feat(auth): derive backend auth keypair from seed (#2769)#2876

Merged
piyalbasu merged 14 commits into
masterfrom
feat/2769-derive-auth-keypair
Jul 14, 2026
Merged

feat(auth): derive backend auth keypair from seed (#2769)#2876
piyalbasu merged 14 commits into
masterfrom
feat/2769-derive-auth-keypair

Conversation

@piyalbasu

@piyalbasu piyalbasu commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

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.

Design doc lives in wallet-eng-monorepo (design-docs/contact-lists/Freighter Auth Keypair Derivation Design Doc.md), alongside the Contact Sync design doc — not in this repo.

Draft: opening for early review of the crypto contract and test vectors before the dependent token-generation work builds on it.

Implementation details (for agents/reviewers)

What changed (all new, under @shared/api/helpers/):

  • deriveAuthKeypair.ts — the primitive:
    • deriveAuthSeed(mnemonic): HMAC-SHA256 (via crypto.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 to Keypair.fromRawEd25519Seed; userId = keypair.rawPublicKey().toString("hex") (lowercase hex — matches the backend's canonical sub). 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 default testMatch collects every file under __tests__/ as a suite and fails a fixture with "must contain at least one test." The intermediate authSeedHex is 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 — declares bip39@3.1.0 (exact pin; promoted from a transitive dep). No new library is introduced to the project.

Notable decision — bip39 directly instead of stellar-hd-wallet: stellar-hd-wallet cannot run under jest/jsdom (its compiled bip39 import throws Cannot read properties of undefined (reading 'wordlists'); verified that adding it to the transform allowlist does not fix it). bip39.mnemonicToSeedSync is byte-identical to what stellar-hd-wallet wraps, 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 under jest-fixed-jsdom (real WebCrypto); tsc -p @shared/api/tsconfig.json clean. Test vectors were independently regenerated from the algorithm and matched.

Known minor follow-ups (non-blocking, not yet applied):

  • Clarify the authKeypairVectors.ts header comment to state HMAC arg order explicitly (key = seedBytes, message = salt) for mobile implementers.
  • Strengthen the independence test to also assert the expected userId (today it only asserts inequality with the wallet key; correctness is already covered by the vector test).
  • Drop the redundant Buffer.from(...) wraps and hoist AUTH_SALT bytes to a module constant (micro-cleanups).
  • Sibling ticket: freighter-mobile mirrors the algorithm + vectors.
  • Update ticket [Extension] Derive auth keypair from seed for backend authentication #2769 acceptance router fix #2 wording.

The two translation.json keys in the diff ("Auto-lock timer" en/pt) are auto-generated by the repo's husky i18next-scanner pre-commit hook filling a pre-existing gap on master; unrelated to this feature.

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

PR Preview build is ready: https://github.com/stellar/freighter/releases/tag/untagged-edd362fb6bcffca39753 (SDF collaborators only — install instructions in the release description)

piyalbasu and others added 10 commits June 29, 2026 16:11
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>
@piyalbasu piyalbasu force-pushed the feat/2769-derive-auth-keypair branch from 6245cdd to b6e0804 Compare June 29, 2026 20:28
@piyalbasu piyalbasu marked this pull request as ready for review June 29, 2026 20:34
Copilot AI review requested due to automatic review settings June 29, 2026 20:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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) and deriveAuthKeypair() (Ed25519 keypair + lowercase-hex userId).
  • 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 JakeUrban left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good but left some suggestions you can take or leave

@@ -0,0 +1,28 @@
// Canonical cross-platform auth-keypair derivation vectors.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we indicate in this file that its used only for tests?

Comment on lines +17 to +19
* 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we file an issue on bip39 for this?

Comment on lines +49 to +66
/**
* 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 };
};

@JakeUrban JakeUrban Jun 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Never mind, I realized that keypair is necessary to sign JWTs, proving possession of the userId's associated private key.

Comment thread @shared/api/helpers/deriveAuthKeypair.ts Outdated
Comment thread @shared/api/helpers/deriveAuthKeypair.ts Outdated
* 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>
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

piyalbasu and others added 2 commits July 14, 2026 10:56
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>
@piyalbasu piyalbasu merged commit 5710ed7 into master Jul 14, 2026
11 checks passed
@piyalbasu piyalbasu deleted the feat/2769-derive-auth-keypair branch July 14, 2026 15:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants