tenseleyflow/shithub / 63f1455

Browse files

Add token package: 32-byte random tokens with sha256-hash storage

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
63f145511840db1789a39ebf2acc1f6b760094ad
Parents
54ca4ca
Tree
814d5ea

2 changed files

StatusFile+-
A internal/auth/token/token.go 52 0
A internal/auth/token/token_test.go 59 0
internal/auth/token/token.goadded
@@ -0,0 +1,52 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package token mints high-entropy random tokens and stores their sha256
4
+// hashes. The pattern: generate a random token, send it in URLs/emails,
5
+// store only the hash. A DB dump leaks no usable tokens; lookups remain
6
+// O(1) via a UNIQUE index on the hash column.
7
+package token
8
+
9
+import (
10
+	"crypto/rand"
11
+	"crypto/sha256"
12
+	"crypto/subtle"
13
+	"encoding/base64"
14
+	"errors"
15
+)
16
+
17
+// SizeBytes is the random payload length. 32 bytes (256 bits) is far
18
+// beyond brute-force budgets for any plausible attacker. base64url-encoded
19
+// it produces a 43-character ASCII-safe URL fragment.
20
+const SizeBytes = 32
21
+
22
+// New mints a fresh token. Returns the URL-safe encoding for inclusion in
23
+// emails and links and the sha256 hash for storage in the DB.
24
+func New() (encoded string, hash []byte, err error) {
25
+	raw := make([]byte, SizeBytes)
26
+	if _, err := rand.Read(raw); err != nil {
27
+		return "", nil, err
28
+	}
29
+	encoded = base64.RawURLEncoding.EncodeToString(raw)
30
+	sum := sha256.Sum256(raw)
31
+	return encoded, sum[:], nil
32
+}
33
+
34
+// HashOf returns the sha256 of the raw bytes encoded in s. Used by lookup
35
+// paths: parse the URL fragment, hash, query by hash.
36
+func HashOf(s string) ([]byte, error) {
37
+	raw, err := base64.RawURLEncoding.DecodeString(s)
38
+	if err != nil {
39
+		return nil, errors.New("token: malformed encoding")
40
+	}
41
+	if len(raw) != SizeBytes {
42
+		return nil, errors.New("token: wrong length")
43
+	}
44
+	sum := sha256.Sum256(raw)
45
+	return sum[:], nil
46
+}
47
+
48
+// Equal compares two hashes in constant time. Use this instead of
49
+// bytes.Equal anywhere a comparison's timing could leak information.
50
+func Equal(a, b []byte) bool {
51
+	return subtle.ConstantTimeCompare(a, b) == 1
52
+}
internal/auth/token/token_test.goadded
@@ -0,0 +1,59 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package token
4
+
5
+import (
6
+	"crypto/sha256"
7
+	"encoding/base64"
8
+	"testing"
9
+)
10
+
11
+func TestNew_Unique(t *testing.T) {
12
+	t.Parallel()
13
+	seen := map[string]bool{}
14
+	for i := 0; i < 100; i++ {
15
+		enc, h, err := New()
16
+		if err != nil {
17
+			t.Fatalf("New: %v", err)
18
+		}
19
+		if seen[enc] {
20
+			t.Fatalf("duplicate encoded token: %s", enc)
21
+		}
22
+		seen[enc] = true
23
+		if len(h) != sha256.Size {
24
+			t.Fatalf("hash size = %d, want %d", len(h), sha256.Size)
25
+		}
26
+		// HashOf(encoded) must reproduce the same hash.
27
+		got, err := HashOf(enc)
28
+		if err != nil {
29
+			t.Fatalf("HashOf: %v", err)
30
+		}
31
+		if !Equal(got, h) {
32
+			t.Fatalf("HashOf mismatch")
33
+		}
34
+	}
35
+}
36
+
37
+func TestHashOf_RejectsMalformed(t *testing.T) {
38
+	t.Parallel()
39
+	if _, err := HashOf("!!!not-base64!!!"); err == nil {
40
+		t.Fatal("expected error for invalid b64")
41
+	}
42
+	short := base64.RawURLEncoding.EncodeToString([]byte("short"))
43
+	if _, err := HashOf(short); err == nil {
44
+		t.Fatal("expected error for wrong length")
45
+	}
46
+}
47
+
48
+func TestEqual(t *testing.T) {
49
+	t.Parallel()
50
+	a := []byte{1, 2, 3, 4}
51
+	b := []byte{1, 2, 3, 4}
52
+	c := []byte{1, 2, 3, 5}
53
+	if !Equal(a, b) {
54
+		t.Fatal("Equal(a,b) = false, want true")
55
+	}
56
+	if Equal(a, c) {
57
+		t.Fatal("Equal(a,c) = true, want false")
58
+	}
59
+}