547 lines
23 KiB
Go
547 lines
23 KiB
Go
package main
|
|
|
|
// baselinesign.go — the accounting is SIGNED, and by a key the host already
|
|
// trusts: a signed baseline, kept on the box and mirrored to the anchor host.
|
|
//
|
|
// WHAT WAS THERE BEFORE. baseline.go's self-digest catches accident and casual
|
|
// edit and says so plainly: "it does NOT establish who took the measurement".
|
|
// The gap baseline.go used to name as "not signed" is closed here.
|
|
//
|
|
// ── WHICH KEY, AND WHY NO NEW ONE ───────────────────────────────────────────
|
|
//
|
|
// The obvious build mints a fresh signing keypair at claim-anchor and teaches
|
|
// the host to pin it. It was proposed and approved in that shape, and then
|
|
// found to be unnecessary: THE BOX ALREADY HOLDS A HOST-BOUND SIGNING KEY. At
|
|
// pair time genOperatorCSR (pair.go:85-96) generates a P-256 key that never
|
|
// leaves this machine, and the operator certificate the bridge issues for it
|
|
// carries this device's fingerprint as a URI SAN — which the host bound at
|
|
// redeem, from that device-signed CSR, under a standing invariant that the
|
|
// heartbeat is verify-not-bind and the redeem CSR is the only binder. The
|
|
// host also holds the CA that issued the certificate.
|
|
//
|
|
// So the accounting is signed with the ANCHOR pairing's operator key, and the
|
|
// host verifies it with three checks it can already make: the certificate
|
|
// chains to its CA, the certificate's fingerprint SAN is the fingerprint bound
|
|
// to this bastion's row, and the signature verifies under the certificate's
|
|
// key. No new key material on customer machines, no new pinning step, no new
|
|
// rotation path — certificate rotation already exists and this rides it.
|
|
//
|
|
// On a one-host box there is no anchor, and the only active pairing's key is
|
|
// the machine's voice. On a multi-host box with no anchor there is no single
|
|
// key that speaks for the machine, and the accounting is written UNSIGNED with
|
|
// the reason recorded in the file rather than guessed at by a reader.
|
|
//
|
|
// ── WHAT IS SIGNED ──────────────────────────────────────────────────────────
|
|
//
|
|
// Not the file bytes, and not Go's struct-ordered JSON: the host is Python, and
|
|
// reproducing encoding/json's field order and escaping there is exactly the
|
|
// kind of contract that drifts. Both sides canonicalise the same way instead:
|
|
//
|
|
// parse the document to a map; drop the two derived keys (`digest`,
|
|
// `attestation`); sort every key; no whitespace; NO HTML ESCAPING; NON-ASCII
|
|
// WRITTEN RAW (Python: ensure_ascii=False — its default escapes every
|
|
// non-ASCII character and produced a 19-byte difference on `ré` in review);
|
|
// the host then re-escapes a raw U+2028/U+2029 to `\u2028`/`\u2029`, because
|
|
// encoding/json escapes those two regardless. Numbers that the two encoders
|
|
// print differently are REFUSED on both sides (baselineCanonical).
|
|
//
|
|
// SHA-256 of that, ECDSA P-256, ASN.1/DER, base64. The host-side verifier
|
|
// lives in the platform's network service and its proof verifies the
|
|
// document TestWriteCrossLanguageFixture writes — one signed document, two
|
|
// languages.
|
|
// The Go side pins the bytes with a golden vector (baselinesign_test.go) so a
|
|
// change to this form cannot pass while breaking every host silently.
|
|
//
|
|
// ── WHAT THIS PROVES, PRECISELY ─────────────────────────────────────────────
|
|
//
|
|
// CONTINUITY, NOT HARDWARE ATTESTATION. The fingerprint in the certificate's
|
|
// SAN is the value THIS BOX computed and put into its own CSR at redeem; the
|
|
// host pinned the first one a device-signed CSR carried. So a verified
|
|
// attestation proves "the pairing that redeemed as X signed this" — exactly as
|
|
// strong as that pairing, and no stronger.
|
|
//
|
|
// NOT A DEFENCE AGAINST ROOT ON THE BOX. The signing key is a 0600 file, so a
|
|
// root intruder can sign anything, and nothing in the signed content is a
|
|
// freshness token — a validly signed accounting from last month restores
|
|
// cleanly. That is inside the accepted model (deltastate.go:25-30: a root
|
|
// intruder can also stop the daemon). What closes the rollback and stale-mirror
|
|
// cases is the BEAT: it carries `baseline_taken_at`, and the host compares it
|
|
// with the `taken_at` of the mirror it verified — a mismatch is a finding.
|
|
//
|
|
// REUSING THE mTLS KEY IS SAFE, and here is why rather than only that it is:
|
|
// TLS 1.3's CertificateVerify signs sixty-four spaces, a context string and a
|
|
// transcript hash; this signs SHA-256 of a JSON object that always begins with
|
|
// `{`. Neither can be coerced into the other's shape, so the two uses are
|
|
// structurally domain-separated, and Go's hedged ECDSA nonce removes the
|
|
// classic key-reuse hazard. "No new key material" is the operational reason;
|
|
// this is the cryptographic one.
|
|
//
|
|
// The self-digest stays exactly as it was. It is the box's own quick integrity
|
|
// check and the key delta-state.json is filed under; the attestation is the
|
|
// host's proof of provenance. Two questions, two answers.
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/ecdsa"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// baselineSigAlg names the scheme in the file, so a verifier that meets a
|
|
// different one refuses rather than guessing.
|
|
const baselineSigAlg = "ecdsa-p256-sha256"
|
|
|
|
// baselineAttestation is the provenance block on the accounting.
|
|
type baselineAttestation struct {
|
|
Alg string `json:"alg"`
|
|
// SignedBy is the host whose pairing key signed — the anchor on an anchored
|
|
// box, the only host on a one-host box. Named so a reader knows which
|
|
// certificate to expect without parsing one.
|
|
SignedBy string `json:"signed_by"`
|
|
// SignerCert is the operator certificate, PEM, followed by the intermediate
|
|
// that issued it (the pairing's operator_cert as the bridge returned it).
|
|
// Carried IN the file because
|
|
// the host does not store the certificates it issues; it stores the CA and
|
|
// the bound fingerprint, which is all it needs to judge this one.
|
|
SignerCert string `json:"signer_cert"`
|
|
// Signature is base64 of the DER-encoded ECDSA signature over
|
|
// SHA-256(baselineCanonical(file)).
|
|
Signature string `json:"signature"`
|
|
}
|
|
|
|
// baselineUnsignedReason is recorded on the file when no key could sign, so
|
|
// "unsigned" is never ambiguous between "this build cannot sign" and "this
|
|
// machine had no key that speaks for it".
|
|
const (
|
|
baselineUnsignedNoPairing = "no active pairing holds a key to sign with"
|
|
baselineUnsignedNoAnchor = "several hosts are paired and none is the anchor, so no one key speaks for this machine"
|
|
baselineUnsignedNoKey = "the pairing that speaks for this machine holds no operator key (an older pairing?)"
|
|
baselineUnsignedAnchorGone = "the anchor host's pairing is not active on this machine"
|
|
)
|
|
|
|
// baselineCanonical is the byte string both sides sign and verify.
|
|
//
|
|
// SEVEN places encoding/json and Python's json.dumps differ on this data,
|
|
// measured by the reviewer against this very function over 33 inputs;
|
|
// everything else (HTML characters, quotes, backslashes, control characters,
|
|
// non-BMP, nested ordering) agrees exactly. Each is closed, and how:
|
|
//
|
|
// 1. U+2028 / U+2029: Go escapes them, Python does not. The HOST re-escapes.
|
|
// 2. Integers beyond 2^53 (float64 round-trip). REFUSED, both sides.
|
|
// 3. Floats with an integral value (1 vs 1.0). REFUSED, both sides.
|
|
// 4. Small exponents (1e-7 vs 1e-07). REFUSED, both sides.
|
|
// 5. `-0`: Python's parser makes it 0, this decoder keeps the literal. REFUSED
|
|
// here — the only side that REFUSES it; the host prints 0 — so it is never
|
|
// signed, and a hand-edited `-0` lands as bad_signature there.
|
|
// 6. A number inside `digest`/`attestation`: this side used to check before
|
|
// dropping the keys, the host after. Dropped first now — ALIGNED.
|
|
// 7. A lone surrogate escape: this decoder folds it to U+FFFD; the host used
|
|
// to refuse. The host now folds the same way — same bytes.
|
|
//
|
|
// The accounting has exactly one numeric field, Unreadable, a small count — so
|
|
// 2-4 are unreachable today and the refusal exists so that adding a float or a
|
|
// nanosecond timestamp later fails LOUDLY here rather than as a false
|
|
// "tampered" verdict on every host. A refusal is an error from signing, never
|
|
// an unsigned file.
|
|
func baselineCanonical(f baselineFile) ([]byte, error) {
|
|
f.Digest = ""
|
|
f.Attestation = nil
|
|
structured, err := json.Marshal(f)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return baselineCanonicalBytes(structured)
|
|
}
|
|
|
|
// baselineCanonicalBytes canonicalises a document from its RAW JSON, the way
|
|
// the host does. The box verifies through this too — through baselineFile it
|
|
// verified through a LOSSY parse, so a field written by a newer bastion was
|
|
// dropped before canonicalisation and an older binary reported "its content
|
|
// has changed", the loudest possible message, for a version skew the host
|
|
// verified fine.
|
|
func baselineCanonicalBytes(raw []byte) ([]byte, error) {
|
|
// UseNumber keeps every number as its source text so the check below sees
|
|
// what was written, not what float64 made of it.
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.UseNumber()
|
|
var m map[string]any
|
|
if err := dec.Decode(&m); err != nil {
|
|
return nil, err
|
|
}
|
|
// Drop the derived keys FIRST, then check numbers — the same order the
|
|
// host uses. Checking first refused a document (a number inside
|
|
// `attestation`) the host would have verified: an asymmetry in a function
|
|
// pair whose whole contract is byte-identity.
|
|
delete(m, "digest")
|
|
delete(m, "attestation")
|
|
if err := baselineCheckNumbers(m, "$"); err != nil {
|
|
return nil, err
|
|
}
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetEscapeHTML(false)
|
|
if err := enc.Encode(m); err != nil {
|
|
return nil, err
|
|
}
|
|
// Encode appends a newline the Python side does not produce.
|
|
return bytes.TrimRight(buf.Bytes(), "\n"), nil
|
|
}
|
|
|
|
// baselineIntExact is the largest integer both encoders print identically.
|
|
const baselineIntExact = 1 << 53
|
|
|
|
// baselineCheckNumbers refuses any number the two encoders would not agree on:
|
|
// anything that is not a plain integer, and any integer beyond 2^53.
|
|
func baselineCheckNumbers(v any, path string) error {
|
|
switch x := v.(type) {
|
|
case json.Number:
|
|
s := x.String()
|
|
if strings.ContainsAny(s, ".eE") {
|
|
return fmt.Errorf("%s is not an integer (%s); the accounting may hold only "+
|
|
"strings, small integers, booleans, lists and objects", path, s)
|
|
}
|
|
// `-0` is the one form NEITHER side otherwise catches: Python's parser
|
|
// turns it into 0 and prints 0, this decoder keeps the literal and
|
|
// prints -0, and the result is a false "tampered" verdict. This is the
|
|
// only side that refuses it, and the only side that signs, so a signed
|
|
// document can never carry one.
|
|
//
|
|
// (The `.eE` guard above is belt-and-braces: Int64() below refuses every
|
|
// such literal anyway. It exists for the error message, not the verdict.)
|
|
if s == "-0" || strings.HasPrefix(s, "-0") {
|
|
return fmt.Errorf("%s = %s is a negative zero, which the two encoders print differently", path, s)
|
|
}
|
|
n, err := x.Int64()
|
|
if err != nil || n > baselineIntExact || n < -baselineIntExact {
|
|
return fmt.Errorf("%s = %s exceeds 2^53; the two encoders would not agree on it", path, s)
|
|
}
|
|
case map[string]any:
|
|
for k, y := range x {
|
|
if err := baselineCheckNumbers(y, path+"."+k); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case []any:
|
|
for i, y := range x {
|
|
if err := baselineCheckNumbers(y, fmt.Sprintf("%s[%d]", path, i)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// baselineSignerPairing picks the pairing whose key speaks for this machine.
|
|
// The second return is the reason none does, or "".
|
|
func baselineSignerPairing(cfgs []*Config) (*Config, string) {
|
|
// ONE filter, applied on BOTH branches. The anchored branch used to resolve
|
|
// the anchor by name alone (pairingFor matches host name and handle, never
|
|
// state), so a box whose anchor pairing was pending or revoked signed with
|
|
// it while the unanchored branch four lines down was filtering exactly that.
|
|
isActive := func(c *Config) bool { return c.State == "" || c.State == pairingActive }
|
|
if a := currentAnchor(); a != nil {
|
|
c := pairingFor(cfgs, a.AnchorHost)
|
|
if c == nil || !isActive(c) {
|
|
return nil, baselineUnsignedAnchorGone
|
|
}
|
|
if strings.TrimSpace(c.OperatorKey) == "" {
|
|
return nil, baselineUnsignedNoKey
|
|
}
|
|
return c, ""
|
|
}
|
|
var active []*Config
|
|
for _, c := range cfgs {
|
|
if isActive(c) {
|
|
active = append(active, c)
|
|
}
|
|
}
|
|
switch len(active) {
|
|
case 0:
|
|
return nil, baselineUnsignedNoPairing
|
|
case 1:
|
|
// A pairing with NO key is a reason to record, not an error to raise:
|
|
// the accounting must still be written. (A key that is PRESENT and
|
|
// unreadable is the error — baselineSign — because a reader cannot
|
|
// tell "meant to be unsigned" from "failed to sign".)
|
|
if strings.TrimSpace(active[0].OperatorKey) == "" {
|
|
return nil, baselineUnsignedNoKey
|
|
}
|
|
return active[0], ""
|
|
default:
|
|
return nil, baselineUnsignedNoAnchor
|
|
}
|
|
}
|
|
|
|
func parseECPrivateKeyPEM(keyPEM string) (*ecdsa.PrivateKey, error) {
|
|
block, _ := pem.Decode([]byte(keyPEM))
|
|
if block == nil {
|
|
return nil, errors.New("no PEM block in the operator key")
|
|
}
|
|
return x509.ParseECPrivateKey(block.Bytes)
|
|
}
|
|
|
|
// baselineSign attests the accounting with the given pairing's operator key.
|
|
func baselineSign(f *baselineFile, signer *Config) error {
|
|
if signer == nil || strings.TrimSpace(signer.OperatorKey) == "" {
|
|
return errors.New(baselineUnsignedNoKey)
|
|
}
|
|
key, err := parseECPrivateKeyPEM(signer.OperatorKey)
|
|
if err != nil {
|
|
return fmt.Errorf("the operator key could not be read: %w", err)
|
|
}
|
|
canon, err := baselineCanonical(*f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sum := sha256.Sum256(canon)
|
|
sig, err := ecdsa.SignASN1(rand.Reader, key, sum[:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f.Attestation = &baselineAttestation{
|
|
Alg: baselineSigAlg,
|
|
SignedBy: signer.HostName,
|
|
SignerCert: signer.OperatorCert,
|
|
Signature: base64.StdEncoding.EncodeToString(sig),
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// baselineVerify checks an attestation with the SAME three tests the host
|
|
// makes, using this pairing's CA and fingerprint.
|
|
//
|
|
// Returns ok and a note. The note is what a person reads, so it names WHICH
|
|
// test failed: an unsigned file, a certificate this CA did not issue, a
|
|
// certificate for a different machine, and a signature that does not match are
|
|
// four different situations and only the last one means the content moved.
|
|
func baselineVerify(f *baselineFile, caPEM, expectFingerprint string) (bool, string) {
|
|
raw, err := json.Marshal(f)
|
|
if err != nil {
|
|
return false, "this accounting could not be re-encoded: " + err.Error()
|
|
}
|
|
return baselineVerifyBytes(raw, caPEM, expectFingerprint)
|
|
}
|
|
|
|
// baselineVerifyBytes is the verifier proper, over the file's own bytes.
|
|
func baselineVerifyBytes(raw []byte, caPEM, expectFingerprint string) (bool, string) {
|
|
var head struct {
|
|
TakenAt string `json:"taken_at"`
|
|
Attestation *baselineAttestation `json:"attestation"`
|
|
}
|
|
if err := json.Unmarshal(raw, &head); err != nil {
|
|
return false, "this accounting cannot be read: " + err.Error()
|
|
}
|
|
a := head.Attestation
|
|
if a == nil {
|
|
return false, "this accounting is not signed"
|
|
}
|
|
if a.Alg != baselineSigAlg {
|
|
return false, "this accounting is signed with " + a.Alg + ", which this bastion does not verify"
|
|
}
|
|
// THE SIGNER CERTIFICATE IS A CHAIN: the operator certificate first, then
|
|
// the intermediate that issued it. The bridge issues operator certificates
|
|
// from an intermediate, and a pairing holds only the root, so judging the
|
|
// first block alone refused every real accounting as "not issued by this
|
|
// pairing's CA". Everything after the first block is offered as an
|
|
// intermediate; Verify still requires the path to end at the pairing's root
|
|
// and every hop to be a CA.
|
|
chain, note := baselineSignerChain(a.SignerCert)
|
|
if note != "" {
|
|
return false, note
|
|
}
|
|
cert := chain[0]
|
|
inter := x509.NewCertPool()
|
|
for _, c := range chain[1:] {
|
|
inter.AddCert(c)
|
|
}
|
|
roots := x509.NewCertPool()
|
|
if !roots.AppendCertsFromPEM([]byte(caPEM)) {
|
|
return false, "this pairing holds no CA to judge the signer certificate against"
|
|
}
|
|
// VALIDITY IS JUDGED AT THE MOMENT THE ACCOUNTING WAS TAKEN, not now. This
|
|
// is provenance over a document at rest: an accounting taken in March under
|
|
// a certificate that expired in June is still that machine's word, and
|
|
// judging at time.Now() made every accounting unverifiable at the next cert
|
|
// rotation — reported, worse, as "not issued by this CA". A certificate
|
|
// that was not valid WHEN THE ACCOUNTING WAS TAKEN is still refused, and by
|
|
// name. An unparseable taken_at falls back to now, the stricter choice.
|
|
at := time.Now()
|
|
if t, ok := parseStamp(head.TakenAt); ok {
|
|
at = t
|
|
}
|
|
chains, err := cert.Verify(x509.VerifyOptions{
|
|
Roots: roots, Intermediates: inter, CurrentTime: at, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
|
})
|
|
if err != nil {
|
|
var inv x509.CertificateInvalidError
|
|
if errors.As(err, &inv) && inv.Reason == x509.Expired {
|
|
return false, "the signer certificate was not valid at the time this accounting was taken (" + head.TakenAt + ")"
|
|
}
|
|
return false, "the signer certificate was not issued by this pairing's CA: " + err.Error()
|
|
}
|
|
// AT MOST ONE INTERMEDIATE, the shape the bridge issues and the only one
|
|
// the host accepts. A longer path verifying here and failing there would
|
|
// leave the box and the host disagreeing about the same accounting.
|
|
short := false
|
|
for _, c := range chains {
|
|
if len(c) <= 3 {
|
|
short = true
|
|
}
|
|
}
|
|
if !short {
|
|
return false, "the signer certificate was not issued by this pairing's CA: its chain is longer than one intermediate"
|
|
}
|
|
// The fingerprint may sit under either name (pair.go fpURIs); a
|
|
// certificate naming two different machines is refused, not resolved.
|
|
fp := ""
|
|
for _, u := range cert.URIs {
|
|
s := u.String()
|
|
for _, p := range []string{fpSANPrefix, hostFPSANPrefix} {
|
|
if !strings.HasPrefix(s, p) {
|
|
continue
|
|
}
|
|
got := strings.TrimPrefix(s, p)
|
|
if fp != "" && got != fp {
|
|
return false, "the signer certificate carries two different device fingerprints"
|
|
}
|
|
fp = got
|
|
}
|
|
}
|
|
if fp == "" {
|
|
return false, "the signer certificate carries no device fingerprint"
|
|
}
|
|
if expectFingerprint != "" && fp != expectFingerprint {
|
|
return false, "the signer certificate belongs to a different machine"
|
|
}
|
|
pub, ok := cert.PublicKey.(*ecdsa.PublicKey)
|
|
if !ok {
|
|
return false, "the signer certificate does not hold an EC key"
|
|
}
|
|
sig, err := base64.StdEncoding.DecodeString(a.Signature)
|
|
if err != nil {
|
|
return false, "the signature on this accounting is not base64"
|
|
}
|
|
canon, err := baselineCanonicalBytes(raw)
|
|
if err != nil {
|
|
return false, "this accounting could not be canonicalised: " + err.Error()
|
|
}
|
|
sum := sha256.Sum256(canon)
|
|
if !ecdsa.VerifyASN1(pub, sum[:], sig) {
|
|
return false, "the signature does not match this accounting — its content has changed since " +
|
|
a.SignedBy + "'s key signed it"
|
|
}
|
|
return true, "signed by " + a.SignedBy + "'s pairing key; verified against its CA and this machine's fingerprint"
|
|
}
|
|
|
|
// baselineSignerChain reads the signer certificate text as [leaf,
|
|
// intermediates...], or says why it cannot.
|
|
//
|
|
// THE TEXT MUST BE THE CANONICAL PEM OF ITS OWN CERTIFICATES, byte for byte:
|
|
// every block a CERTIFICATE with no headers, each re-encoded exactly as
|
|
// pem.EncodeToMemory writes it, concatenated, equal to what was carried. The
|
|
// host applies the same test to the same bytes. Two PEM parsers never agree
|
|
// on every malformed input (pem.Decode skips a block it cannot decode and
|
|
// promotes the next one to leaf; others refuse), so neither side interprets a
|
|
// non-canonical text at all. What the bridge issues and every pairing holds
|
|
// is canonical, so nothing real is refused.
|
|
func baselineSignerChain(text string) ([]*x509.Certificate, string) {
|
|
const bad = "the signer certificate on this accounting cannot be parsed"
|
|
var chain []*x509.Certificate
|
|
var canon []byte
|
|
rest := []byte(text)
|
|
for {
|
|
var b *pem.Block
|
|
if b, rest = pem.Decode(rest); b == nil {
|
|
break
|
|
}
|
|
c, err := x509.ParseCertificate(b.Bytes)
|
|
if err != nil {
|
|
return nil, bad
|
|
}
|
|
chain = append(chain, c)
|
|
canon = append(canon, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: b.Bytes})...)
|
|
}
|
|
if len(chain) == 0 {
|
|
return nil, "the signer certificate on this accounting is not PEM"
|
|
}
|
|
if string(canon) != text {
|
|
return nil, bad + ": it is not the canonical PEM of its certificates"
|
|
}
|
|
return chain, ""
|
|
}
|
|
|
|
// baselineAttestationNote judges the accounting on disk the way the host will,
|
|
// for `observe:baseline:show` and the delta's notes. "" when it verifies.
|
|
func baselineAttestationNote(f *baselineFile) string {
|
|
if f == nil {
|
|
return ""
|
|
}
|
|
p, err := baselinePath()
|
|
if err != nil {
|
|
return "the attestation could not be checked: no state dir"
|
|
}
|
|
raw, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return "the attestation could not be checked: " + err.Error()
|
|
}
|
|
return baselineAttestationNoteBytes(raw, f.Attestation != nil)
|
|
}
|
|
|
|
// baselineAttestationNoteBytes is the note over the file's own bytes (see
|
|
// baselineCanonicalBytes for why not through the struct). `signed` says whether
|
|
// the caller's parse saw an attestation, so an honestly-unsigned file is "" and
|
|
// not a problem.
|
|
//
|
|
// loadPairings runs on every pass here (60 s). It reads a directory and N small
|
|
// files, and it FAILS THE WHOLE LOAD if any one file is mid-write or malformed
|
|
// (config.go) — so a concurrent pair/unpair can produce "pairings unreadable"
|
|
// on one beat. Transient and self-correcting; it is a torn read, not an
|
|
// attestation problem, and this note says which.
|
|
func baselineAttestationNoteBytes(raw []byte, signed bool) string {
|
|
cfgs, err := loadPairings()
|
|
if err != nil {
|
|
return "the attestation could not be checked this pass: pairings unreadable (a pairing " +
|
|
"file may be mid-write; this clears on the next pass)"
|
|
}
|
|
signer, _ := baselineSignerPairing(cfgs)
|
|
if signer == nil {
|
|
if !signed {
|
|
return "" // unsigned and nothing could have signed it; the file says why
|
|
}
|
|
return "this accounting is signed but no pairing on this machine can check it"
|
|
}
|
|
ok, note := baselineVerifyBytes(raw, signer.CACert, signer.Fingerprint)
|
|
if ok {
|
|
return ""
|
|
}
|
|
return note
|
|
}
|
|
|
|
// baselineMirror writes the accounting into the machine store, where the host
|
|
// reads it through dcn beside the findings it stands behind, mirrored to the
|
|
// anchor host. The bytes are identical to the state-dir copy; the store
|
|
// copy is the one the host verifies, and a host that finds them different has
|
|
// found something.
|
|
func baselineMirror(b []byte) error {
|
|
root, err := storeDir()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p := filepath.Join(root, "baseline.json")
|
|
if err := os.WriteFile(p, b, 0o600); err != nil {
|
|
return err
|
|
}
|
|
_ = os.Chmod(p, 0o600)
|
|
return nil
|
|
}
|