diff --git a/age.go b/age.go index b78b4c5..48539a9 100644 --- a/age.go +++ b/age.go @@ -66,6 +66,9 @@ type Stanza struct { Body []byte } +const fileKeySize = 16 +const streamNonceSize = 16 + // Encrypt returns a WriteCloser. Writes to the returned value are encrypted and // written to dst as an age file. Every recipient will be able to decrypt the file. // @@ -76,7 +79,7 @@ func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) { return nil, errors.New("no recipients specified") } - fileKey := make([]byte, 16) + fileKey := make([]byte, fileKeySize) if _, err := rand.Read(fileKey); err != nil { return nil, err } @@ -102,7 +105,7 @@ func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) { return nil, fmt.Errorf("failed to write header: %v", err) } - nonce := make([]byte, 16) + nonce := make([]byte, streamNonceSize) if _, err := rand.Read(nonce); err != nil { return nil, err } @@ -173,7 +176,7 @@ RecipientsLoop: return nil, errors.New("bad header MAC") } - nonce := make([]byte, 16) + nonce := make([]byte, streamNonceSize) if _, err := io.ReadFull(payload, nonce); err != nil { return nil, fmt.Errorf("failed to read nonce: %v", err) } diff --git a/agessh/agessh.go b/agessh/agessh.go index 281f300..3ed5fc3 100644 --- a/agessh/agessh.go +++ b/agessh/agessh.go @@ -5,11 +5,14 @@ // https://developers.google.com/open-source/licenses/bsd // Package agessh provides age.Identity and age.Recipient implementations of -// types "ssh-rsa" and "ssh-ed25519", which allow reusing existing SSH key files -// for encryption with age-encryption.org/v1. +// types "ssh-rsa" and "ssh-ed25519", which allow reusing existing SSH keys for +// encryption with age-encryption.org/v1. // -// These should only be used for compatibility with existing keys, and native -// X25519 keys should be preferred otherwise. +// These recipient types should only be used for compatibility with existing +// keys, and native X25519 keys should be preferred otherwise. +// +// Note that these recipient types are not anonymous: the encrypted message will +// include a short 32-bit ID of the public key, package agessh import ( @@ -346,6 +349,12 @@ func (i *Ed25519Identity) Unwrap(block *age.Stanza) ([]byte, error) { } // aeadEncrypt and aeadDecrypt are copied from package age. +// +// They don't limit the file key size because multi-key attacks are irrelevant +// against the ssh-ed25519 recipient. Being an asymmetric recipient, it would +// only allow a more efficient search for accepted public keys against a +// decryption oracle, but the ssh-X recipients are not anonymous (they have a +// short recipient hash). func aeadEncrypt(key, plaintext []byte) ([]byte, error) { aead, err := chacha20poly1305.New(key) diff --git a/cmd/age/age_test.go b/cmd/age/age_test.go index 5a0b964..2a29b51 100644 --- a/cmd/age/age_test.go +++ b/cmd/age/age_test.go @@ -20,13 +20,20 @@ func TestVectors(t *testing.T) { files, _ := filepath.Glob("testdata/*.age") for _, f := range files { name := strings.TrimSuffix(strings.TrimPrefix(f, "testdata/"), ".age") + expectFailure := strings.HasPrefix(name, "fail_") t.Run(name, func(t *testing.T) { - identities, err := parseIdentitiesFile("testdata/" + name + "_key.txt") - if err != nil { - t.Fatal(err) + var identities []age.Identity + ids, err := parseIdentitiesFile("testdata/" + name + "_key.txt") + if err == nil { + identities = append(identities, ids...) } - for _, i := range identities { - t.Logf("%s", i.Type()) + password, err := ioutil.ReadFile("testdata/" + name + "_password.txt") + if err == nil { + i, err := age.NewScryptIdentity(string(password)) + if err != nil { + t.Fatal(err) + } + identities = append(identities, i) } in, err := os.Open("testdata/" + name + ".age") @@ -34,14 +41,20 @@ func TestVectors(t *testing.T) { t.Fatal(err) } r, err := age.Decrypt(in, identities...) - if err != nil { - t.Fatal(err) + if expectFailure { + if err == nil { + t.Fatal("expected Decrypt failure") + } + } else { + if err != nil { + t.Fatal(err) + } + out, err := ioutil.ReadAll(r) + if err != nil { + t.Fatal(err) + } + t.Logf("%s", out) } - out, err := ioutil.ReadAll(r) - if err != nil { - t.Fatal(err) - } - t.Logf("%s", out) }) } } diff --git a/cmd/age/testdata/fail_large_filekey_scrypt.age b/cmd/age/testdata/fail_large_filekey_scrypt.age new file mode 100644 index 0000000..2f04702 --- /dev/null +++ b/cmd/age/testdata/fail_large_filekey_scrypt.age @@ -0,0 +1,6 @@ +age-encryption.org/v1 +-> scrypt qeKad+OgIkBbr/ndSa7J3Q 1 +C2tmV7/uZjRafxqaQd1JhYkM2KxuHHBy3/d2dJNEZEh8rZCqYfvE/eJUXqiqZsZa +6kWgG1qa6Q6sXPz0vIIpYHGf4gzxG9oTVonMke2kHC4 +--- FQeacPQobvFBd0tuIQnQDd/NEDR4G4MfylkXiq9ZqZ0 +ptt3q)vQo̚K7)%a \ No newline at end of file diff --git a/cmd/age/testdata/fail_large_filekey_scrypt_password.txt b/cmd/age/testdata/fail_large_filekey_scrypt_password.txt new file mode 100644 index 0000000..0249b11 --- /dev/null +++ b/cmd/age/testdata/fail_large_filekey_scrypt_password.txt @@ -0,0 +1 @@ +dog-old-little-breeze-novel-razor-battle-replace-lake-horse \ No newline at end of file diff --git a/cmd/age/testdata/fail_large_filekey_x25519.age b/cmd/age/testdata/fail_large_filekey_x25519.age new file mode 100644 index 0000000..100234e --- /dev/null +++ b/cmd/age/testdata/fail_large_filekey_x25519.age @@ -0,0 +1,6 @@ +age-encryption.org/v1 +-> X25519 /Gt0E6JT7yuYHlwsGW5LbpEEJawOc+QMeMAS+hoOIgw +XU/4Zkz4MksDhge0kosiMTJF8tHnOP0ZSi+6aaMqLMS1PlMIs95nKz3H7JGesTwA +tsxuQrj+TuoGouNB1O0VshA9vsHGurn0Dtw5e7bkw9Q +--- jQNSF6blozj2QFYJ/2iqy0wUcPuz/8vCS7RgKH8wjNI +9y_R\m\Uv6QȶmKav2 \ No newline at end of file diff --git a/cmd/age/testdata/fail_large_filekey_x25519_key.txt b/cmd/age/testdata/fail_large_filekey_x25519_key.txt new file mode 100644 index 0000000..39c1631 --- /dev/null +++ b/cmd/age/testdata/fail_large_filekey_x25519_key.txt @@ -0,0 +1,3 @@ +# created: 2020-09-19T18:42:11+02:00 +# public key: age1uc8zlurjyjpenrslc2thyl28u7ylz6x8c2g9yphvjha6xm8ppf3slq0l25 +AGE-SECRET-KEY-1D8JAD8SXNFVQEFHAUNNAX4QCE3K5CUKMT7YYHNGTUSSP97YGWL4STV89UH diff --git a/primitives.go b/primitives.go index 77c5632..0351345 100644 --- a/primitives.go +++ b/primitives.go @@ -9,6 +9,7 @@ package age import ( "crypto/hmac" "crypto/sha256" + "fmt" "io" "filippo.io/age/internal/format" @@ -16,6 +17,7 @@ import ( "golang.org/x/crypto/hkdf" ) +// aeadEncrypt encrypts a message with a one-time key. func aeadEncrypt(key, plaintext []byte) ([]byte, error) { aead, err := chacha20poly1305.New(key) if err != nil { @@ -30,11 +32,19 @@ func aeadEncrypt(key, plaintext []byte) ([]byte, error) { return aead.Seal(nil, nonce, plaintext, nil), nil } -func aeadDecrypt(key, ciphertext []byte) ([]byte, error) { +// aeadDecrypt decrypts a message of an expected fixed size. +// +// The message size is limited to mitigate multi-key attacks, where a ciphertext +// can be crafted that decrypts successfully under multiple keys. Short +// ciphertexts can only target two keys, which has limited impact. +func aeadDecrypt(key []byte, size int, ciphertext []byte) ([]byte, error) { aead, err := chacha20poly1305.New(key) if err != nil { return nil, err } + if len(ciphertext) != size+aead.Overhead() { + return nil, fmt.Errorf("encrypted message has unexpected length") + } nonce := make([]byte, chacha20poly1305.NonceSize) return aead.Open(nil, nonce, ciphertext, nil) } diff --git a/scrypt.go b/scrypt.go index fd2625f..3d71025 100644 --- a/scrypt.go +++ b/scrypt.go @@ -19,7 +19,8 @@ import ( const scryptLabel = "age-encryption.org/v1/scrypt" -// ScryptRecipient is a password-based recipient. +// ScryptRecipient is a password-based recipient. Anyone with the password can +// decrypt the message. // // If a ScryptRecipient is used, it must be the only recipient for the file: it // can't be mixed with other recipient types and can't be used multiple times @@ -60,8 +61,10 @@ func (r *ScryptRecipient) SetWorkFactor(logN int) { r.workFactor = logN } +const scryptSaltSize = 16 + func (r *ScryptRecipient) Wrap(fileKey []byte) (*Stanza, error) { - salt := make([]byte, 16) + salt := make([]byte, scryptSaltSize) if _, err := rand.Read(salt[:]); err != nil { return nil, err } @@ -133,7 +136,7 @@ func (i *ScryptIdentity) Unwrap(block *Stanza) ([]byte, error) { if err != nil { return nil, fmt.Errorf("failed to parse scrypt salt: %v", err) } - if len(salt) != 16 { + if len(salt) != scryptSaltSize { return nil, errors.New("invalid scrypt recipient block") } logN, err := strconv.Atoi(block.Args[1]) @@ -153,7 +156,14 @@ func (i *ScryptIdentity) Unwrap(block *Stanza) ([]byte, error) { return nil, fmt.Errorf("failed to generate scrypt hash: %v", err) } - fileKey, err := aeadDecrypt(k, block.Body) + // This AEAD is not robust, so an attacker could craft a message that + // decrypts under two different keys (meaning two different passphrases) and + // then use an error side-channel in an online decryption oracle to learn if + // either key is correct. This is deemed acceptable because the usa case (an + // online decryption oracle) is not recommended, and the security loss is + // only one bit. This also does not bypass any scrypt work, but that work + // can be precomputed in an online oracle scenario. + fileKey, err := aeadDecrypt(k, fileKeySize, block.Body) if err != nil { return nil, ErrIncorrectIdentity } diff --git a/x25519.go b/x25519.go index 705ac6d..8edd952 100644 --- a/x25519.go +++ b/x25519.go @@ -23,7 +23,11 @@ import ( const x25519Label = "age-encryption.org/v1/X25519" -// X25519Recipient is the standard age public key, based on a Curve25519 point. +// X25519Recipient is the standard age public key. Messages encrypted to this +// recipient can be decrypted with the corresponding X25519Identity. +// +// This recipient is anonymous, in the sense that an attacker can't tell from +// the message alone if it is encrypted to a certain recipient. type X25519Recipient struct { theirPublicKey []byte } @@ -105,7 +109,8 @@ func (r *X25519Recipient) String() string { return s } -// X25519Identity is the standard age private key, based on a Curve25519 scalar. +// X25519Identity is the standard age private key, which can decrypt messages +// encrypted to the corresponding X25519Recipient. type X25519Identity struct { secretKey, ourPublicKey []byte } @@ -136,7 +141,7 @@ func GenerateX25519Identity() (*X25519Identity, error) { return newX25519IdentityFromScalar(secretKey) } -// ParseX25519Identity returns a new X25519Recipient from a Bech32 private key +// ParseX25519Identity returns a new X25519Identity from a Bech32 private key // encoding with the "AGE-SECRET-KEY-1" prefix. func ParseX25519Identity(s string) (*X25519Identity, error) { t, k, err := bech32.Decode(s) @@ -182,7 +187,7 @@ func (i *X25519Identity) Unwrap(block *Stanza) ([]byte, error) { return nil, err } - fileKey, err := aeadDecrypt(wrappingKey, block.Body) + fileKey, err := aeadDecrypt(wrappingKey, fileKeySize, block.Body) if err != nil { return nil, ErrIncorrectIdentity }