Kyndex
Getting Started

Authentication

OPAQUE password-authenticated key exchange — registration and login flows.

Literal uses the OPAQUE protocol for authentication—a password-authenticated key exchange that ensures your password never leaves your device. The flow has two phases: registration and login, each with a start and finish step.

All auth endpoints are rate-limited. Repeated failures will result in 429 Too Many Requests.

Note: OPAQUE requires a client-side library to handle the cryptographic handshake. We recommend @serenity-kit/opaque for JavaScript/TypeScript environments, which is battle-tested and actively maintained. The Literal SDK (coming soon) will wrap this handshake automatically — for now, your client must orchestrate the two-step registration and login flows.

Register an Account

Registration is a two-step OPAQUE handshake. Your client will use an OPAQUE library (e.g., @serenity-kit/opaque) to orchestrate the exchange.

Prerequisite — Deriving login_bidx: Both registration and login require a login_bidx value derived via an OPRF challenge. If you have not done this yet, see Deriving The Login Bucket first.

Step 1 — Start registration

Send your client-derived login blind index and the blinded OPAQUE registration request from the client library:

curl -X POST https://api.kyndex.co/v1/auth/opaque/register-start \
  -H 'Content-Type: application/json' \
  -d '{
    "login_bidx": 42,
    "registration_request": "<base64-opaque-blinded-request>"
  }'

Request body:

FieldTypeDescription
login_bidxintegerOPRF-derived login bucket (integer 0-8191)
registration_requeststringOPAQUE blinded registration request (base64), produced by opaque.client.startRegistration()

Response (200 OK):

{
  "registration_response": "<base64-opaque-server-message>"
}
FieldDescription
registration_responseServer's OPAQUE registration response (base64), passed to opaque.client.finishRegistration()

Step 2 — Finish registration

Pass the server's response through your OPAQUE client library. It will produce a registration_record (the authentication verifier) and you will derive your UMK (User Master Key) using Argon2id. Encrypt your private keys with the UMK, then send everything to the server:

curl -X POST https://api.kyndex.co/v1/auth/opaque/register-finish \
  -H 'Content-Type: application/json' \
  -d '{
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "login_bidx": 42,
    "registration_record": "<base64-opaque-registration-verifier>",
    "encryption_salt": "<base64-32-bytes>",
    "mlkem_public_key": "<base64-1568-bytes>",
    "x25519_public_key": "<base64-32-bytes>",
    "mlkem_private_encrypted": "<base64-aes-256-gcm-ciphertext>",
    "signing_public_key": "<base64-1984-bytes>",
    "signing_private_encrypted": "<base64-aes-256-gcm-ciphertext>",
    "recovery_key_encrypted": "<base64-optional-recovery-key>",
    "umk_backup": "<base64-optional-backed-up-umk>"
  }'

Request body:

FieldTypeRequiredDescription
idUUIDYesClient-generated user UUID
login_bidxintegerYesOPRF-derived login bucket (integer 0-8191, must match step 1)
registration_recordstringYesOPAQUE registration record from opaque.client.finishRegistration() (base64)
encryption_saltstringYes32-byte random salt for UMK derivation (base64)
mlkem_public_keystringYesML-KEM-1024 public key, 1568 bytes (base64)
x25519_public_keystringYesX25519 public key, 32 bytes (base64)
mlkem_private_encryptedstringYesML-KEM private key encrypted with UMK (base64)
signing_public_keystringYesHybrid ML-DSA-65 + Ed25519 signing public key, 1984 bytes (base64)
signing_private_encryptedstringYesSigning private key encrypted with UMK (base64)
recovery_key_encryptedstringNoOptional recovery key, encrypted with UMK (base64). Required only if umk_backup is also provided.
umk_backupstringNoOptional UMK encrypted with recovery key (base64). Required only if recovery_key_encrypted is also provided.

Response (201 Created):

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "created_at": "2026-01-15T10:30:00.000Z"
}
FieldDescription
idUnique user ID (UUID)
created_atAccount creation timestamp (ISO 8601)

Zero-Knowledge Note — Neither your password nor your email appear in any request. The server receives only a client-derived blind index (login_bidx) and OPAQUE protocol messages. The OPAQUE protocol ensures the server stores only a verifier (registration_record) — it cannot derive your password from it. Your private keys and email are encrypted with your UMK (User Master Key, derived client-side via Argon2id) before transmission. The server stores only the ciphertext; decryption happens on your device.

Log In

Login is also a two-step OPAQUE handshake. Your client uses the OPAQUE library to orchestrate the exchange.

Step 1 — Start login

Send your client-derived login blind index and the blinded OPAQUE login request:

curl -X POST https://api.kyndex.co/v1/auth/opaque/authenticate-start \
  -H 'Content-Type: application/json' \
  -d '{
    "login_bidx": 42,
    "login_request": "<base64-opaque-blinded-login-request>"
  }'

Request body:

FieldTypeDescription
login_bidxintegerOPRF-derived login bucket (integer 0-8191)
login_requeststringOPAQUE blinded login request (base64), produced by opaque.client.startLogin()

Response (200 OK):

{
  "login_responses": [
    "<base64-opaque-candidate-0>",
    "<base64-opaque-candidate-1>",
    "..."
  ],
  "login_session_id": "c8a6b7f8-d9e0-4516-a2b3-c4d5e6f7a8b9"
}
FieldDescription
login_responsesPadded array of OPAQUE login response candidates (base64). Contains real and dummy entries, shuffled. See note below.
login_session_idShort-lived session ID (UUID), valid for 5 minutes. Required for step 2.

Multi-candidate login: The server pads the login_responses array with dummy entries to prevent bucket-occupancy oracle attacks. Your client must try all candidates with opaque.client.finishLogin() — dummy entries will fail, the real entry will succeed. Do not short-circuit on the first success; try all candidates before submitting, otherwise you leak the successful position via response timing. Record the index of the candidate that succeeded as candidate_index for step 2.

Step 2 — Finish login

Pass the server's response through your OPAQUE client library. It will produce a login_finish message. Simultaneously, derive three session tokens on your client:

  • owner_token — used to identify your personal documents
  • user_member_token — used to identify your entity memberships
  • revocation_token — used for identity-free logout across all sessions

Send these to complete the login:

curl -X POST https://api.kyndex.co/v1/auth/opaque/authenticate-finish \
  -H 'Content-Type: application/json' \
  -d '{
    "login_session_id": "c8a6b7f8-d9e0-4516-a2b3-c4d5e6f7a8b9",
    "candidate_index": 0,
    "login_finish": "<base64-opaque-finish-message>",
    "owner_token": "<base64-32-bytes>",
    "user_member_token": "<base64-32-bytes>",
    "revocation_token": "<base64-32-bytes>"
  }'

Request body:

FieldTypeDescription
login_session_idstringSession ID from step 1 (UUID)
candidate_indexintegerIndex of the candidate from login_responses that succeeded in opaque.client.finishLogin()
login_finishstringOPAQUE finish message from opaque.client.finishLogin() (base64)
owner_tokenstring32-byte token identifying your personal scope (base64)
user_member_tokenstring32-byte token identifying your entity memberships (base64)
revocation_tokenstring32-byte token for identity-free session revocation (base64)

Response (200 OK):

{
  "access_token": "YWNjZXNzdG9rZW4zMmJ5dGVzYmFzZTY0ZW5jb2RlZA==",
  "refresh_token": "cmVmcmVzaHRva2VuMzJieXRlc2Jhc2U2NA==",
  "access_expires_at": "2026-01-15T10:45:00.000Z",
  "user": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "email_encrypted": "<base64-aes-256-gcm-ciphertext-or-null>",
    "key_version": 1,
    "mlkem_private_encrypted": "<base64-encrypted-ml-kem-private-key>",
    "signing_private_encrypted": "<base64-encrypted-signing-private-key>",
    "recovery_key_encrypted": "<base64-encrypted-recovery-key>"
  },
  "entity_memberships": [
    {
      "id": "a6e4f5d6-b7c8-4394-e0f1-a2b3c4d5e6f7",
      "entity_id": "e4c2d3b4-f5a6-4172-c8d9-e0f1a2b3c4d5",
      "role": "member",
      "euk_epoch": 1
    }
  ]
}

Response fields:

FieldDescription
access_tokenShort-lived bearer token (32 bytes, base64). Include in Authorization: Bearer <token> header. TTL: 15 minutes.
refresh_tokenLong-lived refresh token (32 bytes, base64). Used to obtain a new access token when expired. TTL: 7 days, single-use.
access_expires_atISO 8601 timestamp when access token expires.
user.idYour user ID (UUID).
user.email_encryptedYour email encrypted with UMK (base64, nullable). Decrypt client-side for display.
user.key_versionCurrent cryptographic key version. Use as x_key_version when decrypting keys with canAad.
user.mlkem_private_encryptedYour ML-KEM private key, encrypted with your UMK. Decrypt locally using your derived UMK.
user.signing_private_encryptedYour signing private key, encrypted with your UMK. Decrypt locally using your derived UMK.
user.recovery_key_encryptedYour recovery key (if set), encrypted with UMK. Omitted if you did not set a recovery key during registration.
entity_membershipsArray of your active entity memberships. Empty if you have no entity memberships.

What To Store After Login

After a successful login, store on your client:

  1. access_token — Use in every authenticated request: Authorization: Bearer <access_token>
  2. refresh_token — Keep secure; use to obtain a new access token before expiry
  3. Decrypted private keys — Decrypt mlkem_private_encrypted and signing_private_encrypted using your UMK, then store in memory
  4. Session tokens (owner_token, user_member_token, revocation_token) — Store securely for subsequent API operations and logout

Use the access_token in subsequent authenticated requests:

Authorization: Bearer <access_token>

Token Lifecycle & Refresh

TokenTTLNotes
Access token15 minutesShort-lived; include in every authenticated request
Refresh token7 daysSingle-use; rotated on each refresh

When your access token expires, exchange the refresh token for a new pair:

curl -X POST https://api.kyndex.co/v1/auth/tokens/refresh \
  -H 'Content-Type: application/json' \
  -d '{
    "refresh_token": "<your-refresh-token>",
    "owner_token": "<base64-32-bytes>",
    "user_member_token": "<base64-32-bytes>"
  }'

Response:

{
  "access_token": "<new-access-token>",
  "refresh_token": "<new-refresh-token>",
  "access_expires_at": "2026-01-15T11:00:00.000Z"
}

owner_token and user_member_token are optional. Omitting them produces a locked session — authenticated but unable to access document operations. Include them to maintain an unlocked session.

Each refresh token is single-use. After refreshing, discard the old token and store the new one. POST /auth/logout invalidates the current session. POST /auth/logout-all revokes every active session for the account.

On this page