| 1 | package historyreadback |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "os" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/hostactors" |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | ) |
| 14 | |
| 15 | // Operation retains the existing signed messages. Save it durably before |
| 16 | // Deliver. Resume uses these exact bytes and never extends their lifetime. |
| 17 | type Operation struct { |
| 18 | Request json.RawMessage `json:"request"` |
| 19 | Reconstruction json.RawMessage `json:"reconstruction,omitempty"` |
| 20 | Retirement json.RawMessage `json:"retirement,omitempty"` |
| 21 | } |
| 22 | |
| 23 | func PrepareOperation(ctx context.Context, d hostactors.Directory, signer release.Signer, scope Scope, g *release.HistoryReconstruction, retire *release.HistoryRecoveryRetirement, now time.Time) (Operation, error) { |
| 24 | _, wire, err := prepareStatus(ctx, d, signer, scope, now) |
| 25 | op := Operation{Request: wire} |
| 26 | if err != nil { |
| 27 | return Operation{}, err |
| 28 | } |
| 29 | if g != nil { |
| 30 | op.Reconstruction, err = release.SignRootEnvelope(signer, *g) |
| 31 | } |
| 32 | if err == nil && retire != nil { |
| 33 | op.Retirement, err = release.SignRootEnvelope(signer, *retire) |
| 34 | } |
| 35 | if err == nil { |
| 36 | _, _, _, err = op.checked(d, now) |
| 37 | } |
| 38 | if err != nil { |
| 39 | return Operation{}, err |
| 40 | } |
| 41 | return op, nil |
| 42 | } |
| 43 | |
| 44 | func (op Operation) checked(d hostactors.Directory, now time.Time) (Request, *release.HistoryReconstruction, *release.HistoryRecoveryRetirement, error) { |
| 45 | var q Request |
| 46 | refuse := func() (Request, *release.HistoryReconstruction, *release.HistoryRecoveryRetirement, error) { |
| 47 | return Request{}, nil, nil, os.ErrPermission |
| 48 | } |
| 49 | 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) { |
| 50 | return refuse() |
| 51 | } |
| 52 | fresh := func(at time.Time) bool { |
| 53 | return !at.IsZero() && !at.Before(now.Add(-release.HostRetryGrantTTL)) && !at.After(now.Add(time.Minute)) |
| 54 | } |
| 55 | if len(op.Reconstruction) > 0 { |
| 56 | var g release.HistoryReconstruction |
| 57 | 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)) { |
| 58 | return refuse() |
| 59 | } |
| 60 | seen := map[string]bool{} |
| 61 | for _, h := range g.Hosts { |
| 62 | s := q.Scope |
| 63 | s.Binding.Host = h.Host |
| 64 | if !validScope(s) || h.Source == "" || seen[h.Host] { |
| 65 | return refuse() |
| 66 | } |
| 67 | seen[h.Host] = true |
| 68 | } |
| 69 | if !seen[q.Scope.Binding.Host] { |
| 70 | return refuse() |
| 71 | } |
| 72 | return q, &g, nil, nil |
| 73 | } |
| 74 | if len(op.Retirement) > 0 { |
| 75 | var g release.HistoryRecoveryRetirement |
| 76 | 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) { |
| 77 | return refuse() |
| 78 | } |
| 79 | return q, nil, &g, nil |
| 80 | } |
| 81 | return q, nil, nil, nil |
| 82 | } |
| 83 | |
| 84 | // Deliver accepts an identical publication after a lost response. Replacing |
| 85 | // another reconstruction requires its exact signed payload digest in Replaces. |
| 86 | func (op Operation) Deliver(ctx context.Context, d hostactors.Directory, now time.Time) error { |
| 87 | q, g, retire, err := op.checked(d, now) |
| 88 | if err != nil { |
| 89 | return err |
| 90 | } |
| 91 | before, _, err := enrolled(ctx, d, q.Scope, now) |
| 92 | if err != nil { |
| 93 | return err |
| 94 | } |
| 95 | if g != nil { |
| 96 | err = op.deliverGrant(ctx, d, release.HistoryReconstructionKey(g.Account, g.App, g.Identity, g.Revision), op.Reconstruction, g.Replaces) |
| 97 | } |
| 98 | if retire != nil { |
| 99 | err = op.deliverGrant(ctx, d, release.HistoryRecoveryRetirementKey(retire.Binding, retire.Identity, retire.OutcomeDigest), op.Retirement, "") |
| 100 | } |
| 101 | if err != nil { |
| 102 | return err |
| 103 | } |
| 104 | if err = recheck(ctx, d, q.Scope, before, now); err != nil { |
| 105 | return err |
| 106 | } |
| 107 | return publish(ctx, d.Store, RequestKey(q.Scope), op.Request) |
| 108 | } |
| 109 | |
| 110 | func (op Operation) deliverGrant(ctx context.Context, d hostactors.Directory, key string, wire []byte, replaces string) error { |
| 111 | old, tag, err := d.Store.Get(ctx, key) |
| 112 | if err == nil { |
| 113 | if bytes.Equal(old, wire) { |
| 114 | return nil |
| 115 | } |
| 116 | var previous release.HistoryReconstruction |
| 117 | if tag == "" || replaces == "" || release.VerifyRootEnvelope(d.PublicKey, old, &previous) != nil || digest(previous) != replaces { |
| 118 | return os.ErrPermission |
| 119 | } |
| 120 | } else if !errors.Is(err, os.ErrNotExist) { |
| 121 | return err |
| 122 | } else { |
| 123 | tag = "" |
| 124 | } |
| 125 | _, err = d.Store.Put(ctx, key, wire, tag) |
| 126 | return err |
| 127 | } |
| 128 | |
| 129 | func (op Operation) Read(ctx context.Context, d hostactors.Directory, now time.Time) (Receipt, error) { |
| 130 | q, g, retire, err := op.checked(d, now) |
| 131 | if err != nil { |
| 132 | return Receipt{}, err |
| 133 | } |
| 134 | if g != nil { |
| 135 | return ReadReconstruction(ctx, d, q, *g, now) |
| 136 | } |
| 137 | if retire != nil { |
| 138 | return ReadRetirement(ctx, d, q, *retire, now) |
| 139 | } |
| 140 | return ReadStatus(ctx, d, q, now) |
| 141 | } |
| 142 | |