diff --git a/.github/scripts/demo-runtime-transaction.py b/.github/scripts/demo-runtime-transaction.py index 3cd8824742..2bfaa27376 100644 --- a/.github/scripts/demo-runtime-transaction.py +++ b/.github/scripts/demo-runtime-transaction.py @@ -349,17 +349,61 @@ class Host: values["PULSE_MOCK_" + key.upper()] = value return values - def profile_matches(self, request): - text = (PATHS["data"] / ".env").read_text().splitlines() - values = self.profile_values(request) - return (all(text.count(key + "=" + value) == 1 for key, value in values.items()) - and "demo_fixtures" in json.loads((PATHS["data"] / "billing.json").read_text()).get("capabilities", [])) + def profile_admission(self): + """Read-only prerequisite; this transaction never grants entitlements. - def profile(self, request, unhealthy): + The running application owns billing HMAC verification, encrypted + secrets and legacy migration. Do not decrypt, clear integrity, re-sign + or rewrite billing/key material to enable a demo profile. + """ env_file = PATHS["data"] / ".env" billing_file = PATHS["data"] / "billing.json" if env_file.is_symlink() or billing_file.is_symlink(): raise Failure("configuration-symlink") + if not env_file.is_file(): + raise Failure("configuration-shape") + + def unique_fields(pairs): + result = {} + for key, value in pairs: + if key in result: + raise Failure("billing-shape") + result[key] = value + return result + + def invalid_constant(_value): + raise Failure("billing-shape") + + try: + fd = os.open(billing_file, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, "rb") as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + raise Failure("billing-shape") + raw = stream.read(1024 * 1024 + 1) + if len(raw) > 1024 * 1024: + raise Failure("billing-shape") + billing = json.loads(raw, object_pairs_hook=unique_fields, + parse_constant=invalid_constant) + except (OSError, ValueError, UnicodeError, RecursionError) as exc: + raise Failure("billing-observation") from exc + if (not isinstance(billing, dict) + or not isinstance(billing.get("capabilities"), list) + or not all(isinstance(capability, str) for capability in billing["capabilities"])): + raise Failure("billing-shape") + if "demo_fixtures" not in billing["capabilities"]: + raise Failure("billing-demo-capability-required") + + def profile_matches(self, request): + self.profile_admission() + text = (PATHS["data"] / ".env").read_text().splitlines() + values = self.profile_values(request) + return all(text.count(key + "=" + value) == 1 for key, value in values.items()) + + def profile(self, request, unhealthy): + env_file = PATHS["data"] / ".env" + # Recheck after quiescent capture too; a failed prerequisite must not + # partially change the profile or bypass the application's licence gate. + self.profile_admission() values = self.profile_values(request) text = env_file.read_text() for key, value in values.items(): @@ -369,14 +413,6 @@ class Host: data_owner = PATHS["data"].stat() os.chown(env_file, data_owner.st_uid, data_owner.st_gid) os.chmod(env_file, 0o600) - billing = json.loads(billing_file.read_text()) - if not isinstance(billing, dict) or not isinstance(billing.get("capabilities", []), list): - raise Failure("billing-shape") - billing["capabilities"] = sorted(set(billing.get("capabilities", []) + ["demo_fixtures"])) - billing.pop("integrity", None) - billing_file.write_text(json.dumps(billing)) - os.chown(billing_file, data_owner.st_uid, data_owner.st_gid) - os.chmod(billing_file, 0o600) if unhealthy: for relative in DEMO_HISTORY: path = PATHS["data"] / relative @@ -546,6 +582,9 @@ class Transaction: if (prior.get("status") not in TERMINAL or (prior.get("child_result_required") and not self.host.prior_closed(entry, prior["status"]))): raise Failure("prior-recovery-required") + # Refuse absent/malformed or unprovisioned billing before stopping + # a service, installing a binary or changing any estate file. + self.host.profile_admission() states = self.host.state() if not all(active(states[name]) for name in ("pulse-relay", "caddy")) or self.host.status(RELAY_HEALTH) != "200": raise Failure("cohost-unhealthy") diff --git a/.github/scripts/tests/demo_runtime_native.py b/.github/scripts/tests/demo_runtime_native.py index e381e2e435..a9cf4e3bf5 100644 --- a/.github/scripts/tests/demo_runtime_native.py +++ b/.github/scripts/tests/demo_runtime_native.py @@ -92,7 +92,7 @@ def main(): Path("/etc/pulse").mkdir(mode=0o755) os.chown("/etc/pulse", owner.pw_uid, owner.pw_gid) Path("/etc/pulse/.env").write_text("DEMO_MODE=true\n") - Path("/etc/pulse/billing.json").write_text('{"capabilities":[]}') + Path("/etc/pulse/billing.json").write_text('{"capabilities":["demo_fixtures"],"limits":{},"meters_enabled":[],"plan_version":"community","subscription_state":"active"}') Path("/etc/pulse/persistent-marker").write_text("original data") for path in Path("/etc/pulse").iterdir(): os.chown(path, owner.pw_uid, owner.pw_gid) diff --git a/.github/scripts/tests/test_demo_runtime_transaction.py b/.github/scripts/tests/test_demo_runtime_transaction.py index 3900e32a01..24f146b657 100644 --- a/.github/scripts/tests/test_demo_runtime_transaction.py +++ b/.github/scripts/tests/test_demo_runtime_transaction.py @@ -156,7 +156,7 @@ class TransactionTest(unittest.TestCase): path.chmod(0o755 if name == "binary" else 0o644) (self.paths["dropins"] / "limits.conf").write_bytes(b"old limits") (self.paths["data"] / ".env").write_text("PRIVATE_TOKEN=not-logged\n") - (self.paths["data"] / "billing.json").write_text('{"capabilities":[],"integrity":"private"}') + (self.paths["data"] / "billing.json").write_text('{"capabilities":["demo_fixtures"],"integrity":"private"}') (self.paths["data"] / "persisted-history").write_bytes(b"original history") (self.paths["data"] / "alerts").mkdir() (self.paths["data"] / "alerts/events.db").write_bytes(b"synthetic opaque persistent file") @@ -188,6 +188,105 @@ class TransactionTest(unittest.TestCase): self.assertEqual(tx.receipt, json.loads((self.attempt / "receipt.json").read_text())) return result, tx.receipt + def test_profile_preserves_encrypted_and_legacy_billing_bytes_key_and_metadata(self): + billing_path = self.paths["data"] / "billing.json" + key_path = self.paths["data"] / ".encryption.key" + key_path.write_bytes(b"synthetic-original-key") + key_path.chmod(0o600) + for representation in ("synthetic-legacy-plaintext", "synthetic-encrypted-ciphertext"): + with self.subTest(representation=representation): + billing = {"capabilities": ["other-feature", "demo_fixtures"], + "integrity": "original-synthetic-signature", + "entitlement_jwt": representation + "-jwt", + "entitlement_refresh_token": representation + "-refresh", + "limits": {"resources": 100}, "subscription_state": "active", + "other_field": {"preserve": [1, 2]}} + billing_path.write_text(json.dumps(billing, indent=2) + "\n") + billing_path.chmod(0o640) + before = engine.estate_hash({"billing": billing_path, "key": key_path}) + self.host.profile(request(), False) + self.host.profile(request(), False) + self.assertEqual(engine.estate_hash({"billing": billing_path, "key": key_path}), before) + self.assertTrue(self.host.profile_matches(request())) + + def test_missing_demo_capability_refuses_before_any_service_or_estate_mutation(self): + (self.paths["data"] / "billing.json").write_text('{"capabilities":[],"integrity":"unchanged"}') + before = engine.estate_hash(self.paths) + code, receipt = self.run_transaction("update", "v6.4.6") + self.assertEqual((code, receipt["status"], receipt["failure"]), + (1, "refused", "billing-demo-capability-required")) + self.assertFalse(receipt["mutated"]) + self.assertEqual((self.host.stops, self.host.starts, self.host.install_count), (0, 0, 0)) + self.assertEqual(engine.estate_hash(self.paths), before) + self.assertFalse((self.attempt / "snapshot").exists()) + + def test_malformed_billing_refuses_before_mutation_with_fixed_diagnostics(self): + path = self.paths["data"] / "billing.json" + cases = (b"private-malformed-input", b"[]", b"null", b"{}", + b'{"capabilities":"demo_fixtures"}', + b'{"capabilities":["demo_fixtures",{}]}', + b'{"capabilities":[],"capabilities":["demo_fixtures"]}', + b'{"capabilities":["demo_fixtures"],"other":NaN}', + b'{"capabilities":["demo_fixtures"],"other":"\xff"}', + b" " * (1024 * 1024 + 1)) + for value in cases: + with self.subTest(length=len(value)): + path.write_bytes(value) + before = engine.estate_hash(self.paths) + code, receipt = self.run_transaction() + self.assertEqual((code, receipt["status"]), (1, "refused")) + self.assertIn(receipt["failure"], {"billing-shape", "billing-observation"}) + self.assertEqual(self.host.stops, 0) + self.assertFalse(receipt["mutated"]) + self.assertEqual(engine.estate_hash(self.paths), before) + self.assertNotIn("private-malformed-input", json.dumps(receipt)) + + def test_missing_symlink_and_nonregular_billing_refuse_without_touching_target(self): + path = self.paths["data"] / "billing.json" + target = self.paths["data"] / "other-billing" + target.write_text('{"capabilities":["demo_fixtures"]}') + path.unlink() + for kind in ("missing", "symlink", "directory"): + with self.subTest(kind=kind): + if kind == "symlink": + path.symlink_to(target) + elif kind == "directory": + path.mkdir() + before = engine.estate_hash(self.paths) + code, receipt = self.run_transaction() + self.assertEqual((code, receipt["status"]), (1, "refused")) + self.assertEqual(self.host.stops, 0) + self.assertEqual(engine.estate_hash(self.paths), before) + if kind == "symlink": + path.unlink() + elif kind == "directory": + path.rmdir() + + def test_profile_rechecks_capability_before_changing_environment(self): + path = self.paths["data"] / "billing.json" + self.host.profile_admission() + path.write_text('{"capabilities":[]}') + before = engine.estate_hash(self.paths) + with self.assertRaisesRegex(engine.Failure, "^billing-demo-capability-required$"): + self.host.profile(request(), True) + self.assertEqual(engine.estate_hash(self.paths), before) + + def test_late_update_failure_preserves_full_billing_and_key_through_recovery(self): + path = self.paths["data"] / "billing.json" + path.write_text(json.dumps({"capabilities": ["demo_fixtures"], + "integrity": "synthetic-original-signature", + "entitlement_jwt": "synthetic-original-secret", + "other": {"keep": True}})) + (self.paths["data"] / ".encryption.key").write_text("synthetic-original-key") + before = engine.estate_hash(self.paths) + self.host.fail_at = 55 + code, receipt = self.run_transaction("update", "v6.4.6") + self.assertEqual((code, receipt["status"]), (1, "rolled_back")) + self.assertEqual(engine.estate_hash(self.paths), before) + self.assertEqual(receipt["recovery"]["elapsed_seconds"], 300) + for secret in ("synthetic-original-signature", "synthetic-original-secret", "synthetic-original-key"): + self.assertNotIn(secret, json.dumps(receipt)) + def test_healthy_profile_change_keeps_data_and_watches_complete_window(self): code, receipt = self.run_transaction() self.assertEqual(code, 0) diff --git a/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md b/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md index cf638ea766..7c1fb4eb34 100644 --- a/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md +++ b/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md @@ -18,6 +18,17 @@ workflow queue does not exclude another repository's host deployment. file lock through capture, atomic runtime/profile work, forward observation, complete restoration and recovery observation. It does not restart Relay or Caddy, upgrade a package, lift the Caddy hold or activate frozen commercial copy. +- Billing and `.encryption.key` are read-only to the profile helper. Before the + first service stop or estate mutation, it requires regular, bounded, + unambiguous billing JSON with an already provisioned `demo_fixtures` + capability. Missing capability or unavailable/malformed state refuses the + transaction with fixed diagnostics; it never grants the capability, drops + integrity or rewrites secret-bearing JSON. It rechecks after quiescent capture + before changing the environment. The application still owns HMAC/entitlement + verification, encryption and legacy plaintext migration; this shape check + does not authenticate a licence. Full-state snapshots/recovery preserve the + original private billing/key estate. Native fixtures seed non-secret demo + capability state only while provisioning their fresh empty disposable estate. - The complete quiescent **chosen footprint**: Pulse executable, VERSION, unit/drop-in identity and demo data estate is retained privately before mutation. No backup or database is deleted for space. The @@ -69,6 +80,16 @@ unchanged-parent control retains the adverse skipped restoration. Collection controls wait for writer closure and reject favourable stale terminals. The production window is not shortened. This is **not native acceptance**. +Connected billing controls also pass actual canonical encrypted and legacy +secret state through the real profile helper, retain every billing/key byte and +metadata, then use the canonical loader to verify encryption/migration and HMAC. +A tampered signature remains rejected by that loader, not laundered by the +helper. Malformed, missing, duplicate-key, nonregular and unprovisioned billing +refuse before service/binary/data mutation. Late failures retain full-state +recovery and sanitised evidence. These are synthetic source tests; they do not +establish production capability provisioning, a green CodeQL check or native +forward/reverse acceptance. + `demo-runtime-native.yml` now carries the next executable acceptance rather than requiring a worker to obtain system mode: exact PR/main source, a fresh secret-free public hosted runner, actual systemd/Caddy and disposable trusted diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 39ff236c60..d5e098d282 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -34,6 +34,20 @@ browser fixture exercise those states; neither proves a binary/data rollback. ### Demo transactions share the service host, not just a workflow queue +The demo runtime profile does not grant or rewrite billing entitlements. Before +the first stop/install/profile mutation, the transaction requires regular, +bounded, unambiguous existing billing state containing `demo_fixtures`; missing +capability or malformed/unavailable state refuses without estate mutation. The +helper rechecks before profile writes and leaves all billing/key bytes, +ownership and modes unchanged, including encrypted or legacy plaintext secrets +and integrity. Only the canonical application verifies HMAC/entitlements and +performs encrypted-at-rest migration; the read-only shape check cannot make a +tampered signature valid. Recovery retains the full original private estate. +Synthetic native fixtures provision their non-secret capability before starting +the transaction on their admitted fresh empty runner, not by granting it during +recovery. Connected Python and `TestBillingState_DemoProfilePreservesCanonicalState` +controls cover these boundaries without asserting installed acceptance. + Stable demo update and recovery keep their existing publication/activation, exact-tag installer signing key, Tailscale, pinned SSH and environment boundaries. The mutating paths now share the Relay host's existing diff --git a/internal/config/billing_demo_profile_test.go b/internal/config/billing_demo_profile_test.go new file mode 100644 index 0000000000..ba1543c3b8 --- /dev/null +++ b/internal/config/billing_demo_profile_test.go @@ -0,0 +1,107 @@ +package config + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + + pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" + "github.com/stretchr/testify/require" +) + +// Use the real billing encryption/HMAC implementation and the real Python +// profile writer. This is a synthetic, private filesystem test, not a native +// service, production entitlement or deployment acceptance. +func TestBillingState_DemoProfilePreservesCanonicalState(t *testing.T) { + for _, mode := range []string{"encrypted", "legacy-plaintext", "tampered-signature"} { + t.Run(mode, func(t *testing.T) { + dir := t.TempDir() + t.Setenv("PULSE_LEGACY_KEY_PATH", filepath.Join(t.TempDir(), ".encryption.key")) + writeTestEncryptionKey(t, dir) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("DEMO_MODE=true\nPRIVATE_SYNTHETIC=unchanged\n"), 0o600)) + store := NewFileBillingStore(dir) + state := &pkglicensing.BillingState{ + Capabilities: []string{pkglicensing.FeatureDemoFixtures}, + SubscriptionState: pkglicensing.SubStateActive, + EntitlementJWT: "synthetic-lease.jwt.value", + EntitlementRefreshToken: "synthetic-refresh-token", + } + require.NoError(t, store.SaveBillingState("default", state)) + billingPath := filepath.Join(dir, "billing.json") + if mode != "encrypted" { + data, err := os.ReadFile(billingPath) + require.NoError(t, err) + var raw pkglicensing.BillingState + require.NoError(t, json.Unmarshal(data, &raw)) + if mode == "legacy-plaintext" { + // The same valid signature covers the unencrypted historical values. + raw.EntitlementJWT = state.EntitlementJWT + raw.EntitlementRefreshToken = state.EntitlementRefreshToken + } else { + raw.Integrity = "invalid-synthetic-signature" + } + data, err = json.Marshal(&raw) + require.NoError(t, err) + require.NoError(t, os.WriteFile(billingPath, data, 0o600)) + } + beforeBilling, err := os.ReadFile(billingPath) + require.NoError(t, err) + keyPath := filepath.Join(dir, ".encryption.key") + beforeKey, err := os.ReadFile(keyPath) + require.NoError(t, err) + info, err := os.Stat(billingPath) + require.NoError(t, err) + helper, err := filepath.Abs(filepath.Join("..", "..", ".github", "scripts", "demo-runtime-transaction.py")) + require.NoError(t, err) + script := `import importlib.util,pathlib,sys +s=importlib.util.spec_from_file_location("demo",sys.argv[1]); m=importlib.util.module_from_spec(s); s.loader.exec_module(m) +m.PATHS={**m.PATHS,"data":pathlib.Path(sys.argv[2])} +request={"profile":{**{name:"2" for name in m.COUNT_KEYS},"seed_duration":"2h","sample_interval":"5m","update_interval":"15s"}} +m.Host().profile(request,False) +assert m.Host().profile_matches(request) +` + cmd := exec.Command("python3", "-c", script, helper, dir) + output, err := cmd.CombinedOutput() + require.NoError(t, err, "synthetic demo profile failed: %s", output) + require.Empty(t, output, "helper must not disclose any billing or key material") + afterBilling, err := os.ReadFile(billingPath) + require.NoError(t, err) + require.Equal(t, beforeBilling, afterBilling, "billing bytes, including all fields and integrity, must be untouched") + afterKey, err := os.ReadFile(keyPath) + require.NoError(t, err) + require.Equal(t, beforeKey, afterKey, "profile must not rotate or rewrite the key") + afterInfo, err := os.Stat(billingPath) + require.NoError(t, err) + require.Equal(t, info.Mode(), afterInfo.Mode()) + require.Equal(t, info.ModTime(), afterInfo.ModTime()) + // The canonical loader still owns both migration and tamper detection. + loaded, err := store.GetBillingState("default") + require.NoError(t, err) + if mode == "tampered-signature" { + require.Nil(t, loaded, "profile must not launder invalid signed billing into accepted state") + return + } + require.NotNil(t, loaded) + require.Equal(t, state.EntitlementJWT, loaded.EntitlementJWT) + require.Equal(t, state.EntitlementRefreshToken, loaded.EntitlementRefreshToken) + afterLoad, err := os.ReadFile(billingPath) + require.NoError(t, err) + require.False(t, bytes.Contains(afterLoad, []byte(state.EntitlementJWT))) + require.False(t, bytes.Contains(afterLoad, []byte(state.EntitlementRefreshToken))) + var persisted pkglicensing.BillingState + require.NoError(t, json.Unmarshal(afterLoad, &persisted)) + manager, err := store.billingCryptoManager() + require.NoError(t, err) + persisted.EntitlementJWT, err = manager.DecryptString(persisted.EntitlementJWT) + require.NoError(t, err) + persisted.EntitlementRefreshToken, err = manager.DecryptString(persisted.EntitlementRefreshToken) + require.NoError(t, err) + hmacKey, err := store.loadHMACKey() + require.NoError(t, err) + require.True(t, verifyBillingIntegrity(&persisted, hmacKey), "preserved state/migration must retain a valid canonical HMAC") + }) + } +}