Integrate KeyLockr SSO login and end-to-end encrypted third-party data storage.
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.
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.
On top of login, your app can store E2E-encrypted data in the user's KeyLockr vault. The KeyLockr server never sees the plaintext.
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).
Register your service in the developer console to obtain a permanent svc_id.
| Field | Required | Description |
|---|---|---|
title | Yes | Service name, shown to the user on the authorization screen. |
url | Yes | Your service homepage URL. |
mode | Yes | auth (login only) or data (login + encrypted storage). Defaults to auth. |
server_ips | For backend verify | Allowlist of your backend IPs (newline-separated), used for app_verify calls. |
description | No | Service 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.
All WebSocket traffic uses the KPS (KeyLockr Postal Service) protocol: every message is encrypted and signed. You must implement these primitives:
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)
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.
| id | When |
|---|---|
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. |
Let users sign in to your site by scanning a QR code. Step by step:
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 }
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}
{
action: "app_auth_result",
sso_client_id: string, // your connection's id
safe_id: string, // the user's SafeID (stable user identifier)
}
{ sso_client_id, safe_id, sign_pk } to your own backend.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.
valid=true, trust the client and create a session keyed on safe_id (a stable user identifier).app_connect_req from the tmp.{tmp_id} identity until it returns status='done'.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:
// 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.)
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
const data_enc = nacl.secretbox(yourData, nd, filekey) // same data-nonce nd as above
action: "app_set_data", body: { data_enc }
// -> {}
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.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /v3/handshake | none | Start a session, returns tmp_id. |
| GET | /v3/ws?sig= | sig query param | WebSocket upgrade; sig = base64url(sign("tmp.{id}.{ts}")), KPS thereafter. |
| POST | /v3/app_verify | IP allowlist | Backend identity verification. |
| GET | /v3/key_enc | none | Server box public key. |
| GET | /v3/key_sign | none | Server sign public key. |
| GET | /v3/clock | none | Server time, for clock sync. |
| Action | Caller | Params | Returns |
|---|---|---|---|
app_connect_req | tmp.* | {} | { status, app_id?, user_id?, user_nickname?, user_enc_pk? } |
app_req_filekey | app.* | {} | { status, data_filekey? } |
app_get_data | app.* | {} | { data_plain, data_encrypted } |
app_set_data | app.* | { data_enc } | {} |
app_update | app.* | { name } | { name } |
app_del | app.* | {} | { id_deleted } |
| Event | When | Payload |
|---|---|---|
app_auth_result | User completes QR authorization | { sso_client_id, safe_id, data_plain?, data_encrypted? } |
app_filekey_result | Phone approves a filekey request | { status, data_filekey?, data_enc? } |
app_updated | Connection renamed | { name } |
app_deleted | User revokes the connection | {} |
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})
}
_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.header.ts is older than ~5 minutes or in the future. Sync with /v3/clock first.server_ips allowlist, or it is rejected.app_scan_info, app_add, and all safe_* / file_* actions.For integration questions, contact infosafex.cloud