Skip to content

Commit 49504f6

Browse files
committed
feat: add generate-auth-keypair CLI and container auto-generation (ADR-T-007 Phase 6)
Add the `torrust-generate-auth-keypair` binary that generates an RSA-2048 key pair and writes both PEM blocks to stdout. The tool refuses to run when stdout is a terminal, uses structured JSON tracing on stderr, and supports a `--debug` flag for verbose output. The container entry script now auto-generates persistent auth keys on first boot into `/etc/torrust/index/auth/` with restrictive permissions (0400 private, 0440 public). A `mktemp` + `trap` pattern ensures key material is never world-readable and the temp file is cleaned up on interruption. Changes: - New binary: src/bin/generate_auth_keypair.rs - Containerfile: copy new binary in both debug and release stages - entry_script_sh: key-generation block before `exec su-exec` - All container configs: set auth key paths to /etc/torrust/index/auth/ - ADR-007: update Phase 6 to reflect implementation - docs/containers.md: document auth/ directory and volume layout - README: note container auto-generation for new users
1 parent 4183c12 commit 49504f6

14 files changed

Lines changed: 273 additions & 34 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2424
- Ephemeral auto-generated RSA-2048 key pair when no keys are configured.
2525
Sessions do not survive server restarts with ephemeral keys. Deployers who
2626
want persistent sessions supply their own key pair via config.
27+
- `torrust-generate-auth-keypair` CLI binary for generating RSA-2048 key pairs.
28+
Outputs both PEM blocks to stdout; refuses to run if stdout is a terminal.
29+
- Container auto-generation of persistent auth keys on first boot. The entry
30+
script runs `torrust-generate-auth-keypair` and writes the PEM files to
31+
`/etc/torrust/index/auth/` on the volume. Sessions survive restarts with no
32+
manual setup.
2733
- `kid` (Key ID) header in every JWT for future key rotation support.
2834
- Configurable token lifetimes: `auth.session_token_lifetime_secs` (default:
2935
2 weeks) and `auth.email_verification_token_lifetime_secs` (default: ~10 years).

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ url = { version = "2", features = ["serde"] }
104104
urlencoding = "2"
105105
uuid = { version = "1", features = ["v4"] }
106106

107+
[[bin]]
108+
name = "torrust-generate-auth-keypair"
109+
path = "src/bin/generate_auth_keypair.rs"
110+
107111
[dev-dependencies]
108112
tempfile = "3"
109113
which = "8"

Containerfile

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ COPY --from=build_debug \
7171
RUN cargo nextest run --workspace-remap /test/src/ --extract-to /test/src/ --no-run --archive-file /test/torrust-index-debug.tar.zst
7272
RUN cargo nextest run --workspace-remap /test/src/ --target-dir-remap /test/src/target/ --cargo-metadata /test/src/target/nextest/cargo-metadata.json --binaries-metadata /test/src/target/nextest/binaries-metadata.json
7373

74-
RUN mkdir -p /app/bin/; cp -l /test/src/target/debug/torrust-index /app/bin/torrust-index
74+
RUN mkdir -p /app/bin/; \
75+
cp -l /test/src/target/debug/torrust-index /app/bin/torrust-index; \
76+
cp -l /test/src/target/debug/torrust-generate-auth-keypair /app/bin/torrust-generate-auth-keypair
7577
# RUN mkdir /app/lib/; cp -l $(realpath $(ldd /app/bin/torrust-index | grep "libz\.so\.1" | awk '{print $3}')) /app/lib/libz.so.1
7678
RUN chown -R root:root /app; chmod -R u=rw,go=r,a+X /app; chmod -R a+x /app/bin
7779

@@ -87,7 +89,8 @@ RUN cargo nextest run --workspace-remap /test/src/ --target-dir-remap /test/src/
8789

8890
RUN mkdir -p /app/bin/; \
8991
cp -l /test/src/target/release/torrust-index /app/bin/torrust-index; \
90-
cp -l /test/src/target/release/health_check /app/bin/health_check;
92+
cp -l /test/src/target/release/health_check /app/bin/health_check; \
93+
cp -l /test/src/target/release/torrust-generate-auth-keypair /app/bin/torrust-generate-auth-keypair
9194
# RUN mkdir -p /app/lib/; cp -l $(realpath $(ldd /app/bin/torrust-index | grep "libz\.so\.1" | awk '{print $3}')) /app/lib/libz.so.1
9295
RUN chown -R root:root /app; chmod -R u=rw,go=r,a+X /app; chmod -R a+x /app/bin
9396

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ TORRUST_INDEX_CONFIG_TOML=$(cat "./storage/index/etc/index.toml") \
118118
cargo run
119119
```
120120

121+
> **Container deployments** auto-generate persistent keys on first boot — no
122+
> manual setup is required. See the [container guide][containers.md] for details.
123+
121124
> Please view our [crate documentation][docs] for more detailed instructions.
122125
123126
### Services

adr/007-jwt-system-refactor.md

Lines changed: 93 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -461,9 +461,11 @@ phased rollout that subsumes Options A and B.
461461
The `rsa` crate (already a transitive dependency via
462462
`jsonwebtoken`'s `rust_crypto` feature) must be added as a
463463
**direct** dependency in `Cargo.toml` along with `rand` (for
464-
`OsRng`). PEM export requires the `pkcs8` + `pem` features on
465-
`rsa` (for `EncodePrivateKey::to_pkcs8_pem`) and `spki` (for
466-
`EncodePublicKey::to_public_key_pem`).
464+
`OsRng`). PEM export uses `EncodePrivateKey::to_pkcs8_pem`
465+
(from `pkcs8`) and `EncodePublicKey::to_public_key_pem` (from
466+
`spki`). Both are pulled in transitively by the `pem` feature
467+
on `rsa` — no extra feature flags are needed beyond
468+
`rsa = { features = ["std", "pem"] }`.
467469

468470
##### Key generation details
469471

@@ -527,7 +529,7 @@ touches the following files (non-exhaustive):
527529
| `compose.yaml` | Remove or comment out `AUTH__PRIVATE_KEY_PATH` / `AUTH__PUBLIC_KEY_PATH` env vars |
528530
| `src/web/api/server/v1/contexts/user/mod.rs` | Update module-level doc example |
529531

530-
#### Phase 6 — `generate-auth-keypair` CLI + Container Auto-Generation
532+
#### Phase 6 — `generate-auth-keypair` CLI + Container Auto-Generation ✅ Implemented
531533

532534
##### Motivation
533535

@@ -560,43 +562,87 @@ pair and writes both PEM blocks to **stdout**. Design constraints:
560562
standard PEM (Base64-encoded PKCS#8 / SPKI) format. The two
561563
blocks are self-delimiting via their `-----BEGIN …-----` /
562564
`-----END …-----` markers.
563-
- **Diagnostic message on stderr** confirming the key was
564-
generated (type, bit size).
565-
- Uses `clap` (already a dependency) for `--help` and future
566-
extensibility (e.g., `--bits`, `--out-dir`).
565+
- **Diagnostic output on stderr** via `tracing` (already a
566+
dependency). A `--debug` flag switches the subscriber from
567+
the default `info` level to `debug`, giving deployers
568+
detailed timing and key-fingerprint output without polluting
569+
the PEM stream on stdout.
570+
- Uses `clap` (already a dependency) for polished `--help`
571+
output and future extensibility (e.g., `--bits`, `--out-dir`).
567572
- Reuses the same `rsa` + `pkcs8` code path as the ephemeral
568573
generator in `src/jwt.rs`.
569574

570575
##### Container integration
571576

572577
The container entry script (`share/container/entry_script_sh`)
573-
auto-generates persistent keys on first boot:
578+
auto-generates persistent keys on first boot (runs **after**
579+
`adduser`, so the `torrust` user already exists):
574580

575581
```sh
576582
# Generate auth keys if not already present on the volume.
577-
private_key="/etc/torrust/index/private.pem"
578-
public_key="/etc/torrust/index/public.pem"
579-
580-
if [ ! -f "$private_key" ] || [ ! -f "$public_key" ]; then
581-
torrust-generate-auth-keypair > /tmp/auth_keys.pem 2>/dev/null
582-
sed -n '/BEGIN PRIVATE/,/END PRIVATE/p' /tmp/auth_keys.pem > "$private_key"
583-
sed -n '/BEGIN PUBLIC/,/END PUBLIC/p' /tmp/auth_keys.pem > "$public_key"
584-
rm -f /tmp/auth_keys.pem
583+
auth_dir="/etc/torrust/index/auth"
584+
private_key="$auth_dir/private.pem"
585+
public_key="$auth_dir/public.pem"
586+
tmpfile=$(mktemp /tmp/auth_keys.XXXXXX)
587+
chmod 0600 "$tmpfile"
588+
trap 'rm -f "$tmpfile"' EXIT
589+
590+
if [ ! -s "$private_key" ] || [ ! -s "$public_key" ]; then
591+
mkdir -p "$auth_dir"
592+
chown torrust:torrust "$auth_dir"
593+
chmod 0700 "$auth_dir"
594+
595+
if ! torrust-generate-auth-keypair > "$tmpfile"; then
596+
echo "ERROR: Failed to generate auth keypair" >&2
597+
exit 1
598+
fi
599+
sed -n '/BEGIN PRIVATE KEY/,/END PRIVATE KEY/p' "$tmpfile" > "$private_key"
600+
sed -n '/BEGIN PUBLIC KEY/,/END PUBLIC KEY/p' "$tmpfile" > "$public_key"
601+
rm -f "$tmpfile"
585602
chown torrust:torrust "$private_key" "$public_key"
586603
chmod 0400 "$private_key"
587604
chmod 0440 "$public_key"
588605
fi
589606
```
590607

608+
Hardening notes:
609+
- **`mkdir -p "$auth_dir"` with `chmod 0700`** creates the
610+
`auth/` subdirectory with restrictive permissions before any
611+
key material is written.
612+
- **`mktemp` + `chmod 0600`** creates the temp file with a
613+
unique name and restrictive permissions immediately, so key
614+
material is never world-readable — even momentarily.
615+
- **`[ ! -s … ]`** (not `[ ! -f … ]`) checks that the file
616+
exists **and** is non-empty, protecting against a previous run
617+
that was killed mid-write and left a 0-byte PEM file.
618+
- **`trap … EXIT`** ensures the temp file is cleaned up even if
619+
the script is interrupted between write and `rm`. `/tmp` in
620+
the container is typically `tmpfs` (RAM-backed), so the key
621+
material never touches persistent storage.
622+
- **`sed` patterns match the exact PEM markers** (`BEGIN PRIVATE
623+
KEY` / `BEGIN PUBLIC KEY`) produced by PKCS#8 / SPKI encoding,
624+
rather than loose substrings.
625+
- **stderr flows to the container log** — only stdout is
626+
redirected to the temp file, so diagnostic `tracing` output
627+
and any error messages from the binary are visible in
628+
`docker logs`. If generation fails, the script exits
629+
non-zero.
630+
- **TOCTOU note:** if two containers race against the same
631+
volume, both could pass the `[ ! -s … ]` check and
632+
overwrite each other's keys. This is unlikely in practice
633+
(single-container deployments are the norm), but can be
634+
mitigated with `flock` if needed.
635+
591636
Because `/etc/torrust/index` is a declared `VOLUME`, the
592637
generated keys persist across container restarts and image
593638
upgrades. Sessions survive as long as the volume is retained.
594639

595-
All container configuration files (`share/default/config/`) set:
640+
All **container** configuration files (those with `container` in
641+
the name under `share/default/config/`) set:
596642
```toml
597643
[auth]
598-
private_key_path = "/etc/torrust/index/private.pem"
599-
public_key_path = "/etc/torrust/index/public.pem"
644+
private_key_path = "/etc/torrust/index/auth/private.pem"
645+
public_key_path = "/etc/torrust/index/auth/public.pem"
600646
```
601647

602648
##### Containerfile changes
@@ -606,25 +652,30 @@ both the debug and release runtime images alongside
606652
`torrust-index` and `health_check`:
607653

608654
```dockerfile
609-
# Extract and Test (debug)
655+
# Extract and Test (debug) — add to the existing cp -l line:
610656
RUN mkdir -p /app/bin/; \
611657
cp -l /test/src/target/debug/torrust-index /app/bin/torrust-index; \
612658
cp -l /test/src/target/debug/torrust-generate-auth-keypair /app/bin/torrust-generate-auth-keypair
613659

614-
# Extract and Test (release)
660+
# Extract and Test (release) — add to the existing cp -l block:
615661
RUN mkdir -p /app/bin/; \
616662
cp -l /test/src/target/release/torrust-index /app/bin/torrust-index; \
617663
cp -l /test/src/target/release/health_check /app/bin/health_check; \
618664
cp -l /test/src/target/release/torrust-generate-auth-keypair /app/bin/torrust-generate-auth-keypair
619665
```
620666

667+
Note: the debug stage currently copies only `torrust-index`
668+
(no `health_check`). The new binary follows the same pattern.
669+
The release stage already copies both `torrust-index` and
670+
`health_check`, so the new binary is appended to that block.
671+
621672
##### Host-supplied keys (custom key workflow)
622673

623674
Hosts who want to use their own RSA key pair have two options:
624675

625676
1. **Pre-supply before first boot.** Mount or copy key files into
626677
the `/etc/torrust/index` volume before starting the container.
627-
The entry script's existence check (`[ ! -f … ]`) will skip
678+
The entry script's existence check (`[ ! -s … ]`) will skip
628679
generation and the server will use the host's keys directly.
629680

630681
2. **Overwrite after first boot.** Let the container auto-generate
@@ -637,19 +688,26 @@ Hosts who want to use their own RSA key pair have two options:
637688
##### Usage outside containers
638689

639690
```sh
640-
# Generate and split into two files:
641-
cargo run --bin torrust-generate-auth-keypair \
642-
| tee >(sed -n '/BEGIN PRIVATE/,/END PRIVATE/p' > private.pem) \
643-
>(sed -n '/BEGIN PUBLIC/,/END PUBLIC/p' > public.pem) \
644-
> /dev/null
691+
tmpfile=$(mktemp /tmp/auth_keys.XXXXXX)
692+
chmod 0600 "$tmpfile"
693+
cargo run --bin torrust-generate-auth-keypair > "$tmpfile"
694+
sed -n '/BEGIN PRIVATE KEY/,/END PRIVATE KEY/p' "$tmpfile" > private.pem
695+
sed -n '/BEGIN PUBLIC KEY/,/END PUBLIC KEY/p' "$tmpfile" > public.pem
696+
rm -f "$tmpfile"
645697
```
646698

699+
> **Avoid** the Bash process-substitution form
700+
> (`tee >(sed …) >(sed …)`). The `>(…)` sub-processes run
701+
> asynchronously, so the `sed` writes may not have flushed
702+
> when the pipeline exits — producing truncated PEM files.
703+
> The POSIX version above is strictly correct.
704+
647705
##### Files affected
648706

649707
| File | Change |
650708
|---|---|
651-
| `src/bin/torrust-generate-auth-keypair.rs` | New binary |
652-
| `Cargo.toml` | No change — auto-discovered by Cargo |
709+
| `src/bin/generate_auth_keypair.rs` | New binary |
710+
| `Cargo.toml` | Add `[[bin]]` section: `name = "torrust-generate-auth-keypair"`, `path = "src/bin/generate_auth_keypair.rs"` |
653711
| `Containerfile` | Copy `torrust-generate-auth-keypair` into `/app/bin/` in both debug and release stages |
654712
| `share/container/entry_script_sh` | Add key-generation block before `exec su-exec` |
655713
| `share/default/config/index.container.sqlite3.toml` | Add `private_key_path` / `public_key_path` to `[auth]` |
@@ -658,6 +716,10 @@ cargo run --bin torrust-generate-auth-keypair \
658716
| `share/default/config/index.public.e2e.container.mysql.toml` | Add `private_key_path` / `public_key_path` to `[auth]` |
659717
| `share/default/config/index.private.e2e.container.sqlite3.toml` | Add `private_key_path` / `public_key_path` to `[auth]` |
660718

719+
Note: there is no `index.private.e2e.container.mysql.toml` at
720+
present. If one is added in the future, it will also need the
721+
`[auth]` key paths.
722+
661723
##### No breaking changes
662724

663725
This phase adds a new binary and updates the container entry
@@ -694,7 +756,7 @@ long as the volume is retained.
694756
Note: the **serialized default config** changes in Phase 5 — the
695757
bare-metal `[auth]` section will no longer contain
696758
`private_key_path` / `public_key_path` entries. Container configs
697-
*do* include these paths (pointing to `/etc/torrust/index/`).
759+
*do* include these paths (pointing to `/etc/torrust/index/auth/`).
698760
Deployers who generate their config from defaults should be aware
699761
of this difference. Existing configs that explicitly set these
700762
fields are unaffected.

docs/containers.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,19 @@ storage/index/
6767
│ └── localhost.key => /var/lib/torrust/index/tls/localhost.key [user supplied]
6868
├── log => /var/log/torrust/index (future use)
6969
└── etc
70+
├── auth
71+
│ ├── private.pem => /etc/torrust/index/auth/private.pem [auto generated on first boot]
72+
│ └── public.pem => /etc/torrust/index/auth/public.pem [auto generated on first boot]
7073
└── index.toml => /etc/torrust/index/index.toml [auto populated]
7174
```
7275

7376
> NOTE: you only need the `tls` directory and certificates in case you have enabled SSL.
77+
>
78+
> The `auth/` directory and RSA key pair are auto-generated on first boot by the
79+
> container entry script. Sessions persist across restarts as long as the
80+
> `/etc/torrust/index` volume is retained. To use your own keys, either
81+
> pre-populate the volume before first boot or overwrite the generated files and
82+
> restart.
7483
7584
## Building the Container
7685

share/container/entry_script_sh

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,5 +78,30 @@ echo '[ ! -z "$TERM" -a -r /etc/motd ] && cat /etc/motd' >> /etc/profile
7878

7979
cd /home/torrust || exit 1
8080

81+
# Generate auth keys if not already present on the volume.
82+
auth_dir="/etc/torrust/index/auth"
83+
private_key="$auth_dir/private.pem"
84+
public_key="$auth_dir/public.pem"
85+
tmpfile=$(mktemp /tmp/auth_keys.XXXXXX)
86+
chmod 0600 "$tmpfile"
87+
trap 'rm -f "$tmpfile"' EXIT
88+
89+
if [ ! -s "$private_key" ] || [ ! -s "$public_key" ]; then
90+
mkdir -p "$auth_dir"
91+
chown torrust:torrust "$auth_dir"
92+
chmod 0700 "$auth_dir"
93+
94+
if ! torrust-generate-auth-keypair > "$tmpfile"; then
95+
echo "ERROR: Failed to generate auth keypair" >&2
96+
exit 1
97+
fi
98+
sed -n '/BEGIN PRIVATE KEY/,/END PRIVATE KEY/p' "$tmpfile" > "$private_key"
99+
sed -n '/BEGIN PUBLIC KEY/,/END PUBLIC KEY/p' "$tmpfile" > "$public_key"
100+
rm -f "$tmpfile"
101+
chown torrust:torrust "$private_key" "$public_key"
102+
chmod 0400 "$private_key"
103+
chmod 0440 "$public_key"
104+
fi
105+
81106
# Switch to torrust user
82107
exec /bin/su-exec torrust "$@"

share/default/config/index.container.mysql.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ threshold = "info"
1515
token = "MyAccessToken"
1616

1717
[auth]
18+
private_key_path = "/etc/torrust/index/auth/private.pem"
19+
public_key_path = "/etc/torrust/index/auth/public.pem"
1820

1921
[database]
2022
connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index"

share/default/config/index.container.sqlite3.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ threshold = "info"
1515
token = "MyAccessToken"
1616

1717
[auth]
18+
private_key_path = "/etc/torrust/index/auth/private.pem"
19+
public_key_path = "/etc/torrust/index/auth/public.pem"
1820

1921
[database]
2022
connect_url = "sqlite:///var/lib/torrust/index/database/sqlite3.db?mode=rwc"

share/default/config/index.private.e2e.container.sqlite3.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ token = "MyAccessToken"
1919
url = "http://tracker:7070"
2020

2121
[auth]
22+
private_key_path = "/etc/torrust/index/auth/private.pem"
23+
public_key_path = "/etc/torrust/index/auth/public.pem"
2224

2325
[database]
2426
connect_url = "sqlite:///var/lib/torrust/index/database/e2e_testing_sqlite3.db?mode=rwc"

0 commit comments

Comments
 (0)