-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathenvelope.go
More file actions
139 lines (131 loc) · 5.9 KB
/
Copy pathenvelope.go
File metadata and controls
139 lines (131 loc) · 5.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
package broker
// The upstream evidence envelope is Interlock's own, tenant-agnostic durable
// evidence format. It exists so the broker never has to trust a caller-*claimed*
// receipt status handed in as a struct field: instead it re-reads authoritative
// evidence from disk and binds it, by hash, to the exact bytes it is about to
// publish.
//
// Threat model — this is HASH-BINDING, not authenticity. The envelope proves the
// durable evidence refers to *these exact staged bytes* for *this run*, which
// defends against stale, copy-pasted, cross-run, or typo'd status claims and makes
// the evidence durable and auditable. It does NOT prove that a trusted supervisor
// produced the bytes: whoever can write StagedPath can write the adjacent
// envelope. True authenticity would require a signed envelope, or the broker
// re-verifying the tenant's own receipt chain — and the latter is exactly what the
// M3 generality guarantee forbids (the broker must not learn any tenant's receipt
// schema). Do not describe this as cryptographic provenance anywhere.
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/operatorstack/interlock/ir"
)
// upstreamEnvelope is the durable, tenant-agnostic evidence the broker re-reads.
// The schema/status pair is the data the engine's receipt_status requirement
// matches (the policy carries the schema; the broker stays generic). artifact_sha256
// binds the envelope to the candidate bytes in Interlock's tagged hash format
// ("sha256:"+hex). It carries NO timestamps or other nondeterministic fields, so
// the pinned envelope hash is replay-safe.
type upstreamEnvelope struct {
Schema string `json:"schema"`
RunID string `json:"run_id"`
Status string `json:"status"`
ArtifactSHA256 string `json:"artifact_sha256"`
}
// UpstreamEvidence is the tenant-owned data a caller supplies to write an
// upstream evidence envelope. The tenant owns the meaning of Schema and Status;
// Interlock never interprets either. Deliberately absent is the artifact hash:
// WriteUpstreamEnvelope computes it from the staged bytes themselves, so the
// tagged-vs-bare-hex footgun is unrepresentable — a caller cannot supply a hash
// at all, let alone the wrong format.
type UpstreamEvidence struct {
Schema string
RunID string
Status string
}
// WriteUpstreamEnvelope writes the durable upstream evidence envelope that
// readUpstreamEnvelope re-reads. It binds the envelope to the exact staged bytes
// by computing ir.HashBytes(staged) internally (never a caller-supplied hash),
// and emits byte-identical output to what the reader decodes: the four
// upstreamEnvelope fields in struct order via json.Marshal plus a trailing
// newline. Co-locating the writer with the reader is why they can never drift.
func WriteUpstreamEnvelope(path string, ev UpstreamEvidence, staged []byte) error {
data, err := json.Marshal(upstreamEnvelope{
Schema: ev.Schema,
RunID: ev.RunID,
Status: ev.Status,
ArtifactSHA256: ir.HashBytes(staged),
})
if err != nil {
return fmt.Errorf("interlock/broker: marshal upstream envelope: %w", err)
}
if err := WriteFileAtomic(path, append(data, '\n'), 0o600); err != nil {
return fmt.Errorf("interlock/broker: write upstream envelope: %w", err)
}
return nil
}
// WriteFileAtomic writes data to a same-directory temp file (fsync'd, chmod'd)
// and renames it into place, so a reader never observes a partial file. This is a
// generic durable-write utility — NOT the broker's protected effect, which is the
// policy-gated atomic publish in Publish. It is exported so the publishing façade
// can persist evidence through the same implementation rather than duplicating it.
func WriteFileAtomic(path string, data []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
file, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*.tmp")
if err != nil {
return err
}
name := file.Name()
defer os.Remove(name)
if _, err = file.Write(data); err != nil {
file.Close()
return err
}
if err = file.Sync(); err != nil {
file.Close()
return err
}
if err = file.Chmod(mode); err != nil {
file.Close()
return err
}
if err = file.Close(); err != nil {
return err
}
return os.Rename(name, path)
}
// readUpstreamEnvelope reads the envelope at path, verifies it correlates to the
// expected run and is hash-bound to the expected artifact bytes, and returns the
// envelope plus its own content hash (in ir.HashBytes tagged format) as an
// audit-only pin. Any read/parse failure, run mismatch, or artifact-hash mismatch
// fails closed.
//
// wantArtifactHash MUST be in Interlock's tagged format (the value ir.HashBytes
// returns); a bare-hex artifact_sha256 in the file will therefore never match and
// fails closed — the format is strict on purpose.
func readUpstreamEnvelope(path, wantRunID, wantArtifactHash string) (upstreamEnvelope, string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return upstreamEnvelope{}, "", fmt.Errorf("interlock/broker: read upstream envelope: %w", err)
}
var env upstreamEnvelope
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&env); err != nil {
return upstreamEnvelope{}, "", fmt.Errorf("interlock/broker: parse upstream envelope: %w", err)
}
if env.Schema == "" || env.Status == "" || env.RunID == "" || env.ArtifactSHA256 == "" {
return upstreamEnvelope{}, "", fmt.Errorf("interlock/broker: upstream envelope missing required fields")
}
if env.RunID != wantRunID {
return upstreamEnvelope{}, "", fmt.Errorf("interlock/broker: upstream envelope run %q does not match request run %q", env.RunID, wantRunID)
}
if env.ArtifactSHA256 != wantArtifactHash {
return upstreamEnvelope{}, "", fmt.Errorf("interlock/broker: upstream envelope artifact hash %q not bound to staged bytes %q", env.ArtifactSHA256, wantArtifactHash)
}
return env, ir.HashBytes(raw), nil
}