Skip to Content
Stealth Address Implementation

Stealth Address Implementation

Detailed implementation guide for Zentalk’s stealth address system. This document focuses on practical implementation details, code patterns, and integration specifics.

For theoretical background on stealth addresses, see Wallet-Based Identity.


Implementation Overview

This section provides concrete implementation details for the stealth address protocol described in the identity documentation. All code examples are in TypeScript and use standard cryptographic libraries.

Dependencies and Libraries

LibraryPurposeVersion
@noble/curvesElliptic curve operations (secp256k1, Ed25519)^1.3.0
@noble/hashesSHA-256, HKDF, HMAC^1.3.0
@scure/baseBase58, Bech32 encoding^1.1.0
import { secp256k1 } from '@noble/curves/secp256k1'; import { ed25519 } from '@noble/curves/ed25519'; import { sha256 } from '@noble/hashes/sha256'; import { hkdf } from '@noble/hashes/hkdf'; import { hmac } from '@noble/hashes/hmac'; import { randomBytes } from '@noble/hashes/utils'; import { bech32m } from '@scure/base';

Stealth Address Generation Algorithm

Complete Generation Implementation

The following implementation provides production-ready stealth address generation with comprehensive error handling.

interface MetaAddress { viewPublicKey: Uint8Array; // 33 bytes compressed secp256k1 spendPublicKey: Uint8Array; // 33 bytes compressed secp256k1 version: number; } interface StealthAddressResult { stealthAddress: string; stealthPublicKey: Uint8Array; ephemeralPublicKey: Uint8Array; sharedSecret: Uint8Array; viewTag: number; } interface GenerationError { code: 'INVALID_META_ADDRESS' | 'RNG_FAILURE' | 'POINT_AT_INFINITY' | 'ENCODING_ERROR'; message: string; } function generateStealthAddress( metaAddress: MetaAddress ): StealthAddressResult | GenerationError { // Step 1: Validate meta-address format const validation = validateMetaAddress(metaAddress); if (!validation.valid) { return { code: 'INVALID_META_ADDRESS', message: validation.error }; } // Step 2: Generate cryptographically secure ephemeral keypair let ephemeralPrivate: Uint8Array; try { ephemeralPrivate = generateEphemeralPrivateKey(); } catch (e) { return { code: 'RNG_FAILURE', message: 'Failed to generate secure random bytes' }; } // Step 3: Compute ephemeral public key R = r * G const ephemeralPublic = secp256k1.getPublicKey(ephemeralPrivate, true); // Step 4: Compute ECDH shared point with view key // sharedPoint = r * V const sharedPoint = secp256k1.getSharedSecret( ephemeralPrivate, metaAddress.viewPublicKey ); // Step 5: Derive shared secret scalar via tagged hash // sharedSecret = H("zentalk/stealth/v1" || sharedPoint || R) const sharedSecretInput = concatBytes( new TextEncoder().encode('zentalk/stealth/v1'), sharedPoint, ephemeralPublic ); const sharedSecret = sha256(sharedSecretInput); // Step 6: Compute stealth public key P = S + sharedSecret * G let stealthPublicKey: Uint8Array; try { const sharedSecretPoint = secp256k1.ProjectivePoint.BASE.multiply( bytesToBigInt(sharedSecret) ); const spendPoint = secp256k1.ProjectivePoint.fromHex( metaAddress.spendPublicKey ); const stealthPoint = spendPoint.add(sharedSecretPoint); if (stealthPoint.equals(secp256k1.ProjectivePoint.ZERO)) { return { code: 'POINT_AT_INFINITY', message: 'Resulting stealth public key is the point at infinity' }; } stealthPublicKey = stealthPoint.toRawBytes(true); } catch (e) { return { code: 'POINT_AT_INFINITY', message: 'Point arithmetic failed: ' + (e as Error).message }; } // Step 7: Generate view tag for efficient scanning // viewTag = first byte of H(sharedSecret) const viewTag = sha256(sharedSecret)[0]; // Step 8: Encode stealth address const stealthAddress = encodeStealthAddress(stealthPublicKey, viewTag); // Step 9: Securely wipe ephemeral private key secureWipe(ephemeralPrivate); return { stealthAddress, stealthPublicKey, ephemeralPublicKey: ephemeralPublic, sharedSecret, viewTag }; }

Randomness Generation

Secure randomness is critical for stealth address security. The ephemeral private key must be generated using a cryptographically secure pseudorandom number generator (CSPRNG).

function generateEphemeralPrivateKey(): Uint8Array { const MAX_ATTEMPTS = 100; for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { // Use platform CSPRNG const candidate = randomBytes(32); // Validate scalar is in valid range for secp256k1 // Must be: 0 < scalar < n (curve order) const scalar = bytesToBigInt(candidate); const curveOrder = secp256k1.CURVE.n; if (scalar > 0n && scalar < curveOrder) { return candidate; } // Extremely unlikely to reach here (probability ~2^-128) secureWipe(candidate); } throw new Error('Failed to generate valid scalar after maximum attempts'); } // Platform-specific CSPRNG sources function getSecureRandom(length: number): Uint8Array { // Browser environment if (typeof crypto !== 'undefined' && crypto.getRandomValues) { const buffer = new Uint8Array(length); crypto.getRandomValues(buffer); return buffer; } // Node.js environment if (typeof require !== 'undefined') { const { randomBytes: nodeRandomBytes } = require('crypto'); return new Uint8Array(nodeRandomBytes(length)); } throw new Error('No secure random source available'); }

Point Arithmetic Operations

Detailed implementation of elliptic curve point operations used in stealth address generation.

// secp256k1 curve parameters for reference const SECP256K1_PARAMS = { p: 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn, n: 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n, a: 0n, b: 7n, Gx: 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n, Gy: 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n }; // Point multiplication: P = k * G function scalarMultiply( scalar: Uint8Array, point?: Uint8Array ): Uint8Array { const k = bytesToBigInt(scalar); if (point) { // Multiply arbitrary point const P = secp256k1.ProjectivePoint.fromHex(point); return P.multiply(k).toRawBytes(true); } else { // Multiply generator point return secp256k1.ProjectivePoint.BASE.multiply(k).toRawBytes(true); } } // Point addition: R = P + Q function pointAdd( pointP: Uint8Array, pointQ: Uint8Array ): Uint8Array { const P = secp256k1.ProjectivePoint.fromHex(pointP); const Q = secp256k1.ProjectivePoint.fromHex(pointQ); const R = P.add(Q); if (R.equals(secp256k1.ProjectivePoint.ZERO)) { throw new Error('Point addition resulted in point at infinity'); } return R.toRawBytes(true); } // Scalar addition (modular): c = (a + b) mod n function scalarAdd( scalarA: Uint8Array, scalarB: Uint8Array ): Uint8Array { const a = bytesToBigInt(scalarA); const b = bytesToBigInt(scalarB); const n = secp256k1.CURVE.n; const c = (a + b) % n; return bigIntToBytes(c, 32); }

Hash Function Usage

Zentalk uses domain-separated hashing throughout the stealth address protocol to prevent cross-protocol attacks.

// Domain separation tags const HASH_DOMAINS = { STEALTH_SHARED_SECRET: 'zentalk/stealth/shared/v1', STEALTH_ADDRESS: 'zentalk/stealth/address/v1', VIEW_TAG: 'zentalk/stealth/viewtag/v1', KEY_DERIVATION: 'zentalk/stealth/derive/v1' } as const; // Tagged hash function function taggedHash( domain: string, ...data: Uint8Array[] ): Uint8Array { // Compute domain tag: SHA256(domain) || SHA256(domain) const domainBytes = new TextEncoder().encode(domain); const domainHash = sha256(domainBytes); const tagPrefix = concatBytes(domainHash, domainHash); // Final hash: SHA256(tagPrefix || data) return sha256(concatBytes(tagPrefix, ...data)); } // HKDF for key derivation function deriveKey( inputKeyMaterial: Uint8Array, info: string, length: number = 32 ): Uint8Array { return hkdf( sha256, inputKeyMaterial, new Uint8Array(32), // salt (can be empty for stealth addresses) new TextEncoder().encode(info), length ); } // View tag derivation for efficient scanning function deriveViewTag(sharedSecret: Uint8Array): number { const viewTagHash = taggedHash( HASH_DOMAINS.VIEW_TAG, sharedSecret ); return viewTagHash[0]; }

Dual-Key Implementation

Key Generation

The dual-key architecture separates scanning capability (view key) from spending capability (spend key).

interface DualKeyPair { viewKey: { privateKey: Uint8Array; publicKey: Uint8Array; }; spendKey: { privateKey: Uint8Array; publicKey: Uint8Array; }; metaAddress: MetaAddress; } function generateDualKeyPair( seed?: Uint8Array ): DualKeyPair { let masterSeed: Uint8Array; if (seed) { // Deterministic generation from seed masterSeed = seed; } else { // Fresh random generation masterSeed = randomBytes(32); } // Derive view key from master seed const viewPrivate = deriveKey( masterSeed, 'zentalk/stealth/viewkey/v1', 32 ); const viewPublic = secp256k1.getPublicKey(viewPrivate, true); // Derive spend key from master seed (different domain) const spendPrivate = deriveKey( masterSeed, 'zentalk/stealth/spendkey/v1', 32 ); const spendPublic = secp256k1.getPublicKey(spendPrivate, true); // Construct meta-address const metaAddress: MetaAddress = { viewPublicKey: viewPublic, spendPublicKey: spendPublic, version: 1 }; // Wipe master seed after derivation secureWipe(masterSeed); return { viewKey: { privateKey: viewPrivate, publicKey: viewPublic }, spendKey: { privateKey: spendPrivate, publicKey: spendPublic }, metaAddress }; }

View Key Delegation

Safe delegation of scanning capability without exposing spending authority.

interface DelegatedViewKey { viewPrivateKey: Uint8Array; spendPublicKey: Uint8Array; delegationTimestamp: number; expiresAt: number; restrictions: DelegationRestrictions; } interface DelegationRestrictions { maxScannableAge: number; // Maximum age of announcements to scan (seconds) allowedSenders?: string[]; // Optional whitelist of sender meta-addresses notifyOnly: boolean; // If true, delegate cannot decrypt content } function createViewKeyDelegation( viewPrivate: Uint8Array, spendPublic: Uint8Array, expirationHours: number, restrictions: DelegationRestrictions ): DelegatedViewKey { const now = Math.floor(Date.now() / 1000); return { viewPrivateKey: viewPrivate, spendPublicKey: spendPublic, delegationTimestamp: now, expiresAt: now + (expirationHours * 3600), restrictions }; } // Scanning service implementation async function delegatedScan( delegation: DelegatedViewKey, announcements: StealthAnnouncement[] ): Promise<ScanMatch[]> { const matches: ScanMatch[] = []; const now = Math.floor(Date.now() / 1000); // Check delegation validity if (now > delegation.expiresAt) { throw new Error('View key delegation has expired'); } for (const announcement of announcements) { // Apply age restriction const announcementAge = now - announcement.timestamp; if (announcementAge > delegation.restrictions.maxScannableAge) { continue; } // Apply sender whitelist if specified if (delegation.restrictions.allowedSenders) { if (!delegation.restrictions.allowedSenders.includes(announcement.senderMeta)) { continue; } } // Perform ECDH to check if announcement is for us const match = checkAnnouncementMatch( delegation.viewPrivateKey, delegation.spendPublicKey, announcement ); if (match) { matches.push({ announcement, sharedSecret: delegation.restrictions.notifyOnly ? undefined : match.sharedSecret }); } } return matches; }

Address Derivation Path

BIP-44 Style Derivation

Zentalk uses a hierarchical deterministic (HD) derivation scheme compatible with BIP-44.

// Derivation path structure // m / purpose' / coin_type' / account' / change / address_index // m / 8475' / 0' / account' / 0 / index // // 8475 = "STLH" in l33t speak, registered with SLIP-0044 (hypothetical) const STEALTH_PURPOSE = 8475; const ZENTALK_COIN_TYPE = 0; // Use 0 for testing, register for production interface DerivationPath { purpose: number; coinType: number; account: number; change: number; addressIndex: number; } function derivePath(path: DerivationPath): string { return `m/${path.purpose}'/${path.coinType}'/${path.account}'/${path.change}/${path.addressIndex}`; } // Example paths: // m/8475'/0'/0'/0/0 - First stealth meta-address for account 0 // m/8475'/0'/0'/0/1 - Second stealth meta-address for account 0 // m/8475'/0'/1'/0/0 - First stealth meta-address for account 1

HD Key Derivation Implementation

import { HDKey } from '@scure/bip32'; import { mnemonicToSeedSync } from '@scure/bip39'; interface StealthHDWallet { masterKey: HDKey; deriveMetaAddress(account: number, index: number): MetaAddress; deriveFullKeyPair(account: number, index: number): DualKeyPair; } function createStealthHDWallet(mnemonic: string): StealthHDWallet { const seed = mnemonicToSeedSync(mnemonic); const masterKey = HDKey.fromMasterSeed(seed); return { masterKey, deriveMetaAddress(account: number, index: number): MetaAddress { // Derive view key path: m/8475'/0'/account'/0/index const viewPath = `m/${STEALTH_PURPOSE}'/${ZENTALK_COIN_TYPE}'/${account}'/0/${index}`; const viewHD = masterKey.derive(viewPath); // Derive spend key path: m/8475'/0'/account'/1/index const spendPath = `m/${STEALTH_PURPOSE}'/${ZENTALK_COIN_TYPE}'/${account}'/1/${index}`; const spendHD = masterKey.derive(spendPath); return { viewPublicKey: secp256k1.getPublicKey(viewHD.privateKey!, true), spendPublicKey: secp256k1.getPublicKey(spendHD.privateKey!, true), version: 1 }; }, deriveFullKeyPair(account: number, index: number): DualKeyPair { const viewPath = `m/${STEALTH_PURPOSE}'/${ZENTALK_COIN_TYPE}'/${account}'/0/${index}`; const viewHD = masterKey.derive(viewPath); const spendPath = `m/${STEALTH_PURPOSE}'/${ZENTALK_COIN_TYPE}'/${account}'/1/${index}`; const spendHD = masterKey.derive(spendPath); const viewPrivate = viewHD.privateKey!; const spendPrivate = spendHD.privateKey!; return { viewKey: { privateKey: viewPrivate, publicKey: secp256k1.getPublicKey(viewPrivate, true) }, spendKey: { privateKey: spendPrivate, publicKey: secp256k1.getPublicKey(spendPrivate, true) }, metaAddress: { viewPublicKey: secp256k1.getPublicKey(viewPrivate, true), spendPublicKey: secp256k1.getPublicKey(spendPrivate, true), version: 1 } }; } }; }

Gap Limit and Address Discovery

const DEFAULT_GAP_LIMIT = 20; async function discoverUsedAddresses( wallet: StealthHDWallet, account: number, scanner: StealthScanner ): Promise<number[]> { const usedIndices: number[] = []; let consecutiveEmpty = 0; let index = 0; while (consecutiveEmpty < DEFAULT_GAP_LIMIT) { const metaAddress = wallet.deriveMetaAddress(account, index); const hasActivity = await scanner.checkMetaAddressActivity(metaAddress); if (hasActivity) { usedIndices.push(index); consecutiveEmpty = 0; } else { consecutiveEmpty++; } index++; } return usedIndices; }

Stealth Address Format and Encoding

Address Structure

Stealth Address Format (Version 1): ┌─────────────────────────────────────────────────────────────────┐ │ Field │ Size │ Description │ ├─────────────────────────────────────────────────────────────────┤ │ Version │ 1 byte │ 0x01 for v1 │ │ View Tag │ 1 byte │ First byte of H(shared) │ │ Stealth Public Key │ 33 bytes │ Compressed secp256k1 point │ │ Checksum │ 4 bytes │ First 4 bytes of SHA256x2 │ └─────────────────────────────────────────────────────────────────┘ Total: 39 bytes raw, ~62 characters Bech32m encoded

Encoding Implementation

const STEALTH_HRP = 'zst'; // Zentalk Stealth interface EncodedStealthAddress { raw: Uint8Array; bech32m: string; base58check: string; } function encodeStealthAddress( stealthPublicKey: Uint8Array, viewTag: number ): EncodedStealthAddress { // Validate inputs if (stealthPublicKey.length !== 33) { throw new Error('Stealth public key must be 33 bytes (compressed)'); } if (viewTag < 0 || viewTag > 255) { throw new Error('View tag must be a single byte (0-255)'); } // Construct raw address const version = new Uint8Array([0x01]); const viewTagByte = new Uint8Array([viewTag]); const checksumInput = concatBytes(version, viewTagByte, stealthPublicKey); const checksum = sha256(sha256(checksumInput)).slice(0, 4); const raw = concatBytes(checksumInput, checksum); // Bech32m encoding (preferred) const bech32mWords = bech32m.toWords(raw); const bech32mAddress = bech32m.encode(STEALTH_HRP, bech32mWords, 90); // Base58Check encoding (alternative) const base58Address = encodeBase58Check(raw); return { raw, bech32m: bech32mAddress, base58check: base58Address }; } function decodeStealthAddress(address: string): { version: number; viewTag: number; stealthPublicKey: Uint8Array; } { let raw: Uint8Array; // Detect encoding format if (address.startsWith(STEALTH_HRP)) { // Bech32m decoding const decoded = bech32m.decode(address, 90); raw = new Uint8Array(bech32m.fromWords(decoded.words)); } else { // Base58Check decoding raw = decodeBase58Check(address); } // Validate length if (raw.length !== 39) { throw new Error(`Invalid address length: expected 39, got ${raw.length}`); } // Validate checksum const checksumInput = raw.slice(0, 35); const providedChecksum = raw.slice(35, 39); const computedChecksum = sha256(sha256(checksumInput)).slice(0, 4); if (!constantTimeEqual(providedChecksum, computedChecksum)) { throw new Error('Invalid checksum'); } return { version: raw[0], viewTag: raw[1], stealthPublicKey: raw.slice(2, 35) }; }

Meta-Address Encoding

const META_HRP = 'zstm'; // Zentalk Stealth Meta interface EncodedMetaAddress { raw: Uint8Array; bech32m: string; } /* Meta-Address Format: ┌─────────────────────────────────────────────────────────────────┐ │ Field │ Size │ Description │ ├─────────────────────────────────────────────────────────────────┤ │ Version │ 1 byte │ 0x01 for v1 │ │ View Public Key │ 33 bytes │ Compressed secp256k1 │ │ Spend Public Key │ 33 bytes │ Compressed secp256k1 │ │ Checksum │ 4 bytes │ First 4 bytes of SHA256x2 │ └─────────────────────────────────────────────────────────────────┘ Total: 71 bytes raw, ~114 characters Bech32m encoded */ function encodeMetaAddress(metaAddress: MetaAddress): EncodedMetaAddress { const version = new Uint8Array([metaAddress.version]); const checksumInput = concatBytes( version, metaAddress.viewPublicKey, metaAddress.spendPublicKey ); const checksum = sha256(sha256(checksumInput)).slice(0, 4); const raw = concatBytes(checksumInput, checksum); const words = bech32m.toWords(raw); const bech32mAddress = bech32m.encode(META_HRP, words, 120); return { raw, bech32m: bech32mAddress }; } function decodeMetaAddress(encoded: string): MetaAddress { const decoded = bech32m.decode(encoded, 120); const raw = new Uint8Array(bech32m.fromWords(decoded.words)); if (raw.length !== 71) { throw new Error(`Invalid meta-address length: expected 71, got ${raw.length}`); } // Validate checksum const checksumInput = raw.slice(0, 67); const providedChecksum = raw.slice(67, 71); const computedChecksum = sha256(sha256(checksumInput)).slice(0, 4); if (!constantTimeEqual(providedChecksum, computedChecksum)) { throw new Error('Invalid checksum'); } return { version: raw[0], viewPublicKey: raw.slice(1, 34), spendPublicKey: raw.slice(34, 67) }; }

Integration with X3DH Key Exchange

Stealth Address in Initial Key Exchange

When initiating contact via stealth address, the X3DH protocol is extended to incorporate the stealth address mechanism.

interface StealthX3DHBundle { // Standard X3DH components identityKey: Uint8Array; signedPreKey: Uint8Array; signedPreKeySignature: Uint8Array; oneTimePreKey?: Uint8Array; // Stealth address extension stealthMetaAddress: MetaAddress; stealthEnabled: boolean; } interface StealthX3DHMessage { // Ephemeral key for X3DH ephemeralKey: Uint8Array; // Stealth address components stealthEphemeralKey: Uint8Array; // R in stealth protocol stealthAddress: string; // Destination stealth address viewTag: number; // For efficient scanning // Encrypted initial message ciphertext: Uint8Array; // One-time prekey ID used (if any) oneTimePreKeyId?: number; } async function initiateStealthX3DH( senderIdentity: IdentityKeyPair, recipientBundle: StealthX3DHBundle ): Promise<StealthX3DHMessage> { // Step 1: Generate stealth address for recipient const stealthResult = generateStealthAddress(recipientBundle.stealthMetaAddress); if ('code' in stealthResult) { throw new Error(`Stealth generation failed: ${stealthResult.message}`); } // Step 2: Perform standard X3DH const ephemeralPrivate = randomBytes(32); const ephemeralPublic = secp256k1.getPublicKey(ephemeralPrivate, true); // DH1 = DH(IK_A, SPK_B) const dh1 = secp256k1.getSharedSecret( senderIdentity.privateKey, recipientBundle.signedPreKey ); // DH2 = DH(EK_A, IK_B) const dh2 = secp256k1.getSharedSecret( ephemeralPrivate, recipientBundle.identityKey ); // DH3 = DH(EK_A, SPK_B) const dh3 = secp256k1.getSharedSecret( ephemeralPrivate, recipientBundle.signedPreKey ); // DH4 = DH(EK_A, OPK_B) if available let dh4: Uint8Array | undefined; if (recipientBundle.oneTimePreKey) { dh4 = secp256k1.getSharedSecret( ephemeralPrivate, recipientBundle.oneTimePreKey ); } // Step 3: Combine X3DH with stealth shared secret const x3dhInput = dh4 ? concatBytes(dh1, dh2, dh3, dh4) : concatBytes(dh1, dh2, dh3); // Include stealth shared secret in key derivation const combinedInput = concatBytes(x3dhInput, stealthResult.sharedSecret); const sessionKey = hkdf( sha256, combinedInput, new Uint8Array(32), new TextEncoder().encode('ZentalkStealthX3DH'), 32 ); // Step 4: Encrypt initial message const initialPlaintext = new TextEncoder().encode(JSON.stringify({ type: 'initial_message', senderIdentity: bytesToHex(senderIdentity.publicKey), timestamp: Date.now() })); const nonce = randomBytes(12); const ciphertext = await encryptAESGCM(sessionKey, nonce, initialPlaintext); // Cleanup secureWipe(ephemeralPrivate); secureWipe(sessionKey); return { ephemeralKey: ephemeralPublic, stealthEphemeralKey: stealthResult.ephemeralPublicKey, stealthAddress: stealthResult.stealthAddress, viewTag: stealthResult.viewTag, ciphertext: concatBytes(nonce, ciphertext), oneTimePreKeyId: recipientBundle.oneTimePreKey ? getPreKeyId(recipientBundle.oneTimePreKey) : undefined }; }

Recipient Processing

async function processStealthX3DHMessage( recipientKeys: { identity: IdentityKeyPair; signedPreKey: KeyPair; oneTimePreKeys: Map<number, KeyPair>; stealthKeys: DualKeyPair; }, message: StealthX3DHMessage ): Promise<{ sessionKey: Uint8Array; senderIdentity: Uint8Array; stealthPrivateKey: Uint8Array; }> { // Step 1: Verify stealth address is for us const stealthMatch = scanSingleAnnouncement( recipientKeys.stealthKeys.viewKey.privateKey, recipientKeys.stealthKeys.spendKey.publicKey, { ephemeralPublicKey: message.stealthEphemeralKey, viewTag: message.viewTag, stealthAddress: message.stealthAddress } ); if (!stealthMatch) { throw new Error('Stealth address does not match our keys'); } // Step 2: Derive stealth private key const stealthPrivateKey = scalarAdd( recipientKeys.stealthKeys.spendKey.privateKey, stealthMatch.sharedSecret ); // Step 3: Perform X3DH from recipient side // DH1 = DH(SPK_B, IK_A) - sender identity from message const senderIdentityKey = extractSenderIdentity(message); // From header or encrypted const dh1 = secp256k1.getSharedSecret( recipientKeys.signedPreKey.privateKey, senderIdentityKey ); // DH2 = DH(IK_B, EK_A) const dh2 = secp256k1.getSharedSecret( recipientKeys.identity.privateKey, message.ephemeralKey ); // DH3 = DH(SPK_B, EK_A) const dh3 = secp256k1.getSharedSecret( recipientKeys.signedPreKey.privateKey, message.ephemeralKey ); // DH4 if OPK was used let dh4: Uint8Array | undefined; if (message.oneTimePreKeyId !== undefined) { const opk = recipientKeys.oneTimePreKeys.get(message.oneTimePreKeyId); if (opk) { dh4 = secp256k1.getSharedSecret(opk.privateKey, message.ephemeralKey); // Delete used OPK recipientKeys.oneTimePreKeys.delete(message.oneTimePreKeyId); } } // Step 4: Derive session key (same as sender) const x3dhInput = dh4 ? concatBytes(dh1, dh2, dh3, dh4) : concatBytes(dh1, dh2, dh3); const combinedInput = concatBytes(x3dhInput, stealthMatch.sharedSecret); const sessionKey = hkdf( sha256, combinedInput, new Uint8Array(32), new TextEncoder().encode('ZentalkStealthX3DH'), 32 ); return { sessionKey, senderIdentity: senderIdentityKey, stealthPrivateKey }; }

Storage Format on Mesh Network

Announcement Storage Schema

interface StealthAnnouncement { // Unique identifier announcementId: Uint8Array; // 32 bytes, H(R || P || timestamp) // Core stealth data ephemeralPublicKey: Uint8Array; // R, 33 bytes compressed stealthPublicKey: Uint8Array; // P, 33 bytes compressed viewTag: number; // 1 byte for fast filtering // Encrypted payload encryptedPayload: Uint8Array; // Variable length payloadNonce: Uint8Array; // 12 bytes // Metadata (minimal for privacy) timestamp: number; // Unix timestamp (bucketed to hour) expiresAt: number; // TTL for mesh storage // Network routing schemaVersion: number; networkId: number; } // Storage key derivation for DHT function computeStorageKey(announcement: StealthAnnouncement): Uint8Array { // Use first 8 bytes of view tag hash + timestamp bucket // This enables efficient range queries while preserving privacy const timeBucket = Math.floor(announcement.timestamp / 3600) * 3600; const input = concatBytes( new Uint8Array([announcement.viewTag]), bigIntToBytes(BigInt(timeBucket), 8) ); return sha256(input); }

DHT Storage Protocol

interface MeshStorageNode { store(announcement: StealthAnnouncement): Promise<void>; retrieve(viewTag: number, timeRange: TimeRange): Promise<StealthAnnouncement[]>; subscribe(viewTags: number[], callback: AnnouncementCallback): Subscription; } const ANNOUNCEMENT_REPLICATION_FACTOR = 3; const ANNOUNCEMENT_TTL_HOURS = 168; // 7 days async function publishStealthAnnouncement( mesh: MeshStorageNode, announcement: StealthAnnouncement ): Promise<void> { // Set TTL announcement.expiresAt = Math.floor(Date.now() / 1000) + (ANNOUNCEMENT_TTL_HOURS * 3600); // Store to DHT with replication await mesh.store(announcement); // Announcements are replicated across REPLICATION_FACTOR nodes // Nodes closest to computeStorageKey(announcement) are responsible } // Efficient retrieval using view tag filtering async function retrieveAnnouncements( mesh: MeshStorageNode, viewPrivateKey: Uint8Array, spendPublicKey: Uint8Array, since: number ): Promise<StealthAnnouncement[]> { // We need to scan ALL view tags since we don't know which ones are ours // However, view tags allow for ~256x reduction in ECDH operations const allAnnouncements: StealthAnnouncement[] = []; for (let viewTag = 0; viewTag < 256; viewTag++) { const announcements = await mesh.retrieve(viewTag, { start: since, end: Math.floor(Date.now() / 1000) }); allAnnouncements.push(...announcements); } return allAnnouncements; }

Serialization Format

/* Announcement Wire Format: ┌─────────────────────────────────────────────────────────────────┐ │ Header (fixed 8 bytes) │ ├─────────────────────────────────────────────────────────────────┤ │ Schema version (1 byte) │ 0x01 │ │ Network ID (1 byte) │ 0x01 = mainnet, 0x02 = testnet │ │ Flags (1 byte) │ Bit flags for optional fields │ │ Reserved (1 byte) │ 0x00 │ │ Timestamp (4 bytes) │ Unix epoch, big-endian │ ├─────────────────────────────────────────────────────────────────┤ │ Stealth Data (68 bytes) │ ├─────────────────────────────────────────────────────────────────┤ │ View tag (1 byte) │ Fast filtering byte │ │ Ephemeral public key (33 bytes)│ Compressed secp256k1 │ │ Stealth public key (33 bytes) │ Compressed secp256k1 │ │ Announcement ID (1 byte) │ Local sequence number │ ├─────────────────────────────────────────────────────────────────┤ │ Encrypted Payload (variable) │ ├─────────────────────────────────────────────────────────────────┤ │ Payload length (2 bytes) │ Big-endian │ │ Nonce (12 bytes) │ AES-GCM nonce │ │ Ciphertext (N bytes) │ AES-GCM encrypted data │ │ Auth tag (16 bytes) │ GCM authentication tag │ └─────────────────────────────────────────────────────────────────┘ */ function serializeAnnouncement(announcement: StealthAnnouncement): Uint8Array { const header = new Uint8Array(8); const view = new DataView(header.buffer); header[0] = announcement.schemaVersion; header[1] = announcement.networkId; header[2] = 0x00; // flags header[3] = 0x00; // reserved view.setUint32(4, announcement.timestamp, false); // big-endian const stealthData = concatBytes( new Uint8Array([announcement.viewTag]), announcement.ephemeralPublicKey, announcement.stealthPublicKey, new Uint8Array([0x00]) // announcement sequence ); const payloadLength = new Uint8Array(2); new DataView(payloadLength.buffer).setUint16(0, announcement.encryptedPayload.length, false); return concatBytes( header, stealthData, payloadLength, announcement.payloadNonce, announcement.encryptedPayload ); } function deserializeAnnouncement(data: Uint8Array): StealthAnnouncement { if (data.length < 78) { // Minimum size: 8 + 68 + 2 throw new Error('Invalid announcement: too short'); } const view = new DataView(data.buffer, data.byteOffset); const schemaVersion = data[0]; const networkId = data[1]; const timestamp = view.getUint32(4, false); const viewTag = data[8]; const ephemeralPublicKey = data.slice(9, 42); const stealthPublicKey = data.slice(42, 75); const payloadLength = view.getUint16(76, false); const payloadNonce = data.slice(78, 90); const encryptedPayload = data.slice(90, 90 + payloadLength); return { announcementId: sha256(concatBytes(ephemeralPublicKey, stealthPublicKey)), ephemeralPublicKey, stealthPublicKey, viewTag, encryptedPayload, payloadNonce, timestamp, expiresAt: timestamp + (ANNOUNCEMENT_TTL_HOURS * 3600), schemaVersion, networkId }; }

Collision Handling

View Tag Collisions

View tags are single bytes, so collisions are expected and handled gracefully.

/* View Tag Collision Analysis: - View tag space: 256 possible values - Expected collisions: For N announcements, ~N/256 will share each view tag - False positive rate: 1/256 = 0.39% With 100,000 daily announcements: - Each view tag bucket: ~390 announcements - Full ECDH operations needed: ~390 per bucket = 100,000 total - With view tag filtering: Still 100,000 (no savings in worst case) - But: Early rejection saves computation on mismatches */ interface CollisionMetrics { totalScanned: number; viewTagMatches: number; fullMatches: number; ecdhOperations: number; } function scanWithCollisionTracking( viewPrivate: Uint8Array, spendPublic: Uint8Array, announcements: StealthAnnouncement[] ): { matches: ScanMatch[]; metrics: CollisionMetrics } { const metrics: CollisionMetrics = { totalScanned: announcements.length, viewTagMatches: 0, fullMatches: 0, ecdhOperations: 0 }; const matches: ScanMatch[] = []; for (const announcement of announcements) { // Step 1: Compute our expected view tag for this R const sharedPoint = secp256k1.getSharedSecret( viewPrivate, announcement.ephemeralPublicKey ); metrics.ecdhOperations++; const expectedViewTag = deriveViewTag( sha256(concatBytes(sharedPoint, announcement.ephemeralPublicKey)) ); // Step 2: Quick view tag check if (expectedViewTag !== announcement.viewTag) { continue; // Not for us (with 255/256 confidence) } metrics.viewTagMatches++; // Step 3: Full verification for view tag matches const sharedSecret = sha256( concatBytes( new TextEncoder().encode('zentalk/stealth/v1'), sharedPoint, announcement.ephemeralPublicKey ) ); const expectedP = pointAdd( spendPublic, scalarMultiply(sharedSecret) ); if (constantTimeEqual(expectedP, announcement.stealthPublicKey)) { metrics.fullMatches++; matches.push({ announcement, sharedSecret }); } // Else: View tag collision (false positive), not for us } return { matches, metrics }; }

Address Collision Prevention

/* Stealth Address Collision Analysis: Probability of two senders generating the same stealth address for the same recipient: - Ephemeral keys are 256-bit random - Collision probability: 2^(-256) per pair - Birthday bound: 2^128 operations for 50% collision chance - Practically impossible with proper RNG */ function validateStealthAddressUniqueness( existingAddresses: Set<string>, newAddress: string ): boolean { if (existingAddresses.has(newAddress)) { // This should never happen with proper RNG // If it does, it indicates either: // 1. Broken RNG (critical security issue) // 2. Replay attack attempt // 3. Corrupt data console.error('CRITICAL: Stealth address collision detected'); return false; } return true; } // Nonce management to prevent ephemeral key reuse class EphemeralKeyTracker { private usedKeys: Set<string> = new Set(); private maxSize = 10000; recordEphemeralKey(ephemeralPublic: Uint8Array): void { const keyHex = bytesToHex(ephemeralPublic); if (this.usedKeys.has(keyHex)) { throw new Error('CRITICAL: Ephemeral key reuse detected'); } this.usedKeys.add(keyHex); // Prune old entries if needed if (this.usedKeys.size > this.maxSize) { const iterator = this.usedKeys.values(); for (let i = 0; i < this.maxSize / 2; i++) { this.usedKeys.delete(iterator.next().value); } } } }

Performance Optimization

Batch Scanning

interface BatchScanConfig { batchSize: number; parallelism: number; useWorkers: boolean; } const DEFAULT_BATCH_CONFIG: BatchScanConfig = { batchSize: 1000, parallelism: navigator.hardwareConcurrency || 4, useWorkers: true }; async function batchScan( viewPrivate: Uint8Array, spendPublic: Uint8Array, announcements: StealthAnnouncement[], config: BatchScanConfig = DEFAULT_BATCH_CONFIG ): Promise<ScanMatch[]> { const matches: ScanMatch[] = []; if (config.useWorkers && typeof Worker !== 'undefined') { // Web Worker-based parallel scanning const workers = createScanWorkerPool(config.parallelism); const batches = chunkArray(announcements, config.batchSize); const batchPromises = batches.map((batch, index) => { const worker = workers[index % workers.length]; return worker.scan(viewPrivate, spendPublic, batch); }); const results = await Promise.all(batchPromises); results.forEach(result => matches.push(...result)); workers.forEach(w => w.terminate()); } else { // Sequential fallback for (let i = 0; i < announcements.length; i += config.batchSize) { const batch = announcements.slice(i, i + config.batchSize); const batchMatches = scanBatch(viewPrivate, spendPublic, batch); matches.push(...batchMatches); } } return matches; } // Web Worker code for parallel scanning const SCAN_WORKER_CODE = ` importScripts('noble-curves.min.js'); self.onmessage = function(e) { const { viewPrivate, spendPublic, announcements } = e.data; const matches = []; for (const announcement of announcements) { // Perform ECDH and matching (same as main thread) const match = checkAnnouncementMatch(viewPrivate, spendPublic, announcement); if (match) matches.push(match); } self.postMessage({ matches }); }; `;

Precomputation Tables

// Precompute multiples of generator point for faster scanning class ECDHPrecomputation { private tableSize = 256; private precomputedPoints: Map<number, Uint8Array> = new Map(); constructor(basePoint: Uint8Array) { // Precompute [1]G, [2]G, [4]G, ..., [128]G let current = secp256k1.ProjectivePoint.fromHex(basePoint); for (let i = 0; i < 8; i++) { this.precomputedPoints.set(1 << i, current.toRawBytes(true)); current = current.double(); } } // Use precomputed table for faster multiplication multiply(scalar: Uint8Array): Uint8Array { // This is a simplified example - production should use // window-based methods like wNAF const k = bytesToBigInt(scalar); return secp256k1.ProjectivePoint.BASE.multiply(k).toRawBytes(true); } }

Caching Strategy

interface ScanCache { lastScanTimestamp: number; processedAnnouncementIds: Set<string>; knownStealthAddresses: Map<string, CachedAddress>; } interface CachedAddress { stealthPublicKey: Uint8Array; stealthPrivateKey: Uint8Array; sharedSecret: Uint8Array; derivedAt: number; } class StealthAddressCache { private cache: ScanCache = { lastScanTimestamp: 0, processedAnnouncementIds: new Set(), knownStealthAddresses: new Map() }; private maxCacheSize = 10000; shouldProcess(announcementId: string): boolean { return !this.cache.processedAnnouncementIds.has(announcementId); } markProcessed(announcementId: string): void { this.cache.processedAnnouncementIds.add(announcementId); this.pruneIfNeeded(); } cacheAddress(address: string, cached: CachedAddress): void { this.cache.knownStealthAddresses.set(address, cached); this.pruneIfNeeded(); } getCachedAddress(address: string): CachedAddress | undefined { return this.cache.knownStealthAddresses.get(address); } private pruneIfNeeded(): void { if (this.cache.processedAnnouncementIds.size > this.maxCacheSize) { // Remove oldest half const idsArray = Array.from(this.cache.processedAnnouncementIds); const toRemove = idsArray.slice(0, idsArray.length / 2); toRemove.forEach(id => this.cache.processedAnnouncementIds.delete(id)); } } updateLastScan(timestamp: number): void { this.cache.lastScanTimestamp = timestamp; } getLastScan(): number { return this.cache.lastScanTimestamp; } }

Benchmarks

OperationTime (ms)Notes
Stealth address generation0.3-0.5Single generation
ECDH operation (secp256k1)0.1-0.3Per announcement
View tag check0.001Negligible
Point addition0.01-0.02After ECDH
Batch scan (1000 items)150-300Single thread
Batch scan (1000 items)40-804 workers
Address encoding (Bech32m)0.05Per address

Utility Functions

Byte Operations

function concatBytes(...arrays: Uint8Array[]): Uint8Array { const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); const result = new Uint8Array(totalLength); let offset = 0; for (const arr of arrays) { result.set(arr, offset); offset += arr.length; } return result; } function bytesToBigInt(bytes: Uint8Array): bigint { let result = 0n; for (const byte of bytes) { result = (result << 8n) | BigInt(byte); } return result; } function bigIntToBytes(value: bigint, length: number): Uint8Array { const result = new Uint8Array(length); for (let i = length - 1; i >= 0; i--) { result[i] = Number(value & 0xffn); value >>= 8n; } return result; } function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes) .map(b => b.toString(16).padStart(2, '0')) .join(''); } function hexToBytes(hex: string): Uint8Array { if (hex.length % 2 !== 0) { throw new Error('Hex string must have even length'); } const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < bytes.length; i++) { bytes[i] = parseInt(hex.substr(i * 2, 2), 16); } return bytes; }

Security Utilities

function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) { return false; } let diff = 0; for (let i = 0; i < a.length; i++) { diff |= a[i] ^ b[i]; } return diff === 0; } function secureWipe(buffer: Uint8Array): void { // Overwrite with random data first crypto.getRandomValues(buffer); // Then zero buffer.fill(0); } function validateMetaAddress(meta: MetaAddress): { valid: boolean; error?: string } { if (meta.version !== 1) { return { valid: false, error: `Unsupported version: ${meta.version}` }; } if (meta.viewPublicKey.length !== 33) { return { valid: false, error: 'View public key must be 33 bytes' }; } if (meta.spendPublicKey.length !== 33) { return { valid: false, error: 'Spend public key must be 33 bytes' }; } // Validate points are on curve try { secp256k1.ProjectivePoint.fromHex(meta.viewPublicKey); secp256k1.ProjectivePoint.fromHex(meta.spendPublicKey); } catch (e) { return { valid: false, error: 'Invalid point: not on curve' }; } return { valid: true }; }

Error Handling

Error Types

enum StealthErrorCode { // Generation errors INVALID_META_ADDRESS = 'INVALID_META_ADDRESS', RNG_FAILURE = 'RNG_FAILURE', POINT_AT_INFINITY = 'POINT_AT_INFINITY', // Scanning errors SCAN_TIMEOUT = 'SCAN_TIMEOUT', NETWORK_ERROR = 'NETWORK_ERROR', INVALID_ANNOUNCEMENT = 'INVALID_ANNOUNCEMENT', // Key management errors KEY_DERIVATION_FAILED = 'KEY_DERIVATION_FAILED', INVALID_DERIVATION_PATH = 'INVALID_DERIVATION_PATH', // Storage errors STORAGE_FULL = 'STORAGE_FULL', SERIALIZATION_ERROR = 'SERIALIZATION_ERROR', // Security errors EPHEMERAL_KEY_REUSE = 'EPHEMERAL_KEY_REUSE', CHECKSUM_MISMATCH = 'CHECKSUM_MISMATCH' } class StealthError extends Error { constructor( public code: StealthErrorCode, message: string, public recoverable: boolean = true ) { super(message); this.name = 'StealthError'; } } function handleStealthError(error: StealthError): void { switch (error.code) { case StealthErrorCode.RNG_FAILURE: case StealthErrorCode.EPHEMERAL_KEY_REUSE: // Critical security errors - halt operations console.error('CRITICAL SECURITY ERROR:', error.message); throw error; case StealthErrorCode.SCAN_TIMEOUT: case StealthErrorCode.NETWORK_ERROR: // Retriable errors console.warn('Retriable error:', error.message); break; case StealthErrorCode.INVALID_META_ADDRESS: case StealthErrorCode.CHECKSUM_MISMATCH: // User input errors console.warn('Invalid input:', error.message); break; default: console.error('Unexpected error:', error.message); } }

Last updated on