2 tofuproxy -- flexible HTTP/HTTPS proxy, TLS terminator, X.509 TOFU
3 manager, WARC/geminispace browser
4 Copyright (C) 2021-2023 Sergey Matveev <stargrave@stargrave.org>
6 This program is free software: you can redistribute it and/or modify
7 it under the terms of the GNU General Public License as published by
8 the Free Software Foundation, version 3 of the License.
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.
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/>.
37 "go.cypherpunks.ru/ucspi"
38 "go.stargrave.org/tofuproxy/caches"
39 "go.stargrave.org/tofuproxy/fifos"
42 const VerifyDialog = `
43 # host err daneStatus certsTheir certsOur
45 if {[string length $err] > 0} {
48 $tErr configure -wrap word -height 5
49 $tErr configure -state disabled
53 proc certsDecode {raws} {
55 foreach raw [split $raws] {
58 foreach line [split [binary decode hex $raw] "\n"] {
59 lappend lines [format "%03d %s" $lineN $line]
62 lappend certs [join $lines "\n"]
67 set certsTheir [certsDecode $certsTheir]
68 set certsOur [certsDecode $certsOur]
73 proc paginator {i delta t l certs} {
75 if {$i == [llength $certs]} {
78 set i [expr {[llength $certs] - 1}]
80 $t configure -state normal
82 $t insert end [lindex $certs $i]
83 $t configure -state disabled
84 $l configure -text "[expr {$i + 1}] / [llength $certs]"
88 proc addCertsWindow {name} {
89 global certs$name t$name sb$name page$name l$name
91 set sb [scrollbar .sb$name -command [list $t yview]]
92 $t configure -wrap word -yscrollcommand [list $sb set]
93 $t configure -state disabled
94 grid $t $sb -sticky nsew
99 set l$name [label .lPage$name]
100 button .bNext$name -text "Next" -command [subst {
101 set page$name \[paginator \$page$name +1 \$t$name \$l$name \$certs$name]
103 button .bPrev$name -text "Prev" -command [subst {
104 set page$name \[paginator \$page$name -1 \$t$name \$l$name \$certs$name]
107 grid .lPage$name .bNext$name .bPrev$name -in .fControl$name
108 set page$name [paginator -1 +1 $t [set l$name] [set certs$name]]
112 if {[llength $certsOur] > 0} { addCertsWindow Our }
114 set lDANE [label .lDANE]
115 if {$daneStatus ne ""} {
116 array set daneColour {ok green bad red}
117 $lDANE configure -bg $daneColour($daneStatus)
118 $lDANE configure -text "DANE-EE: $daneStatus"
120 proc doAccept {} { exit 10 }
121 proc doOnce {} { exit 11 }
122 proc doReject {} { exit 12 }
123 button .bAccept -text "Accept" -bg green -command doAccept
124 button .bOnce -text "Once" -bg green -command doOnce
125 button .bReject -text "Reject" -bg red -command doReject
127 grid .lDANE .bAccept .bOnce .bReject -in .fButtons
128 grid rowconfigure . 0 -weight 1
129 grid columnconfigure . 0 -weight 1
131 bind . <KeyPress> {switch -exact %K {
132 q {exit 0} ; # reject once
136 n {.bNextTheir invoke}
137 p {.bPrevTheir invoke}
144 CmdCerttool = "certtool"
151 func spkiHash(cert *x509.Certificate) string {
152 hsh := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
153 return hex.EncodeToString(hsh[:])
156 type ErrRejected struct {
160 func (err ErrRejected) Error() string { return err.addr + " was rejected" }
162 func certInfo(certRaw []byte) string {
163 cmd := exec.Command(CmdCerttool, "--certificate-info", "--inder")
164 cmd.Stdin = bytes.NewReader(certRaw)
165 out, err := cmd.Output()
176 verifiedChains [][]*x509.Certificate,
178 var certTheir *x509.Certificate
180 if len(verifiedChains) > 0 {
181 certTheir = verifiedChains[0][0]
183 certTheir, err = x509.ParseCertificate(rawCerts[0])
188 certTheirHash := spkiHash(certTheir)
190 defer VerifyM.Unlock()
191 caches.AcceptedM.RLock()
192 certOurHash := caches.Accepted[host]
193 caches.AcceptedM.RUnlock()
194 if certTheirHash == certOurHash {
197 caches.RejectedM.RLock()
198 certOurHash = caches.Rejected[host]
199 caches.RejectedM.RUnlock()
200 if certTheirHash == certOurHash {
201 return ErrRejected{host}
203 daneExists, daneMatched := DANE(host, certTheir)
206 fifos.LogDANE <- fmt.Sprintf("%s\tACK", host)
208 fifos.LogDANE <- fmt.Sprintf("%s\tNAK", host)
211 if len(verifiedChains) > 0 {
212 caHashes := make(map[string]struct{})
213 for _, certs := range verifiedChains {
214 for _, cert := range certs {
215 caHashes[spkiHash(cert)] = struct{}{}
218 var restrictedHosts []string
219 caches.RestrictedM.RLock()
220 for h := range caHashes {
221 restrictedHosts = append(restrictedHosts, caches.Restricted[h]...)
223 caches.RestrictedM.RUnlock()
224 if len(restrictedHosts) > 0 {
225 for _, h := range restrictedHosts {
226 if host == h || strings.HasSuffix(host, "."+h) {
227 goto HostIsNotRestricted
230 fifos.LogCert <- fmt.Sprintf("Restricted\t%s", host)
231 return ErrRejected{host}
235 fn := filepath.Join(Certs, host)
236 certsOur, _, err := ucspi.CertPoolFromFile(fn)
237 if err == nil || dialErr != nil || (daneExists && !daneMatched) {
238 if certsOur != nil && certTheirHash == spkiHash(certsOur[0]) {
239 caches.AcceptedM.Lock()
240 caches.Accepted[host] = certTheirHash
241 caches.AcceptedM.Unlock()
242 if !bytes.Equal(certsOur[0].Raw, rawCerts[0]) {
243 fifos.LogCert <- fmt.Sprintf("Refresh\t%s\t%s", host, certTheirHash)
249 fmt.Fprintf(&b, "set host \"%s\"\n", host)
251 fmt.Fprintf(&b, "set err \"\"\n")
253 fmt.Fprintf(&b, "set err \"%s\"\n", dialErr.Error())
255 var daneStatus string
263 fmt.Fprintf(&b, "set daneStatus \"%s\"\n", daneStatus)
264 hexCerts := make([]string, 0, len(rawCerts))
265 for _, rawCert := range rawCerts {
266 hexCerts = append(hexCerts, hex.EncodeToString([]byte(certInfo(rawCert))))
268 fmt.Fprintf(&b, "set certsTheir \"%s\"\n", strings.Join(hexCerts, " "))
269 hexCerts = make([]string, 0, len(certsOur))
270 for _, cert := range certsOur {
271 hexCerts = append(hexCerts, hex.EncodeToString([]byte(certInfo(cert.Raw))))
273 fmt.Fprintf(&b, "set certsOur \"%s\"\n", strings.Join(hexCerts, " "))
274 b.WriteString(VerifyDialog)
275 cmd := exec.Command(CmdWish)
276 // os.WriteFile("/tmp/verify-dialog.tcl", b.Bytes(), 0666)
279 exitError, ok := err.(*exec.ExitError)
281 fifos.LogCert <- fmt.Sprintf("Reject\t%s\t%s", host, certTheirHash)
282 return ErrRejected{host}
284 switch exitError.ExitCode() {
286 fifos.LogCert <- fmt.Sprintf("Accept\t%s\t%s", host, certTheirHash)
289 fifos.LogCert <- fmt.Sprintf("Once\t%s\t%s", host, certTheirHash)
290 caches.AcceptedM.Lock()
291 caches.Accepted[host] = certTheirHash
292 caches.AcceptedM.Unlock()
295 caches.RejectedM.Lock()
296 caches.Rejected[host] = certTheirHash
297 caches.RejectedM.Unlock()
300 fifos.LogCert <- fmt.Sprintf("Reject\t%s\t%s", host, certTheirHash)
301 return ErrRejected{host}
304 if !errors.Is(err, fs.ErrNotExist) {
307 fifos.LogCert <- fmt.Sprintf("TOFU\t%s\t%s", host, certTheirHash)
310 tmp, err := os.CreateTemp(Certs, "")
314 for _, rawCert := range rawCerts {
315 err = pem.Encode(tmp, &pem.Block{Type: "CERTIFICATE", Bytes: rawCert})
321 os.Rename(tmp.Name(), fn)
322 caches.AcceptedM.Lock()
323 caches.Accepted[host] = certTheirHash
324 caches.AcceptedM.Unlock()