From 23c8ab359c2108654d57176e233d2c099b398f31 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:57:00 +0200 Subject: Client (mutual-TLS) certificates Adds internal/clientcert: a cert/key pair matched to hosts by the same substring-or-regex pattern model as scope.Rule, so mitmux can present a client certificate on an upstream TLS handshake that requires one - the previous behavior was a hard handshake failure with no way to authenticate. Wired into both places mitmux dials an https:// upstream over its own TLS client connection: proxy.go's handleConnect (live proxied traffic) and repeat.go's dialForRepeat (Repeater/Intruder resends), both through a new Server.clientCertFor(host) helper. Stored in a new client_certs table, mirroring the existing scope_rules persistence pattern. The TUI (`t` from history) is add-only like scope, for the same reason: delete and re-add covers changing anything, and it's a rarely-touched, low-cardinality list. The add form takes cert/key file paths and reads them once at save time - PEM content, not the path, is what's stored and later presented, so a cert keeps working even if the original file moves afterward. Verified live against a real mutual-TLS-requiring origin server: without a matching cert the handshake correctly fails; with one configured, the origin receives it and the request succeeds; toggling it off reproduces the failure, confirming the enable/disable path works end to end. --- PLAN.md | 21 +- README.md | 30 ++- cmd/mitmux/main.go | 383 ++++++++++++++++++++++++++++++--- cmd/mitmux/mouse.go | 29 +++ internal/clientcert/clientcert.go | 62 ++++++ internal/clientcert/clientcert_test.go | 111 ++++++++++ internal/ipc/ipc.go | 75 ++++++- internal/ipc/server.go | 47 ++++ internal/proxy/proxy.go | 43 +++- internal/proxy/repeat.go | 15 +- internal/store/store.go | 64 ++++++ 11 files changed, 830 insertions(+), 50 deletions(-) create mode 100644 internal/clientcert/clientcert.go create mode 100644 internal/clientcert/clientcert_test.go diff --git a/PLAN.md b/PLAN.md index 87c01cf..16a8599 100644 --- a/PLAN.md +++ b/PLAN.md @@ -384,8 +384,25 @@ This closes every item from the expanded "worth considering" list. Skipped deliberately (from the research, matches this tool's stated scope): active/passive vulnerability scanning, plugin marketplace, -Collaborator/OAST, team collaboration, CI integration, client TLS -(mutual-TLS) certs, invisible/non-proxy-aware proxying. +Collaborator/OAST, team collaboration, CI integration, +invisible/non-proxy-aware proxying. Client (mutual-TLS) certificates +were later added - see below. + +## Client (mutual-TLS) certificates + +internal/clientcert stores cert/key pairs matched to hosts by the same +substring-or-regex pattern model as scope.Rule (internal/scope) - one +consistent mental model across every "which rule applies to this +host" decision in the tool. A match is looked up in proxy.go's +handleConnect (live proxied HTTPS) and repeat.go's dialForRepeat +(Repeater/Intruder resends), both funneling through +Server.clientCertFor, and passed into the outbound tls.Config's +Certificates field when non-nil. Deliberately add-only in the TUI, no +edit-in-place, same reasoning as scope: delete and re-add covers +changing anything, and it's a rarely-touched, low-cardinality list. +The TUI form takes file paths and reads them once at save time - the +PEM content itself, not the path, is what's stored, so a cert keeps +working even if the original file later moves. ## Licensing, packaging, and browser/mobile support diff --git a/README.md b/README.md index 4d424e6..b1237f6 100644 --- a/README.md +++ b/README.md @@ -49,11 +49,14 @@ list of what's deliberately not implemented (and why), see sent) and grep-match/grep-extract (flag or pull text out of each result's response with a regexp) are both configurable before starting an attack - see [Intruder](#intruder) below. -- **Match-and-replace**: header rewrite rules (add, remove, or modify) - for requests and/or responses, applied live as traffic passes - through. History still shows what was actually sent/received on each - side - match-and-replace transforms the wire, it doesn't rewrite the - audit trail. +- **Match-and-replace**: header or body rewrite rules (add, remove, or + modify) for requests and/or responses, applied live as traffic + passes through. History still shows what was actually sent/received + on each side - match-and-replace transforms the wire, it doesn't + rewrite the audit trail. +- **Client certificates**: configure a mutual-TLS cert/key per host + pattern, presented automatically on matching handshakes - for + proxied traffic and Repeater/Intruder resends alike. - **Flagging**: mark an entry to revisit later (★), filterable via `flagged:true`. - **Comparer**: mark one entry (`c`), then `c` on a different entry to @@ -442,6 +445,22 @@ Repeater and Intruder always record regardless of scope - a request you deliberately resend or fuzz is something you clearly want to see the result of, not noise scope exists to cut. +### Client (mutual-TLS) certificates + +Press `t` from the history view to manage which client certificate +mitmux presents when an upstream server's TLS handshake requests one +- a target requiring mutual TLS otherwise fails the handshake before +any request/response ever happens. `a` adds one: a name, a host +pattern (same substring-or-regex model as scope and match-and-replace +rules), and paths to a PEM certificate file and its matching PEM +private key. The files are read once, at save time, and their content +- not the paths - is what's stored and later presented, so a cert +keeps working even if the original file moves or is deleted +afterward. `space` toggles one on/off, `d` deletes it. Applies to +proxied HTTPS traffic and to Repeater/Intruder resends against +`https://` targets alike; a host matching no configured certificate +just handshakes without one, same as if this feature didn't exist. + ### Mouse This is a real terminal application (any terminal, not just tmux - the @@ -538,7 +557,6 @@ reasoning behind each: - `mitmuxd -install-ca` prints per-OS trust-store install steps; it never runs them for you (see Quick start above for why) - No WebSocket interception -- No client (mutual-TLS) certificate support - Upstream proxy chaining (`-upstream-proxy`) is HTTP CONNECT only, no SOCKS5 - No active or passive vulnerability scanning, no plugin system - this diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index 304d3fc..f529681 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -19,6 +19,7 @@ import ( "github.com/charmbracelet/lipgloss" "mitmux/internal/ca" + "mitmux/internal/clientcert" "mitmux/internal/ipc" "mitmux/internal/proxy" "mitmux/internal/rules" @@ -97,6 +98,7 @@ const ( viewCompare viewDecoder viewScope + viewClientCerts viewHelp ) @@ -139,6 +141,16 @@ const ( fieldRegex ) +type clientCertField int + +const ( + ccFieldName clientCertField = iota + ccFieldPattern + ccFieldCertPath + ccFieldKeyPath + ccFieldRegex +) + type intruderFocus int const ( @@ -193,6 +205,20 @@ type model struct { scopePattern textinput.Model scopeIsRegex bool + // Client (mutual-TLS) certificates: which cert/key pair mitmux + // presents to an upstream server that requires one, matched by host + // pattern the same way scope rules are. Add-only like scope, for the + // same reason - delete and re-add covers changing anything about it. + clientCertTable table.Model + clientCertRows []clientcert.Cert + clientCertForm bool + clientCertField clientCertField + clientCertName textinput.Model + clientCertPattern textinput.Model + clientCertCertPath textinput.Model + clientCertKeyPath textinput.Model + clientCertIsRegex bool + intruderScheme string intruderHost string intruderTemplate viTextarea @@ -328,6 +354,24 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) scopePatternIn := textinput.New() scopePatternIn.Placeholder = "host substring, or a regex - e.g. example.com" + clientCertCols := []table.Column{ + {Title: "On", Width: 3}, + {Title: "Name", Width: 16}, + {Title: "Pattern", Width: 30}, + {Title: "Regex", Width: 5}, + } + clientCertTbl := table.New(table.WithColumns(clientCertCols), table.WithFocused(true)) + clientCertTbl.SetStyles(st) + + ccNameIn := textinput.New() + ccNameIn.Placeholder = "certificate name" + ccPatternIn := textinput.New() + ccPatternIn.Placeholder = "host substring, or a regex - e.g. internal.example.com" + ccCertPathIn := textinput.New() + ccCertPathIn.Placeholder = "path to PEM certificate file" + ccKeyPathIn := textinput.New() + ccKeyPathIn.Placeholder = "path to PEM private key file" + itmpl := newViTextarea() itmpl.ta.Placeholder = "raw request bytes - wrap positions to fuzz in § markers, e.g. /users/§123§" itmpl.ta.ShowLineNumbers = false @@ -365,29 +409,34 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) impIn.Placeholder = "HAR file path to read" return &model{ - client: client, - subCh: subCh, - socketPath: socketPath, - mode: viewList, - table: t, - repeaterIndex: -1, - searchInput: si, - rulesTable: rt, - scopeTable: scopeTbl, - scopePattern: scopePatternIn, - ruleName: nameIn, - ruleMatch: matchIn, - ruleReplace: replaceIn, - ruleScope: "request", - rulePart: "header", - intruderTemplate: itmpl, - intruderPayloads: ipayloads, - intruderResults: iresults, - grepMatchInput: gmIn, - grepExtractInput: geIn, - decoderInput: din, - exportInput: expIn, - importInput: impIn, + client: client, + subCh: subCh, + socketPath: socketPath, + mode: viewList, + table: t, + repeaterIndex: -1, + searchInput: si, + rulesTable: rt, + scopeTable: scopeTbl, + scopePattern: scopePatternIn, + clientCertTable: clientCertTbl, + clientCertName: ccNameIn, + clientCertPattern: ccPatternIn, + clientCertCertPath: ccCertPathIn, + clientCertKeyPath: ccKeyPathIn, + ruleName: nameIn, + ruleMatch: matchIn, + ruleReplace: replaceIn, + ruleScope: "request", + rulePart: "header", + intruderTemplate: itmpl, + intruderPayloads: ipayloads, + intruderResults: iresults, + grepMatchInput: gmIn, + grepExtractInput: geIn, + decoderInput: din, + exportInput: expIn, + importInput: impIn, } } @@ -959,6 +1008,114 @@ func (m *model) scopeRuleFromForm() scope.Rule { return scope.Rule{Enabled: true, Pattern: m.scopePattern.Value(), IsRegex: m.scopeIsRegex} } +type clientCertsLoadedMsg struct { + certs []clientcert.Cert + err error +} + +type clientCertWriteDoneMsg struct { + action string // "added", "deleted", "toggled" - for the status line + err error +} + +func (m *model) loadClientCerts() tea.Msg { + certs, err := m.client.ListClientCerts() + return clientCertsLoadedMsg{certs: certs, err: err} +} + +// addClientCertFromForm reads the cert/key PEM files given in the form +// (client-side - the TUI and daemon conventionally run on the same host +// in this tool, same assumption the CA install flow already makes) and +// sends their contents, not the paths, to the daemon: a client cert has +// to keep working even if the file it was loaded from later moves or is +// deleted, same as a match-and-replace rule doesn't keep re-reading +// anything after it's saved. +func (m *model) addClientCertFromForm() tea.Cmd { + name := m.clientCertName.Value() + pattern := m.clientCertPattern.Value() + isRegex := m.clientCertIsRegex + certPath := m.clientCertCertPath.Value() + keyPath := m.clientCertKeyPath.Value() + client := m.client + return func() tea.Msg { + certPEM, err := os.ReadFile(certPath) + if err != nil { + return clientCertWriteDoneMsg{action: "added", err: fmt.Errorf("read cert file: %w", err)} + } + keyPEM, err := os.ReadFile(keyPath) + if err != nil { + return clientCertWriteDoneMsg{action: "added", err: fmt.Errorf("read key file: %w", err)} + } + _, err = client.AddClientCert(clientcert.Cert{ + Enabled: true, + Name: name, + Pattern: pattern, + IsRegex: isRegex, + CertPEM: certPEM, + KeyPEM: keyPEM, + }) + return clientCertWriteDoneMsg{action: "added", err: err} + } +} + +func (m *model) deleteSelectedClientCert() tea.Cmd { + row := m.clientCertTable.Cursor() + if row < 0 || row >= len(m.clientCertRows) { + return nil + } + id := m.clientCertRows[row].ID + return func() tea.Msg { + err := m.client.DeleteClientCert(id) + return clientCertWriteDoneMsg{action: "deleted", err: err} + } +} + +func (m *model) toggleSelectedClientCert() tea.Cmd { + row := m.clientCertTable.Cursor() + if row < 0 || row >= len(m.clientCertRows) { + return nil + } + c := m.clientCertRows[row] + return func() tea.Msg { + err := m.client.SetClientCertEnabled(c.ID, !c.Enabled) + return clientCertWriteDoneMsg{action: "toggled", err: err} + } +} + +// enterClientCertForm opens the (add-only) client certificate form - +// add-only for the same reason scope rules are: delete and re-add covers +// changing anything about it, and it's a rarely-touched, low-cardinality +// list. +func (m *model) enterClientCertForm() { + m.clientCertForm = true + m.clientCertField = ccFieldName + m.clientCertName.SetValue("") + m.clientCertPattern.SetValue("") + m.clientCertCertPath.SetValue("") + m.clientCertKeyPath.SetValue("") + m.clientCertIsRegex = false + m.focusClientCertField() +} + +// focusClientCertField moves input focus to m.clientCertField, blurring +// the others - same convention as focusRuleField. +func (m *model) focusClientCertField() { + m.clientCertName.Blur() + m.clientCertPattern.Blur() + m.clientCertCertPath.Blur() + m.clientCertKeyPath.Blur() + switch m.clientCertField { + case ccFieldName: + m.clientCertName.Focus() + case ccFieldPattern: + m.clientCertPattern.Focus() + case ccFieldCertPath: + m.clientCertCertPath.Focus() + case ccFieldKeyPath: + m.clientCertKeyPath.Focus() + } +} + func (m *model) Init() tea.Cmd { return tea.Batch(m.loadList, m.waitForEntry, m.loadStatus) } @@ -1001,6 +1158,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.scopeTable.SetHeight(h - 5) m.scopePattern.Width = formWidth + m.clientCertTable.SetWidth(msg.Width) + m.clientCertTable.SetHeight(h - 5) + m.clientCertName.Width = formWidth + m.clientCertPattern.Width = formWidth + m.clientCertCertPath.Width = formWidth + m.clientCertKeyPath.Width = formWidth + // h-9 rather than h-8: one extra line reserved for the payload // rules / grep-match-extract status line in intruderView. itmplHeight := (h - 9) / 3 @@ -1189,6 +1353,24 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusMsg = "scope rule " + msg.action return m, m.loadScopeRules + case clientCertsLoadedMsg: + if msg.err != nil { + m.statusMsg = "client cert error: " + msg.err.Error() + return m, nil + } + m.clientCertRows = msg.certs + setTableRows(&m.clientCertTable, clientCertRowsFor(m.clientCertRows)) + return m, nil + + case clientCertWriteDoneMsg: + if msg.err != nil { + m.statusMsg = "client cert " + msg.action + " error: " + msg.err.Error() + return m, nil + } + m.clientCertForm = false + m.statusMsg = "client cert " + msg.action + return m, m.loadClientCerts + case intrudeStartedMsg: if msg.err != nil { m.intruderRunning = false @@ -1405,6 +1587,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.mode = viewScope m.statusMsg = "" return m, m.loadScopeRules + case "t": + m.mode = viewClientCerts + m.statusMsg = "" + return m, m.loadClientCerts case "esc": if m.query != "" { m.query = "" @@ -1711,6 +1897,70 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.scopeTable, cmd = m.scopeTable.Update(msg) return m, cmd + case viewClientCerts: + if m.clientCertForm { + switch msg.String() { + case "esc": + m.clientCertForm = false + return m, nil + case "ctrl+c": + return m, tea.Quit + case "ctrl+s": + return m, m.addClientCertFromForm() + case "tab": + m.clientCertField = (m.clientCertField + 1) % 5 + m.focusClientCertField() + return m, nil + case "shift+tab": + m.clientCertField = (m.clientCertField + 4) % 5 + m.focusClientCertField() + return m, nil + } + if m.clientCertField == ccFieldRegex { + switch msg.String() { + case "left", "right", "enter", " ": + m.clientCertIsRegex = !m.clientCertIsRegex + return m, nil + } + } + var cmd tea.Cmd + switch m.clientCertField { + case ccFieldName: + m.clientCertName, cmd = m.clientCertName.Update(msg) + case ccFieldPattern: + m.clientCertPattern, cmd = m.clientCertPattern.Update(msg) + case ccFieldCertPath: + m.clientCertCertPath, cmd = m.clientCertCertPath.Update(msg) + case ccFieldKeyPath: + m.clientCertKeyPath, cmd = m.clientCertKeyPath.Update(msg) + } + return m, cmd + } + + switch msg.String() { + case "q", "esc": + m.mode = viewList + return m, nil + case "ctrl+c": + return m, tea.Quit + case "?": + m.prevMode = viewClientCerts + m.mode = viewHelp + return m, nil + case "a": + m.enterClientCertForm() + return m, nil + case "d": + m.statusMsg = "" + return m, m.deleteSelectedClientCert() + case " ": + m.statusMsg = "" + return m, m.toggleSelectedClientCert() + } + var cmd tea.Cmd + m.clientCertTable, cmd = m.clientCertTable.Update(msg) + return m, cmd + case viewIntruder: // Editing a grep pattern is a modal overlay on top of the // normal template/payloads/results panes, same pattern as @@ -1962,6 +2212,12 @@ func (m *model) View() string { } else { body = m.scopeView() } + case viewClientCerts: + if m.clientCertForm { + body = m.clientCertFormView() + } else { + body = m.clientCertView() + } case viewIntruder: body = m.intruderView() case viewCompare: @@ -1991,7 +2247,7 @@ func (m *model) statusBar() string { view := map[viewMode]string{ viewList: "history", viewDetail: "detail", viewRepeater: "repeater", viewRules: "rules", viewIntruder: "intruder", viewCompare: "comparer", viewDecoder: "decoder", - viewScope: "scope", + viewScope: "scope", viewClientCerts: "client certs", }[m.mode] return statusBarStyle.Render(fmt.Sprintf(" mitmux · proxy %s%s · %s ", proxy, count, view)) } @@ -2040,6 +2296,7 @@ func (m *model) helpView() string { "esc clear active search filter", "m match-and-replace rules", "s target scope (what gets recorded)", + "t client (mutual-TLS) certificates", "q quit", ) section("Detail view", @@ -2092,6 +2349,14 @@ func (m *model) helpView() string { "tab toggle regex (in the add form)", "ctrl+s save form esc cancel/back", ) + section("Client certificates", + "Which cert/key mitmux presents to an upstream server that", + "requires mutual TLS, matched by host pattern like scope rules.", + "a add (name, pattern, cert/key file paths, regex toggle)", + "d delete selected space toggle enabled", + "tab/shift+tab move between form fields ctrl+s save form", + "◀▶ / enter toggle regex (when that field is focused)", + ) b.WriteString(helpStyle.Render("press any key to go back")) return b.String() @@ -2147,9 +2412,9 @@ func (m *model) listView() string { b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) b.WriteString("\n") } - help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export · I import · / search · m rules · s scope · ? help · q quit" + help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export · I import · / search · m rules · s scope · t client certs · ? help · q quit" if m.query != "" { - help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (this filter) · I import · / search · esc clear filter · s scope · ? help · q quit" + help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (this filter) · I import · / search · esc clear filter · s scope · t client certs · ? help · q quit" } b.WriteString(helpStyle.Render(help)) return b.String() @@ -2385,6 +2650,72 @@ func scopeRowsFor(rs []scope.Rule) []table.Row { return rows } +func (m *model) clientCertView() string { + var b strings.Builder + title := fmt.Sprintf(" client certificates (%d) - presented on mutual-TLS handshakes to matching hosts ", len(m.clientCertRows)) + b.WriteString(titleStyle.Render(title)) + b.WriteString("\n") + b.WriteString(m.clientCertTable.View()) + b.WriteString("\n") + if m.statusMsg != "" { + b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) + b.WriteString("\n") + } + b.WriteString(helpStyle.Render("a add · d delete · space toggle · esc back · q quit")) + return b.String() +} + +func (m *model) clientCertFormView() string { + var b strings.Builder + b.WriteString(titleStyle.Render(" add client certificate ")) + b.WriteString("\n\n") + + label := func(field clientCertField, text string) string { + if m.clientCertField == field { + return tabActive.Render(text) + } + return tabInactive.Render(text) + } + + b.WriteString(label(ccFieldName, "Name") + "\n") + b.WriteString(m.clientCertName.View() + "\n\n") + b.WriteString(label(ccFieldPattern, "Pattern (substring match, or a regex against the host)") + "\n") + b.WriteString(m.clientCertPattern.View() + "\n\n") + b.WriteString(label(ccFieldCertPath, "Cert file (PEM)") + "\n") + b.WriteString(m.clientCertCertPath.View() + "\n\n") + b.WriteString(label(ccFieldKeyPath, "Key file (PEM)") + "\n") + b.WriteString(m.clientCertKeyPath.View() + "\n\n") + + regexText := "Regex: off (◀▶ to change)" + if m.clientCertIsRegex { + regexText = "Regex: on (◀▶ to change)" + } + b.WriteString(label(ccFieldRegex, regexText) + "\n\n") + + if m.statusMsg != "" { + b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) + b.WriteString("\n") + } + b.WriteString(helpStyle.Render("tab/shift+tab move · ctrl+s save · esc cancel · ctrl+c quit")) + return b.String() +} + +func clientCertRowsFor(cs []clientcert.Cert) []table.Row { + rows := make([]table.Row, len(cs)) + for i, c := range cs { + on := " " + if c.Enabled { + on = "✓" + } + regex := "" + if c.IsRegex { + regex = "yes" + } + rows[i] = table.Row{on, c.Name, c.Pattern, regex} + } + return rows +} + // nextAttackMode cycles Sniper -> BatteringRam -> Pitchfork -> ClusterBomb // -> Sniper. func nextAttackMode(mode proxy.AttackMode) proxy.AttackMode { diff --git a/cmd/mitmux/mouse.go b/cmd/mitmux/mouse.go index 102dc4f..e2d4936 100644 --- a/cmd/mitmux/mouse.go +++ b/cmd/mitmux/mouse.go @@ -79,6 +79,8 @@ func (m *model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m.handleRulesMouse(msg) case viewScope: return m.handleScopeMouse(msg) + case viewClientCerts: + return m.handleClientCertMouse(msg) } return m, nil } @@ -231,3 +233,30 @@ func (m *model) handleScopeMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } return m, nil } + +func (m *model) handleClientCertMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if m.clientCertForm { + return m, nil + } + switch { + case msg.Button == tea.MouseButtonWheelUp: + m.clientCertTable.MoveUp(3) + case msg.Button == tea.MouseButtonWheelDown: + m.clientCertTable.MoveDown(3) + case msg.Button == tea.MouseButtonRight && msg.Action == tea.MouseActionPress: + row := m.clientCertTable.Cursor() + if row < 0 || row >= len(m.clientCertRows) { + return m, nil + } + sel := m.clientCertRows[row] + enableLabel := "enable" + if sel.Enabled { + enableLabel = "disable" + } + m.openContextMenu([]contextMenuItem{ + {label: enableLabel, action: func() tea.Cmd { return m.toggleSelectedClientCert() }}, + {label: "delete", action: func() tea.Cmd { return m.deleteSelectedClientCert() }}, + }) + } + return m, nil +} diff --git a/internal/clientcert/clientcert.go b/internal/clientcert/clientcert.go new file mode 100644 index 0000000..4bba835 --- /dev/null +++ b/internal/clientcert/clientcert.go @@ -0,0 +1,62 @@ +// Package clientcert manages client (mutual-TLS) certificates: which +// certificate mitmux presents to an upstream server that requires one, +// selected by matching the request's hostname the same way scope rules +// do (see internal/scope) - substring match by default, or a regex - so +// the "which rule applies to this host" mental model stays identical +// throughout the tool. +package clientcert + +import ( + "crypto/tls" + "fmt" + "regexp" + "strings" +) + +// Cert is one client certificate, scoped to hosts matching Pattern. +type Cert struct { + ID int64 + Enabled bool + Name string + Pattern string + IsRegex bool + CertPEM []byte + KeyPEM []byte +} + +func (c Cert) matches(host string) bool { + if c.IsRegex { + re, err := regexp.Compile(c.Pattern) + if err != nil { + return false + } + return re.MatchString(host) + } + return strings.Contains(strings.ToLower(host), strings.ToLower(c.Pattern)) +} + +// FindFor returns the first enabled cert whose pattern matches host, or +// nil if none applies - mitmux then just doesn't present a client +// certificate for that connection, same as if mutual TLS weren't +// configured at all. First-match-wins on ID order, same convention as +// match-and-replace rules' Position ordering, minus the extra field: +// client certs are keyed by host, not layered edits, so insertion order +// is a reasonable enough tiebreaker without adding one. +func FindFor(certs []Cert, host string) *Cert { + for i := range certs { + if certs[i].Enabled && certs[i].matches(host) { + return &certs[i] + } + } + return nil +} + +// TLSCertificate parses c's PEM-encoded cert/key pair into the form +// crypto/tls needs to present it during a handshake. +func (c Cert) TLSCertificate() (tls.Certificate, error) { + cert, err := tls.X509KeyPair(c.CertPEM, c.KeyPEM) + if err != nil { + return tls.Certificate{}, fmt.Errorf("parse client certificate %q: %w", c.Name, err) + } + return cert, nil +} diff --git a/internal/clientcert/clientcert_test.go b/internal/clientcert/clientcert_test.go new file mode 100644 index 0000000..712562b --- /dev/null +++ b/internal/clientcert/clientcert_test.go @@ -0,0 +1,111 @@ +package clientcert + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" +) + +// selfSignedPEM generates a throwaway self-signed cert/key pair entirely +// in memory (crypto/tls + crypto/x509 stdlib only) rather than shelling +// out to openssl or checking in a fixture - keeps the test hermetic and +// portable. +func selfSignedPEM(t *testing.T) (certPEM, keyPEM []byte) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + var certBuf, keyBuf bytes.Buffer + pem.Encode(&certBuf, &pem.Block{Type: "CERTIFICATE", Bytes: der}) + pem.Encode(&keyBuf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + return certBuf.Bytes(), keyBuf.Bytes() +} + +func TestFindForExactMatch(t *testing.T) { + certs := []Cert{{ID: 1, Enabled: true, Pattern: "internal.example.com"}} + got := FindFor(certs, "internal.example.com") + if got == nil || got.ID != 1 { + t.Errorf("got %+v, want match on id 1", got) + } +} + +func TestFindForSubstringMatch(t *testing.T) { + certs := []Cert{{ID: 1, Enabled: true, Pattern: "example.com"}} + if FindFor(certs, "api.example.com") == nil { + t.Error("expected subdomain to match by substring, same convention as scope.Rule") + } +} + +func TestFindForRegex(t *testing.T) { + certs := []Cert{{ID: 1, Enabled: true, Pattern: `^api-\d+\.example\.com$`, IsRegex: true}} + if FindFor(certs, "api-42.example.com") == nil { + t.Error("expected regex pattern to match") + } + if FindFor(certs, "api-x.example.com") != nil { + t.Error("expected regex pattern not to match a non-numeric suffix") + } +} + +func TestFindForSkipsDisabled(t *testing.T) { + certs := []Cert{{ID: 1, Enabled: false, Pattern: "example.com"}} + if FindFor(certs, "example.com") != nil { + t.Error("expected a disabled cert not to match") + } +} + +func TestFindForNoMatch(t *testing.T) { + certs := []Cert{{ID: 1, Enabled: true, Pattern: "example.com"}} + if FindFor(certs, "other.org") != nil { + t.Error("expected no match for an unrelated host") + } +} + +func TestFindForFirstMatchWins(t *testing.T) { + certs := []Cert{ + {ID: 1, Enabled: true, Pattern: "example.com"}, + {ID: 2, Enabled: true, Pattern: "example.com"}, + } + got := FindFor(certs, "example.com") + if got == nil || got.ID != 1 { + t.Errorf("got %+v, want the first matching cert (id 1)", got) + } +} + +func TestTLSCertificateValidPair(t *testing.T) { + certPEM, keyPEM := selfSignedPEM(t) + c := Cert{Name: "test", CertPEM: certPEM, KeyPEM: keyPEM} + if _, err := c.TLSCertificate(); err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestTLSCertificateMismatchedPairErrors(t *testing.T) { + certPEM, _ := selfSignedPEM(t) + _, otherKeyPEM := selfSignedPEM(t) + c := Cert{Name: "test", CertPEM: certPEM, KeyPEM: otherKeyPEM} + if _, err := c.TLSCertificate(); err == nil { + t.Error("expected an error pairing a cert with a key that doesn't match it") + } +} diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index d581313..68217c2 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "mitmux/internal/clientcert" "mitmux/internal/proxy" "mitmux/internal/rules" "mitmux/internal/scope" @@ -69,6 +70,13 @@ type Request struct { ScopeRule *scope.Rule `json:"scope_rule,omitempty"` ScopeRuleID int64 `json:"scope_rule_id,omitempty"` + // For "clientcert_add": the new certificate (always an add, same + // reasoning as scope rules above). For "clientcert_delete"/ + // "clientcert_toggle": ClientCertID (and RuleEnabled for toggle) + // identify the target. + ClientCert *clientcert.Cert `json:"client_cert,omitempty"` + ClientCertID int64 `json:"client_cert_id,omitempty"` + // For "set_flagged" and "delete_entry": ID identifies the history // entry. "clear_history" needs no fields at all. Flagged bool `json:"flagged,omitempty"` @@ -99,13 +107,14 @@ type ImportEntry struct { // Response is sent by the daemon to a client. type Response struct { - Type string `json:"type"` // "list", "get", "new", "repeat", "rules", "scope_rules", "intrude_result", "intrude_done", "import_done", "status", "flagged", "deleted", "cleared", or "error" - Entries []store.Summary `json:"entries,omitempty"` // for "list" - Detail *EntryDetail `json:"detail,omitempty"` // for "get" and "repeat" - New *store.Summary `json:"new,omitempty"` // for "new" (subscribe push) - Rules []rules.Rule `json:"rules,omitempty"` // for "rules" - ScopeRules []scope.Rule `json:"scope_rules,omitempty"` // for "scope_rules" - Status *StatusMsg `json:"status,omitempty"` // for "status" + Type string `json:"type"` // "list", "get", "new", "repeat", "rules", "scope_rules", "intrude_result", "intrude_done", "import_done", "status", "flagged", "deleted", "cleared", or "error" + Entries []store.Summary `json:"entries,omitempty"` // for "list" + Detail *EntryDetail `json:"detail,omitempty"` // for "get" and "repeat" + New *store.Summary `json:"new,omitempty"` // for "new" (subscribe push) + Rules []rules.Rule `json:"rules,omitempty"` // for "rules" + ScopeRules []scope.Rule `json:"scope_rules,omitempty"` // for "scope_rules" + ClientCerts []clientcert.Cert `json:"client_certs,omitempty"` // for "client_certs" + Status *StatusMsg `json:"status,omitempty"` // for "status" // For "import_done": how many entries were actually inserted (a // per-entry insert failure is skipped, not fatal to the batch). @@ -444,6 +453,58 @@ func (c *Client) scopeRoundTrip(req Request) ([]scope.Rule, error) { return resp.ScopeRules, nil } +// ListClientCerts returns every client certificate, enabled or not. +func (c *Client) ListClientCerts() ([]clientcert.Cert, error) { + c.mu.Lock() + defer c.mu.Unlock() + return c.clientCertRoundTrip(Request{Type: "clientcert_list"}) +} + +// AddClientCert adds cert and returns its assigned ID. +func (c *Client) AddClientCert(cert clientcert.Cert) (int64, error) { + c.mu.Lock() + defer c.mu.Unlock() + saved, err := c.clientCertRoundTrip(Request{Type: "clientcert_add", ClientCert: &cert}) + if err != nil { + return 0, err + } + if len(saved) == 0 { + return 0, errors.New("clientcert_add: daemon returned no certificate") + } + return saved[0].ID, nil +} + +// DeleteClientCert removes a client certificate. +func (c *Client) DeleteClientCert(id int64) error { + c.mu.Lock() + defer c.mu.Unlock() + _, err := c.clientCertRoundTrip(Request{Type: "clientcert_delete", ClientCertID: id}) + return err +} + +// SetClientCertEnabled toggles a client cert without touching its +// content. +func (c *Client) SetClientCertEnabled(id int64, enabled bool) error { + c.mu.Lock() + defer c.mu.Unlock() + _, err := c.clientCertRoundTrip(Request{Type: "clientcert_toggle", ClientCertID: id, RuleEnabled: enabled}) + return err +} + +func (c *Client) clientCertRoundTrip(req Request) ([]clientcert.Cert, error) { + if err := c.enc.Encode(req); err != nil { + return nil, err + } + var resp Response + if err := c.dec.Decode(&resp); err != nil { + return nil, err + } + if resp.Type == "error" { + return nil, errors.New(resp.Error) + } + return resp.ClientCerts, nil +} + // Subscribe opens a dedicated connection that streams newly captured // history entries as they happen. The returned channel is closed when // the connection ends; call the returned close func to stop early. diff --git a/internal/ipc/server.go b/internal/ipc/server.go index c890a54..4db27b9 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -9,6 +9,7 @@ import ( "regexp" "sync" + "mitmux/internal/clientcert" "mitmux/internal/proxy" "mitmux/internal/rules" "mitmux/internal/scope" @@ -347,6 +348,52 @@ func (s *Server) handleConn(conn net.Conn) { } enc.Encode(Response{Type: "scope_rules"}) + case "clientcert_list": + certs, err := s.db.ListClientCerts() + if err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + enc.Encode(Response{Type: "client_certs", ClientCerts: certs}) + + case "clientcert_add": + if req.ClientCert == nil { + enc.Encode(Response{Type: "error", Error: "clientcert_add: missing certificate"}) + continue + } + cert := *req.ClientCert + if cert.IsRegex { + if _, err := regexp.Compile(cert.Pattern); err != nil { + enc.Encode(Response{Type: "error", Error: "invalid regex: " + err.Error()}) + continue + } + } + if _, err := cert.TLSCertificate(); err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + id, err := s.db.AddClientCert(cert) + if err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + cert.ID = id + enc.Encode(Response{Type: "client_certs", ClientCerts: []clientcert.Cert{cert}}) + + case "clientcert_delete": + if err := s.db.DeleteClientCert(req.ClientCertID); err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + enc.Encode(Response{Type: "client_certs"}) + + case "clientcert_toggle": + if err := s.db.SetClientCertEnabled(req.ClientCertID, req.RuleEnabled); err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + enc.Encode(Response{Type: "client_certs"}) + case "subscribe": sub := s.hub.subscribe() defer s.hub.unsubscribe(sub) diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index aec724a..bc22bcf 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -39,6 +39,7 @@ import ( "golang.org/x/net/http2" "mitmux/internal/ca" + "mitmux/internal/clientcert" "mitmux/internal/rules" "mitmux/internal/scope" "mitmux/internal/store" @@ -228,7 +229,7 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { client.SetDeadline(time.Time{}) dial := func(ctx context.Context) (net.Conn, string, error) { - return dialUpstreamTLS(ctx, hostPort, hostname, s.UpstreamProxy) + return dialUpstreamTLS(ctx, hostPort, hostname, s.UpstreamProxy, s.clientCertFor(hostname)) } handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.forward(dial, "https", hostname, w, r) @@ -256,16 +257,22 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { // HTTP/2 and HTTP/1.1 over ALPN and letting the server pick. Chaining // through another proxy is transparent to everything from here on: once // the CONNECT tunnel is up, TLS and the request/response on top of it -// look identical to a direct connection. -func dialUpstreamTLS(ctx context.Context, hostPort, sni, upstreamProxy string) (net.Conn, string, error) { +// look identical to a direct connection. cert, if non-nil, is presented +// during the handshake for servers that require mutual TLS - see +// clientCertFor. +func dialUpstreamTLS(ctx context.Context, hostPort, sni, upstreamProxy string, cert *tls.Certificate) (net.Conn, string, error) { raw, err := dialViaProxy(ctx, hostPort, upstreamProxy) if err != nil { return nil, "", err } - conn := tls.Client(raw, &tls.Config{ + cfg := &tls.Config{ ServerName: sni, NextProtos: []string{http2.NextProtoTLS, "http/1.1"}, - }) + } + if cert != nil { + cfg.Certificates = []tls.Certificate{*cert} + } + conn := tls.Client(raw, cfg) if err := conn.HandshakeContext(ctx); err != nil { raw.Close() return nil, "", err @@ -273,6 +280,32 @@ func dialUpstreamTLS(ctx context.Context, hostPort, sni, upstreamProxy string) ( return conn, conn.ConnectionState().NegotiatedProtocol, nil } +// clientCertFor returns the client certificate configured for host, if +// any - see clientcert.FindFor. Errors (a bad DB read, an unparseable +// PEM pair) are logged and treated as "no certificate configured" rather +// than failing the connection outright: a broken client-cert config +// shouldn't take down otherwise-working proxying for that host. +func (s *Server) clientCertFor(host string) *tls.Certificate { + if s.store == nil { + return nil + } + certs, err := s.store.ListClientCerts() + if err != nil { + log.Printf("list client certs: %v", err) + return nil + } + c := clientcert.FindFor(certs, host) + if c == nil { + return nil + } + tc, err := c.TLSCertificate() + if err != nil { + log.Printf("client cert %q: %v", c.Name, err) + return nil + } + return &tc +} + // dialUpstreamPlain connects to a plain (non-TLS) upstream for the // non-CONNECT proxy path, which is always HTTP/1.1. Unlike the TLS/ // CONNECT path, chaining here means dialing the upstream proxy's own diff --git a/internal/proxy/repeat.go b/internal/proxy/repeat.go index f682b99..8e7836d 100644 --- a/internal/proxy/repeat.go +++ b/internal/proxy/repeat.go @@ -36,7 +36,7 @@ func (s *Server) sendRaw(ctx context.Context, scheme, host string, raw []byte, s started := time.Now() method, path := parseRequestLine(raw) - conn, err := dialForRepeat(ctx, scheme, host) + conn, err := s.dialForRepeat(ctx, scheme, host) if err != nil { return s.recordRaw(started, time.Since(started), scheme, host, method, path, raw, nil, false, false, 0, err.Error(), source) } @@ -101,8 +101,11 @@ func (s *Server) recordRaw(started time.Time, duration time.Duration, scheme, ho } // dialForRepeat connects to host for scheme, forcing HTTP/1.1 over ALPN -// when TLS is involved (see Repeat's doc comment for why). -func dialForRepeat(ctx context.Context, scheme, host string) (net.Conn, error) { +// when TLS is involved (see Repeat's doc comment for why), presenting a +// client certificate if one is configured for hostname (see +// clientCertFor) - a resent or fuzzed request against a mutual-TLS host +// needs one just as much as a live proxied request does. +func (s *Server) dialForRepeat(ctx context.Context, scheme, host string) (net.Conn, error) { nd := &net.Dialer{Timeout: 10 * time.Second} if scheme != "https" { hostPort := host @@ -122,7 +125,11 @@ func dialForRepeat(ctx context.Context, scheme, host string) (net.Conn, error) { if err != nil { return nil, err } - conn := tls.Client(raw, &tls.Config{ServerName: hostname, NextProtos: []string{"http/1.1"}}) + cfg := &tls.Config{ServerName: hostname, NextProtos: []string{"http/1.1"}} + if cert := s.clientCertFor(hostname); cert != nil { + cfg.Certificates = []tls.Certificate{*cert} + } + conn := tls.Client(raw, cfg) if err := conn.HandshakeContext(ctx); err != nil { raw.Close() return nil, err diff --git a/internal/store/store.go b/internal/store/store.go index befd5a6..0e57c3c 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -15,6 +15,7 @@ import ( _ "modernc.org/sqlite" + "mitmux/internal/clientcert" "mitmux/internal/rules" "mitmux/internal/scope" ) @@ -63,6 +64,16 @@ CREATE TABLE IF NOT EXISTS scope_rules ( pattern TEXT NOT NULL, is_regex INTEGER NOT NULL DEFAULT 0 ); + +CREATE TABLE IF NOT EXISTS client_certs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + enabled INTEGER NOT NULL DEFAULT 1, + name TEXT NOT NULL DEFAULT '', + pattern TEXT NOT NULL, + is_regex INTEGER NOT NULL DEFAULT 0, + cert_pem BLOB NOT NULL, + key_pem BLOB NOT NULL +); ` // Store is a handle to the history database. Safe for concurrent use. @@ -619,6 +630,59 @@ func (s *Store) DeleteScopeRule(id int64) error { return nil } +// ListClientCerts returns every client certificate, including disabled +// ones (the management view needs to show and let you re-enable those +// too). +func (s *Store) ListClientCerts() ([]clientcert.Cert, error) { + rows, err := s.db.Query(`SELECT id, enabled, name, pattern, is_regex, cert_pem, key_pem FROM client_certs ORDER BY id`) + if err != nil { + return nil, fmt.Errorf("list client certs: %w", err) + } + defer rows.Close() + + var out []clientcert.Cert + for rows.Next() { + var c clientcert.Cert + var enabled, isRegex int + if err := rows.Scan(&c.ID, &enabled, &c.Name, &c.Pattern, &isRegex, &c.CertPEM, &c.KeyPEM); err != nil { + return nil, fmt.Errorf("scan client cert row: %w", err) + } + c.Enabled = enabled != 0 + c.IsRegex = isRegex != 0 + out = append(out, c) + } + return out, rows.Err() +} + +// AddClientCert stores c and returns its assigned ID. +func (s *Store) AddClientCert(c clientcert.Cert) (int64, error) { + res, err := s.db.Exec( + `INSERT INTO client_certs (enabled, name, pattern, is_regex, cert_pem, key_pem) VALUES (?, ?, ?, ?, ?, ?)`, + boolToInt(c.Enabled), c.Name, c.Pattern, boolToInt(c.IsRegex), c.CertPEM, c.KeyPEM, + ) + if err != nil { + return 0, fmt.Errorf("add client cert: %w", err) + } + return res.LastInsertId() +} + +// SetClientCertEnabled toggles a client cert without touching its +// content. +func (s *Store) SetClientCertEnabled(id int64, enabled bool) error { + if _, err := s.db.Exec(`UPDATE client_certs SET enabled = ? WHERE id = ?`, boolToInt(enabled), id); err != nil { + return fmt.Errorf("set client cert %d enabled: %w", id, err) + } + return nil +} + +// DeleteClientCert removes a client certificate. +func (s *Store) DeleteClientCert(id int64) error { + if _, err := s.db.Exec(`DELETE FROM client_certs WHERE id = ?`, id); err != nil { + return fmt.Errorf("delete client cert %d: %w", id, err) + } + return nil +} + // DeleteEntry removes a single history entry and its search index row. func (s *Store) DeleteEntry(id int64) error { tx, err := s.db.Begin() -- cgit v1.2.3