Initial commit: puncturable key manager apps and tooling

This commit is contained in:
saymrwulf 2026-02-13 18:44:48 +01:00
commit bea52820ce
53 changed files with 10334 additions and 0 deletions

30
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
}

View 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")
}

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

View 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)
}
}

View 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
}

View 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")
}
}

View 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)
}

View 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>

View file

@ -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 */;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View 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
}
}

View 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
}
}
}
}

View file

@ -0,0 +1,10 @@
import SwiftUI
@main
struct EmergencyPunctureApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View 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>

View 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?
}

View 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: ""

View 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
View 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"

Binary file not shown.

View 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>

View 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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 KiB

View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

14
pyproject.toml Normal file
View 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
View file

@ -0,0 +1,2 @@
flask>=3.0.0
pytest>=8.0.0

View 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"]

View 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()

View 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

View 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
View 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()