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

Feature request — Runtime APIs “nodejs_compat” breaks when needing crypto.createSign #3172

Closed
adueck opened this issue Nov 26, 2024 · 13 comments
Assignees
Labels
crypto feature request Request for Workers team to add a feature nodejs compat

Comments

@adueck
Copy link

adueck commented Nov 26, 2024

The nodejs_compat does not work with crypto.createSign beause the version of unenv has not implemented it yet.

I am trying to use the google sheets api. While this worked just fine with Firebase Functions on Node, it fails to work with Cloudflare Workers while using the "nodejs_compat" flag.

As you can see from this error message the problem is that it is relying on unenv for compatability, and this version library has not yet implemented crypto.createSign

Using googleapis v. 144.0.0 and hono v. 4.6.12 on MacOS

import { Hono } from "hono";
import { google } from "googleapis";

const app = new Hono();

app.get("/", async (c) => {
  const auth = new google.auth.GoogleAuth({
    credentials: {
      // @ts-expect-error
      private_key: c.env.SERVICE_ACCOUNT_KEY,
      // @ts-expect-error
      client_email: c.env.SERVICE_ACCOUNT_EMAIL,
    },
    scopes: [
      "https://www.googleapis.com/auth/spreadsheets",
      "https://www.googleapis.com/auth/drive.file",
    ],
  });
  const { spreadsheets } = google.sheets({
    version: "v4",
    auth,
  });
  const { data } = await spreadsheets.values.get({
    // @ts-expect-error
    spreadsheetId: c.env.SPREADSHEET_ID,
    range: "A3:AI3",
  });
  c.text("Errors before this");
});

Results in the following error:

✘ [ERROR] Error: [unenv] crypto.createSign is not implemented yet!

      at createNotImplementedError
  (file:///Users/me/my-project/new-functions/node_modules/unenv/runtime/_internal/utils.mjs:22:10)
      at Object.fn [as createSign]
  (file:///Users/me/my-project/new-functions/node_modules/unenv/runtime/_internal/utils.mjs:26:11)
      at Object.sign
  (file:///Users/me/my-project/new-functions/node_modules/jwa/index.js:151:25)
      at Object.jwsSign [as sign]
  (file:///Users/me/my-project/new-functions/node_modules/jws/lib/sign-stream.js:32:24)
      at GoogleToken._GoogleToken_requestToken
  (file:///Users/me/my-project/new-functions/node_modules/gtoken/build/src/index.js:235:27)
      at GoogleToken._GoogleToken_getTokenAsyncInner
  (file:///Users/me/my-project/new-functions/node_modules/gtoken/build/src/index.js:180:97)
      at GoogleToken._GoogleToken_getTokenAsync
  (file:///Users/me/my-project/new-functions/node_modules/gtoken/build/src/index.js:160:173)
      at GoogleToken.getToken
  (file:///Users/me/my-project/new-functions/node_modules/gtoken/build/src/index.js:110:102)
      at JWT.refreshTokenNoCache
  (file:///Users/me/my-project/new-functions/node_modules/google-auth-library/build/src/auth/jwtclient.js:173:36)
      at JWT.refreshToken
  (file:///Users/me/my-project/new-functions/node_modules/google-auth-library/build/src/auth/oauth2client.js:187:24)
@jasnell
Copy link
Member

jasnell commented Nov 28, 2024

This is not a bug as the current behavior is intentional. node:crypto support is still a work in progress. Will change this to a feature request.

@jasnell jasnell added the feature request Request for Workers team to add a feature label Nov 28, 2024
@jasnell jasnell changed the title 🐛 Bug Report — Runtime APIs “nodejs_compat” breaks when needing crypto.createSign Feature request — Runtime APIs “nodejs_compat” breaks when needing crypto.createSign Nov 28, 2024
@adueck
Copy link
Author

adueck commented Nov 29, 2024

Ok great, I imagine this would be a very useful feature. 👍 I was really loving the Cloudflare Workers DX (soo good and smooth) but unfortunately had to switch over to AWS Lambda for this.

@alinalihassan
Copy link

I'm also experiencing the same issue here

@jasnell jasnell self-assigned this Jan 9, 2025
@Joelsz
Copy link

Joelsz commented Jan 23, 2025

This issue prevents the use of firebase auth or the firebase-admin SDK on Cloudflare pages, initializeApp() breaks with the following error:

Credential implementation provided to initializeApp() via the "credential" property failed to fetch a valid Google OAuth2 access token with the following error: "[unenv] crypto.createSign is not implemented yet!".

@wiyco
Copy link

wiyco commented Jan 31, 2025

I managed to avoid errors by implementing the functionality without using the googleapis library.
Sharing this as a note - hope it helps someone.

Implementation code (click to expand)

google.ts

import { decodeBase64, encodeBase64, encodeBase64Url } from "./base64";

/**
 * Class for handling Google OAuth2.0 authentication
 * @see {@link https://developers.google.com/identity/protocols/oauth2}
 */
export class GoogleAuth {
  private readonly clientEmail: string;
  private readonly privateKey: string;
  private token: string | null = null;
  private tokenExpiry = 0;

  constructor(clientEmail: string, privateKey: string) {
    this.clientEmail = clientEmail;
    this.privateKey = privateKey.replace(/\\n/g, "\n");
  }

  async getAccessToken(): Promise<string> {
    // Reuse token if still valid
    if (this.token && this.tokenExpiry > Date.now()) {
      return this.token;
    }

    // Create JWT
    const now = Math.floor(Date.now() / 1000);
    const expiry = now + 3600; // 1 hour later

    const header = {
      alg: "RS256",
      typ: "JWT",
    };

    const claim = {
      iss: this.clientEmail,
      // I would suggest making the "scope" configurable as a parameter instead of having it hardcoded.
      scope: "https://www.googleapis.com/auth/spreadsheets.readonly",
      aud: "https://oauth2.googleapis.com/token",
      exp: expiry,
      iat: now,
    };

    try {
      // Base64 encode
      const encodedHeader = encodeBase64Url(header);
      const encodedClaim = encodeBase64Url(claim);

      // Create signature
      const signatureInput = `${encodedHeader}.${encodedClaim}`;
      const key = await this.importPrivateKey();
      const signature = await this.signContent(signatureInput, key);

      // Assemble JWT
      const jwt = `${encodedHeader}.${encodedClaim}.${signature}`;

      // Get access token
      const response = await fetch("https://oauth2.googleapis.com/token", {
        method: "POST",
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: new URLSearchParams({
          grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
          assertion: jwt,
        }),
      });

      if (!response.ok) {
        throw new Error("Failed to get access token");
      }

      const data: {
        access_token: string;
        expires_in: number;
      } = await response.json();
      this.token = data.access_token;
      this.tokenExpiry = Date.now() + data.expires_in * 1000;

      return this.token;
    } catch (err) {
      console.error("getAccessToken error:", err);
      throw err;
    }
  }

  private async importPrivateKey(): Promise<CryptoKey> {
    // Extract private key from PEM format
    const pemContent = this.privateKey
      .trim()
      .split(/[\r\n]+/)
      .map((line) => line.trim())
      .filter(
        (line) =>
          !line.startsWith("-----BEGIN") && !line.startsWith("-----END"),
      )
      .join("");

    try {
      // Base64 decode
      const binaryDer = decodeBase64(pemContent);

      // Import PKCS8 format private key
      return await crypto.subtle.importKey(
        "pkcs8",
        binaryDer,
        {
          name: "RSASSA-PKCS1-v1_5",
          hash: { name: "SHA-256" },
        },
        false,
        ["sign"],
      );
    } catch (err) {
      console.error("Private key import error:", err);
      console.error("PEM content length:", this.privateKey.length);
      throw err;
    }
  }

  private async signContent(content: string, key: CryptoKey): Promise<string> {
    const encoder = new TextEncoder();
    const data = encoder.encode(content);

    try {
      const signature = await crypto.subtle.sign(
        {
          name: "RSASSA-PKCS1-v1_5",
          hash: { name: "SHA-256" },
        },
        key,
        data,
      );

      return encodeBase64(signature);
    } catch (err) {
      console.error("Content signing error:", err);
      throw err;
    }
  }
}

base64.ts

export function encodeBase64(data: ArrayBuffer | ArrayBufferLike): string {
  return btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array(data))))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

export function decodeBase64(str: string): ArrayBuffer {
  // Normalize Base64 string
  const normalizedBase64 = str
    .replace(/-/g, "+")
    .replace(/_/g, "/")
    .replace(/\s+/g, "");

  // Add padding
  const pad = normalizedBase64.length % 4;
  const paddedBase64 = pad
    ? normalizedBase64 + "=".repeat(4 - pad)
    : normalizedBase64;

  try {
    // Base64 decode
    const binaryString = atob(paddedBase64);
    const bytes = new Uint8Array(binaryString.length);
    for (let i = 0; i < binaryString.length; i++) {
      bytes[i] = binaryString.charCodeAt(i);
    }
    return bytes.buffer;
  } catch (error) {
    console.error("Base64 decode error:", error);
    console.error("Input base64 length:", str.length);
    throw error;
  }
}

export function encodeBase64Url(obj: Record<string, unknown>): string {
  const str = JSON.stringify(obj);
  const encoder = new TextEncoder();
  const data = encoder.encode(str);
  return encodeBase64(data.buffer);
}

Usage (Google Sheets)

import type { ParsedEnv } from "~/constants";
import { APIError } from "~/handler";
import { GoogleAuth } from "~/utils/google";

export namespace GoogleSheetsService {
  export async function getInfo(
    props: ParsedEnv["googleSheets"],
  ): Promise<string> {
    const { clientEmail, privateKey, spreadsheetId, range } = props;
    const today = new Date();
    const sheetName = `${today.getMonth() + 1}`;

    try {
      /**
       * Error: [unenv] crypto.createSign is not implemented yet!
       * @description Cloudflare Workers with the "nodejs_compat" flag cannot use crypto.createSign yet.
       * @link https://github.com/cloudflare/workerd/issues/3172
       *
       * @todo Use "googleapis" library when the issue is resolved.
       */
      // const auth = new google.auth.GoogleAuth({
      //   credentials: {
      //     client_email: clientEmail,
      //     private_key: privateKey.replace(/\\n/g, "\n"),
      //   },
      //   scopes: ["https://www.googleapis.com/auth/spreadsheets.readonly"],
      // });
      // const sheets = google.sheets({ version: "v4", auth });
      // const response = await sheets.spreadsheets.values.get({
      //   spreadsheetId,
      //   range: `${sheetName}!${range}`,
      // });
      // return formatMessage(response.data.values);

      /** @todo Temporary implementation */
      const auth = new GoogleAuth(clientEmail, privateKey);
      const accessToken = await auth.getAccessToken();

      const response = await fetch(
        `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(`${sheetName}!${range}`)}`,
        {
          headers: {
            Authorization: `Bearer ${accessToken}`,
            Accept: "application/json",
          },
        },
      );

      if (!response.ok) {
        throw new Error(`Google Sheets API error: ${response.status}`);
      }

      const data: {
        range: string;
        values: string[][] | null | undefined;
      } = await response.json();
      return formatMessage(data.values);
    } catch (err) {
      console.error(err);
      throw new APIError("Failed to fetch google sheets data", 500);
    }
  }
}

Note

This is an alternative approach and not directly related to the feature request in the title.

@Joelsz
Copy link

Joelsz commented Feb 27, 2025

It appears that this is being worked on in #3603. Not sure though what the release timeline looks like.

@jasnell
Copy link
Member

jasnell commented Feb 27, 2025

The goal is to land #3603 this week with release to production hopefully next week or the week after. There are limitations to be aware of tho! Because we are using boringssl+fips the algorithms, key lengths, etc are limited. The sign/verify is known to work with RSA and ED25519 keys but DSA is unsupported. ED448 is unsupported. And there's still a bit more to do to support ECDSA, which I'll be working to verify as a next step.

@thelovekesh
Copy link

@jasnell any updates here?

@jasnell
Copy link
Member

jasnell commented Mar 12, 2025

Yes! createSign(...) has been implemented...

Image

There are some limitations. RSA is supported. RSA-PSS and DSA are not. Most of the limitations are due to our use of Boringssl + FIPS in production, both of which apply additional constraints on the algorithms and parameters that are allowed. But the APIs are there! Let me know if you run into anything that just doesn't work and we'll try to get a fix in as soon as possible.

@jasnell
Copy link
Member

jasnell commented Mar 12, 2025

Going to close this issue now, but will continue to watch. If y'all have any issues with the implemented APIs just comment here and mention me and I'll see it.

@jasnell jasnell closed this as completed Mar 12, 2025
@Joelsz
Copy link

Joelsz commented Mar 12, 2025

Does this take time to roll out? Do I need to update any packages?

I'm still getting the error referenced above #3172 (comment) using Pages / Honox / Vite setup.

@jasnell
Copy link
Member

jasnell commented Mar 12, 2025

Yes, it will likely take a bit of time. If you are using wrangler, then your worker is likely still bundling the polyfill implementation that raises the "not implemented" error. The polyfill will end up taking precedence over the built-in implementation. Once wrangler is updated (which typically lags slightly behind the runtime) then it will switch to using the built-in impl. I believe there are ways of setting up your configuration now tho in advance of that but I'm not familiar enough with wrangler itself to say. I'd have to leave that to folks like @vicb to comment on.

@vicb
Copy link
Contributor

vicb commented Mar 13, 2025

The new crypto API will be available when both:

  • unenv is updated
  • wrangler is updated to use the updated unenv

Our policy is not to update wrangler until the changes are available in prod (prod updates are async with workerd NPM releases).

ETA is mid-next week (Look for the unenv update in the wrangler changelog)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
crypto feature request Request for Workers team to add a feature nodejs compat
Projects
None yet
Development

No branches or pull requests

8 participants