Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: PuTTY PPK support for non-RSA keys #48650

Merged
merged 1 commit into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 2 additions & 9 deletions api/utils/keys/privatekey.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,15 +173,8 @@ func TLSCertificateForSigner(signer crypto.Signer, certPEMBlock []byte) (tls.Cer

// PPKFile returns a PuTTY PPK-formatted keypair
func (k *PrivateKey) PPKFile() ([]byte, error) {
rsaKey, ok := k.Signer.(*rsa.PrivateKey)
if !ok {
return nil, trace.BadParameter("only RSA keys are supported for PPK files, found private key of type %T", k.Signer)
}
ppkFile, err := ppk.ConvertToPPK(rsaKey, k.MarshalSSHPublicKey())
if err != nil {
return nil, trace.Wrap(err)
}
return ppkFile, nil
ppkFile, err := ppk.ConvertToPPK(k.Signer, k.sshPub)
return ppkFile, trace.Wrap(err)
}

// SoftwarePrivateKeyPEM returns the PEM encoding of the private key. If the key
Expand Down
289 changes: 157 additions & 132 deletions api/utils/sshutils/ppk/ppk.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,132 +21,59 @@ package ppk

import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/hmac"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"math/big"

"github.com/gravitational/trace"
"golang.org/x/crypto/ssh"
)

"github.com/gravitational/teleport/api/constants"
const (
encryptionType = "none"
// As work for the future, it'd be nice to get the proxy/user pair name in here to make the name more
// of a unique identifier. this has to be done at generation time because the comment is part of the MAC
fileComment = "teleport-generated-ppk"
)

// ConvertToPPK takes a regular RSA-formatted keypair and converts it into the PPK file format used by the PuTTY SSH client.
// ConvertToPPK takes a regular SSH keypair and converts it into the PPK file format used by the PuTTY SSH client.
// The file format is described here: https://the.earth.li/~sgtatham/putty/0.76/htmldoc/AppendixC.html#ppk
//
// TODO(nklaassen): support Ed25519 and ECDSA keys. The file format supports it,
// we just don't support writing them here.
func ConvertToPPK(privateKey *rsa.PrivateKey, pub []byte) ([]byte, error) {
// https://the.earth.li/~sgtatham/putty/0.76/htmldoc/AppendixC.html#ppk
// RSA keys are stored using an algorithm-name of 'ssh-rsa'. (Keys stored like this are also used by the updated RSA signature schemes that use
// hashes other than SHA-1. The public key data has already provided the key modulus and the public encoding exponent. The private data stores:
// mpint: the private decoding exponent of the key.
// mpint: one prime factor p of the key.
// mpint: the other prime factor q of the key. (RSA keys stored in this format are expected to have exactly two prime factors.)
// mpint: the multiplicative inverse of q modulo p.
ppkPrivateKey := new(bytes.Buffer)

// mpint: the private decoding exponent of the key.
// this is known as 'D'
binary.Write(ppkPrivateKey, binary.BigEndian, getRFC4251Mpint(privateKey.D))

// mpint: one prime factor p of the key.
// this is known as 'P'
// the RSA standard dictates that P > Q
// for some reason what PuTTY names 'P' is Primes[1] to Go, and what PuTTY names 'Q' is Primes[0] to Go
P, Q := privateKey.Primes[1], privateKey.Primes[0]
binary.Write(ppkPrivateKey, binary.BigEndian, getRFC4251Mpint(P))

// mpint: the other prime factor q of the key. (RSA keys stored in this format are expected to have exactly two prime factors.)
// this is known as 'Q'
binary.Write(ppkPrivateKey, binary.BigEndian, getRFC4251Mpint(Q))

// mpint: the multiplicative inverse of q modulo p.
// this is known as 'iqmp'
iqmp := new(big.Int).ModInverse(Q, P)
binary.Write(ppkPrivateKey, binary.BigEndian, getRFC4251Mpint(iqmp))

// now we need to base64-encode the PPK-formatted private key which is made up of the above values
ppkPrivateKeyBase64 := make([]byte, base64.StdEncoding.EncodedLen(ppkPrivateKey.Len()))
base64.StdEncoding.Encode(ppkPrivateKeyBase64, ppkPrivateKey.Bytes())

// read Teleport public key
// fortunately, this is the one thing that's in exactly the same format that the PPK file uses, so we can just copy it verbatim
// remove ssh-rsa plus additional space from beginning of string if present
if !bytes.HasPrefix(pub, []byte(constants.SSHRSAType+" ")) {
return nil, trace.BadParameter("pub does not appear to be an ssh-rsa public key")
func ConvertToPPK(privateKey crypto.Signer, pub ssh.PublicKey) ([]byte, error) {
var ppkPrivateKey bytes.Buffer
if err := writePrivateKey(&ppkPrivateKey, privateKey); err != nil {
return nil, trace.Wrap(err)
}
pub = bytes.TrimSuffix(bytes.TrimPrefix(pub, []byte(constants.SSHRSAType+" ")), []byte("\n"))

// the PPK file contains an anti-tampering MAC which is made up of various values which appear in the file.
// copied from Section C.3 of https://the.earth.li/~sgtatham/putty/0.76/htmldoc/AppendixC.html#ppk:
// hex-mac-data is a hexadecimal-encoded value, 64 digits long (i.e. 32 bytes), generated using the HMAC-SHA-256 algorithm with the following binary data as input:
// string: the algorithm-name header field.
// string: the encryption-type header field.
// string: the key-comment-string header field.
// string: the binary public key data, as decoded from the base64 lines after the 'Public-Lines' header.
// string: the plaintext of the binary private key data, as decoded from the base64 lines after the 'Private-Lines' header.

// these values are also used in the MAC generation, so we declare them as variables
keyType := constants.SSHRSAType
encryptionType := "none"
// as work for the future, it'd be nice to get the proxy/user pair name in here to make the name more
// of a unique identifier. this has to be done at generation time because the comment is part of the MAC
fileComment := "teleport-generated-ppk"

// string: the algorithm-name header field.
macKeyType := getRFC4251String([]byte(keyType))
// create a buffer to hold the elements needed to generate the MAC
macInput := new(bytes.Buffer)
binary.Write(macInput, binary.LittleEndian, macKeyType)

// string: the encryption-type header field.
macEncryptionType := getRFC4251String([]byte(encryptionType))
binary.Write(macInput, binary.BigEndian, macEncryptionType)

// string: the key-comment-string header field.
macComment := getRFC4251String([]byte(fileComment))
binary.Write(macInput, binary.BigEndian, macComment)
ppkPrivateKeyBase64 := base64.StdEncoding.EncodeToString(ppkPrivateKey.Bytes())
ppkPublicKeyBase64 := base64.StdEncoding.EncodeToString(pub.Marshal())

// base64-decode the Teleport public key, as we need its binary representation to generate the MAC
decoded := make([]byte, base64.StdEncoding.EncodedLen(len(pub)))
n, err := base64.StdEncoding.Decode(decoded, pub)
// Compute the anti-tampering MAC.
macString, err := computeMAC(pub, ppkPrivateKey.Bytes())
if err != nil {
return nil, trace.Errorf("could not base64-decode public key: %v, got %v bytes successfully", err, n)
return nil, trace.Wrap(err)
}
decoded = decoded[:n]
// append the decoded public key bytes to the MAC buffer
macPublicKeyData := getRFC4251String(decoded)
binary.Write(macInput, binary.BigEndian, macPublicKeyData)

// append our PPK-formatted private key bytes to the MAC buffer
macPrivateKeyData := getRFC4251String(ppkPrivateKey.Bytes())
binary.Write(macInput, binary.BigEndian, macPrivateKeyData)

// as per the PPK spec, the key for the MAC is blank when the PPK file is unencrypted.
// therefore, the key is a zero-length byte slice.
hmacHash := hmac.New(sha256.New, []byte{})
// generate the MAC using HMAC-SHA-256
hmacHash.Write(macInput.Bytes())
macString := hex.EncodeToString(hmacHash.Sum(nil))

// build the string-formatted output PPK file

// Build the string-formatted output PPK file.
ppk := new(bytes.Buffer)
fmt.Fprintf(ppk, "PuTTY-User-Key-File-3: %v\n", keyType)
fmt.Fprintf(ppk, "PuTTY-User-Key-File-3: %v\n", pub.Type())
fmt.Fprintf(ppk, "Encryption: %v\n", encryptionType)
fmt.Fprintf(ppk, "Comment: %v\n", fileComment)
// chunk the Teleport-formatted public key into 64-character length lines
chunkedPublicKey := chunk(string(pub), 64)
// Chunk the base64-encoded public key into 64-character length lines.
chunkedPublicKey := chunk(ppkPublicKeyBase64, 64)
fmt.Fprintf(ppk, "Public-Lines: %v\n", len(chunkedPublicKey))
for _, r := range chunkedPublicKey {
fmt.Fprintf(ppk, "%s\n", r)
}
// chunk the PPK-formatted private key into 64-character length lines
chunkedPrivateKey := chunk(string(ppkPrivateKeyBase64), 64)
// Chunk the PPK-formatted private key into 64-character length lines.
chunkedPrivateKey := chunk(ppkPrivateKeyBase64, 64)
fmt.Fprintf(ppk, "Private-Lines: %v\n", len(chunkedPrivateKey))
for _, r := range chunkedPrivateKey {
fmt.Fprintf(ppk, "%s\n", r)
Expand All @@ -156,6 +83,135 @@ func ConvertToPPK(privateKey *rsa.PrivateKey, pub []byte) ([]byte, error) {
return ppk.Bytes(), nil
}

// computeMAC computes an anti-tampering MAC which is made up of various values which appear in the PPK file.
// Copied from Section C.2 of https://the.earth.li/~sgtatham/putty/0.76/htmldoc/AppendixC.html#ppk:
// hex-mac-data is a hexadecimal-encoded value, 64 digits long (i.e. 32 bytes), generated using the HMAC-SHA-256 algorithm with the following binary data as input:
// string: the algorithm-name header field.
// string: the encryption-type header field.
// string: the key-comment-string header field.
// string: the binary public key data, as decoded from the base64 lines after the 'Public-Lines' header.
// string: the plaintext of the binary private key data, as decoded from the base64 lines after the 'Private-Lines' header.
func computeMAC(pub ssh.PublicKey, rawPrivateKey []byte) (string, error) {
// Generate the MAC using HMAC-SHA-256. As per the PPK spec, the key for the
// MAC is blank when the PPK file is unencrypted.
var hmacKey []byte
hmacHash := hmac.New(sha256.New, hmacKey)
if err := writeRFC4251Strings(hmacHash,
[]byte(pub.Type()), // the algorithm-name header field
[]byte(encryptionType), // the encryption-type header field
[]byte(fileComment), // the key-comment-string header field
pub.Marshal(), // the binary public-key data
rawPrivateKey, // the plaintext of the binary private key data
); err != nil {
return "", trace.Wrap(err)
}
return hex.EncodeToString(hmacHash.Sum(nil)), nil
}

func writePrivateKey(w io.Writer, signer crypto.Signer) error {
switch k := signer.(type) {
case *rsa.PrivateKey:
return trace.Wrap(writeRSAPrivateKey(w, k))
case *ecdsa.PrivateKey:
return trace.Wrap(writeECDSAPrivateKey(w, k))
case ed25519.PrivateKey:
return trace.Wrap(writeEd25519PrivateKey(w, k))
}
return trace.BadParameter("unsupported private key type %T", signer)
}

func writeRSAPrivateKey(w io.Writer, privateKey *rsa.PrivateKey) error {
// https://the.earth.li/~sgtatham/putty/0.76/htmldoc/AppendixC.html#ppk
// RSA keys are stored using an algorithm-name of 'ssh-rsa'. (Keys stored like this are also used by the updated RSA signature schemes that use
// hashes other than SHA-1. The public key data has already provided the key modulus and the public encoding exponent. The private data stores:
// mpint: the private decoding exponent of the key.
// mpint: one prime factor p of the key.
// mpint: the other prime factor q of the key. (RSA keys stored in this format are expected to have exactly two prime factors.)
// mpint: the multiplicative inverse of q modulo p.

// For some reason what PuTTY names 'P' is Primes[1] to Go, and what PuTTY
// names 'Q' is Primes[0] to Go. RSA keys stored in this format are
// expected to have exactly two prime factors.
P, Q := privateKey.Primes[1], privateKey.Primes[0]
// The multiplicative inverse of q modulo p.
iqmp := new(big.Int).ModInverse(Q, P)
return trace.Wrap(writeRFC4251Mpints(w, privateKey.D, P, Q, iqmp))
}

func writeECDSAPrivateKey(w io.Writer, privateKey *ecdsa.PrivateKey) error {
// https://the.earth.li/~sgtatham/putty/0.76/htmldoc/AppendixC.html#ppk
// NIST elliptic-curve keys are stored using one of the following
// algorithm-name values, each corresponding to a different elliptic curve
// and key size:
// - ‘ecdsa-sha2-nistp256’
// - ‘ecdsa-sha2-nistp384’
// - ‘ecdsa-sha2-nistp521’
// The public key data has already provided the public elliptic curve point. The private key stores:
// mpint: the private exponent, which is the discrete log of the public point.
//
// crypto/ecdsa calls this D.
return trace.Wrap(writeRFC4251Mpint(w, privateKey.D))
}

func writeEd25519PrivateKey(w io.Writer, privateKey ed25519.PrivateKey) error {
// https://the.earth.li/~sgtatham/putty/0.76/htmldoc/AppendixC.html#ppk
// EdDSA elliptic-curve keys are stored using one of the following
// algorithm-name values, each corresponding to a different elliptic curve
// and key size:
// - ‘ssh-ed25519’
// - ‘ssh-ed448’
// The public key data has already provided the public elliptic curve point. The private key stores:
// mpint: the private exponent, which is the discrete log of the public point.
//
// crypto/ed25519 calls the private exponent the seed.
return trace.Wrap(writeRFC4251Mpint(w, new(big.Int).SetBytes(privateKey.Seed())))
}

func writeRFC4251Mpints(w io.Writer, ints ...*big.Int) error {
for _, n := range ints {
if err := writeRFC4251Mpint(w, n); err != nil {
return trace.Wrap(err)
}
}
return nil
}

// writeRFC4251Mpint writes a stream of bytes representing a big-endian
// mixed-precision integer (a big.Int in Go) in the 'mpint' format described in
// RFC4251 Section 5 (https://datatracker.ietf.org/doc/html/rfc4251#section-5)
func writeRFC4251Mpint(w io.Writer, n *big.Int) error {
b := n.Bytes()
// RFC4251: If the most significant bit would be set for a positive number, the number MUST be preceded by a zero byte.
if n.Sign() == 1 && b[0]&0x80 != 0 {
b = append([]byte{0}, b...)
}
return trace.Wrap(writeRFC4251String(w, b))
}

func writeRFC4251Strings(w io.Writer, strs ...[]byte) error {
for _, s := range strs {
if err := writeRFC4251String(w, s); err != nil {
return trace.Wrap(err)
}
}
return nil
}

// writeRFC4251String writes a stream of bytes prepended with a big-endian
// uint32 expressing the length of the data following.
// This is the 'string' format in RFC4251 Section 5 (https://datatracker.ietf.org/doc/html/rfc4251#section-5)
func writeRFC4251String(w io.Writer, s []byte) error {
// Write a uint32 with the length of the byte stream to the buffer.
if err := binary.Write(w, binary.BigEndian, uint32(len(s))); err != nil {
return trace.Wrap(err)
}
// Write the byte stream representing of the rest of the data to the buffer.
if _, err := io.Copy(w, bytes.NewReader(s)); err != nil {
return trace.Wrap(err)
}
return nil
}

// chunk converts a string into a []string with chunks of size chunkSize;
// used to split base64-encoded strings across multiple lines with an even width.
// note: this function operates on Unicode code points rather than bytes, therefore
Expand All @@ -173,34 +229,3 @@ func chunk(s string, size int) []string {
}
return chunks
}

// getRFC4251Mpint returns a stream of bytes representing a mixed-precision integer (a big.Int in Go)
// prepended with a big-endian uint32 expressing the length of the data following.
// This is the 'mpint' format in RFC4251 Section 5 (https://datatracker.ietf.org/doc/html/rfc4251#section-5)
func getRFC4251Mpint(n *big.Int) []byte {
buf := new(bytes.Buffer)
b := n.Bytes()
// RFC4251: If the most significant bit would be set for a positive number, the number MUST be preceded by a zero byte.
if b[0]&0x80 > 0 {
b = append([]byte{0}, b...)
}
// write a uint32 with the length of the byte stream to the buffer
binary.Write(buf, binary.BigEndian, uint32(len(b)))
// write the byte stream representing of the rest of the integer to the buffer
binary.Write(buf, binary.BigEndian, b)
return buf.Bytes()
}

// getRFC4251String returns a stream of bytes representing a string prepended with a big-endian unit32
// expressing the length of the data following.
// This is the 'string' format in RFC4251 Section 5 (https://datatracker.ietf.org/doc/html/rfc4251#section-5)
func getRFC4251String(data []byte) []byte {
buf := new(bytes.Buffer)
// write a uint32 with the length of the byte stream to the buffer
binary.Write(buf, binary.BigEndian, uint32(len(data)))
// write the byte stream representing of the rest of the data to the buffer
for _, v := range data {
binary.Write(buf, binary.BigEndian, v)
}
return buf.Bytes()
}
Loading
Loading