//go:build linux package provisioner import ( "bytes" "crypto/elliptic" "crypto/sha256" "encoding/base64" "fmt" "io" "os" "sort" family "amadan.net/rastrillo/rastrillo/crypto" "github.com/go-json-experiment/json" "github.com/go-json-experiment/json/jsontext" "golang.org/x/sys/unix" ) const floorContext = "oficina/provisioner/floor/v1" const maxRecords = 10000 type floorIdentity struct { Registry string `json:"registry"` Device string `json:"device"` Public string `json:"public_key"` } type floorPoint struct { Sequence int64 `json:"sequence"` Head string `json:"head"` } type floorRecord struct { Version int `json:"version"` Identity floorIdentity `json:"identity"` Point floorPoint `json:"point"` Previous string `json:"previous"` } type floorEnvelope struct { Record floorRecord `json:"record"` Signature string `json:"signature"` } type nativeFloor struct { dir *privateDirectory identity floorIdentity public []byte markerBoundary func() error } func tokenBytes(s string, n int) ([]byte, bool) { b, e := base64.RawURLEncoding.DecodeString(s) return b, e == nil && len(b) == n && base64.RawURLEncoding.EncodeToString(b) == s } func canonicalJSON(v any) ([]byte, error) { b, e := json.Marshal(v) if e != nil { return nil, ErrRefused } j := jsontext.Value(b) if j.Canonicalize(jsontext.CanonicalizeRawInts(false)) != nil { return nil, ErrRefused } return j, nil } func closedJSON(b []byte, v any) error { if json.Unmarshal(b, v, json.RejectUnknownMembers(true)) != nil { return ErrRefused } canonical, e := canonicalJSON(v) if e != nil || !bytes.Equal(canonical, b) { return ErrRefused } return nil } func hash(b []byte) string { h := sha256.Sum256(b); return base64.RawURLEncoding.EncodeToString(h[:]) } func floorName(sequence int64) string { return fmt.Sprintf("%020d.json", sequence) } func newNativeFloor(dir *privateDirectory, id floorIdentity) (*nativeFloor, error) { _, a := tokenBytes(id.Registry, 32) _, b := tokenBytes(id.Device, 32) pub, c := tokenBytes(id.Public, 65) x, y := elliptic.Unmarshal(elliptic.P256(), pub) if dir == nil || !a || !b || !c || x == nil || y == nil { return nil, ErrRefused } return &nativeFloor{dir: dir, identity: id, public: pub}, nil } func (f *nativeFloor) signed(k *family.Keypair, p floorPoint, previous string) ([]byte, error) { if k == nil || k.SignPriv == nil || !bytes.Equal(k.SignPub(), f.public) { return nil, ErrRefused } r := floorRecord{1, f.identity, p, previous} body, e := canonicalJSON(r) if e != nil { return nil, e } sig, e := family.Sign(k, floorContext, body) if e != nil { return nil, ErrUnavailable } return canonicalJSON(floorEnvelope{r, base64.RawURLEncoding.EncodeToString(sig)}) } func (f *nativeFloor) initialize(k *family.Keypair) error { files, e := directoryNames(f.dir) if e != nil { return e } for _, name := range files { if name != "lock" { return ErrRefused } } raw, e := f.signed(k, floorPoint{}, "") if e != nil { return e } if e = f.dir.create(floorName(0), raw); e != nil { return e } return f.updateMarker(floorPoint{}) } func (f *nativeFloor) names() ([]string, error) { entries, e := directoryNames(f.dir) if e != nil { return nil, e } names := make([]string, 0, len(entries)) for _, name := range entries { if name == "lock" || name == "head" || name == "head.tmp" { continue } if len(name) != 25 || name[20:] != ".json" { return nil, ErrRefused } names = append(names, name) } sort.Strings(names) return names, nil } func (f *nativeFloor) current() (floorPoint, string, error) { names, e := f.names() if e != nil { return floorPoint{}, "", e } if len(names) == 0 || len(names) > maxRecords+1 { return floorPoint{}, "", ErrRefused } var last floorPoint var previous string for i, name := range names { if name != floorName(int64(i)) { return floorPoint{}, "", ErrRefused } raw, e := f.dir.read(name, 8192) if e != nil { return floorPoint{}, "", e } var env floorEnvelope if closedJSON(raw, &env) != nil || env.Record.Version != 1 || env.Record.Identity != f.identity || env.Record.Point.Sequence != int64(i) || env.Record.Previous != previous { return floorPoint{}, "", ErrRefused } r := env.Record if i == 0 { if r.Point.Head != "" { return floorPoint{}, "", ErrRefused } } else { if _, ok := tokenBytes(r.Point.Head, 32); !ok { return floorPoint{}, "", ErrRefused } } sig, ok := tokenBytes(env.Signature, 64) body, e := canonicalJSON(r) if !ok || e != nil || !family.Verify(f.public, floorContext, body, sig) { return floorPoint{}, "", ErrRefused } previous = hash(raw) last = r.Point } return last, previous, nil } // advance requires the caller to hold the device process lock. It checks every // immutable floor record again, then exclusively creates the adjacent record. func (f *nativeFloor) advance(k *family.Keypair, expected, next floorPoint) error { current, previous, e := f.current() if e != nil { return e } if current != expected || next.Sequence != expected.Sequence+1 || next.Sequence > maxRecords { return ErrRefused } if _, ok := tokenBytes(next.Head, 32); !ok { return ErrRefused } raw, e := f.signed(k, next, previous) if e != nil { return e } if e = f.dir.create(floorName(next.Sequence), raw); e != nil { return e } return f.updateMarker(next) } // The marker is an optimization only. Every open scans signed immutable records, // including a durable newer record written before a marker-update crash. func (f *nativeFloor) updateMarker(point floorPoint) error { raw, e := canonicalJSON(point) if e != nil { return e } if e = f.dir.create("head.tmp", raw); e != nil { existing, readErr := f.dir.read("head.tmp", 8192) if readErr != nil { return ErrRefused } if !bytes.Equal(existing, raw) { // Only the signed immutable chain carries authority. This private, // regular scratch file can survive a marker rename crash; unlinking // its directory entry never opens or truncates a replacement target. if unix.Unlinkat(int(f.dir.file.Fd()), "head.tmp", 0) != nil || f.dir.file.Sync() != nil { return ErrUnavailable } if e = f.dir.create("head.tmp", raw); e != nil { return e } } } if f.markerBoundary != nil { if e := f.markerBoundary(); e != nil { return e } } if unix.Renameat(int(f.dir.file.Fd()), "head.tmp", int(f.dir.file.Fd()), "head") != nil || f.dir.file.Sync() != nil { return ErrUnavailable } return nil } func directoryNames(dir *privateDirectory) ([]string, error) { // Open a fresh directory description: a retained Readdir offset must never // hide files appended since a previous verification. fd, e := openPrivateDirectory(dir.file.Name()) if e != nil { return nil, e } defer fd.close() oldInfo, e := dir.file.Stat() if e != nil { return nil, ErrUnavailable } newInfo, e := fd.file.Stat() if e != nil || !os.SameFile(oldInfo, newInfo) { return nil, ErrRefused } entries, e := fd.file.Readdirnames(maxRecords + 5) if e != nil && e != io.EOF { return nil, ErrUnavailable } if len(entries) > maxRecords+4 { return nil, ErrRefused } return entries, nil }