Skip to content
Closed
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
110 changes: 110 additions & 0 deletions audits/2026-06-author-stellar-module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Stellar Module Cryptographic Audit — Draft

Date: 2026-06-02
Author: (TBD)

Scope

- Review of `src/chains/stellar/` primitives: keys, scalar, stealth, scan, spend, announcements.

Status

- Baseline: repository tests all pass (136 tests).
- Dependencies pinned in `pnpm-lock.yaml`: `@noble/curves@1.9.7`, `@noble/hashes@1.8.0`.

Next steps

- Complete line-by-line review and produce findings with severity, repro tests, and recommendations.
- Collect ECDH test vectors and run cross-checks against a reference implementation.
- Coordinate disclosure for any Critical/High findings.

Findings

- (To be populated during review)

Findings

1. `signWithScalar` deviates from RFC8032 deterministic construction

- Description: `src/chains/stellar/scalar.ts::signWithScalar()` derives the
nonce prefix as `sha256(scalarBytes)` and then computes `r = SHA-512(prefix || message) mod L`.
RFC8032 specifies deterministic ed25519 signing using SHA-512 of the original 32-byte seed
(not the scalar) to derive the prefix. Using the scalar-derived prefix is a protocol
deviation that changes nonce derivation semantics and could have subtle implications
if the same `stealthScalar` is used across different contexts.
- Severity: High
- Reproduction: See test `test/audits/stellar.test.ts` - skipped (High severity)
- Recommendation: Replace custom signing with an audited construction. Options:
- Store and use the original 32-byte seed where possible and follow RFC8032.
- If only the scalar is available, use a documented and reviewed deterministic
signing construction (and justify why it meets the required properties).
- At minimum, add comprehensive tests and a formal review of `signWithScalar`.

2. Zero `hashScalar` yields stealth = spending key (rare edge case)

- Description: If `hashToScalar(sharedSecret) == 0`, then `deriveStealthPubKey(spend, 0)`
equals the original spending public key. An announcement with such a shared-secret
would cause the stealth address to equal the recipient's spending address, breaking
unlinkability for that payment.
- Severity: Medium (probability ~1/L, still worth noting)
- Reproduction: Unit test confirms `deriveStealthPubKey(spend, 0n) === spend`.
- Recommendation: Treat `hashScalar == 0` as an exceptional case: either
- reject announcements where `hashScalar == 0` (scanner/recipient skip), or
- document the risk and accept it as cryptographically negligible.

3. edwards->montgomery conversion and X25519 integration checks

- Description: `stealth.computeSharedSecret()` converts ed25519 keys to Montgomery form
using `edwardsToMontgomeryPriv`/`edwardsToMontgomeryPub` and calls X25519.
This code path must be validated against independent X25519/Ed25519 conversion
expectations and test vectors.
- Severity: Medium
- Reproduction: Unit test verifies `computeSharedSecret(seedA, pubB)` equals
`x25519.getSharedSecret(edwardsToMontgomeryPriv(seedA), edwardsToMontgomeryPub(pubB))`.
- Recommendation: Collect and store ECDH test vectors (both random and RFC vectors),
add CI tests comparing Wraith's implementation against an independent reference
(e.g., a different library or authoritative test vectors).

4. Domain separation prefixes present and versioned

- Description: The implementation uses explicit prefixes:
- `wraith:spending:`, `wraith:viewing:` (key derivation)
- `wraith:scalar:` (shared-secret -> scalar)
- `wraith:stellar:view-tag:v2:` and legacy `wraith:tag:` (view-tag)
These are versioned which is good practice.
- Severity: Low / Informational
- Reproduction: Unit test validates `computeAnnouncementViewTag()` uses the expected
prefix by comparing the raw SHA-256 computation.
- Recommendation: Keep prefixes versioned; ensure any protocol docs include these exact
strings. Consider centralizing prefix constants in one file to avoid discrepancies.

5. Dependency pinning (lockfile) — recommend exact pins in package.json

- Description: `package.json` uses caret ranges for `@noble/curves` and `@noble/hashes`,
but `pnpm-lock.yaml` currently resolves to `@noble/curves@1.9.7`, `@noble/hashes@1.8.0`.
For cryptographic stability prefer exact pins in `package.json` or CI checks that
enforce lockfile changes require review.
- Severity: Low
- Reproduction: Unit test checks `pnpm-lock.yaml` contains the expected versions.
- Recommendation: Pin to exact versions or add an automated check that flags lockfile
updates to these dependencies for manual review.

6. View-tag prefilter correctness and bias

- Description: The view-tag uses the first byte of `SHA-256(prefix || R || V)`.
SHA-256 of this concatenation should be uniformly distributed across bytes; therefore
the one-byte filter yields approximate 1/256 selection probability.
- Severity: Informational
- Reproduction: Unit test samples many ephemeral keys and asserts the view-tag set
shows sufficient diversity (smoke test).
- Recommendation: Accept this filter; document the expected false-positive rate.

7. Constant-time / side-channel considerations

- Description: Implementation uses JS bigints and high-level libs; strict constant-time
is not guaranteed. `noble-curves` implements many primitives carefully, but higher-level
wrapping code (additions, mod reductions, custom signing) may not be constant-time.
- Severity: Informational
- Recommendation: Document threat model (JS environment) and mark constant-time as
a soft goal. For high-value deployments consider native modules or audited constant-time
implementations.
67 changes: 67 additions & 0 deletions docs/chains/stellar-view-tag-batching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Stellar view-tag batching design

## Problem

The original Stellar scan path computed `S = X25519(v, R_ephemeral)` for every announcement before checking the view tag. That made the one-byte view tag a correctness filter, but not a performance filter: non-matching announcements still paid the dominant ECDH cost.

## Chosen design

New Stellar announcements derive the first metadata byte from public announcement data:

```text
view_tag = SHA-256("wraith:stellar:view-tag:v2:" || R_ephemeral || V_recipient)[0]
```

Where:

- `R_ephemeral` is the 32-byte ed25519 ephemeral public key included in the announcement.
- `V_recipient` is the recipient's 32-byte ed25519 viewing public key from the meta-address.

This keeps the stealth-address secret scalar unchanged:

```text
S = X25519(r_ephemeral, V_recipient) = X25519(v_recipient, R_ephemeral)
hash_scalar = SHA-256("wraith:scalar:" || S) mod L
P_stealth = K_spend + hash_scalar * G
```

Scanners now derive `V_recipient` once from the local viewing seed, hash `R_ephemeral || V_recipient` for every announcement, and only compute X25519 plus ed25519 point addition for the roughly 1/256 announcements whose tag matches.

## Tradeoffs

### Benefits

- The hot scan loop replaces nearly all X25519 operations with one SHA-256 over a small public tuple.
- The full stealth address derivation and private scalar derivation remain unchanged for matching announcements.
- The filter keeps the same one-byte false-positive rate as the previous shared-secret tag.
- Invalid 32-byte ephemeral keys are only parsed as curve points after the public tag passes; if a crafted candidate passes the tag but is not a valid point, it is skipped.

### Costs and compatibility

- The view tag is no longer bound to the ECDH shared secret. It is a public prefilter, not authentication. This is acceptable because the announced stealth address is still verified with the shared-secret-derived scalar before a match is returned.
- A sender that knows a recipient's public viewing key can deliberately choose metadata that passes the recipient's public prefilter. That only causes the recipient to do the same full verification they already needed for candidate announcements, and the stealth address check still prevents false matches.
- Legacy announcements whose metadata used `SHA-256("wraith:tag:" || S)[0]` are not compatible with the optimized `scanAnnouncements` path. The SDK retains `scanAnnouncementsLegacySharedSecretTag` for benchmarks and migration tooling, but using it for normal scans necessarily reintroduces one X25519 per announcement.
- If deployed contracts or indexers need to distinguish old and new metadata semantics, this should be represented as a soft fork/new scheme identifier. The SDK-side cryptographic change is isolated to metadata generation and scanning; the stealth-address math does not change.

## Benchmarks

The benchmark harness lives at `test/chains/stellar/bench/scan.bench.ts` and compares:

1. `scanAnnouncementsLegacySharedSecretTag` over legacy shared-secret-tag announcements.
2. `scanAnnouncements` over new public-announcement-tag announcements.

Run it with:

```bash
pnpm exec vitest bench test/chains/stellar/bench/scan.bench.ts --run
```

The harness covers synthetic 10k, 100k, and 1M announcement datasets with one recipient match and a large pool of foreign announcements. Set `STELLAR_SCAN_BENCH_SIZES=10000` (or a comma-separated list) to run a subset locally.

On this development container, the 10k benchmark reported:

| Dataset | Before: shared-secret tag | After: public prefilter | Speedup |
| -------------------- | ------------------------: | ----------------------: | ------: |
| 10,000 announcements | 31,310.03 ms | 98.83 ms | 316.80x |

The expected speedup grows with dataset size because the optimized path computes the viewing public key once and performs X25519 only for public view-tag hits instead of every same-scheme announcement.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@
}
},
"devDependencies": {
"ed2curve": "^0.3.0",
"tweetnacl": "^1.0.3",
"@commitlint/cli": "^19.6.0",
"@commitlint/config-conventional": "^19.6.0",
"@solana/web3.js": "^1.98.4",
Expand Down
13 changes: 11 additions & 2 deletions src/chains/stellar/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
export { deriveStealthKeys } from './keys';
export { STEALTH_SIGNING_MESSAGE, SCHEME_ID, META_ADDRESS_PREFIX } from './constants';
export { encodeStealthMetaAddress, decodeStealthMetaAddress } from './meta-address';
export { generateStealthAddress, computeSharedSecret, computeViewTag } from './stealth';
export { checkStealthAddress, scanAnnouncements } from './scan';
export {
generateStealthAddress,
computeSharedSecret,
computeAnnouncementViewTag,
computeViewTag,
} from './stealth';
export {
checkStealthAddress,
scanAnnouncements,
scanAnnouncementsLegacySharedSecretTag,
} from './scan';
export { deriveStealthPrivateScalar, signStellarTransaction } from './spend';
export {
seedToScalar,
Expand Down
120 changes: 110 additions & 10 deletions src/chains/stellar/scan.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { computeSharedSecret, computeViewTag } from './stealth';
import { ed25519 } from '@noble/curves/ed25519';
import { computeAnnouncementViewTag, computeSharedSecret, computeViewTag } from './stealth';
import { hashToScalar, deriveStealthPubKey, pubKeyToStellarAddress, L } from './scalar';
import { SCHEME_ID } from './constants';
import type { Announcement, MatchedAnnouncement } from './types';
Expand All @@ -7,12 +8,13 @@ import { hexToBytes } from './utils';
/**
* Checks whether a single announcement belongs to the recipient.
*
* Uses only the viewing key and spending PUBLIC key (no spending private key):
* 1. Compute shared secret: S = ECDH(viewing_key, R_ephemeral)
* 2. View tag quick filter (eliminates ~255/256 non-matches)
* 3. Compute hash_scalar = SHA-256("wraith:scalar:" || S) mod L
* 4. Expected stealth pubkey = K_spend + hash_scalar * G
* 5. Compare with announced stealth address
* Uses the cheap public view-tag prefilter before the X25519 shared secret:
* 1. Derive the viewing public key once from the viewing seed
* 2. View tag quick filter from R_ephemeral || viewing_pubkey
* 3. Compute shared secret: S = ECDH(viewing_key, R_ephemeral) only for tag hits
* 4. Compute hash_scalar = SHA-256("wraith:scalar:" || S) mod L
* 5. Expected stealth pubkey = K_spend + hash_scalar * G
* 6. Compare with announced stealth address
*
* This is view-only: it can detect payments but NOT derive the spending key.
*/
Expand All @@ -27,13 +29,51 @@ export function checkStealthAddress(
hashScalar: bigint | null;
stealthPubKeyBytes: Uint8Array | null;
} {
const sharedSecret = computeSharedSecret(viewingKey, ephemeralPubKey);
const viewingPubKey = ed25519.getPublicKey(viewingKey);
return checkStealthAddressWithViewingPubKey(
ephemeralPubKey,
viewingKey,
viewingPubKey,
spendingPubKey,
viewTag,
);
}

const computedTag = computeViewTag(sharedSecret);
function checkStealthAddressWithViewingPubKey(
ephemeralPubKey: Uint8Array,
viewingKey: Uint8Array,
viewingPubKey: Uint8Array,
spendingPubKey: Uint8Array,
viewTag: number,
): {
isMatch: boolean;
stealthAddress: string | null;
hashScalar: bigint | null;
stealthPubKeyBytes: Uint8Array | null;
} {
const computedTag = computeAnnouncementViewTag(ephemeralPubKey, viewingPubKey);
if (computedTag !== viewTag) {
return { isMatch: false, stealthAddress: null, hashScalar: null, stealthPubKeyBytes: null };
}

try {
return deriveStealthAddressFromAnnouncement(ephemeralPubKey, viewingKey, spendingPubKey);
} catch {
return { isMatch: false, stealthAddress: null, hashScalar: null, stealthPubKeyBytes: null };
}
}

function deriveStealthAddressFromAnnouncement(
ephemeralPubKey: Uint8Array,
viewingKey: Uint8Array,
spendingPubKey: Uint8Array,
): {
isMatch: boolean;
stealthAddress: string | null;
hashScalar: bigint | null;
stealthPubKeyBytes: Uint8Array | null;
} {
const sharedSecret = computeSharedSecret(viewingKey, ephemeralPubKey);
const hScalar = hashToScalar(sharedSecret);

const stealthPubKeyBytes = deriveStealthPubKey(spendingPubKey, hScalar);
Expand All @@ -60,6 +100,7 @@ export function scanAnnouncements(
spendingScalar: bigint,
): MatchedAnnouncement[] {
const matched: MatchedAnnouncement[] = [];
const viewingPubKey = ed25519.getPublicKey(viewingKey);

for (const ann of announcements) {
if (ann.schemeId !== SCHEME_ID) continue;
Expand All @@ -71,7 +112,13 @@ export function scanAnnouncements(
const ephPubKey = hexToBytes(ann.ephemeralPubKey);
if (ephPubKey.length !== 32) continue;

const result = checkStealthAddress(ephPubKey, viewingKey, spendingPubKey, viewTag);
const result = checkStealthAddressWithViewingPubKey(
ephPubKey,
viewingKey,
viewingPubKey,
spendingPubKey,
viewTag,
);

if (
result.isMatch &&
Expand All @@ -91,3 +138,56 @@ export function scanAnnouncements(

return matched;
}

/**
* Pre-optimization scanner retained for benchmarks and migration analysis.
*
* This matches the old Stellar path: every same-scheme announcement pays for
* X25519 first, computes the legacy shared-secret tag second, and only then
* compares the announced stealth address.
*/
export function scanAnnouncementsLegacySharedSecretTag(
announcements: Announcement[],
viewingKey: Uint8Array,
spendingPubKey: Uint8Array,
spendingScalar: bigint,
): MatchedAnnouncement[] {
const matched: MatchedAnnouncement[] = [];

for (const ann of announcements) {
if (ann.schemeId !== SCHEME_ID) continue;

const metadataBytes = hexToBytes(ann.metadata);
if (metadataBytes.length === 0) continue;
const viewTag = metadataBytes[0];

const ephPubKey = hexToBytes(ann.ephemeralPubKey);
if (ephPubKey.length !== 32) continue;

let sharedSecret: Uint8Array;
try {
sharedSecret = computeSharedSecret(viewingKey, ephPubKey);
} catch {
continue;
}

const computedTag = computeViewTag(sharedSecret);
if (computedTag !== viewTag) continue;

const hScalar = hashToScalar(sharedSecret);
const stealthPubKeyBytes = deriveStealthPubKey(spendingPubKey, hScalar);
const stealthAddress = pubKeyToStellarAddress(stealthPubKeyBytes);

if (stealthAddress === ann.stealthAddress) {
const stealthPrivateScalar = (spendingScalar + hScalar) % L;

matched.push({
...ann,
stealthPrivateScalar,
stealthPubKeyBytes,
});
}
}

return matched;
}
Loading