commit bea52820ce6a283d5fcc396c58396c36fe134052 Author: saymrwulf Date: Fri Feb 13 18:44:48 2026 +0100 Initial commit: puncturable key manager apps and tooling diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47a0ee5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# macOS +.DS_Store + +# Python +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +*.egg-info/ + +# Local runtime data / logs +assets/ +*.log +*.err.log +state.json + +# Go / build outputs +goapp/dist/ +goapp/ios/build-ios/ + +# Xcode user/build artifacts +DerivedData/ +*.xcworkspace/xcuserdata/ +*.xcodeproj/xcuserdata/ + +# Environment files +.env + +# iOS packaged artifacts +goapp/ios/dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d24e8ee --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# Puncture: Zero-Trust Cloud-Wide Forward Secrecy + +Python implementation of puncturable encryption (PE) over a GGM tree, with: + +- a **master app** (key management + provider management + asset encryption mapping) +- a **secondary app** (read-only live mirror with password auth and kill-switch login) + +## Core cryptographic model + +- 256-bit master seed root. +- HMAC-SHA256 left/right derivation for GGM child nodes. +- Non-sequential puncture with minimal co-path replacement. +- Tag schema: `[7 bits provider_id] | [25 bits file_time_id]`. +- Active-state model stores only active prefix nodes (not per-file keys). +- Immediate zeroization of replaced node material on puncture. +- Puncture log export/import (`list[str]` of bit strings). + +## Setup (venv) + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +## Run + +Master app on `9122`: + +```bash +python -m puncture.web_app --host 0.0.0.0 --port 9122 +``` + +Secondary app on `9222`: + +```bash +python -m puncture.view_app --host 0.0.0.0 --port 9222 +``` + +## Master app (`:9122`) workflow + +- `/`: main puncture lab (derive/puncture, history, active frontier roots view). +- `/providers`: add/edit/delete providers. +- `/assets`: pick a cleartext file from asset root, select provider/key, encrypt, and register mapping. + +### Asset behavior + +- Ciphertext is written in the **same folder** as the cleartext file. +- Decryption writes recovered cleartext back into the same folder tree (versioned filenames). +- One cleartext file can have multiple provider-key mappings. +- One provider-key can encrypt multiple files. +- After key puncture: + - affected mappings are shown in **red** (`blocked by puncture`) + - if the same cleartext file still has another accessible mapping, that mapping **glows** + +## Secondary app (`:9222`) behavior + +- Password-gated access. +- Live read-only mirror from master (`GET /api/live/state`). +- No share setup and no independent derivation state. +- Kill-switch login format: + - normal login: `` + - kill-switch login: `` + - example: `puncture-view42` punctures provider `42` immediately on master. + +## Environment variables + +Master app: + +- `PUNCTURE_PORT` (default `9122`) +- `PUNCTURE_ASSET_ROOT` (default `/assets`) +- `PUNCTURE_REMOTE_TOKEN` (optional; required for remote puncture endpoint) +- `PUNCTURE_VIEW_SYNC_KEY` (optional signing key for `/api/view-bundle`) + +Secondary app: + +- `PUNCTURE_VIEW_PORT` (default `9222`) +- `PUNCTURE_MASTER_URL` (default `http://127.0.0.1:9122`) +- `PUNCTURE_SECONDARY_PASSWORD` (default `puncture-view`) +- `PUNCTURE_SECONDARY_SECRET` (Flask session secret) +- `PUNCTURE_REMOTE_TOKEN` (optional; sent as `X-Puncture-Token`) + +## API quick reference + +- `GET /api/state` (master full state) +- `GET /api/live/state` (master live data for secondary app) +- `POST /api/remote/puncture-provider` (master remote provider kill endpoint) +- `GET /api/export` / `POST /api/import` +- `POST /api/puncture-log` +- `GET /api/view-bundle` (legacy signed/unsigned viewer bundle export) + +## Tests + +```bash +pytest -q +``` diff --git a/goapp/README.md b/goapp/README.md new file mode 100644 index 0000000..386b8ba --- /dev/null +++ b/goapp/README.md @@ -0,0 +1,112 @@ +# Puncture Go + +Go implementation of the primary puncturable-key system, plus: + +- a desktop macOS app build target +- a DMG installer pipeline +- a native iOS emergency puncture companion app + +## Layout + +- `cmd/server`: headless primary app server (web UI + APIs) +- `cmd/desktop`: macOS desktop target (embedded webview + local server) +- `internal/crypto`: GGM puncturable key manager in Go +- `internal/app`: providers, key journal, assets, encryption/decryption, tree-viz state +- `internal/server`: HTTP routes + embedded web UI +- `packaging/macos`: `.app` and `.dmg` build scripts +- `ios/EmergencyPuncture`: SwiftUI iOS app (generated with xcodegen) + +## Run Primary (Go Server) + +```bash +cd goapp +go run ./cmd/server --host 0.0.0.0 --port 9122 +``` + +Open: `http://127.0.0.1:9122` + +## Local Persistence + +State now persists automatically across restarts (frontier, punctures, providers, key journal, asset mappings, history). + +- Default state file: + - if asset root ends with `assets`: sibling `state.json` + - otherwise: `/.puncture-state.json` +- Override path with `PUNCTURE_STATE_FILE=/absolute/path/state.json` + +## Build macOS Desktop App + DMG + +```bash +cd goapp +./packaging/macos/build_dmg.sh +``` + +Output: + +- `goapp/dist/Puncture.app` +- `goapp/dist/Puncture.dmg` + +Desktop app behavior: + +- On launch, `Puncture.app` now auto-starts iOS Simulator. +- It auto-installs/launches the bundled `EmergencyPuncture` iOS app in Simulator. +- It attempts to place macOS + Simulator windows side-by-side. +- For auto window positioning, macOS may ask Accessibility permission for the app. + +Runtime toggles (optional): + +- `PUNCTURE_WITH_SIMULATOR=0` disables auto-launch. +- `PUNCTURE_SIM_DEVICE="iPhone 17 Pro"` chooses preferred simulator device. +- `PUNCTURE_IOS_SIM_APP=/path/to/EmergencyPuncture.app` overrides bundled app path. + +## Build macOS Desktop Binary (without DMG) + +```bash +cd goapp +CGO_ENABLED=1 go build -tags desktop -o dist/PunctureDesktop ./cmd/desktop +``` + +## iOS Emergency Puncture App + +Project location: + +- `goapp/ios/EmergencyPuncture/EmergencyPuncture.xcodeproj` + +Open in Xcode and run on device/simulator. + +What it does: + +- Connects to master `http://:9122` +- Loads providers from `/api/live/state` +- Sends emergency puncture to `/api/remote/puncture-provider` +- Optional `X-Puncture-Token` header support + +iPhone install + signing manual: + +- `goapp/ios/INSTALL_IPHONE.md` + +Signed IPA build helper: + +```bash +cd goapp/ios +TEAM_ID=YOURTEAMID ./build_signed_ipa.sh +``` + +## API Highlights + +- `GET /api/state` +- `POST /api/derive` +- `POST /api/puncture` +- `POST /api/providers/add` +- `POST /api/providers/delete` +- `POST /api/assets/upload` +- `POST /api/assets/encrypt` +- `POST /api/assets/decrypt` +- `POST /api/remote/puncture-provider` + +## Tests + +```bash +cd goapp +go test ./... +``` diff --git a/goapp/cmd/desktop/main.go b/goapp/cmd/desktop/main.go new file mode 100644 index 0000000..6b7350b --- /dev/null +++ b/goapp/cmd/desktop/main.go @@ -0,0 +1,72 @@ +//go:build darwin && cgo && desktop + +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "path/filepath" + "time" + + webview "github.com/webview/webview_go" + + "puncture-go/internal/server" +) + +func main() { + defaultRoot := defaultAssetRoot() + host := flag.String("host", getenv("PUNCTURE_HOST", "127.0.0.1"), "bind host") + port := flag.Int("port", getenvInt("PUNCTURE_PORT", 9122), "bind port") + assetRoot := flag.String("asset-root", getenv("PUNCTURE_ASSET_ROOT", defaultRoot), "asset root directory") + flag.Parse() + + addr := server.ParseAddr(*host, *port) + srv, _, err := server.Start(addr, *assetRoot) + if err != nil { + log.Fatalf("failed to start embedded server: %v", err) + } + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = srv.Shutdown(ctx) + }() + + url := fmt.Sprintf("http://%s", addr) + w := webview.New(true) + defer w.Destroy() + w.SetTitle("Puncture Go") + w.SetSize(1280, 860, webview.HintNone) + w.Navigate(url) + maybeLaunchSimulatorCompanion(filepath.Base(os.Args[0])) + w.Run() +} + +func defaultAssetRoot() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "./assets" + } + return filepath.Join(home, "Library", "Application Support", "PunctureGo", "assets") +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func getenvInt(key string, fallback int) int { + v := os.Getenv(key) + if v == "" { + return fallback + } + var parsed int + if _, err := fmt.Sscanf(v, "%d", &parsed); err != nil { + return fallback + } + return parsed +} diff --git a/goapp/cmd/desktop/main_stub.go b/goapp/cmd/desktop/main_stub.go new file mode 100644 index 0000000..ddbaa22 --- /dev/null +++ b/goapp/cmd/desktop/main_stub.go @@ -0,0 +1,9 @@ +//go:build !(darwin && cgo && desktop) + +package main + +import "fmt" + +func main() { + fmt.Println("desktop build requires: darwin + cgo + -tags desktop") +} diff --git a/goapp/cmd/desktop/simulator_bridge.go b/goapp/cmd/desktop/simulator_bridge.go new file mode 100644 index 0000000..53ffb69 --- /dev/null +++ b/goapp/cmd/desktop/simulator_bridge.go @@ -0,0 +1,287 @@ +//go:build darwin && cgo && desktop + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" +) + +const defaultSimBundleID = "com.puncture.emergency" + +type simctlDevice struct { + UDID string `json:"udid"` + Name string `json:"name"` + State string `json:"state"` + IsAvailable bool `json:"isAvailable"` +} + +type simctlDevicesPayload struct { + Devices map[string][]simctlDevice `json:"devices"` +} + +func maybeLaunchSimulatorCompanion(desktopProcess string) { + if !envBool("PUNCTURE_WITH_SIMULATOR", true) { + log.Printf("sim companion: disabled by PUNCTURE_WITH_SIMULATOR") + return + } + go func() { + if err := launchSimulatorCompanion(desktopProcess); err != nil { + log.Printf("sim companion: %v", err) + } + }() +} + +func launchSimulatorCompanion(desktopProcess string) error { + appPath := resolveSimulatorCompanionAppPath() + if appPath == "" { + return errors.New("iOS simulator companion app not found; build the DMG again to bundle it") + } + bundleID := getenv("PUNCTURE_SIM_BUNDLE_ID", defaultSimBundleID) + preferred := getenv("PUNCTURE_SIM_DEVICE", "iPhone 17 Pro") + + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Second) + defer cancel() + + udid, name, err := pickSimulatorDevice(ctx, preferred) + if err != nil { + return err + } + log.Printf("sim companion: using device %s (%s)", name, udid) + + if _, err := runCommand(ctx, "open", "-a", "Simulator", "--args", "-CurrentDeviceUDID", udid); err != nil { + // Fallback for older Simulator CLI behavior. + if _, retryErr := runCommand(ctx, "open", "-a", "Simulator"); retryErr != nil { + return fmt.Errorf("failed to open Simulator: %w", retryErr) + } + } + if out, err := runCommand(ctx, "xcrun", "simctl", "boot", udid); err != nil { + if !strings.Contains(strings.ToLower(out), "booted") { + return fmt.Errorf("failed to boot simulator: %w", err) + } + } + if _, err := runCommand(ctx, "xcrun", "simctl", "bootstatus", udid, "-b"); err != nil { + return fmt.Errorf("failed waiting for simulator boot: %w", err) + } + if _, err := runCommand(ctx, "xcrun", "simctl", "install", udid, appPath); err != nil { + return fmt.Errorf("failed to install companion app into simulator: %w", err) + } + launchOut, err := runCommand(ctx, "xcrun", "simctl", "launch", udid, bundleID) + if err != nil { + return fmt.Errorf("failed to launch companion app (%s): %w", bundleID, err) + } + if strings.TrimSpace(launchOut) != "" { + log.Printf("sim companion: %s", strings.TrimSpace(launchOut)) + } + + // Give both apps time to draw their first windows before window arrangement. + go func() { + time.Sleep(1300 * time.Millisecond) + _ = arrangeDesktopWithSimulator(desktopProcess) + time.Sleep(1800 * time.Millisecond) + _ = arrangeDesktopWithSimulator(desktopProcess) + }() + return nil +} + +func resolveSimulatorCompanionAppPath() string { + candidates := []string{} + if explicit := strings.TrimSpace(os.Getenv("PUNCTURE_IOS_SIM_APP")); explicit != "" { + candidates = append(candidates, explicit) + } + if exe, err := os.Executable(); err == nil { + candidates = append(candidates, + filepath.Clean(filepath.Join(filepath.Dir(exe), "..", "Resources", "EmergencyPuncture.app")), + filepath.Clean(filepath.Join(filepath.Dir(exe), "..", "..", "..", "ios", "build-ios", "Build", "Products", "Debug-iphonesimulator", "EmergencyPuncture.app")), + ) + } + if cwd, err := os.Getwd(); err == nil { + candidates = append(candidates, + filepath.Join(cwd, "ios", "build-ios", "Build", "Products", "Debug-iphonesimulator", "EmergencyPuncture.app"), + filepath.Join(cwd, "..", "ios", "build-ios", "Build", "Products", "Debug-iphonesimulator", "EmergencyPuncture.app"), + filepath.Join(cwd, "goapp", "ios", "build-ios", "Build", "Products", "Debug-iphonesimulator", "EmergencyPuncture.app"), + ) + } + seen := map[string]struct{}{} + for _, cand := range candidates { + if cand == "" { + continue + } + abs, err := filepath.Abs(cand) + if err == nil { + cand = abs + } + if _, ok := seen[cand]; ok { + continue + } + seen[cand] = struct{}{} + info, statErr := os.Stat(cand) + if statErr == nil && info.IsDir() { + return cand + } + } + return "" +} + +func pickSimulatorDevice(ctx context.Context, preferredName string) (string, string, error) { + out, err := runCommand(ctx, "xcrun", "simctl", "list", "devices", "available", "-j") + if err != nil { + return "", "", fmt.Errorf("failed to query simulator devices: %w", err) + } + var payload simctlDevicesPayload + if err := json.Unmarshal([]byte(out), &payload); err != nil { + return "", "", fmt.Errorf("failed to parse simulator list: %w", err) + } + + type candidate struct { + Runtime string + Device simctlDevice + } + candidates := make([]candidate, 0, 24) + runtimes := make([]string, 0, len(payload.Devices)) + for runtime := range payload.Devices { + if strings.Contains(strings.ToLower(runtime), "ios") { + runtimes = append(runtimes, runtime) + } + } + sort.Slice(runtimes, func(i, j int) bool { return runtimes[i] > runtimes[j] }) + for _, runtime := range runtimes { + devs := append([]simctlDevice(nil), payload.Devices[runtime]...) + sort.Slice(devs, func(i, j int) bool { + if devs[i].State == devs[j].State { + return devs[i].Name < devs[j].Name + } + return devs[i].State == "Booted" + }) + for _, dev := range devs { + if !dev.IsAvailable || dev.UDID == "" || dev.Name == "" { + continue + } + candidates = append(candidates, candidate{Runtime: runtime, Device: dev}) + } + } + if len(candidates) == 0 { + return "", "", errors.New("no available iOS simulator device found") + } + + preferredName = strings.TrimSpace(preferredName) + lowerPreferred := strings.ToLower(preferredName) + pick := func(match func(candidate) bool) (candidate, bool) { + for _, c := range candidates { + if match(c) { + return c, true + } + } + return candidate{}, false + } + + if preferredName != "" { + if c, ok := pick(func(c candidate) bool { + return strings.EqualFold(c.Device.Name, preferredName) && strings.EqualFold(c.Device.State, "Booted") + }); ok { + return c.Device.UDID, c.Device.Name, nil + } + if c, ok := pick(func(c candidate) bool { + return strings.EqualFold(c.Device.Name, preferredName) + }); ok { + return c.Device.UDID, c.Device.Name, nil + } + if c, ok := pick(func(c candidate) bool { + return strings.Contains(strings.ToLower(c.Device.Name), lowerPreferred) + }); ok { + return c.Device.UDID, c.Device.Name, nil + } + } + if c, ok := pick(func(c candidate) bool { + return strings.EqualFold(c.Device.State, "Booted") && strings.Contains(strings.ToLower(c.Device.Name), "iphone") + }); ok { + return c.Device.UDID, c.Device.Name, nil + } + if c, ok := pick(func(c candidate) bool { + return strings.Contains(strings.ToLower(c.Device.Name), "iphone") + }); ok { + return c.Device.UDID, c.Device.Name, nil + } + return candidates[0].Device.UDID, candidates[0].Device.Name, nil +} + +func arrangeDesktopWithSimulator(desktopProcess string) error { + desktopProcess = strings.TrimSpace(desktopProcess) + if desktopProcess == "" { + desktopProcess = "Puncture" + } + desktopProcess = strings.ReplaceAll(desktopProcess, `"`, "") + script := fmt.Sprintf(` +set topInset to 28 +tell application "Finder" + set b to bounds of window of desktop +end tell +set screenW to item 3 of b +set screenH to item 4 of b +set leftW to (screenW * 62 / 100) +tell application "System Events" + if exists process "%[1]s" then + tell process "%[1]s" + if (count of windows) > 0 then + set position of window 1 to {0, topInset} + set size of window 1 to {leftW, screenH - topInset} + end if + end tell + end if + if exists process "Simulator" then + tell process "Simulator" + if (count of windows) > 0 then + set position of window 1 to {leftW, topInset} + set size of window 1 to {screenW - leftW, screenH - topInset} + end if + end tell + end if + if exists process "%[1]s" then + set frontmost of process "%[1]s" to true + end if +end tell +`, desktopProcess) + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + if _, err := runCommand(ctx, "osascript", "-e", script); err != nil { + log.Printf("sim companion: could not auto-arrange windows (%v)", err) + } + return nil +} + +func runCommand(ctx context.Context, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + out, err := cmd.CombinedOutput() + text := strings.TrimSpace(string(out)) + if err != nil { + if text == "" { + return "", err + } + return text, fmt.Errorf("%w: %s", err, text) + } + return text, nil +} + +func envBool(key string, fallback bool) bool { + v := strings.TrimSpace(strings.ToLower(os.Getenv(key))) + if v == "" { + return fallback + } + switch v { + case "1", "true", "yes", "y", "on": + return true + case "0", "false", "no", "n", "off": + return false + default: + return fallback + } +} diff --git a/goapp/cmd/server/main.go b/goapp/cmd/server/main.go new file mode 100644 index 0000000..bd6fe2e --- /dev/null +++ b/goapp/cmd/server/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "puncture-go/internal/server" +) + +func main() { + host := flag.String("host", getenv("PUNCTURE_HOST", "0.0.0.0"), "bind host") + port := flag.Int("port", getenvInt("PUNCTURE_PORT", 9122), "bind port") + assetRoot := flag.String("asset-root", getenv("PUNCTURE_ASSET_ROOT", "./assets"), "asset root directory") + flag.Parse() + + addr := server.ParseAddr(*host, *port) + log.Printf("starting puncture-go server on %s", addr) + if err := server.Run(addr, *assetRoot); err != nil { + log.Fatalf("server failed: %v", err) + } +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func getenvInt(key string, fallback int) int { + v := os.Getenv(key) + if v == "" { + return fallback + } + var parsed int + if _, err := fmt.Sscanf(v, "%d", &parsed); err != nil { + return fallback + } + return parsed +} diff --git a/goapp/go.mod b/goapp/go.mod new file mode 100644 index 0000000..23a9bd0 --- /dev/null +++ b/goapp/go.mod @@ -0,0 +1,5 @@ +module puncture-go + +go 1.25 + +require github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 diff --git a/goapp/go.sum b/goapp/go.sum new file mode 100644 index 0000000..2421e19 --- /dev/null +++ b/goapp/go.sum @@ -0,0 +1,2 @@ +github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0= +github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk= diff --git a/goapp/internal/app/state.go b/goapp/internal/app/state.go new file mode 100644 index 0000000..7410049 --- /dev/null +++ b/goapp/internal/app/state.go @@ -0,0 +1,1631 @@ +package app + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + ggm "puncture-go/internal/crypto" +) + +const ( + encMagic = "PKE1" + encNonceSize = 16 + encTagSize = 32 + treeDepth = 7 +) + +type Provider struct { + ProviderID int `json:"provider_id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` +} + +type DeletedProvider struct { + ProviderID int `json:"provider_id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + DeletedAt string `json:"deleted_at"` + Applied bool `json:"applied"` +} + +type KeyJournalEntry struct { + ProviderID int `json:"provider_id"` + FileTimeID int `json:"file_time_id"` + Path string `json:"path"` + PathProvider string `json:"path_provider"` + PathResource string `json:"path_resource"` + Description string `json:"description"` + EverDerived bool `json:"ever_derived"` + EverPunctured bool `json:"ever_punctured"` + DeriveCount int `json:"derive_count"` + PunctureCount int `json:"puncture_count"` + LastDerivedAt string `json:"last_derived_at,omitempty"` + LastPuncturedAt string `json:"last_punctured_at,omitempty"` +} + +type AssetRecord struct { + RecordID int `json:"record_id"` + PlaintextRelpath string `json:"plaintext_relpath"` + CiphertextRelpath string `json:"ciphertext_relpath"` + ProviderID int `json:"provider_id"` + FileTimeID int `json:"file_time_id"` + Path string `json:"path"` + Purpose string `json:"purpose"` + CreatedAt string `json:"created_at"` + PlaintextSize int `json:"plaintext_size"` + CiphertextSize int `json:"ciphertext_size"` + DecryptCount int `json:"decrypt_count"` + LastDecryptedAt string `json:"last_decrypted_at,omitempty"` + LastDecryptedRel string `json:"last_decrypted_relpath,omitempty"` +} + +type LastAction struct { + Tone string `json:"tone"` + Title string `json:"title"` + Body string `json:"body"` + ProviderID *int `json:"provider_id"` + FileTimeID *int `json:"file_time_id"` + Path string `json:"path,omitempty"` + PathProvider string `json:"path_provider,omitempty"` + PathResource string `json:"path_resource,omitempty"` + KeyHex string `json:"key_hex,omitempty"` + KeyDesc string `json:"key_description,omitempty"` +} + +type HistoryItem struct { + Time string `json:"time"` + Action string `json:"action"` + Status string `json:"status"` + Summary string `json:"summary"` + ProviderID *int `json:"provider_id"` + FileTimeID *int `json:"file_time_id"` + Path string `json:"path,omitempty"` +} + +type LastPunctureDiff struct { + Time string `json:"time"` + Target string `json:"target"` + TargetKind string `json:"target_kind"` + Removed []string `json:"removed"` + Added []string `json:"added"` +} + +type LastInputs struct { + ProviderID int `json:"provider_id"` + FileTimeID int `json:"file_time_id"` + Purpose string `json:"purpose"` +} + +type Notice struct { + Tone string `json:"tone"` + Message string `json:"message"` +} + +type TreeViz struct { + Depth int `json:"depth"` + CurrentFrontierCount int `json:"current_frontier_count"` + BlockedCount int `json:"blocked_count"` + RemovedCount int `json:"removed_count"` + LastPuncture *LastPunctureDiff `json:"last_puncture,omitempty"` + SVG string `json:"svg"` +} + +type AppState struct { + mu sync.RWMutex + + Manager *ggm.Manager + + Providers map[int]Provider + DeletedProviders []DeletedProvider + KeyJournal map[string]*KeyJournalEntry + AssetRecords []*AssetRecord + AssetRoot string + StateFile string + + LastInputs LastInputs + LastAction LastAction + History []HistoryItem + LastPunctureDiff *LastPunctureDiff + + ProvidersNotice *Notice + AssetNotice *Notice +} + +type persistedState struct { + Version int `json:"version"` + Manager ggm.ExportState `json:"manager"` + Providers map[int]Provider `json:"providers"` + DeletedProviders []DeletedProvider `json:"deleted_providers"` + KeyJournal map[string]*KeyJournalEntry `json:"key_journal"` + AssetRecords []*AssetRecord `json:"asset_records"` + History []HistoryItem `json:"history"` + LastAction LastAction `json:"last_action"` + LastInputs LastInputs `json:"last_inputs"` + LastPunctureDiff *LastPunctureDiff `json:"last_puncture_diff"` +} + +func nowLabel() string { + return time.Now().UTC().Format("15:04:05 UTC") +} + +func defaultProviders() map[int]Provider { + now := nowLabel() + return map[int]Provider{ + 42: {ProviderID: 42, Name: "Provider 42 (Demo)", Description: "Default provider used in Scenario A walkthrough.", CreatedAt: now}, + 17: {ProviderID: 17, Name: "Northwind Cloud", Description: "Example provider entry.", CreatedAt: now}, + 88: {ProviderID: 88, Name: "Blue Harbor Storage", Description: "Example provider entry.", CreatedAt: now}, + } +} + +func defaultStateFilePath(assetRoot string) string { + if v := strings.TrimSpace(os.Getenv("PUNCTURE_STATE_FILE")); v != "" { + if abs, err := filepath.Abs(v); err == nil { + return abs + } + return v + } + if filepath.Base(assetRoot) == "assets" { + return filepath.Join(filepath.Dir(assetRoot), "state.json") + } + return filepath.Join(assetRoot, ".puncture-state.json") +} + +func sanitizeAssetRecords(records []*AssetRecord) []*AssetRecord { + if records == nil { + return []*AssetRecord{} + } + out := make([]*AssetRecord, 0, len(records)) + nextID := 1 + used := map[int]struct{}{} + for _, rec := range records { + if rec == nil { + continue + } + if rec.RecordID <= 0 { + rec.RecordID = nextID + } + for { + if _, exists := used[rec.RecordID]; !exists { + break + } + rec.RecordID++ + } + used[rec.RecordID] = struct{}{} + if rec.RecordID >= nextID { + nextID = rec.RecordID + 1 + } + out = append(out, rec) + } + sort.Slice(out, func(i, j int) bool { return out[i].RecordID < out[j].RecordID }) + return out +} + +func NewAppState(assetRoot string) (*AppState, error) { + if assetRoot == "" { + assetRoot = filepath.Join(".", "assets") + } + absRoot, err := filepath.Abs(assetRoot) + if err != nil { + return nil, err + } + if err := os.MkdirAll(absRoot, 0o755); err != nil { + return nil, err + } + stateFile := defaultStateFilePath(absRoot) + seed, err := ggm.GenerateMasterSeed() + if err != nil { + return nil, err + } + mgr, err := ggm.NewManager(seed) + if err != nil { + return nil, err + } + s := &AppState{ + Manager: mgr, + Providers: defaultProviders(), + DeletedProviders: []DeletedProvider{}, + KeyJournal: map[string]*KeyJournalEntry{}, + AssetRecords: []*AssetRecord{}, + AssetRoot: absRoot, + StateFile: stateFile, + LastInputs: LastInputs{ProviderID: 42, FileTimeID: 123456, Purpose: "Demo key for provider onboarding"}, + LastAction: LastAction{Tone: "info", Title: "Welcome", Body: "Derive a key and puncture it to observe forward secrecy."}, + History: []HistoryItem{}, + LastPunctureDiff: nil, + ProvidersNotice: nil, + AssetNotice: nil, + } + if err := s.loadPersistedStateLocked(); err != nil { + if _, statErr := os.Stat(s.StateFile); statErr == nil { + backup := s.StateFile + ".corrupt." + time.Now().UTC().Format("20060102T150405") + _ = os.Rename(s.StateFile, backup) + s.LastAction = LastAction{ + Tone: "warn", + Title: "State recovered", + Body: fmt.Sprintf("Persisted state was invalid and moved to %s; using fresh state.", filepath.Base(backup)), + } + } + } + s.persistLockedNoFail() + return s, nil +} + +func ptrInt(v int) *int { return &v } + +func (s *AppState) setLastAction(a LastAction) { + s.LastAction = a +} + +func (s *AppState) recordHistory(action, status, summary string, providerID, fileTimeID *int, path string) { + s.History = append([]HistoryItem{{ + Time: nowLabel(), + Action: action, + Status: status, + Summary: summary, + ProviderID: providerID, + FileTimeID: fileTimeID, + Path: path, + }}, s.History...) + if len(s.History) > 40 { + s.History = s.History[:40] + } +} + +func (s *AppState) persistedPayloadLocked() persistedState { + return persistedState{ + Version: 1, + Manager: s.Manager.ExportState(), + Providers: s.Providers, + DeletedProviders: s.DeletedProviders, + KeyJournal: s.KeyJournal, + AssetRecords: s.AssetRecords, + History: s.History, + LastAction: s.LastAction, + LastInputs: s.LastInputs, + LastPunctureDiff: s.LastPunctureDiff, + } +} + +func (s *AppState) persistLocked() error { + if strings.TrimSpace(s.StateFile) == "" { + return nil + } + payload := s.persistedPayloadLocked() + blob, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(s.StateFile), 0o755); err != nil { + return err + } + tmp := s.StateFile + ".tmp" + if err := os.WriteFile(tmp, blob, 0o600); err != nil { + return err + } + return os.Rename(tmp, s.StateFile) +} + +func (s *AppState) persistLockedNoFail() { + if err := s.persistLocked(); err != nil { + fmt.Fprintf(os.Stderr, "puncture-go: failed to persist state: %v\n", err) + } +} + +func (s *AppState) applyPersistedStateLocked(p persistedState) error { + mgr, err := ggm.FromState(p.Manager) + if err != nil { + return err + } + s.Manager = mgr + + if p.Providers != nil { + s.Providers = p.Providers + } else { + s.Providers = defaultProviders() + } + if p.DeletedProviders != nil { + s.DeletedProviders = p.DeletedProviders + } else { + s.DeletedProviders = []DeletedProvider{} + } + if p.KeyJournal != nil { + s.KeyJournal = p.KeyJournal + } else { + s.KeyJournal = map[string]*KeyJournalEntry{} + } + s.AssetRecords = sanitizeAssetRecords(p.AssetRecords) + if p.History != nil { + s.History = p.History + } else { + s.History = []HistoryItem{} + } + if len(s.History) > 40 { + s.History = s.History[:40] + } + s.LastAction = p.LastAction + if strings.TrimSpace(s.LastAction.Title) == "" { + s.LastAction = LastAction{Tone: "info", Title: "Welcome", Body: "Derive a key and puncture it to observe forward secrecy."} + } + s.LastInputs = p.LastInputs + if s.LastInputs.ProviderID == 0 && s.LastInputs.FileTimeID == 0 && strings.TrimSpace(s.LastInputs.Purpose) == "" { + s.LastInputs = LastInputs{ProviderID: 42, FileTimeID: 123456, Purpose: "Demo key for provider onboarding"} + } + s.LastPunctureDiff = p.LastPunctureDiff + s.ProvidersNotice = nil + s.AssetNotice = nil + return nil +} + +func (s *AppState) loadPersistedStateLocked() error { + if strings.TrimSpace(s.StateFile) == "" { + return nil + } + blob, err := os.ReadFile(s.StateFile) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + var payload persistedState + if err := json.Unmarshal(blob, &payload); err != nil { + return err + } + if payload.Manager.ActiveNodes == nil && payload.Manager.PunctureLog == nil { + return errors.New("persisted state missing manager payload") + } + return s.applyPersistedStateLocked(payload) +} + +func splitPath(path string) (string, string) { + if len(path) < 7 { + return path, "" + } + return path[:7], path[7:] +} + +func (s *AppState) ensureKeyEntry(providerID, fileTimeID int, path string) *KeyJournalEntry { + entry, ok := s.KeyJournal[path] + if ok { + return entry + } + pp, pr := splitPath(path) + entry = &KeyJournalEntry{ + ProviderID: providerID, + FileTimeID: fileTimeID, + Path: path, + PathProvider: pp, + PathResource: pr, + } + s.KeyJournal[path] = entry + return entry +} + +func (s *AppState) touchKeyDerive(providerID, fileTimeID int, path, desc string) *KeyJournalEntry { + entry := s.ensureKeyEntry(providerID, fileTimeID, path) + if strings.TrimSpace(desc) != "" { + entry.Description = strings.TrimSpace(desc) + } + entry.EverDerived = true + entry.DeriveCount++ + entry.LastDerivedAt = nowLabel() + return entry +} + +func (s *AppState) touchKeyPuncture(providerID, fileTimeID int, path string, applied bool) *KeyJournalEntry { + entry := s.ensureKeyEntry(providerID, fileTimeID, path) + entry.EverPunctured = true + if applied { + entry.PunctureCount++ + } + entry.LastPuncturedAt = nowLabel() + return entry +} + +func sortedPrefixes(prefixes []string) []string { + out := append([]string(nil), prefixes...) + sort.Slice(out, func(i, j int) bool { + if len(out[i]) == len(out[j]) { + return out[i] < out[j] + } + return len(out[i]) < len(out[j]) + }) + return out +} + +func (s *AppState) setLastPunctureDiff(beforeFrontier, afterFrontier []string, target, kind string) { + beforeSet := map[string]struct{}{} + afterSet := map[string]struct{}{} + for _, p := range beforeFrontier { + beforeSet[p] = struct{}{} + } + for _, p := range afterFrontier { + afterSet[p] = struct{}{} + } + removed := make([]string, 0) + added := make([]string, 0) + for _, p := range beforeFrontier { + if _, ok := afterSet[p]; !ok { + removed = append(removed, p) + } + } + for _, p := range afterFrontier { + if _, ok := beforeSet[p]; !ok { + added = append(added, p) + } + } + s.LastPunctureDiff = &LastPunctureDiff{ + Time: nowLabel(), + Target: target, + TargetKind: kind, + Removed: sortedPrefixes(removed), + Added: sortedPrefixes(added), + } +} + +func normalizeRelPath(rel string) (string, error) { + rel = strings.TrimSpace(rel) + if rel == "" { + return "", errors.New("relative file path is required") + } + if filepath.IsAbs(rel) { + return "", errors.New("absolute paths are not allowed") + } + n := filepath.Clean(rel) + n = filepath.ToSlash(n) + if n == "." || strings.HasPrefix(n, "../") || n == ".." { + return "", errors.New("path traversal is not allowed") + } + return n, nil +} + +func assetAbsPath(root, rel string) (string, error) { + n, err := normalizeRelPath(rel) + if err != nil { + return "", err + } + abs := filepath.Clean(filepath.Join(root, filepath.FromSlash(n))) + if abs != root && !strings.HasPrefix(abs, root+string(os.PathSeparator)) { + return "", errors.New("file path escapes asset root") + } + return abs, nil +} + +func nextRelPath(root, desired string) (string, error) { + n, err := normalizeRelPath(desired) + if err != nil { + return "", err + } + dir := filepath.Dir(n) + if dir == "." { + dir = "" + } + base := filepath.Base(n) + ext := filepath.Ext(base) + stem := strings.TrimSuffix(base, ext) + for i := 1; ; i++ { + cand := base + if i > 1 { + cand = fmt.Sprintf("%s.v%d%s", stem, i, ext) + } + rel := cand + if dir != "" { + rel = filepath.ToSlash(filepath.Join(dir, cand)) + } + abs, err := assetAbsPath(root, rel) + if err != nil { + return "", err + } + if _, statErr := os.Stat(abs); errors.Is(statErr, os.ErrNotExist) { + return rel, nil + } + } +} + +func nextCiphertextRelPath(root, plaintextRel string, providerID, fileTimeID int) (string, error) { + n, err := normalizeRelPath(plaintextRel) + if err != nil { + return "", err + } + dir := filepath.Dir(n) + if dir == "." { + dir = "" + } + filename := filepath.Base(n) + stem := fmt.Sprintf("%s.enc.p%d.k%d", filename, providerID, fileTimeID) + for i := 1; ; i++ { + suffix := ".pke" + if i > 1 { + suffix = fmt.Sprintf(".v%d.pke", i) + } + cand := stem + suffix + rel := cand + if dir != "" { + rel = filepath.ToSlash(filepath.Join(dir, cand)) + } + abs, err := assetAbsPath(root, rel) + if err != nil { + return "", err + } + if _, statErr := os.Stat(abs); errors.Is(statErr, os.ErrNotExist) { + return rel, nil + } + } +} + +func nextDecryptedRelPath(root, plaintextRel string, providerID, fileTimeID int) (string, error) { + target := fmt.Sprintf("%s.dec.p%d.k%d", plaintextRel, providerID, fileTimeID) + return nextRelPath(root, target) +} + +func streamXOR(key, nonce, data []byte) []byte { + out := make([]byte, len(data)) + off := 0 + counter := uint64(0) + for off < len(data) { + mac := hmac.New(sha256.New, key) + mac.Write([]byte("ENC")) + mac.Write(nonce) + ctr := []byte{ + byte(counter >> 56), byte(counter >> 48), byte(counter >> 40), byte(counter >> 32), + byte(counter >> 24), byte(counter >> 16), byte(counter >> 8), byte(counter), + } + mac.Write(ctr) + block := mac.Sum(nil) + chunk := block + if len(data)-off < len(block) { + chunk = block[:len(data)-off] + } + for i := range chunk { + out[off+i] = data[off+i] ^ chunk[i] + } + off += len(chunk) + counter++ + } + return out +} + +func encryptBlob(key, plaintext []byte) ([]byte, error) { + nonce := make([]byte, encNonceSize) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + ciphertext := streamXOR(key, nonce, plaintext) + mac := hmac.New(sha256.New, key) + mac.Write([]byte("TAG")) + mac.Write(nonce) + mac.Write(ciphertext) + tag := mac.Sum(nil) + + blob := make([]byte, 0, len(encMagic)+len(nonce)+len(tag)+len(ciphertext)) + blob = append(blob, []byte(encMagic)...) + blob = append(blob, nonce...) + blob = append(blob, tag...) + blob = append(blob, ciphertext...) + return blob, nil +} + +func decryptBlob(key, blob []byte) ([]byte, error) { + min := len(encMagic) + encNonceSize + encTagSize + if len(blob) < min { + return nil, errors.New("ciphertext too short") + } + if string(blob[:len(encMagic)]) != encMagic { + return nil, errors.New("ciphertext header mismatch") + } + nonceStart := len(encMagic) + nonceEnd := nonceStart + encNonceSize + tagEnd := nonceEnd + encTagSize + nonce := blob[nonceStart:nonceEnd] + tag := blob[nonceEnd:tagEnd] + ciphertext := blob[tagEnd:] + + mac := hmac.New(sha256.New, key) + mac.Write([]byte("TAG")) + mac.Write(nonce) + mac.Write(ciphertext) + expected := mac.Sum(nil) + if !hmac.Equal(tag, expected) { + return nil, errors.New("ciphertext authentication failed") + } + return streamXOR(key, nonce, ciphertext), nil +} + +func (s *AppState) Derive(providerID, fileTimeID int, purpose string) error { + s.mu.Lock() + defer s.mu.Unlock() + defer s.persistLockedNoFail() + path, err := ggm.TagToBinaryPath(providerID, fileTimeID) + if err != nil { + return err + } + s.LastInputs = LastInputs{ProviderID: providerID, FileTimeID: fileTimeID, Purpose: purpose} + key, ok, err := s.Manager.GetKeyForTag(path) + if err != nil { + return err + } + if !ok { + s.setLastAction(LastAction{ + Tone: "warn", + Title: "Derive blocked", + Body: "Key is inaccessible due to prior puncture.", + ProviderID: ptrInt(providerID), + FileTimeID: ptrInt(fileTimeID), + Path: path, + PathProvider: path[:7], + PathResource: path[7:], + }) + s.recordHistory("derive", "void", fmt.Sprintf("Derive blocked for provider=%d,file=%d", providerID, fileTimeID), ptrInt(providerID), ptrInt(fileTimeID), path) + return nil + } + entry := s.touchKeyDerive(providerID, fileTimeID, path, purpose) + s.setLastAction(LastAction{ + Tone: "success", + Title: "Derive succeeded", + Body: "Key derivation succeeded.", + ProviderID: ptrInt(providerID), + FileTimeID: ptrInt(fileTimeID), + Path: path, + PathProvider: path[:7], + PathResource: path[7:], + KeyHex: fmt.Sprintf("%x", key), + KeyDesc: entry.Description, + }) + s.recordHistory("derive", "derived", fmt.Sprintf("Derived key for provider=%d,file=%d", providerID, fileTimeID), ptrInt(providerID), ptrInt(fileTimeID), path) + return nil +} + +func (s *AppState) Puncture(providerID, fileTimeID int) error { + s.mu.Lock() + defer s.mu.Unlock() + defer s.persistLockedNoFail() + path, err := ggm.TagToBinaryPath(providerID, fileTimeID) + if err != nil { + return err + } + before := s.Manager.ActivePrefixes() + applied, err := s.Manager.Puncture(path) + if err != nil { + return err + } + after := s.Manager.ActivePrefixes() + s.setLastPunctureDiff(before, after, path, "tag") + entry := s.touchKeyPuncture(providerID, fileTimeID, path, applied) + s.LastInputs = LastInputs{ProviderID: providerID, FileTimeID: fileTimeID, Purpose: s.LastInputs.Purpose} + if applied { + s.setLastAction(LastAction{Tone: "success", Title: "Puncture succeeded", Body: "Target tag is now permanently inaccessible.", ProviderID: ptrInt(providerID), FileTimeID: ptrInt(fileTimeID), Path: path, PathProvider: path[:7], PathResource: path[7:], KeyDesc: entry.Description}) + s.recordHistory("puncture", "applied", fmt.Sprintf("Punctured provider=%d,file=%d", providerID, fileTimeID), ptrInt(providerID), ptrInt(fileTimeID), path) + } else { + s.setLastAction(LastAction{Tone: "warn", Title: "Puncture no-op", Body: "Target was already inaccessible.", ProviderID: ptrInt(providerID), FileTimeID: ptrInt(fileTimeID), Path: path, PathProvider: path[:7], PathResource: path[7:], KeyDesc: entry.Description}) + s.recordHistory("puncture", "noop", fmt.Sprintf("No-op puncture provider=%d,file=%d", providerID, fileTimeID), ptrInt(providerID), ptrInt(fileTimeID), path) + } + return nil +} + +func (s *AppState) markProviderKeysPunctured(providerID int) int { + n := 0 + stamp := nowLabel() + for _, entry := range s.KeyJournal { + if entry.ProviderID != providerID { + continue + } + if !entry.EverPunctured { + entry.PunctureCount++ + } + entry.EverPunctured = true + entry.LastPuncturedAt = stamp + n++ + } + return n +} + +func (s *AppState) PunctureProvider(providerID int) error { + s.mu.Lock() + defer s.mu.Unlock() + defer s.persistLockedNoFail() + prefix, err := ggm.ProviderIDToPrefix(providerID) + if err != nil { + return err + } + before := s.Manager.ActivePrefixes() + applied, err := s.Manager.PunctureProvider(providerID) + if err != nil { + return err + } + after := s.Manager.ActivePrefixes() + s.setLastPunctureDiff(before, after, prefix, "provider-prefix") + touched := s.markProviderKeysPunctured(providerID) + s.setLastAction(LastAction{Tone: "warn", Title: "Provider prefix punctured", Body: fmt.Sprintf("Provider %d punctured; keys in subtree blocked (known=%d).", providerID, touched), ProviderID: ptrInt(providerID)}) + status := "already-inaccessible" + if applied { + status = "punctured" + } + s.recordHistory("provider-puncture", status, fmt.Sprintf("Provider %d prefix punctured", providerID), ptrInt(providerID), nil, prefix) + return nil +} + +func (s *AppState) AddProvider(providerID int, name, description string) error { + s.mu.Lock() + defer s.mu.Unlock() + defer s.persistLockedNoFail() + if strings.TrimSpace(name) == "" { + return errors.New("name is required") + } + if _, err := ggm.ProviderIDToPrefix(providerID); err != nil { + return err + } + if _, exists := s.Providers[providerID]; exists { + return fmt.Errorf("provider %d already exists", providerID) + } + s.Providers[providerID] = Provider{ProviderID: providerID, Name: strings.TrimSpace(name), Description: strings.TrimSpace(description), CreatedAt: nowLabel()} + s.ProvidersNotice = &Notice{Tone: "success", Message: fmt.Sprintf("Added provider %d", providerID)} + s.recordHistory("provider-add", "added", fmt.Sprintf("Added provider %d", providerID), ptrInt(providerID), nil, "") + return nil +} + +func (s *AppState) EditProvider(providerID int, name, description string) error { + s.mu.Lock() + defer s.mu.Unlock() + defer s.persistLockedNoFail() + p, ok := s.Providers[providerID] + if !ok { + return fmt.Errorf("provider %d does not exist", providerID) + } + if strings.TrimSpace(name) == "" { + return errors.New("name is required") + } + p.Name = strings.TrimSpace(name) + p.Description = strings.TrimSpace(description) + s.Providers[providerID] = p + s.ProvidersNotice = &Notice{Tone: "success", Message: fmt.Sprintf("Updated provider %d", providerID)} + s.recordHistory("provider-edit", "updated", fmt.Sprintf("Updated provider %d", providerID), ptrInt(providerID), nil, "") + return nil +} + +func (s *AppState) DeleteProvider(providerID int) error { + s.mu.Lock() + defer s.mu.Unlock() + defer s.persistLockedNoFail() + p, ok := s.Providers[providerID] + if !ok { + return fmt.Errorf("provider %d does not exist", providerID) + } + prefix, _ := ggm.ProviderIDToPrefix(providerID) + before := s.Manager.ActivePrefixes() + applied, err := s.Manager.PuncturePrefix(prefix) + if err != nil { + return err + } + after := s.Manager.ActivePrefixes() + s.setLastPunctureDiff(before, after, prefix, "provider-prefix") + known := s.markProviderKeysPunctured(providerID) + delete(s.Providers, providerID) + s.DeletedProviders = append([]DeletedProvider{{ProviderID: providerID, Name: p.Name, Prefix: prefix, DeletedAt: nowLabel(), Applied: applied}}, s.DeletedProviders...) + if len(s.DeletedProviders) > 32 { + s.DeletedProviders = s.DeletedProviders[:32] + } + s.ProvidersNotice = &Notice{Tone: "warn", Message: fmt.Sprintf("Deleted provider %d and punctured subtree (known keys marked=%d).", providerID, known)} + s.recordHistory("provider-delete", "punctured", fmt.Sprintf("Deleted provider %d", providerID), ptrInt(providerID), nil, prefix) + return nil +} + +func (s *AppState) SaveUploads(files []*multipart.FileHeader, targetSubdir string) ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + defer s.persistLockedNoFail() + prefix := "" + if strings.TrimSpace(targetSubdir) != "" { + n, err := normalizeRelPath(targetSubdir) + if err != nil { + return nil, err + } + prefix = strings.Trim(n, "/") + } + saved := []string{} + for _, header := range files { + if header == nil || header.Filename == "" { + continue + } + desired := filepath.ToSlash(header.Filename) + if prefix != "" { + desired = filepath.ToSlash(filepath.Join(prefix, filepath.Base(desired))) + } else { + desired = filepath.Base(desired) + } + finalRel, err := nextRelPath(s.AssetRoot, desired) + if err != nil { + return nil, err + } + abs, err := assetAbsPath(s.AssetRoot, finalRel) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + return nil, err + } + src, err := header.Open() + if err != nil { + return nil, err + } + dst, err := os.Create(abs) + if err != nil { + src.Close() + return nil, err + } + _, cpErr := io.Copy(dst, src) + _ = dst.Close() + _ = src.Close() + if cpErr != nil { + return nil, cpErr + } + saved = append(saved, finalRel) + } + if len(saved) == 0 { + return nil, errors.New("choose at least one file to upload") + } + s.AssetNotice = &Notice{Tone: "success", Message: fmt.Sprintf("Uploaded %d file(s).", len(saved))} + s.recordHistory("asset-upload", "uploaded", fmt.Sprintf("Uploaded %d file(s)", len(saved)), nil, nil, "") + return saved, nil +} + +func (s *AppState) Encrypt(plaintextRelpaths []string, providerID, fileTimeID int, purpose string) ([]AssetRecord, []string, error) { + s.mu.Lock() + defer s.mu.Unlock() + defer s.persistLockedNoFail() + if len(plaintextRelpaths) == 0 { + return nil, nil, errors.New("select at least one cleartext file") + } + path, err := ggm.TagToBinaryPath(providerID, fileTimeID) + if err != nil { + return nil, nil, err + } + key, ok, err := s.Manager.GetKeyForTag(path) + if err != nil { + return nil, nil, err + } + if !ok { + return nil, nil, errors.New("selected key is punctured/inaccessible") + } + s.LastInputs = LastInputs{ProviderID: providerID, FileTimeID: fileTimeID, Purpose: strings.TrimSpace(purpose)} + s.touchKeyDerive(providerID, fileTimeID, path, purpose) + + saved := []AssetRecord{} + errs := []string{} + for _, raw := range plaintextRelpaths { + rel, err := normalizeRelPath(raw) + if err != nil { + errs = append(errs, fmt.Sprintf("%s: %v", raw, err)) + continue + } + plainAbs, err := assetAbsPath(s.AssetRoot, rel) + if err != nil { + errs = append(errs, fmt.Sprintf("%s: %v", rel, err)) + continue + } + blob, readErr := os.ReadFile(plainAbs) + if readErr != nil { + errs = append(errs, fmt.Sprintf("%s: %v", rel, readErr)) + continue + } + enc, encErr := encryptBlob(key, blob) + if encErr != nil { + errs = append(errs, fmt.Sprintf("%s: %v", rel, encErr)) + continue + } + cipherRel, err := nextCiphertextRelPath(s.AssetRoot, rel, providerID, fileTimeID) + if err != nil { + errs = append(errs, fmt.Sprintf("%s: %v", rel, err)) + continue + } + cipherAbs, err := assetAbsPath(s.AssetRoot, cipherRel) + if err != nil { + errs = append(errs, fmt.Sprintf("%s: %v", rel, err)) + continue + } + if err := os.MkdirAll(filepath.Dir(cipherAbs), 0o755); err != nil { + errs = append(errs, fmt.Sprintf("%s: %v", rel, err)) + continue + } + if err := os.WriteFile(cipherAbs, enc, 0o644); err != nil { + errs = append(errs, fmt.Sprintf("%s: %v", rel, err)) + continue + } + rec := &AssetRecord{ + RecordID: len(s.AssetRecords) + 1, + PlaintextRelpath: rel, + CiphertextRelpath: cipherRel, + ProviderID: providerID, + FileTimeID: fileTimeID, + Path: path, + Purpose: strings.TrimSpace(purpose), + CreatedAt: nowLabel(), + PlaintextSize: len(blob), + CiphertextSize: len(enc), + } + s.AssetRecords = append(s.AssetRecords, rec) + saved = append(saved, *rec) + } + if len(saved) == 0 { + return nil, errs, errors.New("no file could be encrypted") + } + s.AssetNotice = &Notice{Tone: "success", Message: fmt.Sprintf("Encrypted %d file(s).", len(saved))} + s.recordHistory("asset-encrypt", "encrypted", fmt.Sprintf("Encrypted %d file(s) with provider=%d,key=%d", len(saved), providerID, fileTimeID), ptrInt(providerID), ptrInt(fileTimeID), path) + return saved, errs, nil +} + +func (s *AppState) Decrypt(recordIDs []int) ([]map[string]any, []string, error) { + s.mu.Lock() + defer s.mu.Unlock() + defer s.persistLockedNoFail() + if len(recordIDs) == 0 { + return nil, nil, errors.New("select at least one ciphertext mapping") + } + index := map[int]*AssetRecord{} + for _, rec := range s.AssetRecords { + index[rec.RecordID] = rec + } + restored := []map[string]any{} + errs := []string{} + for _, id := range recordIDs { + rec, ok := index[id] + if !ok { + errs = append(errs, fmt.Sprintf("record %d: not found", id)) + continue + } + key, keyOK, err := s.Manager.GetKeyForTag(rec.Path) + if err != nil || !keyOK { + errs = append(errs, fmt.Sprintf("record %d: key is punctured/inaccessible", id)) + continue + } + cipherAbs, err := assetAbsPath(s.AssetRoot, rec.CiphertextRelpath) + if err != nil { + errs = append(errs, fmt.Sprintf("record %d: %v", id, err)) + continue + } + blob, err := os.ReadFile(cipherAbs) + if err != nil { + errs = append(errs, fmt.Sprintf("record %d: %v", id, err)) + continue + } + plain, err := decryptBlob(key, blob) + if err != nil { + errs = append(errs, fmt.Sprintf("record %d: %v", id, err)) + continue + } + decRel, err := nextDecryptedRelPath(s.AssetRoot, rec.PlaintextRelpath, rec.ProviderID, rec.FileTimeID) + if err != nil { + errs = append(errs, fmt.Sprintf("record %d: %v", id, err)) + continue + } + decAbs, err := assetAbsPath(s.AssetRoot, decRel) + if err != nil { + errs = append(errs, fmt.Sprintf("record %d: %v", id, err)) + continue + } + if err := os.MkdirAll(filepath.Dir(decAbs), 0o755); err != nil { + errs = append(errs, fmt.Sprintf("record %d: %v", id, err)) + continue + } + if err := os.WriteFile(decAbs, plain, 0o644); err != nil { + errs = append(errs, fmt.Sprintf("record %d: %v", id, err)) + continue + } + rec.DecryptCount++ + rec.LastDecryptedAt = nowLabel() + rec.LastDecryptedRel = decRel + restored = append(restored, map[string]any{ + "record_id": id, + "ciphertext_relpath": rec.CiphertextRelpath, + "decrypted_relpath": decRel, + }) + } + if len(restored) == 0 { + return nil, errs, errors.New("no ciphertext could be decrypted") + } + s.AssetNotice = &Notice{Tone: "success", Message: fmt.Sprintf("Decrypted %d mapping(s).", len(restored))} + s.recordHistory("asset-decrypt", "decrypted", fmt.Sprintf("Decrypted %d mapping(s)", len(restored)), nil, nil, "") + return restored, errs, nil +} + +func (s *AppState) listPlaintextRows() []map[string]any { + rows := []map[string]any{} + _ = filepath.Walk(s.AssetRoot, func(path string, info os.FileInfo, err error) error { + if err != nil || info == nil { + return nil + } + if info.IsDir() { + if strings.HasPrefix(info.Name(), ".") { + return filepath.SkipDir + } + return nil + } + if strings.HasSuffix(info.Name(), ".pke") { + return nil + } + rel, relErr := filepath.Rel(s.AssetRoot, path) + if relErr != nil { + return nil + } + rel = filepath.ToSlash(rel) + rows = append(rows, map[string]any{ + "relpath": rel, + "size_bytes": info.Size(), + "size_label": formatBytes(info.Size()), + "modified_at": info.ModTime().UTC().Format("2006-01-02 15:04 UTC"), + }) + return nil + }) + sort.Slice(rows, func(i, j int) bool { return rows[i]["relpath"].(string) < rows[j]["relpath"].(string) }) + return rows +} + +func formatBytes(n int64) string { + units := []string{"B", "KB", "MB", "GB"} + value := float64(n) + idx := 0 + for value >= 1024 && idx < len(units)-1 { + value /= 1024 + idx++ + } + if idx == 0 { + return fmt.Sprintf("%d %s", int(value), units[idx]) + } + return fmt.Sprintf("%.1f %s", value, units[idx]) +} + +func lifecycleState(mappingCount, blockedCount int) string { + if mappingCount <= 0 { + return "eligible" + } + if blockedCount <= 0 { + return "encrypted_live" + } + if blockedCount < mappingCount { + return "encrypted_partial" + } + return "encrypted_blocked" +} + +func lifecycleLabel(state string) string { + switch state { + case "eligible": + return "Eligible" + case "encrypted_live": + return "Encrypted (live)" + case "encrypted_partial": + return "Encrypted (partially blocked)" + case "encrypted_blocked": + return "Encrypted (fully blocked)" + default: + return state + } +} + +func providerRows(providers map[int]Provider, journal map[string]*KeyJournalEntry) []map[string]any { + ids := make([]int, 0, len(providers)) + for id := range providers { + ids = append(ids, id) + } + sort.Ints(ids) + rows := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + p := providers[id] + prefix, _ := ggm.ProviderIDToPrefix(id) + keys := []map[string]any{} + for _, e := range journal { + if e.ProviderID != id { + continue + } + keys = append(keys, map[string]any{ + "provider_id": e.ProviderID, + "file_time_id": e.FileTimeID, + "path": e.Path, + "path_provider": e.PathProvider, + "path_resource": e.PathResource, + "description": e.Description, + "ever_derived": e.EverDerived, + "ever_punctured": e.EverPunctured, + "derive_count": e.DeriveCount, + "puncture_count": e.PunctureCount, + "last_derived_at": e.LastDerivedAt, + "last_punctured_at": e.LastPuncturedAt, + }) + } + sort.Slice(keys, func(i, j int) bool { return keys[i]["file_time_id"].(int) < keys[j]["file_time_id"].(int) }) + derivedIDs := []int{} + puncturedIDs := []int{} + for _, k := range keys { + if k["ever_derived"].(bool) { + derivedIDs = append(derivedIDs, k["file_time_id"].(int)) + } + if k["ever_punctured"].(bool) { + puncturedIDs = append(puncturedIDs, k["file_time_id"].(int)) + } + } + rows = append(rows, map[string]any{ + "provider_id": p.ProviderID, + "name": p.Name, + "description": p.Description, + "created_at": p.CreatedAt, + "prefix": prefix, + "key_rows": keys, + "key_count": len(keys), + "derived_count": len(derivedIDs), + "punctured_count": len(puncturedIDs), + "derived_ids": derivedIDs, + "punctured_ids": puncturedIDs, + }) + } + return rows +} + +func prefixIntersectsActive(prefix string, active []string) bool { + for _, frontier := range active { + if strings.HasPrefix(frontier, prefix) || strings.HasPrefix(prefix, frontier) { + return true + } + } + return false +} + +func nodeX(prefix string, depth int, slotWidth, margin float64) float64 { + if prefix == "" { + leaf := 1 << depth + return margin + (float64(leaf)*slotWidth)/2 + } + idx, _ := parseBinary(prefix) + left := idx * (1 << (depth - len(prefix))) + span := 1 << (depth - len(prefix)) + center := float64(left) + float64(span)/2 + return margin + center*slotWidth +} + +func parseBinary(s string) (int, error) { + v := 0 + for _, c := range s { + v <<= 1 + if c == '1' { + v |= 1 + } + } + return v, nil +} + +func (s *AppState) treeVizLocked() TreeViz { + depth := treeDepth + active := s.Manager.ActivePrefixes() + derivedPrefixes := map[string]struct{}{} + for _, entry := range s.KeyJournal { + if !entry.EverDerived { + continue + } + stop := len(entry.Path) + if stop > depth { + stop = depth + } + for d := 0; d <= stop; d++ { + derivedPrefixes[entry.Path[:d]] = struct{}{} + } + } + frontierExact := map[string]struct{}{} + frontierProxy := map[string]struct{}{} + for _, p := range active { + if len(p) <= depth { + frontierExact[p] = struct{}{} + } else { + frontierProxy[p[:depth]] = struct{}{} + } + } + + removedExact := map[string]struct{}{} + removedProxy := map[string]struct{}{} + if s.LastPunctureDiff != nil { + for _, p := range s.LastPunctureDiff.Removed { + if len(p) <= depth { + removedExact[p] = struct{}{} + } else { + removedProxy[p[:depth]] = struct{}{} + } + } + } + + type node struct { + Prefix string + Status string + } + statuses := map[string]string{} + for level := 0; level <= depth; level++ { + for idx := 0; idx < (1 << level); idx++ { + prefix := "" + if level > 0 { + prefix = fmt.Sprintf("%0*b", level, idx) + } + possible := prefixIntersectsActive(prefix, active) + status := "possible" + if _, ok := removedExact[prefix]; ok { + status = "removed" + } else if level == depth { + if _, ok := removedProxy[prefix]; ok { + status = "removed_proxy" + } + } + if status == "possible" { + if _, ok := frontierExact[prefix]; ok { + status = "frontier" + } else if level == depth { + if _, ok := frontierProxy[prefix]; ok { + status = "frontier_proxy" + } + } + } + if status == "possible" { + if !possible { + status = "blocked" + } else if _, ok := derivedPrefixes[prefix]; ok { + status = "derived" + } + } + statuses[prefix] = status + } + } + + slotWidth := 22.0 + levelHeight := 86.0 + margin := 26.0 + marginTop := 34.0 + leafSlots := 1 << depth + width := int(margin*2 + float64(leafSlots)*slotWidth) + height := int(marginTop + float64(depth)*levelHeight + 64) + + edges := strings.Builder{} + for level := 0; level < depth; level++ { + for idx := 0; idx < (1 << level); idx++ { + parent := "" + if level > 0 { + parent = fmt.Sprintf("%0*b", level, idx) + } + px := nodeX(parent, depth, slotWidth, margin) + py := marginTop + float64(level)*levelHeight + for _, bit := range []string{"0", "1"} { + child := parent + bit + cx := nodeX(child, depth, slotWidth, margin) + cy := marginTop + float64(level+1)*levelHeight + edgeClass := "edge-live" + if statuses[child] == "blocked" { + edgeClass = "edge-blocked" + } + if strings.HasPrefix(statuses[child], "removed") { + edgeClass = "edge-removed" + } + edges.WriteString(fmt.Sprintf(``, edgeClass, px, py, cx, cy)) + } + } + } + + nodes := strings.Builder{} + currentFrontier := 0 + blockedCount := 0 + removedCount := 0 + for level := 0; level <= depth; level++ { + radius := 7.5 + if level == 0 { + radius = 10 + } + for idx := 0; idx < (1 << level); idx++ { + prefix := "" + if level > 0 { + prefix = fmt.Sprintf("%0*b", level, idx) + } + x := nodeX(prefix, depth, slotWidth, margin) + y := marginTop + float64(level)*levelHeight + status := statuses[prefix] + if status == "frontier" || status == "frontier_proxy" { + currentFrontier++ + } + if status == "blocked" { + blockedCount++ + } + if strings.HasPrefix(status, "removed") { + removedCount++ + } + title := "seed root" + if prefix != "" { + title = "prefix " + prefix + } + nodes.WriteString(fmt.Sprintf(`%s`, status, x, y, radius, title)) + } + } + + svg := fmt.Sprintf(`%s%s`, width, height, width, height, edges.String(), nodes.String()) + + return TreeViz{Depth: depth, CurrentFrontierCount: currentFrontier, BlockedCount: blockedCount, RemovedCount: removedCount, LastPuncture: s.LastPunctureDiff, SVG: svg} +} + +func (s *AppState) snapshotLocked() map[string]any { + providerRows := providerRows(s.Providers, s.KeyJournal) + journalRows := make([]map[string]any, 0, len(s.KeyJournal)) + for _, entry := range s.KeyJournal { + journalRows = append(journalRows, map[string]any{ + "provider_id": entry.ProviderID, + "file_time_id": entry.FileTimeID, + "path": entry.Path, + "path_provider": entry.PathProvider, + "path_resource": entry.PathResource, + "description": entry.Description, + "ever_derived": entry.EverDerived, + "ever_punctured": entry.EverPunctured, + "derive_count": entry.DeriveCount, + "puncture_count": entry.PunctureCount, + "last_derived_at": entry.LastDerivedAt, + "last_punctured_at": entry.LastPuncturedAt, + }) + } + sort.Slice(journalRows, func(i, j int) bool { + if journalRows[i]["provider_id"].(int) == journalRows[j]["provider_id"].(int) { + return journalRows[i]["file_time_id"].(int) < journalRows[j]["file_time_id"].(int) + } + return journalRows[i]["provider_id"].(int) < journalRows[j]["provider_id"].(int) + }) + + recordRows := []map[string]any{} + for _, rec := range s.AssetRecords { + _, keyOK, _ := s.Manager.GetKeyForTag(rec.Path) + pp, pr := splitPath(rec.Path) + recordRows = append(recordRows, map[string]any{ + "record_id": rec.RecordID, + "plaintext_relpath": rec.PlaintextRelpath, + "ciphertext_relpath": rec.CiphertextRelpath, + "provider_id": rec.ProviderID, + "file_time_id": rec.FileTimeID, + "path": rec.Path, + "path_provider": pp, + "path_resource": pr, + "purpose": rec.Purpose, + "created_at": rec.CreatedAt, + "plaintext_size": rec.PlaintextSize, + "ciphertext_size": rec.CiphertextSize, + "is_accessible": keyOK, + "show_red": !keyOK, + "show_glow": false, + "decrypt_count": rec.DecryptCount, + "last_decrypted_at": rec.LastDecryptedAt, + "last_decrypted_relpath": rec.LastDecryptedRel, + }) + } + + assetFiles := []map[string]any{} + keyMap := map[string]map[string]any{} + groupByPlain := map[string][]map[string]any{} + for _, row := range recordRows { + plain := row["plaintext_relpath"].(string) + groupByPlain[plain] = append(groupByPlain[plain], row) + + k := fmt.Sprintf("%d:%d:%s", row["provider_id"].(int), row["file_time_id"].(int), row["path"].(string)) + bucket, ok := keyMap[k] + if !ok { + bucket = map[string]any{ + "provider_id": row["provider_id"], + "file_time_id": row["file_time_id"], + "path": row["path"], + "path_provider": row["path_provider"], + "path_resource": row["path_resource"], + "files": []string{}, + "is_accessible": row["is_accessible"], + } + keyMap[k] = bucket + } + bucket["files"] = append(bucket["files"].([]string), plain) + bucket["is_accessible"] = bucket["is_accessible"].(bool) && row["is_accessible"].(bool) + } + + blockedTotal := 0 + glowTotal := 0 + for plain, mappings := range groupByPlain { + sort.Slice(mappings, func(i, j int) bool { + return mappings[i]["created_at"].(string) < mappings[j]["created_at"].(string) + }) + blocked := 0 + for _, m := range mappings { + if !m["is_accessible"].(bool) { + blocked++ + blockedTotal++ + } + } + if blocked > 0 { + for _, m := range mappings { + if m["is_accessible"].(bool) { + m["show_glow"] = true + glowTotal++ + } + } + } + assetFiles = append(assetFiles, map[string]any{ + "plaintext_relpath": plain, + "mapping_count": len(mappings), + "blocked_count": blocked, + "mappings": mappings, + }) + } + sort.Slice(assetFiles, func(i, j int) bool { + return assetFiles[i]["plaintext_relpath"].(string) < assetFiles[j]["plaintext_relpath"].(string) + }) + + keyCards := []map[string]any{} + for _, bucket := range keyMap { + files := bucket["files"].([]string) + sort.Strings(files) + keyCards = append(keyCards, map[string]any{ + "provider_id": bucket["provider_id"], + "file_time_id": bucket["file_time_id"], + "path": bucket["path"], + "path_provider": bucket["path_provider"], + "path_resource": bucket["path_resource"], + "file_count": len(files), + "files": files, + "is_accessible": bucket["is_accessible"], + }) + } + sort.Slice(keyCards, func(i, j int) bool { + if keyCards[i]["provider_id"].(int) == keyCards[j]["provider_id"].(int) { + return keyCards[i]["file_time_id"].(int) < keyCards[j]["file_time_id"].(int) + } + return keyCards[i]["provider_id"].(int) < keyCards[j]["provider_id"].(int) + }) + + plainRows := s.listPlaintextRows() + mappedByRel := map[string]map[string]any{} + for _, af := range assetFiles { + mappedByRel[af["plaintext_relpath"].(string)] = af + } + fileRows := []map[string]any{} + for _, row := range plainRows { + rel := row["relpath"].(string) + mappingCount := 0 + blockedCount := 0 + if m, ok := mappedByRel[rel]; ok { + mappingCount = m["mapping_count"].(int) + blockedCount = m["blocked_count"].(int) + } + state := lifecycleState(mappingCount, blockedCount) + row["mapping_count"] = mappingCount + row["blocked_count"] = blockedCount + row["lifecycle_state"] = state + row["lifecycle_label"] = lifecycleLabel(state) + fileRows = append(fileRows, row) + } + + combo := []map[string]any{} + for _, row := range journalRows { + status := "active" + if row["ever_punctured"].(bool) { + status = "blocked" + } + combo = append(combo, map[string]any{ + "provider_id": row["provider_id"], + "file_time_id": row["file_time_id"], + "status": status, + "label": fmt.Sprintf("Provider %d | Key %d | %s", row["provider_id"].(int), row["file_time_id"].(int), status), + }) + } + + activePrefixes := s.Manager.ActivePrefixes() + tree := s.treeVizLocked() + + return map[string]any{ + "generated_at": nowLabel(), + "active_nodes": s.Manager.ActiveNodeCount(), + "active_prefixes": activePrefixes, + "puncture_log": s.Manager.PunctureLog(), + "last_puncture_diff": s.LastPunctureDiff, + "last_action": s.LastAction, + "history": s.History, + "providers": providerRows, + "deleted_providers": s.DeletedProviders, + "key_journal": journalRows, + "asset_root": s.AssetRoot, + "state_file": s.StateFile, + "tree_viz": tree, + "assets": map[string]any{ + "mapping_count": len(recordRows), + "blocked_count": blockedTotal, + "glow_count": glowTotal, + "asset_files": assetFiles, + "key_cards": keyCards, + }, + "workflow": map[string]any{ + "asset_root": s.AssetRoot, + "stats": map[string]any{ + "cleartext_count": len(fileRows), + "mapping_count": len(recordRows), + "blocked_count": blockedTotal, + "glow_count": glowTotal, + }, + "files": fileRows, + "providers": simpleProviders(providerRows), + "key_combo_options": combo, + "last_inputs": s.LastInputs, + "asset_files": assetFiles, + "key_cards": keyCards, + }, + } +} + +func simpleProviders(rows []map[string]any) []map[string]any { + out := make([]map[string]any, 0, len(rows)) + for _, p := range rows { + out = append(out, map[string]any{"provider_id": p["provider_id"], "name": p["name"]}) + } + return out +} + +func (s *AppState) Snapshot() map[string]any { + s.mu.RLock() + defer s.mu.RUnlock() + return s.snapshotLocked() +} + +func (s *AppState) ExportStateJSON() ([]byte, error) { + s.mu.RLock() + defer s.mu.RUnlock() + payload := s.persistedPayloadLocked() + top := map[string]any{ + "version": payload.Version, + "manager": payload.Manager, + "providers": payload.Providers, + "deleted_providers": payload.DeletedProviders, + "key_journal": payload.KeyJournal, + "asset_records": payload.AssetRecords, + "asset_root": s.AssetRoot, + "state_file": s.StateFile, + "history": payload.History, + "last_action": payload.LastAction, + "last_inputs": payload.LastInputs, + "last_puncture_diff": payload.LastPunctureDiff, + } + return json.MarshalIndent(top, "", " ") +} + +func (s *AppState) Reset() error { + s.mu.Lock() + defer s.mu.Unlock() + defer s.persistLockedNoFail() + seed, err := ggm.GenerateMasterSeed() + if err != nil { + return err + } + mgr, err := ggm.NewManager(seed) + if err != nil { + return err + } + s.Manager = mgr + s.Providers = defaultProviders() + s.DeletedProviders = []DeletedProvider{} + s.KeyJournal = map[string]*KeyJournalEntry{} + s.AssetRecords = []*AssetRecord{} + s.LastInputs = LastInputs{ProviderID: 42, FileTimeID: 123456, Purpose: "Demo key for provider onboarding"} + s.LastAction = LastAction{Tone: "info", Title: "Reset complete", Body: "Lab was reset with fresh root state."} + s.History = []HistoryItem{} + s.LastPunctureDiff = nil + s.recordHistory("system", "reset", "Lab was reset with fresh root state.", nil, nil, "") + return nil +} + +func (s *AppState) RemoteTokenValid(supplied, configured string) bool { + if strings.TrimSpace(configured) == "" { + return true + } + return hmac.Equal([]byte(supplied), []byte(configured)) +} diff --git a/goapp/internal/app/state_test.go b/goapp/internal/app/state_test.go new file mode 100644 index 0000000..4a1cc4d --- /dev/null +++ b/goapp/internal/app/state_test.go @@ -0,0 +1,126 @@ +package app + +import ( + "os" + "path/filepath" + "testing" + + ggm "puncture-go/internal/crypto" +) + +func TestEncryptDecryptRoundtrip(t *testing.T) { + dir := t.TempDir() + state, err := NewAppState(dir) + if err != nil { + t.Fatal(err) + } + plainPath := filepath.Join(dir, "a.txt") + if err := os.WriteFile(plainPath, []byte("alpha"), 0o644); err != nil { + t.Fatal(err) + } + + saved, errs, err := state.Encrypt([]string{"a.txt"}, 42, 555, "test") + if err != nil { + t.Fatalf("encrypt failed: %v (%v)", err, errs) + } + if len(saved) != 1 { + t.Fatalf("expected 1 saved record") + } + + restored, errs, err := state.Decrypt([]int{saved[0].RecordID}) + if err != nil { + t.Fatalf("decrypt failed: %v (%v)", err, errs) + } + if len(restored) != 1 { + t.Fatalf("expected one restored mapping") + } + rel := restored[0]["decrypted_relpath"].(string) + content, err := os.ReadFile(filepath.Join(dir, rel)) + if err != nil { + t.Fatal(err) + } + if string(content) != "alpha" { + t.Fatalf("unexpected decrypted content: %q", string(content)) + } +} + +func TestDecryptFailsAfterPuncture(t *testing.T) { + dir := t.TempDir() + state, err := NewAppState(dir) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "b.txt"), []byte("beta"), 0o644); err != nil { + t.Fatal(err) + } + saved, _, err := state.Encrypt([]string{"b.txt"}, 17, 700, "test") + if err != nil { + t.Fatal(err) + } + if err := state.Puncture(17, 700); err != nil { + t.Fatal(err) + } + _, _, err = state.Decrypt([]int{saved[0].RecordID}) + if err == nil { + t.Fatalf("expected decrypt to fail after puncture") + } +} + +func TestStatePersistsAcrossRestart(t *testing.T) { + dir := t.TempDir() + state1, err := NewAppState(dir) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "c.txt"), []byte("charlie"), 0o644); err != nil { + t.Fatal(err) + } + if err := state1.Derive(42, 111, "persist me"); err != nil { + t.Fatal(err) + } + if _, _, err := state1.Encrypt([]string{"c.txt"}, 42, 111, "persist mapping"); err != nil { + t.Fatal(err) + } + if err := state1.Puncture(42, 111); err != nil { + t.Fatal(err) + } + if _, statErr := os.Stat(state1.StateFile); statErr != nil { + t.Fatalf("expected persisted state file %q to exist: %v", state1.StateFile, statErr) + } + + state2, err := NewAppState(dir) + if err != nil { + t.Fatal(err) + } + path, err := ggm.TagToBinaryPath(42, 111) + if err != nil { + t.Fatal(err) + } + _, ok, err := state2.Manager.GetKeyForTag(path) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatalf("expected punctured key to remain inaccessible after restart") + } + if len(state2.AssetRecords) != 1 { + t.Fatalf("expected 1 persisted asset record, got %d", len(state2.AssetRecords)) + } + entry := state2.KeyJournal[path] + if entry == nil || !entry.EverPunctured { + t.Fatalf("expected persisted key journal puncture entry") + } +} + +func TestDefaultStateFilePath(t *testing.T) { + tmp := t.TempDir() + assets := filepath.Join(tmp, "assets") + state, err := NewAppState(assets) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(tmp, "state.json") + if state.StateFile != want { + t.Fatalf("expected state file %q, got %q", want, state.StateFile) + } +} diff --git a/goapp/internal/crypto/manager.go b/goapp/internal/crypto/manager.go new file mode 100644 index 0000000..bcb724b --- /dev/null +++ b/goapp/internal/crypto/manager.go @@ -0,0 +1,312 @@ +package crypto + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" +) + +const ( + ProviderBits = 7 + ResourceBits = 25 + PathBits = ProviderBits + ResourceBits + KeySize = 32 +) + +type Manager struct { + activeNodes map[string][]byte + punctureLog []string + puncturedPaths map[string]struct{} + puncturedPrefixes map[string]struct{} +} + +type ExportState struct { + ActiveNodes map[string]string `json:"active_nodes"` + PunctureLog []string `json:"puncture_log"` +} + +func GenerateMasterSeed() ([]byte, error) { + buf := make([]byte, KeySize) + if _, err := rand.Read(buf); err != nil { + return nil, err + } + return buf, nil +} + +func NewManager(masterSeed []byte) (*Manager, error) { + if len(masterSeed) != KeySize { + return nil, fmt.Errorf("master seed must be %d bytes", KeySize) + } + root := make([]byte, KeySize) + copy(root, masterSeed) + return &Manager{ + activeNodes: map[string][]byte{"": root}, + punctureLog: []string{}, + puncturedPaths: map[string]struct{}{}, + puncturedPrefixes: map[string]struct{}{}, + }, nil +} + +func validateBinary(s string, minLen, maxLen int) error { + if len(s) < minLen || len(s) > maxLen { + return fmt.Errorf("bitstring length must be in [%d,%d]", minLen, maxLen) + } + for _, c := range s { + if c != '0' && c != '1' { + return errors.New("bitstring must contain only 0 or 1") + } + } + return nil +} + +func validatePath(path string) error { + return validateBinary(path, PathBits, PathBits) +} + +func validatePrefix(prefix string) error { + return validateBinary(prefix, 1, PathBits) +} + +func deriveChild(parent []byte, bit byte) []byte { + marker := byte(0) + if bit == '1' { + marker = 1 + } + mac := hmac.New(sha256.New, parent) + mac.Write([]byte{'G', 'G', 'M', marker}) + return mac.Sum(nil) +} + +func zeroize(buf []byte) { + for i := range buf { + buf[i] = 0 + } +} + +func TagToBinaryPath(providerID, fileTimeID int) (string, error) { + if providerID < 0 || providerID >= (1<= (1<= (1<= 0; depth-- { + prefix := path[:depth] + if _, ok := m.activeNodes[prefix]; ok { + return prefix, true + } + } + return "", false +} + +func (m *Manager) ActiveNodeCount() int { + return len(m.activeNodes) +} + +func (m *Manager) ActivePrefixes() []string { + prefixes := make([]string, 0, len(m.activeNodes)) + for p := range m.activeNodes { + prefixes = append(prefixes, p) + } + sort.Slice(prefixes, func(i, j int) bool { + if len(prefixes[i]) == len(prefixes[j]) { + return prefixes[i] < prefixes[j] + } + return len(prefixes[i]) < len(prefixes[j]) + }) + return prefixes +} + +func (m *Manager) PunctureLog() []string { + out := make([]string, len(m.punctureLog)) + copy(out, m.punctureLog) + return out +} + +func (m *Manager) ExportPunctureLogJSON() string { + buf, _ := json.Marshal(m.punctureLog) + return string(buf) +} + +func (m *Manager) GetKeyForTag(path string) ([]byte, bool, error) { + if err := validatePath(path); err != nil { + return nil, false, err + } + cover, ok := m.findCovering(path) + if !ok { + return nil, false, nil + } + current := m.cloneSeed(cover) + for i := len(cover); i < len(path); i++ { + next := deriveChild(current, path[i]) + zeroize(current) + current = next + } + return current, true, nil +} + +func (m *Manager) Puncture(path string) (bool, error) { + if err := validatePath(path); err != nil { + return false, err + } + if _, exists := m.puncturedPaths[path]; exists { + return false, nil + } + for prefix := range m.puncturedPrefixes { + if len(prefix) <= len(path) && path[:len(prefix)] == prefix { + return false, nil + } + } + + cover, ok := m.findCovering(path) + if !ok { + m.puncturedPaths[path] = struct{}{} + m.punctureLog = append(m.punctureLog, path) + return false, nil + } + + current := m.activeNodes[cover] + delete(m.activeNodes, cover) + for depth := len(cover); depth < PathBits; depth++ { + bit := path[depth] + siblingBit := byte('0') + if bit == '0' { + siblingBit = '1' + } + siblingKey := deriveChild(current, siblingBit) + siblingPrefix := path[:depth] + string(siblingBit) + m.activeNodes[siblingPrefix] = siblingKey + + next := deriveChild(current, bit) + zeroize(current) + current = next + } + zeroize(current) + + m.puncturedPaths[path] = struct{}{} + m.punctureLog = append(m.punctureLog, path) + return true, nil +} + +func (m *Manager) PuncturePrefix(prefix string) (bool, error) { + if err := validatePrefix(prefix); err != nil { + return false, err + } + if len(prefix) == PathBits { + return m.Puncture(prefix) + } + if _, exists := m.puncturedPrefixes[prefix]; exists { + return false, nil + } + for p := range m.puncturedPrefixes { + if len(p) <= len(prefix) && prefix[:len(p)] == p { + return false, nil + } + } + + changed := false + cover, ok := m.findCovering(prefix) + if ok { + changed = true + current := m.activeNodes[cover] + delete(m.activeNodes, cover) + + for depth := len(cover); depth < len(prefix); depth++ { + bit := prefix[depth] + siblingBit := byte('0') + if bit == '0' { + siblingBit = '1' + } + siblingKey := deriveChild(current, siblingBit) + siblingPrefix := prefix[:depth] + string(siblingBit) + m.activeNodes[siblingPrefix] = siblingKey + + next := deriveChild(current, bit) + zeroize(current) + current = next + } + zeroize(current) + } + + for node, seed := range m.activeNodes { + if len(node) >= len(prefix) && node[:len(prefix)] == prefix { + changed = true + zeroize(seed) + delete(m.activeNodes, node) + } + } + + m.puncturedPrefixes[prefix] = struct{}{} + m.punctureLog = append(m.punctureLog, prefix) + return changed, nil +} + +func (m *Manager) PunctureProvider(providerID int) (bool, error) { + prefix, err := ProviderIDToPrefix(providerID) + if err != nil { + return false, err + } + return m.PuncturePrefix(prefix) +} + +func (m *Manager) ExportState() ExportState { + out := ExportState{ActiveNodes: map[string]string{}, PunctureLog: m.PunctureLog()} + for prefix, seed := range m.activeNodes { + out.ActiveNodes[prefix] = hex.EncodeToString(seed) + } + return out +} + +func FromState(state ExportState) (*Manager, error) { + baseSeed := make([]byte, KeySize) + m, _ := NewManager(baseSeed) + m.activeNodes = map[string][]byte{} + m.punctureLog = append([]string(nil), state.PunctureLog...) + m.puncturedPaths = map[string]struct{}{} + m.puncturedPrefixes = map[string]struct{}{} + + for prefix, hexSeed := range state.ActiveNodes { + if prefix != "" { + if err := validatePrefix(prefix); err != nil { + return nil, err + } + } + seed, err := hex.DecodeString(hexSeed) + if err != nil || len(seed) != KeySize { + return nil, errors.New("invalid active seed") + } + m.activeNodes[prefix] = seed + } + for _, bitstring := range m.punctureLog { + if len(bitstring) == PathBits { + m.puncturedPaths[bitstring] = struct{}{} + } else { + m.puncturedPrefixes[bitstring] = struct{}{} + } + } + return m, nil +} diff --git a/goapp/internal/crypto/manager_test.go b/goapp/internal/crypto/manager_test.go new file mode 100644 index 0000000..9be31b5 --- /dev/null +++ b/goapp/internal/crypto/manager_test.go @@ -0,0 +1,68 @@ +package crypto + +import "testing" + +func mustSeed() []byte { + seed := make([]byte, KeySize) + for i := range seed { + seed[i] = byte(i + 1) + } + return seed +} + +func TestDeriveAndPuncture(t *testing.T) { + mgr, err := NewManager(mustSeed()) + if err != nil { + t.Fatal(err) + } + path, err := TagToBinaryPath(42, 123456) + if err != nil { + t.Fatal(err) + } + before, ok, err := mgr.GetKeyForTag(path) + if err != nil || !ok || len(before) != 32 { + t.Fatalf("derive before puncture failed: ok=%v err=%v", ok, err) + } + applied, err := mgr.Puncture(path) + if err != nil || !applied { + t.Fatalf("puncture failed: applied=%v err=%v", applied, err) + } + after, ok, err := mgr.GetKeyForTag(path) + if err != nil { + t.Fatal(err) + } + if ok || after != nil { + t.Fatalf("expected punctured key to be inaccessible") + } +} + +func TestProviderPrefixPuncture(t *testing.T) { + mgr, err := NewManager(mustSeed()) + if err != nil { + t.Fatal(err) + } + p42a, _ := TagToBinaryPath(42, 100) + p42b, _ := TagToBinaryPath(42, 101) + p41, _ := TagToBinaryPath(41, 100) + k41Before, ok, _ := mgr.GetKeyForTag(p41) + if !ok { + t.Fatalf("control key should be derivable") + } + applied, err := mgr.PunctureProvider(42) + if err != nil || !applied { + t.Fatalf("provider puncture failed: %v", err) + } + if _, ok, _ := mgr.GetKeyForTag(p42a); ok { + t.Fatalf("p42 key should be blocked") + } + if _, ok, _ := mgr.GetKeyForTag(p42b); ok { + t.Fatalf("p42 key should be blocked") + } + k41After, ok, _ := mgr.GetKeyForTag(p41) + if !ok { + t.Fatalf("p41 key should remain derivable") + } + if string(k41Before) != string(k41After) { + t.Fatalf("p41 key changed unexpectedly") + } +} diff --git a/goapp/internal/server/http.go b/goapp/internal/server/http.go new file mode 100644 index 0000000..de39956 --- /dev/null +++ b/goapp/internal/server/http.go @@ -0,0 +1,384 @@ +package server + +import ( + "context" + "embed" + "encoding/json" + "fmt" + "log" + "mime/multipart" + "net/http" + "os" + "strconv" + "strings" + "time" + + "puncture-go/internal/app" +) + +//go:embed static/index.html +var staticFS embed.FS + +type HTTPServer struct { + state *app.AppState + remoteToken string + mux *http.ServeMux +} + +func New(state *app.AppState, remoteToken string) *HTTPServer { + s := &HTTPServer{state: state, remoteToken: strings.TrimSpace(remoteToken), mux: http.NewServeMux()} + s.routes() + return s +} + +func (s *HTTPServer) Handler() http.Handler { return s.mux } + +func (s *HTTPServer) routes() { + s.mux.HandleFunc("/", s.handleIndex) + s.mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) + + s.mux.HandleFunc("/api/state", s.handleState) + s.mux.HandleFunc("/api/live/state", s.handleState) + s.mux.HandleFunc("/api/export", s.handleExport) + s.mux.HandleFunc("/api/reset", s.handleReset) + + s.mux.HandleFunc("/api/derive", s.handleDerive) + s.mux.HandleFunc("/api/puncture", s.handlePuncture) + s.mux.HandleFunc("/api/remote/puncture-provider", s.handleRemotePunctureProvider) + + s.mux.HandleFunc("/api/providers/add", s.handleProviderAdd) + s.mux.HandleFunc("/api/providers/edit", s.handleProviderEdit) + s.mux.HandleFunc("/api/providers/delete", s.handleProviderDelete) + + s.mux.HandleFunc("/api/assets/upload", s.handleAssetUpload) + s.mux.HandleFunc("/api/assets/encrypt", s.handleAssetEncrypt) + s.mux.HandleFunc("/api/assets/decrypt", s.handleAssetDecrypt) +} + +func writeJSON(w http.ResponseWriter, code int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(payload) +} + +func decodeJSON(r *http.Request, dst any) error { + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + return dec.Decode(dst) +} + +func (s *HTTPServer) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) + return + } + blob, err := staticFS.ReadFile("static/index.html") + if err != nil { + writeJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(blob) +} + +func (s *HTTPServer) handleState(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) + return + } + writeJSON(w, 200, map[string]any{"ok": true, "state": s.state.Snapshot()}) +} + +func (s *HTTPServer) handleExport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) + return + } + blob, err := s.state.ExportStateJSON() + if err != nil { + writeJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(blob) +} + +func (s *HTTPServer) handleReset(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) + return + } + if err := s.state.Reset(); err != nil { + writeJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + writeJSON(w, 200, map[string]any{"ok": true, "state": s.state.Snapshot()}) +} + +func (s *HTTPServer) handleDerive(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) + return + } + var req struct { + ProviderID int `json:"provider_id"` + FileTimeID int `json:"file_time_id"` + Purpose string `json:"purpose"` + } + if err := decodeJSON(r, &req); err != nil { + writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := s.state.Derive(req.ProviderID, req.FileTimeID, req.Purpose); err != nil { + writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + writeJSON(w, 200, map[string]any{"ok": true, "state": s.state.Snapshot()}) +} + +func (s *HTTPServer) handlePuncture(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) + return + } + var req struct { + ProviderID int `json:"provider_id"` + FileTimeID int `json:"file_time_id"` + } + if err := decodeJSON(r, &req); err != nil { + writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := s.state.Puncture(req.ProviderID, req.FileTimeID); err != nil { + writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + writeJSON(w, 200, map[string]any{"ok": true, "state": s.state.Snapshot()}) +} + +func (s *HTTPServer) handleRemotePunctureProvider(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) + return + } + provided := r.Header.Get("X-Puncture-Token") + if !s.state.RemoteTokenValid(provided, s.remoteToken) { + writeJSON(w, 403, map[string]any{"ok": false, "error": "unauthorized"}) + return + } + var req struct { + ProviderID int `json:"provider_id"` + } + if err := decodeJSON(r, &req); err != nil { + writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := s.state.PunctureProvider(req.ProviderID); err != nil { + writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + writeJSON(w, 200, map[string]any{"ok": true, "provider_id": req.ProviderID, "state": s.state.Snapshot()}) +} + +func (s *HTTPServer) handleProviderAdd(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) + return + } + var req struct { + ProviderID int `json:"provider_id"` + Name string `json:"name"` + Description string `json:"description"` + } + if err := decodeJSON(r, &req); err != nil { + writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := s.state.AddProvider(req.ProviderID, req.Name, req.Description); err != nil { + writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + writeJSON(w, 200, map[string]any{"ok": true, "state": s.state.Snapshot()}) +} + +func (s *HTTPServer) handleProviderEdit(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) + return + } + var req struct { + ProviderID int `json:"provider_id"` + Name string `json:"name"` + Description string `json:"description"` + } + if err := decodeJSON(r, &req); err != nil { + writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := s.state.EditProvider(req.ProviderID, req.Name, req.Description); err != nil { + writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + writeJSON(w, 200, map[string]any{"ok": true, "state": s.state.Snapshot()}) +} + +func (s *HTTPServer) handleProviderDelete(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) + return + } + var req struct { + ProviderID int `json:"provider_id"` + } + if err := decodeJSON(r, &req); err != nil { + writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := s.state.DeleteProvider(req.ProviderID); err != nil { + writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + writeJSON(w, 200, map[string]any{"ok": true, "state": s.state.Snapshot()}) +} + +func filesFromRequest(r *http.Request) ([]*multipart.FileHeader, string, error) { + if err := r.ParseMultipartForm(64 << 20); err != nil { + return nil, "", err + } + form := r.MultipartForm + if form == nil { + return nil, "", fmt.Errorf("multipart form missing") + } + files := form.File["files"] + if len(files) == 0 { + files = form.File["file"] + } + target := r.FormValue("target_subdir") + return files, target, nil +} + +func (s *HTTPServer) handleAssetUpload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) + return + } + files, target, err := filesFromRequest(r) + if err != nil { + writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + saved, err := s.state.SaveUploads(files, target) + if err != nil { + writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error(), "state": s.state.Snapshot()}) + return + } + writeJSON(w, 200, map[string]any{"ok": true, "uploaded": saved, "state": s.state.Snapshot()}) +} + +func (s *HTTPServer) handleAssetEncrypt(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) + return + } + var req struct { + PlaintextRelpaths []string `json:"plaintext_relpaths"` + ProviderID int `json:"provider_id"` + FileTimeID int `json:"file_time_id"` + Purpose string `json:"purpose"` + } + if err := decodeJSON(r, &req); err != nil { + writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + saved, errs, err := s.state.Encrypt(req.PlaintextRelpaths, req.ProviderID, req.FileTimeID, req.Purpose) + if err != nil { + writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error(), "errors": errs, "state": s.state.Snapshot()}) + return + } + writeJSON(w, 200, map[string]any{"ok": true, "saved": saved, "errors": errs, "state": s.state.Snapshot()}) +} + +func (s *HTTPServer) handleAssetDecrypt(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) + return + } + var req struct { + RecordIDs []int `json:"record_ids"` + } + if err := decodeJSON(r, &req); err != nil { + writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + restored, errs, err := s.state.Decrypt(req.RecordIDs) + if err != nil { + writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error(), "errors": errs, "state": s.state.Snapshot()}) + return + } + writeJSON(w, 200, map[string]any{"ok": true, "restored": restored, "errors": errs, "state": s.state.Snapshot()}) +} + +func Run(addr, assetRoot string) error { + state, err := app.NewAppState(assetRoot) + if err != nil { + return err + } + remoteToken := os.Getenv("PUNCTURE_REMOTE_TOKEN") + h := New(state, remoteToken) + server := &http.Server{Addr: addr, Handler: loggingMiddleware(h.Handler())} + log.Printf("puncture-go server listening on %s", addr) + return server.ListenAndServe() +} + +func Start(addr, assetRoot string) (*http.Server, *app.AppState, error) { + state, err := app.NewAppState(assetRoot) + if err != nil { + return nil, nil, err + } + remoteToken := os.Getenv("PUNCTURE_REMOTE_TOKEN") + h := New(state, remoteToken) + srv := &http.Server{Addr: addr, Handler: loggingMiddleware(h.Handler())} + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("server error: %v", err) + } + }() + deadline := time.Now().Add(4 * time.Second) + for time.Now().Before(deadline) { + ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+addr+"/healthz", nil) + resp, err := http.DefaultClient.Do(req) + if err == nil && resp != nil && resp.StatusCode == 200 { + _ = resp.Body.Close() + cancel() + return srv, state, nil + } + if resp != nil { + _ = resp.Body.Close() + } + cancel() + time.Sleep(120 * time.Millisecond) + } + return nil, nil, fmt.Errorf("server did not start in time on %s", addr) +} + +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + log.Printf("%s %s (%s)", r.Method, r.URL.Path, time.Since(start).Truncate(time.Millisecond)) + }) +} + +func ParseAddr(host string, port int) string { + if host == "" { + host = "127.0.0.1" + } + if port <= 0 { + port = 9122 + } + return host + ":" + strconv.Itoa(port) +} diff --git a/goapp/internal/server/static/index.html b/goapp/internal/server/static/index.html new file mode 100644 index 0000000..1700834 --- /dev/null +++ b/goapp/internal/server/static/index.html @@ -0,0 +1,548 @@ + + + + + + Puncture Go + + + +
+
+

Puncture Go: macOS-primary forward secrecy

+

Go implementation with tree-frontier visualization and local encryption/decryption workflow.

+
Loading state...
+
+
Active Nodes
0
+
Puncture Events
0
+
Cipher Mappings
0
+
Blocked Mappings
0
+
+
+ +
+

Tree/Subtree Visualization

+

Frontier roots are highlighted. Removed prior frontier after puncture is red. Impossible future subtrees are blocked.

+
+
Visible Frontier
0
+
Blocked Nodes
0
+
Removed Frontier
0
+
Projection Depth
0
+
+
+
+ Current frontier + Future derivable + Already derived branch + Future impossible + Deleted frontier (last puncture) +
+

+
+ +
+
+

Derive / Puncture

+ + + + + + +
+ + + +
+
+
Latest action
+
+
+
+
+
+ +
+

Providers

+ + + + + + +
+ +
+ + + +
IDNamePrefixDelete
+
+
+ +
+
+

Assets: Upload -> Encrypt

+ + + + +
+ + + +
+ + + + + + + + + + +
+ +
+ + + + +
FileStateMeta
+
+ +
+

Ciphertext Mappings + Decrypt

+
+
+
+ +
+

History

+
+
+
+ + + + diff --git a/goapp/ios/EmergencyPuncture/EmergencyPuncture.xcodeproj/project.pbxproj b/goapp/ios/EmergencyPuncture/EmergencyPuncture.xcodeproj/project.pbxproj new file mode 100644 index 0000000..f94de67 --- /dev/null +++ b/goapp/ios/EmergencyPuncture/EmergencyPuncture.xcodeproj/project.pbxproj @@ -0,0 +1,307 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 30D75D8158EB759004461E4E /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C4FD6BA6A99205C6DD71D9 /* APIClient.swift */; }; + 72793781EBB240D2BDBD902D /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D3E856C899A743DE196B150 /* Models.swift */; }; + EA2882C7632E542F4BCA1414 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85C16F2D6F0687194AFE7768 /* ContentView.swift */; }; + F2C0AD15D0098DDD13EF8FBD /* EmergencyPunctureApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582D93687F8F124CAED644C1 /* EmergencyPunctureApp.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 07A00A5B246ECE26D159E16A /* EmergencyPuncture.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = EmergencyPuncture.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 0D3E856C899A743DE196B150 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 2328E7FE882644CDF6BF9FB5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 35C4FD6BA6A99205C6DD71D9 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; + 582D93687F8F124CAED644C1 /* EmergencyPunctureApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmergencyPunctureApp.swift; sourceTree = ""; }; + 85C16F2D6F0687194AFE7768 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 0D13354FC892E3DC820A07C7 /* Products */ = { + isa = PBXGroup; + children = ( + 07A00A5B246ECE26D159E16A /* EmergencyPuncture.app */, + ); + name = Products; + sourceTree = ""; + }; + A5297052C84FF04DC5EB1219 = { + isa = PBXGroup; + children = ( + A6A0E42C6C35DB6DAB94488B /* Sources */, + 0D13354FC892E3DC820A07C7 /* Products */, + ); + sourceTree = ""; + }; + A6A0E42C6C35DB6DAB94488B /* Sources */ = { + isa = PBXGroup; + children = ( + 35C4FD6BA6A99205C6DD71D9 /* APIClient.swift */, + 85C16F2D6F0687194AFE7768 /* ContentView.swift */, + 582D93687F8F124CAED644C1 /* EmergencyPunctureApp.swift */, + 2328E7FE882644CDF6BF9FB5 /* Info.plist */, + 0D3E856C899A743DE196B150 /* Models.swift */, + ); + path = Sources; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 9CFC9A1FFAEC886621613511 /* EmergencyPuncture */ = { + isa = PBXNativeTarget; + buildConfigurationList = 66F6483E4CB5DE6FB030348E /* Build configuration list for PBXNativeTarget "EmergencyPuncture" */; + buildPhases = ( + 7989A8B86C0595B223807574 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = EmergencyPuncture; + packageProductDependencies = ( + ); + productName = EmergencyPuncture; + productReference = 07A00A5B246ECE26D159E16A /* EmergencyPuncture.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 25F616EA71B6088A929165FA /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 9CFC9A1FFAEC886621613511 = { + DevelopmentTeam = ""; + }; + }; + }; + buildConfigurationList = D22B3D231897C79D1251FFA6 /* Build configuration list for PBXProject "EmergencyPuncture" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = A5297052C84FF04DC5EB1219; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 9CFC9A1FFAEC886621613511 /* EmergencyPuncture */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 7989A8B86C0595B223807574 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 30D75D8158EB759004461E4E /* APIClient.swift in Sources */, + EA2882C7632E542F4BCA1414 /* ContentView.swift in Sources */, + F2C0AD15D0098DDD13EF8FBD /* EmergencyPunctureApp.swift in Sources */, + 72793781EBB240D2BDBD902D /* Models.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 514996E6410F0ED8CC29D7D6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.puncture.emergency; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + 8262900B4A20339CAEF57C89 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = ""; + INFOPLIST_FILE = Sources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_NAME = EmergencyPuncture; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + ACD8AF8CAAC92BB649070398 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.puncture.emergency; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + F1B739458185CA082C51EBB9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = ""; + INFOPLIST_FILE = Sources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_NAME = EmergencyPuncture; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 66F6483E4CB5DE6FB030348E /* Build configuration list for PBXNativeTarget "EmergencyPuncture" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F1B739458185CA082C51EBB9 /* Debug */, + 8262900B4A20339CAEF57C89 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + D22B3D231897C79D1251FFA6 /* Build configuration list for PBXProject "EmergencyPuncture" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 514996E6410F0ED8CC29D7D6 /* Debug */, + ACD8AF8CAAC92BB649070398 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 25F616EA71B6088A929165FA /* Project object */; +} diff --git a/goapp/ios/EmergencyPuncture/EmergencyPuncture.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/goapp/ios/EmergencyPuncture/EmergencyPuncture.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/goapp/ios/EmergencyPuncture/EmergencyPuncture.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/goapp/ios/EmergencyPuncture/Sources/APIClient.swift b/goapp/ios/EmergencyPuncture/Sources/APIClient.swift new file mode 100644 index 0000000..cecb23f --- /dev/null +++ b/goapp/ios/EmergencyPuncture/Sources/APIClient.swift @@ -0,0 +1,70 @@ +import Foundation + +enum APIError: Error, LocalizedError { + case invalidURL + case badStatus(Int) + case backend(String) + + var errorDescription: String? { + switch self { + case .invalidURL: return "Invalid URL" + case .badStatus(let code): return "HTTP \(code)" + case .backend(let message): return message + } + } +} + +@MainActor +final class APIClient { + func loadProviders(masterURL: String, token: String) async throws -> [ProviderLite] { + guard let url = URL(string: masterURL.trimmingCharacters(in: .whitespacesAndNewlines) + "/api/live/state") else { + throw APIError.invalidURL + } + var req = URLRequest(url: url) + req.httpMethod = "GET" + if !token.isEmpty { + req.addValue(token, forHTTPHeaderField: "X-Puncture-Token") + } + let (data, resp) = try await URLSession.shared.data(for: req) + guard let http = resp as? HTTPURLResponse else { throw APIError.badStatus(-1) } + guard (200...299).contains(http.statusCode) else { throw APIError.badStatus(http.statusCode) } + + if let top = try? JSONDecoder().decode([String: [ProviderLite]].self, from: data), let providers = top["providers"] { + return providers + } + if let payload = try? JSONDecoder().decode(LiveStatePayload.self, from: data) { + if let providers = payload.providers { + return providers + } + if let state = payload.state { + return state.providers + } + } + return [] + } + + func punctureProvider(masterURL: String, providerID: Int, token: String) async throws -> RemotePunctureResponse { + guard let url = URL(string: masterURL.trimmingCharacters(in: .whitespacesAndNewlines) + "/api/remote/puncture-provider") else { + throw APIError.invalidURL + } + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.addValue("application/json", forHTTPHeaderField: "Content-Type") + if !token.isEmpty { + req.addValue(token, forHTTPHeaderField: "X-Puncture-Token") + } + + req.httpBody = try JSONEncoder().encode(RemotePunctureRequest(provider_id: providerID)) + let (data, resp) = try await URLSession.shared.data(for: req) + guard let http = resp as? HTTPURLResponse else { throw APIError.badStatus(-1) } + + let decoded = (try? JSONDecoder().decode(RemotePunctureResponse.self, from: data)) ?? RemotePunctureResponse(ok: false, error: "Invalid response", provider_id: nil) + guard (200...299).contains(http.statusCode) else { + throw APIError.backend(decoded.error ?? "HTTP \(http.statusCode)") + } + if !decoded.ok { + throw APIError.backend(decoded.error ?? "Puncture rejected") + } + return decoded + } +} diff --git a/goapp/ios/EmergencyPuncture/Sources/ContentView.swift b/goapp/ios/EmergencyPuncture/Sources/ContentView.swift new file mode 100644 index 0000000..0c4596a --- /dev/null +++ b/goapp/ios/EmergencyPuncture/Sources/ContentView.swift @@ -0,0 +1,115 @@ +import SwiftUI + +struct ContentView: View { + @State private var masterURL: String = "http://192.168.1.49:9122" + @State private var remoteToken: String = "" + @State private var killPassword: String = "" + @State private var selectedProviderID: Int = 42 + @State private var providers: [ProviderLite] = [] + @State private var statusMessage: String = "Ready" + @State private var statusTone: Color = .blue + @State private var loading: Bool = false + + private let api = APIClient() + + var body: some View { + NavigationStack { + Form { + Section("Master Connection") { + TextField("Master URL", text: $masterURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + SecureField("Remote Token (X-Puncture-Token)", text: $remoteToken) + Button("Load Providers") { + Task { await refreshProviders() } + } + } + + Section("Emergency Puncture") { + Picker("Provider", selection: $selectedProviderID) { + ForEach(providers) { provider in + Text("ID \(provider.provider_id) - \(provider.name)").tag(provider.provider_id) + } + } + .pickerStyle(.menu) + + SecureField("Kill Password (optional: append provider id)", text: $killPassword) + + Button(role: .destructive) { + Task { await emergencyPuncture() } + } label: { + if loading { + ProgressView() + } else { + Text("Emergency Puncture Now") + } + } + .disabled(loading) + } + + Section("Status") { + Text(statusMessage) + .foregroundStyle(statusTone) + .font(.system(.body, design: .monospaced)) + } + } + .navigationTitle("Emergency Puncture") + .task { + await refreshProviders() + } + } + } + + private func resolvedProviderFromPassword() -> Int? { + let digitsReversed = killPassword.reversed().prefix { $0.isNumber } + guard !digitsReversed.isEmpty else { + return nil + } + let digits = String(digitsReversed.reversed()) + guard let provider = Int(digits), (0...127).contains(provider) else { + return nil + } + return provider + } + + private func refreshProviders() async { + loading = true + defer { loading = false } + do { + let loaded = try await api.loadProviders(masterURL: masterURL, token: remoteToken) + await MainActor.run { + self.providers = loaded.sorted(by: { $0.provider_id < $1.provider_id }) + if let first = providers.first, !providers.contains(where: { $0.provider_id == selectedProviderID }) { + selectedProviderID = first.provider_id + } + statusMessage = "Loaded \(providers.count) provider(s)." + statusTone = .green + } + } catch { + await MainActor.run { + statusMessage = "Load failed: \(error.localizedDescription)" + statusTone = .red + } + } + } + + private func emergencyPuncture() async { + loading = true + defer { loading = false } + + let providerID = resolvedProviderFromPassword() ?? selectedProviderID + do { + let response = try await api.punctureProvider(masterURL: masterURL, providerID: providerID, token: remoteToken) + await MainActor.run { + statusMessage = "Punctured provider \(response.provider_id ?? providerID)." + statusTone = .orange + } + await refreshProviders() + } catch { + await MainActor.run { + statusMessage = "Emergency puncture failed: \(error.localizedDescription)" + statusTone = .red + } + } + } +} diff --git a/goapp/ios/EmergencyPuncture/Sources/EmergencyPunctureApp.swift b/goapp/ios/EmergencyPuncture/Sources/EmergencyPunctureApp.swift new file mode 100644 index 0000000..45c23a7 --- /dev/null +++ b/goapp/ios/EmergencyPuncture/Sources/EmergencyPunctureApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct EmergencyPunctureApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/goapp/ios/EmergencyPuncture/Sources/Info.plist b/goapp/ios/EmergencyPuncture/Sources/Info.plist new file mode 100644 index 0000000..e8c6380 --- /dev/null +++ b/goapp/ios/EmergencyPuncture/Sources/Info.plist @@ -0,0 +1,33 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/goapp/ios/EmergencyPuncture/Sources/Models.swift b/goapp/ios/EmergencyPuncture/Sources/Models.swift new file mode 100644 index 0000000..f36deee --- /dev/null +++ b/goapp/ios/EmergencyPuncture/Sources/Models.swift @@ -0,0 +1,27 @@ +import Foundation + +struct ProviderLite: Decodable, Identifiable, Hashable { + let provider_id: Int + let name: String + var id: Int { provider_id } +} + +struct LiveStatePayload: Decodable { + let ok: Bool? + let state: LiveState? + let providers: [ProviderLite]? +} + +struct LiveState: Decodable { + let providers: [ProviderLite] +} + +struct RemotePunctureRequest: Encodable { + let provider_id: Int +} + +struct RemotePunctureResponse: Decodable { + let ok: Bool + let error: String? + let provider_id: Int? +} diff --git a/goapp/ios/EmergencyPuncture/project.yml b/goapp/ios/EmergencyPuncture/project.yml new file mode 100644 index 0000000..ab32f0f --- /dev/null +++ b/goapp/ios/EmergencyPuncture/project.yml @@ -0,0 +1,32 @@ +name: EmergencyPuncture +options: + minimumXcodeGenVersion: 2.38.0 +configs: + Debug: debug + Release: release +settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.puncture.emergency + SWIFT_VERSION: 6.0 + IPHONEOS_DEPLOYMENT_TARGET: 17.0 + MARKETING_VERSION: 1.0.0 + CURRENT_PROJECT_VERSION: 1 +targets: + EmergencyPuncture: + type: application + platform: iOS + deploymentTarget: '17.0' + sources: + - Sources + info: + path: Sources/Info.plist + properties: + UILaunchScreen: {} + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + NSAppTransportSecurity: + NSAllowsArbitraryLoads: true + settings: + base: + PRODUCT_NAME: EmergencyPuncture + DEVELOPMENT_TEAM: "" diff --git a/goapp/ios/INSTALL_IPHONE.md b/goapp/ios/INSTALL_IPHONE.md new file mode 100644 index 0000000..e9ab803 --- /dev/null +++ b/goapp/ios/INSTALL_IPHONE.md @@ -0,0 +1,65 @@ +# EmergencyPuncture iPhone Install Guide + +This project already builds successfully for iOS. +Current local artifacts: + +- Unsigned archive: `goapp/ios/dist/EmergencyPuncture.xcarchive` +- Unsigned IPA (not installable as-is): `goapp/ios/dist/EmergencyPuncture-unsigned-proper.ipa` + +To install on a real iPhone, you must sign with an Apple Team. + +## Option 1: Install directly from Xcode (recommended) + +1. Connect your iPhone to the Mac with USB. +2. On iPhone, tap `Trust This Computer` if prompted. +3. Open project: + - `goapp/ios/EmergencyPuncture/EmergencyPuncture.xcodeproj` +4. In Xcode: + - Select target `EmergencyPuncture` + - Open `Signing & Capabilities` + - Enable `Automatically manage signing` + - Select your Team (Apple ID Personal Team or paid Developer Team) +5. In the Xcode device selector, choose your connected iPhone. +6. Press `Run` (Cmd+R). +7. If iOS blocks launch the first time: + - iPhone `Settings > General > VPN & Device Management` + - Trust your developer certificate + - Re-run from Xcode. + +## Option 2: Build signed IPA from terminal + +1. Ensure step 4 above is complete at least once in Xcode (Team selected). +2. Get your Team ID (10 chars) from: + - Apple Developer account + - or Xcode `Signing & Capabilities` details. +3. Run: + +```bash +cd /Users/oho/GitClone/CodexProjects/puncture/goapp/ios +TEAM_ID=YOURTEAMID ./build_signed_ipa.sh +``` + +4. Output: + - Archive: `goapp/ios/dist-signed/EmergencyPuncture-YOURTEAMID.xcarchive` + - IPA: `goapp/ios/dist-signed/export-debugging/EmergencyPuncture.ipa` + +## Install signed IPA to iPhone + +Use one of these: + +1. Apple Configurator 2 (Mac App Store): + - Connect iPhone + - Drag the signed `.ipa` onto the device +2. Xcode Organizer: + - `Window > Organizer` + - Select archive + - `Distribute App` for development/internal flow + +## Known failure and meaning + +- `No Team Found in Archive`: + The app was archived without a Team (`DEVELOPMENT_TEAM` empty), so export cannot sign. + Fix by selecting Team in Xcode, then rebuild/export. +- `Signing for "EmergencyPuncture" requires a development team`: + No Team is configured yet for this target. + Fix in Xcode `Signing & Capabilities` by selecting your Team. diff --git a/goapp/ios/build_signed_ipa.sh b/goapp/ios/build_signed_ipa.sh new file mode 100755 index 0000000..8be40ac --- /dev/null +++ b/goapp/ios/build_signed_ipa.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT="$ROOT/EmergencyPuncture/EmergencyPuncture.xcodeproj" +SCHEME="EmergencyPuncture" +TEAM_ID="${TEAM_ID:-${1:-}}" + +if [[ -z "${TEAM_ID}" ]]; then + echo "Usage:" + echo " TEAM_ID=XXXXXXXXXX $0" + echo "or" + echo " $0 XXXXXXXXXX" + exit 1 +fi + +DIST_DIR="$ROOT/dist-signed" +ARCHIVE_PATH="$DIST_DIR/EmergencyPuncture-${TEAM_ID}.xcarchive" +EXPORT_DIR="$DIST_DIR/export-debugging" +EXPORT_OPTIONS="$DIST_DIR/exportOptions-debugging.plist" + +mkdir -p "$DIST_DIR" + +cat > "$EXPORT_OPTIONS" < + + + + method + debugging + signingStyle + automatic + teamID + ${TEAM_ID} + stripSwiftSymbols + + compileBitcode + + + +PLIST + +echo "==> Archiving with Team ID ${TEAM_ID}" +xcodebuild \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration Release \ + -destination "generic/platform=iOS" \ + -archivePath "$ARCHIVE_PATH" \ + DEVELOPMENT_TEAM="$TEAM_ID" \ + CODE_SIGN_STYLE=Automatic \ + -allowProvisioningUpdates \ + clean archive + +echo "==> Exporting IPA" +xcodebuild \ + -exportArchive \ + -archivePath "$ARCHIVE_PATH" \ + -exportPath "$EXPORT_DIR" \ + -exportOptionsPlist "$EXPORT_OPTIONS" \ + -allowProvisioningUpdates + +IPA_PATH="$(ls -1 "$EXPORT_DIR"/*.ipa | head -n1 || true)" +if [[ -z "$IPA_PATH" ]]; then + echo "No IPA was produced. Check Xcode export logs for signing/provisioning errors." + exit 2 +fi + +echo "Done." +echo "Archive: $ARCHIVE_PATH" +echo "IPA: $IPA_PATH" diff --git a/goapp/packaging/macos/AppIcon.icns b/goapp/packaging/macos/AppIcon.icns new file mode 100644 index 0000000..fb32d92 Binary files /dev/null and b/goapp/packaging/macos/AppIcon.icns differ diff --git a/goapp/packaging/macos/Info.plist b/goapp/packaging/macos/Info.plist new file mode 100644 index 0000000..a089379 --- /dev/null +++ b/goapp/packaging/macos/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + Puncture + CFBundleIdentifier + com.puncture.go + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Puncture Go + CFBundleIconFile + AppIcon + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 13.0 + NSHighResolutionCapable + + + diff --git a/goapp/packaging/macos/build_dmg.sh b/goapp/packaging/macos/build_dmg.sh new file mode 100755 index 0000000..26136da --- /dev/null +++ b/goapp/packaging/macos/build_dmg.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DIST_DIR="$ROOT/dist" +APP_NAME="Puncture" +APP_DIR="$DIST_DIR/${APP_NAME}.app" +DMG_PATH="$DIST_DIR/${APP_NAME}.dmg" +IOS_PROJECT="$ROOT/ios/EmergencyPuncture/EmergencyPuncture.xcodeproj" +IOS_DERIVED="$ROOT/ios/build-ios" +IOS_SIM_APP="$IOS_DERIVED/Build/Products/Debug-iphonesimulator/EmergencyPuncture.app" + +mkdir -p "$DIST_DIR" +rm -rf "$APP_DIR" "$DMG_PATH" +mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources" + +cp "$ROOT/packaging/macos/Info.plist" "$APP_DIR/Contents/Info.plist" +if [[ -f "$ROOT/packaging/macos/AppIcon.icns" ]]; then + cp "$ROOT/packaging/macos/AppIcon.icns" "$APP_DIR/Contents/Resources/AppIcon.icns" +fi + +pushd "$ROOT" >/dev/null +CGO_ENABLED=1 go build -tags desktop -o "$APP_DIR/Contents/MacOS/$APP_NAME" ./cmd/desktop +popd >/dev/null + +# Bundle iOS companion app for automatic Simulator launch from the desktop app. +if command -v xcodebuild >/dev/null 2>&1 && [[ -d "$IOS_PROJECT" ]]; then + echo "Building iOS simulator companion app..." + if xcodebuild \ + -project "$IOS_PROJECT" \ + -scheme EmergencyPuncture \ + -configuration Debug \ + -destination "generic/platform=iOS Simulator" \ + -derivedDataPath "$IOS_DERIVED" \ + CODE_SIGNING_ALLOWED=NO \ + build >/dev/null 2>&1; then + if [[ -d "$IOS_SIM_APP" ]]; then + rm -rf "$APP_DIR/Contents/Resources/EmergencyPuncture.app" + cp -R "$IOS_SIM_APP" "$APP_DIR/Contents/Resources/EmergencyPuncture.app" + echo "Bundled companion app: $IOS_SIM_APP" + fi + else + echo "Warning: could not build iOS simulator companion app; continuing without bundling." + fi +else + echo "Warning: xcodebuild or iOS project missing; companion app was not bundled." +fi + +chmod +x "$APP_DIR/Contents/MacOS/$APP_NAME" + +# Local ad-hoc signing for smoother first launch on macOS. +if command -v codesign >/dev/null 2>&1; then + codesign --force --deep --sign - "$APP_DIR" || true +fi + +hdiutil create -volname "${APP_NAME}" -srcfolder "$APP_DIR" -ov -format UDZO "$DMG_PATH" + +echo "Created app bundle: $APP_DIR" +echo "Created installer DMG: $DMG_PATH" diff --git a/goapp/packaging/macos/icon_work/AppIcon-1024.png b/goapp/packaging/macos/icon_work/AppIcon-1024.png new file mode 100644 index 0000000..37864e3 Binary files /dev/null and b/goapp/packaging/macos/icon_work/AppIcon-1024.png differ diff --git a/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_128x128.png b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_128x128.png new file mode 100644 index 0000000..900d4ef Binary files /dev/null and b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_128x128.png differ diff --git a/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_128x128@2x.png b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_128x128@2x.png new file mode 100644 index 0000000..9a9f557 Binary files /dev/null and b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_128x128@2x.png differ diff --git a/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_16x16.png b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_16x16.png new file mode 100644 index 0000000..9466ffd Binary files /dev/null and b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_16x16.png differ diff --git a/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_16x16@2x.png b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_16x16@2x.png new file mode 100644 index 0000000..f583c3e Binary files /dev/null and b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_16x16@2x.png differ diff --git a/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_256x256.png b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_256x256.png new file mode 100644 index 0000000..9a9f557 Binary files /dev/null and b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_256x256.png differ diff --git a/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_256x256@2x.png b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_256x256@2x.png new file mode 100644 index 0000000..25aa18a Binary files /dev/null and b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_256x256@2x.png differ diff --git a/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_32x32.png b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_32x32.png new file mode 100644 index 0000000..f583c3e Binary files /dev/null and b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_32x32.png differ diff --git a/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_32x32@2x.png b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_32x32@2x.png new file mode 100644 index 0000000..efc86c3 Binary files /dev/null and b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_32x32@2x.png differ diff --git a/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_512x512.png b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_512x512.png new file mode 100644 index 0000000..25aa18a Binary files /dev/null and b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_512x512.png differ diff --git a/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_512x512@2x.png b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_512x512@2x.png new file mode 100644 index 0000000..37864e3 Binary files /dev/null and b/goapp/packaging/macos/icon_work/AppIcon.iconset/icon_512x512@2x.png differ diff --git a/goapp/packaging/macos/icon_work/generate_icon.swift b/goapp/packaging/macos/icon_work/generate_icon.swift new file mode 100644 index 0000000..176d137 --- /dev/null +++ b/goapp/packaging/macos/icon_work/generate_icon.swift @@ -0,0 +1,121 @@ +import AppKit +import ImageIO +import UniformTypeIdentifiers + +let size = CGFloat(1024) +let canvas = NSImage(size: NSSize(width: size, height: size)) + +canvas.lockFocus() +defer { canvas.unlockFocus() } + +let full = NSRect(x: 0, y: 0, width: size, height: size) +NSColor.clear.setFill() +full.fill() + +let plateRect = NSRect(x: 42, y: 42, width: size - 84, height: size - 84) +let plate = NSBezierPath(roundedRect: plateRect, xRadius: 210, yRadius: 210) +let bg = NSGradient(colors: [ + NSColor(calibratedRed: 0.05, green: 0.54, blue: 0.66, alpha: 1.0), + NSColor(calibratedRed: 0.05, green: 0.34, blue: 0.58, alpha: 1.0), + NSColor(calibratedRed: 0.10, green: 0.18, blue: 0.35, alpha: 1.0) +])! +bg.draw(in: plate, angle: -35) + +let gloss = NSGradient(colors: [ + NSColor(calibratedWhite: 1.0, alpha: 0.28), + NSColor(calibratedWhite: 1.0, alpha: 0.04), + NSColor(calibratedWhite: 1.0, alpha: 0.00) +])! +let glossPath = NSBezierPath(ovalIn: NSRect(x: 120, y: 585, width: 780, height: 360)) +gloss.draw(in: glossPath, angle: -65) + +let ringRect = NSRect(x: 168, y: 158, width: 688, height: 688) +let ringOuter = NSBezierPath(ovalIn: ringRect) +NSColor(calibratedRed: 0.62, green: 0.93, blue: 0.92, alpha: 0.22).setStroke() +ringOuter.lineWidth = 18 +ringOuter.stroke() + +let line = NSBezierPath() +line.lineCapStyle = .round +line.lineJoinStyle = .round +line.lineWidth = 22 + +func seg(_ x1: CGFloat, _ y1: CGFloat, _ x2: CGFloat, _ y2: CGFloat) { + line.move(to: NSPoint(x: x1, y: y1)) + line.line(to: NSPoint(x: x2, y: y2)) +} + +seg(512, 770, 368, 640) +seg(512, 770, 656, 640) +seg(368, 640, 290, 520) +seg(368, 640, 450, 520) +seg(656, 640, 578, 520) +seg(656, 640, 736, 520) +seg(736, 520, 790, 405) +NSColor(calibratedRed: 0.78, green: 0.95, blue: 0.98, alpha: 0.90).setStroke() +line.stroke() + +let cut = NSBezierPath() +cut.lineWidth = 24 +cut.lineCapStyle = .round +cut.move(to: NSPoint(x: 740, y: 560)) +cut.line(to: NSPoint(x: 834, y: 452)) +NSColor(calibratedRed: 1.0, green: 0.28, blue: 0.31, alpha: 0.96).setStroke() +cut.stroke() + +func dot(_ x: CGFloat, _ y: CGFloat, _ r: CGFloat, _ color: NSColor) { + let p = NSBezierPath(ovalIn: NSRect(x: x - r, y: y - r, width: r * 2, height: r * 2)) + color.setFill() + p.fill() +} + +dot(512, 770, 27, NSColor(calibratedRed: 0.93, green: 0.99, blue: 1.0, alpha: 0.96)) +let nodeColor = NSColor(calibratedRed: 0.86, green: 0.97, blue: 0.99, alpha: 0.95) +for p in [ + (368.0, 640.0), (656.0, 640.0), + (290.0, 520.0), (450.0, 520.0), (578.0, 520.0), (736.0, 520.0), + (790.0, 405.0) +] { + dot(CGFloat(p.0), CGFloat(p.1), 18, nodeColor) +} + +dot(790, 405, 16, NSColor(calibratedRed: 1.0, green: 0.58, blue: 0.62, alpha: 0.98)) + +let lockBody = NSBezierPath(roundedRect: NSRect(x: 372, y: 225, width: 280, height: 248), xRadius: 62, yRadius: 62) +NSColor(calibratedRed: 0.03, green: 0.16, blue: 0.28, alpha: 0.94).setFill() +lockBody.fill() +NSColor(calibratedRed: 0.75, green: 0.95, blue: 0.99, alpha: 0.92).setStroke() +lockBody.lineWidth = 10 +lockBody.stroke() + +let shackle = NSBezierPath() +shackle.lineWidth = 28 +shackle.lineCapStyle = .round +shackle.move(to: NSPoint(x: 430, y: 474)) +shackle.curve(to: NSPoint(x: 594, y: 474), controlPoint1: NSPoint(x: 430, y: 560), controlPoint2: NSPoint(x: 594, y: 560)) +NSColor(calibratedRed: 0.82, green: 0.97, blue: 1.0, alpha: 0.94).setStroke() +shackle.stroke() + +let keyhole = NSBezierPath(ovalIn: NSRect(x: 492, y: 324, width: 40, height: 54)) +NSColor(calibratedRed: 0.81, green: 0.97, blue: 1.0, alpha: 0.95).setFill() +keyhole.fill() +let stem = NSBezierPath(roundedRect: NSRect(x: 505, y: 286, width: 14, height: 58), xRadius: 7, yRadius: 7) +stem.fill() + +guard let cgImage = NSBitmapImageRep(focusedViewRect: full)?.cgImage else { + fputs("Failed to capture rendered image\n", stderr) + exit(1) +} + +let outURL = URL(fileURLWithPath: "/Users/oho/GitClone/CodexProjects/puncture/goapp/packaging/macos/icon_work/AppIcon-1024.png") +guard let destination = CGImageDestinationCreateWithURL(outURL as CFURL, UTType.png.identifier as CFString, 1, nil) else { + fputs("Failed to create image destination\n", stderr) + exit(1) +} +CGImageDestinationAddImage(destination, cgImage, nil) +if CGImageDestinationFinalize(destination) { + print("Wrote \(outURL.path)") +} else { + fputs("Failed to finalize PNG destination\n", stderr) + exit(1) +} diff --git a/puncture/__init__.py b/puncture/__init__.py new file mode 100644 index 0000000..35f11e9 --- /dev/null +++ b/puncture/__init__.py @@ -0,0 +1,21 @@ +from .key_manager import ( + PATH_BITS, + PROVIDER_BITS, + RESOURCE_BITS, + PuncturableKeyManager, + Tag, + binary_path_to_tag, + provider_id_to_prefix, + tag_to_binary_path, +) + +__all__ = [ + "PATH_BITS", + "PROVIDER_BITS", + "RESOURCE_BITS", + "PuncturableKeyManager", + "Tag", + "binary_path_to_tag", + "provider_id_to_prefix", + "tag_to_binary_path", +] diff --git a/puncture/key_manager.py b/puncture/key_manager.py new file mode 100644 index 0000000..84d00bc --- /dev/null +++ b/puncture/key_manager.py @@ -0,0 +1,283 @@ +"""GGM-tree based puncturable key manager. + +This module implements a 32-bit tag space mapped as: +[7 bits provider_id] | [25 bits file/time_id] +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import os +from dataclasses import dataclass +from typing import Dict, Iterable, List, Optional + + +PROVIDER_BITS = 7 +RESOURCE_BITS = 25 +PATH_BITS = PROVIDER_BITS + RESOURCE_BITS +KEY_SIZE_BYTES = 32 + + +def _zeroize(buf: bytearray) -> None: + for i in range(len(buf)): + buf[i] = 0 + + +def _derive_child(parent_key: bytes | bytearray, bit: str) -> bytearray: + if bit not in {"0", "1"}: + raise ValueError("bit must be '0' or '1'") + marker = b"\x00" if bit == "0" else b"\x01" + child = hmac.new(bytes(parent_key), b"GGM" + marker, hashlib.sha256).digest() + return bytearray(child) + + +def _validate_binary_path(binary_path: str, expected_length: int = PATH_BITS) -> None: + if len(binary_path) != expected_length: + raise ValueError(f"binary_path must be {expected_length} bits") + if any(c not in "01" for c in binary_path): + raise ValueError("binary_path must contain only '0' or '1'") + + +def _validate_binary_prefix(binary_prefix: str, min_len: int = 1, max_len: int = PATH_BITS) -> None: + if not (min_len <= len(binary_prefix) <= max_len): + raise ValueError(f"binary_prefix must be between {min_len} and {max_len} bits") + if any(c not in "01" for c in binary_prefix): + raise ValueError("binary_prefix must contain only '0' or '1'") + + +@dataclass(frozen=True) +class Tag: + provider_id: int + file_time_id: int + + def to_binary_path(self) -> str: + return tag_to_binary_path(self.provider_id, self.file_time_id) + + +def tag_to_binary_path(provider_id: int, file_time_id: int) -> str: + if not (0 <= provider_id < (1 << PROVIDER_BITS)): + raise ValueError(f"provider_id must be in [0, {1 << PROVIDER_BITS})") + if not (0 <= file_time_id < (1 << RESOURCE_BITS)): + raise ValueError(f"file_time_id must be in [0, {1 << RESOURCE_BITS})") + + value = (provider_id << RESOURCE_BITS) | file_time_id + return f"{value:0{PATH_BITS}b}" + + +def provider_id_to_prefix(provider_id: int) -> str: + if not (0 <= provider_id < (1 << PROVIDER_BITS)): + raise ValueError(f"provider_id must be in [0, {1 << PROVIDER_BITS})") + return f"{provider_id:0{PROVIDER_BITS}b}" + + +def binary_path_to_tag(binary_path: str) -> Tag: + _validate_binary_path(binary_path) + value = int(binary_path, 2) + file_time_mask = (1 << RESOURCE_BITS) - 1 + provider_id = value >> RESOURCE_BITS + file_time_id = value & file_time_mask + return Tag(provider_id=provider_id, file_time_id=file_time_id) + + +class PuncturableKeyManager: + """Forward-secret key manager based on puncturable GGM keys. + + State model: + - The manager stores only a prefix-free set of active nodes (prefix -> seed). + - A puncture operation removes one covering ancestor node and replaces it with the + minimal set of sibling/co-path nodes needed to preserve all other leaves. + """ + + def __init__(self, master_seed: bytes): + if len(master_seed) != KEY_SIZE_BYTES: + raise ValueError(f"master_seed must be {KEY_SIZE_BYTES} bytes") + self._active_nodes: Dict[str, bytearray] = {"": bytearray(master_seed)} + self._puncture_log: List[str] = [] + self._punctured_paths: set[str] = set() + self._punctured_prefixes: set[str] = set() + + @staticmethod + def generate_master_seed() -> bytes: + return os.urandom(KEY_SIZE_BYTES) + + @property + def active_node_count(self) -> int: + return len(self._active_nodes) + + def active_prefixes(self) -> List[str]: + return sorted(self._active_nodes.keys(), key=lambda p: (len(p), p)) + + def puncture_log(self) -> List[str]: + return list(self._puncture_log) + + def export_puncture_log_json(self) -> str: + return json.dumps(self._puncture_log) + + def export_state(self) -> dict: + return { + "active_nodes": {prefix: bytes(seed).hex() for prefix, seed in self._active_nodes.items()}, + "puncture_log": list(self._puncture_log), + } + + @classmethod + def from_state(cls, state: dict) -> "PuncturableKeyManager": + # Seed is a placeholder; state fully replaces active nodes. + manager = cls(master_seed=b"\x00" * KEY_SIZE_BYTES) + + active_nodes = state.get("active_nodes") + if not isinstance(active_nodes, dict): + raise ValueError("state['active_nodes'] must be a dict") + + rebuilt_nodes: Dict[str, bytearray] = {} + for prefix, hex_seed in active_nodes.items(): + if any(c not in "01" for c in prefix): + raise ValueError("active node prefixes must be binary strings") + seed = bytes.fromhex(hex_seed) + if len(seed) != KEY_SIZE_BYTES: + raise ValueError("active node seed must be 32 bytes") + rebuilt_nodes[prefix] = bytearray(seed) + + manager._active_nodes = rebuilt_nodes + + puncture_log = state.get("puncture_log", []) + if not isinstance(puncture_log, list): + raise ValueError("state['puncture_log'] must be a list") + for bitstring in puncture_log: + _validate_binary_prefix(bitstring, min_len=1, max_len=PATH_BITS) + manager._puncture_log = list(puncture_log) + manager._punctured_paths = {b for b in puncture_log if len(b) == PATH_BITS} + manager._punctured_prefixes = {b for b in puncture_log if len(b) < PATH_BITS} + return manager + + def _find_covering_prefix(self, binary_path: str) -> Optional[str]: + for depth in range(len(binary_path), -1, -1): + prefix = binary_path[:depth] + if prefix in self._active_nodes: + return prefix + return None + + def get_key_for_tag(self, binary_path: str) -> Optional[bytes]: + _validate_binary_path(binary_path) + + cover = self._find_covering_prefix(binary_path) + if cover is None: + return None + + key: bytes | bytearray = self._active_nodes[cover] + for bit in binary_path[len(cover) :]: + key = _derive_child(key, bit) + return bytes(key) + + def get_key_for_provider_resource(self, provider_id: int, file_time_id: int) -> Optional[bytes]: + return self.get_key_for_tag(tag_to_binary_path(provider_id, file_time_id)) + + def puncture(self, binary_path: str) -> bool: + """Puncture a path using minimal co-path replacement. + + Returns: + True if a new puncture was applied. + False if the path was already punctured / inaccessible. + """ + + _validate_binary_path(binary_path) + + if binary_path in self._punctured_paths: + return False + if any(binary_path.startswith(prefix) for prefix in self._punctured_prefixes): + return False + + cover = self._find_covering_prefix(binary_path) + if cover is None: + # Already inaccessible due to earlier punctures. + self._punctured_paths.add(binary_path) + self._puncture_log.append(binary_path) + return False + + current_key = self._active_nodes.pop(cover) + + for depth in range(len(cover), PATH_BITS): + bit = binary_path[depth] + sibling_bit = "1" if bit == "0" else "0" + + sibling_key = _derive_child(current_key, sibling_bit) + sibling_prefix = binary_path[:depth] + sibling_bit + self._active_nodes[sibling_prefix] = sibling_key + + selected_key = _derive_child(current_key, bit) + _zeroize(current_key) + current_key = selected_key + + # Current leaf key has been punctured; zeroize immediately. + _zeroize(current_key) + + self._punctured_paths.add(binary_path) + self._puncture_log.append(binary_path) + return True + + def puncture_prefix(self, binary_prefix: str) -> bool: + """Puncture a full subtree identified by a prefix. + + Example: 7-bit provider prefix to revoke all keys for that provider. + """ + + _validate_binary_prefix(binary_prefix, min_len=1, max_len=PATH_BITS) + if len(binary_prefix) == PATH_BITS: + return self.puncture(binary_prefix) + + if binary_prefix in self._punctured_prefixes: + return False + if any(binary_prefix.startswith(prefix) for prefix in self._punctured_prefixes): + return False + + changed = False + + cover = self._find_covering_prefix(binary_prefix) + if cover is not None: + current_key = self._active_nodes.pop(cover) + changed = True + + for depth in range(len(cover), len(binary_prefix)): + bit = binary_prefix[depth] + sibling_bit = "1" if bit == "0" else "0" + + sibling_key = _derive_child(current_key, sibling_bit) + sibling_prefix = binary_prefix[:depth] + sibling_bit + self._active_nodes[sibling_prefix] = sibling_key + + selected_key = _derive_child(current_key, bit) + _zeroize(current_key) + current_key = selected_key + + # Punctured subtree root key should not remain in memory. + _zeroize(current_key) + + descendants = [node for node in self._active_nodes.keys() if node.startswith(binary_prefix)] + if descendants: + changed = True + for node in descendants: + doomed = self._active_nodes.pop(node) + _zeroize(doomed) + + self._punctured_prefixes.add(binary_prefix) + self._puncture_log.append(binary_prefix) + return changed + + def puncture_provider_resource(self, provider_id: int, file_time_id: int) -> bool: + return self.puncture(tag_to_binary_path(provider_id, file_time_id)) + + def puncture_provider(self, provider_id: int) -> bool: + return self.puncture_prefix(provider_id_to_prefix(provider_id)) + + def apply_puncture_log(self, puncture_paths: Iterable[str]) -> int: + applied = 0 + for bitstring in puncture_paths: + _validate_binary_prefix(bitstring, min_len=1, max_len=PATH_BITS) + if len(bitstring) == PATH_BITS: + changed = self.puncture(bitstring) + else: + changed = self.puncture_prefix(bitstring) + if changed: + applied += 1 + return applied diff --git a/puncture/simulation.py b/puncture/simulation.py new file mode 100644 index 0000000..f0864c8 --- /dev/null +++ b/puncture/simulation.py @@ -0,0 +1,94 @@ +"""Simulation scenarios for puncturable key manager.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + +from .key_manager import PuncturableKeyManager, tag_to_binary_path + + +@dataclass +class ScenarioResult: + scenario: str + passed: bool + details: dict + + +def run_scenario_a() -> ScenarioResult: + """Scenario A: Upload file to Provider 42, then puncture its key.""" + + seed = PuncturableKeyManager.generate_master_seed() + manager = PuncturableKeyManager(seed) + + provider_id = 42 + file_time_id = 123456 + tag = tag_to_binary_path(provider_id, file_time_id) + + key_before = manager.get_key_for_tag(tag) + punctured = manager.puncture(tag) + key_after = manager.get_key_for_tag(tag) + + passed = key_before is not None and punctured and key_after is None + return ScenarioResult( + scenario="A", + passed=passed, + details={ + "provider_id": provider_id, + "file_time_id": file_time_id, + "path": tag, + "key_before_exists": key_before is not None, + "puncture_applied": punctured, + "key_after_exists": key_after is not None, + "active_nodes": manager.active_node_count, + }, + ) + + +def run_scenario_b() -> ScenarioResult: + """Scenario B: seized node-set cannot derive punctured key but can derive others.""" + + seed = PuncturableKeyManager.generate_master_seed() + manager = PuncturableKeyManager(seed) + + punctured_path = tag_to_binary_path(42, 777777) + control_path = tag_to_binary_path(42, 777778) + + control_before = manager.get_key_for_tag(control_path) + manager.puncture(punctured_path) + + # Simulate nation-state seizure of *current* active node-set only. + seized_state = manager.export_state() + seized_manager = PuncturableKeyManager.from_state(seized_state) + + punctured_from_seized = seized_manager.get_key_for_tag(punctured_path) + control_from_seized = seized_manager.get_key_for_tag(control_path) + + passed = punctured_from_seized is None and control_before == control_from_seized + return ScenarioResult( + scenario="B", + passed=passed, + details={ + "punctured_path": punctured_path, + "control_path": control_path, + "punctured_recoverable_from_seized": punctured_from_seized is not None, + "control_key_still_recoverable": control_from_seized is not None, + "control_key_matches_pre_puncture": control_before == control_from_seized, + "active_nodes_seized": seized_manager.active_node_count, + }, + ) + + +def run_all() -> dict: + scenario_a = run_scenario_a() + scenario_b = run_scenario_b() + + return { + "scenario_a": {"passed": scenario_a.passed, "details": scenario_a.details}, + "scenario_b": {"passed": scenario_b.passed, "details": scenario_b.details}, + "overall_passed": scenario_a.passed and scenario_b.passed, + } + + +if __name__ == "__main__": + print(json.dumps(run_all(), indent=2)) diff --git a/puncture/view_app.py b/puncture/view_app.py new file mode 100644 index 0000000..5cf7d07 --- /dev/null +++ b/puncture/view_app.py @@ -0,0 +1,477 @@ +"""Secondary read-only live view app with password auth and kill-switch login.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import urllib.error +import urllib.request +from typing import Any, Dict, Optional + +from flask import Flask, redirect, render_template_string, request, session, url_for + + +def _master_url() -> str: + return os.getenv("PUNCTURE_MASTER_URL", "http://127.0.0.1:9122").rstrip("/") + + +def _master_token() -> str: + return os.getenv("PUNCTURE_REMOTE_TOKEN", "").strip() + + +def _viewer_password() -> str: + return os.getenv("PUNCTURE_SECONDARY_PASSWORD", "puncture-view") + + +def _build_request(path: str, *, method: str = "GET", payload: Optional[dict] = None) -> urllib.request.Request: + url = _master_url() + path + headers = {"Content-Type": "application/json"} + token = _master_token() + if token: + headers["X-Puncture-Token"] = token + + data = None + if payload is not None: + data = json.dumps(payload).encode("utf-8") + + return urllib.request.Request(url, method=method, data=data, headers=headers) + + +def _fetch_master_state() -> Dict[str, Any]: + req = _build_request("/api/live/state") + with urllib.request.urlopen(req, timeout=8) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def _remote_puncture_provider(provider_id: int) -> Dict[str, Any]: + req = _build_request( + "/api/remote/puncture-provider", + method="POST", + payload={"provider_id": provider_id}, + ) + with urllib.request.urlopen(req, timeout=8) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def _parse_kill_switch(raw_password: str, base_password: str) -> Optional[int]: + if raw_password == base_password: + return None + if not raw_password.startswith(base_password): + return None + + suffix = raw_password[len(base_password) :] + if not re.fullmatch(r"\d{1,3}", suffix or ""): + return None + + provider_id = int(suffix) + if not (0 <= provider_id <= 127): + return None + return provider_id + + +def create_app() -> Flask: + app = Flask(__name__) + app.secret_key = os.getenv("PUNCTURE_SECONDARY_SECRET", "puncture-secondary-secret") + + def _is_auth() -> bool: + return bool(session.get("auth_ok")) + + def _set_notice(tone: str, message: str) -> None: + session["notice"] = {"tone": tone, "message": message} + + def _pull_notice() -> Optional[Dict[str, Any]]: + return session.pop("notice", None) + + @app.get("/login") + def login_page() -> str: + if _is_auth(): + return redirect(url_for("dashboard")) + notice = _pull_notice() + return render_template_string(LOGIN_HTML, notice=notice, master_url=_master_url()) + + @app.post("/login") + def login_submit() -> Any: + entered = request.form.get("password", "") + base = _viewer_password() + + if entered == base: + session["auth_ok"] = True + _set_notice("success", "Authenticated.") + return redirect(url_for("dashboard")) + + kill_provider = _parse_kill_switch(entered, base) + if kill_provider is not None: + try: + result = _remote_puncture_provider(kill_provider) + if not result.get("ok"): + raise ValueError(result.get("error", "kill switch request failed")) + session["auth_ok"] = True + _set_notice( + "warn", + ( + f"Kill switch activated for provider {kill_provider}. " + "All keys under this provider were punctured on master." + ), + ) + return redirect(url_for("dashboard")) + except Exception as exc: + _set_notice("danger", f"Kill switch failed: {exc}") + return redirect(url_for("login_page")) + + _set_notice("danger", "Authentication failed.") + return redirect(url_for("login_page")) + + @app.post("/logout") + def logout() -> Any: + session.clear() + _set_notice("info", "Logged out.") + return redirect(url_for("login_page")) + + @app.get("/") + def dashboard() -> str: + if not _is_auth(): + return redirect(url_for("login_page")) + + notice = _pull_notice() + try: + live = _fetch_master_state() + providers = live.get("providers", []) + key_journal = live.get("key_journal", []) + assets = live.get("assets", {}) + + return render_template_string( + DASHBOARD_HTML, + notice=notice, + fetch_error=None, + generated_at=live.get("generated_at"), + master_url=_master_url(), + provider_count=len(providers), + key_count=len(key_journal), + mapping_count=int(assets.get("mapping_count", 0)), + blocked_count=int(assets.get("blocked_count", 0)), + providers=providers, + key_journal=key_journal, + asset_files=assets.get("asset_files", []), + key_cards=assets.get("key_cards", []), + ) + except Exception as exc: + return render_template_string( + DASHBOARD_HTML, + notice=notice, + fetch_error=str(exc), + generated_at=None, + master_url=_master_url(), + provider_count=0, + key_count=0, + mapping_count=0, + blocked_count=0, + providers=[], + key_journal=[], + asset_files=[], + key_cards=[], + ) + + @app.get("/api/state") + def api_state() -> Dict[str, Any]: + if not _is_auth(): + return {"ok": False, "error": "unauthenticated"}, 401 + live = _fetch_master_state() + return {"ok": True, "live": live} + + return app + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run puncture secondary live-view app") + parser.add_argument("--host", default=os.getenv("PUNCTURE_VIEW_HOST", "0.0.0.0")) + parser.add_argument("--port", type=int, default=int(os.getenv("PUNCTURE_VIEW_PORT", "9222"))) + args = parser.parse_args() + + app = create_app() + app.run(host=args.host, port=args.port, debug=False) + + +LOGIN_HTML = """ + + + + + + Secondary Access + + + +
+

Secondary Live Viewer Login

+

Master source: {{ master_url }}

+

Kill switch format: `password` + `provider_id` (for example `secret42`).

+ + {% if notice %} +
{{ notice.message }}
+ {% endif %} + +
+ + + +
+
+ + +""" + + +DASHBOARD_HTML = """ + + + + + + Secondary Live Viewer + + + +
+
+
+
+ +
+

Secondary Live Viewer

+

Realtime mirror from {{ master_url }} | last fetch: {{ generated_at or 'failed' }}

+ {% if notice %} +
{{ notice.message }}
+ {% endif %} + {% if fetch_error %} +
Master fetch failed: {{ fetch_error }}
+ {% endif %} +
+
Providers
{{ provider_count }}
+
Key IDs tracked
{{ key_count }}
+
Ciphertext mappings
{{ mapping_count }}
+
Blocked mappings
{{ blocked_count }}
+
+
+ +
+
+

Providers

+ {% if providers %} + {% for provider in providers %} +
+ ID {{ provider.provider_id }} - {{ provider.name }} + {% if provider.description %}
{{ provider.description }}
{% endif %} +
Prefix {{ provider.prefix }}
+
Derived IDs: {{ provider.derived_count }} | Punctured IDs: {{ provider.punctured_count }} | Key rows: {{ provider.key_count }}
+
+ {% endfor %} + {% else %} +

No provider data.

+ {% endif %} +
+ +
+

Key Journal

+ {% if key_journal %} + {% for key in key_journal %} +
+ Provider {{ key.provider_id }} | Key ID {{ key.file_time_id }} +
{{ key.path_provider }} | {{ key.path_resource }}
+
Derived {{ key.derive_count }}x | Punctured {{ key.puncture_count }}x
+ {% if key.description %}
Purpose: {{ key.description }}
{% endif %} +
+ {% endfor %} + {% else %} +

No keys tracked.

+ {% endif %} +
+
+ +
+

Assets and Ciphertexts

+ {% if asset_files %} + {% for file in asset_files %} +
+ {{ file.plaintext_relpath }} +
Mappings: {{ file.mapping_count }} | Blocked: {{ file.blocked_count }}
+ {% for row in file.mappings %} +
+
Provider {{ row.provider_id }} | Key ID {{ row.file_time_id }}
+
cipher: {{ row.ciphertext_relpath }}
+
tag: {{ row.path_provider }} | {{ row.path_resource }}
+
Status: {{ 'decryptable' if row.is_accessible else 'blocked by puncture' }}
+
+ {% endfor %} +
+ {% endfor %} + {% else %} +

No asset mappings found.

+ {% endif %} +
+
+ + + + +""" + + +if __name__ == "__main__": + main() diff --git a/puncture/view_sync.py b/puncture/view_sync.py new file mode 100644 index 0000000..0c2f8c7 --- /dev/null +++ b/puncture/view_sync.py @@ -0,0 +1,135 @@ +"""Sync bundle helpers for read-only companion viewer app.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +from datetime import datetime, timezone +from typing import Any, Dict, Optional + + +def _canonical_json(data: Any) -> str: + return json.dumps(data, sort_keys=True, separators=(",", ":")) + + +def _utc_now_label() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def sign_payload(payload: Dict[str, Any], sync_key: str) -> str: + digest = hmac.new(sync_key.encode("utf-8"), _canonical_json(payload).encode("utf-8"), hashlib.sha256) + return digest.hexdigest() + + +def verify_payload_signature(payload: Dict[str, Any], signature_hex: str, sync_key: str) -> bool: + expected = sign_payload(payload, sync_key) + return hmac.compare_digest(expected, signature_hex) + + +def build_view_payload(system: Dict[str, Any], puncture_log: list[str]) -> Dict[str, Any]: + providers = [] + for provider_id in sorted(system["providers"].keys()): + src = system["providers"][provider_id] + providers.append( + { + "provider_id": int(src["provider_id"]), + "name": str(src["name"]), + "description": str(src.get("description", "")), + "created_at": str(src.get("created_at", "")), + } + ) + + key_entries = [] + for entry in system.get("key_journal", {}).values(): + key_entries.append( + { + "provider_id": int(entry["provider_id"]), + "file_time_id": int(entry["file_time_id"]), + "path": str(entry["path"]), + "description": str(entry.get("description", "")), + "ever_derived": bool(entry.get("ever_derived", False)), + "ever_punctured": bool(entry.get("ever_punctured", False)), + "derive_count": int(entry.get("derive_count", 0)), + "puncture_count": int(entry.get("puncture_count", 0)), + "last_derived_at": entry.get("last_derived_at"), + "last_punctured_at": entry.get("last_punctured_at"), + } + ) + + key_entries.sort(key=lambda row: (row["provider_id"], row["file_time_id"])) + + allowed_paths = sorted( + row["path"] + for row in key_entries + if row["ever_derived"] and not row["ever_punctured"] + ) + + return { + "version": 1, + "generated_at": _utc_now_label(), + "providers": providers, + "known_keys": key_entries, + "allowed_paths": allowed_paths, + "puncture_log": list(puncture_log), + "deleted_providers": list(system.get("deleted_providers", [])), + } + + +def wrap_view_bundle(payload: Dict[str, Any], sync_key: Optional[str] = None) -> Dict[str, Any]: + if sync_key: + signature = sign_payload(payload, sync_key) + return {"payload": payload, "hmac_sha256": signature, "signed": True} + return {"payload": payload, "hmac_sha256": None, "signed": False} + + +def extract_view_payload( + bundle_or_payload: Dict[str, Any], + *, + sync_key: Optional[str] = None, + require_signature: bool = False, +) -> Dict[str, Any]: + if "payload" in bundle_or_payload and isinstance(bundle_or_payload["payload"], dict): + payload = bundle_or_payload["payload"] + signature = bundle_or_payload.get("hmac_sha256") + else: + payload = bundle_or_payload + signature = None + + if require_signature and not signature: + raise ValueError("signed bundle required") + + if signature: + if not sync_key: + raise ValueError("bundle is signed but no sync key is configured") + if not verify_payload_signature(payload, str(signature), sync_key): + raise ValueError("bundle signature verification failed") + + _validate_view_payload(payload) + return payload + + +def _validate_view_payload(payload: Dict[str, Any]) -> None: + if not isinstance(payload, dict): + raise ValueError("payload must be a dict") + + required_list_fields = ["providers", "known_keys", "allowed_paths", "puncture_log"] + for field in required_list_fields: + if not isinstance(payload.get(field), list): + raise ValueError(f"payload['{field}'] must be a list") + + for provider in payload["providers"]: + if not isinstance(provider, dict): + raise ValueError("providers entries must be objects") + if "provider_id" not in provider: + raise ValueError("provider entries must contain provider_id") + + for key in payload["known_keys"]: + if not isinstance(key, dict): + raise ValueError("known_keys entries must be objects") + for field in ["provider_id", "file_time_id", "path", "ever_derived", "ever_punctured"]: + if field not in key: + raise ValueError(f"known_keys entries must contain {field}") + + if payload.get("version") not in {1, None}: + raise ValueError("unsupported payload version") diff --git a/puncture/web_app.py b/puncture/web_app.py new file mode 100644 index 0000000..b60183f --- /dev/null +++ b/puncture/web_app.py @@ -0,0 +1,3762 @@ +"""Beginner-friendly web UI for puncturable key management. + +Run with: + python -m puncture.web_app --port 9122 +Then open from iPhone browser using your machine IP and port 9122. +""" + +from __future__ import annotations + +import argparse +import hashlib +import hmac +import os +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from flask import Flask, redirect, render_template_string, request, url_for +from werkzeug.utils import secure_filename + +from .key_manager import PATH_BITS, PuncturableKeyManager, provider_id_to_prefix, tag_to_binary_path +from .view_sync import build_view_payload, wrap_view_bundle + + +ENC_MAGIC = b"PKE1" +ENC_NONCE_SIZE = 16 +ENC_TAG_SIZE = 32 +TREE_VIEW_DEPTH = 7 + + +def _sort_prefixes(prefixes: list[str]) -> list[str]: + return sorted(prefixes, key=lambda item: (len(item), item)) + + +def _utc_now_label() -> str: + return datetime.now(timezone.utc).strftime("%H:%M:%S UTC") + + +def _default_last_action() -> Dict[str, Any]: + return { + "tone": "info", + "title": "Welcome", + "body": ( + "This lab helps you derive a key, puncture it, and verify that the same key cannot be derived again. " + "Start by deriving a key for a Provider ID + File/Time ID." + ), + "provider_id": None, + "file_time_id": None, + "path": None, + "path_provider": None, + "path_resource": None, + "key_hex": None, + "key_description": None, + } + + +def _split_path_bits(path: Optional[str]) -> tuple[Optional[str], Optional[str]]: + if not path: + return None, None + return path[:7], path[7:] + + +def _set_last_action( + system: Dict[str, Any], + *, + tone: str, + title: str, + body: str, + provider_id: Optional[int] = None, + file_time_id: Optional[int] = None, + path: Optional[str] = None, + key_hex: Optional[str] = None, + key_description: Optional[str] = None, +) -> None: + path_provider, path_resource = _split_path_bits(path) + system["last_action"] = { + "tone": tone, + "title": title, + "body": body, + "provider_id": provider_id, + "file_time_id": file_time_id, + "path": path, + "path_provider": path_provider, + "path_resource": path_resource, + "key_hex": key_hex, + "key_description": key_description, + } + + +def _record_history( + system: Dict[str, Any], + *, + action: str, + status: str, + summary: str, + provider_id: Optional[int] = None, + file_time_id: Optional[int] = None, + path: Optional[str] = None, +) -> None: + history = system["history"] + history.insert( + 0, + { + "time": _utc_now_label(), + "action": action, + "status": status, + "summary": summary, + "provider_id": provider_id, + "file_time_id": file_time_id, + "path": path, + }, + ) + del history[24:] + + +def _default_providers() -> Dict[int, Dict[str, Any]]: + created_at = _utc_now_label() + return { + 42: { + "provider_id": 42, + "name": "Provider 42 (Demo)", + "description": "Default provider used in Scenario A walkthrough.", + "created_at": created_at, + }, + 17: { + "provider_id": 17, + "name": "Northwind Cloud", + "description": "Example provider entry. Edit or delete as needed.", + "created_at": created_at, + }, + 88: { + "provider_id": 88, + "name": "Blue Harbor Storage", + "description": "Example provider entry. Edit or delete as needed.", + "created_at": created_at, + }, + } + + +def _ensure_key_entry( + system: Dict[str, Any], + *, + provider_id: int, + file_time_id: int, + path: Optional[str] = None, +) -> Dict[str, Any]: + if path is None: + path = tag_to_binary_path(provider_id, file_time_id) + + journal = system["key_journal"] + entry = journal.get(path) + if entry is None: + path_provider, path_resource = _split_path_bits(path) + entry = { + "provider_id": provider_id, + "file_time_id": file_time_id, + "path": path, + "path_provider": path_provider, + "path_resource": path_resource, + "description": "", + "ever_derived": False, + "ever_punctured": False, + "derive_count": 0, + "puncture_count": 0, + "last_derived_at": None, + "last_punctured_at": None, + } + journal[path] = entry + return entry + + +def _touch_key_derive( + system: Dict[str, Any], + *, + provider_id: int, + file_time_id: int, + path: str, + description: str, +) -> Dict[str, Any]: + entry = _ensure_key_entry(system, provider_id=provider_id, file_time_id=file_time_id, path=path) + if description: + entry["description"] = description + entry["ever_derived"] = True + entry["derive_count"] += 1 + entry["last_derived_at"] = _utc_now_label() + return entry + + +def _touch_key_puncture( + system: Dict[str, Any], + *, + provider_id: int, + file_time_id: int, + path: str, + applied: bool, +) -> Dict[str, Any]: + entry = _ensure_key_entry(system, provider_id=provider_id, file_time_id=file_time_id, path=path) + entry["ever_punctured"] = True + if applied: + entry["puncture_count"] += 1 + entry["last_punctured_at"] = _utc_now_label() + return entry + + +def _asset_root_dir() -> str: + root = os.getenv("PUNCTURE_ASSET_ROOT", os.path.join(os.getcwd(), "assets")) + abs_root = os.path.abspath(root) + os.makedirs(abs_root, exist_ok=True) + return abs_root + + +def _normalize_relpath(rel_path: str) -> str: + if not rel_path: + raise ValueError("relative file path is required") + if os.path.isabs(rel_path): + raise ValueError("absolute paths are not allowed") + + normalized = os.path.normpath(rel_path).replace("\\", "/") + if normalized.startswith("../") or normalized == "..": + raise ValueError("path traversal is not allowed") + return normalized + + +def _asset_abs_path(asset_root: str, rel_path: str) -> str: + rel = _normalize_relpath(rel_path) + abs_path = os.path.abspath(os.path.join(asset_root, rel)) + if not abs_path.startswith(asset_root + os.sep) and abs_path != asset_root: + raise ValueError("file path escapes asset root") + return abs_path + + +def _list_plaintext_files(asset_root: str) -> list[str]: + files: list[str] = [] + for base, dirs, filenames in os.walk(asset_root): + dirs[:] = [d for d in dirs if not d.startswith(".")] + for name in filenames: + if name.endswith(".pke"): + continue + rel = os.path.relpath(os.path.join(base, name), asset_root) + files.append(rel.replace(os.sep, "/")) + return sorted(files) + + +def _format_bytes(count: int) -> str: + units = ["B", "KB", "MB", "GB"] + value = float(count) + idx = 0 + while value >= 1024.0 and idx < len(units) - 1: + value /= 1024.0 + idx += 1 + if idx == 0: + return f"{int(value)} {units[idx]}" + return f"{value:.1f} {units[idx]}" + + +def _list_plaintext_rows(asset_root: str) -> list[Dict[str, Any]]: + rows: list[Dict[str, Any]] = [] + for rel in _list_plaintext_files(asset_root): + abs_path = _asset_abs_path(asset_root, rel) + try: + size = os.path.getsize(abs_path) + except OSError: + size = 0 + try: + modified_at = datetime.fromtimestamp(os.path.getmtime(abs_path), timezone.utc).strftime( + "%Y-%m-%d %H:%M UTC" + ) + except OSError: + modified_at = "unknown" + rows.append( + { + "relpath": rel, + "size_bytes": size, + "size_label": _format_bytes(size), + "modified_at": modified_at, + } + ) + return rows + + +def _asset_lifecycle_state(mapping_count: int, blocked_count: int) -> str: + """Deterministic lifecycle state machine for a cleartext asset.""" + if mapping_count <= 0: + return "eligible" + if blocked_count <= 0: + return "encrypted_live" + if blocked_count < mapping_count: + return "encrypted_partial" + return "encrypted_blocked" + + +def _asset_lifecycle_label(state: str) -> str: + labels = { + "eligible": "Eligible", + "encrypted_live": "Encrypted (live)", + "encrypted_partial": "Encrypted (partially blocked)", + "encrypted_blocked": "Encrypted (fully blocked)", + } + return labels.get(state, state) + + +def _stream_xor(key: bytes, nonce: bytes, data: bytes) -> bytes: + out = bytearray(len(data)) + offset = 0 + counter = 0 + while offset < len(data): + block = hmac.new( + key, + b"ENC" + nonce + counter.to_bytes(8, byteorder="big"), + hashlib.sha256, + ).digest() + chunk = data[offset : offset + len(block)] + for i, value in enumerate(chunk): + out[offset + i] = value ^ block[i] + offset += len(chunk) + counter += 1 + return bytes(out) + + +def _encrypt_blob(key: bytes, plaintext: bytes) -> bytes: + nonce = os.urandom(ENC_NONCE_SIZE) + ciphertext = _stream_xor(key, nonce, plaintext) + tag = hmac.new(key, b"TAG" + nonce + ciphertext, hashlib.sha256).digest() + return ENC_MAGIC + nonce + tag + ciphertext + + +def _decrypt_blob(key: bytes, encrypted_blob: bytes) -> bytes: + minimum = len(ENC_MAGIC) + ENC_NONCE_SIZE + ENC_TAG_SIZE + if len(encrypted_blob) < minimum: + raise ValueError("ciphertext is too short") + if not encrypted_blob.startswith(ENC_MAGIC): + raise ValueError("ciphertext header mismatch") + + nonce_start = len(ENC_MAGIC) + nonce_end = nonce_start + ENC_NONCE_SIZE + tag_end = nonce_end + ENC_TAG_SIZE + nonce = encrypted_blob[nonce_start:nonce_end] + tag = encrypted_blob[nonce_end:tag_end] + ciphertext = encrypted_blob[tag_end:] + + expected_tag = hmac.new(key, b"TAG" + nonce + ciphertext, hashlib.sha256).digest() + if not hmac.compare_digest(tag, expected_tag): + raise ValueError("ciphertext authentication failed") + return _stream_xor(key, nonce, ciphertext) + + +def _next_ciphertext_relpath(asset_root: str, plaintext_relpath: str, provider_id: int, file_time_id: int) -> str: + rel = _normalize_relpath(plaintext_relpath) + directory = os.path.dirname(rel) + filename = os.path.basename(rel) + stem = f"{filename}.enc.p{provider_id}.k{file_time_id}" + + idx = 1 + while True: + suffix = ".pke" if idx == 1 else f".v{idx}.pke" + candidate_name = stem + suffix + candidate_rel = os.path.join(directory, candidate_name) if directory else candidate_name + candidate_abs = _asset_abs_path(asset_root, candidate_rel) + if not os.path.exists(candidate_abs): + return candidate_rel.replace(os.sep, "/") + idx += 1 + + +def _next_plaintext_relpath(asset_root: str, desired_relpath: str) -> str: + rel = _normalize_relpath(desired_relpath) + directory = os.path.dirname(rel) + filename = os.path.basename(rel) + stem, ext = os.path.splitext(filename) + + idx = 1 + while True: + candidate_name = filename if idx == 1 else f"{stem}.v{idx}{ext}" + candidate_rel = os.path.join(directory, candidate_name) if directory else candidate_name + candidate_abs = _asset_abs_path(asset_root, candidate_rel) + if not os.path.exists(candidate_abs): + return candidate_rel.replace(os.sep, "/") + idx += 1 + + +def _next_decrypted_relpath(asset_root: str, plaintext_relpath: str, provider_id: int, file_time_id: int) -> str: + rel = _normalize_relpath(plaintext_relpath) + target = f"{rel}.dec.p{provider_id}.k{file_time_id}" + return _next_plaintext_relpath(asset_root, target) + + +def _new_system() -> Dict[str, Any]: + seed = PuncturableKeyManager.generate_master_seed() + manager = PuncturableKeyManager(seed) + return { + "manager": manager, + "history": [], + "last_inputs": {"provider_id": 42, "file_time_id": 123456, "purpose": "Demo key for provider onboarding"}, + "last_action": _default_last_action(), + "providers": _default_providers(), + "deleted_providers": [], + "providers_notice": None, + "key_journal": {}, + "last_puncture_diff": None, + "asset_root": _asset_root_dir(), + "asset_records": [], + "asset_notice": None, + } + + +def _active_frontier_rows(manager: PuncturableKeyManager) -> list[Dict[str, Any]]: + rows: list[Dict[str, Any]] = [] + for prefix in manager.active_prefixes(): + depth = len(prefix) + wildcard_bits = PATH_BITS - depth + provider_bits = prefix[:7] if depth >= 1 else "" + resource_bits = prefix[7:] if depth > 7 else "" + rows.append( + { + "prefix": prefix, + "depth": depth, + "wildcard_bits": wildcard_bits, + "is_root": depth == 0, + "provider_bits": provider_bits, + "resource_bits": resource_bits, + "coverage_label": ( + "Covers the entire 32-bit tag space (all providers, all file/time IDs)." + if depth == 0 + else f"Covers all tags with this prefix; {wildcard_bits} wildcard bit(s) remain." + ), + } + ) + return rows + + +def _prefix_intersects_active(prefix: str, active_prefixes: list[str]) -> bool: + for frontier in active_prefixes: + if frontier.startswith(prefix) or prefix.startswith(frontier): + return True + return False + + +def _project_prefixes(prefixes: list[str], max_depth: int) -> set[str]: + return {item if len(item) <= max_depth else item[:max_depth] for item in prefixes} + + +def _derived_prefixes(system: Dict[str, Any], max_depth: int) -> set[str]: + prefixes: set[str] = set() + for entry in system["key_journal"].values(): + if not entry.get("ever_derived"): + continue + path = str(entry["path"]) + stop = min(max_depth, len(path)) + for depth in range(stop + 1): + prefixes.add(path[:depth]) + return prefixes + + +def _set_last_puncture_diff( + system: Dict[str, Any], + *, + before_frontier: list[str], + after_frontier: list[str], + target_bitstring: str, + target_kind: str, +) -> None: + before_set = set(before_frontier) + after_set = set(after_frontier) + removed = _sort_prefixes([prefix for prefix in before_frontier if prefix not in after_set]) + added = _sort_prefixes([prefix for prefix in after_frontier if prefix not in before_set]) + system["last_puncture_diff"] = { + "time": _utc_now_label(), + "target": target_bitstring, + "target_kind": target_kind, + "removed": removed, + "added": added, + } + + +def _node_x(prefix: str, *, depth: int, slot_width: float, margin_x: float) -> float: + if not prefix: + leaf_slots = 1 << depth + return margin_x + (leaf_slots * slot_width) / 2.0 + + left_index = int(prefix, 2) * (1 << (depth - len(prefix))) + span = 1 << (depth - len(prefix)) + center_index = left_index + span / 2.0 + return margin_x + center_index * slot_width + + +def _tree_visualization_bundle(system: Dict[str, Any], manager: PuncturableKeyManager) -> Dict[str, Any]: + depth = TREE_VIEW_DEPTH + active_prefixes = manager.active_prefixes() + derived_prefixes = _derived_prefixes(system, depth) + + frontier_exact = {prefix for prefix in active_prefixes if len(prefix) <= depth} + frontier_proxy = {prefix[:depth] for prefix in active_prefixes if len(prefix) > depth} + + last_diff = system.get("last_puncture_diff") or {} + removed_raw = list(last_diff.get("removed", [])) + removed_exact = {prefix for prefix in removed_raw if len(prefix) <= depth} + removed_proxy = {prefix[:depth] for prefix in removed_raw if len(prefix) > depth} + + slot_width = 22.0 + level_height = 86.0 + margin_x = 26.0 + margin_top = 34.0 + leaf_slots = 1 << depth + width = int(margin_x * 2 + leaf_slots * slot_width) + height = int(margin_top + depth * level_height + 64) + + node_status: Dict[str, str] = {} + node_title: Dict[str, str] = {} + for level in range(depth + 1): + for idx in range(1 << level): + prefix = "" if level == 0 else format(idx, f"0{level}b") + possible = _prefix_intersects_active(prefix, active_prefixes) + + if prefix in removed_exact: + status = "removed" + elif level == depth and prefix in removed_proxy: + status = "removed_proxy" + elif prefix in frontier_exact: + status = "frontier" + elif level == depth and prefix in frontier_proxy: + status = "frontier_proxy" + elif not possible: + status = "blocked" + elif prefix in derived_prefixes: + status = "derived" + else: + status = "possible" + + node_status[prefix] = status + + if prefix == "": + label = "seed root" + else: + label = f"prefix {prefix}" + if status == "frontier": + label += " (current frontier)" + elif status == "frontier_proxy": + label += " (frontier continues below visible depth)" + elif status == "removed": + label += " (deleted frontier from last puncture)" + elif status == "removed_proxy": + label += " (deleted frontier below visible depth)" + elif status == "blocked": + label += " (future derivation impossible)" + elif status == "derived": + label += " (contains previously derived path)" + else: + label += " (future derivation still possible)" + node_title[prefix] = label + + edges_svg: list[str] = [] + for level in range(depth): + for idx in range(1 << level): + parent = "" if level == 0 else format(idx, f"0{level}b") + px = _node_x(parent, depth=depth, slot_width=slot_width, margin_x=margin_x) + py = margin_top + level * level_height + for bit in ("0", "1"): + child = parent + bit + cx = _node_x(child, depth=depth, slot_width=slot_width, margin_x=margin_x) + cy = margin_top + (level + 1) * level_height + child_status = node_status[child] + if child_status in {"removed", "removed_proxy"}: + edge_class = "edge-removed" + elif child_status == "blocked": + edge_class = "edge-blocked" + else: + edge_class = "edge-live" + edges_svg.append( + f'' + ) + + nodes_svg: list[str] = [] + for level in range(depth + 1): + radius = 10 if level == 0 else 7.5 + for idx in range(1 << level): + prefix = "" if level == 0 else format(idx, f"0{level}b") + x = _node_x(prefix, depth=depth, slot_width=slot_width, margin_x=margin_x) + y = margin_top + level * level_height + node_class = f"node-{node_status[prefix]}" + title = node_title[prefix] + nodes_svg.append( + ( + f'' + f"{title}" + "" + ) + ) + + current_frontier_count = sum(1 for status in node_status.values() if status in {"frontier", "frontier_proxy"}) + blocked_count = sum(1 for status in node_status.values() if status == "blocked") + removed_count = sum(1 for status in node_status.values() if status in {"removed", "removed_proxy"}) + + svg = ( + f'' + "" + + "".join(edges_svg) + + "".join(nodes_svg) + + "" + ) + + return { + "svg": svg, + "depth": depth, + "current_frontier_count": current_frontier_count, + "blocked_count": blocked_count, + "removed_count": removed_count, + "last_puncture": last_diff or None, + } + + +HTML = """ + + + + + + Puncture Lab + + + +
+
+

Zero-Trust Cloud-Wide Forward Secrecy

+

Puncture Lab: human-readable key revocation

+

+ You can derive a key for a cloud file, puncture it, and prove that the same tag is permanently blocked while other tags keep working. +

+ +
+
+
Active tree nodes
+
{{ active_nodes }}
+
Stored prefixes, not per-file keys
+
+
+
Punctures logged
+
{{ puncture_count }}
+
Irreversible void events in this singleton
+
+
+
Walkthrough progress
+
{{ progress.done }}/3
+
Newbie mode checklist
+
+
+
+ +
+

Current Active Roots (Frontier)

+
+

+ This panel shows the only root prefixes currently active in memory. + It does not show key material or seed bytes. +

+

After puncture, the single root is replaced by a minimal set of subtree roots.

+
+
+ {% for row in active_frontier %} +
+
+ {{ 'ROOT' if row.is_root else ('Prefix ' ~ row.prefix) }} + Depth {{ row.depth }} / 32 +
+
+ {% if row.is_root %} + ROOT + {% else %} + {{ row.provider_bits }} + {% if row.resource_bits %} + |{{ row.resource_bits }} + {% endif %} + {% endif %} +
+

{{ row.coverage_label }}

+
+ {% endfor %} +
+
+ +
+

Tree/Subtree Visualization

+

+ Projection of the first {{ tree_viz.depth }} bits of the 32-bit tree. Root starts as frontier; + green nodes are derivable future space; amber marks branches that already had derivations. +

+
+
+
Visible frontier nodes
+
{{ tree_viz.current_frontier_count }}
+
+
+
Blocked projected nodes
+
{{ tree_viz.blocked_count }}
+
+
+
Removed frontier (last puncture)
+
{{ tree_viz.removed_count }}
+
+
+
{{ tree_viz.svg | safe }}
+
+ Current frontier + Future derivable + Already derived branch + Now impossible + Deleted frontier (last puncture) +
+ {% if tree_viz.last_puncture %} + + {% else %} + + {% endif %} +
+ +
+

Quick Start Walkthrough

+
    +
  1. Derive a key for any Provider ID + File/Time ID.
  2. +
  3. Puncture that same pair to revoke future access.
  4. +
  5. Derive again and confirm the key is now inaccessible.
  6. +
+
+ +
+ +
+
+

Step 1 and 2: Key Workbench

+

Range: provider `0..127`, file/time `0..33,554,431`.

+ +
+ +
+ +
+
+ +
+ + + + + + + + + +
+ +
+
+ +
+ + + + + + +
+ +
+
+ +
+ Same values -> derive, then puncture, then derive again. The second derive should be blocked. +
+
+
+

What "Root" Means Here

+

+ The frontier panel is structural state only: + which prefixes currently hold active subtree roots. +

+

+ Before any puncture there is one active root (`ROOT`). After puncture you will see multiple subtree prefixes. + Any missing prefix region is permanently void. +

+
+
+ +
+

Latest Result

+
{{ last_action.title }}
+
+

{{ last_action.body }}

+ {% if last_action.provider_id is not none %} +

Provider {{ last_action.provider_id }}, File/Time {{ last_action.file_time_id }}

+ {% endif %} + {% if last_action.path %} +
+ Tag bits: + {{ last_action.path_provider }} + | + {{ last_action.path_resource }} +
(7 bits provider | 25 bits file/time)
+
+ {% endif %} + {% if last_action.key_hex %} + {% if last_action.key_description %} +

Purpose: {{ last_action.key_description }}

+ {% endif %} + + +
+ +
+ {% endif %} +
+
+ +
+
+

Puncture Log (Audit)

+

Audit list of punctured bit-strings applied in this singleton system.

+ +
+ +
+
+ +
+

Activity Timeline

+
    + {% if history %} + {% for item in history %} +
  • +
    {{ item.time }} | {{ item.action }} | {{ item.status }}
    +
    {{ item.summary }}
    + {% if item.path %} +
    {{ item.path }}
    + {% endif %} +
  • + {% endfor %} + {% else %} +
  • No actions yet. Start with "Derive Key".
  • + {% endif %} +
+
+
+ +
+

Before You Leave This Page

+
+ This interface is a local demonstration tool. It is intentionally educational and does not include user auth, + encrypted persistence, or hardened production deployment controls. +
+
+
+ +
+
+
+
+ + + + +""" + + +PROVIDERS_HTML = """ + + + + + + Provider Manager + + + +
+
+ +

Provider Manager

+

+ Add, edit, and delete cloud providers. Deleting a provider revokes access to its entire 7-bit subtree by prefix puncture. +

+
+
+
Active providers
+
{{ active_count }}
+
+
+
Deleted providers
+
{{ deleted_count }}
+
+
+
+ + {% if notice %} +
{{ notice.message }}
+ {% endif %} + +
+

Add Provider

+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +
+

Edit Or Delete Providers

+

Editing changes metadata only. Provider ID is immutable to preserve key mapping.

+ {% if providers %} + {% for provider in providers %} +
+
+ {{ provider.name }} + ID {{ provider.provider_id }} | Prefix {{ provider.prefix }} +
+ +
+ + + + + +
+ +
+
+ +
+

Key ID Journal

+

Visualization of key IDs seen for this provider. Only keys used in this lab appear.

+
+
+
Tracked IDs
+
{{ provider.key_count }}
+
+
+
Ever Derived
+
{{ provider.derived_count }}
+
+
+
Ever Punctured
+
{{ provider.punctured_count }}
+
+
+ +
+ Derived IDs + {% if provider.derived_ids %} + {% for file_id in provider.derived_ids %} + {{ file_id }} + {% endfor %} + {% else %} + none + {% endif %} +
+ +
+ Punctured IDs + {% if provider.punctured_ids %} + {% for file_id in provider.punctured_ids %} + {{ file_id }} + {% endfor %} + {% else %} + none + {% endif %} +
+ + {% if provider.key_rows %} + {% for key in provider.key_rows %} +
+
+
File/Time ID {{ key.file_time_id }}
+
+ {% if key.ever_derived %}Derived{% endif %} + {% if key.ever_punctured %}Punctured{% endif %} +
+
+
{{ key.path_provider }} | {{ key.path_resource }}
+
Derived count: {{ key.derive_count }} | Puncture count: {{ key.puncture_count }}
+
Last derived: {{ key.last_derived_at or 'never' }} | Last punctured: {{ key.last_punctured_at or 'never' }}
+
+ + + + +
+ +
+
+
+ {% endfor %} + {% else %} +

No key IDs tracked for this provider yet.

+ {% endif %} +
+ +
+ +
+ +
+
+
+ {% endfor %} + {% else %} +

No active providers. Add one above.

+ {% endif %} +
+ +
+

Deleted Providers (Audit)

+ {% if deleted_providers %} + {% for item in deleted_providers %} +
+
ID {{ item.provider_id }} - {{ item.name }} at {{ item.deleted_at }}
+
Prefix puncture: {{ item.prefix }} | Structural change: {{ 'yes' if item.applied else 'no' }}
+
+ {% endfor %} + {% else %} +

No providers deleted yet.

+ {% endif %} +
+
+ + + +""" + + +ASSETS_HTML = """ + + + + + + Asset Workflow + + + +
+
+ +

Asset Workflow

+

State machine lifecycle: upload -> eligible -> encrypted (live/partial/blocked). Deselect anytime before encryption.

+

Each ciphertext mapping now includes direct decryption to filesystem when its key is still accessible.

+

+
+
Cleartext Files
0
+
Ciphertext Mappings
0
+
Blocked Mappings
0
+
Glow Mappings
0
+
+
+ +
{% if initial_notice %}{{ initial_notice.message }}{% endif %}
+ +
+

Single Lifecycle Flow

+
+
+

1) Choose Files

+ + + + +
+ + Choose files to enable upload. +
+ +

2) Eligible Files

+
+ + +
+
+
+ 0 selected + 0 eligible +
+
+ +
+

3) Encrypt Selection

+ + + + + + + + + + + + +
+ + +
+

Ciphertexts are saved to filesystem immediately after encryption.

+
+
+
+ +
+

Asset Lifecycle States

+
+
+ +
+
+

Asset-Centric Mappings

+
+
+
+

Key-Centric Usage

+
+
+
+
+ + + + +""" + + +def create_app() -> Flask: + app = Flask(__name__) + app.config["system"] = _new_system() + + def _system() -> Dict[str, Any]: + return app.config["system"] + + def _compute_progress(system: Dict[str, Any]) -> Dict[str, Any]: + history = system["history"] + derived_once = any(item["action"] == "derive" and item["status"] == "derived" for item in history) + punctured_once = any(item["action"] == "puncture" and item["status"] == "applied" for item in history) + verified_void = any(item["action"] == "derive" and item["status"] == "void" for item in history) + + done = sum([derived_once, punctured_once, verified_void]) + return { + "derived_once": derived_once, + "punctured_once": punctured_once, + "verified_void": verified_void, + "done": done, + "percent": int((done / 3) * 100), + } + + def _provider_rows(system: Dict[str, Any]) -> list[Dict[str, Any]]: + rows = [] + journal = system["key_journal"] + for provider_id in sorted(system["providers"].keys()): + row = dict(system["providers"][provider_id]) + row["prefix"] = provider_id_to_prefix(provider_id) + + keys = [] + for entry in journal.values(): + if entry["provider_id"] == provider_id: + keys.append(dict(entry)) + keys.sort(key=lambda item: item["file_time_id"]) + + row["key_rows"] = keys + row["keys"] = keys + row["key_count"] = len(keys) + row["derived_count"] = sum(1 for item in keys if item["ever_derived"]) + row["punctured_count"] = sum(1 for item in keys if item["ever_punctured"]) + row["derived_ids"] = [item["file_time_id"] for item in keys if item["ever_derived"]] + row["punctured_ids"] = [item["file_time_id"] for item in keys if item["ever_punctured"]] + rows.append(row) + return rows + + def _set_provider_notice(system: Dict[str, Any], tone: str, message: str) -> None: + system["providers_notice"] = {"tone": tone, "message": message} + + def _set_asset_notice(system: Dict[str, Any], tone: str, message: str) -> None: + system["asset_notice"] = {"tone": tone, "message": message} + + def _normalize_unique_relpaths(raw_relpaths: list[str]) -> list[str]: + normalized = [_normalize_relpath(path) for path in raw_relpaths if str(path).strip()] + if not normalized: + raise ValueError("select existing files or upload files before encrypting") + # de-duplicate while preserving order + return list(dict.fromkeys(normalized)) + + def _persist_uploaded_files(system: Dict[str, Any]) -> list[str]: + target_subdir = request.form.get("target_subdir", "").strip() + target_prefix = "" + if target_subdir: + target_prefix = _normalize_relpath(target_subdir).strip("/") + + files = request.files.getlist("files") + saved: list[str] = [] + for item in files: + filename = secure_filename(item.filename or "") + if not filename: + continue + desired_rel = os.path.join(target_prefix, filename) if target_prefix else filename + final_rel = _next_plaintext_relpath(system["asset_root"], desired_rel) + final_abs = _asset_abs_path(system["asset_root"], final_rel) + os.makedirs(os.path.dirname(final_abs), exist_ok=True) + item.save(final_abs) + saved.append(final_rel) + return saved + + def _encrypt_plaintext_relpaths( + system: Dict[str, Any], + *, + plaintext_relpaths: list[str], + provider_id: int, + file_time_id: int, + purpose: str, + ) -> Dict[str, Any]: + manager: PuncturableKeyManager = system["manager"] + path = tag_to_binary_path(provider_id, file_time_id) + key = manager.get_key_for_tag(path) + if key is None: + raise ValueError("selected key is punctured/inaccessible") + + _touch_key_derive( + system, + provider_id=provider_id, + file_time_id=file_time_id, + path=path, + description=purpose, + ) + + saved: list[tuple[str, str]] = [] + errors: list[str] = [] + for plaintext_relpath in plaintext_relpaths: + try: + plaintext_abs = _asset_abs_path(system["asset_root"], plaintext_relpath) + if not os.path.isfile(plaintext_abs): + raise ValueError("cleartext file does not exist") + + with open(plaintext_abs, "rb") as in_file: + plaintext = in_file.read() + + encrypted_blob = _encrypt_blob(key, plaintext) + ciphertext_relpath = _next_ciphertext_relpath( + system["asset_root"], + plaintext_relpath, + provider_id, + file_time_id, + ) + ciphertext_abs = _asset_abs_path(system["asset_root"], ciphertext_relpath) + os.makedirs(os.path.dirname(ciphertext_abs), exist_ok=True) + with open(ciphertext_abs, "wb") as out_file: + out_file.write(encrypted_blob) + + system["asset_records"].append( + { + "record_id": len(system["asset_records"]) + 1, + "plaintext_relpath": plaintext_relpath, + "ciphertext_relpath": ciphertext_relpath, + "provider_id": provider_id, + "file_time_id": file_time_id, + "path": path, + "purpose": purpose, + "created_at": _utc_now_label(), + "plaintext_size": len(plaintext), + "ciphertext_size": len(encrypted_blob), + "decrypt_count": 0, + "last_decrypted_at": None, + "last_decrypted_relpath": None, + } + ) + saved.append((plaintext_relpath, ciphertext_relpath)) + except Exception as item_exc: + errors.append(f"{plaintext_relpath}: {item_exc}") + + if not saved: + raise ValueError("; ".join(errors[:3]) or "no file could be encrypted") + + return {"path": path, "saved": saved, "errors": errors} + + def _decrypt_asset_records(system: Dict[str, Any], *, record_ids: list[int]) -> Dict[str, Any]: + manager: PuncturableKeyManager = system["manager"] + index: Dict[int, Dict[str, Any]] = { + int(item["record_id"]): item for item in system["asset_records"] if "record_id" in item + } + + restored: list[tuple[int, str, str]] = [] + errors: list[str] = [] + for record_id in record_ids: + row = index.get(record_id) + if row is None: + errors.append(f"record {record_id}: not found") + continue + + try: + key = manager.get_key_for_tag(str(row["path"])) + if key is None: + raise ValueError("key is punctured/inaccessible") + + ciphertext_abs = _asset_abs_path(system["asset_root"], str(row["ciphertext_relpath"])) + if not os.path.isfile(ciphertext_abs): + raise ValueError("ciphertext file missing from filesystem") + + with open(ciphertext_abs, "rb") as in_file: + blob = in_file.read() + + plaintext = _decrypt_blob(key, blob) + decrypted_relpath = _next_decrypted_relpath( + system["asset_root"], + str(row["plaintext_relpath"]), + int(row["provider_id"]), + int(row["file_time_id"]), + ) + decrypted_abs = _asset_abs_path(system["asset_root"], decrypted_relpath) + os.makedirs(os.path.dirname(decrypted_abs), exist_ok=True) + with open(decrypted_abs, "wb") as out_file: + out_file.write(plaintext) + + row["decrypt_count"] = int(row.get("decrypt_count", 0)) + 1 + row["last_decrypted_at"] = _utc_now_label() + row["last_decrypted_relpath"] = decrypted_relpath + + restored.append((record_id, str(row["ciphertext_relpath"]), decrypted_relpath)) + except Exception as item_exc: + errors.append(f"record {record_id}: {item_exc}") + + if not restored: + raise ValueError("; ".join(errors[:3]) or "no ciphertext could be decrypted") + return {"restored": restored, "errors": errors} + + def _key_combo_options(system: Dict[str, Any]) -> list[Dict[str, Any]]: + options: list[Dict[str, Any]] = [] + for row in sorted( + [dict(item) for item in system["key_journal"].values()], + key=lambda item: (item["provider_id"], item["file_time_id"]), + ): + status = "blocked" if row["ever_punctured"] else "active" + options.append( + { + "provider_id": row["provider_id"], + "file_time_id": row["file_time_id"], + "status": status, + "label": f"Provider {row['provider_id']} | Key {row['file_time_id']} | {status}", + } + ) + return options + + def _asset_workflow_snapshot(system: Dict[str, Any]) -> Dict[str, Any]: + dashboard = _asset_dashboard(system) + plain_rows = _list_plaintext_rows(system["asset_root"]) + providers = [ + {"provider_id": item["provider_id"], "name": item["name"]} + for item in _provider_rows(system) + ] + + mapped_by_relpath = {item["plaintext_relpath"]: item for item in dashboard["asset_files"]} + files = [] + for row in plain_rows: + mapped = mapped_by_relpath.get(row["relpath"]) + mapping_count = int(mapped["mapping_count"]) if mapped else 0 + blocked_count = int(mapped["blocked_count"]) if mapped else 0 + lifecycle_state = _asset_lifecycle_state(mapping_count, blocked_count) + files.append( + { + **row, + "mapping_count": mapping_count, + "blocked_count": blocked_count, + "lifecycle_state": lifecycle_state, + "lifecycle_label": _asset_lifecycle_label(lifecycle_state), + } + ) + + return { + "generated_at": _utc_now_label(), + "asset_root": system["asset_root"], + "stats": { + "cleartext_count": len(files), + "mapping_count": int(dashboard["mapping_count"]), + "blocked_count": int(dashboard["blocked_count"]), + "glow_count": int(dashboard["glow_count"]), + }, + "files": files, + "providers": providers, + "key_combo_options": _key_combo_options(system), + "last_inputs": dict(system["last_inputs"]), + "asset_files": dashboard["asset_files"], + "key_cards": dashboard["key_cards"], + } + + def _mark_known_provider_keys_punctured(system: Dict[str, Any], provider_id: int) -> int: + touched = 0 + stamp = _utc_now_label() + for entry in system["key_journal"].values(): + if int(entry["provider_id"]) != provider_id: + continue + if not entry["ever_punctured"]: + entry["puncture_count"] += 1 + entry["ever_punctured"] = True + entry["last_punctured_at"] = stamp + touched += 1 + return touched + + def _asset_dashboard(system: Dict[str, Any]) -> Dict[str, Any]: + manager: PuncturableKeyManager = system["manager"] + records = [dict(item) for item in system["asset_records"]] + + file_map: Dict[str, list[Dict[str, Any]]] = {} + key_map: Dict[str, Dict[str, Any]] = {} + blocked_total = 0 + glow_total = 0 + + for row in records: + path = row["path"] + row["is_accessible"] = manager.get_key_for_tag(path) is not None + row["path_provider"], row["path_resource"] = _split_path_bits(path) + if not row["is_accessible"]: + blocked_total += 1 + + file_map.setdefault(row["plaintext_relpath"], []).append(row) + + key_id = f"{row['provider_id']}:{row['file_time_id']}:{path}" + bucket = key_map.get(key_id) + if bucket is None: + bucket = { + "provider_id": row["provider_id"], + "file_time_id": row["file_time_id"], + "path": path, + "path_provider": row["path_provider"], + "path_resource": row["path_resource"], + "files": set(), + "is_accessible": row["is_accessible"], + } + key_map[key_id] = bucket + bucket["files"].add(row["plaintext_relpath"]) + bucket["is_accessible"] = bucket["is_accessible"] and row["is_accessible"] + + file_cards: list[Dict[str, Any]] = [] + for plaintext_relpath in sorted(file_map.keys()): + mappings = file_map[plaintext_relpath] + mappings.sort(key=lambda item: item["created_at"]) + blocked_count = sum(1 for item in mappings if not item["is_accessible"]) + for item in mappings: + item["show_red"] = not item["is_accessible"] + item["show_glow"] = item["is_accessible"] and blocked_count > 0 + if item["show_glow"]: + glow_total += 1 + + file_cards.append( + { + "plaintext_relpath": plaintext_relpath, + "mapping_count": len(mappings), + "blocked_count": blocked_count, + "mappings": mappings, + } + ) + + key_cards: list[Dict[str, Any]] = [] + for bucket in key_map.values(): + key_cards.append( + { + "provider_id": bucket["provider_id"], + "file_time_id": bucket["file_time_id"], + "path": bucket["path"], + "path_provider": bucket["path_provider"], + "path_resource": bucket["path_resource"], + "file_count": len(bucket["files"]), + "files": sorted(bucket["files"]), + "is_accessible": bucket["is_accessible"], + } + ) + key_cards.sort(key=lambda item: (item["provider_id"], item["file_time_id"])) + + return { + "asset_files": file_cards, + "key_cards": key_cards, + "mapping_count": len(records), + "blocked_count": blocked_total, + "glow_count": glow_total, + } + + def _remote_token_valid() -> bool: + configured = os.getenv("PUNCTURE_REMOTE_TOKEN", "").strip() + if not configured: + return True + supplied = request.headers.get("X-Puncture-Token", "") + return hmac.compare_digest(supplied, configured) + + @app.get("/") + def index() -> str: + sys = _system() + manager: PuncturableKeyManager = sys["manager"] + return render_template_string( + HTML, + tree_viz=_tree_visualization_bundle(sys, manager), + active_nodes=manager.active_node_count, + active_frontier=_active_frontier_rows(manager), + puncture_count=len(manager.puncture_log()), + puncture_log_json=manager.export_puncture_log_json(), + history=sys["history"], + progress=_compute_progress(sys), + last_action=sys["last_action"], + last_inputs=sys["last_inputs"], + ) + + @app.get("/assets") + def assets_page() -> str: + sys = _system() + snapshot = _asset_workflow_snapshot(sys) + return render_template_string( + ASSETS_HTML, + snapshot=snapshot, + initial_notice=sys["asset_notice"], + ) + + @app.get("/api/assets/workflow") + def api_assets_workflow() -> Dict[str, Any]: + sys = _system() + return {"ok": True, "state": _asset_workflow_snapshot(sys)} + + @app.post("/api/assets/workflow/upload") + def api_assets_workflow_upload() -> Dict[str, Any]: + sys = _system() + try: + saved = _persist_uploaded_files(sys) + if not saved: + raise ValueError("choose at least one file to upload") + + preview = ", ".join(saved[:3]) + extra = "" if len(saved) <= 3 else f" and {len(saved) - 3} more" + message = f"Uploaded {len(saved)} file(s): {preview}{extra}." + _set_asset_notice(sys, "success", message) + _record_history( + sys, + action="asset-upload", + status="uploaded", + summary=f"Uploaded {len(saved)} cleartext file(s) into asset root.", + ) + return { + "ok": True, + "uploaded": saved, + "message": message, + "state": _asset_workflow_snapshot(sys), + } + except Exception as exc: + message = f"Upload failed: {exc}" + _set_asset_notice(sys, "danger", message) + _record_history(sys, action="asset-upload", status="error", summary=message) + return {"ok": False, "error": str(exc), "state": _asset_workflow_snapshot(sys)}, 400 + + @app.post("/api/assets/workflow/clear") + def api_assets_workflow_clear() -> Dict[str, Any]: + sys = _system() + sys["last_inputs"] = {"provider_id": 42, "file_time_id": 123456, "purpose": ""} + message = "Cleared saved encryption decisions." + _set_asset_notice(sys, "info", message) + _record_history( + sys, + action="asset-decisions", + status="cleared", + summary="User cleared saved asset form decisions.", + ) + return {"ok": True, "message": message, "state": _asset_workflow_snapshot(sys)} + + @app.post("/api/assets/workflow/encrypt") + def api_assets_workflow_encrypt() -> Dict[str, Any]: + sys = _system() + payload = request.get_json(silent=True) or {} + try: + raw_relpaths = payload.get("plaintext_relpaths", []) + if not isinstance(raw_relpaths, list): + raise ValueError("plaintext_relpaths must be a list") + plaintext_relpaths = _normalize_unique_relpaths([str(item) for item in raw_relpaths]) + + provider_id = int(payload.get("provider_id")) + file_time_id = int(payload.get("file_time_id")) + purpose = str(payload.get("purpose", "")).strip() + sys["last_inputs"] = { + "provider_id": provider_id, + "file_time_id": file_time_id, + "purpose": purpose, + } + + result = _encrypt_plaintext_relpaths( + sys, + plaintext_relpaths=plaintext_relpaths, + provider_id=provider_id, + file_time_id=file_time_id, + purpose=purpose, + ) + saved = result["saved"] + errors = result["errors"] + preview = ", ".join(f"{plain} -> {cipher}" for plain, cipher in saved[:2]) + extra = "" if len(saved) <= 2 else f" and {len(saved) - 2} more" + + if errors: + message = ( + f"Encrypted {len(saved)} file(s) with provider={provider_id}, key_id={file_time_id}: " + f"{preview}{extra}. Failed: {len(errors)}." + ) + _set_asset_notice(sys, "warn", message) + else: + message = ( + f"Encrypted {len(saved)} file(s) with provider={provider_id}, key_id={file_time_id}: " + f"{preview}{extra}." + ) + _set_asset_notice(sys, "success", message) + + _record_history( + sys, + action="asset-encrypt", + status="encrypted", + summary=( + f"Encrypted {len(saved)} file(s) with provider={provider_id}, file_time={file_time_id}. " + + (f"Failures: {len(errors)}." if errors else "") + ), + provider_id=provider_id, + file_time_id=file_time_id, + path=result["path"], + ) + return { + "ok": True, + "saved": [{"plaintext_relpath": p, "ciphertext_relpath": c} for p, c in saved], + "errors": errors, + "message": message, + "state": _asset_workflow_snapshot(sys), + } + except Exception as exc: + message = f"Encryption failed: {exc}" + _set_asset_notice(sys, "danger", message) + _record_history(sys, action="asset-encrypt", status="error", summary=message) + return {"ok": False, "error": str(exc), "state": _asset_workflow_snapshot(sys)}, 400 + + @app.post("/api/assets/workflow/decrypt") + def api_assets_workflow_decrypt() -> Dict[str, Any]: + sys = _system() + payload = request.get_json(silent=True) or {} + try: + raw_ids = payload.get("record_ids", []) + if not isinstance(raw_ids, list): + raise ValueError("record_ids must be a list") + if not raw_ids: + raise ValueError("select at least one ciphertext mapping to decrypt") + + record_ids: list[int] = [] + for value in raw_ids: + record_ids.append(int(value)) + record_ids = list(dict.fromkeys(record_ids)) + + result = _decrypt_asset_records(sys, record_ids=record_ids) + restored = result["restored"] + errors = result["errors"] + preview = ", ".join(f"{src} -> {dst}" for _, src, dst in restored[:2]) + extra = "" if len(restored) <= 2 else f" and {len(restored) - 2} more" + + if errors: + message = f"Decrypted {len(restored)} mapping(s): {preview}{extra}. Failed: {len(errors)}." + _set_asset_notice(sys, "warn", message) + else: + message = f"Decrypted {len(restored)} mapping(s): {preview}{extra}." + _set_asset_notice(sys, "success", message) + + _record_history( + sys, + action="asset-decrypt", + status="decrypted", + summary=f"Decrypted {len(restored)} mapping(s)." + (f" Failures: {len(errors)}." if errors else ""), + ) + return { + "ok": True, + "restored": [ + {"record_id": record_id, "ciphertext_relpath": src, "decrypted_relpath": dst} + for record_id, src, dst in restored + ], + "errors": errors, + "message": message, + "state": _asset_workflow_snapshot(sys), + } + except Exception as exc: + message = f"Decryption failed: {exc}" + _set_asset_notice(sys, "danger", message) + _record_history(sys, action="asset-decrypt", status="error", summary=message) + return {"ok": False, "error": str(exc), "state": _asset_workflow_snapshot(sys)}, 400 + + @app.post("/assets/upload") + def asset_upload() -> Any: + sys = _system() + try: + saved = _persist_uploaded_files(sys) + if not saved: + raise ValueError("no files were selected") + + preview = ", ".join(saved[:3]) + extra = "" if len(saved) <= 3 else f" and {len(saved) - 3} more" + _set_asset_notice( + sys, + "success", + f"Uploaded {len(saved)} cleartext file(s): {preview}{extra}.", + ) + _record_history( + sys, + action="asset-upload", + status="uploaded", + summary=f"Uploaded {len(saved)} cleartext file(s) into asset root.", + ) + except Exception as exc: + _set_asset_notice(sys, "danger", f"Upload failed: {exc}") + _record_history(sys, action="asset-upload", status="error", summary=f"Upload failed: {exc}") + return redirect(url_for("assets_page")) + + @app.post("/assets/encrypt") + def asset_encrypt() -> Any: + sys = _system() + try: + operation = request.form.get("operation", "encrypt").strip().lower() + if operation == "wipe": + sys["last_inputs"] = { + "provider_id": 42, + "file_time_id": 123456, + "purpose": "", + } + _set_asset_notice(sys, "info", "Cleared saved form decisions for asset encryption.") + _record_history( + sys, + action="asset-decisions", + status="cleared", + summary="User cleared saved asset form decisions.", + ) + return redirect(url_for("assets_page")) + + uploaded_relpaths = _persist_uploaded_files(sys) + include_uploads = request.form.get("include_uploads") == "1" + + raw_relpaths = request.form.getlist("plaintext_relpaths") + if not raw_relpaths: + fallback = request.form.get("plaintext_relpath", "").strip() + if fallback: + raw_relpaths = [fallback] + if include_uploads: + raw_relpaths.extend(uploaded_relpaths) + plaintext_relpaths = _normalize_unique_relpaths(raw_relpaths) + provider_id = int(request.form["provider_id"]) + file_time_id = int(request.form["file_time_id"]) + purpose = request.form.get("purpose", "").strip() + sys["last_inputs"] = { + "provider_id": provider_id, + "file_time_id": file_time_id, + "purpose": purpose, + } + + result = _encrypt_plaintext_relpaths( + sys, + plaintext_relpaths=plaintext_relpaths, + provider_id=provider_id, + file_time_id=file_time_id, + purpose=purpose, + ) + saved = result["saved"] + errors = result["errors"] + + preview = ", ".join(f"{plain} -> {cipher}" for plain, cipher in saved[:2]) + extra = "" if len(saved) <= 2 else f" and {len(saved) - 2} more" + if errors: + _set_asset_notice( + sys, + "warn", + ( + f"Encrypted {len(saved)} file(s) with provider={provider_id}, key_id={file_time_id}: " + f"{preview}{extra}. Uploads saved: {len(uploaded_relpaths)}. Failed: {len(errors)}." + ), + ) + else: + _set_asset_notice( + sys, + "success", + ( + f"Encrypted {len(saved)} file(s) with provider={provider_id}, key_id={file_time_id}: " + f"{preview}{extra}. Uploads saved: {len(uploaded_relpaths)}." + ), + ) + _record_history( + sys, + action="asset-encrypt", + status="encrypted", + summary=( + f"Encrypted {len(saved)} file(s) with provider={provider_id}, file_time={file_time_id}. " + + f"Uploads saved: {len(uploaded_relpaths)}. " + + (f"Failures: {len(errors)}." if errors else "") + ), + provider_id=provider_id, + file_time_id=file_time_id, + path=result["path"], + ) + except Exception as exc: + _set_asset_notice(sys, "danger", f"Encryption failed: {exc}") + _record_history(sys, action="asset-encrypt", status="error", summary=f"Encryption failed: {exc}") + return redirect(url_for("assets_page")) + + @app.get("/providers") + def providers_page() -> str: + sys = _system() + return render_template_string( + PROVIDERS_HTML, + providers=_provider_rows(sys), + deleted_providers=sys["deleted_providers"], + active_count=len(sys["providers"]), + deleted_count=len(sys["deleted_providers"]), + notice=sys["providers_notice"], + ) + + @app.post("/providers/add") + def provider_add() -> Any: + sys = _system() + try: + provider_id = int(request.form["provider_id"]) + name = request.form["name"].strip() + description = request.form.get("description", "").strip() + + if not name: + raise ValueError("Display name is required.") + if provider_id in sys["providers"]: + raise ValueError(f"Provider ID {provider_id} already exists.") + + # Validation for provider ID bit range. + provider_id_to_prefix(provider_id) + + sys["providers"][provider_id] = { + "provider_id": provider_id, + "name": name, + "description": description, + "created_at": _utc_now_label(), + } + _set_provider_notice(sys, "success", f"Added provider {provider_id}: {name}.") + _record_history( + sys, + action="provider-add", + status="added", + summary=f"Added provider {provider_id} ({name}).", + ) + except Exception as exc: + _set_provider_notice(sys, "danger", f"Add failed: {exc}") + _record_history(sys, action="provider-add", status="error", summary=f"Add failed: {exc}") + return redirect(url_for("providers_page")) + + @app.post("/providers/edit") + def provider_edit() -> Any: + sys = _system() + try: + provider_id = int(request.form["provider_id"]) + name = request.form["name"].strip() + description = request.form.get("description", "").strip() + + if provider_id not in sys["providers"]: + raise ValueError(f"Provider ID {provider_id} does not exist.") + if not name: + raise ValueError("Display name is required.") + + sys["providers"][provider_id]["name"] = name + sys["providers"][provider_id]["description"] = description + _set_provider_notice(sys, "success", f"Updated provider {provider_id}.") + _record_history( + sys, + action="provider-edit", + status="updated", + summary=f"Updated provider {provider_id}.", + ) + except Exception as exc: + _set_provider_notice(sys, "danger", f"Edit failed: {exc}") + _record_history(sys, action="provider-edit", status="error", summary=f"Edit failed: {exc}") + return redirect(url_for("providers_page")) + + @app.post("/providers/key-note") + def provider_key_note_update() -> Any: + sys = _system() + try: + provider_id = int(request.form["provider_id"]) + file_time_id = int(request.form["file_time_id"]) + description = request.form.get("description", "").strip() + + if provider_id not in sys["providers"]: + raise ValueError(f"Provider ID {provider_id} does not exist.") + + path = tag_to_binary_path(provider_id, file_time_id) + entry = _ensure_key_entry(sys, provider_id=provider_id, file_time_id=file_time_id, path=path) + entry["description"] = description + + _set_provider_notice( + sys, + "success", + f"Saved key purpose for provider={provider_id}, file/time={file_time_id}.", + ) + _record_history( + sys, + action="key-note-edit", + status="updated", + summary=f"Updated key purpose for provider={provider_id}, file/time={file_time_id}.", + provider_id=provider_id, + file_time_id=file_time_id, + path=path, + ) + except Exception as exc: + _set_provider_notice(sys, "danger", f"Key note update failed: {exc}") + _record_history(sys, action="key-note-edit", status="error", summary=f"Key note update failed: {exc}") + return redirect(url_for("providers_page")) + + @app.post("/providers/delete") + def provider_delete() -> Any: + sys = _system() + manager: PuncturableKeyManager = sys["manager"] + try: + provider_id = int(request.form["provider_id"]) + provider = sys["providers"].pop(provider_id, None) + if provider is None: + raise ValueError(f"Provider ID {provider_id} does not exist.") + + prefix = provider_id_to_prefix(provider_id) + before_frontier = manager.active_prefixes() + applied = manager.puncture_prefix(prefix) + after_frontier = manager.active_prefixes() + _set_last_puncture_diff( + sys, + before_frontier=before_frontier, + after_frontier=after_frontier, + target_bitstring=prefix, + target_kind="provider-prefix", + ) + touched = _mark_known_provider_keys_punctured(sys, provider_id) + + sys["deleted_providers"].insert( + 0, + { + "provider_id": provider_id, + "name": provider["name"], + "prefix": prefix, + "deleted_at": _utc_now_label(), + "applied": applied, + }, + ) + del sys["deleted_providers"][32:] + + _set_provider_notice( + sys, + "warn", + ( + f"Deleted provider {provider_id}. Prefix {prefix} punctured across its full subtree; " + f"structural change: {'yes' if applied else 'no'}. " + f"Known key IDs marked punctured: {touched}." + ), + ) + _set_last_action( + sys, + tone="warn", + title="Provider deleted and subtree punctured", + body=( + f"Provider {provider_id} was removed from registry and its 7-bit prefix was punctured. " + "All keys under that provider are now inaccessible." + ), + provider_id=provider_id, + ) + _record_history( + sys, + action="provider-delete", + status="punctured" if applied else "already-inaccessible", + summary=f"Deleted provider {provider_id}; prefix puncture {prefix}.", + ) + except Exception as exc: + _set_provider_notice(sys, "danger", f"Delete failed: {exc}") + _record_history(sys, action="provider-delete", status="error", summary=f"Delete failed: {exc}") + return redirect(url_for("providers_page")) + + @app.post("/derive") + def derive() -> Any: + sys = _system() + manager: PuncturableKeyManager = sys["manager"] + try: + provider_id = int(request.form["provider_id"]) + file_time_id = int(request.form["file_time_id"]) + purpose = request.form.get("purpose", "").strip() + sys["last_inputs"] = {"provider_id": provider_id, "file_time_id": file_time_id, "purpose": purpose} + + path = tag_to_binary_path(provider_id, file_time_id) + key = manager.get_key_for_tag(path) + + if key is None: + body = ( + "No key is derivable for this tag. It was punctured earlier or excluded by previous punctures. " + "This is the expected forward-secrecy behavior." + ) + _set_last_action( + sys, + tone="warn", + title="Derive blocked: key is inaccessible", + body=body, + provider_id=provider_id, + file_time_id=file_time_id, + path=path, + ) + _record_history( + sys, + action="derive", + status="void", + summary=f"Derive blocked for provider={provider_id}, file={file_time_id}.", + provider_id=provider_id, + file_time_id=file_time_id, + path=path, + ) + else: + key_hex = key.hex() + entry = _touch_key_derive( + sys, + provider_id=provider_id, + file_time_id=file_time_id, + path=path, + description=purpose, + ) + body = ( + "Key derivation succeeded. Keep in mind this demo shows the key directly for learning. " + "In production, this should feed cryptographic operations without UI exposure." + ) + _set_last_action( + sys, + tone="success", + title="Derive succeeded", + body=body, + provider_id=provider_id, + file_time_id=file_time_id, + path=path, + key_hex=key_hex, + key_description=entry["description"] or None, + ) + _record_history( + sys, + action="derive", + status="derived", + summary=( + f"Derived key for provider={provider_id}, file={file_time_id}." + + (f" Purpose: {entry['description']}." if entry["description"] else "") + ), + provider_id=provider_id, + file_time_id=file_time_id, + path=path, + ) + + except Exception as exc: + _set_last_action( + sys, + tone="danger", + title="Input error", + body=str(exc), + ) + _record_history(sys, action="derive", status="error", summary=f"Derive failed: {exc}") + return redirect(url_for("index")) + + @app.post("/puncture") + def puncture() -> Any: + sys = _system() + manager: PuncturableKeyManager = sys["manager"] + try: + provider_id = int(request.form["provider_id"]) + file_time_id = int(request.form["file_time_id"]) + sys["last_inputs"] = { + "provider_id": provider_id, + "file_time_id": file_time_id, + "purpose": sys["last_inputs"].get("purpose", ""), + } + + path = tag_to_binary_path(provider_id, file_time_id) + before_frontier = manager.active_prefixes() + applied = manager.puncture(path) + after_frontier = manager.active_prefixes() + _set_last_puncture_diff( + sys, + before_frontier=before_frontier, + after_frontier=after_frontier, + target_bitstring=path, + target_kind="tag", + ) + entry = _touch_key_puncture( + sys, + provider_id=provider_id, + file_time_id=file_time_id, + path=path, + applied=applied, + ) + + if applied: + body = ( + "Puncture applied. This exact tag can no longer derive a key. " + "Other tags remain derivable through the co-path node set." + ) + _set_last_action( + sys, + tone="success", + title="Puncture succeeded", + body=body, + provider_id=provider_id, + file_time_id=file_time_id, + path=path, + key_description=entry["description"] or None, + ) + _record_history( + sys, + action="puncture", + status="applied", + summary=f"Punctured provider={provider_id}, file={file_time_id}.", + provider_id=provider_id, + file_time_id=file_time_id, + path=path, + ) + else: + body = ( + "No change was needed. This tag was already inaccessible, likely due to an earlier puncture." + ) + _set_last_action( + sys, + tone="warn", + title="Puncture no-op", + body=body, + provider_id=provider_id, + file_time_id=file_time_id, + path=path, + key_description=entry["description"] or None, + ) + _record_history( + sys, + action="puncture", + status="noop", + summary=f"No-op puncture for provider={provider_id}, file={file_time_id}.", + provider_id=provider_id, + file_time_id=file_time_id, + path=path, + ) + + except Exception as exc: + _set_last_action( + sys, + tone="danger", + title="Input error", + body=str(exc), + ) + _record_history(sys, action="puncture", status="error", summary=f"Puncture failed: {exc}") + return redirect(url_for("index")) + + @app.post("/demo/scenario-a") + def run_demo_a() -> Any: + sys = _system() + manager: PuncturableKeyManager = sys["manager"] + provider_id = 42 + file_time_id = 123456 + path = tag_to_binary_path(provider_id, file_time_id) + sys["last_inputs"] = { + "provider_id": provider_id, + "file_time_id": file_time_id, + "purpose": "Scenario A demonstration key", + } + + key_before = manager.get_key_for_tag(path) + if key_before is not None: + _touch_key_derive( + sys, + provider_id=provider_id, + file_time_id=file_time_id, + path=path, + description="Scenario A demonstration key", + ) + before_frontier = manager.active_prefixes() + punctured = manager.puncture(path) + after_frontier = manager.active_prefixes() + _set_last_puncture_diff( + sys, + before_frontier=before_frontier, + after_frontier=after_frontier, + target_bitstring=path, + target_kind="tag", + ) + _touch_key_puncture( + sys, + provider_id=provider_id, + file_time_id=file_time_id, + path=path, + applied=punctured, + ) + key_after = manager.get_key_for_tag(path) + + passed = key_before is not None and punctured and key_after is None + if passed: + _set_last_action( + sys, + tone="success", + title="Scenario A passed", + body=( + "Demo complete: key existed before puncture, puncture was applied, and the same key is now inaccessible." + ), + provider_id=provider_id, + file_time_id=file_time_id, + path=path, + ) + _record_history( + sys, + action="scenario-a", + status="passed", + summary="Auto-ran Scenario A successfully.", + provider_id=provider_id, + file_time_id=file_time_id, + path=path, + ) + else: + _set_last_action( + sys, + tone="danger", + title="Scenario A failed", + body="Unexpected result; check activity timeline and state consistency.", + provider_id=provider_id, + file_time_id=file_time_id, + path=path, + ) + _record_history( + sys, + action="scenario-a", + status="failed", + summary="Auto-ran Scenario A and it failed.", + provider_id=provider_id, + file_time_id=file_time_id, + path=path, + ) + + return redirect(url_for("index")) + + @app.post("/reset") + def reset() -> Any: + app.config["system"] = _new_system() + sys = _system() + _record_history(sys, action="system", status="reset", summary="Lab was reset with a fresh root state.") + return redirect(url_for("index")) + + @app.get("/api/state") + def api_state() -> Dict[str, Any]: + sys = _system() + manager: PuncturableKeyManager = sys["manager"] + dashboard = _asset_dashboard(sys) + key_journal_rows = sorted( + [dict(item) for item in sys["key_journal"].values()], + key=lambda row: (row["provider_id"], row["file_time_id"]), + ) + return { + "active_nodes": manager.active_node_count, + "active_prefixes": manager.active_prefixes(), + "active_frontier": _active_frontier_rows(manager), + "last_puncture_diff": sys["last_puncture_diff"], + "puncture_log": manager.puncture_log(), + "last_action": sys["last_action"], + "history": sys["history"], + "providers": list(_provider_rows(sys)), + "deleted_providers": sys["deleted_providers"], + "key_journal": key_journal_rows, + "asset_root": sys["asset_root"], + "assets": dashboard, + } + + @app.get("/api/export") + def api_export() -> Dict[str, Any]: + manager: PuncturableKeyManager = _system()["manager"] + return manager.export_state() + + @app.get("/api/view-bundle") + def api_view_bundle() -> Dict[str, Any]: + sys = _system() + manager: PuncturableKeyManager = sys["manager"] + payload = build_view_payload(sys, manager.puncture_log()) + sync_key = os.getenv("PUNCTURE_VIEW_SYNC_KEY", "").strip() or None + return wrap_view_bundle(payload, sync_key) + + @app.get("/api/live/state") + def api_live_state() -> Dict[str, Any]: + sys = _system() + manager: PuncturableKeyManager = sys["manager"] + dashboard = _asset_dashboard(sys) + key_journal_rows = sorted( + [dict(item) for item in sys["key_journal"].values()], + key=lambda row: (row["provider_id"], row["file_time_id"]), + ) + return { + "generated_at": _utc_now_label(), + "active_nodes": manager.active_node_count, + "active_prefixes": manager.active_prefixes(), + "active_frontier": _active_frontier_rows(manager), + "last_puncture_diff": sys["last_puncture_diff"], + "providers": _provider_rows(sys), + "key_journal": key_journal_rows, + "assets": dashboard, + "asset_root": sys["asset_root"], + } + + @app.post("/api/remote/puncture-provider") + def api_remote_puncture_provider() -> Dict[str, Any]: + if not _remote_token_valid(): + return {"ok": False, "error": "unauthorized"}, 403 + + sys = _system() + manager: PuncturableKeyManager = sys["manager"] + payload = request.get_json(silent=True) or {} + try: + raw_provider_id = payload.get("provider_id", request.form.get("provider_id")) + provider_id = int(raw_provider_id) + provider_id_to_prefix(provider_id) + + before_frontier = manager.active_prefixes() + applied = manager.puncture_provider(provider_id) + after_frontier = manager.active_prefixes() + provider_prefix = provider_id_to_prefix(provider_id) + _set_last_puncture_diff( + sys, + before_frontier=before_frontier, + after_frontier=after_frontier, + target_bitstring=provider_prefix, + target_kind="provider-prefix", + ) + touched = _mark_known_provider_keys_punctured(sys, provider_id) + + _set_last_action( + sys, + tone="warn", + title="Remote kill-switch puncture", + body=( + f"Remote command punctured provider {provider_id}. " + "All keys under this provider prefix are now blocked." + ), + provider_id=provider_id, + ) + _record_history( + sys, + action="remote-provider-puncture", + status="punctured" if applied else "already-inaccessible", + summary=f"Remote puncture on provider {provider_id}.", + provider_id=provider_id, + ) + + return { + "ok": True, + "provider_id": provider_id, + "applied": applied, + "known_key_rows_marked": touched, + "puncture_count": len(manager.puncture_log()), + } + except Exception as exc: + return {"ok": False, "error": str(exc)}, 400 + + @app.post("/api/import") + def api_import() -> Dict[str, Any]: + payload = request.get_json(force=True) + manager = PuncturableKeyManager.from_state(payload) + sys = _system() + sys["manager"] = manager + _set_last_action( + sys, + tone="info", + title="State imported", + body="Imported active-node state and puncture log.", + ) + _record_history(sys, action="system", status="imported", summary="Imported manager state via API.") + return {"ok": True, "active_nodes": manager.active_node_count} + + @app.post("/api/puncture-log") + def api_apply_puncture_log() -> Dict[str, Any]: + payload = request.get_json(force=True) + paths = payload.get("paths", []) + if not isinstance(paths, list): + return {"ok": False, "error": "paths must be a list"}, 400 + + manager: PuncturableKeyManager = _system()["manager"] + applied = manager.apply_puncture_log(paths) + return { + "ok": True, + "applied": applied, + "puncture_count": len(manager.puncture_log()), + "active_nodes": manager.active_node_count, + } + + return app + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run puncture web app") + parser.add_argument("--host", default=os.getenv("PUNCTURE_HOST", "0.0.0.0")) + parser.add_argument("--port", type=int, default=int(os.getenv("PUNCTURE_PORT", "9122"))) + args = parser.parse_args() + + app = create_app() + app.run(host=args.host, port=args.port, debug=False) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2771935 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "puncture" +version = "0.1.0" +description = "Zero-trust cloud-wide forward secrecy via puncturable GGM keys" +requires-python = ">=3.10" +dependencies = ["flask>=3.0.0"] + +[tool.pytest.ini_options] +pythonpath = ["."] +addopts = "-q" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bacdfe9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask>=3.0.0 +pytest>=8.0.0 diff --git a/tests/test_asset_helpers_and_ui.py b/tests/test_asset_helpers_and_ui.py new file mode 100644 index 0000000..297a473 --- /dev/null +++ b/tests/test_asset_helpers_and_ui.py @@ -0,0 +1,450 @@ +import hashlib +import hmac +from io import BytesIO +from pathlib import Path + +from puncture.web_app import ( + ENC_MAGIC, + ENC_NONCE_SIZE, + _asset_abs_path, + _asset_lifecycle_state, + _decrypt_blob, + _encrypt_blob, + _list_plaintext_rows, + _next_ciphertext_relpath, + _next_plaintext_relpath, + _normalize_relpath, + create_app, +) + + +def test_normalize_relpath_rejects_absolute_and_traversal() -> None: + assert _normalize_relpath("docs/a.txt") == "docs/a.txt" + + try: + _normalize_relpath("/etc/passwd") + assert False, "absolute paths must fail" + except ValueError: + pass + + try: + _normalize_relpath("../secret.txt") + assert False, "path traversal must fail" + except ValueError: + pass + + +def test_asset_abs_path_stays_inside_root(tmp_path: Path) -> None: + root = tmp_path / "assets" + root.mkdir() + + ok = _asset_abs_path(str(root), "a/b.txt") + assert ok.startswith(str(root)) + + try: + _asset_abs_path(str(root), "../escape.txt") + assert False, "escape should fail" + except ValueError: + pass + + +def test_next_plaintext_relpath_versions_collisions(tmp_path: Path) -> None: + root = tmp_path / "assets" + (root / "docs").mkdir(parents=True) + (root / "docs" / "a.txt").write_text("x", encoding="utf-8") + (root / "docs" / "a.v2.txt").write_text("x", encoding="utf-8") + + candidate = _next_plaintext_relpath(str(root), "docs/a.txt") + assert candidate == "docs/a.v3.txt" + + +def test_next_ciphertext_relpath_versions_collisions(tmp_path: Path) -> None: + root = tmp_path / "assets" + (root / "docs").mkdir(parents=True) + first = _next_ciphertext_relpath(str(root), "docs/a.txt", 42, 123) + assert first == "docs/a.txt.enc.p42.k123.pke" + (root / first).write_bytes(b"x") + second = _next_ciphertext_relpath(str(root), "docs/a.txt", 42, 123) + assert second == "docs/a.txt.enc.p42.k123.v2.pke" + + +def test_encrypt_blob_has_expected_format_and_tag() -> None: + key = b"k" * 32 + plaintext = b"hello-world" + blob = _encrypt_blob(key, plaintext) + + assert blob.startswith(ENC_MAGIC) + nonce = blob[len(ENC_MAGIC) : len(ENC_MAGIC) + ENC_NONCE_SIZE] + tag = blob[len(ENC_MAGIC) + ENC_NONCE_SIZE : len(ENC_MAGIC) + ENC_NONCE_SIZE + 32] + ciphertext = blob[len(ENC_MAGIC) + ENC_NONCE_SIZE + 32 :] + + assert len(nonce) == ENC_NONCE_SIZE + assert len(tag) == 32 + assert len(ciphertext) == len(plaintext) + assert ciphertext != plaintext + + expected_tag = hmac.new(key, b"TAG" + nonce + ciphertext, hashlib.sha256).digest() + assert tag == expected_tag + + +def test_decrypt_blob_roundtrip_and_authentication() -> None: + key = b"z" * 32 + plaintext = b"secret-content" + blob = _encrypt_blob(key, plaintext) + assert _decrypt_blob(key, blob) == plaintext + + tampered = bytearray(blob) + tampered[-1] ^= 0xFF + try: + _decrypt_blob(key, bytes(tampered)) + assert False, "tampering should fail authentication" + except ValueError as exc: + assert "authentication" in str(exc) + + +def test_list_plaintext_rows_excludes_ciphertexts(tmp_path: Path) -> None: + root = tmp_path / "assets" + root.mkdir() + (root / "a.txt").write_text("a", encoding="utf-8") + (root / "a.txt.enc.p42.k1.pke").write_bytes(b"cipher") + + rows = _list_plaintext_rows(str(root)) + assert len(rows) == 1 + assert rows[0]["relpath"] == "a.txt" + assert rows[0]["size_bytes"] == 1 + assert rows[0]["size_label"].endswith("B") + + +def test_asset_lifecycle_state_machine_classification() -> None: + assert _asset_lifecycle_state(mapping_count=0, blocked_count=0) == "eligible" + assert _asset_lifecycle_state(mapping_count=2, blocked_count=0) == "encrypted_live" + assert _asset_lifecycle_state(mapping_count=3, blocked_count=1) == "encrypted_partial" + assert _asset_lifecycle_state(mapping_count=4, blocked_count=4) == "encrypted_blocked" + + +def test_assets_page_renders_state_machine_workflow(monkeypatch, tmp_path: Path) -> None: + root = tmp_path / "assets" + root.mkdir() + (root / "one.txt").write_text("1", encoding="utf-8") + monkeypatch.setenv("PUNCTURE_ASSET_ROOT", str(root)) + + app = create_app() + client = app.test_client() + resp = client.get("/assets") + html = resp.data.decode("utf-8") + + assert resp.status_code == 200 + assert "Asset Workflow" in html + assert "Single Lifecycle Flow" in html + assert "id=\"upload_btn\"" in html + assert "id=\"eligible_list\"" in html + assert "id=\"encrypt_btn\"" in html + assert "id=\"wipe_btn\"" in html + assert "id=\"combo_quick\"" in html + assert "const INITIAL_STATE" in html + assert "/api/assets/workflow/upload" in html + assert "/api/assets/workflow/encrypt" in html + assert "/api/assets/workflow/decrypt" in html + + +def test_assets_workflow_api_shows_quick_key_combo_options(monkeypatch, tmp_path: Path) -> None: + root = tmp_path / "assets" + root.mkdir() + (root / "one.txt").write_text("1", encoding="utf-8") + monkeypatch.setenv("PUNCTURE_ASSET_ROOT", str(root)) + + app = create_app() + client = app.test_client() + client.post( + "/derive", + data={"provider_id": "42", "file_time_id": "999", "purpose": "seed combo"}, + follow_redirects=True, + ) + payload = client.get("/api/assets/workflow").get_json() + combos = payload["state"]["key_combo_options"] + assert any(item["provider_id"] == 42 and item["file_time_id"] == 999 for item in combos) + labels = [item["label"] for item in combos] + assert any("Provider 42 | Key 999 | active" in label for label in labels) + + +def test_asset_workflow_encrypt_api_requires_selection(monkeypatch, tmp_path: Path) -> None: + root = tmp_path / "assets" + root.mkdir() + (root / "one.txt").write_text("1", encoding="utf-8") + monkeypatch.setenv("PUNCTURE_ASSET_ROOT", str(root)) + + app = create_app() + client = app.test_client() + resp = client.post( + "/api/assets/workflow/encrypt", + json={"provider_id": 42, "file_time_id": 7, "purpose": "none selected", "plaintext_relpaths": []}, + ) + assert resp.status_code == 400 + assert "select existing files or upload files before encrypting" in resp.get_json()["error"] + + +def test_asset_upload_duplicate_filename_is_versioned(monkeypatch, tmp_path: Path) -> None: + root = tmp_path / "assets" + root.mkdir() + monkeypatch.setenv("PUNCTURE_ASSET_ROOT", str(root)) + + app = create_app() + client = app.test_client() + client.post( + "/assets/upload", + data={"target_subdir": "docs", "files": [(BytesIO(b"a"), "dup.txt")]}, + content_type="multipart/form-data", + follow_redirects=True, + ) + client.post( + "/assets/upload", + data={"target_subdir": "docs", "files": [(BytesIO(b"b"), "dup.txt")]}, + content_type="multipart/form-data", + follow_redirects=True, + ) + + assert (root / "docs" / "dup.txt").is_file() + assert (root / "docs" / "dup.v2.txt").is_file() + + +def test_asset_page_renders_blocked_and_glow_mappings(monkeypatch, tmp_path: Path) -> None: + root = tmp_path / "assets" + root.mkdir() + (root / "a.txt").write_text("alpha", encoding="utf-8") + monkeypatch.setenv("PUNCTURE_ASSET_ROOT", str(root)) + + app = create_app() + client = app.test_client() + client.post( + "/assets/encrypt", + data={ + "plaintext_relpaths": ["a.txt"], + "provider_id": "42", + "file_time_id": "100", + "purpose": "a via p42", + }, + follow_redirects=True, + ) + client.post( + "/assets/encrypt", + data={ + "plaintext_relpaths": ["a.txt"], + "provider_id": "17", + "file_time_id": "200", + "purpose": "a via p17", + }, + follow_redirects=True, + ) + client.post("/puncture", data={"provider_id": "42", "file_time_id": "100"}, follow_redirects=True) + + payload = client.get("/api/assets/workflow").get_json()["state"] + file_map = {row["plaintext_relpath"]: row for row in payload["asset_files"]} + rows = {(r["provider_id"], r["file_time_id"]): r for r in file_map["a.txt"]["mappings"]} + assert rows[(42, 100)]["show_red"] is True + assert rows[(17, 200)]["show_glow"] is True + + +def test_asset_workflow_upload_api_makes_files_immediately_eligible(monkeypatch, tmp_path: Path) -> None: + root = tmp_path / "assets_upload_eligible" + root.mkdir() + monkeypatch.setenv("PUNCTURE_ASSET_ROOT", str(root)) + + app = create_app() + client = app.test_client() + resp = client.post( + "/api/assets/workflow/upload", + data={"target_subdir": "incoming", "files": [(BytesIO(b"hello"), "a.txt"), (BytesIO(b"world"), "b.txt")]}, + content_type="multipart/form-data", + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["ok"] is True + assert sorted(data["uploaded"]) == ["incoming/a.txt", "incoming/b.txt"] + assert (root / "incoming" / "a.txt").is_file() + assert (root / "incoming" / "b.txt").is_file() + + states = {row["relpath"]: row["lifecycle_state"] for row in data["state"]["files"]} + assert states["incoming/a.txt"] == "eligible" + assert states["incoming/b.txt"] == "eligible" + + +def test_asset_workflow_encrypt_api_saves_ciphertext_immediately(monkeypatch, tmp_path: Path) -> None: + root = tmp_path / "assets_encrypt_now" + root.mkdir() + monkeypatch.setenv("PUNCTURE_ASSET_ROOT", str(root)) + + app = create_app() + client = app.test_client() + upload = client.post( + "/api/assets/workflow/upload", + data={"target_subdir": "x", "files": [(BytesIO(b"alpha"), "one.txt")]}, + content_type="multipart/form-data", + ).get_json() + assert upload["ok"] is True + + enc = client.post( + "/api/assets/workflow/encrypt", + json={ + "provider_id": 17, + "file_time_id": 987, + "purpose": "encrypt now", + "plaintext_relpaths": ["x/one.txt"], + }, + ) + assert enc.status_code == 200 + data = enc.get_json() + assert data["ok"] is True + assert (root / "x" / "one.txt.enc.p17.k987.pke").is_file() + + file_states = {row["relpath"]: row for row in data["state"]["files"]} + assert file_states["x/one.txt"]["lifecycle_state"] == "encrypted_live" + assert file_states["x/one.txt"]["mapping_count"] == 1 + + +def test_asset_workflow_clear_api_resets_saved_inputs(monkeypatch, tmp_path: Path) -> None: + root = tmp_path / "assets_wipe" + root.mkdir() + monkeypatch.setenv("PUNCTURE_ASSET_ROOT", str(root)) + + app = create_app() + client = app.test_client() + client.post( + "/api/assets/workflow/encrypt", + json={ + "provider_id": 42, + "file_time_id": 111, + "purpose": "will fail no files but sets no state", + "plaintext_relpaths": [], + }, + ) + clear = client.post("/api/assets/workflow/clear") + assert clear.status_code == 200 + payload = clear.get_json() + assert payload["ok"] is True + assert payload["state"]["last_inputs"]["provider_id"] == 42 + assert payload["state"]["last_inputs"]["file_time_id"] == 123456 + + +def test_index_shows_frontier_and_removes_share_step(monkeypatch, tmp_path: Path) -> None: + root = tmp_path / "assets_frontier_index" + root.mkdir() + monkeypatch.setenv("PUNCTURE_ASSET_ROOT", str(root)) + + app = create_app() + client = app.test_client() + resp = client.get("/") + html = resp.data.decode("utf-8") + + assert resp.status_code == 200 + assert "Current Active Roots (Frontier)" in html + assert "Tree/Subtree Visualization" in html + assert "No puncture yet. Root frontier covers the full derivation space." in html + assert "Step 1: Backup Shares (2-of-3)" not in html + assert html.find("Current Active Roots (Frontier)") < html.find("Quick Start Walkthrough") + + +def test_api_state_includes_frontier_and_excludes_share_fields(monkeypatch, tmp_path: Path) -> None: + root = tmp_path / "assets_frontier_api" + root.mkdir() + monkeypatch.setenv("PUNCTURE_ASSET_ROOT", str(root)) + + app = create_app() + client = app.test_client() + payload = client.get("/api/state").get_json() + + assert payload["active_prefixes"] == [""] + assert len(payload["active_frontier"]) == 1 + assert payload["active_frontier"][0]["is_root"] is True + assert "seed_shares" not in payload + assert "shares_acknowledged" not in payload + + +def test_frontier_moves_from_root_after_puncture(monkeypatch, tmp_path: Path) -> None: + root = tmp_path / "assets_frontier_puncture" + root.mkdir() + monkeypatch.setenv("PUNCTURE_ASSET_ROOT", str(root)) + + app = create_app() + client = app.test_client() + + before = client.get("/api/state").get_json() + assert before["active_prefixes"] == [""] + + client.post("/puncture", data={"provider_id": "42", "file_time_id": "123456"}, follow_redirects=True) + after = client.get("/api/state").get_json() + + assert "" not in after["active_prefixes"] + assert len(after["active_prefixes"]) > 1 + assert all(row["depth"] > 0 for row in after["active_frontier"]) + + +def test_api_state_tracks_last_puncture_frontier_diff(monkeypatch, tmp_path: Path) -> None: + root = tmp_path / "assets_last_diff" + root.mkdir() + monkeypatch.setenv("PUNCTURE_ASSET_ROOT", str(root)) + + app = create_app() + client = app.test_client() + client.post("/puncture", data={"provider_id": "42", "file_time_id": "123456"}, follow_redirects=True) + payload = client.get("/api/state").get_json() + + diff = payload["last_puncture_diff"] + assert diff["target_kind"] == "tag" + assert diff["target"] == "01010100000000011110001001000000" + assert "" in diff["removed"] + assert isinstance(diff["added"], list) + + +def test_asset_workflow_decrypt_api_restores_cleartext(monkeypatch, tmp_path: Path) -> None: + root = tmp_path / "assets_decrypt_ok" + root.mkdir() + (root / "p.txt").write_text("plain-alpha", encoding="utf-8") + monkeypatch.setenv("PUNCTURE_ASSET_ROOT", str(root)) + + app = create_app() + client = app.test_client() + enc = client.post( + "/api/assets/workflow/encrypt", + json={ + "provider_id": 42, + "file_time_id": 456, + "purpose": "decrypt-test", + "plaintext_relpaths": ["p.txt"], + }, + ).get_json() + mapping = enc["state"]["asset_files"][0]["mappings"][0] + record_id = int(mapping["record_id"]) + + dec = client.post("/api/assets/workflow/decrypt", json={"record_ids": [record_id]}) + assert dec.status_code == 200 + payload = dec.get_json() + assert payload["ok"] is True + restored = payload["restored"][0]["decrypted_relpath"] + assert (root / restored).is_file() + assert (root / restored).read_text(encoding="utf-8") == "plain-alpha" + + +def test_asset_workflow_decrypt_fails_when_key_punctured(monkeypatch, tmp_path: Path) -> None: + root = tmp_path / "assets_decrypt_blocked" + root.mkdir() + (root / "q.txt").write_text("plain-beta", encoding="utf-8") + monkeypatch.setenv("PUNCTURE_ASSET_ROOT", str(root)) + + app = create_app() + client = app.test_client() + enc = client.post( + "/api/assets/workflow/encrypt", + json={ + "provider_id": 17, + "file_time_id": 700, + "purpose": "puncture-then-decrypt", + "plaintext_relpaths": ["q.txt"], + }, + ).get_json() + mapping = enc["state"]["asset_files"][0]["mappings"][0] + record_id = int(mapping["record_id"]) + + client.post("/puncture", data={"provider_id": "17", "file_time_id": "700"}, follow_redirects=True) + dec = client.post("/api/assets/workflow/decrypt", json={"record_ids": [record_id]}) + assert dec.status_code == 400 + assert "punctured" in dec.get_json()["error"] diff --git a/tests/test_master_asset_mapping.py b/tests/test_master_asset_mapping.py new file mode 100644 index 0000000..4a71b79 --- /dev/null +++ b/tests/test_master_asset_mapping.py @@ -0,0 +1,157 @@ +from pathlib import Path +from io import BytesIO + +from puncture.web_app import create_app + + +def test_asset_mapping_status_red_and_glow(monkeypatch, tmp_path: Path) -> None: + asset_root = tmp_path / "assets" + asset_root.mkdir() + (asset_root / "a.txt").write_text("alpha", encoding="utf-8") + (asset_root / "b.txt").write_text("beta", encoding="utf-8") + + monkeypatch.setenv("PUNCTURE_ASSET_ROOT", str(asset_root)) + + app = create_app() + client = app.test_client() + + client.post( + "/assets/encrypt", + data={ + "plaintext_relpath": "a.txt", + "provider_id": "42", + "file_time_id": "100", + "purpose": "a via p42", + }, + ) + client.post( + "/assets/encrypt", + data={ + "plaintext_relpath": "a.txt", + "provider_id": "17", + "file_time_id": "200", + "purpose": "a via p17", + }, + ) + client.post( + "/assets/encrypt", + data={ + "plaintext_relpath": "b.txt", + "provider_id": "42", + "file_time_id": "100", + "purpose": "b via p42", + }, + ) + + # Puncture shared key (provider 42 / key 100) used by two files. + client.post("/puncture", data={"provider_id": "42", "file_time_id": "100"}, follow_redirects=True) + + live = client.get("/api/live/state").get_json() + files = {row["plaintext_relpath"]: row for row in live["assets"]["asset_files"]} + + file_a = files["a.txt"] + rows_a = {(r["provider_id"], r["file_time_id"]): r for r in file_a["mappings"]} + assert rows_a[(42, 100)]["show_red"] is True + assert rows_a[(42, 100)]["is_accessible"] is False + assert rows_a[(17, 200)]["show_glow"] is True + assert rows_a[(17, 200)]["is_accessible"] is True + + file_b = files["b.txt"] + rows_b = {(r["provider_id"], r["file_time_id"]): r for r in file_b["mappings"]} + assert rows_b[(42, 100)]["show_red"] is True + assert rows_b[(42, 100)]["is_accessible"] is False + + +def test_remote_puncture_provider_endpoint_requires_token(monkeypatch, tmp_path: Path) -> None: + asset_root = tmp_path / "assets2" + asset_root.mkdir() + (asset_root / "c.txt").write_text("content", encoding="utf-8") + + monkeypatch.setenv("PUNCTURE_ASSET_ROOT", str(asset_root)) + monkeypatch.setenv("PUNCTURE_REMOTE_TOKEN", "tok") + + app = create_app() + client = app.test_client() + + # Derive once pre-kill to verify accessibility. + resp_pre = client.post( + "/derive", + data={"provider_id": "42", "file_time_id": "300", "purpose": "pre"}, + follow_redirects=True, + ) + assert b"Derive succeeded" in resp_pre.data + + denied = client.post("/api/remote/puncture-provider", json={"provider_id": 42}) + assert denied.status_code == 403 + + allowed = client.post( + "/api/remote/puncture-provider", + json={"provider_id": 42}, + headers={"X-Puncture-Token": "tok"}, + ) + assert allowed.status_code == 200 + assert allowed.get_json()["ok"] is True + + resp_post = client.post( + "/derive", + data={"provider_id": "42", "file_time_id": "300", "purpose": "post"}, + follow_redirects=True, + ) + assert b"Derive blocked" in resp_post.data + + +def test_asset_page_can_encrypt_multiple_selected_files(monkeypatch, tmp_path: Path) -> None: + asset_root = tmp_path / "assets3" + asset_root.mkdir() + (asset_root / "f1.txt").write_text("f1", encoding="utf-8") + (asset_root / "f2.txt").write_text("f2", encoding="utf-8") + monkeypatch.setenv("PUNCTURE_ASSET_ROOT", str(asset_root)) + + app = create_app() + client = app.test_client() + + client.post( + "/assets/encrypt", + data={ + "plaintext_relpaths": ["f1.txt", "f2.txt"], + "provider_id": "42", + "file_time_id": "444", + "purpose": "batch", + }, + follow_redirects=True, + ) + + ciphers = sorted(asset_root.glob("*.pke")) + assert len(ciphers) == 2 + assert any(".enc.p42.k444.pke" in p.name for p in ciphers) + + live = client.get("/api/live/state").get_json() + assert live["assets"]["mapping_count"] == 2 + files = {row["plaintext_relpath"]: row for row in live["assets"]["asset_files"]} + assert "f1.txt" in files + assert "f2.txt" in files + + +def test_asset_upload_persists_cleartext_files(monkeypatch, tmp_path: Path) -> None: + asset_root = tmp_path / "assets4" + asset_root.mkdir() + monkeypatch.setenv("PUNCTURE_ASSET_ROOT", str(asset_root)) + + app = create_app() + client = app.test_client() + + resp = client.post( + "/assets/upload", + data={ + "target_subdir": "docs", + "files": [ + (BytesIO(b"alpha"), "a.txt"), + (BytesIO(b"beta"), "b.txt"), + ], + }, + content_type="multipart/form-data", + follow_redirects=True, + ) + assert resp.status_code == 200 + assert (asset_root / "docs" / "a.txt").is_file() + assert (asset_root / "docs" / "b.txt").is_file() diff --git a/tests/test_puncture_manager.py b/tests/test_puncture_manager.py new file mode 100644 index 0000000..0f0b95a --- /dev/null +++ b/tests/test_puncture_manager.py @@ -0,0 +1,90 @@ +from puncture.key_manager import ( + PATH_BITS, + PuncturableKeyManager, + provider_id_to_prefix, + tag_to_binary_path, +) + + +def test_mapping_and_derivation_roundtrip() -> None: + manager = PuncturableKeyManager(master_seed=b"\x11" * 32) + path = tag_to_binary_path(42, 123) + key = manager.get_key_for_tag(path) + assert key is not None + assert len(key) == 32 + + +def test_scenario_a_provider_42_then_puncture() -> None: + manager = PuncturableKeyManager(master_seed=b"\x22" * 32) + path = tag_to_binary_path(42, 999) + + key_before = manager.get_key_for_tag(path) + assert key_before is not None + + punctured = manager.puncture(path) + assert punctured is True + assert manager.get_key_for_tag(path) is None + + +def test_non_target_paths_still_accessible_after_puncture() -> None: + manager = PuncturableKeyManager(master_seed=b"\x33" * 32) + target = tag_to_binary_path(42, 12345) + other = tag_to_binary_path(42, 12346) + + other_before = manager.get_key_for_tag(other) + manager.puncture(target) + other_after = manager.get_key_for_tag(other) + + assert manager.get_key_for_tag(target) is None + assert other_before == other_after + + +def test_minimal_copath_replacement_from_root() -> None: + manager = PuncturableKeyManager(master_seed=b"\x44" * 32) + path = tag_to_binary_path(1, 1) + assert manager.active_node_count == 1 + + manager.puncture(path) + + # Remove root (1) and add one sibling node per level (32). + assert manager.active_node_count == PATH_BITS + + +def test_scenario_b_seized_state_cannot_recover_punctured() -> None: + manager = PuncturableKeyManager(master_seed=b"\x55" * 32) + punctured = tag_to_binary_path(42, 2024) + control = tag_to_binary_path(42, 2025) + + control_before = manager.get_key_for_tag(control) + manager.puncture(punctured) + + seized = PuncturableKeyManager.from_state(manager.export_state()) + assert seized.get_key_for_tag(punctured) is None + assert seized.get_key_for_tag(control) == control_before + + +def test_provider_puncture_blocks_all_provider_keys() -> None: + manager = PuncturableKeyManager(master_seed=b"\x66" * 32) + p42_a = tag_to_binary_path(42, 100) + p42_b = tag_to_binary_path(42, 101) + p41 = tag_to_binary_path(41, 100) + + p41_before = manager.get_key_for_tag(p41) + assert manager.puncture_provider(42) is True + + assert manager.get_key_for_tag(p42_a) is None + assert manager.get_key_for_tag(p42_b) is None + assert manager.get_key_for_tag(p41) == p41_before + + +def test_prefix_puncture_log_replay() -> None: + seed = b"\x77" * 32 + source = PuncturableKeyManager(master_seed=seed) + target = PuncturableKeyManager(master_seed=seed) + + provider_prefix = provider_id_to_prefix(42) + source.puncture_prefix(provider_prefix) + + applied = target.apply_puncture_log(source.puncture_log()) + assert applied == 1 + assert target.get_key_for_tag(tag_to_binary_path(42, 1234)) is None diff --git a/tests/test_view_app_policy.py b/tests/test_view_app_policy.py new file mode 100644 index 0000000..743e478 --- /dev/null +++ b/tests/test_view_app_policy.py @@ -0,0 +1,92 @@ +from puncture import view_app + + +def _sample_live_state() -> dict: + return { + "generated_at": "2026-02-12 19:00:00 UTC", + "providers": [ + { + "provider_id": 42, + "name": "Provider 42", + "description": "Demo", + "prefix": "0101010", + "derived_count": 1, + "punctured_count": 0, + "key_count": 1, + } + ], + "key_journal": [ + { + "provider_id": 42, + "file_time_id": 1001, + "path_provider": "0101010", + "path_resource": "0000000000000001111101001", + "derive_count": 1, + "puncture_count": 0, + "description": "alpha", + "ever_punctured": False, + } + ], + "assets": { + "mapping_count": 1, + "blocked_count": 0, + "glow_count": 0, + "asset_files": [ + { + "plaintext_relpath": "docs/a.txt", + "mapping_count": 1, + "blocked_count": 0, + "mappings": [ + { + "provider_id": 42, + "file_time_id": 1001, + "ciphertext_relpath": "docs/a.txt.enc.p42.k1001.pke", + "path_provider": "0101010", + "path_resource": "0000000000000001111101001", + "is_accessible": True, + "show_red": False, + "show_glow": False, + } + ], + } + ], + "key_cards": [], + }, + } + + +def test_secondary_login_success(monkeypatch) -> None: + monkeypatch.setenv("PUNCTURE_SECONDARY_PASSWORD", "secret") + monkeypatch.setattr(view_app, "_fetch_master_state", lambda: _sample_live_state()) + + app = view_app.create_app() + client = app.test_client() + + resp = client.post("/login", data={"password": "secret"}, follow_redirects=True) + assert resp.status_code == 200 + assert b"Secondary Live Viewer" in resp.data + + api_resp = client.get("/api/state") + assert api_resp.status_code == 200 + assert api_resp.get_json()["ok"] is True + + +def test_secondary_kill_switch_password_triggers_remote_puncture(monkeypatch) -> None: + monkeypatch.setenv("PUNCTURE_SECONDARY_PASSWORD", "secret") + monkeypatch.setattr(view_app, "_fetch_master_state", lambda: _sample_live_state()) + + called: dict[str, int] = {} + + def _fake_remote(provider_id: int) -> dict: + called["provider_id"] = provider_id + return {"ok": True, "provider_id": provider_id} + + monkeypatch.setattr(view_app, "_remote_puncture_provider", _fake_remote) + + app = view_app.create_app() + client = app.test_client() + + resp = client.post("/login", data={"password": "secret42"}, follow_redirects=True) + assert resp.status_code == 200 + assert called["provider_id"] == 42 + assert b"Kill switch activated" in resp.data diff --git a/tests/test_view_sync.py b/tests/test_view_sync.py new file mode 100644 index 0000000..96738c5 --- /dev/null +++ b/tests/test_view_sync.py @@ -0,0 +1,88 @@ +from puncture.view_sync import ( + build_view_payload, + extract_view_payload, + sign_payload, + verify_payload_signature, + wrap_view_bundle, +) + + +def _sample_system() -> dict: + return { + "providers": { + 42: { + "provider_id": 42, + "name": "Provider 42", + "description": "Demo", + "created_at": "10:00:00 UTC", + } + }, + "key_journal": { + "01010100000000000000000000000001": { + "provider_id": 42, + "file_time_id": 1, + "path": "01010100000000000000000000000001", + "description": "active", + "ever_derived": True, + "ever_punctured": False, + "derive_count": 1, + "puncture_count": 0, + "last_derived_at": "10:01:00 UTC", + "last_punctured_at": None, + }, + "01010100000000000000000000000010": { + "provider_id": 42, + "file_time_id": 2, + "path": "01010100000000000000000000000010", + "description": "punctured", + "ever_derived": True, + "ever_punctured": True, + "derive_count": 1, + "puncture_count": 1, + "last_derived_at": "10:02:00 UTC", + "last_punctured_at": "10:03:00 UTC", + }, + "01010100000000000000000000000011": { + "provider_id": 42, + "file_time_id": 3, + "path": "01010100000000000000000000000011", + "description": "never derived", + "ever_derived": False, + "ever_punctured": False, + "derive_count": 0, + "puncture_count": 0, + "last_derived_at": None, + "last_punctured_at": None, + }, + }, + "deleted_providers": [], + } + + +def test_build_view_payload_allows_only_derived_non_punctured() -> None: + payload = build_view_payload(_sample_system(), puncture_log=["0101010"]) + assert payload["allowed_paths"] == ["01010100000000000000000000000001"] + assert payload["puncture_log"] == ["0101010"] + assert len(payload["known_keys"]) == 3 + + +def test_sign_and_verify_bundle() -> None: + payload = build_view_payload(_sample_system(), puncture_log=[]) + key = "sync-secret" + signature = sign_payload(payload, key) + assert verify_payload_signature(payload, signature, key) + + wrapped = wrap_view_bundle(payload, key) + extracted = extract_view_payload(wrapped, sync_key=key, require_signature=True) + assert extracted["allowed_paths"] == payload["allowed_paths"] + + +def test_extract_rejects_bad_signature() -> None: + payload = build_view_payload(_sample_system(), puncture_log=[]) + wrapped = {"payload": payload, "hmac_sha256": "deadbeef", "signed": True} + + try: + extract_view_payload(wrapped, sync_key="sync-secret", require_signature=True) + assert False, "expected signature verification failure" + except ValueError as exc: + assert "signature" in str(exc).lower()