keep authelia's keys in bao, seeded from the existing key files
This commit is contained in:
parent
a70b25847e
commit
99fab06348
8 changed files with 557 additions and 89 deletions
|
|
@ -72,6 +72,9 @@ in
|
|||
# cache's key, cache and tokens, twice over against stub tools: the second
|
||||
# run must mint nothing.
|
||||
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 {
|
||||
inherit pkgs self nixosSystem;
|
||||
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;
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -409,13 +409,14 @@ let
|
|||
# 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
|
||||
# credentials or the appservice token beside the controller's client;
|
||||
# and `read` on one leaf alone, the client secret of the journal forwarder
|
||||
# that shares its identity in authelia's container, so a unit whose job
|
||||
# is copying a file cannot recover what is already there. Pinned as the
|
||||
# full capability list per path, because an added capability is exactly
|
||||
# what a presence check misses. The one `read` is found at that leaf and
|
||||
# counted once, 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 alone";
|
||||
# and `read` on six exact leaves alone: the client secret of the journal forwarder
|
||||
# that shares its identity in authelia's container, and authelia's five keys
|
||||
# (the next case), so a unit whose job is copying a file cannot recover
|
||||
# what is already there. Pinned as the full capability list per path,
|
||||
# because an added capability is exactly what a presence check misses.
|
||||
# The forwarder's `read` is found at its leaf, and the six together are
|
||||
# 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 =
|
||||
let
|
||||
s = baoGrantHere.systemd.services.swarm-bao-secret-publisher-policy.script;
|
||||
|
|
@ -429,9 +430,48 @@ let
|
|||
&& !(lib.hasInfix "secret/data/swarm/*" 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.length (lib.splitString "\"read\"" s) == 2
|
||||
&& lib.length (lib.splitString "\"read\"" s) == 7
|
||||
&& !(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
|
||||
# only the exact-path denial, which outranks the prefix, keeps it from
|
||||
|
|
|
|||
|
|
@ -422,6 +422,36 @@ let
|
|||
&& ((inAuthelia secretPublisherHere).systemd.services ? swarm-secret-publish)
|
||||
&& 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)`,
|
||||
# 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