288 lines
9.2 KiB
Go
288 lines
9.2 KiB
Go
package preflight
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/version"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
type Plan struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
ValidatorProtocol int `json:"validator_protocol"`
|
|
Validator version.Info `json:"validator"`
|
|
Installation config.Installation `json:"installation"`
|
|
Release Manifest `json:"release"`
|
|
Platform string `json:"platform"`
|
|
WorkspaceDirectory string `json:"workspace_directory"`
|
|
WorkspaceRevision string `json:"workspace_revision"`
|
|
Inputs []string `json:"inputs"`
|
|
AbsentInputs []string `json:"absent_inputs"`
|
|
InputSeal string `json:"input_seal"`
|
|
Report Report `json:"report"`
|
|
}
|
|
|
|
func invalidPlan() error {
|
|
return errors.New("plan inputs changed or protected plan files are unavailable; repeat validation and produce a new plan")
|
|
}
|
|
|
|
// CaptureInputs gives a value-free freshness guard around live probes and document validation.
|
|
func CaptureInputs(paths []string, workspace string, absent ...string) (func() bool, error) {
|
|
key := make([]byte, 32)
|
|
if _, err := rand.Read(key); err != nil {
|
|
return nil, invalidPlan()
|
|
}
|
|
probe := Plan{Inputs: paths, AbsentInputs: absent}
|
|
before, err := seal(probe, key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
files, err := treeFiles(workspace, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return func() bool {
|
|
after, err := seal(probe, key)
|
|
current, treeErr := treeFiles(workspace, true)
|
|
return err == nil && treeErr == nil && hmac.Equal([]byte(before), []byte(after)) && slices.Equal(files, current)
|
|
}, nil
|
|
}
|
|
|
|
// A separate owner-only random key prevents public/offline guessing of low-entropy secrets.
|
|
// Live prerequisites must still be rechecked immediately before any execution or resumption.
|
|
func seal(plan Plan, key []byte) (string, error) {
|
|
for _, path := range plan.AbsentInputs {
|
|
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
|
return "", invalidPlan()
|
|
}
|
|
}
|
|
mac := hmac.New(sha256.New, key)
|
|
plan.InputSeal = ""
|
|
data, err := json.Marshal(plan)
|
|
if err != nil {
|
|
return "", invalidPlan()
|
|
}
|
|
mac.Write(data)
|
|
var total int
|
|
for _, path := range plan.Inputs {
|
|
contents, err := safeio.ReadCanonicalRegular(path, 32<<20)
|
|
if err != nil {
|
|
return "", invalidPlan()
|
|
}
|
|
total += len(contents)
|
|
if total > 256<<20 {
|
|
return "", invalidPlan()
|
|
}
|
|
length, _ := json.Marshal([]any{path, len(contents)})
|
|
mac.Write(length)
|
|
mac.Write(contents)
|
|
}
|
|
return hex.EncodeToString(mac.Sum(nil)), nil
|
|
}
|
|
|
|
func AbsentOverrides(installation config.Installation) []string {
|
|
paths := []string{}
|
|
for _, path := range installation.Overrides {
|
|
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
|
|
paths = append(paths, path)
|
|
}
|
|
}
|
|
return paths
|
|
}
|
|
func WritePlan(path string, plan *Plan, guards ...func() bool) error {
|
|
if !plan.Report.OK || plan.ValidatorProtocol != Protocol || !filepath.IsAbs(path) {
|
|
return invalidPlan()
|
|
}
|
|
if exists, err := safeio.PreflightPrivateDirectory(filepath.Dir(path)); err != nil || !exists {
|
|
return invalidPlan()
|
|
}
|
|
for _, target := range []string{path, path + ".key"} {
|
|
if _, err := os.Lstat(target); !errors.Is(err, os.ErrNotExist) {
|
|
return invalidPlan()
|
|
}
|
|
}
|
|
key := make([]byte, 32)
|
|
if _, err := rand.Read(key); err != nil {
|
|
return invalidPlan()
|
|
}
|
|
var err error
|
|
plan.InputSeal, err = seal(*plan, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, guard := range guards {
|
|
if guard == nil || !guard() {
|
|
return invalidPlan()
|
|
}
|
|
}
|
|
data, _ := json.MarshalIndent(plan, "", " ")
|
|
if err := safeio.WriteCanonicalNewPrivateFile(path+".key", key, 0o600); err != nil {
|
|
return invalidPlan()
|
|
}
|
|
if err := safeio.WriteCanonicalNewPrivateFile(path, append(data, '\n'), 0o600); err != nil {
|
|
_ = safeio.RemoveCanonicalPrivateRegular(path + ".key")
|
|
return invalidPlan()
|
|
}
|
|
return nil
|
|
}
|
|
func VerifyPlanInputs(path string) error {
|
|
data, err := safeio.ReadCanonicalPrivateRegular(path, 4<<20)
|
|
if err != nil {
|
|
return invalidPlan()
|
|
}
|
|
key, err := safeio.ReadCanonicalPrivateRegular(path+".key", 32)
|
|
if err != nil || len(key) != 32 {
|
|
return invalidPlan()
|
|
}
|
|
var plan Plan
|
|
if json.Unmarshal(data, &plan) != nil || plan.SchemaVersion != 1 || plan.ValidatorProtocol != Protocol || !plan.Report.OK || len(plan.Inputs) == 0 || len(plan.Inputs) > 10000 {
|
|
return invalidPlan()
|
|
}
|
|
actual, err := seal(plan, key)
|
|
if err != nil || !hmac.Equal([]byte(actual), []byte(plan.InputSeal)) {
|
|
return invalidPlan()
|
|
}
|
|
// Detect added or removed workspace files as well as changes to known file bytes.
|
|
if plan.WorkspaceDirectory != "" {
|
|
files, err := treeFiles(plan.WorkspaceDirectory, true)
|
|
if err != nil {
|
|
return invalidPlan()
|
|
}
|
|
for _, file := range files {
|
|
if !slices.Contains(plan.Inputs, file) {
|
|
return invalidPlan()
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func treeFiles(root string, skipGit bool) ([]string, error) {
|
|
paths := []string{}
|
|
count := 0
|
|
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return invalidPlan()
|
|
}
|
|
count++
|
|
if count > 10000 {
|
|
return invalidPlan()
|
|
}
|
|
if skipGit && entry.Name() == ".git" {
|
|
if entry.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if entry.IsDir() {
|
|
return nil
|
|
}
|
|
if !entry.Type().IsRegular() {
|
|
return invalidPlan()
|
|
}
|
|
paths = append(paths, path)
|
|
return nil
|
|
})
|
|
return paths, err
|
|
}
|
|
|
|
// CollectInputs fingerprints exact prepared contents, with normalized configuration in Plan.
|
|
// Referenced credentials are sealed, never copied. Git object stores are excluded.
|
|
func CollectInputs(installation config.Installation, workspace, bootstrap, manifest string) ([]string, string, error) {
|
|
paths := []string{installation.Path, installation.EnvFile, bootstrap, manifest}
|
|
for _, root := range []string{workspace, installation.AuthenticationDirectory()} {
|
|
files, err := treeFiles(root, root == workspace)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
paths = append(paths, files...)
|
|
}
|
|
secrets, err := installation.SecretFiles()
|
|
if err != nil {
|
|
return nil, "", invalidPlan()
|
|
}
|
|
for _, path := range secrets {
|
|
paths = append(paths, path)
|
|
}
|
|
data, err := safeio.ReadCanonicalPrivateRegular(bootstrap, 1<<20)
|
|
if err != nil {
|
|
return nil, "", invalidPlan()
|
|
}
|
|
var bindings struct {
|
|
Databases []struct {
|
|
SecretFiles map[string]string `yaml:"secretFiles"`
|
|
EvidenceSecretFiles map[string]string `yaml:"evidenceSecretFiles"`
|
|
} `yaml:"databases"`
|
|
}
|
|
if yaml.Unmarshal(data, &bindings) != nil {
|
|
return nil, "", invalidPlan()
|
|
}
|
|
for _, entry := range bindings.Databases {
|
|
for _, values := range []map[string]string{entry.SecretFiles, entry.EvidenceSecretFiles} {
|
|
for _, path := range values {
|
|
paths = append(paths, path)
|
|
}
|
|
}
|
|
}
|
|
m, err := LoadManifest(manifest)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
for name := range m.Files {
|
|
paths = append(paths, filepath.Join(filepath.Dir(manifest), filepath.FromSlash(name)))
|
|
}
|
|
for _, path := range installation.Overrides {
|
|
if _, err := os.Lstat(path); err == nil {
|
|
paths = append(paths, path)
|
|
}
|
|
}
|
|
revision := "content-snapshot"
|
|
head := filepath.Join(workspace, ".git", "HEAD")
|
|
if data, err := safeio.ReadCanonicalRegular(head, 1024); err == nil {
|
|
paths = append(paths, head)
|
|
value := strings.TrimSpace(string(data))
|
|
if strings.HasPrefix(value, "ref: refs/") {
|
|
ref := strings.TrimPrefix(value, "ref: ")
|
|
if safeRelative(ref) {
|
|
path := filepath.Join(workspace, ".git", filepath.FromSlash(ref))
|
|
if data, err := safeio.ReadCanonicalRegular(path, 1024); err == nil {
|
|
paths = append(paths, path)
|
|
value = strings.TrimSpace(string(data))
|
|
}
|
|
}
|
|
}
|
|
if len(value) == 40 {
|
|
if _, err := hex.DecodeString(value); err == nil {
|
|
revision = value
|
|
}
|
|
}
|
|
}
|
|
slices.Sort(paths)
|
|
paths = slices.Compact(paths)
|
|
return paths, revision, nil
|
|
}
|
|
|
|
func AddRuntimeObligations(report *Report) {
|
|
for _, check := range []Check{
|
|
{"container-network", "deferred-to-runtime", "bindings", "Repeat authenticated DWH and external connectivity checks from the core network."},
|
|
{"catalog-initialization", "deferred-to-runtime", "catalog", "Apply migrations and verify Catalog health plus prepared binding import."},
|
|
{"pi-operation", "deferred-to-runtime", "release.components.pi", "Verify the bundled Pi version and authenticated provider/model smoke operation inside core; do not install Pi on the host."},
|
|
{"local-embedding", "deferred-to-runtime", "modelCatalog.embedding", "Initialize the local embedding model and verify returned vector dimensions."},
|
|
{"workspace-preprocessing", "deferred-to-runtime", "workspaces", "Sync the exact verified workspace contents, materialize Evidence, preprocess and verify collections."},
|
|
{"workspace-readiness", "deferred-to-runtime", "workspaces", "Complete required administrative and human review gates before claiming final readiness."},
|
|
} {
|
|
report.Checks = append(report.Checks, check)
|
|
}
|
|
}
|