mirror of
https://github.com/saymrwulf/puncture.git
synced 2026-06-14 01:13:43 +00:00
1631 lines
48 KiB
Go
1631 lines
48 KiB
Go
package app
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
ggm "puncture-go/internal/crypto"
|
|
)
|
|
|
|
const (
|
|
encMagic = "PKE1"
|
|
encNonceSize = 16
|
|
encTagSize = 32
|
|
treeDepth = 7
|
|
)
|
|
|
|
type Provider struct {
|
|
ProviderID int `json:"provider_id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
type DeletedProvider struct {
|
|
ProviderID int `json:"provider_id"`
|
|
Name string `json:"name"`
|
|
Prefix string `json:"prefix"`
|
|
DeletedAt string `json:"deleted_at"`
|
|
Applied bool `json:"applied"`
|
|
}
|
|
|
|
type KeyJournalEntry struct {
|
|
ProviderID int `json:"provider_id"`
|
|
FileTimeID int `json:"file_time_id"`
|
|
Path string `json:"path"`
|
|
PathProvider string `json:"path_provider"`
|
|
PathResource string `json:"path_resource"`
|
|
Description string `json:"description"`
|
|
EverDerived bool `json:"ever_derived"`
|
|
EverPunctured bool `json:"ever_punctured"`
|
|
DeriveCount int `json:"derive_count"`
|
|
PunctureCount int `json:"puncture_count"`
|
|
LastDerivedAt string `json:"last_derived_at,omitempty"`
|
|
LastPuncturedAt string `json:"last_punctured_at,omitempty"`
|
|
}
|
|
|
|
type AssetRecord struct {
|
|
RecordID int `json:"record_id"`
|
|
PlaintextRelpath string `json:"plaintext_relpath"`
|
|
CiphertextRelpath string `json:"ciphertext_relpath"`
|
|
ProviderID int `json:"provider_id"`
|
|
FileTimeID int `json:"file_time_id"`
|
|
Path string `json:"path"`
|
|
Purpose string `json:"purpose"`
|
|
CreatedAt string `json:"created_at"`
|
|
PlaintextSize int `json:"plaintext_size"`
|
|
CiphertextSize int `json:"ciphertext_size"`
|
|
DecryptCount int `json:"decrypt_count"`
|
|
LastDecryptedAt string `json:"last_decrypted_at,omitempty"`
|
|
LastDecryptedRel string `json:"last_decrypted_relpath,omitempty"`
|
|
}
|
|
|
|
type LastAction struct {
|
|
Tone string `json:"tone"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
ProviderID *int `json:"provider_id"`
|
|
FileTimeID *int `json:"file_time_id"`
|
|
Path string `json:"path,omitempty"`
|
|
PathProvider string `json:"path_provider,omitempty"`
|
|
PathResource string `json:"path_resource,omitempty"`
|
|
KeyHex string `json:"key_hex,omitempty"`
|
|
KeyDesc string `json:"key_description,omitempty"`
|
|
}
|
|
|
|
type HistoryItem struct {
|
|
Time string `json:"time"`
|
|
Action string `json:"action"`
|
|
Status string `json:"status"`
|
|
Summary string `json:"summary"`
|
|
ProviderID *int `json:"provider_id"`
|
|
FileTimeID *int `json:"file_time_id"`
|
|
Path string `json:"path,omitempty"`
|
|
}
|
|
|
|
type LastPunctureDiff struct {
|
|
Time string `json:"time"`
|
|
Target string `json:"target"`
|
|
TargetKind string `json:"target_kind"`
|
|
Removed []string `json:"removed"`
|
|
Added []string `json:"added"`
|
|
}
|
|
|
|
type LastInputs struct {
|
|
ProviderID int `json:"provider_id"`
|
|
FileTimeID int `json:"file_time_id"`
|
|
Purpose string `json:"purpose"`
|
|
}
|
|
|
|
type Notice struct {
|
|
Tone string `json:"tone"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type TreeViz struct {
|
|
Depth int `json:"depth"`
|
|
CurrentFrontierCount int `json:"current_frontier_count"`
|
|
BlockedCount int `json:"blocked_count"`
|
|
RemovedCount int `json:"removed_count"`
|
|
LastPuncture *LastPunctureDiff `json:"last_puncture,omitempty"`
|
|
SVG string `json:"svg"`
|
|
}
|
|
|
|
type AppState struct {
|
|
mu sync.RWMutex
|
|
|
|
Manager *ggm.Manager
|
|
|
|
Providers map[int]Provider
|
|
DeletedProviders []DeletedProvider
|
|
KeyJournal map[string]*KeyJournalEntry
|
|
AssetRecords []*AssetRecord
|
|
AssetRoot string
|
|
StateFile string
|
|
|
|
LastInputs LastInputs
|
|
LastAction LastAction
|
|
History []HistoryItem
|
|
LastPunctureDiff *LastPunctureDiff
|
|
|
|
ProvidersNotice *Notice
|
|
AssetNotice *Notice
|
|
}
|
|
|
|
type persistedState struct {
|
|
Version int `json:"version"`
|
|
Manager ggm.ExportState `json:"manager"`
|
|
Providers map[int]Provider `json:"providers"`
|
|
DeletedProviders []DeletedProvider `json:"deleted_providers"`
|
|
KeyJournal map[string]*KeyJournalEntry `json:"key_journal"`
|
|
AssetRecords []*AssetRecord `json:"asset_records"`
|
|
History []HistoryItem `json:"history"`
|
|
LastAction LastAction `json:"last_action"`
|
|
LastInputs LastInputs `json:"last_inputs"`
|
|
LastPunctureDiff *LastPunctureDiff `json:"last_puncture_diff"`
|
|
}
|
|
|
|
func nowLabel() string {
|
|
return time.Now().UTC().Format("15:04:05 UTC")
|
|
}
|
|
|
|
func defaultProviders() map[int]Provider {
|
|
now := nowLabel()
|
|
return map[int]Provider{
|
|
42: {ProviderID: 42, Name: "Provider 42 (Demo)", Description: "Default provider used in Scenario A walkthrough.", CreatedAt: now},
|
|
17: {ProviderID: 17, Name: "Northwind Cloud", Description: "Example provider entry.", CreatedAt: now},
|
|
88: {ProviderID: 88, Name: "Blue Harbor Storage", Description: "Example provider entry.", CreatedAt: now},
|
|
}
|
|
}
|
|
|
|
func defaultStateFilePath(assetRoot string) string {
|
|
if v := strings.TrimSpace(os.Getenv("PUNCTURE_STATE_FILE")); v != "" {
|
|
if abs, err := filepath.Abs(v); err == nil {
|
|
return abs
|
|
}
|
|
return v
|
|
}
|
|
if filepath.Base(assetRoot) == "assets" {
|
|
return filepath.Join(filepath.Dir(assetRoot), "state.json")
|
|
}
|
|
return filepath.Join(assetRoot, ".puncture-state.json")
|
|
}
|
|
|
|
func sanitizeAssetRecords(records []*AssetRecord) []*AssetRecord {
|
|
if records == nil {
|
|
return []*AssetRecord{}
|
|
}
|
|
out := make([]*AssetRecord, 0, len(records))
|
|
nextID := 1
|
|
used := map[int]struct{}{}
|
|
for _, rec := range records {
|
|
if rec == nil {
|
|
continue
|
|
}
|
|
if rec.RecordID <= 0 {
|
|
rec.RecordID = nextID
|
|
}
|
|
for {
|
|
if _, exists := used[rec.RecordID]; !exists {
|
|
break
|
|
}
|
|
rec.RecordID++
|
|
}
|
|
used[rec.RecordID] = struct{}{}
|
|
if rec.RecordID >= nextID {
|
|
nextID = rec.RecordID + 1
|
|
}
|
|
out = append(out, rec)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].RecordID < out[j].RecordID })
|
|
return out
|
|
}
|
|
|
|
func NewAppState(assetRoot string) (*AppState, error) {
|
|
if assetRoot == "" {
|
|
assetRoot = filepath.Join(".", "assets")
|
|
}
|
|
absRoot, err := filepath.Abs(assetRoot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.MkdirAll(absRoot, 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
stateFile := defaultStateFilePath(absRoot)
|
|
seed, err := ggm.GenerateMasterSeed()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mgr, err := ggm.NewManager(seed)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s := &AppState{
|
|
Manager: mgr,
|
|
Providers: defaultProviders(),
|
|
DeletedProviders: []DeletedProvider{},
|
|
KeyJournal: map[string]*KeyJournalEntry{},
|
|
AssetRecords: []*AssetRecord{},
|
|
AssetRoot: absRoot,
|
|
StateFile: stateFile,
|
|
LastInputs: LastInputs{ProviderID: 42, FileTimeID: 123456, Purpose: "Demo key for provider onboarding"},
|
|
LastAction: LastAction{Tone: "info", Title: "Welcome", Body: "Derive a key and puncture it to observe forward secrecy."},
|
|
History: []HistoryItem{},
|
|
LastPunctureDiff: nil,
|
|
ProvidersNotice: nil,
|
|
AssetNotice: nil,
|
|
}
|
|
if err := s.loadPersistedStateLocked(); err != nil {
|
|
if _, statErr := os.Stat(s.StateFile); statErr == nil {
|
|
backup := s.StateFile + ".corrupt." + time.Now().UTC().Format("20060102T150405")
|
|
_ = os.Rename(s.StateFile, backup)
|
|
s.LastAction = LastAction{
|
|
Tone: "warn",
|
|
Title: "State recovered",
|
|
Body: fmt.Sprintf("Persisted state was invalid and moved to %s; using fresh state.", filepath.Base(backup)),
|
|
}
|
|
}
|
|
}
|
|
s.persistLockedNoFail()
|
|
return s, nil
|
|
}
|
|
|
|
func ptrInt(v int) *int { return &v }
|
|
|
|
func (s *AppState) setLastAction(a LastAction) {
|
|
s.LastAction = a
|
|
}
|
|
|
|
func (s *AppState) recordHistory(action, status, summary string, providerID, fileTimeID *int, path string) {
|
|
s.History = append([]HistoryItem{{
|
|
Time: nowLabel(),
|
|
Action: action,
|
|
Status: status,
|
|
Summary: summary,
|
|
ProviderID: providerID,
|
|
FileTimeID: fileTimeID,
|
|
Path: path,
|
|
}}, s.History...)
|
|
if len(s.History) > 40 {
|
|
s.History = s.History[:40]
|
|
}
|
|
}
|
|
|
|
func (s *AppState) persistedPayloadLocked() persistedState {
|
|
return persistedState{
|
|
Version: 1,
|
|
Manager: s.Manager.ExportState(),
|
|
Providers: s.Providers,
|
|
DeletedProviders: s.DeletedProviders,
|
|
KeyJournal: s.KeyJournal,
|
|
AssetRecords: s.AssetRecords,
|
|
History: s.History,
|
|
LastAction: s.LastAction,
|
|
LastInputs: s.LastInputs,
|
|
LastPunctureDiff: s.LastPunctureDiff,
|
|
}
|
|
}
|
|
|
|
func (s *AppState) persistLocked() error {
|
|
if strings.TrimSpace(s.StateFile) == "" {
|
|
return nil
|
|
}
|
|
payload := s.persistedPayloadLocked()
|
|
blob, err := json.MarshalIndent(payload, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(s.StateFile), 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp := s.StateFile + ".tmp"
|
|
if err := os.WriteFile(tmp, blob, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, s.StateFile)
|
|
}
|
|
|
|
func (s *AppState) persistLockedNoFail() {
|
|
if err := s.persistLocked(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "puncture-go: failed to persist state: %v\n", err)
|
|
}
|
|
}
|
|
|
|
func (s *AppState) applyPersistedStateLocked(p persistedState) error {
|
|
mgr, err := ggm.FromState(p.Manager)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.Manager = mgr
|
|
|
|
if p.Providers != nil {
|
|
s.Providers = p.Providers
|
|
} else {
|
|
s.Providers = defaultProviders()
|
|
}
|
|
if p.DeletedProviders != nil {
|
|
s.DeletedProviders = p.DeletedProviders
|
|
} else {
|
|
s.DeletedProviders = []DeletedProvider{}
|
|
}
|
|
if p.KeyJournal != nil {
|
|
s.KeyJournal = p.KeyJournal
|
|
} else {
|
|
s.KeyJournal = map[string]*KeyJournalEntry{}
|
|
}
|
|
s.AssetRecords = sanitizeAssetRecords(p.AssetRecords)
|
|
if p.History != nil {
|
|
s.History = p.History
|
|
} else {
|
|
s.History = []HistoryItem{}
|
|
}
|
|
if len(s.History) > 40 {
|
|
s.History = s.History[:40]
|
|
}
|
|
s.LastAction = p.LastAction
|
|
if strings.TrimSpace(s.LastAction.Title) == "" {
|
|
s.LastAction = LastAction{Tone: "info", Title: "Welcome", Body: "Derive a key and puncture it to observe forward secrecy."}
|
|
}
|
|
s.LastInputs = p.LastInputs
|
|
if s.LastInputs.ProviderID == 0 && s.LastInputs.FileTimeID == 0 && strings.TrimSpace(s.LastInputs.Purpose) == "" {
|
|
s.LastInputs = LastInputs{ProviderID: 42, FileTimeID: 123456, Purpose: "Demo key for provider onboarding"}
|
|
}
|
|
s.LastPunctureDiff = p.LastPunctureDiff
|
|
s.ProvidersNotice = nil
|
|
s.AssetNotice = nil
|
|
return nil
|
|
}
|
|
|
|
func (s *AppState) loadPersistedStateLocked() error {
|
|
if strings.TrimSpace(s.StateFile) == "" {
|
|
return nil
|
|
}
|
|
blob, err := os.ReadFile(s.StateFile)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
var payload persistedState
|
|
if err := json.Unmarshal(blob, &payload); err != nil {
|
|
return err
|
|
}
|
|
if payload.Manager.ActiveNodes == nil && payload.Manager.PunctureLog == nil {
|
|
return errors.New("persisted state missing manager payload")
|
|
}
|
|
return s.applyPersistedStateLocked(payload)
|
|
}
|
|
|
|
func splitPath(path string) (string, string) {
|
|
if len(path) < 7 {
|
|
return path, ""
|
|
}
|
|
return path[:7], path[7:]
|
|
}
|
|
|
|
func (s *AppState) ensureKeyEntry(providerID, fileTimeID int, path string) *KeyJournalEntry {
|
|
entry, ok := s.KeyJournal[path]
|
|
if ok {
|
|
return entry
|
|
}
|
|
pp, pr := splitPath(path)
|
|
entry = &KeyJournalEntry{
|
|
ProviderID: providerID,
|
|
FileTimeID: fileTimeID,
|
|
Path: path,
|
|
PathProvider: pp,
|
|
PathResource: pr,
|
|
}
|
|
s.KeyJournal[path] = entry
|
|
return entry
|
|
}
|
|
|
|
func (s *AppState) touchKeyDerive(providerID, fileTimeID int, path, desc string) *KeyJournalEntry {
|
|
entry := s.ensureKeyEntry(providerID, fileTimeID, path)
|
|
if strings.TrimSpace(desc) != "" {
|
|
entry.Description = strings.TrimSpace(desc)
|
|
}
|
|
entry.EverDerived = true
|
|
entry.DeriveCount++
|
|
entry.LastDerivedAt = nowLabel()
|
|
return entry
|
|
}
|
|
|
|
func (s *AppState) touchKeyPuncture(providerID, fileTimeID int, path string, applied bool) *KeyJournalEntry {
|
|
entry := s.ensureKeyEntry(providerID, fileTimeID, path)
|
|
entry.EverPunctured = true
|
|
if applied {
|
|
entry.PunctureCount++
|
|
}
|
|
entry.LastPuncturedAt = nowLabel()
|
|
return entry
|
|
}
|
|
|
|
func sortedPrefixes(prefixes []string) []string {
|
|
out := append([]string(nil), prefixes...)
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if len(out[i]) == len(out[j]) {
|
|
return out[i] < out[j]
|
|
}
|
|
return len(out[i]) < len(out[j])
|
|
})
|
|
return out
|
|
}
|
|
|
|
func (s *AppState) setLastPunctureDiff(beforeFrontier, afterFrontier []string, target, kind string) {
|
|
beforeSet := map[string]struct{}{}
|
|
afterSet := map[string]struct{}{}
|
|
for _, p := range beforeFrontier {
|
|
beforeSet[p] = struct{}{}
|
|
}
|
|
for _, p := range afterFrontier {
|
|
afterSet[p] = struct{}{}
|
|
}
|
|
removed := make([]string, 0)
|
|
added := make([]string, 0)
|
|
for _, p := range beforeFrontier {
|
|
if _, ok := afterSet[p]; !ok {
|
|
removed = append(removed, p)
|
|
}
|
|
}
|
|
for _, p := range afterFrontier {
|
|
if _, ok := beforeSet[p]; !ok {
|
|
added = append(added, p)
|
|
}
|
|
}
|
|
s.LastPunctureDiff = &LastPunctureDiff{
|
|
Time: nowLabel(),
|
|
Target: target,
|
|
TargetKind: kind,
|
|
Removed: sortedPrefixes(removed),
|
|
Added: sortedPrefixes(added),
|
|
}
|
|
}
|
|
|
|
func normalizeRelPath(rel string) (string, error) {
|
|
rel = strings.TrimSpace(rel)
|
|
if rel == "" {
|
|
return "", errors.New("relative file path is required")
|
|
}
|
|
if filepath.IsAbs(rel) {
|
|
return "", errors.New("absolute paths are not allowed")
|
|
}
|
|
n := filepath.Clean(rel)
|
|
n = filepath.ToSlash(n)
|
|
if n == "." || strings.HasPrefix(n, "../") || n == ".." {
|
|
return "", errors.New("path traversal is not allowed")
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func assetAbsPath(root, rel string) (string, error) {
|
|
n, err := normalizeRelPath(rel)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
abs := filepath.Clean(filepath.Join(root, filepath.FromSlash(n)))
|
|
if abs != root && !strings.HasPrefix(abs, root+string(os.PathSeparator)) {
|
|
return "", errors.New("file path escapes asset root")
|
|
}
|
|
return abs, nil
|
|
}
|
|
|
|
func nextRelPath(root, desired string) (string, error) {
|
|
n, err := normalizeRelPath(desired)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
dir := filepath.Dir(n)
|
|
if dir == "." {
|
|
dir = ""
|
|
}
|
|
base := filepath.Base(n)
|
|
ext := filepath.Ext(base)
|
|
stem := strings.TrimSuffix(base, ext)
|
|
for i := 1; ; i++ {
|
|
cand := base
|
|
if i > 1 {
|
|
cand = fmt.Sprintf("%s.v%d%s", stem, i, ext)
|
|
}
|
|
rel := cand
|
|
if dir != "" {
|
|
rel = filepath.ToSlash(filepath.Join(dir, cand))
|
|
}
|
|
abs, err := assetAbsPath(root, rel)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, statErr := os.Stat(abs); errors.Is(statErr, os.ErrNotExist) {
|
|
return rel, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func nextCiphertextRelPath(root, plaintextRel string, providerID, fileTimeID int) (string, error) {
|
|
n, err := normalizeRelPath(plaintextRel)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
dir := filepath.Dir(n)
|
|
if dir == "." {
|
|
dir = ""
|
|
}
|
|
filename := filepath.Base(n)
|
|
stem := fmt.Sprintf("%s.enc.p%d.k%d", filename, providerID, fileTimeID)
|
|
for i := 1; ; i++ {
|
|
suffix := ".pke"
|
|
if i > 1 {
|
|
suffix = fmt.Sprintf(".v%d.pke", i)
|
|
}
|
|
cand := stem + suffix
|
|
rel := cand
|
|
if dir != "" {
|
|
rel = filepath.ToSlash(filepath.Join(dir, cand))
|
|
}
|
|
abs, err := assetAbsPath(root, rel)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, statErr := os.Stat(abs); errors.Is(statErr, os.ErrNotExist) {
|
|
return rel, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func nextDecryptedRelPath(root, plaintextRel string, providerID, fileTimeID int) (string, error) {
|
|
target := fmt.Sprintf("%s.dec.p%d.k%d", plaintextRel, providerID, fileTimeID)
|
|
return nextRelPath(root, target)
|
|
}
|
|
|
|
func streamXOR(key, nonce, data []byte) []byte {
|
|
out := make([]byte, len(data))
|
|
off := 0
|
|
counter := uint64(0)
|
|
for off < len(data) {
|
|
mac := hmac.New(sha256.New, key)
|
|
mac.Write([]byte("ENC"))
|
|
mac.Write(nonce)
|
|
ctr := []byte{
|
|
byte(counter >> 56), byte(counter >> 48), byte(counter >> 40), byte(counter >> 32),
|
|
byte(counter >> 24), byte(counter >> 16), byte(counter >> 8), byte(counter),
|
|
}
|
|
mac.Write(ctr)
|
|
block := mac.Sum(nil)
|
|
chunk := block
|
|
if len(data)-off < len(block) {
|
|
chunk = block[:len(data)-off]
|
|
}
|
|
for i := range chunk {
|
|
out[off+i] = data[off+i] ^ chunk[i]
|
|
}
|
|
off += len(chunk)
|
|
counter++
|
|
}
|
|
return out
|
|
}
|
|
|
|
func encryptBlob(key, plaintext []byte) ([]byte, error) {
|
|
nonce := make([]byte, encNonceSize)
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return nil, err
|
|
}
|
|
ciphertext := streamXOR(key, nonce, plaintext)
|
|
mac := hmac.New(sha256.New, key)
|
|
mac.Write([]byte("TAG"))
|
|
mac.Write(nonce)
|
|
mac.Write(ciphertext)
|
|
tag := mac.Sum(nil)
|
|
|
|
blob := make([]byte, 0, len(encMagic)+len(nonce)+len(tag)+len(ciphertext))
|
|
blob = append(blob, []byte(encMagic)...)
|
|
blob = append(blob, nonce...)
|
|
blob = append(blob, tag...)
|
|
blob = append(blob, ciphertext...)
|
|
return blob, nil
|
|
}
|
|
|
|
func decryptBlob(key, blob []byte) ([]byte, error) {
|
|
min := len(encMagic) + encNonceSize + encTagSize
|
|
if len(blob) < min {
|
|
return nil, errors.New("ciphertext too short")
|
|
}
|
|
if string(blob[:len(encMagic)]) != encMagic {
|
|
return nil, errors.New("ciphertext header mismatch")
|
|
}
|
|
nonceStart := len(encMagic)
|
|
nonceEnd := nonceStart + encNonceSize
|
|
tagEnd := nonceEnd + encTagSize
|
|
nonce := blob[nonceStart:nonceEnd]
|
|
tag := blob[nonceEnd:tagEnd]
|
|
ciphertext := blob[tagEnd:]
|
|
|
|
mac := hmac.New(sha256.New, key)
|
|
mac.Write([]byte("TAG"))
|
|
mac.Write(nonce)
|
|
mac.Write(ciphertext)
|
|
expected := mac.Sum(nil)
|
|
if !hmac.Equal(tag, expected) {
|
|
return nil, errors.New("ciphertext authentication failed")
|
|
}
|
|
return streamXOR(key, nonce, ciphertext), nil
|
|
}
|
|
|
|
func (s *AppState) Derive(providerID, fileTimeID int, purpose string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
defer s.persistLockedNoFail()
|
|
path, err := ggm.TagToBinaryPath(providerID, fileTimeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.LastInputs = LastInputs{ProviderID: providerID, FileTimeID: fileTimeID, Purpose: purpose}
|
|
key, ok, err := s.Manager.GetKeyForTag(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
s.setLastAction(LastAction{
|
|
Tone: "warn",
|
|
Title: "Derive blocked",
|
|
Body: "Key is inaccessible due to prior puncture.",
|
|
ProviderID: ptrInt(providerID),
|
|
FileTimeID: ptrInt(fileTimeID),
|
|
Path: path,
|
|
PathProvider: path[:7],
|
|
PathResource: path[7:],
|
|
})
|
|
s.recordHistory("derive", "void", fmt.Sprintf("Derive blocked for provider=%d,file=%d", providerID, fileTimeID), ptrInt(providerID), ptrInt(fileTimeID), path)
|
|
return nil
|
|
}
|
|
entry := s.touchKeyDerive(providerID, fileTimeID, path, purpose)
|
|
s.setLastAction(LastAction{
|
|
Tone: "success",
|
|
Title: "Derive succeeded",
|
|
Body: "Key derivation succeeded.",
|
|
ProviderID: ptrInt(providerID),
|
|
FileTimeID: ptrInt(fileTimeID),
|
|
Path: path,
|
|
PathProvider: path[:7],
|
|
PathResource: path[7:],
|
|
KeyHex: fmt.Sprintf("%x", key),
|
|
KeyDesc: entry.Description,
|
|
})
|
|
s.recordHistory("derive", "derived", fmt.Sprintf("Derived key for provider=%d,file=%d", providerID, fileTimeID), ptrInt(providerID), ptrInt(fileTimeID), path)
|
|
return nil
|
|
}
|
|
|
|
func (s *AppState) Puncture(providerID, fileTimeID int) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
defer s.persistLockedNoFail()
|
|
path, err := ggm.TagToBinaryPath(providerID, fileTimeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
before := s.Manager.ActivePrefixes()
|
|
applied, err := s.Manager.Puncture(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
after := s.Manager.ActivePrefixes()
|
|
s.setLastPunctureDiff(before, after, path, "tag")
|
|
entry := s.touchKeyPuncture(providerID, fileTimeID, path, applied)
|
|
s.LastInputs = LastInputs{ProviderID: providerID, FileTimeID: fileTimeID, Purpose: s.LastInputs.Purpose}
|
|
if applied {
|
|
s.setLastAction(LastAction{Tone: "success", Title: "Puncture succeeded", Body: "Target tag is now permanently inaccessible.", ProviderID: ptrInt(providerID), FileTimeID: ptrInt(fileTimeID), Path: path, PathProvider: path[:7], PathResource: path[7:], KeyDesc: entry.Description})
|
|
s.recordHistory("puncture", "applied", fmt.Sprintf("Punctured provider=%d,file=%d", providerID, fileTimeID), ptrInt(providerID), ptrInt(fileTimeID), path)
|
|
} else {
|
|
s.setLastAction(LastAction{Tone: "warn", Title: "Puncture no-op", Body: "Target was already inaccessible.", ProviderID: ptrInt(providerID), FileTimeID: ptrInt(fileTimeID), Path: path, PathProvider: path[:7], PathResource: path[7:], KeyDesc: entry.Description})
|
|
s.recordHistory("puncture", "noop", fmt.Sprintf("No-op puncture provider=%d,file=%d", providerID, fileTimeID), ptrInt(providerID), ptrInt(fileTimeID), path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AppState) markProviderKeysPunctured(providerID int) int {
|
|
n := 0
|
|
stamp := nowLabel()
|
|
for _, entry := range s.KeyJournal {
|
|
if entry.ProviderID != providerID {
|
|
continue
|
|
}
|
|
if !entry.EverPunctured {
|
|
entry.PunctureCount++
|
|
}
|
|
entry.EverPunctured = true
|
|
entry.LastPuncturedAt = stamp
|
|
n++
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (s *AppState) PunctureProvider(providerID int) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
defer s.persistLockedNoFail()
|
|
prefix, err := ggm.ProviderIDToPrefix(providerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
before := s.Manager.ActivePrefixes()
|
|
applied, err := s.Manager.PunctureProvider(providerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
after := s.Manager.ActivePrefixes()
|
|
s.setLastPunctureDiff(before, after, prefix, "provider-prefix")
|
|
touched := s.markProviderKeysPunctured(providerID)
|
|
s.setLastAction(LastAction{Tone: "warn", Title: "Provider prefix punctured", Body: fmt.Sprintf("Provider %d punctured; keys in subtree blocked (known=%d).", providerID, touched), ProviderID: ptrInt(providerID)})
|
|
status := "already-inaccessible"
|
|
if applied {
|
|
status = "punctured"
|
|
}
|
|
s.recordHistory("provider-puncture", status, fmt.Sprintf("Provider %d prefix punctured", providerID), ptrInt(providerID), nil, prefix)
|
|
return nil
|
|
}
|
|
|
|
func (s *AppState) AddProvider(providerID int, name, description string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
defer s.persistLockedNoFail()
|
|
if strings.TrimSpace(name) == "" {
|
|
return errors.New("name is required")
|
|
}
|
|
if _, err := ggm.ProviderIDToPrefix(providerID); err != nil {
|
|
return err
|
|
}
|
|
if _, exists := s.Providers[providerID]; exists {
|
|
return fmt.Errorf("provider %d already exists", providerID)
|
|
}
|
|
s.Providers[providerID] = Provider{ProviderID: providerID, Name: strings.TrimSpace(name), Description: strings.TrimSpace(description), CreatedAt: nowLabel()}
|
|
s.ProvidersNotice = &Notice{Tone: "success", Message: fmt.Sprintf("Added provider %d", providerID)}
|
|
s.recordHistory("provider-add", "added", fmt.Sprintf("Added provider %d", providerID), ptrInt(providerID), nil, "")
|
|
return nil
|
|
}
|
|
|
|
func (s *AppState) EditProvider(providerID int, name, description string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
defer s.persistLockedNoFail()
|
|
p, ok := s.Providers[providerID]
|
|
if !ok {
|
|
return fmt.Errorf("provider %d does not exist", providerID)
|
|
}
|
|
if strings.TrimSpace(name) == "" {
|
|
return errors.New("name is required")
|
|
}
|
|
p.Name = strings.TrimSpace(name)
|
|
p.Description = strings.TrimSpace(description)
|
|
s.Providers[providerID] = p
|
|
s.ProvidersNotice = &Notice{Tone: "success", Message: fmt.Sprintf("Updated provider %d", providerID)}
|
|
s.recordHistory("provider-edit", "updated", fmt.Sprintf("Updated provider %d", providerID), ptrInt(providerID), nil, "")
|
|
return nil
|
|
}
|
|
|
|
func (s *AppState) DeleteProvider(providerID int) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
defer s.persistLockedNoFail()
|
|
p, ok := s.Providers[providerID]
|
|
if !ok {
|
|
return fmt.Errorf("provider %d does not exist", providerID)
|
|
}
|
|
prefix, _ := ggm.ProviderIDToPrefix(providerID)
|
|
before := s.Manager.ActivePrefixes()
|
|
applied, err := s.Manager.PuncturePrefix(prefix)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
after := s.Manager.ActivePrefixes()
|
|
s.setLastPunctureDiff(before, after, prefix, "provider-prefix")
|
|
known := s.markProviderKeysPunctured(providerID)
|
|
delete(s.Providers, providerID)
|
|
s.DeletedProviders = append([]DeletedProvider{{ProviderID: providerID, Name: p.Name, Prefix: prefix, DeletedAt: nowLabel(), Applied: applied}}, s.DeletedProviders...)
|
|
if len(s.DeletedProviders) > 32 {
|
|
s.DeletedProviders = s.DeletedProviders[:32]
|
|
}
|
|
s.ProvidersNotice = &Notice{Tone: "warn", Message: fmt.Sprintf("Deleted provider %d and punctured subtree (known keys marked=%d).", providerID, known)}
|
|
s.recordHistory("provider-delete", "punctured", fmt.Sprintf("Deleted provider %d", providerID), ptrInt(providerID), nil, prefix)
|
|
return nil
|
|
}
|
|
|
|
func (s *AppState) SaveUploads(files []*multipart.FileHeader, targetSubdir string) ([]string, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
defer s.persistLockedNoFail()
|
|
prefix := ""
|
|
if strings.TrimSpace(targetSubdir) != "" {
|
|
n, err := normalizeRelPath(targetSubdir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
prefix = strings.Trim(n, "/")
|
|
}
|
|
saved := []string{}
|
|
for _, header := range files {
|
|
if header == nil || header.Filename == "" {
|
|
continue
|
|
}
|
|
desired := filepath.ToSlash(header.Filename)
|
|
if prefix != "" {
|
|
desired = filepath.ToSlash(filepath.Join(prefix, filepath.Base(desired)))
|
|
} else {
|
|
desired = filepath.Base(desired)
|
|
}
|
|
finalRel, err := nextRelPath(s.AssetRoot, desired)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
abs, err := assetAbsPath(s.AssetRoot, finalRel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
src, err := header.Open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dst, err := os.Create(abs)
|
|
if err != nil {
|
|
src.Close()
|
|
return nil, err
|
|
}
|
|
_, cpErr := io.Copy(dst, src)
|
|
_ = dst.Close()
|
|
_ = src.Close()
|
|
if cpErr != nil {
|
|
return nil, cpErr
|
|
}
|
|
saved = append(saved, finalRel)
|
|
}
|
|
if len(saved) == 0 {
|
|
return nil, errors.New("choose at least one file to upload")
|
|
}
|
|
s.AssetNotice = &Notice{Tone: "success", Message: fmt.Sprintf("Uploaded %d file(s).", len(saved))}
|
|
s.recordHistory("asset-upload", "uploaded", fmt.Sprintf("Uploaded %d file(s)", len(saved)), nil, nil, "")
|
|
return saved, nil
|
|
}
|
|
|
|
func (s *AppState) Encrypt(plaintextRelpaths []string, providerID, fileTimeID int, purpose string) ([]AssetRecord, []string, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
defer s.persistLockedNoFail()
|
|
if len(plaintextRelpaths) == 0 {
|
|
return nil, nil, errors.New("select at least one cleartext file")
|
|
}
|
|
path, err := ggm.TagToBinaryPath(providerID, fileTimeID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
key, ok, err := s.Manager.GetKeyForTag(path)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if !ok {
|
|
return nil, nil, errors.New("selected key is punctured/inaccessible")
|
|
}
|
|
s.LastInputs = LastInputs{ProviderID: providerID, FileTimeID: fileTimeID, Purpose: strings.TrimSpace(purpose)}
|
|
s.touchKeyDerive(providerID, fileTimeID, path, purpose)
|
|
|
|
saved := []AssetRecord{}
|
|
errs := []string{}
|
|
for _, raw := range plaintextRelpaths {
|
|
rel, err := normalizeRelPath(raw)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", raw, err))
|
|
continue
|
|
}
|
|
plainAbs, err := assetAbsPath(s.AssetRoot, rel)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", rel, err))
|
|
continue
|
|
}
|
|
blob, readErr := os.ReadFile(plainAbs)
|
|
if readErr != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", rel, readErr))
|
|
continue
|
|
}
|
|
enc, encErr := encryptBlob(key, blob)
|
|
if encErr != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", rel, encErr))
|
|
continue
|
|
}
|
|
cipherRel, err := nextCiphertextRelPath(s.AssetRoot, rel, providerID, fileTimeID)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", rel, err))
|
|
continue
|
|
}
|
|
cipherAbs, err := assetAbsPath(s.AssetRoot, cipherRel)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", rel, err))
|
|
continue
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(cipherAbs), 0o755); err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", rel, err))
|
|
continue
|
|
}
|
|
if err := os.WriteFile(cipherAbs, enc, 0o644); err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", rel, err))
|
|
continue
|
|
}
|
|
rec := &AssetRecord{
|
|
RecordID: len(s.AssetRecords) + 1,
|
|
PlaintextRelpath: rel,
|
|
CiphertextRelpath: cipherRel,
|
|
ProviderID: providerID,
|
|
FileTimeID: fileTimeID,
|
|
Path: path,
|
|
Purpose: strings.TrimSpace(purpose),
|
|
CreatedAt: nowLabel(),
|
|
PlaintextSize: len(blob),
|
|
CiphertextSize: len(enc),
|
|
}
|
|
s.AssetRecords = append(s.AssetRecords, rec)
|
|
saved = append(saved, *rec)
|
|
}
|
|
if len(saved) == 0 {
|
|
return nil, errs, errors.New("no file could be encrypted")
|
|
}
|
|
s.AssetNotice = &Notice{Tone: "success", Message: fmt.Sprintf("Encrypted %d file(s).", len(saved))}
|
|
s.recordHistory("asset-encrypt", "encrypted", fmt.Sprintf("Encrypted %d file(s) with provider=%d,key=%d", len(saved), providerID, fileTimeID), ptrInt(providerID), ptrInt(fileTimeID), path)
|
|
return saved, errs, nil
|
|
}
|
|
|
|
func (s *AppState) Decrypt(recordIDs []int) ([]map[string]any, []string, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
defer s.persistLockedNoFail()
|
|
if len(recordIDs) == 0 {
|
|
return nil, nil, errors.New("select at least one ciphertext mapping")
|
|
}
|
|
index := map[int]*AssetRecord{}
|
|
for _, rec := range s.AssetRecords {
|
|
index[rec.RecordID] = rec
|
|
}
|
|
restored := []map[string]any{}
|
|
errs := []string{}
|
|
for _, id := range recordIDs {
|
|
rec, ok := index[id]
|
|
if !ok {
|
|
errs = append(errs, fmt.Sprintf("record %d: not found", id))
|
|
continue
|
|
}
|
|
key, keyOK, err := s.Manager.GetKeyForTag(rec.Path)
|
|
if err != nil || !keyOK {
|
|
errs = append(errs, fmt.Sprintf("record %d: key is punctured/inaccessible", id))
|
|
continue
|
|
}
|
|
cipherAbs, err := assetAbsPath(s.AssetRoot, rec.CiphertextRelpath)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("record %d: %v", id, err))
|
|
continue
|
|
}
|
|
blob, err := os.ReadFile(cipherAbs)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("record %d: %v", id, err))
|
|
continue
|
|
}
|
|
plain, err := decryptBlob(key, blob)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("record %d: %v", id, err))
|
|
continue
|
|
}
|
|
decRel, err := nextDecryptedRelPath(s.AssetRoot, rec.PlaintextRelpath, rec.ProviderID, rec.FileTimeID)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("record %d: %v", id, err))
|
|
continue
|
|
}
|
|
decAbs, err := assetAbsPath(s.AssetRoot, decRel)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("record %d: %v", id, err))
|
|
continue
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(decAbs), 0o755); err != nil {
|
|
errs = append(errs, fmt.Sprintf("record %d: %v", id, err))
|
|
continue
|
|
}
|
|
if err := os.WriteFile(decAbs, plain, 0o644); err != nil {
|
|
errs = append(errs, fmt.Sprintf("record %d: %v", id, err))
|
|
continue
|
|
}
|
|
rec.DecryptCount++
|
|
rec.LastDecryptedAt = nowLabel()
|
|
rec.LastDecryptedRel = decRel
|
|
restored = append(restored, map[string]any{
|
|
"record_id": id,
|
|
"ciphertext_relpath": rec.CiphertextRelpath,
|
|
"decrypted_relpath": decRel,
|
|
})
|
|
}
|
|
if len(restored) == 0 {
|
|
return nil, errs, errors.New("no ciphertext could be decrypted")
|
|
}
|
|
s.AssetNotice = &Notice{Tone: "success", Message: fmt.Sprintf("Decrypted %d mapping(s).", len(restored))}
|
|
s.recordHistory("asset-decrypt", "decrypted", fmt.Sprintf("Decrypted %d mapping(s)", len(restored)), nil, nil, "")
|
|
return restored, errs, nil
|
|
}
|
|
|
|
func (s *AppState) listPlaintextRows() []map[string]any {
|
|
rows := []map[string]any{}
|
|
_ = filepath.Walk(s.AssetRoot, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info == nil {
|
|
return nil
|
|
}
|
|
if info.IsDir() {
|
|
if strings.HasPrefix(info.Name(), ".") {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if strings.HasSuffix(info.Name(), ".pke") {
|
|
return nil
|
|
}
|
|
rel, relErr := filepath.Rel(s.AssetRoot, path)
|
|
if relErr != nil {
|
|
return nil
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
rows = append(rows, map[string]any{
|
|
"relpath": rel,
|
|
"size_bytes": info.Size(),
|
|
"size_label": formatBytes(info.Size()),
|
|
"modified_at": info.ModTime().UTC().Format("2006-01-02 15:04 UTC"),
|
|
})
|
|
return nil
|
|
})
|
|
sort.Slice(rows, func(i, j int) bool { return rows[i]["relpath"].(string) < rows[j]["relpath"].(string) })
|
|
return rows
|
|
}
|
|
|
|
func formatBytes(n int64) string {
|
|
units := []string{"B", "KB", "MB", "GB"}
|
|
value := float64(n)
|
|
idx := 0
|
|
for value >= 1024 && idx < len(units)-1 {
|
|
value /= 1024
|
|
idx++
|
|
}
|
|
if idx == 0 {
|
|
return fmt.Sprintf("%d %s", int(value), units[idx])
|
|
}
|
|
return fmt.Sprintf("%.1f %s", value, units[idx])
|
|
}
|
|
|
|
func lifecycleState(mappingCount, blockedCount int) string {
|
|
if mappingCount <= 0 {
|
|
return "eligible"
|
|
}
|
|
if blockedCount <= 0 {
|
|
return "encrypted_live"
|
|
}
|
|
if blockedCount < mappingCount {
|
|
return "encrypted_partial"
|
|
}
|
|
return "encrypted_blocked"
|
|
}
|
|
|
|
func lifecycleLabel(state string) string {
|
|
switch state {
|
|
case "eligible":
|
|
return "Eligible"
|
|
case "encrypted_live":
|
|
return "Encrypted (live)"
|
|
case "encrypted_partial":
|
|
return "Encrypted (partially blocked)"
|
|
case "encrypted_blocked":
|
|
return "Encrypted (fully blocked)"
|
|
default:
|
|
return state
|
|
}
|
|
}
|
|
|
|
func providerRows(providers map[int]Provider, journal map[string]*KeyJournalEntry) []map[string]any {
|
|
ids := make([]int, 0, len(providers))
|
|
for id := range providers {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Ints(ids)
|
|
rows := make([]map[string]any, 0, len(ids))
|
|
for _, id := range ids {
|
|
p := providers[id]
|
|
prefix, _ := ggm.ProviderIDToPrefix(id)
|
|
keys := []map[string]any{}
|
|
for _, e := range journal {
|
|
if e.ProviderID != id {
|
|
continue
|
|
}
|
|
keys = append(keys, map[string]any{
|
|
"provider_id": e.ProviderID,
|
|
"file_time_id": e.FileTimeID,
|
|
"path": e.Path,
|
|
"path_provider": e.PathProvider,
|
|
"path_resource": e.PathResource,
|
|
"description": e.Description,
|
|
"ever_derived": e.EverDerived,
|
|
"ever_punctured": e.EverPunctured,
|
|
"derive_count": e.DeriveCount,
|
|
"puncture_count": e.PunctureCount,
|
|
"last_derived_at": e.LastDerivedAt,
|
|
"last_punctured_at": e.LastPuncturedAt,
|
|
})
|
|
}
|
|
sort.Slice(keys, func(i, j int) bool { return keys[i]["file_time_id"].(int) < keys[j]["file_time_id"].(int) })
|
|
derivedIDs := []int{}
|
|
puncturedIDs := []int{}
|
|
for _, k := range keys {
|
|
if k["ever_derived"].(bool) {
|
|
derivedIDs = append(derivedIDs, k["file_time_id"].(int))
|
|
}
|
|
if k["ever_punctured"].(bool) {
|
|
puncturedIDs = append(puncturedIDs, k["file_time_id"].(int))
|
|
}
|
|
}
|
|
rows = append(rows, map[string]any{
|
|
"provider_id": p.ProviderID,
|
|
"name": p.Name,
|
|
"description": p.Description,
|
|
"created_at": p.CreatedAt,
|
|
"prefix": prefix,
|
|
"key_rows": keys,
|
|
"key_count": len(keys),
|
|
"derived_count": len(derivedIDs),
|
|
"punctured_count": len(puncturedIDs),
|
|
"derived_ids": derivedIDs,
|
|
"punctured_ids": puncturedIDs,
|
|
})
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func prefixIntersectsActive(prefix string, active []string) bool {
|
|
for _, frontier := range active {
|
|
if strings.HasPrefix(frontier, prefix) || strings.HasPrefix(prefix, frontier) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func nodeX(prefix string, depth int, slotWidth, margin float64) float64 {
|
|
if prefix == "" {
|
|
leaf := 1 << depth
|
|
return margin + (float64(leaf)*slotWidth)/2
|
|
}
|
|
idx, _ := parseBinary(prefix)
|
|
left := idx * (1 << (depth - len(prefix)))
|
|
span := 1 << (depth - len(prefix))
|
|
center := float64(left) + float64(span)/2
|
|
return margin + center*slotWidth
|
|
}
|
|
|
|
func parseBinary(s string) (int, error) {
|
|
v := 0
|
|
for _, c := range s {
|
|
v <<= 1
|
|
if c == '1' {
|
|
v |= 1
|
|
}
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func (s *AppState) treeVizLocked() TreeViz {
|
|
depth := treeDepth
|
|
active := s.Manager.ActivePrefixes()
|
|
derivedPrefixes := map[string]struct{}{}
|
|
for _, entry := range s.KeyJournal {
|
|
if !entry.EverDerived {
|
|
continue
|
|
}
|
|
stop := len(entry.Path)
|
|
if stop > depth {
|
|
stop = depth
|
|
}
|
|
for d := 0; d <= stop; d++ {
|
|
derivedPrefixes[entry.Path[:d]] = struct{}{}
|
|
}
|
|
}
|
|
frontierExact := map[string]struct{}{}
|
|
frontierProxy := map[string]struct{}{}
|
|
for _, p := range active {
|
|
if len(p) <= depth {
|
|
frontierExact[p] = struct{}{}
|
|
} else {
|
|
frontierProxy[p[:depth]] = struct{}{}
|
|
}
|
|
}
|
|
|
|
removedExact := map[string]struct{}{}
|
|
removedProxy := map[string]struct{}{}
|
|
if s.LastPunctureDiff != nil {
|
|
for _, p := range s.LastPunctureDiff.Removed {
|
|
if len(p) <= depth {
|
|
removedExact[p] = struct{}{}
|
|
} else {
|
|
removedProxy[p[:depth]] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
type node struct {
|
|
Prefix string
|
|
Status string
|
|
}
|
|
statuses := map[string]string{}
|
|
for level := 0; level <= depth; level++ {
|
|
for idx := 0; idx < (1 << level); idx++ {
|
|
prefix := ""
|
|
if level > 0 {
|
|
prefix = fmt.Sprintf("%0*b", level, idx)
|
|
}
|
|
possible := prefixIntersectsActive(prefix, active)
|
|
status := "possible"
|
|
if _, ok := removedExact[prefix]; ok {
|
|
status = "removed"
|
|
} else if level == depth {
|
|
if _, ok := removedProxy[prefix]; ok {
|
|
status = "removed_proxy"
|
|
}
|
|
}
|
|
if status == "possible" {
|
|
if _, ok := frontierExact[prefix]; ok {
|
|
status = "frontier"
|
|
} else if level == depth {
|
|
if _, ok := frontierProxy[prefix]; ok {
|
|
status = "frontier_proxy"
|
|
}
|
|
}
|
|
}
|
|
if status == "possible" {
|
|
if !possible {
|
|
status = "blocked"
|
|
} else if _, ok := derivedPrefixes[prefix]; ok {
|
|
status = "derived"
|
|
}
|
|
}
|
|
statuses[prefix] = status
|
|
}
|
|
}
|
|
|
|
slotWidth := 22.0
|
|
levelHeight := 86.0
|
|
margin := 26.0
|
|
marginTop := 34.0
|
|
leafSlots := 1 << depth
|
|
width := int(margin*2 + float64(leafSlots)*slotWidth)
|
|
height := int(marginTop + float64(depth)*levelHeight + 64)
|
|
|
|
edges := strings.Builder{}
|
|
for level := 0; level < depth; level++ {
|
|
for idx := 0; idx < (1 << level); idx++ {
|
|
parent := ""
|
|
if level > 0 {
|
|
parent = fmt.Sprintf("%0*b", level, idx)
|
|
}
|
|
px := nodeX(parent, depth, slotWidth, margin)
|
|
py := marginTop + float64(level)*levelHeight
|
|
for _, bit := range []string{"0", "1"} {
|
|
child := parent + bit
|
|
cx := nodeX(child, depth, slotWidth, margin)
|
|
cy := marginTop + float64(level+1)*levelHeight
|
|
edgeClass := "edge-live"
|
|
if statuses[child] == "blocked" {
|
|
edgeClass = "edge-blocked"
|
|
}
|
|
if strings.HasPrefix(statuses[child], "removed") {
|
|
edgeClass = "edge-removed"
|
|
}
|
|
edges.WriteString(fmt.Sprintf(`<line class="%s" x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" />`, edgeClass, px, py, cx, cy))
|
|
}
|
|
}
|
|
}
|
|
|
|
nodes := strings.Builder{}
|
|
currentFrontier := 0
|
|
blockedCount := 0
|
|
removedCount := 0
|
|
for level := 0; level <= depth; level++ {
|
|
radius := 7.5
|
|
if level == 0 {
|
|
radius = 10
|
|
}
|
|
for idx := 0; idx < (1 << level); idx++ {
|
|
prefix := ""
|
|
if level > 0 {
|
|
prefix = fmt.Sprintf("%0*b", level, idx)
|
|
}
|
|
x := nodeX(prefix, depth, slotWidth, margin)
|
|
y := marginTop + float64(level)*levelHeight
|
|
status := statuses[prefix]
|
|
if status == "frontier" || status == "frontier_proxy" {
|
|
currentFrontier++
|
|
}
|
|
if status == "blocked" {
|
|
blockedCount++
|
|
}
|
|
if strings.HasPrefix(status, "removed") {
|
|
removedCount++
|
|
}
|
|
title := "seed root"
|
|
if prefix != "" {
|
|
title = "prefix " + prefix
|
|
}
|
|
nodes.WriteString(fmt.Sprintf(`<circle class="node-%s" cx="%.2f" cy="%.2f" r="%.2f"><title>%s</title></circle>`, status, x, y, radius, title))
|
|
}
|
|
}
|
|
|
|
svg := fmt.Sprintf(`<svg class="tree-svg" viewBox="0 0 %d %d" width="%d" height="%d" role="img" aria-label="Projected puncturable tree state"><style>.tree-svg{background:#fff;border:1px solid #ddd3bf;border-radius:12px}.edge-live{stroke:#8fbea3;stroke-width:1.3;opacity:.75}.edge-blocked{stroke:#d7a9a9;stroke-width:1.1;stroke-dasharray:4 4;opacity:.6}.edge-removed{stroke:#b42f2f;stroke-width:1.6;opacity:.95}.node-possible{fill:#d7f0df;stroke:#5d9a6f;stroke-width:1.3}.node-derived{fill:#ffe6ba;stroke:#c27a09;stroke-width:1.5}.node-blocked{fill:#f6d8d8;stroke:#b34f4f;stroke-width:1.2}.node-frontier{fill:#0f766e;stroke:#084a45;stroke-width:1.9}.node-frontier_proxy{fill:#8ecfc5;stroke:#0f766e;stroke-width:1.8}.node-removed{fill:#ef6a6a;stroke:#8e1a1a;stroke-width:2}.node-removed_proxy{fill:#f8b2b2;stroke:#9b1c1c;stroke-width:1.9}</style>%s%s</svg>`, width, height, width, height, edges.String(), nodes.String())
|
|
|
|
return TreeViz{Depth: depth, CurrentFrontierCount: currentFrontier, BlockedCount: blockedCount, RemovedCount: removedCount, LastPuncture: s.LastPunctureDiff, SVG: svg}
|
|
}
|
|
|
|
func (s *AppState) snapshotLocked() map[string]any {
|
|
providerRows := providerRows(s.Providers, s.KeyJournal)
|
|
journalRows := make([]map[string]any, 0, len(s.KeyJournal))
|
|
for _, entry := range s.KeyJournal {
|
|
journalRows = append(journalRows, map[string]any{
|
|
"provider_id": entry.ProviderID,
|
|
"file_time_id": entry.FileTimeID,
|
|
"path": entry.Path,
|
|
"path_provider": entry.PathProvider,
|
|
"path_resource": entry.PathResource,
|
|
"description": entry.Description,
|
|
"ever_derived": entry.EverDerived,
|
|
"ever_punctured": entry.EverPunctured,
|
|
"derive_count": entry.DeriveCount,
|
|
"puncture_count": entry.PunctureCount,
|
|
"last_derived_at": entry.LastDerivedAt,
|
|
"last_punctured_at": entry.LastPuncturedAt,
|
|
})
|
|
}
|
|
sort.Slice(journalRows, func(i, j int) bool {
|
|
if journalRows[i]["provider_id"].(int) == journalRows[j]["provider_id"].(int) {
|
|
return journalRows[i]["file_time_id"].(int) < journalRows[j]["file_time_id"].(int)
|
|
}
|
|
return journalRows[i]["provider_id"].(int) < journalRows[j]["provider_id"].(int)
|
|
})
|
|
|
|
recordRows := []map[string]any{}
|
|
for _, rec := range s.AssetRecords {
|
|
_, keyOK, _ := s.Manager.GetKeyForTag(rec.Path)
|
|
pp, pr := splitPath(rec.Path)
|
|
recordRows = append(recordRows, map[string]any{
|
|
"record_id": rec.RecordID,
|
|
"plaintext_relpath": rec.PlaintextRelpath,
|
|
"ciphertext_relpath": rec.CiphertextRelpath,
|
|
"provider_id": rec.ProviderID,
|
|
"file_time_id": rec.FileTimeID,
|
|
"path": rec.Path,
|
|
"path_provider": pp,
|
|
"path_resource": pr,
|
|
"purpose": rec.Purpose,
|
|
"created_at": rec.CreatedAt,
|
|
"plaintext_size": rec.PlaintextSize,
|
|
"ciphertext_size": rec.CiphertextSize,
|
|
"is_accessible": keyOK,
|
|
"show_red": !keyOK,
|
|
"show_glow": false,
|
|
"decrypt_count": rec.DecryptCount,
|
|
"last_decrypted_at": rec.LastDecryptedAt,
|
|
"last_decrypted_relpath": rec.LastDecryptedRel,
|
|
})
|
|
}
|
|
|
|
assetFiles := []map[string]any{}
|
|
keyMap := map[string]map[string]any{}
|
|
groupByPlain := map[string][]map[string]any{}
|
|
for _, row := range recordRows {
|
|
plain := row["plaintext_relpath"].(string)
|
|
groupByPlain[plain] = append(groupByPlain[plain], row)
|
|
|
|
k := fmt.Sprintf("%d:%d:%s", row["provider_id"].(int), row["file_time_id"].(int), row["path"].(string))
|
|
bucket, ok := keyMap[k]
|
|
if !ok {
|
|
bucket = map[string]any{
|
|
"provider_id": row["provider_id"],
|
|
"file_time_id": row["file_time_id"],
|
|
"path": row["path"],
|
|
"path_provider": row["path_provider"],
|
|
"path_resource": row["path_resource"],
|
|
"files": []string{},
|
|
"is_accessible": row["is_accessible"],
|
|
}
|
|
keyMap[k] = bucket
|
|
}
|
|
bucket["files"] = append(bucket["files"].([]string), plain)
|
|
bucket["is_accessible"] = bucket["is_accessible"].(bool) && row["is_accessible"].(bool)
|
|
}
|
|
|
|
blockedTotal := 0
|
|
glowTotal := 0
|
|
for plain, mappings := range groupByPlain {
|
|
sort.Slice(mappings, func(i, j int) bool {
|
|
return mappings[i]["created_at"].(string) < mappings[j]["created_at"].(string)
|
|
})
|
|
blocked := 0
|
|
for _, m := range mappings {
|
|
if !m["is_accessible"].(bool) {
|
|
blocked++
|
|
blockedTotal++
|
|
}
|
|
}
|
|
if blocked > 0 {
|
|
for _, m := range mappings {
|
|
if m["is_accessible"].(bool) {
|
|
m["show_glow"] = true
|
|
glowTotal++
|
|
}
|
|
}
|
|
}
|
|
assetFiles = append(assetFiles, map[string]any{
|
|
"plaintext_relpath": plain,
|
|
"mapping_count": len(mappings),
|
|
"blocked_count": blocked,
|
|
"mappings": mappings,
|
|
})
|
|
}
|
|
sort.Slice(assetFiles, func(i, j int) bool {
|
|
return assetFiles[i]["plaintext_relpath"].(string) < assetFiles[j]["plaintext_relpath"].(string)
|
|
})
|
|
|
|
keyCards := []map[string]any{}
|
|
for _, bucket := range keyMap {
|
|
files := bucket["files"].([]string)
|
|
sort.Strings(files)
|
|
keyCards = append(keyCards, map[string]any{
|
|
"provider_id": bucket["provider_id"],
|
|
"file_time_id": bucket["file_time_id"],
|
|
"path": bucket["path"],
|
|
"path_provider": bucket["path_provider"],
|
|
"path_resource": bucket["path_resource"],
|
|
"file_count": len(files),
|
|
"files": files,
|
|
"is_accessible": bucket["is_accessible"],
|
|
})
|
|
}
|
|
sort.Slice(keyCards, func(i, j int) bool {
|
|
if keyCards[i]["provider_id"].(int) == keyCards[j]["provider_id"].(int) {
|
|
return keyCards[i]["file_time_id"].(int) < keyCards[j]["file_time_id"].(int)
|
|
}
|
|
return keyCards[i]["provider_id"].(int) < keyCards[j]["provider_id"].(int)
|
|
})
|
|
|
|
plainRows := s.listPlaintextRows()
|
|
mappedByRel := map[string]map[string]any{}
|
|
for _, af := range assetFiles {
|
|
mappedByRel[af["plaintext_relpath"].(string)] = af
|
|
}
|
|
fileRows := []map[string]any{}
|
|
for _, row := range plainRows {
|
|
rel := row["relpath"].(string)
|
|
mappingCount := 0
|
|
blockedCount := 0
|
|
if m, ok := mappedByRel[rel]; ok {
|
|
mappingCount = m["mapping_count"].(int)
|
|
blockedCount = m["blocked_count"].(int)
|
|
}
|
|
state := lifecycleState(mappingCount, blockedCount)
|
|
row["mapping_count"] = mappingCount
|
|
row["blocked_count"] = blockedCount
|
|
row["lifecycle_state"] = state
|
|
row["lifecycle_label"] = lifecycleLabel(state)
|
|
fileRows = append(fileRows, row)
|
|
}
|
|
|
|
combo := []map[string]any{}
|
|
for _, row := range journalRows {
|
|
status := "active"
|
|
if row["ever_punctured"].(bool) {
|
|
status = "blocked"
|
|
}
|
|
combo = append(combo, map[string]any{
|
|
"provider_id": row["provider_id"],
|
|
"file_time_id": row["file_time_id"],
|
|
"status": status,
|
|
"label": fmt.Sprintf("Provider %d | Key %d | %s", row["provider_id"].(int), row["file_time_id"].(int), status),
|
|
})
|
|
}
|
|
|
|
activePrefixes := s.Manager.ActivePrefixes()
|
|
tree := s.treeVizLocked()
|
|
|
|
return map[string]any{
|
|
"generated_at": nowLabel(),
|
|
"active_nodes": s.Manager.ActiveNodeCount(),
|
|
"active_prefixes": activePrefixes,
|
|
"puncture_log": s.Manager.PunctureLog(),
|
|
"last_puncture_diff": s.LastPunctureDiff,
|
|
"last_action": s.LastAction,
|
|
"history": s.History,
|
|
"providers": providerRows,
|
|
"deleted_providers": s.DeletedProviders,
|
|
"key_journal": journalRows,
|
|
"asset_root": s.AssetRoot,
|
|
"state_file": s.StateFile,
|
|
"tree_viz": tree,
|
|
"assets": map[string]any{
|
|
"mapping_count": len(recordRows),
|
|
"blocked_count": blockedTotal,
|
|
"glow_count": glowTotal,
|
|
"asset_files": assetFiles,
|
|
"key_cards": keyCards,
|
|
},
|
|
"workflow": map[string]any{
|
|
"asset_root": s.AssetRoot,
|
|
"stats": map[string]any{
|
|
"cleartext_count": len(fileRows),
|
|
"mapping_count": len(recordRows),
|
|
"blocked_count": blockedTotal,
|
|
"glow_count": glowTotal,
|
|
},
|
|
"files": fileRows,
|
|
"providers": simpleProviders(providerRows),
|
|
"key_combo_options": combo,
|
|
"last_inputs": s.LastInputs,
|
|
"asset_files": assetFiles,
|
|
"key_cards": keyCards,
|
|
},
|
|
}
|
|
}
|
|
|
|
func simpleProviders(rows []map[string]any) []map[string]any {
|
|
out := make([]map[string]any, 0, len(rows))
|
|
for _, p := range rows {
|
|
out = append(out, map[string]any{"provider_id": p["provider_id"], "name": p["name"]})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *AppState) Snapshot() map[string]any {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.snapshotLocked()
|
|
}
|
|
|
|
func (s *AppState) ExportStateJSON() ([]byte, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
payload := s.persistedPayloadLocked()
|
|
top := map[string]any{
|
|
"version": payload.Version,
|
|
"manager": payload.Manager,
|
|
"providers": payload.Providers,
|
|
"deleted_providers": payload.DeletedProviders,
|
|
"key_journal": payload.KeyJournal,
|
|
"asset_records": payload.AssetRecords,
|
|
"asset_root": s.AssetRoot,
|
|
"state_file": s.StateFile,
|
|
"history": payload.History,
|
|
"last_action": payload.LastAction,
|
|
"last_inputs": payload.LastInputs,
|
|
"last_puncture_diff": payload.LastPunctureDiff,
|
|
}
|
|
return json.MarshalIndent(top, "", " ")
|
|
}
|
|
|
|
func (s *AppState) Reset() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
defer s.persistLockedNoFail()
|
|
seed, err := ggm.GenerateMasterSeed()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mgr, err := ggm.NewManager(seed)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.Manager = mgr
|
|
s.Providers = defaultProviders()
|
|
s.DeletedProviders = []DeletedProvider{}
|
|
s.KeyJournal = map[string]*KeyJournalEntry{}
|
|
s.AssetRecords = []*AssetRecord{}
|
|
s.LastInputs = LastInputs{ProviderID: 42, FileTimeID: 123456, Purpose: "Demo key for provider onboarding"}
|
|
s.LastAction = LastAction{Tone: "info", Title: "Reset complete", Body: "Lab was reset with fresh root state."}
|
|
s.History = []HistoryItem{}
|
|
s.LastPunctureDiff = nil
|
|
s.recordHistory("system", "reset", "Lab was reset with fresh root state.", nil, nil, "")
|
|
return nil
|
|
}
|
|
|
|
func (s *AppState) RemoteTokenValid(supplied, configured string) bool {
|
|
if strings.TrimSpace(configured) == "" {
|
|
return true
|
|
}
|
|
return hmac.Equal([]byte(supplied), []byte(configured))
|
|
}
|