bastion 0.57.0
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 4m30s
ci / fuzz (push) Successful in 3m25s

This commit is contained in:
Privatae
2026-10-28 20:35:45 +00:00
parent 4012da0b75
commit 081763a675
26 changed files with 2118 additions and 37 deletions
+254
View File
@@ -0,0 +1,254 @@
package main
// pair_code.go: the pairing code a Host's "Set up a bastion" card hands out,
// so pairing a machine is one pasted line.
//
// privatae-bastion pair skp_… or … | privatae-bastion pair -
//
// The code is the single-use token plus the two things the person used to copy
// by hand: the Host's enroll address and its CA. The CA does not travel; its
// fingerprint does. The bastion takes the CA from the enroll port's own TLS
// chain and keeps it only when its SHA-256 is the one in the code, so the trust
// is the same as `--host-ca`: the fingerprint comes from the owner's own
// signed-in card, as the PEM did. From there the pairing is exactly
// `pair --redeem-url <u> --host-ca <that CA> <t>`.
//
// No error here repeats the code, for the reason pair_stdin.go gives: a log of
// a failed install must not hold the token.
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net"
"net/url"
"strings"
"time"
)
// pairCodePrefix marks a pairing code; a bare token is skb_….
const pairCodePrefix = "skp_"
// pairCodeVersion is the one layout this bastion reads.
const pairCodeVersion = 1
// pairCode is a decoded pairing code: where to redeem, the Host CA's
// fingerprint, and the token.
type pairCode struct {
RedeemURL string // "u": the Host's enroll port, https://<host>:<port>/<path>
CASHA256 string // "ca": SHA-256 of the Host CA certificate's DER, lowercase hex
Token string // "t": the skb_ token
}
func isPairCode(s string) bool { return strings.HasPrefix(s, pairCodePrefix) }
// decodePairCode reads skp_<base64url(JSON)>: exactly the keys v, u, ca and t,
// v the one version this bastion knows, u an enroll URL parseTCPRedeemURL
// takes, ca 64 lowercase hex digits and t a token decodeToken takes.
func decodePairCode(code string) (pairCode, error) {
var pc pairCode
bad := func(why string) (pairCode, error) {
return pairCode{}, fmt.Errorf("the pairing code is not one this bastion can read (%s); copy the whole line from the card again", why)
}
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(code, pairCodePrefix))
if err != nil {
return bad("it is not base64url")
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return bad("it holds no JSON object")
}
// A map keeps the last of two same-named keys, so "exactly these four"
// is counted on the object as written: a key given twice is an extra key.
if n, err := jsonObjectKeyCount(raw); err != nil || n != len(fields) {
return bad("it names a field twice")
}
if len(fields) != 4 {
return bad(fmt.Sprintf("it has %d fields, and a pairing code has 4", len(fields)))
}
var v int
if err := json.Unmarshal(fields["v"], &v); err != nil {
return bad("its version is missing")
}
if v != pairCodeVersion {
return pairCode{}, fmt.Errorf("the pairing code is version %d, and this bastion reads version %d: update the bastion and pair again", v, pairCodeVersion)
}
for k, dst := range map[string]*string{"u": &pc.RedeemURL, "ca": &pc.CASHA256, "t": &pc.Token} {
if err := json.Unmarshal(fields[k], dst); err != nil || *dst == "" {
return bad("its " + k + " field is missing")
}
}
if _, err := parseTCPRedeemURL(pc.RedeemURL); err != nil {
return bad("its address is not an https enroll address")
}
if len(pc.CASHA256) != 64 || strings.ToLower(pc.CASHA256) != pc.CASHA256 {
return bad("its certificate fingerprint is not 64 lowercase hex digits")
}
if _, err := hex.DecodeString(pc.CASHA256); err != nil {
return bad("its certificate fingerprint is not hex")
}
if !strings.HasPrefix(pc.Token, "skb_") {
return bad("it carries no pairing token")
}
if _, _, err := decodeToken(pc.Token); err != nil {
return bad("its pairing token is malformed")
}
return pc, nil
}
// jsonObjectKeyCount counts the keys of the JSON object raw as written,
// repeats included. raw has already decoded as an object.
func jsonObjectKeyCount(raw []byte) (int, error) {
dec := json.NewDecoder(bytes.NewReader(raw))
if _, err := dec.Token(); err != nil { // the opening brace
return 0, err
}
n := 0
for dec.More() {
if _, err := dec.Token(); err != nil { // the key
return 0, err
}
var v json.RawMessage
if err := dec.Decode(&v); err != nil {
return 0, err
}
n++
}
return n, nil
}
// pairAnswerTimeout is how long the enroll port has to answer the first
// connection. A Cloud Host's pairing port drops every address but the one the
// card opened it to, so a pair from anywhere else gets no answer at all, and
// without this it would wait for the system's own connect timeout, minutes.
// A variable so a test can shorten it; nothing else assigns it.
var pairAnswerTimeout = 10 * time.Second
// pairDial dials the enroll port. A variable so a test can stand in a port
// that never answers; nothing else assigns it.
var pairDial = (&net.Dialer{}).DialContext
// pairWindowError is what a pair says when the Host's enroll port did not
// answer: the plain reason, which is nearly always the pairing window. A type
// of its own so the redeem's HTTP client, which wraps every dial error in the
// request's URL, can hand it on as it is.
type pairWindowError struct{ host string }
func (e *pairWindowError) Error() string {
return fmt.Sprintf("%s's pairing port didn't answer within %s. A Cloud Host opens it only to the address "+
"your browser used when you opened \"Set up a bastion\", for 15 minutes. Pair from that network, or "+
"on the card choose \"Pairing a machine on another network?\" and give this machine's address. Nothing was paired.",
e.host, pairAnswerTimeout)
}
// dialEnrollPort opens a TCP connection to the redeem URL's host and port
// within pairAnswerTimeout, or says why not in plain words.
func dialEnrollPort(ctx context.Context, u *url.URL) (net.Conn, error) {
addr := u.Host
if u.Port() == "" {
addr = net.JoinHostPort(u.Hostname(), "443")
}
ctx, cancel := context.WithTimeout(ctx, pairAnswerTimeout)
defer cancel()
c, err := pairDial(ctx, "tcp", addr)
if err == nil {
return c, nil
}
var ne net.Error
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout()) {
return nil, &pairWindowError{host: u.Hostname()}
}
return nil, fmt.Errorf("could not reach %s's pairing port: %v; nothing was paired", u.Hostname(), err)
}
// fetchPinnedHostCA takes the Host CA from the enroll port's TLS chain and
// returns it as PEM, only when it is a CA whose SHA-256 over its DER is
// wantSHA and the port's own certificate verifies under it alone for
// tcpServerName, the check hostCATLS makes on every later connection. No
// system roots are consulted at any point.
func fetchPinnedHostCA(u *url.URL, wantSHA string) (string, error) {
conn, err := dialEnrollPort(context.Background(), u)
if err != nil {
return "", err
}
defer conn.Close()
var caPEM string
tc := &tls.Config{
ServerName: tcpServerName, MinVersion: tls.VersionTLS12,
// The chain is checked by verifyPinnedChain below, against the pinned
// CA alone; the system roots must play no part, so the default
// verification is the thing turned off here.
InsecureSkipVerify: true,
VerifyConnection: func(cs tls.ConnectionState) error {
p, err := verifyPinnedChain(cs.PeerCertificates, wantSHA)
caPEM = p
return err
},
}
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
tlsConn := tls.Client(conn, tc)
if err := tlsConn.Handshake(); err != nil {
return "", fmt.Errorf("%s's pairing port: %v; nothing was paired", u.Hostname(), err)
}
_ = tlsConn.Close()
return caPEM, nil
}
// verifyPinnedChain picks the pinned CA out of a presented chain and checks
// the leaf against it. The leaf is chain[0]; the CA is any later certificate
// whose SHA-256 is wantSHA.
func verifyPinnedChain(chain []*x509.Certificate, wantSHA string) (string, error) {
if len(chain) == 0 {
return "", errors.New("the Host's pairing port presented no certificate")
}
var ca *x509.Certificate
sawCA := false
for _, c := range chain[1:] {
if !c.IsCA {
continue
}
sawCA = true
sum := sha256.Sum256(c.Raw)
if hex.EncodeToString(sum[:]) == wantSHA {
ca = c
break
}
}
if ca == nil {
if sawCA {
return "", errors.New("the Host's pairing port presented a certificate authority other than the one this pairing code names — refusing")
}
return "", errors.New("the Host's pairing port did not present its certificate authority, so this pairing code cannot be checked — refusing (the Host needs a newer release, or pair with --host-ca)")
}
roots := x509.NewCertPool()
roots.AddCert(ca)
inter := x509.NewCertPool()
for _, c := range chain[1:] {
if c != ca {
inter.AddCert(c)
}
}
if _, err := chain[0].Verify(x509.VerifyOptions{Roots: roots, Intermediates: inter, DNSName: tcpServerName}); err != nil {
return "", fmt.Errorf("the Host's pairing port certificate is not signed by the certificate authority this pairing code names: %v — refusing", err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.Raw})), nil
}
// caSHA256 is the fingerprint a pairing code carries for a PEM CA: SHA-256 of
// the first certificate's DER, lowercase hex. "" when the PEM holds none.
func caSHA256(caPEM string) string {
certs := pemCerts(caPEM)
if len(certs) == 0 {
return ""
}
sum := sha256.Sum256(certs[0].Raw)
return hex.EncodeToString(sum[:])
}