{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"64b1ce06-3b4d-43df-b315-83655ec13b96","kind":"audit","nodes":[{"acceptedSubmissionHash":"5b66c6b9dd12f7d6141e09b4cfc7a24faa60c6fb73cc8c59f7dd3c1ca7910443","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"73c9a4296edfc3034e3e3bb9377db61d830d42901a66266f7b2254438befd93e","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"844f5d5a09ce27d64307b8156ced366bda0b0277b2143e0b120c9d470d37bb7e","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","subagents":4,"tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"027315bacd3b67db2ff848a688f07ec44c8e3dcd9b24a31c59188e4b7fa53de6","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"9dc636ba285e00212400f1d295bbacc54b7f269fc34c7f0e3f986331c6859038","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"}],"objective":"Review Submission28/ only (read Submission28/AUDIT_REQUEST.md first). World source e93e55a, NOT deployed; no real Celestao payment, no real grant and no 鼠道修仙 purchase has ever been made. Scope is the 4 commits since 24a3efa: fixes for audit 447bb70d (part A), two fixed links from Celestao to 鼠道修仙 (part B), and a new 鼠道修仙 cosmetic store paid with World GOLD (part C). Submission27 was never sent. The wallet-lock test fails on purpose until this audit is handled.\n\n1. Part C money: own a cosmetic without a live paid order, pay twice for one cosmetic (two tabs, two request keys, the generic /api/gold/quote, retries, replays), keep a cosmetic after a refund, or be refunded twice. Can SQUEEK_ONE_TIME_SQL in the GOLD debit batch be bypassed, or block a payment it should not (other products, other wallets)?\n2. Part C identity: authorization code or grant theft, redirect and state handling, PKCE, the grant after World logout or a wallet switch, cross-wallet reads, CORS. Can the read-only grant reach any route that changes state other than its own logout?\n3. Part C checkout: CSRF on /api/squeek/checkout/quote, the request-key format, a key reused for another cosmetic, an expired order, a contract wallet.\n4. Part A: the failed-run rename in vault.prepare against any interleaving of prepare, delivery, refund, lapse, retire, prune_failed and the operator tool; the 409/422 mapping; the grant form limits; the backup sentence against code.\n5. Part B: does either link carry any user, chart or session data to the other site?\n\nKnown items are in AUDIT_REQUEST.md (part C disclosures) and Submission26/AUDIT_REQUEST.md sections 1 to 9. Report severity, reproduction and fix. Not in scope: chart engines, report wording, the 鼠道修仙 game itself, 3D/rendering, media, AIMarket, Genesis.","parentJobId":null,"planHash":"a66c8d9d6fca7d48e725218956ad85ef4eb80549b9107607f2df7d7cf2979a67","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"64b1ce06-3b4d-43df-b315-83655ec13b96","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"52596","feedbackHash":"ca9e294fab8eab6a9454e6539b55ac83cd5b8223d1e1298000a13f4b39e7af9c","nodeKey":"audit_economics","submissionHash":"5b66c6b9dd12f7d6141e09b4cfc7a24faa60c6fb73cc8c59f7dd3c1ca7910443","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52627","feedbackHash":"26452ac258193f9b58e30252267056beae38c98bcb9350f00774c98d6086be7c","nodeKey":"audit_flow","submissionHash":"73c9a4296edfc3034e3e3bb9377db61d830d42901a66266f7b2254438befd93e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51878","feedbackHash":"32a883cfebc12a3681da542fb760201b9a2ed086dc04f21203ebaf1670587c1f","nodeKey":"audit_judge","submissionHash":"844f5d5a09ce27d64307b8156ced366bda0b0277b2143e0b120c9d470d37bb7e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52625","feedbackHash":"3dc46f2fd13756eace8e7147e9a4f96a96cf5abca28ea9ecc6db882371f6eccc","nodeKey":"audit_math","submissionHash":"027315bacd3b67db2ff848a688f07ec44c8e3dcd9b24a31c59188e4b7fa53de6","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52623","feedbackHash":"caa5ada997cfdec784deb8a8498a7515a22212286d6dc10a05f3706e47732fcd","nodeKey":"audit_permissions","submissionHash":"9dc636ba285e00212400f1d295bbacc54b7f269fc34c7f0e3f986331c6859038","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"d08259b34e1fc41e518f5d2af794c159efb6d9cda520d1e8e35bba23564a2d18","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"96ece23c63b791a1","findings":[{"citation":"resolved","description":"The read-only grant's quote route tells the game where to send the player to pay: `https://imdember.com/squeek-checkout/?sku=<sku>`. The checkout page parses its URL with squeekCheckoutParams (src/world/squeekEntry.ts:16-18), which sets `valid` only when there is exactly one `request` parameter matching SQUEEK_REQUEST_KEY (/^[A-Za-z0-9_-]{22,64}$/). The URL the server hands out has no `request` parameter, so `requestKey` is '' and `valid` is false, and SqueekCheckout.tsx shows 'Invalid purchase link. Return to Squeek Ascension.' (購買連結無效) with no way to create an order. AUDIT_REQUEST.md section C.2 itself says the URL shape is `/squeek-checkout/?sku=…&request=…`. It also omits `lang`, so an English player lands on the Chinese page (zh is the default when lang is not 'en'). No money moves, but the only purchase link the World API gives the game is a dead end. A game adapter that trusts this field can never sell anything, and one that patches the URL itself has to rebuild the request-key contract on the client with nothing on the server to check it against.","line":216,"path":"Submission28/source/server/squeek-store.ts","reproduction":"With a valid grant G (from /api/squeek/token): `GET https://imdember.com/api/squeek/quote?sku=sky.sakura.v1` with `Authorization: Bearer G` and `Origin: https://squeekascension.celestao.com` answers 200 with checkoutUrl `https://imdember.com/squeek-checkout/?sku=sky.sakura.v1`. Open that URL while signed in to World. squeekCheckoutParams('?sku=sky.sakura.v1') returns {sku:'sky.sakura.v1',requestKey:'',zh:true,valid:false}, because SQUEEK_REQUEST_KEY.test('') is false and getAll('request').length is 0. Expected: a page that can create the 200 GOLD order. Actual: the 'Invalid purchase link' alert in Chinese, and no create-order button.","severity":"low","snippet":"   checkoutUrl:cfg.issuer+'/squeek-checkout/?sku='+encodeURIComponent(it.sku)},cors);","title":"GET /api/squeek/quote returns a checkoutUrl that the checkout page always rejects as an invalid purchase link (no request key)"},{"citation":"resolved","description":"The grant gives the game subject_id (in the token response, snapshot, quote, orders and every receipt) plus walletHint (line 48: the first 6 and last 4 characters of the address). The comment says 'The game sees a stable pseudonym, not the wallet address', and AUDIT_REQUEST C.1 says the game gets a pseudonym and a shortened wallet, not the full address. But subject_id is a public, deterministic function of the address: SHA-256 over a fixed public prefix plus the lowercased address, with no server secret. World sign-in wallets are public-chain addresses. Anyone who holds a subject_id (the game, its logs or storage, or any leaderboard or share that shows it) can hash a candidate list, such as the holders of the collections World checks or the addresses seen on chain, and match the one that equals subject_id. The walletHint narrows the candidates first, and it lets anyone confirm a guess at once. The property the design claims, that the game learns a stable id but not the address, does not hold for any wallet in an enumerable set. Fix: derive the subject with a keyed MAC, HMAC-SHA256(server secret, 'squeek-subject-v1:'+address), as the Celestao vault already does for owner (vault.py `owner()` uses hmac with _index_key). Or store a random per-wallet subject. Keep walletHint only if the owner accepts that it confirms a guess. Changing the derivation changes existing subject ids, which is harmless here because nothing has been deployed.","line":47,"path":"Submission28/source/server/squeek-store.ts","reproduction":"Take address A = 0x00000000219ab540356cBB839Cbe05303d7705Fa. Its subject_id is 'sq_'+sha256('squeek-subject-v1:'+A.toLowerCase())[0:40] = sq_1bad7a614249e0af0a6d0fa2e7527c3c0457976b, and its walletHint is 0x0000…05Fa. Given only that subject_id, hash each address in a candidate list [0xd8dA6BF2…6045, A, 0xBE0eB53F…33E8] the same way: the second matches, so the full address is recovered with no World secret (checked with node:crypto). Expected: subject_id cannot be linked to an address without a server-held key. Actual: anyone can compute and invert it over any candidate list.","severity":"low","snippet":"export async function squeekSubject(address:string){return 'sq_'+(await sha256('squeek-subject-v1:'+address.toLowerCase())).slice(0,40);}","title":"The 鼠道修仙 'pseudonym' is an unkeyed hash of the wallet, so anyone who has a candidate list of addresses can turn subject_id back into the full address"}],"hash":"027315bacd3b67db2ff848a688f07ec44c8e3dcd9b24a31c59188e4b7fa53de6","nodeId":"9b26224b-435a-441d-ba14-84a2a4b9e52b","outcome":"completed","summary":"I found two Low-severity defects in Part C and nothing in the payment flow itself, so no finding is Critical, High or Medium. Both findings are in `.imd-findings.json`, and their path, line and quoted code match the tree. None carries a Foundry proof: there is no Solidity in scope, and the package can't run its own tests because `server/economy-config.ts`, `auth.ts`, `celestaoEntry.ts` and the delivery adapters are missing. Everything below comes from reading the code, plus one small script outside the repository for finding 2.\n\n## Findings\n\n1. **Low: the purchase link from `GET /api/squeek/quote` always opens a dead end** (`server/squeek-store.ts:216`). The `checkoutUrl` it returns is `https://imdember.com/squeek-checkout/?sku=…`, with no `request=` parameter and no `lang`. The checkout page only accepts a link with exactly one `request` matching `^[A-Za-z0-9_-]{22,64}$`. So this link shows \"Invalid purchase link\" with no way to create an order, and an English player gets the Chinese page. Your own AUDIT_REQUEST C.2 gives the link as `?sku=…&request=…`. Fix: include the request key and `lang` in the URL, or stop returning the field.\n\n2. **Low: the \"pseudonym\" can be turned back into the full wallet address** (`server/squeek-store.ts:47`). `subject_id` is a plain SHA-256 of a fixed public prefix plus the address, with no server secret. Anyone holding a `subject_id` can hash a list of known addresses (collection holders, on-chain addresses) and find the match. The shortened wallet the game also gets lets anyone confirm a guess at once. I checked this: given only the `subject_id` of `0x00000000219ab540356cBB839Cbe05303d7705Fa`, hashing three candidate addresses picked out the right one. Fix: derive it with a keyed hash, as `vault.owner()` already does on the Celestao side.\n\n## Checked and found sound\n\n- **Part C money:**\n  - **Charged twice:** two tabs, two request keys, the generic `/api/gold/quote`, retries and replayed signatures all end in one charge.\n  - **The one-time rule:** `SQUEEK_ONE_TIME_SQL` runs inside the debit batch, and its bind order matches its placeholders. It filters by the same wallet and the same product, and other products pass. It counts a paid order whose delivery job is missing, queued or working as owned.\n  - **Refunds:** delivery jobs only move from failed to refunded, and nothing puts a failed job back in the queue. A refund removes the cosmetic, and the refund reference stops it being paid twice.\n  - **Squeek delivery:** an error from the 鼠道修仙 delivery never fails a job; the job is retried and the cosmetic stays owned.\n- **Part C identity:**\n  - **Codes and PKCE:** the code is bound to the session, PKCE uses S256, the redirect is fixed, and the code is single-use within its 60 seconds.\n  - **Grant lifetime:** a grant lasts at most as long as the World session, and logging out or switching wallet ends it.\n  - **CORS:** CORS allows only the game's origin, with no credentials.\n  - **Reach:** a grant only reads its own wallet's data. The only change it can make is its own logout; the checkout routes accept only the cookie session.\n- **Part C checkout:**\n  - **CSRF and key reuse:** the Origin check blocks CSRF on `checkout/quote`, and a key reused for another cosmetic answers 409.\n  - **Expiry:** an expired order shows as expired, and the expiry comparisons are consistent.\n  - **Contract wallets:** they are refused when a new order is created, and they can't produce the payment signature anyway.\n- **Part A:**\n  - **The rename:** it holds against prepare, delivery, `strike`, `note_wait`, the operator's `abandon`, `retire_lapsed` and `prune_failed`, with a late delivery still answering terminal-failure.\n  - **Errors:** the 409/422 mapping is correct.\n  - **Grant form:** its limits match what the server returns, which is strings.\n  - **Privacy text:** it matches the 30-day retention constants.\n- **Part B:** the page script in the package adds no link code or data. The link itself is in `prepare_overlay.py`,","treeHash":null,"usage":{"cachedInputTokens":4404501,"cost":{"source":"runtime","usdMicros":2754157},"inputTokens":87,"model":"claude-opus-5-5","outputTokens":36730,"runtime":"claude","turns":45,"wallClockMs":453471}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"629987aaf6c7aa88","findings":[{"citation":"resolved","description":"After the one-time squeek_release_actions insert, the 11 cosmetics are ordinary enabled 'service' rows in world_gold_products. With SQUEEK_SALES_ENABLED=1, worker/app.ts registers createSqueekDelivery(), so delivery.products, and therefore world-gold-api.ts serviceable(product), includes every squeek-* product. goldQuote (world-gold.ts:58-83) checks only that the product's latest version is enabled, that serviceReady holds and that the 'service' control is on. It does not call salesOpen() (squeek-store.ts:91-96), which requires all 11 products enabled, at 200 GOLD, on their latest version. It does not run the squeekEntitlements ALREADY_OWNED pre-check or the EOA pre-check either (squeek-store.ts:162-164). WorldGoldPanel.tsx:141 renders every enabled service product, so the 11 cosmetics appear there with a 'Create order' button, including ones the wallet already owns. So the squeek checkout's 'sales closed' and 'only at 200' conditions are not enforced at the authority. The GOLD debit's SQUEEK_ONE_TIME_SQL still stops a second charge for an owned cosmetic, so this does not cause a double charge. It does let the store sell outside the gate the owner described, at a price other than the approved 200, and orders made this way have request ids without the 'sq_' prefix, so the game's /order?requestKey lookup can never find them (receipt.requestKey is null). Fix: either refuse squeek-* products in goldQuote unless the request comes from the squeek checkout (for example, require requestId to start with 'sq_' and move the salesOpen(), EOA and entitlement checks into the shared quote), or make the gate an SQL condition in the quote and debit batch. That condition would require all 11 products enabled at 200 on their latest version, as the release decision says. Also filter squeek-* products out of WorldGoldPanel's service list, or link them to /squeek-checkout/ the way fortune products link to Celestao.","line":69,"path":"Submission28/source/server/world-gold.ts","reproduction":"State: migration 0025 applied, release action inserted, SQUEEK_SALES_ENABLED=1, service control enabled. (1) The admin publishes a new price version of squeek-sky-meteors-v1 at 300 GOLD through the existing price-version command, or disables one cosmetic. Expected: no cosmetic can be sold, because the squeek checkout's salesOpen() is now false (POST /api/squeek/checkout/quote answers 503 SALES_CLOSED, and catalog/snapshot report salesEnabled:false). Actual: POST /api/gold/quote {expectedAddress:W, requestId:'abcdefghijklmnop1234', product:'squeek-sky-meteors-v1'} returns an orderId for 300 GOLD (or for 200 GOLD on any of the other 10). The economy challenge and pay routes accept it, because serviceProducts includes the product and SQUEEK_ONE_TIME_SQL passes for an unowned cosmetic. The squeek-store-v1 provider delivers it, and squeekEntitlements then lists it as owned. (2) A wallet that owns aura.frostmoon_sword.v1 sees 'Create order' for it in the GOLD panel. The generic quote creates a pending order, and the player learns it is refused only at the signature step (ALREADY_OWNED from createEconomyGoldChallenge), which goldPurchaseError has no text for.","severity":"low","snippet":" const p=(await goldProducts(db)).find(p=>p.product===args.product);if(!p?.enabled)fail('PRODUCT_UNAVAILABLE');","title":"Squeek cosmetics can be quoted through the generic /api/gold/quote and the World GOLD panel, which skip the squeek release gate (11 products at 200 GOLD) and the ownership pre-check"}],"hash":"5b66c6b9dd12f7d6141e09b4cfc7a24faa60c6fb73cc8c59f7dd3c1ca7910443","nodeId":"6533f250-feda-410d-bec6-dcf2a2e41da5","outcome":"completed","summary":"I found one Low finding and nothing High or Critical. It is in `.imd-findings.json` (1 finding). I ran no tests: this package is TypeScript and Python, not Solidity, so there is no Foundry proof. It is also a partial tree, so the suite could not run here.\n\n## Finding (Low): squeek cosmetics can be sold outside the squeek checkout\n**Where:** `Submission28/source/server/world-gold.ts:69` (`goldQuote`), with `WorldGoldPanel.tsx:141`.\n\n- **Cause:** after the release insert, the 11 cosmetics are ordinary enabled service products. With `SQUEEK_SALES_ENABLED=1` they also count as serviceable. The generic `/api/gold/quote` and the GOLD panel's \"Create order\" button only check that a product is enabled, that its delivery is ready and that the service control is on.\n- **What they skip:** the three checks the squeek checkout makes before creating an order:\n  - `salesOpen()`: all 11 cosmetics enabled at 200 GOLD on their latest version.\n  - The already-owned check.\n  - The ordinary-wallet (EOA) check.\n- **Reproduction:**\n  1. The admin publishes version 2 of `squeek-sky-meteors-v1` at 300 GOLD with the existing `product` command. Nothing stops this.\n  2. `checkout/quote` now answers `SALES_CLOSED` and the catalogue shows `salesEnabled:false`.\n  3. `POST /api/gold/quote {product:'squeek-sky-meteors-v1',…}` still creates a 300 GOLD order. It can be paid, it is delivered, and the wallet then owns the cosmetic.\n- **Side effects:**\n  - The panel offers cosmetics the wallet already owns. The refusal only comes at the signature step, and the panel has no error text for `ALREADY_OWNED`.\n  - Orders made this way have no `sq_` request id, so the game's lookup by its own purchase key can never find them.\n- **Fix:** do one of these:\n  - Have `goldQuote` refuse `squeek-*` products unless they come through the squeek checkout (its `sq_` request id), and move the gate, ownership and EOA checks into the shared quote.\n  - Put the 11×200 gate into SQL in the quote and debit.\n\n  Also route the cosmetics in the panel to `/squeek-checkout/`, the way fortune products link to Celestao.\n\n## Checked and found sound\n- **Part C money:**\n  - `SQUEEK_ONE_TIME_SQL` is bound in the right order and runs in the same D1 batch as the debit.\n  - It is limited to the same wallet and the same product, and skips other products.\n  - Two tabs or two request keys can't both be charged. A replayed payment returns the already-paid order without a second debit.\n  - Ownership disappears when a delivery fails or is refunded. A failed job can only move to refunded, never back to queued or delivered.\n  - Refunds are done once per order.\n  - Squeek orders are GOLD only, so the chain-payment code (which only marks EMBEO orders paid) never touches them.\n- **Part C identity:**\n  - The code is single-use and checked against PKCE and the fixed redirect.\n  - The grant row is created in the same batch as the code is consumed, and the grant is tied to the live World session, so World logout ends it.\n  - Reads are limited to the grant's own wallet.\n  - CORS allows only the game's origin and no credentials.\n  - The Bearer grant reaches no route that changes state except its own `logout`.\n- **Part C checkout:**\n  - `checkout/quote` requires a same-origin request and a JSON body, so it is not open to CSRF.\n  - A request key reused for another cosmetic answers 409.\n  - Contract wallets are refused at quote time and again inside the payment SQL.\n- **Part A:**\n  - The failed-run rename in `vault.prepare` is atomic and suffixed with the row's unique id. It holds against lapse/retire, `forget_unpaid` and pruning, and late deliveries look rows up by id, not request id.\n  - The 409/422 mapping in `paid.py` matches the request.\n  - `withinGrantLeft` and the new backup sentence match the request.\n\n## Not covered or not verified\n- **Wallet switch:** I could not confirm that a wallet switch revokes the old World session, which the grant depends on. The current `auth.ts` is not in the package; the only copy is ","treeHash":null,"usage":{"cachedInputTokens":3406990,"cost":{"source":"runtime","usdMicros":2219294},"inputTokens":75,"model":"claude-opus-5-5","outputTokens":24011,"runtime":"claude","turns":40,"wallClockMs":317126}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"aefe32dd2527eb68","findings":[{"citation":"resolved","description":"The worker's allow('home',key) uses the key exactly as given (worker/app.ts limiter(): binding.limit({key:key??...}); no IP is added when a key is passed). For the game's Bearer routes the key is 'squeek:api:'+sha256(token) and for the token route (line 120) it is 'squeek:token:'+sha256(code). Both token and code are supplied by the caller, and any string matching /^[A-Za-z0-9_-]{43}$/ passes the format check, so every request with a fresh random value gets a fresh, never-exhausted bucket. The authorize, catalog and checkout routes key by client IP; these two do not. In addition, readSqueekGrant (a D1 SELECT joining squeek_tokens and sessions) runs before allow(), so even a token that is over its limit still costs a database read. On the token route, each attempt runs a two-statement D1 write batch (UPDATE squeek_auth_codes + INSERT squeek_tokens) before it fails. This does not let anyone steal a grant: codes, verifiers and tokens are 256-bit. But the limit there gives no protection against D1 load or cost from one client. Fix: Check a per-client bucket ('squeek:api:'+deps.client, 'squeek:token:'+deps.client) before any database access, and keep the per-token or per-code bucket only as a second limit. Move readSqueekGrant after both allow() calls.","line":199,"path":"Submission28/source/server/squeek-store.ts","reproduction":"From one IP, send 10,000 requests GET https://imdember.com/api/squeek/snapshot with Origin: https://squeekascension.celestao.com and Authorization: Bearer <a new random 43-char base64url string each time>. Expected: after the per-client budget (as on /api/squeek/catalog, keyed 'squeek:catalog:'+client) the requests get 429 before any D1 query. Actual: every request runs readSqueekGrant's D1 SELECT, then allow('squeek:api:<new hash>') succeeds because the bucket is new, and the reply is 401 AUTH_REQUIRED. The same happens with POST /api/squeek/token and a new random 'code' each time: every request passes allow('squeek:token:<new hash>') and runs the D1 batch. Every reply is 401 INVALID_GRANT, never 429.","severity":"low","snippet":"  const tclock=()=>clock(deps),grant=await readSqueekGrant(db,tokenValue,tclock);\n  if(!await deps.allow('squeek:api:'+(await sha256(tokenValue)).slice(0,32)))return reply(429,{error:'RATE_LIMITED'},cors);","title":"鼠道修仙 token and Bearer-API rate limits are keyed by attacker-chosen values, and the grant lookup runs before the limit"},{"citation":"resolved","description":"AUDIT_REQUEST part C.1 says the grant 'shows a pseudonym ... and a shortened wallet, not the full address'. The pseudonym is sha256 of a fixed public prefix plus the lowercase address. There is no server secret in it, so it is a deterministic public function of a public identifier. Wallet addresses are public, and World GOLD wallets can be listed from chain events and economy contracts. Anyone who sees a subject_id can therefore recover the address by hashing each candidate. That includes the game, its logs and analytics, and anyone it is shown to, such as a leaderboard or a shared receipt; receipts carry subjectId too. The walletHint returned beside it in /api/squeek/snapshot (first 4 and last 4 hex digits) narrows the candidates to about 2^-32 of the address space, so in practice the snapshot gives out the address. The game side is not in this package, so nothing here stops it publishing the subject. This weakens the stated privacy property; it does not affect funds. Fix: Derive the subject with a server-held secret, for example HMAC-SHA256(SQUEEK_SUBJECT_KEY, 'squeek-subject-v1:'+address), and keep it stable by never rotating that key. Drop walletHint, or document that the game is trusted with the address. If the hint stays, the documentation should not call the subject a pseudonym.","line":47,"path":"Submission28/source/server/squeek-store.ts","reproduction":"Take any address, for example 0x1111111111111111111111111111111111111111. Compute 'sq_'+hex(sha256('squeek-subject-v1:0x1111111111111111111111111111111111111111'))[0:40]. The result equals the subject_id that POST /api/squeek/token and GET /api/squeek/snapshot return for a grant of that wallet (squeekSubject at line 47, used at lines 134 and 137). To reverse a subject seen in the game, hash each address in a public list of World or GOLD wallets, kept to those matching walletHint '0x1111…1111', and compare. Expected (per the request): the game learns a pseudonym, not the address. Actual: anyone with the public address list can turn the pseudonym back into the address.","severity":"low","snippet":"export async function squeekSubject(address:string){return 'sq_'+(await sha256('squeek-subject-v1:'+address.toLowerCase())).slice(0,40);}","title":"The 鼠道修仙 'pseudonym' is an unkeyed hash of the wallet address, so anyone with a list of candidate addresses can reverse it; the wallet hint makes this easier"}],"hash":"73c9a4296edfc3034e3e3bb9377db61d830d42901a66266f7b2254438befd93e","nodeId":"d2096c46-dbb1-4891-b793-bcc2228108c2","outcome":"completed","summary":"I found two Low findings in Part C. Nothing I could reproduce lets anyone own a cosmetic without paying, pay twice for one, keep one after a refund, steal a grant, or change state with the read-only grant. Both findings are in `.imd-findings.json`. Neither has a Foundry proof, because nothing in scope is Solidity and the proof rule only covers critical or high Solidity findings.\n\n## Findings\n\n1. **Low: two rate limits can be sidestepped by the caller** (`server/squeek-store.ts:199–200` and `:120`).\n   - The limiter in `worker/app.ts` uses the key exactly as passed, with no IP added. On the game's Bearer routes the key is a hash of the token, and on `/api/squeek/token` it is a hash of the code. The caller chooses both.\n   - So a request with a new random 43-character token or code always gets a fresh limit. Also, the grant lookup (a D1 read) runs *before* the limit check.\n   - **Reproduction:** send `GET /api/squeek/snapshot` many times from one IP, each time with a new random Bearer value. Every request runs the D1 read and gets 401; none gets 429. The same happens on `/api/squeek/token`, which runs its D1 write batch each time.\n   - **Fix:** check a per-client limit before any database access, keep the per-token limit as a second one, and move `readSqueekGrant` after both checks.\n\n2. **Low: the \"pseudonym\" can be turned back into the wallet address** (`server/squeek-store.ts:47`).\n   - It is a plain `sha256` of a fixed prefix plus the address, with no server secret. Wallet addresses are public, so anyone who sees a `subject_id` can find the wallet by hashing a list of candidate addresses.\n   - The `walletHint` next to it (first 4 and last 4 hex digits) narrows the candidates sharply. This contradicts part C.1's claim that the game sees a pseudonym, not the address.\n   - **Fix:** derive it as an HMAC with a server-held key, and drop the hint or document that the game is trusted with the address.\n\n## What I checked and rejected\n\n- **Part C money:**\n  - The one-time check inside the debit has its values in the right order and only binds the order id.\n  - It matches by wallet and product name, so other products and other wallets are not blocked.\n  - Two tabs, two request keys, the generic `/api/gold/quote`, retries and replays are all refused at the challenge or inside the debit.\n  - A refund needs a job in `failed`, which can never return to `delivered`, so a refunded cosmetic is gone and there is no double refund.\n  - The cosmetic delivery never ends in `failed`; an error is retried, so in practice refunds don't occur.\n- **Part C identity:**\n  - PKCE is checked inside the step that uses up the code, so a wrong verifier doesn't burn the code. Codes are single-use and capped at the session's expiry, and the redirect is fixed.\n  - A grant dies with its World session. Grants live in their own table, so a grant cannot be used as a World session.\n  - CORS allows only the game's origin. No route reachable with a grant changes state, except its own logout.\n- **Part C checkout:** `checkout/quote` requires a same-origin `Origin` header and a JSON body. A key reused for another cosmetic gets 409. A contract wallet is refused when an order is created, and again by the generic payment route.\n- **Part A:**\n  - I traced the failed-run rename against prepare, delivery, refund, lapse, retire, `prune_failed` and the operator's `abandon`. The renamed id contains `~`, which a request id cannot contain, so it never collides, and a late delivery still finds the old row by its id.\n  - The 409/422 mapping matches what `world-wallet.js` reads. The grant form helper matches its description.\n  - For the backup sentence, I tested whether data deleted by the prune survives in the database file or in a later backup. It did not on this machine, because its SQLite build overwrites deleted data by default. Whether the production build does the same depends on how its SQLite was built, so I didn't report it.\n\n## Coverage limits\n\n- **Part B could not be checked.","treeHash":null,"usage":{"cachedInputTokens":4284979,"cost":{"source":"runtime","usdMicros":2590156},"inputTokens":80,"model":"claude-opus-5-5","outputTokens":30124,"runtime":"claude","turns":43,"wallClockMs":378623}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2a6239e0c1b8c774","findings":[{"citation":"resolved","description":"The read-only grant's quote route is the only place the World API tells the game where to send a player to pay. It builds cfg.issuer+'/squeek-checkout/?sku='+sku and nothing else. The checkout page parses its URL with squeekCheckoutParams (Submission28/source/src/world/squeekEntry.ts lines 15-18), which sets valid only when the query has exactly one 'request' parameter matching SQUEEK_REQUEST_KEY (/^[A-Za-z0-9_-]{22,64}$/) and exactly one 'sku'. With no 'request' parameter requestKey is '' and valid is false, so SqueekCheckout.tsx line 49 renders the alert 'Invalid purchase link. Return to Squeek Ascension.' and never offers a create-order button. AUDIT_REQUEST.md part C.2 itself documents the page shape as /squeek-checkout/?sku=...&request=.... The URL also carries no lang, so an English player lands on the Chinese page (zh is the default when lang is not 'en'). No GOLD is at risk, but the purchase link the API hands out is a dead end, and an adapter that trusts it can never sell anything. Reported by the math specialist; reproduced against squeekEntry.ts. Fix: either omit checkoutUrl (the game builds the link with its own request key, which the server then validates), or accept an optional requestKey on the quote route, validate it with SQUEEK_REQUEST_KEY, and return cfg.issuer+'/squeek-checkout/?sku='+sku+'&request='+key+'&lang='+lang. Add a test that squeekCheckoutParams(new URL(quote.checkoutUrl).search).valid is true.","line":216,"path":"Submission28/source/server/squeek-store.ts","reproduction":"With a valid grant G: GET https://imdember.com/api/squeek/quote?sku=sky.sakura.v1 with 'Authorization: Bearer G' and 'Origin: https://squeekascension.celestao.com'. Actual response field: checkoutUrl='https://imdember.com/squeek-checkout/?sku=sky.sakura.v1'. Feeding that query to the page parser: squeekCheckoutParams('?sku=sky.sakura.v1') returns {sku:'sky.sakura.v1',requestKey:'',zh:true,valid:false} (checked with node: SQUEEK_REQUEST_KEY.test('') is false and getAll('request').length is 0). Expected: a URL the page accepts and that can create the 200 GOLD order. Actual: the page shows the 'Invalid purchase link' alert and no create-order button. The existing test only asserts squeekCheckoutParams on a hand-built URL with a request key (tests/squeek-store.test.mjs line 161), never on the server's checkoutUrl.","severity":"low","snippet":"   checkoutUrl:cfg.issuer+'/squeek-checkout/?sku='+encodeURIComponent(it.sku)},cors);","title":"GET /api/squeek/quote returns a checkoutUrl without the request key (and lang), so the World checkout page rejects it as an invalid purchase link"},{"citation":"resolved","description":"POST /api/squeek/token is a public endpoint (no client secret, no cookie) and the Bearer routes accept any 43-character token. Their only budget is keyed on the caller's own value: line 120 uses 'squeek:token:'+sha256(code) and line 200 uses 'squeek:api:'+sha256(token). worker/app.ts limiter() (lines 139-145) passes a supplied key to binding.limit({key}) verbatim and adds no client IP, so every request with a fresh random code or token lands in a fresh, never-exhausted bucket. The authorize, catalog and checkout routes key on deps.client (the client IP); these two do not. On the token route each attempt then runs the two-statement D1 write batch at lines 122-131 (UPDATE squeek_auth_codes, INSERT ... SELECT into squeek_tokens). On the Bearer routes readSqueekGrant (a JOIN of squeek_tokens and sessions, line 86-87) runs at line 199 before allow() at line 200, so even a token over its limit costs a D1 read. Codes, verifiers and tokens are 256-bit, so this does not let anyone obtain a grant; it removes the limiter's protection against D1 load and cost from one client. Reported independently by the permissions and flow specialists; merged. Fix: check a per-client bucket first ('squeek:token:'+deps.client before the batch, 'squeek:api:'+deps.client before readSqueekGrant), keep the per-code and per-token keys as a second limit if wanted, and move readSqueekGrant below both allow() calls.","line":200,"path":"Submission28/source/server/squeek-store.ts","reproduction":"From one IP send 10,000 requests POST https://imdember.com/api/squeek/token with Origin https://squeekascension.celestao.com, content-type application/json and body {\"grant_type\":\"authorization_code\",\"client_id\":\"squeek-ascension\",\"redirect_uri\":\"https://squeekascension.celestao.com/\",\"code\":<new random [A-Za-z0-9_-]{43} each time>,\"code_verifier\":\"vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv\"}. Expected: 429 RATE_LIMITED once a per-client budget is spent, before any database write. Actual: every request passes allow('squeek:token:<new hash>') because the key is new, runs db.batch, and answers 401 INVALID_GRANT. Same with GET /api/squeek/snapshot and 'Authorization: Bearer <new random 43 chars>' per request: readSqueekGrant runs its SELECT, allow('squeek:api:<new hash>') succeeds, reply is 401 AUTH_REQUIRED, never 429. In the test harness this is visible by passing deps.allow=key=>{calls.push(key);return true} and checking that neither key contains the client value (deps.client), and, for a Bearer route, that the squeek_tokens SELECT runs before allow is called.","severity":"low","snippet":"  if(!await deps.allow('squeek:api:'+(await sha256(tokenValue)).slice(0,32)))return reply(429,{error:'RATE_LIMITED'},cors);","title":"Squeek token and Bearer routes are rate-limited by a key the caller chooses, and the grant lookup runs before the limiter, so one client gets unlimited D1 work"},{"citation":"resolved","description":"AUDIT_REQUEST.md part C.1 and the comment above squeekSubject say the game sees a stable pseudonym and a shortened wallet, not the full address. The subject is 'sq_'+sha256('squeek-subject-v1:'+lowercase address)[0:40]: a deterministic function of a public identifier under a fixed, published prefix, with no server secret. Wallet addresses are public and the set of World or GOLD wallets is enumerable from chain events and the economy contracts, so anyone holding a subject_id (the game, its logs or storage, a leaderboard or shared receipt; receipts carry subjectId too) can hash a candidate list and recover the address. walletHint (line 48: first 6 and last 4 characters) is returned next to it in /snapshot and confirms the match at once. The stated separation therefore does not hold; funds are not affected. Three specialists reported this (permissions, flow, math); merged. The Celestao vault already does this correctly: vault.py owner() uses HMAC with a server-held key. Fix: derive the subject with HMAC-SHA256 under a Worker secret, for example 'sq_'+HMAC(SQUEEK_SUBJECT_KEY,'squeek-subject-v1:'+address)[0:40], never rotated; drop walletHint or document that the game is trusted with the address. Nothing is deployed, so changing the derivation strands no existing ids.","line":47,"path":"Submission28/source/server/squeek-store.ts","reproduction":"node -e \"const c=require('crypto');console.log('sq_'+c.createHash('sha256').update('squeek-subject-v1:0x1111111111111111111111111111111111111111').digest('hex').slice(0,40))\" prints sq_e2083d592e16e353e42caff5af6947ede8103b46, which is exactly squeekSubject('0x1111111111111111111111111111111111111111') and the subject_id the token endpoint and /snapshot return for that wallet (lines 134, 137). For 0x00000000219ab540356cBB839Cbe05303d7705Fa the same command prints sq_1bad7a614249e0af0a6d0fa2e7527c3c0457976b and /snapshot returns walletHint '0x0000…05Fa'. Given a subject_id from the game and a public list of addresses, one hash per candidate (filtered first by the hint) recovers the wallet with no World secret. Expected: an id that cannot be computed without a server-held key. Actual: anyone can compute and invert it offline.","severity":"low","snippet":"export async function squeekSubject(address:string){return 'sq_'+(await sha256('squeek-subject-v1:'+address.toLowerCase())).slice(0,40);}","title":"The game's \"pseudonym\" is an unkeyed SHA-256 of the public wallet address and is returned beside a wallet hint, so anyone can turn subject_id back into the full address"},{"citation":"resolved","description":"The World session cookie is SameSite=Lax (source/server/auth.ts line 603), so a top-level GET navigation from any site carries it. authorize() checks the Origin header only when present (navigations send none), never checks Sec-Fetch-Site, and shows the consent page /squeek-connect/ only to a signed-out visitor (line 106). For a signed-in visitor it inserts a code bound to the visitor's wallet with whatever code_challenge and state the URL carried and answers 303 to the game with world_code in the query (line 114). A third party therefore holds the matching verifier for a code issued for the victim. PKCE stops injection into the real game (its own verifier does not match), so the remaining barrier is the secrecy of that query string on the game's page, which is not in this package and was not tested. In addition, the token UPDATE at lines 123-125 puts code_challenge=?4 in its WHERE clause, so an exchange with a wrong verifier leaves consumed_at NULL and the code stays redeemable for the rest of its 60 s; RFC 6749 section 4.1.2 expects a code to be single-attempt. If the code leaks within that window (page logs, history-synced analytics, Referer to a subresource), the third party redeems it at the public token endpoint (no client secret, server-side call) and reads the victim's GOLD balance, cosmetics, orders and wallet hint for up to 60 minutes. Read-only impact, hence low. Reported by the permissions specialist; reproduced against the code and the cookie attribute. Fix: issue a code only when Sec-Fetch-Site is same-origin or none; otherwise 303 to /squeek-connect/?next=... so the user's Continue click (a same-origin navigation) is the consent. Also consume the code (set consumed_at, or delete the row) on any failed exchange.","line":114,"path":"Submission28/source/server/squeek-store.ts","reproduction":"State: user signed in to imdember.com as wallet W. On https://evil.example the user clicks a link to https://imdember.com/api/squeek/authorize?client_id=squeek-ascension&redirect_uri=https%3A%2F%2Fsqueekascension.celestao.com%2F&response_type=code&code_challenge_method=S256&code_challenge=<S256(V) for an attacker verifier V>&state=<attacker 22+ chars>&lang=en. Expected: no code without a same-origin confirmation by the user. Actual: an immediate 303 to https://squeekascension.celestao.com/?world_code=<code for W>&world_state=<attacker state>. In the test harness: call authorize with cookie TOKEN and headers sec-fetch-site:cross-site, sec-fetch-mode:navigate; status is 303 with world_code set. Then POST /api/squeek/token with that code and a wrong code_verifier: 401; POST again with V: 200 and a Bearer grant for W (the first failure did not consume the code). Expected per RFC 6749: the second exchange fails too.","severity":"low","snippet":" const target=new URL(cfg.redirect);target.searchParams.set('world_code',code);target.searchParams.set('world_state',q.state);return reply(303,null,{Location:target.href});","title":"/api/squeek/authorize mints an authorization code on any cross-site top-level navigation, with attacker-chosen PKCE and state and no user action; a failed exchange does not consume the code"},{"citation":"resolved","description":"After the one-time squeek_release_actions insert the 11 cosmetics are ordinary enabled 'service' rows in world_gold_products, and with SQUEEK_SALES_ENABLED=1 worker/app.ts line 161 registers createSqueekDelivery(), so world-gold-api.ts serviceable(product) includes every squeek-* product. goldQuote (world-gold.ts lines 58-99) only requires the product's latest version to be enabled, serviceReady and the 'service' control; it never calls salesOpen() (squeek-store.ts lines 91-96, which requires all 11 enabled at 200 GOLD on their latest version) and never runs the squeekEntitlements ALREADY_OWNED pre-check. WorldGoldPanel.tsx line 141 renders every enabled service product with a 'Create order' button, so the cosmetics appear there, including ones the wallet already owns, and the panel's orders get request ids without the 'sq_' prefix, so receipt.requestKey is null and the game's /order?requestKey lookup can never find them (they do appear in /orders and in entitlements). The GOLD debit's SQUEEK_ONE_TIME_SQL still refuses a second charge for an owned cosmetic (verified: PAY-02 test), so no double charge follows; the EOA requirement also holds on this path (world-gold-api.ts line 57). What breaks is the gate: when an administrator disables or reprices one cosmetic, the squeek checkout and catalog report SALES_CLOSED / salesEnabled:false for all 11 while the GOLD panel keeps selling the other 10 (or the repriced one at its new price), and an already-owned cosmetic reaches a pending order and fails only at the signature step with ALREADY_OWNED, a code goldPurchaseError has no text for. Reported by the economics specialist; reproduced against goldQuote, serviceable() and the panel filter. Fix: refuse squeek-* products in goldQuote unless the request id starts with 'sq_' (so only the squeek checkout, which runs salesOpen and the ownership pre-check, can create them), or add the salesOpen condition as SQL in the quote batch; and filter squeek-* products out of the panel's service list or link them to /squeek-checkout/ the way fortune products link to Celestao.","line":69,"path":"Submission28/source/server/world-gold.ts","reproduction":"State: migration 0025 applied, release action inserted, SQUEEK_SALES_ENABLED=1, 'service' control enabled. (1) Admin disables squeek-sky-meteors-v1 (or publishes version 2 at 300 GOLD). Expected: no cosmetic can be bought, as POST /api/squeek/checkout/quote now answers 503 SALES_CLOSED and GET /api/squeek/catalog says salesEnabled:false. Actual: POST /api/gold/quote with {expectedAddress:W,requestId:'abcdefghijklmnop1234',product:'squeek-sky-sakura-v1'} (same-origin, cookie session) returns an orderId at 200 GOLD; the economy challenge and pay routes accept it because serviceProducts includes the product and SQUEEK_ONE_TIME_SQL passes for an unowned cosmetic; the squeek provider delivers it and /snapshot lists it as owned, while /order?requestKey=... returns 404 for it and the receipt in /orders has requestKey null. (2) Wallet W owns aura.frostmoon_sword.v1. In the GOLD panel the product still shows 'Create order'; the generic quote creates a pending order, and only createEconomyGoldChallenge refuses with ALREADY_OWNED (409), which the panel renders as the bare code.","severity":"low","snippet":" const p=(await goldProducts(db)).find(p=>p.product===args.product);if(!p?.enabled)fail('PRODUCT_UNAVAILABLE');","title":"Squeek cosmetics are also quoted and sold through the generic /api/gold/quote and the GOLD panel, which skip the squeek sales gate, the ownership pre-check and the sq_ request-id contract"}],"hash":"844f5d5a09ce27d64307b8156ced366bda0b0277b2143e0b120c9d470d37bb7e","nodeId":"e10c1111-dd9a-4fc5-9658-47629d047a95","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds five merged findings, all Low, each verified against the Submission28 tree with its snippet checked line for line. No file in the submission was changed.\n\n**What I kept, after reproducing it**\n\n- **Dead purchase link from the grant's quote route.** The checkoutUrl at `squeek-store.ts:216` carries only the sku. The page's own parser requires a `request` key, so it shows \"Invalid purchase link\". Confirmed by running the parser logic in node.\n- **Rate limits keyed by caller-chosen values.** The token route keys on the hash of the submitted code and the Bearer routes on the hash of the submitted token, and the Worker limiter adds no IP. The grant lookup also runs before the limiter. Merged from the permissions and flow specialists.\n- **Unkeyed pseudonym plus wallet hint.** The subject id is plain SHA-256 of a public address under a fixed prefix. I recomputed both specialists' example values in node and they match. Merged from three specialists.\n- **Authorize mints a code on a cross-site navigation.** The session cookie is SameSite=Lax and the route checks neither Sec-Fetch-Site nor a user action when signed in. I added one detail the specialist missed: a failed exchange does not consume the code, because the PKCE match is in the UPDATE's WHERE clause.\n- **Generic GOLD quote and panel sell cosmetics outside the squeek gate.** The shared quote never calls the store's sales check or ownership pre-check, and panel orders lack the `sq_` prefix. I dropped the specialist's EOA claim, since the generic route does enforce EOA.\n\n**What I checked and found clean**\n\n- Part A: the failed-run rename in vault.prepare against prepare, delivery, refund, lapse, retire, prune_failed and the operator tool; the 409/422 mapping in start(); the grant form limits.\n- Part C money: SQUEEK_ONE_TIME_SQL passes other products and other wallets, blocks a second charge inside the debit, and releases after a refund. Request-key length fits the shared request-id regex.\n- Part C identity: fixed redirect, PKCE, session-bound grants, cross-wallet reads, CORS, and the read-only grant's inability to reach state-changing routes.\n- Part B: only CSS is in the package. The link generator is redacted, so the data-carrying question could not be checked against code.\n\n**Limits**\n\nThe Node test suite could not be run: the Submission28 tree is partial, the root tree predates the store, and no node_modules are present. Reproductions were done by code tracing and small standalone node computations. No findings are Solidity, so none carries a Foundry proof.","treeHash":null,"usage":{"cachedInputTokens":1838411,"cost":{"source":"runtime","usdMicros":5435277},"inputTokens":450,"model":"claude-fable-5-1","outputTokens":32943,"runtime":"claude","turns":42,"wallClockMs":542494}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a800aae85243bc87","findings":[{"citation":"resolved","description":"POST /api/squeek/token is a public endpoint: no client secret and no session, and it is CORS-open to the game. Its only budget is keyed on sha256(code), a value the caller picks. Every request with a new random 43-character code gets a fresh limiter bucket, so the limit never fires. Each such request then runs the two-statement D1 batch at lines 122-131 (an UPDATE on squeek_auth_codes plus an INSERT...SELECT). The Bearer routes have the same flaw (lines 199-200): readSqueekGrant(db,tokenValue) runs a JOIN query before the limiter is consulted, and the limiter key is sha256(token), also caller-chosen. The pattern was copied from celestao-auth.ts:64 ('celestao:token:'+sha256(code)), but that endpoint first requires the backend bridge secret (celestao-auth.ts:29), so only the Celestao server can reach it. The Squeek copy has no such gate. The other Squeek routes (authorize, catalog, checkout) are keyed on deps.client, the client IP.","line":120,"path":"Submission28/source/server/squeek-store.ts","reproduction":"From one IP, send 10,000 requests: POST https://imdember.com/api/squeek/token, Origin: https://squeekascension.celestao.com, content-type: application/json, body {\"grant_type\":\"authorization_code\",\"client_id\":\"squeek-ascension\",\"redirect_uri\":\"https://squeekascension.celestao.com/\",\"code\":<new random [A-Za-z0-9_-]{43} each time>,\"code_verifier\":\"vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv\"}. Expected: 429 RATE_LIMITED once a per-client budget is used up. Actual: every request reaches db.batch and returns 401 INVALID_GRANT, because allow() sees a new key each time. The same happens with GET /api/squeek/snapshot and 'Authorization: Bearer <new random 43 chars>' on each request: each one runs the readSqueekGrant JOIN and returns 401, never 429. Fix: add a per-client budget ('squeek:token:'+deps.client, 'squeek:api:'+deps.client) checked before any D1 access. Keep the per-code and per-token keys as a second budget if wanted, and move the Bearer-route limiter above readSqueekGrant.","severity":"low","snippet":" if(!await deps.allow!('squeek:token:'+hash.slice(0,32)))return reply(429,{error:'RATE_LIMITED'},{...headers,'Retry-After':'60'});","title":"Squeek token and Bearer routes are rate-limited by a key the caller chooses, so an unauthenticated client gets unlimited D1 work"},{"citation":"resolved","description":"The request (Part C item 1) says the grant 'shows a pseudonym ... and a shortened wallet, not the full address', and the token test asserts that the response does not contain the address (tests/squeek-store.test.mjs:52). But the pseudonym is a deterministic hash of a public value under a fixed, published prefix, with no server key. Anyone can compute sq_ ids for a list of World wallets (stakers, payers and signers are visible on-chain) and match them against a subject id the game shows or stores. The walletHint returned next to it in /snapshot (first 6 and last 4 hex digits, line 48) narrows the candidate list further. So the subject id gives no protection against linking: whoever sees it, such as the game, its logs or a public leaderboard, can recover the full address. That is the separation the design says it provides.","line":47,"path":"Submission28/source/server/squeek-store.ts","reproduction":"node -e \"const c=require('crypto');console.log('sq_'+c.createHash('sha256').update('squeek-subject-v1:0x1111111111111111111111111111111111111111').digest('hex').slice(0,40))\" prints sq_e2083d592e16e353e42caff5af6947ede8103b46, which equals squeekSubject('0x1111111111111111111111111111111111111111') and the subject_id the token endpoint returns for that wallet. Given a subject id from the game and a list of candidate addresses, one hash per candidate recovers the wallet. Expected: an id that cannot be computed without World's server key. Actual: anyone can compute it offline. Fix: derive the subject with HMAC-SHA256 under a Worker secret (for example 'sq_'+HMAC(SQUEEK_SUBJECT_KEY,'squeek-subject-v1:'+address)[0:40]). The game only needs a stable opaque id, so behaviour is unchanged. Alternatively, drop the 'not the full address' claim.","severity":"low","snippet":"export async function squeekSubject(address:string){return 'sq_'+(await sha256('squeek-subject-v1:'+address.toLowerCase())).slice(0,40);}","title":"The game's subject id is an unkeyed SHA-256 of the public wallet address, so anyone can link it to the full address"},{"citation":"resolved","description":"The World session cookie is SameSite=Lax (server/auth.ts:603), so a top-level GET navigation from any site carries it. authorize() checks neither Sec-Fetch-Site nor a user action. The consent page /squeek-connect/ is shown only when the visitor is signed out (line 106). For a signed-in visitor, any third-party page can mint a code bound to the visitor's wallet. The code_challenge and state are the third party's, so it holds the matching verifier, and the code lands in the query string of the game's page. PKCE stops injection into the real game, because the game's own verifier does not match. But the code's secrecy now depends on the static game page, which is not in this package and was not tested: it must not log the URL, put it in history-synced analytics, or leak it through Referer to its subresources. If it leaks within 60 s, the third party redeems it at the public token endpoint (no client secret; CORS does not stop server-side calls). It then gets a 60-minute read grant to the victim's GOLD balance, cosmetics, order list and wallet hint. The impact is read-only, hence low.","line":114,"path":"Submission28/source/server/squeek-store.ts","reproduction":"Signed in to imdember.com as wallet W, the user clicks a link on https://evil.example to https://imdember.com/api/squeek/authorize?client_id=squeek-ascension&redirect_uri=https%3A%2F%2Fsqueekascension.celestao.com%2F&response_type=code&code_challenge_method=S256&code_challenge=<S256 of attacker verifier V>&state=<attacker 22+ chars>&lang=en. Expected: no code without a same-origin confirmation by the user (for example the Continue button on /squeek-connect/). Actual: an immediate 303 to https://squeekascension.celestao.com/?world_code=<code for W>&world_state=<attacker state>, with no interaction. That code plus V, POSTed to /api/squeek/token within 60 s, returns a Bearer grant for W. Fix: issue a code only when Sec-Fetch-Site is same-origin or none. Otherwise 303 to /squeek-connect/?next=..., so the user's click on Continue (a same-origin navigation) is the consent. Alternatively, make the code issuance a POST from that page.","severity":"low","snippet":" const target=new URL(cfg.redirect);target.searchParams.set('world_code',code);target.searchParams.set('world_state',q.state);return reply(303,null,{Location:target.href});","title":"/api/squeek/authorize silently issues an authorization code on any cross-site top-level navigation, with attacker-chosen PKCE and state"}],"hash":"9dc636ba285e00212400f1d295bbacc54b7f269fc34c7f0e3f986331c6859038","nodeId":"9aef4b83-2281-47df-b9c0-9111f96867c4","outcome":"completed","summary":"I found three Low issues in Submission28 and nothing at Critical, High or Medium. All three are in `Submission28/source/server/squeek-store.ts` and are written to `.imd-findings.json`; the file parses and each quoted line matches the file. This was a reading review only: I ran no tests and changed no files. No finding is in Solidity, so none carries a Foundry proof.\n\n## Findings\n\n1. **Low: rate limits on the token and Bearer routes can be dodged (line 120, and lines 199–200).** `POST /api/squeek/token` is public, but its limit is counted per authorization code, and the caller picks the code. Sending a fresh random 43-character code each time never hits the limit. Every such request still runs a two-statement database batch and returns 401 instead of 429. The Bearer routes have the same flaw, and they also look the grant up in the database before checking the limit. The same keying works in `celestao-auth.ts` only because that endpoint first requires a backend secret. **Fix:** limit by client IP before any database access, and move the Bearer-route limit above `readSqueekGrant`.\n\n2. **Low: the game's pseudonym can be traced back to the wallet (line 47).** The subject id is a plain SHA-256 of `'squeek-subject-v1:'+address`, with no server key. For example, `0x1111…1111` always gives `sq_e2083d592e16e353e42caff5af6947ede8103b46`. Anyone holding a list of candidate wallets can match a subject id to the full address. The shortened wallet shown next to it narrows the list. That undoes the request's claim that the game does not see the full address. **Fix:** compute the id with an HMAC under a Worker secret; the game still gets a stable id.\n\n3. **Low: `/api/squeek/authorize` issues a code with no user action (line 114).** The session cookie is `SameSite=Lax`, so any site can send a signed-in user's browser to `authorize` with its own PKCE challenge and state. The user gets an immediate 303 to the game carrying a code for their wallet. The consent page only appears when the user is signed out. PKCE stops the code being injected into the real game. But keeping the code secret then depends on the game page, which is not in this package. If it leaks within 60 seconds, the third party gets a 60-minute read-only grant. **Fix:** issue codes only when `Sec-Fetch-Site` is `same-origin` or `none`; otherwise send the user to `/squeek-connect/`, so its Continue button is the consent.\n\n## Checked with no defect found\n\n- **Part C money:**\n  - **Two tabs, retries and replays:** two tabs with two request keys, an order made through the generic `/api/gold/quote`, retries, and a replayed payment all end in at most one charge. `SQUEEK_ONE_TIME_SQL` sits inside the debit batch, and the code then throws `ALREADY_OWNED`.\n  - **Other products and wallets:** the one-time rule does not block other products or other wallets. Its placeholder order matches its bind values.\n  - **Ownership and refunds:**\n    - Ownership ends as soon as a delivery fails, because `failed` and `refunded` both count as not owned.\n    - A refund needs a `failed` delivery, and a delivery can never move from `failed` back to `delivered`.\n    - The refund credit is idempotent through its reference.\n- **Part C identity:**\n  - **Grant revocation:** the grant dies with its World session on logout, and a wallet switch revokes the old session.\n  - **Code exchange:** codes are single-use and bound to PKCE and the redirect.\n  - **Read routes:** every read is tied to the grant's own wallet.\n  - **Read-only scope:** the read-only grant reaches no route that changes state except its own logout, and no other route accepts it.\n- **Part C checkout:**\n  - **CSRF:** the quote requires the same Origin and a JSON body, so cross-site CSRF is blocked.\n  - **Request keys and expiry:** a key reused for another cosmetic returns 409, and an expired order cannot be paid.\n  - **Contract wallets:** they are refused both at the quote and at the payment.\n- **Part A:**\n  - **The rename:** I traced it against the op","treeHash":null,"usage":{"cachedInputTokens":3926052,"cost":{"source":"runtime","usdMicros":2540786},"inputTokens":72,"model":"claude-opus-5-5","outputTokens":28227,"runtime":"claude","turns":41,"wallClockMs":356763}}],"verification":[]}