keep authelia's keys in bao, seeded from the existing key files
This commit is contained in:
parent
a70b25847e
commit
99fab06348
11 changed files with 557 additions and 89 deletions
|
|
@ -15,7 +15,7 @@ the rest of the cell says how.
|
||||||
<!-- vale write-good.Passive = NO -->
|
<!-- vale write-good.Passive = NO -->
|
||||||
|
|
||||||
| store path | minter | reader | automatic re-mint | automatic re-pull |
|
| store path | minter | reader | automatic re-mint | automatic re-pull |
|
||||||
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `swarm/agents/<agent>/matrix/main` | `swarm-controller`, at agent creation and every 5 minutes | the agent | ✅ when missing or rejected by the homeserver | ✅ the daemon restarts within 5 minutes of a rejection |
|
| `swarm/agents/<agent>/matrix/main` | `swarm-controller`, at agent creation and every 5 minutes | the agent | ✅ when missing or rejected by the homeserver | ✅ the daemon restarts within 5 minutes of a rejection |
|
||||||
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller`, from a credential you hand it | the agent | must be stated | ✅ retried within 2 to 16 minutes after a rejection |
|
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller`, from a credential you hand it | the agent | must be stated | ✅ retried within 2 to 16 minutes after a rejection |
|
||||||
| `swarm/agents/<agent>/matrix-recovery/<account>` | `swarm-controller`, a random passphrase per account | the agent | ❌ never replaced: the account's key backup opens only with this passphrase | ❌ read when the account comes up |
|
| `swarm/agents/<agent>/matrix-recovery/<account>` | `swarm-controller`, a random passphrase per account | the agent | ❌ never replaced: the account's key backup opens only with this passphrase | ❌ read when the account comes up |
|
||||||
|
|
@ -37,6 +37,7 @@ the rest of the cell says how.
|
||||||
| `swarm/services/ci-runner/registration-token` | `swarm-controller`, from the forge, at start and every 5 minutes | `swarm-bao-ci-runner` on the runner's host | ❌ the forge keeps one token until an admin resets it; the controller then writes the new one within 5 minutes | ✅ every 10 minutes; restarts the runner on a change |
|
| `swarm/services/ci-runner/registration-token` | `swarm-controller`, from the forge, at start and every 5 minutes | `swarm-bao-ci-runner` on the runner's host | ❌ the forge keeps one token until an admin resets it; the controller then writes the new one within 5 minutes | ✅ every 10 minutes; restarts the runner on a change |
|
||||||
| `swarm/services/<grafana client id>/metrics/bearer` | `swarm-bao-grafana-metrics-bearer` on Grafana's host, when none is stored | `swarm-metrics-auth` and `swarm-logs-grafana-auth` | ❌ delete it and restart `swarm-bao-grafana-metrics-bearer` | ✅ the auth services re-read within a minute; ❌ Grafana restarts when its copy changes |
|
| `swarm/services/<grafana client id>/metrics/bearer` | `swarm-bao-grafana-metrics-bearer` on Grafana's host, when none is stored | `swarm-metrics-auth` and `swarm-logs-grafana-auth` | ❌ delete it and restart `swarm-bao-grafana-metrics-bearer` | ✅ the auth services re-read within a minute; ❌ Grafana restarts when its copy changes |
|
||||||
| `swarm/services/<grafana client id>/secret-key` | `swarm-bao-grafana-secret-key` on Grafana's host, from the key an earlier release generated; a new one only when there is none | Grafana | ❌ minted once; a different key leaves Grafana unable to read its stored datasource secrets | ❌ Grafana reads it at start; the unit restarts Grafana when the file changes |
|
| `swarm/services/<grafana client id>/secret-key` | `swarm-bao-grafana-secret-key` on Grafana's host, from the key an earlier release generated; a new one only when there is none | Grafana | ❌ minted once; a different key leaves Grafana unable to read its stored datasource secrets | ❌ Grafana reads it at start; the unit restarts Grafana when the file changes |
|
||||||
|
| `swarm/services/swarm-authelia/keys/<name>`, for `jwt`, `session`, `storage-encryption`, `oidc-hmac` and `oidc-issuer` | authelia's `authelia-swarm-secrets` unit in its container, under the secret publisher's identity, from the key file an earlier release generated; a new one only when there is none | authelia | ❌ minted once; a different storage-encryption key leaves authelia's database unreadable and a different issuer key invalidates every issued token | ❌ read when the unit runs; the unit restarts authelia when the file changes |
|
||||||
| `swarm/services/review-classifier/anthropic-api-key` | you, in the store's UI, field `api_key` | `swarm-review-classifier` | ❌ write a new key | ✅ read on every run |
|
| `swarm/services/review-classifier/anthropic-api-key` | you, in the store's UI, field `api_key` | `swarm-review-classifier` | ❌ write a new key | ✅ read on every run |
|
||||||
| `swarm/services/review-classifier/acp-provider` | you, in the store's UI, field `api_key` | `swarm-review-classifier` | ❌ write a new key | ✅ read on every run |
|
| `swarm/services/review-classifier/acp-provider` | you, in the store's UI, field `api_key` | `swarm-review-classifier` | ❌ write a new key | ✅ read on every run |
|
||||||
| `swarm/services/review-classifier/forge/token` | `swarm-controller`, at start and every 5 minutes | `swarm-controller` | ✅ when missing or no longer matching the forge | ✅ read on every review |
|
| `swarm/services/review-classifier/forge/token` | `swarm-controller`, at start and every 5 minutes | `swarm-controller` | ✅ when missing or no longer matching the forge | ✅ read on every review |
|
||||||
|
|
@ -100,9 +101,8 @@ pieces fit, and the reasoning behind them, is in [`credentials.md`](credentials.
|
||||||
| secret | generated by | lives at | hive elsewhere |
|
| secret | generated by | lives at | hive elsewhere |
|
||||||
| ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| swarm-services root CA (cert + key) | `swarm-bao.nix`'s granting unit, inside the store's `pki` mount | the **key** never leaves the mount; the **cert** lands at `/var/lib/swarm-bao-services-pki/services-root.pem` (0644) and at `/var/lib/hive-tls/swarm-services-root.pem` on every hive | nothing to copy — every hive receives the cert with the leaf it asks the store for, or from `pki/ca/pem` where it fronts no swarm name and needs a store identity |
|
| swarm-services root CA (cert + key) | `swarm-bao.nix`'s granting unit, inside the store's `pki` mount | the **key** never leaves the mount; the **cert** lands at `/var/lib/swarm-bao-services-pki/services-root.pem` (0644) and at `/var/lib/hive-tls/swarm-services-root.pem` on every hive | nothing to copy — every hive receives the cert with the leaf it asks the store for, or from `pki/ca/pem` where it fronts no swarm name and needs a store identity |
|
||||||
| authelia session, JWT and storage-encryption keys | authelia's first-boot unit, in-container | `/var/lib/authelia-swarm/{session,jwt,storage-encryption}.key` | generated in place; nothing outside that container reads them |
|
| authelia session, JWT, storage-encryption and OIDC HMAC keys | `authelia-swarm-secrets` in authelia's container. It stores each in the swarm secret store at `swarm/services/swarm-authelia/keys/<name>` when the store holds none: the value of the key an earlier release generated, or a new one when there is no such file. The files stay on disk. While the store's policy doesn't grant the leaf yet, or the store is down, the unit runs on the files. A container with no secret publisher identity generates the files, as before | `/var/lib/authelia-swarm/{session,jwt,storage-encryption,oidc-hmac}.key` | nothing to place: the publisher's store leaf is the one in the row below. A store that stays unreachable on a start with no key file keeps authelia from starting |
|
||||||
| authelia OIDC HMAC key | same unit | `/var/lib/authelia-swarm/oidc-hmac.key` | same |
|
| authelia OIDC issuer key (RSA) | same unit, same store path (`oidc-issuer`) | `/var/lib/authelia-swarm/oidc-issuer.key` | same — relying parties verify against the **public** half at `/jwks.json` |
|
||||||
| authelia OIDC issuer key (RSA) | same unit | `/var/lib/authelia-swarm/oidc-issuer.key` | same — relying parties verify against the **public** half at `/jwks.json` |
|
|
||||||
| OIDC client secret, plaintext half | `authelia crypto hash generate --random` | `/var/lib/authelia-swarm/oidc-clients/<id>.secret` | operator provides the file and names it in whichever option reads it — `sso.clientSecretFile` for a service; the forge and, by default, the hive collector need none — `swarm-bao-forge-oidc` and `swarm-bao-hive-otel-oidc` fetch it from the store ([how](credentials.md#hive-level-secrets)) |
|
| OIDC client secret, plaintext half | `authelia crypto hash generate --random` | `/var/lib/authelia-swarm/oidc-clients/<id>.secret` | operator provides the file and names it in whichever option reads it — `sso.clientSecretFile` for a service; the forge and, by default, the hive collector need none — `swarm-bao-forge-oidc` and `swarm-bao-hive-otel-oidc` fetch it from the store ([how](credentials.md#hive-level-secrets)) |
|
||||||
| OIDC client secret, digest half | the same mint | `oidc-clients/<id>.digest` | authelia's own half; merged at runtime via `settingsFiles` |
|
| OIDC client secret, digest half | the same mint | `oidc-clients/<id>.digest` | authelia's own half; merged at runtime via `settingsFiles` |
|
||||||
| the swarm collector's copy of its OIDC secret | `swarm-bao-otel-oidc.service` reads it out of the swarm secret store, **on every host that runs the collector and holds a store identity** | `/run/swarm-otel-oidc/<id>.secret` on the host's tmpfs, bind-mounted read-only into the `swarm-otel` container — never on persistent disk, so a reboot fetches it again | same unit, same path — one route, co-located or not. A collector with no store identity has `clientSecretFile == null`, its already-supported unauthenticated-push degrade — see [swarm-level secrets](credentials.md#swarm-level-secrets) |
|
| the swarm collector's copy of its OIDC secret | `swarm-bao-otel-oidc.service` reads it out of the swarm secret store, **on every host that runs the collector and holds a store identity** | `/run/swarm-otel-oidc/<id>.secret` on the host's tmpfs, bind-mounted read-only into the `swarm-otel` container — never on persistent disk, so a reboot fetches it again | same unit, same path — one route, co-located or not. A collector with no store identity has `clientSecretFile == null`, its already-supported unauthenticated-push degrade — see [swarm-level secrets](credentials.md#swarm-level-secrets) |
|
||||||
|
|
@ -112,7 +112,7 @@ pieces fit, and the reasoning behind them, is in [`credentials.md`](credentials.
|
||||||
| Grafana's datasource encryption key | `swarm-bao-grafana-secret-key.service` stores it in the swarm secret store at `swarm/services/<grafana client id>/secret-key` when the store holds none, then copies it out, **on every host that runs Grafana**. It stores the key an earlier release generated at `/var/lib/grafana-secret/secret_key` in the container, so the key doesn't change, and mints a new one only on a host with no such file. Same grant as the admin password, create without update. The earlier file stays on disk. While the store's policy doesn't grant the leaf yet, or the store is down, the unit runs Grafana on that file | `/run/swarm-grafana-secrets/secret-key` on the host's tmpfs, bind-mounted read-only into the `swarm-grafana` container: `security.secret_key` | same unit, same path. Grafana doesn't start until the file exists. Changing the key leaves Grafana unable to read the datasource secrets it stored, so never delete the store path to rotate it |
|
| Grafana's datasource encryption key | `swarm-bao-grafana-secret-key.service` stores it in the swarm secret store at `swarm/services/<grafana client id>/secret-key` when the store holds none, then copies it out, **on every host that runs Grafana**. It stores the key an earlier release generated at `/var/lib/grafana-secret/secret_key` in the container, so the key doesn't change, and mints a new one only on a host with no such file. Same grant as the admin password, create without update. The earlier file stays on disk. While the store's policy doesn't grant the leaf yet, or the store is down, the unit runs Grafana on that file | `/run/swarm-grafana-secrets/secret-key` on the host's tmpfs, bind-mounted read-only into the `swarm-grafana` container: `security.secret_key` | same unit, same path. Grafana doesn't start until the file exists. Changing the key leaves Grafana unable to read the datasource secrets it stored, so never delete the store path to rotate it |
|
||||||
| the swarm controller's webhook secret | `swarm-controller` stores it in the swarm secret store at `swarm/controller/swarm-controller/forge/webhook-secret` when the store holds none: the value of `webhook-secret` in its state directory, or a new one when there is no such file. The state directory file stays on disk and is the fallback while the store is unreachable or its policy doesn't grant the path yet | the swarm secret store; the controller holds it in memory and registers the same value with the forge's hooks | nothing to place: the controller's store leaf already exists. A store that stays unreachable on a start with no state directory file leaves the webhook endpoint answering 503 |
|
| the swarm controller's webhook secret | `swarm-controller` stores it in the swarm secret store at `swarm/controller/swarm-controller/forge/webhook-secret` when the store holds none: the value of `webhook-secret` in its state directory, or a new one when there is no such file. The state directory file stays on disk and is the fallback while the store is unreachable or its policy doesn't grant the path yet | the swarm secret store; the controller holds it in memory and registers the same value with the forge's hooks | nothing to place: the controller's store leaf already exists. A store that stays unreachable on a start with no state directory file leaves the webhook endpoint answering 503 |
|
||||||
| the metrics store responder's own store leaf (cert + key) | `signLeaf` in `glue-bao-tls.nix` (`swarm-bao-pki.service`), on the host that runs the secret store, once, subject `deploy.bao.metricsAuthCommonName`. `swarm-bao-metrics-auth-policy.service` writes the `swarm-metrics-auth` role and its read grants on the bearer leaf and on the metrics store forwarder's client secret, nothing else | `/var/lib/swarm-bao-pki/metrics-auth.pem` and `metrics-auth-key.pem` on the store's host, handed to `swarm-metrics-auth` through `LoadCredential` (paths in argv, never values) and read by path by `swarm-victoriametrics-forwarder-oidc` | **the operator** places a leaf with that subject on the metrics store's host and names it in `deploy.victoriametrics.authBaoClientCertFile` / `authBaoClientKeyFile`; evaluation fails naming both until then. If the store is on another host, deploy that host first: the role must exist before the responder's login succeeds, and until it does the gateway refuses every Grafana query (fail closed) |
|
| the metrics store responder's own store leaf (cert + key) | `signLeaf` in `glue-bao-tls.nix` (`swarm-bao-pki.service`), on the host that runs the secret store, once, subject `deploy.bao.metricsAuthCommonName`. `swarm-bao-metrics-auth-policy.service` writes the `swarm-metrics-auth` role and its read grants on the bearer leaf and on the metrics store forwarder's client secret, nothing else | `/var/lib/swarm-bao-pki/metrics-auth.pem` and `metrics-auth-key.pem` on the store's host, handed to `swarm-metrics-auth` through `LoadCredential` (paths in argv, never values) and read by path by `swarm-victoriametrics-forwarder-oidc` | **the operator** places a leaf with that subject on the metrics store's host and names it in `deploy.victoriametrics.authBaoClientCertFile` / `authBaoClientKeyFile`; evaluation fails naming both until then. If the store is on another host, deploy that host first: the role must exist before the responder's login succeeds, and until it does the gateway refuses every Grafana query (fail closed) |
|
||||||
| the secret publisher's store leaf (cert + key) | `signLeaf` in `glue-bao-tls.nix` (`swarm-bao-pki.service`), on the host that runs the secret store, once, subject `deploy.bao.secretPublisherCommonName`. `swarm-bao-secret-publisher-policy.service` writes the `swarm-secret-publisher` role: create and update under `swarm/hives/*`, `swarm/services/*` and the controller's client, and read on authelia's forwarder's client secret alone | `/var/lib/swarm-bao-pki/secret-publisher.pem` and `secret-publisher-key.pem` on the host, handed into authelia's container as nspawn system credentials, which `swarm-secret-publish` and the forwarder's `swarm-journal-forwarder-oidc` load with `LoadCredential`. nspawn won't start authelia's container while a named file is missing | **the operator** copies the leaf to authelia's host, beside the hive's own store leaf, where `glue-secret-publisher-bao-identity.nix` looks, or names it in `deploy.swarm-secret-publisher.baoClientCertFile` / `baoClientKeyFile` |
|
| the secret publisher's store leaf (cert + key) | `signLeaf` in `glue-bao-tls.nix` (`swarm-bao-pki.service`), on the host that runs the secret store, once, subject `deploy.bao.secretPublisherCommonName`. `swarm-bao-secret-publisher-policy.service` writes the `swarm-secret-publisher` role: create and update under `swarm/hives/*`, `swarm/services/*` and the controller's client, and read on authelia's forwarder's client secret and authelia's five keys alone | `/var/lib/swarm-bao-pki/secret-publisher.pem` and `secret-publisher-key.pem` on the host, handed into authelia's container as nspawn system credentials, which `swarm-secret-publish` and the forwarder's `swarm-journal-forwarder-oidc` load with `LoadCredential`. nspawn won't start authelia's container while a named file is missing | **the operator** copies the leaf to authelia's host, beside the hive's own store leaf, where `glue-secret-publisher-bao-identity.nix` looks, or names it in `deploy.swarm-secret-publisher.baoClientCertFile` / `baoClientKeyFile` |
|
||||||
| the swarm controller's copy of its OIDC secret | `swarm-controller` reads it out of the swarm secret store at start, under its own leaf | the daemon's memory only; never on disk | same read, co-located or not. Nothing for an operator to place beyond the controller's store leaf (`deploy.swarm-controller.baoClientCertFile`) |
|
| the swarm controller's copy of its OIDC secret | `swarm-controller` reads it out of the swarm secret store at start, under its own leaf | the daemon's memory only; never on disk | same read, co-located or not. Nothing for an operator to place beyond the controller's store leaf (`deploy.swarm-controller.baoClientCertFile`) |
|
||||||
| a hive's copy of its own OIDC secret | hive-c0re reads it out of the swarm secret store when it first connects to the queue, under the hive's own leaf | the daemon's memory only; never on disk | same read, co-located or not. Nothing for an operator to place beyond the hive's store leaf (`deploy.bao.clientCertFile`) |
|
| a hive's copy of its own OIDC secret | hive-c0re reads it out of the swarm secret store when it first connects to the queue, under the hive's own leaf | the daemon's memory only; never on disk | same read, co-located or not. Nothing for an operator to place beyond the hive's store leaf (`deploy.bao.clientCertFile`) |
|
||||||
| the hive collector's copy of its hive's OIDC secret | `swarm-bao-hive-otel-oidc.service` reads it out of the swarm secret store under the hive's own leaf, **on every hive that runs the collector and holds that leaf** | `/run/hive-otel-oidc/hive-<name>.secret` on the hive's host tmpfs, 0400 root, loaded by `LoadCredential` — never on persistent disk, so a reboot fetches it again | same unit, same path, co-located or not. Nothing for an operator to place beyond the hive's store leaf (`deploy.bao.clientCertFile`) |
|
| the hive collector's copy of its hive's OIDC secret | `swarm-bao-hive-otel-oidc.service` reads it out of the swarm secret store under the hive's own leaf, **on every hive that runs the collector and holds that leaf** | `/run/hive-otel-oidc/hive-<name>.secret` on the hive's host tmpfs, 0400 root, loaded by `LoadCredential` — never on persistent disk, so a reboot fetches it again | same unit, same path, co-located or not. Nothing for an operator to place beyond the hive's store leaf (`deploy.bao.clientCertFile`) |
|
||||||
|
|
|
||||||
|
|
@ -71,11 +71,19 @@ neither is a renaming of the other.
|
||||||
|
|
||||||
The rows are in the [reference](credentials-reference.md#swarm-level--one-of-each-per-swarm).
|
The rows are in the [reference](credentials-reference.md#swarm-level--one-of-each-per-swarm).
|
||||||
|
|
||||||
Authelia mints the three keys for itself, in-container, precisely
|
Nothing outside authelia's container reads its keys (session, JWT, storage encryption,
|
||||||
because nothing outside that container ever reads them. **That's the test worth
|
OIDC HMAC and the OIDC issuer), which is why no delivery step exists for them. **That's the
|
||||||
applying to any secret added here** — and the client secret's plaintext half is
|
test worth applying to any secret added here** — and the client secret's plaintext half is
|
||||||
the one row that fails it, which is the entire reason a delivery step exists.
|
the one row that fails it, which is the entire reason a delivery step exists.
|
||||||
|
|
||||||
|
The store still holds those five keys, at `swarm/services/swarm-authelia/keys/<name>`,
|
||||||
|
so that losing the host doesn't lose them. `authelia-swarm-secrets` in the container reads
|
||||||
|
each with the secret publisher's identity and writes it to the file authelia loads. A store
|
||||||
|
with no value takes the file's, so the key keeps its value; the unit mints a new key only when
|
||||||
|
there is neither. Nothing rotates a key: the storage-encryption key encrypts authelia's
|
||||||
|
database, and a new issuer key invalidates every token issued. A container without the
|
||||||
|
publisher's identity generates the files as before.
|
||||||
|
|
||||||
Three telemetry collectors exist, and each one fails that test: each reads its
|
Three telemetry collectors exist, and each one fails that test: each reads its
|
||||||
own secret out of the store, under its own identity.
|
own secret out of the store, under its own identity.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -128,8 +128,7 @@ publishes it as
|
||||||
It answers only swarm-controller's OIDC client
|
It answers only swarm-controller's OIDC client
|
||||||
(`swarm.controller.queueClientId`) and refuses every other token `403`.
|
(`swarm.controller.queueClientId`) and refuses every other token `403`.
|
||||||
|
|
||||||
On first boot this module seeds the users database with a placeholder subject marked `disabled`, so authelia has a non-empty store to start against before any real account exists (`swarm-authelia.nix:873`). Authelia generates session and storage keys in the container on
|
On first boot this module seeds the users database with a placeholder subject marked `disabled`, so authelia has a non-empty store to start against before any real account exists (`swarm-authelia.nix:873`). Authelia's session, storage and other keys live in the swarm secret store; the container copies them into place at start, seeded from the files an earlier release generated there, and generates them itself only where it has no store identity. Nothing rotates them automatically; replacing one
|
||||||
first boot and never rotates them automatically; replacing one
|
|
||||||
invalidates data already written (sessions, the encrypted store), so
|
invalidates data already written (sessions, the encrypted store), so
|
||||||
that's an operator action.
|
that's an operator action.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,9 @@ in
|
||||||
# cache's key, cache and tokens, twice over against stub tools: the second
|
# cache's key, cache and tokens, twice over against stub tools: the second
|
||||||
# run must mint nothing.
|
# run must mint nothing.
|
||||||
script-test-attic-init = import ./script-tests/attic-init.nix { inherit pkgs; };
|
script-test-attic-init = import ./script-tests/attic-init.nix { inherit pkgs; };
|
||||||
|
# Runs ./host-modules/lib/store-seeded-secret.nix's seed, store-wins and mint
|
||||||
|
# decisions against a stub `bao` that keeps its leaves in files.
|
||||||
|
script-test-store-seeded-secret = import ./script-tests/store-seeded-secret.nix { inherit pkgs; };
|
||||||
module-eval-nats-tls = import ./module-eval/nats-tls.nix {
|
module-eval-nats-tls = import ./module-eval/nats-tls.nix {
|
||||||
inherit pkgs self nixosSystem;
|
inherit pkgs self nixosSystem;
|
||||||
inherit (pkgs) lib;
|
inherit (pkgs) lib;
|
||||||
|
|
|
||||||
86
nix/host-modules/lib/store-seeded-secret.nix
Normal file
86
nix/host-modules/lib/store-seeded-secret.nix
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# Shared shell steps for a unit that keeps a secret it once generated on disk
|
||||||
|
# in the swarm secret store instead. The store's value wins; a store holding
|
||||||
|
# none is seeded from the old file, so the secret keeps its value; a new value
|
||||||
|
# is minted only when there is no file either.
|
||||||
|
#
|
||||||
|
# `store_seeded` prints the value and never rotates one: the store is written
|
||||||
|
# with `-cas=0`, so only a leaf that holds nothing takes a value, and a lost
|
||||||
|
# race reads the winner's back.
|
||||||
|
#
|
||||||
|
# Pure function — NOT a NixOS module. Call it from a module's `let`:
|
||||||
|
#
|
||||||
|
# storeSeededSecret = import ./lib/store-seeded-secret.nix { };
|
||||||
|
# script = ''
|
||||||
|
# ${storeSeededSecret}
|
||||||
|
# store_init
|
||||||
|
# store_login
|
||||||
|
# key="$(store_seeded secret/swarm/services/foo/key /var/lib/foo/key mint_key)" || exit 1
|
||||||
|
# '';
|
||||||
|
#
|
||||||
|
# Needs `bao` and `coreutils` on the caller's `path` and the `BAO_*` client
|
||||||
|
# environment set. Takes the EXIT trap, to remove its scratch file.
|
||||||
|
{ }:
|
||||||
|
''
|
||||||
|
# Bounded, so a store that drops packets cannot hold the caller past its
|
||||||
|
# start timeout; one that refuses answers at once.
|
||||||
|
store_bao() {
|
||||||
|
timeout 15 bao "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
store_init() {
|
||||||
|
STORE_ERR="$(mktemp)"
|
||||||
|
STORE_UP=0
|
||||||
|
trap 'rm -f "$STORE_ERR"' EXIT
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sets STORE_UP. A store that cannot be logged in to leaves every secret to
|
||||||
|
# its file.
|
||||||
|
store_login() {
|
||||||
|
local token
|
||||||
|
if token="$(store_bao login -method=cert -token-only 2>"$STORE_ERR")"; then
|
||||||
|
BAO_TOKEN="$token"
|
||||||
|
export BAO_TOKEN
|
||||||
|
STORE_UP=1
|
||||||
|
else
|
||||||
|
cat "$STORE_ERR" >&2
|
||||||
|
echo "could not log in to the swarm secret store; secrets keep the value of their files." >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# <leaf> <earlier file> <mint command…>. The value on stdout; status 1 when
|
||||||
|
# neither the store nor the file can give one.
|
||||||
|
store_seeded() {
|
||||||
|
local leaf="$1" earlier="$2" value candidate
|
||||||
|
shift 2
|
||||||
|
if [ "$STORE_UP" = 1 ]; then
|
||||||
|
if value="$(store_bao kv get -field=value "$leaf" 2>"$STORE_ERR")" && [ -n "$value" ]; then
|
||||||
|
printf '%s' "$value"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ -s "$earlier" ]; then
|
||||||
|
candidate="$(< "$earlier")"
|
||||||
|
echo "storing the value of $earlier in $leaf." >&2
|
||||||
|
else
|
||||||
|
candidate="$("$@")"
|
||||||
|
echo "no earlier value; minting $leaf." >&2
|
||||||
|
fi
|
||||||
|
if ! printf '%s' "$candidate" | store_bao kv put -cas=0 "$leaf" value=- >/dev/null 2>"$STORE_ERR"; then
|
||||||
|
echo "could not create $leaf; reading it back in case another run created it." >&2
|
||||||
|
cat "$STORE_ERR" >&2
|
||||||
|
fi
|
||||||
|
if value="$(store_bao kv get -field=value "$leaf" 2>"$STORE_ERR")" && [ -n "$value" ]; then
|
||||||
|
printf '%s' "$value"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
cat "$STORE_ERR" >&2
|
||||||
|
echo "the store did not return $leaf." >&2
|
||||||
|
fi
|
||||||
|
if [ -s "$earlier" ]; then
|
||||||
|
echo "using the value of $earlier for $leaf." >&2
|
||||||
|
printf '%s' "$(< "$earlier")"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "no value for $leaf in the store or at $earlier." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
''
|
||||||
|
|
@ -180,6 +180,8 @@ let
|
||||||
instance = lib.removePrefix "authelia-" unitName;
|
instance = lib.removePrefix "authelia-" unitName;
|
||||||
stateDir = "/var/lib/${unitName}";
|
stateDir = "/var/lib/${unitName}";
|
||||||
atomicWriteSecret = import ./lib/atomic-write-secret.nix { };
|
atomicWriteSecret = import ./lib/atomic-write-secret.nix { };
|
||||||
|
refreshConsumer = import ./lib/refresh-consumer.nix { };
|
||||||
|
storeSeededSecret = import ./lib/store-seeded-secret.nix { };
|
||||||
|
|
||||||
caTrust = import ./lib/hive-ca-trust.nix {
|
caTrust = import ./lib/hive-ca-trust.nix {
|
||||||
inherit lib gatewayCfg;
|
inherit lib gatewayCfg;
|
||||||
|
|
@ -464,6 +466,7 @@ let
|
||||||
# The OIDC half of the first-boot generator, kept out of the script
|
# The OIDC half of the first-boot generator, kept out of the script
|
||||||
# body so neither is read through the other's indentation.
|
# body so neither is read through the other's indentation.
|
||||||
oidcGenScript = lib.optionalString oidcEnabled ''
|
oidcGenScript = lib.optionalString oidcEnabled ''
|
||||||
|
${lib.optionalString (!publishes) ''
|
||||||
# The issuer key is the one secret here that is NOT interchangeable
|
# The issuer key is the one secret here that is NOT interchangeable
|
||||||
# with a random blob: it *signs* id tokens, and every relying party
|
# with a random blob: it *signs* id tokens, and every relying party
|
||||||
# verifies them against the public half served at `/jwks.json`. A
|
# verifies them against the public half served at `/jwks.json`. A
|
||||||
|
|
@ -478,7 +481,7 @@ let
|
||||||
echo "generated $issuer"
|
echo "generated $issuer"
|
||||||
fi
|
fi
|
||||||
chmod 0600 "$issuer"
|
chmod 0600 "$issuer"
|
||||||
|
''}
|
||||||
# Each client's secret, minted once and kept as two files: the
|
# Each client's secret, minted once and kept as two files: the
|
||||||
# plaintext its relying party authenticates with, and the digest
|
# plaintext its relying party authenticates with, and the digest
|
||||||
# authelia compares against. The two live in different containers,
|
# authelia compares against. The two live in different containers,
|
||||||
|
|
@ -534,6 +537,68 @@ let
|
||||||
mv ${lib.escapeShellArg "${clientsFile}.tmp"} ${lib.escapeShellArg clientsFile}
|
mv ${lib.escapeShellArg "${clientsFile}.tmp"} ${lib.escapeShellArg clientsFile}
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
storeRetry = import ./lib/store-retry.nix { };
|
||||||
|
|
||||||
|
# authelia's keys, one leaf each under its container's name. The nix half of
|
||||||
|
# `autheliaKeyLeaves` in ./swarm-bao.nix, which grants the publisher
|
||||||
|
# `create` and `read` on exactly these.
|
||||||
|
keyLeaf = name: "secret/swarm/services/${cfg.machine}/keys/${name}";
|
||||||
|
|
||||||
|
# The keys as a file-per-key script, for a container with no store identity:
|
||||||
|
# each is generated once and never rotated here. The session and storage
|
||||||
|
# keys are load-bearing for data already written (sessions, the encrypted
|
||||||
|
# database), so replacing one is an operator action, not a boot action.
|
||||||
|
fileKeysScript = ''
|
||||||
|
for f in ${lib.concatStringsSep " " randomKeys}; do
|
||||||
|
p=${lib.escapeShellArg stateDir}/"$f".key
|
||||||
|
if [ ! -s "$p" ]; then
|
||||||
|
value=$(head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
||||||
|
atomic_write_secret 0600 "" "$p" "$value"
|
||||||
|
echo "generated $p"
|
||||||
|
fi
|
||||||
|
chmod 0600 "$p"
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
|
||||||
|
# The same keys from the store, at the same paths. The store's value wins,
|
||||||
|
# then the file's, which seeds the store when it holds none, so a key never
|
||||||
|
# changes value by moving; a new one is minted only where neither exists.
|
||||||
|
# The issuer key is an RSA pair rather than a random blob because it signs
|
||||||
|
# id tokens, which relying parties verify against the public half.
|
||||||
|
storeKeysScript = ''
|
||||||
|
${storeSeededSecret}
|
||||||
|
${refreshConsumer}
|
||||||
|
store_init
|
||||||
|
store_login
|
||||||
|
|
||||||
|
mint_hex() {
|
||||||
|
head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n'
|
||||||
|
}
|
||||||
|
mint_issuer() {
|
||||||
|
openssl genrsa 4096
|
||||||
|
}
|
||||||
|
|
||||||
|
# <name> <mint command…>. A changed file reaches a running authelia by
|
||||||
|
# restarting it; it reads its keys once, as it starts.
|
||||||
|
keep_key() {
|
||||||
|
local name="$1" p value
|
||||||
|
p=${lib.escapeShellArg stateDir}/"$name".key
|
||||||
|
shift
|
||||||
|
value="$(store_seeded ${lib.escapeShellArg (keyLeaf "")}"$name" "$p" "$@")" || exit 1
|
||||||
|
if secret_differs "$p" "$value"; then
|
||||||
|
atomic_write_secret 0600 "" "$p" "$value"
|
||||||
|
echo "wrote $p"
|
||||||
|
refresh_unit ${lib.escapeShellArg cfg.unit} "$p"
|
||||||
|
fi
|
||||||
|
chmod 0600 "$p"
|
||||||
|
}
|
||||||
|
|
||||||
|
for f in ${lib.concatStringsSep " " randomKeys}; do
|
||||||
|
keep_key "$f" mint_hex
|
||||||
|
done
|
||||||
|
${lib.optionalString oidcEnabled "keep_key oidc-issuer mint_issuer"}
|
||||||
|
'';
|
||||||
|
|
||||||
# A netns of its own on the hive bridge: the container dials the store, the
|
# A netns of its own on the hive bridge: the container dials the store, the
|
||||||
# swarm collector and its own public name through the bridge, and the
|
# swarm collector and its own public name through the bridge, and the
|
||||||
# gateway reaches the three listeners below over sockets, so authelia binds
|
# gateway reaches the three listeners below over sockets, so authelia binds
|
||||||
|
|
@ -955,7 +1020,7 @@ in
|
||||||
(import ../container-modules/swarm-container-resolver.nix {
|
(import ../container-modules/swarm-container-resolver.nix {
|
||||||
inherit (networkCfg) bridgeIp;
|
inherit (networkCfg) bridgeIp;
|
||||||
# The forwarder dials the swarm collector and authelia by name,
|
# The forwarder dials the swarm collector and authelia by name,
|
||||||
# and its fetch and the publisher the store.
|
# and its fetch, the publisher and the key unit the store.
|
||||||
dnsConsumers = [
|
dnsConsumers = [
|
||||||
cfg.unit
|
cfg.unit
|
||||||
]
|
]
|
||||||
|
|
@ -963,6 +1028,7 @@ in
|
||||||
"opentelemetry-collector.service"
|
"opentelemetry-collector.service"
|
||||||
"${forwarderFetchUnit}.service"
|
"${forwarderFetchUnit}.service"
|
||||||
"${publisherUnit}.service"
|
"${publisherUnit}.service"
|
||||||
|
"${unitName}-secrets.service"
|
||||||
];
|
];
|
||||||
})
|
})
|
||||||
caBundleModule
|
caBundleModule
|
||||||
|
|
@ -1022,12 +1088,12 @@ in
|
||||||
# through the store path, and nothing puts the CLI on PATH.
|
# through the store path, and nothing puts the CLI on PATH.
|
||||||
environment.systemPackages = [ deployCfg.authelia.package ];
|
environment.systemPackages = [ deployCfg.authelia.package ];
|
||||||
|
|
||||||
# authelia's own secrets, generated in-container on first
|
# authelia's own keys: jwt, session, storage encryption, OIDC HMAC
|
||||||
# boot. They are jwt/session/storage keys — nothing outside
|
# and the OIDC issuer. Nothing outside this container reads them.
|
||||||
# this container ever reads them, which is what makes
|
# With the publisher's store identity the swarm secret store holds
|
||||||
# in-container generation right rather than merely easier.
|
# them and this unit copies them into place, seeding the store from
|
||||||
# (hive-matrix generates its token host-side only because
|
# the files an earlier release generated here when it holds none;
|
||||||
# hive-c0re has to read that one.)
|
# without it the unit generates them on first boot, as before.
|
||||||
#
|
#
|
||||||
# Same `User`/`Group`/`StateDirectory` as the authelia unit,
|
# Same `User`/`Group`/`StateDirectory` as the authelia unit,
|
||||||
# so systemd creates the directory owned by the account that
|
# so systemd creates the directory owned by the account that
|
||||||
|
|
@ -1049,7 +1115,15 @@ in
|
||||||
++ lib.optionals oidcEnabled [
|
++ lib.optionals oidcEnabled [
|
||||||
pkgs.openssl
|
pkgs.openssl
|
||||||
deployCfg.authelia.package
|
deployCfg.authelia.package
|
||||||
|
]
|
||||||
|
++ lib.optionals publishes [
|
||||||
|
deployCfg.bao.package
|
||||||
|
pkgs.systemd
|
||||||
];
|
];
|
||||||
|
# A store that cannot answer, with a key missing on disk, is a
|
||||||
|
# failed run that retries (./lib/store-retry.nix).
|
||||||
|
startLimitBurst = lib.mkIf publishes storeRetry.startLimitBurst;
|
||||||
|
startLimitIntervalSec = lib.mkIf publishes storeRetry.startLimitIntervalSec;
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
RemainAfterExit = true;
|
RemainAfterExit = true;
|
||||||
|
|
@ -1059,25 +1133,31 @@ in
|
||||||
StateDirectoryMode = "0700";
|
StateDirectoryMode = "0700";
|
||||||
UMask = "0077";
|
UMask = "0077";
|
||||||
SyslogIdentifier = "${unitName}-secrets";
|
SyslogIdentifier = "${unitName}-secrets";
|
||||||
};
|
}
|
||||||
|
// lib.optionalAttrs publishes (
|
||||||
|
storeRetry.serviceConfig
|
||||||
|
// {
|
||||||
|
LoadCredential = lib.attrNames storeFiles;
|
||||||
|
TimeoutStartSec = 120;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
environment = lib.optionalAttrs publishes (
|
||||||
|
let
|
||||||
|
store = storeIdentityIn "${unitName}-secrets";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
BAO_ADDR = store.addr;
|
||||||
|
BAO_CLIENT_CERT = store.clientCertFile;
|
||||||
|
BAO_CLIENT_KEY = store.clientKeyFile;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (store.caFile != null) { BAO_CACERT = store.caFile; }
|
||||||
|
);
|
||||||
script = ''
|
script = ''
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
${atomicWriteSecret}
|
${atomicWriteSecret}
|
||||||
|
|
||||||
# Each is generated once and never rotated here: the
|
${if publishes then storeKeysScript else fileKeysScript}
|
||||||
# session and storage keys are load-bearing for data
|
|
||||||
# already written (sessions, the encrypted store), so
|
|
||||||
# replacing one is an operator action, not a boot action.
|
|
||||||
for f in ${lib.concatStringsSep " " randomKeys}; do
|
|
||||||
p=${lib.escapeShellArg stateDir}/"$f".key
|
|
||||||
if [ ! -s "$p" ]; then
|
|
||||||
value=$(head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
|
||||||
atomic_write_secret 0600 "" "$p" "$value"
|
|
||||||
echo "generated $p"
|
|
||||||
fi
|
|
||||||
chmod 0600 "$p"
|
|
||||||
done
|
|
||||||
${oidcGenScript}
|
${oidcGenScript}
|
||||||
|
|
||||||
# authelia exits at startup on an empty user set, so an empty
|
# authelia exits at startup on an empty user set, so an empty
|
||||||
|
|
|
||||||
|
|
@ -543,9 +543,9 @@ let
|
||||||
# here too — a grant widens when a path gains a WRITER, not when a kind is
|
# here too — a grant widens when a path gains a WRITER, not when a kind is
|
||||||
# declared.
|
# declared.
|
||||||
#
|
#
|
||||||
# Write-only but for one leaf, the client secret of its container's forwarder:
|
# Write-only but for its forwarder's client secret and authelia's keys, read at
|
||||||
# a read on a prefix would let a file-copier recover every hive's credentials.
|
# exact leaves: a read on a prefix would let a file-copier recover every
|
||||||
# That stanza repeats `create`/`update`, which its exact path would drop.
|
# hive's credentials. The forwarder's stanza repeats `create`/`update`.
|
||||||
#
|
#
|
||||||
# The queue callout's three seeds sit under `swarm/services/` and are no
|
# The queue callout's three seeds sit under `swarm/services/` and are no
|
||||||
# secret this principal copies, so their exact paths, which outrank the
|
# secret this principal copies, so their exact paths, which outrank the
|
||||||
|
|
@ -608,7 +608,8 @@ let
|
||||||
path "${credentialMountPath}/data/swarm/services/${hyperhiveCfg.swarm.authelia.otel.clientId}/oidc/client" {
|
path "${credentialMountPath}/data/swarm/services/${hyperhiveCfg.swarm.authelia.otel.clientId}/oidc/client" {
|
||||||
capabilities = ["create", "update", "read"]
|
capabilities = ["create", "update", "read"]
|
||||||
}
|
}
|
||||||
'';
|
''
|
||||||
|
+ lib.concatMapStrings (leaf: createStanza "${credentialMountPath}/data/${leaf}") autheliaKeyLeaves;
|
||||||
|
|
||||||
# The identity the matrix container's `swarm-matrix-ctl` presents. Named outside `hive-*`
|
# The identity the matrix container's `swarm-matrix-ctl` presents. Named outside `hive-*`
|
||||||
# for the reason its two siblings above give — the controller may rewrite
|
# for the reason its two siblings above give — the controller may rewrite
|
||||||
|
|
@ -1087,6 +1088,19 @@ let
|
||||||
# nix half of `secretKeyStorePath` in ./swarm-grafana.nix.
|
# nix half of `secretKeyStorePath` in ./swarm-grafana.nix.
|
||||||
grafanaSecretKeyLeaf = "swarm/services/${hyperhiveCfg.swarm.grafana.oidc.clientId}/secret-key";
|
grafanaSecretKeyLeaf = "swarm/services/${hyperhiveCfg.swarm.grafana.oidc.clientId}/secret-key";
|
||||||
|
|
||||||
|
# authelia's jwt, session, storage-encryption, OIDC HMAC and OIDC issuer keys,
|
||||||
|
# which only the secret publisher in authelia's container creates and reads.
|
||||||
|
# The nix half of `keyLeaf` in ./swarm-authelia.nix. `create` without
|
||||||
|
# `update`, so a stored key is never replaced: the storage-encryption key
|
||||||
|
# encrypts authelia's database and the issuer key signs every id token.
|
||||||
|
autheliaKeyLeaves = map (name: "swarm/services/${autheliaCfg.machine}/keys/${name}") [
|
||||||
|
"jwt"
|
||||||
|
"session"
|
||||||
|
"storage-encryption"
|
||||||
|
"oidc-hmac"
|
||||||
|
"oidc-issuer"
|
||||||
|
];
|
||||||
|
|
||||||
createStanza = path: ''
|
createStanza = path: ''
|
||||||
path "${path}" {
|
path "${path}" {
|
||||||
capabilities = ["create", "read"]
|
capabilities = ["create", "read"]
|
||||||
|
|
|
||||||
|
|
@ -409,13 +409,14 @@ let
|
||||||
# The whole point of a second principal. The two prefixes and one leaf it
|
# The whole point of a second principal. The two prefixes and one leaf it
|
||||||
# publishes to and not `swarm/`, so it cannot touch an agent's
|
# publishes to and not `swarm/`, so it cannot touch an agent's
|
||||||
# credentials or the appservice token beside the controller's client;
|
# credentials or the appservice token beside the controller's client;
|
||||||
# and `read` on one leaf alone, the client secret of the journal forwarder
|
# and `read` on six exact leaves alone: the client secret of the journal forwarder
|
||||||
# that shares its identity in authelia's container, so a unit whose job
|
# that shares its identity in authelia's container, and authelia's five keys
|
||||||
# is copying a file cannot recover what is already there. Pinned as the
|
# (the next case), so a unit whose job is copying a file cannot recover
|
||||||
# full capability list per path, because an added capability is exactly
|
# what is already there. Pinned as the full capability list per path,
|
||||||
# what a presence check misses. The one `read` is found at that leaf and
|
# because an added capability is exactly what a presence check misses.
|
||||||
# counted once, so a read on any other path fails the count.
|
# The forwarder's `read` is found at its leaf, and the six together are
|
||||||
name = "the publisher's grant writes the hive and service prefixes and the controller's client, and reads its forwarder's client alone";
|
# counted, so a read on any other path fails the count.
|
||||||
|
name = "the publisher's grant writes the hive and service prefixes and the controller's client, and reads its forwarder's client and authelia's keys alone";
|
||||||
ok =
|
ok =
|
||||||
let
|
let
|
||||||
s = baoGrantHere.systemd.services.swarm-bao-secret-publisher-policy.script;
|
s = baoGrantHere.systemd.services.swarm-bao-secret-publisher-policy.script;
|
||||||
|
|
@ -429,9 +430,48 @@ let
|
||||||
&& !(lib.hasInfix "secret/data/swarm/*" s)
|
&& !(lib.hasInfix "secret/data/swarm/*" s)
|
||||||
&& !(lib.hasInfix "sys/policies/acl" s)
|
&& !(lib.hasInfix "sys/policies/acl" s)
|
||||||
&& lib.hasInfix "path \"secret/data/swarm/services/swarm-authelia-collector/oidc/client\" {\n capabilities = [\"create\", \"update\", \"read\"]\n}" s
|
&& lib.hasInfix "path \"secret/data/swarm/services/swarm-authelia-collector/oidc/client\" {\n capabilities = [\"create\", \"update\", \"read\"]\n}" s
|
||||||
&& lib.length (lib.splitString "\"read\"" s) == 2
|
&& lib.length (lib.splitString "\"read\"" s) == 7
|
||||||
&& !(lib.hasInfix "\"list\"" s);
|
&& !(lib.hasInfix "\"list\"" s);
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
# authelia's keys are created once and read at every start, never
|
||||||
|
# replaced: the storage-encryption key encrypts authelia's database and
|
||||||
|
# the issuer key signs every id token. Each is an exact leaf, which
|
||||||
|
# outranks the publisher's `services/*` write and drops its `update`.
|
||||||
|
# The controls are the OIDC leaf beside them, still the prefix's, and a
|
||||||
|
# sixth name under the same directory, which is the prefix's too.
|
||||||
|
name = "the publisher creates and reads authelia's five keys and may not replace one";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
grants = grantsIn baoGrantHere.systemd.services.swarm-bao-secret-publisher-policy.script;
|
||||||
|
key = name: grantFor grants "secret/data/swarm/services/swarm-authelia/keys/${name}";
|
||||||
|
in
|
||||||
|
lib.all
|
||||||
|
(
|
||||||
|
name:
|
||||||
|
(key name).caps == [
|
||||||
|
"create"
|
||||||
|
"read"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
[
|
||||||
|
"jwt"
|
||||||
|
"session"
|
||||||
|
"storage-encryption"
|
||||||
|
"oidc-hmac"
|
||||||
|
"oidc-issuer"
|
||||||
|
]
|
||||||
|
&&
|
||||||
|
(key "other").caps == [
|
||||||
|
"create"
|
||||||
|
"update"
|
||||||
|
]
|
||||||
|
&&
|
||||||
|
(grantFor grants "secret/data/swarm/services/swarm-nats/oidc/client").caps == [
|
||||||
|
"create"
|
||||||
|
"update"
|
||||||
|
];
|
||||||
|
}
|
||||||
{
|
{
|
||||||
# The publisher's `services/*` write covers the callout xkey's seed, and
|
# The publisher's `services/*` write covers the callout xkey's seed, and
|
||||||
# only the exact-path denial, which outranks the prefix, keeps it from
|
# only the exact-path denial, which outranks the prefix, keeps it from
|
||||||
|
|
|
||||||
|
|
@ -422,6 +422,36 @@ let
|
||||||
&& ((inAuthelia secretPublisherHere).systemd.services ? swarm-secret-publish)
|
&& ((inAuthelia secretPublisherHere).systemd.services ? swarm-secret-publish)
|
||||||
&& lib.any (lib.hasPrefix "--load-credential=") secretPublisherHere.containers.swarm-authelia.extraFlags;
|
&& lib.any (lib.hasPrefix "--load-credential=") secretPublisherHere.containers.swarm-authelia.extraFlags;
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
# Every key authelia signs or encrypts with comes from the store, seeded
|
||||||
|
# from the file it was generated into, and lands at the path the config
|
||||||
|
# already names. The storage-encryption key encrypts authelia's database,
|
||||||
|
# so a value that moved would leave it unreadable. With no identity the
|
||||||
|
# unit still generates the files, which is the control for each absence:
|
||||||
|
# the generator and the store sequence are found by the same lookups.
|
||||||
|
name = "authelia's keys are read from the store with the publisher's identity, and generated on disk without one";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
unit = m: (inAuthelia m).systemd.services.authelia-swarm-secrets;
|
||||||
|
viaStore = unit secretPublisherHere;
|
||||||
|
onDisk = unit secretPublisherNoIdentity;
|
||||||
|
in
|
||||||
|
lib.hasInfix "store_seeded secret/swarm/services/swarm-authelia/keys/\"$name\" \"$p\" \"$@\"" viaStore.script
|
||||||
|
&& lib.hasInfix "p=/var/lib/authelia-swarm/\"$name\".key" viaStore.script
|
||||||
|
&& lib.hasInfix "keep_key oidc-issuer mint_issuer" viaStore.script
|
||||||
|
&& lib.hasInfix "for f in jwt session storage-encryption oidc-hmac; do\n keep_key \"$f\" mint_hex" viaStore.script
|
||||||
|
&& !(lib.hasInfix "genrsa -out" viaStore.script)
|
||||||
|
&&
|
||||||
|
viaStore.environment.BAO_CLIENT_CERT
|
||||||
|
== "/run/credentials/authelia-swarm-secrets.service/publisher-bao-cert"
|
||||||
|
&& lib.elem "publisher-bao-cert" viaStore.serviceConfig.LoadCredential
|
||||||
|
&& viaStore.serviceConfig.Restart == "on-failure"
|
||||||
|
&& !(lib.hasInfix "store_seeded" onDisk.script)
|
||||||
|
&& lib.hasInfix "genrsa -out" onDisk.script
|
||||||
|
&& lib.hasInfix "if [ ! -s \"$p\" ]; then" onDisk.script
|
||||||
|
&& !(onDisk.environment ? BAO_ADDR)
|
||||||
|
&& !(onDisk.serviceConfig ? LoadCredential);
|
||||||
|
}
|
||||||
{
|
{
|
||||||
# The path unit's start is a no-op while its target is `active (exited)`,
|
# The path unit's start is a no-op while its target is `active (exited)`,
|
||||||
# so a secret minted after the first run would never be published.
|
# so a secret minted after the first run would never be published.
|
||||||
|
|
|
||||||
63
nix/script-tests/store-seeded-secret.nix
Normal file
63
nix/script-tests/store-seeded-secret.nix
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# `checks.script-test-store-seeded-secret` — runs
|
||||||
|
# ../host-modules/lib/store-seeded-secret.nix's `store_seeded` against a stub
|
||||||
|
# `bao` that keeps its leaves in files. The cases are in
|
||||||
|
# ./store-seeded-secret.sh.
|
||||||
|
#
|
||||||
|
# What runs is the rendered helper, sourced as its callers' scripts include it.
|
||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
# `login` answers from `FAKE_BAO_LOGIN_RC`. `kv get` and `kv put -cas=0` act
|
||||||
|
# on a file per leaf under `FAKE_BAO_KV`; `FAKE_BAO_DENY_READ` and
|
||||||
|
# `FAKE_BAO_DENY_WRITE` answer 403 as a policy that grants neither does.
|
||||||
|
# `FAKE_BAO_RACE` has another run create the leaf between this one's read and
|
||||||
|
# its write. Every call is logged without the value `put` reads from stdin.
|
||||||
|
fakeBao = pkgs.writeShellScriptBin "bao" ''
|
||||||
|
echo "$*" >> "$FAKE_BAO_LOG"
|
||||||
|
case "$1 $2" in
|
||||||
|
"login -method=cert")
|
||||||
|
[ "$FAKE_BAO_LOGIN_RC" = 0 ] || { echo "fake bao: certificate refused" >&2; exit 2; }
|
||||||
|
printf 's.fake'
|
||||||
|
;;
|
||||||
|
"kv get")
|
||||||
|
[ "''${BAO_TOKEN-}" = s.fake ] || { echo "fake bao: kv without the login's token" >&2; exit 97; }
|
||||||
|
leaf="$FAKE_BAO_KV/$(printf '%s' "$4" | tr / _)"
|
||||||
|
[ -z "''${FAKE_BAO_DENY_READ-}" ] || { echo "fake bao: permission denied" >&2; exit 2; }
|
||||||
|
if [ -n "''${FAKE_BAO_RACE-}" ] && [ ! -e "$FAKE_BAO_KV/.raced" ]; then
|
||||||
|
touch "$FAKE_BAO_KV/.raced"
|
||||||
|
printf '%s' "$FAKE_BAO_RACE" > "$leaf"
|
||||||
|
echo "fake bao: no value found" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
[ -e "$leaf" ] || { echo "fake bao: no value found" >&2; exit 2; }
|
||||||
|
cat "$leaf"
|
||||||
|
;;
|
||||||
|
"kv put")
|
||||||
|
[ "''${BAO_TOKEN-}" = s.fake ] || { echo "fake bao: kv without the login's token" >&2; exit 97; }
|
||||||
|
[ "$3" = -cas=0 ] || { echo "fake bao: put without -cas=0" >&2; exit 96; }
|
||||||
|
leaf="$FAKE_BAO_KV/$(printf '%s' "$4" | tr / _)"
|
||||||
|
[ -z "''${FAKE_BAO_DENY_WRITE-}" ] || { echo "fake bao: permission denied" >&2; exit 2; }
|
||||||
|
[ ! -e "$leaf" ] || { echo "fake bao: check-and-set parameter did not match" >&2; exit 2; }
|
||||||
|
cat > "$leaf"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "fake bao: unexpected call: $*" >&2
|
||||||
|
exit 98
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
pkgs.runCommand "hyperhive-script-test-store-seeded-secret"
|
||||||
|
{
|
||||||
|
HELPER = pkgs.writeText "store-seeded-secret.sh" (
|
||||||
|
import ../host-modules/lib/store-seeded-secret.nix { }
|
||||||
|
);
|
||||||
|
FAKE_BAO_BIN = "${fakeBao}/bin";
|
||||||
|
nativeBuildInputs = [ pkgs.coreutils ];
|
||||||
|
}
|
||||||
|
''
|
||||||
|
${pkgs.bash}/bin/bash ${./store-seeded-secret.sh}
|
||||||
|
touch "$out"
|
||||||
|
''
|
||||||
145
nix/script-tests/store-seeded-secret.sh
Normal file
145
nix/script-tests/store-seeded-secret.sh
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
# Cases for ./store-seeded-secret.nix. Each case sets up the store's leaf, the
|
||||||
|
# earlier file and the stub's behaviour, runs `store_seeded` under `set -e` as
|
||||||
|
# the callers' scripts do, and compares its exit status, its output, what the
|
||||||
|
# store holds afterwards and whether the mint command ran.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
count=0
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAILED: $case: $*" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
leaf=secret/swarm/services/t/keys/k
|
||||||
|
pem=$'-----BEGIN PRIVATE KEY-----\nMIIfake\nline2\n-----END PRIVATE KEY-----'
|
||||||
|
|
||||||
|
# setup <description>
|
||||||
|
setup() {
|
||||||
|
case=$1
|
||||||
|
count=$((count + 1))
|
||||||
|
dir=$(mktemp -d)
|
||||||
|
mkdir "$dir/kv"
|
||||||
|
export FAKE_BAO_KV=$dir/kv FAKE_BAO_LOG=$dir/bao.log FAKE_BAO_LOGIN_RC=0
|
||||||
|
unset FAKE_BAO_DENY_READ FAKE_BAO_DENY_WRITE FAKE_BAO_RACE
|
||||||
|
: >"$FAKE_BAO_LOG"
|
||||||
|
earlier=$dir/earlier
|
||||||
|
minted=$dir/minted
|
||||||
|
stored=$FAKE_BAO_KV/$(printf '%s' "$leaf" | tr / _)
|
||||||
|
}
|
||||||
|
|
||||||
|
mint() {
|
||||||
|
echo run >>"$minted"
|
||||||
|
printf 'minted-value\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
# run — `store_seeded` for $leaf and $earlier with the stub first on PATH.
|
||||||
|
# Sets $rc and $got.
|
||||||
|
run() {
|
||||||
|
got=$(
|
||||||
|
set -e
|
||||||
|
PATH="$FAKE_BAO_BIN:$PATH"
|
||||||
|
unset BAO_TOKEN
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. "$HELPER"
|
||||||
|
store_init
|
||||||
|
store_login 2>>"$dir/err"
|
||||||
|
store_seeded "$leaf" "$earlier" mint 2>"$dir/err"
|
||||||
|
)
|
||||||
|
rc=$?
|
||||||
|
}
|
||||||
|
|
||||||
|
# expect <status> <output>
|
||||||
|
expect() {
|
||||||
|
if [ "$rc" != "$1" ]; then fail "exited $rc, wanted $1: $(cat "$dir/err")"; fi
|
||||||
|
if [ "$got" != "$2" ]; then fail "printed '$got', wanted '$2'"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# held <value>, or held "" for a leaf the store does not have.
|
||||||
|
held() {
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
if [ -e "$stored" ]; then fail "the store holds '$(cat "$stored")', wanted nothing"; fi
|
||||||
|
elif [ "$(cat "$stored" 2>/dev/null)" != "$1" ]; then
|
||||||
|
fail "the store holds '$(cat "$stored" 2>/dev/null)', wanted '$1'"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ran_mint() {
|
||||||
|
[ -s "$minted" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
setup "an empty store is seeded from the file, unchanged, and nothing is minted"
|
||||||
|
printf 'old-value\n' >"$earlier"
|
||||||
|
run
|
||||||
|
expect 0 old-value
|
||||||
|
held old-value
|
||||||
|
if ran_mint; then fail "minted although the file held a value"; fi
|
||||||
|
if ! grep -q '^kv put -cas=0 ' "$FAKE_BAO_LOG"; then fail "no create-only write was made"; fi
|
||||||
|
|
||||||
|
setup "a multi-line file is seeded unchanged"
|
||||||
|
printf '%s\n' "$pem" >"$earlier"
|
||||||
|
run
|
||||||
|
expect 0 "$pem"
|
||||||
|
held "$pem"
|
||||||
|
|
||||||
|
setup "the store's value wins over the file and is not written over"
|
||||||
|
printf 'stored-value' >"$stored"
|
||||||
|
printf 'old-value\n' >"$earlier"
|
||||||
|
run
|
||||||
|
expect 0 stored-value
|
||||||
|
held stored-value
|
||||||
|
if grep -q '^kv put' "$FAKE_BAO_LOG"; then fail "wrote to a leaf that held a value"; fi
|
||||||
|
if ran_mint; then fail "minted although the store held a value"; fi
|
||||||
|
|
||||||
|
setup "the store's value is used when there is no file"
|
||||||
|
printf 'stored-value' >"$stored"
|
||||||
|
run
|
||||||
|
expect 0 stored-value
|
||||||
|
if ran_mint; then fail "minted although the store held a value"; fi
|
||||||
|
|
||||||
|
setup "a value is minted into the store when neither holds one"
|
||||||
|
run
|
||||||
|
expect 0 minted-value
|
||||||
|
held minted-value
|
||||||
|
if ! ran_mint; then fail "did not mint"; fi
|
||||||
|
|
||||||
|
setup "a run that loses the race takes the winner's value, not its own seed"
|
||||||
|
printf 'old-value\n' >"$earlier"
|
||||||
|
export FAKE_BAO_RACE=winner-value
|
||||||
|
run
|
||||||
|
expect 0 winner-value
|
||||||
|
held winner-value
|
||||||
|
|
||||||
|
setup "a store that grants neither read nor write leaves the file's value in use"
|
||||||
|
printf 'old-value\n' >"$earlier"
|
||||||
|
export FAKE_BAO_DENY_READ=1 FAKE_BAO_DENY_WRITE=1
|
||||||
|
run
|
||||||
|
expect 0 old-value
|
||||||
|
held ""
|
||||||
|
if ran_mint; then fail "minted although the file held a value"; fi
|
||||||
|
|
||||||
|
setup "a store that grants neither, and no file, gives no value and mints nothing into it"
|
||||||
|
export FAKE_BAO_DENY_READ=1 FAKE_BAO_DENY_WRITE=1
|
||||||
|
run
|
||||||
|
expect 1 ""
|
||||||
|
held ""
|
||||||
|
|
||||||
|
setup "a store that refuses the login leaves the file's value in use and is not called again"
|
||||||
|
printf 'old-value\n' >"$earlier"
|
||||||
|
export FAKE_BAO_LOGIN_RC=1
|
||||||
|
run
|
||||||
|
expect 0 old-value
|
||||||
|
if grep -q '^kv' "$FAKE_BAO_LOG"; then fail "called kv after a refused login"; fi
|
||||||
|
|
||||||
|
setup "a store that refuses the login, and no file, gives no value"
|
||||||
|
export FAKE_BAO_LOGIN_RC=1
|
||||||
|
run
|
||||||
|
expect 1 ""
|
||||||
|
if ran_mint; then fail "minted with no store to hold the value"; fi
|
||||||
|
|
||||||
|
if [ "$failures" != 0 ]; then
|
||||||
|
echo "$failures of $count cases failed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "$count cases passed"
|
||||||
Loading…
Reference in a new issue