nodetoken.go 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. // Package nodetoken encrypts replayable per-node bearer tokens at rest with
  2. // row-bound AES-GCM and versioned key IDs.
  3. package nodetoken
  4. import (
  5. "crypto/aes"
  6. "crypto/cipher"
  7. "crypto/rand"
  8. "encoding/base64"
  9. "errors"
  10. "fmt"
  11. "strings"
  12. "sync"
  13. )
  14. // Mode is explicit so a missing key cannot silently downgrade encrypted
  15. // deployments to plaintext.
  16. type Mode int
  17. const (
  18. // ModeOff: legacy plaintext operation. Writes store plaintext; an encrypted
  19. // value cannot be interpreted (no key) and is rejected rather than guessed.
  20. ModeOff Mode = iota
  21. // ModeMigration: key required. Reads accept plaintext OR ciphertext; writes
  22. // always produce ciphertext. Used while migrating existing rows.
  23. ModeMigration
  24. // ModeRequired: key required (startup fails if it cannot load). Reads decrypt
  25. // ciphertext (error on failure) and accept any still-unmigrated plaintext;
  26. // writes always produce ciphertext.
  27. ModeRequired
  28. )
  29. const (
  30. encPrefix = "enc:"
  31. encScheme = "enc:v1:"
  32. keyLen = 32 // AES-256
  33. nonceLen = 12 // GCM standard nonce
  34. aadKeyFormat = "nodes/api_token/%d"
  35. )
  36. // ParseMode maps the NODE_TOKEN_ENCRYPTION env value to a Mode.
  37. func ParseMode(s string) (Mode, error) {
  38. switch strings.ToLower(strings.TrimSpace(s)) {
  39. case "", "off":
  40. return ModeOff, nil
  41. case "migration":
  42. return ModeMigration, nil
  43. case "required":
  44. return ModeRequired, nil
  45. default:
  46. return ModeOff, fmt.Errorf("nodetoken: unknown NODE_TOKEN_ENCRYPTION %q (want off|migration|required)", s)
  47. }
  48. }
  49. // Keyring holds the active write key and previous decryption keys.
  50. type Keyring struct {
  51. ActiveID string
  52. Keys map[string][keyLen]byte
  53. }
  54. func (kr *Keyring) active() ([keyLen]byte, error) {
  55. k, ok := kr.Keys[kr.ActiveID]
  56. if !ok {
  57. return [keyLen]byte{}, fmt.Errorf("nodetoken: active key %q not in keyring", kr.ActiveID)
  58. }
  59. return k, nil
  60. }
  61. // Codec encrypts/decrypts node tokens under a fixed policy and keyring.
  62. type Codec struct {
  63. mode Mode
  64. ring *Keyring // nil only in ModeOff
  65. }
  66. // NewCodec requires an active key outside ModeOff.
  67. func NewCodec(mode Mode, ring *Keyring) (*Codec, error) {
  68. if mode == ModeOff {
  69. return &Codec{mode: ModeOff}, nil
  70. }
  71. if ring == nil || len(ring.Keys) == 0 {
  72. return nil, errors.New("nodetoken: encryption mode requires a key, but none was loaded")
  73. }
  74. if _, err := ring.active(); err != nil {
  75. return nil, err
  76. }
  77. return &Codec{mode: mode, ring: ring}, nil
  78. }
  79. // Enabled reports whether the codec writes ciphertext (mode != off).
  80. func (c *Codec) Enabled() bool { return c.mode != ModeOff }
  81. func aad(nodeID int) []byte { return []byte(fmt.Sprintf(aadKeyFormat, nodeID)) }
  82. // IsEncrypted reports whether a stored value is in this package's ciphertext form.
  83. func IsEncrypted(stored string) bool { return strings.HasPrefix(stored, encPrefix) }
  84. // Encrypt returns plaintext in ModeOff or row-bound enc:v1 ciphertext otherwise.
  85. // Empty and already-valid encrypted values remain unchanged.
  86. func (c *Codec) Encrypt(nodeID int, plaintext string) (string, error) {
  87. return c.EncryptBound(aad(nodeID), plaintext)
  88. }
  89. // Decrypt passes legacy plaintext through; enc: values must authenticate and
  90. // are never reinterpreted as plaintext after an error.
  91. func (c *Codec) Decrypt(nodeID int, stored string) (string, error) {
  92. pt, err := c.DecryptBound(aad(nodeID), stored)
  93. if err != nil {
  94. return "", fmt.Errorf("nodetoken: node %d: %w", nodeID, err)
  95. }
  96. return pt, nil
  97. }
  98. // EncryptBound is Encrypt with an explicit AAD (e.g. settings/pia_token).
  99. func (c *Codec) EncryptBound(bound []byte, plaintext string) (string, error) {
  100. if c.mode == ModeOff || plaintext == "" {
  101. return plaintext, nil
  102. }
  103. if IsEncrypted(plaintext) {
  104. if _, err := c.DecryptBound(bound, plaintext); err != nil {
  105. return "", fmt.Errorf("nodetoken: refusing to store undecryptable ciphertext: %w", err)
  106. }
  107. return plaintext, nil
  108. }
  109. key, err := c.ring.active()
  110. if err != nil {
  111. return "", err
  112. }
  113. gcm, err := newGCM(key)
  114. if err != nil {
  115. return "", err
  116. }
  117. nonce := make([]byte, nonceLen)
  118. if _, err := rand.Read(nonce); err != nil {
  119. return "", err
  120. }
  121. ct := gcm.Seal(nil, nonce, []byte(plaintext), bound)
  122. blob := append(nonce, ct...)
  123. return encScheme + c.ring.ActiveID + ":" + base64.RawURLEncoding.EncodeToString(blob), nil
  124. }
  125. // DecryptBound is Decrypt with an explicit AAD.
  126. func (c *Codec) DecryptBound(bound []byte, stored string) (string, error) {
  127. if c.mode == ModeOff {
  128. return stored, nil
  129. }
  130. if !IsEncrypted(stored) {
  131. return stored, nil
  132. }
  133. rest, ok := strings.CutPrefix(stored, encScheme)
  134. if !ok {
  135. return "", fmt.Errorf("nodetoken: unsupported ciphertext scheme in %q", firstN(stored, 12))
  136. }
  137. keyID, b64, ok := strings.Cut(rest, ":")
  138. if !ok || keyID == "" {
  139. return "", errors.New("nodetoken: malformed ciphertext (missing key id)")
  140. }
  141. if c.ring == nil {
  142. return "", errors.New("nodetoken: encrypted token encountered but encryption is disabled (no key)")
  143. }
  144. key, ok := c.ring.Keys[keyID]
  145. if !ok {
  146. return "", fmt.Errorf("nodetoken: no key %q in keyring to decrypt token", keyID)
  147. }
  148. blob, err := base64.RawURLEncoding.DecodeString(b64)
  149. if err != nil {
  150. return "", fmt.Errorf("nodetoken: base64 decode: %w", err)
  151. }
  152. if len(blob) < nonceLen {
  153. return "", errors.New("nodetoken: ciphertext too short")
  154. }
  155. gcm, err := newGCM(key)
  156. if err != nil {
  157. return "", err
  158. }
  159. pt, err := gcm.Open(nil, blob[:nonceLen], blob[nonceLen:], bound)
  160. if err != nil {
  161. return "", fmt.Errorf("nodetoken: authentication failed: %w", err)
  162. }
  163. return string(pt), nil
  164. }
  165. // ActiveKeyID returns the id new writes use (empty in ModeOff).
  166. func (c *Codec) ActiveKeyID() string {
  167. if c.ring == nil {
  168. return ""
  169. }
  170. return c.ring.ActiveID
  171. }
  172. // EncryptedWithActive reports whether migration can skip a ciphertext row.
  173. func (c *Codec) EncryptedWithActive(stored string) bool {
  174. if c.ring == nil || !IsEncrypted(stored) {
  175. return false
  176. }
  177. rest, ok := strings.CutPrefix(stored, encScheme)
  178. if !ok {
  179. return false
  180. }
  181. keyID, _, ok := strings.Cut(rest, ":")
  182. return ok && keyID == c.ring.ActiveID
  183. }
  184. func newGCM(key [keyLen]byte) (cipher.AEAD, error) {
  185. block, err := aes.NewCipher(key[:])
  186. if err != nil {
  187. return nil, err
  188. }
  189. return cipher.NewGCM(block)
  190. }
  191. func firstN(s string, n int) string {
  192. if len(s) <= n {
  193. return s
  194. }
  195. return s[:n]
  196. }
  197. // --- package singleton, initialized once at startup ---
  198. var (
  199. mu sync.RWMutex
  200. current *Codec
  201. )
  202. // Init installs the process-wide codec. Call once during startup after building
  203. // the keyring; in ModeOff a nil keyring is fine.
  204. func Init(c *Codec) {
  205. mu.Lock()
  206. defer mu.Unlock()
  207. current = c
  208. }
  209. // get returns the installed codec, or a permissive ModeOff codec if Init was
  210. // never called (e.g. unit tests / sqlite dev) so callers never nil-panic.
  211. func get() *Codec {
  212. mu.RLock()
  213. c := current
  214. mu.RUnlock()
  215. if c == nil {
  216. return &Codec{mode: ModeOff}
  217. }
  218. return c
  219. }
  220. // Encrypt/Decrypt/Enabled operate on the process-wide codec.
  221. func Encrypt(nodeID int, plaintext string) (string, error) { return get().Encrypt(nodeID, plaintext) }
  222. func Decrypt(nodeID int, stored string) (string, error) { return get().Decrypt(nodeID, stored) }
  223. func EncryptBound(bound []byte, plaintext string) (string, error) {
  224. return get().EncryptBound(bound, plaintext)
  225. }
  226. func DecryptBound(bound []byte, stored string) (string, error) {
  227. return get().DecryptBound(bound, stored)
  228. }
  229. func Enabled() bool { return get().Enabled() }
  230. func Active() *Codec { return get() }