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

Add support for handshake and KMS challenge #14

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

rupertbg
Copy link

@rupertbg rupertbg commented May 17, 2022

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);
}

@rupertbg rupertbg mentioned this pull request May 17, 2022
@ToshipSo
Copy link

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

@sergiosilvajr
Copy link

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
Copy link

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
Copy link
Author

rupertbg commented May 2, 2023

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
Copy link

sergiosilvajr commented May 17, 2023

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
Copy link
Author

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

@guimilleo
Copy link

does anybody know what happened w @bertrandmartel ?

@rupertbg
Copy link
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants