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}