Files
privatae-bastion/client-go/ops_receipts.go
T
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

300 lines
9.6 KiB
Go

package main
// ops_receipts.go — what an action op's apply produced, signed by this
// machine, and what this machine rolled.
//
// A build step's program reports the images it pushed in its work folder's
// privatae-result.json:
//
// {"images": [{"image": "forge.example.com/acme/web",
// "digest": "sha256:…",
// "source": "https://forge.example.com/acme/app.git",
// "commit": "<40 hex>"}]}
//
// The bastion turns each into a RECEIPT: which machine, which run of which op
// (its sha256 and its params' sha256), the image, digest, source repository
// and commit. It is signed with this machine's own key (owner.go) as a DSSE
// envelope of its own payload type, and held in the machine's record by
// digest. k8s.set_image rolls only a digest a receipt names, of the image the
// receipt names, and only forward: the receipt's commit must descend from the
// commit behind the live image, which this machine knows only from the roll
// that put it there (opRolledCommit). A live image it did not roll is refused,
// and says so, never guessed at.
//
// This machine accepts only its own receipts. Receipts from another machine
// (a build on one, a roll on another) are verified under keys the person's
// device pins, which is the signed ship plan's rung.
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
opReceiptsDirName = "receipts"
opRolledFileName = "rolled.json"
opResultMaxBytes = 64 << 10
opResultMaxImages = 8
)
var opReceiptsMu sync.Mutex
type opReceipt struct {
V int `json:"v"`
Machine string `json:"machine"`
Key string `json:"key"` // this machine's public key, b64
Run string `json:"run"`
Op string `json:"op"`
OpSHA256 string `json:"op_sha256"`
ParamsSHA256 string `json:"params_sha256"`
At string `json:"at"`
Image string `json:"image"`
Digest string `json:"digest"`
Source string `json:"source"`
Commit string `json:"commit"`
}
// opSignedReceipt is a DSSE envelope: the receipt's exact bytes, base64, and
// this machine's signature over DSSE's pre-authentication encoding of them,
// so a verifier never re-serialises what it checks and the type is signed.
type opSignedReceipt struct {
PayloadType string `json:"payloadType"`
Payload string `json:"payload"`
Signature string `json:"signature"`
}
const opReceiptPayloadType = "application/vnd.privatae.receipt.v1+json"
// opReceiptSigningInput is DSSE's PAE: "DSSEv1 <len> <type> <len> <body>".
func opReceiptSigningInput(raw []byte) []byte {
return []byte(fmt.Sprintf("DSSEv1 %d %s %d %s", len(opReceiptPayloadType), opReceiptPayloadType, len(raw), raw))
}
// opResultImage is one image a program says it pushed.
type opResultImage struct {
Image string `json:"image"`
Digest string `json:"digest"`
Source string `json:"source"`
Commit string `json:"commit"`
}
// opParseResult judges a program's result file.
func opParseResult(raw []byte) ([]opResultImage, error) {
var r struct {
Images []opResultImage `json:"images"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return nil, errors.New("the program's result does not parse")
}
if len(r.Images) > opResultMaxImages {
return nil, fmt.Errorf("the program's result names more than %d images", opResultMaxImages)
}
for _, im := range r.Images {
if !opImageRe.MatchString(im.Image) || !opDigestRe.MatchString(im.Digest) || !opCommitRe.MatchString(im.Commit) {
return nil, errors.New("an image in the program's result is not image, sha256 digest and full commit")
}
if err := opRepoURLOK(im.Source); err != nil {
return nil, errors.New("an image's source in the program's result is not a repository URL")
}
}
return r.Images, nil
}
// opIssueReceipts signs one receipt per image and holds each by digest.
func opIssueReceipts(run, op, opSHA, params string, images []opResultImage) ([]opSignedReceipt, error) {
pub, priv, err := loadOrCreateOwnerKey()
if err != nil {
return nil, err
}
hn, _ := os.Hostname()
ps := sha256.Sum256([]byte(params))
var out []opSignedReceipt
for _, im := range images {
r := opReceipt{V: 1, Machine: hn, Key: base64.StdEncoding.EncodeToString(pub), Run: run, Op: op,
OpSHA256: opSHA, ParamsSHA256: hex.EncodeToString(ps[:]), At: time.Now().UTC().Format(time.RFC3339),
Image: im.Image, Digest: im.Digest, Source: opCleanRepoURL(im.Source), Commit: im.Commit}
raw, _ := json.Marshal(r)
s := opSignedReceipt{PayloadType: opReceiptPayloadType, Payload: base64.StdEncoding.EncodeToString(raw),
Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, opReceiptSigningInput(raw)))}
if err := opHoldReceipt(s); err != nil {
return out, err
}
out = append(out, s)
}
return out, nil
}
// opVerifyReceipt checks a receipt: signed by THIS machine's key, well formed.
func opVerifyReceipt(s opSignedReceipt) (*opReceipt, error) {
raw, err := base64.StdEncoding.DecodeString(s.Payload)
if err != nil || s.PayloadType != opReceiptPayloadType {
return nil, errors.New("a receipt is not a receipt envelope")
}
var r opReceipt
if err := json.Unmarshal(raw, &r); err != nil || r.V != 1 {
return nil, errors.New("a receipt does not parse")
}
pub, _, err := loadOrCreateOwnerKey()
if err != nil {
return nil, err
}
if r.Key != base64.StdEncoding.EncodeToString(pub) {
return nil, errors.New("a receipt signed by another machine's key (receipts from other machines come with the signed ship plan)")
}
sig, err := base64.StdEncoding.DecodeString(s.Signature)
if err != nil || !ed25519.Verify(pub, opReceiptSigningInput(raw), sig) {
return nil, errors.New("a receipt's signature does not verify")
}
if !opDigestRe.MatchString(r.Digest) || !opImageRe.MatchString(r.Image) || !opCommitRe.MatchString(r.Commit) {
return nil, errors.New("a receipt names no image, digest and commit")
}
return &r, nil
}
func opReceiptsDir() (string, error) {
d, err := configDir()
if err != nil {
return "", err
}
p := filepath.Join(d, opReceiptsDirName)
return p, os.MkdirAll(p, 0o700)
}
func opReceiptPath(digest string) (string, error) {
if !opDigestRe.MatchString(digest) {
return "", errors.New("not a sha256 digest")
}
d, err := opReceiptsDir()
if err != nil {
return "", err
}
return filepath.Join(d, strings.TrimPrefix(digest, "sha256:")+".json"), nil
}
func opHoldReceipt(s opSignedReceipt) error {
r, err := opVerifyReceipt(s)
if err != nil {
return err
}
p, err := opReceiptPath(r.Digest)
if err != nil {
return err
}
opReceiptsMu.Lock()
defer opReceiptsMu.Unlock()
// The first receipt for a digest stands: a later run naming the same
// digest with another image, source or commit is refused, never allowed
// to rewrite what the digest was built from.
if prevRaw, err := os.ReadFile(p); err == nil {
var prev opSignedReceipt
if json.Unmarshal(prevRaw, &prev) == nil {
if pr, err := opVerifyReceipt(prev); err == nil {
if pr.Image != r.Image || pr.Source != r.Source || pr.Commit != r.Commit {
return fmt.Errorf("this machine already holds a receipt for %s built from %s, not %s", truncate(r.Digest, 19), truncate(pr.Commit, 12), truncate(r.Commit, 12))
}
return nil
}
}
}
raw, _ := json.MarshalIndent(s, "", " ")
return atomicWrite(p, raw)
}
// opReceiptFor is the verified receipt this machine holds for a digest, or nil.
func opReceiptFor(digest string) (*opReceipt, error) {
p, err := opReceiptPath(digest)
if err != nil {
return nil, err
}
opReceiptsMu.Lock()
raw, err := os.ReadFile(p)
opReceiptsMu.Unlock()
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var s opSignedReceipt
if err := json.Unmarshal(raw, &s); err != nil {
return nil, errors.New("a held receipt does not parse")
}
r, err := opVerifyReceipt(s)
if err != nil {
return nil, err
}
if r.Digest != digest {
return nil, errors.New("a held receipt names another digest")
}
return r, nil
}
// ── what this machine rolled ────────────────────────────────────────────────
// opRollRec is the commit behind a digest this machine rolled, from the
// receipt it rolled it under.
type opRollRec struct {
Image string `json:"image"`
Source string `json:"source"`
Commit string `json:"commit"`
Workload string `json:"workload"`
At string `json:"at"`
}
func opRolledPath() (string, error) {
d, err := configDir()
if err != nil {
return "", err
}
return filepath.Join(d, opRolledFileName), nil
}
func opRolledLoad() map[string]opRollRec {
out := map[string]opRollRec{}
p, err := opRolledPath()
if err != nil {
return out
}
if raw, err := os.ReadFile(p); err == nil {
_ = json.Unmarshal(raw, &out)
}
return out
}
func opRecordRoll(digest string, r opRollRec) error {
opReceiptsMu.Lock()
defer opReceiptsMu.Unlock()
m := opRolledLoad()
m[digest] = r
p, err := opRolledPath()
if err != nil {
return err
}
raw, _ := json.MarshalIndent(m, "", " ")
return atomicWrite(p, raw)
}
// opRolledCommit is the commit behind a digest this machine itself rolled.
// Only a roll counts, never a receipt alone: a program could report the live
// digest as something it built, and give an image this machine never rolled
// a history it does not have. ok=false: nothing.
func opRolledCommit(digest string) (image, source, commit string, ok bool) {
opReceiptsMu.Lock()
r, held := opRolledLoad()[digest]
opReceiptsMu.Unlock()
if held && opCommitRe.MatchString(r.Commit) {
return r.Image, r.Source, r.Commit, true
}
return "", "", "", false
}