package historyreadback import ( "bytes" "context" "encoding/json" "errors" "os" "time" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/release" ) // Operation retains the existing signed messages. Save it durably before // Deliver. Resume uses these exact bytes and never extends their lifetime. type Operation struct { Request json.RawMessage `json:"request"` Reconstruction json.RawMessage `json:"reconstruction,omitempty"` Retirement json.RawMessage `json:"retirement,omitempty"` } func PrepareOperation(ctx context.Context, d hostactors.Directory, signer release.Signer, scope Scope, g *release.HistoryReconstruction, retire *release.HistoryRecoveryRetirement, now time.Time) (Operation, error) { _, wire, err := prepareStatus(ctx, d, signer, scope, now) op := Operation{Request: wire} if err != nil { return Operation{}, err } if g != nil { op.Reconstruction, err = release.SignRootEnvelope(signer, *g) } if err == nil && retire != nil { op.Retirement, err = release.SignRootEnvelope(signer, *retire) } if err == nil { _, _, _, err = op.checked(d, now) } if err != nil { return Operation{}, err } return op, nil } func (op Operation) checked(d hostactors.Directory, now time.Time) (Request, *release.HistoryReconstruction, *release.HistoryRecoveryRetirement, error) { var q Request refuse := func() (Request, *release.HistoryReconstruction, *release.HistoryRecoveryRetirement, error) { return Request{}, nil, nil, os.ErrPermission } if release.VerifyRootEnvelope(d.PublicKey, op.Request, &q) != nil || !validRequest(q, now) || q.Scope.Bucket != d.Bucket || (len(op.Reconstruction) > 0 && len(op.Retirement) > 0) { return refuse() } fresh := func(at time.Time) bool { return !at.IsZero() && !at.Before(now.Add(-release.HostRetryGrantTTL)) && !at.After(now.Add(time.Minute)) } if len(op.Reconstruction) > 0 { var g release.HistoryReconstruction if release.VerifyRootEnvelope(d.PublicKey, op.Reconstruction, &g) != nil || g.Schema != 1 || g.Kind != "carlos-release-history" || !validHex(g.Identity, 32) || g.Revision == 0 || g.Revision == ^uint64(0) || g.Operation == "" || len(g.Operation) > 256 || !fresh(g.IssuedAt) || g.Account != q.Scope.Binding.Account || g.App != q.Scope.Binding.App || len(g.Hosts) == 0 || len(g.Hosts) > 256 || (g.Replaces != "" && !validHex(g.Replaces, 32)) { return refuse() } seen := map[string]bool{} for _, h := range g.Hosts { s := q.Scope s.Binding.Host = h.Host if !validScope(s) || h.Source == "" || seen[h.Host] { return refuse() } seen[h.Host] = true } if !seen[q.Scope.Binding.Host] { return refuse() } return q, &g, nil, nil } if len(op.Retirement) > 0 { var g release.HistoryRecoveryRetirement if release.VerifyRootEnvelope(d.PublicKey, op.Retirement, &g) != nil || g.Schema != 1 || g.Kind != "carlos-release-recovery-retirement" || !validHex(g.Identity, 32) || g.Operation == "" || len(g.Operation) > 256 || !fresh(g.IssuedAt) || g.Binding != q.Scope.Binding || g.Source == "" || g.AttemptID == "" || g.VerifiedID == "" || g.AttemptID == g.VerifiedID || !validHex(g.OutcomeDigest, 32) || !validHex(g.VerifiedDigest, 32) { return refuse() } return q, nil, &g, nil } return q, nil, nil, nil } // Deliver accepts an identical publication after a lost response. Replacing // another reconstruction requires its exact signed payload digest in Replaces. func (op Operation) Deliver(ctx context.Context, d hostactors.Directory, now time.Time) error { q, g, retire, err := op.checked(d, now) if err != nil { return err } before, _, err := enrolled(ctx, d, q.Scope, now) if err != nil { return err } if g != nil { err = op.deliverGrant(ctx, d, release.HistoryReconstructionKey(g.Account, g.App, g.Identity, g.Revision), op.Reconstruction, g.Replaces) } if retire != nil { err = op.deliverGrant(ctx, d, release.HistoryRecoveryRetirementKey(retire.Binding, retire.Identity, retire.OutcomeDigest), op.Retirement, "") } if err != nil { return err } if err = recheck(ctx, d, q.Scope, before, now); err != nil { return err } return publish(ctx, d.Store, RequestKey(q.Scope), op.Request) } func (op Operation) deliverGrant(ctx context.Context, d hostactors.Directory, key string, wire []byte, replaces string) error { old, tag, err := d.Store.Get(ctx, key) if err == nil { if bytes.Equal(old, wire) { return nil } var previous release.HistoryReconstruction if tag == "" || replaces == "" || release.VerifyRootEnvelope(d.PublicKey, old, &previous) != nil || digest(previous) != replaces { return os.ErrPermission } } else if !errors.Is(err, os.ErrNotExist) { return err } else { tag = "" } _, err = d.Store.Put(ctx, key, wire, tag) return err } func (op Operation) Read(ctx context.Context, d hostactors.Directory, now time.Time) (Receipt, error) { q, g, retire, err := op.checked(d, now) if err != nil { return Receipt{}, err } if g != nil { return ReadReconstruction(ctx, d, q, *g, now) } if retire != nil { return ReadRetirement(ctx, d, q, *retire, now) } return ReadStatus(ctx, d, q, now) }