関連する仕様

依存するプロトコルと暗号仕様の全体像。

RFC / 仕様役割xnotif での該当箇所
RFC 8030Generic Event Delivery Using HTTP PushPush Service の概念モデル (Application Server → Push Service → User Agent)
draft-ietf-webpush-encryption-04Web Push 暗号化 (aesgcm スキーム)Decryptor クラスの復号処理全体
draft-ietf-httpbis-encryption-encoding-03HTTP Encrypted Content-Encoding (aesgcm)レコード分割・パディング処理
RFC 5869HMAC-based Key Derivation Function (HKDF)共有秘密 → IKM → CEK/nonce の導出
RFC 8292VAPID (Voluntary Application Server Identification)Twitter の VAPID 公開鍵でチャネル登録
SEC 1 v2 §2.3.3楕円曲線公開鍵の非圧縮表現P-256 公開鍵の 65バイト raw エクスポート
Mozilla Autopush ProtocolRFC 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 PushCookie を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://..."       │
  │  }                                     │
  │                                        │
  │        ── 待機 (通知受信待ち) ──        │

各フィールドの意味

フィールド説明
uaidUser Agent ID。Autopush がクライアントを識別する永続 ID。空文字で送信すると新規発行される。復元時は前回の値を送信
use_webpushtrue で RFC 8030 準拠のプッシュメッセージ形式を要求
broadcastsAutopush のブロードキャスト機能。サーバー側の状態をクライアントと同期する仕組み
channelIDRFC 8030 §5 の Subscription に対応。1つの uaid に対して複数チャネルを登録可能
keyVAPID 公開鍵 (RFC 8292)。Application Server(Twitter)を識別する。base64url エンコードされた P-256 非圧縮公開鍵
pushEndpointRFC 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 相当仕様
tokenPushSubscription.endpointRFC 8030 §4 Push Resource URL
encryption_key1PushSubscription.getKey("p256dh")受信者の ECDH P-256 公開鍵 (65バイト, base64url)
encryption_key2PushSubscription.getKey("auth")共有認証シークレット (16バイト, base64url)

Twitter はこの情報を保存し、通知発生時に token の URL に対して暗号化されたペイロードを POST する。encryption_key1 (p256dh) と encryption_key2 (auth) は暗号化に使われる。

Cookie の使用は1回だけ

auth_tokenct0 Cookie はこの登録リクエストにのみ使用される。以降の通知受信は 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.tsDecryptor.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段階構造:

  1. Extract: PRK = HMAC-SHA-256(salt, IKM) — 入力鍵材料を固定長の擬似ランダム鍵に凝縮
  2. 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 0x412 bytes65 のビッグエンディアン表現(続く公開鍵の長さ)
receiver public key65 bytes受信者(xnotif)の P-256 非圧縮公開鍵
0x00 0x412 bytes同上
sender public key65 bytes送信者(Twitter)の一時公開鍵
合計140 bytes

aesgcm と aes128gcm の context の違い

最終版 RFC 8291 (aes128gcm) では context の構築方法が変更され、info が単純化された。aesgcm draft では上記のように両者の公開鍵を明示的に含める必要がある。

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);
フィルタの戻り値動作
truenotification イベントを発火
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)
  };
}

イベント一覧

イベントペイロード説明
notificationTwitterNotification復号済み通知を受信
connectedClientState接続成功。この状態を永続化する
errorErrorエラー発生(接続は継続)
disconnectedWebSocket 切断
reconnectingnumber (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 鍵ペアを生成する。過去の通知の暗号文が漏洩しても、対応する一時秘密鍵がなければ復号不可能