Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array> }`) 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:
Expand Down
92 changes: 92 additions & 0 deletions docs/chains/stellar-stealth-signers.md
Original file line number Diff line number Diff line change
@@ -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<Uint8Array>;
}
```

`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);
```
2 changes: 2 additions & 0 deletions packages/sdk-react/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
10 changes: 9 additions & 1 deletion packages/sdk-react/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 */
Expand Down
23 changes: 22 additions & 1 deletion packages/sdk-react/test/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' } })),
Expand Down Expand Up @@ -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', () => {
Expand Down
10 changes: 9 additions & 1 deletion src/chains/stellar/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
25 changes: 25 additions & 0 deletions src/chains/stellar/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<StealthKeys> {
const message = new TextEncoder().encode(STEALTH_SIGNING_MESSAGE);
const signature = await signer.signMessage(message);
return deriveStealthKeys(signature);
}
Loading
Loading