🚖

Cloudflare WorkersでGCSの署名付きURLを作成する

2024/08/01に公開

GCS に画像をアップロードする場合サーバー側で Google Cloud SDK を使って署名付き URL を作成すれば簡単に実装できます。

import {Storage} from '@google-cloud/storage';

const options = {
  version: 'v4',
  action: 'write',
  expires: Date.now() + 15 * 60 * 1000, // 15 minutes
  contentType: 'image/png',
};

const [url] = await storage
  .bucket('my-bucket-name')
  .file('example.png')
  .getSignedUrl(options);

ただ、@google-cloud/storageは Node.js 環境向けなのでfsなどの Node.js 組み込み関数が使われており、Cloudflare Workers/Cloudflare Pages Functions では実行できませんでした。

Hono のルートを作成する

今回は公式のドキュメントにある 独自のプログラムを使用した V4 署名プロセス を参考に自前で署名付き URL の作成を行います。

/src/routes/api.ts

import {zValidator} from '@hono/zod-validator';
import {Hono} from 'hono';
import {env} from 'hono/adapter';
import {z} from 'zod';
import {generateSignedUrl} from '../services/cloudStorage';

type Bindings = {
  SERVICE_ACCOUNT_KEY_JSON: string;
};

export const apiRoutes = new Hono<{Bindings: Bindings}>().get(
  '/signed-url',
  zValidator(
    'query',
    z.object({'object-name': z.string(), 'file-type': z.string()})
  ),
  async c => {
    const {SERVICE_ACCOUNT_KEY_JSON} = env(c);
    const serviceAccountKey = JSON.parse(SERVICE_ACCOUNT_KEY_JSON);

    const signedUrl = await generateSignedUrl({
      bucketName: 'my-bucket-name', // 保存したいバケットの名前
      objectName: c.req.valid('query')['object-name'], // queryで受け取ったファイル名 (例: example.png)
      fileType: c.req.valid('query')['file-type'], // queryで受け取ったContent-Type (例: 'image/png)
      serviceAccountKey,
    });

    return c.json({signedUrl});
  }
);

Hono を使ってルートを追加する例です。hono/adapterを使用すると、異なる環境での環境変数の呼び出し方が統一されます。
Google Cloud SDK を使用せずに署名付き URL を発行するため、GOOGLE_APPLICATION_CREDENTIALS=./service-account-key.json のようにファイルを読み込む必要がない点が便利です。

.dev.vars

SERVICE_ACCOUNT_KEY_JSON = {"type":"service_account","project_id": // 以下略

ローカルでは.dev.varsに上記のように書き込み、Cloudflare 上では設定の環境変数からSERVICE_ACCOUNT_KEY_JSONを設定します。
改行なしで 1 行にしておいてください。

署名付き URL を発行する

services/cloudStorage.ts

interface ServiceAccountKey {
  type: string;
  project_id: string;
  private_key_id: string;
  private_key: string;
  client_email: string;
  client_id: string;
  auth_uri: string;
  token_uri: string;
  auth_provider_x509_cert_url: string;
  client_x509_cert_url: string;
  universe_domain: string;
}

export const generateSignedUrl = async ({
  bucketName,
  objectName,
  fileType,
  expiration = 15 * 60, // 15 minutes
  httpMethod = 'PUT',
  serviceAccountKey,
}: GenerateSignedUrlOptions): Promise<string> => {
  if (expiration > 15 * 60) {
    throw new Error(
      "Expiration Time can't be longer than 900 seconds (15 minutes)."
    );
  }

  const datetimeNow = new Date();
  const requestTimestamp = datetimeNow
    .toISOString()
    .replace(/[:-]|\.\d{3}/g, '');
  const datestamp = requestTimestamp.slice(0, 8);

  const clientEmail = serviceAccountKey.client_email;
  const credentialScope = `${datestamp}/auto/storage/goog4_request`;
  const credential = `${clientEmail}/${credentialScope}`;

  const canonicalUri = `/${objectName}`;
  const host = `${bucketName}.storage.googleapis.com`;

  const canonicalHeaders = `content-type:${fileType}\nhost:${host}\n`;
  const signedHeaders = 'content-type;host';

  const queryParameters = {
    'X-Goog-Algorithm': 'GOOG4-RSA-SHA256',
    'X-Goog-Credential': credential,
    'X-Goog-Date': requestTimestamp,
    'X-Goog-Expires': expiration.toString(),
    'X-Goog-SignedHeaders': signedHeaders,
  };

  const canonicalQueryString = new URLSearchParams(queryParameters).toString();

  const canonicalRequest = [
    httpMethod,
    canonicalUri,
    canonicalQueryString,
    canonicalHeaders,
    signedHeaders,
    'UNSIGNED-PAYLOAD',
  ].join('\n');

  const canonicalRequestHash = SHA256(canonicalRequest).toString(enc.Hex);

  const stringToSign = [
    'GOOG4-RSA-SHA256',
    requestTimestamp,
    credentialScope,
    canonicalRequestHash,
  ].join('\n');

  const sign = await signString(serviceAccountKey.private_key, stringToSign); // signString()は後述

  // 出来上がった署名付きURL
  const signedUrl = `https://${bucketName}.storage.googleapis.com/${objectName}?${canonicalQueryString}&x-goog-signature=${sign}`;

  return signedUrl;
};

公式の Python コードを参考に TypeScript で再実装していきます。
この辺りは仕様通りに詰めていくだけなので print()console.log() しながら進めればスムーズに実装できます。

signString() の部分がが難しく、Python と同じように実装するのが困難です。また、 Node.js と V8(Cloudflare Workers) で実行環境を考慮する必要があります。
この関数では、SHA256 でハッシュ化した値とサービスアカウントの private_key を使用します。

services/cloudStorage.ts

const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
  const binaryString = ((): string => {
    if (typeof Buffer !== 'undefined') {
      // NOTE: BufferはNode.jsの関数なのでWorkersのV8環境には存在しない
      return Buffer.from(base64, 'base64').toString('binary');
      // biome-ignore lint/style/noUselessElse: <explanation>
    } else {
      // Cloudflare Workers and browsers (V8)
      return atob(base64);
    }
  })();

  const bytes = new Uint8Array(binaryString.length);

  const uint8Array = binaryString.split('').reduce((acc, char, index) => {
    acc[index] = char.charCodeAt(0);
    return acc;
  }, bytes);

  return uint8Array.buffer;
};

const importPrivateKey = async (pem: string): Promise<CryptoKey> => {
  const pemHeader = '-----BEGIN PRIVATE KEY-----';
  const pemFooter = '-----END PRIVATE KEY-----';

  const pemContents = pem.substring(
    pemHeader.length,
    pem.length - pemFooter.length - 1
  );
  const binaryDer = base64ToArrayBuffer(pemContents.replace(/\s/g, ''));

  return crypto.subtle.importKey(
    'pkcs8',
    binaryDer,
    {name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256'},
    true,
    ['sign']
  );
};

const signString = async (
  privateKeyPem: string,
  stringToSign: string
): Promise<string> => {
  const privateKey = await importPrivateKey(privateKeyPem);

  const encoder = new TextEncoder();
  const data = encoder.encode(stringToSign);
  const signature = await crypto.subtle.sign(
    'RSASSA-PKCS1-v1_5',
    privateKey,
    data
  );

  return Array.from(new Uint8Array(signature))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
};

渡されたハッシュ値を PKCS#1 v1.5 パディングを適用します。これでハッシュ値に特定の形式のパディングが追加され署名に適した長さになります。

signString()は RSA プライベートキーを使用して文字列に署名する関数 signString を示しています。署名は RSA-SHA256 アルゴリズムと PKCS1v1.5 パディングを使用して生成され、その結果を 16 進数の文字列として返します。

importPrivateKey()は privateKeyPem から prefix,suffix を取り除いて CryptoKey オブジェクトにしています。

binaryString()は Base64 文字列を ArrayBuffer に変換する関数です。
Vite で実行した Node.js 環境と wrangler/Cloudflare で実行してる V8 で場合分けしています。

以上で Cloudflare Workers で署名付き URL が作成できるようになりました。

署名付き URL を使って画像をアップロードする

クライアント側では上記で作成した API から署名付き URL を取得し、fetch()でファイルを PUT で送れば GCS にアップロードができます。

const name: string = 'example.png'; // アップロード後のファイル名
const file: File; // Fileオブジェクトを入れる

honoClient.api['signed-url']
  .$get({query: {'object-name': name, 'file-type': file.type}})
  .then(async res => {
    const {signedUrl} = await res.json();
    fetch(signedUrl, {
      method: 'PUT',
      headers: {'Content-Type': file.type},
      body: file,
    }).then(() => {
      console.log('success');
    });
  });

Discussion