From 7e70dc162c7c9ff6f3c2713caae73956fe3c873c Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Thu, 25 Jun 2026 17:00:51 -0400 Subject: [PATCH 01/10] 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) --- extension/specs/AUTH_KEYPAIR_DERIVATION.md | 243 +++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 extension/specs/AUTH_KEYPAIR_DERIVATION.md diff --git a/extension/specs/AUTH_KEYPAIR_DERIVATION.md b/extension/specs/AUTH_KEYPAIR_DERIVATION.md new file mode 100644 index 0000000000..82fbcb5a15 --- /dev/null +++ b/extension/specs/AUTH_KEYPAIR_DERIVATION.md @@ -0,0 +1,243 @@ +# Auth Keypair Derivation Spec + +> Design doc for [stellar/freighter#2769](https://github.com/stellar/freighter/issues/2769) +> — _[Extension] Derive auth keypair from seed for backend authentication._ +> Date: 2026-06-25. Status: approved, ready for implementation. + +## Overview + +`freighter-backend-v2` authenticates clients with a stateless, per-request JWT: +each request carries an `Authorization: Bearer ` whose `sub` claim is the +hex-encoded Ed25519 **auth public key**, and the server verifies the request +signature against that key. The auth public key _is_ the user's anonymous +backend user ID ("Unified User Id") — separate from any Stellar `G…` keypair and +never used for wallet signing. + +This spec covers **one primitive**: deriving that auth keypair (and hex user ID) +from the wallet's mnemonic on the Freighter extension, via +`HMAC-SHA256(seedBytes, "freighter-auth-v1")` → Ed25519 keypair. + +See the canonical [Cross-Platform Contact Sync Design Doc](https://github.com/stellar/wallet-eng-monorepo/blob/main/design-docs/contact-lists/Freighter%20Authenticated%20Contact%20Sync%20Design%20Doc.md) +(Auth Flow + Key properties) for the end-to-end scheme. + +## Scope + +**In scope (this ticket / PR):** + +- A pure derivation primitive: `mnemonic → { userId, keypair }`. +- Its unit tests. +- Committed cross-platform test vectors (so `freighter-mobile` can byte-match). + +**Out of scope (downstream, blocked tickets):** + +- Per-request JWT generation in `@shared/api` (the function consumer). +- Any handler wiring, the contacts feature, or UI. +- Lifecycle/caching of the keypair (decided here as a _requirement on the + consumer_, but not implemented in this PR). + +The function is _shaped_ so the JWT ticket can call it on-demand from the +background, but this PR ships only the primitive + tests + vectors. + +## Backend contract (already implemented, PR open) + +The server is fully stateless and **never computes the HMAC**. It reads `sub` +from each JWT, decodes it as an Ed25519 public key, and verifies the request +signature against it (`internal/auth/parser.go`). Consequences for this primitive: + +- The **only** thing our output must byte-match is `freighter-mobile`, not the + server. +- The server canonicalizes `sub` to **lowercase hex** (`parser.go` re-encodes + the decoded key), so the client must emit the user ID as lowercase hex for the + client-side and server-side IDs to be identical. + +## Threat model + +Both leak threats are treated as first-class: + +1. **Private key / seed exposure → impersonation.** Anyone with the auth private + key (or the seed it derives from) can mint valid JWTs and read/overwrite the + user's encrypted contact blob. This is the severe threat. Mitigation: the + primitive is pure crypto that runs **only in the background** (where the + mnemonic already lives); it never logs key material, never crosses to the + popup/content scripts, and persists nothing new at rest. +2. **Public user ID correlation → privacy.** The user ID is the _public_ key; + leaking it can't forge anything, but it can link a person to their contact + blob. Mostly mitigated outside this ticket (TLS, backend access control). Here + we simply never log the user ID and never persist it in plaintext storage. + +## Crypto decisions + +Both halves reuse primitives **already central to the extension — zero new +dependencies.** + +| Step | Choice | Rationale | +| :-------------- | :----------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| HMAC-SHA256 | `crypto.subtle` | Already the extension's core crypto layer (`extension/src/background/helpers/session.ts` uses it for AES-GCM session encryption + PBKDF2). Native, audited, no new dep. | +| Ed25519 keypair | `stellar-sdk` `Keypair.fromRawEd25519Seed` | Already a core dep, already used for wallet keys. `Keypair.rawPublicKey()` gives the raw 32-byte pubkey (the user ID); `Keypair.sign()` gives a raw 64-byte Ed25519 signature, which the downstream JWT ticket reuses directly. | + +**Correctness is library-independent.** HMAC-SHA256 and Ed25519 derivation/signing +are standardized and deterministic (RFC 8032), so any correct implementation +produces identical bytes. Cross-platform parity is guaranteed by the algorithm +spec + committed test vectors below — _not_ by extension and mobile happening to +use the same library. (This is why we did **not** add `tweetnacl` just to mirror +mobile's library.) + +## The primitive + +**Location:** `@shared/api/helpers/deriveAuthKeypair.ts` +(auth code is destined for `@shared/api` per the JWT ticket; sits beside existing +helpers). Tests: `@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts`. + +**Signature (pure, async):** + +```ts +import { Keypair } from "stellar-sdk"; + +/** + * Derives the Freighter backend auth keypair from the wallet mnemonic. + * Pure crypto: no logging, no keyManager, no messaging, no persistence. + * The caller is responsible for supplying the mnemonic (which requires an + * unlocked session) and for handling the locked-session case. + * + * @returns userId hex-encoded Ed25519 public key (lowercase, 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 }>; +``` + +Taking the **mnemonic** (not a pre-computed `seedBytes`) keeps the entire +must-match chain inside one function, so the cross-platform vector is simply +`mnemonic → userId` with nothing for mobile to get wrong at a boundary. + +**Exact algorithm — this is the cross-platform contract:** + +```ts +// 1. BIP39 seed: 64 bytes, EMPTY passphrase. Both repos pin stellar-hd-wallet@1.0.2, +// whose fromMnemonic() does bip39.mnemonicToSeedSync(mnemonic) internally. +const seedBytes = Buffer.from( + StellarHDWallet.fromMnemonic(mnemonic).seedHex, + "hex", +); // 64 bytes + +// 2. HMAC-SHA256. KEY = seedBytes (the 64-byte seed). MESSAGE = utf8(SALT). +// Order matters — HMAC(key, message). Pinned by the test vectors so it +// can never be silently reversed. +const key = await crypto.subtle.importKey( + "raw", + seedBytes, + { name: "HMAC", hash: "SHA-256" }, + false, // not extractable + ["sign"], +); +const authSeed = new Uint8Array( + await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(AUTH_SALT)), +); // 32 bytes + +// 3. Ed25519 keypair from the 32-byte authSeed. +const keypair = Keypair.fromRawEd25519Seed(Buffer.from(authSeed)); + +// 4. User ID = lowercase hex of the raw 32-byte pubkey (matches backend `sub`). +const userId = keypair.rawPublicKey().toString("hex"); +``` + +**Constant:** `AUTH_SALT = "freighter-auth-v1"` — the versioned +domain-separation string from the design doc, exported as a named constant so +extension and mobile reference the identical literal. The `-v1` suffix reserves a +migration path; deterministic derivation means the auth keypair is permanent for +the life of the seed (rotating it changes the user's identity). + +## Lifecycle & session timeout (requirement on the consumer) + +Investigated to settle whether the keypair must be cached. **Conclusion: +on-demand derivation, no caching.** + +- The auth private key is gated behind the same unlocked state as wallet signing. + In the background, the mnemonic is cached **encrypted** in the session + temporary store (`store-account.ts` writes it under `TEMPORARY_STORE_EXTRA_ID`), + decryptable only with the in-memory `hashKey`. +- On session timeout, the `session-timer` alarm fires → + `clearSession()` clears `hashKey` (`ducks/session.ts`). After that, **nothing** + can decrypt the mnemonic or private key. +- Therefore **caching buys nothing against timeout**: a keypair cached in the + encrypted store is itself undecryptable once `hashKey` is gone; caching only + the public user ID still can't _sign_. Every path that can sign a JWT requires + the unlocked session — which is correct: a JWT authorizes reading/overwriting + the contact blob, so it _should_ require the same unlock as touching the wallet. +- There is **no "timed out but still on an active page" state.** The alarm + broadcasts `SESSION_LOCKED`; `SessionLockListener` (mounted on every UI surface) + immediately dispatches `lockAccount()` and navigates to the unlock screen + (`extension/src/popup/components/SessionLockListener/index.tsx`). + +**Requirements this places on the JWT ticket (not this PR):** + +- Derive on-demand; persist nothing new at rest. +- The background accessor that fetches the mnemonic must treat "no mnemonic / + locked" as a **typed, graceful result** (not a thrown error), so an in-flight + derivation that races the lock folds cleanly into the unlock redirect already + in progress. +- Optional, YAGNI: if per-request derivation ever proves hot (`mnemonicToSeedSync` + runs PBKDF2 ×2048, a few ms), memoize the keypair in **volatile in-memory** + session state cleared on timeout alongside `hashKey` — never at rest. + +## Test plan + +Verified end-to-end with the exact algorithm. Vectors are committed in a fixture +(`authKeypairVectors.ts`) in **both** repos; this doc is the canonical source. + +| Mnemonic | `authSeed` (hex) | `userId` (hex pubkey) | +| :---------------------------------------------------------------------------------------------- | :----------------------------------------------------------------- | :----------------------------------------------------------------- | +| `abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about` | `cf8ef34afb730ffd0807ee8731f2378a4f6c702e2f14915976fac4afa711b52d` | `ec57e5d04783b5ade776621ab171b3b197c3acc0a1cb5bad10786dc8e381e797` | +| `illness spike retreat truth genius clock brain pass fit cave bargain toe` | `882835b30a2c5b011f1b2424a84f4cd39f342f4d12be574e4233dbf9b98976d1` | `bd9498475c7191c5e9a5e18edda2402ab0ae527580a6c38b2a32a77c65729cd7` | + +The intermediate `authSeed` is included so a failing mobile test can localize the +divergence: a wrong `authSeed` means the HMAC step (e.g. key/message reversed); a +right `authSeed` but wrong `userId` means the Ed25519 step. + +**`deriveAuthKeypair.test.ts`:** + +1. **Cross-platform match → acceptance #1.** For each vector, + `(await deriveAuthKeypair(mnemonic)).userId === expectedUserId`, and the + internal `authSeed` equals the fixture (catches a reversed HMAC key/message). +2. **Determinism.** Same mnemonic twice → identical `userId` and pubkey bytes. +3. **`crypto.subtle` reproduces the reference vectors.** Specifically guards the + `importKey`/`sign` call against swapping key vs message — the easiest bug to + introduce, the hardest to eyeball. +4. **Independence from the wallet key → acceptance #2.** Derive wallet account 0 + from the same mnemonic; assert `userId !== walletPubkeyHex`. _Verified:_ auth + `ec57e5d0…` ≠ wallet `7691d850…` for vector 1. +5. **No signing side effects → acceptance #3.** The primitive imports nothing + from the `keyManager`/popup/messaging path; a spy asserts + `browser.runtime.sendMessage` is never called. +6. **Format.** `userId` is lowercase hex, length 64. +7. **Negative.** An invalid mnemonic throws a clear error (fails loudly rather + than producing a garbage key). + +## Acceptance criteria + +Reworded from the ticket (see note on #2): + +1. The same seed produces an identical auth keypair (and user ID) on extension + and mobile — enforced by the shared test vectors. +2. **The auth keypair is cryptographically independent from the wallet keypair + and is never used as a Stellar address.** _(Reworded — see below.)_ +3. No wallet-signing prompt is triggered by derivation or use — the primitive is + pure crypto with no signing-path dependencies. + +> **Ticket correction (#2769).** The original criterion _"Auth pubkey is not a +> valid Stellar G address"_ is technically false: any 32 bytes StrKey-encode into +> a format-valid `G…` address (ours encodes to +> `GDWFPZOQI6B3LLPHOZRBVMLRWOYZPQ5MYCQ4WW5NCB4G3SHDQHTZOVCI`). The meaningful, +> true property is **cryptographic independence from the wallet keypair**. Update +> the ticket text to match this wording. + +## Follow-ups / open items + +- **`freighter-mobile` must mirror the algorithm + vectors.** Track a sibling + ticket so mobile commits the same `authKeypairVectors.ts` and asserts identical + output. This doc is the canonical contract. +- **Update ticket #2769** acceptance #2 wording (above). +- **No new dependencies** are introduced (`crypto.subtle` + `stellar-sdk` are + already present). Confirm in review. From bf0439ab63b9edb57133922e1d598eb59a671cf5 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Thu, 25 Jun 2026 17:36:29 -0400 Subject: [PATCH 02/10] feat(auth): HMAC auth-seed derivation + cross-platform vectors (#2769) Co-Authored-By: Claude Sonnet 4.6 --- .../helpers/__tests__/authKeypairVectors.ts | 28 ++++++++++++ .../__tests__/deriveAuthKeypair.test.ts | 19 ++++++++ @shared/api/helpers/deriveAuthKeypair.ts | 44 +++++++++++++++++++ @shared/api/package.json | 1 + .../src/popup/locales/en/translation.json | 2 + .../src/popup/locales/pt/translation.json | 2 + yarn.lock | 1 + 7 files changed, 97 insertions(+) create mode 100644 @shared/api/helpers/__tests__/authKeypairVectors.ts create mode 100644 @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts create mode 100644 @shared/api/helpers/deriveAuthKeypair.ts diff --git a/@shared/api/helpers/__tests__/authKeypairVectors.ts b/@shared/api/helpers/__tests__/authKeypairVectors.ts new file mode 100644 index 0000000000..a266cd19d5 --- /dev/null +++ b/@shared/api/helpers/__tests__/authKeypairVectors.ts @@ -0,0 +1,28 @@ +// Canonical cross-platform auth-keypair derivation vectors. +// SOURCE OF TRUTH: extension/specs/AUTH_KEYPAIR_DERIVATION.md +// freighter-mobile MUST commit the same values and assert identical output. +// authSeedHex isolates failures: a wrong authSeedHex => HMAC step diverged +// (e.g. key/message reversed); a right authSeedHex but wrong userId => +// Ed25519 step diverged. +export interface AuthKeypairVector { + mnemonic: string; + authSeedHex: string; // hex of HMAC-SHA256(seedBytes, AUTH_SALT), 32 bytes + userId: string; // lowercase hex Ed25519 public key, 64 chars +} + +export const AUTH_KEYPAIR_VECTORS: AuthKeypairVector[] = [ + { + mnemonic: + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + authSeedHex: + "cf8ef34afb730ffd0807ee8731f2378a4f6c702e2f14915976fac4afa711b52d", + userId: "ec57e5d04783b5ade776621ab171b3b197c3acc0a1cb5bad10786dc8e381e797", + }, + { + mnemonic: + "illness spike retreat truth genius clock brain pass fit cave bargain toe", + authSeedHex: + "882835b30a2c5b011f1b2424a84f4cd39f342f4d12be574e4233dbf9b98976d1", + userId: "bd9498475c7191c5e9a5e18edda2402ab0ae527580a6c38b2a32a77c65729cd7", + }, +]; diff --git a/@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts b/@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts new file mode 100644 index 0000000000..9ff1f11287 --- /dev/null +++ b/@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts @@ -0,0 +1,19 @@ +import { Buffer } from "buffer"; + +import { AUTH_SALT, deriveAuthSeed } from "../deriveAuthKeypair"; +import { AUTH_KEYPAIR_VECTORS } from "./authKeypairVectors"; + +describe("deriveAuthSeed (HMAC step)", () => { + it("uses the versioned domain-separation salt", () => { + expect(AUTH_SALT).toBe("freighter-auth-v1"); + }); + + it.each(AUTH_KEYPAIR_VECTORS)( + "reproduces the committed authSeed for %#", + async ({ mnemonic, authSeedHex }) => { + const seed = await deriveAuthSeed(mnemonic); + expect(seed).toHaveLength(32); + expect(Buffer.from(seed).toString("hex")).toBe(authSeedHex); + }, + ); +}); diff --git a/@shared/api/helpers/deriveAuthKeypair.ts b/@shared/api/helpers/deriveAuthKeypair.ts new file mode 100644 index 0000000000..4a3cf5306e --- /dev/null +++ b/@shared/api/helpers/deriveAuthKeypair.ts @@ -0,0 +1,44 @@ +import { Buffer } from "buffer"; +import { mnemonicToSeedSync, validateMnemonic } from "bip39"; + +/** + * Versioned domain-separation salt for the backend auth keypair. The `-v1` + * suffix reserves a migration path; derivation is deterministic, so the auth + * keypair is permanent for the life of the seed. + */ +export const AUTH_SALT = "freighter-auth-v1"; + +/** + * Computes the 32-byte auth seed: HMAC-SHA256 keyed on the wallet's 64-byte + * BIP39 seed, over the salt. Throws "Invalid mnemonic (see bip39)" on a + * malformed mnemonic. + * + * 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 + * exports directly — bip39 is already a transitive dep of stellar-hd-wallet, + * and the derivation is byte-for-byte identical. + */ +export const deriveAuthSeed = async (mnemonic: string): Promise => { + // Validate mnemonic first, matching stellar-hd-wallet's error contract. + if (!validateMnemonic(mnemonic)) { + throw new Error("Invalid mnemonic (see bip39)"); + } + // BIP39 seed, 64 bytes, empty passphrase — identical to what StellarHDWallet + // .fromMnemonic(mnemonic).seedHex produces. + const seedBytes = Buffer.from(mnemonicToSeedSync(mnemonic)); + + const key = await crypto.subtle.importKey( + "raw", + seedBytes, + { name: "HMAC", hash: "SHA-256" }, + false, // not extractable + ["sign"], + ); + const mac = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(AUTH_SALT), + ); + return new Uint8Array(mac); +}; diff --git a/@shared/api/package.json b/@shared/api/package.json index abb8f8423a..d7bff69a7c 100644 --- a/@shared/api/package.json +++ b/@shared/api/package.json @@ -8,6 +8,7 @@ "@stellar/js-xdr": "4.0.0", "bignumber.js": "9.3.0", "prettier": "3.8.4", + "stellar-hd-wallet": "1.0.2", "stellar-sdk": "npm:@stellar/stellar-sdk@16.0.0-rc.1", "stellar-sdk-next": "npm:@stellar/stellar-sdk@16.0.0-rc.1", "typescript": "5.8.3", diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 52339ec4bb..0ddd30cdd0 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -77,6 +77,8 @@ "Authorizations": "Authorizations", "Authorize": "Authorize", "Authorized address": "Authorized address", + "Auto-lock timer": "Auto-lock timer", + "Auto-Lock Timer": "Auto-Lock Timer", "available": "available", "Back": "Back", "Balance": "Balance", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index db2c569c1c..a0cb327877 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -77,6 +77,8 @@ "Authorizations": "Autorizações", "Authorize": "Autorizar", "Authorized address": "Endereço autorizado", + "Auto-lock timer": "Auto-lock timer", + "Auto-Lock Timer": "Auto-Lock Timer", "available": "disponível", "Back": "Voltar", "Balance": "Saldo", diff --git a/yarn.lock b/yarn.lock index 35b6640e3d..d09d6f7205 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5430,6 +5430,7 @@ __metadata: "@stellar/js-xdr": "npm:4.0.0" bignumber.js: "npm:9.3.0" prettier: "npm:3.8.4" + stellar-hd-wallet: "npm:1.0.2" stellar-sdk: "npm:@stellar/stellar-sdk@16.0.0-rc.1" stellar-sdk-next: "npm:@stellar/stellar-sdk@16.0.0-rc.1" typescript: "npm:5.8.3" From 6c593012d5ae93aad5d3f2bf5f1a51011031fe95 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Thu, 25 Jun 2026 17:48:19 -0400 Subject: [PATCH 03/10] fix(auth): declare bip39 dep, drop unused stellar-hd-wallet in @shared/api (#2769) Co-Authored-By: Claude Sonnet 4.6 --- @shared/api/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/@shared/api/package.json b/@shared/api/package.json index d7bff69a7c..d4994e36ab 100644 --- a/@shared/api/package.json +++ b/@shared/api/package.json @@ -7,8 +7,8 @@ "@blockaid/client": "0.31.0", "@stellar/js-xdr": "4.0.0", "bignumber.js": "9.3.0", + "bip39": "3.1.0", "prettier": "3.8.4", - "stellar-hd-wallet": "1.0.2", "stellar-sdk": "npm:@stellar/stellar-sdk@16.0.0-rc.1", "stellar-sdk-next": "npm:@stellar/stellar-sdk@16.0.0-rc.1", "typescript": "5.8.3", diff --git a/yarn.lock b/yarn.lock index d09d6f7205..00f3f7ce3a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5429,8 +5429,8 @@ __metadata: "@lavamoat/allow-scripts": "npm:3.3.2" "@stellar/js-xdr": "npm:4.0.0" bignumber.js: "npm:9.3.0" + bip39: "npm:3.1.0" prettier: "npm:3.8.4" - stellar-hd-wallet: "npm:1.0.2" stellar-sdk: "npm:@stellar/stellar-sdk@16.0.0-rc.1" stellar-sdk-next: "npm:@stellar/stellar-sdk@16.0.0-rc.1" typescript: "npm:5.8.3" From 8198876f7dc9bb7fd80d55b7a3cd0878ca38d49c Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Thu, 25 Jun 2026 17:50:25 -0400 Subject: [PATCH 04/10] docs(auth): align spec/plan with bip39-direct derivation (jest interop) (#2769) --- .../plans/2026-06-25-derive-auth-keypair.md | 403 ++++++++++++++++++ extension/specs/AUTH_KEYPAIR_DERIVATION.md | 30 +- 2 files changed, 421 insertions(+), 12 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-25-derive-auth-keypair.md diff --git a/docs/superpowers/plans/2026-06-25-derive-auth-keypair.md b/docs/superpowers/plans/2026-06-25-derive-auth-keypair.md new file mode 100644 index 0000000000..8ff96d0543 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-derive-auth-keypair.md @@ -0,0 +1,403 @@ +# Derive Auth Keypair Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a pure extension-side primitive that derives the Freighter +backend auth keypair (and hex user ID) from the wallet mnemonic via +`HMAC-SHA256(seedBytes, "freighter-auth-v1")` → Ed25519. + +**Architecture:** A single pure async function in +`@shared/api/helpers/deriveAuthKeypair.ts`. Step 1 computes the 32-byte auth +seed with `crypto.subtle` HMAC keyed on the 64-byte BIP39 seed +(`stellar-hd-wallet`). Step 2 turns that seed into an Ed25519 keypair with +`stellar-sdk` `Keypair.fromRawEd25519Seed`; the raw pubkey, lowercase-hex, is +the user ID. Correctness is locked by committed cross-platform test vectors that +`freighter-mobile` will mirror. + +**Tech Stack:** TypeScript, Jest 29 (`jest-fixed-jsdom`), `crypto.subtle` +(WebCrypto), `stellar-sdk` (`@stellar/stellar-sdk@16.0.0-rc.1`), +`stellar-hd-wallet@1.0.2`. + +**Spec:** `extension/specs/AUTH_KEYPAIR_DERIVATION.md` (canonical contract — +read before starting). + +## Global Constraints + +Every task implicitly includes these: + +- **Zero new libraries.** Only `crypto.subtle`, `stellar-sdk`, and + `stellar-hd-wallet` — all already in the project. The only dependency-manifest + change allowed is _declaring_ `stellar-hd-wallet` in + `@shared/api/package.json`. +- **Exact version pins, no caret ranges.** Declare `stellar-hd-wallet` as + `"1.0.2"` (matches the extension's pin; both repos depend on the same + singleton). +- **`AUTH_SALT = "freighter-auth-v1"`** — exact literal, exported as a named + constant. +- **HMAC-SHA256:** KEY = the 64-byte BIP39 seed + (`StellarHDWallet.fromMnemonic(mnemonic).seedHex`, hex→bytes); MESSAGE = + `utf8(AUTH_SALT)`. Order is `HMAC(key, message)` — never reversed. +- **User ID = lowercase hex** of the raw 32-byte Ed25519 public key (matches the + backend's canonical `sub`). +- **Pure function:** no logging, no `keyManager`, no messaging, no persistence. + Background-only usage; locked-session handling belongs to the future consumer, + not here. + +## File Structure + +| File | Responsibility | +| :-------------------------------------------------------- | :------------------------------------------------------------------------------------------ | +| `@shared/api/helpers/deriveAuthKeypair.ts` | The primitive: `AUTH_SALT`, `deriveAuthSeed`, `deriveAuthKeypair`. | +| `@shared/api/helpers/__tests__/authKeypairVectors.ts` | Committed cross-platform test vectors (canonical contract; mirrored in `freighter-mobile`). | +| `@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts` | Unit tests mapped to acceptance criteria. | +| `@shared/api/package.json` | Declare `stellar-hd-wallet@1.0.2` (modify). | + +**Run a single test file:** +`yarn jest @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts` **Run one +test by name:** add `-t ""`. + +--- + +### Task 1: Auth seed (HMAC half) + vectors + dependency + +**Files:** + +- Modify: `@shared/api/package.json` (add `stellar-hd-wallet` dependency) +- Create: `@shared/api/helpers/__tests__/authKeypairVectors.ts` +- Create: `@shared/api/helpers/deriveAuthKeypair.ts` +- Test: `@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts` + +**Interfaces:** + +- Produces: + - `AUTH_SALT: string` (= `"freighter-auth-v1"`) + - `deriveAuthSeed(mnemonic: string): Promise` (32 bytes) + - `AUTH_KEYPAIR_VECTORS: AuthKeypairVector[]` where + `AuthKeypairVector = { mnemonic: string; authSeedHex: string; userId: string }` + +- [ ] **Step 1: Declare the `stellar-hd-wallet` dependency** + +In `@shared/api/package.json`, add to `dependencies` (keep alphabetical with the +existing `stellar-sdk` entries; exact pin, no caret): + +```json + "stellar-hd-wallet": "1.0.2", +``` + +Then install so the workspace records it (it already resolves via hoisting at +the same version, so this only updates the lockfile): + +Run: `yarn install` Expected: completes; `yarn.lock` gains a `@shared/api` → +`stellar-hd-wallet` edge, no version change. + +- [ ] **Step 2: Create the test vectors fixture** + +Create `@shared/api/helpers/__tests__/authKeypairVectors.ts`: + +```ts +// Canonical cross-platform auth-keypair derivation vectors. +// SOURCE OF TRUTH: extension/specs/AUTH_KEYPAIR_DERIVATION.md +// freighter-mobile MUST commit the same values and assert identical output. +// authSeedHex isolates failures: a wrong authSeedHex => HMAC step diverged +// (e.g. key/message reversed); a right authSeedHex but wrong userId => +// Ed25519 step diverged. +export interface AuthKeypairVector { + mnemonic: string; + authSeedHex: string; // hex of HMAC-SHA256(seedBytes, AUTH_SALT), 32 bytes + userId: string; // lowercase hex Ed25519 public key, 64 chars +} + +export const AUTH_KEYPAIR_VECTORS: AuthKeypairVector[] = [ + { + mnemonic: + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + authSeedHex: + "cf8ef34afb730ffd0807ee8731f2378a4f6c702e2f14915976fac4afa711b52d", + userId: "ec57e5d04783b5ade776621ab171b3b197c3acc0a1cb5bad10786dc8e381e797", + }, + { + mnemonic: + "illness spike retreat truth genius clock brain pass fit cave bargain toe", + authSeedHex: + "882835b30a2c5b011f1b2424a84f4cd39f342f4d12be574e4233dbf9b98976d1", + userId: "bd9498475c7191c5e9a5e18edda2402ab0ae527580a6c38b2a32a77c65729cd7", + }, +]; +``` + +- [ ] **Step 3: Write the failing test for `deriveAuthSeed`** + +Create `@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts`: + +```ts +import { Buffer } from "buffer"; + +import { AUTH_SALT, deriveAuthSeed } from "../deriveAuthKeypair"; +import { AUTH_KEYPAIR_VECTORS } from "./authKeypairVectors"; + +describe("deriveAuthSeed (HMAC step)", () => { + it("uses the versioned domain-separation salt", () => { + expect(AUTH_SALT).toBe("freighter-auth-v1"); + }); + + it.each(AUTH_KEYPAIR_VECTORS)( + "reproduces the committed authSeed for %#", + async ({ mnemonic, authSeedHex }) => { + const seed = await deriveAuthSeed(mnemonic); + expect(seed).toHaveLength(32); + expect(Buffer.from(seed).toString("hex")).toBe(authSeedHex); + }, + ); +}); +``` + +- [ ] **Step 4: Run the test to verify it fails** + +Run: `yarn jest @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts` +Expected: FAIL — `Cannot find module '../deriveAuthKeypair'`. + +- [ ] **Step 5: Implement `AUTH_SALT` + `deriveAuthSeed`** + +Create `@shared/api/helpers/deriveAuthKeypair.ts`: + +```ts +import { Buffer } from "buffer"; +import StellarHDWallet from "stellar-hd-wallet"; + +/** + * Versioned domain-separation salt for the backend auth keypair. The `-v1` + * suffix reserves a migration path; derivation is deterministic, so the auth + * keypair is permanent for the life of the seed. + */ +export const AUTH_SALT = "freighter-auth-v1"; + +/** + * Computes the 32-byte auth seed: HMAC-SHA256 keyed on the wallet's 64-byte + * BIP39 seed, over the salt. Throws "Invalid mnemonic (see bip39)" on a + * malformed mnemonic (via stellar-hd-wallet). + */ +export const deriveAuthSeed = async (mnemonic: string): Promise => { + // BIP39 seed, 64 bytes, empty passphrase. stellar-hd-wallet@1.0.2's + // fromMnemonic validates the mnemonic and runs bip39.mnemonicToSeedSync. + const seedBytes = Buffer.from( + StellarHDWallet.fromMnemonic(mnemonic).seedHex, + "hex", + ); + + const key = await crypto.subtle.importKey( + "raw", + seedBytes, + { name: "HMAC", hash: "SHA-256" }, + false, // not extractable + ["sign"], + ); + const mac = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(AUTH_SALT), + ); + return new Uint8Array(mac); +}; +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `yarn jest @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts` +Expected: PASS — 3 tests (salt + 2 vectors). + +- [ ] **Step 7: Commit** + +```bash +git add @shared/api/package.json yarn.lock \ + @shared/api/helpers/deriveAuthKeypair.ts \ + @shared/api/helpers/__tests__/authKeypairVectors.ts \ + @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts +git commit -m "feat(auth): HMAC auth-seed derivation + cross-platform vectors (#2769)" +``` + +--- + +### Task 2: Auth keypair (Ed25519 half) + acceptance-criteria tests + +**Files:** + +- Modify: `@shared/api/helpers/deriveAuthKeypair.ts` +- Test: `@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts` (extend) + +**Interfaces:** + +- Consumes: `deriveAuthSeed`, `AUTH_KEYPAIR_VECTORS` (from Task 1) +- Produces: + - `deriveAuthKeypair(mnemonic: string): Promise<{ userId: string; keypair: Keypair }>` + - `userId` is lowercase hex (64 chars); `keypair` is a `stellar-sdk` `Keypair` + (later JWT ticket calls `keypair.sign()`). + +- [ ] **Step 1: Write the failing tests for `deriveAuthKeypair`** + +Append to `@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts`. Add this +import at the top of the file (alongside the existing ones): + +```ts +import { deriveAuthKeypair } from "../deriveAuthKeypair"; +``` + +Then add these suites: + +```ts +describe("deriveAuthKeypair", () => { + // Acceptance #1: same seed -> identical userId on extension and mobile. + it.each(AUTH_KEYPAIR_VECTORS)( + "derives the committed userId for %#", + async ({ mnemonic, userId }) => { + const result = await deriveAuthKeypair(mnemonic); + expect(result.userId).toBe(userId); + }, + ); + + it("is deterministic for the same mnemonic", async () => { + const { mnemonic } = AUTH_KEYPAIR_VECTORS[0]; + const a = await deriveAuthKeypair(mnemonic); + const b = await deriveAuthKeypair(mnemonic); + expect(a.userId).toBe(b.userId); + expect(a.keypair.rawPublicKey().equals(b.keypair.rawPublicKey())).toBe( + true, + ); + }); + + it("emits a lowercase 64-char hex userId", async () => { + const { userId } = await deriveAuthKeypair( + AUTH_KEYPAIR_VECTORS[0].mnemonic, + ); + expect(userId).toMatch(/^[0-9a-f]{64}$/); + }); + + // Acceptance #2: cryptographically independent from the wallet keypair. + // Wallet account-0 public key for AUTH_KEYPAIR_VECTORS[0]'s mnemonic, hex. + // Hardcoded verified constant — StellarHDWallet (the normal way to derive it) + // cannot run under jest/jsdom (compiled bip39 import breaks). This shows the + // auth key differs from the wallet key derived from the same seed. + it("differs from the wallet account-0 public key for the same mnemonic", async () => { + const WALLET_ACCOUNT_0_HEX = + "7691d85048acc4ed085d9061ce0948bbdf7de6a92b790aaf241d31b7dcaa4238"; + const { userId } = await deriveAuthKeypair( + AUTH_KEYPAIR_VECTORS[0].mnemonic, + ); + expect(userId).not.toBe(WALLET_ACCOUNT_0_HEX); + }); + + // Acceptance #3: pure crypto, no wallet-signing / messaging side effects. + it("never sends an extension message during derivation", async () => { + const sendMessage = jest.fn(); + (globalThis as unknown as { browser?: unknown }).browser = { + runtime: { sendMessage }, + }; + try { + await deriveAuthKeypair(AUTH_KEYPAIR_VECTORS[0].mnemonic); + expect(sendMessage).not.toHaveBeenCalled(); + } finally { + delete (globalThis as unknown as { browser?: unknown }).browser; + } + }); + + it("throws on an invalid mnemonic instead of producing a key", async () => { + await expect( + deriveAuthKeypair("not a valid mnemonic phrase"), + ).rejects.toThrow(/invalid mnemonic/i); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `yarn jest @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts` +Expected: FAIL — `deriveAuthKeypair` is not exported +(`TypeError: ... is not a function`). + +- [ ] **Step 3: Implement `deriveAuthKeypair`** + +In `@shared/api/helpers/deriveAuthKeypair.ts`, add the `Keypair` import and the +function. Update the import block and append at the end of the file: + +```ts +import { Keypair } from "stellar-sdk"; +``` + +```ts +/** + * 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 }; +}; +``` + +- [ ] **Step 4: Run the full test file to verify it passes** + +Run: `yarn jest @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts` +Expected: PASS — all suites (HMAC vectors, userId vectors, determinism, format, +independence, no-side-effects, invalid-mnemonic). + +- [ ] **Step 5: Typecheck and lint the new files** + +Run: `yarn workspace @shared/api tsc --noEmit` (or the repo's root typecheck +script if `@shared/api` has none — check `package.json`); then +`yarn eslint @shared/api/helpers/deriveAuthKeypair.ts @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts @shared/api/helpers/__tests__/authKeypairVectors.ts` +Expected: no type errors, no lint errors. + +- [ ] **Step 6: Commit** + +```bash +git add @shared/api/helpers/deriveAuthKeypair.ts \ + @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts +git commit -m "feat(auth): derive Ed25519 auth keypair + userId from seed (#2769)" +``` + +--- + +## Self-Review + +**Spec coverage:** + +- Scope (primitive + tests + vectors only) → Tasks 1–2; no JWT/handler/UI. ✓ +- Crypto decisions (`crypto.subtle` HMAC + `stellar-sdk` Keypair, zero new libs) + → Task 1 Step 5, Task 2 Step 3, Global Constraints. ✓ +- Exact algorithm (64-byte seed as HMAC key, salt as message, lowercase-hex + userId) → Task 1 Step 5, Task 2 Step 3. ✓ +- `AUTH_SALT` constant → Task 1 Step 5 + test. ✓ +- Cross-platform vectors → Task 1 Step 2; asserted in Task 1 (authSeed) + Task 2 + (userId). ✓ +- Acceptance #1 (parity) → Task 2 Step 1 vector tests. ✓ +- Acceptance #2 (independence, reworded) → Task 2 independence test. ✓ +- Acceptance #3 (no signing prompt) → Task 2 no-side-effects test + pure impl. ✓ +- Negative (invalid mnemonic fails loudly) → Task 2; verified `fromMnemonic` + throws. ✓ +- Lifecycle/locked-session → spec marks it the consumer's concern; correctly NOT + implemented here. ✓ + +**Placeholder scan:** none — all code and commands are concrete. + +**Type consistency:** `deriveAuthSeed → Promise`, +`deriveAuthKeypair → Promise<{ userId: string; keypair: Keypair }>`, +`AuthKeypairVector { mnemonic, authSeedHex, userId }` consistent across both +tasks and the fixture. ✓ + +## Follow-ups (out of this plan, tracked in the spec) + +- `freighter-mobile` mirrors the algorithm + `authKeypairVectors.ts`. +- Update ticket #2769 acceptance #2 wording to "cryptographically independent + from the wallet keypair." diff --git a/extension/specs/AUTH_KEYPAIR_DERIVATION.md b/extension/specs/AUTH_KEYPAIR_DERIVATION.md index 82fbcb5a15..ec62a8d063 100644 --- a/extension/specs/AUTH_KEYPAIR_DERIVATION.md +++ b/extension/specs/AUTH_KEYPAIR_DERIVATION.md @@ -67,13 +67,15 @@ Both leak threats are treated as first-class: ## Crypto decisions -Both halves reuse primitives **already central to the extension — zero new -dependencies.** +All three primitives are **already in the project — no new library is +introduced** (`bip39` is promoted from a transitive dep to a declared one in +`@shared/api`, pinned to `3.1.0`). -| Step | Choice | Rationale | -| :-------------- | :----------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| HMAC-SHA256 | `crypto.subtle` | Already the extension's core crypto layer (`extension/src/background/helpers/session.ts` uses it for AES-GCM session encryption + PBKDF2). Native, audited, no new dep. | -| Ed25519 keypair | `stellar-sdk` `Keypair.fromRawEd25519Seed` | Already a core dep, already used for wallet keys. `Keypair.rawPublicKey()` gives the raw 32-byte pubkey (the user ID); `Keypair.sign()` gives a raw 64-byte Ed25519 signature, which the downstream JWT ticket reuses directly. | +| Step | Choice | Rationale | +| :-------------- | :------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| BIP39 seed | `bip39` (`mnemonicToSeedSync`/`validateMnemonic`) | The standard mnemonic→seed function. Called directly rather than via `stellar-hd-wallet` (whose compiled bip39 import cannot run under jest/jsdom). Byte-identical output — `stellar-hd-wallet` wraps the same call. | +| HMAC-SHA256 | `crypto.subtle` | Already the extension's core crypto layer (`extension/src/background/helpers/session.ts` uses it for AES-GCM session encryption + PBKDF2). Native, audited, no new dep. | +| Ed25519 keypair | `stellar-sdk` `Keypair.fromRawEd25519Seed` | Already a core dep, already used for wallet keys. `Keypair.rawPublicKey()` gives the raw 32-byte pubkey (the user ID); `Keypair.sign()` gives a raw 64-byte Ed25519 signature, which the downstream JWT ticket reuses directly. | **Correctness is library-independent.** HMAC-SHA256 and Ed25519 derivation/signing are standardized and deterministic (RFC 8032), so any correct implementation @@ -115,12 +117,16 @@ must-match chain inside one function, so the cross-platform vector is simply **Exact algorithm — this is the cross-platform contract:** ```ts -// 1. BIP39 seed: 64 bytes, EMPTY passphrase. Both repos pin stellar-hd-wallet@1.0.2, -// whose fromMnemonic() does bip39.mnemonicToSeedSync(mnemonic) internally. -const seedBytes = Buffer.from( - StellarHDWallet.fromMnemonic(mnemonic).seedHex, - "hex", -); // 64 bytes +// 1. BIP39 seed: 64 bytes, EMPTY passphrase. validateMnemonic rejects malformed +// input (the "Invalid mnemonic" error contract); mnemonicToSeedSync produces +// the 64-byte seed. We call `bip39` directly: stellar-hd-wallet cannot run +// under the jest/jsdom test env (its compiled bip39 default-import breaks with +// "Cannot read properties of undefined (reading 'wordlists')"). The bytes are +// identical — stellar-hd-wallet's fromMnemonic() wraps this same bip39 call — +// so cross-platform parity is unaffected (mobile may use either library). +if (!validateMnemonic(mnemonic)) + throw new Error("Invalid mnemonic (see bip39)"); +const seedBytes = Buffer.from(mnemonicToSeedSync(mnemonic)); // 64 bytes // 2. HMAC-SHA256. KEY = seedBytes (the 64-byte seed). MESSAGE = utf8(SALT). // Order matters — HMAC(key, message). Pinned by the test vectors so it From 08f219050069425fcd7aef2a4b946addc4ed7ecd Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Thu, 25 Jun 2026 17:55:18 -0400 Subject: [PATCH 05/10] docs(auth): align plan Task 1 with bip39-direct implementation (#2769) --- .../plans/2026-06-25-derive-auth-keypair.md | 66 ++++++++++--------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/docs/superpowers/plans/2026-06-25-derive-auth-keypair.md b/docs/superpowers/plans/2026-06-25-derive-auth-keypair.md index 8ff96d0543..4142b44940 100644 --- a/docs/superpowers/plans/2026-06-25-derive-auth-keypair.md +++ b/docs/superpowers/plans/2026-06-25-derive-auth-keypair.md @@ -11,15 +11,14 @@ backend auth keypair (and hex user ID) from the wallet mnemonic via **Architecture:** A single pure async function in `@shared/api/helpers/deriveAuthKeypair.ts`. Step 1 computes the 32-byte auth -seed with `crypto.subtle` HMAC keyed on the 64-byte BIP39 seed -(`stellar-hd-wallet`). Step 2 turns that seed into an Ed25519 keypair with -`stellar-sdk` `Keypair.fromRawEd25519Seed`; the raw pubkey, lowercase-hex, is -the user ID. Correctness is locked by committed cross-platform test vectors that +seed with `crypto.subtle` HMAC keyed on the 64-byte BIP39 seed (`bip39`). Step 2 +turns that seed into an Ed25519 keypair with `stellar-sdk` +`Keypair.fromRawEd25519Seed`; the raw pubkey, lowercase-hex, is the user ID. +Correctness is locked by committed cross-platform test vectors that `freighter-mobile` will mirror. **Tech Stack:** TypeScript, Jest 29 (`jest-fixed-jsdom`), `crypto.subtle` -(WebCrypto), `stellar-sdk` (`@stellar/stellar-sdk@16.0.0-rc.1`), -`stellar-hd-wallet@1.0.2`. +(WebCrypto), `stellar-sdk` (`@stellar/stellar-sdk@16.0.0-rc.1`), `bip39@3.1.0`. **Spec:** `extension/specs/AUTH_KEYPAIR_DERIVATION.md` (canonical contract — read before starting). @@ -28,18 +27,21 @@ read before starting). Every task implicitly includes these: -- **Zero new libraries.** Only `crypto.subtle`, `stellar-sdk`, and - `stellar-hd-wallet` — all already in the project. The only dependency-manifest - change allowed is _declaring_ `stellar-hd-wallet` in - `@shared/api/package.json`. -- **Exact version pins, no caret ranges.** Declare `stellar-hd-wallet` as - `"1.0.2"` (matches the extension's pin; both repos depend on the same - singleton). +- **No new library introduced.** Only `crypto.subtle`, `stellar-sdk`, and + `bip39` — all already in the project. The only dependency-manifest change is + _declaring_ `bip39` in `@shared/api/package.json` (it was already a transitive + dep). `stellar-hd-wallet` is NOT used: it cannot run under jest/jsdom (its + compiled bip39 import throws + `Cannot read properties of undefined (reading 'wordlists')`). + `bip39.mnemonicToSeedSync` is byte-identical to what `stellar-hd-wallet` + wraps. +- **Exact version pins, no caret ranges.** Declare `bip39` as `"3.1.0"` (the + version already in the tree). - **`AUTH_SALT = "freighter-auth-v1"`** — exact literal, exported as a named constant. - **HMAC-SHA256:** KEY = the 64-byte BIP39 seed - (`StellarHDWallet.fromMnemonic(mnemonic).seedHex`, hex→bytes); MESSAGE = - `utf8(AUTH_SALT)`. Order is `HMAC(key, message)` — never reversed. + (`bip39.mnemonicToSeedSync(mnemonic)`); MESSAGE = `utf8(AUTH_SALT)`. Order is + `HMAC(key, message)` — never reversed. - **User ID = lowercase hex** of the raw 32-byte Ed25519 public key (matches the backend's canonical `sub`). - **Pure function:** no logging, no `keyManager`, no messaging, no persistence. @@ -53,7 +55,7 @@ Every task implicitly includes these: | `@shared/api/helpers/deriveAuthKeypair.ts` | The primitive: `AUTH_SALT`, `deriveAuthSeed`, `deriveAuthKeypair`. | | `@shared/api/helpers/__tests__/authKeypairVectors.ts` | Committed cross-platform test vectors (canonical contract; mirrored in `freighter-mobile`). | | `@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts` | Unit tests mapped to acceptance criteria. | -| `@shared/api/package.json` | Declare `stellar-hd-wallet@1.0.2` (modify). | +| `@shared/api/package.json` | Declare `bip39@3.1.0` (modify). | **Run a single test file:** `yarn jest @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts` **Run one @@ -65,7 +67,7 @@ test by name:** add `-t ""`. **Files:** -- Modify: `@shared/api/package.json` (add `stellar-hd-wallet` dependency) +- Modify: `@shared/api/package.json` (add `bip39` dependency) - Create: `@shared/api/helpers/__tests__/authKeypairVectors.ts` - Create: `@shared/api/helpers/deriveAuthKeypair.ts` - Test: `@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts` @@ -78,20 +80,20 @@ test by name:** add `-t ""`. - `AUTH_KEYPAIR_VECTORS: AuthKeypairVector[]` where `AuthKeypairVector = { mnemonic: string; authSeedHex: string; userId: string }` -- [ ] **Step 1: Declare the `stellar-hd-wallet` dependency** +- [ ] **Step 1: Declare the `bip39` dependency** -In `@shared/api/package.json`, add to `dependencies` (keep alphabetical with the -existing `stellar-sdk` entries; exact pin, no caret): +In `@shared/api/package.json`, add to `dependencies` (keep alphabetical; exact +pin, no caret): ```json - "stellar-hd-wallet": "1.0.2", + "bip39": "3.1.0", ``` Then install so the workspace records it (it already resolves via hoisting at the same version, so this only updates the lockfile): Run: `yarn install` Expected: completes; `yarn.lock` gains a `@shared/api` → -`stellar-hd-wallet` edge, no version change. +`bip39` edge, no version change. - [ ] **Step 2: Create the test vectors fixture** @@ -165,7 +167,7 @@ Create `@shared/api/helpers/deriveAuthKeypair.ts`: ```ts import { Buffer } from "buffer"; -import StellarHDWallet from "stellar-hd-wallet"; +import { mnemonicToSeedSync, validateMnemonic } from "bip39"; /** * Versioned domain-separation salt for the backend auth keypair. The `-v1` @@ -177,15 +179,19 @@ export const AUTH_SALT = "freighter-auth-v1"; /** * Computes the 32-byte auth seed: HMAC-SHA256 keyed on the wallet's 64-byte * BIP39 seed, over the salt. Throws "Invalid mnemonic (see bip39)" on a - * malformed mnemonic (via stellar-hd-wallet). + * malformed mnemonic. + * + * bip39 is called directly: stellar-hd-wallet cannot run under jest/jsdom (its + * compiled bip39 import throws on `wordlists`). The bytes are identical — + * stellar-hd-wallet's fromMnemonic wraps this same bip39 call. */ export const deriveAuthSeed = async (mnemonic: string): Promise => { - // BIP39 seed, 64 bytes, empty passphrase. stellar-hd-wallet@1.0.2's - // fromMnemonic validates the mnemonic and runs bip39.mnemonicToSeedSync. - const seedBytes = Buffer.from( - StellarHDWallet.fromMnemonic(mnemonic).seedHex, - "hex", - ); + // Validate first, matching stellar-hd-wallet's error contract. + if (!validateMnemonic(mnemonic)) { + throw new Error("Invalid mnemonic (see bip39)"); + } + // BIP39 seed, 64 bytes, empty passphrase. + const seedBytes = Buffer.from(mnemonicToSeedSync(mnemonic)); const key = await crypto.subtle.importKey( "raw", From cbeff2ebe6b6dccbdcae14799d6ce7500d2e5580 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Fri, 26 Jun 2026 09:56:08 -0400 Subject: [PATCH 06/10] feat(auth): derive Ed25519 auth keypair + userId from seed (#2769) Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/deriveAuthKeypair.test.ts | 68 ++++++++++++++++++- @shared/api/helpers/deriveAuthKeypair.ts | 20 ++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts b/@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts index 9ff1f11287..17e36f2628 100644 --- a/@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts +++ b/@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts @@ -1,6 +1,10 @@ import { Buffer } from "buffer"; -import { AUTH_SALT, deriveAuthSeed } from "../deriveAuthKeypair"; +import { + AUTH_SALT, + deriveAuthKeypair, + deriveAuthSeed, +} from "../deriveAuthKeypair"; import { AUTH_KEYPAIR_VECTORS } from "./authKeypairVectors"; describe("deriveAuthSeed (HMAC step)", () => { @@ -17,3 +21,65 @@ describe("deriveAuthSeed (HMAC step)", () => { }, ); }); + +describe("deriveAuthKeypair", () => { + // Acceptance #1: same seed -> identical userId on extension and mobile. + it.each(AUTH_KEYPAIR_VECTORS)( + "derives the committed userId for %#", + async ({ mnemonic, userId }) => { + const result = await deriveAuthKeypair(mnemonic); + expect(result.userId).toBe(userId); + }, + ); + + it("is deterministic for the same mnemonic", async () => { + const { mnemonic } = AUTH_KEYPAIR_VECTORS[0]; + const a = await deriveAuthKeypair(mnemonic); + const b = await deriveAuthKeypair(mnemonic); + expect(a.userId).toBe(b.userId); + expect(a.keypair.rawPublicKey().equals(b.keypair.rawPublicKey())).toBe( + true, + ); + }); + + it("emits a lowercase 64-char hex userId", async () => { + const { userId } = await deriveAuthKeypair( + AUTH_KEYPAIR_VECTORS[0].mnemonic, + ); + expect(userId).toMatch(/^[0-9a-f]{64}$/); + }); + + // Acceptance #2: cryptographically independent from the wallet keypair. + // Wallet account-0 public key for AUTH_KEYPAIR_VECTORS[0]'s mnemonic, hex. + // Hardcoded verified constant — StellarHDWallet (the normal way to derive it) + // cannot run under jest/jsdom (compiled bip39 import breaks). This shows the + // auth key differs from the wallet key derived from the same seed. + it("differs from the wallet account-0 public key for the same mnemonic", async () => { + const WALLET_ACCOUNT_0_HEX = + "7691d85048acc4ed085d9061ce0948bbdf7de6a92b790aaf241d31b7dcaa4238"; + const { userId } = await deriveAuthKeypair( + AUTH_KEYPAIR_VECTORS[0].mnemonic, + ); + expect(userId).not.toBe(WALLET_ACCOUNT_0_HEX); + }); + + // Acceptance #3: pure crypto, no wallet-signing / messaging side effects. + it("never sends an extension message during derivation", async () => { + const sendMessage = jest.fn(); + (globalThis as unknown as { browser?: unknown }).browser = { + runtime: { sendMessage }, + }; + try { + await deriveAuthKeypair(AUTH_KEYPAIR_VECTORS[0].mnemonic); + expect(sendMessage).not.toHaveBeenCalled(); + } finally { + delete (globalThis as unknown as { browser?: unknown }).browser; + } + }); + + it("throws on an invalid mnemonic instead of producing a key", async () => { + await expect( + deriveAuthKeypair("not a valid mnemonic phrase"), + ).rejects.toThrow(/invalid mnemonic/i); + }); +}); diff --git a/@shared/api/helpers/deriveAuthKeypair.ts b/@shared/api/helpers/deriveAuthKeypair.ts index 4a3cf5306e..e40d0b7bef 100644 --- a/@shared/api/helpers/deriveAuthKeypair.ts +++ b/@shared/api/helpers/deriveAuthKeypair.ts @@ -1,5 +1,6 @@ import { Buffer } from "buffer"; import { mnemonicToSeedSync, validateMnemonic } from "bip39"; +import { Keypair } from "stellar-sdk"; /** * Versioned domain-separation salt for the backend auth keypair. The `-v1` @@ -42,3 +43,22 @@ export const deriveAuthSeed = async (mnemonic: string): Promise => { ); return new Uint8Array(mac); }; + +/** + * 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 }; +}; From 8f2d0e6cf24c8d4098c5038438e4eee24817a82b Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Fri, 26 Jun 2026 10:08:58 -0400 Subject: [PATCH 07/10] docs(auth): mark deriveAuthSeed @internal; clarify purity test comment (#2769) Co-Authored-By: Claude Sonnet 4.6 --- @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts | 5 ++++- @shared/api/helpers/deriveAuthKeypair.ts | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts b/@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts index 17e36f2628..5c8e19ae67 100644 --- a/@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts +++ b/@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts @@ -63,7 +63,10 @@ describe("deriveAuthKeypair", () => { expect(userId).not.toBe(WALLET_ACCOUNT_0_HEX); }); - // Acceptance #3: pure crypto, no wallet-signing / messaging side effects. + // Acceptance #3: no messaging side effects. + // Purity is guaranteed structurally: the module imports nothing from the + // messaging/keyManager/storage paths. This spy is a lightweight tripwire + // that would catch a future regression which started sending messages. it("never sends an extension message during derivation", async () => { const sendMessage = jest.fn(); (globalThis as unknown as { browser?: unknown }).browser = { diff --git a/@shared/api/helpers/deriveAuthKeypair.ts b/@shared/api/helpers/deriveAuthKeypair.ts index e40d0b7bef..2777a0667c 100644 --- a/@shared/api/helpers/deriveAuthKeypair.ts +++ b/@shared/api/helpers/deriveAuthKeypair.ts @@ -19,6 +19,8 @@ export const AUTH_SALT = "freighter-auth-v1"; * (bip39 sets __esModule:true without a .default export). We call bip39 named * exports directly — bip39 is already a transitive dep of stellar-hd-wallet, * and the derivation is byte-for-byte identical. + * @internal Exported only for tests (intermediate-value assertions). The + * returned bytes are private-key seed material — do not use as a standalone key. */ export const deriveAuthSeed = async (mnemonic: string): Promise => { // Validate mnemonic first, matching stellar-hd-wallet's error contract. From 805872eeb32103fcc999aafea7760728bb914635 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Fri, 26 Jun 2026 10:33:57 -0400 Subject: [PATCH 08/10] fix(auth): move authKeypairVectors fixture out of __tests__ (CI test collection) (#2769) --- @shared/api/helpers/{__tests__ => }/authKeypairVectors.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename @shared/api/helpers/{__tests__ => }/authKeypairVectors.ts (100%) diff --git a/@shared/api/helpers/__tests__/authKeypairVectors.ts b/@shared/api/helpers/authKeypairVectors.ts similarity index 100% rename from @shared/api/helpers/__tests__/authKeypairVectors.ts rename to @shared/api/helpers/authKeypairVectors.ts From da74cf81548997337eda14ec52c64d723f71a3c0 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Fri, 26 Jun 2026 10:43:53 -0400 Subject: [PATCH 09/10] =?UTF-8?q?chore(auth):=20keep=20PR=20code-only=20?= =?UTF-8?q?=E2=80=94=20spec=20moved=20to=20wallet-eng-monorepo,=20plan=20u?= =?UTF-8?q?ntracked=20(#2769)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- @shared/api/helpers/authKeypairVectors.ts | 2 +- .../plans/2026-06-25-derive-auth-keypair.md | 409 ------------------ extension/specs/AUTH_KEYPAIR_DERIVATION.md | 249 ----------- 3 files changed, 1 insertion(+), 659 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-25-derive-auth-keypair.md delete mode 100644 extension/specs/AUTH_KEYPAIR_DERIVATION.md diff --git a/@shared/api/helpers/authKeypairVectors.ts b/@shared/api/helpers/authKeypairVectors.ts index a266cd19d5..36d6f38793 100644 --- a/@shared/api/helpers/authKeypairVectors.ts +++ b/@shared/api/helpers/authKeypairVectors.ts @@ -1,5 +1,5 @@ // Canonical cross-platform auth-keypair derivation vectors. -// SOURCE OF TRUTH: extension/specs/AUTH_KEYPAIR_DERIVATION.md +// SOURCE OF TRUTH: wallet-eng-monorepo design-docs/contact-lists/Freighter Auth Keypair Derivation Design Doc.md // freighter-mobile MUST commit the same values and assert identical output. // authSeedHex isolates failures: a wrong authSeedHex => HMAC step diverged // (e.g. key/message reversed); a right authSeedHex but wrong userId => diff --git a/docs/superpowers/plans/2026-06-25-derive-auth-keypair.md b/docs/superpowers/plans/2026-06-25-derive-auth-keypair.md deleted file mode 100644 index 4142b44940..0000000000 --- a/docs/superpowers/plans/2026-06-25-derive-auth-keypair.md +++ /dev/null @@ -1,409 +0,0 @@ -# Derive Auth Keypair Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use -> superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps use -> checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Ship a pure extension-side primitive that derives the Freighter -backend auth keypair (and hex user ID) from the wallet mnemonic via -`HMAC-SHA256(seedBytes, "freighter-auth-v1")` → Ed25519. - -**Architecture:** A single pure async function in -`@shared/api/helpers/deriveAuthKeypair.ts`. Step 1 computes the 32-byte auth -seed with `crypto.subtle` HMAC keyed on the 64-byte BIP39 seed (`bip39`). Step 2 -turns that seed into an Ed25519 keypair with `stellar-sdk` -`Keypair.fromRawEd25519Seed`; the raw pubkey, lowercase-hex, is the user ID. -Correctness is locked by committed cross-platform test vectors that -`freighter-mobile` will mirror. - -**Tech Stack:** TypeScript, Jest 29 (`jest-fixed-jsdom`), `crypto.subtle` -(WebCrypto), `stellar-sdk` (`@stellar/stellar-sdk@16.0.0-rc.1`), `bip39@3.1.0`. - -**Spec:** `extension/specs/AUTH_KEYPAIR_DERIVATION.md` (canonical contract — -read before starting). - -## Global Constraints - -Every task implicitly includes these: - -- **No new library introduced.** Only `crypto.subtle`, `stellar-sdk`, and - `bip39` — all already in the project. The only dependency-manifest change is - _declaring_ `bip39` in `@shared/api/package.json` (it was already a transitive - dep). `stellar-hd-wallet` is NOT used: it cannot run under jest/jsdom (its - compiled bip39 import throws - `Cannot read properties of undefined (reading 'wordlists')`). - `bip39.mnemonicToSeedSync` is byte-identical to what `stellar-hd-wallet` - wraps. -- **Exact version pins, no caret ranges.** Declare `bip39` as `"3.1.0"` (the - version already in the tree). -- **`AUTH_SALT = "freighter-auth-v1"`** — exact literal, exported as a named - constant. -- **HMAC-SHA256:** KEY = the 64-byte BIP39 seed - (`bip39.mnemonicToSeedSync(mnemonic)`); MESSAGE = `utf8(AUTH_SALT)`. Order is - `HMAC(key, message)` — never reversed. -- **User ID = lowercase hex** of the raw 32-byte Ed25519 public key (matches the - backend's canonical `sub`). -- **Pure function:** no logging, no `keyManager`, no messaging, no persistence. - Background-only usage; locked-session handling belongs to the future consumer, - not here. - -## File Structure - -| File | Responsibility | -| :-------------------------------------------------------- | :------------------------------------------------------------------------------------------ | -| `@shared/api/helpers/deriveAuthKeypair.ts` | The primitive: `AUTH_SALT`, `deriveAuthSeed`, `deriveAuthKeypair`. | -| `@shared/api/helpers/__tests__/authKeypairVectors.ts` | Committed cross-platform test vectors (canonical contract; mirrored in `freighter-mobile`). | -| `@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts` | Unit tests mapped to acceptance criteria. | -| `@shared/api/package.json` | Declare `bip39@3.1.0` (modify). | - -**Run a single test file:** -`yarn jest @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts` **Run one -test by name:** add `-t ""`. - ---- - -### Task 1: Auth seed (HMAC half) + vectors + dependency - -**Files:** - -- Modify: `@shared/api/package.json` (add `bip39` dependency) -- Create: `@shared/api/helpers/__tests__/authKeypairVectors.ts` -- Create: `@shared/api/helpers/deriveAuthKeypair.ts` -- Test: `@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts` - -**Interfaces:** - -- Produces: - - `AUTH_SALT: string` (= `"freighter-auth-v1"`) - - `deriveAuthSeed(mnemonic: string): Promise` (32 bytes) - - `AUTH_KEYPAIR_VECTORS: AuthKeypairVector[]` where - `AuthKeypairVector = { mnemonic: string; authSeedHex: string; userId: string }` - -- [ ] **Step 1: Declare the `bip39` dependency** - -In `@shared/api/package.json`, add to `dependencies` (keep alphabetical; exact -pin, no caret): - -```json - "bip39": "3.1.0", -``` - -Then install so the workspace records it (it already resolves via hoisting at -the same version, so this only updates the lockfile): - -Run: `yarn install` Expected: completes; `yarn.lock` gains a `@shared/api` → -`bip39` edge, no version change. - -- [ ] **Step 2: Create the test vectors fixture** - -Create `@shared/api/helpers/__tests__/authKeypairVectors.ts`: - -```ts -// Canonical cross-platform auth-keypair derivation vectors. -// SOURCE OF TRUTH: extension/specs/AUTH_KEYPAIR_DERIVATION.md -// freighter-mobile MUST commit the same values and assert identical output. -// authSeedHex isolates failures: a wrong authSeedHex => HMAC step diverged -// (e.g. key/message reversed); a right authSeedHex but wrong userId => -// Ed25519 step diverged. -export interface AuthKeypairVector { - mnemonic: string; - authSeedHex: string; // hex of HMAC-SHA256(seedBytes, AUTH_SALT), 32 bytes - userId: string; // lowercase hex Ed25519 public key, 64 chars -} - -export const AUTH_KEYPAIR_VECTORS: AuthKeypairVector[] = [ - { - mnemonic: - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - authSeedHex: - "cf8ef34afb730ffd0807ee8731f2378a4f6c702e2f14915976fac4afa711b52d", - userId: "ec57e5d04783b5ade776621ab171b3b197c3acc0a1cb5bad10786dc8e381e797", - }, - { - mnemonic: - "illness spike retreat truth genius clock brain pass fit cave bargain toe", - authSeedHex: - "882835b30a2c5b011f1b2424a84f4cd39f342f4d12be574e4233dbf9b98976d1", - userId: "bd9498475c7191c5e9a5e18edda2402ab0ae527580a6c38b2a32a77c65729cd7", - }, -]; -``` - -- [ ] **Step 3: Write the failing test for `deriveAuthSeed`** - -Create `@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts`: - -```ts -import { Buffer } from "buffer"; - -import { AUTH_SALT, deriveAuthSeed } from "../deriveAuthKeypair"; -import { AUTH_KEYPAIR_VECTORS } from "./authKeypairVectors"; - -describe("deriveAuthSeed (HMAC step)", () => { - it("uses the versioned domain-separation salt", () => { - expect(AUTH_SALT).toBe("freighter-auth-v1"); - }); - - it.each(AUTH_KEYPAIR_VECTORS)( - "reproduces the committed authSeed for %#", - async ({ mnemonic, authSeedHex }) => { - const seed = await deriveAuthSeed(mnemonic); - expect(seed).toHaveLength(32); - expect(Buffer.from(seed).toString("hex")).toBe(authSeedHex); - }, - ); -}); -``` - -- [ ] **Step 4: Run the test to verify it fails** - -Run: `yarn jest @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts` -Expected: FAIL — `Cannot find module '../deriveAuthKeypair'`. - -- [ ] **Step 5: Implement `AUTH_SALT` + `deriveAuthSeed`** - -Create `@shared/api/helpers/deriveAuthKeypair.ts`: - -```ts -import { Buffer } from "buffer"; -import { mnemonicToSeedSync, validateMnemonic } from "bip39"; - -/** - * Versioned domain-separation salt for the backend auth keypair. The `-v1` - * suffix reserves a migration path; derivation is deterministic, so the auth - * keypair is permanent for the life of the seed. - */ -export const AUTH_SALT = "freighter-auth-v1"; - -/** - * Computes the 32-byte auth seed: HMAC-SHA256 keyed on the wallet's 64-byte - * BIP39 seed, over the salt. Throws "Invalid mnemonic (see bip39)" on a - * malformed mnemonic. - * - * bip39 is called directly: stellar-hd-wallet cannot run under jest/jsdom (its - * compiled bip39 import throws on `wordlists`). The bytes are identical — - * stellar-hd-wallet's fromMnemonic wraps this same bip39 call. - */ -export const deriveAuthSeed = async (mnemonic: string): Promise => { - // Validate first, matching stellar-hd-wallet's error contract. - if (!validateMnemonic(mnemonic)) { - throw new Error("Invalid mnemonic (see bip39)"); - } - // BIP39 seed, 64 bytes, empty passphrase. - const seedBytes = Buffer.from(mnemonicToSeedSync(mnemonic)); - - const key = await crypto.subtle.importKey( - "raw", - seedBytes, - { name: "HMAC", hash: "SHA-256" }, - false, // not extractable - ["sign"], - ); - const mac = await crypto.subtle.sign( - "HMAC", - key, - new TextEncoder().encode(AUTH_SALT), - ); - return new Uint8Array(mac); -}; -``` - -- [ ] **Step 6: Run the test to verify it passes** - -Run: `yarn jest @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts` -Expected: PASS — 3 tests (salt + 2 vectors). - -- [ ] **Step 7: Commit** - -```bash -git add @shared/api/package.json yarn.lock \ - @shared/api/helpers/deriveAuthKeypair.ts \ - @shared/api/helpers/__tests__/authKeypairVectors.ts \ - @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts -git commit -m "feat(auth): HMAC auth-seed derivation + cross-platform vectors (#2769)" -``` - ---- - -### Task 2: Auth keypair (Ed25519 half) + acceptance-criteria tests - -**Files:** - -- Modify: `@shared/api/helpers/deriveAuthKeypair.ts` -- Test: `@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts` (extend) - -**Interfaces:** - -- Consumes: `deriveAuthSeed`, `AUTH_KEYPAIR_VECTORS` (from Task 1) -- Produces: - - `deriveAuthKeypair(mnemonic: string): Promise<{ userId: string; keypair: Keypair }>` - - `userId` is lowercase hex (64 chars); `keypair` is a `stellar-sdk` `Keypair` - (later JWT ticket calls `keypair.sign()`). - -- [ ] **Step 1: Write the failing tests for `deriveAuthKeypair`** - -Append to `@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts`. Add this -import at the top of the file (alongside the existing ones): - -```ts -import { deriveAuthKeypair } from "../deriveAuthKeypair"; -``` - -Then add these suites: - -```ts -describe("deriveAuthKeypair", () => { - // Acceptance #1: same seed -> identical userId on extension and mobile. - it.each(AUTH_KEYPAIR_VECTORS)( - "derives the committed userId for %#", - async ({ mnemonic, userId }) => { - const result = await deriveAuthKeypair(mnemonic); - expect(result.userId).toBe(userId); - }, - ); - - it("is deterministic for the same mnemonic", async () => { - const { mnemonic } = AUTH_KEYPAIR_VECTORS[0]; - const a = await deriveAuthKeypair(mnemonic); - const b = await deriveAuthKeypair(mnemonic); - expect(a.userId).toBe(b.userId); - expect(a.keypair.rawPublicKey().equals(b.keypair.rawPublicKey())).toBe( - true, - ); - }); - - it("emits a lowercase 64-char hex userId", async () => { - const { userId } = await deriveAuthKeypair( - AUTH_KEYPAIR_VECTORS[0].mnemonic, - ); - expect(userId).toMatch(/^[0-9a-f]{64}$/); - }); - - // Acceptance #2: cryptographically independent from the wallet keypair. - // Wallet account-0 public key for AUTH_KEYPAIR_VECTORS[0]'s mnemonic, hex. - // Hardcoded verified constant — StellarHDWallet (the normal way to derive it) - // cannot run under jest/jsdom (compiled bip39 import breaks). This shows the - // auth key differs from the wallet key derived from the same seed. - it("differs from the wallet account-0 public key for the same mnemonic", async () => { - const WALLET_ACCOUNT_0_HEX = - "7691d85048acc4ed085d9061ce0948bbdf7de6a92b790aaf241d31b7dcaa4238"; - const { userId } = await deriveAuthKeypair( - AUTH_KEYPAIR_VECTORS[0].mnemonic, - ); - expect(userId).not.toBe(WALLET_ACCOUNT_0_HEX); - }); - - // Acceptance #3: pure crypto, no wallet-signing / messaging side effects. - it("never sends an extension message during derivation", async () => { - const sendMessage = jest.fn(); - (globalThis as unknown as { browser?: unknown }).browser = { - runtime: { sendMessage }, - }; - try { - await deriveAuthKeypair(AUTH_KEYPAIR_VECTORS[0].mnemonic); - expect(sendMessage).not.toHaveBeenCalled(); - } finally { - delete (globalThis as unknown as { browser?: unknown }).browser; - } - }); - - it("throws on an invalid mnemonic instead of producing a key", async () => { - await expect( - deriveAuthKeypair("not a valid mnemonic phrase"), - ).rejects.toThrow(/invalid mnemonic/i); - }); -}); -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `yarn jest @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts` -Expected: FAIL — `deriveAuthKeypair` is not exported -(`TypeError: ... is not a function`). - -- [ ] **Step 3: Implement `deriveAuthKeypair`** - -In `@shared/api/helpers/deriveAuthKeypair.ts`, add the `Keypair` import and the -function. Update the import block and append at the end of the file: - -```ts -import { Keypair } from "stellar-sdk"; -``` - -```ts -/** - * 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 }; -}; -``` - -- [ ] **Step 4: Run the full test file to verify it passes** - -Run: `yarn jest @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts` -Expected: PASS — all suites (HMAC vectors, userId vectors, determinism, format, -independence, no-side-effects, invalid-mnemonic). - -- [ ] **Step 5: Typecheck and lint the new files** - -Run: `yarn workspace @shared/api tsc --noEmit` (or the repo's root typecheck -script if `@shared/api` has none — check `package.json`); then -`yarn eslint @shared/api/helpers/deriveAuthKeypair.ts @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts @shared/api/helpers/__tests__/authKeypairVectors.ts` -Expected: no type errors, no lint errors. - -- [ ] **Step 6: Commit** - -```bash -git add @shared/api/helpers/deriveAuthKeypair.ts \ - @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts -git commit -m "feat(auth): derive Ed25519 auth keypair + userId from seed (#2769)" -``` - ---- - -## Self-Review - -**Spec coverage:** - -- Scope (primitive + tests + vectors only) → Tasks 1–2; no JWT/handler/UI. ✓ -- Crypto decisions (`crypto.subtle` HMAC + `stellar-sdk` Keypair, zero new libs) - → Task 1 Step 5, Task 2 Step 3, Global Constraints. ✓ -- Exact algorithm (64-byte seed as HMAC key, salt as message, lowercase-hex - userId) → Task 1 Step 5, Task 2 Step 3. ✓ -- `AUTH_SALT` constant → Task 1 Step 5 + test. ✓ -- Cross-platform vectors → Task 1 Step 2; asserted in Task 1 (authSeed) + Task 2 - (userId). ✓ -- Acceptance #1 (parity) → Task 2 Step 1 vector tests. ✓ -- Acceptance #2 (independence, reworded) → Task 2 independence test. ✓ -- Acceptance #3 (no signing prompt) → Task 2 no-side-effects test + pure impl. ✓ -- Negative (invalid mnemonic fails loudly) → Task 2; verified `fromMnemonic` - throws. ✓ -- Lifecycle/locked-session → spec marks it the consumer's concern; correctly NOT - implemented here. ✓ - -**Placeholder scan:** none — all code and commands are concrete. - -**Type consistency:** `deriveAuthSeed → Promise`, -`deriveAuthKeypair → Promise<{ userId: string; keypair: Keypair }>`, -`AuthKeypairVector { mnemonic, authSeedHex, userId }` consistent across both -tasks and the fixture. ✓ - -## Follow-ups (out of this plan, tracked in the spec) - -- `freighter-mobile` mirrors the algorithm + `authKeypairVectors.ts`. -- Update ticket #2769 acceptance #2 wording to "cryptographically independent - from the wallet keypair." diff --git a/extension/specs/AUTH_KEYPAIR_DERIVATION.md b/extension/specs/AUTH_KEYPAIR_DERIVATION.md deleted file mode 100644 index ec62a8d063..0000000000 --- a/extension/specs/AUTH_KEYPAIR_DERIVATION.md +++ /dev/null @@ -1,249 +0,0 @@ -# Auth Keypair Derivation Spec - -> Design doc for [stellar/freighter#2769](https://github.com/stellar/freighter/issues/2769) -> — _[Extension] Derive auth keypair from seed for backend authentication._ -> Date: 2026-06-25. Status: approved, ready for implementation. - -## Overview - -`freighter-backend-v2` authenticates clients with a stateless, per-request JWT: -each request carries an `Authorization: Bearer ` whose `sub` claim is the -hex-encoded Ed25519 **auth public key**, and the server verifies the request -signature against that key. The auth public key _is_ the user's anonymous -backend user ID ("Unified User Id") — separate from any Stellar `G…` keypair and -never used for wallet signing. - -This spec covers **one primitive**: deriving that auth keypair (and hex user ID) -from the wallet's mnemonic on the Freighter extension, via -`HMAC-SHA256(seedBytes, "freighter-auth-v1")` → Ed25519 keypair. - -See the canonical [Cross-Platform Contact Sync Design Doc](https://github.com/stellar/wallet-eng-monorepo/blob/main/design-docs/contact-lists/Freighter%20Authenticated%20Contact%20Sync%20Design%20Doc.md) -(Auth Flow + Key properties) for the end-to-end scheme. - -## Scope - -**In scope (this ticket / PR):** - -- A pure derivation primitive: `mnemonic → { userId, keypair }`. -- Its unit tests. -- Committed cross-platform test vectors (so `freighter-mobile` can byte-match). - -**Out of scope (downstream, blocked tickets):** - -- Per-request JWT generation in `@shared/api` (the function consumer). -- Any handler wiring, the contacts feature, or UI. -- Lifecycle/caching of the keypair (decided here as a _requirement on the - consumer_, but not implemented in this PR). - -The function is _shaped_ so the JWT ticket can call it on-demand from the -background, but this PR ships only the primitive + tests + vectors. - -## Backend contract (already implemented, PR open) - -The server is fully stateless and **never computes the HMAC**. It reads `sub` -from each JWT, decodes it as an Ed25519 public key, and verifies the request -signature against it (`internal/auth/parser.go`). Consequences for this primitive: - -- The **only** thing our output must byte-match is `freighter-mobile`, not the - server. -- The server canonicalizes `sub` to **lowercase hex** (`parser.go` re-encodes - the decoded key), so the client must emit the user ID as lowercase hex for the - client-side and server-side IDs to be identical. - -## Threat model - -Both leak threats are treated as first-class: - -1. **Private key / seed exposure → impersonation.** Anyone with the auth private - key (or the seed it derives from) can mint valid JWTs and read/overwrite the - user's encrypted contact blob. This is the severe threat. Mitigation: the - primitive is pure crypto that runs **only in the background** (where the - mnemonic already lives); it never logs key material, never crosses to the - popup/content scripts, and persists nothing new at rest. -2. **Public user ID correlation → privacy.** The user ID is the _public_ key; - leaking it can't forge anything, but it can link a person to their contact - blob. Mostly mitigated outside this ticket (TLS, backend access control). Here - we simply never log the user ID and never persist it in plaintext storage. - -## Crypto decisions - -All three primitives are **already in the project — no new library is -introduced** (`bip39` is promoted from a transitive dep to a declared one in -`@shared/api`, pinned to `3.1.0`). - -| Step | Choice | Rationale | -| :-------------- | :------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| BIP39 seed | `bip39` (`mnemonicToSeedSync`/`validateMnemonic`) | The standard mnemonic→seed function. Called directly rather than via `stellar-hd-wallet` (whose compiled bip39 import cannot run under jest/jsdom). Byte-identical output — `stellar-hd-wallet` wraps the same call. | -| HMAC-SHA256 | `crypto.subtle` | Already the extension's core crypto layer (`extension/src/background/helpers/session.ts` uses it for AES-GCM session encryption + PBKDF2). Native, audited, no new dep. | -| Ed25519 keypair | `stellar-sdk` `Keypair.fromRawEd25519Seed` | Already a core dep, already used for wallet keys. `Keypair.rawPublicKey()` gives the raw 32-byte pubkey (the user ID); `Keypair.sign()` gives a raw 64-byte Ed25519 signature, which the downstream JWT ticket reuses directly. | - -**Correctness is library-independent.** HMAC-SHA256 and Ed25519 derivation/signing -are standardized and deterministic (RFC 8032), so any correct implementation -produces identical bytes. Cross-platform parity is guaranteed by the algorithm -spec + committed test vectors below — _not_ by extension and mobile happening to -use the same library. (This is why we did **not** add `tweetnacl` just to mirror -mobile's library.) - -## The primitive - -**Location:** `@shared/api/helpers/deriveAuthKeypair.ts` -(auth code is destined for `@shared/api` per the JWT ticket; sits beside existing -helpers). Tests: `@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts`. - -**Signature (pure, async):** - -```ts -import { Keypair } from "stellar-sdk"; - -/** - * Derives the Freighter backend auth keypair from the wallet mnemonic. - * Pure crypto: no logging, no keyManager, no messaging, no persistence. - * The caller is responsible for supplying the mnemonic (which requires an - * unlocked session) and for handling the locked-session case. - * - * @returns userId hex-encoded Ed25519 public key (lowercase, 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 }>; -``` - -Taking the **mnemonic** (not a pre-computed `seedBytes`) keeps the entire -must-match chain inside one function, so the cross-platform vector is simply -`mnemonic → userId` with nothing for mobile to get wrong at a boundary. - -**Exact algorithm — this is the cross-platform contract:** - -```ts -// 1. BIP39 seed: 64 bytes, EMPTY passphrase. validateMnemonic rejects malformed -// input (the "Invalid mnemonic" error contract); mnemonicToSeedSync produces -// the 64-byte seed. We call `bip39` directly: stellar-hd-wallet cannot run -// under the jest/jsdom test env (its compiled bip39 default-import breaks with -// "Cannot read properties of undefined (reading 'wordlists')"). The bytes are -// identical — stellar-hd-wallet's fromMnemonic() wraps this same bip39 call — -// so cross-platform parity is unaffected (mobile may use either library). -if (!validateMnemonic(mnemonic)) - throw new Error("Invalid mnemonic (see bip39)"); -const seedBytes = Buffer.from(mnemonicToSeedSync(mnemonic)); // 64 bytes - -// 2. HMAC-SHA256. KEY = seedBytes (the 64-byte seed). MESSAGE = utf8(SALT). -// Order matters — HMAC(key, message). Pinned by the test vectors so it -// can never be silently reversed. -const key = await crypto.subtle.importKey( - "raw", - seedBytes, - { name: "HMAC", hash: "SHA-256" }, - false, // not extractable - ["sign"], -); -const authSeed = new Uint8Array( - await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(AUTH_SALT)), -); // 32 bytes - -// 3. Ed25519 keypair from the 32-byte authSeed. -const keypair = Keypair.fromRawEd25519Seed(Buffer.from(authSeed)); - -// 4. User ID = lowercase hex of the raw 32-byte pubkey (matches backend `sub`). -const userId = keypair.rawPublicKey().toString("hex"); -``` - -**Constant:** `AUTH_SALT = "freighter-auth-v1"` — the versioned -domain-separation string from the design doc, exported as a named constant so -extension and mobile reference the identical literal. The `-v1` suffix reserves a -migration path; deterministic derivation means the auth keypair is permanent for -the life of the seed (rotating it changes the user's identity). - -## Lifecycle & session timeout (requirement on the consumer) - -Investigated to settle whether the keypair must be cached. **Conclusion: -on-demand derivation, no caching.** - -- The auth private key is gated behind the same unlocked state as wallet signing. - In the background, the mnemonic is cached **encrypted** in the session - temporary store (`store-account.ts` writes it under `TEMPORARY_STORE_EXTRA_ID`), - decryptable only with the in-memory `hashKey`. -- On session timeout, the `session-timer` alarm fires → - `clearSession()` clears `hashKey` (`ducks/session.ts`). After that, **nothing** - can decrypt the mnemonic or private key. -- Therefore **caching buys nothing against timeout**: a keypair cached in the - encrypted store is itself undecryptable once `hashKey` is gone; caching only - the public user ID still can't _sign_. Every path that can sign a JWT requires - the unlocked session — which is correct: a JWT authorizes reading/overwriting - the contact blob, so it _should_ require the same unlock as touching the wallet. -- There is **no "timed out but still on an active page" state.** The alarm - broadcasts `SESSION_LOCKED`; `SessionLockListener` (mounted on every UI surface) - immediately dispatches `lockAccount()` and navigates to the unlock screen - (`extension/src/popup/components/SessionLockListener/index.tsx`). - -**Requirements this places on the JWT ticket (not this PR):** - -- Derive on-demand; persist nothing new at rest. -- The background accessor that fetches the mnemonic must treat "no mnemonic / - locked" as a **typed, graceful result** (not a thrown error), so an in-flight - derivation that races the lock folds cleanly into the unlock redirect already - in progress. -- Optional, YAGNI: if per-request derivation ever proves hot (`mnemonicToSeedSync` - runs PBKDF2 ×2048, a few ms), memoize the keypair in **volatile in-memory** - session state cleared on timeout alongside `hashKey` — never at rest. - -## Test plan - -Verified end-to-end with the exact algorithm. Vectors are committed in a fixture -(`authKeypairVectors.ts`) in **both** repos; this doc is the canonical source. - -| Mnemonic | `authSeed` (hex) | `userId` (hex pubkey) | -| :---------------------------------------------------------------------------------------------- | :----------------------------------------------------------------- | :----------------------------------------------------------------- | -| `abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about` | `cf8ef34afb730ffd0807ee8731f2378a4f6c702e2f14915976fac4afa711b52d` | `ec57e5d04783b5ade776621ab171b3b197c3acc0a1cb5bad10786dc8e381e797` | -| `illness spike retreat truth genius clock brain pass fit cave bargain toe` | `882835b30a2c5b011f1b2424a84f4cd39f342f4d12be574e4233dbf9b98976d1` | `bd9498475c7191c5e9a5e18edda2402ab0ae527580a6c38b2a32a77c65729cd7` | - -The intermediate `authSeed` is included so a failing mobile test can localize the -divergence: a wrong `authSeed` means the HMAC step (e.g. key/message reversed); a -right `authSeed` but wrong `userId` means the Ed25519 step. - -**`deriveAuthKeypair.test.ts`:** - -1. **Cross-platform match → acceptance #1.** For each vector, - `(await deriveAuthKeypair(mnemonic)).userId === expectedUserId`, and the - internal `authSeed` equals the fixture (catches a reversed HMAC key/message). -2. **Determinism.** Same mnemonic twice → identical `userId` and pubkey bytes. -3. **`crypto.subtle` reproduces the reference vectors.** Specifically guards the - `importKey`/`sign` call against swapping key vs message — the easiest bug to - introduce, the hardest to eyeball. -4. **Independence from the wallet key → acceptance #2.** Derive wallet account 0 - from the same mnemonic; assert `userId !== walletPubkeyHex`. _Verified:_ auth - `ec57e5d0…` ≠ wallet `7691d850…` for vector 1. -5. **No signing side effects → acceptance #3.** The primitive imports nothing - from the `keyManager`/popup/messaging path; a spy asserts - `browser.runtime.sendMessage` is never called. -6. **Format.** `userId` is lowercase hex, length 64. -7. **Negative.** An invalid mnemonic throws a clear error (fails loudly rather - than producing a garbage key). - -## Acceptance criteria - -Reworded from the ticket (see note on #2): - -1. The same seed produces an identical auth keypair (and user ID) on extension - and mobile — enforced by the shared test vectors. -2. **The auth keypair is cryptographically independent from the wallet keypair - and is never used as a Stellar address.** _(Reworded — see below.)_ -3. No wallet-signing prompt is triggered by derivation or use — the primitive is - pure crypto with no signing-path dependencies. - -> **Ticket correction (#2769).** The original criterion _"Auth pubkey is not a -> valid Stellar G address"_ is technically false: any 32 bytes StrKey-encode into -> a format-valid `G…` address (ours encodes to -> `GDWFPZOQI6B3LLPHOZRBVMLRWOYZPQ5MYCQ4WW5NCB4G3SHDQHTZOVCI`). The meaningful, -> true property is **cryptographic independence from the wallet keypair**. Update -> the ticket text to match this wording. - -## Follow-ups / open items - -- **`freighter-mobile` must mirror the algorithm + vectors.** Track a sibling - ticket so mobile commits the same `authKeypairVectors.ts` and asserts identical - output. This doc is the canonical contract. -- **Update ticket #2769** acceptance #2 wording (above). -- **No new dependencies** are introduced (`crypto.subtle` + `stellar-sdk` are - already present). Confirm in review. From 6245cddfdebf93b31dbb8d67835ccf23c3f19e3b Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Fri, 26 Jun 2026 10:45:25 -0400 Subject: [PATCH 10/10] fix(auth): correct fixture import path after moving it out of __tests__ (#2769) --- @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts b/@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts index 5c8e19ae67..1e1dc8364c 100644 --- a/@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts +++ b/@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts @@ -5,7 +5,7 @@ import { deriveAuthKeypair, deriveAuthSeed, } from "../deriveAuthKeypair"; -import { AUTH_KEYPAIR_VECTORS } from "./authKeypairVectors"; +import { AUTH_KEYPAIR_VECTORS } from "../authKeypairVectors"; describe("deriveAuthSeed (HMAC step)", () => { it("uses the versioned domain-separation salt", () => {