mirror of
https://github.com/ProtonMail/proton-bridge.git
synced 2025-12-10 12:46:46 +00:00
375 lines
12 KiB
Go
375 lines
12 KiB
Go
// Copyright (c) 2020 Proton Technologies AG
|
|
//
|
|
// This file is part of ProtonMail Bridge.
|
|
//
|
|
// ProtonMail Bridge 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.
|
|
//
|
|
// ProtonMail Bridge 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package pmapi
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/sirupsen/logrus"
|
|
)
|
|
|
|
// TLSReport is inspired by https://tools.ietf.org/html/rfc7469#section-3.
|
|
type TLSReport struct {
|
|
// DateTime of observed pin validation in time.RFC3339 format.
|
|
DateTime string `json:"date-time"`
|
|
|
|
// Hostname to which the UA made original request that failed pin validation.
|
|
Hostname string `json:"hostname"`
|
|
|
|
// Port to which the UA made original request that failed pin validation.
|
|
Port int `json:"port"`
|
|
|
|
// EffectiveExpirationDate for noted pins in time.RFC3339 format.
|
|
EffectiveExpirationDate string `json:"effective-expiration-date"`
|
|
|
|
// IncludeSubdomains indicates whether or not the UA has noted the
|
|
// includeSubDomains directive for the Known Pinned Host.
|
|
IncludeSubdomains bool `json:"include-subdomains"`
|
|
|
|
// NotedHostname indicates the hostname that the UA noted when it noted
|
|
// the Known Pinned Host. This field allows operators to understand why
|
|
// Pin Validation was performed for, e.g., foo.example.com when the
|
|
// noted Known Pinned Host was example.com with includeSubDomains set.
|
|
NotedHostname string `json:"noted-hostname"`
|
|
|
|
// ServedCertificateChain is the certificate chain, as served by
|
|
// the Known Pinned Host during TLS session setup. It is provided as an
|
|
// array of strings; each string pem1, ... pemN is the Privacy-Enhanced
|
|
// Mail (PEM) representation of each X.509 certificate as described in
|
|
// [RFC7468].
|
|
ServedCertificateChain []string `json:"served-certificate-chain"`
|
|
|
|
// ValidatedCertificateChain is the certificate chain, as
|
|
// constructed by the UA during certificate chain verification. (This
|
|
// may differ from the served-certificate-chain.) It is provided as an
|
|
// array of strings; each string pem1, ... pemN is the PEM
|
|
// representation of each X.509 certificate as described in [RFC7468].
|
|
// UAs that build certificate chains in more than one way during the
|
|
// validation process SHOULD send the last chain built. In this way,
|
|
// they can avoid keeping too much state during the validation process.
|
|
ValidatedCertificateChain []string `json:"validated-certificate-chain"`
|
|
|
|
// The known-pins are the Pins that the UA has noted for the Known
|
|
// Pinned Host. They are provided as an array of strings with the
|
|
// syntax: known-pin = token "=" quoted-string
|
|
// e.g.:
|
|
// ```
|
|
// "known-pins": [
|
|
// 'pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM="',
|
|
// "pin-sha256=\"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=\""
|
|
// ]
|
|
// ```
|
|
KnownPins []string `json:"known-pins"`
|
|
|
|
// AppVersion is used to set `x-pm-appversion` json format from datatheorem/TrustKit.
|
|
AppVersion string `json:"app-version"`
|
|
}
|
|
|
|
// ErrTLSMatch indicates that no TLS fingerprint match could be found.
|
|
var ErrTLSMatch = fmt.Errorf("TLS fingerprint match not found")
|
|
|
|
// DialerWithPinning will provide dial function which checks the fingerprints of public cert
|
|
// received from contacted server. If no match found among know pinse it will report using
|
|
// ReportCertIssueLocal.
|
|
type DialerWithPinning struct {
|
|
// isReported will stop reporting if true.
|
|
isReported bool
|
|
|
|
// report stores known pins.
|
|
report TLSReport
|
|
|
|
// When reportURI is not empty the tls issue report will be send to this URI.
|
|
reportURI string
|
|
|
|
// ReportCertIssueLocal is used send signal to application about certificate issue.
|
|
// It is used only if set.
|
|
ReportCertIssueLocal func()
|
|
|
|
// cm is used to find and switch to a proxy if necessary.
|
|
cm *ClientManager
|
|
|
|
// A logger for logging messages.
|
|
log logrus.FieldLogger
|
|
}
|
|
|
|
// NewDialerWithPinning constructs a new dialer with pinned certs.
|
|
func NewDialerWithPinning(cm *ClientManager, appVersion string) *DialerWithPinning {
|
|
reportURI := "https://reports.protonmail.ch/reports/tls"
|
|
|
|
report := TLSReport{
|
|
EffectiveExpirationDate: time.Now().Add(365 * 24 * 60 * 60 * time.Second).Format(time.RFC3339),
|
|
IncludeSubdomains: false,
|
|
ValidatedCertificateChain: []string{},
|
|
ServedCertificateChain: []string{},
|
|
AppVersion: appVersion,
|
|
|
|
// NOTE: the proxy pins are the same for all proxy servers, guaranteed by infra team ;)
|
|
KnownPins: []string{
|
|
`pin-sha256="drtmcR2kFkM8qJClsuWgUzxgBkePfRCkRpqUesyDmeE="`, // current
|
|
`pin-sha256="YRGlaY0jyJ4Jw2/4M8FIftwbDIQfh8Sdro96CeEel54="`, // hot
|
|
`pin-sha256="AfMENBVvOS8MnISprtvyPsjKlPooqh8nMB/pvCrpJpw="`, // cold
|
|
`pin-sha256="EU6TS9MO0L/GsDHvVc9D5fChYLNy5JdGYpJw0ccgetM="`, // proxy main
|
|
`pin-sha256="iKPIHPnDNqdkvOnTClQ8zQAIKG0XavaPkcEo0LBAABA="`, // proxy backup 1
|
|
`pin-sha256="MSlVrBCdL0hKyczvgYVSRNm88RicyY04Q2y5qrBt0xA="`, // proxy backup 2
|
|
`pin-sha256="C2UxW0T1Ckl9s+8cXfjXxlEqwAfPM4HiW2y3UdtBeCw="`, // proxy backup 3
|
|
},
|
|
}
|
|
|
|
log := logrus.WithField("pkg", "pmapi/tls-pinning")
|
|
|
|
return &DialerWithPinning{
|
|
cm: cm,
|
|
isReported: false,
|
|
reportURI: reportURI,
|
|
report: report,
|
|
log: log,
|
|
}
|
|
}
|
|
|
|
func (p *DialerWithPinning) reportCertIssue(connState tls.ConnectionState) {
|
|
p.isReported = true
|
|
|
|
if p.ReportCertIssueLocal != nil {
|
|
go p.ReportCertIssueLocal()
|
|
}
|
|
|
|
if p.reportURI != "" {
|
|
p.report.NotedHostname = connState.ServerName
|
|
p.report.ServedCertificateChain = marshalCert7468(connState.PeerCertificates)
|
|
|
|
if len(connState.VerifiedChains) > 0 {
|
|
p.report.ServedCertificateChain = marshalCert7468(
|
|
connState.VerifiedChains[len(connState.VerifiedChains)-1],
|
|
)
|
|
}
|
|
|
|
go p.reportCertIssueRemote()
|
|
}
|
|
}
|
|
|
|
func (p *DialerWithPinning) reportCertIssueRemote() {
|
|
b, err := json.Marshal(p.report)
|
|
if err != nil {
|
|
p.log.Errorf("marshal request: %v", err)
|
|
return
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", p.reportURI, bytes.NewReader(b))
|
|
if err != nil {
|
|
p.log.Errorf("create request: %v", err)
|
|
}
|
|
|
|
req.Header.Add("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", CurrentUserAgent)
|
|
req.Header.Set("x-pm-apiversion", strconv.Itoa(Version))
|
|
req.Header.Set("x-pm-appversion", p.report.AppVersion)
|
|
|
|
p.log.Debugf("report req: %+v\n", req)
|
|
|
|
c := &http.Client{}
|
|
res, err := c.Do(req)
|
|
p.log.Debugf("res: %+v\nerr: %v", res, err)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_, _ = ioutil.ReadAll(res.Body)
|
|
if res.StatusCode != http.StatusOK {
|
|
p.log.Errorf("response status: %v", res.Status)
|
|
}
|
|
_ = res.Body.Close()
|
|
}
|
|
|
|
func certFingerprint(cert *x509.Certificate) string {
|
|
hash := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
|
|
return fmt.Sprintf(`pin-sha256=%q`, base64.StdEncoding.EncodeToString(hash[:]))
|
|
}
|
|
|
|
func marshalCert7468(certs []*x509.Certificate) (pemCerts []string) {
|
|
var buffer bytes.Buffer
|
|
for _, cert := range certs {
|
|
if err := pem.Encode(&buffer, &pem.Block{
|
|
Type: "CERTIFICATE",
|
|
Bytes: cert.Raw,
|
|
}); err != nil {
|
|
logrus.WithField("pkg", "pmapi/tls-pinning").Errorf("encoding TLS cert: %v", err)
|
|
}
|
|
pemCerts = append(pemCerts, buffer.String())
|
|
buffer.Reset()
|
|
}
|
|
|
|
return pemCerts
|
|
}
|
|
|
|
// TransportWithPinning creates an http.Transport that checks fingerprints when dialing.
|
|
func (p *DialerWithPinning) TransportWithPinning() *http.Transport {
|
|
return &http.Transport{
|
|
Proxy: http.ProxyFromEnvironment,
|
|
DialTLS: p.dialAndCheckFingerprints,
|
|
MaxIdleConns: 100,
|
|
IdleConnTimeout: 5 * time.Minute,
|
|
ExpectContinueTimeout: 500 * time.Millisecond,
|
|
|
|
// GODT-126: this was initially 10s but logs from users showed a significant number
|
|
// were hitting this timeout, possibly due to flaky wifi taking >10s to reconnect.
|
|
// Bumping to 30s for now to avoid this problem.
|
|
ResponseHeaderTimeout: 30 * time.Second,
|
|
|
|
// If we allow up to 30 seconds for response headers, it is reasonable to allow up
|
|
// to 30 seconds for the TLS handshake to take place.
|
|
TLSHandshakeTimeout: 30 * time.Second,
|
|
}
|
|
}
|
|
|
|
// dialAndCheckFingerprint to set as http.Transport.DialTLS.
|
|
//
|
|
// * note that when DialTLS is not nil the Transport.TLSClientConfig and Transport.TLSHandshakeTimeout are ignored.
|
|
// * dialAndCheckFingerprints fails if certificate is not valid (not signed by authority or not matching hostname).
|
|
// * dialAndCheckFingerprints will pass if certificate pin does not have a match, but will send notification using
|
|
// p.ReportCertIssueLocal() and p.reportCertIssueRemote() if they are not nil.
|
|
func (p *DialerWithPinning) dialAndCheckFingerprints(network, address string) (conn net.Conn, err error) {
|
|
// If DoH is enabled, we hardfail on fingerprint mismatches.
|
|
if p.cm.IsProxyAllowed() && p.isReported {
|
|
return nil, ErrTLSMatch
|
|
}
|
|
|
|
// Try to dial the given address but use a proxy if necessary.
|
|
if conn, err = p.dialWithProxyFallback(network, address); err != nil {
|
|
return
|
|
}
|
|
|
|
// If cert issue was already reported, we don't want to check fingerprints anymore.
|
|
if p.isReported {
|
|
return nil, ErrTLSMatch
|
|
}
|
|
|
|
// Check the cert fingerprint to ensure it is known.
|
|
if err = p.checkFingerprints(conn); err != nil {
|
|
p.log.WithError(err).Error("Error checking cert fingerprints")
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// dialWithProxyFallback tries to dial the given address but falls back to alternative proxies if need be.
|
|
func (p *DialerWithPinning) dialWithProxyFallback(network, address string) (conn net.Conn, err error) {
|
|
p.log.Info("Dialing with proxy fallback")
|
|
|
|
// Try to dial, and if it succeeds, then just return.
|
|
if conn, err = p.dial(network, address); err == nil {
|
|
return
|
|
}
|
|
|
|
p.log.WithField("address", address).WithError(err).Error("Dialing failed")
|
|
|
|
host, port, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// If DoH is not allowed, give up. Or, if we are dialing something other than the API
|
|
// (e.g. we dial protonmail.com/... to check for updates), there's also no point in
|
|
// continuing since a proxy won't help us reach that.
|
|
if !p.cm.IsProxyAllowed() || host != p.cm.getHost() {
|
|
p.log.WithField("address", address).Debug("Aborting dial, cannot switch to a proxy")
|
|
return
|
|
}
|
|
|
|
// Switch to a proxy and retry the dial.
|
|
proxy, err := p.cm.switchToReachableServer()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
proxyAddress := net.JoinHostPort(proxy, port)
|
|
|
|
p.log.WithField("address", proxyAddress).Debug("Trying dial again using a proxy")
|
|
|
|
return p.dial(network, proxyAddress)
|
|
}
|
|
|
|
// dial returns a connection to the given address using the given network.
|
|
func (p *DialerWithPinning) dial(network, address string) (conn net.Conn, err error) {
|
|
var port string
|
|
if p.report.Hostname, port, err = net.SplitHostPort(address); err != nil {
|
|
return
|
|
}
|
|
if p.report.Port, err = strconv.Atoi(port); err != nil {
|
|
return
|
|
}
|
|
p.report.DateTime = time.Now().Format(time.RFC3339)
|
|
|
|
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
|
|
|
// If we are not dialing the standard API then we should skip cert verification checks.
|
|
var tlsConfig *tls.Config = nil
|
|
if address != RootURL {
|
|
tlsConfig = &tls.Config{InsecureSkipVerify: true} // nolint[gosec]
|
|
}
|
|
|
|
return tls.DialWithDialer(dialer, network, address, tlsConfig)
|
|
}
|
|
|
|
func (p *DialerWithPinning) checkFingerprints(conn net.Conn) (err error) {
|
|
if !checkTLSCerts {
|
|
return
|
|
}
|
|
|
|
connState := conn.(*tls.Conn).ConnectionState()
|
|
|
|
hasFingerprintMatch := false
|
|
for _, peerCert := range connState.PeerCertificates {
|
|
fingerprint := certFingerprint(peerCert)
|
|
|
|
for i, pin := range p.report.KnownPins {
|
|
if pin == fingerprint {
|
|
hasFingerprintMatch = true
|
|
|
|
if i != 0 {
|
|
p.log.Warnf("Matched fingerprint (%q) was not primary pinned key (was key #%d)", fingerprint, i)
|
|
}
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if hasFingerprintMatch {
|
|
break
|
|
}
|
|
}
|
|
|
|
if !hasFingerprintMatch {
|
|
p.reportCertIssue(connState)
|
|
return ErrTLSMatch
|
|
}
|
|
|
|
return err
|
|
}
|