Documentation
¶
Overview ¶
Package bridge is the daemon's durable laptop↔phone connection, built on per-device public keys — nothing secret ever crosses the wire. The laptop holds one Ed25519 host keypair (its identity: the public key rides the pairing QR, and probes are answered by signing the phone's nonce) plus a registry of approved phone public keys. Phones sign every request; the daemon verifies against the registry and a nonce cache kills replays.
The wire/crypto contract here is mirrored byte-for-byte in clank-mobile (src/lib/bridgeCrypto.ts) — change one only with the other, and keep the shared test vectors in sync.
Index ¶
- Constants
- Variables
- func CanonicalRequest(ts int64, nonceHex, method, requestURI string, body []byte) []byte
- func DecodeKey(s string) ([]byte, error)
- func DecodeSig(s string) ([]byte, error)
- func DeriveSAS(attemptID, commitHex string, nonceD, devicePub, nonceP, hostPub []byte) string
- func EncodeKey(key []byte) string
- func EncodeSig(sig []byte) string
- func ProbeHandler(store *Store) http.Handler
- func SASCommit(devicePub, nonceP []byte) string
- func SignRequest(priv ed25519.PrivateKey, ts int64, nonceHex, method, requestURI string, ...) string
- func VerifySASReply(hostPub []byte, attemptID, commitHex string, nonceD []byte, sigB64 string) bool
- type AttemptState
- type Authenticator
- type BindStatus
- type DeviceRecord
- type ListenerOptions
- type Listeners
- type Network
- type Pairing
- func (p *Pairing) Begin(device, commitHex string) (id string, nonceD []byte, replySig string, err error)
- func (p *Pairing) Complete(typed string) (device string, err error)
- func (p *Pairing) PollAttempt(id string) AttemptState
- func (p *Pairing) RefreshWindow() []string
- func (p *Pairing) Reveal(id string, devicePub, nonceP []byte) error
- type Status
- type Store
- func (s *Store) AddDevice(pub []byte, name string) error
- func (s *Store) Device(pub []byte) (DeviceRecord, bool)
- func (s *Store) Devices() []DeviceRecord
- func (s *Store) HostPublicKey() []byte
- func (s *Store) NetworkTrusted(fingerprint string) bool
- func (s *Store) RemoveAllDevices() (int, error)
- func (s *Store) RemoveDevice(pub []byte) (bool, error)
- func (s *Store) SignNonce(nonce []byte) []byte
- func (s *Store) SignSASReply(attemptID, commitHex string, nonceD []byte) []byte
- func (s *Store) TouchDevice(pub []byte) error
- func (s *Store) TrustNetwork(fingerprint, label string) error
- type Tailnet
- type TrustedNetwork
Constants ¶
const ( // HeaderKey is the phone's Ed25519 public key, base64url-nopad. HeaderKey = "X-Clank-Key" // HeaderTimestamp is unix seconds, decimal — freshness bound. HeaderTimestamp = "X-Clank-Ts" // HeaderNonce is 16 random bytes, lowercase hex — one-time. HeaderNonce = "X-Clank-Nonce" // HeaderSignature is the Ed25519 signature, base64url-nopad. HeaderSignature = "X-Clank-Sig" )
Signed-request headers. Every authenticated bridge request carries all four; the signature covers the canonical request string below.
const DefaultPort = 7880
DefaultPort is the bridge listener's fixed port — the phone's stored gateway URLs must survive daemon restarts. 7879 belongs to clank-auth-stub; stay clear.
const ( // KeyLen is the Ed25519 seed/public-key size shared by the host // key, device keys, and the QR's hk param. KeyLen = 32 )
const (
// SASDigits is the length of the human-typed code.
SASDigits = 6
)
The SAS pairing handshake authenticates WHICH device public key the laptop enrolls, closing the active-MITM hole at pairing without a TLS pin. It's the Vaudenay short-authentication-string model plus the optical channel we already have:
- commit-then-reveal: the phone commits to (device key ‖ nonce) before it sees the daemon's nonce, so no side can grind its contribution to force a SAS collision after the fact — that's what makes 6 digits enough (grinding is online-only, throttled by the window lease + pending cap + lockout).
- the daemon signs its reply with the host key; the phone verifies against the hk it learned from the QR BEFORE showing anything, so a MITM is caught immediately in the daemon→phone direction and forced to relay the real commit.
- both sides derive the same 6-digit SAS from the full transcript; the phone displays it (never sent on the wire), the user types it at the laptop, and that authenticates the phone→laptop direction.
The whole contract is mirrored byte-for-byte in clank-mobile (src/lib/bridgeCrypto.ts) — shared vectors in sas_test.go ↔ bridgeCrypto.test.ts. No ECDH: we authenticate a datum (the pubkey), we don't establish a channel.
Variables ¶
var ( // ErrPairWindowClosed: Begin with no CLI showing the QR. ErrPairWindowClosed = errors.New("bridge: no pairing window open — run `clank pair` or `clank preview` on the laptop") // ErrPairTooManyPending: pending-attempt cap reached. ErrPairTooManyPending = errors.New("bridge: too many pending pairing attempts — wait a moment and rescan") // ErrPairLockedOut: too many wrong codes; temporarily locked. ErrPairLockedOut = errors.New("bridge: pairing locked after repeated wrong codes — wait a moment") // ErrPairCodeMismatch: typed code matched no waiting phone. ErrPairCodeMismatch = errors.New("bridge: that code matches no waiting phone") // ErrPairAmbiguous: typed code matched more than one attempt; both // are expired and the phones must rescan (a derived SAS can't be // redrawn to break the tie). ErrPairAmbiguous = errors.New("bridge: that code matched two phones — both were cancelled, please rescan") // ErrPairBadCommit: Begin without a valid commitment. ErrPairBadCommit = errors.New("bridge: pairing requires a valid commitment") // ErrPairBadKey: Reveal without a valid device public key/nonce. ErrPairBadKey = errors.New("bridge: pairing reveal requires the phone's public key") // ErrPairNoAttempt: Reveal for an unknown or expired attempt. ErrPairNoAttempt = errors.New("bridge: pairing attempt not found or expired — rescan") // ErrPairCommitMismatch: revealed values don't open the commitment. ErrPairCommitMismatch = errors.New("bridge: pairing reveal did not match the commitment — rescan") )
Functions ¶
func CanonicalRequest ¶
CanonicalRequest is the exact byte string a request signature covers. Tampering with any component — freshness, nonce, method, target, or body — breaks the signature.
func DeriveSAS ¶
DeriveSAS derives the 6-digit code from the full transcript — both nonces, the committed device key, and the host key. Identical on both sides; the phone displays it, the laptop user types it.
func EncodeKey ¶
EncodeKey renders a 32-byte key (host or device public key) for QR links and headers: base64url without padding.
func ProbeHandler ¶
ProbeHandler answers the phone's identity challenge: an unauthenticated route on the bridge listener. The phone sends a random nonce and gets the host key's signature over it, proving this address is really its laptop BEFORE it ever transmits anything — a remembered IP that got reassigned (or squatted) can't answer, so the phone walks away.
func SASCommit ¶
SASCommit hashes the device public key and the phone's nonce into the opaque commitment the phone sends first. Collision resistance is what stops a MITM from later opening the commit to a different key.
func SignRequest ¶
func SignRequest(priv ed25519.PrivateKey, ts int64, nonceHex, method, requestURI string, body []byte) string
SignRequest produces the HeaderSignature value for a request — the client half of the contract, also used by tests and the probe's verification path in reverse.
func VerifySASReply ¶
VerifySASReply checks the daemon's reply signature against the host public key the phone learned from the QR — the phone's parity with the daemon, exercised here so the shared vectors cover both sides.
Types ¶
type AttemptState ¶
type AttemptState string
AttemptState is the phone-visible lifecycle of one attempt.
const ( AttemptPending AttemptState = "pending" AttemptApproved AttemptState = "approved" AttemptExpired AttemptState = "expired" )
type Authenticator ¶
type Authenticator struct {
// contains filtered or unexported fields
}
Authenticator verifies per-request Ed25519 signatures against the approved-device registry. Implements pkg/auth.Authenticator, so the bridge listener is the daemon's existing handler behind auth.Middleware(authenticator) — no proxy, no second API surface.
It also remembers the most recent successful connection (device name + time), in memory only: liveness display, not audit log.
func NewAuthenticator ¶
func NewAuthenticator(store *Store, userID string, lg *log.Logger, now func() time.Time) *Authenticator
NewAuthenticator wires the registry to the single-user principal the laptop daemon runs as (same identity the old front door used). now==nil uses the wall clock.
func (*Authenticator) LastConnection ¶
func (a *Authenticator) LastConnection() (device string, at time.Time)
LastConnection reports the most recent authenticated device and when it was seen. Zero time = never (this daemon run).
func (*Authenticator) MintSessionToken ¶
func (a *Authenticator) MintSessionToken(devicePub []byte) (token string, expiresAt time.Time, err error)
MintSessionToken issues a static short-TTL bearer bound to an approved device. Callers gate this behind a SIGNED request (the runtime's /bridge/session-token route) — a token can never mint another token.
func (*Authenticator) Verify ¶
Verify checks the four signature headers: the key must be an approved device, the timestamp fresh, the nonce unseen, and the signature valid over the canonical request (which covers the body — read here and restored for the downstream handler). Every failure is the same ErrUnauthenticated: an unpaired probe learns nothing about which check tripped.
type BindStatus ¶
type BindStatus struct {
IP string `json:"ip"`
Reason bindReason `json:"reason"`
Err string `json:"err,omitempty"`
}
BindStatus reports one address the last Refresh wanted, and how the bind went ("" = serving).
type DeviceRecord ¶
type DeviceRecord struct {
PubKey string `json:"pubkey"`
Name string `json:"name"`
AddedAt time.Time `json:"added_at"`
LastSeen *time.Time `json:"last_seen,omitempty"`
}
DeviceRecord is one approved phone in the registry — the bridge's authorized_keys line. PubKey (base64url Ed25519) is the identity; Name is cosmetic attribution, never an authorization input.
type ListenerOptions ¶
type ListenerOptions struct {
Port int
Handler http.Handler
Store *Store
Log *log.Logger
LANIP func() (net.IP, error)
Tailnet func(context.Context) *Tailnet
Network func(context.Context) Network
}
ListenerOptions configures Listeners. The discovery funcs default to the real implementations; tests inject fakes.
type Listeners ¶
type Listeners struct {
// contains filtered or unexported fields
}
Listeners owns one http.Server per bound address, sharing the bridge handler. Refresh reconciles the running set against the policy; it runs on demand (daemon start, admin calls, preview start), not on a timer.
func NewListeners ¶
func NewListeners(opts ListenerOptions) *Listeners
NewListeners builds the manager; call Refresh to bind.
func (*Listeners) LastStatus ¶
LastStatus returns the snapshot from the most recent Refresh.
type Network ¶
type Network struct {
Fingerprint string `json:"fingerprint,omitempty"` // sha256(mac|subnet) hex; "" when undetectable
Label string `json:"label,omitempty"` // human hint for prompts/status, e.g. "router aa:bb:… (192.168.1.0/24)"
}
Network identifies the LAN the laptop currently sits on, for keying per-network trust. The fingerprint hashes the default gateway's MAC + the local subnet — permission-free (SSID needs location perms on modern macOS) and the same signal class Windows uses for its private/public network memory. It's a consent key, not a security boundary: spoofing it only replays a consent the user already gave somewhere.
func CurrentNetwork ¶
CurrentNetwork fingerprints the active default-route network. Best-effort: any failure yields Fingerprint "" (treated as untrusted everywhere). TODO(ai-review): no Windows branch — defaultGatewayIP/gatewayMAC only know darwin/linux, so Windows always falls back to Tailscale-only. https://github.com/supaclank/clank/pull/175#discussion_r3609121605
type Pairing ¶
type Pairing struct {
// contains filtered or unexported fields
}
Pairing is the SAS approval ceremony. A new phone scans the QR (addresses + the laptop's host public key, hk), then runs a commit-then-reveal handshake so the 6-digit code the user types authenticates WHICH device key gets enrolled — closing the active-MITM hole at pairing without a TLS pin (see sas.go):
- Begin: phone sends its name + a commit = H(device_pub ‖ nonce_P). The daemon picks nonce_D, opens an attempt, and returns nonce_D plus a host-key signature over (attempt ‖ commit ‖ nonce_D). The phone verifies that against hk before trusting anything.
- Reveal: phone sends device_pub + nonce_P; the daemon checks they open the commit and derives the SAS. Both sides now hold the same 6 digits — the phone displays them, never sending them.
- Complete: the laptop user types the SAS; the daemon approves the revealed attempt whose derived SAS matches and records its key.
Nothing secret ever crosses the wire. The window is open only while a CLI is showing the QR (it leases the window by polling).
func NewPairing ¶
NewPairing wires the ceremony to the device registry. now==nil uses the wall clock.
func (*Pairing) Begin ¶
func (p *Pairing) Begin(device, commitHex string) (id string, nonceD []byte, replySig string, err error)
Begin opens an attempt for a scanning phone: it records the phone's name + commitment, picks the daemon nonce, and returns that nonce with a host-key signature the phone verifies against hk. Pre-auth by nature — window gating, the pending cap, and the lockout are the whole defense until the SAS is typed.
func (*Pairing) Complete ¶
Complete consumes the SAS typed at the laptop, approving the revealed attempt whose derived SAS matches by recording its public key. A miss burns one wrong-entry; a code matching two attempts expires both (a derived SAS can't be redrawn to break the tie).
func (*Pairing) PollAttempt ¶
func (p *Pairing) PollAttempt(id string) AttemptState
PollAttempt reports an attempt's state to the polling phone. Approval carries no payload — the phone's own key is now trusted, and its next signed request just works.
func (*Pairing) RefreshWindow ¶
RefreshWindow keeps the pairing window open — the CLI calls it each tick while the QR is up — and returns the device names of phones that have revealed and are waiting for the user to type their code.
type Status ¶
type Status struct {
Port int `json:"port"`
Binds []BindStatus `json:"binds"`
Tailnet *Tailnet `json:"tailnet,omitempty"`
LANIP string `json:"lan_ip,omitempty"`
Network Network `json:"network"`
// NetworkTrusted mirrors store consent for the CURRENT network so
// the CLI can decide whether to prompt.
NetworkTrusted bool `json:"network_trusted"`
}
Status is the transport snapshot the admin surface exposes.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store owns bridge.json: the host identity key, the approved-device registry, and the per-network LAN consents. Safe for concurrent use.
func OpenStore ¶
OpenStore loads bridge.json at path, minting a host keypair (and the file) when none exists. Files from the retired shared-secret model load cleanly: their network consents survive, the old secret is dropped, and a host key is minted in its place.
func (*Store) AddDevice ¶
AddDevice approves a phone's public key, upserting by key: a re-approved device gets a fresh record (re-pairing is re-trust).
func (*Store) Device ¶
func (s *Store) Device(pub []byte) (DeviceRecord, bool)
Device looks up an approved phone by public key.
func (*Store) Devices ¶
func (s *Store) Devices() []DeviceRecord
Devices returns the registry, pairing order preserved.
func (*Store) HostPublicKey ¶
HostPublicKey returns the laptop's identity public key — the QR's hk param and the probe verification anchor on the phone.
func (*Store) NetworkTrusted ¶
NetworkTrusted reports whether the fingerprinted network has been consented to for plain-LAN serving. Empty fingerprints (detection failed) are never trusted.
func (*Store) RemoveAllDevices ¶
RemoveAllDevices revokes every phone (the host key stays — returning phones still recognize the laptop, they just have to re-pair).
func (*Store) RemoveDevice ¶
RemoveDevice revokes one phone. Reports whether the key was present.
func (*Store) SignNonce ¶
SignNonce answers an identity probe: the host key's signature over the phone-chosen nonce.
func (*Store) SignSASReply ¶
SignSASReply signs the pairing handshake reply with the host key, binding the daemon's nonce to the phone's commit. The phone verifies it against the QR's hk before deriving the SAS.
func (*Store) TouchDevice ¶
TouchDevice bumps a device's last_seen. In-memory state is always current; disk writes are debounced (touchFlushInterval) because this runs on every authenticated request.
func (*Store) TrustNetwork ¶
TrustNetwork records LAN consent for the fingerprinted network.
type Tailnet ¶
type Tailnet struct {
IP string `json:"ip"` // 100.64/10 address, always set when active
DNSName string `json:"dns_name,omitempty"` // MagicDNS name, best-effort (needs the CLI)
}
Tailnet describes the laptop's Tailscale presence, when any.
func DiscoverTailnet ¶
DiscoverTailnet reports the laptop's tailnet address, nil when Tailscale isn't up. The interface scan (100.64/10) is the primary, install-flavor-agnostic signal; the CLI only enriches with the MagicDNS name and is best-effort.
type TrustedNetwork ¶
type TrustedNetwork struct {
AddedAt time.Time `json:"added_at"`
Label string `json:"label,omitempty"`
}
TrustedNetwork records a per-network LAN consent ("trust this LAN?" answered yes), keyed in the store by the network fingerprint (netid.go). Label is a best-effort human hint for `clank pair status`, never an approval input.