The codebase that powers boop.cat
boop.cat
1// Copyright 2025 boop.cat
2// Licensed under the Apache License, Version 2.0
3// See LICENSE file for details.
4
5package deploy
6
7import (
8 "crypto/aes"
9 "crypto/cipher"
10 "crypto/rand"
11 "crypto/sha256"
12 "crypto/sha512"
13 "encoding/base64"
14 "fmt"
15 "io"
16 "os"
17 "strings"
18
19 "golang.org/x/crypto/pbkdf2"
20)
21
22const (
23 saltLength = 16
24 keyLength = 32
25)
26
27func getContentKey() []byte {
28 secret := os.Getenv("ENV_ENCRYPTION_SECRET")
29 if secret == "" {
30 return nil
31 }
32
33 h := sha256.New()
34 h.Write([]byte(secret + ":salt"))
35 salt := h.Sum(nil)[:saltLength]
36
37 return pbkdf2.Key([]byte(secret), salt, 100000, keyLength, sha512.New)
38}
39
40func Encrypt(plaintext string) (string, error) {
41 if plaintext == "" {
42 return "", nil
43 }
44
45 key := getContentKey()
46 if key == nil {
47 return plaintext, nil
48 }
49
50 block, err := aes.NewCipher(key)
51 if err != nil {
52 return "", err
53 }
54
55 gcm, err := cipher.NewGCM(block)
56 if err != nil {
57 return "", err
58 }
59
60 nonce := make([]byte, gcm.NonceSize())
61
62 gcm16, err := cipher.NewGCMWithNonceSize(block, 16)
63 if err != nil {
64 return "", err
65 }
66
67 nonce = make([]byte, 16)
68 if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
69 return "", err
70 }
71
72 ciphertext := gcm16.Seal(nil, nonce, []byte(plaintext), nil)
73
74 tagSize := gcm16.Overhead()
75 if len(ciphertext) < tagSize {
76 return "", fmt.Errorf("ciphertext too short")
77 }
78
79 realCiphertext := ciphertext[:len(ciphertext)-tagSize]
80 authTag := ciphertext[len(ciphertext)-tagSize:]
81
82 ivB64 := base64.StdEncoding.EncodeToString(nonce)
83 authTagB64 := base64.StdEncoding.EncodeToString(authTag)
84 encryptedB64 := base64.StdEncoding.EncodeToString(realCiphertext)
85
86 return fmt.Sprintf("enc:v1:%s:%s:%s", ivB64, authTagB64, encryptedB64), nil
87}
88
89func Decrypt(ciphertext string) (string, error) {
90 if ciphertext == "" {
91 return "", nil
92 }
93 if !strings.HasPrefix(ciphertext, "enc:v1:") {
94 return ciphertext, nil
95 }
96
97 key := getContentKey()
98 if key == nil {
99 return ciphertext, nil
100 }
101
102 parts := strings.Split(ciphertext, ":")
103 if len(parts) != 5 {
104 return "", fmt.Errorf("invalid encrypted format")
105 }
106
107 ivB64 := parts[2]
108 authTagB64 := parts[3]
109 encryptedB64 := parts[4]
110
111 iv, err := base64.StdEncoding.DecodeString(ivB64)
112 if err != nil {
113 return "", err
114 }
115 authTag, err := base64.StdEncoding.DecodeString(authTagB64)
116 if err != nil {
117 return "", err
118 }
119 encrypted, err := base64.StdEncoding.DecodeString(encryptedB64)
120 if err != nil {
121 return "", err
122 }
123
124 block, err := aes.NewCipher(key)
125 if err != nil {
126 return "", err
127 }
128
129 gcm16, err := cipher.NewGCMWithNonceSize(block, 16)
130 if err != nil {
131 return "", err
132 }
133
134 fullCiphertext := append(encrypted, authTag...)
135
136 plaintextBytes, err := gcm16.Open(nil, iv, fullCiphertext, nil)
137 if err != nil {
138 return "", err
139 }
140
141 return string(plaintextBytes), nil
142}