github.com/astaguna/popon-core@v0.0.0-20231019235610-96e42d76a5ff/psiphon/feedback.go (about)

     1  /*
     2  * Copyright (c) 2016, Psiphon Inc.
     3  * All rights reserved.
     4  *
     5  * This program is free software: you can redistribute it and/or modify
     6  * it under the terms of the GNU General Public License as published by
     7  * the Free Software Foundation, either version 3 of the License, or
     8  * (at your option) any later version.
     9  *
    10  * This program is distributed in the hope that it will be useful,
    11  * but WITHOUT ANY WARRANTY; without even the implied warranty of
    12  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    13  * GNU General Public License for more details.
    14  *
    15  * You should have received a copy of the GNU General Public License
    16  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
    17  *
    18   */
    19  
    20  package psiphon
    21  
    22  import (
    23  	"bytes"
    24  	"context"
    25  	"crypto/aes"
    26  	"crypto/cipher"
    27  	"crypto/hmac"
    28  	"crypto/rand"
    29  	"crypto/rsa"
    30  	"crypto/sha1"
    31  	"crypto/sha256"
    32  	"crypto/x509"
    33  	"encoding/base64"
    34  	"encoding/json"
    35  	"fmt"
    36  	"net"
    37  	"net/http"
    38  	"net/url"
    39  	"path"
    40  	"time"
    41  
    42  	"github.com/astaguna/popon-core/psiphon/common"
    43  	"github.com/astaguna/popon-core/psiphon/common/errors"
    44  	"github.com/astaguna/popon-core/psiphon/common/parameters"
    45  	"github.com/astaguna/popon-core/psiphon/common/prng"
    46  )
    47  
    48  // Conforms to the format expected by the feedback decryptor.
    49  // https://bitbucket.org/psiphon/psiphon-circumvention-system/src/default/EmailResponder/FeedbackDecryptor/decryptor.py
    50  type secureFeedback struct {
    51  	IV                   string `json:"iv"`
    52  	ContentCipherText    string `json:"contentCiphertext"`
    53  	WrappedEncryptionKey string `json:"wrappedEncryptionKey"`
    54  	ContentMac           string `json:"contentMac"`
    55  	WrappedMacKey        string `json:"wrappedMacKey"`
    56  }
    57  
    58  // Encrypt and marshal feedback into secure json structure utilizing the
    59  // Encrypt-then-MAC paradigm (https://tools.ietf.org/html/rfc7366#section-3).
    60  func encryptFeedback(diagnostics, b64EncodedPublicKey string) ([]byte, error) {
    61  	publicKey, err := base64.StdEncoding.DecodeString(b64EncodedPublicKey)
    62  	if err != nil {
    63  		return nil, errors.Trace(err)
    64  	}
    65  
    66  	iv, encryptionKey, diagnosticsCiphertext, err := encryptAESCBC([]byte(diagnostics))
    67  	if err != nil {
    68  		return nil, err
    69  	}
    70  	digest, macKey, err := generateHMAC(iv, diagnosticsCiphertext)
    71  	if err != nil {
    72  		return nil, err
    73  	}
    74  
    75  	wrappedMacKey, err := encryptWithPublicKey(macKey, publicKey)
    76  	if err != nil {
    77  		return nil, err
    78  	}
    79  	wrappedEncryptionKey, err := encryptWithPublicKey(encryptionKey, publicKey)
    80  	if err != nil {
    81  		return nil, err
    82  	}
    83  
    84  	var securedFeedback = secureFeedback{
    85  		IV:                   base64.StdEncoding.EncodeToString(iv),
    86  		ContentCipherText:    base64.StdEncoding.EncodeToString(diagnosticsCiphertext),
    87  		WrappedEncryptionKey: base64.StdEncoding.EncodeToString(wrappedEncryptionKey),
    88  		ContentMac:           base64.StdEncoding.EncodeToString(digest),
    89  		WrappedMacKey:        base64.StdEncoding.EncodeToString(wrappedMacKey),
    90  	}
    91  
    92  	encryptedFeedback, err := json.Marshal(securedFeedback)
    93  	if err != nil {
    94  		return nil, errors.Trace(err)
    95  	}
    96  
    97  	return encryptedFeedback, nil
    98  }
    99  
   100  // Encrypt feedback and upload to server. If upload fails
   101  // the routine will sleep and retry multiple times.
   102  func SendFeedback(ctx context.Context, config *Config, diagnostics, uploadPath string) error {
   103  
   104  	if len(diagnostics) == 0 {
   105  		return errors.TraceNew("error diagnostics empty")
   106  	}
   107  
   108  	// Initialize a resolver to use for dials. useBindToDevice is false so
   109  	// that the feedback upload will be tunneled, indirectly, if it routes
   110  	// through the VPN.
   111  	//
   112  	// config.SetResolver makes this resolver available to MakeDialParameters
   113  	// in GetTactics.
   114  	resolver := NewResolver(config, false)
   115  	defer resolver.Stop()
   116  	config.SetResolver(resolver)
   117  
   118  	// Get tactics, may update client parameters
   119  	p := config.GetParameters().Get()
   120  	timeout := p.Duration(parameters.FeedbackTacticsWaitPeriod)
   121  	p.Close()
   122  	getTacticsCtx, cancelFunc := context.WithTimeout(ctx, timeout)
   123  	defer cancelFunc()
   124  
   125  	// Limitation: GetTactics will fail silently if the datastore used for
   126  	// retrieving and storing tactics is opened by another process. This can
   127  	// be the case on Android and iOS where SendFeedback is invoked by the UI
   128  	// process while tunneling is run by the VPN process.
   129  	//
   130  	// - When the Psiphon VPN is running, GetTactics won't load tactics.
   131  	//   However, tactics may be less critical since feedback will be
   132  	//   tunneled. This outcome also avoids fetching tactics while tunneled,
   133  	//   where otherwise the client GeoIP used for tactics would reflect the
   134  	//   tunnel egress point.
   135  	//
   136  	// - When the Psiphon VPN is not running, this will load tactics, and
   137  	//   potentially fetch tactics, with either the correct, untunneled GeoIP
   138  	//   or a network ID of "VPN" if some other non-Psiphon VPN is running
   139  	//   (the caller should ensure a network ID of "VPN" in this case).
   140  
   141  	GetTactics(getTacticsCtx, config)
   142  
   143  	// Get the latest client parameters
   144  	p = config.GetParameters().Get()
   145  	feedbackUploadMinRetryDelay := p.Duration(parameters.FeedbackUploadRetryMinDelaySeconds)
   146  	feedbackUploadMaxRetryDelay := p.Duration(parameters.FeedbackUploadRetryMaxDelaySeconds)
   147  	feedbackUploadTimeout := p.Duration(parameters.FeedbackUploadTimeoutSeconds)
   148  	feedbackUploadMaxAttempts := p.Int(parameters.FeedbackUploadMaxAttempts)
   149  	transferURLs := p.TransferURLs(parameters.FeedbackUploadURLs)
   150  	p.Close()
   151  
   152  	// Initialize the feedback upload dial configuration. config.DeviceBinder
   153  	// is not applied; see resolver comment above.
   154  	untunneledDialConfig := &DialConfig{
   155  		UpstreamProxyURL: config.UpstreamProxyURL,
   156  		CustomHeaders:    config.CustomHeaders,
   157  		DeviceBinder:     nil,
   158  		IPv6Synthesizer:  config.IPv6Synthesizer,
   159  		ResolveIP: func(ctx context.Context, hostname string) ([]net.IP, error) {
   160  			// Note: when domain fronting would be used for untunneled dials a
   161  			// copy of untunneledDialConfig should be used instead, which
   162  			// redefines ResolveIP such that the corresponding fronting
   163  			// provider ID is passed into UntunneledResolveIP to enable the use
   164  			// of pre-resolved IPs.
   165  			IPs, err := UntunneledResolveIP(
   166  				ctx, config, resolver, hostname, "")
   167  			if err != nil {
   168  				return nil, errors.Trace(err)
   169  			}
   170  			return IPs, nil
   171  		},
   172  		TrustedCACertificatesFilename: config.TrustedCACertificatesFilename,
   173  	}
   174  
   175  	uploadId := prng.HexString(8)
   176  
   177  	for i := 0; i < feedbackUploadMaxAttempts; i++ {
   178  
   179  		uploadURL := transferURLs.Select(i)
   180  		if uploadURL == nil {
   181  			return errors.TraceNew("error no feedback upload URL selected")
   182  		}
   183  
   184  		b64PublicKey := uploadURL.B64EncodedPublicKey
   185  		if b64PublicKey == "" {
   186  			if config.FeedbackEncryptionPublicKey == "" {
   187  				return errors.TraceNew("error no default encryption key")
   188  			}
   189  			b64PublicKey = config.FeedbackEncryptionPublicKey
   190  		}
   191  
   192  		secureFeedback, err := encryptFeedback(diagnostics, b64PublicKey)
   193  		if err != nil {
   194  			return errors.Trace(err)
   195  		}
   196  
   197  		feedbackUploadCtx, cancelFunc := context.WithTimeout(
   198  			ctx,
   199  			feedbackUploadTimeout)
   200  		defer cancelFunc()
   201  
   202  		client, _, err := MakeUntunneledHTTPClient(
   203  			feedbackUploadCtx,
   204  			config,
   205  			untunneledDialConfig,
   206  			uploadURL.SkipVerify,
   207  			config.DisableSystemRootCAs,
   208  			uploadURL.FrontingSpecs)
   209  		if err != nil {
   210  			return errors.Trace(err)
   211  		}
   212  
   213  		parsedURL, err := url.Parse(uploadURL.URL)
   214  		if err != nil {
   215  			return errors.TraceMsg(err, "failed to parse feedback upload URL")
   216  		}
   217  
   218  		parsedURL.Path = path.Join(parsedURL.Path, uploadPath, uploadId)
   219  
   220  		request, err := http.NewRequestWithContext(feedbackUploadCtx, "PUT", parsedURL.String(), bytes.NewBuffer(secureFeedback))
   221  		if err != nil {
   222  			return errors.Trace(err)
   223  		}
   224  
   225  		for k, v := range uploadURL.RequestHeaders {
   226  			request.Header.Set(k, v)
   227  		}
   228  		request.Header.Set("User-Agent", MakePsiphonUserAgent(config))
   229  
   230  		err = uploadFeedback(client, request)
   231  		cancelFunc()
   232  		if err != nil {
   233  			if ctx.Err() != nil {
   234  				// Input context has completed
   235  				return errors.TraceMsg(err,
   236  					fmt.Sprintf("feedback upload attempt %d/%d cancelled", i+1, feedbackUploadMaxAttempts))
   237  			}
   238  			// Do not sleep after the last attempt
   239  			if i+1 < feedbackUploadMaxAttempts {
   240  				// Log error, sleep and then retry
   241  				timeUntilRetry := prng.Period(feedbackUploadMinRetryDelay, feedbackUploadMaxRetryDelay)
   242  				NoticeWarning(
   243  					"feedback upload attempt %d/%d failed (retry in %.0fs): %s",
   244  					i+1, feedbackUploadMaxAttempts, timeUntilRetry.Seconds(), errors.Trace(err))
   245  				select {
   246  				case <-ctx.Done():
   247  					return errors.TraceNew(
   248  						fmt.Sprintf("feedback upload attempt %d/%d cancelled before attempt",
   249  							i+2, feedbackUploadMaxAttempts))
   250  				case <-time.After(timeUntilRetry):
   251  				}
   252  				continue
   253  			}
   254  			return errors.TraceMsg(err,
   255  				fmt.Sprintf("feedback upload failed after %d attempts", i+1))
   256  		}
   257  		return nil
   258  	}
   259  
   260  	return nil
   261  }
   262  
   263  // Attempt to upload feedback data to server.
   264  func uploadFeedback(
   265  	client *http.Client, req *http.Request) error {
   266  
   267  	resp, err := client.Do(req)
   268  	if err != nil {
   269  		return errors.Trace(err)
   270  	}
   271  	defer resp.Body.Close()
   272  
   273  	if resp.StatusCode != http.StatusOK {
   274  		return errors.TraceNew("unexpected HTTP status: " + resp.Status)
   275  	}
   276  
   277  	return nil
   278  }
   279  
   280  // Pad src to the next block boundary with PKCS7 padding
   281  // (https://tools.ietf.org/html/rfc5652#section-6.3).
   282  func addPKCS7Padding(src []byte, blockSize int) []byte {
   283  	paddingLen := blockSize - (len(src) % blockSize)
   284  	padding := bytes.Repeat([]byte{byte(paddingLen)}, paddingLen)
   285  	return append(src, padding...)
   286  }
   287  
   288  // Encrypt plaintext with AES in CBC mode.
   289  func encryptAESCBC(plaintext []byte) ([]byte, []byte, []byte, error) {
   290  	// CBC mode works on blocks so plaintexts need to be padded to the
   291  	// next whole block (https://tools.ietf.org/html/rfc5246#section-6.2.3.2).
   292  	plaintext = addPKCS7Padding(plaintext, aes.BlockSize)
   293  
   294  	ciphertext := make([]byte, len(plaintext))
   295  	iv, err := common.MakeSecureRandomBytes(aes.BlockSize)
   296  	if err != nil {
   297  		return nil, nil, nil, err
   298  	}
   299  
   300  	key, err := common.MakeSecureRandomBytes(aes.BlockSize)
   301  	if err != nil {
   302  		return nil, nil, nil, errors.Trace(err)
   303  	}
   304  
   305  	block, err := aes.NewCipher(key)
   306  	if err != nil {
   307  		return nil, nil, nil, errors.Trace(err)
   308  	}
   309  
   310  	mode := cipher.NewCBCEncrypter(block, iv)
   311  	mode.CryptBlocks(ciphertext, plaintext)
   312  
   313  	return iv, key, ciphertext, nil
   314  }
   315  
   316  // Encrypt plaintext with RSA public key.
   317  func encryptWithPublicKey(plaintext, publicKey []byte) ([]byte, error) {
   318  	parsedKey, err := x509.ParsePKIXPublicKey(publicKey)
   319  	if err != nil {
   320  		return nil, errors.Trace(err)
   321  	}
   322  	if rsaPubKey, ok := parsedKey.(*rsa.PublicKey); ok {
   323  		rsaEncryptOutput, err := rsa.EncryptOAEP(sha1.New(), rand.Reader, rsaPubKey, plaintext, nil)
   324  		if err != nil {
   325  			return nil, errors.Trace(err)
   326  		}
   327  		return rsaEncryptOutput, nil
   328  	}
   329  	return nil, errors.TraceNew("feedback key is not an RSA public key")
   330  }
   331  
   332  // Generate HMAC for Encrypt-then-MAC paradigm.
   333  func generateHMAC(iv, plaintext []byte) ([]byte, []byte, error) {
   334  	key, err := common.MakeSecureRandomBytes(16)
   335  	if err != nil {
   336  		return nil, nil, err
   337  	}
   338  
   339  	mac := hmac.New(sha256.New, key)
   340  
   341  	mac.Write(iv)
   342  	mac.Write(plaintext)
   343  
   344  	digest := mac.Sum(nil)
   345  
   346  	return digest, key, nil
   347  }