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_SIZEtimes, 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
jtiper slot, with one shared longexp. Ajtiis 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
expEndorser accepts. Day-scoped windows are well inside any plausible limit, so this gates only the single-token path. - Plugin behavior on an empty pool.
clearNativeFetcherPoolpasses empty credentials toconfigureNativeFetcher. A plugin build that rejects them leaves the previous pool in place; the failure is logged rather than reported as a successful clear.