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
| Library | Purpose | Version |
|---|---|---|
@noble/curves | Elliptic curve operations (secp256k1, Ed25519) | ^1.3.0 |
@noble/hashes | SHA-256, HKDF, HMAC | ^1.3.0 |
@scure/base | Base58, 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 1HD 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 encodedEncoding 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
| Operation | Time (ms) | Notes |
|---|---|---|
| Stealth address generation | 0.3-0.5 | Single generation |
| ECDH operation (secp256k1) | 0.1-0.3 | Per announcement |
| View tag check | 0.001 | Negligible |
| Point addition | 0.01-0.02 | After ECDH |
| Batch scan (1000 items) | 150-300 | Single thread |
| Batch scan (1000 items) | 40-80 | 4 workers |
| Address encoding (Bech32m) | 0.05 | Per 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);
}
}Related Documentation
- Wallet-Based Identity - Stealth address theory and concepts
- Cryptography Fundamentals - Underlying cryptographic primitives
- Protocol Specification - X3DH and Double Ratchet integration
- DHT and Kademlia - Mesh network storage layer
- Multi-Device Support - Managing stealth addresses across devices