diff --git a/backend/sql/sql.go b/backend/sql/sql.go index 6f7622cbb67dbc2a4f25da150d555673a9c96950..408cc574d74536386cc9cd693a1d4161f01fdb4e 100644 --- a/backend/sql/sql.go +++ b/backend/sql/sql.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" _ "github.com/go-sql-driver/mysql" _ "github.com/lib/pq" @@ -32,6 +33,11 @@ func (c *sqlConfig) Valid() error { if c.URI == "" { return errors.New("empty uri") } + for _, q := range []string{sqlQueryGetPublicKey, sqlQueryGetPrivateKeys} { + if _, ok := c.Queries[q]; !ok { + return fmt.Errorf("missing SQL query '%s'", q) + } + } return nil } @@ -88,6 +94,7 @@ func (b *sqlBackend) GetPrivateKeys(ctx context.Context, username string) ([][]b if err := rows.Scan(&key); err != nil { return nil, err } + out = append(out, key) } return out, nil } @@ -102,6 +109,10 @@ func (b *sqlBackend) GetPublicKey(ctx context.Context, username string) ([]byte, row := tx.Stmt(b.privStmt).QueryRow(username) var key []byte if err := row.Scan(&key); err != nil { + if err == sql.ErrNoRows { + // No rows is not an error. + return nil, nil + } return nil, err } return key, nil diff --git a/dovecot/keyproxy_test.go b/dovecot/keyproxy_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ea806c342e717ba7db268b35200ed49d63f8092c --- /dev/null +++ b/dovecot/keyproxy_test.go @@ -0,0 +1,257 @@ +package dovecot + +import ( + "context" + "database/sql" + "encoding/hex" + "fmt" + "io/ioutil" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "git.autistici.org/ai3/go-common/clientutil" + "git.autistici.org/ai3/go-common/userenckey" + "git.autistici.org/id/go-sso" + "golang.org/x/crypto/ed25519" + "gopkg.in/yaml.v2" + + "git.autistici.org/id/keystore/backend" + keystore "git.autistici.org/id/keystore/server" +) + +func withTestDB(t testing.TB, schema string) (func(), string) { + dir, err := ioutil.TempDir("", "") + if err != nil { + t.Fatal(err) + } + dbPath := filepath.Join(dir, "test.db") + + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + _, err = db.Exec(schema) + if err != nil { + t.Fatalf("sql error: %v", err) + } + db.Close() + + return func() { + os.RemoveAll(dir) + }, dbPath +} + +func makeMapSlice(m map[string]interface{}) yaml.MapSlice { + var out yaml.MapSlice + for k, v := range m { + out = append(out, yaml.MapItem{Key: k, Value: v}) + } + return out +} + +var ( + testPw1 = []byte("password1") + testPw2 = []byte("password2") +) + +// Set up a test keystore stack, including keyproxy, along with a test +// database and valid SSO credentials. +func createTestKeyProxy(t testing.TB) (func(), *keystore.KeyStore, *KeyLookupProxy, sso.Signer) { + // Set up a database with a simple schema and some real key + // data. The private key is then encrypted with the two valid + // test passwords. + encPub, encPriv, err := userenckey.GenerateKey() + if err != nil { + t.Fatal(err) + } + key1, err := userenckey.Encrypt(encPriv, testPw1) + if err != nil { + t.Fatal(err) + } + key2, err := userenckey.Encrypt(encPriv, testPw2) + if err != nil { + t.Fatal(err) + } + + cleanup, dbPath := withTestDB(t, fmt.Sprintf(` +CREATE TABLE users ( + email text NOT NULL, + public_key text +); +CREATE UNIQUE INDEX users_idx ON users(email); +INSERT INTO users (email, public_key) VALUES ( + 'test@example.com', '%s'), ( + 'nokeys@example.com', NULL +); +CREATE TABLE keys ( + email text NOT NULL, + key blob NOT NULL +); +INSERT INTO keys (email, key) VALUES ( + 'test@example.com', X'%s'), ( + 'test@example.com', X'%s' +); +`, string(encPub), hex.EncodeToString(key1), hex.EncodeToString(key2))) + + // Backend configuration is shared by the keystore and keyproxy. + backendConfig := &backend.Config{ + Type: "sql", + Params: makeMapSlice(map[string]interface{}{ + "driver": "sqlite3", + "db_uri": dbPath, + "queries": map[string]string{ + "get_public_key": "SELECT public_key FROM users WHERE email = ?", + "get_private_keys": "SELECT key FROM keys WHERE email = ?", + }, + }), + } + + // Start a KeyStore with its own HTTP server. This requires + // valid SSO credentials. + ssoPub, ssoPriv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + ssoSigner, err := sso.NewSigner(ssoPriv) + if err != nil { + t.Fatal(err) + } + pubFile := filepath.Join(filepath.Dir(dbPath), "sso.pub") + ioutil.WriteFile(pubFile, ssoPub, 0600) // nolint + ksConfig := keystore.Config{ + Backend: backendConfig, + SSOPublicKeyFile: pubFile, + SSOService: "service/", + SSODomain: "domain", + } + ks, err := keystore.NewKeyStore(&ksConfig) + if err != nil { + t.Fatalf("NewKeyStore: %v", err) + } + ksSrv := httptest.NewServer(keystore.NewServer(ks)) + + // Create the KeyProxy, connected to the KeyStore. + config := Config{ + Backend: backendConfig, + Keystore: &clientutil.BackendConfig{ + URL: ksSrv.URL, + }, + } + kp, err := NewKeyLookupProxy(&config) + if err != nil { + t.Fatalf("NewKeyLookupProxy: %v", err) + } + + return func() { + ksSrv.Close() + cleanup() + }, ks, kp, ssoSigner +} + +func TestKeyProxy_LookupPublicKey(t *testing.T) { + cleanup, _, kp, _ := createTestKeyProxy(t) + defer cleanup() + + testData := []struct { + username string + found bool + error bool + }{ + {"test@example.com", true, false}, + // User without a key -> no error. + {"nokeys@example.com", false, false}, + // User does not exist -> also no error. + {"UNKNOWN@example.com", false, false}, + } + + for _, td := range testData { + _, found, err := kp.Lookup(context.Background(), "shared/userdb/"+td.username) + hasError := (err != nil) + if hasError != td.error { + t.Errorf("Lookup(%s): err=%v", td.username, err) + } + if found != td.found { + t.Errorf("Lookup(%s): found=%v, expected %v", td.username, found, td.found) + } + } +} + +func TestKeyProxy_LookupPrivateKeys_FromDB(t *testing.T) { + cleanup, _, kp, _ := createTestKeyProxy(t) + defer cleanup() + + testData := []struct { + username string + password string + found bool + error bool + }{ + // Unlock the key from the database with both valid passwords. + {"test@example.com", string(testPw1), true, false}, + {"test@example.com", string(testPw2), true, false}, + // A bad password should always fail with an error, so that + // dovecot knows not to do anything. + {"test@example.com", "bad password", false, true}, + // These users have no encryption keys, so we should *not* + // return an error. + {"nokeys@example.com", "password", false, false}, + {"UNKNOWN@example.com", "password", false, false}, + } + + for _, td := range testData { + _, found, err := kp.Lookup(context.Background(), fmt.Sprintf("shared/passdb/%s/%s", td.username, td.password)) + hasError := (err != nil) + if hasError != td.error { + t.Errorf("Lookup(%s): err=%v", td.username, err) + } + if found != td.found { + t.Errorf("Lookup(%s): found=%v, expected %v", td.username, found, td.found) + } + } +} + +func TestKeyProxy_LookupPrivateKeys_FromKeystore(t *testing.T) { + cleanup, ks, kp, signer := createTestKeyProxy(t) + defer cleanup() + + // Unlock the key in KeyStore with the correct password. + if err := ks.Open(context.Background(), "test@example.com", string(testPw1), 600); err != nil { + t.Fatalf("ks.Open: %v", err) + } + + // Mint a good SSO token. + ssoToken, err := signer.Sign(sso.NewTicket("test@example.com", "service/", "domain", "", nil, 600*time.Second)) + if err != nil { + t.Fatalf("sso.Sign: %v", err) + } + + testData := []struct { + username string + password string + found bool + error bool + }{ + // Fetch the cached key using a SSO token. + {"test@example.com", ssoToken, true, false}, + // Even if we can't authenticate with the keystore, + // fallthru to the database should still work. + {"test@example.com", string(testPw1), true, false}, + {"test@example.com", string(testPw2), true, false}, + // A bad password should always fail. + {"test@example.com", "bad password", false, true}, + } + + for _, td := range testData { + _, found, err := kp.Lookup(context.Background(), fmt.Sprintf("shared/passdb/%s/%s", td.username, td.password)) + hasError := (err != nil) + if hasError != td.error { + t.Errorf("Lookup(%s): err=%v", td.username, err) + } + if found != td.found { + t.Errorf("Lookup(%s): found=%v, expected %v", td.username, found, td.found) + } + } +}