carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package historyreadback
2
3import (
4 "bytes"
5 "context"
6 "crypto/ed25519"
7 "crypto/rand"
8 "crypto/sha256"
9 "encoding/hex"
10 "encoding/json"
11 "errors"
12 "os"
13 "strings"
14 "time"
15
16 "github.com/carlosframework/platform/internal/hostactors"
17 "github.com/carlosframework/platform/internal/release"
18)
19
20type Scope struct {
21 Bucket string `json:"bucket"`
22 Box string `json:"box"`
23 Enrollment string `json:"enrollment"`
24 Binding release.HistoryBinding `json:"binding"`
25}
26
27type Request struct {
28 Schema int `json:"schema"`
29 Kind string `json:"kind"`
30 Scope Scope `json:"scope"`
31 ID string `json:"id"`
32 IssuedAt time.Time `json:"issued_at"`
33}
34
35type Receipt struct {
36 Schema int `json:"schema"`
37 Kind string `json:"kind"`
38 RequestDigest string `json:"request_digest"`
39 ObservedAt time.Time `json:"observed_at"`
40 Status release.HistoryMigrationStatus `json:"status"`
41}
42
43func digest(v any) string {
44 raw, err := json.Marshal(v)
45 if err != nil {
46 return ""
47 }
48 sum := sha256.Sum256(raw)
49 return hex.EncodeToString(sum[:])
50}
51
52func validHex(v string, size int) bool {
53 raw, err := hex.DecodeString(v)
54 return err == nil && len(raw) == size && hex.EncodeToString(raw) == v
55}
56
57func validScope(s Scope) bool {
58 return s.Bucket != "" && s.Box != "" && len(s.Box) <= 253 && !strings.ContainsAny(s.Box, "/\\\x00\r\n") && validHex(s.Enrollment, 16) && release.ValidAccount(s.Binding.Account) && release.ValidApp(s.Binding.App) && s.Binding.Host != "" && len(s.Binding.Host) <= 253 && !strings.ContainsAny(s.Binding.Host, "/\\\x00\r\n")
59}
60
61func validRequest(q Request, now time.Time) bool {
62 return q.Schema == 1 && q.Kind == "carlos-release-history-readback" && validScope(q.Scope) && validHex(q.ID, 32) && !q.IssuedAt.IsZero() && !now.IsZero() && !q.IssuedAt.After(now.Add(time.Minute)) && !now.After(q.IssuedAt.Add(24*time.Hour))
63}
64
65func RequestKey(s Scope) string {
66 return "apps/" + s.Binding.Account + "/" + s.Binding.App + "/release-history/readback/" + digest(s) + "/request.json"
67}
68
69func ResponseKey(q Request) string {
70 return strings.TrimSuffix(RequestKey(q.Scope), "request.json") + "receipt.json"
71}
72
73func enrolled(ctx context.Context, d hostactors.Directory, s Scope, now time.Time) (hostactors.Observed, ed25519.PublicKey, error) {
74 if !validScope(s) || s.Bucket != d.Bucket {
75 return hostactors.Observed{}, nil, os.ErrPermission
76 }
77 o, err := d.Read(ctx)
78 if err != nil {
79 return o, nil, err
80 }
81 b, ok := o.Roster.Boxes[s.Box]
82 if !ok || b.State != "active" || b.Enrollment != s.Enrollment || len(b.CensusPublicKey) != ed25519.PublicKeySize {
83 return o, nil, os.ErrPermission
84 }
85 fenced, err := d.TerminationFence(ctx, o, s.Box, now)
86 if err != nil {
87 return o, nil, err
88 }
89 if fenced {
90 return o, nil, os.ErrPermission
91 }
92 return o, b.CensusPublicKey, nil
93}
94
95func recheck(ctx context.Context, d hostactors.Directory, s Scope, before hostactors.Observed, now time.Time) error {
96 after, _, err := enrolled(ctx, d, s, now)
97 if err != nil {
98 return err
99 }
100 if after.ETag != before.ETag {
101 return os.ErrPermission
102 }
103 return nil
104}
105
106func RequestStatus(ctx context.Context, d hostactors.Directory, signer release.Signer, scope Scope, now time.Time) (Request, error) {
107 q, wire, err := prepareStatus(ctx, d, signer, scope, now)
108 if err != nil {
109 return Request{}, err
110 }
111 if err := publish(ctx, d.Store, RequestKey(scope), wire); err != nil {
112 return Request{}, err
113 }
114 return q, nil
115}
116
117func prepareStatus(ctx context.Context, d hostactors.Directory, signer release.Signer, scope Scope, now time.Time) (Request, []byte, error) {
118 q := Request{Schema: 1, Kind: "carlos-release-history-readback", Scope: scope, IssuedAt: now.UTC()}
119 if !bytes.Equal(signer.RootPublicKey(), d.PublicKey) {
120 return Request{}, nil, os.ErrPermission
121 }
122 before, _, err := enrolled(ctx, d, scope, now)
123 if err != nil {
124 return Request{}, nil, err
125 }
126 nonce := make([]byte, 32)
127 if _, err := rand.Read(nonce); err != nil {
128 return Request{}, nil, err
129 }
130 q.ID = hex.EncodeToString(nonce)
131 if !validRequest(q, now) {
132 return Request{}, nil, os.ErrPermission
133 }
134 wire, err := release.SignRootEnvelope(signer, q)
135 if err != nil {
136 return Request{}, nil, err
137 }
138 if err := recheck(ctx, d, scope, before, now); err != nil {
139 return Request{}, nil, err
140 }
141 return q, wire, nil
142}
143
144func validStatus(v release.HistoryMigrationStatus, b release.HistoryBinding) bool {
145 if v.Schema != 1 || v.Account != b.Account || v.App != b.App || !validHex(v.Identity, 32) || v.Revision == 0 || len(v.Hosts) == 0 || len(v.Hosts) > 256 {
146 return false
147 }
148 seen := map[string]bool{}
149 for _, h := range v.Hosts {
150 if h.Host == "" || len(h.Host) > 253 || seen[h.Host] || strings.ContainsAny(h.Host, "/\\\x00\r\n") || h.Source == "" || len(h.Source) > 4096 {
151 return false
152 }
153 seen[h.Host] = true
154 }
155 return seen[b.Host]
156}
157
158// Respond runs under the caller's root release exclusion. Snapshot reads the
159// private ledger and original recovery journals, never a public projection.
160func Respond(ctx context.Context, d hostactors.Directory, scope Scope, key ed25519.PrivateKey, now time.Time, snapshot func(context.Context) (release.HistoryMigrationStatus, error)) error {
161 if d.Store == nil || !validScope(scope) || scope.Bucket != d.Bucket {
162 return os.ErrPermission
163 }
164 wire, _, err := d.Store.Get(ctx, RequestKey(scope))
165 if errors.Is(err, os.ErrNotExist) {
166 return nil
167 }
168 if err != nil {
169 return err
170 }
171 var q Request
172 if release.VerifyRootEnvelope(d.PublicKey, wire, &q) != nil || !validRequest(q, now) || q.Scope != scope {
173 return os.ErrPermission
174 }
175 before, pub, err := enrolled(ctx, d, scope, now)
176 if err != nil {
177 return err
178 }
179 if len(key) != ed25519.PrivateKeySize || !bytes.Equal(key.Public().(ed25519.PublicKey), pub) || snapshot == nil {
180 return os.ErrPermission
181 }
182 status, err := snapshot(ctx)
183 if err != nil {
184 return err
185 }
186 if !validStatus(status, scope.Binding) {
187 return os.ErrPermission
188 }
189 receipt := Receipt{Schema: 1, Kind: "carlos-release-history-receipt", RequestDigest: digest(q), ObservedAt: now.UTC(), Status: status}
190 response, err := release.SignRootEnvelope(release.RootSigner(key), receipt)
191 if err != nil {
192 return err
193 }
194 if err := recheck(ctx, d, scope, before, now); err != nil {
195 return err
196 }
197 return publish(ctx, d.Store, ResponseKey(q), response)
198}
199
200// ReadStatus authenticates an observation for this challenge. Category
201// completeness, pending work and operation results remain separate facts.
202func ReadStatus(ctx context.Context, d hostactors.Directory, q Request, now time.Time) (Receipt, error) {
203 if !validRequest(q, now) {
204 return Receipt{}, os.ErrPermission
205 }
206 before, pub, err := enrolled(ctx, d, q.Scope, now)
207 if err != nil {
208 return Receipt{}, err
209 }
210 wire, _, err := d.Store.Get(ctx, ResponseKey(q))
211 if err != nil {
212 return Receipt{}, err
213 }
214 var r Receipt
215 if release.VerifyRootEnvelope(pub, wire, &r) != nil || r.Schema != 1 || r.Kind != "carlos-release-history-receipt" || r.RequestDigest != digest(q) || r.ObservedAt.IsZero() || r.ObservedAt.Before(q.IssuedAt.Add(-time.Minute)) || r.ObservedAt.After(now.Add(time.Minute)) || !validStatus(r.Status, q.Scope.Binding) {
216 return Receipt{}, os.ErrPermission
217 }
218 if err := recheck(ctx, d, q.Scope, before, now); err != nil {
219 return Receipt{}, err
220 }
221 return r, nil
222}
223
224func publish(ctx context.Context, store release.Store, key string, wire []byte) error {
225 _, tag, err := store.Get(ctx, key)
226 if err != nil && !errors.Is(err, os.ErrNotExist) {
227 return err
228 }
229 if err == nil && tag == "" {
230 return os.ErrPermission
231 }
232 if errors.Is(err, os.ErrNotExist) {
233 tag = ""
234 }
235 _, err = store.Put(ctx, key, wire, tag)
236 return err
237}
238