oficina / docs Public

Add authenticated document content envelopes

Paul Campbell pushed by paul@keymail.dev fbe7ec697f76254ec5d7ee5b56273ad51dedc71a parent 3d5dfad
2 files changed, +187 −0
  • internal/docs/content_cipher.go +95 −0
  • internal/docs/content_cipher_test.go +92 −0
diff --git a/internal/docs/content_cipher.go b/internal/docs/content_cipher.go
new file mode 100644
index 0000000..fc837cc
--- /dev/null
+++ b/internal/docs/content_cipher.go
@@ -0,0 +1,95 @@
+package docs
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/hex"
+ "errors"
+ "strings"
+
+ "github.com/google/uuid"
+)
+
+const contentEncoding = 1
+const contentUpdateKind byte = 1
+const contentSnapshotKind byte = 2
+const contentKeyCheckKind byte = 3
+
+var developmentContentKey = sha256.Sum256([]byte("docs-development-content-key-not-a-secret"))
+var errContentKey = errors.New("docs: invalid content key")
+var errContentAuthentication = errors.New("docs: content authentication failed")
+var errContentEncoding = errors.New("docs: unsupported content encoding")
+
+type contentCipher struct {
+ aead cipher.AEAD
+ fingerprint string
+}
+
+func newContentCipher(encoded, env string) (*contentCipher, error) {
+ encoded = strings.TrimSpace(encoded)
+ var key []byte
+ if encoded == "" && env == "development" {
+ key = developmentContentKey[:]
+ } else {
+ var err error
+ key, err = base64.StdEncoding.Strict().DecodeString(encoded)
+ if err != nil || len(key) != 32 {
+ return nil, errContentKey
+ }
+ }
+ fingerprint := sha256.Sum256(key)
+ devFingerprint := sha256.Sum256(developmentContentKey[:])
+ if env != "development" && fingerprint == devFingerprint {
+ return nil, errContentKey
+ }
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, errContentKey
+ }
+ aead, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, err
+ }
+ return &contentCipher{aead: aead, fingerprint: hex.EncodeToString(fingerprint[:])}, nil
+}
+
+func contentAAD(kind byte, guid string, seq int64, abi uint32) ([]byte, error) {
+ id, err := uuid.Parse(guid)
+ if err != nil || seq < 0 {
+ return nil, errContentAuthentication
+ }
+ aad := append([]byte("oficina/docs/content/v1"), kind)
+ aad = append(aad, id[:]...)
+ aad = binary.BigEndian.AppendUint64(aad, uint64(seq))
+ aad = binary.BigEndian.AppendUint32(aad, abi)
+ return aad, nil
+}
+
+func (c *contentCipher) seal(kind byte, guid string, seq int64, abi uint32, plaintext []byte) ([]byte, error) {
+ aad, err := contentAAD(kind, guid, seq, abi)
+ if err != nil {
+ return nil, err
+ }
+ nonce := make([]byte, c.aead.NonceSize())
+ if _, err := rand.Read(nonce); err != nil {
+ return nil, err
+ }
+ return c.aead.Seal(nonce, nonce, plaintext, aad), nil
+}
+
+func (c *contentCipher) open(kind byte, guid string, seq int64, abi uint32, sealed []byte) ([]byte, error) {
+ aad, err := contentAAD(kind, guid, seq, abi)
+ if err != nil || len(sealed) < c.aead.NonceSize()+c.aead.Overhead() {
+ return nil, errContentAuthentication
+ }
+ n := c.aead.NonceSize()
+ plaintext, err := c.aead.Open(nil, sealed[:n], sealed[n:], aad)
+ if err != nil {
+ return nil, errContentAuthentication
+ }
+ return plaintext, nil
+}
diff --git a/internal/docs/content_cipher_test.go b/internal/docs/content_cipher_test.go
new file mode 100644
index 0000000..d0578b8
--- /dev/null
+++ b/internal/docs/content_cipher_test.go
@@ -0,0 +1,92 @@
+package docs
+
+import (
+ "bytes"
+ "encoding/base64"
+ "errors"
+ "testing"
+
+ "github.com/google/uuid"
+)
+
+func TestContentCipherAuthenticatesRowIdentity(t *testing.T) {
+ c, err := newContentCipher(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{41}, 32)), "production")
+ if err != nil {
+ t.Fatal(err)
+ }
+ guid := uuid.NewString()
+ body := []byte("private body not shared with another document")
+ sealed, err := c.seal(contentSnapshotKind, guid, 42, 4, body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := c.open(contentSnapshotKind, guid, 42, 4, sealed)
+ if err != nil || !bytes.Equal(got, body) {
+ t.Fatalf("round trip: %q, %v", got, err)
+ }
+ if bytes.Contains(sealed, body) {
+ t.Fatal("ciphertext contains plaintext")
+ }
+ for _, tc := range []struct {
+ name string
+ kind byte
+ guid string
+ seq int64
+ abi uint32
+ }{
+ {"document", contentSnapshotKind, uuid.NewString(), 42, 4},
+ {"sequence", contentSnapshotKind, guid, 43, 4},
+ {"kind", contentUpdateKind, guid, 42, 4},
+ {"abi", contentSnapshotKind, guid, 42, 5},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if _, err := c.open(tc.kind, tc.guid, tc.seq, tc.abi, sealed); !errors.Is(err, errContentAuthentication) {
+ t.Fatalf("authentication: %v", err)
+ }
+ })
+ }
+ sealed[len(sealed)-1] ^= 1
+ if _, err := c.open(contentSnapshotKind, guid, 42, 4, sealed); !errors.Is(err, errContentAuthentication) {
+ t.Fatalf("tamper: %v", err)
+ }
+ for n := 0; n < c.aead.NonceSize()+c.aead.Overhead(); n++ {
+ if _, err := c.open(contentSnapshotKind, guid, 42, 4, sealed[:n]); !errors.Is(err, errContentAuthentication) {
+ t.Fatalf("truncation %d: %v", n, err)
+ }
+ }
+}
+
+func TestContentCipherKeysAndNonces(t *testing.T) {
+ for _, encoded := range []string{"", "not-base64", base64.StdEncoding.EncodeToString([]byte("short")), base64.StdEncoding.EncodeToString(developmentContentKey[:])} {
+ if _, err := newContentCipher(encoded, "production"); !errors.Is(err, errContentKey) {
+ t.Fatalf("invalid production key accepted: %v", err)
+ }
+ }
+ a, err := newContentCipher("", "development")
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := newContentCipher(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{42}, 32)), "development")
+ if err != nil {
+ t.Fatal(err)
+ }
+ guid := uuid.NewString()
+ seen := make(map[string]bool)
+ for i := 0; i < 100; i++ {
+ sealed, err := a.seal(contentUpdateKind, guid, 1, 0, []byte("body"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ nonce := string(sealed[:a.aead.NonceSize()])
+ if seen[nonce] {
+ t.Fatal("nonce repeated")
+ }
+ seen[nonce] = true
+ if _, err := b.open(contentUpdateKind, guid, 1, 0, sealed); !errors.Is(err, errContentAuthentication) {
+ t.Fatalf("wrong key: %v", err)
+ }
+ }
+ if _, err := a.seal(contentUpdateKind, "broken-guid", 1, 0, nil); !errors.Is(err, errContentAuthentication) {
+ t.Fatalf("invalid guid: %v", err)
+ }
+}