Просмотр исходного кода

Add server entry push payloads

Rod Hynes 1 месяц назад
Родитель
Сommit
1a07698aed

+ 2 - 0
.github/workflows/tests.yml

@@ -89,6 +89,7 @@ jobs:
           sudo -E env "PATH=$PATH" go test -v -race -tags "PSIPHON_RUN_PACKET_MANIPULATOR_TEST" ./psiphon/common/packetman
           go test -v -race ./psiphon/common/parameters
           go test -v -race ./psiphon/common/protocol
+          go test -v -race ./psiphon/common/push
           go test -v -race ./psiphon/common/quic
           go test -v -race ./psiphon/common/resolver
           go test -v -race ./psiphon/common/tactics
@@ -124,6 +125,7 @@ jobs:
           sudo -E env "PATH=$PATH" go test -v -covermode=count -coverprofile=packetman.coverprofile -tags "PSIPHON_RUN_PACKET_MANIPULATOR_TEST" ./psiphon/common/packetman
           go test -v -covermode=count -coverprofile=parameters.coverprofile ./psiphon/common/parameters
           go test -v -covermode=count -coverprofile=protocol.coverprofile ./psiphon/common/protocol
+          go test -v -covermode=count -coverprofile=push.coverprofile ./psiphon/common/push
           go test -v -covermode=count -coverprofile=quic.coverprofile ./psiphon/common/quic
           go test -v -covermode=count -coverprofile=resolver.coverprofile ./psiphon/common/resolver
           go test -v -covermode=count -coverprofile=tactics.coverprofile ./psiphon/common/tactics

+ 19 - 3
ConsoleClient/main.go

@@ -51,6 +51,9 @@ func main() {
 	var embeddedServerEntryListFilename string
 	flag.StringVar(&embeddedServerEntryListFilename, "serverList", "", "embedded server entry list input file")
 
+	var pushPayloadFilename string
+	flag.StringVar(&pushPayloadFilename, "pushPayload", "", "server entry push payload input file")
+
 	var formatNotices bool
 	flag.BoolVar(&formatNotices, "formatNotices", false, "emit notices in human-readable format")
 
@@ -224,6 +227,7 @@ func main() {
 		// Tunnel mode
 		worker = &TunnelWorker{
 			embeddedServerEntryListFilename: embeddedServerEntryListFilename,
+			pushPayloadFilename:             pushPayloadFilename,
 		}
 	}
 
@@ -336,6 +340,7 @@ type Worker interface {
 // TunnelWorker is the Worker protocol implementation used for tunnel mode.
 type TunnelWorker struct {
 	embeddedServerEntryListFilename string
+	pushPayloadFilename             string
 	embeddedServerListWaitGroup     *sync.WaitGroup
 	controller                      *psiphon.Controller
 }
@@ -347,8 +352,7 @@ func (w *TunnelWorker) Init(ctx context.Context, config *psiphon.Config) error {
 
 	err := psiphon.OpenDataStore(config)
 	if err != nil {
-		psiphon.NoticeError("error initializing datastore: %s", err)
-		os.Exit(1)
+		return errors.Trace(err)
 	}
 
 	// If specified, the embedded server list is loaded and stored. When there
@@ -389,11 +393,23 @@ func (w *TunnelWorker) Init(ctx context.Context, config *psiphon.Config) error {
 
 	controller, err := psiphon.NewController(config)
 	if err != nil {
-		psiphon.NoticeError("error creating controller: %s", err)
 		return errors.Trace(err)
 	}
 	w.controller = controller
 
+	// Import a server entry push payload. This is primarily for testing and
+	// is always executed synchronously.
+	if w.pushPayloadFilename != "" {
+		payload, err := os.ReadFile(w.pushPayloadFilename)
+		if err != nil {
+			return errors.Trace(err)
+		}
+		if !controller.ImportPushPayload(payload) {
+			// Error details emitted as notices
+			return errors.TraceNew("import push payload failed")
+		}
+	}
+
 	return nil
 }
 

+ 13 - 0
MobileLibrary/Android/PsiphonTunnel/PsiphonTunnel.java

@@ -266,6 +266,19 @@ public class PsiphonTunnel {
         return Psi.importExchangePayload(payload);
     }
 
+    // importPushPayload imports a server entry push payload. If no tunnel is
+    // currently connected, this operation will reset tunnel establishment
+    // with imported server entries prioritized appropriately. The push
+    // payload parameters must be set in the Psiphon config, and Psiphon must
+    // be started.
+    //
+    // Returns true if the import succeeded and false on any error. Error
+    // details are logged to diagnostics. If an import is partially
+    // successful, the imported server entries are retained and prioritized.
+    public boolean importPushPayload(byte[] payload) {
+        return Psi.importPushPayload(payload);
+    }
+
     // Writes Go runtime profile information to a set of files in the specifiec output directory.
     // cpuSampleDurationSeconds and blockSampleDurationSeconds determines how to long to wait and
     // sample profiles that require active sampling. When set to 0, these profiles are skipped.

+ 13 - 0
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.h

@@ -438,6 +438,19 @@ Returns the path where the rotated notices file will be created.
  */
 - (long)getPacketTunnelMTU;
 
+/*!
+ importPushPayload imports a server entry push payload. If no tunnel is
+ currently connected, this operation will reset tunnel establishment
+ with imported server entries prioritized appropriately. The push
+ payload parameters must be set in the Psiphon config, and Psiphon must
+ be started.
+
+ Returns true if the import succeeded and false on any error. Error
+ details are logged to diagnostics. If an import is partially
+ successful, the imported server entries are retained and prioritized.
+ */
+- (BOOL)importPushPayload:(NSData * _Nonnull)payload;
+
 /*!
  Provides the tunnel-core build info json as a string. See the tunnel-core build info code for details https://github.com/Psiphon-Labs/psiphon-tunnel-core/blob/master/psiphon/common/buildinfo.go.
  @return  The build info json as a string.

+ 5 - 0
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.m

@@ -461,6 +461,11 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
     return GoPsiGetPacketTunnelMTU();
 }
 
+// See comment in header.
+- (BOOL)importPushPayload:(NSData * _Nonnull)payload {
+    return GoPsiImportPushPayload(payload);
+}
+
 // See comment in header.
 + (NSString * _Nonnull)getBuildInfo {
     return GoPsiGetBuildInfo();

+ 21 - 1
MobileLibrary/psi/psi.go

@@ -345,7 +345,7 @@ func ExportExchangePayload() string {
 // If an import occurs when Psiphon is working to establsh a tunnel, the newly
 // imported server entry is prioritized.
 //
-// The return value indicates a successful import. If the import failed, a a
+// The return value indicates a successful import. If the import failed, a
 // diagnostic notice has been logged.
 func ImportExchangePayload(payload string) bool {
 
@@ -359,6 +359,26 @@ func ImportExchangePayload(payload string) bool {
 	return controller.ImportExchangePayload(payload)
 }
 
+// ImportPushPayload imports a server entry push payload.
+//
+// If an import occurs when Psiphon is working to establsh a tunnel, the
+// imported server entries are prioritized as indicated in the payload.
+//
+// Returns true if the import succeeded and false on any error. Error
+// details are logged to diagnostics. If an import is partially
+// successful, the imported server entries are retained and prioritized.
+func ImportPushPayload(payload []byte) bool {
+
+	controllerMutex.Lock()
+	defer controllerMutex.Unlock()
+
+	if controller == nil {
+		return false
+	}
+
+	return controller.ImportPushPayload(payload)
+}
+
 var sendFeedbackMutex sync.Mutex
 var sendFeedbackCtx context.Context
 var stopSendFeedback context.CancelFunc

+ 6 - 0
psiphon/common/protocol/protocol.go

@@ -62,6 +62,7 @@ const (
 	SERVER_ENTRY_SOURCE_OBFUSCATED = "OBFUSCATED"
 	SERVER_ENTRY_SOURCE_EXCHANGED  = "EXCHANGED"
 	SERVER_ENTRY_SOURCE_DSL        = "DSL-*"
+	SERVER_ENTRY_SOURCE_PUSH       = "PUSH-*"
 
 	CAPABILITY_SSH_API_REQUESTS            = "ssh-api-requests"
 	CAPABILITY_UNTUNNELED_WEB_API_REQUESTS = "handshake"
@@ -116,6 +117,11 @@ var SupportedServerEntrySources = []string{
 	SERVER_ENTRY_SOURCE_OBFUSCATED,
 	SERVER_ENTRY_SOURCE_EXCHANGED,
 	SERVER_ENTRY_SOURCE_DSL,
+	SERVER_ENTRY_SOURCE_PUSH,
+}
+
+func PushServerEntrySource(source string) string {
+	return "PUSH-" + source
 }
 
 func AllowServerEntrySourceWithUpstreamProxy(source string) bool {

+ 1 - 3
psiphon/common/protocol/serverEntry.go

@@ -1078,9 +1078,7 @@ func encodeServerEntry(
 // used by remote server lists and Psiphon server handshake requests.
 //
 // The resulting ServerEntry.LocalSource is populated with serverEntrySource,
-// which should be one of SERVER_ENTRY_SOURCE_EMBEDDED, SERVER_ENTRY_SOURCE_REMOTE,
-// SERVER_ENTRY_SOURCE_DISCOVERY, SERVER_ENTRY_SOURCE_TARGET,
-// SERVER_ENTRY_SOURCE_OBFUSCATED.
+// which should be one of SupportedServerEntrySources.
 // ServerEntry.LocalTimestamp is populated with the provided timestamp, which
 // should be a RFC 3339 formatted string. These local fields are stored with the
 // server entry and reported to the server as stats (a coarse granularity timestamp

+ 16 - 0
psiphon/common/push/converter/README.md

@@ -0,0 +1,16 @@
+# converter
+
+Example usage:
+
+```
+PSIPHON_PUSH_PAYLOAD_OBFUSCATION_KEY=<base64> \
+PSIPHON_PUSH_PAYLOAD_SIGNATURE_PUBLIC_KEY=<base64> \
+PSIPHON_PUSH_PAYLOAD_SIGNATURE_PRIVATE_KEY=<base64> \
+./converter -config <config-filename> -TTL <duration> -source <source description> -prioritize <input-filename>
+```
+
+* Converter is a tool that converts server lists to and from push payloads. Output is emitted to stdout.
+* The type of input file is determined automatically; if the input is a valid server list, it is converted to a push payload; otherwise the input is treated as a push payload and converted to a server list.
+* If an optional Psiphon config file input is provided, the key values, except for `PSIPHON_PUSH_PAYLOAD_SIGNATURE_PRIVATE_KEY`, will be read from the config parameters, if present.
+* `PSIPHON_PUSH_PAYLOAD_SIGNATURE_PRIVATE_KEY`, `TTL`, `source`, `prioritize`, and optional padding inputs are used only when converting to a push payload.
+* Converter does not check individual server entry signatures.

+ 229 - 0
psiphon/common/push/converter/main.go

@@ -0,0 +1,229 @@
+/*
+ * Copyright (c) 2026, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package main
+
+import (
+	"flag"
+	"fmt"
+	"io"
+	"os"
+	"strings"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/push"
+)
+
+func main() {
+
+	var configFile string
+	flag.StringVar(&configFile, "config", "", "Psiphon config file")
+
+	var ttl time.Duration
+	flag.DurationVar(&ttl, "TTL", 24*time.Hour, "payload TTL")
+
+	var source string
+	flag.StringVar(&source, "source", "push-converter", "payload source")
+
+	var prioritize bool
+	flag.BoolVar(&prioritize, "prioritize", false, "prioritize dials for all payload server entries")
+
+	var minPadding int
+	flag.IntVar(&minPadding, "minPadding", 0, "min obfuscated payload padding")
+
+	var maxPadding int
+	flag.IntVar(&maxPadding, "maxPadding", 0, "max obfuscated payload padding")
+
+	flag.Parse()
+
+	obfuscationKey := os.Getenv("PSIPHON_PUSH_PAYLOAD_OBFUSCATION_KEY")
+	signaturePublicKey := os.Getenv("PSIPHON_PUSH_PAYLOAD_SIGNATURE_PUBLIC_KEY")
+	signaturePrivateKey := os.Getenv("PSIPHON_PUSH_PAYLOAD_SIGNATURE_PRIVATE_KEY")
+
+	if configFile != "" {
+		config, err := loadConfig(configFile)
+		if err != nil {
+			fmt.Fprintln(os.Stderr, errors.Trace(err))
+			os.Exit(1)
+		}
+		if config.PushPayloadObfuscationKey != "" {
+			obfuscationKey = config.PushPayloadObfuscationKey
+		}
+		if config.PushPayloadSignaturePublicKey != "" {
+			signaturePublicKey = config.PushPayloadSignaturePublicKey
+		}
+	}
+
+	inputFile := flag.Arg(0)
+	if inputFile == "" {
+		flag.PrintDefaults()
+		os.Exit(1)
+	}
+
+	err := convert(
+		obfuscationKey,
+		minPadding,
+		maxPadding,
+		signaturePublicKey,
+		signaturePrivateKey,
+		inputFile,
+		ttl,
+		source,
+		prioritize)
+	if err != nil {
+		fmt.Fprintln(os.Stderr, errors.Trace(err))
+		os.Exit(1)
+	}
+
+	os.Exit(0)
+}
+
+func convert(
+	obfuscationKey string,
+	minPadding int,
+	maxPadding int,
+	signaturePublicKey string,
+	signaturePrivateKey string,
+	inputFile string,
+	ttl time.Duration,
+	source string,
+	prioritize bool) error {
+
+	input, err := os.ReadFile(inputFile)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	// If the input file is a valid server entry list, convert to a push
+	// payload. Otherwise assume the input is a push payload and convert to a
+	// server entry list.
+
+	serverEntryFields, err := decodeServerEntryList(string(input))
+	if err == nil {
+
+		var prioritizedServerEntries []*push.PrioritizedServerEntry
+		for _, serverEntry := range serverEntryFields {
+			packed, err := protocol.EncodePackedServerEntryFields(serverEntry)
+			if err != nil {
+				return errors.Trace(err)
+			}
+
+			prioritizedServerEntries = append(prioritizedServerEntries,
+				&push.PrioritizedServerEntry{
+					ServerEntryFields: packed,
+					Source:            source,
+					PrioritizeDial:    prioritize,
+				})
+		}
+
+		payloads, err := push.MakePushPayloads(
+			obfuscationKey,
+			minPadding,
+			maxPadding,
+			signaturePublicKey,
+			signaturePrivateKey,
+			ttl,
+			[][]*push.PrioritizedServerEntry{
+				prioritizedServerEntries})
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		os.Stdout.Write(payloads[0])
+		return nil
+	}
+
+	var serverList []string
+	importer := func(
+		packed protocol.PackedServerEntryFields,
+		_ string,
+		_ bool) error {
+
+		serverEntryFields, err := protocol.DecodePackedServerEntryFields(packed)
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		serverEntry, err := protocol.EncodeServerEntryFields(serverEntryFields)
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		serverList = append(serverList, serverEntry)
+		return nil
+	}
+
+	_, err = push.ImportPushPayload(
+		obfuscationKey,
+		signaturePublicKey,
+		input,
+		importer)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	os.Stdout.Write([]byte(strings.Join(serverList, "\n")))
+	return nil
+}
+
+// decodeServerEntryList is equivalent to protocol.DecodeServerEntryList
+// without local field initialization/validation.
+func decodeServerEntryList(
+	encodedServerEntryList string) ([]protocol.ServerEntryFields, error) {
+
+	serverEntries := make([]protocol.ServerEntryFields, 0)
+	for _, encodedServerEntry := range strings.Split(
+		encodedServerEntryList, "\n") {
+
+		if len(encodedServerEntry) == 0 {
+			continue
+		}
+
+		serverEntryFields, err := protocol.DecodeServerEntryFields(
+			encodedServerEntry, "", "")
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+
+		serverEntries = append(serverEntries, serverEntryFields)
+	}
+	return serverEntries, nil
+}
+
+func loadConfig(configFile string) (*psiphon.Config, error) {
+
+	psiphon.SetNoticeWriter(io.Discard)
+
+	configJSON, err := os.ReadFile(configFile)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+	config, err := psiphon.LoadConfig(configJSON)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+	err = config.Commit(false)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+	return config, nil
+}

+ 332 - 0
psiphon/common/push/push.go

@@ -0,0 +1,332 @@
+/*
+ * Copyright (c) 2026, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+// Package push implements server entry push payloads, which support pushing
+// server entries to clients through external distribution channels. Push
+// payloads use the compact packed CBOR server entry representation.
+//
+// Each server entry has an optional prioritize dial flag which is equivalent
+// to dsl.VersionedServerEntryTag.PrioritizedDial.
+//
+// Payloads include an expiry date to ensure freshness and mitigate replay
+// attacks. The entire payload is digitally signed, and an obfuscation layer
+// is added on top.
+package push
+
+import (
+	"bytes"
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/ed25519"
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/base64"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"github.com/fxamacker/cbor/v2"
+)
+
+const (
+	obfuscationKeySize           = 32
+	signaturePublicKeyDigestSize = 8
+)
+
+// Payload is a push payload, consisting of a list of server entries. To
+// ensure stale server entries and stale dial prioritizations are not
+// imported, the list has an expiry timestamp.
+type Payload struct {
+	Expires                  time.Time                 `cbor:"1,keyasint,omitempty"`
+	PrioritizedServerEntries []*PrioritizedServerEntry `cbor:"2,keyasint,omitempty"`
+}
+
+// SignedPayload is Payload with a digital signature.
+type SignedPayload struct {
+	Signature []byte `cbor:"1,keyasint,omitempty"`
+	Payload   []byte `cbor:"2,keyasint,omitempty"`
+	Padding   []byte `cbor:"3,keyasint,omitempty"`
+}
+
+// PrioritizedServerEntry is a server entry paired with a server entry source
+// description and a dial prioritization indication. PrioritizeDial is
+// equivalent to DSL prioritized dials.
+type PrioritizedServerEntry struct {
+	ServerEntryFields protocol.PackedServerEntryFields `cbor:"1,keyasint,omitempty"`
+	Source            string                           `cbor:"2,keyasint,omitempty"`
+	PrioritizeDial    bool                             `cbor:"3,keyasint,omitempty"`
+}
+
+// ServerEntryImporter is a callback that is invoked for each server entry in
+// an imported push payload.
+type ServerEntryImporter func(
+	packedServerEntryFields protocol.PackedServerEntryFields,
+	source string,
+	prioritizeDial bool) error
+
+// GenerateKeys generates a new obfuscation key and signature key pair for
+// push payloads.
+func GenerateKeys() (
+	payloadObfuscationKey string,
+	payloadSignaturePublicKey string,
+	payloadSignaturePrivateKey string,
+	err error) {
+
+	obfuscationKey := make([]byte, obfuscationKeySize)
+	_, err = rand.Read(obfuscationKey)
+	if err != nil {
+		return "", "", "", errors.Trace(err)
+	}
+
+	publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		return "", "", "", errors.Trace(err)
+	}
+
+	return base64.StdEncoding.EncodeToString(obfuscationKey),
+		base64.StdEncoding.EncodeToString(publicKey),
+		base64.StdEncoding.EncodeToString(privateKey),
+		nil
+}
+
+// ImportPushPayload imports the input push payload. The ServerEntryImporter
+// callback is invoked for each imported server entry and its associated
+// source and prioritizeDial data.
+func ImportPushPayload(
+	payloadObfuscationKey string,
+	payloadSignaturePublicKey string,
+	obfuscatedPayload []byte,
+	serverEntryImporter ServerEntryImporter) (int, error) {
+
+	obfuscationKey, err := base64.StdEncoding.DecodeString(
+		payloadObfuscationKey)
+	if err == nil && len(obfuscationKey) != obfuscationKeySize {
+		err = errors.TraceNew("unexpected obfuscation key size")
+	}
+	if err != nil {
+		return 0, errors.Trace(err)
+	}
+
+	publicKey, err := base64.StdEncoding.DecodeString(
+		payloadSignaturePublicKey)
+	if err == nil && len(publicKey) != ed25519.PublicKeySize {
+		err = errors.TraceNew("unexpected signature public key size")
+	}
+	if err != nil {
+		return 0, errors.Trace(err)
+	}
+
+	blockCipher, err := aes.NewCipher(obfuscationKey)
+	if err != nil {
+		return 0, errors.Trace(err)
+	}
+
+	aead, err := cipher.NewGCM(blockCipher)
+	if err != nil {
+		return 0, errors.Trace(err)
+	}
+
+	if len(obfuscatedPayload) < aead.NonceSize() {
+		return 0, errors.TraceNew("missing nonce")
+	}
+
+	cborSignedPayload, err := aead.Open(
+		nil,
+		obfuscatedPayload[:aead.NonceSize()],
+		obfuscatedPayload[aead.NonceSize():],
+		nil)
+	if err != nil {
+		return 0, errors.Trace(err)
+	}
+
+	var signedPayload SignedPayload
+	err = cbor.Unmarshal(cborSignedPayload, &signedPayload)
+	if err != nil {
+		return 0, errors.Trace(err)
+	}
+
+	if len(signedPayload.Signature) !=
+		signaturePublicKeyDigestSize+ed25519.SignatureSize {
+
+		return 0, errors.TraceNew("invalid signature size")
+	}
+
+	publicKeyDigest := sha256.Sum256(publicKey)
+	expectedPublicKeyID := publicKeyDigest[:signaturePublicKeyDigestSize]
+
+	if !bytes.Equal(
+		expectedPublicKeyID,
+		signedPayload.Signature[:signaturePublicKeyDigestSize]) {
+
+		return 0, errors.TraceNew("unexpected signature public key ID")
+	}
+
+	if !ed25519.Verify(
+		publicKey,
+		signedPayload.Payload,
+		signedPayload.Signature[signaturePublicKeyDigestSize:]) {
+
+		return 0, errors.TraceNew("invalid signature")
+	}
+
+	var payload Payload
+	err = cbor.Unmarshal(signedPayload.Payload, &payload)
+	if err != nil {
+		return 0, errors.Trace(err)
+	}
+
+	if payload.Expires.Before(time.Now().UTC()) {
+		return 0, errors.TraceNew("payload expired")
+	}
+
+	imported := 0
+	for _, prioritizedServerEntry := range payload.PrioritizedServerEntries {
+		err := serverEntryImporter(
+			prioritizedServerEntry.ServerEntryFields,
+			prioritizedServerEntry.Source,
+			prioritizedServerEntry.PrioritizeDial)
+		if err != nil {
+			return imported, errors.Trace(err)
+		}
+		imported += 1
+	}
+
+	return imported, nil
+}
+
+// MakePushPayloads generates batches of push payloads.
+func MakePushPayloads(
+	payloadObfuscationKey string,
+	minPadding int,
+	maxPadding int,
+	payloadSignaturePublicKey string,
+	payloadSignaturePrivateKey string,
+	TTL time.Duration,
+	prioritizedServerEntries [][]*PrioritizedServerEntry) ([][]byte, error) {
+
+	obfuscationKey, err := base64.StdEncoding.DecodeString(
+		payloadObfuscationKey)
+	if err == nil && len(obfuscationKey) != obfuscationKeySize {
+		err = errors.TraceNew("unexpected obfuscation key size")
+	}
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	publicKey, err := base64.StdEncoding.DecodeString(
+		payloadSignaturePublicKey)
+	if err == nil && len(publicKey) != ed25519.PublicKeySize {
+		err = errors.TraceNew("unexpected signature public key size")
+	}
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	privateKey, err := base64.StdEncoding.DecodeString(
+		payloadSignaturePrivateKey)
+	if err == nil && len(privateKey) != ed25519.PrivateKeySize {
+		err = errors.TraceNew("unexpected signature private key size")
+	}
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	expires := time.Now().Add(TTL).UTC()
+
+	maxPaddingLimit := 65535
+	if minPadding > maxPadding || maxPadding > 65535 {
+		return nil, errors.TraceNew("invalid min/max padding")
+	}
+
+	blockCipher, err := aes.NewCipher(obfuscationKey)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	aead, err := cipher.NewGCM(blockCipher)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	publicKeyDigest := sha256.Sum256(publicKey)
+	publicKeyID := publicKeyDigest[:signaturePublicKeyDigestSize]
+
+	// Reuse buffers to reduce some allocations.
+	var signatureBuffer []byte
+	var obfuscationBuffer []byte
+	nonceBuffer := make([]byte, aead.NonceSize())
+	var paddingBuffer []byte
+
+	obfuscatedPayloads := [][]byte{}
+
+	for _, p := range prioritizedServerEntries {
+
+		payload := Payload{
+			Expires:                  expires,
+			PrioritizedServerEntries: p,
+		}
+
+		cborPayload, err := protocol.CBOREncoding.Marshal(&payload)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+
+		signature := ed25519.Sign(privateKey, cborPayload)
+
+		signatureBuffer = signatureBuffer[:0]
+		signatureBuffer = append(signatureBuffer, publicKeyID...)
+		signatureBuffer = append(signatureBuffer, signature...)
+
+		signedPayload := SignedPayload{
+			Signature: signatureBuffer,
+			Payload:   cborPayload,
+		}
+
+		// Padding is an optional part of the obfuscation layer.
+		if maxPadding > 0 {
+			paddingSize := prng.Range(minPadding, maxPadding)
+			if paddingBuffer == nil {
+				paddingBuffer = make([]byte, maxPaddingLimit)
+			}
+			if paddingSize > 0 {
+				signedPayload.Padding = paddingBuffer[0:paddingSize]
+			}
+		}
+
+		cborSignedPayload, err := protocol.CBOREncoding.
+			Marshal(&signedPayload)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+
+		// The faster common/prng is appropriate for obfuscation.
+		prng.Read(nonceBuffer[:])
+
+		obfuscationBuffer = obfuscationBuffer[:0]
+		obfuscationBuffer = append(obfuscationBuffer, nonceBuffer...)
+		obfuscationBuffer = aead.Seal(
+			obfuscationBuffer, nonceBuffer[:], cborSignedPayload, nil)
+
+		obfuscatedPayloads = append(
+			obfuscatedPayloads, append([]byte(nil), obfuscationBuffer...))
+	}
+
+	return obfuscatedPayloads, nil
+}

+ 250 - 0
psiphon/common/push/push_test.go

@@ -0,0 +1,250 @@
+/*
+ * Copyright (c) 2026, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package push
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+)
+
+func TestPush(t *testing.T) {
+
+	err := runTestPush()
+	if err != nil {
+		t.Fatal(err.Error())
+	}
+}
+
+func runTestPush() error {
+
+	obfuscationKey, publicKey, privateKey, err := GenerateKeys()
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	minPadding := 0
+	maxPadding := 65535
+
+	_, incorrectPublicKey, incorrectPrivateKey, err := GenerateKeys()
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	var serverEntries []*PrioritizedServerEntry
+
+	for i := 0; i < 128; i++ {
+
+		serverEntry := &protocol.ServerEntry{
+			Tag:                  prng.Base64String(32),
+			IpAddress:            fmt.Sprintf("192.0.2.%d", i),
+			SshUsername:          prng.HexString(8),
+			SshPassword:          prng.HexString(32),
+			SshHostKey:           prng.Base64String(280),
+			SshObfuscatedPort:    prng.Range(1, 65535),
+			SshObfuscatedKey:     prng.HexString(32),
+			Capabilities:         []string{"OSSH"},
+			Region:               prng.HexString(1),
+			ProviderID:           strings.ToUpper(prng.HexString(8)),
+			ConfigurationVersion: 0,
+			Signature:            prng.Base64String(80),
+		}
+
+		serverEntryFields, err := serverEntry.GetServerEntryFields()
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		packed, err := protocol.EncodePackedServerEntryFields(serverEntryFields)
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		serverEntries = append(serverEntries, &PrioritizedServerEntry{
+			ServerEntryFields: packed,
+			Source:            fmt.Sprintf("source-%d", i),
+			PrioritizeDial:    i < 32 || i >= 96,
+		})
+	}
+
+	// Test: successful import
+
+	pushServerEntries := [][]*PrioritizedServerEntry{
+		serverEntries[0:32], serverEntries[32:64],
+		serverEntries[64:96], serverEntries[96:128],
+	}
+
+	payloads, err := MakePushPayloads(
+		obfuscationKey,
+		minPadding,
+		maxPadding,
+		publicKey,
+		privateKey,
+		1*time.Hour,
+		pushServerEntries)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	if len(payloads) != len(pushServerEntries) {
+		return errors.TraceNew("unexpected payload count")
+	}
+
+	expectPrioritizeDial := true
+
+	importer := func(
+		packedServerEntryFields protocol.PackedServerEntryFields,
+		source string,
+		prioritizeDial bool) error {
+
+		serverEntryFields, err := protocol.DecodePackedServerEntryFields(packedServerEntryFields)
+		if err != nil {
+			return errors.Trace(err)
+		}
+		if !strings.HasPrefix(serverEntryFields["ipAddress"].(string), "192.0.2") {
+			return errors.TraceNew("unexpected server entry IP address")
+		}
+		if prioritizeDial != expectPrioritizeDial {
+			return errors.TraceNew("unexpected prioritize dial")
+		}
+		return nil
+	}
+
+	for i, payload := range payloads {
+
+		expectPrioritizeDial = i == 0 || i == 3
+
+		n, err := ImportPushPayload(
+			obfuscationKey,
+			publicKey,
+			payload,
+			importer)
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		if n != 32 {
+			return errors.TraceNew("unexpected import count")
+		}
+	}
+
+	// Test: expired
+
+	payloads, err = MakePushPayloads(
+		obfuscationKey,
+		minPadding,
+		maxPadding,
+		publicKey,
+		privateKey,
+		1*time.Microsecond,
+		pushServerEntries)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	time.Sleep(10 * time.Millisecond)
+
+	_, err = ImportPushPayload(
+		obfuscationKey,
+		publicKey,
+		payloads[0],
+		importer)
+	if err == nil {
+		return errors.TraceNew("unexpected success")
+	}
+
+	// Test: invalid signature
+
+	payloads, err = MakePushPayloads(
+		obfuscationKey,
+		minPadding,
+		maxPadding,
+		publicKey,
+		incorrectPrivateKey,
+		1*time.Hour,
+		pushServerEntries)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	_, err = ImportPushPayload(
+		obfuscationKey,
+		publicKey,
+		payloads[0],
+		importer)
+	if err == nil {
+		return errors.TraceNew("unexpected success")
+	}
+
+	// Test: wrong signature key
+
+	payloads, err = MakePushPayloads(
+		obfuscationKey,
+		minPadding,
+		maxPadding,
+		publicKey,
+		privateKey,
+		1*time.Hour,
+		pushServerEntries)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	_, err = ImportPushPayload(
+		obfuscationKey,
+		incorrectPublicKey,
+		payloads[0],
+		importer)
+	if err == nil {
+		return errors.TraceNew("unexpected success")
+	}
+
+	// Test: mutate obfuscation layer
+
+	payloads, err = MakePushPayloads(
+		obfuscationKey,
+		minPadding,
+		maxPadding,
+		publicKey,
+		privateKey,
+		1*time.Hour,
+		pushServerEntries)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	payloads[0][0] = ^payloads[0][0]
+
+	_, err = ImportPushPayload(
+		obfuscationKey,
+		publicKey,
+		payloads[0],
+		importer)
+	if err == nil {
+		return errors.TraceNew("unexpected success")
+	}
+
+	return nil
+}

+ 10 - 0
psiphon/config.go

@@ -734,6 +734,16 @@ type Config struct {
 	// temporary tunnels.
 	DisableDSLFetcher bool `json:",omitempty"`
 
+	// PushPayloadObfuscationKey is a base64-encoded, secret key value used to
+	// deobfuscate push payloads. This value is supplied by the Psiphon
+	// Network. Required for push payload imports.
+	PushPayloadObfuscationKey string `json:",omitempty"`
+
+	// PushPayloadSignaturePublicKey is a base64-encoded, public key value
+	// used to verify push payloads. This value is supplied by the Psiphon
+	// Network. Required for push payload imports.
+	PushPayloadSignaturePublicKey string `json:",omitempty"`
+
 	//
 	// The following parameters are deprecated.
 	//

+ 54 - 1
psiphon/controller.go

@@ -42,6 +42,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/push"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tun"
@@ -587,7 +588,7 @@ func (controller *Controller) ExportExchangePayload() string {
 // See the comment for psiphon.ImportExchangePayload for more details about
 // the import.
 //
-// When the import is successful, a signal is set to trigger a restart any
+// When the import is successful, a signal is set to trigger a restart of any
 // establishment in progress. This will cause the newly imported server entry
 // to be prioritized, which it otherwise would not be in later establishment
 // rounds. The establishment process continues after ImportExchangePayload
@@ -615,6 +616,58 @@ func (controller *Controller) ImportExchangePayload(payload string) bool {
 	return true
 }
 
+// ImportPushPayload imports a server entry push payload.
+//
+// When the import is successful, a signal is set to trigger a restart of any
+// establishment in progress. This will cause imported server entries to be
+// prioritized as indicated in the payload. The establishment process
+// continues after ImportPushPayload returns.
+//
+// If the client already has a connected tunnel, or a tunnel connection is
+// established concurrently with the import, the signal has no effect as the
+// overall goal is establish _any_ connection.
+func (controller *Controller) ImportPushPayload(payload []byte) bool {
+
+	importer := func(
+		packedServerEntryFields protocol.PackedServerEntryFields,
+		source string,
+		prioritizeDial bool) error {
+
+		err := DSLStoreServerEntry(
+			controller.config.ServerEntrySignaturePublicKey,
+			packedServerEntryFields,
+			protocol.PushServerEntrySource(source),
+			prioritizeDial,
+			controller.config.GetNetworkID())
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		return nil
+	}
+
+	n, err := push.ImportPushPayload(
+		controller.config.PushPayloadObfuscationKey,
+		controller.config.PushPayloadSignaturePublicKey,
+		payload,
+		importer)
+
+	if err != nil {
+		NoticeWarning("push payload: %d imported, %v", n, err)
+	} else {
+		NoticeInfo("push payload: %d imported", n)
+	}
+
+	if n > 0 {
+		select {
+		case controller.signalRestartEstablishing <- struct{}{}:
+		default:
+		}
+	}
+
+	return err == nil
+}
+
 // remoteServerListFetcher fetches an out-of-band list of server entries
 // for more tunnel candidates. It fetches when signalled, with retries
 // on failure.