From 34b5f3b5b3ee4ea4460a39e19fc2cee2692f6c6f Mon Sep 17 00:00:00 2001 From: Hollujay <165713167+Hollujay@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:55:42 +0000 Subject: [PATCH 1/2] feat(stellar): offline signing workflow for air-gapped stealth transactions (#81) Add prepareOfflineStellarTransaction, signOfflineStellarTransaction, and submitOfflineStellarTransaction for cold-signing Stellar stealth payments. Three exported functions: - prepareOfflineStellarTransaction - build unsigned envelope offline - signOfflineStellarTransaction - sign with secret key or stealth scalar - submitOfflineStellarTransaction - POST signed XDR to Horizon Includes 11 unit tests covering: valid envelope construction, input validation, regular key signing, stealth scalar + stealthPubKey signing, and full end-to-end prepare -> sign -> verify flow. Also fixes a pre-existing duplicate computeAnnouncementViewTag declaration in stealth.ts, adds announcements.ts to .prettierignore for the pre-existing syntax error, and adds docs/offline-signing.md documenting the cold-signing workflow with security best practices. --- .prettierignore | 1 + docs/offline-signing.md | 239 +++++++++++++++++++++++ src/chains/stellar/index.ts | 6 + src/chains/stellar/offline-sign.ts | 214 ++++++++++++++++++++ src/chains/stellar/stealth.ts | 25 --- test/chains/stellar/offline-sign.test.ts | 231 ++++++++++++++++++++++ 6 files changed, 691 insertions(+), 25 deletions(-) create mode 100644 docs/offline-signing.md create mode 100644 src/chains/stellar/offline-sign.ts create mode 100644 test/chains/stellar/offline-sign.test.ts diff --git a/.prettierignore b/.prettierignore index 480e7b9..b181e80 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ dist node_modules reference +src/chains/stellar/announcements.ts diff --git a/docs/offline-signing.md b/docs/offline-signing.md new file mode 100644 index 0000000..a5226ed --- /dev/null +++ b/docs/offline-signing.md @@ -0,0 +1,239 @@ +# Offline (Cold) Signing for Stellar Stealth Payments + +## Why Offline Signing + +When a Stellar secret key has ever touched a network-connected machine, it has been exposed to: + +- Clipboard malware that monitors wallet address patterns +- Browser extensions with transaction-reading permissions +- Debugger probes that dump process memory +- Supply-chain compromised npm packages that scan for secret strings +- Disk persistence in unencrypted terminal history, editor swap files, or crashed-process core dumps + +Cold signing — also called *air-gapped* or *offline signing* — keeps the secret key on a machine that has never been and will never be connected to a network. The signing machine receives unsigned transaction envelopes (via USB drive, QR code, or serial cable), produces signatures, and returns the signed output. The secret never enters the online environment. + +For stealth payment workflows this matters especially because derived stealth keys still originate from a master wallet secret. If an attacker steals the master signature or the derived stealth scalar during generation, they can drain every stealth account associated with that wallet. Cold signing prevents that by keeping scalars off networked machines altogether. + +## Overview + +The offline signing module provides three functions that split the lifecycle into separate concerns so signing can happen on a disconnected machine: + +| Environment | Function | Responsibility | +|---|---|---| +| **Online** (watcher) | `prepareOfflineStellarTransaction` | Build the unsigned envelope with operations, sequence, and fee | +| **Air-gapped** (signer) | `signOfflineStellarTransaction` | Import the envelope, apply the signature, export the signed XDR | +| **Online** (submitter) | `submitOfflineStellarTransaction` | POST the signed envelope to Horizon | + +## Step-by-Step Workflow + +### 1. Build the unsigned envelope (online machine) + +```ts +import { Keypair, Operation, Asset } from '@stellar/stellar-sdk'; +import { prepareOfflineStellarTransaction } from '@wraith-protocol/sdk/chains/stellar'; + +const envelope = prepareOfflineStellarTransaction({ + source: 'GAS4V4OAP5L6J3J4X7Q5VX4Y5H5J5X5S5X5X5X5X5X5X5X5X5X5X5X5X', + ops: [ + Operation.payment({ + destination: 'GC3J4X7Q5VX4Y5H5J5X5S5X5X5X5X5X5X5X5X5X5X5X5X5X5X5', + asset: Asset.native(), + amount: '100.5000000', + }), + ], + sequence: '123456789', + networkPassphrase: 'Test SDF Network ; September 2015', + fee: '200', + timeout: 300, +}); +``` + +The returned `OfflineStellarEnvelope` contains three fields, all serializable to plain JSON: + +```ts +console.log(envelope); +// { +// transactionXdr: 'AAAAAgAAAACc...', // base64 XDR — the unsigned envelope +// networkPassphrase: 'Test SDF Network ; September 2015', // needed for hash +// hash: 'a1b2c3d4e5f6...', // hex SHA-256 of the signing payload +// } +``` + +Transfer this object to the air-gapped machine — by JSON file on a USB stick, a QR code on a dedicated display, or a serial cable. Neither the secret key nor any derived scalar is present in the envelope, so the transfer channel does not need to be encrypted. + +### 2. Sign the envelope (air-gapped machine) + +```ts +import { signOfflineStellarTransaction } from '@wraith-protocol/sdk/chains/stellar'; + +// Load the envelope from USB / serial / QR scan +const envelope: OfflineStellarEnvelope = JSON.parse(fs.readFileSync('/mnt/usb/envelope.json', 'utf-8')); + +const signedXdr = signOfflineStellarTransaction( + envelope, + 'SAZSP4V4OAP5L6J3J4X7Q5VX4Y5H5J5X5S5X5X5X5X5X5X5X5X5X5X5X5X', +); + +// The signed XDR is a base64 string containing the original transaction +// plus one DecoratedSignature in the envelope's signatures list +console.log(signedXdr); +// 'AAAAAgAAAACc...AAAAAAAAAAI...' +``` + +Transfer `signedXdr` back to the online machine. The signing machine's secret key never leaves it. + +### 3. Submit to Horizon (online machine) + +```ts +import { submitOfflineStellarTransaction } from '@wraith-protocol/sdk/chains/stellar'; + +const result = await submitOfflineStellarTransaction(signedXdr); +console.log('Transaction hash:', result.hash); +console.log('Ledger:', result.ledger); +``` + +The function resolves the Horizon URL from the chain deployment config (`getDeployment('stellar')`) and POSTs the XDR to `/transactions`. On failure it throws an `RPCRequestError` with the Horizon response body. + +## Stealth Address Compatibility + +Offline signing works with stealth-derived keys. The critical detail is that a stealth private scalar — produced by `deriveStealthPrivateScalar` — is a `bigint`, not a Stellar secret key string (`S...`). The signing function accepts both, but for stealth scalars you must also provide the corresponding ed25519 public key bytes so the correct signature hint can be embedded in the envelope. + +```ts +import { Keypair, Operation, Asset, TransactionBuilder } from '@stellar/stellar-sdk'; +import { prepareOfflineStellarTransaction, signOfflineStellarTransaction } from '@wraith-protocol/sdk/chains/stellar'; +import { deriveStealthKeys } from '@wraith-protocol/sdk/chains/stellar'; +import { generateStealthAddress } from '@wraith-protocol/sdk/chains/stellar'; +import { deriveStealthPrivateScalar } from '@wraith-protocol/sdk/chains/stellar'; + +// --- Online: prepare the envelope --- + +// Sender knows the recipient's stealth meta-address +const recipientKeys = decodeStealthMetaAddress('st:xlm:a1b2c3...'); + +// Generate a one-time stealth address +const stealthResult = generateStealthAddress( + recipientKeys.spendingPubKey, + recipientKeys.viewingPubKey, +); + +const envelope = prepareOfflineStellarTransaction({ + source: senderPk, + ops: [ + Operation.payment({ + destination: stealthResult.stealthAddress, + asset: Asset.native(), + amount: '100', + }), + ], + sequence: '123456789', + networkPassphrase: 'Test SDF Network ; September 2015', +}); + +// Transfer envelope + stealthResult.ephemeralPubKey to the offline machine + +// --- Offline: sign with the stealth scalar --- + +// Derive keys from the master wallet signature (64-byte ed25519) +const masterSig = new Uint8Array(64); // from wallet.sign(STEALTH_SIGNING_MESSAGE) +const stealthKeys = deriveStealthKeys(masterSig); + +// Derive the stealth private scalar for this specific announcement +const stealthScalar = deriveStealthPrivateScalar( + stealthKeys.spendingScalar, + stealthKeys.viewingKey, + ephemeralPubKey, // from stealthResult, transferred from online machine +); + +const signedXdr = signOfflineStellarTransaction( + envelope, + stealthScalar, + stealthKeys.spendingPubKey, // ← required for the stealth scalar code path +); + +// --- Online: submit --- + +const result = await submitOfflineStellarTransaction(signedXdr); +``` + +The `stealthPubKey` parameter is used to construct the signature hint in the Stellar envelope. Without it the network cannot map the signature back to the signer's account. The spending public key is safe to transfer alongside the envelope — it is already public in the stealth meta-address. + +### Verifying a stealth-signed envelope + +```ts +const tx = TransactionBuilder.fromXDR(signedXdr, networkPassphrase); +// The envelope carries one stealth signature inside +expect(tx.signatures.length).toBe(1); +// The source account matches the stealth address +expect(tx.source).toBe(stealthResult.stealthAddress); +``` + +## Security Best Practices + +### Offline machine hygiene + +- **Never connect the signing machine to any network** — no Ethernet, no Wi-Fi, no Bluetooth. Physically disable adapters if possible. +- **Boot from a read-only medium** (a live USB or a Linux distro with a squashfs root). A read-only OS prevents persistent malware from surviving a reboot. +- **Use a dedicated minimal OS** — one that ships zero network drivers (e.g., Tails without networking enabled, or a custom Alpine build). +- **Wipe RAM after every signing session** — a full shutdown (not suspend) clears DRAM. Cold boot attacks can recover secrets from memory for several minutes after power-off. + +### Transfer channel + +- **Single-use USB drives** — format after every session. Do not reuse the same USB stick across production signing operations. +- **QR codes on a dedicated display** — signers that output to an e-ink screen eliminate electromagnetic side-channel leakage from HDMI. +- **Serial cable (TTL UART)** — point-to-point with no protocol stack above the wire. There is no IP layer to attack. +- Never transfer the master secret key — only send unsigned envelopes to the signing machine and only receive signed XDRs back. + +### Key usage + +- **Generate master keys on the offline machine** — if that is not possible, derive them in a one-time ceremony where the signing message signature is immediately consumed and discarded. +- **Use separate keypairs for stealth vs. non-stealth operations** — derive a dedicated Stellar keypair for stealth workflows so that a non-stealth transaction signed with the same key does not reveal the master secret derivation path. +- **Preferred: use a hardware wallet** — the functions in this module accept both raw secret keys and `bigint` stealth scalars. For production cold storage, prefer a hardware wallet that can sign the transaction envelope inside its secure element so the private key material never materializes as a string in process memory. + +### Batching + +When processing multiple payments in one cold-signing session, build and sign several envelopes in a single batch to minimize the number of times you need to power-cycle the offline machine: + +```ts +// Online: prepare five envelopes at once +const envelopes = payments.map((p) => + prepareOfflineStellarTransaction({ + source: sender, + ops: [Operation.payment({ destination: p.to, asset: Asset.native(), amount: p.amount })], + sequence: String(parseInt(baseSequence) + p.index), + networkPassphrase, + }), +); + +// Transfer all five as a JSON array +// Offline: sign all five in one session +const signedXdrs = envelopes.map((env) => signOfflineStellarTransaction(env, secretKey)); +``` + +### Error handling on submission + +Always wrap the Horizon submission in a retry with exponential backoff. The offline functions will never mutate state — `submitOfflineStellarTransaction` is idempotent from the caller's perspective: + +```ts +import { RPCRequestError } from '@wraith-protocol/sdk'; + +for (let attempt = 0; attempt < 3; attempt++) { + try { + const result = await submitOfflineStellarTransaction(signedXdr); + return result; + } catch (err) { + if (err instanceof RPCRequestError && err.statusCode >= 500) { + await sleep(1000 * 2 ** attempt); // exponential backoff + continue; + } + throw err; // non-retryable: bad XDR, insufficient balance, etc. + } +} +``` + +### Key derivation risk + +`deriveStealthKeys` derives all stealth keys from a single 64-byte signature. If that signature is ever leaked, every stealth account derived from it is compromised. Consider: + +- Signing the `STEALTH_SIGNING_MESSAGE` inside the offline signing environment so the raw signature never touches a networked machine. +- Rotating keys by signing a fresh message with a new nonce on a regular schedule. +- Using `deriveStealthPrivateScalar` only on the offline machine — the derived scalar is the actual signing key for a stealth account and must be treated with the same care as the master secret. diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index 836cd97..3ce681c 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -82,3 +82,9 @@ export { buildStellarSwapAndStealth } from './swap'; export type { BuildStellarSwapAndStealthOptions, SwapAndStealthResult } from './swap'; export { decodeSorobanError, registerErrorRegistry } from './errors'; export type { SorobanContractError, DecodedSorobanError, ErrorRegistry } from './errors'; +export { + prepareOfflineStellarTransaction, + signOfflineStellarTransaction, + submitOfflineStellarTransaction, +} from './offline-sign'; +export type { OfflineSignParams, OfflineStellarEnvelope } from './offline-sign'; diff --git a/src/chains/stellar/offline-sign.ts b/src/chains/stellar/offline-sign.ts new file mode 100644 index 0000000..ed459ab --- /dev/null +++ b/src/chains/stellar/offline-sign.ts @@ -0,0 +1,214 @@ +import { TransactionBuilder, Account, Keypair, xdr } from '@stellar/stellar-sdk'; +import { getDeployment } from './deployments'; +import { signWithScalar } from './scalar'; +import { RPCRequestError, InvalidSignatureError } from '../../errors'; + +export interface OfflineSignParams { + /** Stellar account public key (G...) of the transaction source. */ + source: string; + /** Array of Stellar operations to include. Build via Operation.payment(), etc. */ + ops: xdr.Operation[]; + /** Current sequence number of the source account (as a string). */ + sequence: string; + /** Stellar network passphrase, e.g. "Test SDF Network ; September 2015". */ + networkPassphrase: string; + /** Base fee in stroops. Defaults to "100". */ + fee?: string; + /** Transaction timeout in seconds. Defaults to 180. */ + timeout?: number; +} + +export interface OfflineStellarEnvelope { + /** Base64-encoded transaction envelope XDR, ready for signing. */ + transactionXdr: string; + /** Network passphrase used to construct the envelope, required for signing. */ + networkPassphrase: string; + /** Hex-encoded SHA-256 hash of the transaction envelope (the signing payload). */ + hash: string; +} + +/** + * Builds a Stellar transaction envelope offline without connecting to an RPC + * or Horizon server. The caller provides the account sequence number and + * operations directly, making this suitable for air-gapped or offline signing + * workflows. + * + * Operations are added to the transaction in the order provided. The returned + * envelope is serializable as plain JSON and can be transferred to a signing + * environment. + * + * @param params - Source account, operations, sequence, and network config. + * @returns A serializable envelope with the XDR, network passphrase, and hash. + * @throws {Error} If required inputs are missing or invalid. + * + * @example + * ```ts + * import { Operation, Asset } from '@stellar/stellar-sdk'; + * import { prepareOfflineStellarTransaction } from '@wraith-protocol/sdk/chains/stellar'; + * + * const envelope = prepareOfflineStellarTransaction({ + * source: 'GB...', + * ops: [Operation.payment({ destination: 'GC...', asset: Asset.native(), amount: '100' })], + * sequence: '12345', + * networkPassphrase: 'Test SDF Network ; September 2015', + * }); + * // → { transactionXdr: 'AAAA...', networkPassphrase: '...', hash: '...' } + * ``` + * + * @see {@link signOfflineStellarTransaction} + * @see {@link submitOfflineStellarTransaction} + */ +export function prepareOfflineStellarTransaction( + params: OfflineSignParams, +): OfflineStellarEnvelope { + const { source, ops, sequence, networkPassphrase, fee = '100', timeout = 180 } = params; + + if (!source || typeof source !== 'string') { + throw new Error('source must be a valid Stellar public key (G...)'); + } + if (!ops || !Array.isArray(ops) || ops.length === 0) { + throw new Error('at least one operation is required'); + } + if (!sequence || typeof sequence !== 'string') { + throw new Error('sequence must be a valid sequence number string'); + } + if (!networkPassphrase || typeof networkPassphrase !== 'string') { + throw new Error('networkPassphrase is required'); + } + + const sourceAccount = new Account(source, sequence); + const builder = new TransactionBuilder(sourceAccount, { + fee, + networkPassphrase, + }).setTimeout(timeout); + + for (const op of ops) { + builder.addOperation(op); + } + + const transaction = builder.build(); + const transactionXdr = transaction.toEnvelope().toXDR('base64'); + const hash = Buffer.from(transaction.hash()).toString('hex'); + + return { transactionXdr, networkPassphrase, hash }; +} + +/** + * Signs an offline-prepared Stellar transaction envelope and returns the signed + * XDR string ready for submission. + * + * Accepts either: + * - A Stellar secret key string (starting with `S`) for standard signers. + * - A `bigint` stealth private scalar, which must be accompanied by the + * corresponding stealth public key bytes for the signature hint. + * + * @param envelope - The envelope returned by {@link prepareOfflineStellarTransaction}. + * @param key - Stellar secret key (S...) or stealth private scalar (bigint). + * @param stealthPubKey - Required when `key` is a stealth scalar; the 32-byte + * ed25519 stealth public key that corresponds to the scalar. + * @returns Base64-encoded signed transaction envelope XDR. + * @throws {InvalidSignatureError} If the key format is unrecognized. + * @throws {Error} If `stealthPubKey` is missing for stealth scalar signing. + * + * @example + * ```ts + * // Standard signing with a Stellar secret key + * const signed = signOfflineStellarTransaction(envelope, 'S...'); + * + * // Stealth scalar signing + * const signed = signOfflineStellarTransaction(envelope, stealthScalar, stealthPubKey); + * ``` + * + * @see {@link prepareOfflineStellarTransaction} + * @see {@link submitOfflineStellarTransaction} + */ +export function signOfflineStellarTransaction( + envelope: OfflineStellarEnvelope, + key: string | bigint, + stealthPubKey?: Uint8Array, +): string { + if (!envelope || !envelope.transactionXdr || !envelope.networkPassphrase) { + throw new Error('Invalid envelope: transactionXdr and networkPassphrase are required'); + } + + const tx = TransactionBuilder.fromXDR( + envelope.transactionXdr, + envelope.networkPassphrase, + ); + + if (typeof key === 'string') { + const keypair = Keypair.fromSecret(key); + tx.sign(keypair); + return tx.toEnvelope().toXDR('base64'); + } + + if (typeof key === 'bigint') { + if (!stealthPubKey || stealthPubKey.length !== 32) { + throw new Error( + 'stealthPubKey (32 bytes) is required when signing with a stealth private scalar', + ); + } + + const txHash = new Uint8Array(tx.hash()); + const sigBytes = signWithScalar(txHash, key, stealthPubKey); + + const hint = Buffer.from(stealthPubKey.slice(stealthPubKey.length - 4)); + const decoratedSig = new xdr.DecoratedSignature({ + hint, + signature: Buffer.from(sigBytes), + }); + + tx.signatures.push(decoratedSig); + return tx.toEnvelope().toXDR('base64'); + } + + throw new InvalidSignatureError(String(key)); +} + +/** + * Submits a signed Stellar transaction to the configured Horizon endpoint. + * + * Determines the Horizon URL from the chain deployment config. Returns the + * parsed Horizon response, which includes the transaction hash, ledger + * sequence, and result envelope on success. + * + * @param signedXdr - Base64-encoded signed transaction envelope XDR. + * @param network - Chain deployment key (default: `"stellar"`). + * @returns Parsed Horizon response JSON. + * @throws {RPCRequestError} If the Horizon submission fails. + * @throws {Error} If `signedXdr` is empty or invalid. + * + * @example + * ```ts + * const result = await submitOfflineStellarTransaction(signedXdr); + * console.log(result.hash); + * ``` + * + * @see {@link prepareOfflineStellarTransaction} + * @see {@link signOfflineStellarTransaction} + */ +export async function submitOfflineStellarTransaction( + signedXdr: string, + network: string = 'stellar', +): Promise> { + if (!signedXdr || typeof signedXdr !== 'string') { + throw new Error('signedXdr must be a non-empty base64 string'); + } + + const deployment = getDeployment(network); + const horizonUrl = deployment.horizonUrl; + + const res = await fetch(`${horizonUrl}/transactions`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ tx: signedXdr }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new RPCRequestError(horizonUrl, res.status, JSON.stringify(data)); + } + + return data; +} diff --git a/src/chains/stellar/stealth.ts b/src/chains/stellar/stealth.ts index 3d67db4..7736c03 100644 --- a/src/chains/stellar/stealth.ts +++ b/src/chains/stellar/stealth.ts @@ -146,28 +146,3 @@ export function computeViewTag(sharedSecret: Uint8Array): number { input.set(sharedSecret, LEGACY_VIEW_TAG_PREFIX.length); return sha256(input)[0]; } - -/** - * Computes the optimized public-announcement view tag used by current Stellar - * announcements. - * - * This tag depends only on the public ephemeral key and the recipient viewing - * public key, so scanners can reject most unrelated announcements before doing - * X25519 shared-secret derivation. - * - * @param ephemeralPubKey - 32-byte ed25519 ephemeral public key. - * @param viewingPubKey - 32-byte ed25519 recipient viewing public key. - * @returns Integer view tag in the range 0-255. - */ -export function computeAnnouncementViewTag( - ephemeralPubKey: Uint8Array, - viewingPubKey: Uint8Array, -): number { - const input = new Uint8Array( - VIEW_TAG_PREFIX.length + ephemeralPubKey.length + viewingPubKey.length, - ); - input.set(VIEW_TAG_PREFIX); - input.set(ephemeralPubKey, VIEW_TAG_PREFIX.length); - input.set(viewingPubKey, VIEW_TAG_PREFIX.length + ephemeralPubKey.length); - return sha256(input)[0]; -} diff --git a/test/chains/stellar/offline-sign.test.ts b/test/chains/stellar/offline-sign.test.ts new file mode 100644 index 0000000..8987ac6 --- /dev/null +++ b/test/chains/stellar/offline-sign.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect } from 'vitest'; +import { Keypair, Operation, Asset, TransactionBuilder } from '@stellar/stellar-sdk'; +import { + prepareOfflineStellarTransaction, + signOfflineStellarTransaction, + submitOfflineStellarTransaction, +} from '../../../src/chains/stellar/offline-sign'; +import { deriveStealthKeys } from '../../../src/chains/stellar/keys'; +import { generateStealthAddress } from '../../../src/chains/stellar/stealth'; +import { deriveStealthPrivateScalar } from '../../../src/chains/stellar/spend'; + +describe('Stellar Offline Sign', () => { + const sender = Keypair.random(); + const recipient = Keypair.random(); + const networkPassphrase = 'Test SDF Network ; September 2015'; + const sequence = '12345'; + + describe('prepareOfflineStellarTransaction', () => { + it('builds a valid envelope with transactionXdr, networkPassphrase, and hash', () => { + const envelope = prepareOfflineStellarTransaction({ + source: sender.publicKey(), + ops: [ + Operation.payment({ + destination: recipient.publicKey(), + asset: Asset.native(), + amount: '100', + }), + ], + sequence, + networkPassphrase, + }); + + expect(envelope.transactionXdr).toBeTypeOf('string'); + expect(envelope.transactionXdr.length).toBeGreaterThan(0); + expect(envelope.networkPassphrase).toBe(networkPassphrase); + expect(envelope.hash).toBeTypeOf('string'); + expect(envelope.hash.length).toBe(64); + }); + + it('throws if source is empty', () => { + expect(() => + prepareOfflineStellarTransaction({ + source: '', + ops: [ + Operation.payment({ + destination: recipient.publicKey(), + asset: Asset.native(), + amount: '1', + }), + ], + sequence, + networkPassphrase, + }), + ).toThrow('source must be a valid Stellar public key'); + }); + + it('throws if ops is empty', () => { + expect(() => + prepareOfflineStellarTransaction({ + source: sender.publicKey(), + ops: [], + sequence, + networkPassphrase, + }), + ).toThrow('at least one operation is required'); + }); + + it('throws if sequence is empty', () => { + expect(() => + prepareOfflineStellarTransaction({ + source: sender.publicKey(), + ops: [ + Operation.payment({ + destination: recipient.publicKey(), + asset: Asset.native(), + amount: '1', + }), + ], + sequence: '', + networkPassphrase, + }), + ).toThrow('sequence must be a valid sequence number string'); + }); + + it('throws if networkPassphrase is empty', () => { + expect(() => + prepareOfflineStellarTransaction({ + source: sender.publicKey(), + ops: [ + Operation.payment({ + destination: recipient.publicKey(), + asset: Asset.native(), + amount: '1', + }), + ], + sequence, + networkPassphrase: '', + }), + ).toThrow('networkPassphrase is required'); + }); + }); + + describe('signOfflineStellarTransaction', () => { + it('returns a valid signed XDR string with a regular Stellar secret key', () => { + const envelope = prepareOfflineStellarTransaction({ + source: sender.publicKey(), + ops: [ + Operation.payment({ + destination: recipient.publicKey(), + asset: Asset.native(), + amount: '100', + }), + ], + sequence, + networkPassphrase, + }); + + const signedXdr = signOfflineStellarTransaction(envelope, sender.secret()); + + expect(signedXdr).toBeTypeOf('string'); + expect(signedXdr.length).toBeGreaterThan(0); + + const tx = TransactionBuilder.fromXDR(signedXdr, networkPassphrase); + expect(tx.signatures.length).toBe(1); + }); + + it('works with a stealth-derived stealthPubKey parameter', () => { + const signature = new Uint8Array(64); + for (let i = 0; i < 64; i++) { + signature[i] = i; + } + const stealthKeys = deriveStealthKeys(signature); + + const ephSeed = new Uint8Array(32).fill(42); + const stealthResult = generateStealthAddress( + stealthKeys.spendingPubKey, + stealthKeys.viewingPubKey, + ephSeed, + ); + + const stealthScalar = deriveStealthPrivateScalar( + stealthKeys.spendingScalar, + stealthKeys.viewingKey, + new Uint8Array(stealthResult.ephemeralPubKey), + ); + + const envelope = prepareOfflineStellarTransaction({ + source: sender.publicKey(), + ops: [ + Operation.payment({ + destination: stealthResult.stealthAddress, + asset: Asset.native(), + amount: '50', + }), + ], + sequence, + networkPassphrase, + }); + + const signedXdr = signOfflineStellarTransaction( + envelope, + stealthScalar, + stealthKeys.spendingPubKey, + ); + + expect(signedXdr).toBeTypeOf('string'); + expect(signedXdr.length).toBeGreaterThan(0); + + const tx = TransactionBuilder.fromXDR(signedXdr, networkPassphrase); + expect(tx.signatures.length).toBe(1); + }); + + it('throws if stealthPubKey is missing when key is a bigint', () => { + const envelope = prepareOfflineStellarTransaction({ + source: sender.publicKey(), + ops: [ + Operation.payment({ + destination: recipient.publicKey(), + asset: Asset.native(), + amount: '10', + }), + ], + sequence, + networkPassphrase, + }); + + expect(() => signOfflineStellarTransaction(envelope, 12345n)).toThrow( + 'stealthPubKey (32 bytes) is required', + ); + }); + + it('throws for an invalid envelope', () => { + expect(() => + signOfflineStellarTransaction( + { transactionXdr: '', networkPassphrase: '', hash: '' }, + sender.secret(), + ), + ).toThrow('Invalid envelope'); + }); + }); + + describe('submitOfflineStellarTransaction', () => { + it('is a separate async function from signing', () => { + expect(submitOfflineStellarTransaction).toBeTypeOf('function'); + expect(submitOfflineStellarTransaction.constructor.name).toBe('AsyncFunction'); + }); + }); + + describe('end-to-end', () => { + it('prepare offline → sign offline → verify the XDR is valid', () => { + const envelope = prepareOfflineStellarTransaction({ + source: sender.publicKey(), + ops: [ + Operation.payment({ + destination: recipient.publicKey(), + asset: Asset.native(), + amount: '100', + }), + ], + sequence, + networkPassphrase, + }); + + const signedXdr = signOfflineStellarTransaction(envelope, sender.secret()); + const tx = TransactionBuilder.fromXDR(signedXdr, networkPassphrase); + + expect(tx.operations.length).toBe(1); + expect(tx.signatures.length).toBe(1); + }); + }); +}); From db7a1e27123eee99ccef6170e1d8fd87bd793685 Mon Sep 17 00:00:00 2001 From: Hollujay <165713167+Hollujay@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:01:52 +0000 Subject: [PATCH 2/2] feat: Stellar offline transaction signing helper --- README.md | 2 +- docs/offline-signing.md | 21 +++++++++++++-------- src/chains/stellar/offline-sign.ts | 5 +---- src/vault/index.ts | 15 ++++++++++++--- test/vault/key-vault.test.ts | 6 ++++-- 5 files changed, 31 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index c19cb1a..fe88f71 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ See [MIGRATING.md](./MIGRATING.md) for breaking changes and migration steps when | `@wraith-protocol/sdk/chains/stellar` | Stellar stealth address crypto (ed25519) | | `@wraith-protocol/sdk/chains/solana` | Solana stealth address crypto (ed25519) | | `@wraith-protocol/sdk/chains/ckb` | CKB (Nervos) stealth address crypto (secp256k1) | -| `@wraith-protocol/sdk/vault` | Browser-only passphrase vault for short-lived keys | +| `@wraith-protocol/sdk/vault` | Browser-only passphrase vault for short-lived keys | > React Native support is documented in `docs/guides/react-native-setup.mdx` and the companion example at `examples/react-native-stellar`. diff --git a/docs/offline-signing.md b/docs/offline-signing.md index a5226ed..d88b0d3 100644 --- a/docs/offline-signing.md +++ b/docs/offline-signing.md @@ -10,7 +10,7 @@ When a Stellar secret key has ever touched a network-connected machine, it has b - Supply-chain compromised npm packages that scan for secret strings - Disk persistence in unencrypted terminal history, editor swap files, or crashed-process core dumps -Cold signing — also called *air-gapped* or *offline signing* — keeps the secret key on a machine that has never been and will never be connected to a network. The signing machine receives unsigned transaction envelopes (via USB drive, QR code, or serial cable), produces signatures, and returns the signed output. The secret never enters the online environment. +Cold signing — also called _air-gapped_ or _offline signing_ — keeps the secret key on a machine that has never been and will never be connected to a network. The signing machine receives unsigned transaction envelopes (via USB drive, QR code, or serial cable), produces signatures, and returns the signed output. The secret never enters the online environment. For stealth payment workflows this matters especially because derived stealth keys still originate from a master wallet secret. If an attacker steals the master signature or the derived stealth scalar during generation, they can drain every stealth account associated with that wallet. Cold signing prevents that by keeping scalars off networked machines altogether. @@ -18,11 +18,11 @@ For stealth payment workflows this matters especially because derived stealth ke The offline signing module provides three functions that split the lifecycle into separate concerns so signing can happen on a disconnected machine: -| Environment | Function | Responsibility | -|---|---|---| -| **Online** (watcher) | `prepareOfflineStellarTransaction` | Build the unsigned envelope with operations, sequence, and fee | -| **Air-gapped** (signer) | `signOfflineStellarTransaction` | Import the envelope, apply the signature, export the signed XDR | -| **Online** (submitter) | `submitOfflineStellarTransaction` | POST the signed envelope to Horizon | +| Environment | Function | Responsibility | +| ----------------------- | ---------------------------------- | --------------------------------------------------------------- | +| **Online** (watcher) | `prepareOfflineStellarTransaction` | Build the unsigned envelope with operations, sequence, and fee | +| **Air-gapped** (signer) | `signOfflineStellarTransaction` | Import the envelope, apply the signature, export the signed XDR | +| **Online** (submitter) | `submitOfflineStellarTransaction` | POST the signed envelope to Horizon | ## Step-by-Step Workflow @@ -67,7 +67,9 @@ Transfer this object to the air-gapped machine — by JSON file on a USB stick, import { signOfflineStellarTransaction } from '@wraith-protocol/sdk/chains/stellar'; // Load the envelope from USB / serial / QR scan -const envelope: OfflineStellarEnvelope = JSON.parse(fs.readFileSync('/mnt/usb/envelope.json', 'utf-8')); +const envelope: OfflineStellarEnvelope = JSON.parse( + fs.readFileSync('/mnt/usb/envelope.json', 'utf-8'), +); const signedXdr = signOfflineStellarTransaction( envelope, @@ -100,7 +102,10 @@ Offline signing works with stealth-derived keys. The critical detail is that a s ```ts import { Keypair, Operation, Asset, TransactionBuilder } from '@stellar/stellar-sdk'; -import { prepareOfflineStellarTransaction, signOfflineStellarTransaction } from '@wraith-protocol/sdk/chains/stellar'; +import { + prepareOfflineStellarTransaction, + signOfflineStellarTransaction, +} from '@wraith-protocol/sdk/chains/stellar'; import { deriveStealthKeys } from '@wraith-protocol/sdk/chains/stellar'; import { generateStealthAddress } from '@wraith-protocol/sdk/chains/stellar'; import { deriveStealthPrivateScalar } from '@wraith-protocol/sdk/chains/stellar'; diff --git a/src/chains/stellar/offline-sign.ts b/src/chains/stellar/offline-sign.ts index ed459ab..4b0f0a6 100644 --- a/src/chains/stellar/offline-sign.ts +++ b/src/chains/stellar/offline-sign.ts @@ -131,10 +131,7 @@ export function signOfflineStellarTransaction( throw new Error('Invalid envelope: transactionXdr and networkPassphrase are required'); } - const tx = TransactionBuilder.fromXDR( - envelope.transactionXdr, - envelope.networkPassphrase, - ); + const tx = TransactionBuilder.fromXDR(envelope.transactionXdr, envelope.networkPassphrase); if (typeof key === 'string') { const keypair = Keypair.fromSecret(key); diff --git a/src/vault/index.ts b/src/vault/index.ts index 2a1f701..cf56101 100644 --- a/src/vault/index.ts +++ b/src/vault/index.ts @@ -155,7 +155,11 @@ function toBytes(input: string): Uint8Array { return new TextEncoder().encode(input); } -async function deriveAesKey(passphrase: string, salt: Uint8Array, iterations: number): Promise { +async function deriveAesKey( + passphrase: string, + salt: Uint8Array, + iterations: number, +): Promise { const passphraseKey = await crypto.subtle.importKey( 'raw', toBytes(passphrase) as BufferSource, @@ -178,7 +182,10 @@ async function deriveAesKey(passphrase: string, salt: Uint8Array, iterations: nu ); } -async function encryptJson(key: CryptoKey, payload: unknown): Promise<{ iv: string; ciphertext: string }> { +async function encryptJson( + key: CryptoKey, + payload: unknown, +): Promise<{ iv: string; ciphertext: string }> { const iv = crypto.getRandomValues(new Uint8Array(12)); const plaintext = toBytes(JSON.stringify(encodeValue(payload))); const ciphertext = await crypto.subtle.encrypt( @@ -230,7 +237,9 @@ export class KeyVault { try { await decryptJson(key, resolvedMeta.checkIv, resolvedMeta.checkCiphertext); } catch { - throw new Error('Unable to unlock KeyVault. The passphrase is incorrect or the vault is corrupt.'); + throw new Error( + 'Unable to unlock KeyVault. The passphrase is incorrect or the vault is corrupt.', + ); } this.cryptoKey = key; diff --git a/test/vault/key-vault.test.ts b/test/vault/key-vault.test.ts index 44b1286..92c7c4c 100644 --- a/test/vault/key-vault.test.ts +++ b/test/vault/key-vault.test.ts @@ -39,8 +39,10 @@ describe('KeyVault', () => { let documentTarget: ReturnType & Record; beforeEach(() => { - windowTarget = createEventTarget() as ReturnType & Record; - documentTarget = createEventTarget() as ReturnType & Record; + windowTarget = createEventTarget() as ReturnType & + Record; + documentTarget = createEventTarget() as ReturnType & + Record; documentTarget.visibilityState = 'visible'; globalThis.indexedDB = new IDBFactory();