bertrandmartel / aws-ssm-session

Javascript library for starting an AWS SSM session compatible with Browser and NodeJS
MIT License
50 stars 9 forks source link

Add support for handshake and KMS challenge #14

Open rupertbg opened 2 years ago

rupertbg commented 2 years ago

This adds support for handling the handshake that runs when using SSM Session Manager or ECS Exec with KMS Encryption enabled.

Handling the encryption / decryption I've left out of scope but it looks something like this:

Handle RequestedClientActions where the KMSKeyId to use is supplied and respond to the handshake

if (agentMessage.payloadType === 5) {
      // message comes in as bytes so we decode it
      const decodedMessage = textDecoder.decode(agentMessage.payload)
      // message is a JSON string so we parse it
      const jsonMessage = JSON.parse(decodedMessage)
      // there's a list of objects which are the actions the client is expected to perform for the handshake to complete
      const reqActions = jsonMessage["RequestedClientActions"]
      // we find the KMSEncryption action and grab the KMSKeyId out of it
      const kmsAction = reqActions.find(x => x.ActionType == "KMSEncryption")
      if (kmsAction) {
        console.debug("KMS handshake requested")
        ecsExecKmsKeyId = kmsAction["ActionParameters"]["KMSKeyId"]
        // here we use the key id to setup our KMS requirements like generating a data key
        await setupExecKms()
      }
      console.debug("Sending handshake response")
      // we respond to the handshake request with the generated data keys ciphertext, so the agent can decrypt it and use it
      ssm.sendHandshakeResponse(ecsExecSocket, reqActions, ecsExecSetupCiphertextKey)
    }

You will need credentials for AWS KMS at this stage, so you can generate a data key

SSM requires you to request a 512 bit key from KMS, provide the ciphertext to the Agent via the handshake response, and then split the key into two 256 bit keys and use one for encrypt and one for decrypt (the agent at the other end does the same but in reverse.)

async function setupExecKms() {
  // key size in bytes (512 bits .. 256 bits * 2 keys)
  const keySize = 64;
  console.debug("Initializing KMS client")
  // setup your kms client with credentials from whereever you are getting them
  kmsClient = new AWS.KMS({
    region: "ap-southeast-2",
    credentials: {
      accessKeyId: ecsExecKmsCredentials["AccessKeyId"],
      secretAccessKey: ecsExecKmsCredentials["SecretAccessKey"],
      sessionToken: ecsExecKmsCredentials["SessionToken"],
      expiration: ecsExecKmsCredentials["Expiration"],
    },
  });
  console.debug("Generating data key")
  const genKeyResponse = await kmsClient.generateDataKey({
    KeyId: ecsExecKmsKeyId,
    NumberOfBytes: keySize,
    // the encryption context must be included because the agent will decrypt the key with this same context
    EncryptionContext: {
      "aws:ssm:SessionId": ecsExecSessionId,
      "aws:ssm:TargetId": ecsExecTargetId, // ecs:{cluster_name}_{task_id}_{container_id}
    },
  })
  ecsExecSetupCiphertextKey = genKeyResponse["CiphertextBlob"]
  let plaintext = genKeyResponse["Plaintext"]
  // take the plaintext key and split it to make two keys, one for encryption and one for decryption. the first part of the key must be used for decrypt on the client side, the agent uses the first half for encrypt.
  let decryptionKey = plaintext.slice(0, (keySize / 2))
  let encryptionKey = plaintext.slice((keySize / 2), keySize)
  ecsExecImportedDecryptKey = await window.crypto.subtle.importKey(
    "raw",
    decryptionKey,
    "AES-GCM",
    true,
    ["decrypt"]
  );
  ecsExecImportedEncryptKey = await window.crypto.subtle.importKey(
    "raw",
    encryptionKey,
    "AES-GCM",
    true,
    ["encrypt"]
  );

}

Handle the KMS challenge request payload by decrypting and re-encrypting a challenge value that is sent from the agent.

if (agentMessage.payloadType === 8) {
      console.debug("Handling KMS challenge request")
      const decodedMessage = textDecoder.decode(agentMessage.payload)
      const jsonMessage = JSON.parse(decodedMessage)
      // the challenge request is a json object with a single property "Challenge"
      const challenge = jsonMessage["Challenge"];
      // the challenge comes through as base64 but we need it as bytes
      const challengeData = new Uint8Array(atob(challenge).split("").map(c => c.charCodeAt(0)));
      // decrypt it
      const decryptedChallenge = await decryptExecText(challengeData)
      // re-encrypt it
      const encryptedChallenge = await encryptExecText(decryptedChallenge)
      console.debug("Sending KMS challenge response")
      // back into base64
      const challengeResponseb64 = btoa(String.fromCharCode(...encryptedChallenge))
      // back into a JSON object in the same shape
      const challengeResponseObj = { "Challenge": challengeResponseb64 }
      const challengeResponseJson = JSON.stringify(challengeResponseObj)
      // back from a string to bytes
      const encodedMessage = textEncoder.encode(challengeResponseJson)
      ssm.sendChallengeResponse(ecsExecSocket, encodedMessage);
    }

And don't forget to decrypt / encrypt data that you send and receive after that

Decrypt:

async function decryptExecText(ciphertext) {
  // the IV/Nonce (same thing here) is always the first 12 bytes
  const nonce = new Uint8Array(ciphertext.slice(0, 12))
  // subtlecrypto needs the nonce-less ciphertext
  const ciphertextNoNonce = new Uint8Array(ciphertext.slice(12, ciphertext.byteLength))
  const plaintext = await window.crypto.subtle.decrypt(
    {
      name: "AES-GCM",
      iv: nonce,
    },
    ecsExecImportedDecryptKey,
    ciphertextNoNonce
  )
  return plaintext
}
if (agentMessage.payloadType === 1) {
      // normal agent messages will need to be decrypted
      const decryptedMessage = await decryptExecText(agentMessage.payload)
      const decodedMessage = textDecoder.decode(decryptedMessage)
      ecsExecTerminal.write(decodedMessage);
    }

Encrypt:

async function encryptExecText(plaintext) {
  // create a random nonce / IV, needs to be 12 bytes
  const nonce = window.crypto.getRandomValues(new Uint8Array(12))
  let ciphertext = await window.crypto.subtle.encrypt(
    {
      name: "AES-GCM",
      iv: nonce,
    },
    ecsExecImportedEncryptKey,
    plaintext
  )
  ciphertext = new Uint8Array(ciphertext)
  // pack the nonce and the ciphertext back together into a single byte array
  let nonceAndCiphertext = new Uint8Array(nonce.byteLength + ciphertext.byteLength);
  nonceAndCiphertext.set(nonce);
  nonceAndCiphertext.set(ciphertext, nonce.byteLength);
  return nonceAndCiphertext
}

async function ecsExecTerminalOnData(data) {
  // encode and then encrypt all data as it comes in to be sent to the SSM agent
  const encodedText = textEncoder.encode(data);
  const encryptedText = await encryptExecText(encodedText)
  ssm.sendText(ecsExecSocket, encryptedText);
}
ToshipSo commented 1 year ago

I've tested the code and it works fine with the above documentation. @bertrandmartel Can you please merge this PR?

sergiosilvajr commented 1 year ago

I've tested the code and it works fine with the above documentation. @bertrandmartel Can you please merge this PR?

@ToshipSo , I tried here and the 'slice' function seems not working from my side. Is there any tip how to solve it?

sergiosilvajr commented 1 year ago

I've tested the code and it works fine with the above documentation. @bertrandmartel Can you please merge this PR?

@ToshipSo , I tried here and the 'slice' function seems not working from my side. Is there any tip how to solve it? ce.

. I can confirm the code still works like a charm! About my problem with slice:

`export function transformBlobIntoUInt8Array (blob) { const arr1 = []

for (let i = 0; i < size; i += 1) {
  arr1.push(blob[i])
}
return new Uint8Array(arr1)

}` I did by my own a code to conver a blob into a Uint8Array and it worked.

rupertbg commented 1 year ago

Hey @sergiosilvajr - yes decryptExecText(ciphertext) expects the ciphertext to be the payload from the decode function of this library (aws-ssm-session), when the payloadType === 1.

In most implementations you will still need to check the handshake request (5) for the KMSEncryption field to ensure the server is requesting an encrypted session, and fallback to plaintext communication if it's missing if that's desired.

Also the binaryType of the underlying WebSocket is set to "arraybuffer"

sergiosilvajr commented 1 year ago

Hey @sergiosilvajr - yes decryptExecText(ciphertext) expects the ciphertext to be the payload from the decode function of this library (aws-ssm-session), when the payloadType === 1.

In most implementations you will still need to check the handshake request (5) for the KMSEncryption field to ensure the server is requesting an encrypted session, and fallback to plaintext communication if it's missing if that's desired.

Also the binaryType of the underlying WebSocket is set to "arraybuffer"

Hi, @rupertbg , I am using the KMSEncryption described on this pr and it worked. At this moment I am doing some tests with the websocket using KMSEncryption but the handshaking fails when I have 2 or more users trying to use the interface with websockets to a ssm node at the same time. Any tips on how solve it?

rupertbg commented 1 year ago

@sergiosilvajr I'm not entirely sure off the top of my head but it sounds like potentially an issue with the messageSequenceNumber?

guimilleo commented 1 year ago

does anybody know what happened w @bertrandmartel ?

rupertbg commented 1 year ago

does anybody know what happened w @bertrandmartel ?

Not sure, but you could always use my fork or make a new fork if you want to fix a bug or add new features.