// Package seal is the app's only encryption surface. // // The one rule (spec ยง0.1): a meeting's title is sealed at rest, and // the database never holds a plaintext title or an outsider's address. // The DEK arrives as platform config and is never written to the // database it decrypts. // // Lifted from calendar's seal package, itself lifted from sheets', with // only the label changed. Calendar names the third app as the trigger // to extract this into estilo or rastrillo; Calls is that app, and the // extraction is a task on the plan, not a change here. package seal import ( "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "errors" "hash" "io" "golang.org/x/crypto/hkdf" ) // label is mixed into every derived key. Changing it makes every // existing sealed value unreadable, so it is a constant, not config. const label = "oficina/calls:" const ( PurposeMeetingTitle = "meeting.title" PurposeMeetingLink = "meeting.link" PurposePendingAddress = "pending.address" ) func sha256New() hash.Hash { return sha256.New() } type Sealer struct{ dek []byte } // New refuses anything but a 32-byte key and keeps its own copy. func New(dek []byte) (*Sealer, error) { if len(dek) != 32 { return nil, errors.New("seal: the DEK must be exactly 32 bytes") } k := make([]byte, 32) copy(k, dek) return &Sealer{dek: k}, nil } func (s *Sealer) purposeKey(purpose string) ([]byte, error) { key := make([]byte, 32) r := hkdf.New(sha256New, s.dek, nil, []byte(label+purpose)) if _, err := io.ReadFull(r, key); err != nil { return nil, err } return key, nil } func (s *Sealer) aeadFor(purpose string) (cipher.AEAD, error) { key, err := s.purposeKey(purpose) if err != nil { return nil, err } block, err := aes.NewCipher(key) if err != nil { return nil, err } return cipher.NewGCM(block) } // Seal returns nonce || ciphertext. func (s *Sealer) Seal(purpose string, plaintext []byte) ([]byte, error) { aead, err := s.aeadFor(purpose) if err != nil { return nil, err } nonce := make([]byte, aead.NonceSize()) if _, err := rand.Read(nonce); err != nil { return nil, err } return aead.Seal(nonce, nonce, plaintext, nil), nil } func (s *Sealer) Open(purpose string, ct []byte) ([]byte, error) { aead, err := s.aeadFor(purpose) if err != nil { return nil, err } if len(ct) < aead.NonceSize() { return nil, errors.New("seal: ciphertext too short") } nonce, body := ct[:aead.NonceSize()], ct[aead.NonceSize():] return aead.Open(nil, nonce, body, nil) } // SealString seals a string; an empty string seals to nil so an // optional column stays NULL. func (s *Sealer) SealString(purpose, plaintext string) ([]byte, error) { if plaintext == "" { return nil, nil } return s.Seal(purpose, []byte(plaintext)) } func (s *Sealer) OpenString(purpose string, ct []byte) (string, error) { if len(ct) == 0 { return "", nil } b, err := s.Open(purpose, ct) if err != nil { return "", err } return string(b), nil }