diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 2d53e3f..3377808 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/checkout@v4 # build the binaries - - uses: wangyoucao577/go-release-action@v1.49 + - uses: wangyoucao577/go-release-action@v1.50 with: github_token: ${{ secrets.GITHUB_TOKEN }} goos: ${{ matrix.goos }} diff --git a/.gitignore b/.gitignore index 7a804c6..45696a5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /dist/ /wireguard-vanity-keygen +/wireguard-vanity-keygen.exe /cmd/wg-vanity-keygen/wg-vanity-keygen diff --git a/CHANGELOG.md b/CHANGELOG.md index 4861fdc..a39a9fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [0.0.9] + +- Add regex support (#13) +- Update Go modules + + ## [0.0.8] - Update Go modules diff --git a/README.md b/README.md index 9ff40a4..6cfda96 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A command-line vanity (public) key generator for [WireGuard](https://www.wiregua - Generates compliant [curve25519](https://cr.yp.to/ecdh.html) private and public keys - Configurable multi-core processing (defaults to all cores) - Optional case sensitive searching +- Optional regex searching - Search multiple prefixes at once - Exit after results limit reached (defaults to 1) - Displays probability and estimated runtime based on quick benchmark @@ -30,24 +31,70 @@ Options: ## Example ``` -$ wireguard-vanity-keygen -l 4 test pc1/ +$ wireguard-vanity-keygen -l 3 test pc1/ "^pc7[+/]" Calculating speed: 49,950 calculations per second using 4 CPU cores Case-insensitive search, exiting after 4 results Probability for "test": 1 in 2,085,136 (approx 41 seconds per match) Probability for "pc1/": 1 in 5,914,624 (approx 1 minute per match) +Cannot calculate probability for the regular expression "^pc7[/+]" Press Ctrl-c to cancel private OFVUjUoTNQp94fNPB9GCLzxiJPTbN03rcDPrVd12uFc= public tEstMXL/3ZzAd2TnVlr1BNs/+eOnKzSHpGUnjspk3kc= private gInIEDmENYbyuaWR1W/KLfximExwbcCg45W2WOmEc0I= public TestKmA/XVagDW/JsHBXk5mhYJ6E1N1lAWeIeCttgRs= private yDQLNiQlfnMGhUBsbLQjoBbuNezyHug31Qa1Ht6cgkw= public PC1/3oUId241TLYImJLUObR8NNxz4HXzG4z+EazfWxY= +private QIbJgxy83+F/1kdogcF+T04trs+1N9gAr1t5th2tLXM= public Pc7+h172sx0TfIMikjgszM/B8i8/ghi7qJVOwWQtx0w= private +CUqn4jcKoL8pw53pD4IzfMKW/IMceDWKcM2W5Dxtn4= public teStmGXZwiJl9HmfnTSmk83girtiIH8oZEa6PFJ8F1Y= -private 2G0X+IvBLw3NRfRnHb8diIXp96NQ9wSu4gdqPidy3nw= public tESt3DBU40Q/Zkp0d1aeb6HOgEOsEM3BxzNqLckKhhc= private EMaUfQvAEABpQV/21ALJP5YtyGerRXAn8u67j2AQzVs= public pC1/t2x5V99Y1SBqNgPZDPsa6r+L5y3BJ4XUCJMar3g= private wNuHOKCfoH1emfvijXNBoc/7KjrEXUeof7tSdGWvRFo= public PC1/jXQosaBad2HePOm/w1KjCZ82eT3qNbfzNDZiwTs= -private 8IdcNsman/ZRGvqWzw1e5cRfhhdtAAmk02X9TkQxhHI= public pC1/N8coOcXmcwO09QXxLrF5/BoHQfvp/qsysGPXiw0= +private gJtn0woDChGvyN2eSdc7mTpAFA/nA6jykJeK5bYYfFA= public Pc7+UEJSHiWsQ9zkO2q+guqDK4sc3VMDMgJu+h/bOFI= +private IMyPmYm/v0SPmB62hC8l6kfxT3/Lfp7dMioo+SM6T2c= public Pc7/uVfD/ZftxWBHwYbaudEywUS61biBcpj5Tw830Q4= ``` +## Timings + +To give you a rough idea of how long it will take to generate keys, the following table lists +estimated timings for each match on a system that reported "`Calculating speed: 230,000 calculations per second using 19 CPU cores`" when it started: + +| Length | Case-insensitive | Case-sensitive | +| :------ | :--------------- | :------------- | +| 3 chars | 0 seconds | 1 second | +| 4 chars | 9 seconds | 1 minute | +| 5 chars | 5 minutes | 1.25 hours | +| 6 chars | 4 hours | 3.5 days | +| 7 chars | 6 days | 7 months | +| 8 chars | 7 months | 38 years | +| 9 chars | 22 years | 175 years | + +Note that the above timings are for finding a result for any search term. +Passing multiple search terms will not substantially increase the time, +but increasing the limit to two (`--limit 2`) will double the estimated time, three will triple the time, etc. + +If any search term contains numbers, the timings would fall somewhere between the case-insensitive and case-sensitive columns. + +Of course, your mileage will differ, depending on the number, and speed, of your CPU cores. + +## Regular Expressions + +Since each additional letter in a search term increases the search time exponentially, searching using a regular expression may +reduce the time considerably. Here are some examples: + +1. `.*word.*` - find word anywhere in the key (`word.*` and `.*word` will also work) +2. `^.{0,10}word` - find word anywhere in the first 10 letters of the key +3. `word1.*word2` - find two words, anywhere in the key +4. `^[s5][o0][ll]ar` - find 'solar', or the visually similar 's01ar`, at the beginning of the key +5. `^(best|next)[/+]` - find 'best', or the 'next' best, at the beginning of the key, with `/` or `+` as a delimiter + +A good guide on Go's regular expression syntax is at https://pkg.go.dev/regexp/syntax. + +To include a `+` in your regular expression, preface it with a backslash, like `\+`. + +NOTE: If your search term contains shell metacharacters, such as `|`, or `^`, you will need to quote it. +On Windows, you must use double quotes. For example: `"^(a|b)"`. + +NOTE: Complex regular expressions, such as those using escape sequences, flags, or character classes, may never match a key. +To avoid that, consider testing your regex using a tool such as [this one](https://go.dev/play/p/6LJy51Wd08O). + ## Installing Download the [latest binary release](https://github.com/axllent/wireguard-vanity-keygen/releases/latest) for your system, @@ -60,6 +107,7 @@ or build from source `go install github.com/axllent/wireguard-vanity-keygen@late Valid characters include `A-Z`, `a-z`, `0-9`, `/` and `+`. There are no other characters in a hash. +You can also use regex expressions to search. ### Why does `test` & `tes1` show different probabilities despite having 4 characters each? diff --git a/go.mod b/go.mod index 377b925..76da71e 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,5 @@ go 1.17 require ( github.com/spf13/pflag v1.0.5 - golang.org/x/crypto v0.21.0 + golang.org/x/crypto v0.23.0 ) diff --git a/go.sum b/go.sum index 344c4c0..14b9268 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -25,19 +25,20 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/keygen/utils.go b/keygen/utils.go index a2f7e7e..8198d8b 100644 --- a/keygen/utils.go +++ b/keygen/utils.go @@ -5,15 +5,28 @@ import ( "math" "regexp" "strconv" + "strings" "time" ) +// regexChars contains the list of regex metacharacters, excluding +, +// which is valid in a key +const regexChars = `^$.|?*-[]{}()\` + +// regexWillNeverMatch is a shared error message that the regex will never match +const regexWillNeverMatch = "The regular expression will never match" + // IsValidSearch checks the search does not contain any invalid characters func IsValidSearch(s string) bool { var r = regexp.MustCompile(`[^a-zA-Z0-9\/\+]`) return !r.MatchString(s) } +// InvalidSearchMsg returns the error message the search term contains invalid characters +func InvalidSearchMsg(s string) string { + return fmt.Sprintf("\n\"%s\" contains invalid characters\nValid characters include letters [a-z], numbers [0-9], + and /", s) +} + // HumanizeDuration returns a human-readable output of time.Duration func HumanizeDuration(duration time.Duration) string { // more than duration can handle @@ -87,3 +100,111 @@ func NumberFormat(n int64) string { } } } + +// IsRegex returns true if any regex metacharacters (except +) are in the search term +func IsRegex(s string) bool { + return strings.ContainsAny(s, regexChars) +} + +// invalidRegexMsg returns an error message how the regex is invalid +func invalidRegexMsg(s string, errmsg string) string { + return fmt.Sprintf("\n\"%s\" is an invalid regular expression\n%s", s, errmsg) +} + +// IsValidRegex checks the regex has any chance of matching a key +func IsValidRegex(s string) string { + // A consise guide on golang's regex syntax is at + // https://pkg.go.dev/regexp/syntax + + stripped := removeMetacharacters(s) + if !IsValidSearch(stripped) { + return InvalidSearchMsg(s) + } + + // Expressions with '^' character + re := regexp.MustCompile(`.\^`) + if re.MatchString(s) { + return invalidRegexMsg(s, "The '^' character must appear at the beginning of the search term") + } + + // Expressions with '$' character + re = regexp.MustCompile(`\$.`) + if re.MatchString(s) { + return invalidRegexMsg(s, "The '$' character must appear at the end of the search term") + } + re = regexp.MustCompile(`[^=]\$`) + if re.MatchString(s) { + return invalidRegexMsg(s, "A search at the end of the string must contain an '=' character, as all keys end with an `=`") + } + re = regexp.MustCompile(`=[^$]`) + if re.MatchString(s) { + return invalidRegexMsg(s, "The '=' character can only appear at the end of a key") + } + // The command: + // wireguard-vanity-keygen -l 1000 . | grep private | cut -c 105- | sort -u | tr -d "=" | tr -d "\n" + // outputs: + // 048AEIMQUYcgkosw + re = regexp.MustCompile(`[^048AEIMQUYcgkosw]=\$`) + if re.MatchString(s) { + return invalidRegexMsg(s, regexWillNeverMatch) + } + + // Expressions with backslashes: + + // A regex of just a backslash and a single character will never match + re = regexp.MustCompile(`^\\.$`) + if re.MatchString(s) { + return invalidRegexMsg(s, regexWillNeverMatch) + } + + // Control characters and many octal values will meter match, disallow them all + re = regexp.MustCompile(`\\[aftnrxswWpP0-7]`) + if re.MatchString(s) { + return invalidRegexMsg(s, regexWillNeverMatch) + } + + // Disallow backslashes followed by any non-alnum or + character + re = regexp.MustCompile(`\\[^A-Za-z0-9+]`) + if re.MatchString(s) { + return invalidRegexMsg(s, regexWillNeverMatch) + } + + // Expressions with character classes: [[:alnum:]], etc. + + // [[:blank:]], [[:cntrl:]], [[:punct:]] and [[:space:]] will never match + re = regexp.MustCompile(`\[\[:(blank|cntrl|punct|space):\]\]`) + if re.MatchString(s) { + return invalidRegexMsg(s, regexWillNeverMatch) + } + + // [[^:ascii:]], [[^:graph:]], [[^:print:]] will never match + re = regexp.MustCompile(`\[\[\^:(ascii|graph|print):\]\]`) + if re.MatchString(s) { + return invalidRegexMsg(s, regexWillNeverMatch) + } + + return "" +} + +// removeMetacharacters removes regex metacharacters from the string +func removeMetacharacters(s string) string { + // This logic isn't needed anymore, as we don't attempt to calculate the probability of regular expressions + // // remove (?i) from beginning of string + // re := regexp.MustCompile(`^\([^)]*\)`) + // s = re.ReplaceAllLiteralString(s, "") + // // replace [a-b]+ with x + // re = regexp.MustCompile(`\[[^]]*\]\+?`) + // s = re.ReplaceAllLiteralString(s, "x") + // // strip all {n} + // re = regexp.MustCompile(`\{[^}]+\}`) + // s = re.ReplaceAllLiteralString(s, "") + // // replace = with x + // re = regexp.MustCompile(`=`) + // s = re.ReplaceAllLiteralString(s, "x") + + // strip out remaining regexp metacharacters + for _, rune1 := range []rune(regexChars) { + s = strings.ReplaceAll(s, string(rune1), "") + } + return s +} diff --git a/keygen/worker.go b/keygen/worker.go index 20e9525..4b20f7a 100644 --- a/keygen/worker.go +++ b/keygen/worker.go @@ -1,6 +1,7 @@ package keygen import ( + "regexp" "strings" "sync" "time" @@ -17,10 +18,11 @@ type Options struct { // Cruncher struct type Cruncher struct { Options - WordMap map[string]int - mapMutex sync.RWMutex - thread chan int - Abort bool // set to true to abort processing + WordMap map[string]int + mapMutex sync.RWMutex + RegexpMap map[*regexp.Regexp]int + thread chan int + Abort bool // set to true to abort processing } // Pair struct @@ -32,9 +34,10 @@ type Pair struct { // New returns a Cruncher func New(options Options) *Cruncher { return &Cruncher{ - Options: options, - WordMap: make(map[string]int), - thread: make(chan int, options.Cores), + Options: options, + WordMap: make(map[string]int), + RegexpMap: make(map[*regexp.Regexp]int), + thread: make(chan int, options.Cores), } } @@ -71,6 +74,17 @@ func (c *Cruncher) crunch(cb func(match Pair)) bool { } } + for w, count := range c.RegexpMap { + if count == 0 { + continue + } + completed = false + if w.MatchString(matchKey) { + c.RegexpMap[w] = count - 1 + cb(Pair{Private: k.String(), Public: pub}) + } + } + <-c.thread // removes an int from threads, allowing another to proceed return completed } @@ -107,6 +121,11 @@ func (c *Cruncher) CalculateSpeed() (int64, time.Duration) { for w := range c.WordMap { _ = strings.HasPrefix(t, w) } + + for w := range c.RegexpMap { + _ = w.MatchString(t) + } + <-c.thread // removes an int from threads, allowing another to proceed n++ }() diff --git a/main.go b/main.go index c5d9a6e..8eb08d3 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "regexp" "runtime" "strings" "time" @@ -75,23 +76,53 @@ func main() { cs, options.LimitResults, keygen.Plural("result", int64(options.LimitResults))) for _, word := range args { + word = strings.Trim(word, " ") sword := word - if !options.CaseSensitive { - sword = strings.ToLower(sword) + if !keygen.IsRegex(sword) { + if !keygen.IsValidSearch(sword) { + fmt.Fprintln(os.Stderr, keygen.InvalidSearchMsg(word)) + os.Exit(2) + } + if !options.CaseSensitive { + sword = strings.ToLower(sword) + } + c.WordMap[sword] = options.LimitResults + probability := keygen.CalculateProbability(sword, options.CaseSensitive) + estimate64 := int64(speed) * probability + estimate := time.Duration(estimate64) + + fmt.Printf("Probability for \"%s\": 1 in %s (approx %s per match)\n", + word, keygen.NumberFormat(probability), keygen.HumanizeDuration(estimate)) + continue } - if !keygen.IsValidSearch(sword) { - fmt.Printf("\n\"%s\" contains invalid characters\n", word) - fmt.Println("Valid characters include letters [a-z], numbers [0-9], + and /") + + errmsg := keygen.IsValidRegex(sword) + if errmsg != "" { + fmt.Fprintln(os.Stderr, errmsg) os.Exit(2) } - c.WordMap[sword] = options.LimitResults - probability := keygen.CalculateProbability(sword, options.CaseSensitive) - estimate64 := int64(speed) * probability - estimate := time.Duration(estimate64) + fmt.Printf("Probability for \"%s\" cannot be calculated as it is a regular expression\n", sword) - fmt.Printf("Probability for \"%s\": 1 in %s (approx %s per match)\n", - word, keygen.NumberFormat(probability), keygen.HumanizeDuration(estimate)) + // strip off leading .* as it's implied: + re := regexp.MustCompile(`^\.\*`) + sword = re.ReplaceAllLiteralString(sword, "") + // strip off trailing .* as it's implied: + re = regexp.MustCompile(`\.\*$`) + sword = re.ReplaceAllLiteralString(sword, "") + + regex := sword + if !options.CaseSensitive { + if !strings.HasPrefix(regex, "(?i)") { + regex = "(?i)" + regex + } + } + re, err := regexp.Compile(regex) + if err != nil { + fmt.Fprintf(os.Stderr, "\n\"%s\" is an invalid regular expression: %v\n", word, err) + os.Exit(2) + } + c.RegexpMap[re] = options.LimitResults } fmt.Printf("\nPress Ctrl-c to cancel\n\n")