diff --git a/content/posts/2023-07-23/feature.png b/content/posts/2023-07-23/feature.png new file mode 100644 index 0000000..a164588 Binary files /dev/null and b/content/posts/2023-07-23/feature.png differ diff --git a/content/posts/2023-07-23/index.md b/content/posts/2023-07-23/index.md new file mode 100644 index 0000000..175e23d --- /dev/null +++ b/content/posts/2023-07-23/index.md @@ -0,0 +1,344 @@ +--- +author: usbharu +draft: true +categories: + - 技術 +date: 2023-07-23T23:23:50+09:00 +tags: + - TypeScript + - ActivityPub + - HTTP Signature + - Misskey +keywords: + - TypeScript + - ActivityPub + - HTTP Signature + - Misskey +title: Misskeyで検証に成功するHTTP Signatureを実装する +relpermalink: posts/2023-07-23/ +url: posts/2023-07-23/ +decription: Misskeyで検証に成功するHTTP Signatureを実装する +--- + +## ActivityPub 実装にはほぼ必須となった HTTP Signature + +ActivityPub は現在 Mastodon が 77%を占めているため[^1]、ActivityPub を実装する際には殆どの場合 Mastodon との互換性を意識しながら実装することになります。そこで出てくるのが [HTTP Signature](https://datatracker.ietf.org/doc/draft-cavage-http-signatures/12/) というまだドラフト段階の規格です。この規格は HTTP リクエストの内容に署名し、改ざん等を検出するためのもので 2013 年から Signing HTTP Messages に名前を変えて 10 年もドラフトやってます。 + +[^1]: https://fedidb.org/ 2023/07/23 時点 + +### ドラフト段階のため仕様があやふや + +この規格、10 年もあれこれやっているためライブラリの対応具合にばらつきがかなりあります。そのため署名の検証には 10 年分の規格を読む必要があり死ぬほど大変です。今回は署名の検証には[node-http-signature の peertube フォーク](https://www.npmjs.com/package/@peertube/http-signature)を使用します。このライブラリは Misskey でも使用されているため、今回は Misskey で署名を検証できる署名ということになります。 + +## HTTP Signature の実装 + +この署名の仕方が規格のどの時点に沿っているのか、そもそも沿えているのかわかっていません。 + +### 簡単な仕様 + +今回実装するものは + +- RSA +- SHA256 +- ヘッダーは`Signature`ヘッダー +- ヘッダーの内容は`keyId="{KeyID}",algorithm="rsa-sha256",headers="{含めるヘッダー}",signature="{署名}"` +- 特殊ヘッダーは`(request-target)`のみ + +となります + +### 署名の作成の仕方 + +#### 前提 + +- リクエストヘッダー等は既に完成していること +- `Date`ヘッダーが [RFC7231](https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.1.2) の形式で存在すること +- `Host`ヘッダーが [RFC7230](https://datatracker.ietf.org/doc/html/rfc7230#section-5.4)の形式で存在すること(HTTP クライアント等によっては不要だが署名時に必要) +- POST リクエストの場合 `Digest`ヘッダーに [Resource Digests for HTTP](https://datatracker.ietf.org/doc/draft-ietf-httpbis-digest-headers/00/)のような形式(詳しくは不明)でハッシュが存在すること + +#### 簡単な解説 + +1. 鍵ペアを準備し、公開鍵に KeyID をつけます +1. 署名するヘッダーを決めます +1. 署名するヘッダーのヘッダー名を小文字にします +1. 小文字にしたヘッダー名をスペース区切りで繋げます +1. 繋げたものの先頭に`(request-target) `を追加します `含めるヘッダー`とします +1. それぞれ`{小文字にしたヘッダー名}: {ヘッダーの内容}`という形式にします +1. `(request-target): {HTTPメソッドの小文字} {URLのpath}`を作ります +1. ヘッダー名を繋げたものと同じ順番で改行区切りで繋げます +1. 繋げたものを SHA-256 で署名します +1. 署名したものを Base64 でエンコードします `署名`とします +1. `keyId="{KeyID}",algorithm="rsa-sha256",headers="{含めるヘッダー}",signature="{署名}"`に当てはめて完成です + +#### 本当の解説 + +細かく説明していきます + +想定するリクエストのうち、必要な部分は以下のとおりです。 + +```HTTP Request +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 リクエスト(例) + +例なので鍵によって署名が異なります +また、今回極端に短い鍵を使用しています + +```pem +-----BEGIN PUBLIC KEY----- +MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANFTXhZ0tedFHHi0yBKcweqvV+Eh7YTN +TO2NWqo5gnSJfV8VNIgL3/NL753j085K0zhzEUicdOQxqVrVzTMPhOECAwEAAQ== +-----END PUBLIC KEY----- +``` + +```HTTP Request +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 実装からコピペしてきたので余計なものが混ざっていますが… + +署名 + +```typescript +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 = new Map( + 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 フォーク](https://www.npmjs.com/package/@peertube/http-signature)を使用します + +```typescript +export function genKeyPair(): KeyPairSyncResult { + 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(); +```