Initial commit

Signed-off-by: Julien Riou <julien@riou.xyz>
This commit is contained in:
Julien Riou 2025-08-21 16:22:03 +02:00
commit ef9aca1f3b
Signed by: jriou
GPG key ID: 9A099EDA51316854
26 changed files with 1668 additions and 0 deletions

13
src/internal/encoding.go Normal file
View file

@ -0,0 +1,13 @@
package internal
import (
"encoding/base64"
)
func Encode(data []byte) string {
return base64.StdEncoding.EncodeToString(data)
}
func Decode(data string) ([]byte, error) {
return base64.StdEncoding.DecodeString(data)
}

View file

@ -0,0 +1,77 @@
package internal
import (
"crypto/cipher"
"crypto/rand"
"fmt"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/chacha20poly1305"
)
const (
SaltSize = 32
KeySize = uint32(32) // KeySize is 32 bytes (256 bits).
KeyTime = uint32(5)
KeyMemory = uint32(1024 * 64) // KeyMemory in KiB. here, 64 MiB.
KeyThreads = uint8(4)
)
// NewCipher creates a cipher using XChaCha20-Poly1305
// https://pkg.go.dev/golang.org/x/crypto/chacha20poly1305
// A salt is required to derive the key from a password using argon
func NewCipher(password string, salt []byte) (cipher.AEAD, error) {
key := argon2.IDKey([]byte(password), salt, KeyTime, KeyMemory, KeyThreads, KeySize)
return chacha20poly1305.NewX(key)
}
// Encrypt to encrypt a plaintext with a password
// Returns a byte slice with the generated salt, nonce and the ciphertext
func Encrypt(plaintext []byte, password string) (result []byte, err error) {
salt := make([]byte, SaltSize)
if n, err := rand.Read(salt); err != nil || n != SaltSize {
return nil, err
}
aead, err := NewCipher(password, salt)
if err != nil {
return nil, err
}
result = append(result, salt...)
nonce := make([]byte, aead.NonceSize())
if m, err := rand.Read(nonce); err != nil || m != aead.NonceSize() {
return nil, err
}
result = append(result, nonce...)
ciphertext := aead.Seal(nil, nonce, plaintext, nil)
result = append(result, ciphertext...)
return result, nil
}
// Decrypt to decrypt a ciphertext with a password
// Returns the plaintext
func Decrypt(ciphertext []byte, password string) ([]byte, error) {
if len(ciphertext) < SaltSize {
return nil, fmt.Errorf("ciphertext is too short: cannot read salt")
}
salt := ciphertext[:SaltSize]
aead, err := NewCipher(password, salt)
if err != nil {
return nil, err
}
if len(ciphertext) < SaltSize+aead.NonceSize() {
return nil, fmt.Errorf("ciphertext is too short: cannot read nonce")
}
nonce := ciphertext[SaltSize : SaltSize+aead.NonceSize()]
ciphertext = ciphertext[SaltSize+aead.NonceSize():]
return aead.Open(nil, nonce, ciphertext, nil)
}

View file

@ -0,0 +1,44 @@
package internal
import (
"testing"
)
func TestEncryptAndDecrypt(t *testing.T) {
plaintext := "test"
password := "test"
wrongPassword := password + "wrong"
ciphertext, err := Encrypt([]byte(plaintext), password)
if err != nil {
t.Errorf("unexpected error when encrypting: %v", err)
return
}
if plaintext == string(ciphertext) {
t.Errorf("plaintext and ciphertext are equal")
return
}
cleartext, err := Decrypt(ciphertext, password)
if err != nil {
t.Errorf("unexpected error when decrypting: %v", err)
return
}
if string(cleartext) != string(plaintext) {
t.Errorf("got '%s', expected '%s'", cleartext, plaintext)
return
}
if password == wrongPassword {
t.Errorf("passwords must be different")
return
}
_, err = Decrypt(ciphertext, wrongPassword)
if err == nil {
t.Errorf("expected error when decrypting with a wrong password, got none")
return
}
}

8
src/internal/internal.go Normal file
View file

@ -0,0 +1,8 @@
package internal
const (
RC_OK = 0
RC_ERROR = 1
MIN_PASSWORD_LENGTH = 16
MAX_PASSWORD_LENGTH = 256
)

101
src/internal/utils.go Normal file
View file

@ -0,0 +1,101 @@
package internal
import (
"encoding/json"
"fmt"
"math/rand"
"os"
"path/filepath"
"regexp"
)
func ReadConfig(file string, config interface{}) error {
file, err := filepath.Abs(file)
if err != nil {
return err
}
jsonFile, err := os.ReadFile(file)
if err != nil {
return err
}
err = json.Unmarshal(jsonFile, &config)
if err != nil {
return err
}
return nil
}
func Version(appName, appVersion, gitCommit, goVersion string) string {
version := appName
if appVersion != "" {
version += " " + appVersion
}
if gitCommit != "" {
version += "-" + gitCommit
}
if goVersion != "" {
version += " (compiled with Go " + goVersion + ")"
}
return version
}
func ShowVersion(appName, appVersion, gitCommit, goVersion string) {
fmt.Print(Version(appName, appVersion, gitCommit, goVersion) + "\n")
}
const randomChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
func GenerateChars(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = randomChars[rand.Intn(len(randomChars))]
}
return string(b)
}
// Passwords must be URL compatible and strong enough
// Requiring only alphanumeric chars with a size between 16 and 256
var passwordRegexp = regexp.MustCompile("^[a-zA-Z0-9]{16,256}$")
func ValidatePassword(p string) error {
if !passwordRegexp.MatchString(p) {
return fmt.Errorf("password doesn't match '%s'", passwordRegexp)
}
return nil
}
// HumanDuration converts number of seconds to a human friendly format
// Ex: 3600 -> "1 hour"
// Yes it uses rough approximations ¯\_(ツ)_/¯
func HumanDuration(i int) string {
var w string
if i < 0 {
return ""
} else if i >= 0 && i < 60 {
w = "second"
} else if i >= 60 && i < 60*60 {
i = i / 60
w = "minute"
} else if i >= 60*60 && i < 60*60*24 {
w = "hour"
i = i / (60 * 60)
} else if i >= 60*60*24 && i < 60*60*24*7 {
w = "day"
i = i / (60 * 60 * 24)
} else if i >= 60*60*24*7 && i < 60*60*24*31 {
w = "week"
i = i / (60 * 60 * 24 * 7)
} else if i >= 60*60*24*31 && i < 60*60*24*365 {
w = "month"
i = i / (60 * 60 * 24 * 31)
} else {
w = "year"
i = i / (60 * 60 * 24 * 365)
}
if i > 1 {
w = w + "s"
}
return fmt.Sprintf("%d %s", i, w)
}

View file

@ -0,0 +1,40 @@
package internal
import (
"fmt"
"testing"
)
func TestHumanDuration(t *testing.T) {
tests := []struct {
i int
expected string
}{
{-1, ""},
{1, "1 second"},
{3, "3 seconds"},
{60, "1 minute"},
{120, "2 minutes"},
{3600, "1 hour"},
{7200, "2 hours"},
{86400, "1 day"},
{172800, "2 days"},
{604800, "1 week"},
{1209600, "2 weeks"},
{2678400, "1 month"},
{5356800, "2 months"},
{31536000, "1 year"},
{63072000, "2 years"},
}
for _, tc := range tests {
t.Run(fmt.Sprintf("TestHumanDuration#%d", tc.i), func(t *testing.T) {
got := HumanDuration(tc.i)
if got != tc.expected {
t.Errorf("got '%s', want '%s'", got, tc.expected)
} else {
t.Logf("got '%s', want '%s'", got, tc.expected)
}
})
}
}