package historyreadback import ( "bytes" "context" "crypto/ed25519" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "os" "strings" "time" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/release" ) type Scope struct { Bucket string `json:"bucket"` Box string `json:"box"` Enrollment string `json:"enrollment"` Binding release.HistoryBinding `json:"binding"` } type Request struct { Schema int `json:"schema"` Kind string `json:"kind"` Scope Scope `json:"scope"` ID string `json:"id"` IssuedAt time.Time `json:"issued_at"` } type Receipt struct { Schema int `json:"schema"` Kind string `json:"kind"` RequestDigest string `json:"request_digest"` ObservedAt time.Time `json:"observed_at"` Status release.HistoryMigrationStatus `json:"status"` } func digest(v any) string { raw, err := json.Marshal(v) if err != nil { return "" } sum := sha256.Sum256(raw) return hex.EncodeToString(sum[:]) } func validHex(v string, size int) bool { raw, err := hex.DecodeString(v) return err == nil && len(raw) == size && hex.EncodeToString(raw) == v } func validScope(s Scope) bool { 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") } func validRequest(q Request, now time.Time) bool { 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)) } func RequestKey(s Scope) string { return "apps/" + s.Binding.Account + "/" + s.Binding.App + "/release-history/readback/" + digest(s) + "/request.json" } func ResponseKey(q Request) string { return strings.TrimSuffix(RequestKey(q.Scope), "request.json") + "receipt.json" } func enrolled(ctx context.Context, d hostactors.Directory, s Scope, now time.Time) (hostactors.Observed, ed25519.PublicKey, error) { if !validScope(s) || s.Bucket != d.Bucket { return hostactors.Observed{}, nil, os.ErrPermission } o, err := d.Read(ctx) if err != nil { return o, nil, err } b, ok := o.Roster.Boxes[s.Box] if !ok || b.State != "active" || b.Enrollment != s.Enrollment || len(b.CensusPublicKey) != ed25519.PublicKeySize { return o, nil, os.ErrPermission } fenced, err := d.TerminationFence(ctx, o, s.Box, now) if err != nil { return o, nil, err } if fenced { return o, nil, os.ErrPermission } return o, b.CensusPublicKey, nil } func recheck(ctx context.Context, d hostactors.Directory, s Scope, before hostactors.Observed, now time.Time) error { after, _, err := enrolled(ctx, d, s, now) if err != nil { return err } if after.ETag != before.ETag { return os.ErrPermission } return nil } func RequestStatus(ctx context.Context, d hostactors.Directory, signer release.Signer, scope Scope, now time.Time) (Request, error) { q, wire, err := prepareStatus(ctx, d, signer, scope, now) if err != nil { return Request{}, err } if err := publish(ctx, d.Store, RequestKey(scope), wire); err != nil { return Request{}, err } return q, nil } func prepareStatus(ctx context.Context, d hostactors.Directory, signer release.Signer, scope Scope, now time.Time) (Request, []byte, error) { q := Request{Schema: 1, Kind: "carlos-release-history-readback", Scope: scope, IssuedAt: now.UTC()} if !bytes.Equal(signer.RootPublicKey(), d.PublicKey) { return Request{}, nil, os.ErrPermission } before, _, err := enrolled(ctx, d, scope, now) if err != nil { return Request{}, nil, err } nonce := make([]byte, 32) if _, err := rand.Read(nonce); err != nil { return Request{}, nil, err } q.ID = hex.EncodeToString(nonce) if !validRequest(q, now) { return Request{}, nil, os.ErrPermission } wire, err := release.SignRootEnvelope(signer, q) if err != nil { return Request{}, nil, err } if err := recheck(ctx, d, scope, before, now); err != nil { return Request{}, nil, err } return q, wire, nil } func validStatus(v release.HistoryMigrationStatus, b release.HistoryBinding) bool { 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 { return false } seen := map[string]bool{} for _, h := range v.Hosts { if h.Host == "" || len(h.Host) > 253 || seen[h.Host] || strings.ContainsAny(h.Host, "/\\\x00\r\n") || h.Source == "" || len(h.Source) > 4096 { return false } seen[h.Host] = true } return seen[b.Host] } // Respond runs under the caller's root release exclusion. Snapshot reads the // private ledger and original recovery journals, never a public projection. func Respond(ctx context.Context, d hostactors.Directory, scope Scope, key ed25519.PrivateKey, now time.Time, snapshot func(context.Context) (release.HistoryMigrationStatus, error)) error { if d.Store == nil || !validScope(scope) || scope.Bucket != d.Bucket { return os.ErrPermission } wire, _, err := d.Store.Get(ctx, RequestKey(scope)) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } var q Request if release.VerifyRootEnvelope(d.PublicKey, wire, &q) != nil || !validRequest(q, now) || q.Scope != scope { return os.ErrPermission } before, pub, err := enrolled(ctx, d, scope, now) if err != nil { return err } if len(key) != ed25519.PrivateKeySize || !bytes.Equal(key.Public().(ed25519.PublicKey), pub) || snapshot == nil { return os.ErrPermission } status, err := snapshot(ctx) if err != nil { return err } if !validStatus(status, scope.Binding) { return os.ErrPermission } receipt := Receipt{Schema: 1, Kind: "carlos-release-history-receipt", RequestDigest: digest(q), ObservedAt: now.UTC(), Status: status} response, err := release.SignRootEnvelope(release.RootSigner(key), receipt) if err != nil { return err } if err := recheck(ctx, d, scope, before, now); err != nil { return err } return publish(ctx, d.Store, ResponseKey(q), response) } // ReadStatus authenticates an observation for this challenge. Category // completeness, pending work and operation results remain separate facts. func ReadStatus(ctx context.Context, d hostactors.Directory, q Request, now time.Time) (Receipt, error) { if !validRequest(q, now) { return Receipt{}, os.ErrPermission } before, pub, err := enrolled(ctx, d, q.Scope, now) if err != nil { return Receipt{}, err } wire, _, err := d.Store.Get(ctx, ResponseKey(q)) if err != nil { return Receipt{}, err } var r Receipt 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) { return Receipt{}, os.ErrPermission } if err := recheck(ctx, d, q.Scope, before, now); err != nil { return Receipt{}, err } return r, nil } func publish(ctx context.Context, store release.Store, key string, wire []byte) error { _, tag, err := store.Get(ctx, key) if err != nil && !errors.Is(err, os.ErrNotExist) { return err } if err == nil && tag == "" { return os.ErrPermission } if errors.Is(err, os.ErrNotExist) { tag = "" } _, err = store.Put(ctx, key, wire, tag) return err }