| 1 | //go:build linux |
| 2 | |
| 3 | package provisioner |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "crypto/elliptic" |
| 8 | "crypto/sha256" |
| 9 | "encoding/base64" |
| 10 | "fmt" |
| 11 | "io" |
| 12 | "os" |
| 13 | "sort" |
| 14 | |
| 15 | family "amadan.net/rastrillo/rastrillo/crypto" |
| 16 | "github.com/go-json-experiment/json" |
| 17 | "github.com/go-json-experiment/json/jsontext" |
| 18 | "golang.org/x/sys/unix" |
| 19 | ) |
| 20 | |
| 21 | const floorContext = "oficina/provisioner/floor/v1" |
| 22 | const maxRecords = 10000 |
| 23 | |
| 24 | type floorIdentity struct { |
| 25 | Registry string `json:"registry"` |
| 26 | Device string `json:"device"` |
| 27 | Public string `json:"public_key"` |
| 28 | } |
| 29 | type floorPoint struct { |
| 30 | Sequence int64 `json:"sequence"` |
| 31 | Head string `json:"head"` |
| 32 | } |
| 33 | type floorRecord struct { |
| 34 | Version int `json:"version"` |
| 35 | Identity floorIdentity `json:"identity"` |
| 36 | Point floorPoint `json:"point"` |
| 37 | Previous string `json:"previous"` |
| 38 | } |
| 39 | type floorEnvelope struct { |
| 40 | Record floorRecord `json:"record"` |
| 41 | Signature string `json:"signature"` |
| 42 | } |
| 43 | type nativeFloor struct { |
| 44 | dir *privateDirectory |
| 45 | identity floorIdentity |
| 46 | public []byte |
| 47 | markerBoundary func() error |
| 48 | } |
| 49 | |
| 50 | func tokenBytes(s string, n int) ([]byte, bool) { |
| 51 | b, e := base64.RawURLEncoding.DecodeString(s) |
| 52 | return b, e == nil && len(b) == n && base64.RawURLEncoding.EncodeToString(b) == s |
| 53 | } |
| 54 | func canonicalJSON(v any) ([]byte, error) { |
| 55 | b, e := json.Marshal(v) |
| 56 | if e != nil { |
| 57 | return nil, ErrRefused |
| 58 | } |
| 59 | j := jsontext.Value(b) |
| 60 | if j.Canonicalize(jsontext.CanonicalizeRawInts(false)) != nil { |
| 61 | return nil, ErrRefused |
| 62 | } |
| 63 | return j, nil |
| 64 | } |
| 65 | func closedJSON(b []byte, v any) error { |
| 66 | if json.Unmarshal(b, v, json.RejectUnknownMembers(true)) != nil { |
| 67 | return ErrRefused |
| 68 | } |
| 69 | canonical, e := canonicalJSON(v) |
| 70 | if e != nil || !bytes.Equal(canonical, b) { |
| 71 | return ErrRefused |
| 72 | } |
| 73 | return nil |
| 74 | } |
| 75 | func hash(b []byte) string { h := sha256.Sum256(b); return base64.RawURLEncoding.EncodeToString(h[:]) } |
| 76 | func floorName(sequence int64) string { return fmt.Sprintf("%020d.json", sequence) } |
| 77 | func newNativeFloor(dir *privateDirectory, id floorIdentity) (*nativeFloor, error) { |
| 78 | _, a := tokenBytes(id.Registry, 32) |
| 79 | _, b := tokenBytes(id.Device, 32) |
| 80 | pub, c := tokenBytes(id.Public, 65) |
| 81 | x, y := elliptic.Unmarshal(elliptic.P256(), pub) |
| 82 | if dir == nil || !a || !b || !c || x == nil || y == nil { |
| 83 | return nil, ErrRefused |
| 84 | } |
| 85 | return &nativeFloor{dir: dir, identity: id, public: pub}, nil |
| 86 | } |
| 87 | func (f *nativeFloor) signed(k *family.Keypair, p floorPoint, previous string) ([]byte, error) { |
| 88 | if k == nil || k.SignPriv == nil || !bytes.Equal(k.SignPub(), f.public) { |
| 89 | return nil, ErrRefused |
| 90 | } |
| 91 | r := floorRecord{1, f.identity, p, previous} |
| 92 | body, e := canonicalJSON(r) |
| 93 | if e != nil { |
| 94 | return nil, e |
| 95 | } |
| 96 | sig, e := family.Sign(k, floorContext, body) |
| 97 | if e != nil { |
| 98 | return nil, ErrUnavailable |
| 99 | } |
| 100 | return canonicalJSON(floorEnvelope{r, base64.RawURLEncoding.EncodeToString(sig)}) |
| 101 | } |
| 102 | func (f *nativeFloor) initialize(k *family.Keypair) error { |
| 103 | files, e := directoryNames(f.dir) |
| 104 | if e != nil { |
| 105 | return e |
| 106 | } |
| 107 | for _, name := range files { |
| 108 | if name != "lock" { |
| 109 | return ErrRefused |
| 110 | } |
| 111 | } |
| 112 | raw, e := f.signed(k, floorPoint{}, "") |
| 113 | if e != nil { |
| 114 | return e |
| 115 | } |
| 116 | if e = f.dir.create(floorName(0), raw); e != nil { |
| 117 | return e |
| 118 | } |
| 119 | return f.updateMarker(floorPoint{}) |
| 120 | } |
| 121 | func (f *nativeFloor) names() ([]string, error) { |
| 122 | entries, e := directoryNames(f.dir) |
| 123 | if e != nil { |
| 124 | return nil, e |
| 125 | } |
| 126 | names := make([]string, 0, len(entries)) |
| 127 | for _, name := range entries { |
| 128 | if name == "lock" || name == "head" || name == "head.tmp" { |
| 129 | continue |
| 130 | } |
| 131 | if len(name) != 25 || name[20:] != ".json" { |
| 132 | return nil, ErrRefused |
| 133 | } |
| 134 | names = append(names, name) |
| 135 | } |
| 136 | sort.Strings(names) |
| 137 | return names, nil |
| 138 | } |
| 139 | func (f *nativeFloor) current() (floorPoint, string, error) { |
| 140 | names, e := f.names() |
| 141 | if e != nil { |
| 142 | return floorPoint{}, "", e |
| 143 | } |
| 144 | if len(names) == 0 || len(names) > maxRecords+1 { |
| 145 | return floorPoint{}, "", ErrRefused |
| 146 | } |
| 147 | var last floorPoint |
| 148 | var previous string |
| 149 | for i, name := range names { |
| 150 | if name != floorName(int64(i)) { |
| 151 | return floorPoint{}, "", ErrRefused |
| 152 | } |
| 153 | raw, e := f.dir.read(name, 8192) |
| 154 | if e != nil { |
| 155 | return floorPoint{}, "", e |
| 156 | } |
| 157 | var env floorEnvelope |
| 158 | if closedJSON(raw, &env) != nil || env.Record.Version != 1 || env.Record.Identity != f.identity || env.Record.Point.Sequence != int64(i) || env.Record.Previous != previous { |
| 159 | return floorPoint{}, "", ErrRefused |
| 160 | } |
| 161 | r := env.Record |
| 162 | if i == 0 { |
| 163 | if r.Point.Head != "" { |
| 164 | return floorPoint{}, "", ErrRefused |
| 165 | } |
| 166 | } else { |
| 167 | if _, ok := tokenBytes(r.Point.Head, 32); !ok { |
| 168 | return floorPoint{}, "", ErrRefused |
| 169 | } |
| 170 | } |
| 171 | sig, ok := tokenBytes(env.Signature, 64) |
| 172 | body, e := canonicalJSON(r) |
| 173 | if !ok || e != nil || !family.Verify(f.public, floorContext, body, sig) { |
| 174 | return floorPoint{}, "", ErrRefused |
| 175 | } |
| 176 | previous = hash(raw) |
| 177 | last = r.Point |
| 178 | } |
| 179 | return last, previous, nil |
| 180 | } |
| 181 | |
| 182 | // advance requires the caller to hold the device process lock. It checks every |
| 183 | // immutable floor record again, then exclusively creates the adjacent record. |
| 184 | func (f *nativeFloor) advance(k *family.Keypair, expected, next floorPoint) error { |
| 185 | current, previous, e := f.current() |
| 186 | if e != nil { |
| 187 | return e |
| 188 | } |
| 189 | if current != expected || next.Sequence != expected.Sequence+1 || next.Sequence > maxRecords { |
| 190 | return ErrRefused |
| 191 | } |
| 192 | if _, ok := tokenBytes(next.Head, 32); !ok { |
| 193 | return ErrRefused |
| 194 | } |
| 195 | raw, e := f.signed(k, next, previous) |
| 196 | if e != nil { |
| 197 | return e |
| 198 | } |
| 199 | if e = f.dir.create(floorName(next.Sequence), raw); e != nil { |
| 200 | return e |
| 201 | } |
| 202 | return f.updateMarker(next) |
| 203 | } |
| 204 | |
| 205 | // The marker is an optimization only. Every open scans signed immutable records, |
| 206 | // including a durable newer record written before a marker-update crash. |
| 207 | func (f *nativeFloor) updateMarker(point floorPoint) error { |
| 208 | raw, e := canonicalJSON(point) |
| 209 | if e != nil { |
| 210 | return e |
| 211 | } |
| 212 | if e = f.dir.create("head.tmp", raw); e != nil { |
| 213 | existing, readErr := f.dir.read("head.tmp", 8192) |
| 214 | if readErr != nil { |
| 215 | return ErrRefused |
| 216 | } |
| 217 | if !bytes.Equal(existing, raw) { |
| 218 | // Only the signed immutable chain carries authority. This private, |
| 219 | // regular scratch file can survive a marker rename crash; unlinking |
| 220 | // its directory entry never opens or truncates a replacement target. |
| 221 | if unix.Unlinkat(int(f.dir.file.Fd()), "head.tmp", 0) != nil || f.dir.file.Sync() != nil { |
| 222 | return ErrUnavailable |
| 223 | } |
| 224 | if e = f.dir.create("head.tmp", raw); e != nil { |
| 225 | return e |
| 226 | } |
| 227 | } |
| 228 | } |
| 229 | if f.markerBoundary != nil { |
| 230 | if e := f.markerBoundary(); e != nil { |
| 231 | return e |
| 232 | } |
| 233 | } |
| 234 | if unix.Renameat(int(f.dir.file.Fd()), "head.tmp", int(f.dir.file.Fd()), "head") != nil || f.dir.file.Sync() != nil { |
| 235 | return ErrUnavailable |
| 236 | } |
| 237 | return nil |
| 238 | } |
| 239 | |
| 240 | func directoryNames(dir *privateDirectory) ([]string, error) { |
| 241 | // Open a fresh directory description: a retained Readdir offset must never |
| 242 | // hide files appended since a previous verification. |
| 243 | fd, e := openPrivateDirectory(dir.file.Name()) |
| 244 | if e != nil { |
| 245 | return nil, e |
| 246 | } |
| 247 | defer fd.close() |
| 248 | oldInfo, e := dir.file.Stat() |
| 249 | if e != nil { |
| 250 | return nil, ErrUnavailable |
| 251 | } |
| 252 | newInfo, e := fd.file.Stat() |
| 253 | if e != nil || !os.SameFile(oldInfo, newInfo) { |
| 254 | return nil, ErrRefused |
| 255 | } |
| 256 | entries, e := fd.file.Readdirnames(maxRecords + 5) |
| 257 | if e != nil && e != io.EOF { |
| 258 | return nil, ErrUnavailable |
| 259 | } |
| 260 | if len(entries) > maxRecords+4 { |
| 261 | return nil, ErrRefused |
| 262 | } |
| 263 | return entries, nil |
| 264 | } |
| 265 | |