| 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. |
| 18 | package seal |
| 19 | |
| 20 | import ( |
| 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. |
| 35 | const 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. |
| 40 | const ( |
| 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 | |
| 68 | func sha256New() hash.Hash { return sha256.New() } |
| 69 | |
| 70 | // Sealer holds the data encryption key. One per process. |
| 71 | type Sealer struct{ dek []byte } |
| 72 | |
| 73 | // New returns a Sealer. The DEK must be exactly 32 bytes. |
| 74 | func 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. |
| 91 | func (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. |
| 101 | func (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. |
| 114 | func (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. |
| 127 | func (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. |
| 143 | func (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 | |
| 150 | func (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 | |