grafana / xk6-webcrypto

WIP implementation of the WebCrypto specification for k6
GNU Affero General Public License v3.0
7 stars 4 forks source link

Interoperability with javax.crypto #63

Closed grant-arqit closed 7 months ago

grant-arqit commented 8 months ago

I have a service which encrypts data in a kotlin appliction with the following function.

import java.nio.ByteBuffer
import java.security.SecureRandom
import java.util.*
import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec

fun main() {
    val encodedKey = "U5yzshQRpa1aplMWK/QvbUXlHQyyVk+Zwxz8fyLKRLQ="
    val keyBytes = Base64.getDecoder().decode(encodedKey)
    val key = SecretKeySpec(keyBytes, "AES")

    val secret = "MySecret"
    val secretBytes = secret.toByteArray()
    val encryptedSecret = encrypt(secretBytes, key)
    println("Encrypted/Encoded string: ${Base64.getEncoder().encodeToString(encryptedSecret)}")

}

fun encrypt(plainText: ByteArray, key: SecretKey): ByteArray {
    val iv = ByteArray(12)
    SecureRandom().nextBytes(iv)
    val cipherInstance = Cipher.getInstance("AES/GCM/NoPadding")
    val gcmParameterSpec = GCMParameterSpec(16 * 8, iv)
    cipherInstance.init(Cipher.ENCRYPT_MODE, key, gcmParameterSpec)
    val encrypted = cipherInstance.doFinal(plainText)

    val byteBuffer = ByteBuffer.allocate(iv.count() + encrypted.count())
    byteBuffer.put(iv)
    byteBuffer.put(encrypted)
    return byteBuffer.array()
}
}

The console output of the above standalone code is an encrypted, base64 encoded string. One example of the encrypted string is 0L5oGCBu1Jbe/TTcMu+4wVbK32vRK7UptQjC1zedbpL6Zq3j (each execution will always be different because of the iv prefix element based on a random value)

I've fed the encoded/encrypted string into the K6 test below and I get an OperationError when calling crypto.subtle.decrypt (the import function is fine).

 // using "buffer": "^6.0.3" package
import { crypto } from 'k6/experimental/webcrypto'
import { Buffer as IsomorphicBuffer } from 'buffer/'

class Buffer extends IsomorphicBuffer {}

const base64Decode = (base64String: string): Uint8Array => {
  return Uint8Array.from(Buffer.from(base64String, 'base64'))
}

const byteArrayToString = (byteArray: Uint8Array): string => {
  return Buffer.from(byteArray).toString('utf8')
}

const decryptWithPreshared = async (encodedKey: string, encryptedEncodedData: string ): Promise<string> => {
  const encryptedData = base64Decode(encryptedEncodedData)
  const key = base64Decode(encodedKey)
  const iv = encryptedData.subarray(0, 12)
  const dataToDecrypt = encryptedData.subarray(12)
  const cryptoKey = await crypto.subtle.importKey('raw', key, "AES-GCM", false,  ['decrypt'])
  const cipher: ArrayBuffer = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, cryptoKey, dataToDecrypt)
  return byteArrayToString(new Uint8Array(cipher))
}

export const options = {
  iterations: 1
};

export default async () => {
  console.log(`Decrypted string: ${await decryptWithPreshared('U5yzshQRpa1aplMWK/QvbUXlHQyyVk+Zwxz8fyLKRLQ=', '0L5oGCBu1Jbe/TTcMu+4wVbK32vRK7UptQjC1zedbpL6Zq3j')}`)
  // OperationError thrown when executing above statement
}

Are you able advise what may be going wrong here and if there are any workarounds for a K6 test? Are there any known interoperability issues either between javax.crypto libraries and k6 webcrypto? The buffer npm libs used in the decode and byteArray to string routines successfully execute in the test, but could the conversion / decoded output be incompatible when supplying the output of these functions as inputs to k6 webcrypto? Incidently when decrypting this in a nodejs runtime environment with the isomorphic-webcrypto implementation, decryption works fine without error.

grant-arqit commented 8 months ago

Here's a working version using standard node webcrypto

import { Buffer as IsomorphicBuffer } from 'buffer/'
const { subtle } = globalThis.crypto
class Buffer extends IsomorphicBuffer {}

const base64Decode = (base64String: string): Uint8Array => {
  return Uint8Array.from(Buffer.from(base64String, 'base64'))
}

const byteArrayToString = (byteArray: Uint8Array): string => {
  return Buffer.from(byteArray).toString('utf8')
}

const decryptWithPreshared = async (encodedKey: string, encryptedEncodedData: string ): Promise<string> => {
  const encryptedData = base64Decode(encryptedEncodedData)
  const key = base64Decode(encodedKey)
  const iv = encryptedData.subarray(0, 12)
  const dataToDecrypt = encryptedData.subarray(12)
  const cryptoKey = await subtle.importKey('raw', key, "AES-GCM", false,  ['decrypt'])
  const cipher: ArrayBuffer = await subtle.decrypt({ name: "AES-GCM", iv }, cryptoKey, dataToDecrypt)
  return byteArrayToString(new Uint8Array(cipher))
}

decryptWithPreshared('U5yzshQRpa1aplMWK/QvbUXlHQyyVk+Zwxz8fyLKRLQ=', '0L5oGCBu1Jbe/TTcMu+4wVbK32vRK7UptQjC1zedbpL6Zq3j')
  .then((txt) => {
    console.log(`Decrypted string: ${txt}`)
  })
olegbespalov commented 8 months ago

Hey @grant-arqit !

I checked the case, and there may be a mismatch in types or behavior in k6 vs. nodejs. I need to check deeply what exactly, but currently, it's that in the k6, you have to:

For instance, below, I did some minimal working version using the data that you've provided:

import { crypto } from "k6/experimental/webcrypto";
import encoding from 'k6/encoding';

const displayArrayBufferContent = (buffer) => {
   return [...new Uint8Array(buffer)]
     .map((x) => x.toString(10))
     .join(" ");
 }

 function arrayBufferToString(buffer) {
   return String.fromCharCode.apply(null, new Uint8Array(buffer));
 }

 const base64Decode = (base64String) => {
   return new Uint8Array(encoding.b64decode(base64String));
 }

 export default async function() {
   const keyData = base64Decode('U5yzshQRpa1aplMWK/QvbUXlHQyyVk+Zwxz8fyLKRLQ=');
   const key = await crypto.subtle.importKey('raw', keyData, "AES-GCM", false,  ['decrypt']);

    const encryptedData = base64Decode('0L5oGCBu1Jbe/TTcMu+4wVbK32vRK7UptQjC1zedbpL6Zq3j');
    const iv = new Uint8Array(encryptedData.subarray(0, 12));
    const ciphertext = new Uint8Array(encryptedData.subarray(12));

    console.log('iv: ' + displayArrayBufferContent(iv));
    console.log('ciphertext: ' + displayArrayBufferContent(ciphertext));

    const plaintext = await crypto.subtle.decrypt(
      {
        name: "AES-GCM",
        iv: iv,
      },
      key,
      ciphertext,
    );

    console.log("Decrypted string: '" + arrayBufferToString(plaintext) + "'")
}

that will result me with:

INFO[0000] iv: 208 190 104 24 32 110 212 150 222 253 52 220  source=console
INFO[0000] ciphertext: 50 239 184 193 86 202 223 107 209 43 181 41 181 8 194 215 55 157 110 146 250 102 173 227  source=console
INFO[0000] Decrypted string: 'MySecret'                  source=console

Let me know if that helps Cheers!

olegbespalov commented 8 months ago

I think we also should update the k6's webcrypto docs to showcase such import :thinking:

grant-arqit commented 8 months ago

Thanks @olegbespalov , that's really useful feedback. I'll see if I can adapt our encoding libraries to use k6 encoding and see what further progress can be made.

grant-arqit commented 8 months ago

HI @olegbespalov. I have made some progress, however I am hitting on some k6 specific encoding issues by the looks of things. Do you know how url encoding is treated?

One of my strings which includes *^ characters is encodced to %2A%5E respectively as per RFC3986, but I get the following error.

ERRO[0000] Error: GoError: illegal base64 data at input byte 40

olegbespalov commented 8 months ago

Hey @grant-arqit !

Glad to hear that :relaxed:

Our encoding module is a wrapper around Golang's encoding. You have some control over which encoding is used. See the documentation at https://grafana.com/docs/k6/latest/javascript-api/k6-encoding/b64decode/ or the Golang documentation at https://pkg.go.dev/encoding/base64#pkg-variables.

So I might think that you should use something like encoding.b64decode(enc, 'url').

I'm not sure about RFC3986. Since it's about Uniform Resource Identifier (URI). Golang, base64 implementation is based on https://www.rfc-editor.org/rfc/rfc4648.html.

grant-arqit commented 7 months ago

I've managed to resolve my crypto / encoding issues now. Thanks very much your help.