Refactor TOFU

This commit is contained in:
adnano 2020-09-25 23:06:54 -04:00
parent b4295dd2dc
commit 927dfd29c5
5 changed files with 124 additions and 80 deletions

View File

@ -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
},
}
```

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

68
tofu.go
View File

@ -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,