Files
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

173 lines
6.3 KiB
Go

package main
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Release manifests are signed.
//
// The bastion's binary and the agent's .pyz were already checked against a
// sha256, but the sha256 came from a manifest served by the same origin as the
// bytes. Anyone who could change what that origin serves (the edge, the cache
// in front of it, the DNS pointing at it) could change both, and the check
// would pass. A signature moves the trust from "whoever serves these files" to
// "whoever holds the release key", and that key is kept offline on the key
// holder's own computer, away from the edges, the release machine and the
// platform.
//
// Every manifest is published with a detached signature beside it:
// <manifest>.<first 16 hex of its sha256>.sig (releaseSigURL) holds the base64 ed25519 signature over a short statement
// naming what was released and the sha256 of the manifest's exact bytes:
//
// privatae-release-signature-v1
// <kind: bastion | bastion-agent>
// <version, as the manifest says>
// <sha256 of the manifest, hex>
//
// The statement is what the key holder reads and signs, on their own
// computer, with deploy/release-sign; this file rebuilds it from the bytes it
// downloaded. The bytes that are hashed are the bytes that are then decoded,
// so there is no canonical form to disagree about, and the kind stops a
// bastion manifest's signature from passing as an agent manifest's.
//
// A missing or bad signature is a refusal, the same as a sha256 mismatch:
// the machine stays on what it runs and tries again on the next directive or
// reconcile. There is no fallback to an unsigned manifest.
// productionReleaseKeys are the public halves of the release signing keys,
// base64 of the raw 32-byte ed25519 key. More than one is accepted so a key
// can be rotated: the next key ships in a release signed by the current one,
// and the old one is dropped a release later.
//
// deploy/release-verify.sh reads this list from the source, so keep one
// quoted key per line.
var productionReleaseKeys = []string{
"tWOyZm38DwTwbawSnvExQG/CQsVJBKYWc46arCBZj9E=", // primary
"CKL+J0VSdqrfY4AmxChvylJ1ha4e8GvcrYF2o17Ah+g=", // spare, kept offline
}
// releaseKeys is what verification reads. A var, separate from the
// production list, so a test can sign with its own key while another test
// still checks the production list itself.
var releaseKeys = productionReleaseKeys
// releaseSigURL is where the signature for the manifest at manifestURL lives:
// beside it, named by the first 8 bytes of the manifest's sha256
// (version.json -> version.json.<16 hex>.sig). Each manifest has its own
// signature file and publishing never removes one, so a machine that read the
// manifest a moment before a publish replaced it still finds the signature
// that belongs to what it read, and no cache can pair a manifest with another
// manifest's signature.
func releaseSigURL(manifestURL string, body []byte) (string, error) {
u, err := url.Parse(manifestURL)
if err != nil {
return "", err
}
sum := sha256.Sum256(body)
u.Path += "." + hex.EncodeToString(sum[:8]) + ".sig"
return u.String(), nil
}
// releaseFetchTimeout bounds each of the two small fetches (manifest and
// signature). The directed update runs them on the heartbeat, so a hung edge
// must not hold that loop for the binary download's two minutes.
var releaseFetchTimeout = 20 * time.Second
// releaseFetchMax caps what is read for a manifest or a signature. Both are a
// few kilobytes; anything larger is not one.
const releaseFetchMax = 1 << 20
func fetchSmall(u string) ([]byte, error) {
c := &http.Client{Timeout: releaseFetchTimeout}
resp, err := c.Get(u)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("fetch %s: %d", u, resp.StatusCode)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, releaseFetchMax+1))
if err != nil {
return nil, err
}
if len(b) > releaseFetchMax {
return nil, fmt.Errorf("fetch %s: larger than a release manifest can be", u)
}
return b, nil
}
const (
releaseKindBastion = "bastion"
releaseKindAgent = "bastion-agent"
)
// releaseStatement is the exact text a release signature covers.
// deploy/release-sign/main.go Statement builds the same text; a shared test
// vector in both test suites keeps the two identical.
func releaseStatement(kind, version, sha256hex string) []byte {
return []byte("privatae-release-signature-v1\n" + kind + "\n" + version + "\n" + sha256hex + "\n")
}
// verifyReleaseSignature checks sig (the .sig file's contents) over the
// statement for the manifest body, against every accepted release key.
func verifyReleaseSignature(kind string, body, sig []byte) error {
if len(releaseKeys) == 0 {
return errors.New("this build pins no release key, so no manifest can be verified")
}
var m struct {
Version string `json:"version"`
}
if err := json.Unmarshal(body, &m); err != nil || m.Version == "" || strings.ContainsAny(m.Version, "\r\n") {
return errors.New("release manifest names no usable version")
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(sig)))
if err != nil || len(raw) != ed25519.SignatureSize {
return errors.New("release signature is malformed")
}
sum := sha256.Sum256(body)
msg := releaseStatement(kind, m.Version, hex.EncodeToString(sum[:]))
for _, k := range releaseKeys {
pub, err := base64.StdEncoding.DecodeString(k)
if err != nil || len(pub) != ed25519.PublicKeySize {
continue
}
if ed25519.Verify(ed25519.PublicKey(pub), msg, raw) {
return nil
}
}
return errors.New("release signature does not verify against any pinned release key")
}
// fetchSignedManifest downloads the manifest at u and its signature, and
// returns the manifest's bytes only if the signature verifies for kind.
func fetchSignedManifest(u, kind string) ([]byte, error) {
body, err := fetchSmall(u)
if err != nil {
return nil, err
}
sigURL, err := releaseSigURL(u, body)
if err != nil {
return nil, err
}
sig, err := fetchSmall(sigURL)
if err != nil {
return nil, fmt.Errorf("manifest %s: signature: %w", u, err)
}
if err := verifyReleaseSignature(kind, body, sig); err != nil {
return nil, fmt.Errorf("manifest %s: %w", u, err)
}
return body, nil
}