From 5c361c9a42cb7607615a24f19030c5e82a56e2c4 Mon Sep 17 00:00:00 2001 From: Andreas Auernhammer Date: Mon, 22 Dec 2025 12:11:38 +0100 Subject: [PATCH] add support for RSA private keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds support for RSA private keys. See the `mtls.RSAPrivateKey` struct. RSA keys use the `k3:` prefix and are significantly larger than EdDSA / ECDSA keys. Currently, their text representation includes the private key parameter `D` which is not strictly required since it can be re-computed using the public exponent `E`. However, due to FIPS 140, `D` would have to be computed not using the typical φ(N) where φ(N) = (p-1)(q-1) but using E⁻¹ mod λ(N) where λ(N) = lcm(p-1, q-1). This commit also adds an RSA certificate used for tests generated with OpenSSL 3.6.0 Signed-off-by: Andreas Auernhammer --- key.go | 261 +++++++++++++++++++++++++++++++++++++---- key_test.go | 31 +++++ testdata/certs/rsa.crt | 19 +++ 3 files changed, 287 insertions(+), 24 deletions(-) create mode 100644 testdata/certs/rsa.crt diff --git a/key.go b/key.go index 75b52a4..b2fb4a1 100644 --- a/key.go +++ b/key.go @@ -12,18 +12,21 @@ import ( "crypto/ed25519" "crypto/elliptic" "crypto/rand" + "crypto/rsa" "crypto/sha256" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/asn1" "encoding/base64" + "encoding/binary" "encoding/pem" "errors" + "fmt" "io" + "math" "math/big" "strconv" - "strings" "time" ) @@ -45,27 +48,34 @@ type PrivateKey interface { // ParsePrivateKey parses s and returns it as PrivateKey. // -// Currently, ParsePrivateKey either returns a *EdDSAPrivateKey, -// a *ECDSAPrivateKey or an error. +// Currently, ParsePrivateKey either returns a [*EdDSAPrivateKey], +// a [*ECDSAPrivateKey], a [*RSAPrivateKey] or an error. func ParsePrivateKey(s string) (PrivateKey, error) { - switch { - default: - return nil, errors.New("mtls: invalid private key") - - case strings.HasPrefix(s, "k1:"): - var key EdDSAPrivateKey - if err := key.UnmarshalText([]byte(s)); err != nil { - return nil, err + if len(s) >= 3 { + switch s[:3] { + case "k1:": + var key EdDSAPrivateKey + if err := key.UnmarshalText([]byte(s)); err != nil { + return nil, err + } + return &key, nil + + case "k2:": + var key ECDSAPrivateKey + if err := key.UnmarshalText([]byte(s)); err != nil { + return nil, err + } + return &key, nil + + case "k3:": + var key RSAPrivateKey + if err := key.UnmarshalText([]byte(s)); err != nil { + return nil, err + } + return &key, nil } - return &key, nil - - case strings.HasPrefix(s, "k2:"): - var key ECDSAPrivateKey - if err := key.UnmarshalText([]byte(s)); err != nil { - return nil, err - } - return &key, nil } + return nil, errors.New("mtls: invalid private key") } // EdDSAPrivateKey is a [PrivateKey] for the EdDSA signature algorithm @@ -210,7 +220,7 @@ func (pk *ECDSAPrivateKey) Private() crypto.PrivateKey { } } -// Private returns the ECDSA public key. +// Public returns the ECDSA public key. func (pk *ECDSAPrivateKey) Public() crypto.PublicKey { var X, Y big.Int return &ecdsa.PublicKey{ @@ -223,9 +233,9 @@ func (pk *ECDSAPrivateKey) Public() crypto.PublicKey { // Identity returns the identity of the ECDSA public key. func (pk *ECDSAPrivateKey) Identity() Identity { return pk.identity } -// MarshalText returns a textual representation of the private key. +// MarshalText returns a textual representation of the ECDSA private key. // -// It returns output equivalent to [ECDSAPrivateKey.String] +// It returns output equivalent to [ECDSAPrivateKey.String]. func (pk *ECDSAPrivateKey) MarshalText() ([]byte, error) { // We use FillBytes instead of Bytes since the later returns // a variable-size slice. However, we want all private key @@ -241,7 +251,7 @@ func (pk *ECDSAPrivateKey) MarshalText() ([]byte, error) { return b, nil } -// UnmarshalText parses an private key textual representation. +// UnmarshalText parses a ECDSA private key textual representation. func (pk *ECDSAPrivateKey) UnmarshalText(text []byte) error { if !bytes.HasPrefix(text, []byte("k2:")) { return errors.New("mtls: invalid ECDSA private key") @@ -313,9 +323,166 @@ func (pk *ECDSAPrivateKey) String() string { return "k2:" + base64.RawURLEncoding.EncodeToString(priv) } +// GenerateKey generates a random RSA private key of the given bit size. +// +// If bits is less than 1024, [GenerateKeyRSA] returns an error. See the +// "[Minimum key size]" section for further details. +// +// Most applications should use [crypto/rand.Reader] as random. Note that the +// returned key does not depend deterministically on the bytes read from rand, +// and may change between calls and/or between versions. +// +// [Minimum key size]: https://pkg.go.dev/crypto/rsa#hdr-Minimum_key_size +func GenerateKeyRSA(random io.Reader, bits int) (*RSAPrivateKey, error) { + priv, err := rsa.GenerateKey(random, bits) + if err != nil { + return nil, err + } + if priv.E > math.MaxUint32 { + return nil, errors.New("mtls: public RSA exponent " + strconv.Itoa(priv.E) + " is too large") + } + priv.Precompute() + + identity, err := rsaIdentity(priv) + if err != nil { + return nil, err + } + return &RSAPrivateKey{ + priv: priv, + identity: identity, + }, nil +} + +// RSAPrivateKey represents an RSA [PrivateKey]. +type RSAPrivateKey struct { + priv *rsa.PrivateKey + identity Identity +} + +// Private returns the RSA private key. +func (pk *RSAPrivateKey) Private() crypto.PrivateKey { return pk.priv } + +// Public returns the RSA public key. +func (pk *RSAPrivateKey) Public() crypto.PublicKey { return pk.priv.Public() } + +// Identity returns the identity of the RSA public key. +func (pk *RSAPrivateKey) Identity() Identity { return pk.identity } + +// MarshalText returns a textual representation of the private key. +// +// It returns output equivalent to [RSAPrivateKey.String]. +func (pk *RSAPrivateKey) MarshalText() ([]byte, error) { + return base64.RawURLEncoding.AppendEncode([]byte("k3:"), pk.encode()), nil +} + +// UnmarshalText parses a textual representation of an RSA private key. +func (pk *RSAPrivateKey) UnmarshalText(text []byte) error { + if !bytes.HasPrefix(text, []byte("k3:")) { + return errors.New("mtls: invalid RSA private key") + } + text = text[3:] + + var err error + data := make([]byte, 0, base64.RawURLEncoding.DecodedLen(len(text))) + if data, err = base64.RawURLEncoding.AppendDecode(data, text); err != nil { + return err + } + + var E uint32 + if len(data) < 6 { + return errors.New("mtls: invalid RSA private key parameter E") + } + if n := binary.BigEndian.Uint16(data); n != 4 { + return errors.New("mtls: invalid RSA private key parameter E: invalid encoding length " + strconv.Itoa(int(n))) + } + E = binary.BigEndian.Uint32(data[2:]) + data = data[6:] + + var P, Q, D *big.Int + if data, P, err = decodeRSAParam(data); err != nil { + return fmt.Errorf("mtls: invalid RSA private key parameter P: %w", err) + } + if data, Q, err = decodeRSAParam(data); err != nil { + return fmt.Errorf("mtls: invalid RSA private key parameter Q: %w", err) + } + if data, D, err = decodeRSAParam(data); err != nil { + return fmt.Errorf("mtls: invalid RSA private key parameter D: %w", err) + } + if len(data) != 0 { + return errors.New("mtls: invalid RSA private key: private key contains additional data") + } + + priv := &rsa.PrivateKey{ + PublicKey: rsa.PublicKey{ + N: new(big.Int).Mul(P, Q), + E: int(E), + }, + D: D, + Primes: []*big.Int{P, Q}, + } + priv.Precompute() + if err = priv.Validate(); err != nil { + return fmt.Errorf("mtls: invalid RSA private key: %w", err) + } + + identity, err := rsaIdentity(priv) + if err != nil { + return err + } + + pk.priv = priv + pk.identity = identity + return nil +} + +// String returns a string representation of the private key. +// +// Its output is equivalent to [RSAPrivateKey.MarshalText] +func (pk *RSAPrivateKey) String() string { + return "k3:" + base64.RawURLEncoding.EncodeToString(pk.encode()) +} + +// encode returns the RSA key's binary representation: +// +// len(E) | E | len(P) | P | len(Q) | Q | len(D) | D +// +// All numbers are represented in big endian. +// +// Values that can be re-computed are omitted to be space efficient. +// For example, the modulus N = PQ. The private exponent D can also +// be re-computed given P and Q as D = E⁻¹ mod φ(N) where φ(N) = (p-1)(q-1). +// +// However, FIPS 186-5 requires computing it as E⁻¹ mod λ(N) where λ(N) = lcm(p-1, q-1). +// Hence, we include D to avoid re-implementing private exponent calculations. +// The binary representation puts D at the end such that we can support shorter +// private keys (without D) in the future. +func (pk *RSAPrivateKey) encode() []byte { + var ( + D, P, Q = pk.priv.D, pk.priv.Primes[0], pk.priv.Primes[1] + d, p, q = (D.BitLen() + 7) / 8, (P.BitLen() + 7) / 8, (Q.BitLen() + 7) / 8 + + buf = make([]byte, max(d, p, q)) + out = make([]byte, 0, 12+d+p+q) // length-prefixed encoded len: 2 + 4 + 2 + d + 2 + p + 2 + q + ) + + out = binary.BigEndian.AppendUint16(out, 4) + out = binary.BigEndian.AppendUint32(out, uint32(pk.priv.E)) + + out = binary.BigEndian.AppendUint16(out, uint16(p)) + out = append(out, P.FillBytes(buf[:p])...) + + out = binary.BigEndian.AppendUint16(out, uint16(q)) + out = append(out, Q.FillBytes(buf[:q])...) + + out = binary.BigEndian.AppendUint16(out, uint16(d)) + out = append(out, D.FillBytes(buf[:d])...) + return out +} + var ( oidPublicKeyEdDSA = asn1.ObjectIdentifier{1, 3, 101, 112} oidPublicKeyECDSA = asn1.ObjectIdentifier{1, 2, 840, 10045, 2, 1} + oidPublicKeyRSA = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 1} oidNamedCurveP256 = asn1.ObjectIdentifier{1, 2, 840, 10045, 3, 1, 7} oidNamedCurveP384 = asn1.ObjectIdentifier{1, 3, 132, 0, 34} @@ -384,7 +551,53 @@ func ecdsaIdentity(key *ecdsa.PrivateKey) (Identity, error) { }, nil } -// NewCertificate returns a new TLS certificate using the +func rsaIdentity(key *rsa.PrivateKey) (Identity, error) { + type PKCS1 struct { + N *big.Int + E int + } + pubKey, err := asn1.Marshal(PKCS1{ + N: key.PublicKey.N, + E: key.PublicKey.E, + }) + if err != nil { + return Identity{}, fmt.Errorf("mtls: failed to encode RSA public key: %w", err) + } + b, err := asn1.Marshal(publicKeyInfo{ + Algorithm: pkix.AlgorithmIdentifier{ + Algorithm: oidPublicKeyRSA, + Parameters: asn1.NullRawValue, + }, + PublicKey: asn1.BitString{BitLength: len(pubKey) * 8, Bytes: pubKey}, + }) + if err != nil { + return Identity{}, fmt.Errorf("mtls: failed to encode RSA public key: %w", err) + } + + return Identity{ + hash: sha256.Sum256(b), + }, nil +} + +// decodeRSAParam decodes a length-encoded big endian binary +// representation of an RSA private key parameter. In particular, +// the private exponent D and the prime factors P and Q. +func decodeRSAParam(b []byte) ([]byte, *big.Int, error) { + if len(b) < 2 { + return nil, nil, errors.New("invalid length encoding") + } + + n := binary.BigEndian.Uint16(b) + if n == 0 { + return nil, nil, errors.New("parameter length is zero") + } + if int(n) > len(b)-2 { + return nil, nil, errors.New("parameter length " + strconv.Itoa(int(n)) + " exceeds data") + } + return b[2+n:], new(big.Int).SetBytes(b[2 : 2+n]), nil +} + +// newCertificate returns a new TLS certificate using the // given private key. func newCertificate(key PrivateKey) (*tls.Certificate, error) { serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) diff --git a/key_test.go b/key_test.go index f73dab2..7da469d 100644 --- a/key_test.go +++ b/key_test.go @@ -9,6 +9,7 @@ import ( "crypto/ed25519" "crypto/elliptic" "crypto/rand" + "crypto/rsa" "crypto/x509" "encoding/pem" "os" @@ -65,9 +66,35 @@ func TestGenerateKeyECDSA(t *testing.T) { } } +// TestGenerateKeyRSA tests whether generated RSA private keys +// are equal to their parsed textual representation for common +// key sizes. +func TestGenerateKeyRSA(t *testing.T) { + t.Parallel() + + bitSizes := []int{2048, 3072, 4096} + for _, bits := range bitSizes { + key, err := mtls.GenerateKeyRSA(rand.Reader, bits) + if err != nil { + t.Fatalf("failed to generate %d RSA private key: %v", bits, err) + } + + s := key.String() + key2, err := mtls.ParsePrivateKey(s) + if err != nil { + t.Fatalf("failed to unmarshal %d RSA private key %s: %v", bits, s, err) + } + if k := key.Private().(*rsa.PrivateKey); !k.Equal(key2.Private()) { + t.Fatalf("private keys are not equal: %s != %s", key, key2) + } + } +} + // TestPrivateKey_Identity checks that a certificate's public key identity of matches the // identity of the corresponding private key. func TestPrivateKey_Identity(t *testing.T) { + t.Parallel() + for _, test := range privateKeyIdentityTests { key, err := mtls.ParsePrivateKey(test.PrivateKey) if err != nil { @@ -116,4 +143,8 @@ var privateKeyIdentityTests = []struct { Filename: "./testdata/certs/p-521.crt", PrivateKey: "k2:AT7JYw3tnjgYhqplUPiJbITqAdgo4IuDf9talnHivzMeoEsVR60Vidpl93zAdweZApsStCEpHVPtwGAD2UoGI0o0", }, + { + Filename: "./testdata/certs/rsa.crt", + PrivateKey: "k3:AAQAAQABAIDIgYr4PXOsMUpeV-tjoVEs5h6iCudZAkbWA8o3m9C40tIc3ZKPxANNrNDQBv_aQxJsbEvZ8CWgRKuSP3kcFZq1f96Zra99PBvrypAlhtwzehmDUIDJQwlshNIozBRTL_zHFZDKIjMSwiag3bWQmm_ivuNfL-WWIkUpXNU1NvEsWwCAtkeD1ZsCAF1t-_wPl-iHspI-qLz43B1Op2Z7ja-aIYvViYig7a7e_yyr_RR3PePpOiQMcz4HMtWkf3BbyGwSZWm4wlIcNBrLP0oEpDx1sIh1D7yg9aquDiyYuQwFjZwTMP0Vy4KSoz3U1USM-EEICTyM8qBsBp8mGUkOl26ZS2kBAERYqSbToPba3HeNFITdv9_9PLoUsFYl3wKFLNdDDNredfGpkmgeyGl4BLOJ_BR2tNDWbzspLJ45slAe9e-FB5VIgk7lwd_eHBuNSODbUpTkNmge6ZEunQa3j5Yuvh9LxBg-Gwbnm3kSdt8Q0YqrZTwSZMiogAN6aqI1GwjNRHx_8M9718mSGAmlYWHAfXsgxVr3VTMtZjYmGB2_Slu46M4k58Mf2QrOgP9iGz6ED7q2CL3_PIAT1Ce0qKf3UP8IgVm6ENJx5obKdq2UeyV3OQx5HL99_X74f6KjjVtd1XXjrxoSqBP_HZYce8K1FgWjpFMshsPTAHGrsmk_dseDfSk", + }, } diff --git a/testdata/certs/rsa.crt b/testdata/certs/rsa.crt new file mode 100644 index 0000000..2bf864a --- /dev/null +++ b/testdata/certs/rsa.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDFzCCAf+gAwIBAgIUAf4bnPzWfMFQ5Av9moUaUMLPfLcwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQVGVzdCBDZXJ0ZmljaWF0ZTAeFw0yNTEyMjIxMDM3Mjda +Fw0yNjEyMjIxMDM3MjdaMBsxGTAXBgNVBAMMEFRlc3QgQ2VydGZpY2lhdGUwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCOxBv7ndbv7a8/ta8U9biT7KKc +9ZqoY57bNM6eRM9ZLocpuk9yS68CzFK//YQzHhL3k85BpXwlVyTJG/eVVKbIVzzr +Ap8VIxTfkKzmmu+fHuPjoc3ksRAMX+SQ0T6YinH/EDPvbNJ4s+7DaL6JSoYhKlFq +dIPO6n8fMAIjFILgWsVS1hCv+bIODGAdPMEKOgHpz7wDowGe/R8nxqehgJHZPqxc +J1NZ40P3j2bclzVBXWOEfjsZ+6hMYclK8ooxJWZKsEM187kUjt69k+zR3gBi33YW +AM1Ak4meLMSdvetSEzdor7+3GaC/Db3j1JM3tXGvqyH5MinrlZWUpMvNTNpTAgMB +AAGjUzBRMB0GA1UdDgQWBBSbFFzarsYhnRLIO1b5YMWBOnNbyDAfBgNVHSMEGDAW +gBSbFFzarsYhnRLIO1b5YMWBOnNbyDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 +DQEBCwUAA4IBAQAuvtAAIFTdrGGogWIUdju+T1DJfL553dHW61BhnosUpQOJz0h+ +/IzP99hS8IgU3/qcHkAMuz/udaV5pX9bup6qMZCWZTbeGIDQnqJh4Vw3P6Emj5Xm +r+dNcTrlAtM59xlrSKvsdf2QDvCRhXDGn1ewBcLX3BypIzVEOXNtMdqbCH/jIfTF +bLhgzHo8WK/MIavR1ktHVvW81iIYmNmj/+ge3dh324SX3U4Vwn1cE6KjB8Vga92Z +5BFevEH4K31+8qpIISmVHrl8ucuFpBvmHN+hoe+zmIRT5suHQ2nugB5f5Z6AeO9N +NW/ZEHiIJ7GtCKPiWSHV4BVqKJ827I3o1JOX +-----END CERTIFICATE-----