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 "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.
17type Operation struct {
18 Request json.RawMessage `json:"request"`
19 Reconstruction json.RawMessage `json:"reconstruction,omitempty"`
20 Retirement json.RawMessage `json:"retirement,omitempty"`
21}
22
23func 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
44func (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.
86func (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
110func (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
129func (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