Decryption of app context with Web Crypto API

Zoom Apps Configuration
NextJS Edge Function (middleware) (probably not revelant)

Description
I’ve managed to successfully employ the NodeJS example for decrypting the x-zoom-app-context header, but I’m struggling to port this to Web Crypto API.

Error?
“Unsupported state or unable to authenticate data”

I use the same unpack function found here, and have confirmed that between NodeJS Crypto and Web Crypto API the same bytes are passed in for all parameters:

async function decryptCtxWithHash(cipherText, hash, iv, aad, tag) {
  const key = await subtle.importKey("raw", hash, "aes-gcm", false, [
    "decrypt",
  ]);
  const deciphered = await subtle.decrypt(
    { name: "AES-GCM", iv, additionalData: aad, tagLength: tag.byteLength * 8 },
    key,
    cipherText
  ); //Fails with "Unsupported state or unable to authenticate data"
  const value = deciphered.slice(0, deciphered.byteLength - tag.byteLength);
  return JSON.parse(new TextDecoder().decode(value));
}

The key seems to be imported correctly too

CryptoKey {
  type: 'secret',
  extractable: false,
  algorithm: { name: 'AES-GCM', length: 256 },
  usages: [ 'decrypt' ]
}

@pbowen Does using a different encoding help to work around this issue?

Here’s an example of stack overflow:

Also you can see another implementation of the unpack function in the Basic Sample App.

Out of curiosity, what is the use case for checking this header client side? Are you looking to use the getAppContext() function in a serverless context?

Hi, Max, I’m unsure which encoding you’re referring to?

Like I said, the unpack function is fine, and I managed to decrypt the header in NodeJS just fine, but NextJS Edge Functions only have Web Crypto API available, which is a preferred position in our stack rather than a NodeJS API.
The exact same bytes go into that decryptCtxWithHash function for NodeJS and NextJS, but the NextJS one fails with that error.

(Apologies, I didn’t realise there was a reply function before)
Do you have a suggestion for a different encoding I could try?

I tried testing this on my end but I suppose I don’t have a good understanding of how this is configured on your end.

Are you using the Edge runtime to run this logic on the backend or are you attempting to decrypt this on the client side?

I’m working to reproduce this using the Basic Sample App.

Following this query to resolve my issue.

Can you share your use case for using the web crypto API? Likewise, are you able to provide a code sample that you’re using?

My team chose to situate the decryption logic in our NodeJS API rather than our NextJS middleware, to circumvent this issue. Here is the original middleware I tried to use, in full:

import { NextRequest, NextResponse } from "next/server";

export type ZoomAppContext = {
  uid: string;
  aud: string;
  iss: "marketplace.zoom.us" | string;
  ts: number;
  exp: number;
} & (
  | { typ: "panel" }
  | { typ: "meeting"; mid: string; attendrole: "host" | string }
);

export function ZoomMiddleware(request: NextRequest) {
  //Detect and decrypt Zoom app context
  const zoomAppCtx = request.headers.get("x-zoom-app-context");
  if (zoomAppCtx) {
    const decrypted = decryptAppContext(
      zoomAppCtx,
      process.env.ZOOM_CLIENT_SECRET
    );
    console.log("decrypted", decrypted);
  }
  return NextResponse.next();
}

/**
 * Decode and parse a base64 encoded Zoom App Context
 * @param ctx - Encoded Zoom App Context
 * @return Decoded Zoom App Context object
 */
function unpack(ctx: string) {
  // Decode base64
  let buf = Buffer.from(ctx, "base64");

  // Get iv length (1 byte)
  const ivLength = buf.readUInt8();
  buf = buf.subarray(1);

  // Get iv
  const iv = buf.subarray(0, ivLength);
  buf = buf.subarray(ivLength);

  // Get aad length (2 bytes)
  const aadLength = buf.readUInt16LE();
  buf = buf.subarray(2);

  // Get aad
  const aad = buf.subarray(0, aadLength);
  buf = buf.subarray(aadLength);

  // Get cipher length (4 bytes)
  const cipherLength = buf.readInt32LE();
  buf = buf.subarray(4);

  // Get cipherText
  const cipherText = buf.subarray(0, cipherLength);

  // Get tag
  const tag = buf.subarray(cipherLength);

  return { iv, aad, cipherText, tag };
}

/**
 * Decrypts cipherText from a decoded Zoom App Context object
 * @param cipherText - Data to decrypt
 * @param hash - sha256 hash of the Client Secret
 * @param iv - Initialization Vector for cipherText
 * @param aad - Additional Auth Data for cipher
 * @param tag - cipherText auth tag
 */
async function decryptCtxWithHash(
  cipherText: Buffer,
  hash: ArrayBuffer,
  iv: Buffer,
  aad: Buffer,
  tag: Buffer
) {
  const key = await crypto.subtle.importKey("raw", hash, "aes-gcm", false, [
    "decrypt",
  ]);
  // AES/GCM decryption
  const deciphered = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv, additionalData: aad, tagLength: tag.byteLength * 8,  },
    key,
    cipherText
  );
  const value = deciphered.slice(0, deciphered.byteLength - tag.byteLength);
  console.log("value", value);
  return JSON.parse(new TextDecoder().decode(value)) as ZoomAppContext;
}

/**
 * Decodes, parses and decrypts the x-zoom-app-context header
 * @see https://marketplace.zoom.us/docs/beta-docs/zoom-apps/zoomappcontext#decrypting-the-header-value
 * @param header - Encoded Zoom App Context header
 * @param key - Client Secret for the Zoom App
 * @return Decrypted Zoom App Context or Error
 */
async function decryptAppContext(header: string, key = "") {
  // Decode and parse context
  const { iv, aad, cipherText, tag } = unpack(header);

  // Create sha256 hash from Client Secret (key)
  const hash = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(key)
  );

  // return decrypted context
  return await decryptCtxWithHash(cipherText, hash, iv, aad, tag);
}

I can’t guarantee this is the closest working version I got, but it should be shoe-hornable into a client-side JavaScript environment for testing.

Great stuff, thanks for sharing