From ddd506e7e268ec36627dad0a35e6c52f5a9c14f5 Mon Sep 17 00:00:00 2001 From: Andreas Auernhammer Date: Thu, 16 May 2024 15:34:21 +0200 Subject: [PATCH] add support for passwordless private keys This commit adds support for private keys that are not protected by a password. The `PrivateKey` type now supports text (un)marshaling to en/decode a non-encrypted text representation. The `minisign` command now accepts a `-W` flag for generating a private key without requiring a password. --- cmd/minisign/minisign.go | 76 ++++++++----- example_test.go | 5 +- internal/testdata/minisign_unencrypted.key | 2 + minisign_test.go | 5 +- private.go | 117 +++++++++++++++++++-- private_test.go | 26 +++++ public.go | 4 +- signature.go | 4 +- 8 files changed, 190 insertions(+), 49 deletions(-) create mode 100644 internal/testdata/minisign_unencrypted.key create mode 100644 private_test.go diff --git a/cmd/minisign/minisign.go b/cmd/minisign/minisign.go index e6ae83c..415d9ff 100644 --- a/cmd/minisign/minisign.go +++ b/cmd/minisign/minisign.go @@ -23,7 +23,7 @@ import ( ) const usage = `Usage: - minisign -G [-p ] [-s ] + minisign -G [-p ] [-s ] [-W] minisign -S [-x ] [-s ] [-c ] [-t ] -m ... minisign -V [-H] [-x ] [-p | -P ] [-o] [-q | -Q ] -m minisign -R [-s ] [-p ] @@ -38,6 +38,7 @@ Options: -p Public key file (default: ./minisign.pub) -P Public key as base64 string -s Secret key file (default: $HOME/.minisign/minisign.key) + -W Do not encrypt/decrypt the secret key with a password. -x Signature file (default: .minisig) -c Add a one-line untrusted comment. -t Add a one-line trusted comment. @@ -66,6 +67,7 @@ func main() { pubKeyFileFlag string pubKeyFlag string secKeyFileFlag string + unencryptedKeyFlag bool signatureFlag string untrustedCommentFlag string trustedCommentFlag string @@ -84,6 +86,7 @@ func main() { flag.StringVar(&pubKeyFileFlag, "p", "minisign.pub", "Public key file (default: minisign.pub") flag.StringVar(&pubKeyFlag, "P", "", "Public key as base64 string") flag.StringVar(&secKeyFileFlag, "s", filepath.Join(os.Getenv("HOME"), ".minisign/minisign.key"), "Secret key file (default: $HOME/.minisign/minisign.key") + flag.BoolVar(&unencryptedKeyFlag, "W", false, "Do not encrypt/decrypt the secret key with a password") flag.StringVar(&signatureFlag, "x", "", "Signature file (default: .minisig)") flag.StringVar(&untrustedCommentFlag, "c", "", "Add a one-line untrusted comment") flag.StringVar(&trustedCommentFlag, "t", "", "Add a one-line trusted comment") @@ -102,7 +105,7 @@ func main() { switch { case keyGenFlag: - generateKeyPair(secKeyFileFlag, pubKeyFileFlag, forceFlag) + generateKeyPair(secKeyFileFlag, pubKeyFileFlag, forceFlag, unencryptedKeyFlag) case signFlag: signFiles(secKeyFileFlag, signatureFlag, untrustedCommentFlag, trustedCommentFlag, filesFlag...) case verifyFlag: @@ -115,7 +118,7 @@ func main() { } } -func generateKeyPair(secKeyFile, pubKeyFile string, force bool) { +func generateKeyPair(secKeyFile, pubKeyFile string, force, unencrypted bool) { if !force { _, err := os.Stat(secKeyFile) if err == nil { @@ -145,29 +148,38 @@ func generateKeyPair(secKeyFile, pubKeyFile string, force bool) { } } - var password string - if term.IsTerminal(int(os.Stdin.Fd())) { - fmt.Print("Please enter a password to protect the secret key.\n\n") - password = readPassword(os.Stdin, "Enter Password: ") - passwordAgain := readPassword(os.Stdin, "Enter Password (one more time): ") - if password != passwordAgain { - log.Fatal("Error: passwords don't match") - } - } else { - password = readPassword(os.Stdin, "Enter Password: ") - } publicKey, privateKey, err := minisign.GenerateKey(rand.Reader) if err != nil { log.Fatalf("Error: %v", err) } - fmt.Print("Deriving a key from the password in order to encrypt the secret key... ") - encryptedPrivateKey, err := minisign.EncryptKey(password, privateKey) - if err != nil { - fmt.Println() - log.Fatalf("Error: %v", err) + var privateKeyBytes []byte + if unencrypted { + privateKeyBytes, err = privateKey.MarshalText() + if err != nil { + log.Fatalf("Error: %v", err) + } + } else { + var password string + if term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Print("Please enter a password to protect the secret key.\n\n") + password = readPassword(os.Stdin, "Enter Password: ") + passwordAgain := readPassword(os.Stdin, "Enter Password (one more time): ") + if password != passwordAgain { + log.Fatal("Error: passwords don't match") + } + } else { + password = readPassword(os.Stdin, "Enter Password: ") + } + + fmt.Print("Deriving a key from the password in order to encrypt the secret key... ") + privateKeyBytes, err = minisign.EncryptKey(password, privateKey) + if err != nil { + fmt.Println() + log.Fatalf("Error: %v", err) + } + fmt.Print("done\n\n") } - fmt.Print("done\n\n") fileFlags := os.O_CREATE | os.O_WRONLY | os.O_TRUNC if !force { @@ -178,7 +190,7 @@ func generateKeyPair(secKeyFile, pubKeyFile string, force bool) { log.Fatalf("Error: %v", err) } defer skFile.Close() - if _, err = skFile.Write(encryptedPrivateKey); err != nil { + if _, err = skFile.Write(privateKeyBytes); err != nil { log.Fatalf("Error: %v", err) } @@ -218,19 +230,25 @@ func signFiles(secKeyFile, sigFile, untrustedComment, trustedComment string, fil } } - encryptedPrivateKey, err := os.ReadFile(secKeyFile) + privateKeyBytes, err := os.ReadFile(secKeyFile) if err != nil { log.Fatalf("Error: %v", err) } - password := readPassword(os.Stdin, "Enter password: ") - fmt.Print("Deriving a key from the password in order to decrypt the secret key... ") - privateKey, err := minisign.DecryptKey(password, encryptedPrivateKey) - if err != nil { - fmt.Println() - log.Fatalf("Error: invalid password: %v", err) + var privateKey minisign.PrivateKey + if minisign.IsEncrypted(privateKeyBytes) { + password := readPassword(os.Stdin, "Enter password: ") + + fmt.Print("Deriving a key from the password in order to decrypt the secret key... ") + privateKey, err = minisign.DecryptKey(password, privateKeyBytes) + if err != nil { + fmt.Println() + log.Fatalf("Error: invalid password: %v", err) + } + fmt.Print("done\n\n") + } else if err = privateKey.UnmarshalText(privateKeyBytes); err != nil { + log.Fatalf("Error: %v", err) } - fmt.Print("done\n\n") if sigFile != "" { if dir := filepath.Dir(sigFile); dir != "" && dir != "." && dir != "/" { diff --git a/example_test.go b/example_test.go index 9810900..06ece9c 100644 --- a/example_test.go +++ b/example_test.go @@ -8,7 +8,6 @@ import ( "crypto/rand" "fmt" "io" - "io/ioutil" "strconv" "strings" @@ -160,7 +159,7 @@ func ExampleReader() { // Sign a data stream after processing it. (Here, we just discard it) reader := minisign.NewReader(strings.NewReader(Message)) - if _, err := io.Copy(ioutil.Discard, reader); err != nil { + if _, err := io.Copy(io.Discard, reader); err != nil { panic(err) // TODO: error handling } signature := reader.Sign(privateKey) @@ -168,7 +167,7 @@ func ExampleReader() { // Read a data stream and then verify its authenticity with // the public key. reader = minisign.NewReader(strings.NewReader(Message)) - message, err := ioutil.ReadAll(reader) + message, err := io.ReadAll(reader) if err != nil { panic(err) // TODO: error handling } diff --git a/internal/testdata/minisign_unencrypted.key b/internal/testdata/minisign_unencrypted.key new file mode 100644 index 0000000..d1fcc14 --- /dev/null +++ b/internal/testdata/minisign_unencrypted.key @@ -0,0 +1,2 @@ +untrusted comment: minisign encrypted secret key +RWQAAEIyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbuUYgQpHKDcmmMQj9cgqohWX321PrXUDFfCVWOXDZp8kLw2/qju66KnI28LcOaA7ZywNP5vDVtlHeyzit3lxeqirS5+2UImrAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= diff --git a/minisign_test.go b/minisign_test.go index e180132..e266c42 100644 --- a/minisign_test.go +++ b/minisign_test.go @@ -6,7 +6,6 @@ package minisign import ( "io" - "io/ioutil" "os" "testing" ) @@ -18,7 +17,7 @@ func TestRoundtrip(t *testing.T) { t.Fatalf("Failed to load private key: %v", err) } - message, err := ioutil.ReadFile("./internal/testdata/message.txt") + message, err := os.ReadFile("./internal/testdata/message.txt") if err != nil { t.Fatalf("Failed to load message: %v", err) } @@ -48,7 +47,7 @@ func TestReaderRoundtrip(t *testing.T) { defer file.Close() reader := NewReader(file) - if _, err = io.Copy(ioutil.Discard, reader); err != nil { + if _, err = io.Copy(io.Discard, reader); err != nil { t.Fatalf("Failed to read message: %v", err) } signature := reader.Sign(privateKey) diff --git a/private.go b/private.go index ec1fe63..d439fa1 100644 --- a/private.go +++ b/private.go @@ -5,6 +5,7 @@ package minisign import ( + "bytes" "crypto" "crypto/ed25519" "crypto/rand" @@ -12,8 +13,9 @@ import ( "encoding/base64" "encoding/binary" "errors" + "fmt" "io" - "io/ioutil" + "os" "strconv" "strings" "time" @@ -25,7 +27,7 @@ import ( // PrivateKeyFromFile reads and decrypts the private key // file with the given password. func PrivateKeyFromFile(password, path string) (PrivateKey, error) { - bytes, err := ioutil.ReadFile(path) + bytes, err := os.ReadFile(path) if err != nil { return PrivateKey{}, err } @@ -34,8 +36,7 @@ func PrivateKeyFromFile(password, path string) (PrivateKey, error) { // PrivateKey is a minisign private key. // -// A private key can sign messages to prove the -// their origin and authenticity. +// A private key can sign messages to prove their origin and authenticity. // // PrivateKey implements the crypto.Signer interface. type PrivateKey struct { @@ -101,9 +102,89 @@ func (p PrivateKey) Equal(x crypto.PrivateKey) bool { return p.id == xx.id && subtle.ConstantTimeCompare(p.bytes[:], xx.bytes[:]) == 1 } +// MarshalText returns a textual representation of the private key. +// +// For password-protected private keys refer to [EncryptKey]. +func (p PrivateKey) MarshalText() ([]byte, error) { + var b [privateKeySize]byte + + binary.LittleEndian.PutUint16(b[:], EdDSA) + binary.LittleEndian.PutUint16(b[2:], algorithmNone) + binary.LittleEndian.PutUint16(b[4:], algorithmBlake2b) + + binary.LittleEndian.PutUint64(b[54:], p.id) + copy(b[62:], p.bytes[:]) + + const comment = "untrusted comment: minisign encrypted secret key\n" + encodedBytes := make([]byte, len(comment)+base64.StdEncoding.EncodedLen(len(b))) + copy(encodedBytes, []byte(comment)) + base64.StdEncoding.Encode(encodedBytes[len(comment):], b[:]) + return encodedBytes, nil +} + +// UnmarshalText decodes a textual representation of the private key into p. +// +// It returns an error if the private key is encrypted. For decrypting +// password-protected private keys refer to [DecryptKey]. +func (p *PrivateKey) UnmarshalText(text []byte) error { + text = trimUntrustedComment(text) + b := make([]byte, base64.StdEncoding.DecodedLen(len(text))) + n, err := base64.StdEncoding.Decode(b, text) + if err != nil { + return fmt.Errorf("minisign: invalid private key: %v", err) + } + b = b[:n] + + if len(b) != privateKeySize { + return errors.New("minisign: invalid private key") + } + + var ( + empty [32]byte + + kType = binary.LittleEndian.Uint16(b) + kdf = binary.LittleEndian.Uint16(b[2:]) + hType = binary.LittleEndian.Uint16(b[4:]) + salt = b[6:38] + scryptOps = binary.LittleEndian.Uint64(b[38:]) + scryptMem = binary.LittleEndian.Uint64(b[46:]) + key = b[54:126] + checksum = b[126:privateKeySize] + ) + if kType != EdDSA { + return fmt.Errorf("minisign: invalid private key: invalid key type '%d'", kType) + } + if kdf == algorithmScrypt { + return errors.New("minisign: private key is encrypted") + } + if kdf != algorithmNone { + return fmt.Errorf("minisign: invalid private key: invalid KDF '%d'", kdf) + } + if hType != algorithmBlake2b { + return fmt.Errorf("minisign: invalid private key: invalid hash type '%d'", hType) + } + if !bytes.Equal(salt[:], empty[:]) { + return errors.New("minisign: invalid private key: salt is not empty") + } + if scryptOps != 0 { + return errors.New("minisign: invalid private key: scrypt cost parameter is not zero") + } + if scryptMem != 0 { + return errors.New("minisign: invalid private key: scrypt mem parameter is not zero") + } + if !bytes.Equal(checksum, empty[:]) { + return errors.New("minisign: invalid private key: salt is not empty") + } + + p.id = binary.LittleEndian.Uint64(key[:8]) + copy(p.bytes[:], key[8:]) + return nil +} + const ( - scryptAlgorithm = 0x6353 // hex value for "Sc" - blake2bAlgorithm = 0x3242 // hex value for "B2" + algorithmNone = 0x0000 // hex value for KDF when key is not encrypted + algorithmScrypt = 0x6353 // hex value for "Sc" + algorithmBlake2b = 0x3242 // hex value for "B2" scryptOpsLimit = 0x2000000 // max. Scrypt ops limit based on libsodium scryptMemLimit = 0x40000000 // max. Scrypt mem limit based on libsodium @@ -125,8 +206,8 @@ func EncryptKey(password string, privateKey PrivateKey) ([]byte, error) { var bytes [privateKeySize]byte binary.LittleEndian.PutUint16(bytes[0:], EdDSA) - binary.LittleEndian.PutUint16(bytes[2:], scryptAlgorithm) - binary.LittleEndian.PutUint16(bytes[4:], blake2bAlgorithm) + binary.LittleEndian.PutUint16(bytes[2:], algorithmScrypt) + binary.LittleEndian.PutUint16(bytes[4:], algorithmBlake2b) const ( // TODO(aead): Callers may want to customize the cost parameters defaultOps = 33554432 // libsodium OPS_LIMIT_SENSITIVE @@ -144,6 +225,22 @@ func EncryptKey(password string, privateKey PrivateKey) ([]byte, error) { return encodedBytes, nil } +// IsEncrypted reports whether the private key is encrypted. +func IsEncrypted(privateKey []byte) bool { + privateKey = trimUntrustedComment(privateKey) + bytes := make([]byte, base64.StdEncoding.DecodedLen(len(privateKey))) + n, err := base64.StdEncoding.Decode(bytes, privateKey) + if err != nil { + return false + } + bytes = bytes[:n] + + if len(bytes) != privateKeySize { + return false + } + return binary.LittleEndian.Uint16(bytes[2:4]) == algorithmScrypt +} + var errDecrypt = errors.New("minisign: decryption failed") // DecryptKey tries to decrypt the encrypted private key with @@ -163,10 +260,10 @@ func DecryptKey(password string, privateKey []byte) (PrivateKey, error) { if a := binary.LittleEndian.Uint16(bytes[:2]); a != EdDSA { return PrivateKey{}, errDecrypt } - if a := binary.LittleEndian.Uint16(bytes[2:4]); a != scryptAlgorithm { + if a := binary.LittleEndian.Uint16(bytes[2:4]); a != algorithmScrypt { return PrivateKey{}, errDecrypt } - if a := binary.LittleEndian.Uint16(bytes[4:6]); a != blake2bAlgorithm { + if a := binary.LittleEndian.Uint16(bytes[4:6]); a != algorithmBlake2b { return PrivateKey{}, errDecrypt } diff --git a/private_test.go b/private_test.go new file mode 100644 index 0000000..6db20c1 --- /dev/null +++ b/private_test.go @@ -0,0 +1,26 @@ +// Copyright (c) 2024 Andreas Auernhammer. All rights reserved. +// Use of this source code is governed by a license that can be +// found in the LICENSE file. + +package minisign + +import ( + "bytes" + "os" + "testing" +) + +func TestPrivateKey_Unmarshal(t *testing.T) { + raw, err := os.ReadFile("./internal/testdata/minisign_unencrypted.key") + if err != nil { + t.Fatalf("Failed to read private key: %v", err) + } + + keys := bytes.Split(raw, []byte("\n\n")) // Private keys are separated by a newline + for _, k := range keys { + var key PrivateKey + if err := key.UnmarshalText(k); err != nil { + t.Fatalf("Failed to unmarshal private key: %v\nPrivate key:\n%s", err, string(k)) + } + } +} diff --git a/public.go b/public.go index 61dd45e..ac83b1c 100644 --- a/public.go +++ b/public.go @@ -11,7 +11,7 @@ import ( "encoding/binary" "errors" "fmt" - "io/ioutil" + "os" "strconv" "strings" ) @@ -19,7 +19,7 @@ import ( // PublicKeyFromFile reads a new PublicKey from the // given file. func PublicKeyFromFile(path string) (PublicKey, error) { - bytes, err := ioutil.ReadFile(path) + bytes, err := os.ReadFile(path) if err != nil { return PublicKey{}, err } diff --git a/signature.go b/signature.go index 78feecd..42e2f1f 100644 --- a/signature.go +++ b/signature.go @@ -10,7 +10,7 @@ import ( "encoding/binary" "errors" "fmt" - "io/ioutil" + "os" "strconv" "strings" ) @@ -18,7 +18,7 @@ import ( // SignatureFromFile reads a new Signature from the // given file. func SignatureFromFile(file string) (Signature, error) { - bytes, err := ioutil.ReadFile(file) + bytes, err := os.ReadFile(file) if err != nil { return Signature{}, err }