Commit ac17981a authored by ale's avatar ale

Add dovecot-keylookupd

The dovecot-keylookupd is a dict-proxy dovecot lookup daemon, which
can read public and private keys from the LDAP backend and use them
for Dovecot userdb / passdb lookups.
parent cc414896
Pipeline #786 passed with stages
in 49 seconds
......@@ -49,3 +49,13 @@ a single attribute *key*.
`/api/close` (*CloseRequest*)
Forget the key for a given user.
# Dovecot integration
The final consumer for user encryption keys is the Dovecot
service. The *dovecot-keylookupd* daemon can read the user public and
private keys from LDAP, and serve the *unencrypted* keys to Dovecot
using its [dict proxy
protocol](https://wiki2.dovecot.org/AuthDatabase/Dict).
TODO: explain the lookup protocol.
......@@ -19,10 +19,12 @@ type LDAPQueryConfig struct {
// a query.
SearchBase string `yaml:"search_base"`
SearchFilter string `yaml:"search_filter"`
Scope string `yaml:"scope"`
ScopeStr string `yaml:"scope"`
scope int
// Attr is the LDAP attribute holding the encrypted user key.
Attr string `yaml:"attr"`
PublicKeyAttr string `yaml:"attr"`
PrivateKeyAttr string `yaml:"attr"`
}
// Valid returns an error if the configuration is invalid.
......@@ -33,32 +35,36 @@ func (c *LDAPQueryConfig) Valid() error {
if c.SearchFilter == "" {
return errors.New("empty search_filter")
}
if c.Scope != "one" && c.Scope != "sub" {
return errors.New("unknown scope")
c.scope = ldap.ScopeWholeSubtree
if c.ScopeStr != "" {
s, err := ldaputil.ParseScope(c.ScopeStr)
if err != nil {
return err
}
c.scope = s
}
if c.Attr == "" {
return errors.New("empty attr")
if c.PublicKeyAttr == "" {
return errors.New("empty public_key_attr")
}
if c.PrivateKeyAttr == "" {
return errors.New("empty public_key_attr")
}
return nil
}
func (c *LDAPQueryConfig) searchRequest(username string) *ldap.SearchRequest {
func (c *LDAPQueryConfig) searchRequest(username string, attrs ...string) *ldap.SearchRequest {
u := ldap.EscapeFilter(username)
base := strings.Replace(c.SearchBase, "%s", u, -1)
filter := strings.Replace(c.SearchFilter, "%s", u, -1)
scope := ldap.ScopeWholeSubtree
if c.Scope == "one" {
scope = ldap.ScopeSingleLevel
}
return ldap.NewSearchRequest(
base,
scope,
c.scope,
ldap.NeverDerefAliases,
0,
0,
false,
filter,
[]string{c.Attr},
attrs,
nil,
)
}
......@@ -117,8 +123,8 @@ func NewLDAPBackend(config *LDAPConfig) (*ldapBackend, error) {
}, nil
}
func (b *ldapBackend) GetKeys(ctx context.Context, username string) [][]byte {
result, err := b.pool.Search(ctx, b.config.Query.searchRequest(username))
func (b *ldapBackend) GetPrivateKeys(ctx context.Context, username string) [][]byte {
result, err := b.pool.Search(ctx, b.config.Query.searchRequest(username, b.config.Query.PrivateKeyAttr))
if err != nil {
log.Printf("LDAP error: %v", err)
return nil
......@@ -126,8 +132,30 @@ func (b *ldapBackend) GetKeys(ctx context.Context, username string) [][]byte {
var out [][]byte
for _, ent := range result.Entries {
k := []byte(ent.GetAttributeValue(b.config.Query.Attr))
out = append(out, k)
for _, val := range ent.GetAttributeValues(b.config.Query.PrivateKeyAttr) {
out = append(out, []byte(val))
}
}
return out
}
func (b *ldapBackend) GetPublicKey(ctx context.Context, username string) []byte {
result, err := b.pool.Search(ctx, b.config.Query.searchRequest(username, b.config.Query.PublicKeyAttr))
if err != nil {
log.Printf("LDAP error: %v", err)
return nil
}
if len(result.Entries) == 0 {
return nil
}
if len(result.Entries) > 1 {
log.Printf("public key query for %s returned too many results (%d)", username, len(result.Entries))
return nil
}
s := result.Entries[0].GetAttributeValue(b.config.Query.PublicKeyAttr)
if s == "" {
return nil
}
return []byte(s)
}
package main
import (
"flag"
"io/ioutil"
"log"
"os"
"os/signal"
"syscall"
"time"
"git.autistici.org/ai3/go-common/unix"
"github.com/coreos/go-systemd/daemon"
"gopkg.in/yaml.v2"
"git.autistici.org/id/keystore/dovecot"
)
var (
configFile = flag.String("config", "/etc/keystore/dovecot.yml", "path of config file")
socketPath = flag.String("socket", "/run/dovecot-keystored/socket", "`path` to the UNIX socket to listen on")
systemdSocketActivation = flag.Bool("systemd-socket", false, "use SystemD socket activation")
requestTimeout = flag.Duration("timeout", 5*time.Second, "timeout for incoming requests")
)
// Read YAML config.
func loadConfig() (*dovecot.Config, error) {
data, err := ioutil.ReadFile(*configFile)
if err != nil {
return nil, err
}
var config dovecot.Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, err
}
return &config, nil
}
func main() {
log.SetFlags(0)
flag.Parse()
syscall.Umask(007)
config, err := loadConfig()
if err != nil {
log.Fatal(err)
}
ddp, err := dovecot.NewKeyLookupProxy(config)
if err != nil {
log.Fatal(err)
}
srv := unix.NewLineServer(dovecot.NewDictProxyServer(ddp))
srv.RequestTimeout = *requestTimeout
var sockSrv *unix.SocketServer
if *systemdSocketActivation {
sockSrv, err = unix.NewSystemdSocketServer(srv)
} else {
sockSrv, err = unix.NewUNIXSocketServer(*socketPath, srv)
}
if err != nil {
log.Fatalf("error: %v", err)
}
done := make(chan struct{})
sigCh := make(chan os.Signal, 1)
go func() {
<-sigCh
log.Printf("terminating")
sockSrv.Close()
close(done)
}()
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
log.Printf("starting")
daemon.SdNotify(false, "READY=1")
if err := sockSrv.Serve(); err != nil {
log.Fatal(err)
}
<-done
}
package dovecot
import (
"bytes"
"context"
"encoding/json"
"errors"
"git.autistici.org/ai3/go-common/unix"
)
var (
failResponse = []byte{'F', '\n'}
noMatchResponse = []byte{'N', '\n'}
)
// DictDatabase is an interface to a key/value store by way of the Lookup
// method.
type DictDatabase interface {
Lookup(context.Context, string) (interface{}, bool)
}
// DictProxyServer exposes a Database using the Dovecot dict proxy
// protocol (see https://wiki2.dovecot.org/AuthDatabase/Dict).
//
// It implements the unix.LineHandler interface from the
// ai3/go-common/unix package.
type DictProxyServer struct {
db DictDatabase
}
// NewDictProxyServer creates a new DictProxyServer.
func NewDictProxyServer(db DictDatabase) *DictProxyServer {
return &DictProxyServer{db: db}
}
// ServeLine handles a single command.
func (p *DictProxyServer) ServeLine(ctx context.Context, lw unix.LineResponseWriter, line []byte) error {
if len(line) < 1 {
return errors.New("line too short")
}
switch line[0] {
case 'H':
return p.handleHello(ctx, lw, line[1:])
case 'L':
return p.handleLookup(ctx, lw, line[1:])
default:
return lw.WriteLine(failResponse)
}
}
func (p *DictProxyServer) handleHello(ctx context.Context, lw unix.LineResponseWriter, arg []byte) error {
// TODO: parse the hello line and extract useful information.
return nil
}
func (p *DictProxyServer) handleLookup(ctx context.Context, lw unix.LineResponseWriter, arg []byte) error {
obj, ok := p.db.Lookup(ctx, string(arg))
if !ok {
return lw.WriteLine(noMatchResponse)
}
var buf bytes.Buffer
buf.Write([]byte{'O'})
if err := json.NewEncoder(&buf).Encode(obj); err != nil {
return err
}
buf.Write([]byte{'\n'})
return lw.WriteLine(buf.Bytes())
}
package dovecot
import (
"context"
"encoding/base64"
"errors"
"strings"
"git.autistici.org/ai3/go-common/clientutil"
"git.autistici.org/id/keystore/backend"
"git.autistici.org/id/keystore/client"
"git.autistici.org/id/keystore/userenckey"
)
// Config for the dovecot-keystore daemon.
type Config struct {
Shard string `yaml:"shard"`
LDAPConfig *backend.LDAPConfig `yaml:"ldap"`
Keystore *clientutil.BackendConfig `yaml:"keystore"`
}
// Database represents the interface to the underlying backend for
// encrypted user keys.
type Database interface {
GetPublicKey(context.Context, string) []byte
GetPrivateKeys(context.Context, string) [][]byte
}
func (c *Config) check() error {
if c.LDAPConfig == nil {
return errors.New("missing backend config")
}
return c.LDAPConfig.Valid()
}
type userdbResponse struct {
PublicKey string `json:"mail_crypt_global_public_key"`
}
type passdbResponse struct {
PrivateKey string `json:"mail_crypt_global_private_key"`
}
var passwordSep = "/"
// KeyLookupProxy interfaces Dovecot with the user encryption key database.
type KeyLookupProxy struct {
config *Config
keystore client.Client
db Database
}
func NewKeyLookupProxy(config *Config) (*KeyLookupProxy, error) {
if err := config.check(); err != nil {
return nil, err
}
ksc, err := client.New(config.Keystore)
if err != nil {
return nil, err
}
// There is only one supported backend type, ldap.
ldap, err := backend.NewLDAPBackend(config.LDAPConfig)
if err != nil {
return nil, err
}
return &KeyLookupProxy{
config: config,
keystore: ksc,
db: ldap,
}, nil
}
// Lookup a key using the dovecot dict proxy interface.
//
// We can be sent a userdb lookup, or a passdb lookup, and we can tell
// them apart from the structure of the key:
//
// If it contains passwordSep, then it's a passdb lookup and the key
// consists of 'username' and 'password'. Otherwise, it's a userdb
// lookup and the key is simply the username.
func (s *KeyLookupProxy) Lookup(ctx context.Context, key string) (interface{}, bool) {
if strings.Contains(key, passwordSep) {
kparts := strings.SplitN(key, passwordSep, 2)
return s.lookupPassdb(ctx, kparts[0], kparts[1])
}
return s.lookupUserdb(ctx, key)
}
func (s *KeyLookupProxy) lookupUserdb(ctx context.Context, username string) (interface{}, bool) {
pub := s.db.GetPublicKey(ctx, username)
if pub == nil {
return nil, false
}
return &userdbResponse{PublicKey: b64encode(pub)}, true
}
func (s *KeyLookupProxy) lookupPassdb(ctx context.Context, username, password string) (interface{}, bool) {
// If the password is a SSO token, try to fetch the
// unencrypted key from the keystore daemon.
priv, err := s.keystore.Get(ctx, s.config.Shard, username, password)
if err == nil {
return &passdbResponse{PrivateKey: b64encode(priv)}, true
}
// Otherwise, fetch encrypted keys from the db and attempt to
// decrypt them.
encKeys := s.db.GetPrivateKeys(ctx, username)
if len(encKeys) == 0 {
return nil, false
}
priv, err = userenckey.Decrypt(encKeys, []byte(password))
if err != nil {
return nil, false
}
return &passdbResponse{PrivateKey: b64encode(priv)}, true
}
func b64encode(b []byte) string {
return base64.StdEncoding.EncodeToString(b)
}
......@@ -11,19 +11,19 @@ import (
"git.autistici.org/id/go-sso"
"git.autistici.org/id/keystore/backend"
"git.autistici.org/id/keystore/userenckey"
)
var (
ErrNoKeys = errors.New("no keys available")
ErrBadPassword = errors.New("could not decrypt key with password")
ErrBadUser = errors.New("username does not match authentication token")
ErrInvalidTTL = errors.New("invalid ttl")
ErrNoKeys = errors.New("no keys available")
ErrBadUser = errors.New("username does not match authentication token")
ErrInvalidTTL = errors.New("invalid ttl")
)
// Database represents the interface to the underlying backend for
// encrypted user keys.
type Database interface {
GetKeys(context.Context, string) [][]byte
GetPrivateKeys(context.Context, string) [][]byte
}
type userKey struct {
......@@ -54,7 +54,7 @@ func (c *Config) check() error {
if c.LDAPConfig == nil {
return errors.New("missing backend config")
}
return nil
return c.LDAPConfig.Valid()
}
// KeyStore holds decrypted secrets for users in memory for a short
......@@ -133,21 +133,16 @@ func (s *KeyStore) Open(ctx context.Context, username, password string, ttlSecon
return ErrInvalidTTL
}
encKeys := s.db.GetKeys(ctx, username)
encKeys := s.db.GetPrivateKeys(ctx, username)
if len(encKeys) == 0 {
return ErrNoKeys
}
var pkey []byte
var err error
for _, key := range encKeys {
pkey, err = decrypt(key, []byte(password))
if err == nil {
break
}
}
// Naive and inefficient way of decrypting multiple keys: it
// will recompute the kdf every time, which is expensive.
pkey, err := userenckey.Decrypt(encKeys, []byte(password))
if err != nil {
return ErrBadPassword
return err
}
s.mx.Lock()
......
package server
package userenckey
import (
"errors"
......@@ -7,6 +7,8 @@ import (
"golang.org/x/crypto/scrypt"
)
var ErrBadPassword = errors.New("could not decrypt key with password")
const (
scryptN = 32768
scryptR = 8
......@@ -15,7 +17,20 @@ const (
saltLen = 32
)
func decrypt(data, pw []byte) ([]byte, error) {
// Decrypt one out of multiple keys with the specified password. The
// keys share the same cleartext, but have been encrypted with
// different passwords.
func Decrypt(encKeys [][]byte, pw []byte) ([]byte, error) {
for _, key := range encKeys {
dec, err := decryptData(key, pw)
if err != nil {
return dec, err
}
}
return nil, ErrBadPassword
}
func decryptData(data, pw []byte) ([]byte, error) {
// The KDF salt is prepended to the encrypted key.
if len(data) < saltLen {
return nil, errors.New("short data")
......
package ldaputil
import (
"fmt"
"gopkg.in/ldap.v2"
)
func ParseScope(s string) (int, error) {
switch s {
case "base":
return ldap.ScopeBaseObject, nil
case "one":
return ldap.ScopeSingleLevel, nil
case "sub":
return ldap.ScopeWholeSubtree, nil
default:
return 0, fmt.Errorf("unknown LDAP scope '%s'", s)
}
}
......@@ -40,10 +40,12 @@ func (p *ConnectionPool) connect(ctx context.Context) (*ldap.Conn, error) {
conn := ldap.NewConn(c, false)
conn.Start()
conn.SetTimeout(time.Until(deadline))
if _, err = conn.SimpleBind(ldap.NewSimpleBindRequest(p.bindDN, p.bindPw, nil)); err != nil {
conn.Close()
return nil, err
if p.bindDN != "" {
conn.SetTimeout(time.Until(deadline))
if _, err = conn.SimpleBind(ldap.NewSimpleBindRequest(p.bindDN, p.bindPw, nil)); err != nil {
conn.Close()
return nil, err
}
}
return conn, err
......
......@@ -10,16 +10,24 @@ import (
"git.autistici.org/ai3/go-common/clientutil"
)
// Treat all errors as potential network-level issues, except for a
// whitelist of LDAP protocol level errors that we know are benign.
// Interface matched by net.Error.
type hasTemporary interface {
Temporary() bool
}
// Treat network errors as temporary. Other errors are permanent by
// default.
func isTemporaryLDAPError(err error) bool {
ldapErr, ok := err.(*ldap.Error)
if !ok {
return true
}
switch ldapErr.ResultCode {
case ldap.ErrorNetwork:
return true
switch v := err.(type) {
case *ldap.Error:
switch v.ResultCode {
case ldap.ErrorNetwork:
return true
default:
return false
}
case hasTemporary:
return v.Temporary()
default:
return false
}
......@@ -32,6 +40,7 @@ func (p *ConnectionPool) Search(ctx context.Context, searchRequest *ldap.SearchR
err := clientutil.Retry(func() error {
conn, err := p.Get(ctx)
if err != nil {
// Here conn is nil, so we don't need to Release it.
if isTemporaryLDAPError(err) {
return clientutil.TempError(err)
}
......@@ -44,7 +53,7 @@ func (p *ConnectionPool) Search(ctx context.Context, searchRequest *ldap.SearchR
result, err = conn.Search(searchRequest)
if err != nil && isTemporaryLDAPError(err) {
p.Release(conn, nil)
p.Release(conn, err)
return clientutil.TempError(err)
}
p.Release(conn, err)
......
package unix
import (
"bufio"
"context"
"errors"
"io"
"log"
"net"
"net/textproto"
"os"
"sync"
"sync/atomic"
"time"
"github.com/coreos/go-systemd/activation"
"github.com/theckman/go-flock"
)
// Handler for UNIX socket server connections.
type Handler interface {
ServeConnection(c net.Conn)
}
// SocketServer accepts connections on a UNIX socket, speaking the
// line-based wire protocol, and dispatches incoming requests to the
// wrapped Server.
type SocketServer struct {
l net.Listener
lock *flock.Flock
closing atomic.Value
wg sync.WaitGroup
handler Handler
}
func newServer(l net.Listener, lock *flock.Flock, h Handler) *SocketServer {
s := &SocketServer{
l: l,
lock: lock,
handler: h,
}
s.closing.Store(false)
return s
}
// NewUNIXSocketServer returns a new SocketServer listening on the given path.
func NewUNIXSocketServer(socketPath string, h Handler) (*SocketServer, error) {
// The simplest workflow is: create a new socket, remove it on
// exit. However, if the program crashes, the socket might
// stick around and prevent the next execution from starting
// successfully. We could remove it before starting, but that
// would be dangerous if another instance was listening on
// that socket. So we wrap socket access with a file lock.
lock := flock.NewFlock(socketPath + ".lock")
locked, err := lock.TryLock()
if err != nil {
return nil, err
}
if !locked {
return nil, errors.New("socket is locked by another process")
}
addr, err := net.ResolveUnixAddr("unix", socketPath)
if err != nil {
return nil, err
}
// Always try to unlink the socket before creating it.
os.Remove(socketPath)
l, err := net.ListenUnix("unix", addr)
if err != nil {
return nil, err
}
return newServer(l, lock, h), nil
}
// NewSystemdSocketServer uses systemd socket activation, receiving
// the open socket as a file descriptor on exec.
func NewSystemdSocketServer(h Handler) (*SocketServer, error) {
listeners, err := activation.Listeners(false)
if err != nil {
return nil, err
}
// Our server loop implies a single listener, so find
// the first one passed by systemd and ignore all others.
// TODO: listen on all fds.
for _, l := range listeners {
if l != nil {
return newServer(l, nil, h), nil
}
}
return nil, errors.New("no available sockets found")
}
// Close the socket listener and release all associated resources.
// Waits for active connections to terminate before returning.
func (s *SocketServer) Close() {
s.closing.Store(true)
s.l.Close()
s.wg.Wait()
if s.lock != nil {
s.lock.Unlock()
}
}
func (s *SocketServer) isClosing() bool {
return s.closing.Load().(bool)
}
// Serve connections.
func (s *SocketServer) Serve() error {
for {
conn, err := s.l.Accept()
if err != nil {
if s.isClosing() {
return nil
}
return err
}
s.wg.Add(1)
go func() {
s.handler.ServeConnection(conn)
conn.Close()
s.wg.Done()
}()
}
}
// LineHandler is the handler for LineServer.
type LineHandler interface {
ServeLine(context.Context, LineResponseWriter, []byte) error
}
// ErrCloseConnection must be returned by a LineHandler when we want
// to cleanly terminate the connection without raising an error.