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. |
server_ips | Yes | Allowlist of your backend IPs (newline-separated), used for app_verify calls. |
data_approved | No (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.
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, // 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.
| 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
}
// -> { _res: "ok", tmp_id: string } // tmp_id expires after 3600 seconds
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 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:
// 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.
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)
// 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 }
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.
| 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.* | {} | { file_id, data_plain, data_encrypted } |
app_set_data | app.* | { data_enc } | { file_id } |
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 accepted from 10 minutes in the past through 3 minutes 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