1020 lines
34 KiB
Go
1020 lines
34 KiB
Go
package main
|
|
|
|
// pair_window.go: pairing a server with a Host that connects to it.
|
|
//
|
|
// privatae-bastion pair --open [--port 8812]
|
|
//
|
|
// Run on the server. It opens a pairing window for one Host: a code, good once
|
|
// and for 15 minutes, and the door (hostdoor.go) listening for it. The person
|
|
// gives their Host this server's address and the code; the Host connects, and
|
|
// the pairing is made over that connection the same way round as every other
|
|
// pairing: this bastion is the TLS client and the HTTP client, the Host
|
|
// answers, signs this bastion's operator CSR and issues its bearer.
|
|
//
|
|
// The code is what each side proves to the other. Neither sends it: each sends
|
|
// an HMAC of it over the Host's certificate as this connection presented it,
|
|
// the Host's CA and a nonce from each side. The Host proves first, so a
|
|
// stranger who connects to the open window learns nothing it could test codes
|
|
// against; the bastion proves second, before the Host signs anything, so a
|
|
// machine that is not this server can't pair itself with the Host even if the
|
|
// Host was sent to it. A proof made on one connection is worth nothing on
|
|
// another (the certificate differs), so neither can be relayed. The code is
|
|
// 100 bits, out of reach offline; online, a wrong one costs a wait that
|
|
// doubles each time, and the fifth closes the window.
|
|
//
|
|
// Opening the window at this server is the person's say-so for one Host, so
|
|
// it is the "yes at this computer's terminal" a local machine asks before a
|
|
// second Host. Everything after the Host has proved itself is pair.go's:
|
|
// the conflict rules, pending behind the Host in charge, the anchor rules,
|
|
// the atomic save.
|
|
//
|
|
// The wire contract is docs/host-connects.md.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// windowLifetime is how long a pairing window stays open.
|
|
windowLifetime = 15 * time.Minute
|
|
// windowMaxFailures wrong codes close the window.
|
|
windowMaxFailures = 5
|
|
// windowExchangeTimeout bounds the whole pairing exchange on one connection.
|
|
windowExchangeTimeout = 60 * time.Second
|
|
// windowCodeLen characters of windowAlphabet: 5 bits each, 100 bits.
|
|
windowCodeLen = 20
|
|
// windowAlphabet is Crockford's base32: no I, L, O or U to misread.
|
|
windowAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
// windowTransport is the transport a Host answers a window redeem with.
|
|
windowTransport = "host_dials"
|
|
// windowProofLabel starts every proof's MAC input.
|
|
windowProofLabel = "privatae-pair-v1 "
|
|
)
|
|
|
|
// windowFailWait is the wait after the first wrong code; it doubles with each
|
|
// one after. A variable so a test can shorten it.
|
|
var windowFailWait = time.Second
|
|
|
|
// windowPollEvery is how often `pair --open` looks for the result. A variable
|
|
// so a test can shorten it.
|
|
var windowPollEvery = 500 * time.Millisecond
|
|
|
|
// newWindowCode is a fresh code, normalised (windowCodeLen characters).
|
|
func newWindowCode() (string, error) {
|
|
b := make([]byte, windowCodeLen)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
for i := range b {
|
|
b[i] = windowAlphabet[int(b[i])&31] // 256 is a multiple of 32: uniform
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
// formatWindowCode is the code as the person reads it: groups of five.
|
|
func formatWindowCode(code string) string {
|
|
var parts []string
|
|
for i := 0; i < len(code); i += 5 {
|
|
parts = append(parts, code[i:min(i+5, len(code))])
|
|
}
|
|
return strings.Join(parts, "-")
|
|
}
|
|
|
|
// normalizeWindowCode is what both sides key the proofs with: upper case, no
|
|
// dashes or spaces, O read as 0 and I or L as 1. "" when it is not a code.
|
|
func normalizeWindowCode(s string) string {
|
|
var b strings.Builder
|
|
for _, r := range strings.ToUpper(s) {
|
|
switch r {
|
|
case '-', ' ', '\t':
|
|
continue
|
|
case 'O':
|
|
r = '0'
|
|
case 'I', 'L':
|
|
r = '1'
|
|
}
|
|
if !strings.ContainsRune(windowAlphabet, r) {
|
|
return ""
|
|
}
|
|
b.WriteRune(r)
|
|
}
|
|
if b.Len() != windowCodeLen {
|
|
return ""
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// windowProof is role's proof of the code on one connection: HMAC-SHA256
|
|
// keyed by the normalised code over the label, the role, the SHA-256 of the
|
|
// Host's certificate as presented, that of its CA, and both nonces.
|
|
func windowProof(code, role string, leafSHA, caSHA, nonceB, nonceH []byte) string {
|
|
m := hmac.New(sha256.New, []byte(code))
|
|
m.Write([]byte(windowProofLabel + role + "\x00"))
|
|
for _, p := range [][]byte{leafSHA, caSHA, nonceB, nonceH} {
|
|
m.Write(p)
|
|
}
|
|
return hex.EncodeToString(m.Sum(nil))
|
|
}
|
|
|
|
// ── the window file ──────────────────────────────────────────────────────────
|
|
|
|
// pairWindow is <configDir>/pairing-window.json: the one open window, written
|
|
// by `pair --open`, read by whichever door serves it, which writes the result
|
|
// into it. 0600 in the bastion's own state, like the pairings' bearers.
|
|
type pairWindow struct {
|
|
ID string `json:"id"`
|
|
Code string `json:"code"`
|
|
Door string `json:"door"`
|
|
Opened string `json:"opened"`
|
|
Expires string `json:"expires"`
|
|
// Authority: the person who opened it held machine authority, so the
|
|
// Host paired on it may be put in charge of a machine nobody is in charge
|
|
// of yet (anchor_local.go anchorAfterWindow).
|
|
Authority bool `json:"machine_authority,omitempty"`
|
|
Result *windowResult `json:"result,omitempty"`
|
|
}
|
|
|
|
// windowResult is how the window ended.
|
|
type windowResult struct {
|
|
Host string `json:"host,omitempty"`
|
|
State string `json:"state,omitempty"`
|
|
AdmittedBy string `json:"admitted_by,omitempty"`
|
|
Rotated bool `json:"rotated,omitempty"`
|
|
Taken bool `json:"anchor_taken,omitempty"`
|
|
Why string `json:"anchor_why,omitempty"`
|
|
Replaced string `json:"anchor_replaced,omitempty"`
|
|
Waiting []string `json:"anchor_waiting,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
var windowFileMu sync.Mutex
|
|
|
|
func windowPath() (string, error) {
|
|
d, err := configDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(d, "pairing-window.json"), nil
|
|
}
|
|
|
|
func readWindowFile() *pairWindow {
|
|
p, err := windowPath()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return readWindowAt(p)
|
|
}
|
|
|
|
func readWindowAt(p string) *pairWindow {
|
|
raw, err := os.ReadFile(p)
|
|
if err != nil || len(raw) > 64<<10 {
|
|
return nil
|
|
}
|
|
var w pairWindow
|
|
if json.Unmarshal(raw, &w) != nil || w.ID == "" {
|
|
return nil
|
|
}
|
|
return &w
|
|
}
|
|
|
|
// claimedWindowPath is where a door moves the window the moment it commits a
|
|
// pairing on it. The move is one rename, and so is the terminal closing the
|
|
// window (removeWindow): whichever happens first wins, across processes, so a
|
|
// window closed at the terminal never pairs, and a pairing made is never
|
|
// reported as "nothing was paired".
|
|
func claimedWindowPath() (string, error) {
|
|
p, err := windowPath()
|
|
return p + ".claimed", err
|
|
}
|
|
|
|
// claimWindow takes the window with this id for the pairing about to be
|
|
// saved. False when it is closed, replaced or already ended.
|
|
func claimWindow(id string) bool {
|
|
windowFileMu.Lock()
|
|
defer windowFileMu.Unlock()
|
|
if !windowStillOpen(id) {
|
|
return false
|
|
}
|
|
p, err := windowPath()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
c := p + ".claimed"
|
|
if os.Rename(p, c) != nil {
|
|
return false
|
|
}
|
|
if w := readWindowAt(c); w == nil || w.ID != id {
|
|
_ = os.Rename(c, p) // a newer window landed between the check and the move
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// claimedWindow is the claimed window with this id, or nil.
|
|
func claimedWindow(id string) *pairWindow {
|
|
c, err := claimedWindowPath()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if w := readWindowAt(c); w != nil && w.ID == id {
|
|
return w
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// claimedWindowUnderWay is the window a door claimed for a pairing it has not
|
|
// finished: the window file is gone (claimWindow) and the pairing may not be
|
|
// saved yet, so neither reads as the door being in use, but it is. Bounded:
|
|
// a claim is made before the window expires and its exchange ends within
|
|
// windowExchangeTimeout, so a claimed file a killed process left behind
|
|
// stops counting then.
|
|
func claimedWindowUnderWay(now time.Time) *pairWindow {
|
|
c, err := claimedWindowPath()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
w := readWindowAt(c)
|
|
if w == nil || w.Result != nil {
|
|
return nil
|
|
}
|
|
t, err := time.Parse(time.RFC3339, w.Expires)
|
|
if err != nil || !now.Before(t.Add(windowExchangeTimeout)) {
|
|
return nil
|
|
}
|
|
return w
|
|
}
|
|
|
|
func (w *pairWindow) expired(now time.Time) bool {
|
|
t, err := time.Parse(time.RFC3339, w.Expires)
|
|
return err != nil || !now.Before(t)
|
|
}
|
|
|
|
// loadOpenWindow is the window a door may serve now: present, not expired,
|
|
// not ended, with a usable code.
|
|
func loadOpenWindow() *pairWindow {
|
|
w := readWindowFile()
|
|
if w == nil || w.Result != nil || w.expired(time.Now()) || normalizeWindowCode(w.Code) != w.Code {
|
|
return nil
|
|
}
|
|
return w
|
|
}
|
|
|
|
func saveWindowFile(w *pairWindow) error {
|
|
p, err := windowPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeWindowAt(p, w)
|
|
}
|
|
|
|
func writeWindowAt(p string, w *pairWindow) error {
|
|
raw, err := json.MarshalIndent(w, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := p + ".tmp"
|
|
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, p)
|
|
}
|
|
|
|
// endWindow writes the result into the window, if it is still the one with
|
|
// this id (a newer `pair --open` replaces it).
|
|
func endWindow(id string, res windowResult) {
|
|
windowFileMu.Lock()
|
|
defer windowFileMu.Unlock()
|
|
if w := claimedWindow(id); w != nil {
|
|
if w.Result == nil {
|
|
w.Result = &res
|
|
if c, err := claimedWindowPath(); err == nil {
|
|
_ = writeWindowAt(c, w)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
w := readWindowFile()
|
|
if w == nil || w.ID != id || w.Result != nil {
|
|
return
|
|
}
|
|
w.Result = &res
|
|
_ = saveWindowFile(w)
|
|
}
|
|
|
|
// removeWindow closes the window with this id: its file is removed, unless a
|
|
// door claimed it first (claimWindow), which removeWindow reports.
|
|
func removeWindow(id string) (claimed bool) {
|
|
windowFileMu.Lock()
|
|
defer windowFileMu.Unlock()
|
|
if w := readWindowFile(); w != nil && w.ID == id {
|
|
if p, err := windowPath(); err == nil && os.Remove(p) == nil {
|
|
return false
|
|
}
|
|
}
|
|
return claimedWindow(id) != nil
|
|
}
|
|
|
|
// awaitClaimedResult waits, at most an exchange's length, for the result of a
|
|
// pairing a door claimed the window for, and clears the claimed file.
|
|
func awaitClaimedResult(id string) *windowResult {
|
|
deadline := time.Now().Add(windowExchangeTimeout)
|
|
for time.Now().Before(deadline) {
|
|
if w := claimedWindow(id); w == nil || w.Result != nil {
|
|
if c, err := claimedWindowPath(); err == nil && w != nil {
|
|
_ = os.Remove(c)
|
|
}
|
|
if w == nil {
|
|
return nil
|
|
}
|
|
return w.Result
|
|
}
|
|
time.Sleep(windowPollEvery)
|
|
}
|
|
return &windowResult{Error: "a pairing was under way when the window closed, and it did not finish; run `privatae-bastion status` to see whether the Host was paired"}
|
|
}
|
|
|
|
// ── the door's side of a window ──────────────────────────────────────────────
|
|
|
|
// windowState is what a door knows of the window it serves: its wrong codes
|
|
// and the wait they cost, and whether a pairing is under way on it.
|
|
type windowState struct {
|
|
id string
|
|
failures int
|
|
next time.Time
|
|
busy bool
|
|
}
|
|
|
|
// openWindow is the window this door offers pairing on now, or nil: none
|
|
// open, another door's, a pairing already under way on it, or a wrong code's
|
|
// wait not yet over.
|
|
func (d *hostDoor) openWindow() *pairWindow {
|
|
w := loadOpenWindow()
|
|
if w == nil || w.Door != d.addr {
|
|
return nil
|
|
}
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.win.id != w.ID {
|
|
d.win = windowState{id: w.ID}
|
|
}
|
|
if d.win.busy || d.win.failures >= windowMaxFailures || time.Now().Before(d.win.next) {
|
|
return nil
|
|
}
|
|
return w
|
|
}
|
|
|
|
// windowBegin starts the pairing on the window, if it may still start: the
|
|
// window still open in its file (not closed at the terminal, ended or
|
|
// expired since the connection came in), no pairing under way on it, and no
|
|
// wrong code's wait or window-closing wrong code since that connection was
|
|
// offered it. Several connections can be in their handshake at once; each is
|
|
// asked here again.
|
|
func (d *hostDoor) windowBegin(w *pairWindow) bool {
|
|
if !windowStillOpen(w.ID) {
|
|
return false
|
|
}
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.win.id != w.ID || d.win.busy || d.win.failures >= windowMaxFailures || time.Now().Before(d.win.next) {
|
|
return false
|
|
}
|
|
d.win.busy = true
|
|
return true
|
|
}
|
|
|
|
// windowStillOpen: the window with this id is the open one.
|
|
func windowStillOpen(id string) bool {
|
|
cur := loadOpenWindow()
|
|
return cur != nil && cur.ID == id
|
|
}
|
|
|
|
func (d *hostDoor) windowEnd() {
|
|
d.mu.Lock()
|
|
d.win.busy = false
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
// windowFailed counts a wrong code: a wait of 1, 2, 4, 8 s before the next
|
|
// try, and the fifth closes the window.
|
|
func (d *hostDoor) windowFailed(w *pairWindow, src string) {
|
|
d.mu.Lock()
|
|
d.win.failures++
|
|
n := d.win.failures
|
|
d.win.next = time.Now().Add(time.Duration(1<<(n-1)) * windowFailWait)
|
|
d.mu.Unlock()
|
|
pairingAudit("window_wrong_code", src, fmt.Sprintf("refused: a wrong pairing code (%d of %d)", n, windowMaxFailures))
|
|
if n >= windowMaxFailures {
|
|
endWindow(w.ID, windowResult{Error: fmt.Sprintf("the window closed after %d wrong codes; nothing was paired", n)})
|
|
pairingAudit("window_closed", d.addr, "closed after too many wrong codes")
|
|
}
|
|
}
|
|
|
|
// The messages of a pairing exchange (docs/host-connects.md).
|
|
type windowHelloReq struct {
|
|
V int `json:"v"`
|
|
Nonce string `json:"nonce"`
|
|
}
|
|
|
|
type windowHelloResp struct {
|
|
V int `json:"v"`
|
|
HostHandle string `json:"host_handle"`
|
|
HostName string `json:"host_name"`
|
|
Nonce string `json:"nonce"`
|
|
Proof string `json:"proof"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
type windowRedeemReq struct {
|
|
V int `json:"v"`
|
|
Proof string `json:"proof"`
|
|
WGPublicKey string `json:"wg_public_key"`
|
|
OperatorCSR string `json:"operator_csr"`
|
|
Hostname string `json:"hostname"`
|
|
Platform string `json:"platform"`
|
|
Handle string `json:"handle"`
|
|
}
|
|
|
|
type windowDoneReq struct {
|
|
V int `json:"v"`
|
|
State string `json:"state"`
|
|
HostName string `json:"host_name,omitempty"`
|
|
AdmittedBy string `json:"admitted_by,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// oneConnClient is an HTTP client whose every request goes over tc, and over
|
|
// nothing else: no proxy, no redirect, no second connection.
|
|
func oneConnClient(tc *tls.Conn) *http.Client {
|
|
var mu sync.Mutex
|
|
used := false
|
|
tr := &http.Transport{
|
|
Proxy: nil, MaxConnsPerHost: 1, DisableCompression: true,
|
|
DialTLSContext: func(context.Context, string, string) (net.Conn, error) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if used {
|
|
return nil, errors.New("the Host closed the pairing connection")
|
|
}
|
|
used = true
|
|
return tc, nil
|
|
},
|
|
}
|
|
return &http.Client{Transport: tr, Timeout: windowExchangeTimeout,
|
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
|
}
|
|
|
|
// windowPost POSTs one message of the exchange and decodes the answer (into
|
|
// out, when it is not nil).
|
|
func windowPost(c *http.Client, path string, in, out any) error {
|
|
body, err := json.Marshal(in)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := c.Post("https://"+tcpServerName+path, "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if resp.StatusCode != 200 {
|
|
var e struct {
|
|
Error string `json:"error"`
|
|
}
|
|
_ = json.Unmarshal(rb, &e)
|
|
if e.Error != "" {
|
|
return fmt.Errorf("the Host refused: %s", e.Error)
|
|
}
|
|
return fmt.Errorf("%s: HTTP %d", path, resp.StatusCode)
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(rb, out); err != nil {
|
|
return fmt.Errorf("%s: the Host's answer is not JSON", path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// serveWindow is a pairing on the window, over the connection the Host
|
|
// opened. ca is the CA its chain carried (chainCA).
|
|
func (d *hostDoor) serveWindow(tc *tls.Conn, w *pairWindow, ca *x509.Certificate, src string) {
|
|
defer tc.Close()
|
|
if !d.windowBegin(w) {
|
|
return
|
|
}
|
|
defer d.windowEnd()
|
|
_ = tc.SetDeadline(time.Now().Add(windowExchangeTimeout))
|
|
cs := tc.ConnectionState()
|
|
leaf := sha256.Sum256(cs.PeerCertificates[0].Raw)
|
|
caSum := sha256.Sum256(ca.Raw)
|
|
c := oneConnClient(tc)
|
|
nb := make([]byte, 32)
|
|
if _, err := rand.Read(nb); err != nil {
|
|
return
|
|
}
|
|
var hello windowHelloResp
|
|
if err := windowPost(c, "/v1/pair/hello", windowHelloReq{V: 1, Nonce: hex.EncodeToString(nb)}, &hello); err != nil {
|
|
pairingAudit("window_failed", src, "refused: "+err.Error())
|
|
return
|
|
}
|
|
nh, err := hex.DecodeString(hello.Nonce)
|
|
if err != nil || len(nh) != 32 {
|
|
pairingAudit("window_failed", src, "refused: the Host's hello carries no 32-byte nonce")
|
|
return
|
|
}
|
|
want := windowProof(w.Code, "host", leaf[:], caSum[:], nb, nh)
|
|
if !hmac.Equal([]byte(want), []byte(strings.ToLower(hello.Proof))) {
|
|
d.windowFailed(w, src)
|
|
return
|
|
}
|
|
caPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.Raw}))
|
|
cfg, res := windowCommit(c, w, hello, caPEM, func() string { return windowProof(w.Code, "bastion", leaf[:], caSum[:], nb, nh) })
|
|
done := windowDoneReq{V: 1, State: res.State, HostName: res.Host, AdmittedBy: res.AdmittedBy}
|
|
if res.Error != "" {
|
|
done = windowDoneReq{V: 1, State: "refused", Error: res.Error}
|
|
}
|
|
_ = windowPost(c, "/v1/pair/done", done, nil)
|
|
endWindow(w.ID, res)
|
|
if cfg == nil {
|
|
pairingAudit("window_refused", firstNonEmpty(firstNonEmpty(hello.HostName, hello.HostHandle), src), "refused: "+res.Error)
|
|
return
|
|
}
|
|
// Its lane connections follow from the same address: they count as a
|
|
// known Host's from the first (admitHandshake).
|
|
doorRememberHost(sourceKey(src), time.Now())
|
|
pairingAudit("window_paired", cfg.HostName, fmt.Sprintf("ok: paired through the window from %s, %s; it connects to this server on %s",
|
|
src, cfg.stateName(), cfg.doorAddr()))
|
|
}
|
|
|
|
// windowCommit is the pairing once the Host has proved the code: pair.go's
|
|
// rules, the redeem with this bastion's proof, the checks and the save.
|
|
// bastionProof makes the proof the redeem carries.
|
|
func windowCommit(c *http.Client, w *pairWindow, hello windowHelloResp, hostCA string, bastionProof func() string) (*Config, windowResult) {
|
|
refuse := func(err error) (*Config, windowResult) { return nil, windowResult{Error: err.Error()} }
|
|
handle := hello.HostHandle
|
|
if !validHostName(handle) {
|
|
return refuse(fmt.Errorf("the Host named itself %q, which is not a name this bastion keeps", handle))
|
|
}
|
|
if err := singletonGuardForPair(); err != nil {
|
|
return refuse(err)
|
|
}
|
|
cfgs, err := loadPairings()
|
|
if err != nil {
|
|
return refuse(err)
|
|
}
|
|
wantCA := caSHA256(hostCA)
|
|
replace := false
|
|
// The same Host pairing this server again through a fresh window: a Host
|
|
// whose connection presented the CA a pairing here already pins (its
|
|
// certificate verified under it, chainCA) is that pairing's Host, whatever
|
|
// handle it gives. Opening the window is the person's say-so to give it
|
|
// new credentials, so this replaces the pairing in place, its state, its
|
|
// anchor and its grants kept, with no `unpair` first.
|
|
key := handle
|
|
if same := doorPairingWithCA(cfgs, hostCA); same != nil {
|
|
key = same.HostName
|
|
}
|
|
existing, state, err := pairPrecheck(cfgs, key, false, false)
|
|
var already *alreadyPairedError
|
|
if errors.As(err, &already) {
|
|
// The same Host, its CA unchanged, through a window opened here: the
|
|
// person's say-so to give it new credentials.
|
|
if refusal := sameNameOtherHost(pairingFor(cfgs, key), wantCA); refusal != nil {
|
|
return refuse(refusal)
|
|
}
|
|
replace = true
|
|
existing, state, err = pairPrecheck(cfgs, key, true, false)
|
|
}
|
|
var second *localSecondHostError
|
|
if errors.As(err, &second) {
|
|
// Opening the window at this computer is the yes the terminal asks.
|
|
existing, state, err = pairPrecheckState(false, handle)
|
|
}
|
|
if err != nil {
|
|
return refuse(err)
|
|
}
|
|
if err := replaceKeepsTransport(existing, transportTCP); err != nil {
|
|
return refuse(err)
|
|
}
|
|
if err := tcpPinRefusal(existing, hostCA); err != nil {
|
|
return refuse(err)
|
|
}
|
|
if err := doorCARefusal(cfgs, existing, hostCA); err != nil {
|
|
return refuse(err)
|
|
}
|
|
wgPriv, wgPub, err := genWGKeypair()
|
|
if err != nil {
|
|
return refuse(err)
|
|
}
|
|
fp, err := genSecret(20)
|
|
if err != nil {
|
|
return refuse(err)
|
|
}
|
|
opKey, opCSR, err := genOperatorCSR(fp, true)
|
|
if err != nil {
|
|
return refuse(err)
|
|
}
|
|
// The person may have closed the window at the terminal, or it expired,
|
|
// during the exchange: the Host signs nothing then.
|
|
if !windowStillOpen(w.ID) {
|
|
return refuse(errors.New("the pairing window closed before the pairing finished; nothing was paired"))
|
|
}
|
|
hn, _ := os.Hostname()
|
|
var rr tcpRedeemResp
|
|
if err := windowPost(c, "/v1/pair/redeem", windowRedeemReq{V: 1, Proof: bastionProof(), WGPublicKey: wgPub,
|
|
OperatorCSR: opCSR, Hostname: hn, Platform: runtime.GOOS, Handle: handle}, &rr); err != nil {
|
|
return refuse(err)
|
|
}
|
|
if err := windowAnswerRefusal(rr, hostCA, opKey); err != nil {
|
|
return refuse(err)
|
|
}
|
|
// The redeemed name is authoritative, as on every other path.
|
|
if prev := pairingFor(cfgs, rr.hostName()); prev != nil && (existing == nil || prev.HostName != existing.HostName) {
|
|
if refusal := sameNameOtherHost(prev, wantCA); refusal != nil {
|
|
return refuse(refusal)
|
|
}
|
|
if err := replaceKeepsTransport(prev, transportTCP); err != nil {
|
|
return refuse(err)
|
|
}
|
|
existing, replace = prev, true
|
|
if prev.State == pairingRevoked {
|
|
state = pairingPending
|
|
} else {
|
|
state = prev.State
|
|
}
|
|
}
|
|
rr.LaneAddress = ""
|
|
cfg, err := tcpPairingConfig(rr, cfgs, existing, replace, wgPriv, opKey, fp, hostCA)
|
|
if err != nil {
|
|
return refuse(err)
|
|
}
|
|
cfg.HostDials, cfg.DoorAddr, cfg.LaneAddr = true, w.Door, ""
|
|
if !claimWindow(w.ID) {
|
|
return refuse(errors.New("the pairing window closed before the pairing finished; nothing was paired"))
|
|
}
|
|
cfg, all, err := commitPairing(cfg, existing, replace, state)
|
|
if err != nil {
|
|
return refuse(err)
|
|
}
|
|
if err := carryAnchorToNewName(existing, cfg); err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
|
|
}
|
|
took, aerr := anchorAfterWindow(cfg, all, w.Authority)
|
|
if aerr != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: %v\n", aerr)
|
|
}
|
|
if took.Taken {
|
|
if fresh, lerr := loadPairings(); lerr == nil {
|
|
if c := pairingFor(fresh, cfg.HostName); c != nil {
|
|
cfg = c
|
|
}
|
|
}
|
|
}
|
|
res := windowResult{Host: cfg.HostName, State: cfg.stateName(), Rotated: replace,
|
|
Taken: took.Taken, Why: took.Why, Replaced: took.Replaced, Waiting: took.Waiting}
|
|
if !cfg.active() {
|
|
res.AdmittedBy = admitter()
|
|
}
|
|
return cfg, res
|
|
}
|
|
|
|
// windowAnswerRefusal checks a Host's redeem answer before anything is saved:
|
|
// the window's transport, every field, a name that can be a file name, the CA
|
|
// the one its connection presented, the operator certificate ours.
|
|
func windowAnswerRefusal(rr tcpRedeemResp, hostCA, opKey string) error {
|
|
if rr.Transport != windowTransport {
|
|
return fmt.Errorf("the Host answered transport %q; a Host that connects to this server answers %q", rr.Transport, windowTransport)
|
|
}
|
|
for k, v := range map[string]string{"operator_cert": rr.operatorCert(), "ca_cert": rr.caCert(), "bearer": rr.Bearer,
|
|
"host_name": rr.hostName()} {
|
|
if v == "" {
|
|
return fmt.Errorf("redeem response missing %s", k)
|
|
}
|
|
}
|
|
if !validHostName(rr.hostName()) {
|
|
return fmt.Errorf("redeem response has an unsafe host name %q — refusing", rr.hostName())
|
|
}
|
|
if !sameCAs(rr.caCert(), hostCA) {
|
|
return errors.New("the Host answered with a CA other than the one its connection presented — refusing")
|
|
}
|
|
return operatorCertRefusal(rr.operatorCert(), hostCA, opKey)
|
|
}
|
|
|
|
// carryAnchorToNewName: the anchor's Host paired again through a window under
|
|
// another name, its CA the one the anchor's pairing pinned (doorPairingWithCA),
|
|
// so it is the same Host and stays in charge under its new name, its pinned
|
|
// verify key kept, as claim-anchor --repin does for a renamed anchor. Every
|
|
// other case leaves the anchor as it is, and so does a process without
|
|
// machine authority (every anchor write needs it, claim-anchor --repin's too).
|
|
func carryAnchorToNewName(existing, cfg *Config) error {
|
|
if existing == nil || cfg == nil || existing.HostName == cfg.HostName || !sameCAs(existing.CACert, cfg.CACert) {
|
|
return nil
|
|
}
|
|
if a := currentAnchor(); a == nil || a.AnchorHost != existing.HostName {
|
|
return nil
|
|
}
|
|
if !machineAuthorityHeld() {
|
|
return fmt.Errorf("%s was in charge of this machine as %s; moving that to its new name needs machine authority: `privatae-bastion claim-anchor --repin` (%s)",
|
|
cfg.HostName, existing.HostName, adminHowTo())
|
|
}
|
|
a := currentAnchor()
|
|
if a == nil || a.AnchorHost != existing.HostName {
|
|
return nil
|
|
}
|
|
f, err := loadAnchorFile()
|
|
if err != nil || f == nil {
|
|
return err
|
|
}
|
|
owner := a.Owner
|
|
if oc := currentOwnerClaim(); oc != nil && oc.Owner != "" {
|
|
owner = oc.Owner
|
|
}
|
|
if err := writeAnchorPreservingPin(owner, cfg.HostName, f.VerifyPubkey); err != nil {
|
|
return fmt.Errorf("%s was in charge of this machine as %s, and moving that to its new name failed: %w", cfg.HostName, existing.HostName, err)
|
|
}
|
|
pairingAudit("anchor_rename", cfg.HostName, "ok: in charge of this machine as before, now under this name (it was "+existing.HostName+")")
|
|
return nil
|
|
}
|
|
|
|
// doorPairingWithCA is the pairing whose Host connects to this server and
|
|
// whose pinned CA is hostCA, or nil.
|
|
func doorPairingWithCA(cfgs []*Config, hostCA string) *Config {
|
|
for _, o := range cfgs {
|
|
if o.hostDials() && sameCAs(o.CACert, hostCA) {
|
|
return o
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// doorCARefusal: two pairings whose Hosts connect to this server never pin
|
|
// the same CA, because the CA is how the door tells their connections apart.
|
|
func doorCARefusal(cfgs []*Config, existing *Config, hostCA string) error {
|
|
for _, o := range cfgs {
|
|
if existing != nil && o.HostName == existing.HostName {
|
|
continue
|
|
}
|
|
if o.hostDials() && sameCAs(o.CACert, hostCA) {
|
|
return fmt.Errorf("this Host is already paired with this server as %s; nothing was paired", o.HostName)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── pair --open ──────────────────────────────────────────────────────────────
|
|
|
|
// parsePairOpenArgs reads `pair --open [--port N]`. ok is false when the
|
|
// arguments are not a --open pair at all.
|
|
func parsePairOpenArgs(args []string) (addr string, ok bool, err error) {
|
|
port := ""
|
|
for i := 0; i < len(args); i++ {
|
|
switch {
|
|
case args[i] == "--open":
|
|
ok = true
|
|
case args[i] == "--port" && i+1 < len(args):
|
|
port = args[i+1]
|
|
i++
|
|
case strings.HasPrefix(args[i], "--port="):
|
|
port = strings.TrimPrefix(args[i], "--port=")
|
|
default:
|
|
if ok {
|
|
return "", true, fmt.Errorf("pair --open takes only --port <port>, not %s", args[i])
|
|
}
|
|
}
|
|
}
|
|
if !ok {
|
|
return "", false, nil
|
|
}
|
|
if port == "" {
|
|
return doorDefaultAddr, true, nil
|
|
}
|
|
var n int
|
|
if _, err := fmt.Sscanf(port, "%d", &n); err != nil || n < 1 || n > 65535 || fmt.Sprint(n) != port {
|
|
return "", true, fmt.Errorf("--port %q is not a port", port)
|
|
}
|
|
return ":" + port, true, nil
|
|
}
|
|
|
|
// cmdPairOpen opens the window, says what to give the Host, and waits for it.
|
|
func cmdPairOpen(addr string, out io.Writer) error {
|
|
if err := singletonGuardForPair(); err != nil {
|
|
return err
|
|
}
|
|
if _, err := loadPairings(); err != nil {
|
|
return err
|
|
}
|
|
code, err := newWindowCode()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id, err := genSecret(9)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := time.Now().UTC()
|
|
w := &pairWindow{ID: id, Code: code, Door: addr, Opened: now.Format(time.RFC3339),
|
|
Expires: now.Add(windowLifetime).Format(time.RFC3339), Authority: machineAuthorityHeld()}
|
|
// A door of this window's own, unless the running bastion's door holds the
|
|
// port: it serves the window then (it reads this file on every connection).
|
|
ln, lerr := listenDoor(addr)
|
|
if lerr != nil && pairDaemonRunning() && !doorHeldByRunningBastion(addr) {
|
|
// The running bastion may still hold the port for a pairing just
|
|
// removed (`unpair`): it lets go once it sees the file gone, within a
|
|
// supervision period. Wait for that rather than fail.
|
|
ln, lerr = awaitDoorReleased(addr, out)
|
|
}
|
|
if lerr != nil && !(pairDaemonRunning() && doorHeldByRunningBastion(addr)) {
|
|
return fmt.Errorf("cannot listen on port %s: %v. Another program holds it; choose another with --port", doorPort(addr), lerr)
|
|
}
|
|
windowFileMu.Lock()
|
|
err = saveWindowFile(w)
|
|
windowFileMu.Unlock()
|
|
if err != nil {
|
|
if ln != nil {
|
|
_ = ln.Close()
|
|
}
|
|
return err
|
|
}
|
|
// The way in through this machine's own firewall, while the window is
|
|
// open (doorfw.go). Released last, once the window file is gone: it stays
|
|
// if the pairing made here is active, as the daemon's door then serves it.
|
|
fwSaid := doorFirewallEnsure(addr)
|
|
defer doorFirewallRelease(addr)
|
|
// closed closes the window and says how it ended: a pairing a door had
|
|
// already claimed it for is reported, never "nothing was paired".
|
|
closed := func(why string, err error) error {
|
|
if removeWindow(id) {
|
|
if res := awaitClaimedResult(id); res != nil {
|
|
return reportWindow(out, res)
|
|
}
|
|
}
|
|
pairingAudit("window_closed", addr, why)
|
|
return err
|
|
}
|
|
defer removeWindow(id)
|
|
if ln != nil {
|
|
d := newHostDoor(addr, false)
|
|
go d.serve(ln)
|
|
defer ln.Close()
|
|
}
|
|
pairingAudit("window_opened", addr, "ok: a pairing window for one Host until "+w.Expires)
|
|
printWindow(out, w, fwSaid)
|
|
|
|
sig := make(chan os.Signal, 1)
|
|
signal.Notify(sig, os.Interrupt)
|
|
defer signal.Stop(sig)
|
|
tick := time.NewTicker(windowPollEvery)
|
|
defer tick.Stop()
|
|
for {
|
|
select {
|
|
case <-sig:
|
|
return closed("closed by the person at this server", errors.New("the pairing window is closed; nothing was paired"))
|
|
case <-tick.C:
|
|
}
|
|
if claimedWindow(id) != nil {
|
|
if res := awaitClaimedResult(id); res != nil {
|
|
return reportWindow(out, res)
|
|
}
|
|
}
|
|
cur := readWindowFile()
|
|
if cur == nil || cur.ID != id {
|
|
return errors.New("another pairing window was opened on this server, so this one is closed; nothing was paired here")
|
|
}
|
|
if cur.Result != nil {
|
|
return reportWindow(out, cur.Result)
|
|
}
|
|
if cur.expired(time.Now()) {
|
|
return closed("closed after "+windowLifetime.String(), fmt.Errorf("the pairing window closed after %d minutes and nothing was paired. Run `privatae-bastion pair --open` again when your Host is ready", int(windowLifetime.Minutes())))
|
|
}
|
|
}
|
|
}
|
|
|
|
// doorReleaseWait is how long `pair --open` waits for the running bastion to
|
|
// let go of the door's port after its last pairing there was removed: a
|
|
// supervision period for it to see that (run.go superviseOnce), and the
|
|
// pairing's stop. A variable so a test can shorten it.
|
|
var doorReleaseWait = supervisePeriod + 10*time.Second
|
|
|
|
// awaitDoorReleased binds addr once the running bastion lets go of it, or
|
|
// returns the last bind error after doorReleaseWait. Only an address in use
|
|
// is waited for; any other error is returned at once.
|
|
func awaitDoorReleased(addr string, out io.Writer) (net.Listener, error) {
|
|
deadline := time.Now().Add(doorReleaseWait)
|
|
said := false
|
|
for {
|
|
ln, err := listenDoor(addr)
|
|
if err == nil || !addrInUse(err) || !time.Now().Before(deadline) {
|
|
return ln, err
|
|
}
|
|
if !said {
|
|
fmt.Fprintf(out, "Port %s is in use, most likely by the bastion running here for a pairing that was removed; waiting for it to let go (at most %d seconds)...\n",
|
|
doorPort(addr), int(doorReleaseWait.Seconds()))
|
|
said = true
|
|
}
|
|
time.Sleep(doorReleasePoll)
|
|
}
|
|
}
|
|
|
|
// addrInUse: err is a bind refused because the address is in use (Windows
|
|
// says so as WSAEADDRINUSE, 10048).
|
|
func addrInUse(err error) bool {
|
|
var errno syscall.Errno
|
|
return errors.As(err, &errno) && (errno == syscall.EADDRINUSE || uintptr(errno) == 10048)
|
|
}
|
|
|
|
// doorReleasePoll is how often awaitDoorReleased tries the port again.
|
|
var doorReleasePoll = 250 * time.Millisecond
|
|
|
|
// doorHeldByRunningBastion: a pairing served here has its Host connect on
|
|
// addr, so the running bastion's door is what holds the port.
|
|
func doorHeldByRunningBastion(addr string) bool {
|
|
cfgs, err := loadPairings()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, c := range cfgs {
|
|
if c.hostDials() && c.active() && c.doorAddr() == addr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// printWindow says, in the person's words, what to give their Host.
|
|
func printWindow(out io.Writer, w *pairWindow, fwSaid string) {
|
|
port := doorPort(w.Door)
|
|
fmt.Fprintf(out, "Your Host connects to this server. On your Host, choose \"Add a server\" and give it:\n\n")
|
|
fmt.Fprintf(out, " address: this server's address, port %s\n", port)
|
|
if addrs := machineAddrs(); len(addrs) > 0 {
|
|
fmt.Fprintf(out, " (this server's own addresses: %s - use the one your Host can reach)\n", strings.Join(addrs, ", "))
|
|
}
|
|
fmt.Fprintf(out, " code: %s\n\n", formatWindowCode(w.Code))
|
|
fmt.Fprintf(out, "The code works once, for %d minutes. Nothing on your Host's network needs opening;\n", int(windowLifetime.Minutes()))
|
|
fmt.Fprintf(out, "this server needs TCP port %s reachable from the internet.\n", port)
|
|
if fwSaid != "" {
|
|
fmt.Fprintln(out, fwSaid)
|
|
}
|
|
fmt.Fprintf(out, "Waiting for your Host... (Ctrl-C closes the window)\n")
|
|
}
|
|
|
|
// machineAddrs is this machine's own non-loopback unicast addresses.
|
|
func machineAddrs() []string {
|
|
ifs, err := net.InterfaceAddrs()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, a := range ifs {
|
|
ipn, ok := a.(*net.IPNet)
|
|
if !ok || ipn.IP.IsLoopback() || ipn.IP.IsLinkLocalUnicast() || ipn.IP.IsMulticast() {
|
|
continue
|
|
}
|
|
out = append(out, ipn.IP.String())
|
|
if len(out) == 6 {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// reportWindow prints how the window ended: the pairing as `pair` reports
|
|
// one, or why nothing was paired.
|
|
func reportWindow(out io.Writer, res *windowResult) error {
|
|
if res.Error != "" {
|
|
return errors.New(res.Error)
|
|
}
|
|
cfgs, err := loadPairings()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg := pairingFor(cfgs, res.Host)
|
|
if cfg == nil {
|
|
return fmt.Errorf("the window reported %s paired, but no pairing for it is on this server", res.Host)
|
|
}
|
|
fmt.Fprintln(out)
|
|
pairReport(out, cfg, cfgs, anchorTake{Taken: res.Taken, Why: res.Why, Replaced: res.Replaced, Waiting: res.Waiting})
|
|
return nil
|
|
}
|