bridge

package
v0.3.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 19, 2026 License: AGPL-3.0 Imports: 26 Imported by: 0

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

View Source
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.

View Source
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.

View Source
const (
	// KeyLen is the Ed25519 seed/public-key size shared by the host
	// key, device keys, and the QR's hk param.
	KeyLen = 32
)
View Source
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

View Source
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

func CanonicalRequest(ts int64, nonceHex, method, requestURI string, body []byte) []byte

CanonicalRequest is the exact byte string a request signature covers. Tampering with any component — freshness, nonce, method, target, or body — breaks the signature.

func DecodeKey

func DecodeKey(s string) ([]byte, error)

DecodeKey parses an encoded public key, enforcing exact length.

func DecodeSig

func DecodeSig(s string) ([]byte, error)

DecodeSig parses an encoded signature, enforcing exact length.

func DeriveSAS

func DeriveSAS(attemptID, commitHex string, nonceD, devicePub, nonceP, hostPub []byte) string

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

func EncodeKey(key []byte) string

EncodeKey renders a 32-byte key (host or device public key) for QR links and headers: base64url without padding.

func EncodeSig

func EncodeSig(sig []byte) string

EncodeSig renders an Ed25519 signature: base64url without padding.

func ProbeHandler

func ProbeHandler(store *Store) http.Handler

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

func SASCommit(devicePub, nonceP []byte) string

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

func VerifySASReply(hostPub []byte, attemptID, commitHex string, nonceD []byte, sigB64 string) bool

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

func (a *Authenticator) Verify(r *http.Request) (auth.Principal, error)

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) Close

func (l *Listeners) Close()

Close stops every listener.

func (*Listeners) LastStatus

func (l *Listeners) LastStatus() Status

LastStatus returns the snapshot from the most recent Refresh.

func (*Listeners) Refresh

func (l *Listeners) Refresh(ctx context.Context) Status

Refresh re-runs discovery, reconciles listeners to the policy, and returns the resulting snapshot. Bind failures are recorded, never fatal — the daemon runs bridgeless rather than dying.

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

func CurrentNetwork(ctx context.Context) Network

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):

  1. 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.
  2. 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.
  3. 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

func NewPairing(store *Store, now func() time.Time) *Pairing

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

func (p *Pairing) Complete(typed string) (device string, err error)

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

func (p *Pairing) RefreshWindow() []string

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.

func (*Pairing) Reveal

func (p *Pairing) Reveal(id string, devicePub, nonceP []byte) error

Reveal opens the phone's commit: it verifies device_pub + nonce_P hash to the stored commit, then derives and stores the SAS. A reveal that doesn't open the commit burns the attempt.

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

func OpenStore(path string) (*Store, error)

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

func (s *Store) AddDevice(pub []byte, name string) error

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

func (s *Store) HostPublicKey() []byte

HostPublicKey returns the laptop's identity public key — the QR's hk param and the probe verification anchor on the phone.

func (*Store) NetworkTrusted

func (s *Store) NetworkTrusted(fingerprint string) bool

NetworkTrusted reports whether the fingerprinted network has been consented to for plain-LAN serving. Empty fingerprints (detection failed) are never trusted.

func (*Store) RemoveAllDevices

func (s *Store) RemoveAllDevices() (int, error)

RemoveAllDevices revokes every phone (the host key stays — returning phones still recognize the laptop, they just have to re-pair).

func (*Store) RemoveDevice

func (s *Store) RemoveDevice(pub []byte) (bool, error)

RemoveDevice revokes one phone. Reports whether the key was present.

func (*Store) SignNonce

func (s *Store) SignNonce(nonce []byte) []byte

SignNonce answers an identity probe: the host key's signature over the phone-chosen nonce.

func (*Store) SignSASReply

func (s *Store) SignSASReply(attemptID, commitHex string, nonceD []byte) []byte

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

func (s *Store) TouchDevice(pub []byte) error

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

func (s *Store) TrustNetwork(fingerprint, label string) error

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

func DiscoverTailnet(ctx context.Context) *Tailnet

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL