Skip to content

Commit e95d7f2

Browse files
fix(packaging): generate demo TLS cert at install, never ship it (#596)
A package upgrade silently reverted an operator's TLS certificate. The demo cert/key were shipped in the payload at the production path /etc/openwatch/tls/{cert,key}.pem and were not marked %config(noreplace) (RPM) or listed in conffiles (DEB), so dnf/apt overwrote a replaced certificate with a freshly-built demo cert on every upgrade (each build mints a different self-signed pair). Flagged by the 2026-06-16 pre-release security review. Fix mirrors the identity-key model: ship only the empty tls/ dir plus a provision-tls-cert.sh helper invoked from %post / postinst that generates a demo cert generate-if-absent (never overwrites). An operator's certificate now survives upgrades untouched. The post-upgrade scripts already never touched the keys dir or secrets.env, and never print the DB password (sourced silently). - packaging/common/provision-tls-cert.sh (new, generate-if-absent) - RPM spec + DEB postinst invoke it; cert/key dropped from both payloads - build-rpm.sh / build-deb.sh no longer stage a cert (gen-demo-cert.sh retained only for the runtime-boot / fips tests) - release-package-build spec v1.2.0: C-05 revised, AC-04/06 updated, AC-22 added (+regression test, builds real packages and asserts the cert is absent from the payload)
1 parent df8a3fe commit e95d7f2

8 files changed

Lines changed: 214 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
3636
rules. The `kensa-rules` package version tracks the engine, so it becomes
3737
0.5.0 in the next build.
3838

39+
### Fixed
40+
41+
- A package upgrade no longer overwrites your TLS certificate. The demo
42+
certificate previously shipped inside the package at the production path, so
43+
`dnf update` or `apt upgrade` silently replaced an operator-installed
44+
certificate with a fresh self-signed demo on every upgrade. The demo
45+
certificate is now generated at install time only when the TLS files are
46+
absent (the same generate-if-absent model already used for the server's
47+
identity keys), so a certificate you put in place survives upgrades untouched.
48+
3949
---
4050

4151
## [0.2.0-rc.9] Eyrie — 2026-06-17
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
#!/usr/bin/env bash
2+
# Provision a self-signed demo TLS cert + key at install time, ONLY when
3+
# absent. Invoked from the RPM %post and the DEB postinst.
4+
#
5+
# WHY this exists / GENERATE-IF-ABSENT: the cert lives at the production
6+
# path /etc/openwatch/tls/{cert,key}.pem. If the package shipped it in the
7+
# payload, dnf/apt would silently overwrite an operator's real certificate
8+
# with a freshly-built demo cert on EVERY upgrade — the TLS files are not
9+
# config files, so the package manager replaces them unconditionally and
10+
# without a .rpmsave/.dpkg-dist backup. So the package ships only the empty
11+
# tls/ directory and lays a demo cert down here only when it is missing.
12+
# An operator who installed their own certificate keeps it across upgrades.
13+
#
14+
# This mirrors provision-identity-keys.sh (the JWT key + credential DEK use
15+
# the same never-overwrite, not-in-payload model).
16+
#
17+
# Idempotent and operator-rerunnable: `bash /usr/lib/openwatch/provision-tls-cert.sh`.
18+
19+
set -euo pipefail
20+
21+
TLS_DIR="${OPENWATCH_TLS_DIR:-/etc/openwatch/tls}"
22+
CERT="$TLS_DIR/cert.pem"
23+
KEY="$TLS_DIR/key.pem"
24+
OWNER_GROUP="${OPENWATCH_GROUP:-openwatch}"
25+
26+
if ! command -v openssl >/dev/null 2>&1; then
27+
echo "provision-tls-cert: openssl not found (it is a package dependency)" >&2
28+
exit 1
29+
fi
30+
31+
# 0750 so the service (running as the openwatch user, in the openwatch
32+
# group) can traverse it, but it is not world-readable.
33+
install -d -m 0750 -o root -g "$OWNER_GROUP" "$TLS_DIR"
34+
35+
# Generate only when BOTH files are absent. A half-present pair means the
36+
# operator is mid-rotation or supplied just one half — never clobber either.
37+
if [ ! -e "$CERT" ] && [ ! -e "$KEY" ]; then
38+
# umask 077 so the key is never briefly group/world-readable between
39+
# creation and the explicit chmod below.
40+
( umask 077
41+
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
42+
-subj "/CN=openwatch-demo/O=Hanalyx/OU=demo cert" \
43+
-keyout "$KEY" \
44+
-out "$CERT" \
45+
>/dev/null 2>&1 )
46+
# cert.pem is the public certificate (0644 root:openwatch); key.pem is
47+
# the private key the service reads as the openwatch user (0600).
48+
chown "root:$OWNER_GROUP" "$CERT"
49+
chmod 0644 "$CERT"
50+
chown "$OWNER_GROUP:$OWNER_GROUP" "$KEY"
51+
chmod 0600 "$KEY"
52+
echo "provision-tls-cert: generated a self-signed demo cert at $CERT"
53+
echo "provision-tls-cert: REPLACE it with your own certificate before production use"
54+
fi

packaging/deb/build-deb.sh

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ trap 'rm -rf "$STAGE"' EXIT
5757
mkdir -p "$STAGE/DEBIAN"
5858
mkdir -p "$STAGE/usr/bin"
5959
mkdir -p "$STAGE/usr/lib/openwatch"
60+
# TLS directory ships empty (0750); postinst generates the demo cert/key into
61+
# it (generate-if-absent). The cert/key files are not part of the payload, so
62+
# an upgrade cannot revert an operator's replacement certificate.
6063
mkdir -p "$STAGE/etc/openwatch/tls"
64+
chmod 0750 "$STAGE/etc/openwatch/tls"
6165
# Identity-key directory ships empty (0750); postinst generates the
6266
# per-install keys into it. The key files are not part of the payload.
6367
mkdir -p "$STAGE/etc/openwatch/keys"
@@ -77,11 +81,13 @@ install -m 0644 "$APP_DIR/packaging/common/openwatch.service" "$STAGE/etc/system
7781
install -m 0644 "$APP_DIR/packaging/common/openwatch-backup-cleanup.service" "$STAGE/etc/systemd/system/openwatch-backup-cleanup.service"
7882
install -m 0644 "$APP_DIR/packaging/common/openwatch-backup-cleanup.timer" "$STAGE/etc/systemd/system/openwatch-backup-cleanup.timer"
7983
install -m 0755 "$APP_DIR/packaging/common/provision-identity-keys.sh" "$STAGE/usr/lib/openwatch/provision-identity-keys.sh"
84+
install -m 0755 "$APP_DIR/packaging/common/provision-tls-cert.sh" "$STAGE/usr/lib/openwatch/provision-tls-cert.sh"
8085
install -m 0755 "$APP_DIR/packaging/common/openwatch-upgrade.sh" "$STAGE/usr/lib/openwatch/openwatch-upgrade.sh"
8186
install -m 0755 "$APP_DIR/packaging/common/cleanup-backups.sh" "$STAGE/usr/lib/openwatch/cleanup-backups.sh"
8287

83-
# Demo TLS cert (chmod inside the script).
84-
bash "$APP_DIR/packaging/common/gen-demo-cert.sh" "$STAGE/etc/openwatch/tls" >/dev/null
88+
# The demo TLS cert is NOT staged into the payload — postinst generates it at
89+
# install time (generate-if-absent), so apt upgrade cannot revert an
90+
# operator's replacement certificate. The tls/ dir ships empty.
8591

8692
# Step 3: control + maintainer scripts.
8793
# Render control with the actual version and target arch inserted.

packaging/deb/postinst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ if [ "$1" = "configure" ]; then
1616
# failure should abort configure rather than leave an unbootable service.
1717
/usr/lib/openwatch/provision-identity-keys.sh
1818

19+
# Provision a demo TLS cert + key (generate-if-absent). Not shipped in the
20+
# payload so an upgrade never overwrites an operator's replacement cert.
21+
/usr/lib/openwatch/provision-tls-cert.sh
22+
1923
# UPGRADE: dpkg passes the previously-configured version as $2 (empty on a
2024
# fresh install). Apply pending DB migrations with an auto-backup restore
2125
# point + fail-safe service state. `set -e` propagates a migration failure

packaging/rpm/build-rpm.sh

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ cp "$DIST_DIR/openwatch" "$SRC_DIR/openwatch"
7070
cp "$APP_DIR/packaging/common/openwatch.toml" "$SRC_DIR/openwatch.toml"
7171
cp "$APP_DIR/packaging/common/openwatch.service" "$SRC_DIR/openwatch.service"
7272
cp "$APP_DIR/packaging/common/provision-identity-keys.sh" "$SRC_DIR/provision-identity-keys.sh"
73+
cp "$APP_DIR/packaging/common/provision-tls-cert.sh" "$SRC_DIR/provision-tls-cert.sh"
7374
# Upgrade automation: migrate-on-upgrade helper, backup cleanup, systemd
7475
# timer + units, and the operator-tunable upgrade.conf.
7576
cp "$APP_DIR/packaging/common/openwatch-upgrade.sh" "$SRC_DIR/openwatch-upgrade.sh"
@@ -78,8 +79,9 @@ cp "$APP_DIR/packaging/common/upgrade.conf" "$SRC_DIR/upgrade
7879
cp "$APP_DIR/packaging/common/openwatch-backup-cleanup.service" "$SRC_DIR/openwatch-backup-cleanup.service"
7980
cp "$APP_DIR/packaging/common/openwatch-backup-cleanup.timer" "$SRC_DIR/openwatch-backup-cleanup.timer"
8081

81-
# Demo TLS cert.
82-
bash "$APP_DIR/packaging/common/gen-demo-cert.sh" "$SRC_DIR" >/dev/null
82+
# The demo TLS cert is NOT staged into the payload — provision-tls-cert.sh
83+
# generates it at install time (generate-if-absent), so a package upgrade
84+
# cannot revert an operator's replacement certificate.
8385

8486
(cd "$STAGE_DIR" && tar czf "$RPMTOP/SOURCES/openwatch-${RPM_VERSION}.tar.gz" "openwatch-${RPM_VERSION}")
8587
rm -rf "$STAGE_DIR"

packaging/rpm/openwatch.spec

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,9 @@ Requires(pre): shadow-utils
4949

5050
%description
5151
OpenWatch is the Go rebuild of the compliance scanning platform.
52-
This package ships the single binary, default TOML config, embedded
53-
demo TLS cert, and a hardened systemd unit. Operators replace
52+
This package ships the single binary, default TOML config, and a
53+
hardened systemd unit. A demo TLS cert is generated at install time
54+
(generate-if-absent, so upgrades never overwrite it). Operators replace
5455
/etc/openwatch/tls/* with their own certificate before production use.
5556

5657
%prep
@@ -65,17 +66,20 @@ install -d -m 0755 %{buildroot}/usr/bin
6566
install -m 0755 openwatch %{buildroot}/usr/bin/openwatch
6667

6768
install -d -m 0750 %{buildroot}/etc/openwatch
69+
# TLS directory. The demo cert + key are NOT shipped in the payload — if they
70+
# were, a package upgrade would silently overwrite an operator's replacement
71+
# certificate (TLS files are not %config). %post generates a demo cert here
72+
# only when absent, mirroring the identity-key model below.
6873
install -d -m 0750 %{buildroot}/etc/openwatch/tls
6974
# Identity-key directory. The keys themselves are NOT shipped in the payload
7075
# (they must be unique per install); %post generates them into here.
7176
install -d -m 0750 %{buildroot}/etc/openwatch/keys
7277
install -m 0640 openwatch.toml %{buildroot}/etc/openwatch/openwatch.toml
7378
install -m 0640 upgrade.conf %{buildroot}/etc/openwatch/upgrade.conf
74-
install -m 0644 cert.pem %{buildroot}/etc/openwatch/tls/cert.pem
75-
install -m 0600 key.pem %{buildroot}/etc/openwatch/tls/key.pem
7679

7780
install -d -m 0755 %{buildroot}/usr/lib/openwatch
7881
install -m 0755 provision-identity-keys.sh %{buildroot}/usr/lib/openwatch/provision-identity-keys.sh
82+
install -m 0755 provision-tls-cert.sh %{buildroot}/usr/lib/openwatch/provision-tls-cert.sh
7983
install -m 0755 openwatch-upgrade.sh %{buildroot}/usr/lib/openwatch/openwatch-upgrade.sh
8084
install -m 0755 cleanup-backups.sh %{buildroot}/usr/lib/openwatch/cleanup-backups.sh
8185

@@ -107,6 +111,10 @@ systemctl daemon-reload || :
107111
# as a scriptlet warning, not silently leave an unbootable service.
108112
/usr/lib/openwatch/provision-identity-keys.sh
109113

114+
# Provision a demo TLS cert + key (generate-if-absent). Not shipped in the
115+
# payload so an upgrade never overwrites an operator's replacement cert.
116+
/usr/lib/openwatch/provision-tls-cert.sh
117+
110118
# Enable the daily pre-upgrade-backup cleanup timer (install + upgrade).
111119
systemctl enable --now openwatch-backup-cleanup.timer >/dev/null 2>&1 || :
112120

@@ -134,18 +142,20 @@ systemctl daemon-reload || :
134142
%files
135143
%attr(0755, root, root) /usr/bin/openwatch
136144
%dir %attr(0750, root, openwatch) /etc/openwatch
145+
# TLS dir ships empty (0750); %post generates the demo cert/key into it
146+
# generate-if-absent. The cert/key files are intentionally NOT packaged, so
147+
# a package upgrade cannot revert an operator's replacement certificate.
137148
%dir %attr(0750, root, openwatch) /etc/openwatch/tls
138149
%config(noreplace) %attr(0640, root, openwatch) /etc/openwatch/openwatch.toml
139150
%config(noreplace) %attr(0640, root, openwatch) /etc/openwatch/upgrade.conf
140-
%attr(0644, root, openwatch) /etc/openwatch/tls/cert.pem
141-
%attr(0600, openwatch, openwatch) /etc/openwatch/tls/key.pem
142151
%attr(0644, root, root) /etc/systemd/system/openwatch.service
143152
%attr(0644, root, root) /etc/systemd/system/openwatch-backup-cleanup.service
144153
%attr(0644, root, root) /etc/systemd/system/openwatch-backup-cleanup.timer
145154
# Identity-key directory ships empty (0750); %post generates the per-install
146155
# keys into it. The key files are intentionally NOT packaged.
147156
%dir %attr(0750, root, openwatch) /etc/openwatch/keys
148157
%attr(0755, root, root) /usr/lib/openwatch/provision-identity-keys.sh
158+
%attr(0755, root, root) /usr/lib/openwatch/provision-tls-cert.sh
149159
%attr(0755, root, root) /usr/lib/openwatch/openwatch-upgrade.sh
150160
%attr(0755, root, root) /usr/lib/openwatch/cleanup-backups.sh
151161
%dir %attr(0750, openwatch, openwatch) /var/lib/openwatch

packaging/tests/package_test.go

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,8 @@ func TestSpec_RPMParses(t *testing.T) {
178178
}
179179

180180
// @ac AC-04
181-
// AC-04: the RPM payload contains the binary, config, systemd unit,
182-
// and demo cert.
181+
// AC-04: the RPM payload contains the binary, config, and systemd unit.
182+
// The demo TLS cert is provisioned at install time, NOT shipped (AC-22).
183183
func TestRPM_PayloadContents(t *testing.T) {
184184
t.Run("release-package-build/AC-04", func(t *testing.T) {
185185
rpm := rpmPath(t)
@@ -188,7 +188,6 @@ func TestRPM_PayloadContents(t *testing.T) {
188188
"/usr/bin/openwatch",
189189
"/etc/openwatch/openwatch.toml",
190190
"/etc/systemd/system/openwatch.service",
191-
"/etc/openwatch/tls/cert.pem",
192191
}
193192
for _, f := range mustHave {
194193
if !strings.Contains(out, f) {
@@ -218,7 +217,8 @@ func TestDEB_ControlShape(t *testing.T) {
218217
}
219218

220219
// @ac AC-06
221-
// AC-06: DEB payload contains the same critical files as the RPM.
220+
// AC-06: DEB payload contains the same critical files as the RPM. The demo
221+
// TLS cert is provisioned at install time, NOT shipped (AC-22).
222222
func TestDEB_PayloadContents(t *testing.T) {
223223
t.Run("release-package-build/AC-06", func(t *testing.T) {
224224
deb := debPath(t)
@@ -227,7 +227,6 @@ func TestDEB_PayloadContents(t *testing.T) {
227227
"./usr/bin/openwatch",
228228
"./etc/openwatch/openwatch.toml",
229229
"./etc/systemd/system/openwatch.service",
230-
"./etc/openwatch/tls/cert.pem",
231230
}
232231
for _, f := range mustHave {
233232
if !strings.Contains(out, f) {
@@ -617,6 +616,87 @@ func TestKeys_NotInPayload(t *testing.T) {
617616
})
618617
}
619618

619+
// @ac AC-22
620+
// AC-22: the demo TLS cert is provisioned at install time (generate-if-absent)
621+
// and NEVER shipped in the payload, so a package upgrade cannot revert an
622+
// operator's replacement certificate. Mirrors the identity-key model
623+
// (AC-18/AC-20). This is the regression guard for the pre-release finding that
624+
// a non-%config demo cert at the prod path was silently overwriting operator
625+
// certs on every upgrade.
626+
func TestTLS_PostInstallProvisions(t *testing.T) {
627+
t.Run("release-package-build/AC-22", func(t *testing.T) {
628+
dir := appDir(t)
629+
const helperPath = "/usr/lib/openwatch/provision-tls-cert.sh"
630+
631+
// Scriptlets call the TLS helper.
632+
rpm := rpmPath(t)
633+
if post := rpmQuery(t, rpm, "%{POSTIN}"); !strings.Contains(post, helperPath) {
634+
t.Errorf("RPM %%post does not invoke %s:\n%s", helperPath, post)
635+
}
636+
deb := debPath(t)
637+
if body := readDebControlScript(t, deb, "postinst"); !strings.Contains(body, helperPath) {
638+
t.Errorf("DEB postinst does not invoke %s:\n%s", helperPath, body)
639+
}
640+
641+
// The helper is generate-if-absent and mints a self-signed cert with
642+
// the right key strength, modes, and owners.
643+
helper, err := os.ReadFile(filepath.Join(dir, "packaging", "common", "provision-tls-cert.sh"))
644+
if err != nil {
645+
t.Fatalf("read helper: %v", err)
646+
}
647+
h := string(helper)
648+
wants := []struct{ what, substr string }{
649+
{"generate-if-absent guard", "[ ! -e"},
650+
{"self-signed cert via openssl req -x509", "openssl req -x509"},
651+
{"2048-bit RSA key", "rsa:2048"},
652+
{"cert mode 0644", "chmod 0644"},
653+
{"key mode 0600", "chmod 0600"},
654+
{"key owner openwatch:openwatch", "$OWNER_GROUP:$OWNER_GROUP"},
655+
}
656+
for _, w := range wants {
657+
if !strings.Contains(h, w.substr) {
658+
t.Errorf("TLS helper missing %s (%q)", w.what, w.substr)
659+
}
660+
}
661+
662+
// Payload ships the empty tls dir + the helper, but NEVER the cert/key
663+
// files themselves (that is what made upgrades clobber operator certs).
664+
const tlsDir = "/etc/openwatch/tls"
665+
rpmFiles := rpmQuery(t, rpm, "[%{FILENAMES}\n]")
666+
if !strings.Contains(rpmFiles, helperPath) {
667+
t.Errorf("RPM payload missing the TLS helper %s", helperPath)
668+
}
669+
if !strings.Contains(rpmFiles, tlsDir) {
670+
t.Errorf("RPM payload missing the %s directory", tlsDir)
671+
}
672+
if strings.Contains(rpmFiles, "tls/cert.pem") || strings.Contains(rpmFiles, "tls/key.pem") {
673+
t.Errorf("RPM payload MUST NOT ship the TLS cert/key; got:\n%s", rpmFiles)
674+
}
675+
676+
debFiles := debContents(t, deb)
677+
if !strings.Contains(debFiles, helperPath) {
678+
t.Errorf("DEB payload missing the TLS helper %s", helperPath)
679+
}
680+
if !strings.Contains(debFiles, tlsDir) {
681+
t.Errorf("DEB payload missing the %s directory", tlsDir)
682+
}
683+
if strings.Contains(debFiles, "tls/cert.pem") || strings.Contains(debFiles, "tls/key.pem") {
684+
t.Errorf("DEB payload MUST NOT ship the TLS cert/key; got:\n%s", debFiles)
685+
}
686+
687+
// The build scripts no longer stage a demo cert into the payload.
688+
for _, bs := range []string{"rpm/build-rpm.sh", "deb/build-deb.sh"} {
689+
b, err := os.ReadFile(filepath.Join(dir, "packaging", bs))
690+
if err != nil {
691+
t.Fatalf("read %s: %v", bs, err)
692+
}
693+
if regexp.MustCompile(`(?m)^\s*bash\b.*gen-demo-cert\.sh`).MatchString(string(b)) {
694+
t.Errorf("%s still stages a demo cert into the payload via gen-demo-cert.sh", bs)
695+
}
696+
}
697+
})
698+
}
699+
620700
// readDebControlScript extracts a named maintainer script from a DEB
621701
// using `dpkg-deb --ctrl-tarfile` piped to `tar`. Returns the script body
622702
// as a string.

0 commit comments

Comments
 (0)