From 9a84e437b17f87ba2fc2f9c2c1f14afe74841204 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Thu, 26 Dec 2019 13:16:51 +0100 Subject: [PATCH] all: switch key format to Bech32 Use the BIP173 format, which is whole-word selectable, markup safe, and case insensitive. AGE-SECRET-KEY-1FPSHVEFQXYSX5MMFDE6ZCGRTV4JHQGRFWSS8WETVDSSX76TVV4JQU272CR See https://groups.google.com/d/msg/age-dev/UAjkvLoCr9I/l4Q1h3OPAgAJ. All bech32 Go packages have funky APIs, internal types, or case handling, so include a heavily refactored version of the reference implementation, and the tests from github.com/btcsuite/btcutil/bech32. --- cmd/age-keygen/keygen.go | 2 +- cmd/age/parse.go | 2 +- internal/age/recipients_test.go | 11 ++ internal/age/x25519.go | 33 +++--- internal/bech32/bech32.go | 179 ++++++++++++++++++++++++++++++++ internal/bech32/bech32_test.go | 94 +++++++++++++++++ 6 files changed, 303 insertions(+), 18 deletions(-) create mode 100644 internal/bech32/bech32.go create mode 100644 internal/bech32/bech32_test.go diff --git a/cmd/age-keygen/keygen.go b/cmd/age-keygen/keygen.go index c1e409d..d0b4e20 100644 --- a/cmd/age-keygen/keygen.go +++ b/cmd/age-keygen/keygen.go @@ -53,6 +53,6 @@ func generate(out io.Writer) { } fmt.Fprintf(out, "# created: %s\n", time.Now().Format(time.RFC3339)) - fmt.Fprintf(out, "# %s\n", k.Recipient()) + fmt.Fprintf(out, "# public key: %s\n", k.Recipient()) fmt.Fprintf(out, "%s\n", k) } diff --git a/cmd/age/parse.go b/cmd/age/parse.go index bfbcb1c..4827000 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -21,7 +21,7 @@ import ( func parseRecipient(arg string) (age.Recipient, error) { switch { - case strings.HasPrefix(arg, "pubkey:"): + case strings.HasPrefix(arg, "age1"): return age.ParseX25519Recipient(arg) case strings.HasPrefix(arg, "ssh-"): return age.ParseSSHRecipient(arg) diff --git a/internal/age/recipients_test.go b/internal/age/recipients_test.go index e8f796f..6d58cc3 100644 --- a/internal/age/recipients_test.go +++ b/internal/age/recipients_test.go @@ -38,6 +38,17 @@ func TestX25519RoundTrip(t *testing.T) { t.Errorf("invalid Type values: %v, %v", r.Type(), i.Type()) } + if r1, err := age.ParseX25519Recipient(r.String()); err != nil { + t.Fatal(err) + } else if r1.String() != r.String() { + t.Errorf("recipient did not round-trip through parsing: got %q, want %q", r1, r) + } + if i1, err := age.ParseX25519Identity(i.String()); err != nil { + t.Fatal(err) + } else if i1.String() != i.String() { + t.Errorf("identity did not round-trip through parsing: got %q, want %q", i1, i) + } + fileKey := make([]byte, 16) if _, err := rand.Read(fileKey); err != nil { t.Fatal(err) diff --git a/internal/age/x25519.go b/internal/age/x25519.go index 867462e..c3751b3 100644 --- a/internal/age/x25519.go +++ b/internal/age/x25519.go @@ -14,6 +14,7 @@ import ( "io" "strings" + "filippo.io/age/internal/bech32" "filippo.io/age/internal/format" "golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/curve25519" @@ -42,17 +43,16 @@ func NewX25519Recipient(publicKey []byte) (*X25519Recipient, error) { } func ParseX25519Recipient(s string) (*X25519Recipient, error) { - if !strings.HasPrefix(s, "pubkey:") { - return nil, fmt.Errorf("malformed recipient: %s", s) - } - pubKey := strings.TrimPrefix(s, "pubkey:") - k, err := format.DecodeString(pubKey) + t, k, err := bech32.Decode(s) if err != nil { - return nil, fmt.Errorf("malformed recipient: %s", s) + return nil, fmt.Errorf("malformed recipient %q: %v", s, err) + } + if t != "age" { + return nil, fmt.Errorf("malformed recipient %q: invalid type %q", s, t) } r, err := NewX25519Recipient(k) if err != nil { - return nil, fmt.Errorf("malformed recipient: %s", s) + return nil, fmt.Errorf("malformed recipient %q: %v", s, err) } return r, nil } @@ -96,7 +96,8 @@ func (r *X25519Recipient) Wrap(fileKey []byte) (*format.Recipient, error) { } func (r *X25519Recipient) String() string { - return "pubkey:" + format.EncodeToString(r.theirPublicKey) + s, _ := bech32.Encode("age", r.theirPublicKey) + return s } type X25519Identity struct { @@ -128,17 +129,16 @@ func GenerateX25519Identity() (*X25519Identity, error) { } func ParseX25519Identity(s string) (*X25519Identity, error) { - if !strings.HasPrefix(s, "AGE_SECRET_KEY_") { - return nil, fmt.Errorf("malformed secret key: %s", s) - } - privKey := strings.TrimPrefix(s, "AGE_SECRET_KEY_") - k, err := format.DecodeString(privKey) + t, k, err := bech32.Decode(s) if err != nil { - return nil, fmt.Errorf("malformed secret key: %s", s) + return nil, fmt.Errorf("malformed secret key %q: %v", s, err) + } + if t != "AGE-SECRET-KEY-" { + return nil, fmt.Errorf("malformed secret key %q: invalid type %q", s, t) } r, err := NewX25519Identity(k) if err != nil { - return nil, fmt.Errorf("malformed secret key: %s", s) + return nil, fmt.Errorf("malformed secret key %q: %v", s, err) } return r, nil } @@ -186,5 +186,6 @@ func (i *X25519Identity) Recipient() *X25519Recipient { } func (i *X25519Identity) String() string { - return "AGE_SECRET_KEY_" + format.EncodeToString(i.secretKey) + s, _ := bech32.Encode("AGE-SECRET-KEY-", i.secretKey) + return strings.ToUpper(s) } diff --git a/internal/bech32/bech32.go b/internal/bech32/bech32.go new file mode 100644 index 0000000..29310d4 --- /dev/null +++ b/internal/bech32/bech32.go @@ -0,0 +1,179 @@ +// Copyright (c) 2017 Takatoshi Nakagawa +// Copyright (c) 2019 Google LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// Package bech32 is a modified version of the reference implementation of BIP173. +package bech32 + +import ( + "fmt" + "strings" +) + +var charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + +var generator = []uint32{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3} + +func polymod(values []byte) uint32 { + chk := uint32(1) + for _, v := range values { + top := chk >> 25 + chk = (chk & 0x1ffffff) << 5 + chk = chk ^ uint32(v) + for i := 0; i < 5; i++ { + bit := top >> i & 1 + if bit == 1 { + chk ^= generator[i] + } + } + } + return chk +} + +func hrpExpand(hrp string) []byte { + h := []byte(strings.ToLower(hrp)) + var ret []byte + for _, c := range h { + ret = append(ret, c>>5) + } + ret = append(ret, 0) + for _, c := range h { + ret = append(ret, c&31) + } + return ret +} + +func verifyChecksum(hrp string, data []byte) bool { + return polymod(append(hrpExpand(hrp), data...)) == 1 +} + +func createChecksum(hrp string, data []byte) []byte { + values := append(hrpExpand(hrp), data...) + values = append(values, []byte{0, 0, 0, 0, 0, 0}...) + mod := polymod(values) ^ 1 + ret := make([]byte, 6) + for p := range ret { + shift := 5 * (5 - p) + ret[p] = byte(mod>>shift) & 31 + } + return ret +} + +func convertBits(data []byte, frombits, tobits byte, pad bool) ([]byte, error) { + var ret []byte + acc := uint32(0) + bits := byte(0) + maxv := byte(1<>frombits != 0 { + return nil, fmt.Errorf("invalid data range: data[%d]=%d (frombits=%d)", idx, value, frombits) + } + acc = acc<= tobits { + bits -= tobits + ret = append(ret, byte(acc>>bits)&maxv) + } + } + if pad { + if bits > 0 { + ret = append(ret, byte(acc<<(tobits-bits))&maxv) + } + } else if bits >= frombits { + return nil, fmt.Errorf("illegal zero padding") + } else if byte(acc<<(tobits-bits))&maxv != 0 { + return nil, fmt.Errorf("non-zero padding") + } + return ret, nil +} + +// Encode encodes the HRP and a bytes slice to Bech32. If the HRP is uppercase, +// the output will be uppercase. +func Encode(hrp string, data []byte) (string, error) { + values, err := convertBits(data, 8, 5, true) + if err != nil { + return "", err + } + if len(hrp)+len(values)+7 > 90 { + return "", fmt.Errorf("too long: hrp length=%d, data length=%d", len(hrp), len(values)) + } + if len(hrp) < 1 { + return "", fmt.Errorf("invalid HRP: %q", hrp) + } + for p, c := range hrp { + if c < 33 || c > 126 { + return "", fmt.Errorf("invalid HRP character: hrp[%d]=%d", p, c) + } + } + if strings.ToUpper(hrp) != hrp && strings.ToLower(hrp) != hrp { + return "", fmt.Errorf("mixed case HRP: %q", hrp) + } + lower := strings.ToLower(hrp) == hrp + hrp = strings.ToLower(hrp) + var ret strings.Builder + ret.WriteString(hrp) + ret.WriteString("1") + for _, p := range values { + ret.WriteByte(charset[p]) + } + for _, p := range createChecksum(hrp, values) { + ret.WriteByte(charset[p]) + } + if lower { + return ret.String(), nil + } + return strings.ToUpper(ret.String()), nil +} + +// Decode decodes a Bech32 string. If the string is uppercase, the HRP will be uppercase. +func Decode(s string) (hrp string, data []byte, err error) { + if len(s) > 90 { + return "", nil, fmt.Errorf("too long: len=%d", len(s)) + } + if strings.ToLower(s) != s && strings.ToUpper(s) != s { + return "", nil, fmt.Errorf("mixed case") + } + pos := strings.LastIndex(s, "1") + if pos < 1 || pos+7 > len(s) { + return "", nil, fmt.Errorf("separator '1' at invalid position: pos=%d, len=%d", pos, len(s)) + } + hrp = s[:pos] + for p, c := range hrp { + if c < 33 || c > 126 { + return "", nil, fmt.Errorf("invalid character human-readable part: s[%d]=%d", p, c) + } + } + s = strings.ToLower(s) + for p, c := range s[pos+1:] { + d := strings.IndexRune(charset, c) + if d == -1 { + return "", nil, fmt.Errorf("invalid character data part: s[%d]=%v", p, c) + } + data = append(data, byte(d)) + } + if !verifyChecksum(hrp, data) { + return "", nil, fmt.Errorf("invalid checksum") + } + data, err = convertBits(data[:len(data)-6], 5, 8, false) + if err != nil { + return "", nil, err + } + return hrp, data, nil +} diff --git a/internal/bech32/bech32_test.go b/internal/bech32/bech32_test.go new file mode 100644 index 0000000..61e719a --- /dev/null +++ b/internal/bech32/bech32_test.go @@ -0,0 +1,94 @@ +// Copyright (c) 2013-2017 The btcsuite developers +// Copyright (c) 2016-2017 The Lightning Network Developers +// Copyright (c) 2019 Google LLC +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +package bech32_test + +import ( + "strings" + "testing" + + "filippo.io/age/internal/bech32" +) + +func TestBech32(t *testing.T) { + tests := []struct { + str string + valid bool + }{ + {"A12UEL5L", true}, + {"a12uel5l", true}, + {"an83characterlonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1tt5tgs", true}, + {"abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw", true}, + {"11qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc8247j", true}, + {"split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", true}, + + // invalid checksum + {"split1checkupstagehandshakeupstreamerranterredcaperred2y9e2w", false}, + // invalid character (space) in hrp + {"s lit1checkupstagehandshakeupstreamerranterredcaperredp8hs2p", false}, + {"split1cheo2y9e2w", false}, // invalid character (o) in data part + {"split1a2y9w", false}, // too short data part + {"1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", false}, // empty hrp + // invalid character (DEL) in hrp + {"spl" + string(127) + "t1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", false}, + // too long + {"11qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc8247j", false}, + + // BIP 173 invalid vectors. + {"an84characterslonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1569pvx", false}, + {"pzry9x0s0muk", false}, + {"1pzry9x0s0muk", false}, + {"x1b4n0q5v", false}, + {"li1dgmt3", false}, + {"de1lg7wt\xff", false}, + {"A1G7SGD8", false}, + {"10a06t8", false}, + {"1qzzfhee", false}, + } + + for _, test := range tests { + str := test.str + hrp, decoded, err := bech32.Decode(str) + if !test.valid { + // Invalid string decoding should result in error. + if err == nil { + t.Errorf("expected decoding to fail for invalid string %v", test.str) + } + continue + } + + // Valid string decoding should result in no error. + if err != nil { + t.Errorf("expected string to be valid bech32: %v", err) + } + + // Check that it encodes to the same string. + encoded, err := bech32.Encode(hrp, decoded) + if err != nil { + t.Errorf("encoding failed: %v", err) + } + if encoded != str { + t.Errorf("expected data to encode to %v, but got %v", str, encoded) + } + + // Flip a bit in the string an make sure it is caught. + pos := strings.LastIndexAny(str, "1") + flipped := str[:pos+1] + string((str[pos+1] ^ 1)) + str[pos+2:] + if _, _, err = bech32.Decode(flipped); err == nil { + t.Error("expected decoding to fail") + } + } +}