bastion 0.57.0
This commit is contained in:
@@ -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[:])
|
||||
}
|
||||
Reference in New Issue
Block a user