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のような形式(詳しくは不明)でハッシュが存在すること
簡単な解説#
- 鍵ペアを準備し、公開鍵に KeyID をつけます
- 署名するヘッダーを決めます
- 署名するヘッダーのヘッダー名を小文字にします
- 小文字にしたヘッダー名をスペース区切りで繋げます
- 繋げたものの先頭に
(request-target)
を追加します含めるヘッダー
とします - それぞれ
{小文字にしたヘッダー名}: {ヘッダーの内容}
という形式にします (request-target): {HTTPメソッドの小文字} {URLのpath}
を作ります- ヘッダー名を繋げたものと同じ順番で改行区切りで繋げます
- 繋げたものを SHA-256 で署名します
- 署名したものを Base64 でエンコードします
署名
とします 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
リクエストなら Date
、Host
、を署名しておけば大丈夫だと思います
POST
リクエストの場合 Digest
ヘッダーも必要です。(リクエストボディの改ざんを検知するため)
以後 POST リクエストでDate
、Host
、Digest
ヘッダーを署名する前提で進めます
含めるヘッダーを組み立てる#
含めるヘッダー名を小文字にし、スペース区切りで繋げます。
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();
https://fedidb.org/ 2023/07/23 時点 ↩︎