Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -108,6 +112,7 @@ Then open <http://localhost:8080>.
- `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`
Expand Down Expand Up @@ -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
Expand Down
130 changes: 130 additions & 0 deletions cmd/number-station-tools/m5_6_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 8 additions & 0 deletions cmd/number-station-tools/recording_bundle_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
99 changes: 99 additions & 0 deletions cmd/number-station-tools/restore_receipts.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package main

import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"sort"
"strings"
Expand All @@ -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
Expand All @@ -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
}

Expand All @@ -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)
Expand All @@ -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]
Expand All @@ -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()
Expand Down
Loading
Loading