Initial commit
Signed-off-by: Julien Riou <julien@riou.xyz>
This commit is contained in:
commit
ef9aca1f3b
26 changed files with 1668 additions and 0 deletions
13
src/internal/encoding.go
Normal file
13
src/internal/encoding.go
Normal 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)
|
||||
}
|
77
src/internal/encryption.go
Normal file
77
src/internal/encryption.go
Normal 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)
|
||||
}
|
44
src/internal/encryption_test.go
Normal file
44
src/internal/encryption_test.go
Normal 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
8
src/internal/internal.go
Normal 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
101
src/internal/utils.go
Normal 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)
|
||||
}
|
40
src/internal/utils_test.go
Normal file
40
src/internal/utils_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue