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/dev

FieldRequiredDescription
titleYesService name, shown to the user on the authorization screen.
urlYesYour service homepage URL.
modeYesauth (login only) or data (login + encrypted storage). Defaults to auth.
server_ipsFor backend verifyAllowlist of your backend IPs (newline-separated), used for app_verify calls.
descriptionNoService description.

The svc_id you receive is your permanent service identifier across all API calls. The encryption keypair is generated per session by your client during the handshake (below) — there is nothing to pre-register in the console.

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,
    box: Uint8Array,       // nacl.box( msgpack({header, body}), nonce, serverEncPk, yourEncSk )
    n?:  Uint8Array,       // nonce (omitted -> first 24 bytes of box)
    raw?: any[],           // pre-encrypted blobs that bypass the box
  },
  seal: Uint8Array,        // nacl.sign( sha256(msgpack(kps)), yourSignSk )
}

// decrypted box content:
{
  header: { ts: number,   // unix seconds; rejected if older than ~5 min or in the future
            to: string },  // the action name, e.g. "app_req_filekey"
  body:   { /* action params */ }
}

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? }. After decrypting, verify seal with the server sign key, open box, then 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
  app_tag?: string,       // stable app id, constant across reinstalls (data mode)
}
// -> { tmp_id: string }
  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, app_tag, svc_id) maps to one encrypted record, symmetrically encrypted by a filekey; 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-packed { k, nk, nd } (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 MsgPack struct first:
//   k  = the symmetric filekey, encrypted to your enc_pk
//   nk = nonce used for k
//   nd = nonce for the data secretbox (used by app_get_data / app_set_data)
// Recover the filekey from k with your enc_sk, keep it in memory only — never persist it.
// (Exact byte layout matches the reference KeyLockr client.)
  1. Read data:
action: "app_get_data", body: {}
// -> { data_plain: Uint8Array, data_encrypted: Uint8Array }
const data = nacl.secretbox.open(data_encrypted, nd, filekey)   // nd = data-nonce from data_filekey
  1. Write data (direct overwrite, no revision history):
const data_enc = nacl.secretbox(yourData, nd, filekey)   // same data-nonce nd as above
action: "app_set_data", body: { data_enc }
// -> {}
Reinstall recovery & app_tag

Pass a stable app_tag at handshake (e.g. "acme"). On reinstall or a new device, the phone re-locates the same record by (app_tag, svc_id) and re-encrypts the filekey to your new key. Without an app_tag, each session creates a new 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.*{}{ data_plain, data_encrypted }
app_set_dataapp.*{ data_enc }{}
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: a message is rejected if header.ts is older than ~5 minutes or 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