Initial commit: puncturable key manager apps and tooling
30
.gitignore
vendored
Normal file
|
|
@ -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/
|
||||
96
README.md
Normal file
|
|
@ -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: `<password>`
|
||||
- kill-switch login: `<password><provider_id>`
|
||||
- example: `puncture-view42` punctures provider `42` immediately on master.
|
||||
|
||||
## Environment variables
|
||||
|
||||
Master app:
|
||||
|
||||
- `PUNCTURE_PORT` (default `9122`)
|
||||
- `PUNCTURE_ASSET_ROOT` (default `<cwd>/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
|
||||
```
|
||||
112
goapp/README.md
Normal file
|
|
@ -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: `<asset-root>/.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://<master-ip>: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 ./...
|
||||
```
|
||||
72
goapp/cmd/desktop/main.go
Normal file
|
|
@ -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
|
||||
}
|
||||
9
goapp/cmd/desktop/main_stub.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
//go:build !(darwin && cgo && desktop)
|
||||
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println("desktop build requires: darwin + cgo + -tags desktop")
|
||||
}
|
||||
287
goapp/cmd/desktop/simulator_bridge.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
42
goapp/cmd/server/main.go
Normal file
|
|
@ -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
|
||||
}
|
||||
5
goapp/go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module puncture-go
|
||||
|
||||
go 1.25
|
||||
|
||||
require github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
|
||||
2
goapp/go.sum
Normal file
|
|
@ -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=
|
||||
1631
goapp/internal/app/state.go
Normal file
126
goapp/internal/app/state_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
312
goapp/internal/crypto/manager.go
Normal file
|
|
@ -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<<ProviderBits) {
|
||||
return "", fmt.Errorf("provider_id must be in [0,%d)", 1<<ProviderBits)
|
||||
}
|
||||
if fileTimeID < 0 || fileTimeID >= (1<<ResourceBits) {
|
||||
return "", fmt.Errorf("file_time_id must be in [0,%d)", 1<<ResourceBits)
|
||||
}
|
||||
value := (providerID << ResourceBits) | fileTimeID
|
||||
return fmt.Sprintf("%032b", value), nil
|
||||
}
|
||||
|
||||
func ProviderIDToPrefix(providerID int) (string, error) {
|
||||
if providerID < 0 || providerID >= (1<<ProviderBits) {
|
||||
return "", fmt.Errorf("provider_id must be in [0,%d)", 1<<ProviderBits)
|
||||
}
|
||||
return fmt.Sprintf("%07b", providerID), nil
|
||||
}
|
||||
|
||||
func (m *Manager) cloneSeed(prefix string) []byte {
|
||||
seed := m.activeNodes[prefix]
|
||||
out := make([]byte, len(seed))
|
||||
copy(out, seed)
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manager) findCovering(path string) (string, bool) {
|
||||
for depth := len(path); depth >= 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
|
||||
}
|
||||
68
goapp/internal/crypto/manager_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
384
goapp/internal/server/http.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
548
goapp/internal/server/static/index.html
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Puncture Go</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4efe5;
|
||||
--card: #fffdf8;
|
||||
--ink: #172126;
|
||||
--muted: #5d6a70;
|
||||
--line: #d8cfbe;
|
||||
--teal: #0f766e;
|
||||
--orange: #c2410c;
|
||||
--danger: #8b1d1d;
|
||||
--ok-soft: #ddf6ee;
|
||||
--warn-soft: #ffeede;
|
||||
--danger-soft: #ffe6e6;
|
||||
--info-soft: #eef4ff;
|
||||
--radius: 14px;
|
||||
--sans: "Avenir Next", "Trebuchet MS", "Lucida Grande", sans-serif;
|
||||
--mono: Menlo, Consolas, Monaco, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--sans);
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(900px 420px at -8% -12%, #d8ece8 0%, transparent 60%),
|
||||
radial-gradient(680px 340px at 110% 0%, #f8dcc7 0%, transparent 55%),
|
||||
var(--bg);
|
||||
}
|
||||
.wrap { max-width: 1200px; margin: 0 auto; padding: 14px; }
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
h1 { margin: 2px 0 8px; font-size: clamp(1.35rem, 4vw, 2rem); }
|
||||
h2 { margin: 0 0 8px; font-size: 1.05rem; }
|
||||
p { margin: 0 0 8px; }
|
||||
.muted { color: var(--muted); }
|
||||
.mono { font-family: var(--mono); font-size: 0.8rem; word-break: break-all; }
|
||||
|
||||
.stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
|
||||
.stat { border: 1px solid var(--line); border-radius: 10px; padding: 7px; background: #fff; }
|
||||
.stat .label { color: var(--muted); font-size: 0.74rem; }
|
||||
.stat .value { font-size: 1.12rem; font-weight: 700; }
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
||||
|
||||
label { display: block; margin: 8px 0 4px; font-size: 0.82rem; font-weight: 700; }
|
||||
input, select {
|
||||
width: 100%; border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 8px 9px; font: inherit; color: var(--ink); background: #fff;
|
||||
}
|
||||
|
||||
.btn {
|
||||
appearance: none; border: 0; border-radius: 10px; cursor: pointer;
|
||||
padding: 9px 11px; font: inherit; font-weight: 700;
|
||||
}
|
||||
.btn-primary { background: var(--teal); color: #fff; }
|
||||
.btn-warn { background: var(--orange); color: #fff; }
|
||||
.btn-danger { background: #f8dcdc; color: var(--danger); border: 1px solid #e8bcbc; }
|
||||
.btn-ghost { background: #fff; color: var(--ink); border: 1px solid var(--line); }
|
||||
.btn-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 9px; }
|
||||
|
||||
.notice { border: 1px solid var(--line); border-radius: 10px; padding: 9px; font-weight: 700; }
|
||||
.notice.success { background: var(--ok-soft); color: #0b4f49; }
|
||||
.notice.warn { background: var(--warn-soft); color: #6f2d13; }
|
||||
.notice.danger { background: var(--danger-soft); color: #7f1717; }
|
||||
.notice.info { background: var(--info-soft); color: #1f4f7a; }
|
||||
|
||||
#tree_shell { overflow-x: auto; border: 1px solid var(--line); border-radius: 10px; padding: 8px; background: #fff; }
|
||||
.tree-legend { margin-top: 7px; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip { border-radius: 999px; padding: 4px 7px; font-size: 0.75rem; border: 1px solid var(--line); background: #fff; }
|
||||
.chip.frontier { background: #d9f3ee; border-color: #b8e6db; color: #0b4f49; }
|
||||
.chip.possible { background: #e8f7ec; border-color: #c8e9d1; color: #1d5c2f; }
|
||||
.chip.derived { background: #fff0d0; border-color: #f0d7a2; color: #7b4d0a; }
|
||||
.chip.blocked { background: #fee6e6; border-color: #efc2c2; color: #7c1d1d; }
|
||||
.chip.removed { background: #f8d0d0; border-color: #e7abab; color: #8e1a1a; }
|
||||
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table td, .table th { border-bottom: 1px dashed #ece2cf; padding: 6px 4px; text-align: left; }
|
||||
.table th { color: var(--muted); font-size: 0.76rem; }
|
||||
|
||||
.mapping { border: 1px solid #ece3d1; border-radius: 10px; padding: 8px; margin-top: 7px; background: #fffefb; }
|
||||
.mapping.blocked { background: #ffe7e7; border-color: #eec2c2; color: #7b1c1c; }
|
||||
.mapping.glow { border-color: #96dccc; background: #e9fffa; box-shadow: 0 0 0 2px rgba(30,154,132,0.18); }
|
||||
|
||||
.history { max-height: 260px; overflow: auto; }
|
||||
.history-item { border-bottom: 1px solid #ece5d5; padding: 8px 0; }
|
||||
.history-meta { color: var(--muted); font-size: 0.76rem; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.stats { grid-template-columns: 1fr 1fr; }
|
||||
.grid-2, .grid-3 { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 580px) {
|
||||
.stats { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<section class="card">
|
||||
<h1>Puncture Go: macOS-primary forward secrecy</h1>
|
||||
<p class="muted">Go implementation with tree-frontier visualization and local encryption/decryption workflow.</p>
|
||||
<div id="top_notice" class="notice info">Loading state...</div>
|
||||
<div class="stats" style="margin-top:8px">
|
||||
<div class="stat"><div class="label">Active Nodes</div><div id="s_active_nodes" class="value">0</div></div>
|
||||
<div class="stat"><div class="label">Puncture Events</div><div id="s_punctures" class="value">0</div></div>
|
||||
<div class="stat"><div class="label">Cipher Mappings</div><div id="s_mappings" class="value">0</div></div>
|
||||
<div class="stat"><div class="label">Blocked Mappings</div><div id="s_blocked" class="value">0</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Tree/Subtree Visualization</h2>
|
||||
<p class="muted">Frontier roots are highlighted. Removed prior frontier after puncture is red. Impossible future subtrees are blocked.</p>
|
||||
<div class="stats" style="margin-bottom:8px">
|
||||
<div class="stat"><div class="label">Visible Frontier</div><div id="t_frontier" class="value">0</div></div>
|
||||
<div class="stat"><div class="label">Blocked Nodes</div><div id="t_blocked" class="value">0</div></div>
|
||||
<div class="stat"><div class="label">Removed Frontier</div><div id="t_removed" class="value">0</div></div>
|
||||
<div class="stat"><div class="label">Projection Depth</div><div id="t_depth" class="value">0</div></div>
|
||||
</div>
|
||||
<div id="tree_shell"></div>
|
||||
<div class="tree-legend">
|
||||
<span class="chip frontier">Current frontier</span>
|
||||
<span class="chip possible">Future derivable</span>
|
||||
<span class="chip derived">Already derived branch</span>
|
||||
<span class="chip blocked">Future impossible</span>
|
||||
<span class="chip removed">Deleted frontier (last puncture)</span>
|
||||
</div>
|
||||
<p id="last_puncture_label" class="muted" style="margin-top:7px"></p>
|
||||
</section>
|
||||
|
||||
<section class="grid-2">
|
||||
<article class="card">
|
||||
<h2>Derive / Puncture</h2>
|
||||
<label for="provider_id">Provider ID</label>
|
||||
<input id="provider_id" type="number" min="0" max="127" value="42" />
|
||||
<label for="file_time_id">File/Time ID</label>
|
||||
<input id="file_time_id" type="number" min="0" max="33554431" value="123456" />
|
||||
<label for="purpose">Purpose / Description</label>
|
||||
<input id="purpose" type="text" value="Demo key" maxlength="120" />
|
||||
<div class="btn-row">
|
||||
<button id="derive_btn" class="btn btn-primary">Derive Key</button>
|
||||
<button id="puncture_btn" class="btn btn-warn">Puncture Tag</button>
|
||||
<button id="reset_btn" class="btn btn-danger">Reset Lab</button>
|
||||
</div>
|
||||
<div style="margin-top:10px">
|
||||
<div class="muted">Latest action</div>
|
||||
<div id="last_action_title" style="font-weight:700"></div>
|
||||
<div id="last_action_body"></div>
|
||||
<div id="last_key_hex" class="mono" style="margin-top:6px"></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h2>Providers</h2>
|
||||
<label for="p_add_id">Add Provider ID</label>
|
||||
<input id="p_add_id" type="number" min="0" max="127" value="99" />
|
||||
<label for="p_add_name">Name</label>
|
||||
<input id="p_add_name" type="text" value="New Provider" />
|
||||
<label for="p_add_desc">Description</label>
|
||||
<input id="p_add_desc" type="text" value="" />
|
||||
<div class="btn-row">
|
||||
<button id="p_add_btn" class="btn btn-primary">Add Provider</button>
|
||||
</div>
|
||||
<table class="table" style="margin-top:8px">
|
||||
<thead><tr><th>ID</th><th>Name</th><th>Prefix</th><th>Delete</th></tr></thead>
|
||||
<tbody id="providers_body"></tbody>
|
||||
</table>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="grid-2">
|
||||
<article class="card">
|
||||
<h2>Assets: Upload -> Encrypt</h2>
|
||||
<label for="upload_files">Files</label>
|
||||
<input id="upload_files" type="file" multiple />
|
||||
<label for="target_subdir">Target Subdir</label>
|
||||
<input id="target_subdir" type="text" placeholder="optional/subdir" />
|
||||
<div class="btn-row">
|
||||
<button id="upload_btn" class="btn btn-ghost">Upload</button>
|
||||
<button id="select_all_btn" class="btn btn-ghost">Select all</button>
|
||||
<button id="clear_sel_btn" class="btn btn-ghost">Clear selection</button>
|
||||
</div>
|
||||
|
||||
<label for="combo_quick">Quick key combo</label>
|
||||
<select id="combo_quick"><option value="">Manual selection</option></select>
|
||||
|
||||
<label for="enc_provider_id">Encrypt with Provider ID</label>
|
||||
<input id="enc_provider_id" type="number" min="0" max="127" value="42" />
|
||||
<label for="enc_file_time_id">Encrypt with File/Time ID</label>
|
||||
<input id="enc_file_time_id" type="number" min="0" max="33554431" value="123456" />
|
||||
<label for="enc_purpose">Purpose</label>
|
||||
<input id="enc_purpose" type="text" value="asset encryption" />
|
||||
<div class="btn-row">
|
||||
<button id="encrypt_btn" class="btn btn-primary">Encrypt Selected</button>
|
||||
</div>
|
||||
|
||||
<table class="table" style="margin-top:9px">
|
||||
<thead><tr><th></th><th>File</th><th>State</th><th>Meta</th></tr></thead>
|
||||
<tbody id="files_body"></tbody>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h2>Ciphertext Mappings + Decrypt</h2>
|
||||
<div id="mappings_list"></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>History</h2>
|
||||
<div id="history_box" class="history"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
let appState = null;
|
||||
let selected = new Set();
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
|
||||
function showNotice(tone, message) {
|
||||
const n = el('top_notice');
|
||||
n.className = 'notice ' + tone;
|
||||
n.textContent = message;
|
||||
}
|
||||
|
||||
async function api(path, method='GET', body=null, isForm=false) {
|
||||
const opts = { method };
|
||||
if (body !== null) {
|
||||
if (isForm) {
|
||||
opts.body = body;
|
||||
} else {
|
||||
opts.headers = { 'Content-Type': 'application/json' };
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
}
|
||||
const resp = await fetch(path, opts);
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || !data.ok) {
|
||||
throw new Error(data.error || ('request failed: ' + resp.status));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function renderStats(state) {
|
||||
el('s_active_nodes').textContent = String(state.active_nodes || 0);
|
||||
el('s_punctures').textContent = String((state.puncture_log || []).length);
|
||||
el('s_mappings').textContent = String((state.assets && state.assets.mapping_count) || 0);
|
||||
el('s_blocked').textContent = String((state.assets && state.assets.blocked_count) || 0);
|
||||
}
|
||||
|
||||
function renderTree(state) {
|
||||
const t = state.tree_viz || {};
|
||||
el('t_frontier').textContent = String(t.current_frontier_count || 0);
|
||||
el('t_blocked').textContent = String(t.blocked_count || 0);
|
||||
el('t_removed').textContent = String(t.removed_count || 0);
|
||||
el('t_depth').textContent = String(t.depth || 0);
|
||||
el('tree_shell').innerHTML = t.svg || '<p class="muted">No tree projection available.</p>';
|
||||
if (t.last_puncture) {
|
||||
el('last_puncture_label').textContent = 'Last puncture (' + t.last_puncture.time + '): ' + t.last_puncture.target_kind + ' ' + t.last_puncture.target;
|
||||
} else {
|
||||
el('last_puncture_label').textContent = 'No puncture yet. Root frontier covers full derivation space.';
|
||||
}
|
||||
}
|
||||
|
||||
function renderLastAction(state) {
|
||||
const a = state.last_action || {};
|
||||
el('last_action_title').textContent = a.title || '';
|
||||
el('last_action_body').textContent = a.body || '';
|
||||
el('last_key_hex').textContent = a.key_hex ? ('key: ' + a.key_hex) : '';
|
||||
}
|
||||
|
||||
function renderProviders(state) {
|
||||
const body = el('providers_body');
|
||||
body.innerHTML = '';
|
||||
(state.providers || []).forEach((p) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = '<td>' + p.provider_id + '</td><td>' + p.name + '</td><td class="mono">' + p.prefix + '</td>';
|
||||
const td = document.createElement('td');
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-danger';
|
||||
btn.textContent = 'Delete+Puncture';
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm('Delete provider ' + p.provider_id + ' and puncture all keys?')) return;
|
||||
try {
|
||||
const out = await api('/api/providers/delete', 'POST', { provider_id: Number(p.provider_id) });
|
||||
updateState(out.state);
|
||||
showNotice('warn', 'Provider deleted and subtree punctured.');
|
||||
} catch (err) {
|
||||
showNotice('danger', String(err));
|
||||
}
|
||||
});
|
||||
td.appendChild(btn);
|
||||
tr.appendChild(td);
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderWorkflow(state) {
|
||||
const w = state.workflow || {};
|
||||
const files = w.files || [];
|
||||
const combos = w.key_combo_options || [];
|
||||
const body = el('files_body');
|
||||
body.innerHTML = '';
|
||||
|
||||
const relset = new Set(files.map((f) => f.relpath));
|
||||
selected = new Set([...selected].filter((p) => relset.has(p)));
|
||||
|
||||
files.forEach((f) => {
|
||||
const tr = document.createElement('tr');
|
||||
const c = document.createElement('input');
|
||||
c.type = 'checkbox';
|
||||
c.checked = selected.has(f.relpath);
|
||||
c.addEventListener('change', () => {
|
||||
if (c.checked) selected.add(f.relpath);
|
||||
else selected.delete(f.relpath);
|
||||
});
|
||||
const td0 = document.createElement('td'); td0.appendChild(c);
|
||||
const td1 = document.createElement('td'); td1.className = 'mono'; td1.textContent = f.relpath;
|
||||
const td2 = document.createElement('td'); td2.textContent = f.lifecycle_label;
|
||||
const td3 = document.createElement('td'); td3.textContent = f.size_label + ' · ' + f.modified_at;
|
||||
tr.appendChild(td0); tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
|
||||
body.appendChild(tr);
|
||||
});
|
||||
|
||||
const comboSel = el('combo_quick');
|
||||
comboSel.innerHTML = '<option value="">Manual selection</option>';
|
||||
combos.forEach((c) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = String(c.provider_id) + '|' + String(c.file_time_id);
|
||||
o.textContent = c.label;
|
||||
comboSel.appendChild(o);
|
||||
});
|
||||
comboSel.onchange = () => {
|
||||
if (!comboSel.value) return;
|
||||
const parts = comboSel.value.split('|');
|
||||
if (parts.length !== 2) return;
|
||||
el('enc_provider_id').value = parts[0];
|
||||
el('enc_file_time_id').value = parts[1];
|
||||
};
|
||||
}
|
||||
|
||||
function renderMappings(state) {
|
||||
const wrap = el('mappings_list');
|
||||
wrap.innerHTML = '';
|
||||
const files = (state.assets && state.assets.asset_files) || [];
|
||||
if (files.length === 0) {
|
||||
wrap.innerHTML = '<p class="muted">No ciphertext mappings yet.</p>';
|
||||
return;
|
||||
}
|
||||
files.forEach((f) => {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'mapping';
|
||||
box.innerHTML = '<strong>' + f.plaintext_relpath + '</strong><div class="muted">Mappings: ' + f.mapping_count + ' | Blocked: ' + f.blocked_count + '</div>';
|
||||
(f.mappings || []).forEach((m) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'mapping' + (m.show_red ? ' blocked' : '') + (m.show_glow ? ' glow' : '');
|
||||
const head = document.createElement('div');
|
||||
head.innerHTML = '<strong>Provider ' + m.provider_id + ' | Key ' + m.file_time_id + '</strong> <span class="muted">' + (m.is_accessible ? 'decryptable' : 'blocked') + '</span>';
|
||||
const c = document.createElement('div');
|
||||
c.className = 'mono';
|
||||
c.textContent = 'cipher: ' + m.ciphertext_relpath;
|
||||
const d = document.createElement('div');
|
||||
d.className = 'muted';
|
||||
d.textContent = m.last_decrypted_relpath ? ('last decrypted: ' + m.last_decrypted_relpath + ' (' + (m.last_decrypted_at || 'time unknown') + ')') : 'not decrypted yet';
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn btn-ghost';
|
||||
btn.textContent = m.is_accessible ? 'Decrypt To Filesystem' : 'Blocked (punctured key)';
|
||||
btn.disabled = !m.is_accessible;
|
||||
btn.addEventListener('click', async () => {
|
||||
try {
|
||||
const out = await api('/api/assets/decrypt', 'POST', { record_ids: [Number(m.record_id)] });
|
||||
updateState(out.state);
|
||||
showNotice(out.errors && out.errors.length ? 'warn' : 'success', 'Decryption finished.');
|
||||
} catch (err) {
|
||||
showNotice('danger', String(err));
|
||||
}
|
||||
});
|
||||
row.appendChild(head); row.appendChild(c); row.appendChild(d); row.appendChild(btn);
|
||||
box.appendChild(row);
|
||||
});
|
||||
wrap.appendChild(box);
|
||||
});
|
||||
}
|
||||
|
||||
function renderHistory(state) {
|
||||
const box = el('history_box');
|
||||
box.innerHTML = '';
|
||||
const rows = state.history || [];
|
||||
if (rows.length === 0) {
|
||||
box.innerHTML = '<p class="muted">No actions yet.</p>';
|
||||
return;
|
||||
}
|
||||
rows.forEach((h) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'history-item';
|
||||
item.innerHTML = '<div class="history-meta">' + h.time + ' | ' + h.action + ' | ' + h.status + '</div><div>' + h.summary + '</div>' + (h.path ? ('<div class="mono muted">' + h.path + '</div>') : '');
|
||||
box.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function updateState(state) {
|
||||
appState = state;
|
||||
renderStats(state);
|
||||
renderTree(state);
|
||||
renderLastAction(state);
|
||||
renderProviders(state);
|
||||
renderWorkflow(state);
|
||||
renderMappings(state);
|
||||
renderHistory(state);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const out = await api('/api/state');
|
||||
updateState(out.state);
|
||||
}
|
||||
|
||||
function wireActions() {
|
||||
el('derive_btn').addEventListener('click', async () => {
|
||||
try {
|
||||
const out = await api('/api/derive', 'POST', {
|
||||
provider_id: Number(el('provider_id').value),
|
||||
file_time_id: Number(el('file_time_id').value),
|
||||
purpose: el('purpose').value || ''
|
||||
});
|
||||
updateState(out.state);
|
||||
showNotice('success', 'Derive completed.');
|
||||
} catch (err) {
|
||||
showNotice('danger', String(err));
|
||||
}
|
||||
});
|
||||
|
||||
el('puncture_btn').addEventListener('click', async () => {
|
||||
try {
|
||||
const out = await api('/api/puncture', 'POST', {
|
||||
provider_id: Number(el('provider_id').value),
|
||||
file_time_id: Number(el('file_time_id').value)
|
||||
});
|
||||
updateState(out.state);
|
||||
showNotice('warn', 'Puncture processed.');
|
||||
} catch (err) {
|
||||
showNotice('danger', String(err));
|
||||
}
|
||||
});
|
||||
|
||||
el('reset_btn').addEventListener('click', async () => {
|
||||
if (!confirm('Reset lab and destroy current state?')) return;
|
||||
try {
|
||||
const out = await api('/api/reset', 'POST');
|
||||
selected.clear();
|
||||
updateState(out.state);
|
||||
showNotice('info', 'Lab reset completed.');
|
||||
} catch (err) {
|
||||
showNotice('danger', String(err));
|
||||
}
|
||||
});
|
||||
|
||||
el('p_add_btn').addEventListener('click', async () => {
|
||||
try {
|
||||
const out = await api('/api/providers/add', 'POST', {
|
||||
provider_id: Number(el('p_add_id').value),
|
||||
name: el('p_add_name').value,
|
||||
description: el('p_add_desc').value
|
||||
});
|
||||
updateState(out.state);
|
||||
showNotice('success', 'Provider added.');
|
||||
} catch (err) {
|
||||
showNotice('danger', String(err));
|
||||
}
|
||||
});
|
||||
|
||||
el('upload_btn').addEventListener('click', async () => {
|
||||
const files = el('upload_files').files;
|
||||
if (!files || files.length === 0) {
|
||||
showNotice('warn', 'Select at least one file for upload.');
|
||||
return;
|
||||
}
|
||||
const form = new FormData();
|
||||
for (const f of files) form.append('files', f);
|
||||
form.append('target_subdir', el('target_subdir').value || '');
|
||||
try {
|
||||
const out = await api('/api/assets/upload', 'POST', form, true);
|
||||
(out.uploaded || []).forEach((p) => selected.add(p));
|
||||
el('upload_files').value = '';
|
||||
updateState(out.state);
|
||||
showNotice('success', 'Upload completed.');
|
||||
} catch (err) {
|
||||
showNotice('danger', String(err));
|
||||
}
|
||||
});
|
||||
|
||||
el('select_all_btn').addEventListener('click', () => {
|
||||
const files = ((appState || {}).workflow || {}).files || [];
|
||||
files.forEach((f) => selected.add(f.relpath));
|
||||
renderWorkflow(appState);
|
||||
});
|
||||
|
||||
el('clear_sel_btn').addEventListener('click', () => {
|
||||
selected.clear();
|
||||
renderWorkflow(appState);
|
||||
});
|
||||
|
||||
el('encrypt_btn').addEventListener('click', async () => {
|
||||
if (selected.size === 0) {
|
||||
showNotice('warn', 'Select at least one cleartext file before encryption.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const out = await api('/api/assets/encrypt', 'POST', {
|
||||
plaintext_relpaths: Array.from(selected),
|
||||
provider_id: Number(el('enc_provider_id').value),
|
||||
file_time_id: Number(el('enc_file_time_id').value),
|
||||
purpose: el('enc_purpose').value || ''
|
||||
});
|
||||
selected.clear();
|
||||
updateState(out.state);
|
||||
showNotice((out.errors && out.errors.length) ? 'warn' : 'success', 'Encryption completed.');
|
||||
} catch (err) {
|
||||
showNotice('danger', String(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
wireActions();
|
||||
try {
|
||||
await refresh();
|
||||
showNotice('info', 'State loaded.');
|
||||
} catch (err) {
|
||||
showNotice('danger', String(err));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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 = "<group>"; };
|
||||
2328E7FE882644CDF6BF9FB5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
35C4FD6BA6A99205C6DD71D9 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
||||
582D93687F8F124CAED644C1 /* EmergencyPunctureApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmergencyPunctureApp.swift; sourceTree = "<group>"; };
|
||||
85C16F2D6F0687194AFE7768 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
0D13354FC892E3DC820A07C7 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
07A00A5B246ECE26D159E16A /* EmergencyPuncture.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A5297052C84FF04DC5EB1219 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A6A0E42C6C35DB6DAB94488B /* Sources */,
|
||||
0D13354FC892E3DC820A07C7 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A6A0E42C6C35DB6DAB94488B /* Sources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
35C4FD6BA6A99205C6DD71D9 /* APIClient.swift */,
|
||||
85C16F2D6F0687194AFE7768 /* ContentView.swift */,
|
||||
582D93687F8F124CAED644C1 /* EmergencyPunctureApp.swift */,
|
||||
2328E7FE882644CDF6BF9FB5 /* Info.plist */,
|
||||
0D3E856C899A743DE196B150 /* Models.swift */,
|
||||
);
|
||||
path = Sources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
70
goapp/ios/EmergencyPuncture/Sources/APIClient.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
115
goapp/ios/EmergencyPuncture/Sources/ContentView.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct EmergencyPunctureApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
33
goapp/ios/EmergencyPuncture/Sources/Info.plist
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
27
goapp/ios/EmergencyPuncture/Sources/Models.swift
Normal file
|
|
@ -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?
|
||||
}
|
||||
32
goapp/ios/EmergencyPuncture/project.yml
Normal file
|
|
@ -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: ""
|
||||
65
goapp/ios/INSTALL_IPHONE.md
Normal file
|
|
@ -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.
|
||||
71
goapp/ios/build_signed_ipa.sh
Executable file
|
|
@ -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" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>debugging</string>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
<key>teamID</key>
|
||||
<string>${TEAM_ID}</string>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
<key>compileBitcode</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
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"
|
||||
BIN
goapp/packaging/macos/AppIcon.icns
Normal file
28
goapp/packaging/macos/Info.plist
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Puncture</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.puncture.go</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Puncture Go</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
59
goapp/packaging/macos/build_dmg.sh
Executable file
|
|
@ -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"
|
||||
BIN
goapp/packaging/macos/icon_work/AppIcon-1024.png
Normal file
|
After Width: | Height: | Size: 820 KiB |
BIN
goapp/packaging/macos/icon_work/AppIcon.iconset/icon_128x128.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 62 KiB |
BIN
goapp/packaging/macos/icon_work/AppIcon.iconset/icon_16x16.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
BIN
goapp/packaging/macos/icon_work/AppIcon.iconset/icon_256x256.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 196 KiB |
BIN
goapp/packaging/macos/icon_work/AppIcon.iconset/icon_32x32.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 13 KiB |
BIN
goapp/packaging/macos/icon_work/AppIcon.iconset/icon_512x512.png
Normal file
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 820 KiB |
121
goapp/packaging/macos/icon_work/generate_icon.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
21
puncture/__init__.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
283
puncture/key_manager.py
Normal file
|
|
@ -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
|
||||
94
puncture/simulation.py
Normal file
|
|
@ -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))
|
||||
477
puncture/view_app.py
Normal file
|
|
@ -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 = """
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Secondary Access</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f2e9;
|
||||
--card: #fffdf8;
|
||||
--ink: #172126;
|
||||
--muted: #5e6b70;
|
||||
--line: #d8d0bf;
|
||||
--teal: #0f766e;
|
||||
--danger: #8b1d1d;
|
||||
--warn: #9a3412;
|
||||
--radius: 14px;
|
||||
--sans: "Avenir Next", "Trebuchet MS", "Lucida Grande", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--sans);
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(860px 430px at -10% -10%, #d7ece8 0%, transparent 60%),
|
||||
radial-gradient(680px 350px at 105% 0%, #fae3cf 0%, transparent 55%),
|
||||
var(--bg);
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 14px;
|
||||
}
|
||||
.card {
|
||||
width: min(560px, 100%);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
h1 { margin: 0 0 8px; }
|
||||
p { margin: 0 0 8px; }
|
||||
.muted { color: var(--muted); }
|
||||
label { display: block; margin: 9px 0 4px; font-weight: 700; }
|
||||
input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
}
|
||||
button {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
background: var(--teal);
|
||||
}
|
||||
.notice {
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
padding: 9px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.notice.success { background: #dcf4ef; color: #0c4f49; border-color: #b7e5dc; }
|
||||
.notice.warn { background: #ffeede; color: #6f2d13; border-color: #f0d2bb; }
|
||||
.notice.danger { background: #ffe7e7; color: #7f1717; border-color: #efc3c3; }
|
||||
.notice.info { background: #edf4ff; color: #23466b; border-color: #cad9ee; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<h1>Secondary Live Viewer Login</h1>
|
||||
<p class="muted">Master source: {{ master_url }}</p>
|
||||
<p class="muted">Kill switch format: `password` + `provider_id` (for example `secret42`).</p>
|
||||
|
||||
{% if notice %}
|
||||
<div class="notice {{ notice.tone }}">{{ notice.message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('login_submit') }}">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" required autofocus />
|
||||
<button type="submit">Enter</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
DASHBOARD_HTML = """
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Secondary Live Viewer</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f2e9;
|
||||
--card: #fffdf8;
|
||||
--ink: #172126;
|
||||
--muted: #5e6b70;
|
||||
--line: #d8d0bf;
|
||||
--teal: #0f766e;
|
||||
--danger: #8b1d1d;
|
||||
--warn: #9a3412;
|
||||
--radius: 14px;
|
||||
--sans: "Avenir Next", "Trebuchet MS", "Lucida Grande", sans-serif;
|
||||
--mono: Menlo, Consolas, Monaco, "Liberation Mono", monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--sans);
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(860px 430px at -10% -10%, #d7ece8 0%, transparent 60%),
|
||||
radial-gradient(680px 350px at 105% 0%, #fae3cf 0%, transparent 55%),
|
||||
var(--bg);
|
||||
}
|
||||
.wrap { max-width: 1200px; margin: 0 auto; padding: 14px 14px 30px; }
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
h1 { margin: 4px 0 8px; font-size: clamp(1.5rem, 4vw, 2.1rem); }
|
||||
h2 { margin: 0 0 8px; font-size: 1.1rem; }
|
||||
p { margin: 0 0 8px; }
|
||||
.muted { color: var(--muted); }
|
||||
.mono { font-family: var(--mono); font-size: 0.82rem; word-break: break-all; }
|
||||
|
||||
.button-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 9px 12px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn { background: var(--teal); color: #fff; }
|
||||
.btn-ghost { background: #fff; color: var(--ink); border: 1px solid var(--line); }
|
||||
|
||||
.stats { display: grid; gap: 8px; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 8px; }
|
||||
.stat { border: 1px solid var(--line); border-radius: 10px; padding: 8px; background: #fff; }
|
||||
.stat .label { color: var(--muted); font-size: 0.78rem; }
|
||||
.stat .value { font-size: 1.2rem; font-weight: 700; }
|
||||
|
||||
.grid { display: grid; gap: 10px; grid-template-columns: 1fr 1fr; }
|
||||
|
||||
.notice {
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
padding: 9px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.notice.success { background: #dcf4ef; color: #0c4f49; border-color: #b7e5dc; }
|
||||
.notice.warn { background: #ffeede; color: #6f2d13; border-color: #f0d2bb; }
|
||||
.notice.danger { background: #ffe7e7; color: #7f1717; border-color: #efc3c3; }
|
||||
.notice.info { background: #edf4ff; color: #23466b; border-color: #cad9ee; }
|
||||
|
||||
.provider, .row {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
margin-top: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.blocked { background: #ffe7e7; border-color: #edc1c1; color: #7b1c1c; }
|
||||
.glow { box-shadow: 0 0 0 2px rgba(30, 154, 132, 0.2), 0 0 18px rgba(30, 154, 132, 0.3); }
|
||||
|
||||
@media (max-width: 940px) {
|
||||
.stats { grid-template-columns: 1fr; }
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<section class="card">
|
||||
<div class="button-row">
|
||||
<form method="post" action="{{ url_for('logout') }}"><button class="btn-ghost" type="submit">Logout</button></form>
|
||||
<button class="btn" type="button" onclick="window.location.reload()">Refresh Now</button>
|
||||
</div>
|
||||
<h1>Secondary Live Viewer</h1>
|
||||
<p class="muted">Realtime mirror from {{ master_url }} | last fetch: {{ generated_at or 'failed' }}</p>
|
||||
{% if notice %}
|
||||
<div class="notice {{ notice.tone }}">{{ notice.message }}</div>
|
||||
{% endif %}
|
||||
{% if fetch_error %}
|
||||
<div class="notice danger">Master fetch failed: {{ fetch_error }}</div>
|
||||
{% endif %}
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="label">Providers</div><div class="value">{{ provider_count }}</div></div>
|
||||
<div class="stat"><div class="label">Key IDs tracked</div><div class="value">{{ key_count }}</div></div>
|
||||
<div class="stat"><div class="label">Ciphertext mappings</div><div class="value">{{ mapping_count }}</div></div>
|
||||
<div class="stat"><div class="label">Blocked mappings</div><div class="value">{{ blocked_count }}</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="card">
|
||||
<h2>Providers</h2>
|
||||
{% if providers %}
|
||||
{% for provider in providers %}
|
||||
<div class="provider">
|
||||
<strong>ID {{ provider.provider_id }} - {{ provider.name }}</strong>
|
||||
{% if provider.description %}<div class="muted">{{ provider.description }}</div>{% endif %}
|
||||
<div class="mono">Prefix {{ provider.prefix }}</div>
|
||||
<div class="muted">Derived IDs: {{ provider.derived_count }} | Punctured IDs: {{ provider.punctured_count }} | Key rows: {{ provider.key_count }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="muted">No provider data.</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h2>Key Journal</h2>
|
||||
{% if key_journal %}
|
||||
{% for key in key_journal %}
|
||||
<div class="row{% if key.ever_punctured %} blocked{% endif %}">
|
||||
<strong>Provider {{ key.provider_id }} | Key ID {{ key.file_time_id }}</strong>
|
||||
<div class="mono">{{ key.path_provider }} | {{ key.path_resource }}</div>
|
||||
<div class="muted">Derived {{ key.derive_count }}x | Punctured {{ key.puncture_count }}x</div>
|
||||
{% if key.description %}<div class="muted">Purpose: {{ key.description }}</div>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="muted">No keys tracked.</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Assets and Ciphertexts</h2>
|
||||
{% if asset_files %}
|
||||
{% for file in asset_files %}
|
||||
<div class="provider">
|
||||
<strong>{{ file.plaintext_relpath }}</strong>
|
||||
<div class="muted">Mappings: {{ file.mapping_count }} | Blocked: {{ file.blocked_count }}</div>
|
||||
{% for row in file.mappings %}
|
||||
<div class="row{% if row.show_red %} blocked{% endif %}{% if row.show_glow %} glow{% endif %}">
|
||||
<div><strong>Provider {{ row.provider_id }} | Key ID {{ row.file_time_id }}</strong></div>
|
||||
<div class="mono">cipher: {{ row.ciphertext_relpath }}</div>
|
||||
<div class="mono">tag: {{ row.path_provider }} | {{ row.path_resource }}</div>
|
||||
<div class="muted">Status: {{ 'decryptable' if row.is_accessible else 'blocked by puncture' }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="muted">No asset mappings found.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
setTimeout(() => window.location.reload(), 8000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
135
puncture/view_sync.py
Normal file
|
|
@ -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")
|
||||
3762
puncture/web_app.py
Normal file
14
pyproject.toml
Normal file
|
|
@ -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"
|
||||
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
flask>=3.0.0
|
||||
pytest>=8.0.0
|
||||
450
tests/test_asset_helpers_and_ui.py
Normal file
|
|
@ -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"]
|
||||
157
tests/test_master_asset_mapping.py
Normal file
|
|
@ -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()
|
||||
90
tests/test_puncture_manager.py
Normal file
|
|
@ -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
|
||||
92
tests/test_view_app_policy.py
Normal file
|
|
@ -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
|
||||
88
tests/test_view_sync.py
Normal file
|
|
@ -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()
|
||||