KeyAuth
Sign in

KA1 protocol

KA1 is the wire format. One endpoint, one method, one envelope shape, and no key material ever transmitted.

Transport

Every call is a POST of a JSON envelope to https://keyauth.devnguvcl.dev/api/v1/gateway. The action name lives inside the ciphertext, so an observer cannot distinguish a login from a heartbeat.

Envelope
{
  "v":   1,
  "app": "<32 hex>",
  "sid": "<32 hex or empty>",
  "ts":  <unix seconds>,
  "n":   "<24 hex nonce>",
  "iv":  "<24 hex>",
  "ct":  "<base64>",
  "tag": "<32 hex>",
  "sig": "<64 hex>"
}
Payload
request  { "a": "login", "p": { … } }

success  { "ok": true,  "t": …, "d": { … } }
failure  { "ok": false, "t": …,
           "e": "INVALID_KEY",
           "m": "…" }

Key derivation

Both sides compute this independently
salt    = SHA-256( "KA1" 0x00 appId 0x00 sessionId 0x00 nonce 0x00 binding )
encKey  = HKDF-SHA256( appSecret, salt, "KA1|<dir>|enc", 32 )
macKey  = HKDF-SHA256( appSecret, salt, "KA1|<dir>|mac", 32 )

dir     = "c2s" for requests, "s2c" for responses
binding = "" for requests, the raw request signature for responses

aad     = "KA1" 0x1f v 0x1f app 0x1f sid 0x1f ts 0x1f nonceHex 0x1f dir
ct, tag = AES-256-GCM( encKey, iv, payload, aad )
sig     = HMAC-SHA256( macKey, aad 0x1f ivHex 0x1f ctBase64 )

Four properties follow from that construction, and each one blocks a specific attack:

  • No key on the wire. Both sides derive from the application secret, so capturing traffic yields nothing reusable.
  • Direction separation. Requests and responses use different dir values, so a recorded request can never be replayed back as a response.
  • Response binding. The response salt mixes in the request signature, so a captured response is useless against any other request.
  • Header authentication. The envelope header is the AEAD associated data, so app id, session, timestamp and nonce are all covered by the tag and cannot be edited in flight.

Treat plaintext failures as fatal

Errors raised before the envelope can be opened, such as UNKNOWN_APP or BAD_SIGNATURE, are necessarily returned in plaintext. A correct client must treat any unsigned response as a hard failure. Accepting one as success is how a man in the middle would inject a fake “login ok”.

Session lifecycle

  1. init with an empty sid. The server checks version and blacklists, opens a session and returns the new session id inside the encrypted payload.
  2. Every later call carries that sid in the envelope header.
  3. login validates the key, binds the device and returns the active release.
  4. validate extends the session and re-checks the key, so a ban takes effect mid session.
  5. logout closes it, or it expires after the configured lifetime.

The response to init is keyed with the request session id, which is empty. A client cannot derive keys for an id it has not received yet, which is why the new id travels in the payload rather than the header.

Actions

ActionPurpose
initVersion check, blacklist check, opens a session.
loginValidates a key, binds devices, returns the active release.
validateHeartbeat, extends the session, re-checks the key.
logoutCloses the session immediately.
logWrites a client message into the activity log.
varFetches one encrypted application variable.
file.listRe-reads the active release, optionally with inline payloads.
file.chunkFetches one encrypted chunk of the file behind a JSON key.
banClient initiated ban. Disabled unless enabled per application.

Replay and abuse protection

  • Nonce cache. A nonce seen once is refused with REPLAY_DETECTED.
  • Timestamp window. Outside the per application tolerance you get CLOCK_SKEW.
  • Rate limits. Per IP per application, plus a stricter counter on failed logins.
  • Blacklists. IP and hardware id, checked on every authenticated request.

Implementing this yourself? Writing your own client has a language agnostic checklist and test vectors.