mirror of
https://github.com/FiloSottile/age.git
synced 2025-12-23 13:35:14 +00:00
age: mitigate multi-key attacks on ChaCha20Poly1305
It's possible to craft ChaCha20Poly1305 ciphertexts that decrypt under multiple keys. (I know, it's wild.) The impact is different for different recipients, but in general only applies to Chosen Ciphertext Attacks against online decryption oracles: * With the scrypt recipient, it lets the attacker make a recipient stanza that decrypts with multiple passwords, speeding up a bruteforce in terms of oracle queries (but not scrypt work, which can be precomputed) to logN by binary search. Limiting the ciphertext size limits the keys to two, which makes this acceptable: it's a loss of only one bit of security in a scenario (online decryption oracles) that is not recommended. * With the X25519 recipient, it lets the attacker search for accepted public keys without using multiple recipient stanzas in the message. That lets the attacker bypass the 20 recipients limit (which was not actually intended to defend against deanonymization attacks). This is not really in the threat model for age: we make no attempt to provide anonymity in an online CCA scenario. Anyway, limiting the keys to two by enforcing short ciphertexts mitigates the attack: it only lets the attacker test 40 keys per message instead of 20. * With the ssh-ed25519 recipient, the attack should be irrelevant, since the recipient stanza includes a 32-bit hash of the public key, making it decidedly not anonymous. Also to avoid breaking the abstraction in the agessh package, we don't mitigate the attack for this recipient, but we document the lack of anonymity. This was reported by Paul Grubbs in the context of the upcoming paper "Partitioning Oracle Attacks", USENIX Security 2021 (to appear), by Julia Len, Paul Grubbs, and Thomas Ristenpart.
This commit is contained in:
9
age.go
9
age.go
@@ -66,6 +66,9 @@ type Stanza struct {
|
|||||||
Body []byte
|
Body []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileKeySize = 16
|
||||||
|
const streamNonceSize = 16
|
||||||
|
|
||||||
// Encrypt returns a WriteCloser. Writes to the returned value are encrypted and
|
// 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.
|
// 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")
|
return nil, errors.New("no recipients specified")
|
||||||
}
|
}
|
||||||
|
|
||||||
fileKey := make([]byte, 16)
|
fileKey := make([]byte, fileKeySize)
|
||||||
if _, err := rand.Read(fileKey); err != nil {
|
if _, err := rand.Read(fileKey); err != nil {
|
||||||
return nil, err
|
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)
|
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 {
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -173,7 +176,7 @@ RecipientsLoop:
|
|||||||
return nil, errors.New("bad header MAC")
|
return nil, errors.New("bad header MAC")
|
||||||
}
|
}
|
||||||
|
|
||||||
nonce := make([]byte, 16)
|
nonce := make([]byte, streamNonceSize)
|
||||||
if _, err := io.ReadFull(payload, nonce); err != nil {
|
if _, err := io.ReadFull(payload, nonce); err != nil {
|
||||||
return nil, fmt.Errorf("failed to read nonce: %v", err)
|
return nil, fmt.Errorf("failed to read nonce: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,14 @@
|
|||||||
// https://developers.google.com/open-source/licenses/bsd
|
// https://developers.google.com/open-source/licenses/bsd
|
||||||
|
|
||||||
// Package agessh provides age.Identity and age.Recipient implementations of
|
// Package agessh provides age.Identity and age.Recipient implementations of
|
||||||
// types "ssh-rsa" and "ssh-ed25519", which allow reusing existing SSH key files
|
// types "ssh-rsa" and "ssh-ed25519", which allow reusing existing SSH keys for
|
||||||
// for encryption with age-encryption.org/v1.
|
// encryption with age-encryption.org/v1.
|
||||||
//
|
//
|
||||||
// These should only be used for compatibility with existing keys, and native
|
// These recipient types should only be used for compatibility with existing
|
||||||
// X25519 keys should be preferred otherwise.
|
// 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
|
package agessh
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -346,6 +349,12 @@ func (i *Ed25519Identity) Unwrap(block *age.Stanza) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// aeadEncrypt and aeadDecrypt are copied from package age.
|
// 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) {
|
func aeadEncrypt(key, plaintext []byte) ([]byte, error) {
|
||||||
aead, err := chacha20poly1305.New(key)
|
aead, err := chacha20poly1305.New(key)
|
||||||
|
|||||||
@@ -20,13 +20,20 @@ func TestVectors(t *testing.T) {
|
|||||||
files, _ := filepath.Glob("testdata/*.age")
|
files, _ := filepath.Glob("testdata/*.age")
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
name := strings.TrimSuffix(strings.TrimPrefix(f, "testdata/"), ".age")
|
name := strings.TrimSuffix(strings.TrimPrefix(f, "testdata/"), ".age")
|
||||||
|
expectFailure := strings.HasPrefix(name, "fail_")
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
identities, err := parseIdentitiesFile("testdata/" + name + "_key.txt")
|
var identities []age.Identity
|
||||||
if err != nil {
|
ids, err := parseIdentitiesFile("testdata/" + name + "_key.txt")
|
||||||
t.Fatal(err)
|
if err == nil {
|
||||||
|
identities = append(identities, ids...)
|
||||||
}
|
}
|
||||||
for _, i := range identities {
|
password, err := ioutil.ReadFile("testdata/" + name + "_password.txt")
|
||||||
t.Logf("%s", i.Type())
|
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")
|
in, err := os.Open("testdata/" + name + ".age")
|
||||||
@@ -34,14 +41,20 @@ func TestVectors(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
r, err := age.Decrypt(in, identities...)
|
r, err := age.Decrypt(in, identities...)
|
||||||
if err != nil {
|
if expectFailure {
|
||||||
t.Fatal(err)
|
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)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
cmd/age/testdata/fail_large_filekey_scrypt.age
vendored
Normal file
6
cmd/age/testdata/fail_large_filekey_scrypt.age
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
age-encryption.org/v1
|
||||||
|
-> scrypt qeKad+OgIkBbr/ndSa7J3Q 1
|
||||||
|
C2tmV7/uZjRafxqaQd1JhYkM2KxuHHBy3/d2dJNEZEh8rZCqYfvE/eJUXqiqZsZa
|
||||||
|
6kWgG1qa6Q6sXPz0vIIpYHGf4gzxG9oTVonMke2kHC4
|
||||||
|
--- FQeacPQobvFBd0tuIQnQDd/NEDR4G4MfylkXiq9ZqZ0
|
||||||
|
<EFBFBD><EFBFBD><EFBFBD>p<EFBFBD><EFBFBD>t<18>t<EFBFBD><74><EFBFBD>3q<33><71>)<29><><EFBFBD><EFBFBD>v<EFBFBD><0F>Q<EFBFBD>o̚K<CC9A><4B>7<EFBFBD><17>)<29>%a
|
||||||
1
cmd/age/testdata/fail_large_filekey_scrypt_password.txt
vendored
Normal file
1
cmd/age/testdata/fail_large_filekey_scrypt_password.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
dog-old-little-breeze-novel-razor-battle-replace-lake-horse
|
||||||
6
cmd/age/testdata/fail_large_filekey_x25519.age
vendored
Normal file
6
cmd/age/testdata/fail_large_filekey_x25519.age
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
age-encryption.org/v1
|
||||||
|
-> X25519 /Gt0E6JT7yuYHlwsGW5LbpEEJawOc+QMeMAS+hoOIgw
|
||||||
|
XU/4Zkz4MksDhge0kosiMTJF8tHnOP0ZSi+6aaMqLMS1PlMIs95nKz3H7JGesTwA
|
||||||
|
tsxuQrj+TuoGouNB1O0VshA9vsHGurn0Dtw5e7bkw9Q
|
||||||
|
--- jQNSF6blozj2QFYJ/2iqy0wUcPuz/8vCS7RgKH8wjNI
|
||||||
|
<14>9<EFBFBD><39><EFBFBD>y<EFBFBD>_<><5F>R\<03><>m\<5C><><EFBFBD>Uv6Qȶ<51><15>mK<6D>a<EFBFBD><61><EFBFBD>v<EFBFBD><76><EFBFBD>2
|
||||||
3
cmd/age/testdata/fail_large_filekey_x25519_key.txt
vendored
Normal file
3
cmd/age/testdata/fail_large_filekey_x25519_key.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# created: 2020-09-19T18:42:11+02:00
|
||||||
|
# public key: age1uc8zlurjyjpenrslc2thyl28u7ylz6x8c2g9yphvjha6xm8ppf3slq0l25
|
||||||
|
AGE-SECRET-KEY-1D8JAD8SXNFVQEFHAUNNAX4QCE3K5CUKMT7YYHNGTUSSP97YGWL4STV89UH
|
||||||
@@ -9,6 +9,7 @@ package age
|
|||||||
import (
|
import (
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"filippo.io/age/internal/format"
|
"filippo.io/age/internal/format"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"golang.org/x/crypto/hkdf"
|
"golang.org/x/crypto/hkdf"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// aeadEncrypt encrypts a message with a one-time key.
|
||||||
func aeadEncrypt(key, plaintext []byte) ([]byte, error) {
|
func aeadEncrypt(key, plaintext []byte) ([]byte, error) {
|
||||||
aead, err := chacha20poly1305.New(key)
|
aead, err := chacha20poly1305.New(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -30,11 +32,19 @@ func aeadEncrypt(key, plaintext []byte) ([]byte, error) {
|
|||||||
return aead.Seal(nil, nonce, plaintext, nil), nil
|
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)
|
aead, err := chacha20poly1305.New(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if len(ciphertext) != size+aead.Overhead() {
|
||||||
|
return nil, fmt.Errorf("encrypted message has unexpected length")
|
||||||
|
}
|
||||||
nonce := make([]byte, chacha20poly1305.NonceSize)
|
nonce := make([]byte, chacha20poly1305.NonceSize)
|
||||||
return aead.Open(nil, nonce, ciphertext, nil)
|
return aead.Open(nil, nonce, ciphertext, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
18
scrypt.go
18
scrypt.go
@@ -19,7 +19,8 @@ import (
|
|||||||
|
|
||||||
const scryptLabel = "age-encryption.org/v1/scrypt"
|
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
|
// 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
|
// 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
|
r.workFactor = logN
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scryptSaltSize = 16
|
||||||
|
|
||||||
func (r *ScryptRecipient) Wrap(fileKey []byte) (*Stanza, error) {
|
func (r *ScryptRecipient) Wrap(fileKey []byte) (*Stanza, error) {
|
||||||
salt := make([]byte, 16)
|
salt := make([]byte, scryptSaltSize)
|
||||||
if _, err := rand.Read(salt[:]); err != nil {
|
if _, err := rand.Read(salt[:]); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -133,7 +136,7 @@ func (i *ScryptIdentity) Unwrap(block *Stanza) ([]byte, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse scrypt salt: %v", err)
|
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")
|
return nil, errors.New("invalid scrypt recipient block")
|
||||||
}
|
}
|
||||||
logN, err := strconv.Atoi(block.Args[1])
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, ErrIncorrectIdentity
|
return nil, ErrIncorrectIdentity
|
||||||
}
|
}
|
||||||
|
|||||||
13
x25519.go
13
x25519.go
@@ -23,7 +23,11 @@ import (
|
|||||||
|
|
||||||
const x25519Label = "age-encryption.org/v1/X25519"
|
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 {
|
type X25519Recipient struct {
|
||||||
theirPublicKey []byte
|
theirPublicKey []byte
|
||||||
}
|
}
|
||||||
@@ -105,7 +109,8 @@ func (r *X25519Recipient) String() string {
|
|||||||
return s
|
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 {
|
type X25519Identity struct {
|
||||||
secretKey, ourPublicKey []byte
|
secretKey, ourPublicKey []byte
|
||||||
}
|
}
|
||||||
@@ -136,7 +141,7 @@ func GenerateX25519Identity() (*X25519Identity, error) {
|
|||||||
return newX25519IdentityFromScalar(secretKey)
|
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.
|
// encoding with the "AGE-SECRET-KEY-1" prefix.
|
||||||
func ParseX25519Identity(s string) (*X25519Identity, error) {
|
func ParseX25519Identity(s string) (*X25519Identity, error) {
|
||||||
t, k, err := bech32.Decode(s)
|
t, k, err := bech32.Decode(s)
|
||||||
@@ -182,7 +187,7 @@ func (i *X25519Identity) Unwrap(block *Stanza) ([]byte, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
fileKey, err := aeadDecrypt(wrappingKey, block.Body)
|
fileKey, err := aeadDecrypt(wrappingKey, fileKeySize, block.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrIncorrectIdentity
|
return nil, ErrIncorrectIdentity
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user