diff --git a/README.md b/README.md index cb83665..3366668 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,41 @@ A quick overview of the Gemini protocol: The way this is implemented in this package is like so: 1. Client makes a request with `NewRequest`. The client then sends the request - with `Send(*Request) (*Response, error)`. The client can optionally verify - the server certificate with `VerifyCertificate(*x509.Certificate, *Request)` + with `(*Client).Send(*Request) (*Response, error)`. The client then determines whether + to trust the certificate in `TrustCertificte(*x509.Certificate, *KnownHosts) bool`. + (See [TOFU](#tofu)). 2. Server recieves the request and constructs a response. The server calls the `Serve(*ResponseWriter, *Request)` method on the `Handler` field. The handler writes the response. The server then closes the connection. 3. Client recieves the response as a `*Response`. The client then handles the response. + +## TOFU + +This package provides an easy way to implement Trust-On-First-Use in your +clients. Here is a simple example client using TOFU to authenticate +certificates: + +```go +client := &gemini.Client{ + KnownHosts: gemini.LoadKnownHosts(".local/share/gemini/known_hosts"), + TrustCertificate: func(cert *x509.Certificate, knownHosts *gemini.KnownHosts) bool { + // If the certificate is in the known hosts list, allow the connection + if knownHosts.Has(cert) { + return true + } + // Prompt the user + if userTrustsCertificateTemporarily() { + // Temporarily trust the certificate + return true + } else if userTrustsCertificatePermanently() { + // Add the certificate to the known hosts file + knownHosts.Add(cert) + return true + } + // User does not trust the certificate + return false + }, +} +``` diff --git a/client.go b/client.go index bd4cbd4..06f9a67 100644 --- a/client.go +++ b/client.go @@ -10,12 +10,15 @@ import ( "net" "net/url" "strconv" + "strings" ) // Errors. var ( - ErrProtocol = errors.New("gemini: protocol error") - ErrInvalidURL = errors.New("gemini: requested URL is invalid") + ErrProtocol = errors.New("gemini: protocol error") + ErrInvalidURL = errors.New("gemini: requested URL is invalid") + ErrCertificateNotValid = errors.New("gemini: certificate is invalid") + ErrCertificateNotTrusted = errors.New("gemini: certificate is not trusted") ) // Request represents a Gemini request. @@ -163,24 +166,40 @@ func (resp *Response) read(r *bufio.Reader) error { } // Client represents a Gemini client. -type Client interface { - // VerifyCertificate will be called to verify the server certificate. - // If error is not nil, the connection will be aborted. - VerifyCertificate(cert *x509.Certificate, req *Request) error +type Client struct { + // KnownHosts is a list of known hosts that the client trusts. + KnownHosts *KnownHosts + + // TrustCertificate, if not nil, will be called to determine whether the + // client should trust the given certificate. + TrustCertificate func(cert *x509.Certificate, knownHosts *KnownHosts) bool } // Send sends a Gemini request and returns a Gemini response. -func Send(c Client, req *Request) (*Response, error) { +func (c *Client) Send(req *Request) (*Response, error) { // Connect to the host config := &tls.Config{ InsecureSkipVerify: true, Certificates: []tls.Certificate{req.Certificate}, VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error { + // Parse the certificate cert, err := x509.ParseCertificate(rawCerts[0]) if err != nil { return err } - return c.VerifyCertificate(cert, req) + // Check that the certificate is valid for the hostname + if cert.Subject.CommonName != hostname(req.Host) { + return ErrCertificateNotValid + } + // Check that the client trusts the certificate + if c.TrustCertificate == nil { + if c.KnownHosts == nil || !c.KnownHosts.Has(cert) { + return ErrCertificateNotTrusted + } + } else if !c.TrustCertificate(cert, c.KnownHosts) { + return ErrCertificateNotTrusted + } + return nil }, } conn, err := tls.Dial("tcp", req.Host, config) @@ -206,3 +225,12 @@ func Send(c Client, req *Request) (*Response, error) { } return resp, nil } + +// hostname extracts the host name from a valid host or host:port +func hostname(host string) string { + i := strings.LastIndexByte(host, ':') + if i != -1 { + return host[:i] + } + return host +} diff --git a/examples/client/client.go b/examples/client/client.go index 6887ebf..4f852bf 100644 --- a/examples/client/client.go +++ b/examples/client/client.go @@ -14,8 +14,8 @@ import ( ) var ( - client = &gemini.TOFUClient{ - Trusts: func(cert *x509.Certificate, req *gemini.Request) bool { + client = &gemini.Client{ + TrustCertificate: func(cert *x509.Certificate, knownHosts *gemini.KnownHosts) bool { // Trust all certificates return true }, @@ -45,7 +45,7 @@ func makeRequest(url string) { } req.Certificate = cert - resp, err := gemini.Send(client, req) + resp, err := client.Send(req) if err != nil { log.Fatal(err) } diff --git a/gemini.go b/gemini.go index 5b95b6a..ccdb5df 100644 --- a/gemini.go +++ b/gemini.go @@ -1,13 +1,5 @@ package gemini -import ( - "crypto/x509" - "errors" - "log" - "os" - "path/filepath" -) - // Status codes. const ( StatusInput = 10 @@ -43,47 +35,3 @@ const ( var ( crlf = []byte("\r\n") ) - -// TOFUClient is a client that implements Trust-On-First-Use. -type TOFUClient struct { - // Trusts, if not nil, will be called to determine whether the client should - // trust the provided certificate. - Trusts func(cert *x509.Certificate, req *Request) bool -} - -func (t *TOFUClient) VerifyCertificate(cert *x509.Certificate, req *Request) error { - if knownHosts.Has(req.URL.Host, cert) { - return nil - } - if t.Trusts != nil && t.Trusts(cert, req) { - host := NewKnownHost(cert) - knownHosts = append(knownHosts, host) - knownHostsFile, err := os.OpenFile(knownHostsPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - log.Print(err) - } - if _, err := host.Write(knownHostsFile); err != nil { - log.Print(err) - } - return nil - } - return errors.New("gemini: certificate not trusted") -} - -var ( - knownHosts KnownHosts - knownHostsPath string - knownHostsFile *os.File -) - -func init() { - configDir, err := os.UserConfigDir() - knownHostsPath = filepath.Join(configDir, "gemini") - os.MkdirAll(knownHostsPath, 0755) - knownHostsPath = filepath.Join(knownHostsPath, "known_hosts") - knownHostsFile, err = os.OpenFile(knownHostsPath, os.O_CREATE|os.O_RDONLY, 0644) - if err != nil { - return - } - knownHosts = ParseKnownHosts(knownHostsFile) -} diff --git a/tofu.go b/tofu.go index 30ec6a2..4461fdd 100644 --- a/tofu.go +++ b/tofu.go @@ -5,37 +5,75 @@ import ( "bytes" "crypto/sha512" "crypto/x509" - "errors" "fmt" "io" + "os" + "path/filepath" "strconv" "strings" "time" ) -// Errors. -var ( - ErrInvalidKnownHosts = errors.New("gemini: invalid known hosts") -) - // KnownHosts represents a list of known hosts. -type KnownHosts []KnownHost +type KnownHosts struct { + hosts []KnownHost + file *os.File +} -// Has reports whether the given hostname and certificate are in the list. -func (k KnownHosts) Has(hostname string, cert *x509.Certificate) bool { +// LoadKnownHosts loads the known hosts from the provided path. +// It creates the path and any of its parent directories if they do not exist. +// The returned KnownHosts appends to the file whenever a certificate is added. +func LoadKnownHosts(path string) (*KnownHosts, error) { + if dir := filepath.Dir(path); dir != "." { + err := os.MkdirAll(dir, 0755) + if err != nil { + return nil, err + } + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDONLY, 0644) + if err != nil { + return nil, err + } + k := &KnownHosts{} + k.Parse(f) + f.Close() + // Open the file for append-only use + f, err = os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return nil, err + } + k.file = f + return k, nil +} + +// Add adds a certificate to the KnownHosts. +// If KnownHosts was loaded from a file, Add will append to the file. +func (k *KnownHosts) Add(cert *x509.Certificate) { + host := NewKnownHost(cert) + k.hosts = append(k.hosts, host) + // Append to the file + if k.file != nil { + host.Write(k.file) + } +} + +// Has reports whether the provided certificate is in the list. +func (k *KnownHosts) Has(cert *x509.Certificate) bool { now := time.Now().Unix() + hostname := cert.Subject.CommonName fingerprint := Fingerprint(cert) - for i := range k { - if k[i].Expires > now && k[i].Hostname == hostname && k[i].Fingerprint == fingerprint { + for i := range k.hosts { + if k.hosts[i].Expires > now && k.hosts[i].Hostname == hostname && + k.hosts[i].Fingerprint == fingerprint { return true } } return false } -// ParseKnownHosts parses and returns a list of known hosts from the provided io.Reader. +// Parse parses the provided reader and adds the parsed known hosts to the list. // Invalid lines are ignored. -func ParseKnownHosts(r io.Reader) (hosts KnownHosts) { +func (k *KnownHosts) Parse(r io.Reader) { scanner := bufio.NewScanner(r) for scanner.Scan() { text := scanner.Text() @@ -53,14 +91,13 @@ func ParseKnownHosts(r io.Reader) (hosts KnownHosts) { continue } - hosts = append(hosts, KnownHost{ + k.hosts = append(k.hosts, KnownHost{ Hostname: hostname, Algorithm: algorithm, Fingerprint: fingerprint, Expires: expires, }) } - return } // KnownHost represents a known host. @@ -71,6 +108,7 @@ type KnownHost struct { Expires int64 // unix time of certificate notAfter date } +// NewKnownHost creates a new known host from a certificate. func NewKnownHost(cert *x509.Certificate) KnownHost { return KnownHost{ Hostname: cert.Subject.CommonName,