Skip to content

ブラウザ内 E2E暗号 + Cloudflare Tunnelで自宅に安全アクセス

外出先の端末などでhttps復号されて通信の中身が見られる(zscalerなど)状況において、安心して自宅の開発環境にアクセスする為に、E2E暗号を導入する。

ただし、外出先端末にキーロガーや画面キャプチャを取られた場合は意味をなさない。あくまでzscalerなどhttps復号するものが途中に存在している場合に有効である。

ざっくり構成

  • 外側暗号化は通常時と同様にCloudflare Tunnelにてhttps化する
  • 内側暗号としてProxyサーバとブラウザ間でE2E暗号を実装する
  • E2E暗号をする際にはWebAuthenを導入してユーザ体験の向上を目指す
mermaid
flowchart LR
  U["フリー端末ブラウザ(Zscalerあり)"]
  CF["Cloudflare Tunnel"]
  HP["自宅サーバ(E2E Proxy)"]
  VS["code-server(127.0.0.1)"]

  U -->|HTTPS| CF
  CF -->|HTTPS| HP
  HP -->|localhost| VS

  subgraph B["Browser 内部"]
    JS["E2E Client JS"]
    WA["WebAuthn(Passkey/生体)"]
    CR["WebCrypto(ECDH + AEAD)"]
    JS --> WA
    JS --> CR
  end

  U --- B
mermaid
sequenceDiagram
  participant B as Browser (Public terminal)
  participant A as Authenticator (Passkey: phone/biometric)
  participant P as Home Proxy (E2E endpoint)
  participant V as code-server

  B->>P: 1) GET /bootstrap (challenge + serverEcdhPub + policies)
  Note over B: ブラウザで clientEcdhKeyPair を生成

  B->>A: 2) WebAuthn.get()<br/>challenge = H(challenge || clientEcdhPub || serverEcdhPub)
  A-->>B: assertion (署名付き)

  B->>P: 3) POST /handshake {assertion, clientEcdhPub}
  P->>P: 4) assertion検証(登録済みcredentialの公開鍵で)
  P->>P: 5) ECDH(serverPriv, clientPub) → sharedSecret
  B->>B: 6) ECDH(clientPriv, serverPub) → sharedSecret
  Note over B,P: 7) HKDFで sessionKey を導出(AEAD鍵+nonce規約)

  B->>P: 8) WebSocket開始(以後はAEAD暗号化ペイロードのみ)
  P->>V: 9) 復号して code-server に転送(localhost)
  V-->>P: 10) 応答
  P-->>B: 11) 暗号化して返す

まずはWebAuthenなしで実装イメージを理解する

1) 全体構成図(E2E + WebAuthn)

mermaid
flowchart LR
  U["フリー端末のブラウザ\n(Zscalerあり)"]
  CF["Cloudflare Tunnel\n(道を作るだけ)"]
  P["自宅サーバのProxy\n(復号/中継)"]
  VS["code-server\nlocalhost"]

  U -->|HTTPS| CF -->|HTTPS| P -->|localhost| VS

  subgraph B["ブラウザ内"]
    W["WebAuthn\n(指紋/FaceIDで承認)"]
    K["鍵交換(ECDH)\n= 共有鍵を作る"]
    E["E2E暗号化(AES-GCM)\n= 中身を封筒に入れる"]
    W --> K --> E
  end

  U --- B
  • ZscalerがHTTPSを覗いても、見えるのは 暗号化された中身だけ
  • Proxyが復号して、code-serverに“普通の通信”として渡す

2) 何が起きるか(超ざっくり手順)

「ブラウザ」と「自宅Proxy」がやりとりするのは 3 段階。

  1. 材料をもらう(/bootstrap)
  2. 本人承認を取る(WebAuthn)
  3. 暗号通信を始める(WebSocket)

3) 具体フロー(中で何をしてるか)

Step 1: /bootstrap(鍵交換の準備)

Proxy → Browser に渡すもの

  • session_id:今回の通信のID
  • challenge:今回の承認用の“お題”(ランダム)
  • server_pub:Proxyが作った使い捨て公開鍵(公開してOK)

疑似コード(Proxy)

server_keypair = generate_ECDH_keypair()
session_id = random()
challenge  = random()

store(session_id -> {server_private_key, challenge, expires})

return {session_id, challenge, server_public_key}
Step 2: Browserが自分の使い捨て公開鍵を作る

疑似コード(Browser)

client_keypair = generate_ECDH_keypair()
client_pub = export_public_key(client_keypair)
Step 3: WebAuthnで「この鍵交換を承認した」証拠を作る

ここがキモ。

署名させたい内容(=あとで改ざんチェックできる)

  • session_id
  • challenge
  • server_pub
  • client_pub
  • origin(このサイトのURL)

これらをまとめてハッシュして、そのハッシュをWebAuthnのchallengeにする。

疑似コード(Browser)

transcript = concat("V1", session_id, challenge, server_pub, client_pub, origin)
transcript_hash = sha256(transcript)

assertion = webauthn_get(challenge = transcript_hash)

ここでスマホ指紋/FaceIDの承認が出るイメージ。

Step 4: /handshake(Proxyに承認結果と client_pub を渡す)

疑似コード(Browser → Proxy)

POST /handshake {
  session_id,
  client_pub,
  webauthn_assertion
}
Step 5: Proxyが WebAuthn 検証(本人承認の確認)

Proxyは受け取った client_pub と自分の server_pub/challenge から同じ transcript_hash を作って照合する。

疑似コード(Proxy)

saved = load(session_id)  # {server_priv, challenge}
transcript = concat("V1", session_id, saved.challenge, server_pub, client_pub, origin)
expected_hash = sha256(transcript)

verify_webauthn_assertion(assertion, expected_hash, registered_credential_pubkey)
if fail -> reject

✅ これが通ると「この鍵セットは本人が承認した」が成立。

Step 6: 共有鍵を両者で作る(ECDH)
  • Browser:client_priv + server_pub -> shared
  • Proxy:server_priv + client_pub -> shared で同じ shared ができる。

疑似コード(両方共通)

shared = ECDH(private_key, other_public_key)
session_key = HKDF(shared, salt=session_id+challenge)
Step 7: WebSocketで暗号通信(AES-GCM)

以後の通信は「暗号化済みデータのみ」。

疑似コード(Browser:送信)

ctr += 1
iv = make_iv(session_id, ctr)    # 同じivを絶対使い回さない
ciphertext = AES_GCM_Encrypt(session_key, iv, plaintext)

send({session_id, ctr, iv, ciphertext})

Proxyは受け取ったら復号して code-server に流す。

疑似コード(Proxy:受信)

check ctr is increasing  # リプレイ防止
plaintext = AES_GCM_Decrypt(session_key, iv, ciphertext)
forward_to_code_server(plaintext)
この設計で「防げること/無理なこと」

防げる(あなたの目的)

  • ✅ ZscalerがHTTPSを復号しても 中身は読めない(暗号化済みだから)
  • ✅ 公衆Wi-Fi盗聴も同様に読めない

無理(割り切り)

  • ❌ 端末にキーロガーがいたら入力は取られる
  • ❌ 画面キャプチャも取られる
  • ❌ ブラウザ内のJSを改ざんされたら暗号前後を盗まれる可能性がある → ここはCSPなどで“低減”はできるがゼロにはできない

動く最小PoC(Macローカル / Node.js) ※WebAuthnなし

ディレクトリ構成

e2e-poc/                ← 作業フォルダ(プロジェクト)
├─ package.json         ← npmプロジェクトの設定(依存パッケージ一覧など)
├─ package-lock.json    ← 依存の固定(npmが自動生成)
├─ node_modules/        ← インストールしたライブラリ本体(npmが自動生成)
├─ server.mjs           ← Nodeサーバ本体(/bootstrap, /handshake, /ws)
└─ public/              ← ブラウザが読むファイル置き場(静的配信)
   ├─ index.html        ← 画面(ボタンや入力欄)
   └─ client.js         ← ブラウザ側の暗号処理・WS通信
  • server.mjs:サーバ側(鍵を作る・復号する・WSで返す)
  • public/:ブラウザ側(鍵を作る・暗号化して送る・復号して表示)

0) 事前準備

Node.js 18+ を想定(20でもOK)

bash
mkdir e2e-poc
cd e2e-poc
npm init -y
npm i express ws

1) サーバ(proxy役)を作る server.mjs

js
import express from "express";
import { WebSocketServer } from "ws";
import crypto from "crypto";

const app = express();
app.use(express.json());
app.use(express.static("public"));

/**
 * セッションごとの一時保存(PoCなのでメモリMap)
 * session_id -> {
 *   serverPrivKeyPem,
 *   serverPubKeyPem,
 *   createdAt
 * }
 */
const sessions = new Map();

function b64url(buf) {
  return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function unb64url(s) {
  s = s.replace(/-/g, "+").replace(/_/g, "/");
  while (s.length % 4) s += "=";
  return Buffer.from(s, "base64");
}

/**
 * HKDF-SHA256で 32byte の鍵を作る(PoC用)
 */
function hkdf32(sharedSecret, salt, info) {
  return crypto.hkdfSync("sha256", sharedSecret, salt, info, 32);
}

/**
 * AES-256-GCM decrypt/encrypt
 */
function aesGcmEncrypt(key, iv, plaintext, aad = null) {
  const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
  if (aad) cipher.setAAD(aad);
  const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
  const tag = cipher.getAuthTag();
  return { ct, tag };
}
function aesGcmDecrypt(key, iv, ct, tag, aad = null) {
  const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
  if (aad) decipher.setAAD(aad);
  decipher.setAuthTag(tag);
  return Buffer.concat([decipher.update(ct), decipher.final()]);
}

/**
 * (重要) IVは再利用禁止なので、ctrから決定的に作る
 * 12 bytes = SHA256("iv"||session_id||direction||ctr) の先頭12byte
 */
function deriveIv(sessionId, direction, ctr) {
  const h = crypto.createHash("sha256");
  h.update("iv");
  h.update(sessionId);
  h.update(direction); // "c2s" or "s2c"
  h.update(String(ctr));
  return h.digest().subarray(0, 12);
}

// --- HTTP: bootstrap ---
app.get("/bootstrap", (req, res) => {
  const sessionId = crypto.randomBytes(16).toString("hex");

  // ECDH (P-256) 鍵ペアを生成
  const ecdh = crypto.createECDH("prime256v1");
  ecdh.generateKeys();

  const serverPriv = ecdh.getPrivateKey(); // Buffer
  const serverPub = ecdh.getPublicKey();   // Buffer (uncompressed)

  // salt/info もセッション固定にしておく(PoC)
  const salt = crypto.randomBytes(16);
  const info = Buffer.from("E2E_POC_V1");

  sessions.set(sessionId, {
    serverPriv,
    serverPub,
    salt,
    info,
    createdAt: Date.now(),
  });

  res.json({
    session_id: sessionId,
    server_ecdh_pub: b64url(serverPub),
    salt: b64url(salt),
    info: b64url(info),
    expires_in_sec: 300,
  });
});

// --- HTTP: handshake ---
app.post("/handshake", (req, res) => {
  const { session_id, client_ecdh_pub } = req.body ?? {};
  const s = sessions.get(session_id);
  if (!s) return res.status(400).json({ error: "unknown session" });

  const clientPub = unb64url(client_ecdh_pub);

  // 共有秘密を計算
  const ecdh = crypto.createECDH("prime256v1");
  ecdh.setPrivateKey(s.serverPriv);
  const shared = ecdh.computeSecret(clientPub);

  // セッション鍵(32byte)を導出
  const key = hkdf32(shared, unb64url(req.body.salt ?? b64url(s.salt)), unb64url(req.body.info ?? b64url(s.info)));

  // WebSocketで使うため保存
  s.sessionKey = key;
  s.lastCtrC2S = 0;
  s.lastCtrS2C = 0;

  res.json({ ok: true });
});

const server = app.listen(3000, () => {
  console.log("Open http://localhost:3000");
});

// --- WebSocket: encrypted echo ---
const wss = new WebSocketServer({ server, path: "/ws" });

wss.on("connection", (ws, req) => {
  ws.on("message", (msg) => {
    try {
      const frame = JSON.parse(msg.toString("utf8"));
      const { sid, ctr, ct, tag } = frame;
      const s = sessions.get(sid);
      if (!s?.sessionKey) throw new Error("no session key");

      // リプレイ防止:ctrは単調増加
      if (typeof ctr !== "number" || ctr <= s.lastCtrC2S) throw new Error("ctr replay");
      s.lastCtrC2S = ctr;

      const iv = deriveIv(sid, "c2s", ctr);
      const pt = aesGcmDecrypt(
        s.sessionKey,
        iv,
        unb64url(ct),
        unb64url(tag),
        Buffer.from(String(ctr))
      );

      // ここで本来は code-server へ中継する(PoCではエコー)
      const replyText = Buffer.from(`server saw: ${pt.toString("utf8")}`, "utf8");

      // 返答も暗号化
      s.lastCtrS2C += 1;
      const rctr = s.lastCtrS2C;
      const riv = deriveIv(sid, "s2c", rctr);
      const enc = aesGcmEncrypt(
        s.sessionKey,
        riv,
        replyText,
        Buffer.from(String(rctr))
      );

      ws.send(JSON.stringify({
        sid,
        ctr: rctr,
        ct: b64url(enc.ct),
        tag: b64url(enc.tag),
      }));
    } catch (e) {
      ws.send(JSON.stringify({ error: String(e.message ?? e) }));
    }
  });
});

2) ブラウザ側(クライアント)を作る

public/index.html

html
<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <title>E2E PoC</title>
  <style>
    body { font-family: system-ui, sans-serif; padding: 16px; }
    input, button { font-size: 14px; padding: 8px; }
    #log { white-space: pre-wrap; border: 1px solid #ccc; padding: 8px; margin-top: 12px; height: 280px; overflow: auto; }
  </style>
</head>
<body>
  <h1>E2E 暗号 PoC (WebAuthnなし)</h1>

  <button id="btnStart">1) セッション開始</button>
  <button id="btnConnect" disabled>2) WS接続</button>

  <div style="margin-top:12px;">
    <input id="msg" placeholder="送る文字" size="40" />
    <button id="btnSend" disabled>3) 暗号化して送信</button>
  </div>

  <div id="log"></div>

  <script type="module" src="/client.js"></script>
</body>
</html>

public/client.js

js
const logEl = document.getElementById("log");
const btnStart = document.getElementById("btnStart");
const btnConnect = document.getElementById("btnConnect");
const btnSend = document.getElementById("btnSend");
const msgEl = document.getElementById("msg");

function log(...args) {
  logEl.textContent += args.join(" ") + "\n";
  logEl.scrollTop = logEl.scrollHeight;
}

function b64urlFromBytes(bytes) {
  const bin = String.fromCharCode(...bytes);
  const b64 = btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
  return b64;
}
function bytesFromB64url(s) {
  s = s.replace(/-/g, "+").replace(/_/g, "/");
  while (s.length % 4) s += "=";
  const bin = atob(s);
  return new Uint8Array([...bin].map(c => c.charCodeAt(0)));
}

async function sha256Bytes(dataBytes) {
  const h = await crypto.subtle.digest("SHA-256", dataBytes);
  return new Uint8Array(h);
}

// AES-GCMは 12byte IV が定番
async function deriveIv(sessionId, direction, ctr) {
  const enc = new TextEncoder();
  const sidBytes = enc.encode(sessionId);
  const dirBytes = enc.encode(direction);
  const ctrBytes = enc.encode(String(ctr));

  const merged = new Uint8Array(2 + sidBytes.length + dirBytes.length + ctrBytes.length);
  merged.set(enc.encode("iv"), 0);
  merged.set(sidBytes, 2);
  merged.set(dirBytes, 2 + sidBytes.length);
  merged.set(ctrBytes, 2 + sidBytes.length + dirBytes.length);

  const h = await sha256Bytes(merged);
  return h.slice(0, 12);
}

let sid = null;
let ws = null;
let sessionKey = null;
let ctrC2S = 0;
let ctrS2C = 0;

btnStart.onclick = async () => {
  const boot = await fetch("/bootstrap").then(r => r.json());
  sid = boot.session_id;

  log("bootstrap sid=", sid);

  // client ECDH keypair
  const clientKeyPair = await crypto.subtle.generateKey(
    { name: "ECDH", namedCurve: "P-256" },
    true,
    ["deriveBits"]
  );

  const clientPubRaw = new Uint8Array(await crypto.subtle.exportKey("raw", clientKeyPair.publicKey));
  const serverPubRaw = bytesFromB64url(boot.server_ecdh_pub);

  // import server public key
  const serverPubKey = await crypto.subtle.importKey(
    "raw",
    serverPubRaw,
    { name: "ECDH", namedCurve: "P-256" },
    true,
    []
  );

  // shared secret (256 bits)
  const sharedBits = await crypto.subtle.deriveBits(
    { name: "ECDH", public: serverPubKey },
    clientKeyPair.privateKey,
    256
  );

  // HKDFで32byte鍵を作る
  const salt = bytesFromB64url(boot.salt);
  const info = bytesFromB64url(boot.info);

  const sharedKey = await crypto.subtle.importKey(
    "raw",
    sharedBits,
    "HKDF",
    false,
    ["deriveKey"]
  );

  sessionKey = await crypto.subtle.deriveKey(
    { name: "HKDF", hash: "SHA-256", salt, info },
    sharedKey,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );

  // サーバへ client_pub を送って同じ鍵を作ってもらう
  await fetch("/handshake", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      session_id: sid,
      client_ecdh_pub: b64urlFromBytes(clientPubRaw),
      salt: b64urlFromBytes(salt),
      info: b64urlFromBytes(info),
    }),
  }).then(r => r.json()).then(j => log("handshake:", JSON.stringify(j)));

  btnConnect.disabled = false;
  btnSend.disabled = true;
};

btnConnect.onclick = () => {
  ws = new WebSocket(`ws://${location.host}/ws`);
  ws.onopen = () => {
    log("ws connected");
    btnSend.disabled = false;
  };
  ws.onmessage = async (ev) => {
    const frame = JSON.parse(ev.data);
    if (frame.error) {
      log("server error:", frame.error);
      return;
    }
    const { ctr, ct, tag } = frame;

    // ctrは単調増加(簡易)
    if (ctr <= ctrS2C) {
      log("replay detected?");
      return;
    }
    ctrS2C = ctr;

    const iv = await deriveIv(sid, "s2c", ctr);
    const aad = new TextEncoder().encode(String(ctr));

    const pt = await crypto.subtle.decrypt(
      { name: "AES-GCM", iv, additionalData: aad },
      sessionKey,
      concatBytes(bytesFromB64url(ct), bytesFromB64url(tag))
    );
    log("recv:", new TextDecoder().decode(new Uint8Array(pt)));
  };
};

function concatBytes(a, b) {
  const out = new Uint8Array(a.length + b.length);
  out.set(a, 0);
  out.set(b, a.length);
  return out;
}

btnSend.onclick = async () => {
  if (!ws || ws.readyState !== 1) return;

  ctrC2S += 1;
  const ctr = ctrC2S;

  const iv = await deriveIv(sid, "c2s", ctr);
  const aad = new TextEncoder().encode(String(ctr));
  const pt = new TextEncoder().encode(msgEl.value || "hello");

  const ctWithTag = new Uint8Array(await crypto.subtle.encrypt(
    { name: "AES-GCM", iv, additionalData: aad },
    sessionKey,
    pt
  ));

  // WebCryptoのAES-GCMは ciphertext+tag が末尾に連結されて返る
  const ct = ctWithTag.slice(0, ctWithTag.length - 16);
  const tag = ctWithTag.slice(ctWithTag.length - 16);

  ws.send(JSON.stringify({
    sid,
    ctr,
    ct: b64urlFromBytes(ct),
    tag: b64urlFromBytes(tag),
  }));

  log("sent (encrypted): ctr=", ctr);
};

3) 実行

bash
node server.mjs

ブラウザで開く:

操作: 1. 「セッション開始」 2. 「WS接続」 3. 文字を入れて「暗号化して送信」

期待結果:

  • logに server saw: ... が出る
  • ただし ネットワーク上(WSフレーム)には暗号化された文字列しか流れない

ここまでで確認できること

  • ブラウザが E2E暗号化して送れる
  • サーバが 復号できる
  • サーバが再暗号化して返せる
  • **ctr(カウンタ)**で簡易リプレイ防止できる
  • IVが毎回変わる(同じ鍵・同じIV再利用を避けてる)

次のステップ(WebAuthnを“差し込む”場所)

次にWebAuthnを入れるときは:

  • /handshake の直前で WebAuthn 承認を取って
  • /handshake に assertion を追加して
  • サーバ側が assertion を検証してから sessionKey を確定する

…という差し込みになります。

もし途中で動かなかったら、出たエラー(ブラウザコンソール or serverログ)を貼って。 次は「PoCを code-server の WebSocket に中継する版」に進める?それとも「WebAuthn追加」に進める?