oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1// Package seal is the app's only encryption surface.
2//
3// The one rule: a sheet's contents are sealed at rest, and the bucket never
4// holds a plaintext cell. The DEK arrives as platform config and is never
5// written to the database it decrypts.
6//
7// Purpose keys are derived with HKDF so that a value sealed as a cell input
8// cannot be opened as a cached value. That is not paranoia: the two are
9// adjacent columns of the same row, and a bug that swapped them would be
10// invisible without this.
11package seal
12
13import (
14 "crypto/aes"
15 "crypto/cipher"
16 "crypto/rand"
17 "crypto/sha256"
18 "errors"
19 "hash"
20 "io"
21
22 "golang.org/x/crypto/hkdf"
23)
24
25func sha256New() hash.Hash { return sha256.New() }
26
27// Sealer holds the data encryption key. One per process.
28type Sealer struct{ dek []byte }
29
30// New returns a Sealer. The DEK must be exactly 32 bytes.
31func New(dek []byte) (*Sealer, error) {
32 if len(dek) != 32 {
33 return nil, errors.New("seal: DEK must be 32 bytes")
34 }
35 c := make([]byte, 32)
36 copy(c, dek)
37 return &Sealer{dek: c}, nil
38}
39
40// purposeKey derives the AEAD key for purpose via HKDF over the DEK. A
41// fixed 32-byte read is always inside HKDF-SHA256's 8160-byte expand
42// limit, so this cannot fail today — but the error is still checked and
43// propagated: this is the app's only encryption surface, and a silently
44// discarded read here would degrade into a short or zero-padded key if
45// the key length ever changed, which is the kind of bug that leaves
46// ciphertext looking perfectly fine while being trivially breakable.
47func (s *Sealer) purposeKey(purpose string) ([]byte, error) {
48 key := make([]byte, 32)
49 r := hkdf.New(sha256New, s.dek, nil, []byte("oficina/sheets:"+purpose))
50 if _, err := io.ReadFull(r, key); err != nil {
51 return nil, err
52 }
53 return key, nil
54}
55
56// aeadFor derives the purpose key and returns its AEAD.
57func (s *Sealer) aeadFor(purpose string) (cipher.AEAD, error) {
58 key, err := s.purposeKey(purpose)
59 if err != nil {
60 return nil, err
61 }
62 block, err := aes.NewCipher(key)
63 if err != nil {
64 return nil, err
65 }
66 return cipher.NewGCM(block)
67}
68
69// Seal encrypts plaintext under purpose. The nonce is prepended.
70func (s *Sealer) Seal(purpose string, plaintext []byte) ([]byte, error) {
71 aead, err := s.aeadFor(purpose)
72 if err != nil {
73 return nil, err
74 }
75 nonce := make([]byte, aead.NonceSize())
76 if _, err := rand.Read(nonce); err != nil {
77 return nil, err
78 }
79 return aead.Seal(nonce, nonce, plaintext, nil), nil
80}
81
82// Open decrypts a value sealed under the same purpose.
83func (s *Sealer) Open(purpose string, ct []byte) ([]byte, error) {
84 aead, err := s.aeadFor(purpose)
85 if err != nil {
86 return nil, err
87 }
88 if len(ct) < aead.NonceSize() {
89 return nil, errors.New("seal: ciphertext too short")
90 }
91 nonce, body := ct[:aead.NonceSize()], ct[aead.NonceSize():]
92 return aead.Open(nil, nonce, body, nil)
93}
94