Repository navigation
Move signing to author-present Turnkey sessions and delegated agent keys #286
Description
Follows the decision on #244. Blocks PR #277 (#237).
Problem
PR #277 signs action records with each user's Turnkey key through boop's parent-org API key (TURNKEY_API_PRIVATE_KEY, convex/authInternal.ts), so boop's server can sign as any user without them present. Login is also completed server-side by the parent org today (OTP via authInternal.ts); the browser never holds a Turnkey credential, and sub-orgs are created with no user API keys or passkeys. The decision on #244: the author, not boop, holds signing authority.
Decided model
Key roles
- Author key (Turnkey Ed25519, one per author): controls the author's
did:webvh. Its only job is signing DID updates, i.e. adding and removing device/agent keys. Used through a client-side Turnkey session. - Device keys: one keypair per device, generated on the device and never exported (non-extractable WebCrypto/IndexedDB on web, platform keystore on native). Each is added to the author's
did:webvhas a verification method. Device keys sign routine action records and publish seals, online or offline. - Agent keys: generated by the agent, which registers only the public key when its API key is created. The author approves it in the UI, which adds it to the author's
did:webvhas a verification method. boop never sees an agent's private key (needs an SDK/CLI helper, see Build boop Agent Skill (+ OpenAPI docs & quickstart) #194). Records read "agent X, delegated by author Y". - Revocation = removing the key from the DID document. The did:webvh history records when each key was valid, so verifiers check the signing key was authorized at the record's time.
Sessions and auth
- Move OTP login completion to the client so the Turnkey session keypair is generated in the browser or app. Sessions last 7 days with silent refresh.
- No passkey step-up to seal: a valid session/device is sufficient presence. Passkeys are optional extra factors.
- Turnkey policy must prevent the parent-org API key from signing with users' keys or updating their DIDs. Verify this against Turnkey's actual behavior; don't assume it.
- Recovery: Turnkey's user-completed email auth is the baseline, optionally plus passkeys. If both are lost, the account and data remain, but the author identity is re-genesised; old signatures stay valid.
Existing assets
- Re-genesis everything under the new model. Published usage is negligible, so there's no continuity or redirect tooling.
Carry over from #277
The canonical RFC 8785 payload, the status lifecycle (pending / signed / failed / unsigned), the single recording path, and the independent verifier. Signing moves from the Node action to the client/agent, and the verifier resolves keys via the author's did:webvh history instead of caller-pinned keys.
To work out in implementation
- Turnkey client-session mechanics on web and Capacitor; how the policy blocks the parent org.
- DID update churn: batching device/agent additions, and performance of resolving long did:webvh histories.
- Queue semantics when a device is revoked while it holds unsent signed actions (coordinate with [P1] Prevent offline replay from discarding valid work #238 and Enforce private-share revocation for attachments, caches, and offline writes #260).
- What the What does a stranger see when verifying a published note? #232 verifier UI shows for "signed by device D of author A".
Activity
pullfrog commentedon Oct 8, 2026
brianorwhatever commentedon Oct 8, 2026
Implementation plan
I researched this against Turnkey's docs, the
@turnkey/*package source and this repo. Four findings change how the work has to be done. Points 3 and 4 below need a decision from you; nothing else here departs from the decided model.Findings
The parent API key already cannot sign in a sub-org. Turnkey gives the parent org read-only visibility into sub-orgs. It "cannot stamp sub-org activities", so it cannot call
SIGN_RAW_PAYLOAD,CREATE_API_KEYS,CREATE_USERSorUPDATE_ROOT_QUORUMthere (sub-orgs, root quorum).7bbba38(Re-mint stale-domain DIDs from the client, where the signing key actually lives #216) loggedorganization mismatch: request is targeting organization …, but voters are in organization …whencreateDIDWebVHtried to sign from the server.convex/lib/turnkeySigner.tsandcreateListDIDcan't work either. All three get deleted.authInternal.ts:330). Today nobody can sign with the author's Turnkey Ed25519 key at all.The real custody gap is the login flow, and policy alone does not close it.
authInternal.tsuses legacyINIT_OTP/VERIFY_OTP, which send the plaintext code through Convex and return a verification token that isn't bound to any key. A backend that sees the code could finish a login with a key of its own choosing.encryptOtpCodeToBundle).VERIFY_OTP_V2returns a token bound to that key.STAMP_LOGIN.INIT_OTP_V3andVERIFY_OTP_V2, because the client has no credential yet, but it only ever relays the encrypted bundle.@turnkey/core2.x. The installed 1.11 only supports Turnkey's Auth Proxy. The proxy does a parent-wide email lookup, which conflicts with our shared parent org (docs/login-account-recovery.md).INIT_OTP_V3,VERIFY_OTP_V2andCREATE_SUB_ORGANIZATION, plus an explicitEFFECT_DENYonEMAIL_AUTH, email recovery, legacyOTP_LOGIN/VERIFY_OTP,CREDENTIAL/PRIVATE_KEYresources andEXPORT.CREATE_SUB_ORGANIZATION_V8anddisableEmailAuth/disableEmailRecovery/disableSmsAuth. WithEMAIL_AUTH, the parent picks the target key and the bundle could come back through a boop-hosted magic link. Existing sub-orgs get those features removed by the user's own session on their next login.Conflict with "device keys sign publish seals": did:cel only accepts
did:keycontrollers. Originals 4.0.0 refuses any other controller method (LifecycleManagerdidKeyPublicKeyMultibase). A seal is a CELupdateevent signed by the asset's single controller, so a key registered in the author's did:webvh can't authorize a seal. The options:I'll hold seals and the asset re-genesis until you pick. Nothing else depends on it.
Native "platform keystore". The iOS Secure Enclave only holds P-256 keys. Turnkey has no Capacitor support, and I found no maintained Capacitor plugin for Keystore/Secure Enclave signing. Proposal:
Multikeyverification methods.DeviceKeyStoreinterface. A native Secure Enclave/Keystore plugin follows.Design
Sessions and login (PR a)
/auth/initiaterunsINIT_OTP_V3and returnsotpIdplus the enclave target bundle.IndexedDbStamper, with an Android/iOS WebView stamper behind the same interface./auth/verifyrelaysVERIFY_OTP_V2. If there's no sub-org yet, the backend creates one with V8.loginWithOtp(attestedSTAMP_LOGIN) withexpirationSeconds: 604800. Before expiry it silently runsrefreshSession, never withinvalidateExisting, because Turnkey allows 10 expiring keys per user across all of their devices.Author DID and device keys (PR b)
updateKeys = [Turnkey Ed25519 author key]. Its genesis and every update are signed client-side through the Turnkey session (signRawPayload,HASH_FUNCTION_NOT_APPLICABLE).#device-<id>verification methods (assertionMethod).applyRemintpath. There's no continuity link, as decided.storeDidLogwill validate that each new log is a valid continuation of the stored one: same SCID, entries signed by the previousupdateKeys.(did, versionId).Signed action records, ported from #277 (PR c)
recordActions), the status lifecycle, and the independent verifier.operationId(from [P1] Prevent offline replay from discarding valid work #238's queue);operationId);afterstate,basedOnrevision and devicesignedAt.beforestate.pending: the write landed before the device key was registered; the device attaches its signature after registration.signed: verified at receipt against the author's current DID.failed: the device never signed, or the signature didn't verify.unsigned: legacy records, or credentials with no registered key.signedAt.Revocation and the queue (#238 / #260)
DEVICE_REVOKEDerror.signedAt. A verifier who doesn't trust boop can't detect that without a trusted timestamp. boop's receipt time, plus the existing Bitcoin anchoring if we choose, narrows it.Agent keys (PR d)
POST /api/v1/keysaccepts an agent public key (Ed25519 or P-256) and creates it aspending_approval.#agent-<id>verification method through a Turnkey-session DID update. Revoking the API key removes the verification method.shared/actionRecord.ts. The Build boop Agent Skill (+ OpenAPI docs & quickstart) #194 skill or CLI wraps it.PRs (stacked on
main)Decisions needed
beforestate as unsigned metadata.Until you answer, I'll build (a) and then (b) and (c) on the defaults recommended above. Each is easy to change before merge.