diff --git a/@stellar/typescript-wallet-sdk-km/src/Handlers/albedo.ts b/@stellar/typescript-wallet-sdk-km/src/Handlers/albedo.ts index c9a9b65..f41ed75 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,9 +31,28 @@ 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."); @@ -37,7 +63,7 @@ export const albedoHandler: KeyTypeHandler = { // 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..c3b62fd --- /dev/null +++ b/@stellar/typescript-wallet-sdk-km/test/albedo.test.ts @@ -0,0 +1,183 @@ +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.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..c2dc93d --- /dev/null +++ b/@stellar/typescript-wallet-sdk-km/test/fixtures/albedo.ts @@ -0,0 +1,39 @@ +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. +export function albedoSigningWith(signer: Keypair) { + return ({ xdr: envelope, network }: { xdr: string; network?: string }) => { + const responseNetwork = 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", () => {