Merge "bgpwtf/cccampix: cronjobify ripe-sync"
diff --git a/bgpwtf/cccampix/pgpencryptor/BUILD.bazel b/bgpwtf/cccampix/pgpencryptor/BUILD.bazel
index 33af7b3..2596087 100644
--- a/bgpwtf/cccampix/pgpencryptor/BUILD.bazel
+++ b/bgpwtf/cccampix/pgpencryptor/BUILD.bazel
@@ -6,9 +6,13 @@
     importpath = "code.hackerspace.pl/hscloud/bgpwtf/cccampix/pgpencryptor",
     visibility = ["//visibility:private"],
     deps = [
+        "//bgpwtf/cccampix/pgpencryptor/gpg:go_default_library",
+        "//bgpwtf/cccampix/pgpencryptor/hkp:go_default_library",
+        "//bgpwtf/cccampix/pgpencryptor/model:go_default_library",
         "//bgpwtf/cccampix/proto:go_default_library",
         "//go/mirko:go_default_library",
         "@com_github_golang_glog//:go_default_library",
+        "@com_github_lib_pq//:go_default_library",
         "@org_golang_google_grpc//codes:go_default_library",
         "@org_golang_google_grpc//status:go_default_library",
     ],
diff --git a/bgpwtf/cccampix/pgpencryptor/gpg/BUILD.bazel b/bgpwtf/cccampix/pgpencryptor/gpg/BUILD.bazel
new file mode 100644
index 0000000..dbca7db
--- /dev/null
+++ b/bgpwtf/cccampix/pgpencryptor/gpg/BUILD.bazel
@@ -0,0 +1,8 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["gpg.go"],
+    importpath = "code.hackerspace.pl/hscloud/bgpwtf/cccampix/pgpencryptor/gpg",
+    visibility = ["//visibility:public"],
+)
diff --git a/bgpwtf/cccampix/pgpencryptor/gpg/gpg.go b/bgpwtf/cccampix/pgpencryptor/gpg/gpg.go
new file mode 100644
index 0000000..09fcc7f
--- /dev/null
+++ b/bgpwtf/cccampix/pgpencryptor/gpg/gpg.go
@@ -0,0 +1,193 @@
+package gpg
+
+import (
+	"context"
+	"encoding/hex"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"os/exec"
+	"path"
+	"strings"
+	"time"
+)
+
+var ExecutionTimeLimit = 30 * time.Second
+var BinaryPath = "gpg"
+
+type Encryptor interface {
+	ReadCipherText(size int) ([]byte, error)
+	WritePlainText(chunk []byte) error
+	Finish()
+	Close()
+}
+
+type EncryptorFactory interface {
+	Get(recipient []byte, keyRing []byte) (Encryptor, error)
+}
+
+type CLIEncryptorFactory struct {
+}
+
+func (CLIEncryptorFactory) Get(recipient []byte, keyRing []byte) (Encryptor, error) {
+	return NewCLIEncryptor(recipient, keyRing)
+}
+
+type CLIEncryptor struct {
+	tempDir     string
+	recipient   []byte
+	cmd         *exec.Cmd
+	stdout      io.ReadCloser
+	stderr      io.ReadCloser
+	stdin       io.WriteCloser
+	initialized bool
+}
+
+func NewCLIEncryptor(recipient []byte, keyRing []byte) (*CLIEncryptor, error) {
+	temp, err := getGpgTempDir()
+	if err != nil {
+		return nil, err
+	}
+
+	keyRingTempPath := path.Join(temp, "_keyring")
+	err = ioutil.WriteFile(keyRingTempPath, keyRing, 0600)
+	if err != nil {
+		return nil, err
+	}
+
+	// TODO(lb5tr): test for command injection
+	importParams := []string{
+		"--homedir", temp,
+		"--import", keyRingTempPath,
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), ExecutionTimeLimit)
+	defer cancel()
+
+	cmd := exec.CommandContext(ctx, BinaryPath, importParams...)
+	err = cmd.Start()
+	if err != nil {
+		return nil, fmt.Errorf("failed to start import command: %v", err)
+	}
+
+	err = cmd.Wait()
+	if err != nil {
+		return nil, fmt.Errorf("workspace initialization failed: %v", err)
+	}
+
+	ok, err := isOkExitCode(cmd)
+	if !ok || err != nil {
+		return nil, fmt.Errorf("failed to initialize gpg workspace due to gpg execution failure")
+	}
+
+	// spawn background encryption process
+	// TODO(lb5tr): test for command injection
+	encryptParams := []string{
+		"--encrypt",
+		"--homedir", temp,
+		"--recipient", strings.ToUpper(hex.EncodeToString(recipient)),
+		"--trust-model", "always",
+		"--yes",
+		"--batch",
+	}
+
+	cmd = exec.Command(BinaryPath, encryptParams...)
+	stdout, stderr, stdin, err := makePipes(cmd)
+	if err != nil {
+		return nil, err
+	}
+
+	err = cmd.Start()
+	if err != nil {
+		return nil, fmt.Errorf("failed to start encryptor process: %v", err)
+	}
+
+	encryptor := CLIEncryptor{
+		tempDir:     temp,
+		recipient:   recipient,
+		cmd:         cmd,
+		stdin:       stdin,
+		stderr:      stderr,
+		stdout:      stdout,
+		initialized: true,
+	}
+
+	return &encryptor, nil
+}
+
+func (encryptor *CLIEncryptor) WritePlainText(chunk []byte) error {
+	if !encryptor.initialized {
+		return fmt.Errorf("encryptor is not initialized")
+	}
+
+	encryptor.stdin.Write(chunk)
+	return nil
+}
+
+func (encryptor *CLIEncryptor) ReadCipherText(size int) ([]byte, error) {
+	if !encryptor.initialized {
+		return nil, fmt.Errorf("encryptor is not initialized")
+	}
+
+	buf := make([]byte, size)
+	n, err := encryptor.stdout.Read(buf)
+
+	return buf[:n], err
+}
+
+func (encryptor *CLIEncryptor) Finish() {
+	encryptor.stdin.Close()
+}
+
+func (encryptor *CLIEncryptor) Close() {
+	encryptor.stdout.Close()
+	encryptor.stderr.Close()
+	encryptor.stdin.Close()
+
+	os.RemoveAll(encryptor.tempDir)
+}
+
+func getGpgTempDir() (string, error) {
+	temp, err := ioutil.TempDir("", "gpg-")
+	if err != nil {
+		return "", fmt.Errorf("failed to create temporary gpg workspace %v", err)
+	}
+
+	return temp, nil
+}
+
+func cleanupProcess(cmd *exec.Cmd) {
+	if !cmd.ProcessState.Exited() {
+		cmd.Process.Kill()
+	}
+}
+
+func isOkExitCode(cmd *exec.Cmd) (bool, error) {
+	exitCode := cmd.ProcessState.ExitCode()
+
+	if exitCode == -1 {
+		return false, fmt.Errorf("process is either still runing or was terminated by a signal")
+	}
+
+	return exitCode == 0, nil
+}
+
+func makePipes(cmd *exec.Cmd) (stdout io.ReadCloser, stderr io.ReadCloser, stdin io.WriteCloser, error error) {
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		return nil, nil, nil, fmt.Errorf("cmd.StdoutPipe() failed %v", err)
+	}
+
+	stdin, err = cmd.StdinPipe()
+	if err != nil {
+		return nil, nil, nil, fmt.Errorf("cmd.StdinPipe() failed %v", err)
+	}
+
+	stderr, err = cmd.StderrPipe()
+	if err != nil {
+		return nil, nil, nil, fmt.Errorf("cmd.StderrPipe() failed %v", err)
+	}
+
+	return stdout, stderr, stdin, nil
+}
diff --git a/bgpwtf/cccampix/pgpencryptor/hkp/BUILD.bazel b/bgpwtf/cccampix/pgpencryptor/hkp/BUILD.bazel
new file mode 100644
index 0000000..a2377da
--- /dev/null
+++ b/bgpwtf/cccampix/pgpencryptor/hkp/BUILD.bazel
@@ -0,0 +1,8 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["hkp.go"],
+    importpath = "code.hackerspace.pl/hscloud/bgpwtf/cccampix/pgpencryptor/hkp",
+    visibility = ["//visibility:public"],
+)
diff --git a/bgpwtf/cccampix/pgpencryptor/hkp/hkp.go b/bgpwtf/cccampix/pgpencryptor/hkp/hkp.go
new file mode 100644
index 0000000..bb9ac08
--- /dev/null
+++ b/bgpwtf/cccampix/pgpencryptor/hkp/hkp.go
@@ -0,0 +1,133 @@
+package hkp
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"time"
+)
+
+// TODO(lb5tr): provide as flag
+var keyServers = []string{
+	"http://pool.sks-keyservers.net",
+	"http://keys.gnupg.net",
+}
+
+var (
+	PerServerTimeLimit  = 5 * time.Second
+	PerServerRetryCount = 3
+)
+
+var ErrKeyNotFound = errors.New("not found on hkp servers")
+
+const startMarker string = "-----BEGIN PGP PUBLIC KEY BLOCK-----"
+const endMarker string = "-----END PGP PUBLIC KEY BLOCK-----"
+
+type Client interface {
+	GetKeyRing(ctx context.Context, keyID []byte) ([]byte, error)
+}
+
+type transport interface {
+	get(ctx context.Context, path string) ([]byte, error)
+}
+
+type httpTransport struct {
+}
+
+type HKP struct {
+	transport transport
+}
+
+func NewClient() Client {
+	client := HKP{
+		transport: httpTransport{},
+	}
+	return client
+}
+
+func (hkp HKP) GetKeyRing(ctx context.Context, keyID []byte) ([]byte, error) {
+	key := fmt.Sprintf("0x%x", keyID)
+	output := make(chan []byte)
+	errors := make(chan error)
+
+	go func() {
+		var lastError error
+		for _, server := range keyServers {
+			url := server + "/pks/lookup?op=get&search=" + key
+			for i := 0; i < PerServerRetryCount; i++ {
+				localCtx, cancel := context.WithTimeout(context.Background(), PerServerTimeLimit)
+				keyData, err := hkp.transport.get(localCtx, url)
+				cancel()
+
+				// ErrKeyNotFound is retriable. I've seen cases where upon retry
+				// server responds with key just fine
+
+				switch err {
+				case nil:
+					output <- keyData
+					return
+				case ctx.Err():
+					errors <- err
+					return
+				default:
+					lastError = err
+				}
+			}
+		}
+
+		errors <- lastError
+	}()
+
+	select {
+	case <-ctx.Done():
+		return nil, ctx.Err()
+	case finalError := <-errors:
+		return nil, finalError
+	case result := <-output:
+		return result, nil
+	}
+}
+
+func (httpTransport) get(ctx context.Context, url string) ([]byte, error) {
+	localCtx, cancel := context.WithTimeout(ctx, PerServerTimeLimit)
+	defer cancel()
+
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return nil, fmt.Errorf("http.NewRequest(GET, %q): %v", url, err)
+	}
+
+	req = req.WithContext(localCtx)
+	client := http.DefaultClient
+	res, err := client.Do(req)
+
+	if err != nil {
+		return nil, fmt.Errorf("client.Do(%v): %v", req, err)
+	}
+
+	defer res.Body.Close()
+
+	if res.StatusCode != 200 {
+		if res.StatusCode == 404 {
+			return nil, ErrKeyNotFound
+		}
+
+		return nil, fmt.Errorf("got status code %d", res.StatusCode)
+	}
+
+	buf := bytes.NewBuffer([]byte{})
+	buf.ReadFrom(res.Body)
+	response := buf.Bytes()
+
+	start := bytes.Index(response, []byte(startMarker))
+	end := bytes.Index(response, []byte(endMarker))
+
+	if start == -1 || end == -1 {
+		return nil, fmt.Errorf("failed to read")
+	}
+
+	data := response[start : end+len(endMarker)]
+	return data, nil
+}
diff --git a/bgpwtf/cccampix/pgpencryptor/main.go b/bgpwtf/cccampix/pgpencryptor/main.go
index d8e410a..3d73e01 100644
--- a/bgpwtf/cccampix/pgpencryptor/main.go
+++ b/bgpwtf/cccampix/pgpencryptor/main.go
@@ -3,35 +3,248 @@
 import (
 	"context"
 	"flag"
-
 	"github.com/golang/glog"
+	"github.com/lib/pq"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
+	"io"
+	"time"
 
+	"code.hackerspace.pl/hscloud/bgpwtf/cccampix/pgpencryptor/gpg"
+	"code.hackerspace.pl/hscloud/bgpwtf/cccampix/pgpencryptor/hkp"
+	"code.hackerspace.pl/hscloud/bgpwtf/cccampix/pgpencryptor/model"
 	pb "code.hackerspace.pl/hscloud/bgpwtf/cccampix/proto"
 	"code.hackerspace.pl/hscloud/go/mirko"
 )
 
+var (
+	flagMaxClients     int
+	flagChunkSize      int
+	flagHkpMaxWaitTime time.Duration
+	flagDSN            string
+)
+
 type service struct {
+	hkpClient        hkp.Client
+	encryptorFactory gpg.EncryptorFactory
+	clients          chan (struct{})
+	model            model.Model
 }
 
 func (s *service) KeyInfo(ctx context.Context, req *pb.KeyInfoRequest) (*pb.KeyInfoResponse, error) {
-	return nil, status.Error(codes.Unimplemented, "not implemented yet")
+	var data []byte
+	var err error
+
+	switch req.Caching {
+	case pb.KeyInfoRequest_CACHING_AUTO:
+		_, err := s.model.GetKey(ctx, req.Fingerprint)
+		if err != nil {
+			data, err = s.hkpClient.GetKeyRing(ctx, req.Fingerprint)
+			switch err {
+			case nil:
+				break
+			case hkp.ErrKeyNotFound:
+				return nil, status.Errorf(codes.NotFound, "key not found: %v", err)
+			default:
+				return nil, status.Errorf(codes.Unavailable, "failed to get key from HKP servers: %v", err)
+			}
+		}
+	case pb.KeyInfoRequest_CACHING_FORCE_REMOTE:
+		data, err = s.hkpClient.GetKeyRing(ctx, req.Fingerprint)
+		switch err {
+		case nil:
+			break
+		case hkp.ErrKeyNotFound:
+			return nil, status.Errorf(codes.NotFound, "key not found: %v", err)
+		default:
+			return nil, status.Errorf(codes.Unavailable, "failed to get key from HKP servers: %v", err)
+		}
+	case pb.KeyInfoRequest_CACHING_FORCE_LOCAL:
+		_, err := s.model.GetKey(ctx, req.Fingerprint)
+		switch err {
+		case nil:
+			break
+		case model.ErrKeyNotFound:
+			return nil, status.Errorf(codes.NotFound, "key not found: %v", err)
+		default:
+			return nil, status.Errorf(codes.Unavailable, "failed to read key from local db: %v", err)
+		}
+	default:
+		return nil, status.Errorf(codes.InvalidArgument, "caching field value is invalid")
+	}
+
+	// successfully read fresh key from hkp, update db
+	if data != nil {
+		err := s.model.PutKey(ctx, &model.PgpKey{
+			Fingerprint: req.Fingerprint,
+			KeyData:     data,
+			Okay:        true,
+		})
+
+		if err != nil {
+			return nil, status.Errorf(codes.Unavailable, "failed to cache key received from HKP: %v", err)
+		}
+	}
+
+	return &pb.KeyInfoResponse{}, nil
 }
 
 func (s *service) Encrypt(stream pb.PGPEncryptor_EncryptServer) error {
-	return status.Error(codes.Unimplemented, "not implemented yet")
+	select {
+	case s.clients <- struct{}{}:
+		break
+	case <-stream.Context().Done():
+		return status.Errorf(codes.ResourceExhausted, "PGPEncryptor to many sessions running, try again later")
+	}
+
+	defer func() {
+		<-s.clients
+	}()
+
+	ctx, _ := context.WithTimeout(context.Background(), flagHkpMaxWaitTime)
+	initialMessage, err := stream.Recv()
+	if err != nil {
+		return status.Errorf(codes.Canceled, "failed to read data from the client: %v", err)
+	}
+
+	key, err := s.model.GetKey(ctx, initialMessage.Fingerprint)
+	if err != nil {
+		if err != nil {
+			switch err {
+			case model.ErrKeyNotFound:
+				return status.Errorf(codes.NotFound, "recipient key not found: %v", err)
+			default:
+				return status.Errorf(codes.Unavailable, "error when getting keyring: %v", err)
+			}
+		}
+	}
+
+	if key.Okay == false {
+		return status.Errorf(codes.InvalidArgument, "cached key is invalid, call PGPEncryptor.KeyInfo with caching=FORCE_REMOTE to refresh")
+	}
+
+	senderDone := make(chan struct{})
+	errors := make(chan error)
+	enc, err := s.encryptorFactory.Get(key.Fingerprint, key.KeyData)
+	defer enc.Close()
+
+	if err != nil {
+		return status.Errorf(codes.Unavailable, "PGPEncryptor error while creating encryptor: %v", err)
+	}
+
+	err = enc.WritePlainText(initialMessage.Data)
+	if err != nil {
+		return err
+	}
+
+	// keep reading incoming messages and pass them to the encryptor
+	go func() {
+		defer enc.Finish()
+
+		for {
+			in, err := stream.Recv()
+
+			if err != nil {
+				if err == io.EOF {
+					return
+				}
+
+				errors <- status.Errorf(codes.Unavailable, "PGPEncryptor error while receiving message: %v", err)
+				return
+			}
+
+			err = enc.WritePlainText(in.Data)
+			if err != nil {
+				errors <- status.Errorf(codes.Unavailable, "PGPEncryptor error while writing data to encryptor: %v", err)
+				return
+			}
+
+			if in.Info == pb.EncryptRequest_CHUNK_LAST {
+				return
+			}
+		}
+	}()
+
+	// start sender routine
+	go func() {
+		defer close(senderDone)
+
+		for {
+			data, err := enc.ReadCipherText(flagChunkSize)
+			if err != nil && err != io.EOF {
+				errors <- status.Errorf(codes.Unavailable, "PGPEncryptor error while reading cipher stream: %v", err)
+				return
+			}
+
+			info := pb.EncryptResponse_CHUNK_INFO_MORE
+			if err == io.EOF {
+				info = pb.EncryptResponse_CHUNK_LAST
+			}
+
+			res := &pb.EncryptResponse{
+				Data: data,
+				Info: info,
+			}
+
+			err = stream.Send(res)
+			if err != nil {
+				errors <- status.Errorf(codes.Unavailable, "PGPEncryptor error while sending data to client: %v", err)
+				return
+			}
+
+			if info == pb.EncryptResponse_CHUNK_LAST {
+				return
+			}
+		}
+	}()
+
+	// sync with sender routine
+	select {
+	case <-senderDone:
+		return nil
+	case err := <-errors:
+		return err
+	}
 }
 
 func main() {
+	flag.IntVar(&flagMaxClients, "maxClients", 20, "maximum number of concurrent encryption sessions")
+	flag.IntVar(&flagChunkSize, "chunkSize", 1024*8, "maximum size of chunk sent back to client")
+	flag.DurationVar(&flagHkpMaxWaitTime, "hkpMaxWaitTime", 10*time.Second, "maximum time awaiting reply from HKP")
+	flag.StringVar(&flagDSN, "dsn", "", "PostrgreSQL connection string")
+
+	flag.DurationVar(&gpg.ExecutionTimeLimit, "gpgExecutionTimeLimit", 30*time.Second, "execution time limit for gpg commands")
+	flag.StringVar(&gpg.BinaryPath, "gpgPath", "gpg", "path to gpg binary")
+
+	flag.DurationVar(&hkp.PerServerTimeLimit, "hkpPerServerTimeLimit", 5*time.Second, "time for HKP server to reply with key")
+	flag.IntVar(&hkp.PerServerRetryCount, "hkpPerServerRetryCount", 3, "retry count per HKP server")
 	flag.Parse()
+
+	// Picking an existing postgres-like driver for sqlx.BindType to work
+	// See: https://github.com/jmoiron/sqlx/blob/ed7c52c43ee1e12a35efbcfea8dbae2d62a90370/bind.go#L24
+	mirko.TraceSQL(&pq.Driver{}, "pgx")
 	mi := mirko.New()
 
+	m, err := model.Connect(mi.Context(), "pgx", flagDSN)
+	if err != nil {
+		glog.Exitf("Failed to create model: %v", err)
+	}
+
+	err = m.MigrateUp()
+	if err != nil {
+		glog.Exitf("Failed to migrate up: %v", err)
+	}
+
 	if err := mi.Listen(); err != nil {
 		glog.Exitf("Listen failed: %v", err)
 	}
 
-	s := &service{}
+	s := &service{
+		hkpClient:        hkp.NewClient(),
+		encryptorFactory: gpg.CLIEncryptorFactory{},
+		clients:          make(chan struct{}, flagMaxClients),
+		model:            m,
+	}
 	pb.RegisterPGPEncryptorServer(mi.GRPC(), s)
 
 	if err := mi.Serve(); err != nil {
diff --git a/bgpwtf/cccampix/pgpencryptor/model/BUILD.bazel b/bgpwtf/cccampix/pgpencryptor/model/BUILD.bazel
new file mode 100644
index 0000000..b8cd8f4
--- /dev/null
+++ b/bgpwtf/cccampix/pgpencryptor/model/BUILD.bazel
@@ -0,0 +1,19 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = [
+        "model.go",
+        "pgp.go",
+        "schema.go",
+    ],
+    importpath = "code.hackerspace.pl/hscloud/bgpwtf/cccampix/pgpencryptor/model",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//bgpwtf/cccampix/pgpencryptor/model/migrations:go_default_library",
+        "@com_github_golang_migrate_migrate_v4//:go_default_library",
+        "@com_github_golang_migrate_migrate_v4//database/cockroachdb:go_default_library",
+        "@com_github_jmoiron_sqlx//:go_default_library",
+        "@com_github_lib_pq//:go_default_library",
+    ],
+)
diff --git a/bgpwtf/cccampix/pgpencryptor/model/migrations/1565567797_init.down.sql b/bgpwtf/cccampix/pgpencryptor/model/migrations/1565567797_init.down.sql
new file mode 100644
index 0000000..ccfaa52
--- /dev/null
+++ b/bgpwtf/cccampix/pgpencryptor/model/migrations/1565567797_init.down.sql
@@ -0,0 +1 @@
+DROP TABLE pgp_keyrings;
diff --git a/bgpwtf/cccampix/pgpencryptor/model/migrations/1565567797_init.up.sql b/bgpwtf/cccampix/pgpencryptor/model/migrations/1565567797_init.up.sql
new file mode 100644
index 0000000..d1c6209
--- /dev/null
+++ b/bgpwtf/cccampix/pgpencryptor/model/migrations/1565567797_init.up.sql
@@ -0,0 +1,9 @@
+CREATE TABLE pgp_keys (
+       id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+       fingerprint STRING NOT NULL,
+       time_created INT NOT NULL,
+       okay BOOL NOT NULL,
+       key_data STRING,
+
+       UNIQUE(fingerprint)
+);
diff --git a/bgpwtf/cccampix/pgpencryptor/model/migrations/BUILD.bazel b/bgpwtf/cccampix/pgpencryptor/model/migrations/BUILD.bazel
new file mode 100644
index 0000000..e6c3bb0
--- /dev/null
+++ b/bgpwtf/cccampix/pgpencryptor/model/migrations/BUILD.bazel
@@ -0,0 +1,23 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+load("@io_bazel_rules_go//extras:embed_data.bzl", "go_embed_data")
+
+go_embed_data(
+    name = "migrations_data",
+    srcs = glob(["*.sql"]),
+    package = "migrations",
+    flatten = True,
+)
+
+go_library(
+    name = "go_default_library",
+    srcs = [
+        "migrations.go",
+        ":migrations_data",  # keep
+    ],
+    importpath = "code.hackerspace.pl/hscloud/bgpwtf/cccampix/pgpencryptor/model/migrations",
+    visibility = ["//bgpwtf/cccampix/pgpencryptor/model:__subpackages__"],
+    deps = [
+        "//go/mirko:go_default_library",
+        "@com_github_golang_migrate_migrate_v4//:go_default_library",
+    ],
+)
diff --git a/bgpwtf/cccampix/pgpencryptor/model/migrations/migrations.go b/bgpwtf/cccampix/pgpencryptor/model/migrations/migrations.go
new file mode 100644
index 0000000..5e72e6e
--- /dev/null
+++ b/bgpwtf/cccampix/pgpencryptor/model/migrations/migrations.go
@@ -0,0 +1,15 @@
+package migrations
+
+import (
+	"code.hackerspace.pl/hscloud/go/mirko"
+	"fmt"
+	"github.com/golang-migrate/migrate/v4"
+)
+
+func New(dburl string) (*migrate.Migrate, error) {
+	source, err := mirko.NewMigrationsFromBazel(Data)
+	if err != nil {
+		return nil, fmt.Errorf("could not create migrations: %v", err)
+	}
+	return migrate.NewWithSourceInstance("bazel", source, dburl)
+}
diff --git a/bgpwtf/cccampix/pgpencryptor/model/model.go b/bgpwtf/cccampix/pgpencryptor/model/model.go
new file mode 100644
index 0000000..4de3d4e
--- /dev/null
+++ b/bgpwtf/cccampix/pgpencryptor/model/model.go
@@ -0,0 +1,58 @@
+package model
+
+import (
+	"code.hackerspace.pl/hscloud/bgpwtf/cccampix/pgpencryptor/model/migrations"
+	"context"
+	"fmt"
+	migrate "github.com/golang-migrate/migrate/v4"
+	_ "github.com/golang-migrate/migrate/v4/database/cockroachdb"
+	"github.com/jmoiron/sqlx"
+	_ "github.com/lib/pq"
+	"strings"
+)
+
+type Model interface {
+	MigrateUp() error
+	PutKey(ctx context.Context, key *PgpKey) error
+	GetKey(ctx context.Context, keyID []byte) (*PgpKey, error)
+}
+
+type sqlModel struct {
+	db  *sqlx.DB
+	dsn string
+}
+
+type PgpKey struct {
+	Fingerprint []byte
+	KeyData     []byte
+	Okay        bool
+}
+
+func Connect(ctx context.Context, driver, dsn string) (Model, error) {
+	if dsn == "" {
+		return nil, fmt.Errorf("dsn cannot be empty")
+	}
+	db, err := sqlx.ConnectContext(ctx, driver, dsn)
+	if err != nil {
+		return nil, fmt.Errorf("could not connect to database: %v", err)
+	}
+	return &sqlModel{
+		db:  db,
+		dsn: dsn,
+	}, nil
+}
+
+func (m *sqlModel) MigrateUp() error {
+	dsn := "cockroach://" + strings.TrimPrefix(m.dsn, "postgres://")
+	mig, err := migrations.New(dsn)
+	if err != nil {
+		return err
+	}
+	err = mig.Up()
+	switch err {
+	case migrate.ErrNoChange:
+		return nil
+	default:
+		return err
+	}
+}
diff --git a/bgpwtf/cccampix/pgpencryptor/model/pgp.go b/bgpwtf/cccampix/pgpencryptor/model/pgp.go
new file mode 100644
index 0000000..3a9c19b
--- /dev/null
+++ b/bgpwtf/cccampix/pgpencryptor/model/pgp.go
@@ -0,0 +1,83 @@
+package model
+
+import (
+	"context"
+	"database/sql"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"time"
+)
+
+var ErrKeyNotFound = errors.New("key not found in cache")
+
+func (s *sqlModel) GetKey(ctx context.Context, keyID []byte) (*PgpKey, error) {
+	q := `
+		SELECT fingerprint, okay, key_data
+		FROM pgp_keys
+		WHERE fingerprint = $1
+		LIMIT 1
+	`
+	data := sqlPGPKey{}
+	err := s.db.Get(&data, q, hex.EncodeToString(keyID))
+
+	if err == sql.ErrNoRows {
+		return nil, ErrKeyNotFound
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	fp, err := hex.DecodeString(data.Fingerprint)
+	if err != nil {
+		return nil, fmt.Errorf("data corruption: could not decode fingerprint")
+	}
+
+	kd, err := hex.DecodeString(data.KeyData)
+	if err != nil {
+		return nil, fmt.Errorf("data corruption: could not decode keydata")
+	}
+
+	key := PgpKey{
+		Fingerprint: fp,
+		KeyData:     kd,
+		Okay:        data.Okay,
+	}
+
+	return &key, err
+}
+
+func (s *sqlModel) PutKey(ctx context.Context, key *PgpKey) error {
+	q := `
+		INSERT INTO pgp_keys
+			(fingerprint, time_created, okay, key_data)
+			VALUES
+			(:fingerprint, :time_created, :okay, :key_data)
+		ON CONFLICT (fingerprint)
+		DO UPDATE SET
+			fingerprint = :fingerprint,
+			time_created = :time_created,
+			key_data = :key_data,
+			okay = :okay
+		WHERE pgp_keys.okay = FALSE
+	`
+
+	keyData := []byte{}
+	if key.KeyData != nil {
+		keyData = key.KeyData
+	}
+
+	data := &sqlPGPKey{
+		Fingerprint: hex.EncodeToString(key.Fingerprint),
+		KeyData:     hex.EncodeToString(keyData),
+		TimeCreated: time.Now().UnixNano(),
+		Okay:        key.Okay,
+	}
+
+	if _, err := s.db.NamedExecContext(ctx, q, data); err != nil {
+		return fmt.Errorf("INSERT pgp_keys: %v", err)
+	}
+
+	return nil
+}
diff --git a/bgpwtf/cccampix/pgpencryptor/model/schema.go b/bgpwtf/cccampix/pgpencryptor/model/schema.go
new file mode 100644
index 0000000..33cf839
--- /dev/null
+++ b/bgpwtf/cccampix/pgpencryptor/model/schema.go
@@ -0,0 +1,9 @@
+package model
+
+type sqlPGPKey struct {
+	ID          string `db:"id"`
+	Fingerprint string `db:"fingerprint"`
+	KeyData     string `db:"key_data"`
+	Okay        bool   `db:"okay"`
+	TimeCreated int64  `db:"time_created"`
+}