Developer API

This page fully specifies KeyLockr SSO, end-to-end encrypted AppData, optional Access Point (AP) authorization, and backend verification. Every required wire format, cryptographic rule, error, and example is included.

Overview

One external identity field

All external SSO requests send the effective app_tag. Without a custom tag, the value is the decimal Service ID, but it still travels in app_tag; there is no external svc_id or binding_type.

An SSO binding uses safe_id + app_tag + enc_pk as its persistent identity, while sign_pk is only the current login credential. AP bindings remain identified by safe_id + app_tag + sign_pk. An AppData namespace uses safe_id + app_tag.

The Safe always displays the title registered in MyDeveloper. Handshake name is only the physical device name and cannot spoof the service name.

sso

The base identity for every registered App. Create your own login session from safe_id after Safe approval.

app_data

Optional E2E encrypted namespace. Clients for one Safe/tag share ciphertext while retaining separate keypairs.

ap

Optional, approval-gated Access Point capability with full access or an explicit scope subset.

Delegated SSO quick start

  1. The App calls its own backend /kl/login/start.
  2. The App opens the backend-provided deep link and waits for its registered Universal Link/App Link (recommended) or restricted custom-scheme callback.
  3. The App sends its private finish_secret and opaque assertion to its own backend /kl/login/finish.
Network boundary: the third-party App does not call KeyLockr HTTP/KPS/WebSocket, and its backend does not call KeyLockr during finish. Only KeyLockr Mobile calls the KeyLockr backend as the Safe.

Register a service

my.keylockr.app/developer

FieldOwnerRule
titleDeveloperRequired and public; the authoritative App name shown by the Safe and third-party login pages.
app_tagDeveloperOptional custom identity. Blank uses the decimal Service ID and may be replaced once later. A custom value must be globally unique, non-numeric, and non-reserved; after it is set, it is locked and every client/backend configuration must switch to the new tag.
server_ipsDeveloperOne actual fixed backend egress IPv4 or IPv6 address per line for app_verify and sso_user_pubkey. Dual-stack services must add both addresses. Blank disables both; it does not allow all callers.
data_approvedDeveloperAllows new bindings to request app_data=true.
ap_approvedAdminAllows new bindings to request ap=true.
ap_allowed_scopesAdminMaximum AP scopes; NULL permits a full-AP request.
plugin_approvedAdminApproves direct Mobile Safe access to a plugin backend.
plugin_api_urlAdminApproved plugin backend URL.
sso_key_ownerDeveloperHandshake key owner selected by the service owner: client (default) or backend. Only backend mode overrides the request enc_pk; it supports neither AP nor Delegated SSO.
encrypt_pk_base64DeveloperOptional third-party backend X25519 service key maintained by the service owner in MyDeveloper and always published by /svc_pk. It cannot be cleared while backend ownership or Delegated SSO is enabled. Client ownership may retain this key without changing the App handshake.
auth_request_sign_pk_current_base64DeveloperThe only accepted current Ed25519 public key for delegated requests; standard Base64 decoding to exactly 32 bytes. After replacement, requests later delivered with the old key are rejected.
mobile_return_urlsDeveloperOne exact canonical callback URL per line. Prefer a Universal Link/App Link; a native App's registered custom scheme, such as cing://sso/callback, is also permitted. No query, fragment, wildcard, or dangerous scheme.
delegated_sso_enabledDeveloperPer-service switch, off by default; it can be enabled only when every required field is valid.

An app_tag is a public identifier, not a client secret. App Attest is not part of this flow, and there is no app_attest_challenge API.

Keys and environments

PurposeProductionDevelopment
HTTP APIhttps://api.keylockr.app/v3https://apidev.keylockr.app/v3
WebSocketwss://api.keylockr.app/v3/wswss://apidev.keylockr.app/v3/ws
Server box pkGET /key_encGET /key_enc
Server signing pkGET /key_signGET /key_sign
Deployed buildGET /build_verGET /build_ver

Key endpoints return a standard-Base64 32-byte public key. With client ownership, every physical App/Browser persistently retains its own X25519 box keypair and creates an Ed25519 signing keypair for the current login. After a successful normal logout, it must delete or replace the signing keypair while retaining the box keypair. Even when the third-party backend has a registered service key, the client never receives or uses the backend enc_sk. A backend-owned logical deployment persistently retains one shared X25519 box keypair and maintains a current signing credential per Safe; it registers the X25519 enc_pk in MyDeveloper. Transmit only public keys; private keys never leave their owner.

sign_pk, sign_sk = nacl.sign.keyPair() // 32-byte pk, 64-byte sk
enc_pk, enc_sk   = nacl.box.keyPair()  // 32-byte pk, 32-byte sk
Losing enc_sk loses the original persistent SSO identity and requires QR authorization with a new enc_pk. Losing or deliberately deleting sign_sk invalidates only the current login credential: after approval with the retained encryption keypair, the new sign_pk reuses the same app_id and AppData. An app_id is not a bearer token.

Delegated SSO v1

/start must run immediately before the App shows a QR or opens the deep link. From one start_wall_time snapshot, the backend derives a 600-second challenge deadline and a 720-second retention deadline, using independent CSPRNG outputs for the public challenge and private finish_secret.

POST your-backend.example/kl/login/start

// Cache-Control: no-store
{
  deeplink: string,        // keylockr://sso-request?request=...
  finish_secret: string,   // private, 32-byte base64url
  request_digest: string,  // exact SHA-256(auth_request)
  expires_at: number       // Unix seconds
}

The signed request uses a fixed 11-field MessagePack claim, the current Ed25519 key, and its kid. The challenge is a public transaction ID, not an authorization credential; only the original App holding the finish_secret can finish. The request also signs the app_tag, return URL, capabilities, optional client public keys, device name, and AP scopes.

Delegated v1 accepts client ownership only and requires both client_sign_pk and client_enc_pk. Backend-owned services have no delegated-request signing credential in this wire version and must use the legacy advanced handshake below; an X25519 encrypt_pk_base64 is not a signing key.

A verified-domain Universal Link/App Link is recommended; a registered canonical custom scheme is explicitly allowed. Another App may claim a custom scheme and observe the opaque assertion, but without the backend private key and original finish_secret it cannot finish from the callback alone. The receiver must still verify the KL seal, box, every claim, request digest, and finish_secret; any failure must not call finish, create a session, or revoke a binding.
POST your-backend.example/kl/login/finish
{
  finish_secret: string,
  backend_assertion: string
}

// After offline KL seal + box + claim verification and atomic Redis completion:
{
  session_result: object,
  safe_id: string,
  user_nickname: string
}

Finish does not call KeyLockr or /app_verify. Pending/completed state lives only in Redis. An exact secret/assertion retry returns the same session; mismatches and expired replays return the same generic authentication failure. Before activating the session, the login client must show the verified nickname and full Safe ID for explicit confirmation. Safe ID, not nickname, is the stable identity.

KeyLockr Mobile internally calls app_delegated_info and app_delegated_approve. These Safe KPS actions are not third-party App/backend APIs.

LimitFixed value
auth_request4 KiB decoded
return_url2 KiB UTF-8; no query/fragment
backend assertion / client result3 KiB / 12 KiB
success wrapper / encoded result / callback URL16 KiB / 21,846 chars / 24 KiB
request / assertion lifetime600 seconds / 120 seconds

Delegated v1 does not permit data_deferred or a third-party App fallback to KeyLockr. Any inline overflow ends with app_auth_result_too_large. Rejection is exactly keylockr_result=cancel and must not call finish.

Legacy advanced: capability handshake

This section preserves the existing client-owned/backend-owned handshake, temporary WebSocket, native callback, and /app_verify compatibility flow. New mobile SSO must not silently fall back here after delegated failure.
POST https://api.keylockr.app/v3/handshake
Content-Type: application/octet-stream
Accept: application/octet-stream

// MessagePack, not JSON and not KPS-encrypted
{
  sign_pk: Uint8Array,          // exactly 32 bytes
  enc_pk?: Uint8Array,          // 32 bytes for client ownership; ignored for backend
  app_tag: "your_effective_tag",
  name?: "Work MacBook",        // required for AppData or AP
  app_data?: true,
  ap?: true,
  requested_scopes?: ["2fa"]    // AP only; non-empty when present
}
// -> { _res: "ok", tmp_id: string }

The sign_pk is always required and exactly 32 bytes. With sso_key_owner=client, enc_pk is required and exactly 32 bytes; KL does not override the request key even when encrypt_pk_base64 is registered. With sso_key_owner=backend, KL ignores any supplied or omitted enc_pk and uses only a valid registered 32-byte X25519 service key; a missing or invalid key never falls back.

RequestApprovalnameExpected capabilities
SSOvalid serviceoptional["sso"]
SSO + AppDatadata_approvedrequired["sso","app_data"]
SSO + APap_approvedrequired["sso","ap"]
SSO + AppData + APbothrequired["sso","app_data","ap"]

After trimming, name must contain 1–255 Unicode characters. With ap=true, omitting requested_scopes requests full AP; a restricted service must send a non-empty approved subset. Never send scopes without ap; only sso_key_owner=client may request AP, regardless of whether a separate service key exists.

The tmp_id and its temporary key/capability state live for 3600 seconds. Check _res before reading every HTTP response, even when its HTTP status is 200.

WebSocket, QR, and authorization result

1. Sign the WebSocket query
message = utf8("tmp.{tmp_id}.{unix_seconds}")
signed  = nacl.sign(message, sign_sk) // 64-byte signature || message
sig     = base64url_without_padding(signed)

wss://api.keylockr.app/v3/ws?sig={urlencoded_sig}

Use the NaCl signed-message form, not a detached signature. Timestamps are accepted from 10 minutes in the past through 3 minutes in the future.

2. Show one QR only after the socket connects
sso://login?tmp_id={tmp_id}
keylockr://sso?tmp_id={tmp_id}

Both URI forms use the same tmp_id. Name, AppData, AP, and scopes live in server handshake state and never in the QR. Backend-owned must open connected with its persistent enc_sk before showing the QR; under explicit backend ownership, an untrusted caller cannot decrypt or continue KPS because its handshake enc_pk is replaced by the registered key.

3. Native apps may direct-open and return
keylockr://sso?tmp_id={tmp_id}&return_url={percent_encoded_absolute_uri}

The return_url must be an absolute URI with a valid scheme, is direct-link only, and must never be included in a QR. After approval, the Safe may append keylockr_result={base64url_without_padding(completion_kps)}; it is the complete server-signed app_auth_result KPS encrypted only for this client and is the same completion result delivered by the temporary WebSocket.

Accept exactly one keylockr_result. Base64url-decode it, verify the KPS seal, box, and timestamp, then exactly match tmp_id, identity, and capabilities; the URL itself is not a bearer token. The callback and WebSocket should share one waiter that settles on the first valid result and deduplicates later replays. If no result is present, fall back to the WebSocket.

The Safe rejects about, blob, content, data, facetime, facetime-audio, file, intent, javascript, keylockr, mailto, market, sms, sso, tel, telprompt, and every scheme matching itms* (for example itms-services).

4. Validate the completion event
// Decrypted KPS frame:
header.from = "app_auth_result"
body = {
  _res: "ok",
  status: "done",
  tmp_id: string,
  app_id: string,
  safe_id: string,
  capabilities: string[],
  user_nickname: string,
  user_enc_pk: Uint8Array,
  ap_access?: { full_access: boolean, scopes: string[] },
  account_key_for_ap?: Uint8Array,
  data_filekey?: Uint8Array,
  data_plain?: Uint8Array,
  data_encrypted?: Uint8Array,
  data_deferred?: true,
  ver?: string
}

// Terminal completion when the frame cannot be compacted:
body = {
  _res: "ok",
  status: "error",
  tmp_id: string,
  code: "app_auth_result_too_large"
}
Before saving any ID or key, verify KPS and require a tmp_id exactly matching this handshake before interpreting status. On status=error, require a non-empty code and terminate. Only status=done may proceed to validate app_id/safe_id, capabilities, AppData ver, and AP fields.

Completion may arrive through the temporary WebSocket or a native callback; there is no HTTP polling. After a brief disconnect, reconnect with the same keypair while tmp_id remains valid to replay the completed result.

The completion KPS shared by WebSocket and callback is limited to 12 KiB. data_deferred=true is set only when the Safe supplied a client filekey and AppData content was actually omitted; retain data_filekey and ver, then call app_get_data. Without a client filekey, an older Safe result that would exceed the limit omits only the currently unusable data_encrypted, retains data_plain and ver, and does not set data_deferred; call app_req_filekey for the filekey, then obtain the latest encrypted content from app_filekey_result or app_get_data. A result still over the limit after trimming terminates with app_auth_result_too_large.

The AP Account Key format is nonce(24) || nacl.box(AK, nonce, client_enc_pk, safe_enc_sk). Open it with user_enc_pk and the client enc_sk, require 32 bytes, and never log or upload it.

// Existing AP only: recover a locally lost Account Key.
action: "app_req_account_key"
body: {}

{
  _res: "ok",
  account_key_for_ap: Uint8Array,
  safe_enc_pk: Uint8Array
}
// or { _res: "ok", status: "not_available" }

not_available means the binding never completed Account Key delivery; delete it and authorize again because there is no background repair. A non-AP binding receives app_not_ap.

KPS crypto protocol

Every WebSocket frame and POST /v3/ App action uses binary MessagePack KPS.

Client to server

{
  kps: {
    id: "tmp.{tmp_id}" | "app.{app_id}",
    app_ver: string,
    box: Uint8Array,
    n: Uint8Array,      // exactly 24 random bytes
    raw?: Uint8Array[]
  },
  seal: Uint8Array
}

inner = msgpack({
  header: { ts: unix_seconds, to: action },
  body: action_params
})
box  = nacl.box(inner, n, server_enc_pk, client_enc_sk)
seal = nacl.sign(
  sha256(canonical_msgpack_with_sorted_map_keys(kps)),
  client_sign_sk
)

Server to client

{
  kps: {
    box: Uint8Array,
    n: Uint8Array,
    raw?: Uint8Array[]
  },
  seal: Uint8Array
}

// After seal verification and box.open:
{
  header: { ts: number, from: string },
  body: {
    _res: "ok" | "err",
    code?: string,
    msg?: string
  }
}

Outer, kps, inner, and header are closed core schemas: reject unknown or duplicate fields, trailing values, invalid UTF-8, wrong types, and oversized values. A general action body is an extension boundary: validate _res, required fields, types, and conditions, then ignore unknown fields. Reject unknown action fields only when that action is explicitly documented as exact/closed.

The receive order is fixed: strictly decode outer/core without restoring raw, verify the seal and canonical KPS hash, validate the 24-byte nonce and open the box, strictly decode inner/header, recursively validate every field__ index, raw binary type, bound, and field/field__ collision, materialize the logical body, then validate the action schema and body._res. Abort on any failure.

Raw is only for already encrypted blobs: the seal protects integrity, but the box does not hide it. Even when an unknown extension field will be ignored, its placeholder must pass full validation before materialization.

The seal uses a NaCl signed-message, and canonical MessagePack must sort map keys. header.ts is Unix seconds, not milliseconds, accepted from 10 minutes past through 3 minutes future.

Complete AppData flow

AppData actions use the app.{app_id} KPS identity. KeyLockr stores only an encrypted filekey and application ciphertext and never holds the plaintext filekey.

1. Use the authorization result; request a filekey only when needed

A current Safe's initial app_auth_result directly supplies data_filekey, ver, and existing data_encrypted when it fits the completion limit; a new namespace may be empty. After validation, open the filekey and use that data immediately. If data_deferred=true, call app_get_data directly. Neither path needs a second phone unlock. Use app_req_filekey only when an older Safe omitted data_filekey, the local key was later lost, or a separate re-unlock is required.

For the compatibility path, connect the App WebSocket with nacl.sign("app.{app_id}.{ts}", sign_sk) and register an app_filekey_result waiter before sending the action, so a fast approval cannot be missed.

action: "app_req_filekey"
body: {}

// Immediate or polling KPS response:
{
  _res: "ok",
  status: "done" | "safe_auth_required" | "denied",
  data_filekey?: Uint8Array, // done only
  ver?: string              // done only
}

// App WebSocket completion:
header.from = "app_filekey_result"
body = {
  _res: "ok",
  status: "done" | "denied",
  data_filekey?: Uint8Array,
  data_enc?: Uint8Array,
  ver?: string
}

A safe_auth_required status means the Safe has been notified. Wait for the push while WebSocket is available; use spaced polling only after disconnect or timeout. Polling done does not include data_enc, so call app_get_data next.

2. Open data_filekey

// MessagePack envelope; new data uses full names.
{
  encFileKey: Uint8Array,   // 48 bytes
  nonceForKey: Uint8Array,  // 24 bytes
  nonceForData: Uint8Array, // 24 bytes; v1 read fallback only
  apEncPk: Uint8Array       // 32-byte sender pk
}

// Read-only legacy aliases:
{ k, nk, nd, p }

file_key = nacl.box.open(
  encFileKey,
  nonceForKey,
  apEncPk,
  client_enc_sk
)
// file_key must be exactly 32 bytes.

Readers normalize all four fields with fullName ?? legacyShortName and validate lengths. nonceForData is only for legacy v1 reads and must never be used for new writes.

3. Read and decrypt

action: "app_get_data"
body: {}

// KPS response:
{
  _res: "ok",
  file_id: string,
  ver: string,
  data_plain: Uint8Array,
  data_encrypted: Uint8Array
}

The data_plain field is Safe-created MessagePack metadata {name, app_tag} and is not third-party mutable. Store application data only in data_encrypted.

// Every new write is v2:
nonce = randomBytes(24)
data_encrypted =
  nonce || nacl.secretbox(msgpack(application_data), nonce, file_key)

// Read:
1. If length >= 40, try nonce-prefix v2 and verify its MAC.
2. Only if v2 fails, try the whole blob as v1 with nonceForData.
3. If both fail, abort. Never parse unauthenticated bytes.

4. Write with version CAS

action: "app_set_data"
body: {
  ver: string,          // exact latest value
  data_enc: Uint8Array  // complete v2 nonce-prefixed ciphertext
}

// KPS response:
{ _res: "ok", file_id: string, ver: string }

ver is mandatory. Each operation may read once after the initial write and send at most one more CAS write; a second conflict terminates. For a single-slot payload such as a 4S recovery key, an authoritative value exactly equal to the intended value is idempotent completion: adopt the latest ver without writing. A different value fails closed; never overwrite, concatenate, or choose by timestamp. Only a payload with a defined deterministic field-level merge may merge once and retry once.

Binding lifecycle

Update the local display name

action: "app_update"
body: {
  name?: string,
  name_enc?: Uint8Array
}

// -> { _res: "ok", name?: string, name_enc?: Uint8Array }

At least one of name or name_enc is required. An App may update only its own name, not AP scopes. name_enc uses the AP Account Key nonce(24) || secretbox format; an SSO/AppData client without the Account Key must not fabricate it with another key.

Log out while retaining the binding

action: "app_logout"
body: {}

// -> { _res: "ok", status: "logged_out", app_id: string }

app_logout is SSO-only and authenticates the caller with the current sign_pk. On success, the server clears only that row's sign_pk; the app_id, enc_pk, AppData file, and capabilities remain. The caller then deletes or replaces its local signing keypair while retaining the encryption keypair. After the Safe approves the same enc_pk with a new sign_pk, the server reactivates that same row; every old-credential request fails while logged out. AP clients must not use this action.

Permanently revoke the current binding

action: "app_del"
body: {}

// -> { _res: "ok", id_deleted: string }
app_del permanently revokes the current app_id and is only for explicit user disconnection after a fully verified success; it is not normal logout or a failure-cleanup API. A delegated or alias response does not prove that this transaction created the binding, and the same SSO enc_pk may reuse an existing binding. Callback, finish, session persistence, or AppData failures clear only local pending/session state and never call app_logout or app_del automatically. Explicitly revoking the last binding referencing an AppData file deletes that file in the same transaction.

Backend verification and service keys

POST /v3/app_verify

Content-Type: application/json

{
  app_tag: "your_effective_tag",
  app_id: string,
  safe_id: string,
  sign_pk: string // standard base64, same 32-byte Ed25519 pk as handshake
}

// Normal mismatch:
{ _res: "ok", valid: false }

// Verified:
{ _res: "ok", valid: true, nickname: string }

// Allowlist configuration/rejection:
{ _res: "err", code: "server_verification_disabled" | "ip_not_allowed",
  request_ip: string }

This endpoint is for App/Browser claims with sso_key_owner=client. The caller must be in server_ips. Check _res first; an IP, configuration, or parameter error with _res=err is not valid=false. request_ip is the canonical IPv4/IPv6 used by KL; an invalid source is returned as an empty string rather than reflected. Add a non-empty value in MyDeveloper only after confirming it is your fixed egress. KL never returns the full allowlist. After verification, have your backend issue its own short-lived session; app_id is not a bearer token. The service may separately register encrypt_pk_base64 for data encrypted to the backend, but the App still uses only its own enc_pk/enc_sk; never give the backend enc_sk to the App.

POST /v3/sso_user_pubkey

Content-Type: application/json

{ app_tag: string, safe_id: string }

{ _res: "ok", found: false }
// or
{ _res: "ok", found: true, sign_pk: string } // standard base64 Ed25519 pk

Only an approved plugin backend with server_ips, plugin_approved, and plugin_api_url may call this endpoint. found=false intentionally does not reveal whether approval, binding, or a key is missing.

GET https://my.keylockr.app/svc_pk/{app_tag}

{ _res: "ok", pk_base64: string }

This endpoint only publishes MyDeveloper encrypt_pk_base64; it neither publishes nor infers sso_key_owner. A non-empty value serves service-to-service profile/data encryption independently of plugin approval and handshake ownership. Only when sso_key_owner=backend is separately selected does KL ignore the request enc_pk and require the server to retain the matching enc_sk. Client ownership may keep a non-empty service key while the App handshake still uses its own key.

GET https://my.keylockr.app/svc_info/{urlencoded_app_tag}

{ _res: "ok", title: string }
// Unknown or invalid identity:
{ _res: "err", code: "invalid svc" }

This public JSON endpoint lets third-party login pages retrieve the authoritative MyDeveloper display name. Put app_tag in one path segment using UTF-8 percent-encoding. The response contains only title; it does not return url, description, sso_key_owner, approval state, or private configuration. Treat the title as untrusted text, never inject it as HTML, and cache it for the lifetime of the current service configuration. On error, use your own product fallback name. The endpoint is IP-rate-limited; handle rate-limit responses with the standard try_later rules.

Errors, retries, and troubleshooting

Code/statusRequired handling
binding_key_invalidRequire sign_pk, and the client-owned request enc_pk, to be exactly 32 bytes.
binding_sign_credential_conflictStop: the current sign_pk for this Safe/tag already belongs to another enc_pk identity, or a new signing credential attempted to claim an AP.
binding_enc_identity_conflictStop: the enc_pk identity for this Safe/tag is already used by another binding or type; do not retry through another transport.
sso_key_owner_invalidSet MyDeveloper sso_key_owner to client or backend.
sso_backend_key_requiredBackend ownership requires a registered service encryption key.
sso_backend_key_invalidCorrect the backend-owned service key to a standard-Base64 32-byte X25519 public key; do not fall back to the request key.
sso_backend_key_ap_conflictBackend ownership does not support AP; select client ownership or disable AP, then start a new handshake.
binding_name_invalidAppData/AP requires a 1–255 Unicode-character physical device name.
sso_service_invalidUse the effective app_tag, not a title or svc_id field.
sso_data_not_approvedEnable AppData in MyDeveloper, then start a new handshake.
ap_not_approved / ap_scope_not_approvedRequest only an approved AP capability and scope set.
binding_scope_invalidScopes require ap=true; when present they must be non-empty, non-blank, and unique.
tmpid_expiredCreate a new handshake, temporary WebSocket, and QR.
data_expiredCalibrate Unix-second time with GET /clock, then retry.
sign_invalidStop and ensure sign_sk matches the bound sign_pk.
hash_mismatchFix sorted-key canonical MessagePack.
app_404 / app_pkdata_invalidClear the local app_id and authorize again.
app_action_not_allowedThe binding lacks that capability; do not retry to bypass it.
app_credential_pendingPersistent invalidation is being replayed. Wait briefly and retry a limited number of times; never bypass it with an old session or cache.
app_credential_staleStop the old KPS/WebSocket session and reconnect with the current credential; authorize again only if that credential is also invalid.
server_errorA database or other backend dependency failed temporarily. Wait briefly and retry a bounded number of times with timeout and cancellation; do not log out or clear the keypair.
app_logout_stale_credentialConcurrent reauthorization replaced the current credential; do not clear the new value, and complete only local logout.
app_logout_not_ssoAP does not support normal SSO logout; retain its pairing keypair and use app_del only for permanent disconnection.
app_not_apA non-AP binding cannot request an Account Key.
app_file_not_foundAn auth-only binding has no AppData; authorize again with app_data=true.
file_ver_conflictRead at most once; an equal single-slot value completes idempotently, a different value terminates, and a mergeable payload may write at most once more.
server_verification_disabledConfigure non-empty server_ips in MyDeveloper; request_ip is the canonical observed source, or empty when it cannot be parsed.
ip_not_allowedConfirm request_ip is your fixed egress, then add that IPv4 or IPv6 address to server_ips.
try_later N ...Wait for the specified duration and retry only after a user action.
try_later_auto N ...Wait for the specified duration, then retry automatically.
safe_auth_requiredWait for the filekey WebSocket push; poll only after disconnect or timeout.
deniedStop waiting and show that the Safe denied the request.

Every wait and retry requires a local timeout and cancellation path. Reply to a KPS ping event with KPS pong. Never log private keys, the Account Key, filekeys, or decrypted AppData.

API reference

The authoritative closed JSON schema for /web/* alias login and registration is downloadable separately. Consumers must implement from the document rather than inspect KeyLockr source to infer fields.

Web Auth API.md

Endpoint/actionEncoding/identityPurpose
GET /v3/key_encplain base64 / publicServer X25519 public key.
GET /v3/key_signplain base64 / publicServer Ed25519 public key.
GET /v3/clockJSON / publicUnix-second clock calibration.
GET /v3/build_verJSON / publicCurrent deployed build, not cached.
POST /v3/handshakeMessagePack / publicCreate an App-capability authorization tmp.
GET /v3/ws?sig=KPS / signed queryTemporary or App WebSocket.
POST /v3/KPS / app.*Send an encrypted App action over HTTP.
app_req_filekeyKPS / app.*Request the AppData filekey for an older Safe or a later re-unlock.
app_req_account_keyKPS / app.* APAn authorized AP recovers its Account Key.
app_get_dataKPS / app.*Read AppData metadata, ciphertext, and version.
app_set_dataKPS / app.*Write AppData ciphertext with version CAS.
app_updateKPS / app.*Update the current client display name.
app_logoutKPS / app.* SSOClear the current sign_pk while retaining the enc_pk, app_id, and AppData.
app_delKPS / app.*Permanently revoke the current binding.
ping / pongKPS / current IDConnection verification and keepalive.
POST /v3/app_verifyJSON / service IPBackend verification of an App/Safe binding.
POST /v3/sso_user_pubkeyJSON / approved plugin IPApproved plugin backend reads a bound Safe signing key.
GET my.keylockr.app/svc_pk/{app_tag}JSON / publicThird-party backend service encryption key, independent of ownership.
GET my.keylockr.app/svc_info/{urlencoded_app_tag}JSON / publicPublic authoritative service title for third-party login pages.

app_scan_info, app_add, safe_*, and general file_* actions belong to the Mobile Safe authorization/data flow and are not called by third-party SSO/AppData clients.

Runnable Go sample

delegated.go is the recommended flow. It implements signed /start requests, a strict callback parser, offline KL assertion verification, the finish_secret check, and an atomic completion-store contract. The canonical fixture is shared by 9305, iOS, Android, and this example. main.go/kps.go/kps_decode.go/appdata.go retain only the legacy direct-client compatibility flow; delegated never falls back automatically.

# Delegated fixture and finish verification
env GOWORK=off go test ./...

# Legacy direct-client pure SSO compatibility example
go run . -app-tag YOUR_APP_TAG

# SSO + AppData read
go run . -app-tag YOUR_APP_TAG -app-data

# AppData read plus demo CAS write
go run . -app-tag YOUR_APP_TAG -app-data -write "hello"

# Restricted AP
go run . -app-tag YOUR_APP_TAG -ap -scopes 2fa

# Full AP
go run . -app-tag YOUR_APP_TAG -ap

The delegated example never calls app_verify. In the legacy compatibility command, app_verify succeeds only from a fixed egress IP allowed by server_ips. No example prints private keys, finish secrets, assertions, complete callbacks, the Account Key, filekeys, or decrypted data.

Pre-production completion checklist

Completing this checklist covers every protocol requirement needed to implement and ship independently.