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
+}