diff --git a/README.md b/README.md index 4903928..57b39bd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ identities and child mnemonics, each of which can be recreated from that mnemonic at any time. The same mnemonic, key type and index always give the same key. -It uses the BIP-85 entropy deriver from `git.eeqj.de/sneak/secret/pkg/bip85` and +It uses the BIP-85 entropy deriver from `sneak.berlin/go/secret/pkg/bip85` and takes the same steps as that repository's `agehd` package. Commands are grouped by what is derived: `keyfunc ssh ...` for ed25519 SSH keys, @@ -100,7 +100,9 @@ m/83696968'/'/' to one tool. Each key type's number is spelled the way BIP-85 spells its own RSA application (`828365` is the ASCII codes of `R`, `S`, `A` written out): SSH is `838372` (`S` `S` `H`), age is `657169` (`A` `G` `E`). -- `n` is the key index: flag `--index` / `-n`, default `0`. +- `n` is the key index, a decimal number: flag `--index` / `-n`, default `0`. A + leading zero is still decimal (`-n 010` is index 10); a base prefix, an + underscore or a sign (`0x10`, `1_0`, `+1`) is refused. ## Giving it the mnemonic diff --git a/go.mod b/go.mod index 107494e..8a4b92a 100644 --- a/go.mod +++ b/go.mod @@ -4,13 +4,13 @@ go 1.26.0 require ( filippo.io/age v1.3.2 - git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd github.com/btcsuite/btcd v0.25.0 github.com/btcsuite/btcd/btcutil v1.2.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.12.1 golang.org/x/crypto v0.57.0 golang.org/x/term v0.46.0 + sneak.berlin/go/secret v0.0.0-20261007105610-ef0ae90768c8 ) require ( diff --git a/go.sum b/go.sum index 5c2cbff..a6b1fa6 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c= filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk= filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= -git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd h1:6YFV6horz2wDFPWWhour8qx8gLGyO0qoplwEeOuQ2J4= -git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd/go.mod h1:gKCcMZvlBOqusn/BxR8IyFmSJQr6R4vvjJ926iNpOSI= github.com/btcsuite/btcd v0.25.0 h1:JPbjwvHGpSywBRuorFFqTjaVP4y6Qw69XJ1nQ6MyWJM= github.com/btcsuite/btcd v0.25.0/go.mod h1:qbPE+pEiR9643E1s1xu57awsRhlCIm1ZIi6FfeRA4KE= github.com/btcsuite/btcd/btcec/v2 v2.5.0 h1:KioMXOWa76b86sTZZOmbzv/ldaQCmB8KFAyn5PbB8E8= @@ -30,8 +28,6 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= -github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= @@ -42,3 +38,5 @@ golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +sneak.berlin/go/secret v0.0.0-20261007105610-ef0ae90768c8 h1:Bd2bUsRCN8xhcrpH3LpJqHxR2QffeOuF5z76iP4BFnw= +sneak.berlin/go/secret v0.0.0-20261007105610-ef0ae90768c8/go.mod h1:uVx9ZuE7ZMIOBY2B1ElXCs97CTsEfuham3NQRdLj5lI= diff --git a/internal/childmnemonic/childmnemonic.go b/internal/childmnemonic/childmnemonic.go index c4d6b89..7b6c7d0 100644 --- a/internal/childmnemonic/childmnemonic.go +++ b/internal/childmnemonic/childmnemonic.go @@ -5,10 +5,10 @@ import ( "errors" "fmt" - "git.eeqj.de/sneak/secret/pkg/bip85" "github.com/btcsuite/btcd/btcutil/hdkeychain" "sneak.berlin/go/keyfunc/internal/bip39" "sneak.berlin/go/keyfunc/internal/derive" + "sneak.berlin/go/secret/pkg/bip85" ) // english is the number BIP-85 gives the English word list. diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 0b29adc..8113091 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -2,6 +2,7 @@ package cli_test import ( "bytes" + "strconv" "strings" "testing" @@ -106,6 +107,56 @@ func TestAnIndexWithNoHardenedChildIsRefused(t *testing.T) { require.Empty(t, out) } +func TestAnIndexWithALeadingZeroIsDecimal(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + line := run(t, "ssh", "pub", "-n", "010") + require.Equal(t, run(t, "ssh", "pub", "-n", "10"), line) + require.True(t, + strings.HasSuffix(strings.TrimSpace(line), " keyfunc/ssh/10"), + ) +} + +// The mnemonic command in these runs fails, so a refusal naming the +// flag shows that the number was refused before the mnemonic was +// looked for. +func TestAnIndexThatIsNotDecimalDigitsIsRefused(t *testing.T) { + t.Parallel() + + for _, index := range []string{"0x10", "1_0", "+1", "-1", ""} { + t.Run(index, func(t *testing.T) { + t.Parallel() + + out, err := execute(t, + "ssh", "pub", "-n", index, "--mnemonic-command", "exit 1", + ) + require.ErrorIs(t, err, strconv.ErrSyntax) + require.ErrorContains(t, err, "--index") + require.Empty(t, out) + }) + } +} + +// Neither a command group nor --version reads the index, so these +// show that it is refused while the flags are parsed. +func TestAnIndexNotInDecimalIsRefusedBeforeAnyCommandRuns(t *testing.T) { + t.Parallel() + + for _, args := range [][]string{ + {"ssh", "-n", "0x10"}, + {"-n", "0x10", "--version"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + t.Parallel() + + out, err := execute(t, args...) + require.ErrorIs(t, err, strconv.ErrSyntax) + require.ErrorContains(t, err, "--index") + require.Empty(t, out) + }) + } +} + func TestTheMnemonicCommandIsUsed(t *testing.T) { t.Setenv(mnemonic.Variable, "") @@ -138,6 +189,22 @@ func TestALengthTheToolDoesNotOfferIsRefused(t *testing.T) { require.Empty(t, out) } +func TestTheWordCountIsDecimal(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + words := strings.Fields(run(t, "mnemonic", "--words", "012")) + require.Len(t, words, twelve) + + // As for the index, the failing mnemonic command shows that the + // number was refused first. + out, err := execute(t, + "mnemonic", "--words", "0x18", "--mnemonic-command", "exit 1", + ) + require.ErrorIs(t, err, strconv.ErrSyntax) + require.ErrorContains(t, err, "--words") + require.Empty(t, out) +} + // run executes the tool with the given arguments and returns what it // wrote to standard output. func run(t *testing.T, args ...string) string { diff --git a/internal/cli/mnemonic/mnemonic.go b/internal/cli/mnemonic/mnemonic.go index 8c33332..05fdcea 100644 --- a/internal/cli/mnemonic/mnemonic.go +++ b/internal/cli/mnemonic/mnemonic.go @@ -31,8 +31,9 @@ func Command() *cobra.Command { }, } - cmd.Flags().Uint32( - "words", childmnemonic.DefaultWords, + words := options.Decimal(childmnemonic.DefaultWords) + cmd.Flags().Var( + &words, "words", "how long the child mnemonic is: 12, 18 or 24 words", ) diff --git a/internal/cli/options/options.go b/internal/cli/options/options.go index 5d454ec..c008541 100644 --- a/internal/cli/options/options.go +++ b/internal/cli/options/options.go @@ -4,6 +4,7 @@ package options import ( "fmt" + "strconv" "github.com/spf13/cobra" "sneak.berlin/go/keyfunc/internal/mnemonic" @@ -16,8 +17,8 @@ func Add(cmd *cobra.Command) { "mnemonic-command", "", "shell command whose output is the mnemonic", ) - cmd.PersistentFlags().Uint32P( - "index", "n", 0, + cmd.PersistentFlags().VarP( + new(Decimal), "index", "n", "which key to derive", ) } @@ -41,3 +42,33 @@ func Mnemonic(cmd *cobra.Command) (string, error) { return mnemonic.Read(cmd.Context(), command) } + +// Decimal is a flag value for a number written in decimal digits only. +// The flag library's own uint32 flags read 010 as octal 8 and also +// take 0x10 or 1_0. +type Decimal uint32 + +// String returns the number in decimal digits. +func (d *Decimal) String() string { + return strconv.FormatUint(uint64(*d), 10) +} + +// Set reads text as a decimal number that fits in 32 bits: a leading +// zero is still decimal, and a base prefix, an underscore or a sign is +// refused. +func (d *Decimal) Set(text string) error { + number, err := strconv.ParseUint(text, 10, 32) + if err != nil { + return err + } + + *d = Decimal(number) + + return nil +} + +// Type is what --help shows as the flag's type, and what GetUint32 +// checks before it reads the value back. +func (d *Decimal) Type() string { + return "uint32" +} diff --git a/internal/derive/derive.go b/internal/derive/derive.go index cc98fa3..55b944b 100644 --- a/internal/derive/derive.go +++ b/internal/derive/derive.go @@ -5,10 +5,10 @@ import ( "errors" "fmt" - "git.eeqj.de/sneak/secret/pkg/bip85" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" "sneak.berlin/go/keyfunc/internal/bip39" + "sneak.berlin/go/secret/pkg/bip85" ) const (