Reference source · 0.1.0-dev.11
refresh-recovery.md
docs/refresh-recovery.md · Download original file
# Refresh rotation and response-loss recovery
The executable profile rotates application-bound refresh credentials without extending their patient authorization. Prototype `0.1.0-dev.2` adds a bounded recovery transaction so an ordinary lost response need not revoke legitimate access. This is an OPRP experiment, not a standardized OAuth extension.
## Logical request and outcome
A protected refresh request contains exactly `operation: refresh`, `refresh_token`, and `request_id`. The logical request ID persists across retries for that same refresh credential. Every attempt still has a new envelope/message ID and fresh signature/encryption. The Host checks the caller's current credential, its bound application key, active grant, authorized end time, and representative authority before rotation or recovery.
| Presented credential and request | Host outcome |
| --- | --- |
| Unused current refresh credential | Mark it used, create a random successor, issue a bounded JWT, and commit recovery metadata atomically. |
| Used credential, same logical request ID, within 120 seconds, successor still unused | Return the same successor and a fresh JWT within the original grant. No additional rotation occurs. |
| Used credential, same ID, successor already used | Return `refresh_superseded`; do not return a later credential or revoke solely for this stale retry. |
| Used credential, same ID, recovery window elapsed | Return `refresh_recovery_expired`; do not revive the receipt or extend the grant. |
| Used credential under a different ID | Treat as reuse: revoke the grant and return `refresh_reuse_detected`. |
| Any credential after grant expiry, revocation, loss of representative authority, or caller-key mismatch | Reject before returning any recovered credential. |
The recovery window is an illustrative reference limit. It is not a replacement for grant expiry or a grace period for revoked authority. A client outside that window must reestablish usable token state under current Host policy. It must not generate a different request ID and resend the already-used credential.
Recovery returns the immediate successor only. It never walks a rotation chain to disclose the current credential to someone presenting an old ancestor. A stolen refresh value without the application's signing key cannot invoke this protocol. Compromise of the application's private key still requires credential revocation and incident recovery; rotation cannot repair a compromised identity.
These choices build on sender constraint, rotation and reuse guidance in [RFC 9700](https://www.rfc-editor.org/rfc/rfc9700.html#section-4.14). The RFC does not define this OPRP idempotency/recovery mechanism; its design and implementation remain part of the independent review required before production.
## Durable Host receipt
The Host keeps only the old/successor refresh hashes for ordinary lookup. Recovery additionally stores the logical request ID, original rotation time, successor hash, and an encrypted receipt containing the successor. It never stores that successor in plaintext. The receipt key is derived from the old refresh secret supplied by the authenticated caller; the Host does not need to retain that old secret.
The reference adapter uses:
```text
context = compact JSON array ["OPRP-REFRESH-RECOVERY-0.1", grant_id, application, signing_key_id, request_id]
key = HKDF-SHA-512(old_refresh_bytes, SHA-512(context), "OPRP-0.1/refresh-receipt", 32)
receipt = AES-256-GCM(key, fresh 12-byte nonce, {refresh_token: successor}, context)
```
Its stored value is base64url of `nonce || 16-byte tag || ciphertext`. Expired encrypted receipts are cleared lazily on subsequent refresh calls; their used-state and logical-ID metadata remain for reuse detection. Encryption failures reveal no token and do not relax authority checks. Host database/storage protection remains its operator's responsibility: this internal cache method is not a required universal at-rest method for independent Hosts.
The receipt, used flag and successor hash are committed with rotation in one SQLite transaction. A response lost after that commit is recovered after restart using the original caller identity, credential, old refresh value and logical ID. Production key custody, distributed services, retention and incident tooling remain unfinished.
## SDK persistence and restart
`Client.exportState()` includes its current authorization and any `pendingRefresh`. `restoreState(state)` binds the snapshot to the same application signing key and Host identity. This state contains secrets and must be protected by the application's storage system. Neither JSON serialization nor the SDK hook provides encryption or a secure key store.
An optional `persistState` callback receives a snapshot before any refresh transmission. If that write fails, the SDK does not transmit. On success it persists the new credential and clears the pending transaction. If a response is lost, a later call retries the remembered transaction. Recreating a client and restoring the last durably saved snapshot preserves that ID across process restart.
The application's callback must atomically commit the state before resolving. Tests use an independent captured snapshot to demonstrate the contract; no production token-store adapter is bundled. One refresh runs at a time inside a client instance. Multiple instances or processes sharing credentials still need a shared lease/state mechanism; choosing independent IDs for the same old credential can correctly trigger reuse detection.
Use one client authorization context at a time. Do not switch a client's patient/grant while its requests are in flight. Separate patient/grant clients can share a deployment identity while maintaining distinct token state. Mobile instance identities and platform storage remain SDK work.
## Evidence and boundaries
[Recovery tests](../tests/refresh-recovery.test.ts) simulate loss after commit, restart the Host and client, restore persisted state, and recover without extending authority. They verify same-successor retries, superseded retries, new-ID reuse revocation, recovery expiry, encrypted receipt cleanup, and failure of persistence before transmission. [Python interop](../scripts/interop.ts) exercises this logical exchange through its independently implemented protected messages. Broader crash injection, simultaneous processes and real platform secure storage remain acceptance work.