working and tested

This commit is contained in:
2024-08-03 18:20:29 +01:00
parent 646c0cc8ca
commit 5c3f7f81d5
15 changed files with 18984 additions and 0 deletions

90
README.md Normal file
View 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
View 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
View 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=

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2071
wordlists/czech.go Normal file

File diff suppressed because it is too large Load Diff

2071
wordlists/english.go Normal file

File diff suppressed because it is too large Load Diff

2071
wordlists/french.go Normal file

File diff suppressed because it is too large Load Diff

2071
wordlists/italian.go Normal file

File diff suppressed because it is too large Load Diff

2071
wordlists/japanese.go Normal file

File diff suppressed because it is too large Load Diff

2071
wordlists/korean.go Normal file

File diff suppressed because it is too large Load Diff

2071
wordlists/spanish.go Normal file

File diff suppressed because it is too large Load Diff

35
wordstr.go Normal file
View 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
View 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
View 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)
}
}
}