From 8c4a2bbef2c4acb7486e3b6ffa527e8808078940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 15 Oct 2026 13:36:27 -0700 Subject: [PATCH 1/2] fix(km): make Albedo and Freighter signing follow the configured network Correctness fixes for how the key manager picks the network when a key is held by an external wallet (Albedo or Freighter). - Albedo handler: resolve the network the same way the Freighter handler does (custom.networkPassphrase, then key.network, then Networks.PUBLIC), pass it to Albedo, and parse the signed envelope with it, instead of relying on Albedo's default network and a fixed Networks.PUBLIC. Networks Albedo's tx intent doesn't support are rejected before Albedo opens, and a response for another network is rejected. - Albedo and Freighter handlers: throw a "Network mismatch" error, before opening the wallet, when the resolved network differs from the transaction's networkPassphrase. - KeyManager.signTransaction(): for keys without a network, fall back to defaultNetworkPassphrase. The caller's custom.networkPassphrase and the key's network still come first, and other custom fields are passed through unchanged. - KeyManager.fetchAuthToken(): pass the network the challenge was validated against to the handler, so Albedo and Freighter keys without a network sign the challenge for that network. Tests: - Add albedo.test.ts, with a stand-in for Albedo that signs for the requested network as albedo.link does. It covers signing for the key's network, custom.networkPassphrase taking precedence, the default network, a response for the wrong network, networks Albedo doesn't support, and network mismatches. - Cover network mismatches in freighter.test.ts. - Cover the defaultNetworkPassphrase fallback in signTransaction() and fetchAuthToken() in keyManager.test.ts, including a transaction for another network refused by a KeyManager and the caller's other custom fields reaching Freighter. Co-Authored-By: Claude Opus 5.5 --- .../src/Handlers/albedo.ts | 38 ++- .../src/Handlers/freighter.ts | 8 + .../src/Types/index.ts | 3 +- .../src/keyManager.ts | 24 +- .../test/albedo.test.ts | 193 ++++++++++++++ .../test/fixtures/albedo.ts | 40 +++ .../test/freighter.test.ts | 36 +++ .../test/keyManager.test.ts | 240 ++++++++++++++++++ 8 files changed, 575 insertions(+), 7 deletions(-) create mode 100644 @stellar/typescript-wallet-sdk-km/test/albedo.test.ts create mode 100644 @stellar/typescript-wallet-sdk-km/test/fixtures/albedo.ts diff --git a/@stellar/typescript-wallet-sdk-km/src/Handlers/albedo.ts b/@stellar/typescript-wallet-sdk-km/src/Handlers/albedo.ts index c9a9b65..e9cb722 100644 --- a/@stellar/typescript-wallet-sdk-km/src/Handlers/albedo.ts +++ b/@stellar/typescript-wallet-sdk-km/src/Handlers/albedo.ts @@ -11,10 +11,17 @@ import { KeyType, } from "../Types"; +// Albedo's tx intent takes a network name, and it supports only these two +// networks. +const albedoNetworks = new Map([ + [Networks.PUBLIC, "public"], + [Networks.TESTNET, "testnet"], +]); + export const albedoHandler: KeyTypeHandler = { keyType: KeyType.albedo, async signTransaction(params: HandlerSignTransactionParams) { - const { transaction, key } = params; + const { transaction, key, custom } = params; if (key.privateKey !== "") { throw new Error( @@ -24,20 +31,45 @@ export const albedoHandler: KeyTypeHandler = { ); } + const networkPassphrase = + (custom && custom.networkPassphrase) || key.network || Networks.PUBLIC; + + // Albedo signs for the network named here, not the one the transaction + // was built for, so refuse rather than sign for a different network. + if (networkPassphrase !== transaction.networkPassphrase) { + throw new Error( + `Network mismatch: the transaction is for "${transaction.networkPassphrase}", but signing was requested for "${networkPassphrase}"`, + ); + } + + const network = albedoNetworks.get(networkPassphrase); + + if (!network) { + throw new Error( + `Albedo can only sign for Networks.PUBLIC or Networks.TESTNET, not "${networkPassphrase}"`, + ); + } + try { const xdr = transaction.toXdr(); - const response = await albedo.tx({ xdr }); + const response = await albedo.tx({ xdr, network }); if (!response.signed_envelope_xdr) { throw new Error("We couldn’t sign the transaction with Albedo."); } + if (response.network !== network) { + throw new Error( + `Albedo signed for the "${response.network}" network, but "${network}" was requested`, + ); + } + // fromXdr() returns type "Transaction | FeeBumpTransaction" and // signTransaction() doesn't like "| FeeBumpTransaction" type, so casting // to "Transaction" type. return TransactionBuilder.fromXdr( response.signed_envelope_xdr, - Networks.PUBLIC, + networkPassphrase, ) as Transaction; } catch (error) { const errorMsg = error.toString(); diff --git a/@stellar/typescript-wallet-sdk-km/src/Handlers/freighter.ts b/@stellar/typescript-wallet-sdk-km/src/Handlers/freighter.ts index 847e41f..1670ec2 100644 --- a/@stellar/typescript-wallet-sdk-km/src/Handlers/freighter.ts +++ b/@stellar/typescript-wallet-sdk-km/src/Handlers/freighter.ts @@ -25,6 +25,14 @@ export const freighterHandler: KeyTypeHandler = { const networkPassphrase = (custom && custom.networkPassphrase) || key.network || Networks.PUBLIC; + // Freighter signs for the network named here, not the one the transaction + // was built for, so refuse rather than sign for a different network. + if (networkPassphrase !== transaction.networkPassphrase) { + throw new Error( + `Network mismatch: the transaction is for "${transaction.networkPassphrase}", but signing was requested for "${networkPassphrase}"`, + ); + } + try { const response = await freighterApi.signTransaction(transaction.toXdr(), { networkPassphrase, diff --git a/@stellar/typescript-wallet-sdk-km/src/Types/index.ts b/@stellar/typescript-wallet-sdk-km/src/Types/index.ts index 697de67..5310ecc 100644 --- a/@stellar/typescript-wallet-sdk-km/src/Types/index.ts +++ b/@stellar/typescript-wallet-sdk-km/src/Types/index.ts @@ -13,7 +13,8 @@ export interface BaseKey { extra?: any; path?: string; publicKey: string; - // if the network is not set, the key is assumed to be on Networks.PUBLIC + // if the network is not set, KeyManager assumes the key is on its default + // network passphrase (Networks.PUBLIC unless configured otherwise) network?: string; type: KeyType | string; } diff --git a/@stellar/typescript-wallet-sdk-km/src/keyManager.ts b/@stellar/typescript-wallet-sdk-km/src/keyManager.ts index 83a42b3..db4d101 100644 --- a/@stellar/typescript-wallet-sdk-km/src/keyManager.ts +++ b/@stellar/typescript-wallet-sdk-km/src/keyManager.ts @@ -69,7 +69,8 @@ export class KeyManager { } /** - * Set the default network passphrase + * Set the default network passphrase, used for keys that don't set their + * own network. * @param {string} passphrase - The passphrase. */ public setDefaultNetworkPassphrase(passphrase: string) { @@ -184,7 +185,10 @@ export class KeyManager { * @param {string} params.id - Key to sign with. The id is computed as * `sha1(private key + public key)`. * @param {string} params.password - the key password - * @param {{[key: string]: any}} [params.custom] - custom arguments + * @param {{[key: string]: any}} [params.custom] - custom arguments. For keys + * held by an external wallet (Freighter, Albedo), `networkPassphrase` is + * the network to sign for. It defaults to the key's network, then to the + * default network passphrase, and must match the transaction's network. * @returns {Promise} Signed transaction */ public async signTransaction( @@ -208,10 +212,20 @@ export class KeyManager { } const keyHandler = this.keyHandlerMap[key.type]; + + // Handlers that sign through an external wallet (Freighter, Albedo) can't + // see defaultNetworkPassphrase, so fill it in for keys without a network. + // Those handlers refuse a transaction built for any other network. const signedTransaction = await keyHandler.signTransaction({ transaction, key, - custom, + custom: { + ...custom, + networkPassphrase: + (custom && custom.networkPassphrase) || + key.network || + this.defaultNetworkPassphrase, + }, }); return signedTransaction; } @@ -388,9 +402,13 @@ export class KeyManager { const keyHandler = this.keyHandlerMap[key.type]; + // Handlers that sign through an external wallet (Freighter, Albedo) can't + // see defaultNetworkPassphrase, so tell them which network the challenge + // was validated against. const signedTransaction = await keyHandler.signTransaction({ transaction, key, + custom: { networkPassphrase: keyNetwork }, }); const signedTransactionXDR: string = signedTransaction diff --git a/@stellar/typescript-wallet-sdk-km/test/albedo.test.ts b/@stellar/typescript-wallet-sdk-km/test/albedo.test.ts new file mode 100644 index 0000000..84250db --- /dev/null +++ b/@stellar/typescript-wallet-sdk-km/test/albedo.test.ts @@ -0,0 +1,193 @@ +import { + Keypair, + Networks, + Account, + TransactionBuilder, + Operation, + Transaction, + Asset, + BASE_FEE, + WebAuth, +} from "@stellar/stellar-sdk"; +import albedo from "@albedo-link/intent"; + +import { albedoSigningWith } from "./fixtures/albedo"; +import { albedoHandler } from "../src/Handlers/albedo"; +import { Key, KeyType } from "../src/Types"; + +jest.mock("@albedo-link/intent", () => ({ + __esModule: true, + default: { + tx: jest.fn(), + }, +})); + +const mockTx = albedo.tx as jest.Mock; + +function buildTestTransaction( + source: Keypair, + networkPassphrase: string, +): Transaction { + return new TransactionBuilder(new Account(source.publicKey(), "100"), { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation( + Operation.payment({ + destination: Keypair.random().publicKey(), + asset: Asset.native(), + amount: "10", + }), + ) + .setTimeout(30) + .build(); +} + +function makeAlbedoKey(signer: Keypair, overrides: Partial = {}): Key { + return { + id: "albedo-test-key", + privateKey: "", + publicKey: signer.publicKey(), + type: KeyType.albedo, + ...overrides, + }; +} + +describe("albedoHandler", () => { + let signer: Keypair; + + beforeEach(() => { + jest.clearAllMocks(); + signer = Keypair.random(); + mockTx.mockImplementation(albedoSigningWith(signer)); + }); + + it("signs for the network the key is scoped to", async () => { + const tx = buildTestTransaction(signer, Networks.TESTNET); + const key = makeAlbedoKey(signer, { network: Networks.TESTNET }); + + const result = await albedoHandler.signTransaction({ + transaction: tx, + key, + }); + + expect(result.networkPassphrase).toBe(Networks.TESTNET); + expect(WebAuth.verifyTxSignedBy(result, signer.publicKey())).toBe(true); + }); + + it("signs for custom.networkPassphrase over the key's network", async () => { + const tx = buildTestTransaction(signer, Networks.TESTNET); + const key = makeAlbedoKey(signer, { network: Networks.PUBLIC }); + + const result = await albedoHandler.signTransaction({ + transaction: tx, + key, + custom: { networkPassphrase: Networks.TESTNET }, + }); + + expect(result.networkPassphrase).toBe(Networks.TESTNET); + expect(WebAuth.verifyTxSignedBy(result, signer.publicKey())).toBe(true); + }); + + it("asks Albedo for the public network when no network is set", async () => { + const tx = buildTestTransaction(signer, Networks.PUBLIC); + const key = makeAlbedoKey(signer); + + const result = await albedoHandler.signTransaction({ + transaction: tx, + key, + }); + + expect(mockTx).toHaveBeenCalledWith( + expect.objectContaining({ xdr: tx.toXdr(), network: "public" }), + ); + expect(result.networkPassphrase).toBe(Networks.PUBLIC); + expect(WebAuth.verifyTxSignedBy(result, signer.publicKey())).toBe(true); + }); + + it("throws when Albedo signs for a different network than requested", async () => { + mockTx.mockImplementation(albedoSigningWith(signer, "public")); + const tx = buildTestTransaction(signer, Networks.TESTNET); + const key = makeAlbedoKey(signer, { network: Networks.TESTNET }); + + await expect( + albedoHandler.signTransaction({ transaction: tx, key }), + ).rejects.toThrow(/"public".*"testnet"/); + }); + + it.each([ + ["Networks.FUTURENET", Networks.FUTURENET], + ["a private network passphrase", "Private Network ; October 2026"], + ])("refuses %s without opening Albedo", async (_, networkPassphrase) => { + const tx = buildTestTransaction(signer, networkPassphrase); + const key = makeAlbedoKey(signer, { network: networkPassphrase }); + + await expect( + albedoHandler.signTransaction({ transaction: tx, key }), + ).rejects.toThrow(networkPassphrase); + expect(mockTx).not.toHaveBeenCalled(); + }); + + it.each([ + { + setup: "a key without a network", + txNetwork: Networks.TESTNET, + keyNetwork: undefined, + customNetwork: undefined, + }, + { + setup: "a key scoped to another network", + txNetwork: Networks.PUBLIC, + keyNetwork: Networks.TESTNET, + customNetwork: undefined, + }, + { + setup: "custom.networkPassphrase for another network", + txNetwork: Networks.TESTNET, + keyNetwork: Networks.TESTNET, + customNetwork: Networks.PUBLIC, + }, + ])( + "refuses a transaction for another network ($setup) without opening Albedo", + async ({ txNetwork, keyNetwork, customNetwork }) => { + const tx = buildTestTransaction(signer, txNetwork); + const key = makeAlbedoKey(signer, { network: keyNetwork }); + + await expect( + albedoHandler.signTransaction({ + transaction: tx, + key, + custom: { networkPassphrase: customNetwork }, + }), + ).rejects.toThrow(`"${txNetwork}"`); + expect(mockTx).not.toHaveBeenCalled(); + }, + ); + + it("refuses a key that holds a private key without opening Albedo", async () => { + const tx = buildTestTransaction(signer, Networks.TESTNET); + const key = makeAlbedoKey(signer, { privateKey: signer.secret() }); + + await expect( + albedoHandler.signTransaction({ transaction: tx, key }), + ).rejects.toThrow(signer.publicKey()); + expect(mockTx).not.toHaveBeenCalled(); + }); + + it("throws when Albedo returns no signed envelope", async () => { + mockTx.mockResolvedValue({ + intent: "tx", + pubkey: signer.publicKey(), + network: "testnet", + xdr: "", + tx_hash: "", + signed_envelope_xdr: "", + }); + const tx = buildTestTransaction(signer, Networks.TESTNET); + const key = makeAlbedoKey(signer, { network: Networks.TESTNET }); + + await expect( + albedoHandler.signTransaction({ transaction: tx, key }), + ).rejects.toThrow("We couldn’t sign the transaction with Albedo"); + }); +}); diff --git a/@stellar/typescript-wallet-sdk-km/test/fixtures/albedo.ts b/@stellar/typescript-wallet-sdk-km/test/fixtures/albedo.ts new file mode 100644 index 0000000..08d4076 --- /dev/null +++ b/@stellar/typescript-wallet-sdk-km/test/fixtures/albedo.ts @@ -0,0 +1,40 @@ +import { + Keypair, + Networks, + Transaction, + TransactionBuilder, + xdr, +} from "@stellar/stellar-sdk"; + +// The tx intent's `network` param takes a network name or that network's +// passphrase, and Albedo signs with the matching passphrase. +const albedoPassphrases: { [network: string]: string } = { + public: Networks.PUBLIC, + testnet: Networks.TESTNET, + [Networks.PUBLIC]: Networks.PUBLIC, + [Networks.TESTNET]: Networks.TESTNET, +}; + +// Stands in for albedo.link answering a tx intent: it signs the XDR with the +// user's account for the requested network ("public" when none is given) and +// echoes that network back. `signedNetwork` makes it sign for another network +// than the one requested. +export function albedoSigningWith(signer: Keypair, signedNetwork?: string) { + return ({ xdr: envelope, network }: { xdr: string; network?: string }) => { + const responseNetwork = signedNetwork || network || "public"; + const tx = TransactionBuilder.fromXdr( + envelope, + albedoPassphrases[responseNetwork], + ) as Transaction; + tx.sign(signer); + + return Promise.resolve({ + intent: "tx", + pubkey: signer.publicKey(), + network: responseNetwork, + xdr: envelope, + tx_hash: xdr.encodeBytes(tx.hash(), "hex"), + signed_envelope_xdr: tx.toXdr(), + }); + }; +} diff --git a/@stellar/typescript-wallet-sdk-km/test/freighter.test.ts b/@stellar/typescript-wallet-sdk-km/test/freighter.test.ts index bb7676e..3302ee2 100644 --- a/@stellar/typescript-wallet-sdk-km/test/freighter.test.ts +++ b/@stellar/typescript-wallet-sdk-km/test/freighter.test.ts @@ -246,5 +246,41 @@ describe("freighterHandler", () => { expect.objectContaining({ networkPassphrase: Networks.PUBLIC }), ); }); + + it.each([ + { + setup: "a key without a network", + txNetwork: Networks.TESTNET, + keyNetwork: undefined, + customNetwork: undefined, + }, + { + setup: "a key scoped to another network", + txNetwork: Networks.PUBLIC, + keyNetwork: Networks.TESTNET, + customNetwork: undefined, + }, + { + setup: "custom.networkPassphrase for another network", + txNetwork: Networks.TESTNET, + keyNetwork: Networks.TESTNET, + customNetwork: Networks.PUBLIC, + }, + ])( + "refuses a transaction for another network ($setup) without calling Freighter", + async ({ txNetwork, keyNetwork, customNetwork }) => { + const tx = buildTestTransaction(txNetwork); + const key = makeFreighterKey({ network: keyNetwork }); + + await expect( + freighterHandler.signTransaction({ + transaction: tx, + key, + custom: { networkPassphrase: customNetwork }, + }), + ).rejects.toThrow(`"${txNetwork}"`); + expect(mockSignTransaction).not.toHaveBeenCalled(); + }, + ); }); }); diff --git a/@stellar/typescript-wallet-sdk-km/test/keyManager.test.ts b/@stellar/typescript-wallet-sdk-km/test/keyManager.test.ts index 1962559..b7eacf1 100644 --- a/@stellar/typescript-wallet-sdk-km/test/keyManager.test.ts +++ b/@stellar/typescript-wallet-sdk-km/test/keyManager.test.ts @@ -1,4 +1,6 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ +import albedo from "@albedo-link/intent"; +import freighterApi from "@stellar/freighter-api"; import { Networks, Keypair, @@ -9,6 +11,7 @@ import { Transaction, TimeoutInfinite, BASE_FEE, + WebAuth, } from "@stellar/stellar-sdk"; import { DefaultAuthHeaderSigner, @@ -22,6 +25,7 @@ import path from "path"; import randomBytes from "randombytes"; import sinon from "sinon"; +import { albedoSigningWith } from "./fixtures/albedo"; import { createBrowserStorageArea } from "./fixtures/browserStorage"; import { KeyManager } from "../src"; import { DomainSigningModifiedError } from "../src/Exceptions"; @@ -34,6 +38,24 @@ import { ScryptEncrypter, } from "../src/Plugins"; +jest.mock("@albedo-link/intent", () => ({ + __esModule: true, + default: { + tx: jest.fn(), + }, +})); + +const mockAlbedoTx = albedo.tx as jest.Mock; + +jest.mock("@stellar/freighter-api", () => ({ + __esModule: true, + default: { + signTransaction: jest.fn(), + }, +})); + +const mockFreighterSign = freighterApi.signTransaction as jest.Mock; + describe("KeyManager", () => { let clock: sinon.SinonFakeTimers; @@ -1669,6 +1691,224 @@ describe("fetchAuthToken", () => { ); } }); + + test.each([ + { + source: "the key's network", + keyNetwork: Networks.TESTNET, + defaultNetworkPassphrase: undefined, + }, + { + source: "the manager's default network", + keyNetwork: undefined, + defaultNetworkPassphrase: Networks.TESTNET, + }, + ])( + "Has Albedo sign the challenge for $source", + async ({ keyNetwork, defaultNetworkPassphrase }) => { + const authServer = "https://www.stellar.org/auth"; + const password = "very secure password"; + const token = "👍"; + + const serverKey = Keypair.random(); + const serverAccount = new Account(serverKey.publicKey(), "-1"); + const albedoAccount = Keypair.random(); + + const testKeyManager = new KeyManager({ + keyStore: new MemoryKeyStore(), + defaultNetworkPassphrase, + }); + testKeyManager.registerEncrypter(IdentityEncrypter); + + const challenge = new TransactionBuilder(serverAccount, { + fee: BASE_FEE, + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.manageData({ + name: "stellar.org auth", + value: randomBytes(48).toString("base64"), + source: albedoAccount.publicKey(), + }), + ) + .addOperation( + Operation.manageData({ + name: "web_auth_domain", + value: new URL(authServer).hostname, + source: serverAccount.accountId(), + }), + ) + .setTimeout(300) + .build(); + challenge.sign(serverKey); + + jest + .spyOn(global, "fetch") + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + transaction: challenge.toXdr(), + network_passphrase: Networks.TESTNET, + }), + ), + ) + .mockResolvedValueOnce(new Response(JSON.stringify({ token }))); + mockAlbedoTx.mockImplementation(albedoSigningWith(albedoAccount)); + + const keyMetadata = await testKeyManager.storeKey({ + key: { + type: KeyType.albedo, + publicKey: albedoAccount.publicKey(), + privateKey: "", + network: keyNetwork, + }, + password, + encrypterName: "IdentityEncrypter", + }); + + const res = await testKeyManager.fetchAuthToken({ + id: keyMetadata.id, + password, + authServer, + authServerKey: serverKey.publicKey(), + authServerHomeDomains: ["stellar.org"], + }); + + const submittedXdr: string = JSON.parse( + (global.fetch as any).mock.calls[1][1].body, + ).transaction; + const submittedTx = new Transaction(submittedXdr, Networks.TESTNET); + + expect( + WebAuth.verifyTxSignedBy(submittedTx, albedoAccount.publicKey()), + ).toBe(true); + expect(res).toBe(token); + }, + ); +}); + +describe("signTransaction with a key held by an external wallet", () => { + const password = "very secure password"; + let walletAccount: Keypair; + + beforeEach(() => { + jest.resetAllMocks(); + walletAccount = Keypair.random(); + mockAlbedoTx.mockImplementation(albedoSigningWith(walletAccount)); + }); + + // A manager whose default network is testnet, holding one wallet key. + async function createTestnetManager( + keyNetwork?: string, + type: KeyType = KeyType.albedo, + ) { + const keyManager = new KeyManager({ + keyStore: new MemoryKeyStore(), + defaultNetworkPassphrase: Networks.TESTNET, + }); + keyManager.registerEncrypter(IdentityEncrypter); + const { id } = await keyManager.storeKey({ + key: { + type, + publicKey: walletAccount.publicKey(), + privateKey: "", + network: keyNetwork, + }, + password, + encrypterName: "IdentityEncrypter", + }); + return { keyManager, id }; + } + + function buildTransaction(networkPassphrase: string): Transaction { + return new TransactionBuilder( + new Account(walletAccount.publicKey(), "100"), + { fee: BASE_FEE, networkPassphrase }, + ) + .addOperation( + Operation.payment({ + destination: Keypair.random().publicKey(), + asset: Asset.native(), + amount: "10", + }), + ) + .setTimeout(30) + .build(); + } + + test.each([ + { + source: "the manager's default network", + txNetwork: Networks.TESTNET, + keyNetwork: undefined, + customNetwork: undefined, + }, + { + source: "the key's network over the manager's default", + txNetwork: Networks.PUBLIC, + keyNetwork: Networks.PUBLIC, + customNetwork: undefined, + }, + { + source: "custom.networkPassphrase over the key's network", + txNetwork: Networks.PUBLIC, + keyNetwork: Networks.TESTNET, + customNetwork: Networks.PUBLIC, + }, + ])( + "Has Albedo sign for $source", + async ({ txNetwork, keyNetwork, customNetwork }) => { + const { keyManager, id } = await createTestnetManager(keyNetwork); + const tx = buildTransaction(txNetwork); + + const result = await keyManager.signTransaction({ + transaction: tx, + id, + password, + custom: { networkPassphrase: customNetwork }, + }); + + expect(result.networkPassphrase).toBe(txNetwork); + expect(WebAuth.verifyTxSignedBy(result, walletAccount.publicKey())).toBe( + true, + ); + }, + ); + + test("Refuses a mainnet transaction for a key on a testnet manager without opening Albedo", async () => { + const { keyManager, id } = await createTestnetManager(); + const tx = buildTransaction(Networks.PUBLIC); + + await expect( + keyManager.signTransaction({ transaction: tx, id, password }), + ).rejects.toThrow(`"${Networks.PUBLIC}"`); + expect(mockAlbedoTx).not.toHaveBeenCalled(); + }); + + test("Gives Freighter the manager's default network and the caller's other custom fields", async () => { + const { keyManager, id } = await createTestnetManager( + undefined, + KeyType.freighter, + ); + const tx = buildTransaction(Networks.TESTNET); + const address = walletAccount.publicKey(); + mockFreighterSign.mockResolvedValue({ + signedTxXdr: tx.toXdr(), + signerAddress: address, + }); + + await keyManager.signTransaction({ + transaction: tx, + id, + password, + custom: { address }, + }); + + expect(mockFreighterSign).toHaveBeenCalledWith(tx.toXdr(), { + networkPassphrase: Networks.TESTNET, + address, + }); + }); }); describe("KeyManager Scrypt", () => { From 5dc2b8770c37b32ec24c456f8b8578075c04da77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 15 Oct 2026 15:48:50 -0700 Subject: [PATCH 2/2] fix(km): drop the Albedo response network check Albedo builds the network field of its tx response from the request's own network param, so now that the handler always sends the network, the check could never fail. The network is already pinned by sending it, by the network mismatch check, and by parsing the signed envelope with the same passphrase. Co-Authored-By: Claude Opus 5.5 --- .../typescript-wallet-sdk-km/src/Handlers/albedo.ts | 6 ------ @stellar/typescript-wallet-sdk-km/test/albedo.test.ts | 10 ---------- .../typescript-wallet-sdk-km/test/fixtures/albedo.ts | 7 +++---- 3 files changed, 3 insertions(+), 20 deletions(-) diff --git a/@stellar/typescript-wallet-sdk-km/src/Handlers/albedo.ts b/@stellar/typescript-wallet-sdk-km/src/Handlers/albedo.ts index e9cb722..f41ed75 100644 --- a/@stellar/typescript-wallet-sdk-km/src/Handlers/albedo.ts +++ b/@stellar/typescript-wallet-sdk-km/src/Handlers/albedo.ts @@ -58,12 +58,6 @@ export const albedoHandler: KeyTypeHandler = { throw new Error("We couldn’t sign the transaction with Albedo."); } - if (response.network !== network) { - throw new Error( - `Albedo signed for the "${response.network}" network, but "${network}" was requested`, - ); - } - // fromXdr() returns type "Transaction | FeeBumpTransaction" and // signTransaction() doesn't like "| FeeBumpTransaction" type, so casting // to "Transaction" type. diff --git a/@stellar/typescript-wallet-sdk-km/test/albedo.test.ts b/@stellar/typescript-wallet-sdk-km/test/albedo.test.ts index 84250db..c3b62fd 100644 --- a/@stellar/typescript-wallet-sdk-km/test/albedo.test.ts +++ b/@stellar/typescript-wallet-sdk-km/test/albedo.test.ts @@ -105,16 +105,6 @@ describe("albedoHandler", () => { expect(WebAuth.verifyTxSignedBy(result, signer.publicKey())).toBe(true); }); - it("throws when Albedo signs for a different network than requested", async () => { - mockTx.mockImplementation(albedoSigningWith(signer, "public")); - const tx = buildTestTransaction(signer, Networks.TESTNET); - const key = makeAlbedoKey(signer, { network: Networks.TESTNET }); - - await expect( - albedoHandler.signTransaction({ transaction: tx, key }), - ).rejects.toThrow(/"public".*"testnet"/); - }); - it.each([ ["Networks.FUTURENET", Networks.FUTURENET], ["a private network passphrase", "Private Network ; October 2026"], diff --git a/@stellar/typescript-wallet-sdk-km/test/fixtures/albedo.ts b/@stellar/typescript-wallet-sdk-km/test/fixtures/albedo.ts index 08d4076..c2dc93d 100644 --- a/@stellar/typescript-wallet-sdk-km/test/fixtures/albedo.ts +++ b/@stellar/typescript-wallet-sdk-km/test/fixtures/albedo.ts @@ -17,11 +17,10 @@ const albedoPassphrases: { [network: string]: string } = { // Stands in for albedo.link answering a tx intent: it signs the XDR with the // user's account for the requested network ("public" when none is given) and -// echoes that network back. `signedNetwork` makes it sign for another network -// than the one requested. -export function albedoSigningWith(signer: Keypair, signedNetwork?: string) { +// echoes that network back. +export function albedoSigningWith(signer: Keypair) { return ({ xdr: envelope, network }: { xdr: string; network?: string }) => { - const responseNetwork = signedNetwork || network || "public"; + const responseNetwork = network || "public"; const tx = TransactionBuilder.fromXdr( envelope, albedoPassphrases[responseNetwork],