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.
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 header — authentication_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:
| Header | Value |
|---|---|
X-Keon-Timestamp | Unix 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-Signature | Lowercase 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
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
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 theauth_keyvalue sent in the request body. The server stores it asauth_key_hashfor subsequent verifications. - Every request after that (GET, or subsequent PUT): the key is whatever the server has stored as
auth_key_hashfor thatlookup_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.
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:
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:
| Field | Required | Notes |
|---|---|---|
blob | yes | The encrypted ciphertext as a string (typically base64). Server stores it verbatim. |
version | yes | Integer. Must be strictly greater than the server's currently-stored version. On first PUT, ≥ 1. |
auth_key | only on first PUT for this lookup_hash | Hex 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. |
1{2 "blob": "<base64 ciphertext>",3 "version": 8,4 "auth_key": "<hex, first PUT only>"5}Responses
200 OK — write accepted:
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.
1{2 "error": "Version conflict",3 "server_version": 84}400 Bad Request — missing blob, or first PUT (no stored row) without an auth_key.
403 Forbidden — missing headers, expired timestamp, or signature mismatch.
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:
- Client generates a random
auth_key(e.g. 32 random bytes, hex-encoded). - Client derives
lookup_hashfrom the user's master key. - Client encrypts its state →
blob. - Client computes
sha256(blob), then signs the PUT messagePUT:<lookup_hash>:<ts>:<body_hash>using the newly-generatedauth_key. - PUTs
{blob, version: 1, auth_key}withX-Keon-TimestampandX-Keon-Signatureheaders. - Server stores all three; returns
{version: 1, status: "ok"}.
On every subsequent device that needs the same blob:
- Client derives the same
auth_keyandlookup_hashfrom the master key (both deterministic from the same secret). - 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.)
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
| Code | When |
|---|---|
200 | GET success / PUT accepted |
400 | Missing blob; first PUT without auth_key |
403 | Missing headers; expired timestamp (> 300 s skew); signature mismatch |
404 | GET when no blob is stored for this lookup_hash (checked before signature verify, so a 404 here doesn't mean your signature was wrong) |
409 | PUT with version <= stored_version |
See also
- Keon Wallets Directory - the public-facing wallet registry