Watch
0
0
Fork
You've already forked hyperhive
0

keep authelia's keys in bao, seeded from the existing key files

This commit is contained in:
atlas 2026-10-27 12:23:41 +01:00 • committed by mara
commit 99fab06348
3 changed files with 557 additions and 89 deletions

View 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
}
''

View file

@ -180,6 +180,8 @@ let
instance = lib.removePrefix "authelia-" unitName;
stateDir = "/var/lib/${unitName}";
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 {
inherit lib gatewayCfg;
@ -464,21 +466,22 @@ let
# The OIDC half of the first-boot generator, kept out of the script
# body so neither is read through the other's indentation.
oidcGenScript = lib.optionalString oidcEnabled ''
# The issuer key is the one secret here that is NOT interchangeable
# with a random blob: it *signs* id tokens, and every relying party
# verifies them against the public half served at `/jwks.json`. A
# symmetric secret cannot do that, so this one is an RSA pair.
#
# Rotating it invalidates every token already issued, which is why
# it is generated once and left alone — same reason as the session
# and storage keys above.
issuer=${lib.escapeShellArg stateDir}/oidc-issuer.key
if [ ! -s "$issuer" ]; then
openssl genrsa -out "$issuer" 4096
echo "generated $issuer"
fi
chmod 0600 "$issuer"
${lib.optionalString (!publishes) ''
# The issuer key is the one secret here that is NOT interchangeable
# with a random blob: it *signs* id tokens, and every relying party
# verifies them against the public half served at `/jwks.json`. A
# symmetric secret cannot do that, so this one is an RSA pair.
#
# Rotating it invalidates every token already issued, which is why
# it is generated once and left alone — same reason as the session
# and storage keys above.
issuer=${lib.escapeShellArg stateDir}/oidc-issuer.key
if [ ! -s "$issuer" ]; then
openssl genrsa -out "$issuer" 4096
echo "generated $issuer"
fi
chmod 0600 "$issuer"
''}
# Each client's secret, minted once and kept as two files: the
# plaintext its relying party authenticates with, and the digest
# authelia compares against. The two live in different containers,
@ -534,6 +537,68 @@ let
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
# swarm collector and its own public name through the bridge, and the
# gateway reaches the three listeners below over sockets, so authelia binds
@ -955,7 +1020,7 @@ in
(import ../container-modules/swarm-container-resolver.nix {
inherit (networkCfg) bridgeIp;
# 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 = [
cfg.unit
]
@ -963,6 +1028,7 @@ in
"opentelemetry-collector.service"
"${forwarderFetchUnit}.service"
"${publisherUnit}.service"
"${unitName}-secrets.service"
];
})
caBundleModule
@ -1022,12 +1088,12 @@ in
# through the store path, and nothing puts the CLI on PATH.
environment.systemPackages = [ deployCfg.authelia.package ];
# authelia's own secrets, generated in-container on first
# boot. They are jwt/session/storage keys — nothing outside
# this container ever reads them, which is what makes
# in-container generation right rather than merely easier.
# (hive-matrix generates its token host-side only because
# hive-c0re has to read that one.)
# authelia's own keys: jwt, session, storage encryption, OIDC HMAC
# and the OIDC issuer. Nothing outside this container reads them.
# With the publisher's store identity the swarm secret store holds
# them and this unit copies them into place, seeding the store from
# the files an earlier release generated here when it holds none;
# without it the unit generates them on first boot, as before.
#
# Same `User`/`Group`/`StateDirectory` as the authelia unit,
# so systemd creates the directory owned by the account that
@ -1049,7 +1115,15 @@ in
++ lib.optionals oidcEnabled [
pkgs.openssl
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 = {
Type = "oneshot";
RemainAfterExit = true;
@ -1059,25 +1133,31 @@ in
StateDirectoryMode = "0700";
UMask = "0077";
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 = ''
set -euo pipefail
${atomicWriteSecret}
# Each is generated once and never rotated here: the
# 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
${if publishes then storeKeysScript else fileKeysScript}
${oidcGenScript}
# authelia exits at startup on an empty user set, so an empty

View file

@ -543,9 +543,9 @@ let
# here too — a grant widens when a path gains a WRITER, not when a kind is
# declared.
#
# Write-only but for one leaf, the client secret of its container's forwarder:
# a read on a prefix would let a file-copier recover every hive's credentials.
# That stanza repeats `create`/`update`, which its exact path would drop.
# Write-only but for its forwarder's client secret and authelia's keys, read at
# exact leaves: a read on a prefix would let a file-copier recover every
# hive's credentials. The forwarder's stanza repeats `create`/`update`.
#
# The queue callout's three seeds sit under `swarm/services/` and are no
# 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" {
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-*`
# 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.
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: ''
path "${path}" {
capabilities = ["create", "read"]