Skip to content

Move signing to author-present Turnkey sessions and delegated agent keys #286

Open

Description

@brianorwhatever

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:webvh as 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:webvh as 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

Activity

  1. added
    enhancementNew feature or request
    P1High: core correctness or explicitly prioritized near-term capability
    on Oct 8, 2026
  2. pullfrog commented on Oct 8, 2026

    @pullfrog
  3. brianorwhatever commented on Oct 8, 2026

    @brianorwhatever
    ContributorAuthor

    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

    1. 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_USERS or UPDATE_ROOT_QUORUM there (sub-orgs, root quorum).

    2. The real custody gap is the login flow, and policy alone does not close it.

      • authInternal.ts uses legacy INIT_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.
      • Turnkey's fix is the encrypted OTP flow (OTP migration guide):
        • The client encrypts the code to the enclave together with its own session public key (encryptOtpCodeToBundle).
        • VERIFY_OTP_V2 returns a token bound to that key.
        • The client logs in itself with an attested STAMP_LOGIN.
        • The guide says the token is useless to an app server "without the matching client private key".
      • The backend still has to stamp INIT_OTP_V3 and VERIFY_OTP_V2, because the client has no credential yet, but it only ever relays the encrypted bundle.
      • This requires @turnkey/core 2.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).
      • Policies can't constrain a root quorum ("The Root Quorum bypasses any policies"). The parent's remaining power over sub-orgs is starting auth and recovery flows. Closing that takes three layers:
        • Parent-org policy on boop's API user. That user must not be a parent root user. It gets allow rules only for INIT_OTP_V3, VERIFY_OTP_V2 and CREATE_SUB_ORGANIZATION, plus an explicit EFFECT_DENY on EMAIL_AUTH, email recovery, legacy OTP_LOGIN/VERIFY_OTP, CREDENTIAL/PRIVATE_KEY resources and EXPORT.
        • Sub-org features. New sub-orgs are created with CREATE_SUB_ORGANIZATION_V8 and disableEmailAuth/disableEmailRecovery/disableSmsAuth. With EMAIL_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.
        • Client-side audit. On login the client checks the sub-org's users and root quorum, and refuses to sign DID updates if it finds a credential it didn't create.
      • Recovery stays user-completed email OTP, as decided.
      • Residual risk, which is inherent: whoever serves the JS can subvert the client. The same is true of device keys.
      • I can't apply Turnkey policies (no operator credentials). The PR ships the exact policy JSON and a runbook; an operator has to apply it before the policy guarantee holds, and I've marked several expressions as unvalidated against the live API.
    3. Conflict with "device keys sign publish seals": did:cel only accepts did:key controllers. Originals 4.0.0 refuses any other controller method (LifecycleManager didKeyPublicKeyMultibase). A seal is a CEL update event signed by the asset's single controller, so a key registered in the author's did:webvh can't authorize a seal. The options:

      • A (recommended): extend Originals so a did:cel controller can be a did:webvh. An event is then valid if its key was a verification method in that DID at event time. This matches the model exactly, but it's an SDK change and release.
      • B: move seals out of the CEL log into boop-signed seal records, signed by a device key and committing to the asset DID, version and content hash. The CEL then proves only genesis. This contradicts What does a note's signature commit to, and when does it seal? #229, under which each publish appends a body version to the CEL.
      • C: make the device key the controller and rotate it when another device seals. This fails "seal from any device" whenever the controlling device is lost or offline.

      I'll hold seals and the asset re-genesis until you pick. Nothing else depends on it.

    4. 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:

      • Device keys are P-256 (ES256) everywhere. Non-extractable WebCrypto keys work in every browser and WebView, and P-256 is the curve that can later live in the Secure Enclave or Android Keystore.
      • They're registered as P-256 Multikey verification methods.
      • The verifier accepts Ed25519 for author and agent keys and P-256 for device keys.
      • The first release stores non-extractable keys in IndexedDB, including inside the Capacitor WebView, behind a DeviceKeyStore interface. A native Secure Enclave/Keystore plugin follows.
      • That is a temporary departure from "platform keystore on native": WebView IndexedDB can be evicted under storage pressure. Eviction means a new device key and a re-login, not lost authority.

    Design

    Sessions and login (PR a)

    • /auth/initiate runs INIT_OTP_V3 and returns otpId plus the enclave target bundle.
    • The client generates the session key: a non-extractable P-256 key via IndexedDbStamper, with an Android/iOS WebView stamper behind the same interface.
    • The client encrypts the code, and /auth/verify relays VERIFY_OTP_V2. If there's no sub-org yet, the backend creates one with V8.
    • The client then calls loginWithOtp (attested STAMP_LOGIN) with expirationSeconds: 604800. Before expiry it silently runs refreshSession, never with invalidateExisting, because Turnkey allows 10 expiring keys per user across all of their devices.
    • Convex only issues boop's own app JWT after Turnkey verification succeeds.
    • Assumption: "7 days, silent refresh" is the Turnkey session. boop's app session lifetime is unchanged, since signing authority now sits with device keys, not sessions.
    • An expired Turnkey session only blocks DID updates (adding devices, approving agents). Routine signing keeps working.

    Author DID and device keys (PR b)

    • The author's did:webvh is re-genesised with updateKeys = [Turnkey Ed25519 author key]. Its genesis and every update are signed client-side through the Turnkey session (signRawPayload, HASH_FUNCTION_NOT_APPLICABLE).
    • Device keys become #device-<id> verification methods (assertionMethod).
    • Rows are moved by the existing applyRemint path. There's no continuity link, as decided.
    • storeDidLog will validate that each new log is a valid continuation of the stored one: same SCID, entries signed by the previous updateKeys.
    • Settings gets a Devices list with revoke. Revoking removes the verification method and revokes that device's boop sessions.
    • Batching: pending device and agent additions go into one DID update. Resolved histories are cached per (did, versionId).

    Signed action records, ported from #277 (PR c)

    • Kept from Sign action records with owner Turnkey keys through one shared path #277: the RFC 8785 canonical payload, the single recording path (recordActions), the status lifecycle, and the independent verifier.
    • Change: the device builds and signs the payload at action time, online or offline. The server recomputes the canonical payload from the mutation's arguments with the same shared function, and requires the bytes to match. It never asks a device to sign bytes the device didn't build. Without this, the server could use the device as a blind signer.
    • The signed payload binds:
    • Server receive time and server IDs become unsigned, server-attested metadata. That's a change from Sign action records with owner Turnkey keys through one shared path #277, which signed server time and before state.
    • Status meanings:
      • 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.
    • A write never fails because signing is unavailable. A bad signature is rejected.
    • The verifier resolves the author's did:webvh history and checks that the signing verification method was present at the record's signedAt.

    Revocation and the queue (#238 / #260)

    • The server rejects records signed by a key that isn't in the author's current DID at receipt, with a permanent DEVICE_REVOKED error.
    • Enforce private-share revocation for attachments, caches, and offline writes #260's denied-operation path turns those queued ops into recoverable local drafts. Revoking a device also revokes its sessions, so the device is signed out.
    • Known limit: a holder of a revoked key can backdate 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/keys accepts an agent public key (Ed25519 or P-256) and creates it as pending_approval.
    • The author approves it in the UI, which adds a #agent-<id> verification method through a Turnkey-session DID update. Revoking the API key removes the verification method.
    • Agents build and sign records with a small helper exported from shared/actionRecord.ts. The Build boop Agent Skill (+ OpenAPI docs & quickstart) #194 skill or CLI wraps it.
    • Records show "agent X, delegated by author Y". The private key never touches boop.

    PRs (stacked on main)

    • a: client-side OTP and Turnkey sessions, the policy runbook, and deletion of the server signers.
    • b: author DID re-genesis, device keys, and the device list/revoke UI.
    • c: signed action records and the verifier. Supersedes Sign action records with owner Turnkey keys through one shared path #277, which I'll close when (c) lands.
    • d: agent key registration and approval.
    • e: seals and asset re-genesis, after the decision in finding 3.

    Decisions needed

    1. Seals: option A, B or C from finding 3.
    2. Device keys: P-256, with IndexedDB in the Capacitor WebView first and a native keystore plugin as a follow-up.
    3. Signed payload: the device-built intent payload, with server time and before state as unsigned metadata.
    4. Revocation: reject at receipt, and accept the backdating limit, optionally narrowed with anchoring.

    Until you answer, I'll build (a) and then (b) and (c) on the defaults recommended above. Each is easy to change before merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High: core correctness or explicitly prioritized near-term capabilityenhancementNew feature or requestprovenance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions