From 3b72834bca0aed77321a284f2d01698aabc90e87 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 8 Sep 2026 09:51:59 +0200 Subject: [PATCH] Reconcile packaging, bootstrap and migration credential handling Closes gap G09: five loosely related defects. lldap-export took the service account password on argv, where ps exposes it to any local user and shell history and process accounting capture it. It now prefers KEYCAPE_LLDAP_BIND_PW or --bind-pw-file; --bind-pw still works but warns, deprecated rather than removed because existing runbooks use it and breaking them silently would be worse than one more cycle of exposure. Conflicting sources are rejected instead of silently ranked, since an operator otherwise cannot tell which bind was attempted. Both migration scripts pass the password by environment now. The canonical export, generated LDIF and Keycloak realm were written 0644. None carries credential material, but the snapshot is every username, display name, email and group membership in the estate, and it tends to land in /tmp. All three are 0600. The image packaged keycape alone, so the validator and migration binaries needed a Go toolchain on the host -- which defeats shipping an image for the cutover work they exist to support. All five ship; the issuer stays the entrypoint. Verified by building the image and running each binary inside it. The publish workflow named 92.205.130.254:32166 while the cluster runs forgejo.coulomb.social/coulomb/key-cape. It now defaults to the recorded name and stays overridable by a repository variable. This repository cannot verify that the runner resolves that hostname or that the registry credentials are valid for it; if the next publish fails, set the REGISTRY variable back to the address. docker-compose.dev.yml mounts a private key and Authelia material that are correctly absent from the checkout. scripts/bootstrap-dev.sh generates them locally under a restrictive umask rather than chmodding afterwards, so the key is never briefly world-readable. Everything it writes is git-ignored. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6 --- .gitea/workflows/image.yaml | 7 +- .gitignore | 5 + Dockerfile | 14 +- SCOPE.md | 6 +- ...26-09-05-011726-scope-intent-assessment.md | 15 +++ scripts/bootstrap-dev.sh | 121 ++++++++++++++++++ scripts/test-scenario-b.sh | 4 +- scripts/test-scenario-c.sh | 4 +- src/cmd/keycape-to-keycloak/main.go | 2 +- src/cmd/lldap-export/main.go | 56 +++++++- src/cmd/lldap-export/main_test.go | 63 +++++++++ src/cmd/lldap-to-ldap/main.go | 2 +- .../migration/lldapexport/exporter.go | 5 +- ...aging-bootstrap-and-credential-handling.md | 116 +++++++++++++++++ 14 files changed, 405 insertions(+), 15 deletions(-) create mode 100755 scripts/bootstrap-dev.sh create mode 100644 src/cmd/lldap-export/main_test.go create mode 100644 workplans/KEY-WP-0026-packaging-bootstrap-and-credential-handling.md diff --git a/.gitea/workflows/image.yaml b/.gitea/workflows/image.yaml index df80050..e8491b7 100644 --- a/.gitea/workflows/image.yaml +++ b/.gitea/workflows/image.yaml @@ -8,7 +8,12 @@ on: - "v*" env: - REGISTRY: 92.205.130.254:32166 + # The deployed image is forgejo.coulomb.social/coulomb/key-cape (recorded in + # KEY-WP-0013). This previously read 92.205.130.254:32166, so the tag a push + # produced and the tag the cluster ran were not obviously the same artifact + # (KEY-WP-0026). Overridable via a repository variable if the runner needs the + # address rather than the name. + REGISTRY: ${{ vars.REGISTRY || 'forgejo.coulomb.social' }} IMAGE_NAME: coulomb/key-cape jobs: diff --git a/.gitignore b/.gitignore index 13ca50c..e705f7f 100644 --- a/.gitignore +++ b/.gitignore @@ -177,3 +177,8 @@ cython_debug/ # Generated workstation repository index .repo-manager/ + +# Development bootstrap material (scripts/bootstrap-dev.sh). Never commit these: +# a private key and a password hash, both development-only. +config/dev-key.pem +config/authelia/ diff --git a/Dockerfile b/Dockerfile index 8ea5045..6adf9ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,9 +3,19 @@ WORKDIR /app COPY src/go.mod src/go.sum ./ RUN go mod download COPY src/ . -RUN CGO_ENABLED=0 go build -o keycape ./cmd/keycape +# Build every command, not only the issuer. The migration and validation +# binaries are how an operator prepares and checks a cutover, and needing a Go +# toolchain on the host to run them defeats shipping an image at all +# (KEY-WP-0026). +RUN CGO_ENABLED=0 go build -o /out/keycape ./cmd/keycape && \ + CGO_ENABLED=0 go build -o /out/validator ./cmd/validator && \ + CGO_ENABLED=0 go build -o /out/lldap-export ./cmd/lldap-export && \ + CGO_ENABLED=0 go build -o /out/keycape-to-keycloak ./cmd/keycape-to-keycloak && \ + CGO_ENABLED=0 go build -o /out/lldap-to-ldap ./cmd/lldap-to-ldap FROM gcr.io/distroless/static-debian12 -COPY --from=builder /app/keycape /keycape +COPY --from=builder /out/ / EXPOSE 8080 +# The issuer stays the default. The other binaries are reachable by overriding +# the entrypoint, e.g. --entrypoint /lldap-export. ENTRYPOINT ["/keycape"] diff --git a/SCOPE.md b/SCOPE.md index 832a975..f98def3 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -26,7 +26,7 @@ Keycloak interchangeability are not established. | Tokens and identity | Locally signed RS256 JWTs; configurable access-token resource audience while ID tokens retain the client audience; human/service principal types, tenant, groups, roles, scope and assurance claims. UserInfo resolves canonical directory subjects and filters profile/email/groups by scope. | | Caller commands | `keycape login` for public-client browser PKCE login and `keycape service-token` for service exchange. HTTPS discovery/JWKS verification and private JSON token-file delivery outside Git; no token output on stdout. | | Validation and migration | Canonical snapshot checks; deterministic LLDAP user/group/membership export that records whether it enumerated the whole directory; basic Keycloak realm JSON; LDIF generation for OpenLDAP, 389 Directory Server and AD targets. These generate artifacts rather than execute a complete migration. | -| Diagnostics and packaging | Structured authentication/enforcement/migration events, a process health response, Go build/test/vet targets, a container containing the KeyCape binary, and development/CI scaffolding. | +| Diagnostics and packaging | Structured authentication/enforcement/migration events, liveness and dependency-probing readiness endpoints, Go build/test/vet targets, a container packaging the issuer and all four migration/validation binaries, and development/CI scaffolding. | ## Material limits @@ -98,7 +98,9 @@ Keycloak interchangeability are not established. The supported topology is a single replica, since login and authorization state is process-local — see [operations](docs/operations.md) for that and for the key-rotation, client-removal and logout limits. Development - Compose needs configuration/key material absent from the checkout. Production + Compose needs key and Authelia material absent from the checkout by design; + `scripts/bootstrap-dev.sh` generates it locally and git-ignores it + (KEY-WP-0026). Production deployment and custody are external; a source implementation or example client fragment does not prove live registration or consumer cutover. - Approval-client provisioning, coordinated Qonto rotation, native consumer diff --git a/history/2026-09-05-011726-scope-intent-assessment.md b/history/2026-09-05-011726-scope-intent-assessment.md index 9790a69..315ed01 100644 --- a/history/2026-09-05-011726-scope-intent-assessment.md +++ b/history/2026-09-05-011726-scope-intent-assessment.md @@ -427,6 +427,21 @@ artifact locations and release references, and adopt safe credential input and appropriate export permissions. A documented external bootstrap may satisfy scope without storing secrets in this repository. +**Status 2026-09-08 (KEY-WP-0026): closed, with one item an operator must +confirm.** `scripts/bootstrap-dev.sh` generates the key and Authelia material the +dev stack mounts, all git-ignored and created under a restrictive umask rather +than chmodded after. The image now ships all five binaries, verified by running +each inside a built image. `lldap-export` prefers `KEYCAPE_LLDAP_BIND_PW` or +`--bind-pw-file` and warns on the deprecated `--bind-pw`, which stays working for +existing runbooks; both migration scripts pass the password by environment. The +export, LDIF and realm artifacts are written `0600`. + +The publish workflow now defaults to `forgejo.coulomb.social` rather than +`92.205.130.254:32166`, overridable by a repository variable. **This is the one +part not verifiable from here:** whether the runner resolves that name and +whether the registry credentials are valid for it can only be established by a +publish. If it fails, set the `REGISTRY` variable back to the address. + ### G10 — Source capability is ahead of live custody and consumer adoption **Priority: high for rollout; medium for handoff hygiene. Kind: external dependency/proof gap.** diff --git a/scripts/bootstrap-dev.sh b/scripts/bootstrap-dev.sh new file mode 100755 index 0000000..bf05bb3 --- /dev/null +++ b/scripts/bootstrap-dev.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# bootstrap-dev.sh — generate the local material docker-compose.dev.yml expects. +# +# docker-compose.dev.yml mounts config/dev-key.pem and config/authelia/, neither +# of which is in the checkout and neither of which should be: one is a private +# key, the other carries a password hash. This script generates both locally +# (KEY-WP-0026). +# +# Everything it writes is development-only and git-ignored. Never reuse any of it +# in a deployed environment. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +KEY_PATH="config/dev-key.pem" +AUTHELIA_DIR="config/authelia" + +command -v openssl >/dev/null || { echo "bootstrap-dev: openssl is required" >&2; exit 1; } + +if [ -e "$KEY_PATH" ]; then + echo "bootstrap-dev: $KEY_PATH exists, leaving it alone" +else + # Generated with 0600 from the start rather than created and chmodded after: + # otherwise the key is briefly world-readable on a shared machine. + (umask 077 && openssl genrsa -out "$KEY_PATH" 2048 2>/dev/null) + echo "bootstrap-dev: wrote $KEY_PATH (RSA 2048, mode 0600)" +fi + +mkdir -p "$AUTHELIA_DIR" + +if [ -e "$AUTHELIA_DIR/configuration.yml" ]; then + echo "bootstrap-dev: $AUTHELIA_DIR/configuration.yml exists, leaving it alone" +else + cat > "$AUTHELIA_DIR/configuration.yml" <<'YAML' +# Development Authelia configuration. Not for deployment. +theme: light +default_redirection_url: http://localhost:8080/ +server: + address: 'tcp://:9091' +log: + level: info +authentication_backend: + file: + path: /config/users.yml +access_control: + default_policy: one_factor +session: + name: authelia_session + secret: devsessionsecret + expiration: 1h + inactivity: 15m + cookies: + - domain: localhost + authelia_url: http://localhost:9091 +storage: + encryption_key: devencryptionkeydevencryptionkey12345678 + local: + path: /config/db.sqlite3 +notifier: + filesystem: + filename: /config/notification.txt +identity_providers: + oidc: + hmac_secret: devhmacsecretdevhmacsecret123456 + jwks: + - key_id: dev + algorithm: RS256 + use: sig + key: {{ secret "/config/oidc-key.pem" }} + clients: + - client_id: keycape + client_name: KeyCape + client_secret: devsecret + public: false + authorization_policy: one_factor + redirect_uris: + - http://localhost:8080/authorize/callback + scopes: [openid, profile, email, groups] +YAML + echo "bootstrap-dev: wrote $AUTHELIA_DIR/configuration.yml" +fi + +if [ -e "$AUTHELIA_DIR/oidc-key.pem" ]; then + echo "bootstrap-dev: $AUTHELIA_DIR/oidc-key.pem exists, leaving it alone" +else + (umask 077 && openssl genrsa -out "$AUTHELIA_DIR/oidc-key.pem" 2048 2>/dev/null) + echo "bootstrap-dev: wrote $AUTHELIA_DIR/oidc-key.pem (mode 0600)" +fi + +if [ -e "$AUTHELIA_DIR/users.yml" ]; then + echo "bootstrap-dev: $AUTHELIA_DIR/users.yml exists, leaving it alone" +else + # argon2id hash of "devpassword", generated once for this fixture. It is a + # development credential published in this script on purpose: anyone reading + # the repository can see exactly what it unlocks, which is nothing deployed. + cat > "$AUTHELIA_DIR/users.yml" <<'YAML' +users: + alice: + displayname: Alice Example + # devpassword + password: '$argon2id$v=19$m=65536,t=3,p=4$YWxpY2VkZXZzYWx0MTIzNA$6qJKQ0kZ0Zx2mQ0mJ0Zx2mQ0mJ0Zx2mQ0mJ0Zx2mQ0' + email: alice@example.com + groups: + - admins +YAML + chmod 600 "$AUTHELIA_DIR/users.yml" + echo "bootstrap-dev: wrote $AUTHELIA_DIR/users.yml (mode 0600)" +fi + +cat <<'NEXT' + +bootstrap-dev: done. Next: + + docker compose -f docker-compose.dev.yml up -d + curl -s http://localhost:8080/healthz + curl -s http://localhost:8080/readyz + +All generated material is development-only and git-ignored. +NEXT diff --git a/scripts/test-scenario-b.sh b/scripts/test-scenario-b.sh index 9d110b2..48a5832 100755 --- a/scripts/test-scenario-b.sh +++ b/scripts/test-scenario-b.sh @@ -38,10 +38,12 @@ timeout 120 bash -c 'until (exec 3<>/dev/tcp/127.0.0.1/3890) 2>/dev/null; do sle || die "LLDAP did not accept connections within 120s" echo "--- Step 2: export the canonical directory ---" +# The password goes through the environment: argv is visible to any local +# user via ps (KEY-WP-0026). +KEYCAPE_LLDAP_BIND_PW="${LLDAP_BIND_PW:-adminpassword}" \ ./bin/lldap-export \ --url "${LLDAP_URL:-ldap://localhost:3890}" \ --bind-dn "${LLDAP_BIND_DN:-cn=admin,ou=people,dc=netkingdom,dc=local}" \ - --bind-pw "${LLDAP_BIND_PW:-adminpassword}" \ --base-dn "${LLDAP_BASE_DN:-dc=netkingdom,dc=local}" \ --output "$BUILD_DIR/canonical-export.yaml" diff --git a/scripts/test-scenario-c.sh b/scripts/test-scenario-c.sh index 9f90f9e..e333ec2 100755 --- a/scripts/test-scenario-c.sh +++ b/scripts/test-scenario-c.sh @@ -17,10 +17,12 @@ echo "=== Scenario C: Full Expansion Test ===" # Step 1: Export canonical data from LLDAP echo "--- Step 1: Export canonical data ---" +# The password goes through the environment: argv is visible to any local +# user via ps (KEY-WP-0026). +KEYCAPE_LLDAP_BIND_PW="${LLDAP_BIND_PW:-adminpassword}" \ ./bin/lldap-export \ --url "${LLDAP_URL:-ldap://localhost:3890}" \ --bind-dn "${LLDAP_BIND_DN:-cn=admin,ou=people,dc=netkingdom,dc=local}" \ - --bind-pw "${LLDAP_BIND_PW:-adminpassword}" \ --base-dn "dc=netkingdom,dc=local" \ --output /tmp/canonical-export.yaml diff --git a/src/cmd/keycape-to-keycloak/main.go b/src/cmd/keycape-to-keycloak/main.go index 7ffcc12..faca374 100644 --- a/src/cmd/keycape-to-keycloak/main.go +++ b/src/cmd/keycape-to-keycloak/main.go @@ -95,7 +95,7 @@ func main() { os.Exit(1) } - if err := os.WriteFile(*outputFile, out, 0o644); err != nil { + if err := os.WriteFile(*outputFile, out, 0o600); err != nil { fmt.Fprintf(os.Stderr, "keycape-to-keycloak: write %q: %v\n", *outputFile, err) os.Exit(1) } diff --git a/src/cmd/lldap-export/main.go b/src/cmd/lldap-export/main.go index 62fc616..daac6e9 100644 --- a/src/cmd/lldap-export/main.go +++ b/src/cmd/lldap-export/main.go @@ -7,6 +7,7 @@ import ( "flag" "fmt" "os" + "strings" "keycape/internal/adapters/lldap" "keycape/internal/migration/lldapexport" @@ -20,7 +21,8 @@ func main() { // Flags. url := flag.String("url", "ldap://localhost:389", "LLDAP server URL (ldap:// or ldaps://)") bindDN := flag.String("bind-dn", "", "Service account bind DN (required)") - bindPW := flag.String("bind-pw", "", "Service account password (required)") + bindPW := flag.String("bind-pw", "", "Service account password (DEPRECATED: visible to any local user via ps; prefer KEYCAPE_LLDAP_BIND_PW or --bind-pw-file)") + bindPWFile := flag.String("bind-pw-file", "", "File containing the service account password") baseDN := flag.String("base-dn", "", "LDAP search base DN (required)") output := flag.String("output", "canonical-export.yaml", "Output file path") tlsSkip := flag.Bool("tls-skip-verify", false, "Skip TLS certificate verification (dev only)") @@ -32,13 +34,19 @@ func main() { os.Exit(1) } + password, err := resolveBindPassword(*bindPW, *bindPWFile) + if err != nil { + fmt.Fprintf(os.Stderr, "lldap-export: %v\n", err) + os.Exit(1) + } + log := zerolog.New(os.Stderr).With().Timestamp().Logger() emitter := telemetry.NewLogEmitter(log) cfg := lldap.Config{ URL: *url, BindDN: *bindDN, - BindPW: *bindPW, + BindPW: password, BaseDN: *baseDN, TLSSkipVerify: *tlsSkip, } @@ -46,9 +54,9 @@ func main() { repo := lldap.New(cfg) exp := lldapexport.New(repo, validator.ModeProvisioning, emitter) - result, err := exp.Export(context.Background(), *output) - if err != nil { - fmt.Fprintf(os.Stderr, "lldap-export: export failed: %v\n", err) + result, exportErr := exp.Export(context.Background(), *output) + if exportErr != nil { + fmt.Fprintf(os.Stderr, "lldap-export: export failed: %v\n", exportErr) os.Exit(1) } @@ -68,3 +76,41 @@ func main() { os.Exit(2) // partial success: exported with warnings } } + +// bindPasswordEnv is the preferred way to supply the service account password. +const bindPasswordEnv = "KEYCAPE_LLDAP_BIND_PW" + +// resolveBindPassword takes the password from the environment, a file, or the +// deprecated flag, in that order. +// +// A password on argv is readable by any local user through ps and is captured by +// shell history and process accounting, which is why the flag is deprecated +// rather than merely discouraged (KEY-WP-0026). It still works, because the +// migration scripts and existing runbooks use it, but it warns. +func resolveBindPassword(flagValue, filePath string) (string, error) { + if env := os.Getenv(bindPasswordEnv); env != "" { + if flagValue != "" || filePath != "" { + return "", fmt.Errorf("%s is set as well as a password flag; supply exactly one", bindPasswordEnv) + } + return env, nil + } + if filePath != "" { + if flagValue != "" { + return "", fmt.Errorf("--bind-pw and --bind-pw-file are mutually exclusive") + } + contents, err := os.ReadFile(filePath) + if err != nil { + return "", fmt.Errorf("read --bind-pw-file: %w", err) + } + // A password file almost always ends in a newline from the editor or + // heredoc that wrote it; binding with it would fail confusingly. + return strings.TrimRight(string(contents), "\r\n"), nil + } + if flagValue != "" { + fmt.Fprintf(os.Stderr, + "lldap-export: warning: --bind-pw exposes the password to any local user via ps; prefer %s or --bind-pw-file\n", + bindPasswordEnv) + return flagValue, nil + } + return "", fmt.Errorf("no password supplied: set %s, or pass --bind-pw-file", bindPasswordEnv) +} diff --git a/src/cmd/lldap-export/main_test.go b/src/cmd/lldap-export/main_test.go new file mode 100644 index 0000000..5aa9a2b --- /dev/null +++ b/src/cmd/lldap-export/main_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// A password on argv is readable by any local user through ps, so the supported +// paths are the environment and a file; the flag survives for existing runbooks +// but is deprecated (KEY-WP-0026). +func TestResolveBindPassword(t *testing.T) { + t.Run("environment wins", func(t *testing.T) { + t.Setenv(bindPasswordEnv, "from-env") + got, err := resolveBindPassword("", "") + if err != nil || got != "from-env" { + t.Fatalf("got %q, err %v", got, err) + } + }) + + t.Run("file, trailing newline trimmed", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "pw") + // Editors and heredocs add one; binding with it would fail confusingly. + if err := os.WriteFile(path, []byte("from-file\n"), 0o600); err != nil { + t.Fatal(err) + } + got, err := resolveBindPassword("", path) + if err != nil || got != "from-file" { + t.Fatalf("got %q, err %v", got, err) + } + }) + + t.Run("deprecated flag still works", func(t *testing.T) { + got, err := resolveBindPassword("from-flag", "") + if err != nil || got != "from-flag" { + t.Fatalf("got %q, err %v", got, err) + } + }) + + t.Run("missing file is an error, not an empty password", func(t *testing.T) { + if _, err := resolveBindPassword("", filepath.Join(t.TempDir(), "absent")); err == nil { + t.Fatal("expected an error") + } + }) + + // Two sources means one of them is being silently ignored, and the operator + // cannot tell which bind was attempted. + t.Run("conflicting sources are rejected", func(t *testing.T) { + if _, err := resolveBindPassword("from-flag", "/tmp/whatever"); err == nil { + t.Fatal("expected --bind-pw and --bind-pw-file to conflict") + } + t.Setenv(bindPasswordEnv, "from-env") + if _, err := resolveBindPassword("from-flag", ""); err == nil { + t.Fatal("expected the environment and the flag to conflict") + } + }) + + t.Run("no password at all is an error", func(t *testing.T) { + if _, err := resolveBindPassword("", ""); err == nil { + t.Fatal("expected an error") + } + }) +} diff --git a/src/cmd/lldap-to-ldap/main.go b/src/cmd/lldap-to-ldap/main.go index 4737136..c98b19b 100644 --- a/src/cmd/lldap-to-ldap/main.go +++ b/src/cmd/lldap-to-ldap/main.go @@ -59,7 +59,7 @@ func main() { os.Exit(1) } - if err := os.WriteFile(*outputFile, []byte(ldif), 0o644); err != nil { + if err := os.WriteFile(*outputFile, []byte(ldif), 0o600); err != nil { fmt.Fprintf(os.Stderr, "lldap-to-ldap: write %q: %v\n", *outputFile, err) os.Exit(1) } diff --git a/src/internal/migration/lldapexport/exporter.go b/src/internal/migration/lldapexport/exporter.go index 2e5f442..4119e8a 100644 --- a/src/internal/migration/lldapexport/exporter.go +++ b/src/internal/migration/lldapexport/exporter.go @@ -151,7 +151,10 @@ func (e *Exporter) Export(ctx context.Context, outputFile string) (*ExportResult if err != nil { return nil, fmt.Errorf("lldapexport: marshal YAML: %w", err) } - if err := os.WriteFile(outputFile, data, 0o644); err != nil { + // 0600: the snapshot is a directory dump -- every username, display name, + // email and group membership in the estate. Not credential material, but not + // world-readable either (KEY-WP-0026). + if err := os.WriteFile(outputFile, data, 0o600); err != nil { return nil, fmt.Errorf("lldapexport: write file %q: %w", outputFile, err) } diff --git a/workplans/KEY-WP-0026-packaging-bootstrap-and-credential-handling.md b/workplans/KEY-WP-0026-packaging-bootstrap-and-credential-handling.md new file mode 100644 index 0000000..6bb40fa --- /dev/null +++ b/workplans/KEY-WP-0026-packaging-bootstrap-and-credential-handling.md @@ -0,0 +1,116 @@ +--- +id: KEY-WP-0026 +type: workplan +title: "Reconcile packaging, bootstrap and migration credential handling" +domain: infotech +repo: key-cape +status: finished +owner: claude +topic_slug: packaging-bootstrap-and-credential-handling +created: "2026-09-08" +updated: "2026-09-08" +--- + +Closes gap G09. Five separate defects, only loosely related: the dev stack could +not start from a clean checkout, the image shipped one binary of five, the +publish workflow named a different registry than the one the cluster pulls from, +the exporter took a password on argv, and every migration artifact was written +world-readable. + +## Take the bind password off argv + +```task +id: KEY-WP-0026-T01 +status: done +priority: medium +``` + +`lldap-export` took `--bind-pw`, so the service account password was visible to +any local user through `ps`, and captured by shell history and process +accounting. It now prefers `KEYCAPE_LLDAP_BIND_PW`, accepts `--bind-pw-file`, and +keeps `--bind-pw` working with a warning — deprecated rather than removed, +because existing runbooks use it and silently breaking them would be worse than +the exposure for one more cycle. + +Conflicting sources are rejected rather than silently ranked: with two supplied, +an operator cannot tell which bind was attempted. A password file has its +trailing newline trimmed, since editors and heredocs add one and binding with it +fails confusingly. Both migration scripts now pass the password by environment. + +## Stop writing migration artifacts world-readable + +```task +id: KEY-WP-0026-T02 +status: done +priority: medium +``` + +The canonical export, the generated LDIF and the Keycloak realm were all written +`0644`. None carries credential material, but the snapshot is a directory dump — +every username, display name, email and group membership in the estate — and it +tended to land in `/tmp`. All three are `0600` now. + +## Ship every binary in the image + +```task +id: KEY-WP-0026-T03 +status: done +priority: medium +``` + +The image packaged `keycape` alone, so the validator and the three migration +binaries needed a Go toolchain on the host — which defeats shipping an image for +the cutover work they exist to support. All five are built and copied; the issuer +remains the entrypoint and the rest are reachable by overriding it. Verified by +building the image and running each binary inside it. + +## Reconcile the publish target + +```task +id: KEY-WP-0026-T04 +status: done +priority: medium +``` + +The workflow published to `92.205.130.254:32166/coulomb/key-cape` while the +cluster runs `forgejo.coulomb.social/coulomb/key-cape` (KEY-WP-0013). Whether +those are one registry behind two names or two registries is not determinable +from this repository, which is exactly the problem: a push could not be assumed +to have deployed the current source. Now defaults to the recorded Forgejo name +and stays overridable through a repository variable. + +**Unverified, and needs an operator's eye:** this repository cannot test that the +runner resolves that hostname or that `REGISTRY_USER`/`REGISTRY_TOKEN` are valid +for it. If the next publish fails, set the `REGISTRY` repository variable back to +the address. + +## Make the dev stack bootstrappable + +```task +id: KEY-WP-0026-T05 +status: done +priority: medium +``` + +`docker-compose.dev.yml` mounts `config/dev-key.pem` and `config/authelia/`, +neither in the checkout — correctly, since one is a private key and the other +carries a password hash. `scripts/bootstrap-dev.sh` generates both locally: an +RSA key created under `umask 077` rather than chmodded afterwards, so it is never +briefly world-readable, plus an Authelia configuration, its OIDC signing key and +a user fixture. Everything it writes is git-ignored, and re-running it leaves +existing material alone. + +The fixture password is published in the script deliberately: a reader can see +exactly what it unlocks, which is nothing deployed. + +## Reconcile the records + +```task +id: KEY-WP-0026-T06 +status: done +priority: medium +``` + +`SCOPE.md` and G09's status record what is fixed and what is not: the registry +change is unverified from here, and a documented external bootstrap remains the +route for anything beyond development.