feat: add vanity ID generator command-line tool

Add a new cmd/vanity-id tool for generating cryptographic identities with custom prefixes using parallel processing and efficient random generation. Includes comprehensive README with usage examples, performance notes, and integration guide.

lemon-mint committed Nov 3, 2025 at 18:10 UTC 476e138651934fb0ea36c8f796f8fd2ead3f8a3c
2 files changed +299
cmd/vanity-id/README.md new
+117
@@ -0,0 +1,117 @@
1 +# Vanity ID Generator
2 +
3 +A high-performance parallel vanity ID generator that uses `cryptoops.DeriveID` and `randpool` to quickly generate cryptographic identities with custom prefix patterns.
4 +
5 +## Features
6 +
7 +- **Parallel Processing**: Uses multiple goroutines to maximize CPU utilization
8 +- **Fast Random Generation**: Leverages `randpool.CSPRNG_RAND` for efficient random number generation
9 +- **Real-time Statistics**: Displays attempt rate, progress, and estimated time every 2 seconds
10 +- **Smart ETA Calculation**: Mathematically calculates expected completion time based on prefix length
11 +- **Configurable**: Customizable prefix, worker count, and result limit
12 +
13 +## Usage
14 +
15 +```bash
16 +# Generate one ID with "CHAT" prefix (default)
17 +go run ./cmd/vanity-id
18 +
19 +# Generate IDs with custom prefix
20 +go run ./cmd/vanity-id -prefix PORTAL
21 +
22 +# Generate multiple IDs
23 +go run ./cmd/vanity-id -prefix DNS -max 3
24 +
25 +# Use more workers (default is number of CPUs)
26 +go run ./cmd/vanity-id -prefix KEY -workers 16
27 +
28 +# Generate unlimited IDs (press Ctrl+C to stop)
29 +go run ./cmd/vanity-id -prefix TEST -max 0
30 +```
31 +
32 +## Command-line Options
33 +
34 +- `-prefix`: ID prefix to search for (default: "CHAT")
35 +- `-workers`: Number of parallel workers (default: number of CPUs)
36 +- `-max`: Maximum number of results to find, 0 = unlimited (default: 1)
37 +
38 +## Output Example
39 +
40 +```
41 +Searching for IDs with prefix: TEST (4 characters)
42 +Using 8 parallel workers
43 +Max results: 1
44 +Expected attempts per result: 524288 (average)
45 +
46 +[Stats] Attempts: 546004 | Found: 0 | Rate: 272418/sec | Elapsed: 2.0s | ETA: 2s
47 +[#1] Found at 3.60s (attempt #807217):
48 + ID: TESTIWIBIRNDLZOHD3H2D6AD7Q
49 + PrivateKey: ZIhWbN39MThmbqREW+Ir7PvRxzzcuEVvJlOGwuive1ZL6RMsaBDcOSWj5MzSeyS+uqG8JARUssjODC70oC+sXg==
50 + PublicKey: S+kTLGgQ3Dklo+TM0nskvrqhvCQEVLLIzgwu9KAvrF4=
51 +
52 +
53 +=== Final Stats ===
54 +Total attempts: 810086
55 +Total found: 1
56 +Elapsed time: 3.60s
57 +Rate: 225067 attempts/sec
58 +```
59 +
60 +**Note**: Keys are displayed in base64 encoding for readability:
61 +- PrivateKey: 64 bytes (ed25519 seed + public key)
62 +- PublicKey: 32 bytes
63 +
64 +## How It Works
65 +
66 +1. **Random Key Generation**: Each worker generates random ed25519 private keys using `randpool.CSPRNG_RAND`
67 +2. **ID Derivation**: The corresponding ID is derived using `cryptoops.DeriveID` which uses HMAC-SHA256 and base32 encoding
68 +3. **Prefix Matching**: The ID is checked against the desired prefix
69 +4. **ETA Calculation**: Expected completion time is calculated based on:
70 + - Current attempt rate (attempts/sec)
71 + - Remaining results needed
72 + - Mathematical probability (32^n for n character prefix)
73 +5. **Result Collection**: Matching credentials are collected and displayed with their full private/public key pairs
74 +
75 +## Performance Notes
76 +
77 +- The search difficulty increases exponentially with prefix length
78 +- Each additional character multiplies the expected attempts by ~32 (base32 alphabet size)
79 +- Average attempts needed:
80 + - 1 character: ~16 attempts
81 + - 2 characters: ~512 attempts
82 + - 3 characters: ~16,384 attempts
83 + - 4 characters: ~524,288 attempts
84 + - 5 characters: ~16,777,216 attempts
85 +
86 +On a typical 8-core CPU, you can expect:
87 +- ~250,000-300,000 attempts/second
88 +- 1-2 character prefixes: instant
89 +- 3 character prefixes: < 1 second
90 +- 4 character prefixes: 2-10 seconds
91 +- 5 character prefixes: 1-5 minutes
92 +
93 +## Integration
94 +
95 +The generated credentials can be used with the `cryptoops.Credential` type:
96 +
97 +```go
98 +import (
99 + "crypto/ed25519"
100 + "encoding/base64"
101 + "gosuda.org/portal/portal/core/cryptoops"
102 +)
103 +
104 +// Use the private key from the output (base64 encoded)
105 +privateKeyB64 := "ZIhWbN39MThmbqREW+Ir7PvRxzzcuEVvJlOGwuive1ZL6RMsaBDcOSWj5MzSeyS+uqG8JARUssjODC70oC+sXg=="
106 +privateKeyBytes, err := base64.StdEncoding.DecodeString(privateKeyB64)
107 +if err != nil {
108 + panic(err)
109 +}
110 +
111 +cred, err := cryptoops.NewCredentialFromPrivateKey(ed25519.PrivateKey(privateKeyBytes))
112 +if err != nil {
113 + panic(err)
114 +}
115 +
116 +// Verify the ID matches
117 +fmt.Println(cred.ID()) // Should print: TESTIWIBIRNDLZOHD3H2D6AD7Q
\ No newline at end of file
cmd/vanity-id/main.go new
+182
@@ -0,0 +1,182 @@
1 +package main
2 +
3 +import (
4 + "crypto/ed25519"
5 + "encoding/base64"
6 + "flag"
7 + "fmt"
8 + "math"
9 + "runtime"
10 + "strings"
11 + "sync"
12 + "sync/atomic"
13 + "time"
14 +
15 + "gosuda.org/portal/portal/core/cryptoops"
16 + "gosuda.org/portal/portal/utils/randpool"
17 +)
18 +
19 +func main() {
20 + prefix := flag.String("prefix", "CHAT", "ID prefix to search for")
21 + workers := flag.Int("workers", runtime.NumCPU(), "Number of parallel workers")
22 + maxResults := flag.Int("max", 1, "Maximum number of results to find (0 = unlimited)")
23 + flag.Parse()
24 +
25 + // Convert prefix to uppercase (base32 encoding is uppercase)
26 + *prefix = strings.ToUpper(*prefix)
27 +
28 + // Calculate expected attempts (base32 has 32 characters)
29 + expectedAttempts := math.Pow(32, float64(len(*prefix)))
30 +
31 + fmt.Printf("Searching for IDs with prefix: %s (%d characters)\n", *prefix, len(*prefix))
32 + fmt.Printf("Using %d parallel workers\n", *workers)
33 + fmt.Printf("Max results: %d\n", *maxResults)
34 + fmt.Printf("Expected attempts per result: %.0f (average)\n", expectedAttempts/2)
35 + fmt.Println()
36 +
37 + var (
38 + attempts uint64
39 + found uint64
40 + startTime = time.Now()
41 + results = make(chan *Result, *workers)
42 + wg sync.WaitGroup
43 + ctx = make(chan struct{}) // Context for stopping workers
44 + )
45 +
46 + // Start worker goroutines
47 + for i := 0; i < *workers; i++ {
48 + wg.Add(1)
49 + go worker(*prefix, &attempts, &found, results, &wg, *maxResults, ctx)
50 + }
51 +
52 + // Start stats reporter
53 + done := make(chan bool)
54 + go statsReporter(&attempts, &found, startTime, done, len(*prefix), *maxResults)
55 +
56 + // Collect and print results
57 + foundCount := 0
58 + for result := range results {
59 + foundCount++
60 + elapsed := time.Since(startTime)
61 + fmt.Printf("\n[#%d] Found at %.2fs (attempt #%d):\n", foundCount, elapsed.Seconds(), result.Attempt)
62 + fmt.Printf(" ID: %s\n", result.ID)
63 + fmt.Printf(" PrivateKey: %s\n", base64.StdEncoding.EncodeToString(result.PrivateKey))
64 + fmt.Printf(" PublicKey: %s\n", base64.StdEncoding.EncodeToString(result.PublicKey))
65 + fmt.Println()
66 +
67 + // If we've reached max results, signal workers to stop
68 + if *maxResults > 0 && foundCount >= *maxResults {
69 + close(ctx)
70 + // Wait for all workers to finish
71 + go func() {
72 + wg.Wait()
73 + close(results)
74 + }()
75 + }
76 + }
77 +
78 + done <- true
79 + elapsed := time.Since(startTime)
80 + fmt.Printf("\n=== Final Stats ===\n")
81 + fmt.Printf("Total attempts: %d\n", atomic.LoadUint64(&attempts))
82 + fmt.Printf("Total found: %d\n", foundCount)
83 + fmt.Printf("Elapsed time: %.2fs\n", elapsed.Seconds())
84 + fmt.Printf("Rate: %.0f attempts/sec\n", float64(atomic.LoadUint64(&attempts))/elapsed.Seconds())
85 +}
86 +
87 +type Result struct {
88 + ID string
89 + PrivateKey ed25519.PrivateKey
90 + PublicKey ed25519.PublicKey
91 + Attempt uint64
92 +}
93 +
94 +func worker(prefix string, attempts, found *uint64, results chan<- *Result, wg *sync.WaitGroup, maxResults int, ctx <-chan struct{}) {
95 + defer wg.Done()
96 +
97 + var seed [32]byte
98 +
99 + for {
100 + // Check if we should stop
101 + select {
102 + case <-ctx:
103 + return
104 + default:
105 + }
106 +
107 + // Generate random seed using randpool
108 + randpool.CSPRNG_RAND(seed[:])
109 +
110 + // Generate private key from seed (this is 64 bytes: 32 byte seed + 32 byte public key)
111 + privateKey := ed25519.NewKeyFromSeed(seed[:])
112 +
113 + // Extract public key (last 32 bytes of private key)
114 + publicKey := ed25519.PublicKey(privateKey[32:])
115 +
116 + // Derive ID
117 + id := cryptoops.DeriveID(publicKey)
118 +
119 + // Increment attempts counter
120 + attemptNum := atomic.AddUint64(attempts, 1)
121 +
122 + // Check if ID starts with the desired prefix
123 + if strings.HasPrefix(id, prefix) {
124 + // Increment found counter
125 + atomic.AddUint64(found, 1)
126 +
127 + // Try to send result, but return if context is closed
128 + select {
129 + case results <- &Result{
130 + ID: id,
131 + PrivateKey: privateKey,
132 + PublicKey: publicKey,
133 + Attempt: attemptNum,
134 + }:
135 + case <-ctx:
136 + return
137 + }
138 + }
139 + }
140 +}
141 +
142 +func statsReporter(attempts, found *uint64, startTime time.Time, done <-chan bool, prefixLen int, maxResults int) {
143 + ticker := time.NewTicker(2 * time.Second)
144 + defer ticker.Stop()
145 +
146 + // Calculate expected attempts per result
147 + expectedAttemptsPerResult := math.Pow(32, float64(prefixLen)) / 2
148 +
149 + for {
150 + select {
151 + case <-ticker.C:
152 + elapsed := time.Since(startTime)
153 + a := atomic.LoadUint64(attempts)
154 + f := atomic.LoadUint64(found)
155 + rate := float64(a) / elapsed.Seconds()
156 +
157 + // Calculate estimated time to completion
158 + var etaStr string
159 + if rate > 0 && maxResults > 0 {
160 + remainingResults := maxResults - int(f)
161 + if remainingResults > 0 {
162 + expectedRemainingAttempts := float64(remainingResults) * expectedAttemptsPerResult
163 + etaSeconds := expectedRemainingAttempts / rate
164 +
165 + if etaSeconds < 60 {
166 + etaStr = fmt.Sprintf(" | ETA: %.0fs", etaSeconds)
167 + } else if etaSeconds < 3600 {
168 + etaStr = fmt.Sprintf(" | ETA: %.1fm", etaSeconds/60)
169 + } else {
170 + etaStr = fmt.Sprintf(" | ETA: %.1fh", etaSeconds/3600)
171 + }
172 + }
173 + }
174 +
175 + fmt.Printf("\r[Stats] Attempts: %d | Found: %d | Rate: %.0f/sec | Elapsed: %.1fs%s",
176 + a, f, rate, elapsed.Seconds(), etaStr)
177 + case <-done:
178 + fmt.Println() // New line after final stats
179 + return
180 + }
181 + }
182 +}