feat(transfer): migrate KMS signing to challenge-bound WebAuthn ceremony#356
Conversation
KMS v0.20.0+ rejects the legacy raw passkey assertion (replayable, no challenge binding) — the root cause of the transfer 500 (Tier-2 BLS). Migrate to the SDK's KmsSignerAdapter, which carries a per-call, challenge-bound WebAuthn ceremony assertion through to KMS /SignHash (WYSIWYS commitment, replay-safe). Per aastar-sdk@0.26.1 + #354 handoff. Backend: - kms.service: add createSignerAdapter(resolveKey) → KmsSignerAdapter - auth.service: add resolveKmsKey(userId) → { keyId, address } - sdk.providers: wire signer = KmsSignerAdapter(kms, resolveKey); inject KmsService + AuthService instead of the legacy BackendSignerAdapter - remove dead BackendSignerAdapter (sdk.module/index + file) - execute-transfer.dto: add WebAuthnAssertionDto { ChallengeId, Credential }; deprecate passkeyAssertion (optional, transition only) - transfer.service: require webAuthnAssertion; pass it (+ useAirAccountTiering) to both the tiered and BLS-fallback executeTransfer calls Frontend: - transfer page: drop extractLegacyAssertion; send the raw ceremony { ChallengeId, Credential } as webAuthnAssertion (begin→get order) - api.ts: execute payload type → webAuthnAssertion Deps: @aastar/sdk ^0.24.2 → ^0.26.1 (both workspaces; lockfile incl. the backend's nested copy). Identical dep tree, so surgical lockfile bump. Deferred (per #354 §5-6, not needed for basic transfer): commitChallenge device-passkey path (SDK auto-binds commitment on the server-held key path, so strict flip is zero-change here), agent/session key create+refresh, 24h TTL re-mint. Verification: backend+frontend type-check, backend build, lint all green. On-chain Sepolia Tier-2 acceptance to follow (needs a device passkey).
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
The dev-board KMS is in strict mode, which requires the WebAuthn challenge to be the WYSIWYS commitment SHA-256(nonce || payload) — not a bare nonce. The payload (userOpHash) is only known after the SDK builds the UserOp, so a one-shot executeTransfer can't work for the browser device-passkey path (ordering inversion). @aastar/sdk@0.26.2 adds the two-phase API; YAA wires it. Flow (per #354 guide): prepare → SDK builds UserOp, derives tier-aware payload, calls KMS BeginAuthentication, returns publicKeyOptions whose challenge is already the SDK-computed commitment (+ transferId, challengeId) ceremony → browser device passkey signs that commitment verbatim submit → SDK signs the matching digest + submits; KMS accepts under strict YAA never computes commitChallenge, never selects the payload, never builds the UserOp — all owned by the SDK. Backend: - transfer.service: replace executeTransfer/Inner with prepareTransfer + submitPreparedTransfer (useAirAccountTiering: true required); keep PMv4 gas-token resolution in resolvePaymasterToken; address-book recording uses result.to - transfer.controller: /transfer/execute → /transfer/prepare + /transfer/submit - dto: add PrepareTransferDto + SubmitTransferDto; remove execute-transfer.dto Frontend: - transfer page: prepare → startAuthentication(prep.publicKeyOptions) → submit; the frontend no longer calls KMS directly (the SDK does BeginAuthentication in prepare). Drop the now-unused kmsClient import. - api.ts: transferAPI.execute → prepare + submit Deps: @aastar/sdk ^0.26.1 → ^0.26.2 (both workspaces + lockfile root & nested). Verification: backend+frontend type-check, backend build, lint all green. On-chain Sepolia Tier-2 acceptance (strict): /SignHash 200 + success=true — to follow.
clestons
left a comment
There was a problem hiding this comment.
clestons review — #356 [4-round: DeepSeek R1a → Opus R2 (cross-system) → Codex R3 PK → Opus R4]
feat(transfer): migrate KMS signing to challenge-bound WebAuthn ceremony — the YAA app-layer integration of the SDK's two-phase transfer (consumes @aastar/sdk 0.26.x, the prepareTransfer/submitPreparedTransfer + KmsSignerAdapter I verified in aastar-sdk#143). 17 files, +272/-294. The crypto/WYSIWYS lives in the SDK (verified); YAA's job is correct wiring + no legacy bypass.
Wiring — correct ✅
- Transfer is now exclusively two-phase ceremony: the controller exposes only
@Post("prepare")/@Post("submit")— the old one-shotexecuteTransferendpoint is removed,BackendSignerAdapter(raw-passkey, KMS-rejected) is deleted, and there's no functionalpasskeyAssertionpath. Every transfer goes through the challenge-bound ceremony (Codex C1). - userId from
req.user.sub(JWT), not a body field;resolveKmsKey(userId)resolves the key viafindUserById(userId)→ only the authenticated user's ownkmsKeyId/walletAddress(no cross-user) (Codex C2, verified). - SDK contract matched: assertion forwarded as
{ ChallengeId, Credential },useAirAccountTiering: trueset (required — a one-time device-passkey can't do non-tiered BLS dual-sign) (Codex C3). KmsSignerAdapterwired viacreateSignerAdapter(resolveKey); dep@aastar/sdkbumped (lockfile shows a single@aastar/sdk0.26.2 — no duplicate/runtime-mixed SDK).
Whole-system check (this is where I looked beyond the diff)
Codex flagged "any OTHER live legacy signing path?" — I grepped the backend:
kms.service.signHashWithAssertion(LegacyPasskeyAssertion)andauth.service.getUserWallet()→ legacycreateKmsSigner(assertionProvider)still EXIST, but both have zero live callers (orphaned by this migration). So they're dead code, not a live raw-passkey path — no security risk, but please delete them (you removedBackendSignerAdapter; these two legacy KMS-signing methods are the same vintage and should go too, to remove the future-misuse surface).
Minor
- DeepSeek [Medium]: the address-book recording uses
result.to, which may beundefinedfrom the submit response — it's after the on-chain submit, so cosmetic (bookkeeping), not fund-routing. Guard with the originaldto.tofallback. - Frontend transfer path goes through the server (
/transfer/prepare+/submit); thelib/yaaa.tskmsClient(direct-KMS-via-proxy) is for the separate guardian-recovery flow, not transfers.
Verdict
APPROVE. The KMS-signing migration is functionally complete and correct: transfers run exclusively through the SDK's challenge-bound two-phase ceremony, there's no live legacy raw-passkey path, the userId is JWT-scoped, key resolution is user-scoped, and the wiring matches the SDK contract (the crypto being verified in #143). This closes the transfer-500 root cause end-to-end (TA → SDK → YAA). Cleanup before/after merge: delete the now-orphaned signHashWithAssertion / getUserWallet+legacy createKmsSigner (dead code), and add the result.to fallback. Merge is the maintainer's call.
Background — the transfer 500
The old path sent a legacy raw passkey assertion to KMS
/SignHash. KMS v0.20.0+ rejects it (replayable, no challenge binding) → the root cause ofexecuteTransferfailures (esp. Tier-2 BLS). Fix per@aastar/sdk@0.26.1+ the #354 handoff.The SDK migrated signing to a challenge-bound WebAuthn ceremony (
signHashWithWebAuthn, WYSIWYS commitment). YAA now wires the SDK'sKmsSignerAdapterand forwards the ceremony assertion.Changes
Deps:
@aastar/sdk^0.24.2→^0.26.1(both workspaces; lockfile bumped surgically incl. the backend's nested copy — identical dep tree).Backend
kms.service:createSignerAdapter(resolveKey)→new KmsSignerAdapter(kms, resolveKey)auth.service:resolveKmsKey(userId)→{ keyId, address }sdk.providers:signer = KmsSignerAdapter(...); injectKmsService+AuthService(drop legacyBackendSignerAdapter)BackendSignerAdapter(module/index/file)execute-transfer.dto: addWebAuthnAssertionDto { ChallengeId, Credential }; deprecatepasskeyAssertiontransfer.service: requirewebAuthnAssertion; pass it +useAirAccountTieringto tiered & BLS-fallback callsFrontend
transfer/page: dropextractLegacyAssertion; send raw{ ChallengeId, Credential }aswebAuthnAssertion(begin→get order — already correct)api.ts:executepayload type →webAuthnAssertionDeferred (per #354 §5–6, not needed for basic transfer)
commitChallengedevice-passkey path — SDK auto-binds commitment on the server-held key path, so the KMS strict flip is zero-change hereVerification
/SignHash200 + on-chainUserOperationEvent success=true