| 1 | package historyreadback |
| 2 | |
| 3 | import ( |
| 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 | |
| 20 | type 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 | |
| 27 | type 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 | |
| 35 | type 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 | |
| 43 | func 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 | |
| 52 | func 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 | |
| 57 | func 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 | |
| 61 | func 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 | |
| 65 | func RequestKey(s Scope) string { |
| 66 | return "apps/" + s.Binding.Account + "/" + s.Binding.App + "/release-history/readback/" + digest(s) + "/request.json" |
| 67 | } |
| 68 | |
| 69 | func ResponseKey(q Request) string { |
| 70 | return strings.TrimSuffix(RequestKey(q.Scope), "request.json") + "receipt.json" |
| 71 | } |
| 72 | |
| 73 | func 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 | |
| 95 | func 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 | |
| 106 | func 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 | |
| 117 | func 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 | |
| 144 | func 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. |
| 160 | func 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. |
| 202 | func 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 | |
| 224 | func 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 | |