💆

[Deno]じゃあ、みんなでRFC 9421 - HTTP Message Signaturesをやってみよう(Ed25519)

2024/02/26に公開

Intro

ついに約11年の時を経て2024年2月14日にHTTP Message SignaturesがRFC 9421として公開されました。

https://datatracker.ietf.org/doc/html/rfc9421

https://httpsig.org/

私はActivityPub実装を長いことやっているため、この時をずっと待っていました。
もちろんActivityPubだけではなく、TLS 1.3でカバーできない分野でも使われそうで期待が高まります。
例えばcurlの--aws-sigv4オプションでおなじみのAWS Signature Version 4が似たような仕組みでHTTPリクエストに署名を行っています。

https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html

そこで今回はRFC 9421のHTTP Message Signaturesをチョットデキルようにするため、以下#appendix-B.2.6で紹介しているEd25519アルゴリズムを使用した例をDenoとTypeScriptで実装していきたいと思います。

https://datatracker.ietf.org/doc/html/rfc9421#appendix-B.2.6

Source

最初にソースコード全文を紹介します。

httpsig.ts
const privateKeyJwk = {
  kty: "OKP",
  crv: "Ed25519",
  kid: "test-key-ed25519",
  d: "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU",
  x: "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs",
  key_ops: ["sign"],
  ext: true,
};
const PRIVATE_KEY = await crypto.subtle.importKey(
  "jwk",
  privateKeyJwk,
  { name: "Ed25519" },
  true,
  ["sign"],
);
const PUBLIC_KEY = await privateKeyToPublicKey(PRIVATE_KEY);
const publicKeyJwk = await crypto.subtle.exportKey("jwk", PUBLIC_KEY);

console.log(privateKeyJwk);
console.log(publicKeyJwk);
console.log(PRIVATE_KEY);
console.log(PUBLIC_KEY);

function stob(s: string) {
  return Uint8Array.from(s, (c) => c.charCodeAt(0));
}

function btos(b: ArrayBuffer) {
  return String.fromCharCode(...new Uint8Array(b));
}

async function privateKeyToPublicKey(key: CryptoKey) {
  const jwk = await crypto.subtle.exportKey("jwk", key);
  delete jwk.d;
  jwk.key_ops = ["verify"];
  const result = await crypto.subtle.importKey(
    "jwk",
    jwk,
    { name: "Ed25519" },
    true,
    ["verify"],
  );
  return result;
}

async function sign(msg: string) {
  const result = await crypto.subtle.sign(
    "Ed25519",
    PRIVATE_KEY,
    stob(msg),
  );
  return result;
}

async function check(sig: ArrayBuffer, msg: string) {
  const result = await crypto.subtle.verify(
    "Ed25519",
    PUBLIC_KEY,
    sig,
    stob(msg),
  );
  return result;
}

const param = `("date" "@method" "@path" "@authority" \
"content-type" "content-length");created=1618884473\
;keyid="test-key-ed25519"`;
const base = `"date": Tue, 20 Apr 2021 02:07:55 GMT
"@method": POST
"@path": /foo
"@authority": example.com
"content-type": application/json
"content-length": 18
"@signature-params": ${param}`;
const signature = await sign(base);
const ok = await check(signature, base);

console.log(`Signature-Input: sig-b26=${param}`);
console.log(`Signature: sig-b26=:${btoa(btos(signature))}:`);
console.log(signature);
console.log(ok);
$ deno run httpsig.ts

それではやっていきましょう。

PrivateKey

まずは#appendix-B.1.4で紹介しているJSON Web Key(本記事ではJWKと呼びます)形式の秘密鍵を入力します。

const privateKeyJwk = {
  kty: "OKP",
  crv: "Ed25519",
  kid: "test-key-ed25519",
  d: "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU",
  x: "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs",
  key_ops: ["sign"],
  ext: true,
};

PEM形式ではないJWK形式の何が嬉しいかというと、PEMの中身を解析しなくてもKey TypeやKey IDが分かるだけではなく、RFC 9421のHTTP Message SignaturesはJSON Web Signature#section-3.3.7(JWS)の使用を許可しているため、"alg"をJWSとJWKに追加すればJOSEの署名アルゴリズムに基づき署名と検証ができるようになるためです。
今回はJWSで署名と検証は行いませんが、鍵で出来ることが多ければ多いほど幸せになります!

const PRIVATE_KEY = await crypto.subtle.importKey(
  "jwk",
  privateKeyJwk,
  { name: "Ed25519" },
  true,
  ["sign"],
);

後はWeb Crypto APIを使い、JWKのObject型をCryptoKey型に変換するだけです。

秘密鍵を出力してみましょう。

console.log(privateKeyJwk);
console.log(PRIVATE_KEY);
{
  kty: "OKP",
  crv: "Ed25519",
  kid: "test-key-ed25519",
  d: "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU",
  x: "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs",
  key_ops: [ "sign" ],
  ext: true
}
CryptoKey {
  type: "private",
  extractable: true,
  algorithm: { name: "Ed25519" },
  usages: [ "sign" ]
}

これでCryptoKey型の秘密鍵を使えば署名できるようになりました。

PublicKey

続いて#appendix-B.1.4で紹介している公開鍵を出力します。

const PUBLIC_KEY = await privateKeyToPublicKey(PRIVATE_KEY);
const publicKeyJwk = await crypto.subtle.exportKey("jwk", PUBLIC_KEY);

Web Crypto APIに入力したPEM形式の秘密鍵からPEM形式の公開鍵を出力するのはめっちゃ難しいのですが、JWK形式のEd25519鍵ならこれだけで済みます。

async function privateKeyToPublicKey(key: CryptoKey) {
  const jwk = await crypto.subtle.exportKey("jwk", key);
  delete jwk.d;
  jwk.key_ops = ["verify"];
  const result = await crypto.subtle.importKey(
    "jwk",
    jwk,
    { name: "Ed25519" },
    true,
    ["verify"],
  );
  return result;
}

やっていることは至って簡単。
JWKのdプロパティを削除して、key_opsプロパティをsignからverifyにするだけです。
Web Crypto APIのおかげで簡単にJWK形式の公開鍵を出力することができました!

console.log(publicKeyJwk);
console.log(PUBLIC_KEY);

公開鍵を出力してみましょう。

{
  kty: "OKP",
  crv: "Ed25519",
  x: "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs",
  key_ops: [ "verify" ],
  ext: true
}
CryptoKey {
  type: "public",
  extractable: true,
  algorithm: { name: "Ed25519" },
  usages: [ "verify" ]
}

これでCryptoKey型の公開鍵を使えば検証できるようになりました。

ref: [Deno]Web Crypto APIに入力したPEM形式の秘密鍵からPEM形式の公開鍵を出力するのめっちゃ難しいな!(0_0)
ref: 早速ですがDenoでEd25519鍵を作ります

SignatureInput

いよいよHTTP Message Signaturesに必要なHTTP Fieldを作成していきたいと思います。
まずはSignature-Input HTTP Field#section-4.1から。

const param = `("date" "@method" "@path" "@authority" \
"content-type" "content-length");created=1618884473\
;keyid="test-key-ed25519"`;

署名したいHTTP Field#section-2.1をStructured Field Values(本記事ではSFVと呼びます)のInner Listで表現します。
また、@から始まるHTTP Fieldでは表現できないHTTP Messageのコンポーネントを含めることもでき、これらのことをDerived Components#section-2.2と呼びます。
なお、全て小文字で記載する必要があるため、注意が必要です。
例えば古いDraftでは"keyId"とiが大文字でしたが、RFC 9421では"keyid"とiを小文字にする必要があります。

なぜこのような仕様になったかというと、HTTP Message SignaturesはHTTP/2やHTTP/3でも使われることを想定しているためです。
例えばHTTP/1.1ではHostヘッダーが必須ですが、HTTP/2やHTTP/3ではauthority#section-2.2.3コンポーネントが推奨されるため、署名するHTTP FieldとDerived Componentsを取捨選択#section-7.5.4する必要があります。

そのため、古いDraftで使われていた(request-target)もHTTP/1.1でしか使われていないため、RFC 9421では@request-target#section-2.2.5として存在するものの、使用が推奨されないものとなりました。
このSignature-Inputや後述する@signature-paramsは古いDraftでheadersと呼ばれていたものですが、HTTP/2やHTTP/3ではTrailerが存在するため、SFVの文脈でSignature Parameters#section-2.3と呼ばれるようになりました。

改行せず、1行で書くとこんな感じです。

const param = '("date" "@method" "@path" "@authority" "content-type" "content-length");created=1618884473;keyid="test-key-ed25519"';

最初のInner ListをItemにして、SFVのDictionaryでSignature-Input HTTP Field#section-4.1を出力してみましょう。

console.log(`Signature-Input: sig-b26=${param}`);
Signature-Input: sig-b26=("date" "@method" "@path" "@authority" "content-type" "content-length");created=1618884473;keyid="test-key-ed25519"

#appendix-B.2.6で紹介しているSignature-Input HTTP Fieldと一致していることを確認できました。

ref: Structured Field Values による Header Field の構造化 | blog.jxck.io
ref: RFC 8941 - Structured Field Values for HTTP
ref: Structured Field Values for HTTP

Signature

さらにSignature HTTP Field#section-4.2を作成します。

async function sign(msg: string) {
  const result = await crypto.subtle.sign(
    "Ed25519",
    PRIVATE_KEY,
    stob(msg),
  );
  return result;
}

先に説明したようにHTTP Message SignaturesはHTTP/2やHTTP/3でも使われることを想定しているため、古いDraftのようにHTTP/1.1のHTTP Messageをすべて小文字にして署名みたいなことができなくなってしまいました。
そこでSignature Base#section-2.5と呼ばれるASCII文字列でHTTP Messageのコンポーネントを並べてあげたものを作ることになります。
コンポーネント名のStringと@signature-params行のInner ListはSFVなので理解できますが、突然SFVを一部使用した謎のABNFが出てきてびっくりします。

const base = `"date": Tue, 20 Apr 2021 02:07:55 GMT
"@method": POST
"@path": /foo
"@authority": example.com
"content-type": application/json
"content-length": 18
"@signature-params": ${param}`;

でも今回は#appendix-B.2.6で紹介しているSignature Baseのサンプルがあるので、これをコピペするだけで大丈夫です!

function stob(s: string) {
  return Uint8Array.from(s, (c) => c.charCodeAt(0));
}

function btos(b: ArrayBuffer) {
  return String.fromCharCode(...new Uint8Array(b));
}

ArrayBuffer型をString型にする関数を作ったり、String型をArrayBuffer型にする関数を作ったりした後、秘密鍵を使いArrayBuffer型の署名を作りましょう。

const signature = await sign(base);

改行せず、1行で書くとこんな感じです。

const signature = await sign('"date": Tue, 20 Apr 2021 02:07:55 GMT\n"@method": POST\n"@path": /foo\n"@authority": example.com\n"content-type": application/json\n"content-length": 18\n"@signature-params": ("date" "@method" "@path" "@authority" "content-type" "content-length");created=1618884473;keyid="test-key-ed25519"');

署名をbtoa()関数でBase64形式にしたら、SFVのByte SequenceにしたものをItemにして、SFVのDictionaryでSignature HTTP Field#section-4.2を出力してみましょう。

console.log(`Signature: sig-b26=:${btoa(btos(signature))}:`);
console.log(signature);
Signature: sig-b26=:wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1u02Gb04v9EDgwUPiu4A0w6vuQv5lIp5WPpBKRCw==:
ArrayBuffer {
  [Uint8Contents]: <c2 a7 00 a9 b9 98 27 68 e2 da 09 5f 00 c6 91 cb 88 2b b9 86 27 c7 69 c4 14 dd 87 37 a8 eb 9c 39 d0 08 ad 6e d3 61 9b d3 8b fd 10 38 30 50 f8 ae e0 0d 30 ea fb 90 bf 99 48 a7 95 8f a4 12 91 0b>,
  byteLength: 64
}

#appendix-B.2.6で紹介しているSignature HTTP Fieldと一致していることを確認できました。

Verify

最後に署名の検証を行います。

async function check(sig: ArrayBuffer, msg: string) {
  const result = await crypto.subtle.verify(
    "Ed25519",
    PUBLIC_KEY,
    sig,
    stob(msg),
  );
  return result;
}

とは言うものの公開鍵を使い、先程作った署名と、署名の時と同様のSignature Base#section-2.5で検証するだけです。

const ok = await check(signature, base);

改行せず、1行で書くとこんな感じです。

const ok = await check(signature, '"date": Tue, 20 Apr 2021 02:07:55 GMT\n"@method": POST\n"@path": /foo\n"@authority": example.com\n"content-type": application/json\n"content-length": 18\n"@signature-params": ("date" "@method" "@path" "@authority" "content-type" "content-length");created=1618884473;keyid="test-key-ed25519"');

検証に成功しているか確認してみましょう。

console.log(ok);
true

チェックできました!

Outro

ね、簡単でしょう?

それもそのはず今回は例に記載されたJWKとSignature Baseをそのままコピペしているからなんですね。

これを現実に落とし込むと公開鍵をどのように交換するのか、SFVを正確にパースできるパッケージは存在するのか、SFVを正確にパースできる署名はちゃんと検証できるものなのか#section-7.5.3、複数の署名#section-4.3が送られてきたらどうするのか#section-7.2.6、秘密鍵が流出した場合、新しい公開鍵をどのように交換すればよいのか#section-7.3.2、やらなくてはならないことがたくさんあります。

それらの内容はRFC 9421でも定義されておらず、非常に長いSecurity Considerations#section-7で説明されているだけでベストプラクティスは誰も教えてくれません。

また、RFC 9421公開まで約11年かかっており、Draftのまま実装され放置されたソフトウェアがたくさんあります(MastodonやMisskeyといったActivityPub実装など)
受信については#appendix-Aで説明されていますが、送信については考慮されていません。

付録A. HTTP メッセージ署名の検出

この仕様で使われている Signature フィールドの標準化されていない定義も含め、過去に署名付き HTTP メッセージを作成する試みは数多くありました。この仕様と他の文書、あるいは他の草案の様々なバージョンとの間の非互換性は、予期せぬ問題を引き起こす可能性があります。
実装者はまず、この仕様で定義されているSignature-Inputフィールドを検出して検証し、 この文書で記述されている仕組みが使用されており、代替の仕組みでないことを 検出することが推奨される。Signature-Inputフィールドが存在する場合、すべてのSignatureフィールドはこの仕様の文脈で解析され解釈される。

www.DeepL.com/Translator(無料版)で翻訳しました。

なのでみんなでHTTP Message Signaturesをやってみて、知見をどんどん増やしていけたら幸いです。

またRFC 9421と同日の2024年2月14日に古いInstance Digests in HTTP(RFC 3230)は廃止され、Digest FieldsがRFC 9530として新たに公開されました。
HTTP Message Signaturesの例でもたくさん使われています。
こちらも併せてご参照ください。

https://datatracker.ietf.org/doc/html/rfc9530

ref: 【RFC 9421】HTTPメッセージの電子署名に関するインターネット標準について – TechHarmony
ref: Issues · httpwg/http-extensions · GitHub
ref: Issues · swicg/activitypub-http-signature · GitHub

Discussion