main ece7682

bootstrap.go RawHistoryFolder

1package quic
2
3import (
4	"bytes"
5	"context"
6	"crypto/rand"
7	"crypto/sha256"
8	"crypto/subtle"
9	"encoding/base64"
10	"encoding/hex"
11	"encoding/json"
12	"errors"
13	"fmt"
14	"io"
15	"net"
16	"strconv"
17	"sync"
18	"time"
19
20	"github.com/bnema/vev/internal/protocol/wire"
21)
22
23// Bootstrap normative bounds: ephemeral listener/certificate,
24// 32-byte token, nonce, ≤4 KiB readiness, ≤15-second expiry, exact
25// SHA-256 pin, one bounded auth record, atomic one-time consumption,
26// ≤4 unauthenticated peers, 3-second auth deadline, exactly one
27// bidirectional stream, private IPC only after auth, deterministic
28// process cleanup.
29const (
30	bootstrapTokenBytes   = 32
31	bootstrapNonceBytes   = 16
32	bootstrapMaxReadiness = 4 << 10
33	bootstrapExpiry       = 15 * time.Second
34	bootstrapAuthDeadline = 3 * time.Second
35	bootstrapMaxPending   = 4
36	// bootstrapSchemaVersion versions the readiness record shape
37	// independently of the session protocol epoch.
38	bootstrapSchemaVersion = 1
39	// bootstrapMaxAuthRecord bounds the single auth envelope payload
40	// before JSON parsing: token + nonce in base64 plus record
41	// framing cannot legitimately approach this size.
42	bootstrapMaxAuthRecord = 512
43)
44
45// Readiness is the ≤4 KiB JSON record the bootstrap server prints on
46// stdout. The client parses exactly one line: schema version, port,
47// token, nonce, fingerprint, and expiry bind the QUIC dial that
48// follows. The port is host-independent: the client composes it with
49// the SSH target host, never trusting a remote loopback address. The
50// token travels base64; no secret beyond it leaves the SSH channel,
51// and the token is consumed atomically on first use.
52type Readiness struct {
53	Version     int    `json:"version"`
54	Port        int    `json:"port"`
55	Token       string `json:"token"`
56	Nonce       string `json:"nonce"`
57	Fingerprint string `json:"fingerprint"`
58	ExpiresAt   int64  `json:"expires_at"`
59}
60
61var (
62	errBootstrapExpired   = errors.New("quic: bootstrap readiness expired")
63	errBootstrapConsumed  = errors.New("quic: bootstrap token already consumed")
64	errBootstrapMalformed = errors.New("quic: malformed bootstrap readiness")
65	errBootstrapAuth      = errors.New("quic: bootstrap authentication failed")
66)
67
68// Server is the ephemeral bootstrap endpoint: one QUIC listener with a
69// fresh certificate, one atomic token, and deterministic cleanup. The
70// token is consumed exactly once by the first authenticated stream;
71// every other attempt fails closed.
72type Server struct {
73	listener *Listener
74	// Only verifiers are stored: the raw token and nonce buffers are
75	// erased as soon as the readiness record is encoded, so a process
76	// memory disclosure never yields the reusable credential itself.
77	tokenVerifier []byte
78	nonceVerifier []byte
79	expires       time.Time
80	fingerprint   []byte
81
82	consumedMu sync.Mutex
83	consumed   bool
84
85	closeOnce sync.Once
86	closeErr  error
87}
88
89// NewServer mints an ephemeral certificate, binds a wildcard QUIC
90// listener, and returns the server plus its readiness record. The
91// caller prints exactly one readiness line on stdout. Only the port
92// is published: the client resolves the SSH target host itself.
93//
94// The production bootstrap listener is intentionally reachable from
95// the remote SSH peer, not only over a local tunnel: it binds the
96// wildcard address so both IPv4 and IPv6 (dual-stack where the OS
97// supports it) are served. Generic ListenConfig callers retain the
98// safe loopback default. Listener options (for example
99// WithListenerRuntimeObserver) forward to the underlying listener.
100func NewServer(opts ...ListenerOption) (*Server, Readiness, error) {
101	cert, fingerprint, err := GenerateEphemeralCert()
102	if err != nil {
103		return nil, Readiness{}, err
104	}
105	token := make([]byte, bootstrapTokenBytes)
106	if _, err := rand.Read(token); err != nil {
107		return nil, Readiness{}, err
108	}
109	defer zeroBytes(token)
110	nonce := make([]byte, bootstrapNonceBytes)
111	if _, err := rand.Read(nonce); err != nil {
112		return nil, Readiness{}, err
113	}
114	defer zeroBytes(nonce)
115	listener, err := ListenConfig(":0", cert, Config{HandshakeIdleTimeout: bootstrapAuthDeadline}, bootstrapMaxPending, opts...)
116	if err != nil {
117		return nil, Readiness{}, err
118	}
119	port := listenerPort(listener)
120	if port <= 0 {
121		_ = listener.Close()
122		return nil, Readiness{}, errBootstrapMalformed
123	}
124	expires := time.Now().Add(bootstrapExpiry)
125	server := &Server{
126		listener:      listener,
127		tokenVerifier: verifier(token),
128		nonceVerifier: verifier(nonce),
129		expires:       expires,
130		fingerprint:   fingerprint,
131	}
132	return server, Readiness{
133		Version:     bootstrapSchemaVersion,
134		Port:        port,
135		Token:       base64.StdEncoding.EncodeToString(token),
136		Nonce:       base64.StdEncoding.EncodeToString(nonce),
137		Fingerprint: hex.EncodeToString(fingerprint),
138		ExpiresAt:   expires.Unix(),
139	}, nil
140}
141
142// listenerPort extracts the bound port in host-independent form.
143func listenerPort(listener *Listener) int {
144	_, port, err := net.SplitHostPort(listener.Addr())
145	if err != nil {
146		return 0
147	}
148	parsed, err := strconv.Atoi(port)
149	if err != nil {
150		return 0
151	}
152	return parsed
153}
154
155// EncodeReadiness marshals one readiness line (no trailing newline;
156// the caller adds exactly one).
157func EncodeReadiness(readiness Readiness) ([]byte, error) {
158	raw, err := json.Marshal(readiness)
159	if err != nil {
160		return nil, err
161	}
162	if len(raw) > bootstrapMaxReadiness {
163		return nil, errBootstrapMalformed
164	}
165	return raw, nil
166}
167
168// ParseReadiness parses and validates one readiness line: schema
169// version, size, JSON shape, token/nonce/port/fingerprint presence,
170// and expiry. The remote server is authoritative for expiry during auth;
171// client-side parsing rejects only implausibly long-lived credentials and
172// allows bounded host-clock skew. Secret values never appear in the error.
173func ParseReadiness(line []byte) (Readiness, error) {
174	line = bytes.TrimSpace(line)
175	if len(line) == 0 || len(line) > bootstrapMaxReadiness {
176		return Readiness{}, errBootstrapMalformed
177	}
178	var readiness Readiness
179	if err := json.Unmarshal(line, &readiness); err != nil {
180		return Readiness{}, errBootstrapMalformed
181	}
182	if readiness.Version != bootstrapSchemaVersion {
183		return Readiness{}, errBootstrapMalformed
184	}
185	if readiness.Port <= 0 || readiness.Port > 65535 {
186		return Readiness{}, errBootstrapMalformed
187	}
188	token, err := base64.StdEncoding.DecodeString(readiness.Token)
189	if err != nil || len(token) != bootstrapTokenBytes {
190		return Readiness{}, errBootstrapMalformed
191	}
192	zeroBytes(token)
193	nonce, err := base64.StdEncoding.DecodeString(readiness.Nonce)
194	if err != nil || len(nonce) != bootstrapNonceBytes {
195		return Readiness{}, errBootstrapMalformed
196	}
197	zeroBytes(nonce)
198	fingerprint, err := hex.DecodeString(readiness.Fingerprint)
199	if err != nil || len(fingerprint) != sha256.Size {
200		return Readiness{}, errBootstrapMalformed
201	}
202	now := time.Now()
203	expires := time.Unix(readiness.ExpiresAt, 0)
204	const bootstrapClockSkew = 5 * time.Minute
205	if !expires.After(now.Add(-bootstrapClockSkew)) || expires.After(now.Add(bootstrapExpiry+bootstrapClockSkew)) {
206		return Readiness{}, errBootstrapExpired
207	}
208	return readiness, nil
209}
210
211// verifier is the server-side material stored for a secret: the raw
212// token/nonce buffers are erased immediately after this digest exists.
213func verifier(secret []byte) []byte {
214	sum := sha256.Sum256(secret)
215	return sum[:]
216}
217
218// authRecord is the single bounded auth record: token plus nonce, each
219// exactly once. The server compares in constant time and consumes the
220// token atomically.
221type authRecord struct {
222	Token []byte `json:"token"`
223	Nonce []byte `json:"nonce"`
224}
225
226// Accept waits for one authenticated stream: the peer must send exactly
227// one auth record (token + nonce) within the auth deadline, consumed
228// atomically. The returned transport owns the single bidirectional
229// stream; unauthenticated attempts fail closed without IPC access.
230// Accept stops at token expiry or context cancellation: an expired
231// bootstrap exits instead of admitting peers past the advertised
232// credential lifetime. The blocking listener wait is bounded by the
233// same conditions, so a parked Accept cannot outlive the server.
234func (s *Server) Accept(ctx context.Context) (wire.Transport, error) {
235	if err := ctx.Err(); err != nil {
236		return nil, err
237	}
238	// The wait for a connection is itself bounded by the credential
239	// lifetime: an expired bootstrap exits instead of admitting a peer.
240	waitCtx, cancelWait := context.WithDeadline(ctx, s.expires)
241	defer cancelWait()
242	for {
243		admittedConn, err := s.listener.admitOne(waitCtx, bootstrapAuthDeadline)
244		if err != nil {
245			switch {
246			case ctx.Err() != nil:
247				return nil, ctx.Err()
248			case !time.Now().Before(s.expires):
249				return nil, errBootstrapExpired
250			default:
251				return nil, err
252			}
253		}
254		err = s.authenticate(waitCtx, admittedConn)
255		admittedConn.release()
256		if err == nil {
257			return admittedConn.transport, nil
258		}
259		_ = admittedConn.transport.Close()
260		if errors.Is(err, errBootstrapExpired) || !time.Now().Before(s.expires) {
261			return nil, errBootstrapExpired
262		}
263		if ctx.Err() != nil {
264			return nil, ctx.Err()
265		}
266		// Authentication failed: keep listening for the legitimate
267		// peer until the credential expires.
268	}
269}
270
271// authenticate reads the single bounded auth record and consumes the
272// token atomically. The deadline held by the admitted connection covers
273// stream open and this read; the record length prefix is bound before any
274// payload allocation; and expiry is rechecked under the consumption lock
275// so a record racing the credential lifetime cannot authenticate late.
276func (s *Server) authenticate(ctx context.Context, admittedConn admitted) error {
277	if !time.Now().Before(s.expires) {
278		return errBootstrapExpired
279	}
280	deadline := admittedConn.deadline
281	if s.expires.Before(deadline) {
282		deadline = s.expires
283	}
284	ctx, cancel := context.WithDeadline(ctx, deadline)
285	defer cancel()
286	type authResult struct {
287		payload []byte
288		err     error
289	}
290	results := make(chan authResult, 1)
291	go func() {
292		// RecvBounded rejects an over-limit length prefix before
293		// allocating, so an unauthenticated peer cannot force a large
294		// pre-auth allocation by advertising a huge frame.
295		envelope, err := admittedConn.transport.RecvBounded(bootstrapMaxAuthRecord)
296		results <- authResult{payload: envelope.Payload, err: err}
297	}()
298	select {
299	case <-ctx.Done():
300		return fmt.Errorf("%w: %w", errBootstrapAuth, ctx.Err())
301	case result := <-results:
302		if result.err != nil {
303			return errBootstrapAuth
304		}
305		defer zeroBytes(result.payload)
306		var record authRecord
307		if err := json.Unmarshal(result.payload, &record); err != nil {
308			return errBootstrapAuth
309		}
310		// The decoded secret buffers are erased as soon as the
311		// comparison finishes.
312		defer zeroBytes(record.Token)
313		defer zeroBytes(record.Nonce)
314		return s.consume(record)
315	}
316}
317
318// consume atomically checks expiry and consumes the one-time token.
319// Expiry is rechecked while holding the consumption lock so a record that
320// raced the credential lifetime can never authenticate after expiry, and
321// the token is consumed exactly once across concurrent attempts.
322func (s *Server) consume(record authRecord) error {
323	s.consumedMu.Lock()
324	defer s.consumedMu.Unlock()
325	if !time.Now().Before(s.expires) {
326		return errBootstrapExpired
327	}
328	if s.consumed {
329		return errBootstrapConsumed
330	}
331	tokenSum := sha256.Sum256(record.Token)
332	nonceSum := sha256.Sum256(record.Nonce)
333	if subtle.ConstantTimeCompare(tokenSum[:], s.tokenVerifier) != 1 ||
334		subtle.ConstantTimeCompare(nonceSum[:], s.nonceVerifier) != 1 {
335		return errBootstrapAuth
336	}
337	s.consumed = true
338	return nil
339}
340
341// Close shuts down the listener and fails pending accepts. Established
342// transports are unaffected. Token material is erased: the credential
343// dies with the bootstrap process in every case.
344func (s *Server) Close() error {
345	s.closeOnce.Do(func() {
346		s.closeErr = s.listener.Close()
347		s.consumedMu.Lock()
348		defer s.consumedMu.Unlock()
349		zeroBytes(s.tokenVerifier)
350		zeroBytes(s.nonceVerifier)
351		s.tokenVerifier = nil
352		s.nonceVerifier = nil
353	})
354	return s.closeErr
355}
356
357// Addr returns the bound address for the readiness record.
358func (s *Server) Addr() string { return s.listener.Addr() }
359
360// Dial dials a bootstrap server: pin the fingerprint, connect QUIC to
361// the caller-resolved address, and send the single auth record. The
362// address is composed by the caller from the SSH target host plus the
363// readiness port: the readiness record never carries a dialable
364// address. The token travels only inside the encrypted stream after
365// the pinned handshake.
366func Dial(ctx context.Context, addr string, readiness Readiness, config Config, timeout time.Duration, opts ...Option) (wire.Transport, error) {
367	if addr == "" {
368		return nil, errBootstrapMalformed
369	}
370	token, err := base64.StdEncoding.DecodeString(readiness.Token)
371	if err != nil || len(token) != bootstrapTokenBytes {
372		return nil, errBootstrapMalformed
373	}
374	defer zeroBytes(token)
375	nonce, err := base64.StdEncoding.DecodeString(readiness.Nonce)
376	if err != nil || len(nonce) != bootstrapNonceBytes {
377		return nil, errBootstrapMalformed
378	}
379	defer zeroBytes(nonce)
380	fingerprint, err := hex.DecodeString(readiness.Fingerprint)
381	if err != nil || len(fingerprint) != sha256.Size {
382		return nil, errBootstrapMalformed
383	}
384	dialer := DialConfig(addr, "vev-bootstrap", fingerprint, config, timeout, opts...)
385	transport, err := dialer.Dial(ctx)
386	if err != nil {
387		return nil, err
388	}
389	record, err := json.Marshal(authRecord{Token: token, Nonce: nonce})
390	if err != nil {
391		_ = transport.Close()
392		return nil, err
393	}
394	// The framed copy of the secret is erased once the send attempt ends.
395	defer zeroBytes(record)
396	authCtx, cancel := context.WithTimeout(ctx, bootstrapAuthDeadline)
397	defer cancel()
398	if err := sendAuthRecord(authCtx, transport, record); err != nil {
399		_ = transport.Close()
400		return nil, err
401	}
402	return transport, nil
403}
404
405// sendAuthRecord sends the framed auth record within ctx. On timeout it
406// closes the transport and joins the blocked send goroutine before returning,
407// so the caller may erase record after return without racing the sender.
408// The join relies on Close unblocking an in-flight Send, which wire.Transport
409// implementations used here guarantee.
410func sendAuthRecord(ctx context.Context, transport wire.Transport, record []byte) error {
411	sent := make(chan error, 1)
412	go func() {
413		sent <- transport.Send(wire.Envelope{Payload: record})
414	}()
415	select {
416	case <-ctx.Done():
417		_ = transport.Close()
418		<-sent
419		return fmt.Errorf("%w: %w", errBootstrapAuth, ctx.Err())
420	case err := <-sent:
421		return err
422	}
423}
424
425// ReadReadiness reads exactly one readiness line (≤4 KiB) from r with a
426// bounded wait. Stderr is never mixed in: the caller wires stdout only.
427func ReadReadiness(ctx context.Context, r io.Reader) (Readiness, error) {
428	type readResult struct {
429		line []byte
430		err  error
431	}
432	results := make(chan readResult, 1)
433	go func() {
434		var buf bytes.Buffer
435		tmp := make([]byte, 512)
436		for buf.Len() <= bootstrapMaxReadiness {
437			n, err := r.Read(tmp)
438			if n > 0 {
439				buf.Write(tmp[:n])
440				if index := bytes.IndexByte(buf.Bytes(), '\n'); index >= 0 {
441					results <- readResult{line: buf.Bytes()[:index]}
442					return
443				}
444			}
445			if err != nil {
446				results <- readResult{err: err}
447				return
448			}
449		}
450		results <- readResult{err: errBootstrapMalformed}
451	}()
452	select {
453	case <-ctx.Done():
454		return Readiness{}, fmt.Errorf("%w: %w", errBootstrapMalformed, ctx.Err())
455	case result := <-results:
456		if result.err != nil {
457			return Readiness{}, errBootstrapMalformed
458		}
459		return ParseReadiness(result.line)
460	}
461}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close