Skip to content

Commit 97d0cd5

Browse files
Web3NLclaude
andcommitted
docs: rewrite derive-ii-principal CI lessons with correct root cause
The previous doc described a 9-layer CDP virtual authenticator approach that was entirely unnecessary. The actual root cause was icp-cli 0.2.1 deploying II with dummy_auth=opt null (Some(None)) instead of opt opt{...}, causing the DUMMY_AUTH runtime check to evaluate false and activating real WebAuthn. The fix was 4 lines of JS bundle patching to force the DUMMY_AUTH path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a34a46b commit 97d0cd5

1 file changed

Lines changed: 154 additions & 0 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# Lessons Learned: `dapp derive-ii-principal` on Headless Linux CI
2+
3+
## What the command does
4+
5+
`dapp derive-ii-principal <origin>` opens a headless Playwright/Chromium browser,
6+
navigates to a page at the canister origin, triggers an II login popup, clicks
7+
through the II UI, and writes the derived principal to stdout. Called 4× in
8+
`04-deploy.sh` to set controllers on freshly created canisters.
9+
10+
---
11+
12+
## Root cause: icp-cli 0.2.1 config bug (not an II update)
13+
14+
`icp-cli` bundles a dev build of Internet Identity that has **DUMMY_AUTH compiled
15+
in** (`pP=!0` build flag). In DUMMY_AUTH mode, the II auth class is always `ga`
16+
(a pure software Ed25519 key), which never calls any WebAuthn APIs. No platform
17+
authenticator is needed.
18+
19+
However, `icp-cli` 0.2.1 deploys this canister with a buggy config:
20+
21+
```
22+
dummy_auth = opt null ← icp-cli 0.2.1 (WRONG: Some(None))
23+
dummy_auth = opt opt {...} ← correct would be Some(Some({...}))
24+
```
25+
26+
The II bundle checks: `Vr.dummy_auth[0]?.[0] !== void 0`
27+
28+
- With `opt null` (Some(None)): `Vr.dummy_auth[0]` is `None`/`undefined` → check is `false`**real WebAuthn path** (`ps` class, calls `credentials.create()`)
29+
- With `opt opt {...}` (Some(Some({...}))): check is `true`**DUMMY_AUTH path** (`ga` class, pure software)
30+
31+
On headless Linux, `credentials.create()` throws `NotSupportedError`. That's the
32+
entire failure. The bundle has DUMMY_AUTH capability; the config bug disables it.
33+
34+
**This is not an II version change.** The same bug exists in all icp-cli 0.2.x
35+
releases. The CI used a slightly newer II build than local (`start.xYiFCL5p.js`
36+
vs `start.CggNA08c.js`), but both have the same config bug.
37+
38+
---
39+
40+
## The fix
41+
42+
Patch the II JS bundle in the `context.route()` proxy handler to force the
43+
DUMMY_AUTH check to `true`:
44+
45+
```javascript
46+
// In context.route() after decompressing the response body:
47+
const p = url.pathname;
48+
if (p.includes('/_app/immutable/entry/start') && p.endsWith('.js')) {
49+
let src = body.toString('utf8');
50+
// Force DUMMY_AUTH — bypasses all real WebAuthn, uses pure Ed25519 software key
51+
src = src.replaceAll('Vr.dummy_auth[0]?.[0]!==void 0?', 'true?');
52+
// Unique seed per invocation so each call registers a fresh credential;
53+
// avoids "credential already registered" conflicts across the 4 CLI calls
54+
src = src.replace('return BigInt(0)}', 'return BigInt(Math.floor(Math.random()*Number.MAX_SAFE_INTEGER))}');
55+
body = Buffer.from(src, 'utf8');
56+
}
57+
```
58+
59+
With DUMMY_AUTH active, no `credentials.create()` or `credentials.get()` is ever
60+
called. The entire CDP virtual authenticator approach (which dominated earlier
61+
debugging sessions) was unnecessary.
62+
63+
---
64+
65+
## Remaining CI patches (environment workarounds only)
66+
67+
These fix headless Linux environment gaps. They do not change II auth logic.
68+
69+
### 1 — DNS: `*.localhost` doesn't resolve
70+
71+
**Problem**: Chromium's sandboxed network service on Linux doesn't resolve
72+
`*.localhost` subdomains. PocketIC canisters live at `<id>.localhost:8080`.
73+
74+
**Fix**: `context.route()` intercepts all `*.localhost` requests and proxies them
75+
via Node.js `http.request()` to `127.0.0.1`, preserving the `Host` header so
76+
PocketIC routes to the right canister.
77+
78+
**Why not `--host-resolver-rules` alone?** The Chrome flag works for browser
79+
requests but not for Playwright's `route.fetch()`, which runs in Node.js and uses
80+
OS DNS. Since we need to patch the bundle anyway, the Node.js proxy handles both.
81+
82+
### 2 — Gzip: `route.fulfill()` doesn't decompress
83+
84+
**Problem**: PocketIC serves gzip/brotli assets. `route.fulfill()` passes the
85+
raw bytes to Chrome with hop-by-hop headers stripped; Chrome tries to parse
86+
compressed bytes as JS and throws "Invalid or unexpected token".
87+
88+
**Fix**: Decompress in Node.js (`zlib.gunzipSync`, `brotliDecompressSync`) before
89+
calling `route.fulfill()`, and strip all hop-by-hop headers.
90+
91+
### 3 — `isUserVerifyingPlatformAuthenticatorAvailable()` returns false
92+
93+
**Problem**: Newer II builds (icp-cli 0.2.1's `start.xYiFCL5p.js`) gate the
94+
"Create new identity" button on this returning `true`. Headless Linux returns
95+
`false`, leaving the button disabled even after DUMMY_AUTH is forced.
96+
97+
**Fix**: `addInitScript` overrides the API to return `Promise.resolve(true)` before
98+
any page JS runs. Must be synchronous (before II's SvelteKit boot code), which is
99+
why `addInitScript` is used rather than a post-load patch.
100+
101+
### 4 — `credentials.get()` throws `NotSupportedError`, freezes UI
102+
103+
**Problem**: The II `/authorize` page calls `credentials.get({allowCredentials: []})`
104+
on mount for passkey autofill. On headless Linux this throws `NotSupportedError`,
105+
which propagates up and blocks the UI from rendering buttons.
106+
107+
**Fix**: `addInitScript` replaces `navigator.credentials.get` with a function that
108+
rejects with `NotAllowedError`. II treats this as "no passkeys found" and renders
109+
the UI normally.
110+
111+
---
112+
113+
## The dead end: CDP virtual authenticators
114+
115+
Earlier debugging sessions spent significant time building a CDP virtual
116+
authenticator approach: intercept `credentials.create()`, synthesise a valid
117+
WebAuthn attestation object with correct CBOR, DER signatures, authData layout,
118+
etc. (9 layers of patches). **This was entirely unnecessary.**
119+
120+
The clue that should have ended this earlier: `icp-cli` ships a *dev* II build.
121+
Dev builds never need real WebAuthn. If you're patching CBOR decoders and
122+
generating synthetic ECDSA signatures, you're on the wrong path.
123+
124+
**Rule**: Before adding any WebAuthn workaround for local II dev, confirm
125+
`dummy_auth` is active. If DUMMY_AUTH is supposed to be on but isn't, the config
126+
is wrong — fix the config (or patch the check), don't simulate WebAuthn.
127+
128+
---
129+
130+
## 4-call credential conflict
131+
132+
`04-deploy.sh` calls `derive-ii-principal` 4× against the same running II
133+
canister. Without the random seed patch, all 4 calls would derive the same
134+
Ed25519 key (seed 0 → 32 zero bytes credential ID). The first call registers it;
135+
calls 2–4 fail with "credential already registered".
136+
137+
The `vz()` random seed patch ensures each CLI invocation creates a unique
138+
credential, so all 4 calls succeed independently.
139+
140+
For Playwright E2E tests the same issue applies: each test gets a fresh browser
141+
context (no II session) but shares the same II canister. A per-context random
142+
seed (generated once in the `tests/fixtures.ts` fixture) prevents conflicts
143+
between tests while allowing the second II popup within the same test to reuse
144+
the existing session ("Continue" appears directly).
145+
146+
---
147+
148+
## Files changed
149+
150+
| File | Purpose |
151+
|------|---------|
152+
| `packages-rs/my-canister-dapp-cli/playwright-js/derive-principal.mjs` | CLI: all 4 patches above |
153+
| `tests/fixtures.ts` | E2E tests: same 4 patches as a Playwright test fixture |
154+
| `tests/ii-helpers.ts` | Simplified II popup flow (CWP → "Create new identity" directly) |

0 commit comments

Comments
 (0)