Files

161 lines
4.1 KiB
Go

// Copyright (C) 2020-2026, Hanzo AI Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package kms
import (
"encoding/base64"
"fmt"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tools/hook"
)
// registerHooks binds record lifecycle hooks for transparent field-level
// encryption. Configured fields are encrypted before write and decrypted
// after read, making encryption invisible to the application layer.
func (p *plugin) registerHooks() {
// Encrypt on create (before DB write).
p.app.OnRecordCreate().Bind(&hook.Handler[*core.RecordEvent]{
Id: "__kmsEncryptCreate__",
Func: func(e *core.RecordEvent) error {
if err := p.encryptFields(e.Record); err != nil {
return fmt.Errorf("kms: encrypt on create: %w", err)
}
if err := p.updateFHEIndex(e.Record); err != nil {
p.logger.Warn("kms: fhe index update on create failed", "error", err)
}
return e.Next()
},
Priority: -50, // Run before most user hooks.
})
// Encrypt on update (before DB write).
p.app.OnRecordUpdate().Bind(&hook.Handler[*core.RecordEvent]{
Id: "__kmsEncryptUpdate__",
Func: func(e *core.RecordEvent) error {
if err := p.encryptFields(e.Record); err != nil {
return fmt.Errorf("kms: encrypt on update: %w", err)
}
if err := p.updateFHEIndex(e.Record); err != nil {
p.logger.Warn("kms: fhe index update on update failed", "error", err)
}
return e.Next()
},
Priority: -50,
})
// Decrypt on enrich (after DB read, before API response).
p.app.OnRecordEnrich().Bind(&hook.Handler[*core.RecordEnrichEvent]{
Id: "__kmsDecryptEnrich__",
Func: func(e *core.RecordEnrichEvent) error {
if err := p.decryptFields(e.Record); err != nil {
p.logger.Warn("kms: decrypt on enrich failed",
"collection", e.Record.Collection().Name,
"record", e.Record.Id,
"error", err,
)
// Don't fail the request — return encrypted data rather than error.
}
return e.Next()
},
Priority: -50,
})
}
// encryptFields encrypts the configured fields on a record in-place.
// Fields are stored as base64-encoded ciphertext in the DB.
func (p *plugin) encryptFields(record *core.Record) error {
col := record.Collection().Name
fields, ok := p.encFieldSet[col]
if !ok {
return nil
}
if !p.client.IsUnlocked() {
return fmt.Errorf("kms client is locked — cannot encrypt fields for %q", col)
}
cek, err := p.client.ExportCEK()
if err != nil {
return fmt.Errorf("get CEK: %w", err)
}
defer clear(cek)
aad := []byte(p.config.OrgSlug + ":" + col + ":" + record.Id)
for field := range fields {
val := record.GetString(field)
if val == "" {
continue
}
// Skip if already encrypted (base64 with our prefix).
if isEncryptedValue(val) {
continue
}
encrypted, err := sealField(cek, []byte(val), aad)
if err != nil {
return fmt.Errorf("encrypt field %q: %w", field, err)
}
record.Set(field, encryptedPrefix+base64.RawURLEncoding.EncodeToString(encrypted))
}
return nil
}
// decryptFields decrypts the configured fields on a record in-place.
func (p *plugin) decryptFields(record *core.Record) error {
col := record.Collection().Name
fields, ok := p.encFieldSet[col]
if !ok {
return nil
}
if !p.client.IsUnlocked() {
return nil // Return encrypted data if locked.
}
cek, err := p.client.ExportCEK()
if err != nil {
return fmt.Errorf("get CEK: %w", err)
}
defer clear(cek)
aad := []byte(p.config.OrgSlug + ":" + col + ":" + record.Id)
for field := range fields {
val := record.GetString(field)
if val == "" {
continue
}
if !isEncryptedValue(val) {
continue
}
ciphertext, err := base64.RawURLEncoding.DecodeString(val[len(encryptedPrefix):])
if err != nil {
return fmt.Errorf("decode field %q: %w", field, err)
}
plaintext, err := openField(cek, ciphertext, aad)
if err != nil {
return fmt.Errorf("decrypt field %q: %w", field, err)
}
record.Set(field, string(plaintext))
}
return nil
}
const encryptedPrefix = "enc:v1:"
// isEncryptedValue checks if a value has our encryption prefix.
func isEncryptedValue(val string) bool {
return len(val) > len(encryptedPrefix) && val[:len(encryptedPrefix)] == encryptedPrefix
}