Skip to content
SafePass

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 SPCR v1 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.
  • cursor is a nonnegative decimal integer no greater than 9223372036854775807, 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.

Documentation