diff --git a/.env.example b/.env.example index e8dd0da..af44c45 100644 --- a/.env.example +++ b/.env.example @@ -21,7 +21,12 @@ OPENAI_MODEL=gpt-4o-mini # 跑在 TEE 上的可驗證推論。金鑰從 https://pc.0g.ai 取得。 # 沒設也能玩,旁白會退到本地模板並照實標示「未接上 0G Compute」。 OG_COMPUTE_API_KEY=sk-your-0g-router-key -OG_COMPUTE_MODEL=deepseek-chat-v3-0324 +# 必須挑 verifiability = TeeML 的模型,否則「同一位 agent」的主張不成立。 +# router 的 /v1/models 會標三種:TeeML(模型跑在 TEE 裡)、TeeTLS(只有 TLS 在 TEE +# 裡,推論在上游廠商)、沒有這個欄位(完全沒 TEE,Claude / GPT 系列屬於這類)。 +# 撰文當下 TeeML 只有:0gm-1.0-35b-a3b、0gm-1.0-35b-a3b-sia、glm-5.3、 +# whisper-large-v3、z-image-turbo。 +OG_COMPUTE_MODEL=0gm-1.0-35b-a3b # OG_COMPUTE_BASE_URL=https://router-api.0g.ai/v1 # provider 的 attestation(遠端證明)端點。 diff --git a/README.md b/README.md index 704a1a9..d6de522 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ | 賽道 | 狀態 | 實測證據 | |---|---|---| -| **0G Compute** | ✅ 通 | `deepseek-chat-v3-0324 · TEE 就緒`,戰後旁白由 enclave 產生 | +| **0G Compute** | ✅ 通 | `0gm-1.0-35b-a3b`(**TeeML** · TDX · dstack)—— 敵方 agent 與戰後旁白都跑在 enclave 裡 | | **0G Storage** | ✅ 通 | 檔案 root `0x31dbf57395ca6d1b8f401f944453b846ce0c2a0cedddd97c27c4bc8a84d7cb25`
Flow 合約 submit tx `0x9c7377b88930c28412c95dfb452793c500233531264e09fef8537b91358b2218`
儲存費 92200934886 neuron,節點回報 `Single file upload completed` | | **0G Chain** | ✅ 通 | 區塊 `#54068232`、5 個確認,calldata 讀回後**四項全部相符**(摘要 / 勝負 / 回合數 / 記憶核心) | @@ -60,14 +60,14 @@ | 用了哪些 0G 技術 | 為什麼要用它 | 在哪一行用到 | |---|---|---| | **0G Compute Network**
TEE 可驗證推論 | 戰報要永久存檔,寫它的那個 agent 就不能是黑箱。金鑰在 pc.0g.ai 選 **Private(TEE enclave)** 開的,推論實際跑在 enclave 裡,不是只呼叫一個 OpenAI 相容端點。 | [`functions/api/narrate.js#L55-L134`](https://github.com/ConsssLab/web/blob/main/functions/api/narrate.js#L55-L134) | -| **0G Compute Network**
Router 設定 | 敵方 agent 與旁白 agent 共用同一份供應商工廠,**兩者預設都指向 0G Compute**。要主張「整場都是同一位 agent」,對打的那個 agent 就必須在 enclave 裡 —— 所以預設不是 OpenAI。 | [`functions/api/og/_shared.js#L22-L23`](https://github.com/ConsssLab/web/blob/main/functions/api/og/_shared.js#L22-L23) · [`functions/api/og/_shared.js#L68-L87`](https://github.com/ConsssLab/web/blob/main/functions/api/og/_shared.js#L68-L87) · [`functions/api/og/_shared.js#L89-L97`](https://github.com/ConsssLab/web/blob/main/functions/api/og/_shared.js#L89-L97) | +| **0G Compute Network**
Router 設定 | 敵方 agent 與旁白 agent 共用同一份供應商工廠,**兩者預設都指向 0G Compute**。要主張「整場都是同一位 agent」,對打的那個 agent 就必須在 enclave 裡 —— 所以預設不是 OpenAI。 | [`functions/api/og/_shared.js#L22-L23`](https://github.com/ConsssLab/web/blob/main/functions/api/og/_shared.js#L22-L23) · [`functions/api/og/_shared.js#L68-L87`](https://github.com/ConsssLab/web/blob/main/functions/api/og/_shared.js#L68-L87) · [`functions/api/og/_shared.js#L108-L116`](https://github.com/ConsssLab/web/blob/main/functions/api/og/_shared.js#L108-L116) | | **0G Storage**
真實寫入 | 記憶碎片要「永久保存」就必須真的落地。用官方 SDK 走完整協議:切 256-byte chunk 算 merkle root → 對 Flow 合約送 submit(付儲存費)→ 把 segment 傳給 storage node。**全程用玩家自己的錢包簽,伺服器不持有私鑰。** | [`public/js/storage.js#L72-L116`](https://github.com/ConsssLab/web/blob/main/public/js/storage.js#L72-L116)
節點代理(Node):[`proxy/api/index.js`](https://github.com/ConsssLab/web/blob/main/proxy/api/index.js) —— 節點是裸 IP + 非標準埠,Cloudflare Workers 打不到(error 1003 / 521),這一段必須跑在 Node 上 | | **0G Storage**
indexer 唯讀查詢 | 「上傳沒報錯」不等於存進去了。拿 root hash 回頭問 indexer,確認 storage node 真的收下並 finalized 才敢標成已存檔。唯讀、不需金鑰,評審可自行查證。 | [`functions/api/og/storage.js#L117-L159`](https://github.com/ConsssLab/web/blob/main/functions/api/og/storage.js#L117-L159) | -| **0G Storage**
節點活性探測 | 結果畫面的燈號要照實反映網路狀態,不能寫死成綠燈。 | [`functions/api/og/status.js#L34-L57`](https://github.com/ConsssLab/web/blob/main/functions/api/og/status.js#L34-L57) | +| **0G Storage**
節點活性探測 | 結果畫面的燈號要照實反映網路狀態,不能寫死成綠燈。 | [`functions/api/og/status.js#L35-L58`](https://github.com/ConsssLab/web/blob/main/functions/api/og/status.js#L35-L58) | | **0G Chain**
錨定寫入 | 戰報需要一個不可竄改、有時間戳的存在證明。40 bytes 結構化 calldata(魔術字 `CSSW` + 版本 + 戰績 + SHA-256),用合約建立交易送出,chainscan 上一眼認得出來。 | [`functions/api/og/shard.js#L85-L107`](https://github.com/ConsssLab/web/blob/main/functions/api/og/shard.js#L85-L107) · [`public/js/og.js#L235-L260`](https://github.com/ConsssLab/web/blob/main/public/js/og.js#L235-L260) | | **0G Chain**
鏈上回驗 | **這是整個專案的重點**:按了按鈕、錢包沒報錯,不代表資料真的在鏈上。所以再用 `eth_getTransactionByHash` 把交易讀回來、反解 calldata、跟本地碎片逐欄比對,四項全對才打勾。 | [`functions/api/og/verify.js#L19-L47`](https://github.com/ConsssLab/web/blob/main/functions/api/og/verify.js#L19-L47) · [`functions/api/og/verify.js#L49-L104`](https://github.com/ConsssLab/web/blob/main/functions/api/og/verify.js#L49-L104) | -| **0G Chain**
鏈況與網路切換 | 標題頁即時顯示 Galileo 區塊高度;chainId **從鏈上實際讀回來**校準而不是寫死(0G 換過 chain ID 16601→16602,寫死會讓切鏈整個失敗)。 | [`functions/api/og/status.js#L59-L157`](https://github.com/ConsssLab/web/blob/main/functions/api/og/status.js#L59-L157) · [`public/js/og.js#L167-L220`](https://github.com/ConsssLab/web/blob/main/public/js/og.js#L167-L220) | -| — **敵方 AI agent**
(OpenAI,非 0G) | 對戰時每回合都要叫一次,需要低延遲,所以另外走 OpenAI。模型輸出一律當不可信資料重新過濾。 | [`functions/api/agent.js#L188-L291`](https://github.com/ConsssLab/web/blob/main/functions/api/agent.js#L188-L291) · [`public/js/ai.js#L16-L68`](https://github.com/ConsssLab/web/blob/main/public/js/ai.js#L16-L68) | +| **0G Chain**
鏈況與網路切換 | 標題頁即時顯示 Galileo 區塊高度;chainId **從鏈上實際讀回來**校準而不是寫死(0G 換過 chain ID 16601→16602,寫死會讓切鏈整個失敗)。 | [`functions/api/og/status.js#L60-L177`](https://github.com/ConsssLab/web/blob/main/functions/api/og/status.js#L60-L177) · [`public/js/og.js#L167-L220`](https://github.com/ConsssLab/web/blob/main/public/js/og.js#L167-L220) | +| — **敵方 AI agent**
(OpenAI,非 0G) | 對戰時每回合都要叫一次,需要低延遲,所以另外走 OpenAI。模型輸出一律當不可信資料重新過濾。 | [`functions/api/agent.js#L168-L276`](https://github.com/ConsssLab/web/blob/main/functions/api/agent.js#L168-L276) · [`public/js/ai.js#L16-L68`](https://github.com/ConsssLab/web/blob/main/public/js/ai.js#L16-L68) | ### 評審可以自己打的端點 @@ -273,7 +273,7 @@ API 金鑰請選 **Secret**(加密),不要用一般變數。 | 變數 | 賽道 | 必要性 | 說明 | | --- | --- | --- | --- | | `OG_COMPUTE_API_KEY` | 賽道一 | **建議設** | 0G Compute Router 金鑰,從 [pc.0g.ai](https://pc.0g.ai) 取得。敵方 agent 與旁白 agent 都吃這把 | -| `OG_COMPUTE_MODEL` | 賽道一 | 選用 | 預設 `deepseek-chat-v3-0324` | +| `OG_COMPUTE_MODEL` | 賽道一 | 選用 | 預設 `0gm-1.0-35b-a3b`。**必須挑 `verifiability = TeeML` 的模型** —— `TeeTLS` 只有傳輸層在 enclave 裡,推論在上游廠商那邊跑,撐不起「同一位 agent」的主張 | | `OG_COMPUTE_ATTESTATION_URL` | 賽道一 | 想驗到 enclave 等級才需要 | provider 的 attestation 端點。沒設的話「同一位 agent」最多只能驗到 `signed`(同一把金鑰),畫面會照實降級顯示 | | `AI_PROVIDER` | 敵方 agent | 選用 | `0g`(**預設**)或 `openai`。切回 OpenAI 會讓「同一位 agent」的驗證降級 | | `OPENAI_API_KEY` | 敵方 agent | 只有切回 OpenAI 才要 | **只存在 Function 端**,不會進前端 bundle | diff --git a/functions/api/agent.js b/functions/api/agent.js index 8479d64..3013e74 100644 --- a/functions/api/agent.js +++ b/functions/api/agent.js @@ -13,9 +13,8 @@ */ import { sealTurn } from './og/tee.js'; +import { providerConfig } from './og/_shared.js'; -const OPENAI_BASE = 'https://api.openai.com/v1'; -const OG_ROUTER_BASE = 'https://router-api.0g.ai/v1'; const MAX_BODY = 16 * 1024; const MAX_PLAYS = 4; @@ -30,35 +29,16 @@ const json = (data, status = 200) => headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, }); -/** - * 預設 **0G Compute**,設 AI_PROVIDER=openai 才切回 OpenAI。 +/* + * 供應商設定改成從 og/_shared.js 匯入,不要在這裡再寫一份。 * - * 一開始是反過來的(預設 OpenAI,理由是每回合都要叫、要低延遲)。但那讓「TEE」 - * 變成空話:跟玩家對打的 agent 根本不在 enclave 裡,就無從證明「整場都是同一位」。 - * 要讓那個主張成立,敵方 agent 必須跑在 0G Compute 上並帶回可驗證的簽名。 - * 沒設金鑰時仍會退回本地啟發式,並照實標示「未接上模型」。 + * 原本這裡有一份幾乎一樣的 providerConfig,包含寫死的模型名。結果是兩個真相來源: + * 改了 _shared.js 完全不會影響這支,而實際打 router 的正是這支。 + * 那個寫死的模型名(deepseek-chat-v3-0324)在 router 上根本不存在,於是每一回合 + * 都拿到 404 → 靜靜退回本地啟發式 —— 敵方 agent 從來沒有真的跑在 0G 上過, + * 而我們一直以為有。 */ -function providerConfig(env) { - const choice = String(env.AI_PROVIDER || '0g').toLowerCase(); - if (choice === '0g' || choice === '0g-compute') { - return { - id: '0g-compute', - label: '0G Compute Network Router', - base: env.OG_COMPUTE_BASE_URL || OG_ROUTER_BASE, - key: env.OG_COMPUTE_API_KEY, - model: env.OG_COMPUTE_MODEL || 'deepseek-chat-v3-0324', - keyName: 'OG_COMPUTE_API_KEY', - }; - } - return { - id: 'openai', - label: 'OpenAI API', - base: env.OPENAI_BASE_URL || OPENAI_BASE, - key: env.OPENAI_API_KEY, - model: env.OPENAI_MODEL || 'gpt-4o-mini', - keyName: 'OPENAI_API_KEY', - }; -} + const SYSTEM = `你是回合制策略遊戲《鏈州英雄傳 ConSSS Wars》裡的反派 AI agent「遺忘者」。 你在鏈國 0G 進攻對方的「記憶核心」。你要贏,也要有角色感。 @@ -225,7 +205,12 @@ export async function onRequestPost({ request, env }) { body: JSON.stringify({ model: cfg.model, temperature: 0.8, - max_tokens: 320, + max_tokens: 512, + // 0G 自家模型預設開著 thinking,實測 max_tokens 會被推理吃光、content 回空字串 + // (reasoning_tokens 35 / finish_reason "length" / content "")。解析不到 JSON + // 就會靜靜退回本地啟發式 —— 正是這個 bug 躲了那麼久的原因。 + // reasoning_effort: 'none' 實測有效:reasoning_tokens 0,content 直接是乾淨的 JSON。 + reasoning_effort: 'none', response_format: { type: 'json_object' }, messages: [ { role: 'system', content: SYSTEM }, diff --git a/functions/api/og/_shared.js b/functions/api/og/_shared.js index 2323c23..b092c96 100644 --- a/functions/api/og/_shared.js +++ b/functions/api/og/_shared.js @@ -86,13 +86,32 @@ export function providerConfig(env) { }; } +/** + * 預設模型。**必須是 verifiability = TeeML 的那幾個之一。** + * + * router 的 /v1/models 會標三種狀態,差別直接決定我們的主張成不成立: + * + * TeeML 模型本身跑在 TEE 裡 ← 只有這個撐得起「同一位 agent」 + * TeeTLS TEE 只終結 TLS,推論在上游廠商那邊跑(大多數模型是這種) + * (沒有) 完全沒有 TEE —— Claude / GPT 系列都屬於這類 + * + * 撰文當下 TeeML 的只有五個:0gm-1.0-35b-a3b、0gm-1.0-35b-a3b-sia、glm-5.3、 + * whisper-large-v3、z-image-turbo。選 0gm-1.0-35b-a3b 的理由: + * · 支援 response_format —— 我們需要 JSON 模式,-sia 那個不支援 + * · glm-5.3 的 deep thinking 永遠開著且關不掉,逐回合呼叫太慢 + * · 便宜($0.00000008 / prompt token) + * + * 換模型前先確認它是 TeeML,否則畫面上的 TEE 主張會變成空話。 + */ +const OG_COMPUTE_DEFAULT_MODEL = '0gm-1.0-35b-a3b'; + /** 0G Compute Network Router 的設定。跟敵方 agent 用哪家無關,這個永遠指向 0G。 */ export const ogComputeConfig = (env) => ({ id: '0g-compute', label: '0G Compute Network Router', base: env.OG_COMPUTE_BASE_URL || OG_ROUTER_BASE, key: env.OG_COMPUTE_API_KEY, - model: env.OG_COMPUTE_MODEL || 'deepseek-chat-v3-0324', + model: env.OG_COMPUTE_MODEL || OG_COMPUTE_DEFAULT_MODEL, keyName: 'OG_COMPUTE_API_KEY', }); diff --git a/functions/api/og/same-agent.js b/functions/api/og/same-agent.js index 483cd0d..b94e49d 100644 --- a/functions/api/og/same-agent.js +++ b/functions/api/og/same-agent.js @@ -9,8 +9,8 @@ * 分級的定義見 tee.js 的 verifyChain。 */ -import { json, clampStr, clampInt } from './_shared.js'; -import { verifyChain } from './tee.js'; +import { json, clampStr, clampInt, ogComputeConfig } from './_shared.js'; +import { verifyChain, probeComputeModel } from './tee.js'; const MAX_BODY = 32 * 1024; const MAX_TURNS = 12; @@ -24,11 +24,14 @@ const normalizeTurn = (t) => ({ signer: clampStr(t && t.signer, 200) || null, measurement: clampStr(t && t.measurement, 200) || null, proofId: clampStr(t && t.proofId, 120) || null, + // 實測 0G router 沒有回簽名,但每次回應都帶服務它的 provider 鏈上位址 + // (標頭 x-provider / body x_0g_trace.provider)。這是目前拿得到的最強身分。 + providerAddress: clampStr(t && t.providerAddress, 80) || null, model: clampStr(t && t.model, 60) || null, provider: clampStr(t && t.provider, 40) || null, }); -export async function onRequestPost({ request }) { +export async function onRequestPost({ request, env }) { let payload; try { const raw = await request.text(); @@ -41,7 +44,12 @@ export async function onRequestPost({ request }) { const chain = Array.isArray(payload && payload.chain) ? payload.chain.slice(0, MAX_TURNS).map(normalizeTurn) : []; - const attestation = payload && typeof payload.attestation === 'object' ? payload.attestation : null; + // TEE 狀態由伺服器自己去 router 查,不採信前端送來的東西 —— + // 這是要證明給玩家看的結論,材料不能由被證明的那一方提供。 + const probe = await probeComputeModel(ogComputeConfig(env)); + const attestation = probe.ok + ? { modelInTee: probe.modelInTee, verifiability: probe.verifiability, teeType: probe.teeType, teeVerifier: probe.teeVerifier } + : { error: probe.error || null }; const verdict = verifyChain(chain, attestation); return json({ @@ -53,8 +61,14 @@ export async function onRequestPost({ request }) { responseHash: t.responseHash, signed: Boolean(t.signature), signer: t.signer, + providerAddress: t.providerAddress, model: t.model, - sameAsFirst: !t.signer || !chain[0].signer ? null : t.signer === chain[0].signer, + // 「跟第一回合是不是同一個」—— 有簽章公鑰就比公鑰,沒有就比 provider 位址 + sameAsFirst: (() => { + const mine = t.signer || t.providerAddress; + const first = chain[0].signer || chain[0].providerAddress; + return !mine || !first ? null : mine.toLowerCase() === first.toLowerCase(); + })(), })), }); } diff --git a/functions/api/og/status.js b/functions/api/og/status.js index 9556a5e..25711d0 100644 --- a/functions/api/og/status.js +++ b/functions/api/og/status.js @@ -10,6 +10,7 @@ */ import { json, rpc, rpcUrlOf, indexerOf, GALILEO, providerConfig, ogComputeConfig } from './_shared.js'; +import { probeComputeModel } from './tee.js'; /** * indexer 的活性探測:能拿到節點清單就算通。 @@ -74,7 +75,15 @@ export async function onRequestGet({ env }) { model: compute.model, endpoint: compute.base, configured: Boolean(compute.key), - tee: Boolean(compute.key), + // 這幾格由下面的 probeComputeModel 填。設了金鑰不等於叫得動那個模型, + // 更不等於那個模型跑在 TEE 裡 —— 兩件事都要實際查過才敢說。 + modelAvailable: null, + verifiability: null, + modelInTee: null, + teeAttested: null, + teeType: null, + tee: false, + error: null, }, // 敵方 AI agent 另外報,因為它照指定走 OpenAI(也可用 AI_PROVIDER=0g 整支切過來) @@ -125,7 +134,7 @@ export async function onRequestGet({ env }) { }, }; - const [chainRes, indexerRes] = await Promise.all([ + const [chainRes, indexerRes, computeRes] = await Promise.all([ Promise.all([ rpc(rpcUrl, 'eth_blockNumber'), rpc(rpcUrl, 'eth_chainId'), @@ -135,6 +144,7 @@ export async function onRequestGet({ env }) { (err) => ({ ok: false, err }), ), probeIndexer(indexer), + probeComputeModel(compute), ]); if (chainRes.ok) { @@ -148,6 +158,16 @@ export async function onRequestGet({ env }) { out.chain.error = String(e && e.message ? e.message : e).slice(0, 160); } + out.compute.modelAvailable = computeRes.ok; + out.compute.verifiability = computeRes.verifiability ?? null; + out.compute.modelInTee = computeRes.ok ? Boolean(computeRes.modelInTee) : null; + out.compute.teeAttested = computeRes.ok ? Boolean(computeRes.teeAttested) : null; + out.compute.teeType = computeRes.teeType ?? null; + // 只有「模型叫得動」而且「模型本身跑在 TEE 裡」才算 TEE 就緒 + out.compute.tee = Boolean(computeRes.ok && computeRes.modelInTee); + out.compute.error = computeRes.ok ? null : computeRes.error || null; + if (computeRes.teeModels) out.compute.teeModels = computeRes.teeModels; + out.storage.live = indexerRes.ok; out.storage.nodeCount = indexerRes.ok ? indexerRes.nodeCount : null; out.storage.method = indexerRes.ok ? indexerRes.method : null; diff --git a/functions/api/og/tee.js b/functions/api/og/tee.js index 904fcb1..e1a4466 100644 --- a/functions/api/og/tee.js +++ b/functions/api/og/tee.js @@ -22,7 +22,7 @@ * 也不要因為欄位名猜錯就假裝驗過了 —— 那比沒有這個功能更糟。 */ -import { sha256Hex } from './_shared.js'; +import { sha256Hex, ogComputeConfig } from './_shared.js'; /** 這些鍵名任一出現(不分大小寫、忽略底線與連字號)就當成該類材料。 */ const KEYS = { @@ -30,7 +30,10 @@ const KEYS = { signer: ['signeraddress', 'signingaddress', 'signer', 'publickey', 'pubkey', 'signingkey', 'teepublickey', 'providerpubkey'], measurement: ['measurement', 'mrenclave', 'mrsigner', 'enclavemeasurement', 'codehash', 'imagehash', 'rtmr'], quote: ['quote', 'attestation', 'attestationreport', 'rareport', 'teequote', 'evidence'], - proofId: ['proofid', 'proof', 'requestid', 'inferenceid'], + proofId: ['proofid', 'proof', 'requestid', 'inferenceid', 'resid', 'reskey'], + // 實測回應裡真正拿得到的身分:服務這一次推論的 provider 鏈上位址。 + // 標頭是 x-provider,body 是 x_0g_trace.provider,兩邊同值。 + providerAddress: ['provider', 'provideraddress'], }; const norm = (k) => String(k).toLowerCase().replace(/[-_\s]/g, ''); @@ -83,6 +86,56 @@ export function extractEvidence(headers, body) { return found; } +/** + * 查這個 key 在 router 上看得到哪些模型,並回報設定的那個是什麼狀態。 + * + * 為什麼要查:我們的預設模型名曾經寫成 router 上根本不存在的 deepseek-chat-v3-0324, + * 每一回合都拿到 404 → 靜靜退回本地啟發式。整段期間狀態燈是綠的,因為當時只檢查 + * 「有沒有設金鑰」。有金鑰不代表叫得動那個模型。 + * + * 順便把 verifiability 一起回報,因為它決定 TEE 的主張成不成立: + * TeeML 模型本身跑在 TEE 裡 ← 只有這個撐得起「同一位 agent」 + * TeeTLS TEE 只終結 TLS,推論在上游廠商那邊 + * (沒有) 完全沒有 TEE + */ +export async function probeComputeModel(cfg, timeoutMs = 6000) { + if (!cfg.key) return { ok: false, error: `未設定 ${cfg.keyName}` }; + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + try { + const res = await fetch(`${cfg.base.replace(/\/+$/, '')}/models`, { + signal: ac.signal, + headers: { authorization: `Bearer ${cfg.key}`, accept: 'application/json' }, + }); + if (!res.ok) return { ok: false, error: `models ${res.status}` }; + const data = await res.json(); + const list = Array.isArray(data && data.data) ? data.data : []; + const hit = list.find((m) => m && m.id === cfg.model); + if (!hit) { + return { + ok: false, + available: list.length, + error: `router 上沒有 ${cfg.model} 這個模型`, + teeModels: list.filter((m) => m && m.verifiability === 'TeeML').map((m) => m.id).slice(0, 8), + }; + } + return { + ok: true, + available: list.length, + verifiability: hit.verifiability || null, + teeAttested: Boolean(hit.tee_attested), + teeType: hit.tee_type || null, + teeVerifier: hit.tee_verifier || null, + // TeeTLS 只保證傳輸層在 enclave 裡,推論本身不是 —— 不能拿來主張「同一位 agent」 + modelInTee: hit.verifiability === 'TeeML', + }; + } catch (err) { + return { ok: false, error: err && err.name === 'AbortError' ? 'models 逾時' : String(err).slice(0, 80) }; + } finally { + clearTimeout(timer); + } +} + /** 這一回合的證據封包。boardHash 綁住「它看到的盤面」,responseHash 綁住「它說了什麼」。 */ export async function sealTurn({ turn, view, legal, rawResponse, headers, body, model, provider }) { const evidence = extractEvidence(headers, body); @@ -96,6 +149,7 @@ export async function sealTurn({ turn, view, legal, rawResponse, headers, body, provider: provider || null, signature: evidence.signature ? shorten(evidence.signature) : null, signer: evidence.signer ? shorten(evidence.signer) : null, + providerAddress: evidence.providerAddress ? shorten(evidence.providerAddress) : null, measurement: evidence.measurement ? shorten(evidence.measurement) : null, proofId: evidence.proofId ? shorten(evidence.proofId) : null, }; @@ -123,53 +177,66 @@ export function verifyChain(chain, attestation) { const turns = Array.isArray(chain) ? chain : []; if (turns.length === 0) return { level: 'none', ok: false, turns: 0, reason: '沒有任何回合的紀錄' }; - const signers = new Set(turns.map((t) => t.signer).filter(Boolean)); + const lower = (v) => (v ? String(v).toLowerCase() : null); + const signers = new Set(turns.map((t) => lower(t.signer)).filter(Boolean)); + const addresses = new Set(turns.map((t) => lower(t.providerAddress)).filter(Boolean)); const models = new Set(turns.map((t) => t.model).filter(Boolean)); const providers = new Set(turns.map((t) => t.provider).filter(Boolean)); const signed = turns.filter((t) => t.signature).length; + const addressed = turns.filter((t) => t.providerAddress).length; + const allSigned = signed === turns.length; + const allAddressed = addressed === turns.length; + const sameSigner = signers.size === 1; + const sameAddress = addresses.size === 1; const sameModel = models.size <= 1; const sameProvider = providers.size <= 1; - const sameSigner = signers.size === 1; - const allSigned = signed === turns.length; - const attestedKey = attestation && (attestation.publicKey || attestation.signer) || null; + const attestedKey = (attestation && (attestation.publicKey || attestation.signer)) || null; const keyMatches = - Boolean(attestedKey) && sameSigner && [...signers][0] - ? String([...signers][0]).toLowerCase().includes(String(attestedKey).toLowerCase().slice(0, 16)) + Boolean(attestedKey) && sameSigner + ? String([...signers][0]).includes(String(attestedKey).toLowerCase().slice(0, 16)) : false; + const modelInTee = Boolean(attestation && attestation.modelInTee); - let level = 'none'; - if (allSigned && sameSigner && keyMatches && attestation && attestation.measurement) level = 'attested'; + let level; + if (signers.size > 1 || addresses.size > 1) level = 'changed'; + else if (allSigned && sameSigner && keyMatches && attestation && attestation.measurement) level = 'attested'; else if (allSigned && sameSigner) level = 'signed'; - else if (signed > 0 && signers.size > 1) level = 'changed'; + else if (allAddressed && sameAddress && modelInTee) level = 'same-provider'; + else if (allAddressed && sameAddress) level = 'same-provider-untrusted'; else if (sameModel && sameProvider) level = 'consistent'; - - const noAttest = attestation && attestation.error ? `(${attestation.error})` : ''; - const reason = - level === 'attested' - ? '每回合都有簽名、同一把 enclave 公鑰,且對得上 attestation 的 measurement' - : level === 'signed' - ? `每回合都有簽名且出自同一把公鑰,但沒取得 attestation${noAttest},無法證明那把金鑰長在 enclave 裡` - : level === 'changed' - ? `簽章公鑰中途換過(這 ${turns.length} 個回合出現了 ${signers.size} 把不同的公鑰)—— 這正是「agent 被換掉」的樣子,不能主張是同一位` - : level === 'consistent' - ? signed === 0 - ? '供應商沒有回傳任何可驗證的簽名。目前只能說每回合的供應商與模型相同 —— 這是我們伺服器的紀錄,不是密碼學證明' - : `只有 ${signed}/${turns.length} 個回合帶回簽名,其餘沒有,不足以主張整場都被簽過。供應商與模型本身是一致的,但那只是我們伺服器的紀錄` - : '每回合的供應商或模型不一致,無法主張是同一位 agent'; + else level = 'none'; + + const teeTag = attestation && attestation.verifiability ? `(${attestation.verifiability}${attestation.teeType ? ' · ' + attestation.teeType : ''})` : ''; + const reason = { + attested: '每回合都有簽名、同一把 enclave 公鑰,且對得上 attestation 的 measurement', + signed: `每回合都有簽名且出自同一把公鑰,但沒取得 attestation${attestation && attestation.error ? `(${attestation.error})` : ''},無法證明那把金鑰長在 enclave 裡`, + 'same-provider': `每一回合都由同一個 provider(${[...addresses][0]})服務,而該模型在 router 上登記為在 TEE 內執行${teeTag}。0G 目前不隨回應附簽名,所以這一級靠的是 router 的回報,不是我們自己驗過的密碼學證明`, + 'same-provider-untrusted': `每一回合都由同一個 provider(${[...addresses][0]})服務,但該模型沒有登記為在 TEE 內執行 —— 換一個 verifiability = TeeML 的模型才撐得起 TEE 的主張`, + changed: signers.size > 1 + ? `簽章公鑰中途換過(出現 ${signers.size} 把)—— 這正是「agent 被換掉」的樣子` + : `provider 位址中途換過(出現 ${addresses.size} 個)—— 不能主張整場是同一位 agent`, + consistent: `拿不到 provider 位址也拿不到簽名(${signed}/${turns.length} 回合有簽名)。目前只能說每回合的供應商與模型相同 —— 這是我們伺服器的紀錄,不是密碼學證明`, + none: '每回合的供應商或模型不一致,無法主張是同一位 agent', + }[level]; return { level, + // ok 只留給真正驗過密碼學證明的那一級。same-provider 是有意義的證據,但不是證明。 ok: level === 'attested', turns: turns.length, signedTurns: signed, + addressedTurns: addressed, sameSigner, + sameAddress, sameModel, sameProvider, signer: sameSigner ? [...signers][0] : null, + providerAddress: sameAddress ? [...addresses][0] : null, models: [...models], providers: [...providers], + verifiability: (attestation && attestation.verifiability) || null, measurement: (attestation && attestation.measurement) || null, reason, }; diff --git a/public/css/style.css b/public/css/style.css index 9057e2b..0a1528e 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1269,6 +1269,13 @@ body { .tee-badge[data-level='attested'] { background: var(--blue); color: #fff; border-color: var(--blue); } .tee-badge[data-level='signed'] { background: #F2C36B; color: var(--ink); } +/* + * same-provider 是實測下最常見的一級:每回合都同一個 provider,模型登記為 TeeML。 + * 它是有意義的證據,但不是我們自己驗過的密碼學證明 —— 所以給琥珀色,不給藍色。 + * 藍色只留給 attested。 + */ +.tee-badge[data-level='same-provider'] { background: #F2C36B; color: var(--ink); } +.tee-badge[data-level='same-provider-untrusted'] { background: var(--paper-edge); color: var(--muted); } .tee-badge[data-level='consistent'] { background: var(--paper-edge); color: var(--muted); } .tee-badge[data-level='changed'] { background: var(--terracotta); color: #fff; border-color: var(--terracotta); } .tee-badge[data-level='none'] { background: var(--terracotta); color: #fff; border-color: var(--terracotta); } diff --git a/public/js/main.js b/public/js/main.js index dab775a..d634215 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -890,10 +890,19 @@ async function pollStorage(root, note, attempts = 6) { * 結果分四級,刻意不做成通過/不通過:能證明到哪一層完全取決於供應商回了什麼。 * 把「只是紀錄一致」畫成綠燈,比沒有這個功能更糟。 */ +/** + * 等級的文案刻意分得很細,因為能證明到哪一層完全取決於供應商給了什麼材料。 + * + * 實測 0G router **不隨回應附簽名**,但每次回應都帶服務它的 provider 鏈上位址 + * (標頭 x-provider)。所以現實中最常落在 same-provider —— 它是有意義的證據 + * (provider 中途被換掉會被抓到),但不是我們自己驗過的密碼學證明,文案必須講清楚。 + */ const TEE_BADGE = { attested: '已驗證 · enclave 等級', signed: '部分驗證 · 缺 attestation', - changed: '⚠ 公鑰中途換過', + 'same-provider': '同一個 provider · TEE 模型', + 'same-provider-untrusted': '同一個 provider · 但非 TEE 模型', + changed: '⚠ 中途換過 agent', consistent: '未驗證 · 僅紀錄一致', none: '無法驗證', }; @@ -906,15 +915,12 @@ async function verifyTee() { btn.textContent = '驗證中…'; try { - // attestation 拿不到不算失敗 —— 它只是決定最高能驗到哪一級 - const attestation = await fetch('/api/og/attest', { headers: { accept: 'application/json' } }) - .then((r) => (r.ok ? r.json() : null)) - .catch(() => null); - + // TEE 狀態由伺服器自己去 router 查 —— 要證明給玩家看的結論, + // 材料不能由被證明的那一方(前端)提供。 const res = await fetch('/api/og/same-agent', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ chain: game.teeChain, attestation }), + body: JSON.stringify({ chain: game.teeChain }), }); if (!res.ok) throw new Error(`same-agent ${res.status}`); const v = await res.json(); @@ -925,13 +931,15 @@ async function verifyTee() { badge.dataset.level = v.level; reason.textContent = v.reason; - // 逐回合亮燈:同一把公鑰=藍、換過=橘、沒簽名=空心 + // 逐回合亮燈:跟第一回合同一個身分=藍、換過=橘、完全沒身分=空心。 + // 身分優先看簽章公鑰,沒有就看 provider 位址。 const list = $('tee-turns'); list.textContent = ''; for (const t of v.perTurn || []) { const li = document.createElement('li'); li.className = 'tee-turn'; - li.dataset.state = !t.signed ? 'unsigned' : t.sameAsFirst === false ? 'changed' : 'ok'; + const identified = t.signed || Boolean(t.providerAddress); + li.dataset.state = !identified ? 'unsigned' : t.sameAsFirst === false ? 'changed' : 'ok'; const dot = document.createElement('span'); dot.className = 'tee-turn-dot'; const label = document.createElement('span'); @@ -950,10 +958,11 @@ async function verifyTee() { const meta = $('tee-meta'); meta.textContent = ''; const rows = [ - ['回合數', `${v.signedTurns} / ${v.turns} 有簽名`], + ['回合數', `${v.turns} 回合,其中 ${v.addressedTurns ?? 0} 回合帶有 provider 位址、${v.signedTurns} 回合帶有簽名`], ['模型', (v.models || []).join(' / ') || '—'], - ['供應商', (v.providers || []).join(' / ') || '—'], - ['簽章公鑰', v.signer || '未取得'], + ['TEE 等級', v.verifiability ? `${v.verifiability}(TeeML 才是模型跑在 enclave 裡)` : '未取得'], + ['provider 位址', v.providerAddress || '未取得或中途換過'], + ['簽章公鑰', v.signer || '0G 目前不隨回應附簽名'], ['enclave measurement', v.measurement || '未取得'], ]; for (const [k, val] of rows) {