Files

119 lines
3.8 KiB
Go

// auth.go: authentication between the hub and its peers.
//
// The transport is NOT assumed trustworthy. It could be Tailscale,
// ZeroTier, WireGuard, or a port opened straight to the internet: that's
// up to whoever runs it, not us. So the connection defends itself.
//
// The token is REQUIRED and generated by the program (nobody gets to pick
// "1234"), and it never travels over the network: the hub sends a random
// challenge and the peer replies with an HMAC of it, keyed by the token.
// Anyone listening to the traffic doesn't get the token, and can't replay
// an old response because the challenge changes on every connection.
//
// This proves the PEER's identity to the hub. It doesn't encrypt
// anything by itself — that's TLS's job now, one layer up (see
// tlscert.go/pin.go), which in turn proves the HUB's identity to the
// peer via certificate pinning. Neither layer replaces the other.
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
)
const (
tokenBytes = 16 // 128 bits
nonceBytes = 16
// Deadline to authenticate. A connection that goes quiet after the
// challenge gets closed instead of holding a slot forever.
authTimeoutSeconds = 10
// Cap on simultaneous connections: keeps someone from starving the
// port of resources by opening sockets.
maxPeerConns = 8
)
func tokenPath() string {
if dir, ok := exeDir(); ok {
return filepath.Join(dir, "token.txt")
}
return "token.txt"
}
// tokenFile is token.txt's on-disk shape.
type tokenFile struct {
Token string `json:"token"`
}
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// resolveToken decides the effective token. If the config has one, that
// wins (for anyone who wants to pick it by hand or share a pre-agreed
// one). Otherwise it uses the one in token.txt, generating and saving one
// if that doesn't exist either.
//
// Also returns whether it was just generated, so it can be announced
// loudly on the console: that's what needs to be handed to your partner.
func resolveToken(cfg config) (token string, generated bool, err error) {
if t := strings.TrimSpace(cfg.Token); t != "" {
return t, false, nil
}
path := tokenPath()
if data, rerr := os.ReadFile(path); rerr == nil {
var tf tokenFile
if jerr := json.Unmarshal(data, &tf); jerr == nil {
if t := strings.TrimSpace(tf.Token); t != "" {
return t, false, nil
}
}
}
t, gerr := randomHex(tokenBytes)
if gerr != nil {
return "", false, fmt.Errorf("couldn't generate a token: %w", gerr)
}
blob, merr := json.MarshalIndent(tokenFile{Token: t}, "", " ")
if merr != nil {
return "", false, fmt.Errorf("couldn't encode token.txt: %w", merr)
}
if werr := os.WriteFile(path, blob, 0600); werr != nil {
return "", false, fmt.Errorf("couldn't save token.txt: %w", werr)
}
return t, true, nil
}
// proofFor computes the answer to the challenge: HMAC-SHA256(token, nonce).
func proofFor(token, nonce string) string {
m := hmac.New(sha256.New, []byte(token))
m.Write([]byte(nonce))
return hex.EncodeToString(m.Sum(nil))
}
// proofValid compares in constant time. With == the comparison short-
// circuits at the first differing byte, which leaks information via timing.
func proofValid(token, nonce, got string) bool {
want := proofFor(token, nonce)
return subtle.ConstantTimeCompare([]byte(want), []byte(got)) == 1
}
func logTokenBanner(token string, generated bool) {
if generated {
log.Printf("generated a new token and saved it to token.txt")
}
log.Printf("connection token: %s", token)
log.Printf("your partner needs to put THAT exact token in their config.toml; without it, their connection gets rejected")
}