Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Session.use_store(Session::MemoryStore(UserSession))

# Configure once
Session.configure do |config|
config.secret = ENV["SESSION_SECRET"]
# The secret is read from SESSION_SECRET (random per process in development without it)
config.store = Session::MemoryStore(UserSession).new
end

Expand Down Expand Up @@ -100,7 +100,7 @@ config.store = Session::ClusteredRedisStore(UserSession).new(client: Redis.new)

```crystal
Session.configure do |config|
config.secret = ENV["SESSION_SECRET"] # 32+ chars recommended
# The secret is read from SESSION_SECRET (random per process in development without it)
config.use_kdf = true # Enable PBKDF2
config.kdf_iterations = 100_000 # OWASP recommended
config.bind_to_ip = true # Prevent session hijacking
Expand Down Expand Up @@ -237,7 +237,7 @@ class HelloHandler
end

Session.configure do |config|
config.secret = ENV["SESSION_SECRET"]
# The secret is read from SESSION_SECRET (random per process in development without it)
config.store = Session::MemoryStore(UserSession).new
end

Expand Down Expand Up @@ -269,7 +269,7 @@ end
Session::Web.use_store(Session::CookieStore(UserSession))

Session.configure do |config|
config.secret = ENV["SESSION_SECRET"]
# The secret is read from SESSION_SECRET (random per process in development without it)
config.store = Session::CookieStore(UserSession).new
end

Expand All @@ -292,7 +292,7 @@ Cookies are always `HttpOnly`, `Secure` and `SameSite=Strict`; `config.cookie_se
```crystal
Session.configure do |config|
# Core
config.secret = ENV["SESSION_SECRET"] # Required
# The secret is read from SESSION_SECRET (random per process in development without it)
config.timeout = 1.hour # Session lifetime
config.session_key = "_session" # Cookie name
config.sliding_expiration = true # Extend on access
Expand Down
22 changes: 21 additions & 1 deletion UPGRADE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Upgrade Guide

## No published default secret

The secret used to default to a string published in this repository, so an app that never set one signed its
cookies with a key anyone could use to forge a session. Now:

- `config.secret` defaults to `SESSION_SECRET`. Without it, a random key for this process (one warning), so
sessions end when the process restarts and are not shared between instances. Set `SESSION_SECRET` to keep them.
- In production and staging a missing secret raises `Session::InsecureSecretException` wherever the secret is
used. With a `CookieStore`, or `encrypt_redis_data`, `Session::SessionHandler` then fails every request with `Session::InsecureSecretException` (a 500, logged by the server) instead of answering without a working session; a store that does not encrypt never reads the secret, so nothing is raised.
- A secret shorter than 32 bytes raises `Session::InsecureSecretException` too, instead of an `ArgumentError` from
AES that the handler logged and swallowed. This holds with `use_kdf` on (the `:production`, `:high_security` and
`:clustered` presets): the HMAC signature uses the secret itself, so PBKDF2 does not make a short one safe.
- `Session.configure` checks the secret when the configured store encrypts with it (a `CookieStore`, or
`encrypt_redis_data`), so a missing or short secret fails at boot instead of on every request. Set the secret (or
`SESSION_SECRET`) before, or inside, the `Session.configure` block that sets such a store.
- `require_secure_secret` now reads `CRYSTAL_ENV` (case-insensitive) when it is checked, not when the shard loads;
setting it explicitly still wins.
- `config.secret = nil` goes back to the default. `Configuration::DEFAULT_SECRET` still exists and is still refused
where `require_secure_secret` holds.

## Crystal 1.22.1 or newer (no code change)

`shard.yml` now declares `crystal: ">= 1.22.1"`. It was `crystal: 1.4.1`, a 1.4.1 minimum that no CI job ever built.
Expand Down Expand Up @@ -98,7 +118,7 @@ end
Session.use_store(Session::MemoryStore(UserSession))

Session.configure do |config|
config.secret = ENV["SESSION_SECRET"]
# The secret is read from SESSION_SECRET (random per process in development without it)
config.store = Session::MemoryStore(UserSession).new
end
```
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ end
Session.use_store(Session::RedisStore(UserSession))

Session.configure do |config|
config.secret = ENV["SESSION_SECRET"]
# The secret is read from SESSION_SECRET (random per process in development without it)
config.timeout = 24.hours
config.store = Session::RedisStore(UserSession).new(client: Redis.new)
end
Expand Down
2 changes: 1 addition & 1 deletion docs/api/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ The `authenticated?` method is abstract and must be implemented by your subclass
```crystal
# Configure the session library
Session.configure do |config|
config.secret = ENV["SESSION_SECRET"]
# The secret is read from SESSION_SECRET (random per process in development without it)
config.store = Session::MemoryStore(UserSession).new
end

Expand Down
6 changes: 3 additions & 3 deletions docs/clustering/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ This page covers all configuration options for session clustering.

```crystal
Session.configure do |config|
config.secret = ENV["SESSION_SECRET"]
# The secret is read from SESSION_SECRET (random per process in development without it)

# Enable clustering with defaults
config.cluster.enabled = true
Expand All @@ -35,7 +35,7 @@ end

```crystal
Session.configure do |config|
config.secret = ENV["SESSION_SECRET"]
# The secret is read from SESSION_SECRET (random per process in development without it)
config.timeout = 1.hour

# Cluster configuration
Expand Down Expand Up @@ -183,7 +183,7 @@ config.store = Session::ClusteredRedisStore(UserSession).new(

```crystal
Session.configure do |config|
config.secret = ENV["SESSION_SECRET"]
# The secret is read from SESSION_SECRET (random per process in development without it)

case ENV["APP_ENV"]?
when "production"
Expand Down
2 changes: 1 addition & 1 deletion docs/clustering/multi-node-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ spec:
```crystal
# config/session.cr
Session.configure do |config|
config.secret = ENV["SESSION_SECRET"]
# The secret is read from SESSION_SECRET (random per process in development without it)
config.timeout = 24.hours

# Clustering configuration
Expand Down
2 changes: 1 addition & 1 deletion docs/clustering/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ A thread-safe, in-memory cache with:

```crystal
Session.configure do |config|
config.secret = ENV["SESSION_SECRET"]
# The secret is read from SESSION_SECRET (random per process in development without it)

# Enable clustering
config.cluster.enabled = true
Expand Down
12 changes: 6 additions & 6 deletions docs/configuration/basic.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Configure Session to match your application's requirements.

```crystal
Session.configure do |config|
config.secret = ENV["SESSION_SECRET"]
# The secret is read from SESSION_SECRET (random per process in development without it)
config.store = Session::MemoryStore(UserSession).new
end
```
Expand All @@ -29,7 +29,7 @@ end
```crystal
Session.configure do |config|
config.apply_preset(:production)
config.secret = ENV.fetch("SESSION_SECRET")
# The secret is read from SESSION_SECRET (random per process in development without it)
config.store = Session::RedisStore(UserSession).new(client: Redis.new)
end
```
Expand All @@ -56,7 +56,7 @@ Session.configure do |config|
config.timeout = 8.hours
config.bind_to_user_agent = true

config.secret = ENV.fetch("SESSION_SECRET")
# The secret is read from SESSION_SECRET (random per process in development without it)
config.store = Session::RedisStore(UserSession).new
end
```
Expand All @@ -66,7 +66,7 @@ end
```crystal
Session.configure do |config|
# Core settings
config.secret = ENV["SESSION_SECRET"] # Required: encryption key
# The secret is read from SESSION_SECRET (random per process in development without it)
config.timeout = 1.hour # Session lifetime
config.session_key = "_session" # Cookie name

Expand All @@ -81,7 +81,7 @@ end

| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `secret` | `String` | (default) | Encryption secret (32+ chars recommended) |
| `secret` | `String` | `SESSION_SECRET` | Encryption and signing secret (32+ bytes, also with `use_kdf`) |
| `timeout` | `Time::Span` | `1.hour` | Session lifetime |
| `session_key` | `String` | `"_session"` | Cookie name |
| `store` | `Store` | `nil` | Storage backend (typed by `Session.use_store`) |
Expand All @@ -93,7 +93,7 @@ end
<!-- skip -->
```crystal
Session.configure do |config|
config.secret = ENV["SESSION_SECRET"]
# The secret is read from SESSION_SECRET (random per process in development without it)

case ENV["APP_ENV"]?
when "production"
Expand Down
9 changes: 4 additions & 5 deletions docs/configuration/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@ Configure security features to protect session data.

```crystal
Session.configure do |config|
# Required: Secret key for encryption
config.secret = ENV["SESSION_SECRET"]
# The secret is read from SESSION_SECRET (random per process in development without it)

# Enforce secure secret (raises if using default)
# Raise on a missing or published secret (default: in production and staging)
config.require_secure_secret = true

# Digest algorithm for HMAC
Expand Down Expand Up @@ -57,8 +56,8 @@ end

| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `secret` | `String` | (default) | Encryption key |
| `require_secure_secret` | `Bool` | `false` | Enforce non-default secret |
| `secret` | `String` | `SESSION_SECRET` | Encryption key; random per process when unset, outside production and staging |
| `require_secure_secret` | `Bool` | production, staging | Raise on a missing or published secret |
| `digest_algorithm` | `Symbol` | `:sha256` | HMAC algorithm |
| `digest_fallback` | `Bool` | `true` | Allow SHA1 fallback |
| `use_kdf` | `Bool` | `false` | Enable PBKDF2 |
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ end
Session.use_store(Session::MemoryStore(TestSession))

Session.configure do |config|
config.secret = "test-secret-for-verification"
# The secret is read from SESSION_SECRET (random per process in development without it)
config.store = Session::MemoryStore(TestSession).new
end

Expand Down
9 changes: 5 additions & 4 deletions docs/getting-started/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ Declare the store type with `Session.use_store`, then configure:
Session.use_store(Session::MemoryStore(UserSession))

Session.configure do |config|
# Required: Secret key for encryption (use environment variable in production)
config.secret = ENV["SESSION_SECRET"]? || "your-32-character-secret-key-here"
# The key comes from SESSION_SECRET (32 bytes or more). Without it, development gets a random
# key per process; in production and staging, a store that encrypts (CookieStore, or
# encrypt_redis_data) makes Session.configure raise Session::InsecureSecretException at boot.

# Session timeout
config.timeout = 24.hours
Expand Down Expand Up @@ -113,7 +114,7 @@ require "session"
Session.use_store(Session::MemoryStore(UserSession))

Session.configure do |config|
config.secret = ENV["SESSION_SECRET"]
# The secret is read from SESSION_SECRET (random per process in development without it)
config.store = Session::MemoryStore(UserSession).new
end

Expand Down Expand Up @@ -190,7 +191,7 @@ end
Session.use_store(Session::MemoryStore(UserSession))

Session.configure do |config|
config.secret = ENV["SESSION_SECRET"]? || "dev-secret-32-characters-long!!"
# The secret is read from SESSION_SECRET (random per process in development without it)
config.timeout = 1.hour
config.sliding_expiration = true
config.store = Session::MemoryStore(UserSession).new
Expand Down
4 changes: 2 additions & 2 deletions docs/integrations/azu-framework.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,14 @@ end
Session::Web.use_store(Session::CookieStore(UserSession))

Session.configure do |config|
config.secret = ENV["SESSION_SECRET"]
# The secret is read from SESSION_SECRET (random per process in development without it)
config.timeout = 8.hours
config.session_key = "myapp_session"
config.store = Session::CookieStore(UserSession).new
end
```

`SESSION_SECRET` is required in production and staging (`CRYSTAL_ENV`): the published default secret is refused there, because anyone could forge a cookie signed with it. Generate one with `Random::Secure.hex(32)` and keep it in your process environment.
`SESSION_SECRET` (32 bytes or more) is required in production and staging (`CRYSTAL_ENV`): without it the cookie store cannot sign: `Session.configure` raises `Session::InsecureSecretException` at boot, and a `Session::SessionHandler` built without `Session.configure` fails every request with it (a 500). Elsewhere a missing secret means a random key for this process (with one warning), so sessions end at a restart. Generate one with `Random::Secure.hex(32)` and keep it in your process environment. This is close to `Azu::Env.secret("SESSION_SECRET")` but not the same: azu refuses the random fallback in its `strict_environments` (`production` unless `AZU_STRICT_ENVS` or `Azu::Env.strict_environments=` widens it), while session refuses it in production and staging (`CRYSTAL_ENV`), whatever `AZU_STRICT_ENVS` says.

## Mount the handler

Expand Down
6 changes: 3 additions & 3 deletions docs/integrations/http-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ end
Session.use_store(Session::MemoryStore(UserSession))

Session.configure do |config|
config.secret = ENV["SESSION_SECRET"]
# The secret is read from SESSION_SECRET (random per process in development without it)
config.timeout = 24.hours
config.session_key = "_myapp_session"
config.store = Session::MemoryStore(UserSession).new
Expand Down Expand Up @@ -143,7 +143,7 @@ end

# Configure
Session.configure do |config|
config.secret = ENV.fetch("SESSION_SECRET", "dev-secret-change-in-production!")
# The secret is read from SESSION_SECRET (random per process in development without it)
config.timeout = 1.hour
config.sliding_expiration = true
config.store = Session::MemoryStore(UserSession).new
Expand Down Expand Up @@ -222,7 +222,7 @@ server.listen

```crystal
Session.configure do |config|
config.secret = ENV["SESSION_SECRET"]
# The secret is read from SESSION_SECRET (random per process in development without it)
config.timeout = 24.hours
config.session_key = "_app_session"

Expand Down
2 changes: 1 addition & 1 deletion docs/resilience/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Session uses specific exception types for precise error handling and graceful de
| `SessionEncryptionException` | -- | Encryption or decryption failure |
| `CookieSizeExceededException` | `actual_size`, `max_size` | Cookie exceeds 4KB limit |
| `SessionBindingException` | `binding_type` | Client fingerprint mismatch |
| `InsecureSecretException` | -- | Default secret with `require_secure_secret` |
| `InsecureSecretException` | -- | Missing or published secret with `require_secure_secret`, or a secret under 32 bytes; `SessionHandler` raises it (a 500) for a store that encrypts |
| `CircuitOpenException` | `time_until_retry` | Circuit breaker is open |
| `ConnectionPoolTimeoutException` | -- | Pool checkout timeout |
| `ClusterException` | -- | General cluster failure |
Expand Down
18 changes: 11 additions & 7 deletions docs/security/encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ Decryption reverses the process: decode, verify HMAC signature, then decrypt. Th

```crystal
Session.configure do |config|
config.secret = ENV["SESSION_SECRET"] # 32+ characters recommended
# The secret is read from SESSION_SECRET (random per process without it, outside production and staging)
config.digest_algorithm = :sha256 # HMAC algorithm
config.digest_fallback = true # Allow SHA1 fallback for migration
config.require_secure_secret = true # Raise if using default secret
config.require_secure_secret = true # Raise on a missing, published or short secret
end
```

Expand Down Expand Up @@ -47,16 +47,20 @@ The default digest is `:sha256`. If you're migrating from an older version that

## Secret Key Requirements

- Minimum 32 characters for AES-256
- Set `require_secure_secret = true` in production to raise `InsecureSecretException` if the default secret is used
- Without `require_secure_secret`, a warning is logged once on first use of the default secret
- At least 32 bytes, with or without `use_kdf` (the HMAC signature uses the secret itself): a shorter one raises
`InsecureSecretException`, from `Session.configure` when the store encrypts with it
- Unset, `secret` is `SESSION_SECRET`. Without that, `InsecureSecretException` where `require_secure_secret` holds
(production and staging by default, read from `CRYSTAL_ENV` each time the secret is read), and a random per-process
key with one warning elsewhere. With a `CookieStore`, or `encrypt_redis_data`, `Session::SessionHandler` then fails every request with `Session::InsecureSecretException` (a 500, logged by the server) instead of answering without a working session; a store that does not encrypt never reads the secret, so nothing is raised.
- The secret older releases used by default (`Configuration::DEFAULT_SECRET`) is public: set explicitly, it is refused
where `require_secure_secret` holds and warned about elsewhere

## Properties

| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `secret` | `String` | (default) | Encryption key (32+ chars) |
| `require_secure_secret` | `Bool` | `false` | Raise on default secret |
| `secret` | `String` | `SESSION_SECRET` | Encryption key (32+ bytes) |
| `require_secure_secret` | `Bool` | production, staging | Raise on a missing or published secret |
| `digest_algorithm` | `Symbol` | `:sha256` | HMAC algorithm |
| `digest_fallback` | `Bool` | `true` | Allow SHA1 fallback |
| `encrypt_redis_data` | `Bool` | `false` | Encrypt data in Redis |
Expand Down
9 changes: 6 additions & 3 deletions docs/security/key-derivation.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Key Derivation

PBKDF2-SHA256 derives a strong 32-byte encryption key from your secret, adding protection against brute-force attacks on weak or short secrets.
PBKDF2-SHA256 derives the 32-byte AES key from your secret. It does not make a weak or short secret safe: the HMAC
signature on every cookie uses the secret itself, so a guessable secret can be found offline from one cookie whatever
the KDF does. The secret must still be at least 32 random bytes (`openssl rand -hex 32`); a shorter one raises
`Session::InsecureSecretException`.

## Configuration

Expand All @@ -14,9 +17,9 @@ end

## How It Works

When `use_kdf` is enabled, the library runs `OpenSSL::PKCS5.pbkdf2_hmac` at initialization to derive a 32-byte key from your secret. This derived key is then used for all AES-256-CBC encryption operations instead of the raw secret.
When `use_kdf` is enabled, `Message::Encryptor` runs `OpenSSL::PKCS5.pbkdf2_hmac` when it is built to derive a 32-byte key from your secret. This derived key is then used for all AES-256-CBC encryption operations instead of the raw secret.

The derived key is computed once and cached, so there is no per-request performance cost.
`Session.config.encryptor` reuses its encryptor while the secret, digest and KDF settings stay the same, so PBKDF2 runs once per setting, not per request. Changing any of them (or `SESSION_SECRET`, when no secret is configured) derives the key again on the next use. An `Encryptor` you build yourself derives its key once, in `new`.

## Properties

Expand Down
Loading
Loading