package onion import ( "context" "crypto/cipher" "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/binary" "encoding/hex" "fmt" "io" "math/big" "net" "slices" "sync" "time" "github.com/pkg/errors" "go.uber.org/zap" "golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/hkdf" "source.quilibrium.com/quilibrium/monorepo/types/consensus" "source.quilibrium.com/quilibrium/monorepo/types/keys" tp2p "source.quilibrium.com/quilibrium/monorepo/types/p2p" ) type createdWaitKey struct { peer string circ uint32 } // OnionRouter provides TOR-style onion routing as a flexible transport // mechanism, pluggable with other Capabilities. It has very familiar // communication semantics to TOR. Uses ChaCha20-Poly1305 instead of AES-GCM // to provide better performance where hardware acceleration is unavailable. type OnionRouter struct { logger *zap.Logger peers tp2p.PeerInfoManager signers consensus.SignerRegistry keyManager keys.KeyManager link Transport keyFn KeyFn secretFn SharedSecretFn keyUsage string rng io.Reader // circuits and streams mu sync.RWMutex circuits map[[16]byte]*Circuit streams map[uint32]*onionStream // key: circuitID||streamID packed linkCirc map[string]map[uint32]*Circuit // peerID -> circID -> circuit createdWait map[createdWaitKey]chan []byte // waits for CREATED payloads } type Option func(*OnionRouter) func WithTransport(t Transport) Option { return func(r *OnionRouter) { r.link = t } } func WithKeyConstructor(f KeyFn) Option { return func(r *OnionRouter) { r.keyFn = f } } func WithSharedSecret(f SharedSecretFn) Option { return func(r *OnionRouter) { r.secretFn = f } } func NewOnionRouter( logger *zap.Logger, peerManager tp2p.PeerInfoManager, signerRegistry consensus.SignerRegistry, keyManager keys.KeyManager, opts ...Option, ) *OnionRouter { r := &OnionRouter{ logger: logger, peers: peerManager, signers: signerRegistry, keyManager: keyManager, keyUsage: DefaultOnionKeyPurpose, rng: rand.Reader, circuits: make(map[[16]byte]*Circuit), streams: make(map[uint32]*onionStream), linkCirc: make(map[string]map[uint32]*Circuit), } for _, o := range opts { o(r) } if r.link != nil { r.link.OnReceive(r.handleInboundCell) } return r } type hopKeys struct { // AEADs for this hop kf, kb aead // forward/backward // per-direction nonces (monotonic counters) fCtr, bCtr uint64 } type hopState struct { peerID []byte onionPK []byte keys hopKeys } type Circuit struct { ID [16]byte Hops []hopState // entry -> ... -> exit EntryCircID uint32 CreatedAt time.Time LinkCirc map[string]uint32 // peerID(string) -> link-local circID // Guards per-hop nonce counters during forward (client->exit) layering // and backward (exit->client) peeling. These avoid concurrent increments // from e.g. the write pump and Close() sending END, and from multiple // inbound cells arriving concurrently. fwdMu sync.Mutex bwdMu sync.Mutex } // AEAD wrapper type aead struct { aead cipherAEAD nonce [12]byte // prefix; last 8 bytes overwritten by counter } type cipherAEAD interface { Seal(dst, nonce, plaintext, ad []byte) []byte Open(dst, nonce, ciphertext, ad []byte) ([]byte, error) } // Build a circuit using Tor-like telescoping: for each hop, do a one-way DH // with that hop’s onion key -> HKDF -> derive Kf/Kb and per-hop nonce prefixes. func (r *OnionRouter) BuildCircuit(ctx context.Context, hops int) ( *Circuit, error, ) { if r.secretFn == nil { return nil, errors.Wrap( errors.New("shared secret fn required"), "build circuit", ) } cands, err := r.selectRoutingPeers() if err != nil { return nil, err } if len(cands) < hops { return nil, errors.Wrap( fmt.Errorf("need %d routing peers, have %d", hops, len(cands)), "build circuit", ) } var id [16]byte if _, err := io.ReadFull(r.rng, id[:]); err != nil { return nil, err } c := &Circuit{ID: id, CreatedAt: time.Now(), LinkCirc: map[string]uint32{}} // Choose entry hop and register link-local circ on client<->entry link entryPM := cands[0] r.registerEntryCirc(c, entryPM.PeerId) c.LinkCirc[string(entryPM.PeerId)] = c.EntryCircID // assign circIDs for the other hops for _, pm := range cands[1:hops] { c.LinkCirc[string(pm.PeerId)] = uint32(randUint(r.rng, 1<<32)) } // Handshake telescopically for i, pm := range cands[:hops] { onionPub, err := r.resolveOnionKey(pm.PeerId) if err != nil { return nil, errors.Wrap(err, "build circuit") } // Ephemeral and shared secret per hop clientEphPub, ephPriv, err := r.keyFn() if err != nil { return nil, errors.Wrap(err, "build circuit") } shared, err := r.secretFn(ephPriv, onionPub) if err != nil { return nil, errors.Wrap(err, "build circuit") } // Nonce for transcript var cNonce [16]byte if _, err := io.ReadFull(r.rng, cNonce[:]); err != nil { return nil, err } createPayload := r.buildCreatePayload(c, onionPub, clientEphPub, cNonce) if i == 0 { // First hop: direct CREATE/CREATED hk, err := r.extendToHop(ctx, c, pm.PeerId, onionPub) if err != nil { return nil, errors.Wrap(err, "build circuit") } c.Hops = append(c.Hops, hopState{ peerID: slices.Clone(pm.PeerId), onionPK: slices.Clone(onionPub), keys: hk, }) continue } // Hop i>0: send relay EXTEND via entry // Register wait for EXTENDED arriving on entry link key := createdWaitKey{peer: string(c.Hops[0].peerID), circ: c.EntryCircID} ch := make(chan []byte, 1) r.mu.Lock() if r.createdWait == nil { r.createdWait = make(map[createdWaitKey]chan []byte) } r.createdWait[key] = ch r.mu.Unlock() // Pack EXTEND payload: nextHopID || createPayload (createPayload is fixed // 121 bytes) extendData := append(append([]byte{}, pm.PeerId...), createPayload...) if err := r.sendRelay(c, relayHeader{ Cmd: CmdExtend, StreamID: 0, Length: uint16(len(extendData)), Data: extendData, }); err != nil { return nil, errors.Wrap(err, "send extend") } // Wait for EXTENDED (CREATED wrapped in a relay cell) on entry var resp []byte select { case <-ctx.Done(): return nil, ctx.Err() case resp = <-ch: // resp is the CREATED payload: 57||16||32 } // Verify MAC if len(resp) < 57+16+32 { return nil, errors.New("extended: short payload") } serverEph := resp[0:57] var sNonce [16]byte copy(sNonce[:], resp[57:73]) mac := resp[73:105] kMac := hkdfExpand(shared, []byte("QOR-NTOR-X448 v1/handshake-mac"), 32) hash := sha256.Sum256(onionPub) transcript := concat([]byte("QOR-NTOR-X448 v1"), c.ID[:], hash[:], clientEphPub, serverEph, cNonce[:], sNonce[:]) if !hmacEqual(mac, hmacSHA256(kMac, transcript)) { return nil, errors.New("extended: MAC mismatch") } // Derive hop keys hk, err := deriveHopKeys(c.ID[:], pm.PeerId, shared) if err != nil { return nil, err } c.Hops = append(c.Hops, hopState{ peerID: slices.Clone(pm.PeerId), onionPK: slices.Clone(onionPub), keys: hk, }) // cleanup waiter r.mu.Lock() delete(r.createdWait, key) r.mu.Unlock() } r.mu.Lock() r.circuits[c.ID] = c r.mu.Unlock() r.logger.Info( "built circuit", zap.String("id", hex.EncodeToString(c.ID[:])), zap.Int("hops", len(c.Hops)), ) return c, nil } // BuildCircuitToExit constructs a circuit of length hops whose exit hop is // exitPeerID. It performs the same Tor-like telescoping used by BuildCircuit, // but with a deterministic exit. func (r *OnionRouter) BuildCircuitToExit( ctx context.Context, hops int, exitPeerID []byte, ) (*Circuit, error) { if r.secretFn == nil || r.keyFn == nil { return nil, errors.Wrap( errors.New("key constructor and shared secret functions are required"), "build circuit to exit", ) } if hops < 1 { return nil, errors.Wrap( errors.New("hops must be >= 1"), "build circuit to exit", ) } // Ensure the exit peer exists and is routing-capable. exitPM := r.peers.GetPeerInfo(exitPeerID) if exitPM == nil || !hasCapability(exitPM, ProtocolRouting) { return nil, errors.Wrap( errors.New("exit peer unavailable or lacks routing capability"), "build circuit to exit", ) } // Gather candidates for the *non-exit* hops. cands, err := r.selectRoutingPeers() if err != nil { return nil, err } // Filter out the chosen exit. filtered := make([]*tp2p.PeerInfo, 0, len(cands)) for _, pm := range cands { if string(pm.PeerId) == string(exitPeerID) { continue } filtered = append(filtered, pm) } if (hops - 1) > len(filtered) { return nil, errors.Wrap( fmt.Errorf( "need %d routing peers (excluding exit), have %d", hops-1, len(filtered), ), "build circuit to exit", ) } // Pick first (hops-1) from filtered (already shuffled in selectRoutingPeers), // then append exit at the end. route := make([]*tp2p.PeerInfo, 0, hops) if hops > 1 { route = append(route, filtered[:hops-1]...) } route = append(route, exitPM) // exit last // Allocate circuit ID and per-link circ ids (same pattern as BuildCircuit). var id [16]byte if _, err := io.ReadFull(r.rng, id[:]); err != nil { return nil, err } c := &Circuit{ID: id, CreatedAt: time.Now(), LinkCirc: map[string]uint32{}} // Entry hop registration entryPM := route[0] r.registerEntryCirc(c, entryPM.PeerId) c.LinkCirc[string(entryPM.PeerId)] = c.EntryCircID // Assign circIDs for other hops on their respective links for _, pm := range route[1:] { c.LinkCirc[string(pm.PeerId)] = uint32(randUint(r.rng, 1<<32)) } // Telescoping handshake over the chosen route for i, pm := range route { onionPub, err := r.resolveOnionKey(pm.PeerId) if err != nil { return nil, errors.Wrap(err, "build circuit to exit") } if i == 0 { // First hop via CREATE/CREATED hk, err := r.extendToHop(ctx, c, pm.PeerId, onionPub) if err != nil { return nil, errors.Wrap(err, "build circuit to exit") } c.Hops = append(c.Hops, hopState{ peerID: slices.Clone(pm.PeerId), onionPK: slices.Clone(onionPub), keys: hk, }) continue } // Hop i>0: relay EXTEND via entry. // Prepare per-hop ephemeral + shared secret. clientEphPub, ephPriv, err := r.keyFn() if err != nil { return nil, errors.Wrap(err, "build circuit to exit") } shared, err := r.secretFn(ephPriv, onionPub) if err != nil { return nil, errors.Wrap(err, "build circuit to exit") } var cNonce [16]byte if _, err := io.ReadFull(r.rng, cNonce[:]); err != nil { return nil, err } createPayload := r.buildCreatePayload(c, onionPub, clientEphPub, cNonce) // Waiter for EXTENDED arriving on the entry link. key := createdWaitKey{peer: string(c.Hops[0].peerID), circ: c.EntryCircID} ch := make(chan []byte, 1) r.mu.Lock() if r.createdWait == nil { r.createdWait = make(map[createdWaitKey]chan []byte) } r.createdWait[key] = ch r.mu.Unlock() defer func() { r.mu.Lock() delete(r.createdWait, key) r.mu.Unlock() }() // Pack EXTEND payload: nextHopID || createPayload (createPayload is 121 // bytes). extendData := append(append([]byte{}, pm.PeerId...), createPayload...) if err := r.sendRelay(c, relayHeader{ Cmd: CmdExtend, StreamID: 0, Length: uint16(len(extendData)), Data: extendData, }); err != nil { return nil, errors.Wrap(err, "build circuit to exit") } // Await EXTENDED. var resp []byte select { case <-ctx.Done(): return nil, ctx.Err() case resp = <-ch: } // Verify MAC and derive hop keys (same as BuildCircuit). if len(resp) < 57+16+32 { return nil, errors.Wrap( errors.New("extended: short payload"), "build circuit to exit", ) } serverEph := resp[0:57] var sNonce [16]byte copy(sNonce[:], resp[57:73]) mac := resp[73:105] kMac := hkdfExpand(shared, []byte("QOR-NTOR-X448 v1/handshake-mac"), 32) hash := sha256.Sum256(onionPub) transcript := concat( []byte("QOR-NTOR-X448 v1"), c.ID[:], hash[:], clientEphPub, serverEph, cNonce[:], sNonce[:], ) if !hmacEqual(mac, hmacSHA256(kMac, transcript)) { return nil, errors.Wrap( errors.New("extended: MAC mismatch"), "build circuit to exit", ) } hk, err := deriveHopKeys(c.ID[:], pm.PeerId, shared) if err != nil { return nil, err } c.Hops = append(c.Hops, hopState{ peerID: slices.Clone(pm.PeerId), onionPK: slices.Clone(onionPub), keys: hk, }) // Clean createdWait for this hop before next iteration. r.mu.Lock() delete(r.createdWait, key) r.mu.Unlock() } r.mu.Lock() r.circuits[c.ID] = c r.mu.Unlock() r.logger.Info( "built circuit (to exit)", zap.String("id", hex.EncodeToString(c.ID[:])), zap.Int("hops", len(c.Hops)), zap.Binary("exit", exitPeerID), ) return c, nil } // RegisterIntro builds a circuit to the given intro relay and registers this // node as an introduction point for the provided 32-byte serviceID. Keep the // returned circuit alive on the caller side if you want the intro to persist. func (r *OnionRouter) RegisterIntro( ctx context.Context, introPeerID []byte, serviceID [32]byte, ) (*Circuit, error) { if len(introPeerID) == 0 { return nil, errors.Wrap( errors.New("intro peer id required"), "register intro", ) } c, err := r.BuildCircuit(ctx, 3) if err != nil { return nil, err } sid := uint16(randUint(r.rng, 1<<16)) payload := make([]byte, 32) copy(payload, serviceID[:]) if err := r.sendRelay(c, relayHeader{ Cmd: CmdIntroEstablish, StreamID: sid, Length: 32, Data: payload, }); err != nil { return nil, errors.Wrap(err, "register intro") } return c, nil } // ClientStartRendezvous sends REND1(cookie, clientSid) on the provided circuit // (to the rendezvous relay). func (r *OnionRouter) ClientStartRendezvous( c *Circuit, cookie [16]byte, clientSid uint16, ) error { data := make([]byte, 18) copy(data[:16], cookie[:]) binary.BigEndian.PutUint16(data[16:18], clientSid) return errors.Wrap( r.sendRelay(c, relayHeader{ Cmd: CmdRend1, StreamID: clientSid, Length: uint16(len(data)), Data: data, }), "client start rendezvous", ) } // ClientIntroduce sends INTRODUCE(serviceID, rendezvousPeer, cookie, clientSid) // to an intro relay over circuit c. func (r *OnionRouter) ClientIntroduce( c *Circuit, serviceID [32]byte, rendezvousPeer string, cookie [16]byte, clientSid uint16, ) error { rp := []byte(rendezvousPeer) intro := make([]byte, 0, 32+1+len(rp)+16+2) intro = append(intro, serviceID[:]...) intro = append(intro, byte(len(rp))) intro = append(intro, rp...) intro = append(intro, cookie[:]...) intro = append(intro, byte(clientSid>>8), byte(clientSid)) return errors.Wrap( r.sendRelay(c, relayHeader{ Cmd: CmdIntroduce, StreamID: 0, Length: uint16(len(intro)), Data: intro, }), "client introduce", ) } // ServiceCompleteRendezvous sends REND2(cookie, serviceSid) on the provided // circuit (to the rendezvous relay). func (r *OnionRouter) ServiceCompleteRendezvous( c *Circuit, cookie [16]byte, serviceSid uint16, ) error { data := make([]byte, 18) copy(data[:16], cookie[:]) binary.BigEndian.PutUint16(data[16:18], serviceSid) return r.sendRelay(c, relayHeader{ Cmd: CmdRend2, StreamID: serviceSid, Length: uint16(len(data)), Data: data, }) } // OpenStreamConn returns a net.Conn bound to (circuit, streamID) that // reads/writes via onion DATA/END. It does NOT send BEGIN; intended for // rendezvous streams where both sides already know the stream IDs. func (r *OnionRouter) OpenStreamConn(c *Circuit, sid uint16) net.Conn { s := &onionStream{ circID: c.ID, streamID: sid, readCh: make(chan []byte, 32), writeCh: make(chan []byte, 32), errCh: make(chan error, 1), closed: make(chan struct{}), } r.mu.Lock() r.streams[streamsID(c.ID, sid)] = s r.mu.Unlock() // Pump writes -> DATA cells go func() { for { select { case <-s.closed: return case b, ok := <-s.writeCh: if !ok { return } for len(b) > 0 { chunk := b max := payloadMax() if len(chunk) > max { chunk = chunk[:max] } _ = r.sendRelay(c, relayHeader{ Cmd: CmdData, StreamID: sid, Length: uint16(len(chunk)), Data: chunk, }) if len(b) <= max { break } b = b[max:] } } } }() return &onionConn{r: r, c: c, s: s, deadlineMx: &sync.Mutex{}} } func (r *OnionRouter) buildCreatePayload( c *Circuit, onionPub []byte, clientEphPub []byte, cNonce [16]byte, ) []byte { // fp(32) || circuitID(16) || clientEph(57) || cNonce(16) = 121 bytes fp := sha256.Sum256(onionPub) payload := make([]byte, 0, 32+16+57+16) payload = append(payload, fp[:]...) payload = append(payload, c.ID[:]...) payload = append(payload, clientEphPub...) payload = append(payload, cNonce[:]...) return payload } // extendToHop performs the entry-hop handshake (CREATE/CREATED) and returns // derived hopKeys. func (r *OnionRouter) extendToHop( ctx context.Context, c *Circuit, hopPeerID, onionPub []byte, ) (hopKeys, error) { if r.keyFn == nil { return hopKeys{}, errors.Wrap( errors.New("key fn required"), "extend to hop", ) } if r.secretFn == nil { return hopKeys{}, errors.Wrap( errors.New("shared secret fn required"), "extend to hop", ) } if c.EntryCircID == 0 { return hopKeys{}, errors.Wrap( errors.New("entry circ id not registered"), "extend to hop", ) } // 1) Ephemeral keypair + DH clientEphPub, ephPriv, err := r.keyFn() if err != nil { return hopKeys{}, errors.Wrap(err, "extend to hop") } shared, err := r.secretFn(ephPriv, onionPub) if err != nil { return hopKeys{}, errors.Wrap(err, "extend to hop") } // 2) Build CREATE payload: fp(server static), circuitID, client eph, // client nonce fp := sha256.Sum256(onionPub) // 32B fingerprint of server's static key var cNonce [16]byte if _, err := io.ReadFull(r.rng, cNonce[:]); err != nil { return hopKeys{}, errors.Wrap( fmt.Errorf("rng: %w", err), "extend to hop", ) } // Layout: fp(32) || circuitID(16) || clientEph(57) || cNonce(16) createPayload := make([]byte, 32+16+57+16) off := 0 copy(createPayload[off:off+32], fp[:]) off += 32 copy(createPayload[off:off+16], c.ID[:]) off += 16 copy(createPayload[off:off+57], clientEphPub) off += 57 copy(createPayload[off:off+16], cNonce[:]) off += 16 // 3) Prepare to await CREATED (demuxed by srcPeerID + circID) key := createdWaitKey{ peer: string(hopPeerID), circ: c.LinkCirc[string(hopPeerID)], } ch := make(chan []byte, 1) r.mu.Lock() if r.createdWait == nil { r.createdWait = make(map[createdWaitKey]chan []byte) } r.createdWait[key] = ch r.mu.Unlock() defer func() { r.mu.Lock() delete(r.createdWait, key) r.mu.Unlock() }() circ := c.LinkCirc[string(hopPeerID)] if circ == 0 { return hopKeys{}, errors.Wrap( errors.New("missing per-hop circ id"), "extend to hop", ) } // 4) Send CREATE as a link-level control cell if err := r.sendControl( ctx, hopPeerID, circ, CmdCreate, createPayload, ); err != nil { return hopKeys{}, err } // 5) Wait for CREATED var resp []byte select { case <-ctx.Done(): return hopKeys{}, ctx.Err() case resp = <-ch: } // CREATED payload layout: serverEph(57) || sNonce(16) || mac(32) if len(resp) < 57+16+32 { return hopKeys{}, errors.Wrap( errors.New("created: short payload"), "extend to hop", ) } serverEph := resp[0:57] var sNonce [16]byte copy(sNonce[:], resp[57:73]) mac := resp[73:105] // 6) Verify MAC over the handshake transcript // K_mac = HKDF(shared, info="QOR-NTOR-X448 v1/handshake-mac", len=32) kMac := hkdfExpand(shared, []byte("QOR-NTOR-X448 v1/handshake-mac"), 32) transcript := concat( []byte("QOR-NTOR-X448 v1"), c.ID[:], fp[:], clientEphPub, serverEph, cNonce[:], sNonce[:], ) expMac := hmacSHA256(kMac, transcript) if !hmacEqual(mac, expMac) { return hopKeys{}, errors.Wrap( errors.New("created: MAC mismatch"), "extend to hop", ) } // 7) Derive relay-layer keys for this hop (forward/backward + nonce prefixes) keys, err := deriveHopKeys(c.ID[:], hopPeerID, shared) if err != nil { return hopKeys{}, errors.Wrap(err, "extend to hop") } return keys, nil } // sendControl constructs a frame with link-level control cell pads to CellSize // and sends func (r *OnionRouter) sendControl( ctx context.Context, peerID []byte, circID uint32, cmd byte, payload []byte, ) error { if len(payload) > CellSize-3 { return errors.Wrap( fmt.Errorf("control payload too large"), "send control", ) } buf := make([]byte, 1+2+len(payload)) buf[0] = cmd binary.BigEndian.PutUint16(buf[1:3], uint16(len(payload))) copy(buf[3:], payload) if len(buf) < CellSize { pad := make([]byte, CellSize) copy(pad, buf) buf = pad } return errors.Wrap( r.link.Send(ctx, peerID, circID, buf), "send control", ) } func (r *OnionRouter) registerEntryCirc(c *Circuit, entryPeerId []byte) uint32 { r.mu.Lock() defer r.mu.Unlock() id := uint32(randUint(r.rng, 1<<32)) if r.linkCirc == nil { r.linkCirc = make(map[string]map[uint32]*Circuit) } pk := string(entryPeerId) if r.linkCirc[pk] == nil { r.linkCirc[pk] = make(map[uint32]*Circuit) } r.linkCirc[pk][id] = c c.EntryCircID = id return id } func (r *OnionRouter) closeStream(c *Circuit, s *onionStream) { // Drop from map key := streamsID(c.ID, s.streamID) r.mu.Lock() s.closeOnce.Do(func() { close(s.closed) }) s.readCloseOnce.Do(func() { close(s.readCh) }) delete(r.streams, key) r.mu.Unlock() } func (r *OnionRouter) selectRoutingPeers() ([]*tp2p.PeerInfo, error) { ids := r.peers.GetPeersBySpeed() out := make([]*tp2p.PeerInfo, 0, len(ids)) seen := map[string]struct{}{} for _, id := range ids { k := string(id) if _, ok := seen[k]; ok { continue } seen[k] = struct{}{} pm := r.peers.GetPeerInfo(id) if pm == nil { continue } if !hasCapability(pm, ProtocolRouting) { continue } out = append(out, pm) } shuffle(r.rng, out) return out, nil } func hasCapability(pm *tp2p.PeerInfo, x uint32) bool { for _, c := range pm.Capabilities { if c.ProtocolIdentifier == x { return true } } return false } func (r *OnionRouter) resolveOnionKey( peerIdentityAddr []byte, ) ([]byte, error) { keys, err := r.signers.GetSignedX448KeysByParent(peerIdentityAddr, r.keyUsage) if err != nil { return nil, err } if len(keys) == 0 || keys[0].Key.KeyValue == nil || len(keys[0].Key.KeyValue) == 0 { return nil, errors.Wrap( errors.New("no onion key"), "resolve onion key", ) } return slices.Clone(keys[0].Key.KeyValue), nil } // Derive Tor-like forward/backward keys with independent nonce prefixes. // Inputs are domain-separated with circuitID and peerID so counters are unique. func deriveHopKeys(circuitID, peerID, dh []byte) (hopKeys, error) { info := append([]byte("QOR-HOP-KEYS v1\x00"), circuitID...) info = append(info, peerID...) kdf := hkdf.New(sha256.New, dh, nil, info) m := make([]byte, 32+12+32+12) // Kf(32) || Nf(12) || Kb(32) || Nb(12) if _, err := io.ReadFull(kdf, m); err != nil { return hopKeys{}, err } kfKey, nf := m[0:32], m[32:44] kbKey, nb := m[44:76], m[76:88] aKf, err := chacha20poly1305.New(kfKey) if err != nil { return hopKeys{}, err } aKb, err := chacha20poly1305.New(kbKey) if err != nil { return hopKeys{}, err } var nf12, nb12 [12]byte copy(nf12[:], nf) copy(nb12[:], nb) return hopKeys{ kf: aead{aead: wrap(aKf), nonce: nf12}, kb: aead{aead: wrap(aKb), nonce: nb12}, fCtr: 0, bCtr: 0, }, nil } type stdAEAD struct{ a cipher.AEAD } func wrap(a cipher.AEAD) stdAEAD { return stdAEAD{a: a} } func (s stdAEAD) Seal(dst, n, p, ad []byte) []byte { return s.a.Seal(dst, n, p, ad) } func (s stdAEAD) Open(dst, n, c, ad []byte) ([]byte, error) { return s.a.Open(dst, n, c, ad) } func (s stdAEAD) Noncesize() int { return s.a.NonceSize() } // Nonce = noncePrefix[0:4] || uint64(counter) big-endian. // (12 bytes total for ChaCha20-Poly1305) func nonceFrom(prefix [12]byte, ctr uint64) []byte { var n [12]byte copy(n[:4], prefix[:4]) binary.BigEndian.PutUint64(n[4:], ctr) return n[:] } // applyForward encrypts for exit->...->entry (reverse hop order) func applyForward(c *Circuit, inner []byte) ([]byte, error) { out := inner // buildutils:allow-slice-alias slice is static for i := len(c.Hops) - 1; i >= 0; i-- { h := &c.Hops[i] nonce := nonceFrom(h.keys.kf.nonce, h.keys.fCtr) h.keys.fCtr++ out = h.keys.kf.aead.Seal(nil, nonce, out, nil) } return out, nil } // peelBackward decrypts data coming back from entry (encrypting hop-by-hop with // Kb) func peelBackward(c *Circuit, outer []byte) ([]byte, error) { in := outer // buildutils:allow-slice-alias slice is static for i := 0; i < len(c.Hops); i++ { h := &c.Hops[i] nonce := nonceFrom(h.keys.kb.nonce, h.keys.bCtr) h.keys.bCtr++ var err error in, err = h.keys.kb.aead.Open(nil, nonce, in, nil) if err != nil { return nil, errors.Wrap( fmt.Errorf("open layer %d: %w", i, err), "peel backward", ) } } return in, nil } type onionStream struct { circID [16]byte streamID uint16 // app I/O readCh chan []byte writeCh chan []byte errCh chan error // close closeOnce sync.Once closed chan struct{} readCloseOnce sync.Once } // streamsID returns packed key for streams map: upper 16 bytes circuitID, // lower 16 bits streamID. func streamsID(cID [16]byte, sID uint16) uint32 { // short key for map, low collision risk for local process return binary.BigEndian.Uint32(cID[12:16]) ^ uint32(sID)<<16 ^ 0x5eed } // GRPCDialer returns a grpc.WithContextDialer-compatible function. func (r *OnionRouter) GRPCDialer(c *Circuit) func( ctx context.Context, addr string, ) (net.Conn, error) { return func(ctx context.Context, addr string) (net.Conn, error) { // Allocate a new StreamID (simple random 16-bit) sid := uint16(randUint(r.rng, 1<<16)) s := &onionStream{ circID: c.ID, streamID: sid, readCh: make(chan []byte, 32), writeCh: make(chan []byte, 32), errCh: make(chan error, 1), closed: make(chan struct{}), } r.mu.Lock() r.streams[streamsID(c.ID, sid)] = s r.mu.Unlock() // Send BEGIN(addr) if err := r.sendRelay(c, relayHeader{ Cmd: CmdBegin, StreamID: sid, Length: uint16(len(addr)), Data: []byte(addr), }); err != nil { return nil, err } // Pump writes -> DATA cells go func() { for { select { case <-s.closed: return case b, ok := <-s.writeCh: if !ok { return } for len(b) > 0 { chunk := b max := payloadMax() if len(chunk) > max { chunk = chunk[:max] } _ = r.sendRelay(c, relayHeader{ Cmd: CmdData, StreamID: sid, Length: uint16(len(chunk)), Data: chunk, }) if len(b) <= max { break } b = b[max:] } } } }() return &onionConn{r: r, c: c, s: s, deadlineMx: &sync.Mutex{}}, nil } } // sendRelay builds a relay header, layers it, and ships a fixed-size cell to // the entry hop. func (r *OnionRouter) sendRelay(c *Circuit, h relayHeader) error { max := payloadMax() raw, err := marshalRelay(h, max) if err != nil { return err } // Protect forward counters while layering this cell c.fwdMu.Lock() layered, err := applyForward(c, raw) c.fwdMu.Unlock() if err != nil { return err } entry := c.Hops[0].peerID return r.link.Send(context.Background(), entry, c.EntryCircID, layered) } func payloadMax() int { // AEAD adds 16 bytes tag per layer; but we layer *before* link padding. // We packed header+data and then layered; the tag growth is inside the // layered blob. To stay simple, we cap relay payload to (CellSize - minimal // headers) generously. return 256 } // handleInboundCell handles inbound from link: peel layers and dispatch to // stream. func (r *OnionRouter) handleInboundCell( srcPeerID []byte, circID uint32, cell []byte, ) { // Link-control fast path if len(cell) >= 3 { cmd := cell[0] l := int(binary.BigEndian.Uint16(cell[1:3])) if 3+l <= len(cell) && cmd == CmdCreated { payload := cell[3 : 3+l] r.onCreated(srcPeerID, circID, payload) return } } r.mu.RLock() m := r.linkCirc[string(srcPeerID)] c := m[circID] r.mu.RUnlock() if c == nil { return } // Protect backward counters while peeling this cell c.bwdMu.Lock() plain, err := peelBackward(c, cell) c.bwdMu.Unlock() if err != nil { return } // parse relay header h, err := unmarshalRelay(plain) if err != nil { return } // EXTENDED: deliver to the same wait channel used above if h.Cmd == CmdExtended { r.onCreated(srcPeerID, circID, append([]byte(nil), h.Data...)) return } key := streamsID(c.ID, h.StreamID) r.mu.Lock() s, ok := r.streams[key] defer r.mu.Unlock() if !ok { // might be BEGIN OK control; ignore for brevity return } switch h.Cmd { case CmdData: select { case <-s.closed: case s.readCh <- append([]byte(nil), h.Data...): } case CmdEnd: r.mu.Unlock() r.closeStream(c, s) r.mu.Lock() case CmdSendMe: // TODO(2.1.1+): metering default: // ignore or log } } // onCreated pushes CREATED payload to the waiting goroutine (if any). func (r *OnionRouter) onCreated( srcPeerID []byte, circID uint32, payload []byte, ) { key := createdWaitKey{peer: string(srcPeerID), circ: circID} r.mu.RLock() ch := r.createdWait[key] r.mu.RUnlock() if ch != nil { select { case ch <- append([]byte(nil), payload...): default: } } } func shuffle[T any](rng io.Reader, s []T) { n := len(s) for i := n - 1; i > 0; i-- { j := randInt(rng, i+1) s[i], s[j] = s[j], s[i] } } func randInt(r io.Reader, n int) int { if n <= 1 { return 0 } max := big.NewInt(int64(n)) v, err := rand.Int(r, max) if err != nil { return 0 } return int(v.Int64()) } func randUint(r io.Reader, n uint64) uint64 { if n <= 1 { return 0 } max := new(big.Int).SetUint64(n) v, _ := rand.Int(r, max) return v.Uint64() } func hkdfExpand(secret, info []byte, n int) []byte { r := hkdf.New(sha256.New, secret, nil, info) out := make([]byte, n) io.ReadFull(r, out) return out } func hmacSHA256(key, data []byte) []byte { h := hmac.New(sha256.New, key) h.Write(data) return h.Sum(nil) } func hmacEqual(a, b []byte) bool { return subtle.ConstantTimeCompare(a, b) == 1 } func concat(parts ...[]byte) []byte { var tot int for _, p := range parts { tot += len(p) } out := make([]byte, 0, tot) for _, p := range parts { out = append(out, p...) } return out }