1 // tofuproxy -- flexible HTTP/HTTPS proxy, TLS terminator, X.509 TOFU
2 // manager, WARC/geminispace browser
3 // Copyright (C) 2021-2024 Sergey Matveev <stargrave@stargrave.org>
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, version 3 of the License.
9 // This program is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with this program. If not, see <http://www.gnu.org/licenses/>.
35 "go.cypherpunks.ru/ucspi"
36 "go.stargrave.org/tofuproxy/caches"
37 "go.stargrave.org/tofuproxy/fifos"
40 const VerifyDialog = `
41 # host err daneStatus certsTheir certsOur
43 if {[string length $err] > 0} {
46 $tErr configure -wrap word -height 5
47 $tErr configure -state disabled
51 proc certsDecode {raws} {
53 foreach raw [split $raws] {
56 foreach line [split [binary decode hex $raw] "\n"] {
57 lappend lines [format "%03d %s" $lineN $line]
60 lappend certs [join $lines "\n"]
65 set certsTheir [certsDecode $certsTheir]
66 set certsOur [certsDecode $certsOur]
71 proc paginator {i delta t l certs} {
73 if {$i == [llength $certs]} {
76 set i [expr {[llength $certs] - 1}]
78 $t configure -state normal
80 $t insert end [lindex $certs $i]
81 $t configure -state disabled
82 $l configure -text "[expr {$i + 1}] / [llength $certs]"
86 proc addCertsWindow {name} {
87 global certs$name t$name sb$name page$name l$name
89 set sb [scrollbar .sb$name -command [list $t yview]]
90 $t configure -wrap word -yscrollcommand [list $sb set]
91 $t configure -state disabled
92 grid $t $sb -sticky nsew
97 set l$name [label .lPage$name]
98 button .bNext$name -text "Next" -command [subst {
99 set page$name \[paginator \$page$name +1 \$t$name \$l$name \$certs$name]
101 button .bPrev$name -text "Prev" -command [subst {
102 set page$name \[paginator \$page$name -1 \$t$name \$l$name \$certs$name]
105 grid .lPage$name .bNext$name .bPrev$name -in .fControl$name
106 set page$name [paginator -1 +1 $t [set l$name] [set certs$name]]
110 if {[llength $certsOur] > 0} { addCertsWindow Our }
112 set lDANE [label .lDANE]
113 if {$daneStatus ne ""} {
114 array set daneColour {ok green bad red}
115 $lDANE configure -bg $daneColour($daneStatus)
116 $lDANE configure -text "DANE-EE: $daneStatus"
118 proc doAccept {} { exit 10 }
119 proc doOnce {} { exit 11 }
120 proc doReject {} { exit 12 }
121 button .bAccept -text "Accept" -bg green -command doAccept
122 button .bOnce -text "Once" -bg green -command doOnce
123 button .bReject -text "Reject" -bg red -command doReject
125 grid .lDANE .bAccept .bOnce .bReject -in .fButtons
126 grid rowconfigure . 0 -weight 1
127 grid columnconfigure . 0 -weight 1
129 bind . <KeyPress> {switch -exact %K {
130 q {exit 0} ; # reject once
134 n {.bNextTheir invoke}
135 p {.bPrevTheir invoke}
142 CmdCerttool = "certtool"
149 func spkiHash(cert *x509.Certificate) string {
150 hsh := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
151 return hex.EncodeToString(hsh[:])
154 type ErrRejected struct {
158 func (err ErrRejected) Error() string { return err.addr + " was rejected" }
160 func certInfo(certRaw []byte) string {
161 cmd := exec.Command(CmdCerttool, "--certificate-info", "--inder")
162 cmd.Stdin = bytes.NewReader(certRaw)
163 out, err := cmd.Output()
174 verifiedChains [][]*x509.Certificate,
176 var certTheir *x509.Certificate
178 if len(verifiedChains) > 0 {
179 certTheir = verifiedChains[0][0]
181 certTheir, err = x509.ParseCertificate(rawCerts[0])
186 certTheirHash := spkiHash(certTheir)
188 defer VerifyM.Unlock()
189 caches.AcceptedM.RLock()
190 certOurHash := caches.Accepted[host]
191 caches.AcceptedM.RUnlock()
192 if certTheirHash == certOurHash {
195 caches.RejectedM.RLock()
196 certOurHash = caches.Rejected[host]
197 caches.RejectedM.RUnlock()
198 if certTheirHash == certOurHash {
199 return ErrRejected{host}
201 daneExists, daneMatched := DANE(host, certTheir)
204 fifos.LogDANE <- fmt.Sprintf("%s\tACK", host)
206 fifos.LogDANE <- fmt.Sprintf("%s\tNAK", host)
209 if len(verifiedChains) > 0 {
210 caHashes := make(map[string]struct{})
211 for _, certs := range verifiedChains {
212 for _, cert := range certs {
213 caHashes[spkiHash(cert)] = struct{}{}
216 var restrictedHosts []string
217 caches.RestrictedM.RLock()
218 for h := range caHashes {
219 restrictedHosts = append(restrictedHosts, caches.Restricted[h]...)
221 caches.RestrictedM.RUnlock()
222 if len(restrictedHosts) > 0 {
223 for _, h := range restrictedHosts {
224 if host == h || strings.HasSuffix(host, "."+h) {
225 goto HostIsNotRestricted
228 fifos.LogCert <- fmt.Sprintf("Restricted\t%s", host)
229 return ErrRejected{host}
233 fn := filepath.Join(Certs, host)
234 certsOur, _, err := ucspi.CertPoolFromFile(fn)
235 if err == nil || dialErr != nil || (daneExists && !daneMatched) {
236 if certsOur != nil && certTheirHash == spkiHash(certsOur[0]) {
237 caches.AcceptedM.Lock()
238 caches.Accepted[host] = certTheirHash
239 caches.AcceptedM.Unlock()
240 if !bytes.Equal(certsOur[0].Raw, rawCerts[0]) {
241 fifos.LogCert <- fmt.Sprintf("Refresh\t%s\t%s", host, certTheirHash)
247 fmt.Fprintf(&b, "set host \"%s\"\n", host)
249 fmt.Fprintf(&b, "set err \"\"\n")
251 fmt.Fprintf(&b, "set err \"%s\"\n", dialErr.Error())
253 var daneStatus string
261 fmt.Fprintf(&b, "set daneStatus \"%s\"\n", daneStatus)
262 hexCerts := make([]string, 0, len(rawCerts))
263 for _, rawCert := range rawCerts {
264 hexCerts = append(hexCerts, hex.EncodeToString([]byte(certInfo(rawCert))))
266 fmt.Fprintf(&b, "set certsTheir \"%s\"\n", strings.Join(hexCerts, " "))
267 hexCerts = make([]string, 0, len(certsOur))
268 for _, cert := range certsOur {
269 hexCerts = append(hexCerts, hex.EncodeToString([]byte(certInfo(cert.Raw))))
271 fmt.Fprintf(&b, "set certsOur \"%s\"\n", strings.Join(hexCerts, " "))
272 b.WriteString(VerifyDialog)
273 cmd := exec.Command(CmdWish)
274 // os.WriteFile("/tmp/verify-dialog.tcl", b.Bytes(), 0666)
277 exitError, ok := err.(*exec.ExitError)
279 fifos.LogCert <- fmt.Sprintf("Reject\t%s\t%s", host, certTheirHash)
280 return ErrRejected{host}
282 switch exitError.ExitCode() {
284 fifos.LogCert <- fmt.Sprintf("Accept\t%s\t%s", host, certTheirHash)
287 fifos.LogCert <- fmt.Sprintf("Once\t%s\t%s", host, certTheirHash)
288 caches.AcceptedM.Lock()
289 caches.Accepted[host] = certTheirHash
290 caches.AcceptedM.Unlock()
293 caches.RejectedM.Lock()
294 caches.Rejected[host] = certTheirHash
295 caches.RejectedM.Unlock()
298 fifos.LogCert <- fmt.Sprintf("Reject\t%s\t%s", host, certTheirHash)
299 return ErrRejected{host}
302 if !errors.Is(err, fs.ErrNotExist) {
305 fifos.LogCert <- fmt.Sprintf("TOFU\t%s\t%s", host, certTheirHash)
308 tmp, err := os.CreateTemp(Certs, "")
312 for _, rawCert := range rawCerts {
313 err = pem.Encode(tmp, &pem.Block{Type: "CERTIFICATE", Bytes: rawCert})
319 os.Rename(tmp.Name(), fn)
320 caches.AcceptedM.Lock()
321 caches.Accepted[host] = certTheirHash
322 caches.AcceptedM.Unlock()