Files
crowd-funder-for-time-pwa/doc/background-jwt-pool.md
T

7.0 KiB

Background New Activity JWT pool

How the app credentials native background prefetch for New Activity.

1. What the pool is for

Background prefetch runs in WorkManager on Android and a background task on iOS, with no JavaScript executing. It calls Endorser directly and needs a Bearer JWT that was minted while the app was awake, possibly days earlier.

The app mints a pool of BACKGROUND_JWT_POOL_SIZE JWTs and hands them to the plugin through configureNativeFetcher. Each covers one UTC day, and the native fetcher picks the one matching the day it runs.

Source: src/libs/crypto/backgroundJwtPool.ts, src/services/notifications/nativeFetcherConfig.ts, src/constants/backgroundJwt.ts.

2. Token shape

Each token in the pool carries:

Claim Value
iss the minting DID
iat mint time
nbf its day's opening midnight, minus BACKGROUND_JWT_WINDOW_SLACK_SECONDS
exp its day's closing midnight, plus BACKGROUND_JWT_WINDOW_SLACK_SECONDS

The slack widens the window at both ends for clock skew between the device and Endorser. Widening is safe; narrowing can leave a prefetch inside the day with no usable token.

Two properties follow from the day windows, and both are load-bearing:

  • Each token grants one day. A token read from a log line or a captured header buys one day of Endorser access rather than the whole grant.
  • The tokens are distinct. ES256K signing is deterministic, so JWTs built from identical payloads are byte-identical. Differing windows are what keep the pool from collapsing into one string repeated POOL_SIZE times, which would defeat any duplicate-token rule the server applies.

3. Slot ordering

Both native fetchers select with pool[epochDay % pool.size()] and hold no record of when the pool was minted. The minter therefore files the token covering a given UTC day at index epochDay % BACKGROUND_JWT_POOL_SIZE.

Any POOL_SIZE consecutive days hit every index exactly once, so the array is dense whatever day minting starts on.

This is a contract across three languages. Changing the index arithmetic on one side without the others produces tokens presented outside their windows, which Endorser rejects with no local error. src/test/backgroundJwtPool.test.ts asserts the invariant by replaying the native selector against the minted pool.

Implementations: TimeSafariNativeFetcher.selectBearerTokenForRequest in android/app/src/main/java/app/timesafari/ and ios/App/App/.

4. Identities that can mint

Seed-phrase (did:ethr) identities only.

Passkey (did:peer) identities raise BackgroundJwtUnsupportedIdentityError. Each of their signatures is a WebAuthn assertion, so minting a pool would raise one biometric prompt per token, and createJwtNavigator overrides the day window with a one-minute exp — the tokens would expire long before the prefetch they were minted for. configureNativeFetcherIfReady catches the error and leaves prefetch unconfigured.

The delegated alertSearch batch rejects the same identities, with the notify-api answering DELEGATED_JWT_UNSUPPORTED_IDENTITY.

5. Lifecycle

Event Action
App foreground, startup, notification-time change configureNativeFetcherIfReady mints a pool and configures the fetcher
Active identity changes ($setActiveDid) clearNativeFetcherPool drops the pool the fetcher holds

The identity is decrypted once per mint and reused for every signature. Decrypting per token costs seconds on a phone, and minting runs on every foreground.

6. What the pool bounds

The grant is BACKGROUND_JWT_POOL_SIZE days wide and each token inside it is one day wide.

clearNativeFetcherPool is custody, not revocation. Endorser exposes no revocation mechanism, so a token that left the device before the clear stays valid until its window closes. What the clear bounds is the ordinary case — an account switch, a sign-out, a shared or lost handset — where no copy was taken and the device's own store is the only remaining exposure.

Anti-replay in the strict sense is unavailable on this path. It would require either a server-side one-time-use store, which Endorser does not offer, or per-request signing, which would put the private key in native code. Day-scoped windows narrow the exposure instead of eliminating it.

7. Constants

All in src/constants/backgroundJwt.ts.

Constant Meaning
BACKGROUND_JWT_POOL_SIZE Consecutive UTC days the pool covers, one token each. The whole forward grant a user authorizes per mint.
BACKGROUND_JWT_WINDOW_SLACK_SECONDS Padding on each end of a day window, for clock skew.
BACKGROUND_JWT_SECONDS_PER_DAY The day frame each slot is cut from.
BACKGROUND_JWT_EXPIRY_DAYS / _SECONDS Lifetime for the single-token background path.

Past the last covered day the pool carries no credential and prefetch stops until the app opens again.

8. A different credential

The notify-api's delegated alertSearch batch (src/services/notifications/alertAuthorizationBatch.ts) is a separate credential with a separate inventory. It authorizes the notification service to run a user's daily alertSearch server-side; this pool authorizes the user's own device to prefetch. They share the day-window shape and nothing else. See notification-wakeup-service/README.md.

9. Rejected

  • A unique jti per slot, with one shared long exp. A jti is an identifier, not a replay defense: it does nothing unless the server keeps a seen-set, and Endorser's behavior here was never confirmed. Day windows make the tokens distinct for the same cost while also bounding each one.
  • One long-lived token instead of a pool. Fails if Endorser rejects duplicate JWT strings across days. That policy question is open (§10), so the design does not depend on the answer.
  • Sizing the pool as expiryDays + buffer. The rationale was headroom for duplicate-token rules. With one token per day, the pool size is the grant length in days and needs no separate buffer term.
  • Per-request signing in native code (DPoP-style). The only true anti-replay option, rejected to keep one signing implementation in TypeScript rather than forking crypto into Java and Swift.
  • Routing all New Activity through the notification service and deleting this path. Rejected: it would force every user onto server-side delegation, and the service is single-replica, so the direct device-to-Endorser path has no equivalent.

10. Open questions

  • Endorser duplicate-JWT policy. Whether Endorser rejects a Bearer JWT string it has already seen is unconfirmed. The pool is correct either way; the answer would determine whether a pool is required at all.
  • Maximum exp Endorser accepts. Day-scoped windows are well inside any plausible limit, so this gates only the single-token path.
  • Plugin behavior on an empty pool. clearNativeFetcherPool passes empty credentials to configureNativeFetcher. A plugin build that rejects them leaves the previous pool in place; the failure is logged rather than reported as a successful clear.