Commit c05eedc7 authored by ale's avatar ale

Initial commit

parents
package backend
import (
"context"
"errors"
"io/ioutil"
"log"
"strings"
"time"
"gopkg.in/ldap.v2"
)
type LDAPQueryConfig struct {
// SearchBase, SearchFilter and Scope define parameters for
// the LDAP search. The search should return a single object.
// SearchBase and SearchFilter can contain the string "%s",
// which will be replaced with the username before performing
// a query.
SearchBase string `yaml:"search_base"`
SearchFilter string `yaml:"search_filter"`
Scope string `yaml:"scope"`
// Attr is the LDAP attribute holding the encrypted user key.
Attr string `yaml:"attr"`
}
// Valid returns an error if the configuration is invalid.
func (c *LDAPQueryConfig) Valid() error {
if c.SearchBase == "" {
return errors.New("empty search_base")
}
if c.SearchFilter == "" {
return errors.New("empty search_filter")
}
if c.Scope != "one" && c.Scope != "sub" {
return errors.New("unknown scope")
}
if c.Attr == "" {
return errors.New("empty attr")
}
return nil
}
func (c *LDAPQueryConfig) searchRequest(username 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,
ldap.NeverDerefAliases,
0,
0,
false,
filter,
[]string{c.Attr},
nil,
)
}
// LDAPConfig holds the global configuration for the LDAP user backend.
type LDAPConfig struct {
URI string `yaml:"uri"`
BindDN string `yaml:"bind_dn"`
BindPwFile string `yaml:"bind_pw_file"`
Query *LDAPQueryConfig `yaml:"query"`
}
// Valid returns an error if the configuration is invalid.
func (c *LDAPConfig) Valid() error {
if c.URI == "" {
return errors.New("empty uri")
}
if c.BindDN == "" {
return errors.New("empty bind_dn")
}
if c.BindPwFile == "" {
return errors.New("empty bind_pw_file")
}
if c.Query == nil {
return errors.New("missing query configuration")
}
return c.Query.Valid()
}
type ldapBackend struct {
config *LDAPConfig
conn *ldap.Conn
}
func NewLDAPBackend(config *LDAPConfig) (*ldapBackend, error) {
// Validate configuration.
if err := config.Valid(); err != nil {
return nil, err
}
// Read the bind password.
bindPw, err := ioutil.ReadFile(config.BindPwFile)
if err != nil {
return nil, err
}
// Connect.
conn, err := ldap.Dial("unix", "/var/lib/ldapi")
if err != nil {
return nil, err
}
if err = conn.Bind(config.BindDN, strings.TrimSpace(string(bindPw))); err != nil {
conn.Close()
return nil, err
}
return &ldapBackend{
config: config,
conn: conn,
}, nil
}
func (b *ldapBackend) GetKeys(ctx context.Context, username string) [][]byte {
// Try to turn the context deadline into a LDAP connection timeout...
if deadline, ok := ctx.Deadline(); ok {
b.conn.SetTimeout(time.Until(deadline))
}
result, err := b.conn.Search(b.config.Query.searchRequest(username))
if err != nil {
log.Printf("LDAP error: %v", err)
return nil
}
var out [][]byte
for _, ent := range result.Entries {
k := []byte(ent.GetAttributeValue(b.config.Query.Attr))
out = append(out, k)
}
return out
}
package main
import (
"flag"
"io/ioutil"
"log"
"os"
"strings"
"gopkg.in/yaml.v2"
"git.autistici.org/ai3/go-common/serverutil"
"git.autistici.org/id/keystore"
"git.autistici.org/id/keystore/server"
)
// func init() {
// if err := syscall.Mlockall(syscall.MCL_CURRENT | syscall.MCL_FUTURE); err != nil {
// panic(err)
// }
// }
var (
addr = flag.String("addr", ":5006", "address to listen on")
configFile = flag.String("config", "/etc/keystore/config.yml", "path of config file")
)
// Wrap the keystore.Config together with the server setup in a single
// configuration object.
type Config struct {
KeyStoreConfig *keystore.Config `yaml:"keystore"`
ServerConfig *serverutil.ServerConfig `yaml:"http_server"`
}
func loadConfig() (*Config, error) {
// Read YAML config.
data, err := ioutil.ReadFile(*configFile)
if err != nil {
return nil, err
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, err
}
return &config, nil
}
// Set defaults for command-line flags using variables from the environment.
func setFlagDefaultsFromEnv() {
flag.VisitAll(func(f *flag.Flag) {
envVar := "KEYSTORE_" + strings.ToUpper(strings.Replace(f.Name, "-", "_", -1))
if value := os.Getenv(envVar); value != "" {
f.DefValue = value
f.Value.Set(value)
}
})
}
func main() {
setFlagDefaultsFromEnv()
flag.Parse()
config, err := loadConfig()
if err != nil {
log.Fatal(err)
}
ks, err := keystore.New(config.KeyStoreConfig)
if err != nil {
log.Fatal(err)
}
srv := server.New(ks)
if err := serverutil.Serve(srv, config.ServerConfig, *addr); err != nil {
log.Fatal(err)
}
}
package keystore
import (
"github.com/miscreant/miscreant/go"
"golang.org/x/crypto/scrypt"
)
func decrypt(data, pw []byte) ([]byte, error) {
// Apply the key derivation function to the password to obtain
// a 64 byte key.
dk, err := scrypt.Key(pw, nil, 16384, 1, 8, 64)
if err != nil {
return nil, err
}
// Set up the AES-SIV secret box.
cipher, err := miscreant.NewAESCMACSIV(dk)
if err != nil {
return nil, err
}
return cipher.Open(nil, data)
}
package keystore
import (
"context"
"errors"
"io/ioutil"
"strings"
"sync"
"time"
"git.autistici.org/id/go-sso"
"git.autistici.org/id/keystore/backend"
)
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")
)
// Database represents the interface to the underlying backend for
// encrypted user keys.
type Database interface {
GetKeys(context.Context, string) [][]byte
}
type userKey struct {
pkey []byte
expiry time.Time
}
// Config for the KeyStore.
type Config struct {
SSOPublicKeyFile string `yaml:"sso_public_key_file"`
SSOService string `yaml:"sso_service"`
SSODomain string `yaml:"sso_domain"`
//Backend string `yaml:"backend"`
LDAPConfig *backend.LDAPConfig `yaml:"ldap"`
}
func (c *Config) check() error {
if c.SSOService == "" {
return errors.New("sso_service is empty")
}
if !strings.HasSuffix(c.SSOService, "/") {
return errors.New("sso_service is invalid (does not end with /)")
}
if c.SSODomain == "" {
return errors.New("sso_domain is empty")
}
if c.LDAPConfig == nil {
return errors.New("missing backend config")
}
return nil
}
// KeyStore holds decrypted secrets for users in memory for a short
// time (of the order of a SSO session lifespan). User secrets can be
// opened with a password (used to decrypt the key, which is stored
// encrypted in a database), queried, and closed (forgotten).
//
// The database can provide multiple versions of the encrypted key (to
// support multiple decryption passwords), in which case we'll try
// them all sequentially until one of them decrypts successfully with
// the provided password.
//
// In order to query the KeyStore, you need to present a valid SSO
// token for the user whose secrets you would like to obtain.
//
type KeyStore struct {
mx sync.Mutex
userKeys map[string]userKey
db Database
service string
validator sso.Validator
}
// New creates a new KeyStore with the given config and returns it.
func New(config *Config) (*KeyStore, error) {
if err := config.check(); err != nil {
return nil, err
}
ssoKey, err := ioutil.ReadFile(config.SSOPublicKeyFile)
if err != nil {
return nil, err
}
v, err := sso.NewValidator(ssoKey, config.SSODomain)
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
}
s := &KeyStore{
userKeys: make(map[string]userKey),
service: config.SSOService,
validator: v,
db: ldap,
}
go s.expire()
return s, nil
}
func (s *KeyStore) expire() {
for t := range time.NewTicker(600 * time.Second).C {
s.mx.Lock()
for u, k := range s.userKeys {
if k.expiry.After(t) {
wipeBytes(k.pkey)
delete(s.userKeys, u)
}
}
s.mx.Unlock()
}
}
// Open the user's key store with the given password. If successful,
// the unencrypted user key will be stored for at most ttlSeconds, or
// until Close is called.
//
// A Context is needed because this method might issue an RPC.
func (s *KeyStore) Open(ctx context.Context, username, password string, ttlSeconds int) error {
if ttlSeconds == 0 {
return ErrInvalidTTL
}
encKeys := s.db.GetKeys(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
}
}
if err != nil {
return ErrBadPassword
}
s.mx.Lock()
s.userKeys[username] = userKey{
pkey: pkey,
expiry: time.Now().Add(time.Duration(ttlSeconds) * time.Second),
}
s.mx.Unlock()
return nil
}
// Get the unencrypted key for the specified user. The caller needs to
// provide a valid SSO ticket for the user.
func (s *KeyStore) Get(username, ssoTicket string) ([]byte, error) {
// Validate the SSO ticket.
tkt, err := s.validator.Validate(ssoTicket, "", s.service, nil)
if err != nil {
return nil, err
}
if tkt.User != username {
return nil, ErrBadUser
}
s.mx.Lock()
defer s.mx.Unlock()
u, ok := s.userKeys[username]
if !ok {
return nil, ErrNoKeys
}
return u.pkey, nil
}
// Close the user's key store and wipe the associated unencrypted key
// from memory.
func (s *KeyStore) Close(username string) {
s.mx.Lock()
if k, ok := s.userKeys[username]; ok {
wipeBytes(k.pkey)
delete(s.userKeys, username)
}
s.mx.Unlock()
}
func wipeBytes(b []byte) {
for i := 0; i < len(b); i++ {
b[i] = 0
}
}
package keystore
type OpenRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TTL int `json:"ttl"`
}
type OpenResponse struct{}
type GetRequest struct {
Username string `json:"username"`
SSOTicket string `json:"sso_ticket"`
}
type GetResponse struct {
Key []byte `json:"key"`
}
type CloseRequest struct {
Username string `json:"username"`
}
type CloseResponse struct{}
package server
import (
"log"
"net/http"
"git.autistici.org/ai3/go-common/serverutil"
"git.autistici.org/id/keystore"
)
var emptyResponse struct{}
type keyStoreServer struct {
*keystore.KeyStore
}
func (s *keyStoreServer) handleOpen(w http.ResponseWriter, r *http.Request) {
var req keystore.OpenRequest
if !serverutil.DecodeJSONRequest(w, r, &req) {
return
}
err := s.KeyStore.Open(r.Context(), req.Username, req.Password, req.TTL)
if err != nil {
log.Printf("Open(%s) error: %v", req.Username, err)
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
serverutil.EncodeJSONResponse(w, &emptyResponse)
}
func (s *keyStoreServer) handleGet(w http.ResponseWriter, r *http.Request) {
var req keystore.GetRequest
if !serverutil.DecodeJSONRequest(w, r, &req) {
return
}
key, err := s.KeyStore.Get(req.Username, req.SSOTicket)
if err != nil {
log.Printf("Get(%s) error: %v", req.Username, err)
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
serverutil.EncodeJSONResponse(w, &keystore.GetResponse{Key: key})
}
func (s *keyStoreServer) handleClose(w http.ResponseWriter, r *http.Request) {
var req keystore.CloseRequest
if !serverutil.DecodeJSONRequest(w, r, &req) {
return
}
s.KeyStore.Close(req.Username)
serverutil.EncodeJSONResponse(w, &emptyResponse)
}
func (s *keyStoreServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/open":
s.handleOpen(w, r)
case "/api/get_key":
s.handleGet(w, r)
case "/api/close":
s.handleClose(w, r)
default:
http.NotFound(w, r)
}
}
func New(ks *keystore.KeyStore) http.Handler {
return &keyStoreServer{ks}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment