From a08d0ae07c6354a222a5640db114ba3bfb43dd40 Mon Sep 17 00:00:00 2001 From: Jefferson Youashi <119521983+clintjeff2@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:20:25 +0100 Subject: [PATCH 1/2] perf(sdk/stellar): public-prefilter view-tag scan with legacy path (#45) * perf(stellar): prefilter scans with public view tags * test(stellar): cover legacy view-tag scanner --- docs/chains/stellar-view-tag-batching.md | 67 +++++++++++ src/chains/stellar/index.ts | 13 +- src/chains/stellar/scan.ts | 120 +++++++++++++++++-- src/chains/stellar/stealth.ts | 46 +++++-- test/chains/stellar/bench/scan.bench.ts | 145 +++++++++++++++++++++++ test/chains/stellar/scan.test.ts | 84 ++++++++++++- 6 files changed, 452 insertions(+), 23 deletions(-) create mode 100644 docs/chains/stellar-view-tag-batching.md create mode 100644 test/chains/stellar/bench/scan.bench.ts diff --git a/docs/chains/stellar-view-tag-batching.md b/docs/chains/stellar-view-tag-batching.md new file mode 100644 index 0000000..e814b83 --- /dev/null +++ b/docs/chains/stellar-view-tag-batching.md @@ -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. diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index 44b7bd3..b478622 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -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, diff --git a/src/chains/stellar/scan.ts b/src/chains/stellar/scan.ts index f5bf6a1..acbf587 100644 --- a/src/chains/stellar/scan.ts +++ b/src/chains/stellar/scan.ts @@ -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'; @@ -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. */ @@ -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); @@ -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; @@ -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 && @@ -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; +} diff --git a/src/chains/stellar/stealth.ts b/src/chains/stellar/stealth.ts index 526cf1d..fb43603 100644 --- a/src/chains/stellar/stealth.ts +++ b/src/chains/stellar/stealth.ts @@ -2,8 +2,11 @@ import { ed25519 } from '@noble/curves/ed25519'; import { x25519 } from '@noble/curves/ed25519'; import { sha256 } from '@noble/hashes/sha256'; import { edwardsToMontgomeryPub, edwardsToMontgomeryPriv } from '@noble/curves/ed25519'; -import type { GeneratedStealthAddress } from './types'; import { hashToScalar, deriveStealthPubKey, pubKeyToStellarAddress } from './scalar'; +import type { GeneratedStealthAddress } from './types'; + +const VIEW_TAG_PREFIX = new TextEncoder().encode('wraith:stellar:view-tag:v2:'); +const LEGACY_VIEW_TAG_PREFIX = new TextEncoder().encode('wraith:tag:'); /** * Generates a one-time stealth address for a recipient on Stellar. @@ -12,7 +15,7 @@ import { hashToScalar, deriveStealthPubKey, pubKeyToStellarAddress } from './sca * 1. Generate ephemeral ed25519 keypair (r, R) * 2. ECDH: shared_secret = X25519(r, V_recipient) * 3. hash_scalar = SHA-256("wraith:scalar:" || shared_secret) mod L - * 4. view_tag = SHA-256("wraith:tag:" || shared_secret)[0] + * 4. view_tag = SHA-256("wraith:stellar:view-tag:v2:" || R || V)[0] * 5. P_stealth = K_spend + hash_scalar * G (point addition) * 6. stealth_address = Stellar encoding of P_stealth * @@ -33,7 +36,7 @@ export function generateStealthAddress( const sharedSecret = computeSharedSecret(ephSeed, viewingPubKey); - const viewTag = computeViewTag(sharedSecret); + const viewTag = computeAnnouncementViewTag(ephPubKey, viewingPubKey); const hScalar = hashToScalar(sharedSecret); @@ -59,13 +62,38 @@ export function computeSharedSecret(privateKey: Uint8Array, publicKey: Uint8Arra } /** - * Computes the view tag from a shared secret. - * view_tag = SHA-256("wraith:tag:" || shared_secret)[0] + * Computes the view tag from the public announcement tuple. + * + * view_tag = SHA-256("wraith:stellar:view-tag:v2:" || R_ephemeral || V_recipient)[0] + * + * The tag intentionally depends only on public data already present in the + * announcement/meta-address. Scanners can reject ~255/256 announcements with + * one SHA-256 instead of paying for X25519 first; only candidates that pass + * this public prefilter need the full shared-secret derivation. + */ +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]; +} + +/** + * Computes the legacy view tag from a shared secret. + * + * @deprecated Stellar scanning now uses computeAnnouncementViewTag() so the + * view-tag filter runs before X25519. This function is kept for compatibility + * checks and benchmark comparisons with the pre-batching scan path. */ export function computeViewTag(sharedSecret: Uint8Array): number { - const prefix = new TextEncoder().encode('wraith:tag:'); - const input = new Uint8Array(prefix.length + sharedSecret.length); - input.set(prefix); - input.set(sharedSecret, prefix.length); + const input = new Uint8Array(LEGACY_VIEW_TAG_PREFIX.length + sharedSecret.length); + input.set(LEGACY_VIEW_TAG_PREFIX); + input.set(sharedSecret, LEGACY_VIEW_TAG_PREFIX.length); return sha256(input)[0]; } diff --git a/test/chains/stellar/bench/scan.bench.ts b/test/chains/stellar/bench/scan.bench.ts new file mode 100644 index 0000000..c5d64ec --- /dev/null +++ b/test/chains/stellar/bench/scan.bench.ts @@ -0,0 +1,145 @@ +import { bench, describe, expect, test } from 'vitest'; +import { deriveStealthKeys } from '../../../../src/chains/stellar/keys'; +import { + computeAnnouncementViewTag, + computeSharedSecret, + computeViewTag, + generateStealthAddress, +} from '../../../../src/chains/stellar/stealth'; +import { + scanAnnouncements, + scanAnnouncementsLegacySharedSecretTag, +} from '../../../../src/chains/stellar/scan'; +import { SCHEME_ID } from '../../../../src/chains/stellar/constants'; +import { bytesToHex } from '../../../../src/chains/stellar/utils'; +import type { Announcement, StealthKeys } from '../../../../src/chains/stellar/types'; + +const MATCH_INDEX = 997; +const POOL_SIZE = 512; +const DEFAULT_DATASET_SIZES = [10_000, 100_000, 1_000_000] as const; +const DATASET_SIZES = ( + process.env.STELLAR_SCAN_BENCH_SIZES?.split(',').map(Number) ?? [...DEFAULT_DATASET_SIZES] +).filter((size) => Number.isFinite(size) && size > 0); +const BENCH_OPTIONS = { time: 1, iterations: 1, warmupTime: 0, warmupIterations: 0 }; + +const keys = deriveStealthKeys(new Uint8Array(64).fill(0xaa)); +const foreignKeys = deriveStealthKeys(new Uint8Array(64).fill(0xbb)); + +function seedFor(index: number): Uint8Array { + const seed = new Uint8Array(32); + let state = (index + 1) * 0x9e3779b1; + for (let i = 0; i < seed.length; i++) { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + seed[i] = state & 0xff; + } + return seed; +} + +function makeAnnouncementFor( + recipient: StealthKeys, + ephemeralSeed: Uint8Array, + tagScheme: 'legacy-shared-secret' | 'public-announcement', +): Announcement { + const stealth = generateStealthAddress( + recipient.spendingPubKey, + recipient.viewingPubKey, + ephemeralSeed, + ); + const sharedSecret = computeSharedSecret(ephemeralSeed, recipient.viewingPubKey); + const viewTag = + tagScheme === 'legacy-shared-secret' + ? computeViewTag(sharedSecret) + : computeAnnouncementViewTag(stealth.ephemeralPubKey, recipient.viewingPubKey); + + return { + schemeId: SCHEME_ID, + stealthAddress: stealth.stealthAddress, + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey), + metadata: viewTag.toString(16).padStart(2, '0'), + }; +} + +const pools = { + legacy: Array.from({ length: POOL_SIZE }, (_, i) => + makeAnnouncementFor(foreignKeys, seedFor(i), 'legacy-shared-secret'), + ), + optimized: Array.from({ length: POOL_SIZE }, (_, i) => + makeAnnouncementFor(foreignKeys, seedFor(i), 'public-announcement'), + ), +}; + +const matchingAnnouncements = { + legacy: makeAnnouncementFor(keys, seedFor(POOL_SIZE + 1), 'legacy-shared-secret'), + optimized: makeAnnouncementFor(keys, seedFor(POOL_SIZE + 1), 'public-announcement'), +}; + +function makeDataset(size: number, tagScheme: 'legacy' | 'optimized') { + const foreignPool = pools[tagScheme]; + const matchingAnnouncement = matchingAnnouncements[tagScheme]; + + return Array.from({ length: size }, (_, i) => + i === MATCH_INDEX ? matchingAnnouncement : foreignPool[i % foreignPool.length], + ); +} + +const datasets = new Map( + DATASET_SIZES.map((size) => [ + size, + { + legacy: makeDataset(size, 'legacy'), + optimized: makeDataset(size, 'optimized'), + }, + ]), +); + +describe('Stellar scan benchmark fixtures', () => { + test('optimized scanner preserves correctness on the 10k synthetic dataset', () => { + const dataset = datasets.get(10_000)?.optimized; + expect(dataset).toBeDefined(); + + const matched = scanAnnouncements( + dataset!, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + + expect(matched).toHaveLength(1); + expect(matched[0].stealthAddress).toBe(matchingAnnouncements.optimized.stealthAddress); + }); +}); + +describe('Stellar scan announcement view-tag batching', () => { + for (const size of DATASET_SIZES) { + const dataset = datasets.get(size)!; + + bench( + `before: shared-secret view tag (${size.toLocaleString()} announcements)`, + () => { + scanAnnouncementsLegacySharedSecretTag( + dataset.legacy, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + }, + BENCH_OPTIONS, + ); + + bench( + `after: public view-tag prefilter (${size.toLocaleString()} announcements)`, + () => { + scanAnnouncements( + dataset.optimized, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + }, + BENCH_OPTIONS, + ); + } +}); diff --git a/test/chains/stellar/scan.test.ts b/test/chains/stellar/scan.test.ts index 4cbbc5b..b801cce 100644 --- a/test/chains/stellar/scan.test.ts +++ b/test/chains/stellar/scan.test.ts @@ -1,7 +1,16 @@ import { describe, test, expect } from 'vitest'; import { deriveStealthKeys } from '../../../src/chains/stellar/keys'; -import { generateStealthAddress } from '../../../src/chains/stellar/stealth'; -import { checkStealthAddress, scanAnnouncements } from '../../../src/chains/stellar/scan'; +import { + computeAnnouncementViewTag, + computeSharedSecret, + computeViewTag, + generateStealthAddress, +} from '../../../src/chains/stellar/stealth'; +import { + checkStealthAddress, + scanAnnouncements, + scanAnnouncementsLegacySharedSecretTag, +} from '../../../src/chains/stellar/scan'; import { SCHEME_ID } from '../../../src/chains/stellar/constants'; import { bytesToHex } from '../../../src/chains/stellar/utils'; import type { Announcement } from '../../../src/chains/stellar/types'; @@ -110,6 +119,77 @@ describe('scanAnnouncements', () => { expect(matched).toHaveLength(0); }); + test('skips invalid ephemeral keys even when the public view tag matches', () => { + const keys = deriveStealthKeys(testSig); + const invalidEphemeralPubKey = new Uint8Array(32); + const matchingPublicTag = computeAnnouncementViewTag( + invalidEphemeralPubKey, + keys.viewingPubKey, + ); + + const announcements: Announcement[] = [ + { + schemeId: SCHEME_ID, + stealthAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(invalidEphemeralPubKey), + metadata: matchingPublicTag.toString(16).padStart(2, '0'), + }, + ]; + + const matched = scanAnnouncements( + announcements, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + + expect(matched).toHaveLength(0); + }); + + test('keeps legacy shared-secret view tags on the legacy scanner path', () => { + const keys = deriveStealthKeys(testSig); + let ephemeralSeed = new Uint8Array(32).fill(0x11); + let stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey, ephemeralSeed); + let sharedSecret = computeSharedSecret(ephemeralSeed, keys.viewingPubKey); + let legacyTag = computeViewTag(sharedSecret); + + // Use a deterministic seed whose legacy shared-secret tag differs from the + // optimized public-announcement tag so the migration boundary is explicit. + for (let i = 0; legacyTag === stealth.viewTag && i < 255; i++) { + ephemeralSeed = new Uint8Array(32).fill(0x12 + i); + stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey, ephemeralSeed); + sharedSecret = computeSharedSecret(ephemeralSeed, keys.viewingPubKey); + legacyTag = computeViewTag(sharedSecret); + } + + expect(legacyTag).not.toBe(stealth.viewTag); + + const announcements: Announcement[] = [ + { + schemeId: SCHEME_ID, + stealthAddress: stealth.stealthAddress, + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey), + metadata: legacyTag.toString(16).padStart(2, '0'), + }, + ]; + + expect( + scanAnnouncements(announcements, keys.viewingKey, keys.spendingPubKey, keys.spendingScalar), + ).toHaveLength(0); + + const legacyMatched = scanAnnouncementsLegacySharedSecretTag( + announcements, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + + expect(legacyMatched).toHaveLength(1); + expect(legacyMatched[0].stealthAddress).toBe(stealth.stealthAddress); + }); + test('filters mix of own and foreign announcements', () => { const keys = deriveStealthKeys(testSig); const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey); From 3a0ea7b40555ff199fc3f5c312d4158e1099eac6 Mon Sep 17 00:00:00 2001 From: Abdulmajeed Abdullateef <81535615+Abdulmajeed82@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:24:38 +0000 Subject: [PATCH 2/2] chore(audit): initial findings, add audit tests and report skeleton --- audits/2026-06-author-stellar-module.md | 110 ++++++++++++++ package.json | 2 + test/audits/stellar.test.ts | 187 ++++++++++++++++++++++++ 3 files changed, 299 insertions(+) create mode 100644 audits/2026-06-author-stellar-module.md create mode 100644 test/audits/stellar.test.ts diff --git a/audits/2026-06-author-stellar-module.md b/audits/2026-06-author-stellar-module.md new file mode 100644 index 0000000..e5f4a6a --- /dev/null +++ b/audits/2026-06-author-stellar-module.md @@ -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. diff --git a/package.json b/package.json index 5a0d713..e5d9087 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/audits/stellar.test.ts b/test/audits/stellar.test.ts new file mode 100644 index 0000000..ac7b7e8 --- /dev/null +++ b/test/audits/stellar.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from 'vitest'; +import { + ed25519, + x25519, + edwardsToMontgomeryPriv, + edwardsToMontgomeryPub, +} from '@noble/curves/ed25519'; +import ed2curve from 'ed2curve'; +import nacl from 'tweetnacl'; +import { deriveStealthPubKey } from '../../src/chains/stellar/scalar'; +import { deriveStealthKeys } from '../../src/chains/stellar/keys'; +import { sha256 } from '@noble/hashes/sha256'; +import fs from 'fs'; + +const VIEW_TAG_PREFIX = new TextEncoder().encode('wraith:stellar:view-tag:v2:'); +function computeAnnouncementViewTagLocal( + 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]; +} + +describe('audits:stellar', () => { + it('edwards->montgomery conversions match ed2curve (random seed)', () => { + const seed = ed25519.utils.randomPrivateKey(); + const pub = ed25519.getPublicKey(seed); + + const noblePrivX = edwardsToMontgomeryPriv(seed); + const noblePubX = edwardsToMontgomeryPub(pub); + + const ed2Priv = ed2curve.convertSecretKey(seed); + const ed2Pub = ed2curve.convertPublicKey(pub); + + expect(ed2Priv).not.toBeNull(); + expect(ed2Pub).not.toBeNull(); + + expect(Buffer.from(noblePrivX)).toEqual(Buffer.from(ed2Priv as Uint8Array)); + expect(Buffer.from(noblePubX)).toEqual(Buffer.from(ed2Pub as Uint8Array)); + }); + + it('shared secret matches tweetnacl.scalarMult via conversions', () => { + const aSeed = ed25519.utils.randomPrivateKey(); + const bSeed = ed25519.utils.randomPrivateKey(); + + const aPub = ed25519.getPublicKey(aSeed); + const bPub = ed25519.getPublicKey(bSeed); + + const aPrivX_noble = edwardsToMontgomeryPriv(aSeed); + const bPubX_noble = edwardsToMontgomeryPub(bPub); + + const ourSS = x25519.getSharedSecret(aPrivX_noble, bPubX_noble); + + const aPrivX = ed2curve.convertSecretKey(aSeed)!; + const bPubX = ed2curve.convertPublicKey(bPub)!; + + const naclSS = nacl.scalarMult(aPrivX, bPubX); + + // noble.x25519.getSharedSecret returns 32-byte shared secret; compare full arrays + expect(Buffer.from(ourSS.slice(0, 32))).toEqual(Buffer.from(naclSS)); + }); + it('domain separation prefixes are distinct', () => { + const prefixes = [ + 'wraith:spending:', + 'wraith:viewing:', + 'wraith:scalar:', + 'wraith:stellar:view-tag:v2:', + 'wraith:tag:', + ]; + const set = new Set(prefixes); + expect(set.size).toBe(prefixes.length); + }); + + it('edwards->montgomery conversions match ed2curve for several random keys', () => { + for (let i = 0; i < 8; i++) { + const seed = ed25519.utils.randomPrivateKey(); + const pub = ed25519.getPublicKey(seed); + + const noblePrivX = edwardsToMontgomeryPriv(seed); + const ed2PrivX = ed2curve.convertSecretKey(seed); + expect(ed2PrivX).not.toBeNull(); + expect(Buffer.from(noblePrivX)).toEqual(Buffer.from(ed2PrivX as Uint8Array)); + + const noblePubX = edwardsToMontgomeryPub(pub); + const ed2PubX = ed2curve.convertPublicKey(pub); + expect(ed2PubX).not.toBeNull(); + expect(Buffer.from(noblePubX)).toEqual(Buffer.from(ed2PubX as Uint8Array)); + } + }); + + it('x25519 shared-secret parity with tweetnacl.scalarMult', () => { + for (let i = 0; i < 8; i++) { + const aSeed = ed25519.utils.randomPrivateKey(); + const bSeed = ed25519.utils.randomPrivateKey(); + + const aPub = ed25519.getPublicKey(aSeed); + const bPub = ed25519.getPublicKey(bSeed); + + const aPrivX = edwardsToMontgomeryPriv(aSeed); + const bPrivX = edwardsToMontgomeryPriv(bSeed); + const aPubX = edwardsToMontgomeryPub(aPub); + const bPubX = edwardsToMontgomeryPub(bPub); + + const nobleSS1 = x25519.getSharedSecret(aPrivX, bPubX).slice(0, 32); + const nobleSS2 = x25519.getSharedSecret(bPrivX, aPubX).slice(0, 32); + + const naclSS1 = nacl.scalarMult(new Uint8Array(aPrivX), new Uint8Array(bPubX)); + const naclSS2 = nacl.scalarMult(new Uint8Array(bPrivX), new Uint8Array(aPubX)); + + expect(Buffer.from(nobleSS1)).toEqual(Buffer.from(naclSS1)); + expect(Buffer.from(nobleSS2)).toEqual(Buffer.from(naclSS2)); + expect(Buffer.from(nobleSS1)).toEqual(Buffer.from(nobleSS2)); + } + }); + + it('deriveStealthPubKey throws or returns spending key when hashScalar == 0 (math property)', () => { + const seed = ed25519.utils.randomPrivateKey(); + const pub = ed25519.getPublicKey(seed); + + try { + const out = deriveStealthPubKey(pub, 0n); + // If implementation handles zero, it should equal the original spending pubkey + expect(Buffer.from(out)).toEqual(Buffer.from(pub)); + } catch (err: any) { + // If noble throws on multiply(0), assert we get an invalid-scalar like error + expect(String(err)).toMatch(/invalid scalar|expected 1 <= sc/gi); + } + }); + + it('deriveStealthKeys uses domain-separated prefixes', () => { + const sig = new Uint8Array(64).fill(1); + const keys = deriveStealthKeys(sig); + + const spendingInput = new Uint8Array( + new TextEncoder().encode('wraith:spending:').length + sig.length, + ); + spendingInput.set(new TextEncoder().encode('wraith:spending:')); + spendingInput.set(sig, new TextEncoder().encode('wraith:spending:').length); + const expectedSpending = sha256(spendingInput); + + expect(Buffer.from(keys.spendingKey)).toEqual(Buffer.from(expectedSpending)); + }); + + it('pnpm-lock pins noble versions', () => { + const lock = fs.readFileSync('pnpm-lock.yaml', 'utf8'); + expect( + lock.includes("'@noble/curves':\n specifier: ^1.8.0\n version: 1.9.7"), + ).toBeTruthy(); + expect( + lock.includes("'@noble/hashes':\n specifier: ^1.7.0\n version: 1.8.0"), + ).toBeTruthy(); + }); + + it('view-tag distribution smoke test', () => { + const viewingPub = ed25519.getPublicKey(ed25519.utils.randomPrivateKey()); + const tags = new Set(); + for (let i = 0; i < 1024; i++) { + const eph = ed25519.getPublicKey(ed25519.utils.randomPrivateKey()); + tags.add(computeAnnouncementViewTagLocal(eph, viewingPub)); + } + // Expect at least 200 unique tags in 1024 samples (smoke check) + expect(tags.size).toBeGreaterThan(200); + }); + + it.skip('signWithScalar deviates from RFC8032 deterministic signing (skipped - high severity)', async () => { + // Dynamic import to avoid loading project module during normal test collection. + const { seedToScalar } = await import('../../../src/chains/stellar/scalar'); + const { signWithScalar } = await import('../../../src/chains/stellar/scalar'); + const seed = ed25519.utils.randomPrivateKey(); + const message = new TextEncoder().encode('test message'); + + const scalar = seedToScalar(seed); + const pub = ed25519.getPublicKey(seed); + + const sigRFC = ed25519.sign(message, seed); + const sigScalar = signWithScalar(message, scalar, pub); + + expect(ed25519.verify(sigRFC, message, pub)).toBeTruthy(); + expect(ed25519.verify(sigScalar, message, pub)).toBeTruthy(); + expect(Buffer.from(sigRFC)).not.toEqual(Buffer.from(sigScalar)); + }); +});