Merge branch 'tofu' of sloum/bombadillo into develop

Adds the following:
- Storing/retrieving of TLS certs from servers in `.bombadillo.ini`
- `purge` command is added to clear out stale certificates
- Bombadillo now validates that certs match the ones on file
- Bombadillo now validates certificates are within valid window
- Bombadillo now validates that certificate hostnames match the host that is requested
- Logic to get a new certificate (if one is presented) when an expired certificate is still present
- Logic to get a certificate if none is present
This commit is contained in:
Sloom Sloum Sluom IV 2019-10-02 15:49:35 -04:00 committed by Gitea
commit 66acf6102f
7 changed files with 230 additions and 45 deletions

View File

@ -61,7 +61,7 @@ func (b *Bookmarks) ToggleFocused() {
}
func (b Bookmarks) IniDump() string {
if len(b.Titles) < 0 {
if len(b.Titles) < 1 {
return ""
}
out := "[BOOKMARKS]\n"

View File

@ -16,7 +16,6 @@ import (
"tildegit.org/sloum/bombadillo/gopher"
"tildegit.org/sloum/bombadillo/http"
"tildegit.org/sloum/bombadillo/telnet"
// "tildegit.org/sloum/mailcap"
)
//------------------------------------------------\\
@ -33,6 +32,7 @@ type client struct {
BookMarks Bookmarks
TopBar Headbar
FootBar Footbar
Certs gemini.TofuDigest
}
@ -154,7 +154,7 @@ func (c *client) TakeControlInput() {
c.ClearMessage()
c.Scroll(-1)
case 'q', 'Q':
// quite bombadillo
// quit bombadillo
cui.Exit()
case 'g':
// scroll to top
@ -279,6 +279,8 @@ func (c *client) simpleCommand(action string) {
case "B", "BOOKMARKS":
c.BookMarks.ToggleOpen()
c.Draw()
case "R", "REFRESH":
// TODO build refresh code
case "SEARCH":
c.search("", "", "?")
case "HELP", "?":
@ -299,8 +301,27 @@ func (c *client) doCommand(action string, values []string) {
switch action {
case "CHECK", "C":
c.displayConfigValue(values[0])
case "PURGE", "P":
err := c.Certs.Purge(values[0])
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
}
if values[0] == "*" {
c.SetMessage("All certificates have been purged", false)
c.DrawMessage()
} else {
c.SetMessage(fmt.Sprintf("The certificate for %q has been purged", strings.ToLower(values[0])), false)
c.DrawMessage()
}
err = saveConfig()
if err != nil {
c.SetMessage("Error saving purge to file", true)
c.DrawMessage()
}
case "SEARCH":
c.search(strings.Join(values, " "), "", "")
c.search(values[0], "", "")
case "WRITE", "W":
if values[0] == "." {
values[0] = c.PageState.History[c.PageState.Position].Location.Full
@ -360,7 +381,8 @@ func (c *client) doCommandAs(action string, values []string) {
if c.BookMarks.IsOpen {
c.Draw()
}
case "SEARCH":
c.search(strings.Join(values, " "), "", "")
case "WRITE", "W":
u, err := MakeUrl(values[0])
if err != nil {
@ -472,7 +494,7 @@ func (c *client) saveFile(u Url, name string) {
case "gopher":
file, err = gopher.Retrieve(u.Host, u.Port, u.Resource)
case "gemini":
file, err = gemini.Fetch(u.Host, u.Port, u.Resource)
file, err = gemini.Fetch(u.Host, u.Port, u.Resource, &c.Certs)
default:
c.SetMessage(fmt.Sprintf("Saving files over %s is not supported", u.Scheme), true)
c.DrawMessage()
@ -551,7 +573,7 @@ func (c *client) doLinkCommand(action, target string) {
num -= 1
links := c.PageState.History[c.PageState.Position].Links
if num >= len(links) || num < 0 {
if num >= len(links) || num < 1 {
c.SetMessage(fmt.Sprintf("Invalid link id: %s", target), true)
c.DrawMessage()
return
@ -832,12 +854,13 @@ func (c *client) Visit(url string) {
c.Draw()
}
case "gemini":
capsule, err := gemini.Visit(u.Host, u.Port, u.Resource)
capsule, err := gemini.Visit(u.Host, u.Port, u.Resource, &c.Certs)
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
}
go saveConfig()
switch capsule.Status {
case 1:
c.search("", u.Full, capsule.Content)
@ -955,7 +978,7 @@ func (c *client) Visit(url string) {
//--------------------------------------------------\\
func MakeClient(name string) *client {
c := client{0, 0, defaultOptions, "", false, MakePages(), MakeBookmarks(), MakeHeadbar(name), MakeFootbar()}
c := client{0, 0, defaultOptions, "", false, MakePages(), MakeBookmarks(), MakeHeadbar(name), MakeFootbar(), gemini.MakeTofuDigest()}
return &c
}

View File

@ -68,9 +68,11 @@ func (s *scanner) scanText() Token {
capInput := strings.ToUpper(buf.String())
switch capInput {
case "DELETE", "ADD", "WRITE", "SET", "RECALL", "R", "SEARCH",
"W", "A", "D", "S", "Q", "QUIT", "B", "BOOKMARKS", "H",
"HOME", "?", "HELP", "C", "CHECK":
case "D", "DELETE", "A", "ADD","W", "WRITE",
"S", "SET", "R", "REFRESH", "SEARCH",
"Q", "QUIT", "B", "BOOKMARKS", "H",
"HOME", "?", "HELP", "C", "CHECK",
"P", "PURGE":
return Token{Action, capInput}
}

View File

@ -24,8 +24,8 @@ type Config struct {
Bookmarks struct {
Titles, Links []string
}
Colors []KeyValue
Settings []KeyValue
Certs []KeyValue
}
type KeyValue struct {
@ -90,8 +90,8 @@ func (p *Parser) Parse() (Config, error) {
case "BOOKMARKS":
c.Bookmarks.Titles = append(c.Bookmarks.Titles, keyval.Value)
c.Bookmarks.Links = append(c.Bookmarks.Links, keyval.Key)
case "COLORS":
c.Colors = append(c.Colors, keyval)
case "CERTS":
c.Certs = append(c.Certs, keyval)
case "SETTINGS":
c.Settings = append(c.Settings, keyval)
}

View File

@ -1,28 +1,138 @@
package gemini
import (
"bytes"
"crypto/sha1"
"crypto/tls"
"fmt"
"io/ioutil"
"strconv"
"strings"
// "tildegit.org/sloum/mailcap"
"time"
)
type Capsule struct {
MimeMaj string
MimeMin string
Status int
Content string
Links []string
Links []string
}
type TofuDigest struct {
certs map[string]string
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
func (t *TofuDigest) Purge(host string) error {
host = strings.ToLower(host)
if host == "*" {
t.certs = make(map[string]string)
return nil
} else if _, ok := t.certs[strings.ToLower(host)]; ok {
delete(t.certs, host)
return nil
}
return fmt.Errorf("Invalid host %q", host)
}
func (t *TofuDigest) Add(host, hash string) {
t.certs[strings.ToLower(host)] = hash
}
func (t *TofuDigest) Exists(host string) bool {
if _, ok := t.certs[strings.ToLower(host)]; ok {
return true
}
return false
}
func (t *TofuDigest) Find(host string) (string, error) {
if hash, ok := t.certs[strings.ToLower(host)]; ok {
return hash, nil
}
return "", fmt.Errorf("Invalid hostname, no key saved")
}
func (t *TofuDigest) Match(host string, cState *tls.ConnectionState) error {
host = strings.ToLower(host)
now := time.Now()
for _, cert := range cState.PeerCertificates {
if t.certs[host] != hashCert(cert.Raw) {
continue
}
if now.Before(cert.NotBefore) {
return fmt.Errorf("Certificate is not valid yet")
}
if now.After(cert.NotAfter) {
return fmt.Errorf("EXP")
}
if err := cert.VerifyHostname(host); err != nil {
return fmt.Errorf("Certificate error: %s", err)
}
return nil
}
return fmt.Errorf("No matching certificate was found for host %q", host)
}
func (t *TofuDigest) newCert(host string, cState *tls.ConnectionState) error {
host = strings.ToLower(host)
now := time.Now()
for _, cert := range cState.PeerCertificates {
if now.Before(cert.NotBefore) {
continue
}
if now.After(cert.NotAfter) {
continue
}
if err := cert.VerifyHostname(host); err != nil {
continue
}
t.Add(host, hashCert(cert.Raw))
return nil
}
return fmt.Errorf("No valid certificates were offered by host %q", host)
}
func (t *TofuDigest) IniDump() string {
if len(t.certs) < 1 {
return ""
}
var out strings.Builder
out.WriteString("[CERTS]\n")
for k, v := range t.certs {
out.WriteString(k)
out.WriteString("=")
out.WriteString(v)
out.WriteString("\n")
}
return out.String()
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
func Retrieve(host, port, resource string) (string, error) {
func Retrieve(host, port, resource string, td *TofuDigest) (string, error) {
if host == "" || port == "" {
return "", fmt.Errorf("Incomplete request url")
}
@ -41,6 +151,38 @@ func Retrieve(host, port, resource string) (string, error) {
defer conn.Close()
connState := conn.ConnectionState()
// Begin TOFU screening...
// If no certificates are offered, bail out
if len(connState.PeerCertificates) < 1 {
return "", fmt.Errorf("Insecure, no certificates offered by server")
}
if td.Exists(host) {
// See if we have a matching cert
err := td.Match(host, &connState)
if err != nil && err.Error() != "EXP" {
// If there is no match and it isnt because of an expiration
// just return the error
return "", err
} else if err != nil {
// The cert expired, see if they are offering one that is valid...
err := td.newCert(host, &connState)
if err != nil {
// If there are no valid certs to offer, let the client know
return "", err
}
}
} else {
err = td.newCert(host, &connState)
if err != nil {
// If there are no valid certs to offer, let the client know
return "", err
}
}
send := "gemini://" + addr + "/" + resource + "\r\n"
_, err = conn.Write([]byte(send))
@ -56,8 +198,8 @@ func Retrieve(host, port, resource string) (string, error) {
return string(result), nil
}
func Fetch(host, port, resource string) ([]byte, error) {
rawResp, err := Retrieve(host, port, resource)
func Fetch(host, port, resource string, td *TofuDigest) ([]byte, error) {
rawResp, err := Retrieve(host, port, resource, td)
if err != nil {
return make([]byte, 0), err
}
@ -103,9 +245,9 @@ func Fetch(host, port, resource string) ([]byte, error) {
}
func Visit(host, port, resource string) (Capsule, error) {
func Visit(host, port, resource string, td *TofuDigest) (Capsule, error) {
capsule := MakeCapsule()
rawResp, err := Retrieve(host, port, resource)
rawResp, err := Retrieve(host, port, resource, td)
if err != nil {
return capsule, err
}
@ -226,8 +368,20 @@ func handleRelativeUrl(u, root, current string) string {
return fmt.Sprintf("%s%s", current, u)
}
func hashCert(cert []byte) string {
hash := sha1.Sum(cert)
hex := make([][]byte, len(hash))
for i, data := range hash {
hex[i] = []byte(fmt.Sprintf("%02X", data))
}
return fmt.Sprintf("%s", string(bytes.Join(hex, []byte(":"))))
}
func MakeCapsule() Capsule {
return Capsule{"", "", 0, "", make([]string, 0, 5)}
}
func MakeTofuDigest() TofuDigest {
return TofuDigest{make(map[string]string)}
}

11
main.go
View File

@ -24,6 +24,7 @@ import (
"os"
"strings"
_ "tildegit.org/sloum/bombadillo/gemini"
"tildegit.org/sloum/bombadillo/config"
"tildegit.org/sloum/bombadillo/cui"
"tildegit.org/sloum/mailcap"
@ -39,8 +40,8 @@ var mc *mailcap.Mailcap
func saveConfig() error {
var opts strings.Builder
bkmrks := bombadillo.BookMarks.IniDump()
certs := bombadillo.Certs.IniDump()
opts.WriteString(bkmrks)
opts.WriteString("\n[SETTINGS]\n")
for k, v := range bombadillo.Options {
if k == "theme" && v != "normal" && v != "inverse" {
@ -53,6 +54,10 @@ func saveConfig() error {
opts.WriteRune('\n')
}
opts.WriteString(bkmrks)
opts.WriteString(certs)
return ioutil.WriteFile(bombadillo.Options["configlocation"] + "/.bombadillo.ini", []byte(opts.String()), 0644)
}
@ -122,6 +127,10 @@ func loadConfig() error {
bombadillo.BookMarks.Add([]string{v, settings.Bookmarks.Links[i]})
}
for _, v := range settings.Certs {
bombadillo.Certs.Add(v.Key, v.Value)
}
return nil
}

39
url.go
View File

@ -37,7 +37,7 @@ type Url struct {
// an error (or nil).
func MakeUrl(u string) (Url, error) {
var out Url
re := regexp.MustCompile(`^((?P<scheme>gopher|telnet|http|https|gemini):\/\/)?(?P<host>[\w\-\.\d]+)(?::(?P<port>\d+)?)?(?:/(?P<type>[01345679gIhisp])?)?(?P<resource>.*)?$`)
re := regexp.MustCompile(`^((?P<scheme>[a-zA-Z]+):\/\/)?(?P<host>[\w\-\.\d]+)(?::(?P<port>\d+)?)?(?:/(?P<type>[01345679gIhisp])?)?(?P<resource>.*)?$`)
match := re.FindStringSubmatch(u)
if valid := re.MatchString(u); !valid {
@ -59,14 +59,16 @@ func MakeUrl(u string) (Url, error) {
}
}
if out.Scheme == "" {
out.Scheme = "gopher"
}
if out.Host == "" {
return out, fmt.Errorf("no host")
}
out.Scheme = strings.ToLower(out.Scheme)
if out.Scheme == "" {
out.Scheme = "gopher"
}
if out.Scheme == "gopher" && out.Port == "" {
out.Port = "70"
} else if out.Scheme == "http" && out.Port == "" {
@ -75,21 +77,20 @@ func MakeUrl(u string) (Url, error) {
out.Port = "443"
} else if out.Scheme == "gemini" && out.Port == "" {
out.Port = "1965"
}
if out.Scheme == "gopher" && out.Mime == "" {
out.Mime = "1"
}
if out.Mime == "" && (out.Resource == "" || out.Resource == "/") && out.Scheme == "gopher" {
out.Mime = "1"
}
if out.Mime == "7" && strings.Contains(out.Resource, "\t") {
out.Mime = "1"
} else if out.Scheme == "telnet" && out.Port == "" {
out.Port = "23"
}
if out.Scheme == "gopher" {
if out.Mime == "" {
out.Mime = "1"
}
if out.Resource == "" || out.Resource == "/" {
out.Mime = "1"
}
if out.Mime == "7" && strings.Contains(out.Resource, "\t") {
out.Mime = "1"
}
switch out.Mime {
case "1", "0", "h", "7":
out.DownloadOnly = false
@ -101,10 +102,6 @@ func MakeUrl(u string) (Url, error) {
out.Mime = ""
}
if out.Scheme == "http" || out.Scheme == "https" {
out.Mime = ""
}
out.Full = out.Scheme + "://" + out.Host + ":" + out.Port + "/" + out.Mime + out.Resource
return out, nil