oficina / calendar Public

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

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: an event's contents are sealed at rest, and the bucket
4// never holds a plaintext title, place or note. The DEK arrives as
5// platform config and is never written to the database it decrypts.
6//
7// Purpose keys are derived with HKDF so that a value sealed as a title
8// cannot be opened as a note. That is not paranoia: they are adjacent
9// columns of the same row, and a bug that swapped them would be
10// invisible without this.
11//
12// Lifted from sheets' seal package unchanged except for the label
13// prefix. Deliberately a copy rather than a shared module: the label is
14// part of the ciphertext's meaning, so two apps sharing this code would
15// have to agree on a prefix scheme forever. estilo's rule applies — the
16// second implementation is what tells you the shape the first got
17// wrong, and a third app is the trigger to extract it.
18package seal
19
20import (
21 "crypto/aes"
22 "crypto/cipher"
23 "crypto/rand"
24 "crypto/sha256"
25 "errors"
26 "hash"
27 "io"
28
29 "golang.org/x/crypto/hkdf"
30)
31
32// label prefixes every derived key. Changing it makes every sealed
33// value in every existing database unreadable, so it is a constant with
34// a warning rather than a setting.
35const label = "oficina/calendar:"
36
37// The purposes this app seals under. A constant per purpose, rather
38// than a string at each call site, because a typo in a literal would
39// derive a different key and silently produce a value nothing can open.
40const (
41 PurposeEventTitle = "event.title"
42 PurposeEventLocation = "event.location"
43 PurposeEventNotes = "event.notes"
44 PurposeEventUID = "event.uid"
45 PurposeCalendarName = "calendar.name"
46 PurposeMemberName = "member.name"
47
48 // PurposeAttendeeAddress seals an attendee's address; the matching
49 // Fingerprint purpose is what dedupes it without a plaintext
50 // lookup column.
51 PurposeAttendeeAddress = "attendee.address"
52 PurposeAttendeeLookup = "attendee.lookup"
53
54 // The booking purposes. A page's text and availability sentence,
55 // the booking's snapshot of that text, the management token, and
56 // everything a booker typed. PurposeBookingIP is a Fingerprint
57 // purpose: the client IP is never stored, only its keyed digest.
58 PurposeBookingTitle = "booking.title"
59 PurposeBookingDescription = "booking.description"
60 PurposeBookingAvailability = "booking.availability"
61 PurposeBookingToken = "booking.token"
62 PurposeBookerName = "booking.booker.name"
63 PurposeBookerAddress = "booking.booker.address"
64 PurposeBookerNote = "booking.booker.note"
65 PurposeBookingIP = "booking.ip"
66)
67
68func sha256New() hash.Hash { return sha256.New() }
69
70// Sealer holds the data encryption key. One per process.
71type Sealer struct{ dek []byte }
72
73// New returns a Sealer. The DEK must be exactly 32 bytes.
74func New(dek []byte) (*Sealer, error) {
75 if len(dek) != 32 {
76 return nil, errors.New("seal: DEK must be 32 bytes")
77 }
78 c := make([]byte, 32)
79 copy(c, dek)
80 return &Sealer{dek: c}, nil
81}
82
83// purposeKey derives the AEAD key for purpose via HKDF over the DEK.
84//
85// A fixed 32-byte read is always inside HKDF-SHA256's 8160-byte expand
86// limit, so this cannot fail today — but the error is still checked and
87// propagated. This is the app's only encryption surface, and a silently
88// discarded read here would degrade into a short or zero-padded key if
89// the key length ever changed: the kind of bug that leaves ciphertext
90// looking perfectly fine while being trivially breakable.
91func (s *Sealer) purposeKey(purpose string) ([]byte, error) {
92 key := make([]byte, 32)
93 r := hkdf.New(sha256New, s.dek, nil, []byte(label+purpose))
94 if _, err := io.ReadFull(r, key); err != nil {
95 return nil, err
96 }
97 return key, nil
98}
99
100// aeadFor derives the purpose key and returns its AEAD.
101func (s *Sealer) aeadFor(purpose string) (cipher.AEAD, error) {
102 key, err := s.purposeKey(purpose)
103 if err != nil {
104 return nil, err
105 }
106 block, err := aes.NewCipher(key)
107 if err != nil {
108 return nil, err
109 }
110 return cipher.NewGCM(block)
111}
112
113// Seal encrypts plaintext under purpose. The nonce is prepended.
114func (s *Sealer) Seal(purpose string, plaintext []byte) ([]byte, error) {
115 aead, err := s.aeadFor(purpose)
116 if err != nil {
117 return nil, err
118 }
119 nonce := make([]byte, aead.NonceSize())
120 if _, err := rand.Read(nonce); err != nil {
121 return nil, err
122 }
123 return aead.Seal(nonce, nonce, plaintext, nil), nil
124}
125
126// Open decrypts a value sealed under the same purpose.
127func (s *Sealer) Open(purpose string, ct []byte) ([]byte, error) {
128 aead, err := s.aeadFor(purpose)
129 if err != nil {
130 return nil, err
131 }
132 if len(ct) < aead.NonceSize() {
133 return nil, errors.New("seal: ciphertext too short")
134 }
135 nonce, body := ct[:aead.NonceSize()], ct[aead.NonceSize():]
136 return aead.Open(nil, nonce, body, nil)
137}
138
139// SealString and OpenString are the forms every call site actually
140// wants. An empty plaintext seals to nil rather than to a ciphertext,
141// so an absent note costs no bytes and reads back as absent — and an
142// empty column is never mistaken for a decryption failure.
143func (s *Sealer) SealString(purpose, plaintext string) ([]byte, error) {
144 if plaintext == "" {
145 return nil, nil
146 }
147 return s.Seal(purpose, []byte(plaintext))
148}
149
150func (s *Sealer) OpenString(purpose string, ct []byte) (string, error) {
151 if len(ct) == 0 {
152 return "", nil
153 }
154 b, err := s.Open(purpose, ct)
155 if err != nil {
156 return "", err
157 }
158 return string(b), nil
159}
160