ブラウザ内 E2E暗号 + Cloudflare Tunnelで自宅に安全アクセス
外出先の端末などでhttps復号されて通信の中身が見られる(zscalerなど)状況において、安心して自宅の開発環境にアクセスする為に、E2E暗号を導入する。
ただし、外出先端末にキーロガーや画面キャプチャを取られた場合は意味をなさない。あくまでzscalerなどhttps復号するものが途中に存在している場合に有効である。
ざっくり構成
- 外側暗号化は通常時と同様にCloudflare Tunnelにてhttps化する
- 内側暗号としてProxyサーバとブラウザ間でE2E暗号を実装する
- E2E暗号をする際にはWebAuthenを導入してユーザ体験の向上を目指す
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 --- BsequenceDiagram
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)
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 段階。
- 材料をもらう(/bootstrap)
- 本人承認を取る(WebAuthn)
- 暗号通信を始める(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)
mkdir e2e-poc
cd e2e-poc
npm init -y
npm i express ws1) サーバ(proxy役)を作る server.mjs
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
<!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
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) 実行
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追加」に進める?