Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1dd31d8
docs: plan the full-handshake carrier, and amend design.md's 4% concl…
myleshorton Sep 3, 2026
b4f9c2f
docs: correct the carrier -- the client cannot encrypt under the tick…
myleshorton Sep 3, 2026
5cba3b6
measure: the cover identities publish no ECHConfig, so GREASE ECH holds
myleshorton Sep 3, 2026
27af1d8
Carry the ticket in the ECH payload, so the opening can be a full han…
myleshorton Sep 3, 2026
a4062ea
Give every credential a full-handshake companion ticket, and rotate both
myleshorton Sep 4, 2026
afeef38
Emit and accept the full-handshake opening end to end
myleshorton Sep 4, 2026
1c484dc
Draw only from pool hellos that can carry a full-handshake ticket
myleshorton Sep 4, 2026
6719631
docs: record what the carrier work built, and what is left
myleshorton Sep 4, 2026
38c32c0
Remember contacted egresses, and open the first connection to each in…
myleshorton Sep 4, 2026
86c0c9d
docs: record the mix policy and the two obligations it puts on callers
myleshorton Sep 4, 2026
9c99713
Replay the hellos we emit at the real covers, and require a ServerHel…
myleshorton Sep 4, 2026
5230e81
Add CI: an offline job, and a live job that replays at the real covers
myleshorton Sep 4, 2026
455a46e
Correct the drift finding: the remainder varies by vantage point, not…
myleshorton Sep 4, 2026
b58fe9d
Close the Reset race, and stop sweep panicking on a short record
myleshorton Sep 4, 2026
566dbef
Degrade when the credential has no companion, and type the psk
myleshorton Sep 4, 2026
17cf6ff
Report a nil connection instead of panicking on it
myleshorton Sep 4, 2026
e38e3af
Make the rotation length checks exact, and correct what they defend a…
myleshorton Sep 4, 2026
8ae154a
Record that mixed-version deployment is not supported, deliberately
myleshorton Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions .github/workflows/go.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
name: Go

on:
push:
branches: ["main"]
pull_request:
workflow_dispatch:
# The live job checks our emitted hellos against real servers, so it can
# start failing without anybody touching this repo: a cover can rotate its
# certificate, change its ServerHello, or stop accepting a shape we send.
# A daily run is what turns that from a surprise at deploy time into a
# notification.
schedule:
- cron: "17 6 * * *"

permissions:
contents: read

jobs:
# Everything that needs no network. Kept separate from the live job so a
# flaky runner or a blocked egress cannot be mistaken for a code regression.
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: "go.mod"

- name: gofmt
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "these files are not gofmt'd:"
echo "$unformatted"
gofmt -d $unformatted
exit 1
fi

- name: Vet
run: go vet ./...

- name: Build
run: go build ./...

# The suite includes TestShippedPackagesImportNoTLSLibrary, which is the
# guard on this transport's central design property: no shipped package
# may import a TLS stack. It is a test rather than a lint because it has
# to walk the import graph, but it is really a build gate.
- name: Test
run: go test -count=1 ./...

- name: Test with race detector
run: go test -count=1 -race ./...

# Replays what we actually emit at the real cover hosts and requires a
# ServerHello back.
#
# This is the only test that can fail for a reason no local test can see, and
# the failure mode is not theoretical: an earlier version of freshKeyShare
# filled key shares with random bytes, real servers answered decode_error,
# and every offline test passed throughout. A censor replaying one of our
# hellos to the SNI we claim is running exactly this check.
#
# It needs the network, so the tests are gated on TWIDDLE_LIVE_PROBE and skip
# by default. They also skip rather than fail when a host is unreachable after
# three attempts, so a network fault does not read as a rejection -- a real
# rejection reproduces on every attempt and still fails.
live:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: "go.mod"

# On a push or pull request this replays a handful of shapes -- enough to
# catch a hello real servers reject, which is the failure that matters.
# The scheduled run sets TWIDDLE_LIVE_FULL_SWEEP and covers every distinct
# shape. The split is not thrift: the exhaustive sweep is ~120 connections
# to three real hosts, and running it repeatedly gets throttled, which
# then reads as a code regression.
- name: Replay emitted hellos at the real covers
env:
TWIDDLE_LIVE_PROBE: "1"
TWIDDLE_LIVE_FULL_SWEEP: ${{ github.event_name == 'schedule' && '1' || '' }}
run: |
go test -count=1 -v -timeout 25m -run 'Live|RealCover|AcceptedByTheReal|SampleFull|Probe|AtLeastOneCover' ./...
65 changes: 55 additions & 10 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,15 @@ type TicketKey [32]byte
// carries the next, exactly as NewSessionTicket does.
type Credential struct {
Ticket []byte
PSK [32]byte
// FullTicket is the same clientID and psk sealed at FullTicketLen, for the
// full-handshake carrier, which cannot use Ticket: the two paths size
// tickets for incompatible reasons. See IssueFullFor and echcarrier.go.
//
// Nil is legal and means resumption-only -- a credential provisioned before
// the carrier existed. Twiddle refuses the full path rather than emitting
// an opening no server can authenticate.
FullTicket []byte
PSK [32]byte
}

func NewTicketKey() (*TicketKey, error) {
Expand All @@ -96,21 +104,61 @@ func (k *TicketKey) Issue(clientID uint64, ticketLen int) (*Credential, error) {
}

func (k *TicketKey) issueAt(clientID uint64, ticketLen int, now time.Time) (*Credential, error) {
cred := &Credential{}
if _, err := rand.Read(cred.PSK[:]); err != nil {
return nil, err
}
var err error
if cred.Ticket, err = k.seal(clientID, cred.PSK, ticketLen, now); err != nil {
return nil, err
}
// Sealed at the SAME instant, deliberately. ReplayCache refuses a ticket
// older than the client's newest, so two tickets of one credential bearing
// different issue times would make whichever path the client used second
// look like a stale capture and fail.
if cred.FullTicket, err = k.seal(clientID, cred.PSK, FullTicketLen, now); err != nil {
return nil, err
}
return cred, nil
}

// IssueFullFor mints the full-handshake companion for an EXISTING ticket,
// which is how a credential provisioned before the carrier is upgraded.
//
// A client needs both tickets because the two paths size them for
// incompatible reasons. On the resumption path the length is a fidelity
// parameter -- the ticket sets the emitted hello size, so it must match the
// identity being impersonated. Inside the ECH payload it must instead fit
// Chrome's smallest bucket. Those constraints do not meet: a microsoft-sized
// 256-byte ticket fits no ECH bucket at all.
//
// It takes the ticket rather than the fields so the clientID, psk AND issue
// time can only come from the ticket being companioned. Passing those
// separately would make it possible to seal a companion with a different
// issue time, which ReplayCache would then read as a stale capture.
func (k *TicketKey) IssueFullFor(ticket []byte) ([]byte, error) {
clientID, psk, issued, err := k.Open(ticket)
if err != nil {
return nil, err
}
return k.seal(clientID, psk, FullTicketLen, issued)
}

// seal builds one ticket. The plaintext is padded to fill ticketLen so every
// ticket a server issues at a given length is that length, as a real server's
// would be.
func (k *TicketKey) seal(clientID uint64, psk [32]byte, ticketLen int, now time.Time) ([]byte, error) {
if ticketLen < MinTicketLen {
return nil, fmt.Errorf("twiddle: ticket length %d below minimum %d", ticketLen, MinTicketLen)
}
aead, err := k.aead()
if err != nil {
return nil, err
}
cred := &Credential{}
if _, err := rand.Read(cred.PSK[:]); err != nil {
return nil, err
}

plain := make([]byte, ticketLen-ticketNonceLen-ticketTagLen)
binary.BigEndian.PutUint64(plain[0:8], clientID)
copy(plain[8:40], cred.PSK[:])
copy(plain[8:40], psk[:])
binary.BigEndian.PutUint64(plain[40:48], uint64(now.Unix()))
if _, err := rand.Read(plain[ticketFixed:]); err != nil {
return nil, err
Expand All @@ -120,8 +168,7 @@ func (k *TicketKey) issueAt(clientID uint64, ticketLen int, now time.Time) (*Cre
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
cred.Ticket = aead.Seal(nonce, nonce, plain, nil)
return cred, nil
return aead.Seal(nonce, nonce, plain, nil), nil
}

// Open recovers a ticket's contents. Only the holder of the ticket key can do
Expand Down Expand Up @@ -341,5 +388,3 @@ func parsePSK(d []byte) (ticket []byte, age [4]byte, binder []byte, err error) {
}
return ticket, age, d[p+1 : p+1+bl], nil
}


4 changes: 4 additions & 0 deletions cmd/twiddlecred/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,9 @@ func main() {
}
fmt.Printf("ticket_key=%s\n", hex.EncodeToString(k[:]))
fmt.Printf("ticket=%s\n", base64.StdEncoding.EncodeToString(cred.Ticket))
// The full-handshake companion. Provisioning that omits it leaves the
// client resumption-only, which is the distinguisher the carrier exists to
// remove -- see docs/full-handshake-carrier.md.
fmt.Printf("full_ticket=%s\n", base64.StdEncoding.EncodeToString(cred.FullTicket))
fmt.Printf("psk=%s\n", hex.EncodeToString(cred.PSK[:]))
}
15 changes: 15 additions & 0 deletions conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,23 @@ type Conn struct {
recvSeq uint64
pending []byte
rerr error

// fullHandshake records which opening shape this connection used. Set once
// by Client or Server before the connection is handed out, and read-only
// after, so it needs no lock.
fullHandshake bool
}

// FullHandshake reports whether this connection opened with a full handshake
// rather than a resumption.
//
// Exposed for measurement. The point of the full-handshake carrier is to stop
// emitting 100% resumptions (see docs/full-handshake-carrier.md), and the only
// way to know the deployed mix is to count it -- a ContactMemory that silently
// degraded on every connection, because no cover was ever probed, would
// otherwise look exactly like one that was working.
func (c *Conn) FullHandshake() bool { return c.fullHandshake }

// NewConn wraps raw. isClient selects which direction's keys are used to send.
func NewConn(raw net.Conn, s *Session, isClient bool, sh Shaper) (*Conn, error) {
sendKeys, recvKeys := s.Client, s.Server
Expand Down
Loading
Loading