diff --git a/client/internal/sam/datagram.go b/client/internal/sam/datagram.go index 2b45c3d..fb84691 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,41 @@ 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.VerifyTargetAt(s.endpoint.Hash(), uint32(s.now())) + 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 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 } func (s *samSession) forwardRaw(delivery networking.StreamingTunnelDelivery) { @@ -187,10 +230,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 +248,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..77714ab --- /dev/null +++ b/client/internal/sam/datagram_modern_test.go @@ -0,0 +1,294 @@ +package sam + +import ( + "bufio" + "context" + "io" + "net" + "strconv" + "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 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="+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="+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) + } + control.Close() + } +} diff --git a/client/internal/sam/offline_test.go b/client/internal/sam/offline_test.go new file mode 100644 index 0000000..58915a7 --- /dev/null +++ b/client/internal/sam/offline_test.go @@ -0,0 +1,259 @@ +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} + 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) + } + 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) { + 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, Now: func() time.Time { return fixed }}) + 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(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") + 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 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, Now: func() time.Time { return fixed }}) + 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(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") + 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.Fatal("sender signed with an expired offline signature") + } + 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) + } +} + +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) + } +} + +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/private_destination.go b/client/internal/sam/private_destination.go index d47a069..f0f3d36 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 @@ -129,6 +163,7 @@ func decodePrivateDestination(encoded string) (*foundation.LocalDestination, err return nil, ErrInvalidKey } x25519 = generated.Bytes() + defer clear(x25519) elgamal = encryptionPrivate } else { x25519 = encryptionPrivate @@ -142,9 +177,64 @@ 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 +} + +// 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 + 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 + } + 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 := section[offset : offset+publicLength] + offset += publicLength + signature := section[offset : offset+signatureLength] + offset += signatureLength + transientPrivate := 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..3579e85 100644 --- a/client/internal/sam/protocol.go +++ b/client/internal/sam/protocol.go @@ -145,6 +145,21 @@ 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 + } + 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() @@ -173,7 +188,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 +201,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) @@ -252,13 +267,18 @@ 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") } 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 := &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() return connection.writeLine("SESSION STATUS RESULT=DUPLICATED_ID") @@ -266,8 +286,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 +314,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 +446,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 +487,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.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 e2a29d2..b0d5e59 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 = meta + } + 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() @@ -334,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) } @@ -362,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 3922153..01ab410 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,7 +56,9 @@ type samSession struct { listenProtocol uint8 rawHeader bool udpTarget *net.UDPAddr + offline *foundation.OfflineSignature datagramOverhead int + now func() int64 forward bool mu sync.Mutex @@ -56,10 +77,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, 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/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..4d9b382 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,12 +302,31 @@ func (d *LocalDestination) Sign(message []byte) ([]byte, error) { if d.released { return nil, cryptography.ErrSensitiveReleased } + if d.offline != nil { + if uint32(offlineTimeNow().Unix()) > d.offline.expires { + return nil, ErrOfflineSignatureExpired + } + switch d.offline.keyType { + case SigningEdDSASHA512Ed25519: + 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 + } + } switch d.signingType { case SigningEdDSASHA512Ed25519: return ed25519.Sign(ed25519.PrivateKey(d.signingPrivate), message), nil case SigningRedDSASHA512Ed25519: var private [32]byte copy(private[:], d.signingPrivate) + defer clear(private[:]) return Red25519Sign(private, message) default: return nil, ErrEncryptedSigningKey @@ -377,6 +397,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 +419,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 +471,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 +539,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 +606,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..b3cfaf5 --- /dev/null +++ b/foundation/offline_signature.go @@ -0,0 +1,182 @@ +package foundation + +import ( + "bytes" + "crypto/ed25519" + "encoding/binary" + "errors" + "time" + + "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. +type OfflineSignature struct { + Expires uint32 + Type SigningKeyType + PublicKey []byte + Signature []byte +} + +// 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: + 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 + } + 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 + } + 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..bc8c1bb --- /dev/null +++ b/foundation/offline_signature_test.go @@ -0,0 +1,201 @@ +package foundation + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/binary" + "errors" + "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} + 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) + } + 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 TestOfflineDestinationRejectsExpiredSigning(t *testing.T) { + 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) + 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) + 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..3e466e8 100644 --- a/networking/internal/datagram/modern.go +++ b/networking/internal/datagram/modern.go @@ -17,16 +17,12 @@ const ( v3AllowedFlags = flagVersionMask | flagOptions ) -// OfflineSignature holds a transient signing key and its authorizing signature. -type OfflineSignature struct { - Expires uint32 - Type foundation.SigningKeyType - PublicKey []byte - Signature []byte - Signed []byte -} +// FlagOffline marks a Datagram2 packet carrying an offline signature section. +const FlagOffline = flagOffline -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 @@ -35,6 +31,7 @@ type V2 struct { Offline OfflineSignature Payload, Signature []byte signedRest []byte + offlineSigned []byte } // ParseV2 parses a protocol-19 Datagram2 packet. @@ -93,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() @@ -121,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..03409ee 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,13 +120,39 @@ 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) { + 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 @@ -131,8 +167,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 +177,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..b380956 --- /dev/null +++ b/networking/internal/netdb/local_ls2_offline_test.go @@ -0,0 +1,157 @@ +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 := 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 + 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) + } +} + +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) + } +} 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 { 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..7259830 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 = meta + } + 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 {