関連する仕様
依存するプロトコルと暗号仕様の全体像。
| RFC / 仕様 | 役割 | xnotif での該当箇所 |
|---|---|---|
| RFC 8030 | Generic Event Delivery Using HTTP Push | Push Service の概念モデル (Application Server → Push Service → User Agent) |
| draft-ietf-webpush-encryption-04 | Web Push 暗号化 (aesgcm スキーム) | Decryptor クラスの復号処理全体 |
| draft-ietf-httpbis-encryption-encoding-03 | HTTP Encrypted Content-Encoding (aesgcm) | レコード分割・パディング処理 |
| RFC 5869 | HMAC-based Key Derivation Function (HKDF) | 共有秘密 → IKM → CEK/nonce の導出 |
| RFC 8292 | VAPID (Voluntary Application Server Identification) | Twitter の VAPID 公開鍵でチャネル登録 |
| SEC 1 v2 §2.3.3 | 楕円曲線公開鍵の非圧縮表現 | P-256 公開鍵の 65バイト raw エクスポート |
| Mozilla Autopush Protocol | RFC 8030 の WebSocket トランスポート実装 | AutopushClient の hello/register/ack メッセージ |
aesgcm vs aes128gcm
RFC 8291 で最終標準化されたのは
aes128gcmスキームだが、Twitter は旧aesgcmスキーム(draft 版)を使用している。両者は info バイト列の構築方法、パディング形式、HTTP ヘッダの使い方が異なる。xnotif はaesgcmを実装している。
なぜ Web Push なのか
| 手法 | 欠点 |
|---|---|
| Twitter API v2 | 高額・厳しいレートリミット・審査が必要 |
| スクレイピング | ヘッドレスブラウザが必要・BAN リスク・リアルタイム性なし |
| Web Push | Cookie を1回使うだけ・リアルタイム・軽量 |
RFC 8030 では、Push の3つの役割を定義している:
┌──────────────────┐ ┌──────────────┐ ┌────────────┐
│ Application │ POST │ Push │ WS │ User │
│ Server │─────>│ Service │─────>│ Agent │
│ (Twitter) │ │ (Autopush) │ │ (xnotif) │
└──────────────────┘ └──────────────┘ └────────────┘
- Application Server: 通知を送信する側(= Twitter)
- Push Service: メッセージを中継するサーバー(= Mozilla Autopush)
- User Agent: 通知を受信する側(= ブラウザ、ここでは xnotif)
xnotif は User Agent の役割を Node.js プロセスとして再現する。
全体アーキテクチャ
┌──────────────┐
│ Twitter/X │
│ Server │
└──────┬───────┘
│ HTTPS POST (暗号化ペイロード)
│ Crypto-Key: dh=<server_pubkey>
│ Encryption: salt=<salt>
v
┌──────────────┐
│ Mozilla │
│ Autopush │
│ (WebSocket) │
└──────┬───────┘
│ wss://push.services.mozilla.com
│ messageType: "notification"
v
┌────────────────────────────────────────────┐
│ xnotif (Node.js) │
│ │
│ ┌─────────────┐ ┌──────────┐ ┌───────┐ │
│ │ Autopush │→ │ Decryptor│→ │ Filter│ │
│ │ Client │ │ (AESGCM) │ │ │ │
│ └─────────────┘ └──────────┘ └───┬───┘ │
│ │ │
│ EventEmitter ← ← ← ← ─┘ │
└────────────────────────────────────────────┘
│
v
アプリケーションコード
(on "notification")
Step 1: ECDH 鍵ペアの生成
Web Push の暗号化は ECDH (Elliptic Curve Diffie-Hellman) による鍵共有に基づく。xnotif は起動時に P-256 曲線上の鍵ペアを生成する。
// decrypt.ts - Decryptor.create()
const keyPair = await crypto.subtle.generateKey(
{ name: "ECDH", namedCurve: "P-256" },
true, // extractable: true(JWK エクスポートのため)
["deriveBits"]
);- 秘密鍵: JWK (JSON Web Key) 形式で永続化される。
dパラメータがスカラー値 - 公開鍵: SEC 1 §2.3.3 の非圧縮形式でエクスポート。先頭
0x04+ X座標 32バイト + Y座標 32バイト = 65バイト - auth secret:
crypto.getRandomValues(new Uint8Array(16))で生成する 16バイトの乱数。HKDF の第1段階で入力鍵材料 (IKM) と混合される
公開鍵と auth secret は base64url エンコードされ、Twitter への Push 登録時に p256dh / auth として送信される。これは W3C Push API の PushSubscription.getKey() が返す値と同じ。
状態の永続化
connectedイベントで受け取ったClientStateに JWK 秘密鍵と auth secret が含まれる。これをファイルに保存しておけば、次回起動時にDecryptor.create(jwk, auth)で鍵ペアを復元でき、Twitter への再登録が不要になる。
Step 2: Autopush 接続 (Mozilla Push Service)
プロトコル概要
Mozilla Autopush は RFC 8030 (HTTP Push) を WebSocket トランスポートで実装した Push Service。RFC 8030 自体は HTTP/2 Server Push を想定しているが、Autopush は独自の WebSocket ベースのプロトコルを提供しており、xnotif はこちらを使用する。
接続先: wss://push.services.mozilla.com/
ハンドシェイクシーケンス
Client Autopush Server
│ │
│──── WebSocket OPEN ───────────────────>│
│ │
│──── hello ────────────────────────────>│
│ { │
│ "messageType": "hello", │
│ "use_webpush": true, │
│ "uaid": "", │
│ "broadcasts": {} │
│ } │
│ │
│<──── hello ACK ───────────────────────│
│ { │
│ "messageType": "hello", │
│ "uaid": "a1b2c3...", │
│ "broadcasts": { ... } │
│ } │
│ │
│──── register ─────────────────────────>│
│ { │
│ "messageType": "register", │
│ "channelID": "<uuid>", │
│ "key": "<VAPID public key>" │
│ } │
│ │
│<──── register ACK ────────────────────│
│ { │
│ "messageType": "register", │
│ "status": 200, │
│ "pushEndpoint": "https://..." │
│ } │
│ │
│ ── 待機 (通知受信待ち) ── │
各フィールドの意味
| フィールド | 説明 |
|---|---|
uaid | User Agent ID。Autopush がクライアントを識別する永続 ID。空文字で送信すると新規発行される。復元時は前回の値を送信 |
use_webpush | true で RFC 8030 準拠のプッシュメッセージ形式を要求 |
broadcasts | Autopush のブロードキャスト機能。サーバー側の状態をクライアントと同期する仕組み |
channelID | RFC 8030 §5 の Subscription に対応。1つの uaid に対して複数チャネルを登録可能 |
key | VAPID 公開鍵 (RFC 8292)。Application Server(Twitter)を識別する。base64url エンコードされた P-256 非圧縮公開鍵 |
pushEndpoint | RFC 8030 §4 の Push Resource。Application Server がここに POST することでメッセージが配信される |
VAPID キー
// client.ts で定義されたハードコード値
const VAPID_KEY =
"BF5oEo0xDUpgylKDTlsd8pZmxQA1leYINiY-rSscWYK_3tWAkz4VMbtf1MLE_Yyd6iII6o-e3Q9TCN5vZMzVMEs";これは Twitter の Application Server VAPID 公開鍵 (RFC 8292)。Autopush はこの鍵をチャネル登録時に受け取り、Twitter からの POST リクエストに含まれる VAPID JWT 署名を検証して、正当な送信者からのメッセージであることを確認する。
通知受信と ACK
// autopush.ts - 通知メッセージ受信時
case "notification": {
// 即座に ACK を返送(Autopush プロトコル要件)
this.send({
messageType: "ack",
updates: [{
channelID: notification.channelID,
version: notification.version,
code: 100, // 100 = 正常受信
}],
});
this.options.onNotification(notification);
}ACK を返さないと Autopush は再送を続ける。code: 100 は Autopush 独自のステータスコードで「正常受領」を意味する。
自動再接続
WebSocket が切断されると、指数バックオフで自動再接続する:
1秒 → 2秒 → 4秒 → 8秒 → ... → 最大60秒
close() を明示的に呼んだ場合は closed フラグが立ち、再接続しない。
Step 3: Twitter への Push Subscription 登録
Autopush から取得した endpoint と暗号鍵を Twitter の内部 API に登録する。おそらく本当はFirefoxでやったほうが良い。
POST /1.1/notifications/settings/login.json
Authorization: Bearer <session bearer token>
x-csrf-token: <ct0 cookie value>
Content-Type: application/json
{
"push_device_info": {
"os_version": "Web/Chrome",
"udid": "Web/Chrome",
"env": 3,
"locale": "en",
"protocol_version": 1,
"token": "https://updates.push.services.mozilla.com/wpush/v2/...",
"encryption_key1": "<p256dh: 受信者公開鍵 base64url>",
"encryption_key2": "<auth: 認証シークレット base64url>"
}
}
W3C Push API との対応
| Twitter API フィールド | W3C Push API 相当 | 仕様 |
|---|---|---|
token | PushSubscription.endpoint | RFC 8030 §4 Push Resource URL |
encryption_key1 | PushSubscription.getKey("p256dh") | 受信者の ECDH P-256 公開鍵 (65バイト, base64url) |
encryption_key2 | PushSubscription.getKey("auth") | 共有認証シークレット (16バイト, base64url) |
Twitter はこの情報を保存し、通知発生時に token の URL に対して暗号化されたペイロードを POST する。encryption_key1 (p256dh) と encryption_key2 (auth) は暗号化に使われる。
Cookie の使用は1回だけ
auth_tokenとct0Cookie はこの登録リクエストにのみ使用される。以降の通知受信は Autopush WebSocket 経由で行われ、セッション Cookie は不要。
endpoint が前回保存した値と同一の場合、再登録をスキップする(冪等性)。
Step 4: 受信と復号 (aesgcm Web Push Encryption)
通知受信時、Autopush から以下の構造のメッセージが届く:
{
"messageType": "notification",
"channelID": "...",
"version": "...",
"data": "<base64url エンコードされた暗号文>",
"headers": {
"crypto_key": "dh=<送信者の一時 ECDH 公開鍵 base64url>",
"encryption": "salt=<16バイトのソルト base64url>;rs=<レコードサイズ>"
}
}これらの HTTP ヘッダは RFC 8030 §5 で規定された Push Message の Crypto-Key / Encryption ヘッダに対応する。Autopush がそのまま WebSocket メッセージの headers フィールドに転送している。
復号の全手順
以下は decrypt.ts の Decryptor.decrypt() が行う処理の詳細。
4.1 ヘッダのパース
Crypto-Key: dh=<server_public_key_base64url>
Encryption: salt=<salt_base64url>;rs=<record_size>
dh: 送信者(Twitter)がこのメッセージ専用に生成した一時 ECDH P-256 公開鍵(エフェメラルキー)。65バイト非圧縮形式を base64url エンコードsalt: 16バイトのランダム値。HKDF の salt パラメータrs(optional): レコードサイズ。ペイロードをチャンクに分割する際の単位
4.2 ECDH 共有秘密の導出
shared_secret = ECDH(receiver_private_key, sender_public_key)
→ 32 bytes (P-256 の x座標)
// decrypt.ts L82-89
const sharedSecret = new Uint8Array(
await crypto.subtle.deriveBits(
{ name: "ECDH", public: remotePubKey },
this.keyPair.privateKey,
256, // P-256 → 256ビット = 32バイト
),
);P-256 ECDH では、両者の鍵ペアから楕円曲線上の共通点を計算し、その x 座標(32バイト)が共有秘密となる。
4.3 IKM の導出 (HKDF 第1段階)
PRK = HMAC-SHA-256(salt=auth_secret, IKM=shared_secret) ... Extract
IKM = HMAC-SHA-256(PRK, "Content-Encoding: auth\0" || 0x01) ... Expand
→ 32 bytes
// decrypt.ts L92-93
const authInfo = new TextEncoder().encode("Content-Encoding: auth\0");
const ikm = await hkdf(new Uint8Array(this.authSecret), sharedSecret, authInfo, 32);RFC 5869 の HKDF は Extract-then-Expand の2段階構造:
- Extract:
PRK = HMAC-SHA-256(salt, IKM)— 入力鍵材料を固定長の擬似ランダム鍵に凝縮 - Expand:
OKM = HMAC-SHA-256(PRK, info || 0x01)— PRK を所望の長さに展開
この段階では、ECDH 共有秘密と auth secret を HKDF で混合し、以降のステップで使う IKM を得る。auth secret を混合することで、push endpoint URL が漏洩しても第三者が復号できないようにしている(draft-ietf-webpush-encryption §3.2)。
4.4 context バイト列の構築
aesgcm スキーム固有の context は、CEK と nonce の導出に使う info パラメータの一部。
context = "P-256" || 0x00
|| 0x00 0x41 || receiver_public_key (65 bytes)
|| 0x00 0x41 || sender_public_key (65 bytes)
// decrypt.ts L96-102
const context = concatBuffers(
new TextEncoder().encode("P-256\0").buffer, // curve label + null
new Uint8Array([0, 65]).buffer, // receiver key length (BE)
this.publicKeyRaw, // receiver public key (65 bytes)
new Uint8Array([0, 65]).buffer, // sender key length (BE)
remotePubKeyBytes, // sender public key (65 bytes)
);| バイト列 | サイズ | 説明 |
|---|---|---|
"P-256\0" | 6 bytes | 曲線名 + null 終端 (draft-ietf-webpush-encryption §3.4) |
0x00 0x41 | 2 bytes | 65 のビッグエンディアン表現(続く公開鍵の長さ) |
| receiver public key | 65 bytes | 受信者(xnotif)の P-256 非圧縮公開鍵 |
0x00 0x41 | 2 bytes | 同上 |
| sender public key | 65 bytes | 送信者(Twitter)の一時公開鍵 |
| 合計 | 140 bytes |
aesgcm と aes128gcm の context の違い
最終版 RFC 8291 (
aes128gcm) では context の構築方法が変更され、info が単純化された。aesgcmdraft では上記のように両者の公開鍵を明示的に含める必要がある。
4.5 CEK (Content Encryption Key) の導出 (HKDF 第2段階)
info = "Content-Encoding: aesgcm\0" || context (140 bytes)
CEK = HKDF(salt=encryption_salt, IKM=ikm, info=info, L=16)
→ 16 bytes (AES-128 鍵)
// decrypt.ts L105-109
const cekInfo = concatBuffers(
new TextEncoder().encode("Content-Encoding: aesgcm\0").buffer,
context,
);
const cek = await hkdf(new Uint8Array(salt), ikm, new Uint8Array(cekInfo), 16);4.6 Nonce の導出 (HKDF 第2段階)
info = "Content-Encoding: nonce\0" || context (140 bytes)
nonce = HKDF(salt=encryption_salt, IKM=ikm, info=info, L=12)
→ 12 bytes (AES-GCM IV)
// decrypt.ts L112-116
const nonceInfo = concatBuffers(
new TextEncoder().encode("Content-Encoding: nonce\0").buffer,
context,
);
const nonce = await hkdf(new Uint8Array(salt), ikm, new Uint8Array(nonceInfo), 12);4.7 AES-128-GCM 復号
導出した CEK (16バイト) と nonce (12バイト) で暗号文を復号する。
// decrypt.ts L119-141
const cekKey = await crypto.subtle.importKey("raw", cek, "AES-GCM", false, ["decrypt"]);
// レコードサイズ (rs) が指定されている場合はチャンク分割
if (rs >= 18) {
// ペイロードを rs バイトごとに分割
// 各チャンクの nonce = base_nonce XOR chunk_index(下位6バイトでXOR)
const chunks = splitPayload(payload, rs);
for (let i = 0; i < chunks.length; i++) {
const chunkNonce = adjustNonce(nonce, i);
// 各チャンクを個別に AES-GCM 復号
}
} else {
// 単一レコードとして一括復号
decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: nonce },
cekKey,
payload,
);
}チャンクごとの nonce 調整
draft-ietf-httpbis-encryption-encoding §3 に基づき、レコード分割時は各チャンクのシーケンス番号を nonce に XOR する:
chunk_nonce[i] = base_nonce XOR (i as big-endian, lower 6 bytes)
// decrypt.ts L203-210
function adjustNonce(nonce: Uint8Array, offset: number): Uint8Array {
const adjusted = new Uint8Array(nonce);
// 12バイトの nonce のうち、下位6バイト (index 6-11) に offset を XOR
for (let i = 11; i >= 6; i--) {
adjusted[i] ^= (offset >>> ((11 - i) * 8)) & 0xff;
}
return adjusted;
}4.8 aesgcm パディングの除去
aesgcm スキーム (draft-ietf-httpbis-encryption-encoding §2) では、平文の先頭に 2バイトのビッグエンディアンパディング長 が付与される。
復号後のバイト列:
┌──────────┬───────────────────┬──────────────┐
│ pad_len │ padding (0x00...) │ 実際の平文 │
│ (2 bytes)│ (pad_len bytes) │ (残り全部) │
└──────────┴───────────────────┴──────────────┘
// decrypt.ts L144-148
const view = new DataView(decrypted);
const paddingLen = view.getUint16(0); // 先頭2バイト = パディング長
const plaintext = decrypted.slice(2 + paddingLen); // パディングをスキップ
return new TextDecoder().decode(plaintext); // UTF-8 JSON 文字列aes128gcm との違い
RFC 8188 (
aes128gcm) ではパディングが末尾に移動し、デリミタバイト (0x02または0x01) で区切る方式に変更された。aesgcmは先頭2バイト長プレフィックスという旧方式。
復号フロー全体図
Crypto-Key ヘッダ Encryption ヘッダ 暗号化ペイロード
│ │ │
v v │
sender_pub_key salt, rs │
│ │ │
│ receiver_priv_key │ │
v │ │ │
ECDH ←────┘ │ │
│ │ │
v │ │
shared_secret │ │
│ │ │
│ auth_secret │ │
v │ │ │
HKDF ←────┘ │ │
(Extract+Expand) │ │
info="Content-Encoding: │ │
auth\0" │ │
│ │ │
v │ │
IKM │ │
│ │ │
├─── HKDF ──────────────┤ │
│ salt=salt │ │
│ info="..aesgcm\0" │ │
│ +context │ │
v │ │
CEK (16 bytes) │ │
│ │ │
├─── HKDF ──────────────┘ │
│ salt=salt │
│ info="..nonce\0" │
│ +context │
v │
nonce (12 bytes) │
│ │
v v
AES-128-GCM Decrypt ──────────────────────> padded plaintext
│
strip 2+N bytes
│
v
JSON string
(TwitterNotification)
Step 5: フィルタリングとイベント発火
復号後の JSON を TwitterNotification にパースし、オプションのフィルタ関数を通してからイベントを発火する。
// client.ts L71-80 - フィルタロジック
if (this.options.filter) {
try {
if (!this.options.filter(notification)) return; // false → 破棄
} catch (filterErr) {
this.emit("error", filterErr); // 例外 → error イベント + 破棄
return;
}
}
this.emit("notification", notification);| フィルタの戻り値 | 動作 |
|---|---|
true | notification イベントを発火 |
false | サイレントに破棄 |
| 例外を throw | 破棄 + error イベントを発火 |
主要な型定義
TwitterNotification
interface TwitterNotification {
title: string; // 例: "@jack"
body: string; // 例: "さんがあなたのツイートをいいねしました"
icon?: string; // プロフィール画像 URL
timestamp?: number; // Unix epoch (ms)
tag?: string; // 重複排除用タグ (W3C Notification API §2.6.2)
data?: {
type?: string; // "mention" | "tweet" | "like" | "follow" など
uri?: string; // X.com 上のリンク
lang?: string; // BCP 47 言語コード
scribe_target?: string; // Twitter 内部の分類タグ
impression_id?: string; // 表示追跡 ID
[key: string]: unknown;
};
}ClientState(永続化用)
interface ClientState {
uaid: string; // Autopush User Agent ID
channelId: string; // RFC 8030 Subscription のチャネル ID
endpoint: string; // Push Resource URL
remoteBroadcasts: Record<string, string>;
decryptor: {
jwk: JsonWebKey; // ECDH P-256 秘密鍵 (RFC 7517)
auth: string; // auth secret (base64url, 16 bytes)
};
}イベント一覧
| イベント | ペイロード | 説明 |
|---|---|---|
notification | TwitterNotification | 復号済み通知を受信 |
connected | ClientState | 接続成功。この状態を永続化する |
error | Error | エラー発生(接続は継続) |
disconnected | — | WebSocket 切断 |
reconnecting | number (ms) | 自動再接続中(指数バックオフ 1s → 60s) |
使用例
import { createClient, type ClientState } from "xnotif";
import * as fs from "fs";
// 前回の状態を復元
let state: ClientState | undefined;
if (fs.existsSync("state.json")) {
state = JSON.parse(fs.readFileSync("state.json", "utf-8"));
}
const client = createClient({
cookies: { auth_token: "...", ct0: "..." },
state,
filter: (n) => n.data?.type === "tweet",
});
client.on("connected", (newState) => {
// 次回起動時に鍵ペアを復元するため状態を保存
fs.writeFileSync("state.json", JSON.stringify(newState));
});
client.on("notification", (n) => {
console.log(`${n.title}: ${n.body}`);
});
client.on("error", (err) => console.error(err));
await client.start();内部モジュール構成
packages/core/src/
├── index.ts 公開エクスポート (createClient, 型)
├── client.ts NotificationClient — 高レベル API, EventEmitter
├── autopush.ts AutopushClient — Autopush WebSocket プロトコル
├── decrypt.ts Decryptor — aesgcm Web Push 復号 (ECDH + HKDF + AES-GCM)
├── twitter.ts Twitter Push 登録 API (/1.1/notifications/settings/login.json)
├── types.ts TypeScript 型定義
└── utils.ts base64url ↔ ArrayBuffer 変換, バッファ結合
設計上のポイント
- Cookie 露出の最小化: セッション Cookie は登録時の1回だけ使用。通知受信ループでは不要
- Web 標準 API のみ:
WebSocket,crypto.subtle,TextEncoder/Decoder。Node.js 22+ でランタイム互換 - 冪等な接続:
start()の複数回呼び出しや endpoint 未変更時の再登録スキップ - 自動再接続: 指数バックオフ(1秒 → 最大60秒)で自動復帰
- エフェメラルキーによる前方秘匿性: Twitter は通知ごとに一時 ECDH 鍵ペアを生成する。過去の通知の暗号文が漏洩しても、対応する一時秘密鍵がなければ復号不可能