tenseleyflow/shithub / bb02d2b

Browse files

Add chacha20poly1305 secretbox for at-rest secret encryption with per-row nonce

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
bb02d2b749ec4c97c8ac2a4a9ddbe22ca1da8e0a
Parents
09851c8
Tree
4362b99

2 changed files

StatusFile+-
A internal/auth/secretbox/secretbox.go 108 0
A internal/auth/secretbox/secretbox_test.go 121 0
internal/auth/secretbox/secretbox.goadded
@@ -0,0 +1,108 @@
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
+}
internal/auth/secretbox/secretbox_test.goadded
@@ -0,0 +1,121 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package secretbox
4
+
5
+import (
6
+	"bytes"
7
+	"testing"
8
+)
9
+
10
+func mustBox(t *testing.T) *Box {
11
+	t.Helper()
12
+	k, err := GenerateKey()
13
+	if err != nil {
14
+		t.Fatalf("GenerateKey: %v", err)
15
+	}
16
+	b, err := FromBytes(k)
17
+	if err != nil {
18
+		t.Fatalf("FromBytes: %v", err)
19
+	}
20
+	return b
21
+}
22
+
23
+func TestSealOpen_RoundTrip(t *testing.T) {
24
+	t.Parallel()
25
+	b := mustBox(t)
26
+	plaintext := []byte("hunter2-totp-secret-bytes")
27
+
28
+	ct, nonce, err := b.Seal(plaintext)
29
+	if err != nil {
30
+		t.Fatalf("Seal: %v", err)
31
+	}
32
+	if bytes.Contains(ct, plaintext) {
33
+		t.Fatal("ciphertext contains plaintext substring")
34
+	}
35
+	if len(nonce) != NonceSize {
36
+		t.Fatalf("nonce size = %d, want %d", len(nonce), NonceSize)
37
+	}
38
+
39
+	got, err := b.Open(ct, nonce)
40
+	if err != nil {
41
+		t.Fatalf("Open: %v", err)
42
+	}
43
+	if !bytes.Equal(got, plaintext) {
44
+		t.Fatalf("Open mismatch: %s", got)
45
+	}
46
+}
47
+
48
+func TestSealOpen_DifferentNoncesEachCall(t *testing.T) {
49
+	t.Parallel()
50
+	b := mustBox(t)
51
+	plaintext := []byte("same input each time")
52
+	_, n1, _ := b.Seal(plaintext)
53
+	_, n2, _ := b.Seal(plaintext)
54
+	if bytes.Equal(n1, n2) {
55
+		t.Fatal("nonces collided across two Seal calls")
56
+	}
57
+}
58
+
59
+func TestOpen_RejectsTamper(t *testing.T) {
60
+	t.Parallel()
61
+	b := mustBox(t)
62
+	ct, nonce, _ := b.Seal([]byte("authentic"))
63
+	ct[0] ^= 0x01
64
+	if _, err := b.Open(ct, nonce); err == nil {
65
+		t.Fatal("expected error for tampered ciphertext")
66
+	}
67
+}
68
+
69
+func TestOpen_RejectsWrongNonce(t *testing.T) {
70
+	t.Parallel()
71
+	b := mustBox(t)
72
+	ct, nonce, _ := b.Seal([]byte("authentic"))
73
+	otherNonce := make([]byte, len(nonce))
74
+	copy(otherNonce, nonce)
75
+	otherNonce[0] ^= 0x01
76
+	if _, err := b.Open(ct, otherNonce); err == nil {
77
+		t.Fatal("expected error for wrong nonce")
78
+	}
79
+}
80
+
81
+func TestOpen_RejectsWrongKey(t *testing.T) {
82
+	t.Parallel()
83
+	a := mustBox(t)
84
+	b := mustBox(t)
85
+	ct, nonce, _ := a.Seal([]byte("alice"))
86
+	if _, err := b.Open(ct, nonce); err == nil {
87
+		t.Fatal("expected error opening with different key")
88
+	}
89
+}
90
+
91
+func TestFromBase64_InvalidLength(t *testing.T) {
92
+	t.Parallel()
93
+	if _, err := FromBase64(EncodeKey([]byte("too short"))); err == nil {
94
+		t.Fatal("expected error for short key")
95
+	}
96
+	if _, err := FromBase64(""); err == nil {
97
+		t.Fatal("expected error for empty key")
98
+	}
99
+}
100
+
101
+func TestFromBase64_RoundTrip(t *testing.T) {
102
+	t.Parallel()
103
+	k, _ := GenerateKey()
104
+	b1, err := FromBase64(EncodeKey(k))
105
+	if err != nil {
106
+		t.Fatalf("FromBase64: %v", err)
107
+	}
108
+	b2, err := FromBytes(k)
109
+	if err != nil {
110
+		t.Fatalf("FromBytes: %v", err)
111
+	}
112
+	pt := []byte("alice")
113
+	ct, n, _ := b1.Seal(pt)
114
+	out, err := b2.Open(ct, n)
115
+	if err != nil {
116
+		t.Fatalf("Open: %v", err)
117
+	}
118
+	if !bytes.Equal(out, pt) {
119
+		t.Fatal("round-trip mismatch")
120
+	}
121
+}