Developer API

Integrate KeyLockr SSO login and end-to-end encrypted third-party data storage.

Overview

KeyLockr offers third-party services two integration capabilities. Both authorize via a phone QR scan, and the user's private key never leaves their device.

authSSO Login

Users sign in to your site by scanning a QR code with the KeyLockr mobile app. You receive a stable user identifier (SafeID) and nickname. No passwords, no OAuth.

dataEncrypted Storage

On top of login, your app can store E2E-encrypted data in the user's KeyLockr vault. The KeyLockr server never sees the plaintext.

Crypto-identity model (not OAuth)

No client_secret, no redirect_uri, no bearer tokens. Your service is identified by a svc_id, a per-session NaCl keypair, and a server-IP allowlist used for backend verification. Every message is signed and encrypted (the KPS protocol).

1. Register a Service

Register your service in the developer console to obtain a permanent svc_id.

my.keylockr.app/developer

FieldRequiredDescription
titleYesService name, shown to the user on the authorization screen.
server_ipsYesAllowlist of your backend IPs (newline-separated), used for app_verify calls.
data_approvedNo (on by default)Developer-controlled App Data Storage toggle; when off, new bindings are auth-only.

The svc_id you receive is your permanent service identifier across all API calls. App Data Storage is developer-controlled and enabled by default (data); turn it off to make new bindings auth-only. Existing data clients are not revoked. The encryption keypair is generated per session by your client during the handshake below; there is nothing to pre-register.

2. KPS Crypto Basics

All WebSocket traffic uses the KPS (KeyLockr Postal Service) protocol: every message is encrypted and signed. You must implement these primitives:

  • NaCl box (X25519 + XSalsa20-Poly1305) — asymmetric encryption of KPS envelopes.
  • NaCl secretbox — symmetric encryption of app data with a filekey (data mode).
  • NaCl sign (Ed25519) — signs every outgoing message.
  • SHA-256 + MsgPack — message serialization and the signature hash.
Server keys & clock
GET https://api.keylockr.app/v3/key_enc    -> base64 32-byte box public key
GET https://api.keylockr.app/v3/key_sign   -> base64 32-byte sign public key
GET https://api.keylockr.app/v3/clock      -> { ts }   // server unix time (seconds)
KPS envelope

Outgoing (you → server):

{
  kps: {
    id: string,            // "{type}.{id}", e.g. "tmp.abc123" or "app.456"
    app_ver: string,       // advisory client release version; send it on every request
    box: Uint8Array,       // nacl.box( msgpack({header, body}), nonce, serverEncPk, yourEncSk )
    n:   Uint8Array,       // REQUIRED, exactly 24 bytes; no box-prefix fallback
    raw?: Uint8Array[],    // pre-encrypted blobs: outside box confidentiality, inside seal integrity
  },
  seal: Uint8Array,        // nacl.sign( sha256(canonical_msgpack(kps)), yourSignSk )
}

// decrypted box content:
{
  header: { ts: number,   // unix seconds; accepted from 10 min past through 3 min future
            to: string },  // the action name, e.g. "app_req_filekey"
  body:   { /* action params */ }
}

The standalone n field is required and must be exactly 24 bytes; there is no omitted-nonce or box-prefix fallback. seal uses the NaCl signed-message form, and the kps map must use key-sorted canonical MessagePack before hashing. Put only already-encrypted binary blobs in raw.

Server responses mirror this shape, but the header is only { ts, from }. The ok/error flag _res lives inside body ('ok' or 'err'); on error the body is { _res: 'err', code, msg? }. On receipt, first verify seal with the server sign key, then open box, and finally check body._res.

Identity id types
idWhen
tmp.{tmp_id}Before authorization — during handshake and while polling for the result.
app.{sso_client_id}After authorization — permanent identity, for app_req_filekey / app_get_data / app_set_data, etc.

3. SSO Login Flow auth

Let users sign in to your site by scanning a QR code. Step by step:

  1. Your frontend generates a NaCl keypair and handshakes with KeyLockr:
POST https://api.keylockr.app/v3/handshake     // Content-Type: application/octet-stream (msgpack)
{
  sign_pk: Uint8Array,    // your Ed25519 sign public key (32 bytes)
  enc_pk:  Uint8Array,    // your X25519 box public key (32 bytes)
  name:    string,        // display name, e.g. "Acme Web"
  svc_id:  string,        // your registered svc_id
}
// -> { _res: "ok", tmp_id: string }   // tmp_id expires after 3600 seconds
  1. Open a WebSocket and show the user a QR code. The WS connection needs a sig query param for auth (proving you hold the handshake sign_sk), so the server can route the authorization push to you:
// WS auth: sig = base64url( nacl.sign("tmp.{tmp_id}.{ts}", yourSignSk) )   // ts = unix seconds
wss://api.keylockr.app/v3/ws?sig={sig}

// QR shown to the user:
keylockr://sso?tmp_id={tmp_id}
  1. The user scans it with the KeyLockr mobile app and confirms.
  2. Your frontend receives a push over the WebSocket:
{
  action: "app_auth_result",
  sso_client_id: string,   // your connection's id
  safe_id:       string,   // the user's SafeID (stable user identifier)
}
  1. Your frontend sends { sso_client_id, safe_id, sign_pk } to your own backend.
  2. Your backend verifies with KeyLockr (must originate from an IP in your server_ips allowlist):
POST https://api.keylockr.app/v3/app_verify
{
  svc_id:        string,
  sso_client_id: string,
  safe_id:       string,
  sign_pk:       string,   // base64-encoded, the same sign_pk used in the handshake
}
// -> { _res: "ok", valid: boolean, nickname?: string }
//    on failure (e.g. caller IP not in server_ips): { _res: "err", code }
//    Check _res first — a hard failure is NOT the same as valid:false.
  1. On valid=true, trust the client and create a session keyed on safe_id (a stable user identifier).
Polling alternative: instead of waiting for the WS push, you may repeatedly call app_connect_req from the tmp.{tmp_id} identity until it returns status='done'.

4. App Data Storage data

Beyond login, your app can store E2E-encrypted data in the user's vault. Each user has one encrypted record per svc_id, symmetrically encrypted by a random 32-byte NaCl secretbox filekey (not an AES key); KeyLockr stores only ciphertext.

In data mode, after authorization reconnect the WebSocket as app.{sso_client_id} (again with the sig query param, this time signing "app.{sso_client_id}.{ts}"), then:

  1. Request the filekey from the phone:
// KPS action (over WS)
action: "app_req_filekey", body: {}
// -> { status: "done" | "safe_auth_required" | "denied", data_filekey?: Uint8Array }

// First call queues a request and notifies the phone -> "safe_auth_required".
// After the user approves, a push arrives:
{
  action: "app_filekey_result",
  status: "done",
  data_filekey: Uint8Array,   // MsgPack v2 envelope (see below), E2E to YOUR enc_pk
  data_enc:     Uint8Array,   // the current encrypted data (saves one app_get_data)
}
// data_filekey is NOT a raw box. Unpack the current full-name MsgPack struct:
// {
//   encFileKey: Uint8Array,   // 48 bytes: crypto_box_easy(32-byte filekey, nonceForKey, yourEncPk, safeEncSk)
//   nonceForKey: Uint8Array,  // 24 bytes
//   nonceForData: Uint8Array, // legacy v1 data fallback only; never use for v2 writes
//   apEncPk: Uint8Array,      // 32-byte Safe sender public key
// }
const rawFk = msgpack.decode(data_filekey)
const fk = { // dual-vocab read; all new writers use the full names
  encFileKey: rawFk.encFileKey ?? rawFk.k,
  nonceForKey: rawFk.nonceForKey ?? rawFk.nk,
  nonceForData: rawFk.nonceForData ?? rawFk.nd,
  apEncPk: rawFk.apEncPk ?? rawFk.p,
}
const filekey = nacl.box.open(fk.encFileKey, fk.nonceForKey, fk.apEncPk, yourEncSk)
if (!filekey || filekey.length !== 32) throw new Error('invalid filekey envelope')
// Keep the recovered filekey in memory only — never persist it.
  1. Read data:
action: "app_get_data", body: {}
// -> { file_id: string, data_plain: Uint8Array, data_encrypted: Uint8Array }
// v2 is self-describing: [24-byte random nonce] || secretbox ciphertext.
const nonce = data_encrypted.slice(0, 24)
const ciphertext = data_encrypted.slice(24)
let data = nacl.secretbox.open(ciphertext, nonce, filekey)
// Migration compatibility: if the v2 MAC fails, try the whole blob as v1 with fk.nonceForData.
if (!data) data = nacl.secretbox.open(data_encrypted, fk.nonceForData, filekey)
  1. Write data (direct overwrite, no revision history):
// Every v2 write MUST use a fresh random nonce and prefix it to the ciphertext.
const nonce = randomBytes(24)
const ciphertext = nacl.secretbox(yourData, nonce, filekey)
const data_enc = concat(nonce, ciphertext)
action: "app_set_data", body: { data_enc }
// -> { file_id: string }
Reinstall recovery

On reinstall or a new device, the phone re-locates the same record by user and svc_id, then re-encrypts the filekey to your new public key. sign_pk and enc_pk identify the current connection, not the stored record.

By design, app_set_data creates no revision (to preserve forward secrecy). The filekey lives in memory only; after an app restart, re-request authorization from the phone.

5. API Reference

HTTP endpoints
MethodPathAuthPurpose
POST/v3/handshakenoneStart a session, returns tmp_id.
GET/v3/ws?sig=sig query paramWebSocket upgrade; sig = base64url(sign("tmp.{id}.{ts}")), KPS thereafter.
POST/v3/app_verifyIP allowlistBackend identity verification.
GET/v3/key_encnoneServer box public key.
GET/v3/key_signnoneServer sign public key.
GET/v3/clocknoneServer time, for clock sync.
KPS actions (WebSocket)
ActionCallerParamsReturns
app_connect_reqtmp.*{}{ status, app_id?, user_id?, user_nickname?, user_enc_pk? }
app_req_filekeyapp.*{}{ status, data_filekey? }
app_get_dataapp.*{}{ file_id, data_plain, data_encrypted }
app_set_dataapp.*{ data_enc }{ file_id }
app_updateapp.*{ name }{ name }
app_delapp.*{}{ id_deleted }
WebSocket push events
EventWhenPayload
app_auth_resultUser completes QR authorization{ sso_client_id, safe_id, data_plain?, data_encrypted? }
app_filekey_resultPhone approves a filekey request{ status, data_filekey?, data_enc? }
app_updatedConnection renamed{ name }
app_deletedUser revokes the connection{}

6. Go Sample

A standalone, runnable minimal Go client that walks the full SSO login flow (fetch keys → handshake → show QR → wait over WS → backend app_verify). It depends on just three public libraries, none of them KeyLockr-internal:

golang.org/x/crypto/nacl/{box,sign}   // E2E encryption + signatures
github.com/vmihailenco/msgpack/v5      // wire format (MUST sort map keys)
github.com/gorilla/websocket           // WebSocket

Full source (go.mod + kps.go + main.go) in the repo: server9304_kl/examples/sso-go/

The crux is sealing/opening the KPS envelope. Note that msgpack must sort map keys — seal hashes msgpack(kps) independently on each side, so non-canonical encoding fails verification:

// canonical msgpack — keys sorted, matches the KeyLockr server
func pack(v any) []byte {
    var buf bytes.Buffer
    enc := msgpack.NewEncoder(&buf)
    enc.SetSortMapKeys(true)   // required
    enc.Encode(v)
    return buf.Bytes()
}

// build an outgoing KPS message: { kps: {id, box, n}, seal }
func (c *Client) SealKPS(action string, body map[string]any) []byte {
    inner := map[string]any{
        "header": map[string]any{"ts": time.Now().Unix(), "to": action},
        "body":   body,
    }
    var nonce [24]byte
    io.ReadFull(rand.Reader, nonce[:])
    sealed := box.Seal(nil, pack(inner), &nonce, c.srvEncPk, c.encSk)

    kps := map[string]any{"id": c.id, "box": sealed, "n": nonce[:]}
    hash := sha256.Sum256(pack(kps))                  // hash over the kps map
    seal := sign.Sign(nil, hash[:], c.signSk)         // sign the hash
    return pack(map[string]any{"kps": kps, "seal": seal})
}

Notes

  • All plain HTTP endpoints (handshake / app_verify) wrap their response in _res: { _res: 'ok', … } on success, { _res: 'err', code } on failure. Always check _res before using the payload — don't treat a hard failure as a normal result.
  • Timestamp validity: header.ts is accepted from 10 minutes in the past through 3 minutes in the future. Sync with /v3/clock first.
  • app_verify must originate from an IP in your server_ips allowlist, or it is rejected.
  • safe_id is the user's permanent identifier within KeyLockr; use it as your user ID.
  • The filekey is never persisted — keep it in memory only; re-authorize after an app restart.
  • These actions are internal to the KeyLockr mobile app and are not called by third parties: app_scan_info, app_add, and all safe_* / file_* actions.

For integration questions, contact infosafex.cloud