From 4b2f7c40a2556073b6ba96b66e23d0ccd22f176f Mon Sep 17 00:00:00 2001 From: Lee Yunjin Date: Sat, 5 Sep 2026 12:16:44 +0900 Subject: [PATCH 1/5] feat(sam): support Datagram2/Datagram3 and offline signatures Add DATAGRAM2/DATAGRAM3 session styles mapped to protocols 19 and 20, extend private destination parsing with the offline signature section, and carry the transient signing key through LocalDestination so datagram signing works when the long-term key is kept offline. --- client/internal/sam/datagram.go | 97 ++++-- client/internal/sam/datagram_modern_test.go | 290 ++++++++++++++++++ client/internal/sam/offline_test.go | 160 ++++++++++ client/internal/sam/private_destination.go | 156 +++++++--- client/internal/sam/protocol.go | 26 +- client/internal/sam/server_test.go | 16 + client/internal/sam/session.go | 34 +- client/internal/sam/udp.go | 9 +- client/internal/sam/udp_test.go | 2 +- foundation/address_generator.go | 100 +++++- foundation/offline_signature.go | 154 ++++++++++ foundation/offline_signature_test.go | 174 +++++++++++ .../destination/destination_interface.go | 6 + networking/internal/datagram/modern.go | 3 + networking/networking_subsystem.go | 6 + node/internal/runtime/client_destination.go | 22 ++ 16 files changed, 1167 insertions(+), 88 deletions(-) create mode 100644 client/internal/sam/datagram_modern_test.go create mode 100644 client/internal/sam/offline_test.go create mode 100644 foundation/offline_signature.go create mode 100644 foundation/offline_signature_test.go diff --git a/client/internal/sam/datagram.go b/client/internal/sam/datagram.go index 2b45c3d..0c0694a 100644 --- a/client/internal/sam/datagram.go +++ b/client/internal/sam/datagram.go @@ -22,7 +22,7 @@ func (s *Server) handleSend(connection *serverConnection, cmd command) error { if session == nil { return connection.writeLine(cmd.verb + " STATUS RESULT=INVALID_ID") } - if cmd.verb == "DATAGRAM" && session.style != styleDatagram { + if cmd.verb == "DATAGRAM" && !isDatagramStyle(session.style) { return connection.writeLine("DATAGRAM STATUS RESULT=I2P_ERROR MESSAGE=WRONG_STYLE") } if cmd.verb == "RAW" && session.style != styleRaw { @@ -67,12 +67,11 @@ func (s *Server) handleSend(connection *serverConnection, cmd command) error { } framedLease = lease defer framedLease.ReleaseSensitive() - n, marshalErr := session.endpoint.MarshalDatagramV1To(framed, body) + n, marshalErr := marshalSessionDatagram(session, framed, hash, body) if marshalErr != nil || n != len(framed) { return connection.writeLine("DATAGRAM STATUS RESULT=I2P_ERROR") } payload = framed - protocol = networking.DatagramProtocolDatagram1 } if cmd.verb == "RAW" { if _, ok := cmd.values["PROTOCOL"]; ok { @@ -113,26 +112,41 @@ func (s *samSession) forwardReceivedMessage(message *destination.ReceivedMessage } }() defer message.Release() - switch s.style { - case styleDatagram: + switch { + case isDatagramStyle(s.style): s.forwardDatagram(message.Delivery) - case styleRaw: + case s.style == styleRaw: s.forwardRaw(message.Delivery) } } -func (s *samSession) forwardDatagram(delivery networking.StreamingTunnelDelivery) { - packet, err := networking.DatagramParsePacket(networking.DatagramProtocolDatagram1, delivery.Payload) - if err != nil { - return +func marshalSessionDatagram(session *samSession, dst []byte, target foundation.Hash, payload []byte) (int, error) { + switch session.protocol { + case networking.DatagramProtocolDatagram1: + return session.endpoint.MarshalDatagramV1To(dst, payload) + case networking.DatagramProtocolDatagram2: + modern, ok := session.endpoint.(destination.ModernDatagramEndpoint) + if !ok { + return 0, ErrUnsupported + } + return modern.MarshalDatagramV2To(dst, target, payload) + case networking.DatagramProtocolDatagram3: + modern, ok := session.endpoint.(destination.ModernDatagramEndpoint) + if !ok { + return 0, ErrUnsupported + } + return modern.MarshalDatagramV3To(dst, payload) } - valid, err := packet.V1.Verify() - if err != nil || !valid || packet.V1.From.Hash() != delivery.From { + return 0, ErrProtocol +} + +func (s *samSession) forwardDatagram(delivery networking.StreamingTunnelDelivery) { + source, payload, ok := s.parseReceivedDatagram(delivery) + if !ok { return } - source := foundation.EncodeI2PBase64(packet.V1.From.Bytes()) if s.udpTarget != nil { - wire, lease, ok := datagramUDPWire(source, delivery.FromPort, delivery.ToPort, packet.V1.Payload) + wire, lease, ok := datagramUDPWire(source, delivery.FromPort, delivery.ToPort, payload) if !ok { return } @@ -140,12 +154,38 @@ func (s *samSession) forwardDatagram(delivery networking.StreamingTunnelDelivery _, _ = s.server.udp.WriteTo(wire, s.udpTarget) return } - header, lease, ok := datagramReceivedHeader(source, delivery.FromPort, delivery.ToPort, len(packet.V1.Payload)) + header, lease, ok := datagramReceivedHeader(source, delivery.FromPort, delivery.ToPort, len(payload)) if !ok { return } defer lease.Release() - _ = s.control.writeFrame(header, packet.V1.Payload) + _ = s.control.writeFrame(header, payload) +} + +func (s *samSession) parseReceivedDatagram(delivery networking.StreamingTunnelDelivery) (string, []byte, bool) { + packet, err := networking.DatagramParsePacket(s.protocol, delivery.Payload) + if err != nil { + return "", nil, false + } + switch s.protocol { + case networking.DatagramProtocolDatagram1: + valid, err := packet.V1.Verify() + if err != nil || !valid || packet.V1.From.Hash() != delivery.From { + return "", nil, false + } + return foundation.EncodeI2PBase64(packet.V1.From.Bytes()), packet.V1.Payload, true + case networking.DatagramProtocolDatagram2: + valid, err := packet.V2.VerifyTarget(s.endpoint.Hash()) + if err != nil || !valid || packet.V2.From.Hash() != delivery.From { + return "", nil, false + } + return foundation.EncodeI2PBase64(packet.V2.From.Bytes()), packet.V2.Payload, true + case networking.DatagramProtocolDatagram3: + // Datagram3 is unauthenticated by spec: no signature to verify and the + // source is a bare 32-byte hash, not a full destination. + return foundation.EncodeI2PBase64(packet.V3.From[:]), packet.V3.Payload, true + } + return "", nil, false } func (s *samSession) forwardRaw(delivery networking.StreamingTunnelDelivery) { @@ -187,10 +227,16 @@ func destinationHash(value string) (foundation.Hash, error) { return hash, nil } -func datagramV1Overhead(endpoint destination.DestinationEndpoint) int { +func datagramOverhead(protocol uint8, endpoint destination.DestinationEndpoint, offline *foundation.OfflineSignature) int { if endpoint == nil { return 0 } + if protocol == networking.DatagramProtocolDatagram3 { + return 34 + } + if protocol != networking.DatagramProtocolDatagram1 && protocol != networking.DatagramProtocolDatagram2 { + return 0 + } identity, err := foundation.ParseDestination(endpoint.Destination()) if err != nil { return 0 @@ -199,7 +245,22 @@ func datagramV1Overhead(endpoint destination.DestinationEndpoint) int { if !ok { return 0 } - return identity.EncodedLen() + signatureLen + overhead := identity.EncodedLen() + signatureLen + if protocol == networking.DatagramProtocolDatagram2 { + // Flags word; options section is absent. + overhead += 2 + if offline != nil { + transientLen, ok := offline.Type.SignatureLen() + if !ok { + return 0 + } + // Expires, transient key type, transient public key, and the + // authorization signature; the payload signature uses the + // transient key. + overhead += 6 + len(offline.PublicKey) + transientLen + } + } + return overhead } func (s *samSession) datagramFrame(payloadLen int) ([]byte, *pool.Lease, bool) { diff --git a/client/internal/sam/datagram_modern_test.go b/client/internal/sam/datagram_modern_test.go new file mode 100644 index 0000000..763a8de --- /dev/null +++ b/client/internal/sam/datagram_modern_test.go @@ -0,0 +1,290 @@ +package sam + +import ( + "bufio" + "context" + "io" + "net" + "strings" + "testing" + "time" + + "gosuda.org/ivnp/foundation" + "gosuda.org/ivnp/interfaces/destination" + "gosuda.org/ivnp/networking" +) + +func TestParseStyleDatagramModern(t *testing.T) { + for _, value := range []string{"DATAGRAM2", "datagram2", "DATAGRAM3", "datagram3"} { + style, ok := parseStyle(value) + if !ok { + t.Fatalf("parseStyle(%q) rejected", value) + } + if style != sessionStyle(strings.ToUpper(value)) { + t.Fatalf("parseStyle(%q) = %q", value, style) + } + } + if _, ok := parseStyle("DATAGRAM4"); ok { + t.Fatal("parseStyle accepted DATAGRAM4") + } +} + +func TestConfigurePacketTransportDatagramModern(t *testing.T) { + server := &Server{} + cases := []struct { + style sessionStyle + protocol uint8 + }{ + {styleDatagram, networking.DatagramProtocolDatagram1}, + {styleDatagram2, networking.DatagramProtocolDatagram2}, + {styleDatagram3, networking.DatagramProtocolDatagram3}, + } + for _, tc := range cases { + config := sessionTransportConfig{} + if err := server.configurePacketTransport(nil, &config, tc.style, map[string]string{}, false); err != nil { + t.Fatalf("style %s: %v", tc.style, err) + } + if config.protocol != tc.protocol || config.listenProtocol != tc.protocol { + t.Fatalf("style %s protocol = %d/%d, want %d", tc.style, config.protocol, config.listenProtocol, tc.protocol) + } + for _, option := range []string{"PROTOCOL", "HEADER", "LISTEN_PROTOCOL"} { + config = sessionTransportConfig{} + if err := server.configurePacketTransport(nil, &config, tc.style, map[string]string{option: "1"}, true); err == nil { + t.Fatalf("style %s accepted %s option", tc.style, option) + } + } + } +} + +func TestDatagramOverheadPerProtocol(t *testing.T) { + local, err := foundation.GenerateLocalDestination() + if err != nil { + t.Fatal(err) + } + defer local.ReleaseSensitive() + endpoint := &loopEndpoint{ + local: local, + controller: &loopController{endpoints: make(map[foundation.Hash]*loopEndpoint)}, + subscriptions: make(map[destination.DestinationRoute]*loopSubscription), + } + defer endpoint.Close() + v1 := datagramOverhead(networking.DatagramProtocolDatagram1, endpoint, nil) + v2 := datagramOverhead(networking.DatagramProtocolDatagram2, endpoint, nil) + v3 := datagramOverhead(networking.DatagramProtocolDatagram3, endpoint, nil) + if v1 <= 0 || v2 != v1+2 { + t.Fatalf("v1 = %d, v2 = %d, want v1+2", v1, v2) + } + if v3 != 34 { + t.Fatalf("v3 overhead = %d, want 34", v3) + } + if other := datagramOverhead(6, endpoint, nil); other != 0 { + t.Fatalf("stream protocol overhead = %d, want 0", other) + } + offline := &foundation.OfflineSignature{Type: foundation.SigningEdDSASHA512Ed25519, PublicKey: make([]byte, 32)} + identity, err := local.Identity() + if err != nil { + t.Fatal(err) + } + authorizationLen, ok := identity.SigningKeyType().SignatureLen() + if !ok { + t.Fatal("unknown signing key type") + } + transientLen, ok := offline.Type.SignatureLen() + if !ok { + t.Fatal("unknown transient key type") + } + want := identity.EncodedLen() + 2 + 6 + len(offline.PublicKey) + authorizationLen + transientLen + if got := datagramOverhead(networking.DatagramProtocolDatagram2, endpoint, offline); got != want { + t.Fatalf("offline v2 overhead = %d, want %d", got, want) + } + // Offline signatures never enter Datagram3 or Datagram1 wire formats. + if got := datagramOverhead(networking.DatagramProtocolDatagram3, endpoint, offline); got != 34 { + t.Fatalf("offline v3 overhead = %d, want 34", got) + } +} + +func createDatagramSession(t *testing.T, address, id, style string) (net.Conn, *bufio.Reader, *foundation.LocalDestination) { + t.Helper() + control, reader := samDial(t, address) + _, _ = io.WriteString(control, "SESSION CREATE STYLE="+style+" ID="+id+" DESTINATION=TRANSIENT\n") + line := readSAMLine(t, reader) + if !strings.Contains(line, "RESULT=OK DESTINATION=") { + t.Fatalf("%s create = %q", style, line) + } + local, err := decodePrivateDestination(strings.Split(line, " DESTINATION=")[1]) + if err != nil { + t.Fatal(err) + } + return control, reader, local +} + +func TestDatagramModernRoundtrip(t *testing.T) { + for _, style := range []string{"DATAGRAM", "DATAGRAM2", "DATAGRAM3"} { + t.Run(style, func(t *testing.T) { + controller := &loopController{endpoints: make(map[foundation.Hash]*loopEndpoint)} + server, err := NewServer(ServerConfig{Address: "127.0.0.1:0", Controller: controller, MaxSessions: 4}) + if err != nil { + t.Fatal(err) + } + if err = server.Start(t.Context()); err != nil { + t.Fatal(err) + } + defer func() { _ = server.Close(); _ = server.Wait() }() + control, reader, local := createDatagramSession(t, server.Addr().String(), "dg", style) + defer control.Close() + defer local.ReleaseSensitive() + target := string(local.Destination()) + _, _ = io.WriteString(control, "DATAGRAM SEND ID=dg DESTINATION="+target+" SIZE=4\nDATA") + if line := readSAMLine(t, reader); line != "DATAGRAM STATUS RESULT=OK" { + t.Fatalf("datagram status = %q", line) + } + line := readSAMLine(t, reader) + var wantSource string + if style == "DATAGRAM3" { + hash := local.Hash() + wantSource = foundation.EncodeI2PBase64(hash[:]) + } else { + wantSource = string(local.Destination()) + } + if !strings.HasPrefix(line, "DATAGRAM RECEIVED DESTINATION="+wantSource+" ") || !strings.Contains(line, "SIZE=4") { + t.Fatalf("datagram receive = %q, want source %q", line, wantSource) + } + body := make([]byte, 4) + if _, err = io.ReadFull(reader, body); err != nil || string(body) != "DATA" { + t.Fatalf("datagram body = %q, %v", body, err) + } + }) + } +} + +func TestDatagram2DropsForgedDatagrams(t *testing.T) { + controller := &loopController{endpoints: make(map[foundation.Hash]*loopEndpoint)} + server, err := NewServer(ServerConfig{Address: "127.0.0.1:0", Controller: controller, MaxSessions: 4}) + if err != nil { + t.Fatal(err) + } + if err = server.Start(t.Context()); err != nil { + t.Fatal(err) + } + defer func() { _ = server.Close(); _ = server.Wait() }() + control, reader, local := createDatagramSession(t, server.Addr().String(), "dg2", "DATAGRAM2") + defer control.Close() + defer local.ReleaseSensitive() + + sender, err := foundation.GenerateLocalDestination() + if err != nil { + t.Fatal(err) + } + defer sender.ReleaseSensitive() + senderEndpoint, err := controller.CreateDestination(t.Context(), destination.DestinationSpec{Local: sender}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = senderEndpoint.Close() }() + receiverHash := local.Hash() + + craft := func(target foundation.Hash, tamper bool) []byte { + identity, identityErr := sender.Identity() + if identityErr != nil { + t.Fatal(identityErr) + } + overhead := identity.EncodedLen() + 2 + 64 + frame := make([]byte, overhead+4) + n, marshalErr := networking.DatagramMarshalV2To(frame, target, identity, 2, foundation.Mapping{}, networking.DatagramOfflineSignature{}, []byte("DATA"), sender.Sign) + if marshalErr != nil || n != len(frame) { + t.Fatalf("marshal = %d, %v", n, marshalErr) + } + if tamper { + frame[len(frame)-65] ^= 0xff + } + return frame + } + deliver := func(payload []byte) { + if err = senderEndpoint.SendMessage(t.Context(), networking.StreamingTunnelDelivery{From: sender.Hash(), To: receiverHash, Protocol: networking.DatagramProtocolDatagram2, Payload: payload}); err != nil { + t.Fatal(err) + } + } + + other, err := foundation.GenerateLocalDestination() + if err != nil { + t.Fatal(err) + } + defer other.ReleaseSensitive() + deliver(craft(other.Hash(), false)) // valid signature bound to the wrong target hash + deliver(craft(receiverHash, true)) // target hash matches but signature is broken + deliver(craft(receiverHash, false)) // genuine datagram still arrives + + if err = control.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatal(err) + } + line := readSAMLine(t, reader) + if !strings.HasPrefix(line, "DATAGRAM RECEIVED DESTINATION="+string(sender.Destination())+" ") || !strings.Contains(line, "SIZE=4") { + t.Fatalf("datagram receive = %q", line) + } + body := make([]byte, 4) + if _, err = io.ReadFull(reader, body); err != nil || string(body) != "DATA" { + t.Fatalf("datagram body = %q, %v", body, err) + } +} + +func TestRawSendRejectsModernDatagramProtocols(t *testing.T) { + server := &Server{} + for _, protocol := range []string{"19", "20"} { + config := sessionTransportConfig{} + if err := server.configurePacketTransport(nil, &config, styleRaw, map[string]string{"PROTOCOL": protocol}, false); err == nil { + t.Fatalf("RAW accepted PROTOCOL=%s", protocol) + } + } +} + +type v1OnlyEndpoint struct { + destination.DestinationEndpoint +} + +type v1OnlyController struct { + inner destination.DestinationController +} + +func (c *v1OnlyController) CreateDestination(ctx context.Context, spec destination.DestinationSpec) (destination.DestinationEndpoint, error) { + endpoint, err := c.inner.CreateDestination(ctx, spec) + if err != nil { + return nil, err + } + return &v1OnlyEndpoint{endpoint}, nil +} +func (c *v1OnlyController) DestroyDestination(ctx context.Context, endpoint destination.DestinationEndpoint) error { + wrapped, ok := endpoint.(*v1OnlyEndpoint) + if !ok { + return ErrProtocol + } + return c.inner.DestroyDestination(ctx, wrapped.DestinationEndpoint) +} + +func TestDatagramModernSendWithoutEndpointSupport(t *testing.T) { + peer, err := foundation.GenerateLocalDestination() + if err != nil { + t.Fatal(err) + } + defer peer.ReleaseSensitive() + loop := &loopController{endpoints: make(map[foundation.Hash]*loopEndpoint)} + server, err := NewServer(ServerConfig{Address: "127.0.0.1:0", Controller: &v1OnlyController{inner: loop}, Resolver: fixedResolver(string(peer.Destination())), MaxSessions: 4}) + if err != nil { + t.Fatal(err) + } + if err = server.Start(t.Context()); err != nil { + t.Fatal(err) + } + defer func() { _ = server.Close(); _ = server.Wait() }() + for _, style := range []string{"DATAGRAM2", "DATAGRAM3"} { + control, reader := samDial(t, server.Addr().String()) + _, _ = io.WriteString(control, "SESSION CREATE STYLE="+style+" ID=dg DESTINATION=TRANSIENT\n") + if line := readSAMLine(t, reader); !strings.Contains(line, "RESULT=OK") { + t.Fatalf("%s create = %q", style, line) + } + _, _ = io.WriteString(control, "DATAGRAM SEND ID=dg DESTINATION=peer.i2p SIZE=4\nDATA") + if line := readSAMLine(t, reader); line != "DATAGRAM STATUS RESULT=I2P_ERROR" { + t.Fatalf("%s send = %q", style, line) + } + control.Close() + } +} diff --git a/client/internal/sam/offline_test.go b/client/internal/sam/offline_test.go new file mode 100644 index 0000000..e284aaf --- /dev/null +++ b/client/internal/sam/offline_test.go @@ -0,0 +1,160 @@ +package sam + +import ( + "crypto/ed25519" + "crypto/rand" + "io" + "strings" + "testing" + "time" + + "gosuda.org/ivnp/foundation" +) + +// offlineSAMPrivateDestination builds a SAM private destination whose signing +// private key is all zero followed by an Offline Signature section authorizing +// a freshly generated transient Ed25519 key. +func offlineSAMPrivateDestination(t *testing.T, expires uint32) (private, public string) { + t.Helper() + longTerm, err := foundation.GenerateLocalDestination() + if err != nil { + t.Fatal(err) + } + defer longTerm.ReleaseSensitive() + identityRaw, err := foundation.DecodeI2PBase64(longTerm.Destination()) + if err != nil { + t.Fatal(err) + } + defer clear(identityRaw) + var encryption [32]byte + if err = longTerm.CopyX25519Private(encryption[:]); err != nil { + t.Fatal(err) + } + defer clear(encryption[:]) + transientPublic, transientFull, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + offline := foundation.OfflineSignature{Expires: expires, Type: foundation.SigningEdDSASHA512Ed25519, PublicKey: transientPublic} + signed := offline.SignedContent() + offline.Signature, err = longTerm.Sign(signed) + if err != nil { + t.Fatal(err) + } + seed := transientFull.Seed() + defer clear(seed) + wire := make([]byte, 0, len(identityRaw)+32+32+len(signed)+len(offline.Signature)+len(seed)) + wire = append(wire, identityRaw...) + wire = append(wire, encryption[:]...) + wire = append(wire, make([]byte, 32)...) + wire = append(wire, signed...) + wire = append(wire, offline.Signature...) + wire = append(wire, seed...) + encoded := foundation.EncodeI2PBase64(wire) + clear(wire) + return encoded, string(longTerm.Destination()) +} + +func TestDatagram2OfflineRoundtrip(t *testing.T) { + controller := &loopController{endpoints: make(map[foundation.Hash]*loopEndpoint)} + server, err := NewServer(ServerConfig{Address: "127.0.0.1:0", Controller: controller, MaxSessions: 4}) + if err != nil { + t.Fatal(err) + } + if err = server.Start(t.Context()); err != nil { + t.Fatal(err) + } + defer func() { _ = server.Close(); _ = server.Wait() }() + private, public := offlineSAMPrivateDestination(t, uint32(time.Now().Add(time.Hour).Unix())) + control, reader := samDial(t, server.Addr().String()) + defer control.Close() + _, _ = io.WriteString(control, "SESSION CREATE STYLE=DATAGRAM2 ID=offline DESTINATION="+private+"\n") + line := readSAMLine(t, reader) + if !strings.Contains(line, "RESULT=OK DESTINATION=") { + t.Fatalf("offline create = %q", line) + } + if echoed := strings.Split(line, " DESTINATION=")[1]; echoed != private { + t.Fatal("SESSION STATUS did not echo the offline private destination") + } + _, _ = io.WriteString(control, "DATAGRAM SEND ID=offline DESTINATION="+public+" SIZE=4\nDATA") + if line = readSAMLine(t, reader); line != "DATAGRAM STATUS RESULT=OK" { + t.Fatalf("datagram status = %q", line) + } + line = readSAMLine(t, reader) + if !strings.HasPrefix(line, "DATAGRAM RECEIVED DESTINATION="+public+" ") || !strings.Contains(line, "SIZE=4") { + t.Fatalf("datagram receive = %q", line) + } + body := make([]byte, 4) + if _, err = io.ReadFull(reader, body); err != nil || string(body) != "DATA" { + t.Fatalf("datagram body = %q, %v", body, err) + } +} + +func TestDatagram2OfflineExpiredDropped(t *testing.T) { + controller := &loopController{endpoints: make(map[foundation.Hash]*loopEndpoint)} + server, err := NewServer(ServerConfig{Address: "127.0.0.1:0", Controller: controller, MaxSessions: 4}) + if err != nil { + t.Fatal(err) + } + if err = server.Start(t.Context()); err != nil { + t.Fatal(err) + } + defer func() { _ = server.Close(); _ = server.Wait() }() + address := server.Addr().String() + receiver, receiverReader, receiverLocal := createDatagramSession(t, address, "receiver", "DATAGRAM2") + defer receiver.Close() + defer receiverLocal.ReleaseSensitive() + target := string(receiverLocal.Destination()) + + private, _ := offlineSAMPrivateDestination(t, uint32(time.Now().Add(-time.Hour).Unix())) + sender, senderReader := samDial(t, address) + defer sender.Close() + _, _ = io.WriteString(sender, "SESSION CREATE STYLE=DATAGRAM2 ID=sender DESTINATION="+private+"\n") + if line := readSAMLine(t, senderReader); !strings.Contains(line, "RESULT=OK") { + t.Fatalf("expired offline create = %q", line) + } + _, _ = io.WriteString(sender, "DATAGRAM SEND ID=sender DESTINATION="+target+" SIZE=4\nDATA") + if line := readSAMLine(t, senderReader); line != "DATAGRAM STATUS RESULT=OK" { + t.Fatalf("datagram status = %q", line) + } + if err = receiver.SetReadDeadline(time.Now().Add(300 * time.Millisecond)); err != nil { + t.Fatal(err) + } + if _, err = receiverReader.ReadString('\n'); err == nil { + t.Fatal("receiver accepted a datagram with an expired offline signature") + } +} + +func TestSessionCreateOfflineForgedSignatureRejected(t *testing.T) { + controller := &loopController{endpoints: make(map[foundation.Hash]*loopEndpoint)} + server, err := NewServer(ServerConfig{Address: "127.0.0.1:0", Controller: controller, MaxSessions: 4}) + if err != nil { + t.Fatal(err) + } + if err = server.Start(t.Context()); err != nil { + t.Fatal(err) + } + defer func() { _ = server.Close(); _ = server.Wait() }() + private, public := offlineSAMPrivateDestination(t, uint32(time.Now().Add(time.Hour).Unix())) + raw, err := foundation.DecodeI2PBase64([]byte(private)) + if err != nil { + t.Fatal(err) + } + identityRaw, err := foundation.DecodeI2PBase64([]byte(public)) + if err != nil { + t.Fatal(err) + } + // Flip a bit inside the authorization signature (after the identity, + // encryption key, zero signing key, and the expires/type/public key + // fields of the offline section). + raw[len(identityRaw)+32+32+6+32] ^= 0xff + clear(identityRaw) + forged := foundation.EncodeI2PBase64(raw) + clear(raw) + control, reader := samDial(t, server.Addr().String()) + defer control.Close() + _, _ = io.WriteString(control, "SESSION CREATE STYLE=DATAGRAM2 ID=forged DESTINATION="+forged+"\n") + if line := readSAMLine(t, reader); !strings.Contains(line, "RESULT=INVALID_KEY") { + t.Fatalf("forged offline create = %q", line) + } +} diff --git a/client/internal/sam/private_destination.go b/client/internal/sam/private_destination.go index d47a069..0a56c7d 100644 --- a/client/internal/sam/private_destination.go +++ b/client/internal/sam/private_destination.go @@ -14,7 +14,11 @@ var ErrInvalidKey = errors.New("sam: invalid private destination") // SAM private Destinations are the binary public Destination followed by the // encryption private key and signing private key, encoded with I2P base64. For -// Ed25519 SAM carries the 32-byte seed, not Go's 64-byte expanded key. +// Ed25519 SAM carries the 32-byte seed, not Go's 64-byte expanded key. An +// all-zero signing private key introduces an Offline Signature section: +// expires (4 BE), transient signing key type (2 BE), transient signing public +// key, authorization signature by the offline key, transient signing private +// key. func encodePrivateDestination(destination *foundation.LocalDestination) ([]byte, error) { if destination == nil { return nil, ErrInvalidKey @@ -43,49 +47,58 @@ func encodePrivateDestination(destination *foundation.LocalDestination) ([]byte, if err != nil || consumed != len(publicRaw) { return nil, ErrInvalidKey } - offset := 2 + publicLength - var signing []byte + stateSigningLength := 0 switch identity.SigningKeyType() { case foundation.SigningEdDSASHA512Ed25519: - if len(state) < offset+ed25519.PrivateKeySize+32 { - return nil, ErrInvalidKey - } - signing = state[offset : offset+ed25519.SeedSize] - offset += ed25519.PrivateKeySize + stateSigningLength = ed25519.PrivateKeySize case foundation.SigningRedDSASHA512Ed25519: - if len(state) < offset+32+32 { - return nil, ErrInvalidKey - } - signing = state[offset : offset+32] - offset += 32 + stateSigningLength = 32 default: return nil, ErrInvalidKey } + offset := 2 + publicLength + stateSigningLength + if len(state) < offset+32 { + return nil, ErrInvalidKey + } + var encryption []byte switch identity.CryptoKeyType() { case foundation.CryptoX25519: - x25519 := state[offset : offset+32] - wire := make([]byte, len(publicRaw)+32+len(signing)) - position := copy(wire, publicRaw) - position += copy(wire[position:], x25519) - copy(wire[position:], signing) - encoded := []byte(foundation.EncodeI2PBase64(wire)) - clear(wire) - return encoded, nil + encryption = state[offset : offset+32] case foundation.CryptoElGamal: - elgamalOffset := offset + 32 - if len(state) < elgamalOffset+256 { + if len(state) < offset+32+256 { return nil, ErrInvalidKey } - wire := make([]byte, len(publicRaw)+256+len(signing)) - position := copy(wire, publicRaw) - position += copy(wire[position:], state[elgamalOffset:elgamalOffset+256]) - copy(wire[position:], signing) - encoded := []byte(foundation.EncodeI2PBase64(wire)) - clear(wire) - return encoded, nil + encryption = state[offset+32 : offset+32+256] default: return nil, ErrInvalidKey } + var signing []byte + if offlineLength := destination.OfflinePrivateEncodedLen(); offlineLength > 0 { + signing = make([]byte, 32+offlineLength) + if _, err = destination.MarshalOfflinePrivateTo(signing[32:]); err != nil { + clear(signing) + return nil, err + } + defer clear(signing) + } else { + signing = state[2+publicLength : 2+publicLength+32] + } + wire := make([]byte, len(publicRaw)+len(encryption)+len(signing)) + position := copy(wire, publicRaw) + position += copy(wire[position:], encryption) + copy(wire[position:], signing) + encoded := []byte(foundation.EncodeI2PBase64(wire)) + clear(wire) + return encoded, nil +} + +func zeroBytes(value []byte) bool { + for _, b := range value { + if b != 0 { + return false + } + } + return true } func decodePrivateDestination(encoded string) (*foundation.LocalDestination, error) { @@ -103,23 +116,44 @@ func decodePrivateDestination(encoded string) (*foundation.LocalDestination, err if identity.CryptoKeyType() == foundation.CryptoElGamal { encryptionLength = 256 } - if len(wire) != consumed+encryptionLength+signingLength { + if len(wire) < consumed+encryptionLength+signingLength { return nil, ErrInvalidKey } encryptionPrivate := wire[consumed : consumed+encryptionLength] - signing := wire[consumed+encryptionLength:] - var private []byte + signing := wire[consumed+encryptionLength : consumed+encryptionLength+signingLength] + offlineSection := wire[consumed+encryptionLength+signingLength:] + var offline *offlinePrivateKey + if zeroBytes(signing) { + parsed, parseErr := parseOfflinePrivateKey(identity, offlineSection) + if parseErr != nil { + return nil, parseErr + } + offline = parsed + defer offline.clear() + } else if len(offlineSection) != 0 { + return nil, ErrInvalidKey + } + privateLength := 0 switch identity.SigningKeyType() { case foundation.SigningEdDSASHA512Ed25519: - private = ed25519.NewKeyFromSeed(signing) + privateLength = ed25519.PrivateKeySize case foundation.SigningRedDSASHA512Ed25519: if identity.CryptoKeyType() != foundation.CryptoX25519 { return nil, ErrInvalidKey } - private = append([]byte(nil), signing...) + privateLength = 32 default: return nil, ErrInvalidKey } + private := make([]byte, privateLength) + if offline == nil { + switch identity.SigningKeyType() { + case foundation.SigningEdDSASHA512Ed25519: + private = ed25519.NewKeyFromSeed(signing) + case foundation.SigningRedDSASHA512Ed25519: + copy(private, signing) + } + } defer clear(private) var x25519 []byte var elgamal []byte @@ -142,9 +176,61 @@ func decodePrivateDestination(encoded string) (*foundation.LocalDestination, err offset += copy(state[offset:], x25519) offset += copy(state[offset:], elgamal) state[offset] = 0x07 + if offline != nil { + destination, err := foundation.ImportLocalDestinationOffline(state, offline.OfflineSignature, offline.transientPrivate) + if err != nil { + return nil, ErrInvalidKey + } + return destination, nil + } destination, err := foundation.ImportLocalDestination(state) if err != nil { return nil, ErrInvalidKey } return destination, nil } + +type offlinePrivateKey struct { + foundation.OfflineSignature + transientPrivate []byte +} + +func (o *offlinePrivateKey) clear() { + o.PublicKey = nil + o.Signature = nil + clear(o.transientPrivate) + o.transientPrivate = nil +} + +func parseOfflinePrivateKey(identity foundation.Identity, section []byte) (*offlinePrivateKey, error) { + if len(section) < 6 { + return nil, ErrInvalidKey + } + keyType := foundation.SigningKeyType(binary.BigEndian.Uint16(section[4:6])) + publicLength, ok := keyType.PublicKeyLen() + if !ok { + return nil, ErrInvalidKey + } + signatureLength, ok := identity.SigningKeyType().SignatureLen() + if !ok { + return nil, ErrInvalidKey + } + if len(section) != 6+publicLength+signatureLength+ed25519.SeedSize { + return nil, ErrInvalidKey + } + offset := 6 + public := append([]byte(nil), section[offset:offset+publicLength]...) + offset += publicLength + signature := append([]byte(nil), section[offset:offset+signatureLength]...) + offset += signatureLength + transientPrivate := append([]byte(nil), section[offset:]...) + return &offlinePrivateKey{ + OfflineSignature: foundation.OfflineSignature{ + Expires: binary.BigEndian.Uint32(section[:4]), + Type: keyType, + PublicKey: public, + Signature: signature, + }, + transientPrivate: transientPrivate, + }, nil +} diff --git a/client/internal/sam/protocol.go b/client/internal/sam/protocol.go index b443d58..7bcbc3b 100644 --- a/client/internal/sam/protocol.go +++ b/client/internal/sam/protocol.go @@ -145,6 +145,11 @@ func (s *Server) createSession(ctx context.Context, connection *serverConnection if err != nil { return connection.writeLine("SESSION STATUS RESULT=INVALID_KEY") } + var offline *foundation.OfflineSignature + if meta, ok := local.OfflineSignature(); ok { + meta := meta + offline = &meta + } private, err := encodePrivateDestination(local) if err != nil { local.ReleaseSensitive() @@ -173,7 +178,7 @@ func (s *Server) createSession(ctx context.Context, connection *serverConnection return errors.Join(connection.writeLine("SESSION STATUS RESULT=I2P_ERROR MESSAGE=SESSION_NOT_READY"), cleanupErr) } } - root := newRootSession(s, id, style, endpoint, connection, fromPort, toPort, listenPort, protocol, listenProtocol, rawHeader, udpTarget) + root := newRootSession(s, id, style, endpoint, connection, fromPort, toPort, listenPort, protocol, listenProtocol, rawHeader, udpTarget, offline) if err = s.addRoot(root); err != nil { cleanupErr := s.destroyDestination(endpoint) result := "I2P_ERROR" @@ -186,8 +191,8 @@ func (s *Server) createSession(ctx context.Context, connection *serverConnection return errors.Join(connection.writeLine("SESSION STATUS RESULT="+result), cleanupErr) } connection.root = root - if style == styleDatagram { - err = root.startReceiver(destination.DestinationRoute{Protocol: networking.DatagramProtocolDatagram1, ToPort: listenPort}, s.config.SessionQueue) + if isDatagramStyle(style) { + err = root.startReceiver(destination.DestinationRoute{Protocol: protocol, ToPort: listenPort}, s.config.SessionQueue) } if style == styleRaw { err = root.startReceiver(destination.DestinationRoute{Protocol: listenProtocol, ToPort: listenPort}, s.config.SessionQueue) @@ -258,7 +263,7 @@ func (s *Server) addSubsession(connection *serverConnection, cmd command) error } ctx, cancel := context.WithCancel(root.ctx) child := &samSession{server: s, root: root, id: id, style: style, endpoint: root.endpoint, control: connection, ctx: ctx, cancel: cancel, sourceIP: root.sourceIP, fromPort: fromPort, toPort: toPort, listenPort: listenPort, protocol: protocol, listenProtocol: listenProtocol, rawHeader: rawHeader, udpTarget: udpTarget, children: make(map[string]*samSession), attachments: make(map[net.Conn]struct{}), queueBytes: newByteBudget(s.config.MaxSessionQueueBytes), acceptRequests: make(chan acceptRequest, s.config.SessionQueue)} - child.datagramOverhead = root.datagramOverhead + child.datagramOverhead = datagramOverhead(protocol, root.endpoint, root.offline) if err = s.addChild(child); err != nil { cancel() return connection.writeLine("SESSION STATUS RESULT=DUPLICATED_ID") @@ -266,8 +271,8 @@ func (s *Server) addSubsession(connection *serverConnection, cmd command) error root.mu.Lock() root.children[id] = child root.mu.Unlock() - if style == styleDatagram { - err = child.startReceiver(destination.DestinationRoute{Protocol: networking.DatagramProtocolDatagram1, ToPort: listenPort}, s.config.SessionQueue) + if isDatagramStyle(style) { + err = child.startReceiver(destination.DestinationRoute{Protocol: protocol, ToPort: listenPort}, s.config.SessionQueue) } if style == styleRaw { err = child.startReceiver(destination.DestinationRoute{Protocol: listenProtocol, ToPort: listenPort}, s.config.SessionQueue) @@ -294,7 +299,7 @@ func (s *Server) removeSubsession(connection *serverConnection, cmd command) err func parseStyle(value string) (sessionStyle, bool) { switch sessionStyle(strings.ToUpper(value)) { - case styleStream, styleDatagram, styleRaw, stylePrimary: + case styleStream, styleDatagram, styleDatagram2, styleDatagram3, styleRaw, stylePrimary: return sessionStyle(strings.ToUpper(value)), true } return "", false @@ -426,7 +431,7 @@ func (s *Server) sessionTransport(connection *serverConnection, style sessionSty switch style { case styleStream: err = s.configureStreamTransport(&config, values, child) - case styleDatagram, styleRaw: + case styleDatagram, styleDatagram2, styleDatagram3, styleRaw: err = s.configurePacketTransport(connection, &config, style, values, child) case stylePrimary: if child || values["PORT"] != "" || values["HOST"] != "" || values["HEADER"] != "" || values["PROTOCOL"] != "" { @@ -467,8 +472,9 @@ func (s *Server) configureStreamTransport(config *sessionTransportConfig, values } func (s *Server) configurePacketTransport(connection *serverConnection, config *sessionTransportConfig, style sessionStyle, values map[string]string, child bool) error { - if style == styleDatagram { - config.protocol, config.listenProtocol = networking.DatagramProtocolDatagram1, networking.DatagramProtocolDatagram1 + if isDatagramStyle(style) { + config.protocol = datagramStyleProtocol(style) + config.listenProtocol = config.protocol if values["PROTOCOL"] != "" || values["HEADER"] != "" || values["LISTEN_PROTOCOL"] != "" { return ErrProtocol } diff --git a/client/internal/sam/server_test.go b/client/internal/sam/server_test.go index e2a29d2..3d4cd70 100644 --- a/client/internal/sam/server_test.go +++ b/client/internal/sam/server_test.go @@ -87,6 +87,22 @@ func (e *loopEndpoint) MarshalDatagramV1To(dst, payload []byte) (int, error) { } return networking.DatagramMarshalV1To(dst, identity, payload, e.local.Sign) } +func (e *loopEndpoint) MarshalDatagramV2To(dst []byte, target foundation.Hash, payload []byte) (int, error) { + identity, err := e.local.Identity() + if err != nil { + return 0, err + } + flags := uint16(2) + var offline networking.DatagramOfflineSignature + if meta, ok := e.local.OfflineSignature(); ok { + flags |= networking.DatagramFlagOffline + offline = networking.DatagramOfflineSignature{Expires: meta.Expires, Type: meta.Type, PublicKey: meta.PublicKey, Signature: meta.Signature} + } + return networking.DatagramMarshalV2To(dst, target, identity, flags, foundation.Mapping{}, offline, payload, e.local.Sign) +} +func (e *loopEndpoint) MarshalDatagramV3To(dst, payload []byte) (int, error) { + return networking.DatagramMarshalV3To(dst, e.local.Hash(), 3, foundation.Mapping{}, payload) +} func (e *loopEndpoint) Subscribe(route destination.DestinationRoute, _ int) (destination.MessageSubscription, error) { sub := &loopSubscription{ch: make(chan *destination.ReceivedMessage, 8), done: make(chan struct{})} e.mu.Lock() diff --git a/client/internal/sam/session.go b/client/internal/sam/session.go index 3922153..4d08544 100644 --- a/client/internal/sam/session.go +++ b/client/internal/sam/session.go @@ -8,18 +8,37 @@ import ( "sync" "sync/atomic" + "gosuda.org/ivnp/foundation" "gosuda.org/ivnp/interfaces/destination" + "gosuda.org/ivnp/networking" ) type sessionStyle string const ( - styleStream sessionStyle = "STREAM" - styleDatagram sessionStyle = "DATAGRAM" - styleRaw sessionStyle = "RAW" - stylePrimary sessionStyle = "PRIMARY" + styleStream sessionStyle = "STREAM" + styleDatagram sessionStyle = "DATAGRAM" + styleDatagram2 sessionStyle = "DATAGRAM2" + styleDatagram3 sessionStyle = "DATAGRAM3" + styleRaw sessionStyle = "RAW" + stylePrimary sessionStyle = "PRIMARY" ) +func isDatagramStyle(style sessionStyle) bool { + return style == styleDatagram || style == styleDatagram2 || style == styleDatagram3 +} + +func datagramStyleProtocol(style sessionStyle) uint8 { + switch style { + case styleDatagram2: + return networking.DatagramProtocolDatagram2 + case styleDatagram3: + return networking.DatagramProtocolDatagram3 + default: + return networking.DatagramProtocolDatagram1 + } +} + type samSession struct { server *Server root *samSession @@ -37,6 +56,7 @@ type samSession struct { listenProtocol uint8 rawHeader bool udpTarget *net.UDPAddr + offline *foundation.OfflineSignature datagramOverhead int forward bool @@ -56,10 +76,10 @@ type samSession struct { wg sync.WaitGroup } -func newRootSession(server *Server, id string, style sessionStyle, endpoint destination.DestinationEndpoint, control *serverConnection, fromPort, toPort, listenPort uint16, protocol, listenProtocol uint8, rawHeader bool, udpTarget *net.UDPAddr) *samSession { +func newRootSession(server *Server, id string, style sessionStyle, endpoint destination.DestinationEndpoint, control *serverConnection, fromPort, toPort, listenPort uint16, protocol, listenProtocol uint8, rawHeader bool, udpTarget *net.UDPAddr, offline *foundation.OfflineSignature) *samSession { ctx, cancel := context.WithCancel(server.ctx) - s := &samSession{server: server, id: id, style: style, endpoint: endpoint, control: control, ctx: ctx, cancel: cancel, sourceIP: connectionIP(control.Conn), fromPort: fromPort, toPort: toPort, listenPort: listenPort, protocol: protocol, listenProtocol: listenProtocol, rawHeader: rawHeader, udpTarget: udpTarget, children: make(map[string]*samSession), attachments: make(map[net.Conn]struct{}), queueBytes: newByteBudget(server.config.MaxSessionQueueBytes), acceptRequests: make(chan acceptRequest, server.config.SessionQueue)} - s.datagramOverhead = datagramV1Overhead(endpoint) + s := &samSession{server: server, id: id, style: style, endpoint: endpoint, control: control, ctx: ctx, cancel: cancel, sourceIP: connectionIP(control.Conn), fromPort: fromPort, toPort: toPort, listenPort: listenPort, protocol: protocol, listenProtocol: listenProtocol, rawHeader: rawHeader, udpTarget: udpTarget, offline: offline, children: make(map[string]*samSession), attachments: make(map[net.Conn]struct{}), queueBytes: newByteBudget(server.config.MaxSessionQueueBytes), acceptRequests: make(chan acceptRequest, server.config.SessionQueue)} + s.datagramOverhead = datagramOverhead(protocol, endpoint, offline) s.root = s return s } diff --git a/client/internal/sam/udp.go b/client/internal/sam/udp.go index 48339f9..8d889ce 100644 --- a/client/internal/sam/udp.go +++ b/client/internal/sam/udp.go @@ -136,7 +136,7 @@ func (s *Server) parseUDPPacket(wire []byte, source net.Addr) (udpPacket, bool) return udpPacket{}, false } session := s.session(parts[1]) - parseUDPPacketRejected := session == nil || (session.style != styleDatagram && session.style != styleRaw) + parseUDPPacketRejected := session == nil || (!isDatagramStyle(session.style) && session.style != styleRaw) if !parseUDPPacketRejected { parseUDPPacketRejected = !sameSourceIP(session.sourceIP, source) } @@ -170,7 +170,7 @@ func (s *Server) parseUDPPacket(wire []byte, source net.Addr) (udpPacket, bool) } } payloadLen := len(wire) - newline - 1 - if session.style == styleDatagram && (session.datagramOverhead <= 0 || payloadLen > s.config.MaxDatagramBytes-session.datagramOverhead) { + if isDatagramStyle(session.style) && (session.datagramOverhead <= 0 || payloadLen > s.config.MaxDatagramBytes-session.datagramOverhead) { return udpPacket{}, false } lease, ok := pool.AcquireLease(payloadLen) @@ -219,19 +219,18 @@ func (s *Server) sendUDPPacket(packet udpPacket) { payload := packet.payload protocol := packet.protocol var framedLease *pool.Lease - if session.style == styleDatagram { + if isDatagramStyle(session.style) { framed, lease, ok := session.datagramFrame(len(packet.payload)) if !ok { return } framedLease = lease defer framedLease.ReleaseSensitive() - n, marshalErr := session.endpoint.MarshalDatagramV1To(framed, packet.payload) + n, marshalErr := marshalSessionDatagram(session, framed, hash, packet.payload) if marshalErr != nil || n != len(framed) { return } payload = framed - protocol = networking.DatagramProtocolDatagram1 } _ = session.endpoint.SendMessage(session.ctx, networking.StreamingTunnelDelivery{From: session.endpoint.Hash(), To: hash, FromPort: packet.fromPort, ToPort: packet.toPort, Protocol: protocol, Payload: payload}) } diff --git a/client/internal/sam/udp_test.go b/client/internal/sam/udp_test.go index 4f906ab..79b0ce9 100644 --- a/client/internal/sam/udp_test.go +++ b/client/internal/sam/udp_test.go @@ -328,7 +328,7 @@ func TestDatagramFrameUsesExactPayloadCapacity(t *testing.T) { } defer endpoint.Close() payload := []byte("exact datagram") - overhead := datagramV1Overhead(endpoint) + overhead := datagramOverhead(networking.DatagramProtocolDatagram1, endpoint, nil) session := &samSession{ server: &Server{config: ServerConfig{MaxDatagramBytes: overhead + len(payload)}}, endpoint: endpoint, diff --git a/foundation/address_generator.go b/foundation/address_generator.go index 4d82fe8..3781a5b 100644 --- a/foundation/address_generator.go +++ b/foundation/address_generator.go @@ -35,6 +35,7 @@ type LocalDestination struct { identityCryptoType CryptoKeyType elgamalPrivate cryptography.ElGamalPrivateKey cryptoCapabilities byte + offline *offlineSigning released bool } @@ -301,6 +302,18 @@ func (d *LocalDestination) Sign(message []byte) ([]byte, error) { if d.released { return nil, cryptography.ErrSensitiveReleased } + if d.offline != nil { + switch d.offline.keyType { + case SigningEdDSASHA512Ed25519: + return ed25519.Sign(ed25519.NewKeyFromSeed(d.offline.private), message), nil + case SigningRedDSASHA512Ed25519: + var private [32]byte + copy(private[:], d.offline.private) + return Red25519Sign(private, message) + default: + return nil, ErrEncryptedSigningKey + } + } switch d.signingType { case SigningEdDSASHA512Ed25519: return ed25519.Sign(ed25519.PrivateKey(d.signingPrivate), message), nil @@ -377,6 +390,15 @@ func (d *LocalDestination) Clone() (*LocalDestination, error) { identityCryptoType: d.identityCryptoType, elgamalPrivate: d.elgamalPrivate, } + if d.offline != nil { + clone.offline = &offlineSigning{ + expires: d.offline.expires, + keyType: d.offline.keyType, + public: append([]byte(nil), d.offline.public...), + signature: append([]byte(nil), d.offline.signature...), + private: append([]byte(nil), d.offline.private...), + } + } return clone, nil } @@ -390,6 +412,10 @@ func (d *LocalDestination) ReleaseSensitive() { clear(d.signingPrivate) clear(d.x25519Private[:]) clear(d.elgamalPrivate[:]) + if d.offline != nil { + d.offline.clear() + d.offline = nil + } d.released = true } d.mu.Unlock() @@ -438,6 +464,38 @@ func (d *LocalDestination) MarshalPrivateTo(dst []byte) (int, error) { // ImportLocalDestination reconstructs and validates a LocalDestination from serialized private state bytes. func ImportLocalDestination(src []byte) (*LocalDestination, error) { + return importLocalDestination(src, nil) +} + +// ImportLocalDestinationOffline reconstructs a LocalDestination whose long-term +// signing private key is absent (all zero in src) and replaced by an authorized +// transient signing key. The authorization signature is verified against the +// destination identity. +func ImportLocalDestinationOffline(src []byte, offline OfflineSignature, transientPrivate []byte) (*LocalDestination, error) { + if len(src) < 2 { + return nil, ErrInvalidIdentity + } + n := int(binary.BigEndian.Uint16(src[:2])) + if n == 0 || len(src) < 2+n { + return nil, ErrInvalidIdentity + } + identity, err := ParseDestination(src[2 : 2+n]) + if err != nil { + return nil, ErrInvalidIdentity + } + signing, err := parseOfflineSigning(identity, offline, transientPrivate) + if err != nil { + return nil, err + } + destination, err := importLocalDestination(src, signing) + if err != nil { + signing.clear() + return nil, err + } + return destination, nil +} + +func importLocalDestination(src []byte, offline *offlineSigning) (*LocalDestination, error) { if len(src) < 2 { return nil, ErrInvalidIdentity } @@ -474,21 +532,38 @@ func ImportLocalDestination(src []byte) (*LocalDestination, error) { off := 2 + n private := append([]byte(nil), src[off:off+privateLen]...) var public ed25519.PublicKey - switch signingType { - case SigningEdDSASHA512Ed25519: - public = ed25519.PrivateKey(private).Public().(ed25519.PublicKey) - case SigningRedDSASHA512Ed25519: - scalar, scalarErr := new(edwards25519.Scalar).SetCanonicalBytes(private) - if scalarErr != nil { + if offline != nil { + // The long-term signing private key stays offline; only zero padding + // occupies its slot in the serialized state. + for _, value := range private { + if value != 0 { + clear(private) + return nil, ErrInvalidIdentity + } + } + signing, rest := identity.SigningKeyParts() + if len(rest) != 0 { + clear(private) + return nil, ErrInvalidIdentity + } + public = append(ed25519.PublicKey(nil), signing...) + } else { + switch signingType { + case SigningEdDSASHA512Ed25519: + public = ed25519.PrivateKey(private).Public().(ed25519.PublicKey) + case SigningRedDSASHA512Ed25519: + scalar, scalarErr := new(edwards25519.Scalar).SetCanonicalBytes(private) + if scalarErr != nil { + clear(private) + return nil, ErrInvalidIdentity + } + public = append(ed25519.PublicKey(nil), new(edwards25519.Point).ScalarBaseMult(scalar).Bytes()...) + } + signing, rest := identity.SigningKeyParts() + if len(rest) != 0 || !bytes.Equal(signing, public) { clear(private) return nil, ErrInvalidIdentity } - public = append(ed25519.PublicKey(nil), new(edwards25519.Point).ScalarBaseMult(scalar).Bytes()...) - } - signing, rest := identity.SigningKeyParts() - if len(rest) != 0 || !bytes.Equal(signing, public) { - clear(private) - return nil, ErrInvalidIdentity } off += privateLen x25519, err := ecdh.X25519().NewPrivateKey(src[off : off+32]) @@ -524,6 +599,7 @@ func ImportLocalDestination(src []byte) (*LocalDestination, error) { identityCryptoType: identity.CryptoKeyType(), elgamalPrivate: elgamalPrivate, cryptoCapabilities: capabilities, + offline: offline, } copy(d.x25519Private[:], x25519.Bytes()) return d, nil diff --git a/foundation/offline_signature.go b/foundation/offline_signature.go new file mode 100644 index 0000000..62b04ff --- /dev/null +++ b/foundation/offline_signature.go @@ -0,0 +1,154 @@ +package foundation + +import ( + "bytes" + "crypto/ed25519" + "encoding/binary" + + "filippo.io/edwards25519" +) + +// OfflineSignature authorizes a transient signing key to act on behalf of a +// destination whose long-term signing private key is kept offline. It appears +// in SAM private destinations and protocol-19 Datagram2 packets. +type OfflineSignature struct { + Expires uint32 + Type SigningKeyType + PublicKey []byte + Signature []byte +} + +// SignedContent returns the authorized content (expires, key type, public key) +// covered by the offline signature. +func (o OfflineSignature) SignedContent() []byte { + signed := make([]byte, 6+len(o.PublicKey)) + binary.BigEndian.PutUint32(signed[:4], o.Expires) + binary.BigEndian.PutUint16(signed[4:6], uint16(o.Type)) + copy(signed[6:], o.PublicKey) + return signed +} + +func offlineTransientPrivateLen(keyType SigningKeyType) (int, bool) { + switch keyType { + case SigningEdDSASHA512Ed25519, SigningRedDSASHA512Ed25519: + return 32, true + } + return 0, false +} + +func offlineTransientPublic(keyType SigningKeyType, private []byte) ([]byte, error) { + switch keyType { + case SigningEdDSASHA512Ed25519: + return ed25519.NewKeyFromSeed(private).Public().(ed25519.PublicKey), nil + case SigningRedDSASHA512Ed25519: + scalar, err := new(edwards25519.Scalar).SetCanonicalBytes(private) + if err != nil { + return nil, ErrInvalidIdentity + } + return new(edwards25519.Point).ScalarBaseMult(scalar).Bytes(), nil + } + return nil, ErrInvalidIdentity +} + +type offlineSigning struct { + expires uint32 + keyType SigningKeyType + public []byte + signature []byte + private []byte +} + +func (o *offlineSigning) meta() OfflineSignature { + return OfflineSignature{ + Expires: o.expires, + Type: o.keyType, + PublicKey: append([]byte(nil), o.public...), + Signature: append([]byte(nil), o.signature...), + } +} + +func (o *offlineSigning) clear() { + o.public = nil + o.signature = nil + clear(o.private) + o.private = nil +} + +func parseOfflineSigning(identity Identity, offline OfflineSignature, transientPrivate []byte) (*offlineSigning, error) { + publicLen, ok := offline.Type.PublicKeyLen() + privateLen, privateOK := offlineTransientPrivateLen(offline.Type) + if !ok || !privateOK || len(offline.PublicKey) != publicLen || len(transientPrivate) != privateLen { + return nil, ErrInvalidIdentity + } + signatureLen, ok := identity.SigningKeyType().SignatureLen() + if !ok || len(offline.Signature) != signatureLen { + return nil, ErrInvalidIdentity + } + valid, err := identity.Verify(offline.SignedContent(), offline.Signature) + if err != nil || !valid { + return nil, ErrInvalidIdentity + } + derived, err := offlineTransientPublic(offline.Type, transientPrivate) + if err != nil || !bytes.Equal(derived, offline.PublicKey) { + return nil, ErrInvalidIdentity + } + return &offlineSigning{ + expires: offline.Expires, + keyType: offline.Type, + public: append([]byte(nil), offline.PublicKey...), + signature: append([]byte(nil), offline.Signature...), + private: append([]byte(nil), transientPrivate...), + }, nil +} + +// OfflineSignature returns the offline signature authorizing this destination's +// transient signing key, or false when the destination holds its long-term key. +func (d *LocalDestination) OfflineSignature() (OfflineSignature, bool) { + if d == nil { + return OfflineSignature{}, false + } + d.mu.RLock() + defer d.mu.RUnlock() + if d.released || d.offline == nil { + return OfflineSignature{}, false + } + return d.offline.meta(), true +} + +// OfflinePrivateEncodedLen returns the encoded length of the offline signature +// section including the transient private key, or 0 when absent. +func (d *LocalDestination) OfflinePrivateEncodedLen() int { + if d == nil { + return 0 + } + d.mu.RLock() + defer d.mu.RUnlock() + if d.released || d.offline == nil { + return 0 + } + return 6 + len(d.offline.public) + len(d.offline.signature) + len(d.offline.private) +} + +// MarshalOfflinePrivateTo serializes the offline signature section (expires, +// transient key type, transient public key, authorization signature, transient +// private key) into dst. Callers must wipe dst after use. +func (d *LocalDestination) MarshalOfflinePrivateTo(dst []byte) (int, error) { + if d == nil { + return 0, ErrInvalidIdentity + } + d.mu.RLock() + defer d.mu.RUnlock() + if d.released || d.offline == nil { + return 0, ErrInvalidIdentity + } + n := 6 + len(d.offline.public) + len(d.offline.signature) + len(d.offline.private) + if len(dst) < n { + return 0, ErrDestinationSmall + } + binary.BigEndian.PutUint32(dst[:4], d.offline.expires) + binary.BigEndian.PutUint16(dst[4:6], uint16(d.offline.keyType)) + off := 6 + copy(dst[6:], d.offline.public) + off += copy(dst[off:], d.offline.signature) + copy(dst[off:], d.offline.private) + return n, nil +} diff --git a/foundation/offline_signature_test.go b/foundation/offline_signature_test.go new file mode 100644 index 0000000..abc299f --- /dev/null +++ b/foundation/offline_signature_test.go @@ -0,0 +1,174 @@ +package foundation + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/binary" + "testing" + "time" +) + +func offlineTestState(t *testing.T, expires uint32) (state []byte, offline OfflineSignature, transientPrivate []byte, longTerm *LocalDestination) { + t.Helper() + longTerm, err := GenerateLocalDestination() + if err != nil { + t.Fatal(err) + } + state = make([]byte, longTerm.PrivateEncodedLen()) + n, err := longTerm.MarshalPrivateTo(state) + if err != nil { + t.Fatal(err) + } + state = state[:n] + publicLength := int(binary.BigEndian.Uint16(state[:2])) + // The offline destination keeps no long-term signing private key. + clear(state[2+publicLength : 2+publicLength+ed25519.PrivateKeySize]) + transientPublic, transientFull, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + transientPrivate = transientFull.Seed() + offline = OfflineSignature{Expires: expires, Type: SigningEdDSASHA512Ed25519, PublicKey: transientPublic} + offline.Signature, err = longTerm.Sign(offline.SignedContent()) + if err != nil { + t.Fatal(err) + } + return state, offline, transientPrivate, longTerm +} + +func TestImportLocalDestinationOfflineRoundtrip(t *testing.T) { + expires := uint32(time.Now().Add(time.Hour).Unix()) + state, offline, transientPrivate, longTerm := offlineTestState(t, expires) + defer longTerm.ReleaseSensitive() + defer clear(transientPrivate) + defer clear(state) + + destination, err := ImportLocalDestinationOffline(state, offline, transientPrivate) + if err != nil { + t.Fatal(err) + } + defer destination.ReleaseSensitive() + if destination.Hash() != longTerm.Hash() { + t.Fatal("offline destination hash differs from long-term destination") + } + meta, ok := destination.OfflineSignature() + if !ok || meta.Expires != offline.Expires || meta.Type != offline.Type { + t.Fatalf("offline metadata = %+v, %v", meta, ok) + } + message := []byte("datagram signing input") + signature, err := destination.Sign(message) + if err != nil { + t.Fatal(err) + } + if !ed25519.Verify(ed25519.PublicKey(offline.PublicKey), message, signature) { + t.Fatal("Sign did not use the transient signing key") + } + longTermSignature := mustSign(t, longTerm, message) + if string(signature) == string(longTermSignature) { + t.Fatal("offline destination signed with the long-term key") + } + + clone, err := destination.Clone() + if err != nil { + t.Fatal(err) + } + if _, ok = clone.OfflineSignature(); !ok { + t.Fatal("Clone lost the offline signature") + } + clone.ReleaseSensitive() + if _, err = clone.Sign(message); err == nil { + t.Fatal("released offline destination still signs") + } + if _, ok = clone.OfflineSignature(); ok { + t.Fatal("released offline signature still reported") + } +} + +func mustSign(t *testing.T, d *LocalDestination, message []byte) []byte { + t.Helper() + signature, err := d.Sign(message) + if err != nil { + t.Fatal(err) + } + return signature +} + +func TestImportLocalDestinationOfflineRejectsForgery(t *testing.T) { + expires := uint32(time.Now().Add(time.Hour).Unix()) + state, offline, transientPrivate, longTerm := offlineTestState(t, expires) + defer longTerm.ReleaseSensitive() + defer clear(transientPrivate) + defer clear(state) + + t.Run("bad authorization signature", func(t *testing.T) { + forged := offline + forged.Signature = append([]byte(nil), offline.Signature...) + forged.Signature[0] ^= 0xff + if _, err := ImportLocalDestinationOffline(state, forged, transientPrivate); err == nil { + t.Fatal("accepted forged authorization signature") + } + }) + t.Run("mismatched transient private", func(t *testing.T) { + _, otherPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + if _, err = ImportLocalDestinationOffline(state, offline, otherPrivate.Seed()); err == nil { + t.Fatal("accepted transient private key not matching the authorized public key") + } + }) + t.Run("long-term key present", func(t *testing.T) { + full, err := GenerateLocalDestination() + if err != nil { + t.Fatal(err) + } + defer full.ReleaseSensitive() + fullState := make([]byte, full.PrivateEncodedLen()) + n, err := full.MarshalPrivateTo(fullState) + if err != nil { + t.Fatal(err) + } + if _, err = ImportLocalDestinationOffline(fullState[:n], offline, transientPrivate); err == nil { + t.Fatal("accepted offline import over a present long-term signing key") + } + }) + t.Run("zero key without offline", func(t *testing.T) { + if _, err := ImportLocalDestination(state); err == nil { + t.Fatal("standard import accepted an all-zero signing key") + } + }) +} + +func TestOfflinePrivateSerialization(t *testing.T) { + expires := uint32(time.Now().Add(time.Hour).Unix()) + state, offline, transientPrivate, longTerm := offlineTestState(t, expires) + defer longTerm.ReleaseSensitive() + defer clear(transientPrivate) + defer clear(state) + + destination, err := ImportLocalDestinationOffline(state, offline, transientPrivate) + if err != nil { + t.Fatal(err) + } + defer destination.ReleaseSensitive() + length := destination.OfflinePrivateEncodedLen() + if length != 6+len(offline.PublicKey)+len(offline.Signature)+len(transientPrivate) { + t.Fatalf("offline section length = %d", length) + } + section := make([]byte, length) + n, err := destination.MarshalOfflinePrivateTo(section) + if err != nil || n != length { + t.Fatalf("marshal = %d, %v", n, err) + } + defer clear(section) + if binary.BigEndian.Uint32(section[:4]) != expires { + t.Fatal("offline section expires mismatch") + } + if SigningKeyType(binary.BigEndian.Uint16(section[4:6])) != offline.Type { + t.Fatal("offline section key type mismatch") + } + meta, _ := destination.OfflineSignature() + if string(section[6:6+len(meta.PublicKey)]) != string(meta.PublicKey) { + t.Fatal("offline section public key mismatch") + } +} diff --git a/interfaces/destination/destination_interface.go b/interfaces/destination/destination_interface.go index da1a413..6e27979 100644 --- a/interfaces/destination/destination_interface.go +++ b/interfaces/destination/destination_interface.go @@ -94,6 +94,12 @@ type DestinationEndpoint interface { Close() error } +// ModernDatagramEndpoint is an optional interface for endpoints that can serialize repliable Datagram2 (protocol 19, authenticated) and Datagram3 (protocol 20, unauthenticated) packets. +type ModernDatagramEndpoint interface { + MarshalDatagramV2To(dst []byte, target foundation.Hash, payload []byte) (int, error) + MarshalDatagramV3To(dst []byte, payload []byte) (int, error) +} + // SourcePortDestinationEndpoint is an optional interface for endpoints that allow selecting the local virtual port. type SourcePortDestinationEndpoint interface { DialI2PFromPort(context.Context, string, uint16) (net.Conn, error) diff --git a/networking/internal/datagram/modern.go b/networking/internal/datagram/modern.go index 9655a7c..a8f6bd5 100644 --- a/networking/internal/datagram/modern.go +++ b/networking/internal/datagram/modern.go @@ -17,6 +17,9 @@ const ( v3AllowedFlags = flagVersionMask | flagOptions ) +// FlagOffline marks a Datagram2 packet carrying an offline signature section. +const FlagOffline = flagOffline + // OfflineSignature holds a transient signing key and its authorizing signature. type OfflineSignature struct { Expires uint32 diff --git a/networking/networking_subsystem.go b/networking/networking_subsystem.go index 31e9814..b43fe2b 100644 --- a/networking/networking_subsystem.go +++ b/networking/networking_subsystem.go @@ -17,6 +17,7 @@ import ( ) type ( + DatagramOfflineSignature = datagram.OfflineSignature GarlicDatabaseLookupReplyWrapper = garlic.DatabaseLookupReplyWrapper GarlicRatchetConfig = garlic.RatchetConfig GarlicRatchetManager = garlic.RatchetManager @@ -148,7 +149,10 @@ type ( const ( DatagramProtocolDatagram1 = datagram.ProtocolDatagram1 + DatagramProtocolDatagram2 = datagram.ProtocolDatagram2 + DatagramProtocolDatagram3 = datagram.ProtocolDatagram3 DatagramProtocolRaw = datagram.ProtocolRaw + DatagramFlagOffline = datagram.FlagOffline I2NPDatabaseLookup = i2np.DatabaseLookup I2NPDatabaseStore = i2np.DatabaseStore I2NPDeliveryStatus = i2np.DeliveryStatus @@ -183,6 +187,8 @@ const ( var ( DatagramMarshalV1To = datagram.MarshalV1To + DatagramMarshalV2To = datagram.MarshalV2To + DatagramMarshalV3To = datagram.MarshalV3To DatagramParsePacket = datagram.ParsePacket GarlicECIESOpenRouterMessage = garlicecies.OpenRouterMessage GarlicECIESSealRouterMessage = garlicecies.SealRouterMessage diff --git a/node/internal/runtime/client_destination.go b/node/internal/runtime/client_destination.go index c737496..2c4229e 100644 --- a/node/internal/runtime/client_destination.go +++ b/node/internal/runtime/client_destination.go @@ -190,6 +190,28 @@ func (e *clientDestinationEndpoint) MarshalDatagramV1To(dst, payload []byte) (in } return networking.DatagramMarshalV1To(dst, identity, payload, e.runtime.local.Sign) } +func (e *clientDestinationEndpoint) MarshalDatagramV2To(dst []byte, target foundation.Hash, payload []byte) (int, error) { + if e == nil || e.runtime == nil || e.runtime.local == nil || !e.runtime.active() { + return 0, net.ErrClosed + } + identity, err := e.runtime.local.Identity() + if err != nil { + return 0, err + } + flags := uint16(2) + var offline networking.DatagramOfflineSignature + if meta, ok := e.runtime.local.OfflineSignature(); ok { + flags |= networking.DatagramFlagOffline + offline = networking.DatagramOfflineSignature{Expires: meta.Expires, Type: meta.Type, PublicKey: meta.PublicKey, Signature: meta.Signature} + } + return networking.DatagramMarshalV2To(dst, target, identity, flags, foundation.Mapping{}, offline, payload, e.runtime.local.Sign) +} +func (e *clientDestinationEndpoint) MarshalDatagramV3To(dst, payload []byte) (int, error) { + if e == nil || e.runtime == nil || e.runtime.local == nil || !e.runtime.active() { + return 0, net.ErrClosed + } + return networking.DatagramMarshalV3To(dst, e.Hash(), 3, foundation.Mapping{}, payload) +} func (e *clientDestinationEndpoint) Subscribe(route client.ClientDestinationRoute, capacity int) (client.ClientMessageSubscription, error) { session, err := e.session() if err != nil { From 830e457f8cbb08f326f162f2c1e4f6379498e295 Mon Sep 17 00:00:00 2001 From: Lee Yunjin Date: Sat, 5 Sep 2026 12:37:17 +0900 Subject: [PATCH 2/5] fix(foundation): reject expired offline signing keys LocalDestination.Sign now fails with ErrOfflineSignatureExpired once the offline authorization has lapsed, so stale transient keys cannot keep producing Datagram2 traffic. Also wipe the throwaway X25519 key generated for ElGamal private destinations and document that Datagram3's FROM field is attacker-controlled. --- client/internal/sam/datagram.go | 5 ++++- client/internal/sam/offline_test.go | 6 +++--- client/internal/sam/private_destination.go | 1 + foundation/address_generator.go | 3 +++ foundation/offline_signature.go | 5 +++++ foundation/offline_signature_test.go | 18 ++++++++++++++++++ 6 files changed, 34 insertions(+), 4 deletions(-) diff --git a/client/internal/sam/datagram.go b/client/internal/sam/datagram.go index 0c0694a..d41dc1d 100644 --- a/client/internal/sam/datagram.go +++ b/client/internal/sam/datagram.go @@ -182,7 +182,10 @@ func (s *samSession) parseReceivedDatagram(delivery networking.StreamingTunnelDe return foundation.EncodeI2PBase64(packet.V2.From.Bytes()), packet.V2.Payload, true case networking.DatagramProtocolDatagram3: // Datagram3 is unauthenticated by spec: no signature to verify and the - // source is a bare 32-byte hash, not a full destination. + // source is a bare 32-byte hash supplied by the sender. The FROM value + // delivered to SAM clients is attacker-controlled and must not be + // trusted for authorization decisions; use DATAGRAM or DATAGRAM2 when + // the source identity matters. return foundation.EncodeI2PBase64(packet.V3.From[:]), packet.V3.Payload, true } return "", nil, false diff --git a/client/internal/sam/offline_test.go b/client/internal/sam/offline_test.go index e284aaf..f4a8314 100644 --- a/client/internal/sam/offline_test.go +++ b/client/internal/sam/offline_test.go @@ -90,7 +90,7 @@ func TestDatagram2OfflineRoundtrip(t *testing.T) { } } -func TestDatagram2OfflineExpiredDropped(t *testing.T) { +func TestDatagram2OfflineExpiredRejected(t *testing.T) { controller := &loopController{endpoints: make(map[foundation.Hash]*loopEndpoint)} server, err := NewServer(ServerConfig{Address: "127.0.0.1:0", Controller: controller, MaxSessions: 4}) if err != nil { @@ -114,8 +114,8 @@ func TestDatagram2OfflineExpiredDropped(t *testing.T) { t.Fatalf("expired offline create = %q", line) } _, _ = io.WriteString(sender, "DATAGRAM SEND ID=sender DESTINATION="+target+" SIZE=4\nDATA") - if line := readSAMLine(t, senderReader); line != "DATAGRAM STATUS RESULT=OK" { - t.Fatalf("datagram status = %q", line) + if line := readSAMLine(t, senderReader); line == "DATAGRAM STATUS RESULT=OK" { + t.Fatal("sender signed with an expired offline signature") } if err = receiver.SetReadDeadline(time.Now().Add(300 * time.Millisecond)); err != nil { t.Fatal(err) diff --git a/client/internal/sam/private_destination.go b/client/internal/sam/private_destination.go index 0a56c7d..70c81bc 100644 --- a/client/internal/sam/private_destination.go +++ b/client/internal/sam/private_destination.go @@ -163,6 +163,7 @@ func decodePrivateDestination(encoded string) (*foundation.LocalDestination, err return nil, ErrInvalidKey } x25519 = generated.Bytes() + defer clear(x25519) elgamal = encryptionPrivate } else { x25519 = encryptionPrivate diff --git a/foundation/address_generator.go b/foundation/address_generator.go index 3781a5b..32569ac 100644 --- a/foundation/address_generator.go +++ b/foundation/address_generator.go @@ -303,6 +303,9 @@ func (d *LocalDestination) Sign(message []byte) ([]byte, error) { return nil, cryptography.ErrSensitiveReleased } if d.offline != nil { + if uint32(time.Now().Unix()) > d.offline.expires { + return nil, ErrOfflineSignatureExpired + } switch d.offline.keyType { case SigningEdDSASHA512Ed25519: return ed25519.Sign(ed25519.NewKeyFromSeed(d.offline.private), message), nil diff --git a/foundation/offline_signature.go b/foundation/offline_signature.go index 62b04ff..9da194d 100644 --- a/foundation/offline_signature.go +++ b/foundation/offline_signature.go @@ -4,10 +4,15 @@ import ( "bytes" "crypto/ed25519" "encoding/binary" + "errors" "filippo.io/edwards25519" ) +// ErrOfflineSignatureExpired is returned when a transient signing key is used +// past the expiry authorized by the offline signature. +var ErrOfflineSignatureExpired = errors.New("i2p: offline signature is expired") + // OfflineSignature authorizes a transient signing key to act on behalf of a // destination whose long-term signing private key is kept offline. It appears // in SAM private destinations and protocol-19 Datagram2 packets. diff --git a/foundation/offline_signature_test.go b/foundation/offline_signature_test.go index abc299f..0273ee7 100644 --- a/foundation/offline_signature_test.go +++ b/foundation/offline_signature_test.go @@ -4,6 +4,7 @@ import ( "crypto/ed25519" "crypto/rand" "encoding/binary" + "errors" "testing" "time" ) @@ -93,6 +94,23 @@ func mustSign(t *testing.T, d *LocalDestination, message []byte) []byte { return signature } +func TestOfflineDestinationRejectsExpiredSigning(t *testing.T) { + expires := uint32(time.Now().Add(-time.Hour).Unix()) + state, offline, transientPrivate, longTerm := offlineTestState(t, expires) + defer longTerm.ReleaseSensitive() + defer clear(transientPrivate) + defer clear(state) + + destination, err := ImportLocalDestinationOffline(state, offline, transientPrivate) + if err != nil { + t.Fatal(err) + } + defer destination.ReleaseSensitive() + if _, err = destination.Sign([]byte("stale datagram")); !errors.Is(err, ErrOfflineSignatureExpired) { + t.Fatalf("Sign() error = %v, want ErrOfflineSignatureExpired", err) + } +} + func TestImportLocalDestinationOfflineRejectsForgery(t *testing.T) { expires := uint32(time.Now().Add(time.Hour).Unix()) state, offline, transientPrivate, longTerm := offlineTestState(t, expires) From f612c45a5347b6111ce47f0d7cbad210092564b6 Mon Sep 17 00:00:00 2001 From: Lee Yunjin Date: Sat, 5 Sep 2026 12:55:40 +0900 Subject: [PATCH 3/5] fix: address review findings on offline signatures and datagram styles - Inject clock into SAM server and foundation offline signing instead of reading the wall clock in verification and expiry paths - Parse SAM offline private key sections as zero-copy views over the caller-owned wire buffer - Unify datagram offline signature type with foundation.OfflineSignature - Wipe expanded Ed25519 keys and Red25519 key copies after signing - Add bounded MarshalSignedContentTo serializer with explicit capacity errors - Reject SESSION CREATE for offline destinations combined with legacy DATAGRAM style or encrypted LeaseSet options; Datagram1 cannot carry the offline authorization and blinding requires the long-term key - Publish LeaseSet2 with the offline signature section and flag so peers can verify transient-key signatures, refusing expired authorizations - Deflake loopback test: RECEIVED lines may precede the STATUS reply on the shared control connection --- client/internal/sam/datagram.go | 2 +- client/internal/sam/offline_test.go | 80 +++++++++++- client/internal/sam/private_destination.go | 11 +- client/internal/sam/protocol.go | 12 +- client/internal/sam/server.go | 6 + client/internal/sam/server_test.go | 50 +++++--- client/internal/sam/session.go | 3 +- foundation/address_generator.go | 8 +- foundation/offline_signature.go | 41 ++++-- foundation/offline_signature_test.go | 13 +- networking/internal/datagram/modern.go | 19 ++- .../internal/netdb/local_encrypted_ls2.go | 5 + networking/internal/netdb/local_ls2.go | 46 ++++++- .../internal/netdb/local_ls2_offline_test.go | 118 ++++++++++++++++++ node/internal/runtime/client_destination.go | 2 +- 15 files changed, 357 insertions(+), 59 deletions(-) create mode 100644 networking/internal/netdb/local_ls2_offline_test.go diff --git a/client/internal/sam/datagram.go b/client/internal/sam/datagram.go index d41dc1d..fb84691 100644 --- a/client/internal/sam/datagram.go +++ b/client/internal/sam/datagram.go @@ -175,7 +175,7 @@ func (s *samSession) parseReceivedDatagram(delivery networking.StreamingTunnelDe } return foundation.EncodeI2PBase64(packet.V1.From.Bytes()), packet.V1.Payload, true case networking.DatagramProtocolDatagram2: - valid, err := packet.V2.VerifyTarget(s.endpoint.Hash()) + valid, err := packet.V2.VerifyTargetAt(s.endpoint.Hash(), uint32(s.now())) if err != nil || !valid || packet.V2.From.Hash() != delivery.From { return "", nil, false } diff --git a/client/internal/sam/offline_test.go b/client/internal/sam/offline_test.go index f4a8314..a14e27a 100644 --- a/client/internal/sam/offline_test.go +++ b/client/internal/sam/offline_test.go @@ -36,7 +36,12 @@ func offlineSAMPrivateDestination(t *testing.T, expires uint32) (private, public t.Fatal(err) } offline := foundation.OfflineSignature{Expires: expires, Type: foundation.SigningEdDSASHA512Ed25519, PublicKey: transientPublic} - signed := offline.SignedContent() + var content [6 + ed25519.PublicKeySize]byte + contentLen, err := offline.MarshalSignedContentTo(content[:]) + if err != nil { + t.Fatal(err) + } + signed := content[:contentLen] offline.Signature, err = longTerm.Sign(signed) if err != nil { t.Fatal(err) @@ -56,8 +61,9 @@ func offlineSAMPrivateDestination(t *testing.T, expires uint32) (private, public } func TestDatagram2OfflineRoundtrip(t *testing.T) { + fixed := time.Now() controller := &loopController{endpoints: make(map[foundation.Hash]*loopEndpoint)} - server, err := NewServer(ServerConfig{Address: "127.0.0.1:0", Controller: controller, MaxSessions: 4}) + server, err := NewServer(ServerConfig{Address: "127.0.0.1:0", Controller: controller, MaxSessions: 4, Now: func() time.Time { return fixed }}) if err != nil { t.Fatal(err) } @@ -65,7 +71,7 @@ func TestDatagram2OfflineRoundtrip(t *testing.T) { t.Fatal(err) } defer func() { _ = server.Close(); _ = server.Wait() }() - private, public := offlineSAMPrivateDestination(t, uint32(time.Now().Add(time.Hour).Unix())) + private, public := offlineSAMPrivateDestination(t, uint32(fixed.Add(time.Hour).Unix())) control, reader := samDial(t, server.Addr().String()) defer control.Close() _, _ = io.WriteString(control, "SESSION CREATE STYLE=DATAGRAM2 ID=offline DESTINATION="+private+"\n") @@ -91,8 +97,9 @@ func TestDatagram2OfflineRoundtrip(t *testing.T) { } func TestDatagram2OfflineExpiredRejected(t *testing.T) { + fixed := time.Now() controller := &loopController{endpoints: make(map[foundation.Hash]*loopEndpoint)} - server, err := NewServer(ServerConfig{Address: "127.0.0.1:0", Controller: controller, MaxSessions: 4}) + server, err := NewServer(ServerConfig{Address: "127.0.0.1:0", Controller: controller, MaxSessions: 4, Now: func() time.Time { return fixed }}) if err != nil { t.Fatal(err) } @@ -106,7 +113,7 @@ func TestDatagram2OfflineExpiredRejected(t *testing.T) { defer receiverLocal.ReleaseSensitive() target := string(receiverLocal.Destination()) - private, _ := offlineSAMPrivateDestination(t, uint32(time.Now().Add(-time.Hour).Unix())) + private, _ := offlineSAMPrivateDestination(t, uint32(fixed.Add(-time.Hour).Unix())) sender, senderReader := samDial(t, address) defer sender.Close() _, _ = io.WriteString(sender, "SESSION CREATE STYLE=DATAGRAM2 ID=sender DESTINATION="+private+"\n") @@ -158,3 +165,66 @@ func TestSessionCreateOfflineForgedSignatureRejected(t *testing.T) { t.Fatalf("forged offline create = %q", line) } } + +func TestSessionCreateOfflineDatagram1Rejected(t *testing.T) { + controller := &loopController{endpoints: make(map[foundation.Hash]*loopEndpoint)} + server, err := NewServer(ServerConfig{Address: "127.0.0.1:0", Controller: controller, MaxSessions: 4}) + if err != nil { + t.Fatal(err) + } + if err = server.Start(t.Context()); err != nil { + t.Fatal(err) + } + defer func() { _ = server.Close(); _ = server.Wait() }() + private, _ := offlineSAMPrivateDestination(t, uint32(time.Now().Add(time.Hour).Unix())) + control, reader := samDial(t, server.Addr().String()) + defer control.Close() + // The Datagram1 wire format cannot carry the offline signature section, so + // receivers verifying against the long-term identity key would drop every + // packet; refuse the session instead. + _, _ = io.WriteString(control, "SESSION CREATE STYLE=DATAGRAM ID=legacy DESTINATION="+private+"\n") + if line := readSAMLine(t, reader); !strings.Contains(line, "RESULT=INVALID_KEY") { + t.Fatalf("offline DATAGRAM create = %q", line) + } +} + +func TestSessionCreateOfflineDatagram3Allowed(t *testing.T) { + controller := &loopController{endpoints: make(map[foundation.Hash]*loopEndpoint)} + server, err := NewServer(ServerConfig{Address: "127.0.0.1:0", Controller: controller, MaxSessions: 4}) + if err != nil { + t.Fatal(err) + } + if err = server.Start(t.Context()); err != nil { + t.Fatal(err) + } + defer func() { _ = server.Close(); _ = server.Wait() }() + private, _ := offlineSAMPrivateDestination(t, uint32(time.Now().Add(time.Hour).Unix())) + control, reader := samDial(t, server.Addr().String()) + defer control.Close() + // Datagram3 is unsigned, so an offline destination is usable as-is. + _, _ = io.WriteString(control, "SESSION CREATE STYLE=DATAGRAM3 ID=unsigned DESTINATION="+private+"\n") + if line := readSAMLine(t, reader); !strings.Contains(line, "RESULT=OK") { + t.Fatalf("offline DATAGRAM3 create = %q", line) + } +} + +func TestSessionCreateOfflineEncryptedLeaseSetRejected(t *testing.T) { + controller := &loopController{endpoints: make(map[foundation.Hash]*loopEndpoint)} + server, err := NewServer(ServerConfig{Address: "127.0.0.1:0", Controller: controller, MaxSessions: 4}) + if err != nil { + t.Fatal(err) + } + if err = server.Start(t.Context()); err != nil { + t.Fatal(err) + } + defer func() { _ = server.Close(); _ = server.Wait() }() + private, _ := offlineSAMPrivateDestination(t, uint32(time.Now().Add(time.Hour).Unix())) + control, reader := samDial(t, server.Addr().String()) + defer control.Close() + // Encrypted LeaseSet blinding derives from the long-term signing private + // key, which an offline destination does not hold. + _, _ = io.WriteString(control, "SESSION CREATE STYLE=DATAGRAM2 ID=encrypted DESTINATION="+private+" I2CP.LEASESETTYPE=5\n") + if line := readSAMLine(t, reader); !strings.Contains(line, "RESULT=INVALID_KEY") { + t.Fatalf("offline encrypted create = %q", line) + } +} diff --git a/client/internal/sam/private_destination.go b/client/internal/sam/private_destination.go index 70c81bc..f0f3d36 100644 --- a/client/internal/sam/private_destination.go +++ b/client/internal/sam/private_destination.go @@ -196,13 +196,16 @@ type offlinePrivateKey struct { transientPrivate []byte } +// clear detaches the offline key material views. The backing wire buffer is +// caller-owned and wiped by the caller, which covers these subslices. func (o *offlinePrivateKey) clear() { o.PublicKey = nil o.Signature = nil - clear(o.transientPrivate) o.transientPrivate = nil } +// parseOfflinePrivateKey parses the SAM offline signature section as views over +// the caller-owned section; the caller keeps section alive and wipes it. func parseOfflinePrivateKey(identity foundation.Identity, section []byte) (*offlinePrivateKey, error) { if len(section) < 6 { return nil, ErrInvalidKey @@ -220,11 +223,11 @@ func parseOfflinePrivateKey(identity foundation.Identity, section []byte) (*offl return nil, ErrInvalidKey } offset := 6 - public := append([]byte(nil), section[offset:offset+publicLength]...) + public := section[offset : offset+publicLength] offset += publicLength - signature := append([]byte(nil), section[offset:offset+signatureLength]...) + signature := section[offset : offset+signatureLength] offset += signatureLength - transientPrivate := append([]byte(nil), section[offset:]...) + transientPrivate := section[offset:] return &offlinePrivateKey{ OfflineSignature: foundation.OfflineSignature{ Expires: binary.BigEndian.Uint32(section[:4]), diff --git a/client/internal/sam/protocol.go b/client/internal/sam/protocol.go index 7bcbc3b..d8c6025 100644 --- a/client/internal/sam/protocol.go +++ b/client/internal/sam/protocol.go @@ -150,6 +150,16 @@ func (s *Server) createSession(ctx context.Context, connection *serverConnection meta := meta offline = &meta } + if offline != nil { + // Blinding an encrypted LeaseSet requires the long-term signing private + // key, which an offline destination does not hold, and the legacy + // Datagram1 wire format cannot carry the offline signature section + // receivers need to verify against the transient key. + if policy.Encrypted || style == styleDatagram { + local.ReleaseSensitive() + return connection.writeLine("SESSION STATUS RESULT=INVALID_KEY") + } + } private, err := encodePrivateDestination(local) if err != nil { local.ReleaseSensitive() @@ -262,7 +272,7 @@ func (s *Server) addSubsession(connection *serverConnection, cmd command) error return connection.writeLine("SESSION STATUS RESULT=I2P_ERROR MESSAGE=INVALID_OPTION") } ctx, cancel := context.WithCancel(root.ctx) - child := &samSession{server: s, root: root, id: id, style: style, endpoint: root.endpoint, control: connection, ctx: ctx, cancel: cancel, sourceIP: root.sourceIP, fromPort: fromPort, toPort: toPort, listenPort: listenPort, protocol: protocol, listenProtocol: listenProtocol, rawHeader: rawHeader, udpTarget: udpTarget, children: make(map[string]*samSession), attachments: make(map[net.Conn]struct{}), queueBytes: newByteBudget(s.config.MaxSessionQueueBytes), acceptRequests: make(chan acceptRequest, s.config.SessionQueue)} + child := &samSession{server: s, root: root, id: id, style: style, endpoint: root.endpoint, control: connection, ctx: ctx, cancel: cancel, sourceIP: root.sourceIP, fromPort: fromPort, toPort: toPort, listenPort: listenPort, protocol: protocol, listenProtocol: listenProtocol, rawHeader: rawHeader, udpTarget: udpTarget, now: root.now, children: make(map[string]*samSession), attachments: make(map[net.Conn]struct{}), queueBytes: newByteBudget(s.config.MaxSessionQueueBytes), acceptRequests: make(chan acceptRequest, s.config.SessionQueue)} child.datagramOverhead = datagramOverhead(protocol, root.endpoint, root.offline) if err = s.addChild(child); err != nil { cancel() diff --git a/client/internal/sam/server.go b/client/internal/sam/server.go index f530b9f..2484d22 100644 --- a/client/internal/sam/server.go +++ b/client/internal/sam/server.go @@ -51,6 +51,9 @@ type ServerConfig struct { ReadinessTimeout time.Duration CleanupTimeout time.Duration AllowLoopbackForward bool + // Now supplies the current time for offline signature expiry checks on + // received datagrams. Nil defaults to time.Now. + Now func() time.Time } // Server serves inbound SAM while Network remains the external SAM client. @@ -137,6 +140,9 @@ func NewServer(config ServerConfig) (*Server, error) { if config.CleanupTimeout <= 0 { config.CleanupTimeout = defaultCleanupTimeout } + if config.Now == nil { + config.Now = time.Now + } return &Server{config: config, sessions: make(map[string]*samSession), destinations: make(map[foundation.Hash]*samSession), connections: make(map[net.Conn]struct{}), done: make(chan struct{}), sem: make(chan struct{}, config.MaxConnections), queueBytes: newByteBudget(config.MaxServerQueueBytes)}, nil } diff --git a/client/internal/sam/server_test.go b/client/internal/sam/server_test.go index 3d4cd70..b0d5e59 100644 --- a/client/internal/sam/server_test.go +++ b/client/internal/sam/server_test.go @@ -96,7 +96,7 @@ func (e *loopEndpoint) MarshalDatagramV2To(dst []byte, target foundation.Hash, p var offline networking.DatagramOfflineSignature if meta, ok := e.local.OfflineSignature(); ok { flags |= networking.DatagramFlagOffline - offline = networking.DatagramOfflineSignature{Expires: meta.Expires, Type: meta.Type, PublicKey: meta.PublicKey, Signature: meta.Signature} + offline = meta } return networking.DatagramMarshalV2To(dst, target, identity, flags, foundation.Mapping{}, offline, payload, e.local.Sign) } @@ -350,15 +350,24 @@ func TestEmbeddedServerLiveLoopbackStylesAndRecovery(t *testing.T) { target := string(local.Destination()) local.ReleaseSensitive() _, _ = io.WriteString(datagramControl, "DATAGRAM SEND ID=dg DESTINATION="+target+" TO_PORT=9 SIZE=4\nDATA") - if line = readSAMLine(t, datagramReader); line != "DATAGRAM STATUS RESULT=OK" { - t.Fatalf("datagram status = %q", line) - } - line = readSAMLine(t, datagramReader) - if !strings.Contains(line, "DATAGRAM RECEIVED") || !strings.Contains(line, "SIZE=4") { - t.Fatalf("datagram receive = %q", line) - } + // Inbound forwarding runs on a separate goroutine, so DATAGRAM RECEIVED may + // arrive before the STATUS reply on the shared control connection. + var datagramStatusOK, datagramReceived bool body := make([]byte, 4) - _, _ = io.ReadFull(datagramReader, body) + for !datagramStatusOK || !datagramReceived { + line = readSAMLine(t, datagramReader) + switch { + case line == "DATAGRAM STATUS RESULT=OK": + datagramStatusOK = true + case strings.Contains(line, "DATAGRAM RECEIVED") && strings.Contains(line, "SIZE=4"): + datagramReceived = true + if _, err = io.ReadFull(datagramReader, body); err != nil { + t.Fatal(err) + } + default: + t.Fatalf("datagram reply = %q", line) + } + } if string(body) != "DATA" { t.Fatalf("datagram body = %q", body) } @@ -378,15 +387,22 @@ func TestEmbeddedServerLiveLoopbackStylesAndRecovery(t *testing.T) { target = string(local.Destination()) local.ReleaseSensitive() _, _ = io.WriteString(rawControl, "RAW SEND ID=raw DESTINATION="+target+" TO_PORT=10 SIZE=3\nRAW") - if line = readSAMLine(t, rawReader); line != "RAW STATUS RESULT=OK" { - t.Fatalf("raw status = %q", line) - } - line = readSAMLine(t, rawReader) - if !strings.Contains(line, "RAW RECEIVED") || !strings.Contains(line, "SIZE=3") { - t.Fatalf("raw receive = %q", line) - } + var rawStatusOK, rawReceived bool body = make([]byte, 3) - _, _ = io.ReadFull(rawReader, body) + for !rawStatusOK || !rawReceived { + line = readSAMLine(t, rawReader) + switch { + case line == "RAW STATUS RESULT=OK": + rawStatusOK = true + case strings.Contains(line, "RAW RECEIVED") && strings.Contains(line, "SIZE=3"): + rawReceived = true + if _, err = io.ReadFull(rawReader, body); err != nil { + t.Fatal(err) + } + default: + t.Fatalf("raw reply = %q", line) + } + } if string(body) != "RAW" { t.Fatalf("raw body = %q", body) } diff --git a/client/internal/sam/session.go b/client/internal/sam/session.go index 4d08544..01ab410 100644 --- a/client/internal/sam/session.go +++ b/client/internal/sam/session.go @@ -58,6 +58,7 @@ type samSession struct { udpTarget *net.UDPAddr offline *foundation.OfflineSignature datagramOverhead int + now func() int64 forward bool mu sync.Mutex @@ -78,7 +79,7 @@ type samSession struct { func newRootSession(server *Server, id string, style sessionStyle, endpoint destination.DestinationEndpoint, control *serverConnection, fromPort, toPort, listenPort uint16, protocol, listenProtocol uint8, rawHeader bool, udpTarget *net.UDPAddr, offline *foundation.OfflineSignature) *samSession { ctx, cancel := context.WithCancel(server.ctx) - s := &samSession{server: server, id: id, style: style, endpoint: endpoint, control: control, ctx: ctx, cancel: cancel, sourceIP: connectionIP(control.Conn), fromPort: fromPort, toPort: toPort, listenPort: listenPort, protocol: protocol, listenProtocol: listenProtocol, rawHeader: rawHeader, udpTarget: udpTarget, offline: offline, children: make(map[string]*samSession), attachments: make(map[net.Conn]struct{}), queueBytes: newByteBudget(server.config.MaxSessionQueueBytes), acceptRequests: make(chan acceptRequest, server.config.SessionQueue)} + s := &samSession{server: server, id: id, style: style, endpoint: endpoint, control: control, ctx: ctx, cancel: cancel, sourceIP: connectionIP(control.Conn), fromPort: fromPort, toPort: toPort, listenPort: listenPort, protocol: protocol, listenProtocol: listenProtocol, rawHeader: rawHeader, udpTarget: udpTarget, offline: offline, now: func() int64 { return server.config.Now().Unix() }, children: make(map[string]*samSession), attachments: make(map[net.Conn]struct{}), queueBytes: newByteBudget(server.config.MaxSessionQueueBytes), acceptRequests: make(chan acceptRequest, server.config.SessionQueue)} s.datagramOverhead = datagramOverhead(protocol, endpoint, offline) s.root = s return s diff --git a/foundation/address_generator.go b/foundation/address_generator.go index 32569ac..4d9b382 100644 --- a/foundation/address_generator.go +++ b/foundation/address_generator.go @@ -303,15 +303,18 @@ func (d *LocalDestination) Sign(message []byte) ([]byte, error) { return nil, cryptography.ErrSensitiveReleased } if d.offline != nil { - if uint32(time.Now().Unix()) > d.offline.expires { + if uint32(offlineTimeNow().Unix()) > d.offline.expires { return nil, ErrOfflineSignatureExpired } switch d.offline.keyType { case SigningEdDSASHA512Ed25519: - return ed25519.Sign(ed25519.NewKeyFromSeed(d.offline.private), message), nil + key := ed25519.NewKeyFromSeed(d.offline.private) + defer clear(key) + return ed25519.Sign(key, message), nil case SigningRedDSASHA512Ed25519: var private [32]byte copy(private[:], d.offline.private) + defer clear(private[:]) return Red25519Sign(private, message) default: return nil, ErrEncryptedSigningKey @@ -323,6 +326,7 @@ func (d *LocalDestination) Sign(message []byte) ([]byte, error) { case SigningRedDSASHA512Ed25519: var private [32]byte copy(private[:], d.signingPrivate) + defer clear(private[:]) return Red25519Sign(private, message) default: return nil, ErrEncryptedSigningKey diff --git a/foundation/offline_signature.go b/foundation/offline_signature.go index 9da194d..b3cfaf5 100644 --- a/foundation/offline_signature.go +++ b/foundation/offline_signature.go @@ -5,6 +5,7 @@ import ( "crypto/ed25519" "encoding/binary" "errors" + "time" "filippo.io/edwards25519" ) @@ -23,16 +24,30 @@ type OfflineSignature struct { Signature []byte } -// SignedContent returns the authorized content (expires, key type, public key) -// covered by the offline signature. -func (o OfflineSignature) SignedContent() []byte { - signed := make([]byte, 6+len(o.PublicKey)) - binary.BigEndian.PutUint32(signed[:4], o.Expires) - binary.BigEndian.PutUint16(signed[4:6], uint16(o.Type)) - copy(signed[6:], o.PublicKey) - return signed +// Present reports whether the offline signature carries a transient public key. +func (o OfflineSignature) Present() bool { return o.PublicKey != nil } + +// SignedContentLen returns the encoded length of the authorized content +// (expires, key type, public key) covered by the offline signature. +func (o OfflineSignature) SignedContentLen() int { return 6 + len(o.PublicKey) } + +// MarshalSignedContentTo serializes the authorized content covered by the +// offline signature into dst without growing it. +func (o OfflineSignature) MarshalSignedContentTo(dst []byte) (int, error) { + n := o.SignedContentLen() + if len(dst) < n { + return 0, ErrDestinationSmall + } + binary.BigEndian.PutUint32(dst[:4], o.Expires) + binary.BigEndian.PutUint16(dst[4:6], uint16(o.Type)) + copy(dst[6:], o.PublicKey) + return n, nil } +// offlineTimeNow is the time source for offline signature expiry enforcement; +// tests override it (never in parallel) to pin expiry decisions. +var offlineTimeNow = time.Now + func offlineTransientPrivateLen(keyType SigningKeyType) (int, bool) { switch keyType { case SigningEdDSASHA512Ed25519, SigningRedDSASHA512Ed25519: @@ -89,7 +104,15 @@ func parseOfflineSigning(identity Identity, offline OfflineSignature, transientP if !ok || len(offline.Signature) != signatureLen { return nil, ErrInvalidIdentity } - valid, err := identity.Verify(offline.SignedContent(), offline.Signature) + var content [6 + ed25519.PublicKeySize]byte + if offline.SignedContentLen() > len(content) { + return nil, ErrInvalidIdentity + } + n, err := offline.MarshalSignedContentTo(content[:]) + if err != nil { + return nil, ErrInvalidIdentity + } + valid, err := identity.Verify(content[:n], offline.Signature) if err != nil || !valid { return nil, ErrInvalidIdentity } diff --git a/foundation/offline_signature_test.go b/foundation/offline_signature_test.go index 0273ee7..bc8c1bb 100644 --- a/foundation/offline_signature_test.go +++ b/foundation/offline_signature_test.go @@ -30,7 +30,12 @@ func offlineTestState(t *testing.T, expires uint32) (state []byte, offline Offli } transientPrivate = transientFull.Seed() offline = OfflineSignature{Expires: expires, Type: SigningEdDSASHA512Ed25519, PublicKey: transientPublic} - offline.Signature, err = longTerm.Sign(offline.SignedContent()) + var content [6 + ed25519.PublicKeySize]byte + contentLen, err := offline.MarshalSignedContentTo(content[:]) + if err != nil { + t.Fatal(err) + } + offline.Signature, err = longTerm.Sign(content[:contentLen]) if err != nil { t.Fatal(err) } @@ -95,7 +100,11 @@ func mustSign(t *testing.T, d *LocalDestination, message []byte) []byte { } func TestOfflineDestinationRejectsExpiredSigning(t *testing.T) { - expires := uint32(time.Now().Add(-time.Hour).Unix()) + fixed := time.Unix(1_800_000_000, 0) + original := offlineTimeNow + offlineTimeNow = func() time.Time { return fixed } + defer func() { offlineTimeNow = original }() + expires := uint32(fixed.Add(-time.Hour).Unix()) state, offline, transientPrivate, longTerm := offlineTestState(t, expires) defer longTerm.ReleaseSensitive() defer clear(transientPrivate) diff --git a/networking/internal/datagram/modern.go b/networking/internal/datagram/modern.go index a8f6bd5..3e466e8 100644 --- a/networking/internal/datagram/modern.go +++ b/networking/internal/datagram/modern.go @@ -20,16 +20,9 @@ const ( // FlagOffline marks a Datagram2 packet carrying an offline signature section. const FlagOffline = flagOffline -// OfflineSignature holds a transient signing key and its authorizing signature. -type OfflineSignature struct { - Expires uint32 - Type foundation.SigningKeyType - PublicKey []byte - Signature []byte - Signed []byte -} - -func (o OfflineSignature) Present() bool { return o.PublicKey != nil } +// OfflineSignature is the canonical foundation offline signature: a transient +// signing key plus its authorizing signature. +type OfflineSignature = foundation.OfflineSignature type V2 struct { From foundation.Identity @@ -38,6 +31,7 @@ type V2 struct { Offline OfflineSignature Payload, Signature []byte signedRest []byte + offlineSigned []byte } // ParseV2 parses a protocol-19 Datagram2 packet. @@ -96,8 +90,9 @@ func ParseV2(src []byte) (V2, error) { off += originSignatureLen out.Offline = OfflineSignature{ Expires: expires, Type: offlineType, PublicKey: publicKey, - Signature: offlineSignature, Signed: src[offlineStart : off-originSignatureLen], + Signature: offlineSignature, } + out.offlineSigned = src[offlineStart : off-originSignatureLen] signingType = offlineType } signatureLen, ok := signingType.SignatureLen() @@ -124,7 +119,7 @@ func (d V2) VerifyTargetAt(target foundation.Hash, now uint32) (bool, error) { if now > d.Offline.Expires { return false, ErrDatagram } - valid, err := d.From.Verify(d.Offline.Signed, d.Offline.Signature) + valid, err := d.From.Verify(d.offlineSigned, d.Offline.Signature) if err != nil || !valid { return valid, err } diff --git a/networking/internal/netdb/local_encrypted_ls2.go b/networking/internal/netdb/local_encrypted_ls2.go index 18b2b7e..bba0ca9 100644 --- a/networking/internal/netdb/local_encrypted_ls2.go +++ b/networking/internal/netdb/local_encrypted_ls2.go @@ -79,6 +79,11 @@ func NewLocalEncryptedLeaseSet(destination *foundation.LocalDestination, inner * if kind != foundation.SigningEdDSASHA512Ed25519 && kind != foundation.SigningRedDSASHA512Ed25519 { return nil, ErrEncryptedLeaseSet } + // Blinding derives from the long-term signing private key, which an + // offline destination does not hold. + if _, offline := destination.OfflineSignature(); offline { + return nil, ErrEncryptedLeaseSet + } return &LocalEncryptedLeaseSet{ destination: destination, inner: inner, diff --git a/networking/internal/netdb/local_ls2.go b/networking/internal/netdb/local_ls2.go index a683da2..ac2da3b 100644 --- a/networking/internal/netdb/local_ls2.go +++ b/networking/internal/netdb/local_ls2.go @@ -19,6 +19,7 @@ type LocalLeaseSet2 struct { hash foundation.Hash public [32]byte types []foundation.CryptoKeyType + offline *foundation.OfflineSignature mu sync.RWMutex leases []Lease2 } @@ -69,7 +70,15 @@ func NewLocalLeaseSet2WithTypes(destination *foundation.LocalDestination, reques } seen[cryptoType] = true } - return &LocalLeaseSet2{identity: owned, hash: owned.Hash(), public: public, types: types}, nil + var offline *foundation.OfflineSignature + if meta, ok := destination.OfflineSignature(); ok { + meta := meta + if _, ok = meta.Type.SignatureLen(); !ok { + return nil, ErrLocalLeaseSet2 + } + offline = &meta + } + return &LocalLeaseSet2{identity: owned, hash: owned.Hash(), public: public, types: types, offline: offline}, nil } func (s *LocalLeaseSet2) Hash() foundation.Hash { return s.hash } @@ -102,6 +111,7 @@ func (s *LocalLeaseSet2) MarshalTo(dst []byte, nowMillis uint64, sign func([]byt public := s.public types := append([]foundation.CryptoKeyType(nil), s.types...) leases := append([]Lease2(nil), s.leases...) + offline := s.offline s.mu.RUnlock() if len(leases) == 0 || len(leases) > MaxLeases { return 0, ErrLocalLeaseSet2 @@ -110,6 +120,27 @@ func (s *LocalLeaseSet2) MarshalTo(dst []byte, nowMillis uint64, sign func([]byt if published > uint64(^uint32(0)) { return 0, ErrLocalLeaseSet2 } + flags := uint16(0) + offlineLen := 0 + signingType := identity.SigningKeyType() + if offline != nil { + // Peers verify the LS2 signature with the transient key authorized by + // this offline signature; publishing past its expiry is useless. + if published > uint64(offline.Expires) { + return 0, ErrLocalLeaseSet2 + } + keyLen, ok := offline.Type.PublicKeyLen() + if !ok || len(offline.PublicKey) != keyLen { + return 0, ErrLocalLeaseSet2 + } + authorizationLen, ok := signingType.SignatureLen() + if !ok || len(offline.Signature) != authorizationLen { + return 0, ErrLocalLeaseSet2 + } + flags = leaseSetOfflineFlag + offlineLen = 6 + keyLen + authorizationLen + signingType = offline.Type + } var latest uint32 for _, lease := range leases { if lease.TunnelID == 0 || lease.EndDate <= uint32(published) { @@ -131,8 +162,8 @@ func (s *LocalLeaseSet2) MarshalTo(dst []byte, nowMillis uint64, sign func([]byt if keyCount == 0 || keyCount > 255 { return 0, ErrLocalLeaseSet2 } - unsignedLen := len(identityBytes) + 8 + 2 + 1 + keyCount*(4+32) + 1 + len(leases)*40 - signatureLen, ok := identity.SigningKeyType().SignatureLen() + unsignedLen := len(identityBytes) + 8 + offlineLen + 2 + 1 + keyCount*(4+32) + 1 + len(leases)*40 + signatureLen, ok := signingType.SignatureLen() if !ok || len(dst) < unsignedLen+signatureLen { return 0, foundation.ErrDestinationSmall } @@ -141,8 +172,15 @@ func (s *LocalLeaseSet2) MarshalTo(dst []byte, nowMillis uint64, sign func([]byt off += 4 binary.BigEndian.PutUint16(dst[off:off+2], uint16(expires)) off += 2 - binary.BigEndian.PutUint16(dst[off:off+2], 0) + binary.BigEndian.PutUint16(dst[off:off+2], flags) off += 2 + if offline != nil { + binary.BigEndian.PutUint32(dst[off:off+4], offline.Expires) + binary.BigEndian.PutUint16(dst[off+4:off+6], uint16(offline.Type)) + off += 6 + off += copy(dst[off:], offline.PublicKey) + off += copy(dst[off:], offline.Signature) + } // Canonical empty mapping and every locally accepted encryption format in // local preference order, independent of a resolver's parser order. dst[off], dst[off+1] = 0, 0 diff --git a/networking/internal/netdb/local_ls2_offline_test.go b/networking/internal/netdb/local_ls2_offline_test.go new file mode 100644 index 0000000..6f69d84 --- /dev/null +++ b/networking/internal/netdb/local_ls2_offline_test.go @@ -0,0 +1,118 @@ +package netdb + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/binary" + "errors" + "testing" + "time" + + "gosuda.org/ivnp/foundation" +) + +// offlineTestDestination builds a LocalDestination whose long-term signing +// private key is absent and replaced by an authorized transient Ed25519 key. +func offlineTestDestination(t *testing.T, expires uint32) *foundation.LocalDestination { + t.Helper() + longTerm, err := foundation.GenerateLocalDestination() + if err != nil { + t.Fatal(err) + } + defer longTerm.ReleaseSensitive() + state := make([]byte, longTerm.PrivateEncodedLen()) + n, err := longTerm.MarshalPrivateTo(state) + if err != nil { + t.Fatal(err) + } + state = state[:n] + defer clear(state) + publicLength := int(binary.BigEndian.Uint16(state[:2])) + clear(state[2+publicLength : 2+publicLength+ed25519.PrivateKeySize]) + transientPublic, transientFull, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + transientPrivate := transientFull.Seed() + defer clear(transientPrivate) + offline := foundation.OfflineSignature{Expires: expires, Type: foundation.SigningEdDSASHA512Ed25519, PublicKey: transientPublic} + var content [6 + ed25519.PublicKeySize]byte + contentLen, err := offline.MarshalSignedContentTo(content[:]) + if err != nil { + t.Fatal(err) + } + offline.Signature, err = longTerm.Sign(content[:contentLen]) + if err != nil { + t.Fatal(err) + } + destination, err := foundation.ImportLocalDestinationOffline(state, offline, transientPrivate) + if err != nil { + t.Fatal(err) + } + return destination +} + +func TestLocalLeaseSet2OfflineSignatureSection(t *testing.T) { + now := uint64(time.Now().UnixMilli()) + destination := offlineTestDestination(t, uint32(now/1000)+3600) + defer destination.ReleaseSensitive() + local, err := NewLocalLeaseSet2(destination) + if err != nil { + t.Fatal(err) + } + var gateway foundation.Hash + gateway[0] = 1 + if err = local.ReplaceInboundLeases([]Lease{{Gateway: gateway, TunnelID: 7, EndDate: now + 120_000}}); err != nil { + t.Fatal(err) + } + payload := make([]byte, MaxLeaseSetBytes) + n, err := local.MarshalTo(payload, now, destination.Sign) + if err != nil { + t.Fatal(err) + } + set, err := ParseLeaseSet2(payload[:n]) + if err != nil { + t.Fatal(err) + } + if set.Header.Flags&leaseSetOfflineFlag == 0 || !set.Header.Offline.Present() { + t.Fatalf("offline flag/section missing: flags=%#x", set.Header.Flags) + } + if set.Header.Offline.Expires != uint32(now/1000)+3600 { + t.Fatalf("offline expires = %d", set.Header.Offline.Expires) + } + if ok, err := set.Verify(); err != nil || !ok { + t.Fatalf("verified offline LS2 = %t, %v", ok, err) + } +} + +func TestLocalLeaseSet2OfflineExpiredRefusesPublication(t *testing.T) { + now := uint64(time.Now().UnixMilli()) + destination := offlineTestDestination(t, uint32(now/1000)-1) + defer destination.ReleaseSensitive() + local, err := NewLocalLeaseSet2(destination) + if err != nil { + t.Fatal(err) + } + var gateway foundation.Hash + gateway[0] = 1 + if err = local.ReplaceInboundLeases([]Lease{{Gateway: gateway, TunnelID: 7, EndDate: now + 120_000}}); err != nil { + t.Fatal(err) + } + payload := make([]byte, MaxLeaseSetBytes) + if _, err = local.MarshalTo(payload, now, destination.Sign); !errors.Is(err, ErrLocalLeaseSet2) { + t.Fatalf("expired offline MarshalTo = %v, want ErrLocalLeaseSet2", err) + } +} + +func TestNewLocalEncryptedLeaseSetRejectsOfflineDestination(t *testing.T) { + destination := offlineTestDestination(t, uint32(1_000_000_000_000/1000)+3600) + defer destination.ReleaseSensitive() + local, err := NewLocalLeaseSet2(destination) + if err != nil { + t.Fatal(err) + } + encrypted, err := NewLocalEncryptedLeaseSet(destination, local, EncryptedLeaseSetAuthorization{}, nil) + if err == nil || encrypted != nil { + t.Fatalf("offline encrypted LeaseSet = %#v, %v", encrypted, err) + } +} diff --git a/node/internal/runtime/client_destination.go b/node/internal/runtime/client_destination.go index 2c4229e..7259830 100644 --- a/node/internal/runtime/client_destination.go +++ b/node/internal/runtime/client_destination.go @@ -202,7 +202,7 @@ func (e *clientDestinationEndpoint) MarshalDatagramV2To(dst []byte, target found var offline networking.DatagramOfflineSignature if meta, ok := e.runtime.local.OfflineSignature(); ok { flags |= networking.DatagramFlagOffline - offline = networking.DatagramOfflineSignature{Expires: meta.Expires, Type: meta.Type, PublicKey: meta.PublicKey, Signature: meta.Signature} + offline = meta } return networking.DatagramMarshalV2To(dst, target, identity, flags, foundation.Mapping{}, offline, payload, e.runtime.local.Sign) } From f260e77e49e3f50b3ff29755617dc03c6350295b Mon Sep 17 00:00:00 2001 From: Lee Yunjin Date: Sat, 5 Sep 2026 13:04:05 +0900 Subject: [PATCH 4/5] test: deflake SSU2 allocation and SAM endpoint-support tests TestSSU2LiveVectorReadAuthDispatchWriteAllocations timed out on loaded CI runners: the 500ms handshake timeout and 5s warmup/delivery waits left no room for scheduling delays. Raise the handshake timeout to 2s and the waits to 30s; the allocation assertions are unchanged. TestDatagramModernSendWithoutEndpointSupport reused the session ID dg across styles, but session teardown after connection close is asynchronous, so the second SESSION CREATE raced with removal and could fail with DUPLICATED_ID. Use a distinct ID per style. --- client/internal/sam/datagram_modern_test.go | 10 +++++++--- networking/internal/router/manager_allocation_test.go | 8 ++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/client/internal/sam/datagram_modern_test.go b/client/internal/sam/datagram_modern_test.go index 763a8de..77714ab 100644 --- a/client/internal/sam/datagram_modern_test.go +++ b/client/internal/sam/datagram_modern_test.go @@ -5,6 +5,7 @@ import ( "context" "io" "net" + "strconv" "strings" "testing" "time" @@ -275,13 +276,16 @@ func TestDatagramModernSendWithoutEndpointSupport(t *testing.T) { t.Fatal(err) } defer func() { _ = server.Close(); _ = server.Wait() }() - for _, style := range []string{"DATAGRAM2", "DATAGRAM3"} { + for i, style := range []string{"DATAGRAM2", "DATAGRAM3"} { + // Session teardown on connection close is asynchronous; use a distinct + // ID per style instead of relying on the previous session being gone. + id := "dg" + strconv.Itoa(i) control, reader := samDial(t, server.Addr().String()) - _, _ = io.WriteString(control, "SESSION CREATE STYLE="+style+" ID=dg DESTINATION=TRANSIENT\n") + _, _ = io.WriteString(control, "SESSION CREATE STYLE="+style+" ID="+id+" DESTINATION=TRANSIENT\n") if line := readSAMLine(t, reader); !strings.Contains(line, "RESULT=OK") { t.Fatalf("%s create = %q", style, line) } - _, _ = io.WriteString(control, "DATAGRAM SEND ID=dg DESTINATION=peer.i2p SIZE=4\nDATA") + _, _ = io.WriteString(control, "DATAGRAM SEND ID="+id+" DESTINATION=peer.i2p SIZE=4\nDATA") if line := readSAMLine(t, reader); line != "DATAGRAM STATUS RESULT=I2P_ERROR" { t.Fatalf("%s send = %q", style, line) } diff --git a/networking/internal/router/manager_allocation_test.go b/networking/internal/router/manager_allocation_test.go index b3ce067..faa09d2 100644 --- a/networking/internal/router/manager_allocation_test.go +++ b/networking/internal/router/manager_allocation_test.go @@ -99,14 +99,14 @@ func TestSSU2LiveVectorReadAuthDispatchWriteAllocations(t *testing.T) { aliceMetrics, bobMetrics := observability.NewRegistry(), observability.NewRegistry() aliceManager, err := NewSSU2Manager(SSU2ManagerConfig{ Database: aliceDB, StaticPrivate: aliceStatic, IntroKey: aliceIntro, - IdleTimeout: time.Minute, HandshakeTimeout: 500 * time.Millisecond, Metrics: aliceMetrics, + IdleTimeout: time.Minute, HandshakeTimeout: 2 * time.Second, Metrics: aliceMetrics, }) if err != nil { t.Fatal(err) } bobManager, err := NewSSU2Manager(SSU2ManagerConfig{ Database: bobDB, StaticPrivate: bobStatic, IntroKey: bobIntro, - IdleTimeout: time.Minute, HandshakeTimeout: 500 * time.Millisecond, Metrics: bobMetrics, + IdleTimeout: time.Minute, HandshakeTimeout: 2 * time.Second, Metrics: bobMetrics, }) if err != nil { t.Fatal(err) @@ -131,7 +131,7 @@ func TestSSU2LiveVectorReadAuthDispatchWriteAllocations(t *testing.T) { t.Fatal(err) } } - waitForSSU2Live(t, 5*time.Second, func() bool { + waitForSSU2Live(t, 30*time.Second, func() bool { aliceManager.mu.RLock() session := aliceManager.sessionsByPeer[bob.Hash()] peerTests := len(aliceManager.peerTests) @@ -154,7 +154,7 @@ func TestSSU2LiveVectorReadAuthDispatchWriteAllocations(t *testing.T) { if sendErr != nil { t.Fatal(sendErr) } - waitForSSU2Live(t, 5*time.Second, func() bool { + waitForSSU2Live(t, 30*time.Second, func() bool { return delivered.Load() >= before+65 }, "measured live vector/auth/dispatch/write delivery") if allocations != 0 { From 50ab427b935896ebd9eb2838690ae5a1df78be71 Mon Sep 17 00:00:00 2001 From: Lee Yunjin Date: Sat, 5 Sep 2026 13:23:44 +0900 Subject: [PATCH 5/5] fix: close remaining offline-signature gaps from review - SESSION ADD under an offline PRIMARY root now rejects STYLE=DATAGRAM, matching the createSession restriction; Datagram1 cannot carry the offline authorization - LocalLeaseSet2.MarshalTo caps lease end times at the offline authorization expiry so no published lease outlives the transient key - Wipe the expanded transient Ed25519 key in the netdb offline test helper --- client/internal/sam/offline_test.go | 29 +++++++++++++ client/internal/sam/protocol.go | 5 +++ networking/internal/netdb/local_ls2.go | 13 ++++-- .../internal/netdb/local_ls2_offline_test.go | 41 ++++++++++++++++++- 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/client/internal/sam/offline_test.go b/client/internal/sam/offline_test.go index a14e27a..58915a7 100644 --- a/client/internal/sam/offline_test.go +++ b/client/internal/sam/offline_test.go @@ -228,3 +228,32 @@ func TestSessionCreateOfflineEncryptedLeaseSetRejected(t *testing.T) { t.Fatalf("offline encrypted create = %q", line) } } + +func TestSessionAddOfflinePrimaryDatagram1Rejected(t *testing.T) { + controller := &loopController{endpoints: make(map[foundation.Hash]*loopEndpoint)} + server, err := NewServer(ServerConfig{Address: "127.0.0.1:0", Controller: controller, MaxSessions: 8}) + if err != nil { + t.Fatal(err) + } + if err = server.Start(t.Context()); err != nil { + t.Fatal(err) + } + defer func() { _ = server.Close(); _ = server.Wait() }() + private, _ := offlineSAMPrivateDestination(t, uint32(time.Now().Add(time.Hour).Unix())) + control, reader := samDial(t, server.Addr().String()) + defer control.Close() + _, _ = io.WriteString(control, "SESSION CREATE STYLE=PRIMARY ID=primary DESTINATION="+private+"\n") + if line := readSAMLine(t, reader); !strings.Contains(line, "RESULT=OK") { + t.Fatalf("offline primary create = %q", line) + } + // The subsession shares the offline root endpoint, so the Datagram1 + // restriction from createSession applies here as well. + _, _ = io.WriteString(control, "SESSION ADD STYLE=DATAGRAM ID=legacy\n") + if line := readSAMLine(t, reader); !strings.Contains(line, "RESULT=INVALID_KEY") { + t.Fatalf("offline DATAGRAM add = %q", line) + } + _, _ = io.WriteString(control, "SESSION ADD STYLE=DATAGRAM2 ID=modern\n") + if line := readSAMLine(t, reader); !strings.Contains(line, "RESULT=OK") { + t.Fatalf("offline DATAGRAM2 add = %q", line) + } +} diff --git a/client/internal/sam/protocol.go b/client/internal/sam/protocol.go index d8c6025..3579e85 100644 --- a/client/internal/sam/protocol.go +++ b/client/internal/sam/protocol.go @@ -267,6 +267,11 @@ func (s *Server) addSubsession(connection *serverConnection, cmd command) error if !ok || style == stylePrimary { return connection.writeLine("SESSION STATUS RESULT=I2P_ERROR MESSAGE=UNSUPPORTED_STYLE") } + if root.offline != nil && style == styleDatagram { + // Same restriction as createSession: Datagram1 cannot carry the + // offline signature section, so peers would drop every packet. + return connection.writeLine("SESSION STATUS RESULT=INVALID_KEY") + } fromPort, toPort, listenPort, protocol, listenProtocol, rawHeader, udpTarget, err := s.sessionTransport(connection, style, cmd.values, true) if err != nil { return connection.writeLine("SESSION STATUS RESULT=I2P_ERROR MESSAGE=INVALID_OPTION") diff --git a/networking/internal/netdb/local_ls2.go b/networking/internal/netdb/local_ls2.go index ac2da3b..03409ee 100644 --- a/networking/internal/netdb/local_ls2.go +++ b/networking/internal/netdb/local_ls2.go @@ -142,12 +142,17 @@ func (s *LocalLeaseSet2) MarshalTo(dst []byte, nowMillis uint64, sign func([]byt signingType = offline.Type } var latest uint32 - for _, lease := range leases { - if lease.TunnelID == 0 || lease.EndDate <= uint32(published) { + for i := range leases { + if offline != nil && leases[i].EndDate > offline.Expires { + // Remote verifiers stop trusting the transient key at the offline + // authorization expiry, so no lease may outlive it. + leases[i].EndDate = offline.Expires + } + if leases[i].TunnelID == 0 || leases[i].EndDate <= uint32(published) { return 0, ErrLocalLeaseSet2 } - if lease.EndDate > latest { - latest = lease.EndDate + if leases[i].EndDate > latest { + latest = leases[i].EndDate } } expires := uint64(latest) - published diff --git a/networking/internal/netdb/local_ls2_offline_test.go b/networking/internal/netdb/local_ls2_offline_test.go index 6f69d84..b380956 100644 --- a/networking/internal/netdb/local_ls2_offline_test.go +++ b/networking/internal/netdb/local_ls2_offline_test.go @@ -33,7 +33,8 @@ func offlineTestDestination(t *testing.T, expires uint32) *foundation.LocalDesti if err != nil { t.Fatal(err) } - transientPrivate := transientFull.Seed() + transientPrivate := append([]byte(nil), transientFull.Seed()...) + clear(transientFull) defer clear(transientPrivate) offline := foundation.OfflineSignature{Expires: expires, Type: foundation.SigningEdDSASHA512Ed25519, PublicKey: transientPublic} var content [6 + ed25519.PublicKeySize]byte @@ -116,3 +117,41 @@ func TestNewLocalEncryptedLeaseSetRejectsOfflineDestination(t *testing.T) { t.Fatalf("offline encrypted LeaseSet = %#v, %v", encrypted, err) } } + +func TestLocalLeaseSet2OfflineCapsLeaseExpiry(t *testing.T) { + now := uint64(time.Now().UnixMilli()) + expires := uint32(now/1000) + 60 + destination := offlineTestDestination(t, expires) + defer destination.ReleaseSensitive() + local, err := NewLocalLeaseSet2(destination) + if err != nil { + t.Fatal(err) + } + var gateway foundation.Hash + gateway[0] = 1 + if err = local.ReplaceInboundLeases([]Lease{{Gateway: gateway, TunnelID: 7, EndDate: now + 3_600_000}}); err != nil { + t.Fatal(err) + } + payload := make([]byte, MaxLeaseSetBytes) + n, err := local.MarshalTo(payload, now, destination.Sign) + if err != nil { + t.Fatal(err) + } + set, err := ParseLeaseSet2(payload[:n]) + if err != nil { + t.Fatal(err) + } + leases := set.Leases() + lease, ok, err := leases.Next() + if err != nil || !ok { + t.Fatalf("lease = %t, %v", ok, err) + } + // Remote verifiers stop trusting the transient key at the offline + // authorization expiry, so no published lease may outlive it. + if lease.EndDate != expires { + t.Fatalf("lease end = %d, want capped at offline expiry %d", lease.EndDate, expires) + } + if ok, err = set.Verify(); err != nil || !ok { + t.Fatalf("verify = %t, %v", ok, err) + } +}