Files
privatae-bastion/client-go/baselinesign.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

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
}