diff --git a/@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts b/@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts new file mode 100644 index 0000000000..1e1dc8364c --- /dev/null +++ b/@shared/api/helpers/__tests__/deriveAuthKeypair.test.ts @@ -0,0 +1,88 @@ +import { Buffer } from "buffer"; + +import { + AUTH_SALT, + deriveAuthKeypair, + 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); + }, + ); +}); + +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: 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 = { + 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/authKeypairVectors.ts b/@shared/api/helpers/authKeypairVectors.ts new file mode 100644 index 0000000000..36d6f38793 --- /dev/null +++ b/@shared/api/helpers/authKeypairVectors.ts @@ -0,0 +1,28 @@ +// Canonical cross-platform auth-keypair derivation vectors. +// 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 => +// 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/deriveAuthKeypair.ts b/@shared/api/helpers/deriveAuthKeypair.ts new file mode 100644 index 0000000000..2777a0667c --- /dev/null +++ b/@shared/api/helpers/deriveAuthKeypair.ts @@ -0,0 +1,66 @@ +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` + * 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. + * @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. + 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); +}; + +/** + * 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 }; +}; diff --git a/@shared/api/package.json b/@shared/api/package.json index abb8f8423a..d4994e36ab 100644 --- a/@shared/api/package.json +++ b/@shared/api/package.json @@ -7,6 +7,7 @@ "@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-sdk": "npm:@stellar/stellar-sdk@16.0.0-rc.1", "stellar-sdk-next": "npm:@stellar/stellar-sdk@16.0.0-rc.1", 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..00f3f7ce3a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5429,6 +5429,7 @@ __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-sdk: "npm:@stellar/stellar-sdk@16.0.0-rc.1" stellar-sdk-next: "npm:@stellar/stellar-sdk@16.0.0-rc.1"