Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,29 @@ func main() {

For a full example, see [example/main.go](https://github.com/bincyber/go-sqlcrypter/blob/master/example/main.go).

#### Nullable columns: `NullEncryptedBytes`

Use [`NullEncryptedBytes`](https://github.com/bincyber/go-sqlcrypter/blob/master/null_encrypted_bytes.go) when a column may be **SQL `NULL`**, and you need to tell **`NULL`** apart from an empty string stored as ciphertext:

| State | `Valid` | Plaintext | Stored value |
|-------|---------|-----------|--------------|
| Absent | `false` | _(n/a)_ | SQL `NULL` |
| Present, empty | `true` | `""` | Non-`NULL` BYTEA / blob (encrypted empty) |
| Present | `true` | non-empty | Non-`NULL` BYTEA / blob |

By contrast, [`EncryptedBytes`](https://github.com/bincyber/go-sqlcrypter/blob/master/encrypted_bytes.go) maps **empty plaintext to `NULL`** on write (`driver.Valuer`), which cannot represent _“empty but not null.”_

Constructors:

```go
present := sqlcrypter.NewNullEncryptedBytes("secret") // Valid=true; empty string allowed
absent := sqlcrypter.NullEncryptedBytesNull() // Valid=false => SQL NULL
```

- **sqlc / pgx (Postgres `BYTEA`):** put `sqlcrypter.NullEncryptedBytes` on the generated struct field. The type implements [`pgtype.BytesScanner`](https://pkg.go.dev/github.com/jackc/pgx/v5/pgtype#BytesScanner) and [`pgtype.BytesValuer`](https://pkg.go.dev/github.com/jackc/pgx/v5/pgtype#BytesValuer). The default `BYTEA` codec picks these up, therefore no manual `pgtype.Map` registration is required.
- **JSON:** absent values marshal as JSON `null` while present values marshal as base64-encoded ciphertext (including when plaintext is empty).
- **GORM:** `GormDataType()` is `nullencryptedbytes` and `GormDBDataType` follows the same dialect mapping as `EncryptedBytes` (`bytea` / `binary` / `blob` / `varbinary`).

### Development

[Docker Compose](https://docs.docker.com/compose/) backs local services; `make dev/up` and related targets invoke it against [testing/docker-compose.yml](https://github.com/bincyber/go-sqlcrypter/blob/master/testing/docker-compose.yml).
Expand Down
30 changes: 30 additions & 0 deletions crypter_bytes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package sqlcrypter

import "bytes"

// encryptPlaintextBytes encrypts plaintext and returns ciphertext bytes.
func encryptPlaintextBytes(plaintext []byte) ([]byte, error) {
reader := bytes.NewReader(plaintext)
writer := new(bytes.Buffer)
if err := Encrypt(writer, reader); err != nil {
return nil, err
}
ct := writer.Bytes()
// Avoid returning a nil []byte slice: database/sql and JSON callers treat
// nil []byte as NULL/absent; NullEncryptedBytes needs a non-nil ciphertext
// for encrypted-empty plaintext (distinct from SQL NULL).
if ct == nil {
ct = []byte{}
}
return ct, nil
}

// decryptCiphertextBytes decrypts ciphertext into plaintext bytes.
func decryptCiphertextBytes(ciphertext []byte) (EncryptedBytes, error) {
reader := bytes.NewReader(ciphertext)
writer := new(bytes.Buffer)
if err := Decrypt(writer, reader); err != nil {
return nil, err
}
return EncryptedBytes(writer.Bytes()), nil
}
3 changes: 2 additions & 1 deletion crypter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ func Test_Set(t *testing.T) {
c := &base64Crypter{}
err := Init(c)
require.NoError(t, err)
require.Equal(t, crypter, c)
_, ok := crypter.(*base64Crypter)
require.True(t, ok, "crypter should be a *base64Crypter after Init")
}

func Test_Init_Nil(t *testing.T) {
Expand Down
19 changes: 4 additions & 15 deletions encrypted_bytes.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package sqlcrypter

import (
"bytes"
"database/sql"
"database/sql/driver"
"encoding/json"
Expand Down Expand Up @@ -78,14 +77,12 @@ func (e *EncryptedBytes) Scan(value any) error {
}

// Decrypt value to e
reader := bytes.NewReader(b)
writer := new(bytes.Buffer)

if err := Decrypt(writer, reader); err != nil {
pt, err := decryptCiphertextBytes(b)
if err != nil {
return err
}

*e = writer.Bytes()
*e = pt

return nil
}
Expand All @@ -98,15 +95,7 @@ func (e EncryptedBytes) Value() (driver.Value, error) {
return b, nil
}

// Encrypt contents of e before storing in the database
reader := bytes.NewReader(e)
writer := new(bytes.Buffer)

if err := Encrypt(writer, reader); err != nil {
return nil, err
}

return writer.Bytes(), nil
return encryptPlaintextBytes(e)
}

// MarshalJSON implements json.Marshaler interface
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ require (
github.com/aws/aws-sdk-go-v2/service/kms v1.51.1
github.com/dgraph-io/ristretto v0.2.0
github.com/hashicorp/vault/api v1.23.0
github.com/jackc/pgx/v5 v5.9.2
github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.11.1
golang.org/x/time v0.15.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)

Expand Down Expand Up @@ -45,13 +47,13 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
17 changes: 14 additions & 3 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -71,21 +71,28 @@ github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A=
github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
Expand All @@ -103,6 +110,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
Expand All @@ -114,5 +123,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
180 changes: 180 additions & 0 deletions null_encrypted_bytes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package sqlcrypter

import (
"database/sql"
"database/sql/driver"
"encoding/json"
"fmt"

"github.com/jackc/pgx/v5/pgtype"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)

// NullEncryptedBytes is a nullable encrypted column value for database/sql,
// GORM, JSON, and pgx/sqlc (BYTEA) workflows.
//
// Semantics:
// - Valid == false: SQL NULL; JSON null; Plaintext/Bytes are empty/nil.
// - Valid == true: plaintext is stored encrypted, including empty plaintext
// (distinct from SQL NULL).
type NullEncryptedBytes struct {
Data EncryptedBytes
Valid bool
}

// NewNullEncryptedBytes returns a present value with plaintext s (empty string allowed).
func NewNullEncryptedBytes(s string) NullEncryptedBytes {
var data EncryptedBytes
if s == "" {
data = EncryptedBytes([]byte{})
} else {
data = EncryptedBytes([]byte(s))
}
return NullEncryptedBytes{Data: data, Valid: true}
}

// NullEncryptedBytesNull returns an absent (SQL NULL) value.
func NullEncryptedBytesNull() NullEncryptedBytes {
return NullEncryptedBytes{Valid: false}
}

func (n *NullEncryptedBytes) GormDataType() string {
return "nullencryptedbytes"
}

func (n *NullEncryptedBytes) GormDBDataType(db *gorm.DB, field *schema.Field) string {
switch db.Name() {
case "mysql":
return "binary"
case "postgres":
return "bytea"
case "sqlite":
return "blob"
case "sqlserver":
return "varbinary"
default:
return ""
}
}

// String intentionally returns a redacted placeholder.
func (n NullEncryptedBytes) String() string {
return "[REDACTED]"
}

// Plaintext returns decrypted plaintext when Valid; otherwise "".
func (n NullEncryptedBytes) Plaintext() string {
if !n.Valid {
return ""
}
return string(n.Data)
}

// Bytes returns a slice view of plaintext when Valid; otherwise nil.
// The returned slice aliases Data; mutating it mutates the stored plaintext.
func (n NullEncryptedBytes) Bytes() []byte {
if !n.Valid {
return nil
}
return n.Data[:]
}

// Scan implements sql.Scanner.
func (n *NullEncryptedBytes) Scan(value any) error {
if value == nil {
n.Valid = false
n.Data = nil
return nil
}
b, ok := value.([]byte)
if !ok {
return fmt.Errorf("sqlcrypter: NullEncryptedBytes.Scan expected []byte, got %T", value)
}
if b == nil {
n.Valid = false
n.Data = nil
return nil
}
pt, err := decryptCiphertextBytes(b)
if err != nil {
return err
}
n.Data = pt
n.Valid = true
return nil
}

// Value implements driver.Valuer.
func (n NullEncryptedBytes) Value() (driver.Value, error) {
if !n.Valid {
//nolint:nilnil // driver.Valuer: nil value with nil error means SQL NULL
return nil, nil
}
return encryptPlaintextBytes(n.Data)
}

// MarshalJSON encodes absent values as JSON null; present values as base64 ciphertext.
func (n NullEncryptedBytes) MarshalJSON() ([]byte, error) {
if !n.Valid {
return []byte("null"), nil
}
ciphertext, err := encryptPlaintextBytes(n.Data)
if err != nil {
return nil, err
}
return json.Marshal(ciphertext)
}

// UnmarshalJSON decodes JSON null as absent; otherwise expects base64 ciphertext bytes.
func (n *NullEncryptedBytes) UnmarshalJSON(data []byte) error {
var ciphertext []byte
if err := json.Unmarshal(data, &ciphertext); err != nil {
return err
}
if ciphertext == nil {
n.Valid = false
n.Data = nil
return nil
}
n.Valid = true
return n.Scan(ciphertext)
}

// ScanBytes implements pgtype.BytesScanner for pgx/sqlc BYTEA columns.
func (n *NullEncryptedBytes) ScanBytes(src []byte) error {
if src == nil {
n.Valid = false
n.Data = nil
return nil
}
// src is only valid until the next database call; copy before Scan.
b := append([]byte(nil), src...)
return n.Scan(b)
}

// BytesValue implements pgtype.BytesValuer for pgx/sqlc BYTEA columns.
func (n NullEncryptedBytes) BytesValue() ([]byte, error) {
v, err := n.Value()
if err != nil {
return nil, err
}
if v == nil {
return nil, nil
}
b, ok := v.([]byte)
if !ok {
return nil, fmt.Errorf("sqlcrypter: NullEncryptedBytes.BytesValue expected []byte, got %T", v)
}
return b, nil
}

var (
_ driver.Valuer = &NullEncryptedBytes{}
_ sql.Scanner = &NullEncryptedBytes{}
_ json.Marshaler = &NullEncryptedBytes{}
_ json.Unmarshaler = &NullEncryptedBytes{}
_ fmt.Stringer = &NullEncryptedBytes{}
_ pgtype.BytesScanner = &NullEncryptedBytes{}
_ pgtype.BytesValuer = &NullEncryptedBytes{}
)
Loading
Loading