Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions src/content/docs/concepts/stealth-address.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
```
Expand All @@ -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
```

Expand Down Expand Up @@ -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
Expand Down
41 changes: 23 additions & 18 deletions src/content/docs/cookbook/05-stealth-scanning.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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`)
```
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -402,17 +406,18 @@ 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
}
```

**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)
}
```
Expand Down
6 changes: 3 additions & 3 deletions src/content/docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
```

Expand Down
2 changes: 1 addition & 1 deletion src/content/docs/guides/cross-chain-stealth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/content/docs/guides/ethereum-privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions src/content/docs/security/audit-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
22 changes: 12 additions & 10 deletions src/content/docs/security/crypto-assumptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions src/content/docs/security/security-properties.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 19 additions & 10 deletions src/content/docs/specs/eip-5564.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
```
Expand Down Expand Up @@ -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

Expand All @@ -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'

Expand All @@ -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) {
Expand Down Expand Up @@ -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)
)
```

Expand Down
Loading