working and tested
This commit is contained in:
90
README.md
Normal file
90
README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# wordstr
|
||||
|
||||
`wordstr` is a tool to convert a nostr bech32 encoded `nsec` or hexadecimal
|
||||
encoded secret key into a word mnemonic that can be stored in hard copy.
|
||||
|
||||
It uses the standard BIP-39 word list to represent 11 bit value elements of
|
||||
the secret key but instead of hashing it to derive the actual secret key or
|
||||
extended secret key as with bitcoin keys.
|
||||
|
||||
usage:
|
||||
|
||||
to generate a word key:
|
||||
|
||||
```bash
|
||||
wordstr from <hex/nsec nostr secret key>
|
||||
```
|
||||
|
||||
to convert a word key back to hex and bech32 `nsec` format:
|
||||
|
||||
```bash
|
||||
wordstr to <27 word mnemonic key>
|
||||
```
|
||||
|
||||
the output of the `to` command is formatted as such for easy use in scripts
|
||||
or invocation from other programs:
|
||||
|
||||
```
|
||||
HSEC="<hex secret key>"
|
||||
NSEC="<bech32 nsec secret key>"
|
||||
```
|
||||
|
||||
## building/installing
|
||||
|
||||
`wordstr` can be run directly from a system with a configured Go
|
||||
installation as follows:
|
||||
|
||||
```
|
||||
go run wordstr.mleku.dev@latest <parameters>
|
||||
```
|
||||
|
||||
or if your `GOBIN` refers to a location also present in your `PATH`, installed:
|
||||
|
||||
```bash
|
||||
go install wordstr.mleku.dev@latest
|
||||
```
|
||||
|
||||
## technical details of protocol
|
||||
|
||||
The implementation found in this repository uses big integers to derive the
|
||||
individual ciphers of the word key, and from them in reverse using a place
|
||||
table to reverse it. Possibly a more complex bit-rotating method could be
|
||||
implemented but it is a longer algorithm, and unnecessary for any
|
||||
non-embedded hardware. For embedded hardware, hexadecimal is preferable
|
||||
anyway.
|
||||
|
||||
This encoding is to provide hard copy cold storage for users' nostr
|
||||
secret keys, as a method of entering the secret key from such cold storage
|
||||
into a signing device.
|
||||
|
||||
24 words of a 2048 word dictionary from BIP-34 produces 11 bits per word,
|
||||
and the nearest common length between these two is 264 bits, or 33 bytes,
|
||||
giving us 1 byte for check purposes.
|
||||
|
||||
The first bit must always be 1, to simplify the derivation of the ciphers of
|
||||
the mnemonic key, so we derive a check with the first byte of the SHA256
|
||||
hash of the 33 byte key, with the most significant bit set to 1.
|
||||
|
||||
- when generated, first byte of the hash of the key, bitwise OR 128 so the
|
||||
MSB is always 1, and the remainder match the hash of the correct key
|
||||
|
||||
- when checked, the value is masked using bitwise AND 127, and checked
|
||||
against the 7 least significant bits of the first byte of the hash of the 32
|
||||
remaining bytes, so, the same operation as when generating, on the first
|
||||
byte of the hash, and then compared to the first byte itself
|
||||
|
||||
This provides a protection against a 1/128 chance of a bit flip producing an
|
||||
also valid key versus the 7 bits of check, it would be nice if it was
|
||||
stronger but it will rarely flag as wrong without actually being wrong, and
|
||||
the 11 bit ciphers of this encoding mean more than 50% more words, and a
|
||||
much greater chance of error in transcribing in total, and a reduction in
|
||||
complexity of encoding by avoiding the need for another word and fiddly bit
|
||||
checking beyond this one OR masking on one byte.
|
||||
|
||||
For this reason, the error will return the decoded key from a word key that
|
||||
fails in case the bit flip is in that first word but not the key itself,
|
||||
this is unlikely, however, and deriving the npub will confirm this by the
|
||||
mismatch. This could be validated by fetching the relevant user metadata
|
||||
kind 0 event to show the user in such a case, and enable providing a
|
||||
correction to the word key. It is unlikely to recover the key but 1/128 of
|
||||
cases it could.
|
||||
19
go.mod
Normal file
19
go.mod
Normal file
@@ -0,0 +1,19 @@
|
||||
module wordstr.mleku.dev
|
||||
|
||||
go 1.23rc2
|
||||
|
||||
require (
|
||||
ec.mleku.dev/v2 v2.3.5
|
||||
github.com/Hubmakerlabs/replicatr v1.2.17
|
||||
github.com/minio/sha256-simd v1.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gookit/color v1.5.4 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
||||
github.com/templexxx/cpu v0.1.0 // indirect
|
||||
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
)
|
||||
34
go.sum
Normal file
34
go.sum
Normal file
@@ -0,0 +1,34 @@
|
||||
ec.mleku.dev/v2 v2.3.5 h1:95cTJn4EemxzvdejpdZSse6w79pYx1Ty3nL2zDLKqQU=
|
||||
ec.mleku.dev/v2 v2.3.5/go.mod h1:4hK39Si4F2Aav4H4jBtzSUR7xlFxeuS4pPK3t0Ol8VQ=
|
||||
github.com/Hubmakerlabs/replicatr v1.2.17 h1:FKtsx4aR1Slrqrk0rbU/TYZBy+tODrk4boZ6ycF8KN0=
|
||||
github.com/Hubmakerlabs/replicatr v1.2.17/go.mod h1:54kOzCa/UyTf+yQc4aLH81GfxHwVJ8F9KeBLZb6hKms=
|
||||
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY=
|
||||
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
|
||||
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
|
||||
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/templexxx/cpu v0.0.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
|
||||
github.com/templexxx/cpu v0.1.0 h1:wVM+WIJP2nYaxVxqgHPD4wGA2aJ9rvrQRV8CvFzNb40=
|
||||
github.com/templexxx/cpu v0.1.0/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
|
||||
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b h1:XeDLE6c9mzHpdv3Wb1+pWBaWv/BlHK0ZYIu/KaL6eHg=
|
||||
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b/go.mod h1:7rwmCH0wC2fQvNEvPZ3sKXukhyCTyiaZ5VTZMQYpZKQ=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20240318143956-a85f2c67cd81 h1:6R2FC06FonbXQ8pK11/PDFY6N6LWlf9KlzibaCapmqc=
|
||||
golang.org/x/exp v0.0.0-20240318143956-a85f2c67cd81/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
lukechampine.com/frand v1.4.2 h1:RzFIpOvkMXuPMBb9maa4ND4wjBn71E1Jpf8BzJHMaVw=
|
||||
lukechampine.com/frand v1.4.2/go.mod h1:4S/TM2ZgrKejMcKMbeLjISpJMO+/eZ1zu3vYX9dtj3s=
|
||||
2071
wordlists/chinese_simplified.go
Normal file
2071
wordlists/chinese_simplified.go
Normal file
File diff suppressed because it is too large
Load Diff
2071
wordlists/chinese_traditional.go
Normal file
2071
wordlists/chinese_traditional.go
Normal file
File diff suppressed because it is too large
Load Diff
2071
wordlists/czech.go
Normal file
2071
wordlists/czech.go
Normal file
File diff suppressed because it is too large
Load Diff
2071
wordlists/english.go
Normal file
2071
wordlists/english.go
Normal file
File diff suppressed because it is too large
Load Diff
2071
wordlists/french.go
Normal file
2071
wordlists/french.go
Normal file
File diff suppressed because it is too large
Load Diff
2071
wordlists/italian.go
Normal file
2071
wordlists/italian.go
Normal file
File diff suppressed because it is too large
Load Diff
2071
wordlists/japanese.go
Normal file
2071
wordlists/japanese.go
Normal file
File diff suppressed because it is too large
Load Diff
2071
wordlists/korean.go
Normal file
2071
wordlists/korean.go
Normal file
File diff suppressed because it is too large
Load Diff
2071
wordlists/spanish.go
Normal file
2071
wordlists/spanish.go
Normal file
File diff suppressed because it is too large
Load Diff
35
wordstr.go
Normal file
35
wordstr.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"wordstr.mleku.dev/wordstr"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("Usage: wordstr from/to <nsec>/<word key>")
|
||||
os.Exit(1)
|
||||
}
|
||||
// calculate the place multipliers and assemble them in descending order.
|
||||
var err error
|
||||
places := wordstr.GetPlaces()
|
||||
var hsec, nsec, words string
|
||||
switch {
|
||||
case os.Args[1] == "from":
|
||||
if words, err = wordstr.FromNsec(os.Args[2]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(words)
|
||||
case os.Args[1] == "to":
|
||||
if hsec, nsec, err = wordstr.ToNsec(places, os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("HSEC=\"%s\"\nNSEC=\"%s\"\n", hsec, nsec)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "%s [from|to] <nsec>/<word key>\n", os.Args[0])
|
||||
}
|
||||
}
|
||||
118
wordstr/wordstr.go
Normal file
118
wordstr/wordstr.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package wordstr
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Hubmakerlabs/replicatr/pkg/ec/secp256k1"
|
||||
"github.com/Hubmakerlabs/replicatr/pkg/nostr/bech32encoding"
|
||||
"github.com/minio/sha256-simd"
|
||||
"wordstr.mleku.dev/wordlists"
|
||||
)
|
||||
|
||||
func GetPlaces() (places []*big.Int) {
|
||||
p := big.NewInt(1)
|
||||
for _ = range 24 {
|
||||
p1 := big.NewInt(0)
|
||||
p1.SetBytes(p.Bytes())
|
||||
places = append([]*big.Int{p1}, places...)
|
||||
p.Mul(p, big.NewInt(2048))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func FromNsec(nsec string) (words string, err error) {
|
||||
var sk []byte
|
||||
if len(nsec) == 2*secp256k1.SecKeyBytesLen {
|
||||
if sk, err = hex.DecodeString(nsec); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to decode nsec from hex form: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
var prf string
|
||||
var val any
|
||||
if prf, val, err = bech32encoding.Decode(nsec); err != nil {
|
||||
err = fmt.Errorf("%s", err)
|
||||
fmt.Println(1, err)
|
||||
return
|
||||
}
|
||||
if prf != bech32encoding.NsecHRP {
|
||||
err = fmt.Errorf("nostr nsec must start with %s, not %s\n", bech32encoding.NsecHRP, prf)
|
||||
fmt.Println(2, err)
|
||||
return
|
||||
}
|
||||
if sk, err = hex.DecodeString(val.(string)); err != nil {
|
||||
err = fmt.Errorf("failed to decode nsec from hex form: %s\n", err)
|
||||
fmt.Println(3, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
// left-pad in case the secret is smaller than 32 bytes long
|
||||
if len(sk) != secp256k1.SecKeyBytesLen {
|
||||
sk = append(make([]byte, secp256k1.SecKeyBytesLen+1-len(sk)), sk...)
|
||||
}
|
||||
h := sha256.Sum256(sk)
|
||||
// add 7 bits of check with the MSB forced to 1 (128)
|
||||
sec := append([]byte{h[0] | 128}, sk...)
|
||||
div, mod := big.NewInt(0), big.NewInt(0)
|
||||
div.SetBytes(sec)
|
||||
var ww []string
|
||||
for div.Cmp(big.NewInt(0)) > 0 {
|
||||
div.DivMod(div, big.NewInt(2048), mod)
|
||||
w := wordlists.English[mod.Int64()]
|
||||
ww = append([]string{w}, ww...)
|
||||
}
|
||||
words = strings.Join(ww, " ")
|
||||
return
|
||||
}
|
||||
|
||||
func ToNsec(places []*big.Int, words []string) (hsec, nsec string, err error) {
|
||||
if len(words) != 24 {
|
||||
err = fmt.Errorf("not enough words in key, require 24, got %d", len(os.Args[2:]))
|
||||
return
|
||||
}
|
||||
var indexes []int64
|
||||
for _, word := range words {
|
||||
var found bool
|
||||
for i := range wordlists.English {
|
||||
if wordlists.English[i] == word {
|
||||
indexes = append(indexes, int64(i))
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
err = fmt.Errorf("word not found in wordlist: %s", word)
|
||||
return
|
||||
}
|
||||
}
|
||||
key := big.NewInt(0)
|
||||
for i := range indexes {
|
||||
places[i].Mul(places[i], big.NewInt(indexes[i]))
|
||||
}
|
||||
for i := range places {
|
||||
key = key.Add(key, places[i])
|
||||
}
|
||||
skb := key.Bytes()
|
||||
if len(skb) < secp256k1.SecKeyBytesLen+1 {
|
||||
skb = append(make([]byte, secp256k1.SecKeyBytesLen+1-len(skb)), skb...)
|
||||
}
|
||||
k, check := skb[1:], skb[0]&127
|
||||
h := sha256.Sum256(k)
|
||||
// force the MSB to 1
|
||||
actual := h[0] & 127
|
||||
if check != actual {
|
||||
err = fmt.Errorf("key parity check failed, got %d, should have been %d - if key is intact, here it is: %0x",
|
||||
actual, check, k)
|
||||
return
|
||||
}
|
||||
hsec = hex.EncodeToString(k)
|
||||
if nsec, err = bech32encoding.HexToNsec(hsec); err != nil {
|
||||
err = fmt.Errorf("failed to encode nsec from hex form: %s", err)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
49
wordstr/wordstr_test.go
Normal file
49
wordstr/wordstr_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package wordstr_test
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ec.mleku.dev/v2/secp256k1"
|
||||
"github.com/Hubmakerlabs/replicatr/pkg/nostr/bech32encoding"
|
||||
"wordstr.mleku.dev/wordstr"
|
||||
)
|
||||
|
||||
func TestWordstr(t *testing.T) {
|
||||
var err error
|
||||
var sec *secp256k1.SecretKey
|
||||
var wordsx, wordsb, nsec, hsec, nsec2 string
|
||||
for _ = range 1000 {
|
||||
if sec, err = secp256k1.GenerateSecretKey(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skb := sec.Serialize()
|
||||
skh := hex.EncodeToString(skb)
|
||||
// hex
|
||||
if wordsx, err = wordstr.FromNsec(skh); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// bech32
|
||||
if nsec, err = bech32encoding.HexToNsec(skh); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wordsb, err = wordstr.FromNsec(nsec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wordsx != wordsb {
|
||||
t.Fatalf("words do not match hex %s vs nsec %s\n%s\n!=\n%s", skh, nsec, wordsx, wordsb)
|
||||
}
|
||||
split := strings.Split(wordsx, " ")
|
||||
places := wordstr.GetPlaces()
|
||||
if hsec, nsec2, err = wordstr.ToNsec(places, split); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nsec2 != nsec {
|
||||
t.Fatalf("did not recover same nsec\n%s\n!=\n%s", nsec, nsec2)
|
||||
}
|
||||
if hsec != skh {
|
||||
t.Fatalf("did not recover same hsec\n%s\n!=\n%s", skh, hsec)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user