Go · 3513 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package secretbox is a thin AEAD wrapper used to protect at-rest
4 // secrets — currently TOTP secrets, future sprints will reuse it for
5 // per-user key material (PATs, deploy keys, etc.).
6 //
7 // Construction: chacha20poly1305 (XChaCha20 not used — we mint nonces
8 // per-row and store them, so the smaller 12-byte nonce is fine).
9 //
10 // Key sourcing: 32-byte key, base64-encoded in config (auth.totp_key_b64
11 // or env SHITHUB_TOTP_KEY). Operators rotate by re-encrypting all rows —
12 // a key change without a rotation breaks every existing 2FA login. That
13 // procedure is documented in docs/internal/2fa.md.
14 package secretbox
15
16 import (
17 "crypto/cipher"
18 "crypto/rand"
19 "encoding/base64"
20 "errors"
21 "fmt"
22
23 "golang.org/x/crypto/chacha20poly1305"
24 )
25
26 // KeySize is the required key length in bytes.
27 const KeySize = chacha20poly1305.KeySize // 32
28
29 // NonceSize is the AEAD nonce length, stored alongside the ciphertext.
30 const NonceSize = chacha20poly1305.NonceSize // 12
31
32 // Box wraps a ready-to-use AEAD. Construct with FromBase64 or FromBytes.
33 type Box struct {
34 aead cipher.AEAD
35 }
36
37 // FromBase64 decodes the supplied base64 key (standard or url alphabet)
38 // and returns a Box. Returns an error if the key isn't 32 bytes.
39 func FromBase64(b64 string) (*Box, error) {
40 if b64 == "" {
41 return nil, errors.New("secretbox: empty key")
42 }
43 raw, err := decodeKey(b64)
44 if err != nil {
45 return nil, fmt.Errorf("secretbox: decode key: %w", err)
46 }
47 return FromBytes(raw)
48 }
49
50 // FromBytes wraps raw key bytes. Returns an error if not exactly KeySize.
51 func FromBytes(key []byte) (*Box, error) {
52 if len(key) != KeySize {
53 return nil, fmt.Errorf("secretbox: key must be %d bytes, got %d", KeySize, len(key))
54 }
55 aead, err := chacha20poly1305.New(key)
56 if err != nil {
57 return nil, fmt.Errorf("secretbox: aead: %w", err)
58 }
59 return &Box{aead: aead}, nil
60 }
61
62 // Seal encrypts plaintext under a freshly minted random nonce. Returns
63 // (ciphertext, nonce). Nonces are 12 bytes — at our scale the birthday
64 // bound is well above the operational lifetime of any single key.
65 func (b *Box) Seal(plaintext []byte) (ciphertext, nonce []byte, err error) {
66 nonce = make([]byte, NonceSize)
67 if _, err := rand.Read(nonce); err != nil {
68 return nil, nil, fmt.Errorf("secretbox: nonce: %w", err)
69 }
70 ciphertext = b.aead.Seal(nil, nonce, plaintext, nil)
71 return ciphertext, nonce, nil
72 }
73
74 // Open decrypts ciphertext with the stored nonce. Authenticated decryption
75 // failures (tamper, wrong key) return a non-nil error.
76 func (b *Box) Open(ciphertext, nonce []byte) ([]byte, error) {
77 if len(nonce) != NonceSize {
78 return nil, fmt.Errorf("secretbox: nonce must be %d bytes, got %d", NonceSize, len(nonce))
79 }
80 pt, err := b.aead.Open(nil, nonce, ciphertext, nil)
81 if err != nil {
82 return nil, fmt.Errorf("secretbox: open: %w", err)
83 }
84 return pt, nil
85 }
86
87 // GenerateKey returns a fresh 32-byte key suitable for FromBytes /
88 // FromBase64. Used by the test harness and an operator helper.
89 func GenerateKey() ([]byte, error) {
90 k := make([]byte, KeySize)
91 if _, err := rand.Read(k); err != nil {
92 return nil, err
93 }
94 return k, nil
95 }
96
97 // EncodeKey returns the standard-base64 encoding suitable for placing in
98 // auth.totp_key_b64.
99 func EncodeKey(key []byte) string {
100 return base64.StdEncoding.EncodeToString(key)
101 }
102
103 func decodeKey(s string) ([]byte, error) {
104 if raw, err := base64.StdEncoding.DecodeString(s); err == nil {
105 return raw, nil
106 }
107 return base64.URLEncoding.DecodeString(s)
108 }
109