Skip to content

feat(rpc): allow admin-issued preload UCANs to sign for claimed users#232

Open
rabble wants to merge 2 commits into
mainfrom
feat/preload-sign-after-claim
Open

feat(rpc): allow admin-issued preload UCANs to sign for claimed users#232
rabble wants to merge 2 commits into
mainfrom
feat/preload-sign-after-claim

Conversation

@rabble
Copy link
Copy Markdown
Member

@rabble rabble commented May 14, 2026

Summary

  • Drop the is_unclaimed gate in load_preloaded_user_handler (api/src/api/http/nostr_rpc.rs) so admin-issued preload UCANs continue to work after a user claims their account.
  • Existence check preserved — non-existent users still rejected with InvalidToken.
  • Updated docstrings on load_preloaded_user_handler and the MODE 2 branch in get_handler to reflect the new behavior.

Why

We've discovered more classic Vine archives belonging to accounts that users have already claimed, and users want them published under their accounts. The mint side (/api/admin/preload-user) was already idempotent on vine_id and happily returned a fresh signing UCAN regardless of claim status; the signing endpoint at nostr_rpc.rs was the sole remaining block.

Behavior change

Before: a server-signed preload UCAN could only sign for users where email IS NULL. Once a user claimed, their preload UCANs became dead.

After: the same preload UCAN works for both unclaimed and claimed users, until the UCAN itself expires (30 days) or gets evicted from cache.

The mint gate is unchanged: only is_full_admin callers can mint these UCANs, so the externally-reachable attack surface is the same.

What this expands

A leaked admin token can now sign as any user (claimed or not) for as long as a 30-day preload UCAN lives. Before, claim was a user-side seal that closed that door. Acceptable for our small ops team and the Vine republish workflow, but the threat model genuinely changed.

Follow-ups (not in this PR)

  • Add audit trail for admin-issued preload UCAN signing operations #231 — add per-sign audit trail to admin_audit_events so we can answer "who signed what as whom" months after the fact. Schema + insertion-site notes are in the issue.
  • Consider shortening preload UCAN TTL from 30d → 24h to cut the leaked-token window.
  • get_user_token (pubkey-based admin endpoint at admin.rs:344-393) still has its own is_unclaimed block. Inconsistent with the new vine_id behavior, but the import script doesn't use that path so it's not blocking. Worth aligning later.

Test plan

  • Build passes (cargo build -p keycast_api)
  • Existing admin_preload_test, admin_token_test, admin_batch_claim_test still pass (they test mint-side and is_unclaimed repository behavior, both unchanged)
  • Manual: in staging, mint a preload UCAN for a claimed test account, hit /api/nostr with sign_event, verify it returns a signed event instead of InvalidToken
  • Manual: confirm preload UCAN for nonexistent pubkey still returns InvalidToken

🤖 Generated with Claude Code

Drops the is_unclaimed gate in load_preloaded_user_handler so admin-minted
preload UCANs continue to work after a user claims their account. Needed
to publish additional legacy Vine archives discovered after handover.

Mint side (`/api/admin/preload-user`) was already idempotent on vine_id and
returned a fresh UCAN regardless of claim status; the signing path was the
sole remaining block. Existence check is preserved (None → 404-equivalent).

Audit logging for these signs is intentionally not added in this PR; tracked
separately in #231 with schema and insertion-site notes.
Copy link
Copy Markdown
Contributor

@irab irab left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks fine - definitely would be good to change that expiry from 30D to however long the import script normally takes to run. I assume 1-4hrs?

@dcadenas dcadenas self-requested a review May 14, 2026 19:08
Copy link
Copy Markdown
Contributor

@dcadenas dcadenas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new preload signing path needs tighter auth checks before this can merge.
It must only accept real preload UCANs, and cached preload handlers must stop at the UCAN expiry.

The PR description bounds the leaked-token window at 30 days. With the cache-expiry issue below, a warm cache pushes that to effectively unbounded, so the stated tradeoff doesn't hold yet.


Additional findings not shown inline

  • Carry UCAN expiry into cached preload handlers
    None, // No expiry (handled by token expiry)

    Carry the UCAN expiry into cached preload handlers.
    This path caches preload handlers with expires_at: None, so after the first valid use, later cache hits return the handler without revalidating the UCAN.
    The cache uses time_to_idle(3600), which resets on every access, so a token hit at least once per hour never evicts. A warm cache entry can keep signing past the 30-day UCAN lifetime until process restart, effectively unbounded for continuously-used tokens.
    Store the UCAN expiration on the handler or cache entry, and add a test that a warm cache rejects the token after expiry.

@@ -563,7 +563,8 @@ async fn get_handler(
h
} else if is_server_signed(&ucan) {
Copy link
Copy Markdown
Contributor

@dcadenas dcadenas May 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restrict this branch to real preload UCANs before loading personal keys.
Right now any server-signed UCAN without bunker_pubkey reaches load_preloaded_user_handler. That means claim-session tokens, or the admin UCAN returned by GET /api/admin/token, can become full-access RPC signing tokens for whoever the UCAN's audience is, provided personal_keys exists for them.
Require preload-specific facts, such as redirect_origin == "preload" and issued_by_admin, or add a dedicated token-purpose fact before this path loads the handler.
Add tests that accept an admin preload UCAN for a claimed user and reject claim/admin server-signed no-bunker tokens.

…o cache

Address Daniel review feedback (#232):

1. Only accept "real" preload UCANs in MODE 2.
   load_preloaded_user_handler now requires redirect_origin == "preload";
   server-signed UCANs from other flows (admin sessions, claim, etc.) are
   rejected with InvalidToken. Previously, any server-signed UCAN without a
   bunker_pubkey routed here and could decrypt + sign as any user.

2. Carry UCAN expiry into the cached preload handler.
   get_handler extracts ucan.expires_at() and threads it through to
   HttpRpcHandler::new instead of passing expires_at: None. On cache hit,
   handler.is_valid() now returns false once the UCAN lifetime ends, so a
   warm cache entry can no longer keep signing past the UCAN's exp until
   idle eviction.

Tests:
- test_warm_cache_preload_handler_rejected_after_ucan_expiry: mints a
  preload UCAN with a 2s lifetime, succeeds once (populates cache),
  sleeps past exp, and asserts the second call (cache hit) returns
  InvalidToken and evicts the entry.
- test_server_signed_non_preload_redirect_origin_rejected: mints a
  server-signed UCAN with redirect_origin: "admin" and asserts the
  signing path rejects it before touching the user's key.

All 8 nostr_rpc_integration_test tests pass. Adjacent admin_preload_test,
admin_token_test, admin_batch_claim_test tests also pass.
@rabble
Copy link
Copy Markdown
Member Author

rabble commented May 14, 2026

@dcadenas Both findings addressed in df843b9:

1. Real preload UCANs onlyload_preloaded_user_handler now takes the UCAN's redirect_origin as a parameter and rejects anything that isn't "preload". Server-signed admin-session UCANs and claim UCANs can no longer fall through MODE 2 to sign as users.

2. UCAN expiry carried into cached handlerget_handler now extracts ucan.expires_at() and threads it into HttpRpcHandler::new instead of passing None. The cache hit path's handler.is_valid() check will now return false once the UCAN lifetime ends, so warm cache entries can't keep signing past exp.

Tests added (api/tests/nostr_rpc_integration_test.rs):

  • test_warm_cache_preload_handler_rejected_after_ucan_expiry — exactly what you asked for. Mints a preload UCAN with lifetime: 2s, exercises a successful sign to populate the cache, sleeps 3s past expiry, then asserts the second call (cache hit) returns InvalidToken and the cache entry is evicted.
  • test_server_signed_non_preload_redirect_origin_rejected — mints a server-signed UCAN with redirect_origin: "admin" and verifies the signing path rejects it before touching the user's key.

Both new tests pass. The 6 pre-existing tests in this file still pass, as do the adjacent admin_preload_test, admin_token_test, and admin_batch_claim_test suites.

@rabble rabble requested a review from dcadenas May 14, 2026 21:07
Copy link
Copy Markdown
Contributor

@dcadenas dcadenas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔑 claimed-user preload signing lands cleanly

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants