From ba289276639c6215b1f104b2672c1946b3e6dc58 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 8 Sep 2026 16:56:19 +0200 Subject: [PATCH 1/5] M5.6: add receipt hash chain --- cmd/number-station-tools/restore_receipts.go | 99 ++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/cmd/number-station-tools/restore_receipts.go b/cmd/number-station-tools/restore_receipts.go index ad6346d..21fb4cb 100644 --- a/cmd/number-station-tools/restore_receipts.go +++ b/cmd/number-station-tools/restore_receipts.go @@ -1,8 +1,11 @@ package main import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" + "fmt" "os" "sort" "strings" @@ -22,12 +25,23 @@ type restoreReceipt struct { Conflicts int `json:"conflicts"` Unmatched int `json:"unmatched"` SkippedByPolicy int `json:"skipped_by_policy,omitempty"` + PreviousHash string `json:"previous_hash,omitempty"` + ReceiptHash string `json:"receipt_hash"` } type restoreReceiptData struct { Receipts []restoreReceipt `json:"receipts"` } +type restoreReceiptVerification struct { + Valid bool `json:"valid"` + Count int `json:"count"` + HeadHash string `json:"head_hash,omitempty"` + FailedIndex int `json:"failed_index,omitempty"` + FailedReceiptID string `json:"failed_receipt_id,omitempty"` + Error string `json:"error,omitempty"` +} + type restoreReceiptStore struct { mu sync.Mutex path string @@ -49,6 +63,12 @@ func openRestoreReceiptStore(path string) (*restoreReceiptStore, error) { if s.data.Receipts == nil { s.data.Receipts = []restoreReceipt{} } + if len(s.data.Receipts) > 0 && receiptChainIsLegacy(s.data.Receipts) { + sealReceiptChain(s.data.Receipts) + if err := s.save(); err != nil { + return nil, fmt.Errorf("cannot migrate legacy restore receipts: %w", err) + } + } return s, nil } @@ -64,9 +84,75 @@ func (s *restoreReceiptStore) save() error { return os.Rename(tmp, s.path) } +func receiptChainIsLegacy(receipts []restoreReceipt) bool { + if len(receipts) == 0 { + return false + } + for _, receipt := range receipts { + if strings.TrimSpace(receipt.PreviousHash) != "" || strings.TrimSpace(receipt.ReceiptHash) != "" { + return false + } + } + return true +} + +func restoreReceiptHash(receipt restoreReceipt) (string, error) { + receipt.ReceiptHash = "" + body, err := json.Marshal(receipt) + if err != nil { + return "", err + } + sum := sha256.Sum256(body) + return hex.EncodeToString(sum[:]), nil +} + +func sealReceiptChain(receipts []restoreReceipt) { + previous := "" + for i := range receipts { + receipts[i].PreviousHash = previous + hash, _ := restoreReceiptHash(receipts[i]) + receipts[i].ReceiptHash = hash + previous = hash + } +} + +func verifyReceiptChain(receipts []restoreReceipt) restoreReceiptVerification { + verification := restoreReceiptVerification{Valid: true, Count: len(receipts)} + previous := "" + for i, receipt := range receipts { + storedPrevious := strings.ToLower(strings.TrimSpace(receipt.PreviousHash)) + storedHash := strings.ToLower(strings.TrimSpace(receipt.ReceiptHash)) + if storedPrevious != previous { + return restoreReceiptVerification{Valid: false, Count: len(receipts), FailedIndex: i, FailedReceiptID: receipt.ID, Error: "previous_hash does not match preceding receipt"} + } + if len(storedHash) != 64 { + return restoreReceiptVerification{Valid: false, Count: len(receipts), FailedIndex: i, FailedReceiptID: receipt.ID, Error: "receipt_hash is missing or invalid"} + } + if _, err := hex.DecodeString(storedHash); err != nil { + return restoreReceiptVerification{Valid: false, Count: len(receipts), FailedIndex: i, FailedReceiptID: receipt.ID, Error: "receipt_hash is not hexadecimal"} + } + receipt.PreviousHash = storedPrevious + receipt.ReceiptHash = storedHash + expected, err := restoreReceiptHash(receipt) + if err != nil { + return restoreReceiptVerification{Valid: false, Count: len(receipts), FailedIndex: i, FailedReceiptID: receipt.ID, Error: "cannot calculate receipt hash"} + } + if expected != storedHash { + return restoreReceiptVerification{Valid: false, Count: len(receipts), FailedIndex: i, FailedReceiptID: receipt.ID, Error: "receipt content hash mismatch"} + } + previous = storedHash + } + verification.HeadHash = previous + return verification +} + func (s *restoreReceiptStore) add(receipt restoreReceipt) (restoreReceipt, error) { s.mu.Lock() defer s.mu.Unlock() + verification := verifyReceiptChain(s.data.Receipts) + if !verification.Valid { + return restoreReceipt{}, errors.New("restore receipt chain integrity check failed") + } receipt.ID = id() receipt.At = time.Now().UTC().Format(time.RFC3339Nano) receipt.Mode = strings.TrimSpace(receipt.Mode) @@ -76,6 +162,13 @@ func (s *restoreReceiptStore) add(receipt restoreReceipt) (restoreReceipt, error receipt.PlanToken = strings.ToLower(strings.TrimSpace(receipt.PlanToken)) receipt.SelectedRecordingIDs = append([]string(nil), receipt.SelectedRecordingIDs...) sort.Strings(receipt.SelectedRecordingIDs) + receipt.PreviousHash = verification.HeadHash + receipt.ReceiptHash = "" + hash, err := restoreReceiptHash(receipt) + if err != nil { + return restoreReceipt{}, err + } + receipt.ReceiptHash = hash s.data.Receipts = append(s.data.Receipts, receipt) if err := s.save(); err != nil { s.data.Receipts = s.data.Receipts[:len(s.data.Receipts)-1] @@ -84,6 +177,12 @@ func (s *restoreReceiptStore) add(receipt restoreReceipt) (restoreReceipt, error return receipt, nil } +func (s *restoreReceiptStore) verify() restoreReceiptVerification { + s.mu.Lock() + defer s.mu.Unlock() + return verifyReceiptChain(s.data.Receipts) +} + func (s *restoreReceiptStore) list(limit int) []restoreReceipt { s.mu.Lock() defer s.mu.Unlock() From e05ae0af4d3d79ea82b55676293b1758bc1213a6 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 8 Sep 2026 16:56:49 +0200 Subject: [PATCH 2/5] M5.6: expose receipt chain verification --- cmd/number-station-tools/recording_bundle_api.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cmd/number-station-tools/recording_bundle_api.go b/cmd/number-station-tools/recording_bundle_api.go index d02203c..359aabf 100644 --- a/cmd/number-station-tools/recording_bundle_api.go +++ b/cmd/number-station-tools/recording_bundle_api.go @@ -52,6 +52,14 @@ func registerRecordingRestoreHandler(mux *http.ServeMux, db *store, rs *recordin writeJSON(w, http.StatusOK, receipts.list(limit)) }) + mux.HandleFunc("GET /api/recording-archive/receipts/verify", func(w http.ResponseWriter, _ *http.Request) { + if receiptStoreErr != nil { + http.Error(w, "restore receipt store is unavailable", http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, receipts.verify()) + }) + mux.HandleFunc("POST /api/recording-archive/plan", func(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxPortableArchiveUploadBytes) plan, err := planPortableRestore(r.Body, db, rs, audioDir) From 2a31e4f4e3354817ab4725984302dac649459ab3 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 8 Sep 2026 16:57:16 +0200 Subject: [PATCH 3/5] M5.6: test receipt hash chain integrity --- cmd/number-station-tools/m5_6_test.go | 130 ++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 cmd/number-station-tools/m5_6_test.go diff --git a/cmd/number-station-tools/m5_6_test.go b/cmd/number-station-tools/m5_6_test.go new file mode 100644 index 0000000..462b26b --- /dev/null +++ b/cmd/number-station-tools/m5_6_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRestoreReceiptHashChain(t *testing.T) { + path := filepath.Join(t.TempDir(), "receipts.json") + store, err := openRestoreReceiptStore(path) + if err != nil { + t.Fatal(err) + } + first, err := store.add(restoreReceipt{Mode: "full", ImportedRecordings: 1}) + if err != nil { + t.Fatal(err) + } + second, err := store.add(restoreReceipt{Mode: "selected", PlanToken: strings.Repeat("a", 64), SelectedRecordingIDs: []string{"r2", "r1"}, ImportedRecordings: 1}) + if err != nil { + t.Fatal(err) + } + if first.PreviousHash != "" || len(first.ReceiptHash) != 64 { + t.Fatalf("first receipt chain fields=%#v", first) + } + if second.PreviousHash != first.ReceiptHash || len(second.ReceiptHash) != 64 { + t.Fatalf("second receipt chain fields=%#v first=%#v", second, first) + } + verification := store.verify() + if !verification.Valid || verification.Count != 2 || verification.HeadHash != second.ReceiptHash { + t.Fatalf("verification=%#v", verification) + } +} + +func TestRestoreReceiptHashChainDetectsTampering(t *testing.T) { + path := filepath.Join(t.TempDir(), "receipts.json") + store, err := openRestoreReceiptStore(path) + if err != nil { + t.Fatal(err) + } + if _, err := store.add(restoreReceipt{Mode: "full", ImportedRecordings: 1}); err != nil { + t.Fatal(err) + } + if _, err := store.add(restoreReceipt{Mode: "full", ImportedRecordings: 2}); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var data restoreReceiptData + if err := json.Unmarshal(body, &data); err != nil { + t.Fatal(err) + } + data.Receipts[0].ImportedRecordings = 99 + body, _ = json.MarshalIndent(data, "", " ") + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + tampered, err := openRestoreReceiptStore(path) + if err != nil { + t.Fatal(err) + } + verification := tampered.verify() + if verification.Valid || verification.FailedIndex != 0 || verification.Error == "" { + t.Fatalf("verification=%#v", verification) + } + if _, err := tampered.add(restoreReceipt{Mode: "full"}); err == nil || !strings.Contains(err.Error(), "integrity") { + t.Fatalf("expected integrity rejection, got %v", err) + } +} + +func TestRestoreReceiptLegacyMigration(t *testing.T) { + path := filepath.Join(t.TempDir(), "receipts.json") + legacy := restoreReceiptData{Receipts: []restoreReceipt{ + {ID: "old-1", At: "2026-01-01T00:00:00Z", Mode: "full", ImportedRecordings: 1}, + {ID: "old-2", At: "2026-01-02T00:00:00Z", Mode: "full", ImportedRecordings: 2}, + }} + body, _ := json.MarshalIndent(legacy, "", " ") + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + store, err := openRestoreReceiptStore(path) + if err != nil { + t.Fatal(err) + } + verification := store.verify() + if !verification.Valid || verification.Count != 2 || verification.HeadHash == "" { + t.Fatalf("verification=%#v", verification) + } + reopened, err := openRestoreReceiptStore(path) + if err != nil { + t.Fatal(err) + } + if !reopened.verify().Valid { + t.Fatal("migrated receipt chain did not persist") + } +} + +func TestRestoreReceiptVerifyAPI(t *testing.T) { + db := restoreTestDB(t) + recordingPath := filepath.Join(t.TempDir(), "recordings.json") + rs, _ := openRecordingStore(recordingPath) + receipts, err := openRestoreReceiptStore(recordingPath + ".restore-receipts.json") + if err != nil { + t.Fatal(err) + } + if _, err := receipts.add(restoreReceipt{Mode: "full", ImportedRecordings: 1}); err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + registerRecordingRestoreHandler(mux, db, rs, filepath.Join(t.TempDir(), "audio")) + req := httptest.NewRequest(http.MethodGet, "/api/recording-archive/receipts/verify", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var verification restoreReceiptVerification + if err := json.Unmarshal(w.Body.Bytes(), &verification); err != nil { + t.Fatal(err) + } + if !verification.Valid || verification.Count != 1 || len(verification.HeadHash) != 64 { + t.Fatalf("verification=%#v", verification) + } +} From 4a5e2729eab267e71b4cfbe0b1bc933982d1f826 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 8 Sep 2026 16:57:56 +0200 Subject: [PATCH 4/5] M5.6: document receipt hash chain --- README.md | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ac4df25..78d0ba3 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Self-hosted, local-first tools for numbers stations, shortwave monitoring and signal-analysis workflows. -## Current milestone: M5.5 +## Current milestone: M5.6 The application currently provides: @@ -47,6 +47,8 @@ The application currently provides: - strict rejection of stale plan tokens after archive, recording, annotation or observation changes - strict rejection of selected duplicates, conflicts, unmatched observations and unknown recording IDs - persistent local restore receipts for successful full and selective restores +- SHA-256 receipt hash chaining with `previous_hash` and `receipt_hash` +- read-only receipt-chain verification with tamper detection and chain head reporting - read-only restore audit history with mode, UTC time, plan token, selection and result counts - per-recording annotation import/skip counts before restore - local rebuild of waveform, frequency, spectrogram and fingerprint data from restored managed audio @@ -59,22 +61,24 @@ and `duplicate capture`. They are stored locally alongside recording metadata an do not require AI, an API key, or an external service. Managed playback reads audio directly from the appliance. Browser-native codec -support determines whether a WAV/FLAC recording can be played; M5.5 does not +support determines whether a WAV/FLAC recording can be played; M5.6 does not transcode audio or send it to an external service. Saved timestamp annotations persist in `recordings.json`, can be searched across the archive, exported as JSON or CSV, and safely imported from the versioned JSON format. -M5.5 adds an operator-visible audit primitive to the hardened restore flow. Every -successful full or selective restore persists a receipt in a separate local JSON -file beside the recording store. Receipts record a generated receipt ID, UTC time, -restore mode, result counts and, for selective restore, the approved plan token and -selected recording IDs. The receipt log contains no archive payload or duplicated -audio. `GET /api/recording-archive/receipts` returns recent receipts read-only. +M5.6 makes the M5.5 restore audit trail tamper-evident. Every newly persisted +receipt includes the SHA-256 of its canonical content and the hash of the preceding +receipt. Appending a receipt first verifies the complete existing chain and refuses +to extend a broken log. `GET /api/recording-archive/receipts/verify` reports the +chain status, receipt count and current head hash, or identifies the first failing +receipt. Existing M5.5 receipt files that contain no hashes are sealed once when +first opened by M5.6; this establishes integrity from that migration point onward +but cannot retroactively prove that legacy data was untouched before migration. The application does not require an SDR, receiver, API key or external data provider for these workflows. -See [docs/M5_5.md](docs/M5_5.md) for the current scope. +See [docs/M5_6.md](docs/M5_6.md) for the current scope. ## Run with Go @@ -108,6 +112,7 @@ Then open . - `GET /api/recording-bundle` - `GET /api/recording-archive` - `GET /api/recording-archive/receipts?limit=50` +- `GET /api/recording-archive/receipts/verify` - `POST /api/recording-archive/plan` - `POST /api/recording-archive/import-selected?recording_id=...&plan_token=...` - `POST /api/recording-archive/import` @@ -138,7 +143,7 @@ Number Station Tools is intended to grow into a workbench for: - waveform, frequency, spectrogram, fingerprint and signal-analysis workflows - categorized timestamp annotations, bookmarks and operator notes on recordings - cross-recording annotation search, portable transfer and timeline navigation -- portable recording manifests, verified archival bundles, token-bound dry-run planning, policy-controlled restore and local audit receipts +- portable recording manifests, verified archival bundles, token-bound dry-run planning, policy-controlled restore and tamper-evident local audit receipts - human review and classification of signal matches and repeated transmissions - optional SDR and network-receiver integrations - optional data-provider imports From d4640847198a1e1da5d251e55ecbe3dcc1050608 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 8 Sep 2026 16:58:22 +0200 Subject: [PATCH 5/5] M5.6: document receipt integrity --- docs/M5_6.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/M5_6.md diff --git a/docs/M5_6.md b/docs/M5_6.md new file mode 100644 index 0000000..c87f9ef --- /dev/null +++ b/docs/M5_6.md @@ -0,0 +1,64 @@ +# M5.6 — restore receipt integrity / hash chain + +M5.6 makes the persistent restore receipts introduced in M5.5 tamper-evident. + +## Receipt chain + +Each restore receipt now stores: + +- `previous_hash` — SHA-256 hash of the preceding receipt, blank for the first receipt +- `receipt_hash` — SHA-256 of the receipt's canonical JSON content with `receipt_hash` blanked + +Receipts remain in append order in the local receipt store. The current chain head is the `receipt_hash` of the newest appended receipt. + +Before a new receipt is appended, the complete existing chain is verified. A broken chain is not extended. + +This is tamper evidence, not an external signature or trusted timestamp. An attacker that can rewrite the complete local receipt file and recompute every hash can create a new internally consistent chain. External anchoring is outside M5.6 scope. + +## Verification API + +`GET /api/recording-archive/receipts/verify` + +A valid response reports: + +- `valid: true` +- receipt `count` +- current `head_hash` + +A failed verification reports `valid: false` and identifies the first failing receipt/index plus a reason such as: + +- `previous_hash` does not match the preceding receipt +- missing/invalid receipt hash +- non-hexadecimal receipt hash +- receipt content hash mismatch + +The endpoint is read-only. + +## Legacy M5.5 migration + +An M5.5 receipt file contains no hash fields. If an existing file has receipts and every receipt is completely unchained, M5.6 seals the receipts in their existing append order and atomically persists the new chain when the store is opened. + +That migration establishes tamper evidence from the M5.6 migration point onward. It cannot prove that the legacy M5.5 data had not been modified before migration. + +Mixed partially-hashed data is not treated as legacy. Verification will report it as invalid, and new receipt appends will be refused. + +## Persistence and failure semantics + +The receipt store remains a separate local JSON file beside the recording store and is saved atomically with mode `0600`. + +A successful restore whose receipt cannot be persisted is still reported as a restore whose audit persistence failed, matching M5.5 semantics. M5.6 additionally refuses to append a receipt when the existing chain fails integrity verification. + +No archive payload, audio content or derived signal data is duplicated into the receipt log. + +## Scope exclusions + +M5.6 does not provide: + +- digital signatures or public-key verification +- external timestamping +- remote transparency logs +- immutable/WORM storage +- external chain-head anchoring +- UI visualization of the receipt history + +Those can be layered on later without changing the restore archive format.