メインコンテンツへスキップ

Misskeyで検証に成功するHTTP Signatureを実装する

·636 文字·3 分
技術 TypeScript ActivityPub HTTP Signature Misskey
usbharu
著者
usbharu
Kotlin~♪

ActivityPub 実装にはほぼ必須となった HTTP Signature #

ActivityPub は現在 Mastodon が 77%を占めているため1、ActivityPub を実装する際には殆どの場合 Mastodon との互換性を意識しながら実装することになります。そこで出てくるのが HTTP Signature というまだドラフト段階の規格です。この規格は HTTP リクエストの内容に署名し、改ざん等を検出するためのもので 2013 年から Signing HTTP Messages に名前を変えて 10 年もドラフトやってます。

ドラフト段階のため仕様があやふや #

この規格、10 年もあれこれやっているためライブラリの対応具合にばらつきがかなりあります。そのため署名の検証には 10 年分の規格を読む必要があり死ぬほど大変です。今回は署名の検証には node-http-signature の peertube フォークを使用します。このライブラリは Misskey でも使用されているため、今回は Misskey で署名を検証できる署名ということになります。

HTTP Signature の実装 #

この署名の仕方が規格のどの時点に沿っているのか、そもそも沿えているのかわかっていません。

簡単な仕様 #

今回実装するものは

  • RSA
  • SHA256
  • ヘッダーはSignatureヘッダー
  • ヘッダーの内容はkeyId="{KeyID}",algorithm="rsa-sha256",headers="{含めるヘッダー}",signature="{署名}"
  • 特殊ヘッダーは(request-target)のみ

となります

署名の作成の仕方 #

前提 #

  • リクエストヘッダー等は既に完成していること
  • Dateヘッダーが RFC7231 の形式で存在すること
  • Hostヘッダーが RFC7230の形式で存在すること(HTTP クライアント等によっては不要だが署名時に必要)
  • POST リクエストの場合 Digestヘッダーに Resource Digests for HTTPのような形式(詳しくは不明)でハッシュが存在すること

簡単な解説 #

  1. 鍵ペアを準備し、公開鍵に KeyID をつけます
  2. 署名するヘッダーを決めます
  3. 署名するヘッダーのヘッダー名を小文字にします
  4. 小文字にしたヘッダー名をスペース区切りで繋げます
  5. 繋げたものの先頭に(request-target) を追加します 含めるヘッダーとします
  6. それぞれ{小文字にしたヘッダー名}: {ヘッダーの内容}という形式にします
  7. (request-target): {HTTPメソッドの小文字} {URLのpath}を作ります
  8. ヘッダー名を繋げたものと同じ順番で改行区切りで繋げます
  9. 繋げたものを SHA-256 で署名します
  10. 署名したものを Base64 でエンコードします 署名とします
  11. keyId="{KeyID}",algorithm="rsa-sha256",headers="{含めるヘッダー}",signature="{署名}"に当てはめて完成です

本当の解説 #

細かく説明していきます

想定するリクエストのうち、必要な部分は以下のとおりです。

POST / HTTP/1.1
Host: example.com
Date: Sun, 23 Jul 2023 16:25:49 GMT
Digest: SHA-256=IyxgCgKTw1/vRwmfp9e2QZW91Wh1ZlN3TzV8CQRR8mY=

{"hoge":"fuga"}
鍵ペアを準備し、公開鍵に KeyID をつける #

まず鍵ペアを準備します。PKCS8 という形式を使い、秘密鍵は大事に保管し、公開鍵に KeyID をつけます。 この KeyID は鍵を識別できたらなんでもいいのですが、ActivityPub では Actor の publicKey ID と同じものを使います。 今回はhttps://example.com/#main-keyを使用します

署名するヘッダーを決める #

GET リクエストなら DateHost、を署名しておけば大丈夫だと思います POST リクエストの場合 Digestヘッダーも必要です。(リクエストボディの改ざんを検知するため)

以後 POST リクエストでDateHostDigestヘッダーを署名する前提で進めます

含めるヘッダーを組み立てる #

含めるヘッダー名を小文字にし、スペース区切りで繋げます。

date host digest

特殊ヘッダー(request-target)を追加します

(request-target) date host digest
署名する文字列を作製する #

含めるヘッダーで指定した順番でヘッダーの内容を含めていきます。

特殊ヘッダー(request-target)(request-target): {httpメソッドの小文字} {urlのpath}という形式にします。

(request-target): get /
date: Sun, 23 Jul 2023 16:25:49 GMT
host: example.com
digest: SHA-256=IyxgCgKTw1/vRwmfp9e2QZW91Wh1ZlN3TzV8CQRR8mY=
SHA-256 で署名 #

先程作った文字列を秘密鍵で署名して下さい。

組み立てる #

署名は閑静です

keyId="https://example.com/#main-key",algorithm="rsa-sha256",headers="(request-target) date host digest",signature="{署名}"
完成 #

最終的に完成した HTTP リクエスト(例)

例なので鍵によって署名が異なります また、今回極端に短い鍵を使用しています

-----BEGIN PUBLIC KEY-----
MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANFTXhZ0tedFHHi0yBKcweqvV+Eh7YTN
TO2NWqo5gnSJfV8VNIgL3/NL753j085K0zhzEUicdOQxqVrVzTMPhOECAwEAAQ==
-----END PUBLIC KEY-----
POST / HTTP/1.1
Host: example.com
Date: Sun, 23 Jul 2023 16:25:49 GMT
Digest: SHA-256=IyxgCgKTw1/vRwmfp9e2QZW91Wh1ZlN3TzV8CQRR8mY=
Signature: keyId="https://example.com/#main-key",algorithm="rsa-sha256",headers="(request-target) date host digest",signature="UcoThRRT3YSGCUZJbVX8mUCbiheVcEo0+wfSEXX5UI69+jOlUUEGe0/v1UkpbH0+x8gflf6q4Y5GYEsZrbs3vw=="

{"hoge":"fuga"}

TypeScript での実装 #

現在作成中の ActivityPub 実装からコピペしてきたので余計なものが混ざっていますが…

署名

import * as crypto from "crypto";

export type Sign = {
  signature: string;
  signatureHeader: string;
};

export type Header = [string, string];
export type Headers = Header[];

export type Request = {
  headers: Headers;
  method: "GET" | "POST" | string;
};

export type Key = {
  privateKeyPem: string;
  keyId: string;
};

export interface HttpSignatureService {
  sign(url: string, request: Request, key: Key, signHeader: string[]): Request;

  signRaw(signString: string, key: Key, headers: string[]): Sign;
}

export class DefaultHttpSignatureService implements HttpSignatureService {
  sign(url: string, request: Request, key: Key, signHeader: string[]): Request {
    const sign = this.signRaw(
      this.buildSignString(new URL(url), request, signHeader),
      key,
      signHeader
    );
    return {
      headers: [...request.headers, ["Signature", sign.signatureHeader]],
      method: request.method,
    };
  }

  signRaw(signString: string, key: Key, headers: string[]): Sign {
    const signature = crypto
      .sign("sha256", Buffer.from(signString), key.privateKeyPem)
      .toString("base64");
    return {
      signature: signature,
      signatureHeader: `keyId="${
        key.keyId
      }",algorithm="rsa-sha256",headers="${headers.join(
        " "
      )}",signature="${signature}"`,
    };
  }

  protected buildSignString(
    url: URL,
    request: Request,
    signHeaders: string[]
  ): string {
    const headers: Map<string, string> = new Map<string, string>(
      request.headers.map(([name, value]) => [name.toLowerCase(), value])
    );
    const result = signHeaders
      .map((value) => {
        return value.startsWith("(")
          ? this.specialHeader(value, url, request)
          : this.generalHeader(value, headers.get(value));
      })
      .join("\n");
    return result;
  }

  protected specialHeader(
    fieldName: string,
    url: URL,
    request: Request
  ): string {
    if (fieldName !== "(request-target)") {
      throw new Error(fieldName + " is unsupported type");
    }
    return `(request-target): ${request.method.toLowerCase()} ${url.pathname}`;
  }

  protected generalHeader(fieldName: string, value?: string): string {
    if (typeof value === "undefined") {
      throw new Error(fieldName + " is undefined");
    }
    return `${fieldName}: ${value}`;
  }
}

検証

検証には node-http-signature の peertube フォークを使用します

export function genKeyPair(): KeyPairSyncResult<string, string> {
  return crypto.generateKeyPairSync("rsa", {
    modulusLength: 4096,
    publicKeyEncoding: {
      type: "spki",
      format: "pem",
    },
    privateKeyEncoding: {
      type: "pkcs8",
      format: "pem",
    },
  });
}

function testSign() {
  const body = {
    hoge: "fuga",
  };

  const date = new Date().toUTCString();

  const keyPair = genKeyPair();

  const signatureService = new DefaultHttpSignatureService();

  const digest = `SHA-256=${crypto
    .createHash("sha256")
    .update(JSON.stringify(body))
    .digest("base64")}`;

  const request: Request = {
    headers: [
      ["Date", date],
      ["Host", "example.com"],
      ["Content-Type", "application/activity+json"],
      ["Digest", digest],
    ],
    method: "POST",
  };

  const key: Key = {
    privateKeyPem: keyPair.privateKey,
    keyId: "https://example.com/#main-key",
  };
  const signedRequest = signatureService.sign(
    "https://example.com",
    request,
    key,
    ["(request-target)", "date", "host", "digest"]
  );

  //ここからはテスト用のダミーデータ作成

  const headers: IncomingMessage = {
    headers: {
      date: date,
      host: "example.com",
      "content-type": "application/activity+json",
      digest: digest,
      signature: signedRequest.headers
        .find(([name, value]) => name == "Signature")
        ?.at(1), //signature ヘッダーの検索
    },
    method: "POST",
    url: "/",
    httpVersion: "1.1",
  } as unknown as IncomingMessage;

  const parsedSignature = httpSignature.parseRequest(headers, { headers: [] });
  const verifySignature = httpSignature.verifySignature(
    parsedSignature,
    keyPair.publicKey
  );

  console.log(verifySignature); // true
}

testSign();

  1. https://fedidb.org/ 2023/07/23 時点 ↩︎