SafePass encrypted sync protocol, version 1
This document defines the revision exchange implemented by the ASP.NET sync
API and its clients. The HTTP contract and PostgreSQL repository pass
integration tests against live PostgreSQL. The Flutter client and the browser
extension both speak this contract: apps/flutter_client/lib/src/sync and
apps/browser_ext/src/remote_api.ts implement upload, ordered pull and conflict
handling, and the extension's sync client is covered by
apps/browser_ext/test/remote_api.test.ts plus the Chromium end-to-end run.
Identifiers and limits
accountId,recordId,revisionId, parent IDs and idempotency keys are opaque, case-sensitive ASCII strings of 1–128 bytes. Allowed characters are letters, digits,-,_,., and@.- A revision envelope is the complete binary
SPCRv1 envelope, encoded as unpadded base64url in JSON. Binary envelope limits are defined in crypto-format-v1.md. - A revision may have zero parents for a new record or one parent for an ordinary edit. A conflict resolution names every head the client displayed. Conflict resolution supports up to eight parents. The service caps each record at eight live heads so a conflict can always be closed.
cursoris a nonnegative decimal integer no greater than9223372036854775807, assigned by the service. JSON encodes it as a string. Clients must not submit or infer a cursor for a new revision.
Upload
POST /v1/records/{recordId}/revisions
{
"revisionId": "rev_01J...",
"parentRevisionIds": ["rev_previous"],
"idempotencyKey": "device_01J_operation_01J",
"envelope": "U1BDUgE...",
"tombstone": false,
"expectedHeadIds": null
}expectedHeadIds is omitted or null for a new record and ordinary edit. A
conflict resolution includes the complete set of head IDs that the client read;
the service compares that set with current heads and inserts the merge in the
same transaction. A mismatch returns 409 conflict_heads_changed with the
current opaque heads. The merge parents are set to that validated head set.
The API scopes revision IDs to an account and record, and idempotency keys to an
account. Repeating an identical request with the same idempotency key returns
the original cursor and current heads. Reusing an idempotency key for another
operation returns 409 idempotency_key_reused; reusing a revision ID under a
different idempotency key returns 409 revision_id_collision. It never
replaces an existing revision.
Successful response:
{
"inserted": true,
"cursor": "42",
"heads": [
{"revisionId": "rev_01J...", "cursor": "42", "tombstone": false}
]
}Head metadata does not contain decrypted fields. The server retains every accepted revision, including superseded branches and tombstones. A tombstone flag reveals that an operation is a deletion; this is part of the documented server-visible metadata.
Delta download
GET /v1/sync?afterCursor=41&limit=200
{
"changes": [
{
"recordId": "rec_01J...",
"revisionId": "rev_01J...",
"parentRevisionIds": ["rev_previous"],
"idempotencyKey": "device_01J_operation_01J",
"envelope": "U1BDUgE...",
"tombstone": false,
"cursor": "42"
}
],
"nextCursor": "42",
"hasMore": false
}Cursors are monotonically increasing within an account. limit is in the range
1–1000. The server orders changes by cursor and the Flutter client applies each
revision and its cursor in the same SQLCipher transaction. It does not advance
the pull cursor from an upload response; an uploaded operation is acknowledged
only when the ordered delta stream returns its matching idempotency key. On
retry, the client requests from the last committed cursor, so interrupted pages
resume without skipping server changes.
Conflict and deletion behavior
Ordinary uploads may create concurrent heads. The response returns all current
heads; clients preserve and display both encrypted versions for user choice.
The user-approved resolution is a new immutable encrypted revision whose
expectedHeadIds and parents include all heads displayed. A concurrent edit
after that display makes the request stale instead of silently losing a branch.
Deletion is a new encrypted revision with tombstone: true; prior revisions
remain in history. The local client may hide a tombstoned record after the
delete revision is committed, but restore/retention policy must not discard
server history implicitly.
Authentication and transport
Every sync endpoint requires TLS and a short-lived authenticated session. Sync
routes validate a signed bearer JWT's issuer, audience, expiry, signature,
token-use, issue time, token ID, and account identifier. The API exposes OPAQUE
registration/login routes under /v1/auth; a successful proof issues a
15-minute access JWT and a one-time refresh token. Flutter persists the refresh
token in SQLCipher and rotates it after unlock and before access expiry. The
server stores only a SHA-256 hash; replay revokes that device family. Refresh
tokens expire after 30 days and a family expires after 90 days. Access JWTs with
an sid claim stop working when their session family is revoked. Legacy JWTs
without sid remain valid until expiry. The
browser extension stores its refresh token inside its encrypted IndexedDB vault
and rotates it through the same /v1/auth/refresh route. Deployments
must terminate TLS at a trusted endpoint. JSON
bodies are limited to 2 MiB and sync requests are rate limited. Logs exclude
request bodies, envelopes, tokens, passwords and recovery values.
Account recovery envelope
The first linked client obtains a short-lived authenticated challenge and
uploads an account-bound SPRE v1 envelope plus a phrase-derived Ed25519 public
key to PUT /v1/account/recovery-envelope. Subsequent changes require a
signature from the previous key over the new key and envelope hash. An
authenticated device retrieves the ciphertext and public key from
GET /v1/account/recovery-envelope and unwraps the vault key locally with the
user's 24-word recovery phrase. A newly linked local vault must be empty; the
client re-wraps the shared key with that device's own local master password,
then downloads record revisions through the normal sync cursor. The API stores
the ciphertext without decryption.
The signed recovery protocol can also reset the OPAQUE account password.
Successful reset replaces the verifier, consumes outstanding recovery
challenges, and revokes every refresh session in one database transaction.
Restoring a linked local vault rotates its local recovery phrase; the client
prompts for the previous phrase to authorize a signed envelope-key transition
after the user confirms the new phrase. See
account-recovery-v1.md for wire details.