diff --git a/docs/AUDIT_LOGGING.md b/docs/AUDIT_LOGGING.md index 403e91800..1e3f75ce6 100644 --- a/docs/AUDIT_LOGGING.md +++ b/docs/AUDIT_LOGGING.md @@ -4,6 +4,11 @@ Pulse's audit log records security-relevant events with tamper-evident signature **Requires:** Pro, legacy Pro+, or Cloud license with the `audit_logging` capability to query, export, and verify events via the API. Events are recorded on all plans, but the API endpoints are license-gated. +An active licence alone does not enable the audit UI/API on the public +community runtime. If the panel says **Pulse Pro runtime required**, follow +**Download Pulse Pro** to the private runtime for your deployment. Do not buy +a second licence or reset the data directory to clear this gate. + For plan details, see [PULSE_PRO.md](PULSE_PRO.md). For API endpoints, see [API Reference](API.md#-audit-log-pro). --- @@ -116,7 +121,9 @@ sensitive data; keep it outside shared repositories and issue attachments. ## Tamper Detection -Every audit event is cryptographically signed at creation time. You can verify that an event has not been modified: +When signing is available, Pulse signs audit events at creation time. Events +captured without a signer remain unsigned. Use **Verify** in the signed-in +Audit Log panel, or the authenticated read below, to check a stored signature: ```bash curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ @@ -132,7 +139,49 @@ Response: } ``` -If `verified` is `false`, the event data has been tampered with since it was recorded. +`verified: true` means the signature matches the verifier's key and supported +signature format. It does not prove that the history is complete or that no +events were deleted. `verified: false` means the check failed, not that Pulse +has established why it failed. Keep that result as evidence. + +### Verification failures and safe recovery + +| Result | What it establishes | Safe next step | +| --- | --- | --- | +| **Not checked** | No verification result has been obtained. | Use **Verify** for the relevant event. | +| **Unsigned** | No signature was stored. | Check whether signing was available when the event was captured. Later enabling signing cannot authenticate it retroactively. | +| **Failed** / `verified: false` | The stored event and signature do not verify with the current key and supported format. | Preserve the evidence; a different key, missing or damaged signature, unsupported format or modified signed fields can cause this. | +| **Unavailable** / `available: false` | The current logger cannot offer verification. | Check the audit capability/runtime gate and bounded startup logs for persistent-store initialisation failures. | +| **Error** / non-successful HTTP response | The verification request did not return a usable result. | Keep the HTTP status and redacted error; it is not proof that the event failed verification. | + +Start with the event's time, the last known working version and whether the +data mount, service account, runtime or backup changed. Inspect a bounded +startup log excerpt locally; distinguish encryption/signing initialisation +errors from an audit database/query error. Do not print the key files or post +whole audit exports, service environments or backups. For an empty panel, +clear filters and check the selected organisation before assuming events were +lost. See [Getting Help](TROUBLESHOOTING.md#-getting-help) for safe evidence. + +**Preserve before changing anything.** Keep a consistent private backup of the +current data directory, including the audit store and its signing and +encryption keys. Use your established snapshot/backup procedure for a running +instance; copying a live `.db` file alone is not a consistent backup. For an +offline filesystem backup, stop Pulse first and retain the complete store, +including any SQLite sidecar files, with its original ownership and access. + +Do not delete or regenerate a key, replace a live signing key with an older +one, edit audit rows or re-sign old events to make verification pass. A newly +generated signing key does not verify events signed with the previous key. +The encrypted signing key also needs its matching `.encryption.key`; restoring +only one member is not a recovery. Swapping keys can break verification of +newer events while concealing the original problem. + +If a matching historical backup exists, restore and compare it in an isolated +test instance using the corresponding Pulse runtime/version, without +replacing the live data or contacting monitored systems and notification +destinations. Keep the current instance and both backups intact. If the +original key is lost or an event was unsigned, do not claim that a restart, +new key or successful check on a different event authenticated that history. --- @@ -160,16 +209,28 @@ See [Multi-Tenant Organizations](MULTI_TENANT.md) for details. | Export | License-gated (402) | Available | | `persistentLogging` API flag | `false` | `true` | -On all plans, audit events are written to the SQLite database. However, the query, verify, and export API endpoints require the `audit_logging` license feature and return `402 Payment Required` without it. The `persistentLogging` flag in API responses indicates whether the licensed query capabilities are available. +Pulse attempts SQLite-backed capture on all plans; failure to initialise the +store can leave console-only logging. The query, verify and export endpoints +also require the `audit_logging` capability and a compatible runtime. A +licence/runtime gate is not proof that events were never captured. For an +authorised query, `persistentLogging` describes the active logger, not a count +of historical events or proof that their signatures are valid. --- ## Storage -Audit events are stored in a SQLite database in the Pulse data directory: +The default audit store lives in the Pulse data directory: - **Single-tenant:** `{data-dir}/audit/audit.db` - **Multi-tenant:** `{data-dir}/orgs/{org-id}/audit/audit.db` +The default encrypted signing key is `.audit-signing.key` **inside the audit +store directory**, alongside `audit.db`, not at the data-directory root. Its +matching `.encryption.key` is in the data directory (or the organisation's data +directory for a non-default organisation). Keep both with the corresponding +history. A runtime may supply a different audit directory or managed signing +key; use that deployment's actual configuration rather than guessing a path. + Data directory locations: - systemd: `/etc/pulse/` - Docker/Kubernetes: `/data/` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4b30522e8..e1a12bbe6 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -6,7 +6,7 @@ Pulse uses a split-configuration model to ensure security and flexibility. | ------ | --------- | ---------------- | | `.env` | Authentication & Secrets | πŸ”’ **Critical** (Read-only by owner) | | `.encryption.key` | Encryption key for `.enc` files | πŸ”’ **Critical** | -| `.audit-signing.key` | Audit log signing key (Pro/legacy Pro+/Cloud, encrypted) | πŸ”’ **Sensitive** | +| `audit/.audit-signing.key` | Encrypted signing key for the default audit store; preserve with its history and matching encryption key | πŸ”’ **Sensitive** | | `system.json` | General Settings | πŸ“ Standard | | `nodes.enc` | Node Credentials | πŸ”’ **Encrypted** (AES-256-GCM) | | `alerts.json` | Alert Rules | πŸ“ Standard | @@ -35,7 +35,7 @@ Pulse uses a split-configuration model to ensure security and flexibility. | `sessions.json` | Persistent sessions (includes OIDC refresh tokens) | πŸ”’ **Sensitive** | | `update-history.jsonl` | Update history log (in-app updates) | πŸ“ Standard | | `metrics.db` | Persistent metrics history (SQLite) | πŸ“ Standard | -| `audit.db` | Audit log database (Pro/legacy Pro+/Cloud, SQLite) | πŸ”’ **Sensitive** | +| `audit/audit.db` | Default audit database; capture on all plans, licensed query/export | πŸ”’ **Sensitive** | | `baselines.json` | AI baseline data for anomaly detection | πŸ“ Standard | | `ai_correlations.json` | AI correlation analysis cache | πŸ“ Standard | | `ai_patterns.json` | AI pattern detection data | πŸ“ Standard | @@ -45,7 +45,11 @@ Pulse uses a split-configuration model to ensure security and flexibility. Guest metadata entries are keyed by the canonical guest ID format `instance:node:vmid` (for example, `pve1:node1:100`). Legacy dash-separated keys are migrated automatically. -All files are located in `/etc/pulse/` (Systemd) or `/data/` (Docker/Kubernetes) by default. +Paths are relative to `/etc/pulse/` (Systemd) or `/data/` (Docker/Kubernetes) by +default. The audit database and default signing key are in the `audit/` +subdirectory. Runtime-specific audit storage and non-default organisation +paths can differ; see [Audit storage and safe recovery](AUDIT_LOGGING.md#storage). +Do not reset signing or encryption keys as a troubleshooting step. Path overrides: - `PULSE_DATA_DIR` sets the base directory for `system.json`, encrypted files, and the bootstrap token. diff --git a/docs/DEPLOYMENT_MODELS.md b/docs/DEPLOYMENT_MODELS.md index c522c48d1..554293931 100644 --- a/docs/DEPLOYMENT_MODELS.md +++ b/docs/DEPLOYMENT_MODELS.md @@ -25,7 +25,7 @@ Pulse uses a split config model: - **Local auth and secrets**: `.env` (managed by Quick Security Setup or environment overrides, not shown in the UI) - **Encryption key**: `.encryption.key` (required to decrypt `.enc` files) -- **Audit signing key**: `.audit-signing.key` (Pro/legacy Pro+/Cloud, encrypted) +- **Audit signing key**: `audit/.audit-signing.key` (default store, encrypted; preserve with its matching encryption key and history) - **System settings**: `system.json` (editable in the UI unless locked by env) - **Nodes and credentials**: `nodes.enc` (encrypted) - **Notification config**: `email.enc`, `webhooks.enc`, `apprise.enc` (encrypted) @@ -40,7 +40,7 @@ Pulse uses a split config model: - **AI pattern data**: `ai_patterns.json` - **AI remediation data**: `ai_remediations.json` - **AI incident tracking**: `ai_incidents.json` -- **Audit log database**: `audit.db` (Pro/legacy Pro+/Cloud, SQLite) +- **Audit log database**: `audit/audit.db` (default store; persistent capture on all plans, licensed query/export) - **Relay/Pro/legacy Pro+/Cloud license**: `license.enc` (encrypted) - **Host metadata**: `host_metadata.json` - **Docker metadata**: `docker_metadata.json` @@ -64,6 +64,12 @@ Path mapping: - systemd/LXC: `/etc/pulse/*` - Docker/Helm: `/data/*` +The audit paths above are the default store, not a licence-dependent guarantee +that storage initialised successfully. Runtime-specific storage can differ; +see [Audit storage and safe recovery](AUDIT_LOGGING.md#storage) before restoring +history or keys. Do not replace keys in a live instance to clear a verification +failure. + Enterprise/internal multi-org layout: - Default org uses the root data dir for backward compatibility. - Non-default orgs use `/orgs//`. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 60109b2dc..d6a966caa 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -109,19 +109,40 @@ only the relevant port numbers and a redacted error if help is needed. - If another admin can log in, use `POST /api/security/reset-lockout` to clear the lockout for your username or IP. #### Audit Log verification shows unsigned events -- **Symptom**: Audit Log entries show β€œUnsigned” or verification fails in the UI. -- **Root cause**: Audit signing is disabled (crypto manager unavailable), so events are stored without signatures. -- **Fix**: Ensure `.encryption.key` is present and Pro/legacy Pro+/Cloud audit logging is enabled, then restart Pulse to regenerate `.audit-signing.key`. Newly created events will be signed; existing unsigned events remain unsigned. + +**Unsigned** means no signature was stored for that event; it is not the same +as **Failed** verification or a request **Error**. Signing can be unavailable +when Pulse cannot initialise its encryption manager. Check a bounded startup +log excerpt and the persistent data mount and access for the service account, +without printing key contents. Do not delete or regenerate `.encryption.key` +or an audit signing key to make the warning disappear. Restoring signing for +new events cannot authenticate an old unsigned event. + +See [Audit verification and safe recovery](AUDIT_LOGGING.md#verification-failures-and-safe-recovery) +for the different results and evidence to retain. #### Audit Log is empty -- **Symptom**: Audit Log shows zero events or "Console Logging Only." -- **Root cause**: Community plan uses console logging only, or Pro/legacy Pro+/Cloud audit logging is not enabled. -- **Fix**: Use Pro, legacy Pro+, or Cloud with audit logging enabled, then generate new audit events (logins, token creation, password changes). + +Clear the event, user, date and success filters, and check the selected +organisation first. A query error is not an empty history. **Pulse Pro runtime +required** means an active licence is running on the public community runtime; +follow the panel's **Download Pulse Pro** link rather than buying another +licence or resetting storage. Without the audit capability, reads and exports +are gated, but Pulse still attempts to capture events persistently on all +plans. **Console Logging Only** can also reflect unavailable persistent +storage: inspect the bounded startup logs for audit initialisation errors. +Do not change passwords or create tokens merely to populate the panel. #### Audit Log verification fails for older events -- **Symptom**: Older events fail verification while newer events pass. -- **Root cause**: The audit signing key changed (for example, `.audit-signing.key` was regenerated), so signatures no longer match. -- **Fix**: Restore the previous `.audit-signing.key` from backup to verify older events. If rotated intentionally, expect older events to fail verification. + +A failed signature check does not by itself prove tampering. An event signed +with a different key can fail even when its contents are unchanged; missing +signatures and an unsupported or damaged signature format also cannot verify. +Keep the failure as evidence. Do not swap an old key into the live instance, +edit audit rows or re-sign old events. Preserve the current data and keys +privately before any recovery; compare a matching backup only in an isolated +restore, not by overwriting today's history. Follow +[safe audit recovery](AUDIT_LOGGING.md#verification-failures-and-safe-recovery). ### Monitoring Data diff --git a/frontend-modern/public/docs/AUDIT_LOGGING.md b/frontend-modern/public/docs/AUDIT_LOGGING.md index 403e91800..1e3f75ce6 100644 --- a/frontend-modern/public/docs/AUDIT_LOGGING.md +++ b/frontend-modern/public/docs/AUDIT_LOGGING.md @@ -4,6 +4,11 @@ Pulse's audit log records security-relevant events with tamper-evident signature **Requires:** Pro, legacy Pro+, or Cloud license with the `audit_logging` capability to query, export, and verify events via the API. Events are recorded on all plans, but the API endpoints are license-gated. +An active licence alone does not enable the audit UI/API on the public +community runtime. If the panel says **Pulse Pro runtime required**, follow +**Download Pulse Pro** to the private runtime for your deployment. Do not buy +a second licence or reset the data directory to clear this gate. + For plan details, see [PULSE_PRO.md](PULSE_PRO.md). For API endpoints, see [API Reference](API.md#-audit-log-pro). --- @@ -116,7 +121,9 @@ sensitive data; keep it outside shared repositories and issue attachments. ## Tamper Detection -Every audit event is cryptographically signed at creation time. You can verify that an event has not been modified: +When signing is available, Pulse signs audit events at creation time. Events +captured without a signer remain unsigned. Use **Verify** in the signed-in +Audit Log panel, or the authenticated read below, to check a stored signature: ```bash curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ @@ -132,7 +139,49 @@ Response: } ``` -If `verified` is `false`, the event data has been tampered with since it was recorded. +`verified: true` means the signature matches the verifier's key and supported +signature format. It does not prove that the history is complete or that no +events were deleted. `verified: false` means the check failed, not that Pulse +has established why it failed. Keep that result as evidence. + +### Verification failures and safe recovery + +| Result | What it establishes | Safe next step | +| --- | --- | --- | +| **Not checked** | No verification result has been obtained. | Use **Verify** for the relevant event. | +| **Unsigned** | No signature was stored. | Check whether signing was available when the event was captured. Later enabling signing cannot authenticate it retroactively. | +| **Failed** / `verified: false` | The stored event and signature do not verify with the current key and supported format. | Preserve the evidence; a different key, missing or damaged signature, unsupported format or modified signed fields can cause this. | +| **Unavailable** / `available: false` | The current logger cannot offer verification. | Check the audit capability/runtime gate and bounded startup logs for persistent-store initialisation failures. | +| **Error** / non-successful HTTP response | The verification request did not return a usable result. | Keep the HTTP status and redacted error; it is not proof that the event failed verification. | + +Start with the event's time, the last known working version and whether the +data mount, service account, runtime or backup changed. Inspect a bounded +startup log excerpt locally; distinguish encryption/signing initialisation +errors from an audit database/query error. Do not print the key files or post +whole audit exports, service environments or backups. For an empty panel, +clear filters and check the selected organisation before assuming events were +lost. See [Getting Help](TROUBLESHOOTING.md#-getting-help) for safe evidence. + +**Preserve before changing anything.** Keep a consistent private backup of the +current data directory, including the audit store and its signing and +encryption keys. Use your established snapshot/backup procedure for a running +instance; copying a live `.db` file alone is not a consistent backup. For an +offline filesystem backup, stop Pulse first and retain the complete store, +including any SQLite sidecar files, with its original ownership and access. + +Do not delete or regenerate a key, replace a live signing key with an older +one, edit audit rows or re-sign old events to make verification pass. A newly +generated signing key does not verify events signed with the previous key. +The encrypted signing key also needs its matching `.encryption.key`; restoring +only one member is not a recovery. Swapping keys can break verification of +newer events while concealing the original problem. + +If a matching historical backup exists, restore and compare it in an isolated +test instance using the corresponding Pulse runtime/version, without +replacing the live data or contacting monitored systems and notification +destinations. Keep the current instance and both backups intact. If the +original key is lost or an event was unsigned, do not claim that a restart, +new key or successful check on a different event authenticated that history. --- @@ -160,16 +209,28 @@ See [Multi-Tenant Organizations](MULTI_TENANT.md) for details. | Export | License-gated (402) | Available | | `persistentLogging` API flag | `false` | `true` | -On all plans, audit events are written to the SQLite database. However, the query, verify, and export API endpoints require the `audit_logging` license feature and return `402 Payment Required` without it. The `persistentLogging` flag in API responses indicates whether the licensed query capabilities are available. +Pulse attempts SQLite-backed capture on all plans; failure to initialise the +store can leave console-only logging. The query, verify and export endpoints +also require the `audit_logging` capability and a compatible runtime. A +licence/runtime gate is not proof that events were never captured. For an +authorised query, `persistentLogging` describes the active logger, not a count +of historical events or proof that their signatures are valid. --- ## Storage -Audit events are stored in a SQLite database in the Pulse data directory: +The default audit store lives in the Pulse data directory: - **Single-tenant:** `{data-dir}/audit/audit.db` - **Multi-tenant:** `{data-dir}/orgs/{org-id}/audit/audit.db` +The default encrypted signing key is `.audit-signing.key` **inside the audit +store directory**, alongside `audit.db`, not at the data-directory root. Its +matching `.encryption.key` is in the data directory (or the organisation's data +directory for a non-default organisation). Keep both with the corresponding +history. A runtime may supply a different audit directory or managed signing +key; use that deployment's actual configuration rather than guessing a path. + Data directory locations: - systemd: `/etc/pulse/` - Docker/Kubernetes: `/data/` diff --git a/frontend-modern/public/docs/CONFIGURATION.md b/frontend-modern/public/docs/CONFIGURATION.md index 4b30522e8..e1a12bbe6 100644 --- a/frontend-modern/public/docs/CONFIGURATION.md +++ b/frontend-modern/public/docs/CONFIGURATION.md @@ -6,7 +6,7 @@ Pulse uses a split-configuration model to ensure security and flexibility. | ------ | --------- | ---------------- | | `.env` | Authentication & Secrets | πŸ”’ **Critical** (Read-only by owner) | | `.encryption.key` | Encryption key for `.enc` files | πŸ”’ **Critical** | -| `.audit-signing.key` | Audit log signing key (Pro/legacy Pro+/Cloud, encrypted) | πŸ”’ **Sensitive** | +| `audit/.audit-signing.key` | Encrypted signing key for the default audit store; preserve with its history and matching encryption key | πŸ”’ **Sensitive** | | `system.json` | General Settings | πŸ“ Standard | | `nodes.enc` | Node Credentials | πŸ”’ **Encrypted** (AES-256-GCM) | | `alerts.json` | Alert Rules | πŸ“ Standard | @@ -35,7 +35,7 @@ Pulse uses a split-configuration model to ensure security and flexibility. | `sessions.json` | Persistent sessions (includes OIDC refresh tokens) | πŸ”’ **Sensitive** | | `update-history.jsonl` | Update history log (in-app updates) | πŸ“ Standard | | `metrics.db` | Persistent metrics history (SQLite) | πŸ“ Standard | -| `audit.db` | Audit log database (Pro/legacy Pro+/Cloud, SQLite) | πŸ”’ **Sensitive** | +| `audit/audit.db` | Default audit database; capture on all plans, licensed query/export | πŸ”’ **Sensitive** | | `baselines.json` | AI baseline data for anomaly detection | πŸ“ Standard | | `ai_correlations.json` | AI correlation analysis cache | πŸ“ Standard | | `ai_patterns.json` | AI pattern detection data | πŸ“ Standard | @@ -45,7 +45,11 @@ Pulse uses a split-configuration model to ensure security and flexibility. Guest metadata entries are keyed by the canonical guest ID format `instance:node:vmid` (for example, `pve1:node1:100`). Legacy dash-separated keys are migrated automatically. -All files are located in `/etc/pulse/` (Systemd) or `/data/` (Docker/Kubernetes) by default. +Paths are relative to `/etc/pulse/` (Systemd) or `/data/` (Docker/Kubernetes) by +default. The audit database and default signing key are in the `audit/` +subdirectory. Runtime-specific audit storage and non-default organisation +paths can differ; see [Audit storage and safe recovery](AUDIT_LOGGING.md#storage). +Do not reset signing or encryption keys as a troubleshooting step. Path overrides: - `PULSE_DATA_DIR` sets the base directory for `system.json`, encrypted files, and the bootstrap token. diff --git a/frontend-modern/public/docs/DEPLOYMENT_MODELS.md b/frontend-modern/public/docs/DEPLOYMENT_MODELS.md index c522c48d1..554293931 100644 --- a/frontend-modern/public/docs/DEPLOYMENT_MODELS.md +++ b/frontend-modern/public/docs/DEPLOYMENT_MODELS.md @@ -25,7 +25,7 @@ Pulse uses a split config model: - **Local auth and secrets**: `.env` (managed by Quick Security Setup or environment overrides, not shown in the UI) - **Encryption key**: `.encryption.key` (required to decrypt `.enc` files) -- **Audit signing key**: `.audit-signing.key` (Pro/legacy Pro+/Cloud, encrypted) +- **Audit signing key**: `audit/.audit-signing.key` (default store, encrypted; preserve with its matching encryption key and history) - **System settings**: `system.json` (editable in the UI unless locked by env) - **Nodes and credentials**: `nodes.enc` (encrypted) - **Notification config**: `email.enc`, `webhooks.enc`, `apprise.enc` (encrypted) @@ -40,7 +40,7 @@ Pulse uses a split config model: - **AI pattern data**: `ai_patterns.json` - **AI remediation data**: `ai_remediations.json` - **AI incident tracking**: `ai_incidents.json` -- **Audit log database**: `audit.db` (Pro/legacy Pro+/Cloud, SQLite) +- **Audit log database**: `audit/audit.db` (default store; persistent capture on all plans, licensed query/export) - **Relay/Pro/legacy Pro+/Cloud license**: `license.enc` (encrypted) - **Host metadata**: `host_metadata.json` - **Docker metadata**: `docker_metadata.json` @@ -64,6 +64,12 @@ Path mapping: - systemd/LXC: `/etc/pulse/*` - Docker/Helm: `/data/*` +The audit paths above are the default store, not a licence-dependent guarantee +that storage initialised successfully. Runtime-specific storage can differ; +see [Audit storage and safe recovery](AUDIT_LOGGING.md#storage) before restoring +history or keys. Do not replace keys in a live instance to clear a verification +failure. + Enterprise/internal multi-org layout: - Default org uses the root data dir for backward compatibility. - Non-default orgs use `/orgs//`. diff --git a/frontend-modern/public/docs/TROUBLESHOOTING.md b/frontend-modern/public/docs/TROUBLESHOOTING.md index 60109b2dc..d6a966caa 100644 --- a/frontend-modern/public/docs/TROUBLESHOOTING.md +++ b/frontend-modern/public/docs/TROUBLESHOOTING.md @@ -109,19 +109,40 @@ only the relevant port numbers and a redacted error if help is needed. - If another admin can log in, use `POST /api/security/reset-lockout` to clear the lockout for your username or IP. #### Audit Log verification shows unsigned events -- **Symptom**: Audit Log entries show β€œUnsigned” or verification fails in the UI. -- **Root cause**: Audit signing is disabled (crypto manager unavailable), so events are stored without signatures. -- **Fix**: Ensure `.encryption.key` is present and Pro/legacy Pro+/Cloud audit logging is enabled, then restart Pulse to regenerate `.audit-signing.key`. Newly created events will be signed; existing unsigned events remain unsigned. + +**Unsigned** means no signature was stored for that event; it is not the same +as **Failed** verification or a request **Error**. Signing can be unavailable +when Pulse cannot initialise its encryption manager. Check a bounded startup +log excerpt and the persistent data mount and access for the service account, +without printing key contents. Do not delete or regenerate `.encryption.key` +or an audit signing key to make the warning disappear. Restoring signing for +new events cannot authenticate an old unsigned event. + +See [Audit verification and safe recovery](AUDIT_LOGGING.md#verification-failures-and-safe-recovery) +for the different results and evidence to retain. #### Audit Log is empty -- **Symptom**: Audit Log shows zero events or "Console Logging Only." -- **Root cause**: Community plan uses console logging only, or Pro/legacy Pro+/Cloud audit logging is not enabled. -- **Fix**: Use Pro, legacy Pro+, or Cloud with audit logging enabled, then generate new audit events (logins, token creation, password changes). + +Clear the event, user, date and success filters, and check the selected +organisation first. A query error is not an empty history. **Pulse Pro runtime +required** means an active licence is running on the public community runtime; +follow the panel's **Download Pulse Pro** link rather than buying another +licence or resetting storage. Without the audit capability, reads and exports +are gated, but Pulse still attempts to capture events persistently on all +plans. **Console Logging Only** can also reflect unavailable persistent +storage: inspect the bounded startup logs for audit initialisation errors. +Do not change passwords or create tokens merely to populate the panel. #### Audit Log verification fails for older events -- **Symptom**: Older events fail verification while newer events pass. -- **Root cause**: The audit signing key changed (for example, `.audit-signing.key` was regenerated), so signatures no longer match. -- **Fix**: Restore the previous `.audit-signing.key` from backup to verify older events. If rotated intentionally, expect older events to fail verification. + +A failed signature check does not by itself prove tampering. An event signed +with a different key can fail even when its contents are unchanged; missing +signatures and an unsupported or damaged signature format also cannot verify. +Keep the failure as evidence. Do not swap an old key into the live instance, +edit audit rows or re-sign old events. Preserve the current data and keys +privately before any recovery; compare a matching backup only in an isolated +restore, not by overwriting today's history. Follow +[safe audit recovery](AUDIT_LOGGING.md#verification-failures-and-safe-recovery). ### Monitoring Data diff --git a/pkg/audit/signer_recovery_guidance_test.go b/pkg/audit/signer_recovery_guidance_test.go new file mode 100644 index 000000000..ad8ab2ba4 --- /dev/null +++ b/pkg/audit/signer_recovery_guidance_test.go @@ -0,0 +1,142 @@ +package audit_test + +import ( + "bytes" + "encoding/base64" + "os" + "path/filepath" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/crypto" + "github.com/rcourtman/pulse-go-rewrite/pkg/audit" +) + +// These controls exercise the recovery guide's key/evidence distinctions with +// the real signer and encryption manager. They use only synthetic, private +// temporary directories: no SQLite store, installed instance or external call. +func TestSignerRecoveryGuidance(t *testing.T) { + dataDir := t.TempDir() + writeEncryptionKey := func(dir string, value byte) *crypto.CryptoManager { + t.Helper() + key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{value}, 32)) + if err := os.WriteFile(filepath.Join(dir, ".encryption.key"), []byte(key), 0o600); err != nil { + t.Fatalf("write synthetic encryption key: %v", err) + } + manager, err := crypto.NewCryptoManagerAt(dir) + if err != nil { + t.Fatalf("load synthetic encryption manager: %v", err) + } + return manager + } + manager := writeEncryptionKey(dataDir, 'A') + auditDir := filepath.Join(dataDir, "audit") + signer, err := audit.NewSigner(auditDir, manager) + if err != nil { + t.Fatalf("create original signer: %v", err) + } + event := audit.Event{ + ID: "synthetic-recovery-evidence", + Timestamp: time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC), + EventType: "login", + User: "synthetic-user", + Path: "/api/login", + Success: true, + Details: "synthetic recovery control", + } + event.Signature = signer.Sign(event) + if !signer.Verify(event) { + t.Fatal("original event must verify before recovery comparisons") + } + storedKey, err := os.ReadFile(filepath.Join(auditDir, ".audit-signing.key")) + if err != nil { + t.Fatalf("read synthetic encrypted key: %v", err) + } + + t.Run("reload preserves verification without regeneration", func(t *testing.T) { + reloaded, err := audit.NewSigner(auditDir, manager) + if err != nil || !reloaded.Verify(event) { + t.Fatalf("reload failed to verify unchanged event: %v", err) + } + currentKey, err := os.ReadFile(filepath.Join(auditDir, ".audit-signing.key")) + if err != nil || !bytes.Equal(storedKey, currentKey) { + t.Fatal("reload must not replace the stored signing key") + } + }) + + t.Run("new signing key fails unchanged historical event", func(t *testing.T) { + freshSigner, err := audit.NewSigner(filepath.Join(t.TempDir(), "audit"), manager) + if err != nil { + t.Fatalf("create unrelated signer: %v", err) + } + if freshSigner.Verify(event) { + t.Fatal("a new key must not verify an unchanged old event") + } + if !signer.Verify(event) { + t.Fatal("failed check with another key must not alter the original evidence") + } + }) + + t.Run("signing key alone cannot restore with different encryption key", func(t *testing.T) { + restoreDir := t.TempDir() + otherManager := writeEncryptionKey(restoreDir, 'B') + restoreAuditDir := filepath.Join(restoreDir, "audit") + if err := os.MkdirAll(restoreAuditDir, 0o700); err != nil { + t.Fatalf("create isolated restore directory: %v", err) + } + keyPath := filepath.Join(restoreAuditDir, ".audit-signing.key") + if err := os.WriteFile(keyPath, storedKey, 0o600); err != nil { + t.Fatalf("copy synthetic encrypted key: %v", err) + } + if _, err := audit.NewSigner(restoreAuditDir, otherManager); err == nil { + t.Fatal("wrong encryption key must not silently recover the signer") + } + after, err := os.ReadFile(keyPath) + if err != nil || !bytes.Equal(after, storedKey) { + t.Fatal("a failed restore must preserve the supplied encrypted key") + } + }) + + t.Run("matching key pair verifies in isolated restore", func(t *testing.T) { + restoreDir := t.TempDir() + restoredManager := writeEncryptionKey(restoreDir, 'A') + restoreAuditDir := filepath.Join(restoreDir, "audit") + if err := os.MkdirAll(restoreAuditDir, 0o700); err != nil { + t.Fatalf("create isolated restore directory: %v", err) + } + if err := os.WriteFile(filepath.Join(restoreAuditDir, ".audit-signing.key"), storedKey, 0o600); err != nil { + t.Fatalf("copy synthetic encrypted key: %v", err) + } + restoredSigner, err := audit.NewSigner(restoreAuditDir, restoredManager) + if err != nil || !restoredSigner.Verify(event) { + t.Fatalf("matching isolated key pair failed to verify old event: %v", err) + } + if !signer.Verify(event) { + t.Fatal("isolated restore must leave the original verifier usable") + } + }) + + t.Run("unsigned is not retrospectively authenticated", func(t *testing.T) { + unsigned := event + unsigned.Signature = "" + if signer.Verify(unsigned) { + t.Fatal("working signer must not authenticate an unsigned old event") + } + }) + + t.Run("altered data and unsupported format remain failures", func(t *testing.T) { + altered := event + altered.Details = "altered synthetic evidence" + if signer.Verify(altered) { + t.Fatal("changed signed content must still fail verification") + } + unsupported := event + unsupported.Signature = "v99:" + event.Signature + if signer.Verify(unsupported) { + t.Fatal("unsupported signature format must not be accepted") + } + if !signer.Verify(event) { + t.Fatal("negative comparisons must preserve the original evidence") + } + }) +} diff --git a/scripts/tests/test_audit_recovery_docs.py b/scripts/tests/test_audit_recovery_docs.py new file mode 100644 index 000000000..8240521ef --- /dev/null +++ b/scripts/tests/test_audit_recovery_docs.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Keep audit help from treating a failed check as a diagnosis or a reset task. + +Signer/key behaviour is exercised separately by TestSignerRecoveryGuidance; +these checks bind those distinctions to the actual canonical and shipped help. +""" + +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +GUIDES = ("AUDIT_LOGGING", "TROUBLESHOOTING", "CONFIGURATION", "DEPLOYMENT_MODELS") + + +def read_guide(name: str) -> str: + return " ".join((ROOT / "docs" / f"{name}.md").read_text(encoding="utf-8").split()) + + +class AuditRecoveryDocsTest(unittest.TestCase): + def test_shipped_guides_match_the_canonical_evidence(self): + for name in GUIDES: + with self.subTest(guide=name): + self.assertEqual((ROOT / "docs" / f"{name}.md").read_bytes(), + (ROOT / "frontend-modern/public/docs" / f"{name}.md").read_bytes()) + + def test_verification_results_are_not_conflated(self): + guide = read_guide("AUDIT_LOGGING") + for result in ("**Not checked**", "**Unsigned**", "**Failed**", "**Unavailable**", "**Error**"): + with self.subTest(result=result): + self.assertIn(result, guide) + self.assertIn("not that Pulse has established why it failed", guide) + self.assertIn("not proof that the event failed verification", guide) + self.assertIn("does not prove that the history is complete", guide) + self.assertNotIn("the event data has been tampered with since it was recorded", guide) + + def test_recovery_preserves_history_and_both_keys(self): + guide = read_guide("AUDIT_LOGGING") + for boundary in ("consistent private backup", "copying a live `.db` file alone", + "including any SQLite sidecar files", "matching `.encryption.key`", + "isolated test instance", "without replacing the live data", + "or contacting monitored systems and notification destinations", + "Do not delete or regenerate a key", "edit audit rows or re-sign old events"): + with self.subTest(boundary=boundary): + self.assertIn(boundary, guide) + self.assertIn("newly generated signing key does not verify events signed with the previous key", guide) + self.assertIn("Later enabling signing cannot authenticate it retroactively", guide) + + def test_troubleshooting_does_not_offer_a_live_key_swap(self): + guide = read_guide("TROUBLESHOOTING") + self.assertNotIn("restart Pulse to regenerate", guide) + self.assertNotIn("Restore the previous `.audit-signing.key` from backup to verify", guide) + self.assertIn("Do not swap an old key into the live instance", guide) + self.assertIn("cannot authenticate an old unsigned event", guide) + self.assertIn("AUDIT_LOGGING.md#verification-failures-and-safe-recovery", guide) + + def test_empty_and_gated_are_not_missing_history(self): + for name in ("TROUBLESHOOTING", "AUDIT_LOGGING"): + guide = read_guide(name) + with self.subTest(guide=name): + self.assertIn("Pulse Pro runtime required", guide) + self.assertIn("Download Pulse Pro", guide) + self.assertIn("filters", guide) + self.assertIn("organisation", guide) + short = read_guide("TROUBLESHOOTING") + self.assertIn("A query error is not an empty history", short) + self.assertIn("Do not change passwords or create tokens merely to populate the panel", short) + self.assertNotIn("Community plan uses console logging only", short) + + def test_storage_paths_match_the_default_store_not_the_old_root_path(self): + for name in ("CONFIGURATION", "DEPLOYMENT_MODELS"): + guide = read_guide(name) + with self.subTest(guide=name): + self.assertIn("`audit/audit.db`", guide) + self.assertIn("`audit/.audit-signing.key`", guide) + self.assertIn("AUDIT_LOGGING.md#storage", guide) + self.assertNotRegex(guide, r"(?:\| |\*\*: )`\.audit-signing\.key`") + guide = read_guide("AUDIT_LOGGING") + self.assertIn("**inside the audit store directory**", guide) + self.assertIn("organisation's data directory", guide) + self.assertIn("runtime may supply a different audit directory or managed signing key", guide) + + +if __name__ == "__main__": + unittest.main()