Add authenticated document content envelopes
2 files changed,
+187
−0
internal/docs/content_cipher.go+95 −0internal/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) |
| + } |
| +} |