Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions @shared/api/helpers/__tests__/deriveAuthKeypair.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
28 changes: 28 additions & 0 deletions @shared/api/helpers/authKeypairVectors.ts
Original file line number Diff line number Diff line change
@@ -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",
},
];
66 changes: 66 additions & 0 deletions @shared/api/helpers/deriveAuthKeypair.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array> => {
// 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 };
};
1 change: 1 addition & 0 deletions @shared/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions extension/src/popup/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions extension/src/popup/locales/pt/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading