Encrypted Sync (Keon)

How Keon Wallet syncs encrypted state between devices

The endpoint Keon Wallet uses to sync state between a user's devices. The blob is encrypted client-side before it leaves the device — the server stores opaque ciphertext and never sees plaintext.

Info

Most readers will not call this directly. It's the wallet's plumbing. We document it for transparency and so wallet implementers have a precise reference.

Authentication

The endpoint does not use the api-key headerauthentication_classes and permission_classes are explicitly empty on SyncView. Authentication is purely HMAC-SHA256, keyed with a per-user auth_key that the client generates locally during wallet creation. The API key is irrelevant here; sending one is harmless but ignored.

Two headers are required on every request:

HeaderValue
X-Keon-TimestampUnix timestamp in seconds as an ASCII integer. Must be within 5 minutes (300 s) of server time. Out-of-window requests get 403 Request expired.
X-Keon-SignatureLowercase hex HMAC-SHA256 digest. See the message construction below.

Missing either header → 403 Missing signature or timestamp. Bad signature → 403 Invalid signature.

Signing — the exact message bytes

The HMAC message is method-aware. Get it wrong and every request fails verification.

GET request message

Code
txt
1GET:<lookup_hash>:<timestamp>

Three ASCII fields, colon-separated, no trailing newline. <lookup_hash> is the path segment as the server sees it (lowercase hex, as you put it in the URL). <timestamp> is the exact string you put in X-Keon-Timestamp.

PUT request message

Code
txt
1PUT:<lookup_hash>:<timestamp>:<sha256(blob_string)>

<sha256(blob_string)> is the lowercase hex SHA-256 of the blob field's string value — not the whole JSON body, not the body bytes. If the blob is base64 ciphertext like "Zm9v...", you hash that string verbatim (the bytes of those ASCII characters), without the surrounding JSON quotes.

Key used for HMAC

  • First PUT for a lookup_hash (registration): the key is the auth_key value sent in the request body. The server stores it as auth_key_hash for subsequent verifications.
  • Every request after that (GET, or subsequent PUT): the key is whatever the server has stored as auth_key_hash for that lookup_hash. The client must hold the same value locally.

Note that auth_key_hash is the literal auth_key value — it's a name from the model field, not an additional hash step. Both sides HMAC with the same hex string.

Reference signing code

The auth_key must be a hex-encoded byte string (any length the client picks — 32 bytes / 64 hex chars is a sensible default). The server treats it as opaque hex and feeds it to bytes.fromhex(...) before HMAC.

Retrieve the encrypted blob

GET /api/keon/sync/{lookup_hash}/

lookup_hash is a deterministic, opaque identifier the client derives from the user's master key (no user identity leaks). Hex string in the URL path.

Code
bash
1TS=$(date +%s)
2SIG=$(echo -n "GET:$LOOKUP_HASH:$TS" | openssl dgst -sha256 -hmac "$AUTH_KEY_BIN" -hex | awk '{print $2}')
3 
4curl "https://api.tokenkithq.io/api/keon/sync/$LOOKUP_HASH/" \
5 -H "X-Keon-Timestamp: $TS" \
6 -H "X-Keon-Signature: $SIG"

Responses

200 OK — blob exists and signature verified:

Code
json
1{
2 "version": 7,
3 "blob": "<base64 ciphertext>",
4 "last_modified": "2026-05-23T11:42:18+00:00"
5}

404 Not Found — no blob stored for this lookup_hash yet. Treat as "fresh device, no prior sync".

403 Forbidden — one of: missing headers, expired timestamp, no blob exists for that hash (so server cannot verify), or signature mismatch. Body carries an error string identifying which.

Store or update the blob

PUT /api/keon/sync/{lookup_hash}/

Request body:

FieldRequiredNotes
blobyesThe encrypted ciphertext as a string (typically base64). Server stores it verbatim.
versionyesInteger. Must be strictly greater than the server's currently-stored version. On first PUT, ≥ 1.
auth_keyonly on first PUT for this lookup_hashHex string. Becomes the HMAC key for all future requests on this hash. Omit on subsequent PUTs — the server uses its stored copy and ignores any value sent here.
Code
json
1{
2 "blob": "<base64 ciphertext>",
3 "version": 8,
4 "auth_key": "<hex, first PUT only>"
5}

Responses

200 OK — write accepted:

Code
json
1{
2 "version": 8,
3 "status": "ok"
4}

409 Conflict — version is not newer than what's stored. The doc-stated rule is exact: server rejects when request.version <= stored_version. Re-fetch with GET, merge locally, and retry with stored_version + 1.

Code
json
1{
2 "error": "Version conflict",
3 "server_version": 8
4}

400 Bad Request — missing blob, or first PUT (no stored row) without an auth_key.

403 Forbidden — missing headers, expired timestamp, or signature mismatch.

Warning

Version-conflict semantics changed from what older client code may assume. Equal versions also conflict (<=, not <). Always increment past the server's reported server_version, never just write back what you had.

Registration walkthrough

For a brand-new wallet on a brand-new lookup_hash:

  1. Client generates a random auth_key (e.g. 32 random bytes, hex-encoded).
  2. Client derives lookup_hash from the user's master key.
  3. Client encrypts its state → blob.
  4. Client computes sha256(blob), then signs the PUT message PUT:<lookup_hash>:<ts>:<body_hash> using the newly-generated auth_key.
  5. PUTs {blob, version: 1, auth_key} with X-Keon-Timestamp and X-Keon-Signature headers.
  6. Server stores all three; returns {version: 1, status: "ok"}.

On every subsequent device that needs the same blob:

  1. Client derives the same auth_key and lookup_hash from the master key (both deterministic from the same secret).
  2. GET with HMAC signed by auth_key.

If the client cannot regenerate auth_key deterministically from the master key, the user can't sync across devices — the auth_key needs to be reproducible, not random, for multi-device wallets. (Pick the right KDF for your use case.)

Warning

lookup_hash must be unguessable. It's the only thing identifying a blob — anyone who knows it and the corresponding auth_key can read and overwrite the blob. Derive it from the master key with a fixed-purpose KDF; do not hand-roll.

Status codes summary

CodeWhen
200GET success / PUT accepted
400Missing blob; first PUT without auth_key
403Missing headers; expired timestamp (> 300 s skew); signature mismatch
404GET when no blob is stored for this lookup_hash (checked before signature verify, so a 404 here doesn't mean your signature was wrong)
409PUT with version <= stored_version

See also