ssh: limit bcrypt KDF rounds in OpenSSH private key decryption

passphraseProtectedOpenSSHKey decodes the bcrypt round count from
the key file and passes it directly to bcrypt_pbkdf.Key, whose
running time is linear in that count. Files specifying very large
round values cause the decryption to consume excessive CPU time:
a uint32 maximum is several months of work on commodity hardware.

Cap the accepted round count at 2048 (128x the default of 16, a
few seconds of CPU on a modern core). OpenSSH itself does not
impose an upper bound, but accepting arbitrary values turns key
loading into a resource-exhaustion footgun for any code that
processes files supplied by end users.

This issue was found during a security audit by NCC Group
Cryptography Services, sponsored by Teleport, and was assessed
and is being fixed as a non-security bug.

Change-Id: I01112bdf1b484ae4fab5dc8841d1a272f112df74
Reviewed-on: https://go-review.googlesource.com/c/crypto/+/782422
Reviewed-by: David Chase <drchase@google.com>
Reviewed-by: Filippo Valsorda <filippo@golang.org>
Reviewed-by: Junyang Shao <shaojunyang@google.com>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
This commit is contained in:
Nicola Murino
2026-06-02 00:22:48 -07:00
parent d4a85f4749
commit b315afd69a
2 changed files with 43 additions and 0 deletions
+11
View File
@@ -1466,6 +1466,17 @@ func passphraseProtectedOpenSSHKey(passphrase []byte) openSSHDecryptFunc {
return nil, err
}
// OpenSSH does not impose an upper bound on the bcrypt round count
// stored in the key file, but bcrypt_pbkdf cost is linear in rounds:
// the default is 16, ssh-keygen lets users pick anything up to
// INT_MAX. Cap at 2048 (128x the default, a few seconds of CPU) so
// that an oversized value in the file cannot tie up the caller for
// months.
const maxRounds = 1 << 11
if opts.Rounds > maxRounds {
return nil, fmt.Errorf("ssh: bcrypt KDF rounds %d exceed maximum %d", opts.Rounds, maxRounds)
}
k, err := bcrypt_pbkdf.Key(passphrase, []byte(opts.Salt), int(opts.Rounds), 32+16)
if err != nil {
return nil, err
+32
View File
@@ -506,6 +506,38 @@ func TestParseEncryptedPrivateKeysWithIncorrectPassphrase(t *testing.T) {
}
}
func TestParseEncryptedPrivateKeyExcessiveBcryptRounds(t *testing.T) {
// Craft a minimal openssh-key-v1 blob whose KdfOpts declares a bcrypt
// round count above the accepted maximum. The check must reject the file
// before bcrypt_pbkdf is invoked, so the rest of the blob (public key,
// encrypted body) can be empty.
kdfOpts := Marshal(struct {
Salt []byte
Rounds uint32
}{
Salt: []byte("salt-not-used"),
Rounds: (1 << 11) + 1,
})
header := Marshal(openSSHEncryptedPrivateKey{
CipherName: "aes256-ctr",
KdfName: "bcrypt",
KdfOpts: string(kdfOpts),
NumKeys: 1,
})
pemBytes := pem.EncodeToMemory(&pem.Block{
Type: "OPENSSH PRIVATE KEY",
Bytes: append([]byte(privateKeyAuthMagic), header...),
})
_, err := ParseRawPrivateKeyWithPassphrase(pemBytes, []byte("password"))
if err == nil {
t.Fatal("expected error for excessive bcrypt rounds, got nil")
}
if !strings.Contains(err.Error(), "bcrypt KDF rounds") {
t.Errorf("got error %q, want substring %q", err.Error(), "bcrypt KDF rounds")
}
}
func TestParseDSA(t *testing.T) {
// We actually exercise the ParsePrivateKey codepath here, as opposed to
// using the ParseRawPrivateKey+NewSignerFromKey path that testdata_test.go