Skip to content
Merged
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
138 changes: 138 additions & 0 deletions @shared/api/helpers/__tests__/authedFetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { Buffer } from "buffer";
import { Keypair } from "stellar-sdk";

import { authedFetch } from "../authedFetch";

const KP = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 9));
const resp = (status: number): Response => new Response(null, { status });

const authHeaderOf = (call: unknown[]): string =>
((call[1] as RequestInit).headers as Record<string, string>).Authorization;

const claimsOf = (call: unknown[]): Record<string, unknown> => {
const jwt = authHeaderOf(call).replace(/^Bearer /, "");
const payload = jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
return JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
};

describe("authedFetch", () => {
it("returns the response and does not retry on success", async () => {
const fetchImpl = jest.fn().mockResolvedValue(resp(200));
const r = await authedFetch({
keypair: KP,
baseUrl: "http://x",
method: "GET",
path: "/p",
fetchImpl,
});
expect(r.status).toBe(200);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(authHeaderOf(fetchImpl.mock.calls[0])).toMatch(/^Bearer .+/);
});

it("regenerates the JWT and retries once on 401, returning the retry result", async () => {
const fetchImpl = jest
.fn()
.mockResolvedValueOnce(resp(401))
.mockResolvedValueOnce(resp(200));
const r = await authedFetch({
keypair: KP,
baseUrl: "http://x",
method: "GET",
path: "/p",
fetchImpl,
});
expect(r.status).toBe(200);
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(authHeaderOf(fetchImpl.mock.calls[1])).toMatch(/^Bearer .+/);
});

it("retries at most once on persistent 401", async () => {
const fetchImpl = jest.fn().mockResolvedValue(resp(401));
const r = await authedFetch({
keypair: KP,
baseUrl: "http://x",
method: "GET",
path: "/p",
fetchImpl,
});
expect(r.status).toBe(401);
expect(fetchImpl).toHaveBeenCalledTimes(2);
});

it("sets Content-Type: application/json for non-GET by default", async () => {
const fetchImpl = jest.fn().mockResolvedValue(resp(201));
await authedFetch({
keypair: KP,
baseUrl: "http://x",
method: "POST",
path: "/p",
body: "{}",
fetchImpl,
});
const hdrs = (fetchImpl.mock.calls[0][1] as RequestInit).headers as Record<
string,
string
>;
expect(hdrs["Content-Type"]).toBe("application/json");
});

it("upper-cases the wire method so it matches the signed methodAndPath", async () => {
// fetch does not auto-uppercase PATCH/custom verbs; authedFetch must, or the
// server's r.Method ("patch") won't match the JWT's "PATCH ..." claim -> 401.
const fetchImpl = jest.fn().mockResolvedValue(resp(200));
await authedFetch({
keypair: KP,
baseUrl: "http://x",
method: "patch",
path: "/p",
body: "{}",
fetchImpl,
});
expect((fetchImpl.mock.calls[0][1] as RequestInit).method).toBe("PATCH");
});

it("signs the full request target when baseUrl carries an /api/v1 prefix", async () => {
// INDEXER_V2_URL is "<host>/api/v1"; callers append the endpoint suffix. The
// signed methodAndPath must be the full URI the server sees, not the bare
// path fragment, or the request 401s.
const fetchImpl = jest.fn().mockResolvedValue(resp(200));
await authedFetch({
keypair: KP,
baseUrl: "http://x/api/v1",
method: "GET",
path: "/contacts",
fetchImpl,
});
expect(fetchImpl.mock.calls[0][0]).toBe("http://x/api/v1/contacts");
expect(claimsOf(fetchImpl.mock.calls[0]).methodAndPath).toBe(
"GET /api/v1/contacts",
);
});

it("includes the query string in the signed request target", async () => {
const fetchImpl = jest.fn().mockResolvedValue(resp(200));
await authedFetch({
keypair: KP,
baseUrl: "http://x/api/v1",
method: "GET",
path: "/contacts?cursor=abc",
fetchImpl,
});
expect(claimsOf(fetchImpl.mock.calls[0]).methodAndPath).toBe(
"GET /api/v1/contacts?cursor=abc",
);
});

it("strips a trailing slash from baseUrl when building the URL", async () => {
const fetchImpl = jest.fn().mockResolvedValue(resp(200));
await authedFetch({
keypair: KP,
baseUrl: "http://x/",
method: "GET",
path: "/p",
fetchImpl,
});
expect(fetchImpl.mock.calls[0][0]).toBe("http://x/p");
});
});
106 changes: 106 additions & 0 deletions @shared/api/helpers/__tests__/buildAuthJwt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { Buffer } from "buffer";
import { Keypair } from "stellar-sdk";

import { buildAuthJwt, ISS, JWT_LIFETIME_SECONDS } from "../buildAuthJwt";

const KP = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 7));
const NOW = 1_700_000_000_000; // fixed ms epoch
const PATH = "/api/v1/auth/whoami";

const decodeSegment = (seg: string): Record<string, unknown> =>
JSON.parse(
Buffer.from(seg.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString(
"utf8",
),
);

describe("buildAuthJwt", () => {
it("uses an EdDSA JWS header", async () => {
const jwt = await buildAuthJwt({
keypair: KP,
method: "GET",
path: PATH,
now: NOW,
});
expect(decodeSegment(jwt.split(".")[0])).toEqual({
alg: "EdDSA",
typ: "JWT",
});
});

it("sets the documented claims", async () => {
const jwt = await buildAuthJwt({
keypair: KP,
method: "GET",
path: PATH,
now: NOW,
});
const claims = decodeSegment(jwt.split(".")[1]);
expect(claims.sub).toBe(KP.rawPublicKey().toString("hex"));
expect(claims.iss).toBe(ISS);
expect(claims.iat).toBe(Math.floor(NOW / 1000));
expect((claims.exp as number) - (claims.iat as number)).toBe(
JWT_LIFETIME_SECONDS,
);
expect(claims.methodAndPath).toBe("GET /api/v1/auth/whoami");
});

it("hashes an absent body to the empty-input SHA-256", async () => {
const jwt = await buildAuthJwt({
keypair: KP,
method: "GET",
path: PATH,
now: NOW,
});
expect(decodeSegment(jwt.split(".")[1]).bodyHash).toBe(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
);
});

it("hashes a non-empty body with hex SHA-256", async () => {
const jwt = await buildAuthJwt({
keypair: KP,
method: "PUT",
path: "/x",
body: "hello",
now: NOW,
});
expect(decodeSegment(jwt.split(".")[1]).bodyHash).toBe(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
);
});

it("produces a signature that verifies over the signing input", async () => {
const jwt = await buildAuthJwt({
keypair: KP,
method: "GET",
path: PATH,
now: NOW,
});
const [h, p, s] = jwt.split(".");
const sig = Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64");
expect(KP.verify(Buffer.from(`${h}.${p}`, "utf8"), sig)).toBe(true);
});

it("emits url-safe base64 with no padding", async () => {
const jwt = await buildAuthJwt({
keypair: KP,
method: "GET",
path: PATH,
now: NOW,
});
expect(jwt).not.toMatch(/[+/=]/);
});

it("uppercases the method in methodAndPath", async () => {
const jwt = await buildAuthJwt({
keypair: KP,
method: "get",
path: PATH,
now: NOW,
});
expect(decodeSegment(jwt.split(".")[1]).methodAndPath).toBe(
"GET /api/v1/auth/whoami",
);
});
});
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);
});
});
29 changes: 29 additions & 0 deletions @shared/api/helpers/authKeypairVectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Test fixture: consumed only by the derivation unit tests, not by production code.
// It is the canonical cross-platform parity contract — freighter-mobile commits the
// same values and asserts identical output.
// SOURCE OF TRUTH: wallet-eng-monorepo design-docs/contact-lists/Freighter Auth Keypair Derivation Design Doc.md
// 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",
},
];
Loading
Loading