Authentication
The design goal is narrow and specific: a node proves its identity with a credential that is bound to the device — useless if exfiltrated — and rotatable.
Enrolment, once
1. Operator triggers pairing in the app.
2. Backend issues a short-lived, single-use pairing code (or a QR encoding
{backendUrl, pairingCode}). The operator enters or scans it.
3. Node POSTs /enroll {pairingCode, deviceInfo, nonce, installId} over HTTPS.
4. Backend validates, registers the device, returns a long-lived
DEVICE CREDENTIAL.
5. Node stores it in DeviceCredentialStore: a Keystore-wrapped key sealing
EncryptedSharedPreferences. Plaintext never hits disk.See Pairing for the operator walkthrough and REST API for the exact contract.
Session auth, every connect
The WSS handshake carries the device credential — an Authorization header, or
a first AUTH frame where headers are not available. The backend validates it
and binds the session to a deviceId.
On an invalid or expired credential the socket is refused, and the node pauses rather than reconnecting. A revoked credential retried with exponential backoff across a fleet is a denial-of-service against your own server. The node falls back to a re-enrolment prompt instead.
Why bearer-token-in-Keystore for v1
mTLS is stronger. It is also considerably more complex to provision, and that complexity lands on every backend author and every operator.
A Keystore-bound bearer token is the simplest thing that is genuinely secure
when stored correctly, easy for any backend to validate, and easy for future
non-Android nodes to reproduce. The protocol reserves publicKey on the
enrolment request specifically so that request signing or mTLS can be adopted
later without a redesign.
“Keystore-bound” is the load-bearing part. The credential is sealed by a key that cannot leave the device’s hardware-backed keystore, so copying the file off the phone yields nothing usable.
Rotation
The backend pushes a new credential over the authenticated channel:
{ "kind": "command", "type": "config_update", "payload": { "credential": "…" } }Rotation is seamless — no interruption, no operator involvement. Credentials carry an expiry and the node refreshes before it lapses.
Rotate when:
- a credential is nearing expiry (automatic)
- you suspect compromise
- an operator with access to the backend leaves
- on a schedule, if your policy requires one
Revocation
{ "kind": "command", "type": "revoke", "payload": {} }Triggers a full node reset — credential, queues, policy, socket, watchdog. The device returns to its unpaired state and requires physical access to re-pair.
wipe does the same and also removes message history from the device. It is the
remote kill switch for a lost or stolen handset.
Because the credential is already Keystore-bound and useless off-device, the urgency of a remote wipe is about the message history on the phone rather than about the credential itself.
Server-side responsibilities
The core owns node credential auth — hashing, constant-time comparison, rotation and revocation — because it is protocol-defined and security-critical, and implementing it once beats implementing it in every adapter.
You own operator and API-consumer auth: whoever calls luno.sms.send must
be authenticated by your application, which passes an authenticated Principal
into the core. The core then authorizes — does this principal own this device? —
but never authenticates. See the two
axes.
An unauthenticated endpoint that forwards to luno.sms.send is an open SMS
relay pointed at a SIM you are paying for. The SDK cannot prevent this — the
authentication boundary is deliberately yours, because only you know what a
principal is in your system.