Skip to content

Commit 14b6497

Browse files
authored
Merge pull request #36 from ashioyajotham/fix/require-encryption-state-keys
fix(security): require ENCRYPTION_KEY and STATE_KEY at startup
2 parents cbffe8f + d11c407 commit 14b6497

8 files changed

Lines changed: 424 additions & 78 deletions

File tree

.env.example

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,18 @@ BASE_URL=http://localhost:8080
1111
REDIRECT_PATH=/auth/callback
1212
REDIS_URL=redis://redis:6379
1313

14-
# --- Security (REQUIRED: Change these for production!) ---
15-
# Generate new keys: openssl rand -base64 32
16-
ENCRYPTION_KEY=dGhpcy1pcy1hLXRlc3QtZW5jcnlwdGlvbi1rZXktMTI=
17-
STATE_KEY=dGhpcy1pcy1hLXRlc3Qtc3RhdGUta2V5LTEyMzQ1Njc=
14+
# --- Security (REQUIRED — services will refuse to start without these) ---
15+
16+
# REQUIRED — 32-byte AES-256-GCM master key for encrypting stored tokens.
17+
# Generate with: openssl rand -base64 32
18+
# WARNING: If this key changes or is lost, all stored connections become
19+
# permanently unreadable. Back this up like a production secret.
20+
ENCRYPTION_KEY=
21+
22+
# REQUIRED — 32-byte key for signing OAuth state parameters (CSRF prevention).
23+
# Must be identical on Broker and Gateway. Generate with: openssl rand -base64 32
24+
STATE_KEY=
25+
1826
API_KEY=nexus-admin-key
1927

2028
# --- Policies ---

docs/deployment.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,39 +3,47 @@
33
## Configuration
44

55
### Shared
6-
- `STATE_KEY` (base64‑32B): HMAC signing for `state` and nonce binding. **Must match between Gateway and Broker.**
6+
- `STATE_KEY` **(REQUIRED**, base64‑32B): HMAC signing for `state` and nonce binding. **Must match between Gateway and Broker.** Services will refuse to start if this is missing or invalid. Generate with `openssl rand -base64 32`.
77

88
### Broker (nexus-broker)
9-
Required for production:
9+
Required — the broker will not start without these:
1010
- `DATABASE_URL` (PostgreSQL connection string).
1111
- `BASE_URL` (Public URL, e.g., `https://broker.example.com`).
12+
- `ENCRYPTION_KEY` **(REQUIRED**, base64‑32B): AES‑GCM key for token encryption. **Must be stable.** If this key is lost, all stored connections become permanently unrecoverable. Generate with `openssl rand -base64 32`.
1213
- `REDIRECT_PATH` (Default `/auth/callback`).
13-
- `ENCRYPTION_KEY` (base64‑32B): AES‑GCM key for token encryption. **Must be stable.**
1414
- `API_KEY`: Key required for internal API access.
1515
- `ALLOWED_CIDRS`: Comma-separated list of allowed IP ranges (e.g., `10.0.0.0/8`).
1616
- `ALLOWED_RETURN_DOMAINS`: Comma-separated list of allowed domains for return URLs.
1717

1818
### Gateway (nexus-gateway)
1919
- `PORT`: Service port.
2020
- `BROKER_BASE_URL`: URL of the Broker (internal if possible).
21-
- `STATE_KEY`: Same as Broker.
21+
- `STATE_KEY` **(REQUIRED)**: Same as Broker — must match exactly.
2222
- `BROKER_API_KEY`: Key to authenticate with the Broker.
2323

2424
## Local Development (Quickstart)
2525

26+
### 0. Generate required keys
27+
```bash
28+
# Run once, paste outputs into your .env file
29+
openssl rand -base64 32 # → ENCRYPTION_KEY
30+
openssl rand -base64 32 # → STATE_KEY (must be the same in Broker and Gateway)
31+
```
32+
2633
### 1. Run the Broker
2734
```bash
35+
cp .env.example .env
36+
# Edit .env — fill in ENCRYPTION_KEY and STATE_KEY from step 0
2837
cd nexus-broker
29-
# Create a .env file based on .env.example
30-
source .env
38+
source .env
3139
go run ./cmd/nexus-broker
3240
```
3341

3442
### 2. Run the Gateway
3543
```bash
3644
cd nexus-gateway
45+
source ../.env # reuse the same STATE_KEY
3746
export BROKER_BASE_URL="http://localhost:8080"
38-
export STATE_KEY="$(openssl rand -base64 32)" # Use the same key as the Broker
3947
go run ./cmd/nexus-rest
4048
```
4149

nexus-broker/cmd/nexus-broker/main.go

Lines changed: 12 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,14 @@ package main
22

33
import (
44
"context"
5-
"crypto/rand"
6-
"encoding/base64"
75
"log"
86
"net/url"
97
"os"
108
"strings"
119
"time"
1210

1311
"github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/caching"
12+
"github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/config"
1413
"github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/handlers"
1514
"github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/provider"
1615
"github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/server"
@@ -45,43 +44,20 @@ func main() {
4544
log.Fatal("BASE_URL environment variable is required")
4645
}
4746

48-
// Setup encryption key (32 bytes for AES-256)
49-
var encryptionKey []byte
50-
if encryptionKeyStr != "" {
51-
key, err := base64.StdEncoding.DecodeString(encryptionKeyStr)
52-
if err != nil {
53-
log.Fatal("Invalid ENCRYPTION_KEY format, must be base64 encoded")
54-
}
55-
if len(key) != 32 {
56-
log.Fatal("ENCRYPTION_KEY must be 32 bytes (256 bits)")
57-
}
58-
encryptionKey = key
59-
} else {
60-
// Generate a random key for development
61-
log.Println("WARNING: Using generated encryption key. Set ENCRYPTION_KEY environment variable for production.")
62-
encryptionKey = make([]byte, 32)
63-
if _, err := rand.Read(encryptionKey); err != nil {
64-
log.Fatal("Failed to generate encryption key:", err)
65-
}
47+
// Validate required cryptographic keys — broker must not start without them.
48+
// An ephemeral key would silently encrypt tokens that become unreadable on restart.
49+
encryptionKey, err := config.ValidateKey("ENCRYPTION_KEY", encryptionKeyStr)
50+
if err != nil {
51+
log.Fatalf("Fatal configuration error: %v", err)
6652
}
67-
68-
// Setup state signing key
69-
var stateKey []byte
70-
if stateKeyStr != "" {
71-
key, err := base64.StdEncoding.DecodeString(stateKeyStr)
72-
if err != nil {
73-
log.Fatal("Invalid STATE_KEY format, must be base64 encoded")
74-
}
75-
stateKey = key
76-
} else {
77-
// Generate a random key for development
78-
log.Println("WARNING: Using generated state key. Set STATE_KEY environment variable for production.")
79-
stateKey = make([]byte, 32)
80-
if _, err := rand.Read(stateKey); err != nil {
81-
log.Fatal("Failed to generate state key:", err)
82-
}
53+
stateKey, err := config.ValidateKey("STATE_KEY", stateKeyStr)
54+
if err != nil {
55+
log.Fatalf("Fatal configuration error: %v", err)
8356
}
8457

58+
log.Printf("ENCRYPTION_KEY fingerprint: %s", config.KeyFingerprint(encryptionKey))
59+
log.Printf("STATE_KEY fingerprint: %s", config.KeyFingerprint(stateKey))
60+
8561
// Connect to database
8662
db, err := sqlx.Connect("postgres", databaseURL)
8763
if err != nil {

nexus-broker/pkg/config/keys.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package config
2+
3+
import (
4+
"encoding/base64"
5+
"fmt"
6+
)
7+
8+
// ValidateKey checks that a key value is set, valid base64, and decodes to
9+
// exactly 32 bytes (AES-256). Returns the decoded key or an error with an
10+
// actionable message including the environment variable name.
11+
func ValidateKey(envName, value string) ([]byte, error) {
12+
if value == "" {
13+
return nil, fmt.Errorf(
14+
"%s is not set. "+
15+
"This key is required and must be stable across restarts. "+
16+
"Generate one with: openssl rand -base64 32",
17+
envName,
18+
)
19+
}
20+
21+
decoded, err := base64.StdEncoding.DecodeString(value)
22+
if err != nil {
23+
return nil, fmt.Errorf(
24+
"%s is not valid base64: %w. "+
25+
"Expected a base64-encoded 32-byte key. "+
26+
"Generate one with: openssl rand -base64 32",
27+
envName, err,
28+
)
29+
}
30+
31+
if len(decoded) != 32 {
32+
return nil, fmt.Errorf(
33+
"%s decoded to %d bytes, expected exactly 32. "+
34+
"Generate a correct key with: openssl rand -base64 32",
35+
envName, len(decoded),
36+
)
37+
}
38+
39+
return decoded, nil
40+
}
41+
42+
// KeyFingerprint returns the first 8 characters of the base64-encoded key,
43+
// safe to log for diagnostics without exposing the full secret.
44+
func KeyFingerprint(key []byte) string {
45+
encoded := base64.StdEncoding.EncodeToString(key)
46+
if len(encoded) >= 8 {
47+
return encoded[:8] + "..."
48+
}
49+
return encoded
50+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package config
2+
3+
import (
4+
"crypto/rand"
5+
"encoding/base64"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func validKey(t *testing.T) string {
11+
t.Helper()
12+
raw := make([]byte, 32)
13+
if _, err := rand.Read(raw); err != nil {
14+
t.Fatal(err)
15+
}
16+
return base64.StdEncoding.EncodeToString(raw)
17+
}
18+
19+
func TestValidateKey_Valid(t *testing.T) {
20+
encoded := validKey(t)
21+
key, err := ValidateKey("TEST_KEY", encoded)
22+
if err != nil {
23+
t.Fatalf("expected no error, got: %v", err)
24+
}
25+
if len(key) != 32 {
26+
t.Fatalf("expected 32 bytes, got %d", len(key))
27+
}
28+
}
29+
30+
func TestValidateKey_Empty(t *testing.T) {
31+
_, err := ValidateKey("ENCRYPTION_KEY", "")
32+
if err == nil {
33+
t.Fatal("expected error for empty key")
34+
}
35+
if !strings.Contains(err.Error(), "ENCRYPTION_KEY is not set") {
36+
t.Fatalf("error should mention env var name, got: %v", err)
37+
}
38+
if !strings.Contains(err.Error(), "openssl rand -base64 32") {
39+
t.Fatalf("error should include generation hint, got: %v", err)
40+
}
41+
}
42+
43+
func TestValidateKey_InvalidBase64(t *testing.T) {
44+
_, err := ValidateKey("STATE_KEY", "not!!valid!!base64$$")
45+
if err == nil {
46+
t.Fatal("expected error for invalid base64")
47+
}
48+
if !strings.Contains(err.Error(), "STATE_KEY is not valid base64") {
49+
t.Fatalf("error should mention invalid base64, got: %v", err)
50+
}
51+
}
52+
53+
func TestValidateKey_WrongLength_Short(t *testing.T) {
54+
short := base64.StdEncoding.EncodeToString(make([]byte, 16))
55+
_, err := ValidateKey("ENCRYPTION_KEY", short)
56+
if err == nil {
57+
t.Fatal("expected error for 16-byte key")
58+
}
59+
if !strings.Contains(err.Error(), "16 bytes") {
60+
t.Fatalf("error should report actual length, got: %v", err)
61+
}
62+
if !strings.Contains(err.Error(), "expected exactly 32") {
63+
t.Fatalf("error should state expected length, got: %v", err)
64+
}
65+
}
66+
67+
func TestValidateKey_WrongLength_Long(t *testing.T) {
68+
long := base64.StdEncoding.EncodeToString(make([]byte, 64))
69+
_, err := ValidateKey("ENCRYPTION_KEY", long)
70+
if err == nil {
71+
t.Fatal("expected error for 64-byte key")
72+
}
73+
if !strings.Contains(err.Error(), "64 bytes") {
74+
t.Fatalf("error should report actual length, got: %v", err)
75+
}
76+
}
77+
78+
func TestKeyFingerprint(t *testing.T) {
79+
raw := make([]byte, 32)
80+
for i := range raw {
81+
raw[i] = byte(i)
82+
}
83+
fp := KeyFingerprint(raw)
84+
encoded := base64.StdEncoding.EncodeToString(raw)
85+
86+
if !strings.HasPrefix(encoded, strings.TrimSuffix(fp, "...")) {
87+
t.Fatalf("fingerprint %q should be prefix of full encoding %q", fp, encoded)
88+
}
89+
if !strings.HasSuffix(fp, "...") {
90+
t.Fatalf("fingerprint should end with ellipsis, got: %q", fp)
91+
}
92+
if len(fp) != 11 { // 8 chars + "..."
93+
t.Fatalf("fingerprint should be 11 chars (8 + ...), got %d: %q", len(fp), fp)
94+
}
95+
}

0 commit comments

Comments
 (0)