跳轉到

security(資安實作)

本檔角色:資安的實作層 —— UGC Mesh Sanitize Worker、P2P 訊息驗簽、CSP / SRI、依賴管控、Pinning Server 安全 Header、本地安全事件記錄、CVE 通報模板。 設計、防禦層次、簽章覆蓋表、不變式見 資安規範.md;身分 / 簽章原語 / SignedPayloadkey-manager.md;序列化見 ledger-admission.md §1。對應 src/security/

1. UGC Mesh Sanitize Worker

所有不可信 GLB 的首次解析都走本 isolated Web Worker、同一限額——上傳前清洗、收件驗證的指紋重算前置解析、車庫預覽 / 進房首次載入(信任模型見 資安規範.md §2.4)。上傳情境拒收即丟棄、不寫 IPFS;載入情境解析失敗 / 超時 / 超限 = 本地拒用該資產(fail-fast)。

1.1 介面

type SanitizeRequest = {
  meshBuffer: ArrayBuffer; // GLB(管線唯一格式)
};
type SanitizeResult =
  | { ok: true; sanitized: ArrayBuffer; stats: MeshStats }
  | { ok: false; reason: SanitizeFailReason; details: string };

type SanitizeFailReason =
  | "parse-error"
  | "external-url-ref"
  | "malicious-extras"
  | "invalid-coordinates"
  | "degenerate-triangles"
  | "too-many-triangles"
  | "out-of-bounds"
  | "texture-too-large";

interface MeshStats {
  triangleCount: number;
  vertexCount: number;
  boundingBox: { min: Vec3; max: Vec3 };
}

1.2 限制常數

// 結構安全「絕對天花板」——只擋任何 type 都不可能合法的 GLB。
// per-type / per-stage 幾何・質量・密度檢核歸 Stage 1–3 pipeline
//(protocol.md §3 PART_* / VEHICLE_* / TRACK_*、零件與共用介面.md §7、runtime.md §10、UGC機制 §9.5),sanitize 不重複設限。
const SANITIZE_LIMITS = {
  MAX_GLB_BYTES: TRACK_GLB_SIZE_MAX_MB * 1024 * 1024, // 全 type 最大 = 場地 80 MiB(protocol.md §3)
  MAX_TRIANGLES: TRACK_VISUAL_TRIANGLE_COUNT_MAX, // 全 type 最大 = 場地 visual 3M(protocol.md §3)
  MAX_AABB_M: TRACK_AABB_MAX_M, // 全 type 最大 = [500, 500, 100] m(protocol.md §3)
  MAX_TEXTURE_EDGE_PX: SOURCE_TEXTURE_EDGE_MAX_PX, // 首次來源 admission 上限 = 8192px
  MAX_JSON_BYTES: SANITIZE_JSON_CHUNK_MAX_BYTES, // 16 MiB;TextDecoder / JSON.parse 前拒絕
  MAX_DOCUMENT_ENTRIES: SANITIZE_DOCUMENT_MAX_ENTRIES, // 250,000;全 JSON document 走訪成本
  MAX_DOCUMENT_DEPTH: SANITIZE_DOCUMENT_MAX_DEPTH, // 64;全 document 結構深度
  MAX_EXTRAS_BYTES: SANITIZE_EXTRAS_MAX_BYTES, // 16 MiB——extras 總量非檔案大小;場地 route[]/entity extras 合法可達 MiB 級(protocol.md §4)
  MAX_EXTRAS_DEPTH: SANITIZE_EXTRAS_MAX_DEPTH, // 8(protocol.md §4)
} as const;

const SANITIZE_TIMEOUT_MS = 5_000; // 對齊 資安規範 §2.2
const SANITIZE_WORKER_VERSION = "v1"; // JSON / collection admission schema;live 後 bump 即升 protocol

貼圖邊長分成兩個不可混用的 protocol 階段:

  • SOURCE_TEXTURE_EDGE_MAX_PX = 8192:首次 admission 對 encoded source image 的絕對天花板;8192px 是合法輸入,sanitize 不得以 canonical 成品預算提前拒收。
  • CANONICAL_TEXTURE_EDGE_MAX_PX = 4096:Stage 3 canonical finalizer 等比降採樣與轉換後的成品上限;finalizer 必須以壓縮後成品重驗,任何邊超過 4096px 即拒收。

因此 SANITIZE_LIMITS.MAX_TEXTURE_EDGE_PX 映射前者;場地 4K 貼圖預算與 strict canonical validation 使用後者。兩值皆以 protocol.md §4 為數值權威,不得收斂成單一常數。

主要 glTF collection 另有逐類硬上限:nodes 50,000meshes 10,000accessors / bufferViews 100,000buffers 64images / textures / samplers / materials 4,096scenes 64animations 1,024skins / cameras 4,096extensionsUsed / extensionsRequired 128;全 mesh primitives 合計上限 50,000。任何欄位不是 array 或超額均在幾何、extras 走訪前以 parse-error 拒絕。

質量 / 密度不在 sanitize 檢核——質量 = 體積 × 材質密度、材質 Stage 2 才指派(算式表.md §1);per-type 質量 / 密度比檢核見 protocol.md §3 + 零件與共用介面.md §7non-manifold 不拒收——volume 解析有 watertight → voxel 備援鏈(runtime.md §10.1),non-watertight mesh 屬合法輸入。

1.3 嚴格隔離(資安規範 §2.3

Worker 跑在 isolated context:無 DOM、無 network(不得 fetch,先由解碼前輸入大小與文件/collection/幾何/extras 結構天花板限制配置成本,逾時即 terminate();拒收後 GLB 丟棄。瀏覽器沒有可攜的 per-worker heap 硬上限,因此規格不得宣稱固定 MB 記憶體配額。

1.4 檢查順序

async function sanitizeMesh(req: SanitizeRequest): Promise<SanitizeResult> {
  // 0a. 輸入大小(parse 前擋,防解壓炸彈進 parser)
  if (req.meshBuffer.byteLength > SANITIZE_LIMITS.MAX_GLB_BYTES)
    return {
      ok: false,
      reason: "parse-error",
      details: `GLB ${req.meshBuffer.byteLength}B > absolute ceiling`,
    };

  let mesh;
  try {
    mesh = await parseMesh(req.meshBuffer, "glb");
  } catch (err) {
    return { ok: false, reason: "parse-error", details: String(err) };
  }

  // 0b. GLB 結構(資安規範 §2.1):JSON chunk 先驗 16 MiB 再 TextDecoder / JSON.parse;
  //     parse 後先驗主要 collection、全文件 entries / depth,再走幾何與 extras。
  //     拒外部 URL 參照、拒惡意 extras(> MAX_EXTRAS_BYTES / 巢狀 > MAX_EXTRAS_DEPTH / 可執行 payload)
  if (hasExternalUrlRef(mesh))
    return {
      ok: false,
      reason: "external-url-ref",
      details: "embedded external URI",
    };
  if (hasMaliciousExtras(mesh, SANITIZE_LIMITS))
    return {
      ok: false,
      reason: "malicious-extras",
      details: "oversized/deep/executable extras",
    };

  // 1. 三角形數(絕對天花板;per-type 上限由 pipeline 檢核)
  if (mesh.triangleCount > SANITIZE_LIMITS.MAX_TRIANGLES)
    return {
      ok: false,
      reason: "too-many-triangles",
      details: `${mesh.triangleCount} > ${SANITIZE_LIMITS.MAX_TRIANGLES}`,
    };

  // 2. 座標 NaN / Infinity
  for (const v of mesh.vertices)
    if (
      !Number.isFinite(v[0]) ||
      !Number.isFinite(v[1]) ||
      !Number.isFinite(v[2])
    )
      return {
        ok: false,
        reason: "invalid-coordinates",
        details: "NaN or Infinity vertex",
      };

  // 3. Bounding box(絕對天花板,m;per-type AABB 上下限由 pipeline 檢核)
  const bbox = computeBoundingBox(mesh.vertices);
  const sizeM = [
    bbox.max[0] - bbox.min[0],
    bbox.max[1] - bbox.min[1],
    bbox.max[2] - bbox.min[2],
  ]; // 原始軸 [X, Y, Z]
  // MAX_AABB_M 軸序=[長, 寬, 高]=[Z, X, Y]:canonical 軸序重排後逐項對照(editor 檢核同款重排;
  // 逐 index 直比會把高 Y 對到寬上限、長 Z 對到高上限=誤放高/誤拒長)
  const sizeCanonical = [sizeM[2], sizeM[0], sizeM[1]];
  if (sizeCanonical.some((s, axis) => s > SANITIZE_LIMITS.MAX_AABB_M[axis]))
    return { ok: false, reason: "out-of-bounds", details: `bounds-m ${sizeM}` };

  // 4. 退化三角形(面積 < PART_DEGENERATE_TRIANGLE_AREA_MIN_M2〔protocol.md §3〕,> 5% 拒收)
  let degenerate = 0;
  for (const tri of mesh.triangles)
    if (triangleAreaM2(tri) < PART_DEGENERATE_TRIANGLE_AREA_MIN_M2)
      degenerate++;
  if (degenerate > mesh.triangleCount * 0.05)
    return {
      ok: false,
      reason: "degenerate-triangles",
      details: `${degenerate} degenerate`,
    };

  // 5. encoded source texture 邊長(canonical 4096px 成品上限由 Stage 3 finalizer 負責)
  if (
    mesh.textures.some(
      (t) => Math.max(t.width, t.height) > SANITIZE_LIMITS.MAX_TEXTURE_EDGE_PX,
    )
  )
    return {
      ok: false,
      reason: "texture-too-large",
      details: `> ${SANITIZE_LIMITS.MAX_TEXTURE_EDGE_PX}px`,
    };

  // 6. 通過 → 正規化重打包
  return {
    ok: true,
    sanitized: serializeNormalized(mesh),
    stats: {
      triangleCount: mesh.triangleCount,
      vertexCount: mesh.vertices.length,
      boundingBox: bbox,
    },
  };
}

1.5 主執行緒呼叫(timeout + transferable)

class MeshSanitizer {
  async sanitize(req: SanitizeRequest): Promise<SanitizeResult> {
    const worker = new Worker(
      new URL("./sanitize.worker.ts", import.meta.url),
      { type: "module" },
    );
    return new Promise((resolve) => {
      const done = (r: SanitizeResult) => {
        clearTimeout(timer);
        worker.terminate();
        resolve(r);
      };
      const timer = setTimeout(
        () => done({ ok: false, reason: "parse-error", details: "timeout" }),
        SANITIZE_TIMEOUT_MS,
      );
      worker.addEventListener("message", (e: MessageEvent<SanitizeResult>) =>
        done(e.data),
      );
      worker.addEventListener("error", () =>
        done({ ok: false, reason: "parse-error", details: "worker error" }),
      );
      worker.postMessage(req, [req.meshBuffer]); // transferable,零拷貝
    });
  }
}

場地 UGC 的規格外攻擊(巨大 AABB / 三角爆量 / 內容空洞)對策主表見 UGC機制.md §9.5;本 Worker 為零件 / 場地共用的 mesh 層 sanitize。

2. P2P 訊息驗簽

SignedPayload 定義見 key-manager.md §9timestamp + nonce 在簽章涵蓋內)。先驗 envelope 固定五欄、signer 長度、safe-integer timestamp、nonce 恰 16 bytes、signature byte-string;再走白名單 + 時戳 + nonce + 簽章四關:

async function verifyP2PMessage<T>(
  signed: SignedPayload<T>,
  trustedPeers: Set<PeerId>,
  nonceSet: NonceSet,
): Promise<Result<T>> {
  if (!trustedPeers.has(signed.signer)) return error("untrusted-peer"); // 1. 房間成員白名單
  if (Math.abs(Date.now() - signed.timestamp) > 30_000)
    return error("timestamp-out-of-range"); // 2. ±30s
  if (nonceSet.has(signed.nonce)) return error("nonce-replay"); // 3. nonce 不在 30s set
  const message = buildSignedMessage(
    signed.payload,
    signed.timestamp,
    signed.nonce,
    signed.signer,
  ); // key-manager.md §9
  if (!(await keyManager.verify(message, signed.signature, signed.signer)))
    return error("invalid-signature"); // 4. 簽章
  if (!nonceSet.add(signed.nonce, signed.timestamp))
    return error("nonce-capacity"); // 8,192 硬上限、容量滿 fail-closed
  return ok(signed.payload);
}

P2P_MESSAGE_TIMESTAMP_TOLERANCE_SEC = 30P2P_MESSAGE_NONCE_BYTES = 16P2P_NONCE_SET_MAX_ENTRIES = 8192(資安規範 §3.3)。Gossip 接收另使用「驗 envelope / signer / 簽章但暫不 commit nonce → topic payload schema → per-signer rate limit → commit nonce」順序;因此限流拒絕的 unique nonce 不會先灌滿 replay 集合。一般非 Gossip 呼叫仍使用一次完成驗證與 commit 的 verifyP2PMessage

3. CSP(生產環境;build 時生成 <meta> 注入)

交付機制:主站 = GitHub Pages、無法設自訂 HTTP header → production build 完成 prerender 後,scripts/inject-csp.mjs 遍歷 dist 的每一份 HTML,呼叫 scripts/csp-policy.mjscontentSecurityPolicyFor(html),再注入 <meta http-equiv="Content-Security-Policy">。各路由的 inline import map、JSON-LD 與 stylesheet onload 內容不同,因此 policy 是逐頁生成,不可把 單一預先組好的 policy 複製到全部頁面。

const baseline = [
  "default-src 'self'",
  "worker-src 'self' blob:", // Sanitize / 指紋 Worker
  "connect-src 'self' wss: https:", // scheme 級白名單:玩家可自選 signaling / pinning / gateway 節點(資安規範 §4),固定域名白名單會擋自選端點;仍擋非 TLS 外連
  "img-src 'self' data: blob:", // GLB texture 可能用 blob
  "style-src 'self' 'unsafe-inline'", // Angular
  "font-src 'self'",
  "object-src 'none'",
  "base-uri 'self'",
  "form-action 'self'",
  "upgrade-insecure-requests",
].join("; ");

// 每份 HTML 另生成:
// script-src 'self' 'wasm-unsafe-eval' <每個 inline script 的 sha256>
// 有 inline event handler 時才加入 'unsafe-hashes' 與各 handler 的 sha256

script-src 永不加入 'unsafe-inline';hash 由實際 UTF-8 內容計算並去重。upgrade-insecure-requests 保留為 production 縱深防禦;Chromium localhost smoke 已確認本機 HTTP 位址仍依 potentially trustworthy 例外保留,不需要為本機驗證移除 production directive。

meta CSP 限制frame-ancestors<meta> 交付無效(規格明定)、GH Pages 亦無法送 X-Frame-Options主站無框架保護。風險評估 = 無 cookie / 無跨源授權狀態(身分為本地金鑰、PIN 只在本地驗證),clickjacking 攻擊面有限,接受;自架 pinning server 走 HTTP header(§6)不受此限。signaling 為 wss、pinning 走 HTTPS API。

4. SRI 工具

async function computeSRI(
  url: string,
  algo: "sha384" = "sha384",
): Promise<string> {
  const buffer = await (await fetch(url)).arrayBuffer();
  const hash = await crypto.subtle.digest("SHA-384", buffer);
  return `${algo}-${btoa(String.fromCharCode(...new Uint8Array(hash)))}`;
}

CI build 時為 build 產物(bundle)自動生成 SRI hash——runtime 原則上無第三方 JS / CSS(資安規範.md §5)。實作 =angular.json production "subresourceIntegrity": true(bundle 自動帶 integrity 屬性);computeSRI 供腳本 / 診斷用。

5. 依賴管控

# .npmrc
save-exact=true       # 禁 ^ / ~,鎖死版本
engine-strict=true

package.json 依賴皆 pin 精確版本(無 ^ / ~;版號 =lockfile 實際解析值)。CI 兩道:主 CI pnpm install --frozen-lockfile(比對 lockfile)+.github/workflows/security.yml(push / PR / 每週排程)pnpm audit --audit-level moderate——audit warning ≥ moderate 阻擋 merge(資安規範.md §6.2)。只允許 well-known 核心套件(Rapier / libp2p / OrbitDB…)。

5.1 Vendored 檔案政策

Vendored 原始碼或二進位必須有 manifest/provenance 記錄來源、immutable 版本與 SHA-256; check:vendored 對 repository bytes 與 manifest 做 fail-closed 比對。遠端上游比對只在可取得 公開 immutable provenance 的 gate 執行,不能讓 private/離線 CI 依賴浮動 raw URL;任何更新 都必須同批更新來源紀錄、雜湊與授權資訊,不接受未記錄的手改。

6. Pinning Server 安全 Header

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()
Content-Security-Policy: <如 §3>
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin

Cross-Origin-Embedder-Policy / Cross-Origin-Opener-Policy 屬 pinning server 的通用強化 header(本服務無 SharedArrayBuffer 需求)。主站不適用:GH Pages 無法設此二 header → 主站無 cross-origin isolation、SharedArrayBuffer 不可用——亦不需要(Rapier deterministic build 與 SIMD / parallel feature 互斥,物理一律單執行緒)。

7. 本地安全事件記錄(不傳遠端)

APP_SECURITY_LOG 是瀏覽器 bundle 內唯一的 app-scope LocalSecurityLog;只存記憶體、採 500 筆 環形上限,重新整理即清空,且沒有任何遠端 transport。事件來源固定為:

  • MeshSanitizer 的 Worker timeout/error/拒收,以及 editor 純函式 admission 拒收;
  • verifyP2PMessageverifyP2PEnvelope 的所有 canonical P2pRejectReason
  • app 啟動時掛上的 securitypolicyviolation listener;
  • pin/session 模組日後接入的 pin-attemptsession-expired

寫入時即依事件類型正面表列欄位,而不是先保存原始 details 再於 UI 遮蔽:sanitize/驗章/session 只保留最長 96 字元 reason;pin 只保留 outcomereason;CSP 的 blockedURIsourceFile 只保留 HTTP(S)/WS(S) origin 或非網路 scheme,另可保留 directive 與非負 line number。payload、 signature、PeerId、URL path/query/fragment 與未知欄位一律不落記錄。list() 回傳 defensive copy, 呼叫端不能改寫內部事件。

設定頁的維護區只提供玩家主動下載;exportJson() 固定輸出 { "version": 1, "events": [...] } 的 UTF-8 JSON,沿用同一份已去識別 snapshot,不會自動上傳、持久化或附加其他診斷資料。

8. CVE 通報

SECURITY.md(通報 channel = GitHub Security Advisories + PGP email):

CVE 等級 / 回應時限見 資安規範.md §10

漏洞通報、協調揭露時限與獎勵政策的唯一權威見 資安規範.md §11。未修補漏洞的重現資料只在該節指定的私密通報管道中交換,不得以公開 issue/PR 提交;修復或協調揭露後才可加入去敏 regression test。

9. 不變式

不變式見 資安規範.md §16(sanitize 拒收不上鏈 / 不可信 GLB 不進主執行緒解析 / 驗章失敗不 apply / nonce 重複或 timestamp>30s reject / MatchResult 攜帶 payout/frontier 或 facts/多簽無效即 reject / CSP-SRI 違規阻擋 merge / 本地安全記錄不保存識別資訊且不傳遠端)。

10. 跨模組對接

模組 對接
key-manager/key-manager.md 簽章原語 / SignedPayload
ledger/ledger.md event sign + ledgerSigningDigest(ledgerAddress, event) + 帳本檢查點 multisig
network-sync/ ephemeral checksum + sign
ugc-fork/ upload sign + 反複製 mesh 指紋(anti-piracy.md
pinning-service/ sign 上傳請求 + 安全 Header
moderation/moderation.md 檢舉 / 仲裁