Non-Interactive Session Tokens (NISTs)
To achieve fast reconnects after network outages, Keybase allows clients to authenticate users via Non-Interactive Session Tokens (NISTs). With NISTs, the client doesn't have to wait for a challenge from the server; it can just sign a statement with its private key over session credentials.
To generate a NIST, the client first posts a "long-form NIST" which is a signature made with the user's private device key. The payload of the signature is an array of the form:
payload = [ 34, // Hard-coded constant, for this version of session token strings 1, // implies "long-form", and 2 implies "short-form" "keybase.io", // the host we're authenticating to uid, // encoded in binary device_id, // encoded in binary kid, // key ID, encoded in binary generated, // in seconds since 1970 UTC lifetime, // Seconds it can be valid for, no more than 2 days session_id // a random 16-byte session ID, only can be used once, binary encoded ]
The user's client then generates a signature with the
key that corresponds to
in the above payload:
sig = sign(key, "Keybase-Auth-NIST-1\0" + msgpack(payload))
"Keybase-Auth-NIST-1\0" is a context string so that these signatures cannot be
used in other conexts (like signing chats or signing updates to the user's sigchain). The
msgpack is the canonical packing via the Msgpack encoding
Next, we make an abbreviated version of the above signature payload, so that we don't
need to send unnecessary data over to the server. These fields must have the same
values as in
payload. Elided values are imputed by the server:
payload_short = [ uid, device_id, generated, lifetime, session_id ]
Finally, this signature is wrapped up and encoded to a base64 string:
long_token_binary = msgpack([ 34, // Same version as above 1, // Same "long-form" mode as above sig, // the output of the signature (in binary) payload_short // an abbreviated version of payload ]) long_token = long_token_binary.toString('base64')
long_token is then supplied as a
X-Keybase-Session HTTP header, and can be sent over
to the server piggy-backing on any sort of request.
Once a long-form NIST has been accepted by the server, and the server replies with an
HTTP OK, then it's safe to use a short-form version of a previously-posted long-form NIST,
which is a hash:
short_token = msgpack([ 34, 2, SHA256(long_token_binary)[0:19] ]).toString('base64')
long_token_binary is the binary encoding of the long-form NIST from above (before
2 in the "mode" field implies short-form. The
string can be specified in a
X-Keybase-Session header field, where it's synonymous
with the long-form NIST it's derived from.
To save some bandwidth, we're only using the first 19 bytes of the SHA256 of the long token. If Keybase had 224 concurrently active users, an attacker would have a 1 in 2128 chance of guessing a session token, which is small enough to be comfortable with. We don't need the full collision-resistence property of SHA256 here, and we can shave bandwidth.
The Finer Points
NISTs stay valid until
generated+lifetime, as computed by the server's clock. If the
client uploads a NIST with a
generated time more than a day away from the current time,
or with a
lifetime that's too short or long, or with
generated+lifetime in the past,
then the NIST is immiedately rejected.
When a user deletes or resets his/her account, or revokes the signing device, the NIST (whether short-form or long-form) is also revoked and is no longer valid.
Clients must manage token lifetime themselves, and know to refresh when their tokens are running out of time.
Pssst, we're hiring.