156 lines
7.0 KiB
Markdown
156 lines
7.0 KiB
Markdown
# 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.
|