Toggleable transparent interception via mitmdump in a venv. Own RSA CA generated in-app, downloadable (public cert only, key never leaves the box), with per-OS install guide and fingerprint. nftables redirect accepts only dnat'd connections (never an open proxy). HTTP Explorer with headers and opt-in body viewers (JSON/ text/hex/image, sandboxed). Honest status: pinned/untrusting = reported errors, excluded hosts pass through, bodies off by default. Helper op + systemd unit + installer venv build + boot restore.
166 lines
5.4 KiB
Go
166 lines
5.4 KiB
Go
// Package proxy manages optional, authorised HTTPS interception: a locally
|
|
// generated CA that the operator installs on test devices by hand, a mitmdump
|
|
// service, and ingestion of the decrypted HTTP transactions.
|
|
//
|
|
// Consent is the whole design. The CA private key never leaves the appliance,
|
|
// is never pushed to a device, and interception only happens for devices whose
|
|
// owner has explicitly installed and trusted the CA. Nothing is covert.
|
|
package proxy
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/hex"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// CAInfo describes the interception CA for display. It never includes the key.
|
|
type CAInfo struct {
|
|
Present bool `json:"present"`
|
|
Subject string `json:"subject"`
|
|
SHA256 string `json:"sha256"` // fingerprint, colon-separated
|
|
NotBefore string `json:"not_before"`
|
|
NotAfter string `json:"not_after"`
|
|
Serial string `json:"serial"`
|
|
DaysLeft int `json:"days_left"`
|
|
KeyType string `json:"key_type"`
|
|
}
|
|
|
|
// caPaths returns the key+cert path (mitmproxy format) and the public cert path.
|
|
func caPaths(dir string) (combined, certOnly string) {
|
|
return filepath.Join(dir, "mitmproxy-ca.pem"), filepath.Join(dir, "mitmproxy-ca-cert.pem")
|
|
}
|
|
|
|
// EnsureCA creates the interception CA if absent, in the mitmproxy confdir layout.
|
|
// RSA-2048 is used (not EC) for the broadest device trust-store compatibility.
|
|
func EnsureCA(dir, hostname string) error {
|
|
combined, certOnly := caPaths(dir)
|
|
if _, err := os.Stat(combined); err == nil {
|
|
return nil
|
|
}
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return err
|
|
}
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
|
cn := "PiLens Interception CA"
|
|
if hostname != "" {
|
|
cn = "PiLens Interception CA (" + hostname + ")"
|
|
}
|
|
tpl := &x509.Certificate{
|
|
SerialNumber: serial,
|
|
Subject: pkix.Name{CommonName: cn, Organization: []string{"PiLens appliance"}},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().AddDate(3, 0, 0),
|
|
IsCA: true,
|
|
BasicConstraintsValid: true,
|
|
MaxPathLen: 0,
|
|
MaxPathLenZero: true,
|
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature,
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
|
// mitmproxy reads the key+cert from mitmproxy-ca.pem (0600) and derives the rest.
|
|
if err := writeFile(combined, append(append([]byte{}, keyPEM...), certPEM...), 0o600); err != nil {
|
|
return err
|
|
}
|
|
// Public cert for download / device install (never contains the key).
|
|
if err := writeFile(certOnly, certPEM, 0o644); err != nil {
|
|
return err
|
|
}
|
|
writeFile(filepath.Join(dir, "mitmproxy-ca-cert.cer"), certPEM, 0o644)
|
|
return nil
|
|
}
|
|
|
|
func writeFile(path string, data []byte, mode os.FileMode) error {
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, mode); err != nil {
|
|
return err
|
|
}
|
|
os.Chmod(tmp, mode)
|
|
return os.Rename(tmp, path)
|
|
}
|
|
|
|
// PublicCertPEM returns the CA's public certificate (safe to distribute). It never
|
|
// returns the private key.
|
|
func PublicCertPEM(dir string) ([]byte, error) {
|
|
_, certOnly := caPaths(dir)
|
|
b, err := os.ReadFile(certOnly)
|
|
if err != nil {
|
|
return nil, errors.New("interception CA not generated yet")
|
|
}
|
|
if strings.Contains(string(b), "PRIVATE KEY") { // defensive: must never happen
|
|
return nil, errors.New("refusing to serve a file containing a private key")
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// Info reads the CA metadata for display.
|
|
func Info(dir string) CAInfo {
|
|
_, certOnly := caPaths(dir)
|
|
b, err := os.ReadFile(certOnly)
|
|
if err != nil {
|
|
return CAInfo{Present: false}
|
|
}
|
|
blk, _ := pem.Decode(b)
|
|
if blk == nil {
|
|
return CAInfo{Present: false}
|
|
}
|
|
c, err := x509.ParseCertificate(blk.Bytes)
|
|
if err != nil {
|
|
return CAInfo{Present: false}
|
|
}
|
|
sum := sha256.Sum256(c.Raw)
|
|
return CAInfo{
|
|
Present: true,
|
|
Subject: c.Subject.String(),
|
|
SHA256: colonHex(sum[:]),
|
|
NotBefore: c.NotBefore.UTC().Format("2006-01-02"),
|
|
NotAfter: c.NotAfter.UTC().Format("2006-01-02"),
|
|
Serial: c.SerialNumber.Text(16),
|
|
DaysLeft: int(time.Until(c.NotAfter).Hours() / 24),
|
|
KeyType: "RSA 2048",
|
|
}
|
|
}
|
|
|
|
// Rotate deletes the CA so a fresh one is generated. Devices trusting the old CA
|
|
// must install the new one; existing interception breaks until they do.
|
|
func Rotate(dir, hostname string) error {
|
|
combined, certOnly := caPaths(dir)
|
|
for _, p := range []string{combined, certOnly, filepath.Join(dir, "mitmproxy-ca-cert.cer"), filepath.Join(dir, "mitmproxy-ca.p12"), filepath.Join(dir, "mitmproxy-ca-cert.p12"), filepath.Join(dir, "mitmproxy-dhparam.pem")} {
|
|
os.Remove(p)
|
|
}
|
|
return EnsureCA(dir, hostname)
|
|
}
|
|
|
|
func colonHex(b []byte) string {
|
|
h := strings.ToUpper(hex.EncodeToString(b))
|
|
var parts []string
|
|
for i := 0; i < len(h); i += 2 {
|
|
parts = append(parts, h[i:i+2])
|
|
}
|
|
return strings.Join(parts, ":")
|
|
}
|
|
|
|
// Fingerprint of a cert file, for the install instructions ("verify this matches").
|
|
func Fingerprint(dir string) string {
|
|
return fmt.Sprintf("SHA-256 %s", Info(dir).SHA256)
|
|
}
|