# Audit report

> IMD Ember World (https://imdember.com) - code audit of wallet sign-in, sessions and home authorization (World only)
>
> Please read this first: this repository contains NO Solidity and no smart contract. It is a TypeScript Cloudflare Worker (the server) and the TypeScript/React client code for wallet sign-in (SIWE, EIP-4361). The site never asks a wallet for a transaction, a token/NFT approval, a Permit/Permit2 or typed-data signature; the only signature is personal_sign of a server-built SIWE text. The team states there is no direct "loss of funds" path by design (please verify rather than assume it); please rate findings by what an attacker could do to a player through this code: sign in as an address without its key, keep or revive a session after revocation, end another address's sessions, get owner rights for seats or a house that are not theirs, make the page ask the wallet to sign something other than the site's own sign-in text, or leak data. Denial of sign-in is in scope as availability. If your checklist is Solidity-only, say which parts you could not apply rather than forcing them.
>
> Repository: the public review snapshot pinned at the commit shown when "Read" was clicked (expected b6e986be6d1c85a720a3b8231feb3adf1ab2b780; its parent c2a8c33 is an earlier reviewed version). Code is in source/. Docs at the root are in Traditional Chinese and are the team's claims; the code is the reference. README.md maps each finding of an earlier review (F-1..F-8) to what changed; those fix statuses have not been re-reviewed.
>
> Facts you cannot check without network (stated by the team, from the deploy record and one read-only fetch on 2026-09-29):
> - Live at https://imdember.com: Cloudflare Worker "imd-world", version 50c688c9-1bcf-4b68-a0ab-b7a9dc6ec82f, built from private commit 2da46cdafcf8ad3fb3571ea0273ecc5d1ab5be1d.
> - Rebuilding the Worker from source/ alone (wrangler deploy --dry-run) gives SHA-256 14584fe4df57e7505fc38e57a3b8b99590d948051cbc3a52b3d5a9ea969ff5e4, equal to the deploy record (manifests/deploy-record-SHA256SUMS.txt).
> - Live response headers match source/public/_headers (CSP script-src 'self', frame-ancestors 'none', HSTS) and server/world-api.ts API_HEADERS (no CORS headers).
> - D1 migrations 0001-0003 applied; rate-limit bindings as in source/wrangler.jsonc; a Cloudflare edge rule blocks an IP sending over 20 /api/ requests in 10 s. These are team-side and unverified.
>
> Entry points (source/worker/app.ts routes /api/* to source/server/auth.ts handleAccountApi, then server/world-api.ts, else static assets):
> - POST /api/auth/challenge {address}: builds and stores the SIWE message (server/auth.ts challenge, INSERT_CHALLENGE budgets), sets __Host-imd_flow.
> - POST /api/auth/verify {nonce, signature}: server/auth.ts verify and verifySignature (ECDSA via viem recoverMessageAddress; else ERC-1271 isValidSignature on mainnet with claims and budgets), atomic consume, session insert.
> - POST /api/auth/logout and POST /api/auth/logout-all: revoke this session / every live session of the session's address.
> - GET /api/auth/session; GET /api/me/home (owner data, wallet taken from the session only; server/ownership.ts ownerOf via Multicall3); GET /api/wallet/:address/assets (public, unverified roster data).
> - GET /api/world/* (read-only public data proxy, no session).
> - Cron: server/presence.ts (presence rows and cleanup).
> - Client: source/src/world/auth.ts (AuthClient: connect, sign-in, logout, account switch, tabs), siwe.ts (checkSignInMessage before personal_sign), wallet.ts (EIP-6963), homeEntry.ts (who may "Enter your home"), moves.ts (local-only move gate).
>
> Please look hardest at:
> 1. Signature verification: server/auth.ts verify and verifySignature. The message is re-read from D1, never from the client; domain, URI, chain id 1, version, statement (current or SIWE_PREVIOUS_STATEMENTS), Issued At and Expiration Time must equal the stored row; ERC-6492 refused; ERC-1271 needs code and exactly the 32-byte magic word; one ERC-1271 check per challenge (CLAIM_ERC1271); a failed check burns the challenge, except 503 LIMITER_UNAVAILABLE / AUTH_UNAVAILABLE (missing binding or D1 error) and a lost claim race (409), see SIWE.md section 3 step 5. Can a signature by anyone else, a replay, a race, a relayed message or a crafted contract answer produce a session for an address it should not?
> 2. Session issuance and revocation: the batch UPDATE login_challenges + INSERT sessions (one session per nonce; sessions.nonce UNIQUE), the token (32 random bytes, only its SHA-256 stored), __Host- cookies (HttpOnly, Secure, SameSite Lax/Strict), the 7-day absolute expiry, readSession, logout and logout-all (REVOKE_ALL_SESSIONS; only a live session can ask), Origin allow-list and JSON-only POSTs (CSRF). Can a revoked or expired session still pass readSession? Can logout-all be triggered for another address?
> 3. Limiters failing closed: worker/app.ts limiter (a missing binding throws LimiterMissing, 503 off loopback) and server/auth.ts permit ('auth', 'verify', 'home', 'chain', 'code' refuse when the binding throws; only 'api' and 'seat' fail open); the D1 budgets in INSERT_CHALLENGE, CLAIM_ERC1271, CLAIM_CONTRACT, BURN_UNCLAIMED (single-statement atomic counts). Is any path left unthrottled, or does any refusal leave a challenge usable?
> 4. Ownership and owner rights: /api/me/home uses only the session address; ownerOf is the only proof (roster and NFT index are candidates); failures are 503, not "owns nothing"; the client grants owner mode only from that answer (auth.ts statusOf / ownerAddress) and Enter only for the session's own house (homeEntry.ts enterGate, enterableHome). Can a client-supplied address, header, stale house read or another tab's cookie change whose seats or house are used?
> 5. The page-side check (siwe.ts checkSignInMessage): does anything other than this site's exact 11-line message for this account and nonce reach personal_sign? (Known limit, stated: it does not stop script injected into this origin or a phishing page.)
>
> Tests: source/tests (node --test; see TESTS/README.md: copy source/ into its own git repo, npm ci, add the two stubs from TESTS/stubs). They use the real handler, migrations on node:sqlite and synthetic keys; only npm ci needs network. If your environment has no network you cannot install, run the tests or rebuild the Worker; the recorded outputs are in TESTS/ (npm-test-output*.txt, worker-dry-run-output.txt, siwe-sample/, probes/). The snapshot run: 141 tests, 138 pass, 3 fail, all three needing withheld house geometry or interior code.
>
> Out of scope: Genesis Mint (no Mint code exists here; a future Mint page is planned on the same origin and will be reviewed separately), withheld files (3D world, art, music, house placement, interior rendering, WorldApp.tsx; listed by hash in manifests/).
>
> Please report each finding with severity, file:line, the attacker's preconditions and impact on a player, and a reproduction or a clear argument; list what you could not check. This is a code review record, not a certification: please do not call the site safe, secure, audited or certified.

| | |
|---|---|
| Repository | https://github.com/tungweb3/imd-ember-world-review.git |
| Commit | `b6e986be6d1c85a720a3b8231feb3adf1ab2b780` |
| Job | `519db624-a82f-4dfe-91b9-1a519d1d3dd1` |
| Judged | 2026-09-29 15:24 UTC |
| Findings | 1 medium · 7 low |

Four agents audited the code as it is at `b6e986b`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Medium: Two unauthenticated checks can repeatedly lock a chosen smart wallet out of sign-in

`source/server/auth.ts:124`

```
export const CLAIM_CONTRACT=`UPDATE login_challenges SET called_at=?1 WHERE nonce=?2 AND called_at IS NULL
 AND (SELECT count(*) FROM (SELECT 1 FROM login_challenges WHERE net=?3 AND called_at>?4 LIMIT ?5))<?5
 AND (SELECT count(*) FROM (SELECT 1 FROM login_challenges WHERE address=?6 AND called_at>?4 LIMIT ?7))<?7`;
```

CLAIM_CONTRACT charges the address-wide allowance of two checks per minute before ERC-1271 validates the signature. Any caller can request challenges for the public victim address and use its own flow cookies to consume both slots with invalid signatures. A valid signature from the owner on a different network is then refused before its eth_call and its challenge is burnt. An attacker timed ahead of the owner can renew this targeted denial with two challenges and two verifies per minute, within all configured local limits. Existing sessions and ECDSA sign-in remain unaffected. This is the acknowledged F-3 residual, merged from the economics, flow and permissions reports, but it is reproducible denial of sign-in for a player who needs ERC-1271. Remove the unauthenticated cross-network veto for a chosen address, for example by scoping this allowance to (address, network), while retaining network/location cost ceilings and assessing the resulting shared-capacity tradeoff.

**Reproduction**

Executed against createWorker using the repository wallet-harness, actual viem and actual migrations on node:sqlite. Configure AUTH_LIMITER=20, CHAIN_LIMITER=20 and API_LIMITER=180 with windowLimiter. Deploy a fixture at C=0x5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a that has code and returns the ABI-encoded magic only for hashes/signatures genuinely signed by a synthetic owner; other inputs return 0xffffffff. A quiet owner challenge/verify from 198.51.100.20 returns 200. Advance 60,001 ms. From 203.0.113.9 request two separate challenges for C, preserve their individual flow cookies, and verify each with signature 0x12: both return 401 SIGNATURE_INVALID and set called_at. Within that minute a fresh challenge from 198.51.100.20 with its valid owner signature returns 429 CHAIN_BUSY and invalidated_at is set. Repeated for ten allowance windows: every pair returned 401/401 and every owner attempt 429. After a quiet window the owner returned 200. Expected isolation: strangers on another network should not spend this player-specific allowance. The attached specialist JavaScript test also ran, but its always-accepting wallet fixture was replaced for this validation; it is not a Solidity proof.

### 2. Low: A refused index refresh deletes the cached candidates needed for the ownership fallback

`source/server/ownership.ts:138`

```
    const entry:Entry<T>={at:now};
    entry.inflight=load().then(v=>{entry.value=v;return v;},e=>{if(this.map.get(key)===entry)this.map.delete(key);throw e;}).finally(()=>{entry.inflight=undefined;});
    this.map.set(key,entry);while(this.map.size>CACHE_LIMIT)this.map.delete(this.map.keys().next().value!);
```

Cache.get replaces an expired entry with {at:now} and deletes that replacement when loading fails. Consequently proof() catches Limited at line 172 only after the last successful candidate list has disappeared; peek(address) cannot implement the documented roster-plus-stored-index fallback. Preconditions: a signed-in player owns a qualifying seat that the NFT index previously discovered but the IMD roster does not yet list for that owner, and a subsequent due index read is refused. The response becomes HTTP 200 with eligible=0, size=null and recheck=limited, removing owner mode and the Enter offer despite unchanged ownership. Shared chain:index capacity can be consumed by other signed-in addresses. Keep the last successful candidate value through a refused reload and re-prove those candidates with ownerOf; do not reuse an old ownership proof as the fix.

**Reproduction**

Executed both supplied cache tests (the .t.sol attachment is JavaScript) against the unchanged Worker, actual viem, real migrations on in-memory node:sqlite and the repository fake RPC/roster. For a synthetic EOA A, set fakeChain owners[361]=A, fakeImd seats[361]=51320 and online=[361], but leave roster owners empty. Sign in A; GET /api/me/home?fresh=1 with CHAIN_LIMITER allowing returns seats=[361], eligible=1. Advance 31,000 ms and refuse chain:index; repeat. Expected: cached index candidate 361 remains and ownerOf still proves eligible=1 with recheck=limited. Actual: seats=[], eligible=0, size=null, recheck=limited. Independently repeated with ordinary /api/me/home after 300,001 ms with the same result. No file or production service was modified.

### 3. Low: An older home response body can restore owner mode after a newer check removed it

`source/src/world/auth.ts:161`

```
      if(r.ok){const home=await r.json() as MeHome;if(g!==this.gen)return;
        if(this.s.session&&home.address.toLowerCase()!==this.s.session.address){await this.restore();return;}  // another tab switched the cookie
        this.homeOkAt=this.now();this.set({home,checking:false});return;}
```

refreshHome checks both gen and homeGen when response headers arrive, but only gen after awaiting the JSON body. Overlapping home requests for the same session share gen. A pre-transfer result whose body completes last can therefore overwrite a newer authoritative eligible=0 result and refresh homeOkAt. Preconditions: a still-live session, an earlier qualifying home response delayed in transit, and a newer overlapping check after the seat is sold or no longer qualifies. This restores the former holder's local owner mode, Enter gate and local move controls until a later check, despite the newer server decision. It does not issue a session, modify another player's server state or transfer assets. Recheck both generations after successful/error body parsing and immediately before committing state.

**Reproduction**

Executed unchanged AuthClient, enterGate and createWorker with real SQLite migrations and viem. Sign in synthetic EOA A, with fixture seat 1 owned by A, registered and online; restore the client and confirm owner mode. Start R1=refreshHome(true,true). Obtain its genuine successful response from the Worker, retain the exact bytes and return its headers in a native Response with an open ReadableStream so r.json() waits. Set the chain fixture ownerOf(1)=B and advance 31,000 ms. Complete R2=refreshHome(true,true) normally: eligible=0, status=signedInNoHouse, enterGate(A's house)=sign-in. Then enqueue and close R1's unchanged body. Actual: eligible=1, status=owner and enterGate=ok; a direct fresh server GET still returns eligible=0. Expected: R1 is discarded as an obsolete homeGen. Only the provided geometry import stubs were loaded; no withheld rendering code was exercised. Merges the math and permissions reports.

### 4. Low: Candidate truncation can discard the only qualifying seat and silently remove the house

`source/server/ownership.ts:174`

```
      const ids=[...candidates].sort(compareIds).slice(0,CANDIDATE_CAP),indexedAt=indexed?.at??0;
```

proof() sorts every roster/index candidate by token ID and keeps only the first 256 before checking ownership or eligibility. Inactive/unregistered seats occupy the same slots as eligible seats, and truncation is not represented as an incomplete result. Preconditions: a player holds 255 lower-ID ineligible seats and a higher-ID qualifying seat; an unsolicited transfer of one additional lower-ID seat makes 257 candidates. The qualifying seat is never checked, so the player loses owner mode and the Enter offer even though its ownership and activity did not change. This is a conditional availability issue, requiring a large holder and an attacker able to transfer a lower-ID seat; it is not an ownership forgery. Check all eligibility-relevant candidates in bounded batches or return an explicit incomplete/unavailable result rather than a definitive zero when the cap is reached.

**Reproduction**

Executed two controls through the unchanged Worker/Ownership/liveWorld code, actual viem Multicall encoding/decoding and real SQLite migrations. A synthetic EOA A owns token IDs 0..254 plus 1000. The correct roster and NFT index list those 256 seats; only seat 1000 is a registered agent and online. Use complete index pages of 100/100/56 entries. Sign in A and GET /api/me/home: 256 ownerOf checks include 1000, eligible=1, size=s. In a fresh otherwise identical Worker, additionally assign unregistered seat 255 to A in both correct upstream lists (pages 100/100/57). Actual: ownerOf checks exactly IDs 0..255, never 1000; eligible=0, size=null and recheck is absent. Expected: the unchanged qualifying seat still counts, or discovery is explicitly reported incomplete. This models the refreshed state after another holder transfers seat 255 to A; it does not require a malformed upstream answer.

### 5. Low: Slow verify bodies backdate contract checks and bypass the rolling D1 budgets

`source/server/auth.ts:431`

```
  const now=(deps.now??Date.now)(),db=deps.db;
```

accountRoute captures now before awaiting the rate limiter and request body. verify uses that stale timestamp for challenge expiry and the checked_at/called_at claims at lines 344 and 350. A client can start incomplete JSON uploads in separate windows, then finish them together, oldest first. Actual RPC checks occur in one current window but D1 records them in different old windows, bypassing the three-per-minute network and two-per-minute address shares. Expired-in-real-time challenges can also spend RPC capacity. The actual-time per-location limiter still caps calls, and the fresh-clock atomic consume prevents expired challenges from issuing sessions. Impact is increased ability for one network to consume smart-wallet verification capacity and deny others sign-in, not identity forgery. Refresh the clock after the body is read and when claiming budgets; consider a body-read deadline.

**Reproduction**

Executed native streamed Requests against unchanged createWorker, actual viem, real SQLite migrations and repository RPC/limiter fixtures. At T request three challenges from 203.0.113.9, two for deployed fixture contract A=0x2222222222222222222222222222222222222222 and one for B=0x3333333333333333333333333333333333333333; both reject signatures. With each matching flow cookie, immediately start verify with Origin https://imdember.com and JSON {nonce,signature:"0x00"}, withholding its closing brace. At T+60,000 repeat. At T+70,000 close all six bodies in start order. All six return 401 and make eth_call during this same completion window; called_at contains three T timestamps and three T+60,000 timestamps. Expected: only three current-minute contract checks from the network. A seven-group variant completed at T+370,000 records 21 old claims, makes 21 code reads and 20 eth_calls, with 20 invalid-signature responses and one 429; another network then receives 429 CHAIN_BUSY before eth_call. No sessions are issued. These are handler-level streaming reproductions; production Cloudflare slow-upload buffering/timeouts were not exercised.

### 6. Low: Unverified challenges let a network neighbour spend a player's sign-in allowance

`source/server/auth.ts:106`

```
 SELECT ?1,?2,?3,?4,?5,?6,?7,?8 WHERE (SELECT count(*) FROM (SELECT 1 FROM login_challenges WHERE net=?8 AND issued_at>?9 LIMIT ?10))<?10
 AND (SELECT count(*) FROM (SELECT 1 FROM login_challenges WHERE address=?2 AND issued_at>?9 AND net=?8 LIMIT ?13))<?13
```

The per-wallet cooldown counts all challenges naming (address, /24 or /48), regardless of which flow or person requested them. A neighbour in the same network can consume a chosen wallet's five slots without a signature. The same shared network counter lets two IPs consume all 30 challenge slots and deny sign-in to every other player in that prefix. Preconditions: attacker shares the victim's IPv4 /24 or IPv6 /48 (for example a campus/carrier network); targeted variant also needs the public victim address. Repeating within each rolling window maintains denial, while existing sessions remain valid. This is the documented F-5 network-sharing residual, merged from economics and flow, with localized availability impact. Narrow the unauthenticated wallet allowance to a flow or exact client key and consider fair admission for unused clients under the global cost ceiling; retain an explicit bound against network-wide abuse.

**Reproduction**

Executed real createWorker and migrations with AUTH_LIMITER=windowLimiter(20). From 203.0.113.5, request five separate challenges for victim V within a minute: all 200. V's first request from 203.0.113.77 then returns 429 SIGN_IN_BUSY with Retry-After 60 (wallet cooldown), despite V having made no previous request. Separately send 15 unique-address challenges from each of 192.0.2.5 and 192.0.2.6: all 30 succeed. The first request from 192.0.2.77 then returns 429 SIGN_IN_BUSY (network share). Repeated both cases across three windows, advancing 60,001 ms each time; both victims were refused every time. Expected player isolation: a neighbour's unsigned challenges should not exhaust a specific player's personal allowance. All attack request counts stay below the configured per-IP limits and can be spread across the minute to meet the stated edge rule.

### 7. Low: Twenty networks can continuously occupy the global challenge ceiling and deny all new sign-ins

`source/server/auth.ts:108`

```
 AND (SELECT count(*) FROM (SELECT 1 FROM login_challenges WHERE issued_at>?11 LIMIT ?12))<?12`;
```

The global 60-challenges-per-6-seconds valve admits on a first-come basis after the network checks. Twenty /24 networks spending their full 30/min shares can keep all global slots occupied, refusing even a new wallet from an otherwise unused network. With AUTH_LIMITER=20/min per IP this requires at least two IPs in each of those networks for the demonstrated schedule (40 IPs total), not the single-IP-per-network cost claimed by the specialist. No signing key or chain transaction is needed. Preconditions: that distributed client capacity and timely slot occupancy; impact is denial of new sign-ins worldwide while existing sessions continue. This is an explicit design residual, not a limiter bypass, so rated low. Consider admission fairness/reserved capacity for previously unused client networks under the global cost bound; merely increasing the cap raises cost without removing the shared-capacity mechanism.

**Reproduction**

Executed 1,800 accepted challenge requests over 180 simulated seconds against unchanged createWorker and actual D1 migration SQL, using AUTH_LIMITER=windowLimiter(20). For i=0..1799, at T+i*100 ms POST a fresh address from IP 203.0.(i%20).(1+(floor(i/20)%2)). This sends 10 requests/second globally, 30/min per /24, and 15/min per IP. After the first 60 writes, every five seconds at T+i*100+1 ms a victim on unused network 198.51.100.0/24 requests its own challenge. All 35 victim probes return 429 SIGN_IN_BUSY; every attacker request succeeds. Expected availability under cross-network abuse: unused players retain a route to start authentication. Actual: the address/network checks allow the victim but the global count rejects it. The schedule also stays below the stated edge rule per IP, although live Cloudflare bindings/WAF were not exercised. This corrects the original specialist's inconsistent request-rate arithmetic.

### 8. Low: A refused ownership discovery is displayed as a successful on-chain no-seat result

`source/src/world/WalletPanel.tsx:132`

```
      rows.length===0?<p className="empty-state">{me?text('鏈上核實：這個錢包目前沒有 IMD 席位。','Checked on chain: this wallet holds no IMD seat right now.'):text('IMD 公開名冊目前沒有列出此錢包的席位。','IMD’s public roster lists no seat for this wallet.')}</p>:
```

When the shared chain:index budget refuses a first discovery and the roster has no seats for the player, /api/me/home returns seats=[], eligible=0, block=null, recheck=limited without performing a chain read. statusOf treats this as signedInNoHouse, and this branch labels it as verified on-chain absence. The limited notice at line 115 is restricted to owner mode and is therefore hidden for this case. Preconditions: roster lag for a genuine seat owner and exhausted index capacity; twenty throwaway EOA sessions can consume that per-location allowance without owning seats. The player is incorrectly told that ownership was disproved and loses owner controls while discovery is unavailable. Handle limited/incomplete discovery explicitly in the non-owner state and render an unavailable/retry message rather than a definitive no-seat claim. This is independent of the cached-candidate loss: it reproduces on the player's very first discovery.

**Reproduction**

Executed unchanged Worker and AuthClient with actual viem/SQLite. Configure seat 361 as registered/online and on-chain-owned by synthetic EOA V, while the IMD owners roster is empty. Enable AUTH_LIMITER=20, CHAIN_LIMITER=20 and API_LIMITER=180 window fixtures. From one attacker IP, sign in twenty different synthetic EOAs and read each home once, spacing iterations by 2,500 ms; all requests succeed and spend twenty chain:index units. V signs in from another network and requests /api/me/home?fresh=1 in the same minute. Actual: HTTP 200, seats=[], eligible=0, block=null, recheck=limited, and zero additional chain requests. Restoring the real AuthClient yields signedInNoHouse; real seatRows returns []. Executing the exact me?text(...) expression extracted from WalletPanel.tsx:132 with this state returns "Checked on chain: this wallet holds no IMD seat right now." The owner-only limited notice is unreachable. Expected: display that the check could not be completed. This validates the actual render expression and state path, not a full browser render.

---

Judge's submission `4fa6600ba6317754eb28384a16c77912e8565cb6e806ae73f2a5a790aba4aa8d`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
