Packages

Key management service with embedded OTP runtime, optional HTTP API, SQLite/PostgreSQL storage, and external RMK providers

Current section

Files

Jump to
kms docs sessions.md
Raw

docs/sessions.md

# Sessions

KMS sessions are bearer credentials that let a caller use principal-scoped permissions without passing the principal password on every operation.

A session contains:

- a high-entropy bearer `secret` returned to the caller;
- a persisted secret verifier stored as versioned HMAC-SHA256;
- encrypted private-key material needed for authorized operations;
- state: `pending` or `authenticated`;
- expiration controls for TTL, idle timeout, and absolute lifetime.

## Trust model

Treat session secrets like passwords or API tokens.

- Send remote session secrets only over TLS or a trusted local network.
- Do not log session secrets, request bodies, APM payloads, crash dumps, or proxy bodies.
- Store session secrets only in memory or a secure client-side secret store.
- Use `refresh_session/2` to rotate bearer secrets.
- Use `nudge_session/2` only to keep an active session from idling out.

## Session types

### Principal password sessions

Created by authenticating a principal with a password:

```elixir
{:ok, session} = KMS.create_session("users", "alice", %{password: password})
```

If the principal has no enabled factors, the session is immediately `authenticated`.

If the principal has enabled factors, the session starts as `pending` and includes a `challenge`. The caller must complete factors with `advance_session/2`.

### Passphrase sessions

Passphrase sessions are created directly from a caller-supplied high-entropy passphrase:

```elixir
{:ok, session} = KMS.create_passphrase_session(%{passphrase: secret})
```

KMS rejects low-entropy passphrases at creation time. Human passphrases like `open sesame` are not accepted.

Passphrase sessions have no `principal_id`. They can encrypt/decrypt only session-protected aliases with an envelope wrapped for that passphrase, or create a new passphrase-protected alias. They cannot access RMK-only aliases, manage tags, create principals, or share aliases onward. The passphrase session itself is not an alias grant.

Use [Factor-bound aliases](use_cases/factor_bound_aliases.md) for the complete principal sharing and anonymous passphrase replacement/revocation workflow. Revocation removes an envelope, so existing sessions also lose that access.

## Session states

### `pending`

Pending sessions have not completed all required factors.

Pending sessions:

- are not active for authorization;
- can be advanced with `advance_session/2`;
- expire according to session lifetime controls;
- track failed factor attempts.

### `authenticated`

Authenticated sessions can be used for authorized operations.

Example:

```elixir
{:ok, cleartext} = KMS.decrypt_alias(ciphertext, "my-key", session: session.secret)
```

A session is active only when:

- state is `authenticated`;
- `expires_at` is in the future;
- `idle_expires_at` is in the future;
- `absolute_expires_at` is in the future.

## Factor flow

Create session:

```elixir
{:ok, session} = KMS.create_session("users", "alice", %{password: password})
```

If pending:

```elixir
{:ok, session} = KMS.advance_session(session.secret, %{code: code})
```

Each completed factor advances the session. After all required factors complete, the session becomes `authenticated`.

The built-in `one_time_code` provider returns the code in the challenge response. This is for development and testing only.

For production MFA, KMS includes a built-in `totp` provider compatible with authenticator apps such as Google Authenticator, 1Password, Authy, and similar RFC 6238 applications. TOTP is still widely used and acceptable for many deployments, but it is phishable. WebAuthn/passkeys are stronger when available.

## Bearer secret storage

KMS never stores the raw session secret in the database.

New sessions store:

```text
hmac-sha256$v1$<base64-hmac>
```

The HMAC key is configured with:

```elixir
config :kms, :session_hash_key, System.fetch_env!("KMS_SESSION_HASH_KEY")
```

Production startup requires `KMS_SESSION_HASH_KEY` to be high entropy.

Legacy unsalted SHA-256 verifiers are still accepted so old sessions can expire naturally.

## Lifetime controls

KMS has three session lifetime controls:

```elixir
config :kms, :session_default_ttl_ms, :timer.hours(1)
config :kms, :session_max_ttl_ms, :timer.hours(1)
config :kms, :session_idle_timeout_ms, :timer.hours(1)
config :kms, :session_max_lifetime_ms, :timer.hours(12)
```

Fields:

- `idle_expires_at`: idle timeout deadline.
- `absolute_expires_at`: hard lifetime deadline from session creation.
- `expires_at`: effective expiry, the earliest of idle and absolute deadlines.

Caller-provided `ttl_ms` is capped by `session_max_ttl_ms`, `session_idle_timeout_ms`, and `session_max_lifetime_ms`.

After `absolute_expires_at`, forced re-authentication is required. Refresh/nudge cannot extend past absolute lifetime.

## Refreshing sessions

Refresh rotates the session bearer secret:

```elixir
{:ok, refreshed} = KMS.refresh_session(session, ttl_ms: :timer.minutes(30))
```

Refresh behavior:

- requires an active authenticated session;
- decrypts private-key material with the old secret;
- generates a new bearer secret;
- stores a new HMAC verifier;
- re-encrypts private-key material with the new secret;
- invalidates the old secret;
- extends lifetime subject to configured caps.

Remote HTTP route:

```text
POST /api/sessions/refresh
```

## Nudging sessions

Nudge extends idle expiry without rotating the bearer secret:

```elixir
{:ok, nudged} = KMS.nudge_session(session.secret, ttl_ms: :timer.minutes(30))
```

Nudge behavior:

- requires an active authenticated session;
- extends idle/effective expiry subject to caps;
- does not decrypt private-key material;
- does not rotate the bearer secret.

Remote HTTP route:

```text
POST /api/sessions/nudge
```

Use nudge for activity pings. Use refresh when the client can safely receive and store a new bearer secret.

## Password attempt rate limits

KMS enforces fixed-window failed password attempt limits by default.

Default config:

```elixir
config :kms, :session_login_rate_limit,
  principal: [limit: 5, window_ms: :timer.minutes(5)],
  source: [limit: 50, window_ms: :timer.minutes(5)]
```

Scopes:

- `principal`: failed attempts per `{kind, ref}`.
- `source`: failed attempts per remote source.

Remote HTTP session creation supplies `source` from the connection remote IP.

Library callers can supply a source manually:

```elixir
KMS.create_session("users", "alice", %{password: password}, source: "203.0.113.10")
```

Use `source` for the request origin identifier your application trusts: client IP, proxy-real IP, tenant/client id, device id, or mTLS certificate fingerprint.

Successful password authentication clears the principal counter. Source counters remain until the window expires to resist username spraying.

Disable built-in enforcement if your application provides its own controls:

```elixir
config :kms, :session_login_rate_limit, false
```

## TOTP authenticator factors

Create enrollment data:

```elixir
{:ok, enrollment} =
  KMS.Principals.Factors.TOTP.enrollment("KMS", "alice@example.com")
```

Render `enrollment.otpauth_uri` as a QR code, ask the user for the first code, then confirm it:

```elixir
:ok = KMS.Principals.Factors.TOTP.verify_enrollment(enrollment.secret, user_code)
```

Store the factor after confirmation:

```elixir
{:ok, factor} =
  KMS.create_principal_factor("users", "alice", %{
    provider: "totp",
    label: "authenticator",
    position: 1,
    config: enrollment.config
  })
```

The factor config stores the TOTP seed under `"encrypted_secret"` after RMK encryption. The TOTP challenge returned during session creation includes metadata only and never includes the code or seed.

During authentication:

```elixir
{:ok, pending} = KMS.create_session("users", "alice", %{password: password})
{:ok, session} = KMS.advance_session(pending.secret, %{code: "123456"})
```

Default TOTP settings:

- 6 digits
- 30-second period
- SHA-1 HMAC, per RFC 6238 / authenticator app convention
- +/- 1 time-step verification window

TOTP security notes:

- TOTP is shared-secret based; protect enrollment secrets.
- TOTP is phishable; prefer WebAuthn/passkeys for phishing-resistant MFA when possible.
- Keep factor and login rate limits enabled.
- Use TLS and never log codes or request bodies.

## Factor attempt controls

Factor attempts also have controls:

```elixir
config :kms, :session_factor_attempt_limit, 5
config :kms, :session_factor_rate_limit, [limit: 5, window_ms: :timer.minutes(5)]
```

`session_factor_attempt_limit` caps failed factor attempts per pending session.

`session_factor_rate_limit` is optional fixed-window throttling for factor attempts. If unset/invalid, only the attempt-count limit applies.

## Deleting sessions

Delete one session:

```elixir
{:ok, session} = KMS.delete_session(session.secret)
```

Delete sessions for a principal:

```elixir
{:ok, count} = KMS.delete_sessions("users", "alice")
```

Expired sessions are removed by cleanup code. `expires_at` is the effective expiry used for cleanup.

## Remote HTTP API

All non-health HTTP routes require the configured admin bearer token.

Session routes:

```text
POST /api/sessions/create
POST /api/sessions/passphrase
POST /api/sessions/advance
POST /api/sessions/active
POST /api/sessions/list
POST /api/sessions/refresh
POST /api/sessions/nudge
POST /api/sessions/delete
POST /api/sessions/delete_all
```

Remote JSON session responses include metadata and the bearer `secret` only when needed by the caller. Remote session handles redact `secret` and `challenge` from `inspect/1` and JSON encoding.

## Local library API

Common functions:

```elixir
KMS.create_session(kind, ref, attrs)
KMS.create_session(kind, ref, attrs, opts)
KMS.create_passphrase_session(attrs)
KMS.advance_session(secret, attrs)
KMS.fetch_session(secret)
KMS.fetch_active_session(secret)
KMS.refresh_session(session, opts)
KMS.nudge_session(secret, opts)
KMS.delete_session(secret)
KMS.delete_sessions(kind, ref)
```

Use `session:` on authorization-sensitive operations:

```elixir
KMS.encrypt_alias("clear", "alias", session: session.secret)
KMS.decrypt_alias(ciphertext, "alias", session: session.secret)
```

Calls without `session:` are trusted local/admin calls for RMK-only aliases; session-protected aliases still require a usable recipient session.

## Operational notes

- Set `KMS_SESSION_HASH_KEY` in production.
- Set TLS or trusted TLS termination for remote HTTP.
- Disable request-body logging in proxies, app logs, APM tools, and error reporters.
- Disable Erlang crash dump writing in production with `ERL_CRASH_DUMP_BYTES=0` or `ERL_CRASH_DUMP_SECONDS=0`.
- Treat any leaked session secret as active credential compromise.
- Rotate/refresh sessions after suspected exposure.