From a0e8e39b0e0b190060b82ffcceeda7226e3abfa5 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Sun, 7 Jun 2026 16:27:07 +0700 Subject: [PATCH] docs: align stealth scheme + examples to canonical EIP-5564 (SDK 0.10.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @sip-protocol/sdk@0.10.0 flipped the stealth scheme to canonical EIP-5564 (ECDH shared secret on the viewing key; one-time address on the spending key; view-only scanning with viewing-private + spending-public). Align the docs with the published SDK: - Revert the stealth-scheme descriptions that PR #109 had flipped to the old swapped scheme (security/crypto-assumptions, security/audit-checklist) back to canonical, fix the muddled security/security-properties proof sketch, and the concepts/stealth-address + guides/cross-chain-stealth pseudocode. #109's unrelated fixes (Pedersen H try-and-increment, ACIR opcode counts, UltraHonk) are kept intact. - Fix the specs/eip-5564 cryptographic math to canonical. - Migrate check*StealthAddress code examples to the view-only 0.10.0 signature (addr, viewingPrivateKey, spendingPublicKey) across glossary, specs/eip-5564, guides/ethereum-privacy, and cookbook/05-stealth-scanning — and rework the cookbook scanner so it never holds the spending private key. derive* examples are unchanged (their signature did not change). Relates to sip-protocol/sip-protocol#1099, #1100. --- src/content/docs/concepts/stealth-address.md | 29 ++++++------- .../docs/cookbook/05-stealth-scanning.mdx | 41 +++++++++++-------- src/content/docs/glossary.md | 6 +-- .../docs/guides/cross-chain-stealth.md | 2 +- src/content/docs/guides/ethereum-privacy.md | 3 +- src/content/docs/security/audit-checklist.md | 6 +-- .../docs/security/crypto-assumptions.md | 22 +++++----- .../docs/security/security-properties.md | 8 ++-- src/content/docs/specs/eip-5564.md | 29 ++++++++----- 9 files changed, 82 insertions(+), 64 deletions(-) diff --git a/src/content/docs/concepts/stealth-address.md b/src/content/docs/concepts/stealth-address.md index ac92b8c..1333800 100644 --- a/src/content/docs/concepts/stealth-address.md +++ b/src/content/docs/concepts/stealth-address.md @@ -115,11 +115,12 @@ const { stealthAddress, ephemeralPublicKey } = ### Step 3: Recipient Scans and Claims ```typescript -// Recipient scans for their transactions +// Recipient scans for their transactions (view-only: +// viewing PRIVATE key + spending PUBLIC key, per canonical EIP-5564) const isMine = checkStealthAddress( stealthAddress, - spendingPrivateKey, - viewingPrivateKey + viewingPrivateKey, + metaAddress.spendingKey ) if (isMine) { @@ -153,25 +154,25 @@ KeyGen(): GenerateStealth(P, Q): 1. r ← random_scalar() // Ephemeral private 2. R ← r · G // Ephemeral public - 3. S ← r · P // Shared secret (ECDH) + 3. S ← r · Q // Shared secret (ECDH on viewing key) 4. h ← SHA256(S) // Hash shared secret 5. view_tag ← h[0] // First byte (optimization) - 6. A ← Q + h · G // Stealth address + 6. A ← P + h · G // Stealth address (on spending key) Return: (A, R, view_tag) ``` ### Scanning ``` -ScanForStealth(R, A, view_tag, p, q): - 1. S' ← p · R // Recompute shared secret +ScanForStealth(R, A, view_tag, q, P): + 1. S' ← q · R // Recompute shared secret (viewing PRIVATE key) 2. h' ← SHA256(S') // Quick reject using view tag 3. if h'[0] ≠ view_tag: return NOT_MINE - // Full verification - 4. A' ← Q + h' · G + // Full verification (spending PUBLIC key — no spending private key needed) + 4. A' ← P + h' · G 5. if A' ≠ A: return NOT_MINE Return: MINE ``` @@ -180,9 +181,9 @@ ScanForStealth(R, A, view_tag, p, q): ``` DerivePrivateKey(R, p, q): - 1. S ← p · R + 1. S ← q · R // Recompute shared secret (viewing PRIVATE key) 2. h ← SHA256(S) - 3. a ← q + h (mod n) // Stealth private key + 3. a ← p + h (mod n) // Stealth private key (needs spending PRIVATE key) Return: a ``` @@ -273,11 +274,11 @@ const decoded = decodeStealthMetaAddress(encoded) const { stealthAddress, ephemeralPublicKey } = generateStealthAddress(decoded) -// Recipient scans +// Recipient scans (view-only: viewing PRIVATE key + spending PUBLIC key) const isMine = checkStealthAddress( stealthAddress, - meta.spendingPrivateKey, - meta.viewingPrivateKey + meta.viewingPrivateKey, + meta.metaAddress.spendingKey ) // Recipient claims diff --git a/src/content/docs/cookbook/05-stealth-scanning.mdx b/src/content/docs/cookbook/05-stealth-scanning.mdx index fbbba7f..4ab8b8e 100644 --- a/src/content/docs/cookbook/05-stealth-scanning.mdx +++ b/src/content/docs/cookbook/05-stealth-scanning.mdx @@ -63,11 +63,12 @@ const incomingStealthAddresses: StealthAddress[] = [ const myPayments: StealthAddress[] = [] for (const stealthAddr of incomingStealthAddresses) { - // Check if this payment is for you + // Check if this payment is for you (view-only: viewing PRIVATE key + + // spending PUBLIC key, per canonical EIP-5564) const isMine = checkStealthAddress( stealthAddr, - myKeys.spendingPrivateKey, - myKeys.viewingPrivateKey + myKeys.viewingPrivateKey, + myKeys.metaAddress.spendingKey ) if (isMine) { @@ -116,20 +117,22 @@ for (const payment of myPayments) { Use view tags to quickly filter out payments that aren't for you: ```typescript +// Scanning is view-only — the scanner needs the viewing PRIVATE key and the +// spending PUBLIC key only, never the spending private key (canonical EIP-5564). interface OptimizedStealthScanner { ownKeys: { - spendingPrivateKey: string viewingPrivateKey: string + spendingPublicKey: string } scanned: number found: number } -function createScanner(spendingPriv: string, viewingPriv: string): OptimizedStealthScanner { +function createScanner(viewingPriv: string, spendingPub: string): OptimizedStealthScanner { return { ownKeys: { - spendingPrivateKey: spendingPriv, viewingPrivateKey: viewingPriv, + spendingPublicKey: spendingPub, }, scanned: 0, found: 0, @@ -152,8 +155,8 @@ async function efficientScan( const isMine = checkStealthAddress( addr, - scanner.ownKeys.spendingPrivateKey, - scanner.ownKeys.viewingPrivateKey + scanner.ownKeys.viewingPrivateKey, + scanner.ownKeys.spendingPublicKey ) if (isMine) { @@ -171,7 +174,7 @@ async function efficientScan( } // Usage -const scanner = createScanner(myKeys.spendingPrivateKey, myKeys.viewingPrivateKey) +const scanner = createScanner(myKeys.viewingPrivateKey, myKeys.metaAddress.spendingKey) const payments = await efficientScan(scanner, incomingStealthAddresses) console.log(`Scan complete: ${scanner.found}/${scanner.scanned} payments found`) ``` @@ -205,8 +208,8 @@ async function monitorForPayments(monitor: PaymentMonitor) { for (const addr of newAddresses) { const isMine = checkStealthAddress( addr, - monitor.scanner.ownKeys.spendingPrivateKey, - monitor.scanner.ownKeys.viewingPrivateKey + monitor.scanner.ownKeys.viewingPrivateKey, + monitor.scanner.ownKeys.spendingPublicKey ) if (isMine) { @@ -224,7 +227,7 @@ async function monitorForPayments(monitor: PaymentMonitor) { // Usage const paymentMonitor: PaymentMonitor = { - scanner: createScanner(myKeys.spendingPrivateKey, myKeys.viewingPrivateKey), + scanner: createScanner(myKeys.viewingPrivateKey, myKeys.metaAddress.spendingKey), lastScannedBlock: 1000000, onPaymentFound: (payment) => { console.log('Payment found:', payment.address) @@ -273,11 +276,12 @@ class StealthWallet { let newPayments = 0 for (const addr of stealthAddresses) { - // Check if this payment is for us + // Check if this payment is for us (view-only: viewing PRIVATE key + + // spending PUBLIC key, per canonical EIP-5564) const isMine = checkStealthAddress( addr, - this.spendingPrivateKey, - this.viewingPrivateKey + this.viewingPrivateKey, + this.metaAddress.spendingKey ) if (!isMine) continue @@ -402,7 +406,7 @@ await secureVault.store('stealth-keys', { ```typescript // ❌ Wrong - expensive full check for every address for (const addr of allAddresses) { - const isMine = checkStealthAddress(addr, spending, viewing) + const isMine = checkStealthAddress(addr, viewingPrivateKey, spendingPublicKey) // Full computation every time } ``` @@ -410,9 +414,10 @@ for (const addr of allAddresses) { **Solution:** checkStealthAddress already uses view tags internally for optimization. Just use it correctly. ```typescript -// ✅ Correct - view tag optimization built-in +// ✅ Correct - view tag optimization built-in (view-only: viewing PRIVATE +// key + spending PUBLIC key) for (const addr of allAddresses) { - const isMine = checkStealthAddress(addr, spending, viewing) + const isMine = checkStealthAddress(addr, viewingPrivateKey, spendingPublicKey) // View tag checked first (fast rejection) } ``` diff --git a/src/content/docs/glossary.md b/src/content/docs/glossary.md index bb786e4..64108b7 100644 --- a/src/content/docs/glossary.md +++ b/src/content/docs/glossary.md @@ -296,13 +296,13 @@ A number used in elliptic curve operations. Private keys and blinding factors ar ### Scanning -The process of checking transactions to find those addressed to you. Recipients scan using their viewing key to detect incoming payments. +The process of checking transactions to find those addressed to you. Recipients scan view-only — with the viewing PRIVATE key plus the spending PUBLIC key — never the spending private key (canonical EIP-5564). ```typescript const isForMe = checkStealthAddress( stealthAddress, - spendingPrivateKey, - viewingPrivateKey + viewingPrivateKey, + metaAddress.spendingKey ) ``` diff --git a/src/content/docs/guides/cross-chain-stealth.md b/src/content/docs/guides/cross-chain-stealth.md index ed7ef34..efc4021 100644 --- a/src/content/docs/guides/cross-chain-stealth.md +++ b/src/content/docs/guides/cross-chain-stealth.md @@ -169,7 +169,7 @@ There is no mathematical transformation to convert a secp256k1 public key into a SIP stealth addresses are derived from the meta-address public keys: ``` -Stealth Address = ViewingKey + hash(SharedSecret) × G +Stealth Address = SpendingKey + hash(SharedSecret) × G ``` Where `G` is the generator point of the specific curve. Using a secp256k1 generator with ed25519 arithmetic (or vice versa) produces invalid addresses. diff --git a/src/content/docs/guides/ethereum-privacy.md b/src/content/docs/guides/ethereum-privacy.md index c78b280..003840c 100644 --- a/src/content/docs/guides/ethereum-privacy.md +++ b/src/content/docs/guides/ethereum-privacy.md @@ -143,10 +143,11 @@ for (const ann of announcements) { } // Full check: does this stealth address belong to us? + // View-only — viewing PRIVATE key + spending PUBLIC key (canonical EIP-5564) const isMine = checkStealthAddress( stealthAddress, - myMetaAddress.spendingPrivateKey, myMetaAddress.viewingPrivateKey, + myMetaAddress.metaAddress.spendingKey, ) if (!isMine) continue diff --git a/src/content/docs/security/audit-checklist.md b/src/content/docs/security/audit-checklist.md index 2a6c7e4..c8d7390 100644 --- a/src/content/docs/security/audit-checklist.md +++ b/src/content/docs/security/audit-checklist.md @@ -133,9 +133,9 @@ const bytes = crypto.getRandomValues(new Uint8Array(32)) - [ ] **File**: `stealth.ts:generateStealthAddress()` - [ ] Ephemeral key is fresh random -- [ ] ECDH performed correctly: S = r·K_spend +- [ ] ECDH performed correctly: S = r·K_view - [ ] Scalar derivation: s = H(S) -- [ ] Address computation: P = K_view + s·G +- [ ] Address computation: P = K_spend + s·G #### 3.3 Address Checking @@ -147,7 +147,7 @@ const bytes = crypto.getRandomValues(new Uint8Array(32)) #### 3.4 Private Key Recovery - [ ] **File**: `stealth.ts:deriveStealthPrivateKey()` -- [ ] Correctly computes: p = k_view + H(k_spend·R) +- [ ] Correctly computes: p = k_spend + H(k_view·R) - [ ] Result matches stealth address public key - [ ] No timing variance based on input diff --git a/src/content/docs/security/crypto-assumptions.md b/src/content/docs/security/crypto-assumptions.md index c63401e..96d8fce 100644 --- a/src/content/docs/security/crypto-assumptions.md +++ b/src/content/docs/security/crypto-assumptions.md @@ -112,21 +112,23 @@ H = tryAndIncrement(H_DOMAIN) Recipient publishes: (K_spend, K_view) - stealth meta-address Sender generates: r (ephemeral private key) R = r·G (ephemeral public key) - S = r·K_spend (shared secret via ECDH) + S = r·K_view (shared secret via ECDH) s = H(S) (scalar derivation) - P = K_view + s·G (stealth address) + P = K_spend + s·G (stealth address) -Recipient computes: S' = k_spend·R (same shared secret) +Recipient computes: S' = k_view·R (same shared secret) s' = H(S') - p = k_view + s' (stealth private key) + p = k_spend + s' (stealth private key) ``` -Consistency: `S = r·K_spend = k_spend·R` (ECDH), and -`P = K_view + s·G = (k_view + s)·G = p·G`. Deriving the stealth private key -`p` therefore requires **both** recipient private keys (`k_spend` to recover -`S`, `k_view` as the base scalar). This matches the implementation in -`stealth/secp256k1.ts` and the formula `A = Q + H(r·P)·G` in -[security-properties](/security/security-properties/). +Consistency: `S = r·K_view = k_view·R` (ECDH on the **viewing** key), and +`P = K_spend + s·G = (k_spend + s)·G = p·G`. Deriving the stealth private key +`p` requires **both** recipient private keys (`k_view` to recover `S`, and +`k_spend` as the base scalar), while **detection needs only `k_view` plus the +spending public key `K_spend`** — compute `S = k_view·R`, then check whether +`K_spend + H(S)·G == P`. This matches the implementation in +`stealth/secp256k1.ts` and the canonical stealth-address formula +`A = K_spend + H(S)·G` in [security-properties](/security/security-properties/). ### Security Properties diff --git a/src/content/docs/security/security-properties.md b/src/content/docs/security/security-properties.md index bcee9c8..7b98381 100644 --- a/src/content/docs/security/security-properties.md +++ b/src/content/docs/security/security-properties.md @@ -39,19 +39,19 @@ This document describes the formal security properties provided by SIP Protocol' **Definition**: Stealth addresses derived from the same meta-address are unlinkable to observers without the viewing key. -**Proof Sketch**: Each stealth address A = Q + H(r·P)·G uses fresh ephemeral key r. The shared secret r·P is ECDH output, computationally indistinguishable from random without knowing r or p. Thus A appears as a random curve point. +**Proof Sketch**: Each stealth address `A = K_spend + H(S)·G` uses a fresh ephemeral key `r`, where the shared secret is the ECDH output `S = r·K_view` (sender) `= k_view·R` (recipient) over the **viewing** key. `S` is computationally indistinguishable from random without knowing `r` or `k_view`, so `A` appears as a random curve point. ### Recipient Privacy -**Definition**: Given a stealth address A and meta-address (P, Q), no PPT adversary can determine if A was derived from (P, Q) without the viewing key. +**Definition**: Given a stealth address `A` and meta-address `(K_spend, K_view)`, no PPT adversary can determine if `A` was derived from that meta-address without the viewing private key `k_view` (used to recompute the ECDH shared secret `S = k_view·R`). **Guarantee**: Follows from DDH assumption on secp256k1. ### Forward Secrecy -**Property**: Compromise of viewing key doesn't reveal past transactions' spending keys. +**Property**: Compromise of the viewing key doesn't reveal past transactions' spending keys. -**Mechanism**: Spending key (p) is separate from viewing key (q). Viewing key allows scanning but not spending. +**Mechanism**: The spending private key `k_spend` is separate from the viewing private key `k_view`. The viewing key allows scanning/detection (recompute `S = k_view·R`, check `K_spend + H(S)·G == A`) but **not** spending — the stealth private key `p = k_spend + H(S)` additionally requires `k_spend`. ### View Tag Security diff --git a/src/content/docs/specs/eip-5564.md b/src/content/docs/specs/eip-5564.md index 13253a7..2f41f00 100644 --- a/src/content/docs/specs/eip-5564.md +++ b/src/content/docs/specs/eip-5564.md @@ -92,12 +92,13 @@ Given: - Ephemeral private key: `r` (randomly generated) - Ephemeral public key: `R = r * G` -The stealth address is derived as: +The stealth address is derived as (canonical EIP-5564 — shared secret from the +VIEWING key, stealth public key offset from the SPENDING key): ``` -1. Compute shared secret point: S = r * K +1. Compute shared secret point: S = r * V 2. Hash the shared secret: h = SHA256(S) -3. Derive stealth public key: P = V + h * G +3. Derive stealth public key: P = K + h * G 4. Derive Ethereum address: addr = keccak256(decompress(P))[12:32] 5. View tag: vt = h[0] ``` @@ -151,15 +152,18 @@ console.log('Publish:', stealthAddress.ephemeralPublicKey) Sender computes: ``` -S = r * K (ephemeral private × spending public) +S = r * V (ephemeral private × viewing public) ``` Recipient computes: ``` -S = k * R (spending private × ephemeral public) +S = v * R (viewing private × ephemeral public) ``` -Both arrive at the same point due to ECDH properties. +Both arrive at the same point due to ECDH properties. Because the shared secret +derives from the VIEWING key, the recipient can detect payments with only the +viewing private key plus the spending PUBLIC key — the spending private key is +needed solely to derive the spendable stealth key. ## View Tag Optimization @@ -177,6 +181,10 @@ The view tag enables efficient scanning by allowing quick rejection of non-match mismatch before doing the full elliptic-curve derivation), so scanning is a single call per candidate: +Scanning is view-only — it needs the recipient's viewing PRIVATE key and their +spending PUBLIC key (the meta-address `spendingKey`), never the spending private +key (canonical EIP-5564): + ```typescript import { checkStealthAddress } from '@sip-protocol/sdk' @@ -186,8 +194,8 @@ for (const payment of potentialPayments) { // expensive derivation only runs when the tag matches (~0.4% of the time) const isMine = checkStealthAddress( payment.stealthAddress, - mySpendingPrivateKey, - myViewingPrivateKey + myViewingPrivateKey, + mySpendingPublicKey ) if (isMine) { @@ -270,9 +278,10 @@ const announcements = await announcer.getAnnouncements({ schemeId: 1, }) -// Each candidate is then checked with checkStealthAddress (view tag handled internally) +// Each candidate is then checked with checkStealthAddress (view tag handled +// internally; view-only — viewing PRIVATE key + spending PUBLIC key) const myPayments = announcements.filter(a => - checkStealthAddress(a.stealthAddress, mySpendingPrivateKey, myViewingPrivateKey) + checkStealthAddress(a.stealthAddress, myViewingPrivateKey, mySpendingPublicKey) ) ```