github.com/gravitational/teleport/api@v0.0.0-20240507183017-3110591cbafc/utils/sshutils/ppk/ppk.go (about)

     1  /*
     2  Copyright 2021 Gravitational, Inc.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  // Package ppk provides functions implementing conversion between Teleport's native RSA
    18  // keypairs and PuTTY's PPK format. It also provides functions for working with RFC4251-formatted
    19  // mpints and strings.
    20  package ppk
    21  
    22  import (
    23  	"bytes"
    24  	"crypto/hmac"
    25  	"crypto/rsa"
    26  	"crypto/sha256"
    27  	"encoding/base64"
    28  	"encoding/binary"
    29  	"encoding/hex"
    30  	"fmt"
    31  	"math/big"
    32  
    33  	"github.com/gravitational/trace"
    34  
    35  	"github.com/gravitational/teleport/api/constants"
    36  )
    37  
    38  // ConvertToPPK takes a regular RSA-formatted keypair and converts it into the PPK file format used by the PuTTY SSH client.
    39  // The file format is described here: https://the.earth.li/~sgtatham/putty/0.76/htmldoc/AppendixC.html#ppk
    40  func ConvertToPPK(privateKey *rsa.PrivateKey, pub []byte) ([]byte, error) {
    41  	// https://the.earth.li/~sgtatham/putty/0.76/htmldoc/AppendixC.html#ppk
    42  	// 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
    43  	// hashes other than SHA-1. The public key data has already provided the key modulus and the public encoding exponent. The private data stores:
    44  	// mpint: the private decoding exponent of the key.
    45  	// mpint: one prime factor p of the key.
    46  	// mpint: the other prime factor q of the key. (RSA keys stored in this format are expected to have exactly two prime factors.)
    47  	// mpint: the multiplicative inverse of q modulo p.
    48  	ppkPrivateKey := new(bytes.Buffer)
    49  
    50  	// mpint: the private decoding exponent of the key.
    51  	// this is known as 'D'
    52  	binary.Write(ppkPrivateKey, binary.BigEndian, getRFC4251Mpint(privateKey.D))
    53  
    54  	// mpint: one prime factor p of the key.
    55  	// this is known as 'P'
    56  	// the RSA standard dictates that P > Q
    57  	// for some reason what PuTTY names 'P' is Primes[1] to Go, and what PuTTY names 'Q' is Primes[0] to Go
    58  	P, Q := privateKey.Primes[1], privateKey.Primes[0]
    59  	binary.Write(ppkPrivateKey, binary.BigEndian, getRFC4251Mpint(P))
    60  
    61  	// mpint: the other prime factor q of the key. (RSA keys stored in this format are expected to have exactly two prime factors.)
    62  	// this is known as 'Q'
    63  	binary.Write(ppkPrivateKey, binary.BigEndian, getRFC4251Mpint(Q))
    64  
    65  	// mpint: the multiplicative inverse of q modulo p.
    66  	// this is known as 'iqmp'
    67  	iqmp := new(big.Int).ModInverse(Q, P)
    68  	binary.Write(ppkPrivateKey, binary.BigEndian, getRFC4251Mpint(iqmp))
    69  
    70  	// now we need to base64-encode the PPK-formatted private key which is made up of the above values
    71  	ppkPrivateKeyBase64 := make([]byte, base64.StdEncoding.EncodedLen(ppkPrivateKey.Len()))
    72  	base64.StdEncoding.Encode(ppkPrivateKeyBase64, ppkPrivateKey.Bytes())
    73  
    74  	// read Teleport public key
    75  	// 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
    76  	// remove ssh-rsa plus additional space from beginning of string if present
    77  	if !bytes.HasPrefix(pub, []byte(constants.SSHRSAType+" ")) {
    78  		return nil, trace.BadParameter("pub does not appear to be an ssh-rsa public key")
    79  	}
    80  	pub = bytes.TrimSuffix(bytes.TrimPrefix(pub, []byte(constants.SSHRSAType+" ")), []byte("\n"))
    81  
    82  	// the PPK file contains an anti-tampering MAC which is made up of various values which appear in the file.
    83  	// copied from Section C.3 of https://the.earth.li/~sgtatham/putty/0.76/htmldoc/AppendixC.html#ppk:
    84  	// 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:
    85  	// string: the algorithm-name header field.
    86  	// string: the encryption-type header field.
    87  	// string: the key-comment-string header field.
    88  	// string: the binary public key data, as decoded from the base64 lines after the 'Public-Lines' header.
    89  	// string: the plaintext of the binary private key data, as decoded from the base64 lines after the 'Private-Lines' header.
    90  
    91  	// these values are also used in the MAC generation, so we declare them as variables
    92  	keyType := constants.SSHRSAType
    93  	encryptionType := "none"
    94  	// as work for the future, it'd be nice to get the proxy/user pair name in here to make the name more
    95  	// of a unique identifier. this has to be done at generation time because the comment is part of the MAC
    96  	fileComment := "teleport-generated-ppk"
    97  
    98  	// string: the algorithm-name header field.
    99  	macKeyType := getRFC4251String([]byte(keyType))
   100  	// create a buffer to hold the elements needed to generate the MAC
   101  	macInput := new(bytes.Buffer)
   102  	binary.Write(macInput, binary.LittleEndian, macKeyType)
   103  
   104  	// string: the encryption-type header field.
   105  	macEncryptionType := getRFC4251String([]byte(encryptionType))
   106  	binary.Write(macInput, binary.BigEndian, macEncryptionType)
   107  
   108  	// string: the key-comment-string header field.
   109  	macComment := getRFC4251String([]byte(fileComment))
   110  	binary.Write(macInput, binary.BigEndian, macComment)
   111  
   112  	// base64-decode the Teleport public key, as we need its binary representation to generate the MAC
   113  	decoded := make([]byte, base64.StdEncoding.EncodedLen(len(pub)))
   114  	n, err := base64.StdEncoding.Decode(decoded, pub)
   115  	if err != nil {
   116  		return nil, trace.Errorf("could not base64-decode public key: %v, got %v bytes successfully", err, n)
   117  	}
   118  	decoded = decoded[:n]
   119  	// append the decoded public key bytes to the MAC buffer
   120  	macPublicKeyData := getRFC4251String(decoded)
   121  	binary.Write(macInput, binary.BigEndian, macPublicKeyData)
   122  
   123  	// append our PPK-formatted private key bytes to the MAC buffer
   124  	macPrivateKeyData := getRFC4251String(ppkPrivateKey.Bytes())
   125  	binary.Write(macInput, binary.BigEndian, macPrivateKeyData)
   126  
   127  	// as per the PPK spec, the key for the MAC is blank when the PPK file is unencrypted.
   128  	// therefore, the key is a zero-length byte slice.
   129  	hmacHash := hmac.New(sha256.New, []byte{})
   130  	// generate the MAC using HMAC-SHA-256
   131  	hmacHash.Write(macInput.Bytes())
   132  	macString := hex.EncodeToString(hmacHash.Sum(nil))
   133  
   134  	// build the string-formatted output PPK file
   135  	ppk := new(bytes.Buffer)
   136  	fmt.Fprintf(ppk, "PuTTY-User-Key-File-3: %v\n", keyType)
   137  	fmt.Fprintf(ppk, "Encryption: %v\n", encryptionType)
   138  	fmt.Fprintf(ppk, "Comment: %v\n", fileComment)
   139  	// chunk the Teleport-formatted public key into 64-character length lines
   140  	chunkedPublicKey := chunk(string(pub), 64)
   141  	fmt.Fprintf(ppk, "Public-Lines: %v\n", len(chunkedPublicKey))
   142  	for _, r := range chunkedPublicKey {
   143  		fmt.Fprintf(ppk, "%s\n", r)
   144  	}
   145  	// chunk the PPK-formatted private key into 64-character length lines
   146  	chunkedPrivateKey := chunk(string(ppkPrivateKeyBase64), 64)
   147  	fmt.Fprintf(ppk, "Private-Lines: %v\n", len(chunkedPrivateKey))
   148  	for _, r := range chunkedPrivateKey {
   149  		fmt.Fprintf(ppk, "%s\n", r)
   150  	}
   151  	fmt.Fprintf(ppk, "Private-MAC: %v\n", macString)
   152  
   153  	return ppk.Bytes(), nil
   154  }
   155  
   156  // chunk converts a string into a []string with chunks of size chunkSize;
   157  // used to split base64-encoded strings across multiple lines with an even width.
   158  // note: this function operates on Unicode code points rather than bytes, therefore
   159  // using it with multi-byte characters will result in unevenly chunked strings.
   160  // it's intended usage is only for chunking base64-encoded strings.
   161  func chunk(s string, size int) []string {
   162  	var chunks []string
   163  	for b := []byte(s); len(b) > 0; {
   164  		n := size
   165  		if n > len(b) {
   166  			n = len(b)
   167  		}
   168  		chunks = append(chunks, string(b[:n]))
   169  		b = b[n:]
   170  	}
   171  	return chunks
   172  }
   173  
   174  // getRFC4251Mpint returns a stream of bytes representing a mixed-precision integer (a big.Int in Go)
   175  // prepended with a big-endian uint32 expressing the length of the data following.
   176  // This is the 'mpint' format in RFC4251 Section 5 (https://datatracker.ietf.org/doc/html/rfc4251#section-5)
   177  func getRFC4251Mpint(n *big.Int) []byte {
   178  	buf := new(bytes.Buffer)
   179  	b := n.Bytes()
   180  	// RFC4251: If the most significant bit would be set for a positive number, the number MUST be preceded by a zero byte.
   181  	if b[0]&0x80 > 0 {
   182  		b = append([]byte{0}, b...)
   183  	}
   184  	// write a uint32 with the length of the byte stream to the buffer
   185  	binary.Write(buf, binary.BigEndian, uint32(len(b)))
   186  	// write the byte stream representing of the rest of the integer to the buffer
   187  	binary.Write(buf, binary.BigEndian, b)
   188  	return buf.Bytes()
   189  }
   190  
   191  // getRFC4251String returns a stream of bytes representing a string prepended with a big-endian unit32
   192  // expressing the length of the data following.
   193  // This is the 'string' format in RFC4251 Section 5 (https://datatracker.ietf.org/doc/html/rfc4251#section-5)
   194  func getRFC4251String(data []byte) []byte {
   195  	buf := new(bytes.Buffer)
   196  	// write a uint32 with the length of the byte stream to the buffer
   197  	binary.Write(buf, binary.BigEndian, uint32(len(data)))
   198  	// write the byte stream representing of the rest of the data to the buffer
   199  	for _, v := range data {
   200  		binary.Write(buf, binary.BigEndian, v)
   201  	}
   202  	return buf.Bytes()
   203  }