From c61c69d02dab5f4b26b4579b8a88364524b399e6 Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Fri, 24 Jul 2026 14:32:51 +0100 Subject: [PATCH] feat(stellar): add signer interface for passkey stealth key derivation --- CHANGELOG.md | 7 + docs/chains/stellar-stealth-signers.md | 92 ++++++++++++++ packages/sdk-react/src/env.d.ts | 2 + packages/sdk-react/src/hooks.ts | 10 +- packages/sdk-react/test/hooks.test.tsx | 23 +++- src/chains/stellar/index.ts | 10 +- src/chains/stellar/keys.ts | 25 ++++ src/chains/stellar/signer.ts | 169 +++++++++++++++++++++++++ test/chains/stellar/signer.test.ts | 126 ++++++++++++++++++ 9 files changed, 461 insertions(+), 3 deletions(-) create mode 100644 docs/chains/stellar-stealth-signers.md create mode 100644 src/chains/stellar/signer.ts create mode 100644 test/chains/stellar/signer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d8edbf..b548847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to the Wraith Protocol SDK will be documented in this file. ## Upcoming: 2.0.0 +### Added + +- **Stellar `StellarStealthSigner` Interface** (issue #121): `deriveStealthKeys()` now has a signer-based counterpart, `deriveStealthKeysFromSigner()`, that accepts any `StellarStealthSigner` (`{ signMessage(message): Promise }`) instead of assuming a synchronous Freighter-shaped ed25519 signature. + - `FreighterStealthSigner` wraps the existing Freighter-style wallet API; the raw `deriveStealthKeys(signature)` path is unchanged. + - `WebAuthnPasskeyStealthSigner` is a reference passkey adapter that uses the WebAuthn `prf` extension to derive stable key material across sessions, since raw WebAuthn assertion signatures are non-deterministic. + - `useStellarStealthKeys()` in `@wraith-protocol/sdk-react` gained a `generateFromSigner()` method alongside the existing `generate()`. + ### Changed - **Stellar Chain Module Cryptographic Audit Fixes**: Applied all findings from independent cryptographic audit (issue #55). Breaking changes: diff --git a/docs/chains/stellar-stealth-signers.md b/docs/chains/stellar-stealth-signers.md new file mode 100644 index 0000000..73b852c --- /dev/null +++ b/docs/chains/stellar-stealth-signers.md @@ -0,0 +1,92 @@ +# Stellar stealth signers + +## Background + +`deriveStealthKeys(signature)` derives Stellar stealth spending/viewing keys from a raw +64-byte ed25519 signature, which assumes a Freighter-shaped wallet that can synchronously +produce that signature. A WebAuthn passkey (secp256r1, via a Stellar smart account) can't +satisfy that shape: assertions are asynchronous, are not ed25519, and are not deterministic +across sessions (ECDSA uses a fresh nonce, and the authenticator's signature counter and +`clientDataJSON` challenge change on every call). + +`StellarStealthSigner` decouples key derivation from the signing mechanism so any wallet, +including a passkey-only one, can derive stealth keys as long as it can produce 64 +deterministic bytes for a fixed message. + +## API + +### `StellarStealthSigner` + +```ts +interface StellarStealthSigner { + signMessage(message: Uint8Array): Promise; +} +``` + +`signMessage` doesn't need to return a real signature — only 64 bytes that are reproducible +for the same wallet and message across separate calls, including separate sessions and +devices for the same underlying wallet. + +### `deriveStealthKeysFromSigner` + +```ts +import { deriveStealthKeysFromSigner } from '@wraith-protocol/sdk/chains/stellar'; + +const keys = await deriveStealthKeysFromSigner(signer); +``` + +Signs `STEALTH_SIGNING_MESSAGE` with `signer` and feeds the result into `deriveStealthKeys`. +Existing code that already holds a raw Freighter signature can keep calling +`deriveStealthKeys(signature)` directly — this path is unchanged. + +### `FreighterStealthSigner` + +```ts +import { FreighterStealthSigner } from '@wraith-protocol/sdk/chains/stellar'; + +const signer = new FreighterStealthSigner(freighterApi); // { signMessage(message): Promise<{ signedMessage }> } +const keys = await deriveStealthKeysFromSigner(signer); +``` + +Wraps a Freighter-shaped wallet and passes its ed25519 signature straight through, matching +the existing derivation exactly. + +### `WebAuthnPasskeyStealthSigner` + +```ts +import { WebAuthnPasskeyStealthSigner } from '@wraith-protocol/sdk/chains/stellar'; + +const signer = new WebAuthnPasskeyStealthSigner({ + credentialId, // the passkey's credential ID + rpId: 'wraith.dev', + // credentials defaults to navigator.credentials in a browser +}); + +const keys = await deriveStealthKeysFromSigner(signer); +``` + +Reference adapter for a passkey-backed smart account. Instead of hashing the (non-deterministic) +assertion signature, it requests the WebAuthn Level 3 `prf` extension with two fixed salts. +PRF output is deterministically derived from the credential's private key material and the +salt, so the same passkey produces the same 64 bytes on every device and session, without +ever exposing the underlying secret. Concatenating the two 32-byte PRF outputs gives the +64 bytes `deriveStealthKeys` expects. + +This adapter only covers stealth-key derivation. Authorizing the on-chain smart-account +operation that funds or spends a stealth address is a separate WebAuthn `get()` call +(without the `prf` extension); that assertion doesn't need to be deterministic and is +submitted directly to the smart-account contract. + +If the authenticator doesn't support `prf`, `signMessage` throws `KeyDerivationFailedError`. + +### `useStellarStealthKeys` (sdk-react) + +```ts +const { keys, generate, generateFromSigner } = useStellarStealthKeys(); + +// existing Freighter path, unchanged +generate(rawSignature); + +// signer-based path (Freighter wrapper, passkey adapter, or a custom signer) +await generateFromSigner(signer); +``` diff --git a/packages/sdk-react/src/env.d.ts b/packages/sdk-react/src/env.d.ts index a8ca87a..5752660 100644 --- a/packages/sdk-react/src/env.d.ts +++ b/packages/sdk-react/src/env.d.ts @@ -1,9 +1,11 @@ declare module '@wraith-protocol/sdk/chains/stellar' { export const deriveStealthKeys: any; + export const deriveStealthKeysFromSigner: any; export const fetchAnnouncements: any; export const buildStealthPayment: any; export const getDeployment: any; export type StealthKeys = any; + export type StellarStealthSigner = any; export type FetchAnnouncementsOptions = any; export type Announcement = any; export type BuildStealthPaymentOptions = any; diff --git a/packages/sdk-react/src/hooks.ts b/packages/sdk-react/src/hooks.ts index b987fdb..30ed3f7 100644 --- a/packages/sdk-react/src/hooks.ts +++ b/packages/sdk-react/src/hooks.ts @@ -1,10 +1,12 @@ import { useState, useCallback, useEffect } from 'react'; import { deriveStealthKeys, + deriveStealthKeysFromSigner, fetchAnnouncements, buildStealthPayment, getDeployment, type StealthKeys, + type StellarStealthSigner, type FetchAnnouncementsOptions, type Announcement, type BuildStealthPaymentOptions, @@ -21,7 +23,13 @@ export function useStellarStealthKeys() { return derived; }, []); - return { keys, generate }; + const generateFromSigner = useCallback(async (signer: StellarStealthSigner) => { + const derived = await deriveStealthKeysFromSigner(signer); + setKeys(derived); + return derived; + }, []); + + return { keys, generate, generateFromSigner }; } /** Hook to scan for announcements */ diff --git a/packages/sdk-react/test/hooks.test.tsx b/packages/sdk-react/test/hooks.test.tsx index 3a36179..ca6db4e 100644 --- a/packages/sdk-react/test/hooks.test.tsx +++ b/packages/sdk-react/test/hooks.test.tsx @@ -2,13 +2,19 @@ import { renderHook, act } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import { useStellarStealthKeys, useStellarBalance } from '../src/hooks'; -import { deriveStealthKeys } from '@wraith-protocol/sdk/chains/stellar'; +import { + deriveStealthKeys, + deriveStealthKeysFromSigner, +} from '@wraith-protocol/sdk/chains/stellar'; vi.mock('@wraith-protocol/sdk/chains/stellar', async () => { const original: any = await vi.importActual('@wraith-protocol/sdk/chains/stellar'); return { ...original, deriveStealthKeys: vi.fn(() => ({ viewPrivateKey: 'vpk', spendPrivateKey: 'spk' })), + deriveStealthKeysFromSigner: vi.fn(() => + Promise.resolve({ viewPrivateKey: 'vpk-signer', spendPrivateKey: 'spk-signer' }), + ), fetchAnnouncements: vi.fn(() => Promise.resolve({ announcements: [] })), buildStealthPayment: vi.fn(() => Promise.resolve({})), getDeployment: vi.fn(() => ({ rpcUrl: 'https://rpc', contracts: { names: 'foo' } })), @@ -42,6 +48,21 @@ describe('useStellarStealthKeys', () => { expect(result.current.keys).toEqual({ viewPrivateKey: 'vpk', spendPrivateKey: 'spk' }); expect(deriveStealthKeys).toHaveBeenCalled(); }); + + it('generates keys from a signer', async () => { + const { result } = renderHook(() => useStellarStealthKeys()); + const signer = { signMessage: vi.fn(() => Promise.resolve(new Uint8Array(64))) }; + + await act(async () => { + await result.current.generateFromSigner(signer); + }); + + expect(result.current.keys).toEqual({ + viewPrivateKey: 'vpk-signer', + spendPrivateKey: 'spk-signer', + }); + expect(deriveStealthKeysFromSigner).toHaveBeenCalledWith(signer); + }); }); describe('useStellarBalance', () => { diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index d294b4b..9131c45 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -1,4 +1,12 @@ -export { deriveStealthKeys } from './keys'; +export { deriveStealthKeys, deriveStealthKeysFromSigner } from './keys'; +export { FreighterStealthSigner, WebAuthnPasskeyStealthSigner } from './signer'; +export type { + StellarStealthSigner, + FreighterLikeWallet, + WebAuthnPRFAssertion, + WebAuthnCredentialsContainer, + WebAuthnPasskeyStealthSignerOptions, +} from './signer'; export { STEALTH_SIGNING_MESSAGE, SCHEME_ID, diff --git a/src/chains/stellar/keys.ts b/src/chains/stellar/keys.ts index c6698cd..e29ca39 100644 --- a/src/chains/stellar/keys.ts +++ b/src/chains/stellar/keys.ts @@ -3,6 +3,8 @@ import { ed25519 } from '@noble/curves/ed25519'; import { sha256 } from '@noble/hashes/sha256'; import type { StealthKeys } from './types'; import { seedToScalar } from './scalar'; +import { STEALTH_SIGNING_MESSAGE } from './constants'; +import type { StellarStealthSigner } from './signer'; /** * Derives Stellar stealth spending and viewing keys from a wallet signature. @@ -51,3 +53,26 @@ export function deriveStealthKeys(signature: Uint8Array): StealthKeys { viewingPubKey, }; } + +/** + * Derives Stellar stealth keys using any {@link StellarStealthSigner}. + * + * This is the signer-based entry point: it asks `signer` to sign + * {@link STEALTH_SIGNING_MESSAGE} and feeds the resulting bytes into + * {@link deriveStealthKeys}. Existing callers that already hold a raw 64-byte + * Freighter signature can keep calling `deriveStealthKeys` directly; this + * wrapper exists for signers (e.g. passkeys) whose signing step isn't a + * simple synchronous ed25519 signature. + * + * @throws {InvalidSignatureError} If the signer does not return 64 bytes. + * + * @see {@link deriveStealthKeys} + * @see {@link StellarStealthSigner} + */ +export async function deriveStealthKeysFromSigner( + signer: StellarStealthSigner, +): Promise { + const message = new TextEncoder().encode(STEALTH_SIGNING_MESSAGE); + const signature = await signer.signMessage(message); + return deriveStealthKeys(signature); +} diff --git a/src/chains/stellar/signer.ts b/src/chains/stellar/signer.ts new file mode 100644 index 0000000..a2e8a68 --- /dev/null +++ b/src/chains/stellar/signer.ts @@ -0,0 +1,169 @@ +import { sha256 } from '@noble/hashes/sha256'; +import { KeyDerivationFailedError } from '../../errors'; + +/** + * Minimal signing capability required to derive Stellar stealth keys. + * + * Implementations may wrap any wallet or signer shape (Freighter, a WebAuthn + * passkey, a smart-account session key) as long as `signMessage` returns bytes + * that are stable for a given wallet/message pair across separate calls, + * including across sessions and devices for the same underlying wallet. + * {@link deriveStealthKeysFromSigner} feeds the returned bytes into the same + * domain-separated SHA-256 derivation used by {@link deriveStealthKeys}, so any + * signer that satisfies this contract is interchangeable. + * + * @see {@link deriveStealthKeysFromSigner} + * @see {@link FreighterStealthSigner} + * @see {@link WebAuthnPasskeyStealthSigner} + */ +export interface StellarStealthSigner { + /** + * Returns 64 deterministic bytes for `message`. + * + * The bytes do not need to be a raw ed25519 signature: {@link deriveStealthKeys} + * only requires exactly 64 bytes that are reproducible for the same signer + * and message. + */ + signMessage(message: Uint8Array): Promise; +} + +/** Freighter-shaped wallet API accepted by {@link FreighterStealthSigner}. */ +export interface FreighterLikeWallet { + signMessage(message: string): Promise<{ signedMessage: Uint8Array | string }>; +} + +/** + * Wraps a Freighter-shaped ed25519 wallet as a {@link StellarStealthSigner}. + * + * This is the existing derivation path: it signs the message with the + * wallet's ed25519 key and passes the raw 64-byte signature straight through, + * unchanged from calling `deriveStealthKeys` with a Freighter signature + * directly. + * + * @see {@link StellarStealthSigner} + */ +export class FreighterStealthSigner implements StellarStealthSigner { + constructor(private readonly wallet: FreighterLikeWallet) {} + + async signMessage(message: Uint8Array): Promise { + const { signedMessage } = await this.wallet.signMessage(new TextDecoder().decode(message)); + return typeof signedMessage === 'string' ? base64ToBytes(signedMessage) : signedMessage; + } +} + +function base64ToBytes(base64: string): Uint8Array { + return new Uint8Array(Buffer.from(base64, 'base64')); +} + +/** WebAuthn assertion shape needed to extract PRF extension output. */ +export interface WebAuthnPRFAssertion { + getClientExtensionResults(): { + prf?: { + results?: { + first?: ArrayBuffer; + second?: ArrayBuffer; + }; + }; + }; +} + +/** Minimal shape of `navigator.credentials` needed to request a WebAuthn assertion. */ +export interface WebAuthnCredentialsContainer { + get(options: Record): Promise; +} + +/** Options for {@link WebAuthnPasskeyStealthSigner}. */ +export interface WebAuthnPasskeyStealthSignerOptions { + /** Credential ID of the passkey to request an assertion for. */ + credentialId: Uint8Array; + /** + * `navigator.credentials` or a compatible implementation (e.g. a test mock, + * or a smart-account SDK's WebAuthn shim). Defaults to the global + * `navigator.credentials` when running in a browser. + */ + credentials?: WebAuthnCredentialsContainer; + /** Relying party ID passed through to `navigator.credentials.get()`. */ + rpId?: string; +} + +/** + * Reference passkey adapter for deriving Stellar stealth keys from a WebAuthn + * (secp256r1 smart-account) signer. + * + * WebAuthn `get()` assertions are not deterministic across sessions: ECDSA + * signing uses a fresh nonce and the authenticator's signature counter and + * `clientDataJSON` challenge change on every call. Hashing the raw assertion + * signature the way the Freighter path hashes a raw ed25519 signature would + * therefore derive a different stealth key every time. + * + * Instead this adapter requests the WebAuthn Level 3 `prf` extension, whose + * output is deterministically derived from the credential's private key + * material and a fixed salt, independent of the assertion signature. Two + * salts are evaluated so the combined output is 64 bytes, matching what + * {@link deriveStealthKeys} expects. + * + * This adapter only covers stealth-key derivation. Authorizing the on-chain + * smart-account operation that funds or spends a stealth address is a + * separate WebAuthn `get()` call (without the `prf` extension) whose + * assertion is submitted to the smart-account contract; it does not need to + * be deterministic. + * + * @see {@link StellarStealthSigner} + */ +export class WebAuthnPasskeyStealthSigner implements StellarStealthSigner { + private readonly credentialId: Uint8Array; + private readonly credentials: WebAuthnCredentialsContainer; + private readonly rpId?: string; + + constructor(options: WebAuthnPasskeyStealthSignerOptions) { + this.credentialId = options.credentialId; + this.rpId = options.rpId; + + const globalCredentials = (globalThis as { navigator?: { credentials?: unknown } }).navigator + ?.credentials as WebAuthnCredentialsContainer | undefined; + const credentials = options.credentials ?? globalCredentials; + if (!credentials) { + throw new KeyDerivationFailedError( + 'No WebAuthn credentials container available; pass `credentials` explicitly outside a browser context.', + ); + } + this.credentials = credentials; + } + + async signMessage(message: Uint8Array): Promise { + const first = derivePRFSalt(message, 'first'); + const second = derivePRFSalt(message, 'second'); + + const assertion = await this.credentials.get({ + publicKey: { + challenge: message, + rpId: this.rpId, + allowCredentials: [{ id: this.credentialId, type: 'public-key' }], + userVerification: 'required', + extensions: { + prf: { eval: { first, second } }, + }, + }, + }); + + const results = assertion?.getClientExtensionResults().prf?.results; + if (!results?.first || !results?.second) { + throw new KeyDerivationFailedError( + 'Authenticator did not return PRF extension results; a PRF-capable passkey is required for deterministic stealth key derivation.', + ); + } + + const combined = new Uint8Array(64); + combined.set(new Uint8Array(results.first).subarray(0, 32), 0); + combined.set(new Uint8Array(results.second).subarray(0, 32), 32); + return combined; + } +} + +function derivePRFSalt(message: Uint8Array, label: 'first' | 'second'): Uint8Array { + const prefix = new TextEncoder().encode(`wraith:stellar:passkey-prf:${label}:`); + const input = new Uint8Array(prefix.length + message.length); + input.set(prefix); + input.set(message, prefix.length); + return sha256(input); +} diff --git a/test/chains/stellar/signer.test.ts b/test/chains/stellar/signer.test.ts new file mode 100644 index 0000000..332ced1 --- /dev/null +++ b/test/chains/stellar/signer.test.ts @@ -0,0 +1,126 @@ +import { describe, test, expect } from 'vitest'; +import { sha256 } from '@noble/hashes/sha256'; +import { KeyDerivationFailedError } from '../../../src/errors'; +import { deriveStealthKeys, deriveStealthKeysFromSigner } from '../../../src/chains/stellar/keys'; +import { STEALTH_SIGNING_MESSAGE } from '../../../src/chains/stellar/constants'; +import { + FreighterStealthSigner, + WebAuthnPasskeyStealthSigner, + type WebAuthnCredentialsContainer, +} from '../../../src/chains/stellar/signer'; + +const message = new TextEncoder().encode(STEALTH_SIGNING_MESSAGE); + +describe('FreighterStealthSigner', () => { + test('existing Freighter path is unchanged: signature bytes pass straight through', async () => { + const rawSignature = new Uint8Array(64).fill(0xab); + const signer = new FreighterStealthSigner({ + signMessage: async () => ({ signedMessage: rawSignature }), + }); + + const bytes = await signer.signMessage(message); + expect(bytes).toEqual(rawSignature); + + const viaSigner = await deriveStealthKeysFromSigner(signer); + const direct = deriveStealthKeys(rawSignature); + expect(viaSigner).toEqual(direct); + }); + + test('decodes a base64-encoded signedMessage', async () => { + const rawSignature = new Uint8Array(64).fill(0xcd); + const base64 = Buffer.from(rawSignature).toString('base64'); + const signer = new FreighterStealthSigner({ + signMessage: async () => ({ signedMessage: base64 }), + }); + + const bytes = await signer.signMessage(message); + expect(bytes).toEqual(rawSignature); + }); +}); + +/** Simulates a PRF-capable authenticator: deterministic per credential + salt. */ +function mockPasskeyCredentials(credentialId: Uint8Array): WebAuthnCredentialsContainer { + return { + get: async (options: any) => { + const eval_ = options.publicKey.extensions.prf.eval; + const derive = (salt: Uint8Array) => { + const input = new Uint8Array(credentialId.length + salt.length); + input.set(credentialId); + input.set(salt, credentialId.length); + return sha256(input).buffer; + }; + return { + getClientExtensionResults: () => ({ + prf: { + results: { + first: derive(new Uint8Array(eval_.first)), + second: derive(new Uint8Array(eval_.second)), + }, + }, + }), + }; + }, + }; +} + +describe('WebAuthnPasskeyStealthSigner', () => { + const credentialId = new Uint8Array(16).fill(0x11); + + test('derives the same stealth keys across two separate sessions', async () => { + const session1 = new WebAuthnPasskeyStealthSigner({ + credentialId, + credentials: mockPasskeyCredentials(credentialId), + }); + const session2 = new WebAuthnPasskeyStealthSigner({ + credentialId, + credentials: mockPasskeyCredentials(credentialId), + }); + + const keys1 = await deriveStealthKeysFromSigner(session1); + const keys2 = await deriveStealthKeysFromSigner(session2); + + expect(keys1.spendingKey).toEqual(keys2.spendingKey); + expect(keys1.viewingKey).toEqual(keys2.viewingKey); + expect(keys1.spendingPubKey).toEqual(keys2.spendingPubKey); + expect(keys1.viewingPubKey).toEqual(keys2.viewingPubKey); + }); + + test('different credentials derive different keys', async () => { + const otherCredentialId = new Uint8Array(16).fill(0x22); + const signerA = new WebAuthnPasskeyStealthSigner({ + credentialId, + credentials: mockPasskeyCredentials(credentialId), + }); + const signerB = new WebAuthnPasskeyStealthSigner({ + credentialId: otherCredentialId, + credentials: mockPasskeyCredentials(otherCredentialId), + }); + + const keysA = await deriveStealthKeysFromSigner(signerA); + const keysB = await deriveStealthKeysFromSigner(signerB); + + expect(keysA.spendingKey).not.toEqual(keysB.spendingKey); + }); + + test('throws when no credentials container is available', () => { + expect( + () => + new WebAuthnPasskeyStealthSigner({ + credentialId, + }), + ).toThrow(KeyDerivationFailedError); + }); + + test('throws when the authenticator does not return PRF results', async () => { + const signer = new WebAuthnPasskeyStealthSigner({ + credentialId, + credentials: { + get: async () => ({ + getClientExtensionResults: () => ({}), + }), + }, + }); + + await expect(signer.signMessage(message)).rejects.toThrow(KeyDerivationFailedError); + }); +});