make the background-search JWTs in the pool all unique, removing problematic jti
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
# 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.
|
||||
@@ -1,203 +0,0 @@
|
||||
# Plan: Background New Activity JWT — extended expiry + token pool
|
||||
|
||||
**Date:** 2026-03-27 14:29 PST
|
||||
**Status:** Draft for implementation
|
||||
**Audience:** TimeSafari / crowd-funder developers
|
||||
**Related:** `doc/endorser-jwt-background-prefetch-options.md`, `android/.../TimeSafariNativeFetcher.java`, `src/services/notifications/nativeFetcherConfig.ts`, `src/libs/crypto/index.ts`
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem statement
|
||||
|
||||
Background prefetch for New Activity calls Endorser with a Bearer JWT configured via `configureNativeFetcher`. The token previously came from `getHeaders()` → `accessToken()`, which used **`exp` ≈ 60 seconds** (`src/libs/crypto/index.ts`). Prefetch runs **minutes later** in WorkManager **without JavaScript**, so the JWT can be **expired** before the POST (`JWT_VERIFY_FAILED`).
|
||||
|
||||
**Goals:**
|
||||
|
||||
1. Use JWTs whose **`exp`** covers the gap between **last app-side configure** and **prefetch** (and ideally days without opening the app).
|
||||
2. Optionally support a **pool** of distinct JWT strings so Endorser can enforce **duplicate-JWT** / **one-time-use** rules without breaking daily prefetch. **Pool size** should follow **`expiryDays + buffer`** (one distinct token per day over the JWT lifetime, plus headroom for retries / edge cases); **implementation uses `BACKGROUND_JWT_POOL_SIZE = 100`** until policy changes.
|
||||
3. Keep pool size and expiry policy **easy to change** (constants / remote config later).
|
||||
|
||||
---
|
||||
|
||||
## 2. Guiding principles
|
||||
|
||||
| Principle | Implication |
|
||||
|-----------|-------------|
|
||||
| **Background has no JS** | Token selection and HTTP must run in **native** (or plugin) code using **persisted** data. |
|
||||
| **Single source of truth for signing** | Continue using **`createEndorserJwtForDid`** (same keys as today); do not fork crypto in Java/Kotlin. |
|
||||
| **Configurable pool size** | One constant `BACKGROUND_JWT_POOL_SIZE`; **currently 100**. Size should satisfy **`≥ expiryDays + buffer`** (see below). |
|
||||
| **Phased delivery** | Ship **extended expiry** first; add **pool** when server duplicate rules require it or in the same release if coordinated. |
|
||||
|
||||
### 2.1 Pool size rationale (`expiryDays + buffer`)
|
||||
|
||||
For **one New Activity prefetch per day**, each day should use a **distinct** JWT string if the server rejects reuse. Over the JWT lifetime (aligned with **`exp`**), you need at least **one token per day** the pool might be used without regeneration.
|
||||
|
||||
**Rule of thumb:**
|
||||
|
||||
```text
|
||||
BACKGROUND_JWT_POOL_SIZE ≥ ceil(BACKGROUND_JWT_EXPIRY_DAYS) + BACKGROUND_JWT_POOL_BUFFER
|
||||
```
|
||||
|
||||
- **`BACKGROUND_JWT_EXPIRY_DAYS`** — human-facing match to `exp` (e.g. **90**); convert to `BACKGROUND_JWT_EXPIRY_SECONDS` for the payload.
|
||||
- **`BACKGROUND_JWT_POOL_BUFFER`** — extra slots for **same-day retries**, manual tests, or stricter duplicate rules (e.g. **10**).
|
||||
|
||||
**Example:** 90‑day `exp` + buffer 10 ⇒ **minimum 100** logical slots. **This plan keeps `BACKGROUND_JWT_POOL_SIZE = 100`** as the shipped default so it matches that example; if `expiryDays` or buffer change later, **bump the constant** so the inequality still holds.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phases
|
||||
|
||||
### Phase A — Extended expiry only (minimum viable)
|
||||
|
||||
**Scope**
|
||||
|
||||
- Introduce a dedicated mint path for **background / native fetcher** use (name TBD, e.g. `accessTokenForBackgroundNotifications(did)`), producing **one** JWT per configure call with:
|
||||
- `iss`: DID (unchanged)
|
||||
- `iat`: now
|
||||
- `exp`: now + **`BACKGROUND_JWT_EXPIRY_SECONDS`** (derived from **`BACKGROUND_JWT_EXPIRY_DAYS`**; see §2.1 / Phase B constants — **confirm** with Endorser policy)
|
||||
- Optional: `jti` or nonce for uniqueness if needed for logging/debug
|
||||
|
||||
- **`configureNativeFetcherIfReady`** should pass this token (or keep using a thin wrapper) instead of reusing the **60s** `accessToken()` when configuring native fetcher **only** — **do not** change interactive `getHeaders()` / passkey caching behavior for normal API calls unless product asks for it.
|
||||
|
||||
**Files (likely)**
|
||||
|
||||
- `src/libs/crypto/index.ts` — new function or parameters; keep `accessToken()` default at 60s for existing callers.
|
||||
- `src/services/notifications/nativeFetcherConfig.ts` — obtain background JWT via the new mint path, not `getHeaders()`’s generic path, **or** add a dedicated branch that calls the new mint after resolving `did`.
|
||||
|
||||
**Native**
|
||||
|
||||
- **`TimeSafariNativeFetcher`**: still one `jwtToken` field; no pool yet. Ensure `configure()` is called whenever TS refreshes (startup, resume, Account — already partially covered).
|
||||
|
||||
**Exit criteria**
|
||||
|
||||
- Logcat: prefetch POST returns **200** (or non-expired 4xx) when user has not opened the app for several **minutes** after configure.
|
||||
- Endorser accepts **`exp`** far enough in the future (coordinate TTL policy).
|
||||
|
||||
---
|
||||
|
||||
### Phase B — Token pool (size 100; driven by `expiryDays + buffer`)
|
||||
|
||||
**Why**
|
||||
|
||||
- Endorser may **reject duplicate JWT strings** (same bearer used twice). One long-lived token could fail on **day 2** if the server marks each JWT as consumed.
|
||||
- A **pool** of **N** distinct JWTs (different payload, e.g. unique `jti` per token) gives **N** independent strings with the same long **`exp`**. **N** should follow **§2.1** (`expiryDays + buffer`); **100** is the initial **`BACKGROUND_JWT_POOL_SIZE`** (satisfies e.g. 90 + 10).
|
||||
|
||||
**Scope**
|
||||
|
||||
1. **Constants** (single place, e.g. `src/constants/backgroundJwt.ts` or next to native fetcher config):
|
||||
|
||||
```text
|
||||
BACKGROUND_JWT_EXPIRY_DAYS = 90 // align with Endorser; drives exp
|
||||
BACKGROUND_JWT_EXPIRY_SECONDS = 90 * 24 * 60 * 60 // derived
|
||||
BACKGROUND_JWT_POOL_BUFFER = 10 // retries / headroom; tune with server team
|
||||
BACKGROUND_JWT_POOL_SIZE = 100 // must be >= expiryDays + buffer; adjust if policy changes
|
||||
```
|
||||
|
||||
2. **Mint in TS** (uses `createEndorserJwtForDid`):
|
||||
|
||||
- Loop `i = 0 .. POOL_SIZE - 1`
|
||||
- Payload: `{ iss, iat, exp, jti: `${did}#bg#${i}` or uuid }` — **confirm** `jti` format with Endorser if required.
|
||||
|
||||
3. **Persistence** — native code must read the pool **without JS**:
|
||||
|
||||
- **Option B1 (preferred):** Implement in **`@timesafari/daily-notification-plugin`** (not in the app): extend **`configureNativeFetcher`** to accept an optional JWT pool, persist it for native read. **Handoff spec:** `doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md` — copy or reference that file in the plugin repo PR.
|
||||
- **Option B2 (app-only, no plugin release):** Write JSON to **Capacitor Preferences** or **encrypted storage** from TS; **TimeSafariNativeFetcher** reads the same store on Android (requires knowing Capacitor’s Android `SharedPreferences` name/key convention or a tiny **bridge** in `MainActivity`). Use only if plugin work is deferred.
|
||||
|
||||
4. **Selection policy in `TimeSafariNativeFetcher`** (before each POST):
|
||||
|
||||
- **By calendar day:** `index = (epochDay + offset) % POOL_SIZE` (stable per day).
|
||||
- Or **sequential:** persist `lastUsedIndex` in prefs and increment (wrap). **Decision:** document chosen policy; day-based is easier to reason about for “one token per day.”
|
||||
|
||||
5. **configureNativeFetcherIfReady** (and any “reset notifications on startup” hook):
|
||||
|
||||
- Regenerate full pool when user opens app (per product decision), then call configure with pool + **current** `apiBaseUrl` / `did`.
|
||||
|
||||
6. **iOS:** When iOS native fetcher exists, mirror Android behavior.
|
||||
|
||||
**Exit criteria**
|
||||
|
||||
- Prefetch succeeds on **consecutive days** with duplicate-JWT enforcement enabled on a **staging** Endorser.
|
||||
- Pool **refreshes** on startup without breaking dual schedule.
|
||||
|
||||
---
|
||||
|
||||
## 4. Detailed tasks (checklist)
|
||||
|
||||
### Crypto & TypeScript
|
||||
|
||||
- [ ] Add `BACKGROUND_JWT_EXPIRY_DAYS`, `BACKGROUND_JWT_EXPIRY_SECONDS`, `BACKGROUND_JWT_POOL_BUFFER`, and `BACKGROUND_JWT_POOL_SIZE` (exported constants), with a **comment** that `POOL_SIZE >= expiryDays + buffer` (see §2.1).
|
||||
- [ ] Implement `mintBackgroundJwtPool(did: string): Promise<string[]>` (or split single + pool).
|
||||
- [ ] Ensure each JWT has **unique** `jti` (or equivalent) for duplicate detection.
|
||||
- [ ] **Do not** break existing `accessToken()` 60s behavior for unrelated features.
|
||||
- [ ] Wire `configureNativeFetcherIfReady` to pass **single extended token** (Phase A) then **pool** (Phase B).
|
||||
- [ ] On **logout / identity clear**, clear persisted pool and call plugin clear if needed.
|
||||
|
||||
### Android
|
||||
|
||||
- [ ] **Phase A:** No structural change if `configure()` still receives one string; verify non-null `jwtToken` after configure.
|
||||
- [ ] **Phase B:** Parse pool from persisted JSON; implement `selectTokenForRequest()`; use selected token in `Authorization` header instead of sole `jwtToken` field (keep `configure` for `apiBaseUrl` / `did`).
|
||||
- [ ] Unit or instrumentation tests optional: selection index deterministic.
|
||||
|
||||
### Plugin (Option B1 — **daily-notification-plugin** repo)
|
||||
|
||||
- [ ] Follow **`doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md`** (API shape, Android/iOS, versioning).
|
||||
- [ ] Release new plugin version; bump dependency in this app.
|
||||
|
||||
### Product & server
|
||||
|
||||
- [ ] Endorser: confirm **max `exp`**, **duplicate JWT** semantics, recommended **`jti`** format.
|
||||
- [ ] Document operational limit: if user never opens app for **longer than `exp` allows** (or longer than **pool × daily use** without refresh), prefetch may fail until next open — align with `doc/endorser-jwt-background-prefetch-options.md`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Security notes
|
||||
|
||||
- Longer-lived JWTs and **many** tokens increase impact if device is compromised. Mitigations: **encrypted prefs** where possible, **no logging** of full JWTs, **revocation** story with Endorser (key rotation, deny list).
|
||||
- Pool regeneration on **login** should replace old pools.
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing plan
|
||||
|
||||
| Test | Expected |
|
||||
|------|----------|
|
||||
| Configure → wait **> 5 min** → prefetch | **200** from `plansLastUpdatedBetween` (Phase A) |
|
||||
| Two consecutive **days** with duplicate-JWT staging | **200** both days (Phase B) |
|
||||
| Logout | Pool cleared; no stale bearer |
|
||||
| Lower `BACKGROUND_JWT_POOL_SIZE` in dev only (below `expiryDays + buffer`) | Expect possible reuse / server duplicate errors — use to reproduce failures |
|
||||
|
||||
---
|
||||
|
||||
## 7. Rollout / staging
|
||||
|
||||
1. Implement Phase A behind feature flag **optional** (or direct if low risk).
|
||||
2. Verify on **test-api.endorser.ch** with server team.
|
||||
3. Phase B behind flag or same release once server duplicate rules are understood.
|
||||
|
||||
---
|
||||
|
||||
## 8. Where plugin documentation lives
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| **`doc/plan-background-jwt-pool-and-expiry.md`** (this file) | End-to-end app plan: crypto, pool sizing, native host, rollout. |
|
||||
| **`doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md`** | **Plugin-only** handoff: extend `configureNativeFetcher`, persist pool, Android/iOS notes — intended for PRs in **daily-notification-plugin** (or Cursor on that repo). |
|
||||
|
||||
Keeping them **separate** avoids mixing consumer app tasks with plugin API contract; the plan **links** to the plugin feedback doc for Option B1.
|
||||
|
||||
---
|
||||
|
||||
## 9. References
|
||||
|
||||
| Topic | Location |
|
||||
|--------|----------|
|
||||
| Current 60s `accessToken` | `src/libs/crypto/index.ts` |
|
||||
| `createEndorserJwtForDid` | `src/libs/endorserServer.ts` |
|
||||
| Native configure | `src/services/notifications/nativeFetcherConfig.ts` |
|
||||
| Android HTTP | `android/.../TimeSafariNativeFetcher.java` |
|
||||
| Options doc (TTL, refresh, BFF) | `doc/endorser-jwt-background-prefetch-options.md` |
|
||||
| Plugin: `configureNativeFetcher` + JWT pool | `doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md` |
|
||||
|
||||
---
|
||||
|
||||
*Update this plan when Phase A/B ship or when Endorser policy changes.*
|
||||
@@ -3,7 +3,7 @@
|
||||
**Date:** 2026-03-27 PST
|
||||
**Target repo:** `@timesafari/daily-notification-plugin` (daily-notification-plugin)
|
||||
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
|
||||
**Related app plan:** `doc/plan-background-jwt-pool-and-expiry.md` (Phase B, Option B1)
|
||||
**Related app plan:** `doc/background-jwt-pool.md`
|
||||
|
||||
---
|
||||
|
||||
@@ -85,7 +85,7 @@ When `configureNativeFetcher` exists on iOS, mirror Android: accept optional poo
|
||||
|
||||
| Topic | Location |
|
||||
|--------|----------|
|
||||
| End-to-end plan (Phase A/B, pool sizing) | `doc/plan-background-jwt-pool-and-expiry.md` |
|
||||
| Pool design, slot ordering, lifecycle | `doc/background-jwt-pool.md` |
|
||||
| Android fetcher | `android/.../TimeSafariNativeFetcher.java` |
|
||||
| Current configure call | `src/services/notifications/nativeFetcherConfig.ts` |
|
||||
| JWT options (expired token context) | `doc/endorser-jwt-background-prefetch-options.md` |
|
||||
|
||||
Reference in New Issue
Block a user