Skip to content

Commit

Permalink
add support for passwordless private keys
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
aead committed May 16, 2024
1 parent 46482c0 commit 6d740d9
Show file tree
Hide file tree
Showing 8 changed files with 190 additions and 49 deletions.
76 changes: 47 additions & 29 deletions cmd/minisign/minisign.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import (
)

const usage = `Usage:
minisign -G [-p <pubKey>] [-s <secKey>]
minisign -G [-p <pubKey>] [-s <secKey>] [-W]
minisign -S [-x <signature>] [-s <secKey>] [-c <comment>] [-t <comment>] -m <file>...
minisign -V [-H] [-x <signature>] [-p <pubKey> | -P <pubKey>] [-o] [-q | -Q ] -m <file>
minisign -R [-s <secKey>] [-p <pubKey>]
Expand All @@ -38,6 +38,7 @@ Options:
-p <pubKey> Public key file (default: ./minisign.pub)
-P <pubKey> Public key as base64 string
-s <secKey> Secret key file (default: $HOME/.minisign/minisign.key)
-W Do not encrypt/decrypt the secret key with a password.
-x <signature> Signature file (default: <file>.minisig)
-c <comment> Add a one-line untrusted comment.
-t <comment> Add a one-line trusted comment.
Expand Down Expand Up @@ -66,6 +67,7 @@ func main() {
pubKeyFileFlag string
pubKeyFlag string
secKeyFileFlag string
unencryptedKeyFlag bool
signatureFlag string
untrustedCommentFlag string
trustedCommentFlag string
Expand All @@ -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: <file>.minisig)")
flag.StringVar(&untrustedCommentFlag, "c", "", "Add a one-line untrusted comment")
flag.StringVar(&trustedCommentFlag, "t", "", "Add a one-line trusted comment")
Expand All @@ -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:
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}

Expand Down Expand Up @@ -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 != "/" {
Expand Down
5 changes: 2 additions & 3 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"crypto/rand"
"fmt"
"io"
"io/ioutil"
"strconv"
"strings"

Expand Down Expand Up @@ -160,15 +159,15 @@ 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)

// 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
}
Expand Down
2 changes: 2 additions & 0 deletions internal/testdata/minisign_unencrypted.key
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
untrusted comment: minisign encrypted secret key
RWQAAEIyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbuUYgQpHKDcmmMQj9cgqohWX321PrXUDFfCVWOXDZp8kLw2/qju66KnI28LcOaA7ZywNP5vDVtlHeyzit3lxeqirS5+2UImrAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
5 changes: 2 additions & 3 deletions minisign_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ package minisign

import (
"io"
"io/ioutil"
"os"
"testing"
)
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
117 changes: 107 additions & 10 deletions private.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@
package minisign

import (
"bytes"
"crypto"
"crypto/ed25519"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"io"
"io/ioutil"
"os"
"strconv"
"strings"
"time"
Expand All @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
}

Expand Down
Loading

0 comments on commit 6d740d9

Please sign in to comment.