diff --git a/frontend/src/components/UserPeerEditModal.vue b/frontend/src/components/UserPeerEditModal.vue
index 21cd098..7594d7b 100644
--- a/frontend/src/components/UserPeerEditModal.vue
+++ b/frontend/src/components/UserPeerEditModal.vue
@@ -211,17 +211,18 @@ async function del() {
{{ $t('modals.peer-edit.header-crypto') }}
{{ $t('modals.peer-edit.private-key.label') }}
-
+ {{ $t('modals.peer-edit.private-key.help') }}
{{ $t('modals.peer-edit.public-key.label') }}
-
{{ $t('modals.peer-edit.preshared-key.label') }}
-
diff --git a/frontend/src/lang/translations/de.json b/frontend/src/lang/translations/de.json
index 53be17e..6815274 100644
--- a/frontend/src/lang/translations/de.json
+++ b/frontend/src/lang/translations/de.json
@@ -39,7 +39,8 @@
"profile": "Mein Profil",
"settings": "Einstellungen",
"login": "Anmelden",
- "logout": "Abmelden"
+ "logout": "Abmelden",
+ "keygen": "Schlüsselgenerator"
},
"home": {
"headline": "WireGuard® VPN Portal",
@@ -188,6 +189,25 @@
"api-link": "API Dokumentation"
}
},
+ "keygen": {
+ "headline": "WireGuard Key Generator",
+ "abstract": "Hier können Sie WireGuard Schlüsselpaare generieren. Die Schlüssel werden lokal auf Ihrem Computer generiert und niemals an den Server gesendet.",
+ "headline-keypair": "Neues Schlüsselpaar",
+ "headline-preshared-key": "Neuer Pre-shared Key",
+ "button-generate": "Erzeugen",
+ "private-key": {
+ "label": "Private Key",
+ "placeholder": "Der private Schlüssel"
+ },
+ "public-key": {
+ "label": "Public Key",
+ "placeholder": "Der öffentliche Schlüssel"
+ },
+ "preshared-key": {
+ "label": "Preshared Key",
+ "placeholder": "Der Pre-shared Schlüssel"
+ }
+ },
"modals": {
"user-view": {
"headline": "User Account:",
@@ -420,7 +440,8 @@
},
"private-key": {
"label": "Private Key",
- "placeholder": "The private key"
+ "placeholder": "The private key",
+ "help": "Der private Schlüssel wird sicher auf dem Server gespeichert. Wenn der Benutzer bereits eine Kopie besitzt, kann dieses Feld entfallen. Der Server funktioniert auch ausschließlich mit dem öffentlichen Schlüssel des Peers."
},
"public-key": {
"label": "Public Key",
diff --git a/frontend/src/lang/translations/en.json b/frontend/src/lang/translations/en.json
index 93e230d..49ef9a8 100644
--- a/frontend/src/lang/translations/en.json
+++ b/frontend/src/lang/translations/en.json
@@ -40,7 +40,8 @@
"settings": "Settings",
"audit": "Audit Log",
"login": "Login",
- "logout": "Logout"
+ "logout": "Logout",
+ "keygen": "Key Generator"
},
"home": {
"headline": "WireGuard® VPN Portal",
@@ -206,6 +207,25 @@
"message": "Message"
}
},
+ "keygen": {
+ "headline": "WireGuard Key Generator",
+ "abstract": "Generate a new WireGuard keys. The keys are generated in your local browser and are never sent to the server.",
+ "headline-keypair": "New Key Pair",
+ "headline-preshared-key": "New Preshared Key",
+ "button-generate": "Generate",
+ "private-key": {
+ "label": "Private Key",
+ "placeholder": "The private key"
+ },
+ "public-key": {
+ "label": "Public Key",
+ "placeholder": "The public key"
+ },
+ "preshared-key": {
+ "label": "Preshared Key",
+ "placeholder": "The pre-shared key"
+ }
+ },
"modals": {
"user-view": {
"headline": "User Account:",
@@ -439,7 +459,8 @@
},
"private-key": {
"label": "Private Key",
- "placeholder": "The private key"
+ "placeholder": "The private key",
+ "help": "The private key is stored securely on the server. If the user already holds a copy, you may omit this field. The server still functions exclusively with the peer’s public key."
},
"public-key": {
"label": "Public Key",
diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js
index 1bbc722..ab1eb05 100644
--- a/frontend/src/router/index.js
+++ b/frontend/src/router/index.js
@@ -64,6 +64,14 @@ const router = createRouter({
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AuditView.vue')
+ },
+ {
+ path: '/key-generator',
+ name: 'key-generator',
+ // route level code-splitting
+ // this generates a separate chunk (About.[hash].js) for this route
+ // which is lazy-loaded when the route is visited.
+ component: () => import('../views/KeyGeneraterView.vue')
}
],
linkActiveClass: "active",
@@ -114,11 +122,11 @@ router.beforeEach(async (to) => {
}
// redirect to login page if not logged in and trying to access a restricted page
- const publicPages = ['/', '/login']
+ const publicPages = ['/', '/login', '/key-generator']
const authRequired = !publicPages.includes(to.path)
if (authRequired && !auth.IsAuthenticated) {
- auth.SetReturnUrl(to.fullPath) // store original destination before starting the auth process
+ auth.SetReturnUrl(to.fullPath) // store the original destination before starting the auth process
return '/login'
}
})
diff --git a/frontend/src/views/KeyGeneraterView.vue b/frontend/src/views/KeyGeneraterView.vue
new file mode 100644
index 0000000..511348f
--- /dev/null
+++ b/frontend/src/views/KeyGeneraterView.vue
@@ -0,0 +1,241 @@
+
+
+
+
+
+ {{ $t('keygen.abstract') }}
+
+
+
+
{{ $t('keygen.headline-keypair') }}
+
+
+ {{ $t('keygen.private-key.label') }}
+
+
+
+ {{ $t('keygen.public-key.label') }}
+
+
+
+
+
+ {{ $t('keygen.button-generate') }}
+
+
+
+
+
+
{{ $t('keygen.headline-preshared-key') }}
+
+
+ {{ $t('keygen.preshared-key.label') }}
+
+
+
+
+
+ {{ $t('keygen.button-generate') }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/internal/app/gorm_encryption.go b/internal/app/gorm_encryption.go
new file mode 100644
index 0000000..cf0271a
--- /dev/null
+++ b/internal/app/gorm_encryption.go
@@ -0,0 +1,201 @@
+package app
+
+import (
+ "bytes"
+ "context"
+ "crypto/aes"
+ "crypto/cipher"
+ "encoding/base64"
+ "fmt"
+ "reflect"
+ "strings"
+
+ "gorm.io/gorm/schema"
+
+ "github.com/h44z/wg-portal/internal/domain"
+)
+
+// GormEncryptedStringSerializer is a GORM serializer that encrypts and decrypts string values using AES256.
+// It is used to store sensitive information in the database securely.
+// If the serializer encounters a value that is not a string, it will return an error.
+type GormEncryptedStringSerializer struct {
+ useEncryption bool
+ keyPhrase string
+ prefix string
+}
+
+// NewGormEncryptedStringSerializer creates a new GormEncryptedStringSerializer.
+// It needs to be registered with GORM to be used:
+// schema.RegisterSerializer("encstr", gormEncryptedStringSerializerInstance)
+// You can then use it in your model like this:
+//
+// EncryptedField string `gorm:"serializer:encstr"`
+func NewGormEncryptedStringSerializer(keyPhrase string) GormEncryptedStringSerializer {
+ return GormEncryptedStringSerializer{
+ useEncryption: keyPhrase != "",
+ keyPhrase: keyPhrase,
+ prefix: "WG_ENC_",
+ }
+}
+
+// Scan implements the GORM serializer interface. It decrypts the value after reading it from the database.
+func (s GormEncryptedStringSerializer) Scan(
+ ctx context.Context,
+ field *schema.Field,
+ dst reflect.Value,
+ dbValue any,
+) (err error) {
+ var dbStringValue string
+ if dbValue != nil {
+ switch v := dbValue.(type) {
+ case []byte:
+ dbStringValue = string(v)
+ case string:
+ dbStringValue = v
+ default:
+ return fmt.Errorf("unsupported type %T for encrypted field %s", dbValue, field.Name)
+ }
+ }
+
+ if !s.useEncryption {
+ field.ReflectValueOf(ctx, dst).SetString(dbStringValue) // keep the original value
+ return nil
+ }
+
+ if !strings.HasPrefix(dbStringValue, s.prefix) {
+ field.ReflectValueOf(ctx, dst).SetString(dbStringValue) // keep the original value
+ return nil
+ }
+
+ encryptedString := strings.TrimPrefix(dbStringValue, s.prefix)
+ decryptedString, err := DecryptAES256(encryptedString, s.keyPhrase)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt value for field %s: %w", field.Name, err)
+ }
+
+ field.ReflectValueOf(ctx, dst).SetString(decryptedString)
+ return
+}
+
+// Value implements the GORM serializer interface. It encrypts the value before storing it in the database.
+func (s GormEncryptedStringSerializer) Value(
+ _ context.Context,
+ _ *schema.Field,
+ _ reflect.Value,
+ fieldValue any,
+) (any, error) {
+ if fieldValue == nil {
+ return nil, nil
+ }
+
+ if !s.useEncryption {
+ return fieldValue, nil // keep the original value
+ }
+
+ switch v := fieldValue.(type) {
+ case string:
+ if v == "" {
+ return "", nil // empty string, no need to encrypt
+ }
+ encryptedString, err := EncryptAES256(v, s.keyPhrase)
+ if err != nil {
+ return nil, err
+ }
+ return s.prefix + encryptedString, nil
+ case domain.PreSharedKey:
+ if v == "" {
+ return "", nil // empty string, no need to encrypt
+ }
+ encryptedString, err := EncryptAES256(string(v), s.keyPhrase)
+ if err != nil {
+ return nil, err
+ }
+ return s.prefix + encryptedString, nil
+ default:
+ return nil, fmt.Errorf("encryption only supports string values, got %T", fieldValue)
+ }
+}
+
+// EncryptAES256 encrypts the given plaintext with the given key using AES256 in CBC mode with PKCS7 padding
+func EncryptAES256(plaintext, key string) (string, error) {
+ if len(plaintext) == 0 {
+ return "", fmt.Errorf("plaintext must not be empty")
+ }
+ if len(key) == 0 {
+ return "", fmt.Errorf("key must not be empty")
+ }
+ key = trimEncKey(key)
+ iv := key[:aes.BlockSize]
+
+ block, err := aes.NewCipher([]byte(key))
+ if err != nil {
+ return "", err
+ }
+
+ plain := []byte(plaintext)
+ plain = pkcs7Padding(plain, aes.BlockSize)
+
+ ciphertext := make([]byte, len(plain))
+
+ mode := cipher.NewCBCEncrypter(block, []byte(iv))
+ mode.CryptBlocks(ciphertext, plain)
+
+ b64String := base64.StdEncoding.EncodeToString(ciphertext)
+
+ return b64String, nil
+}
+
+// DecryptAES256 decrypts the given ciphertext with the given key using AES256 in CBC mode with PKCS7 padding
+func DecryptAES256(encrypted, key string) (string, error) {
+ if len(encrypted) == 0 {
+ return "", fmt.Errorf("ciphertext must not be empty")
+ }
+ if len(key) == 0 {
+ return "", fmt.Errorf("key must not be empty")
+ }
+ key = trimEncKey(key)
+ iv := key[:aes.BlockSize]
+
+ ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
+ if err != nil {
+ return "", err
+ }
+ if len(ciphertext)%aes.BlockSize != 0 {
+ return "", fmt.Errorf("invalid ciphertext length, must be a multiple of %d", aes.BlockSize)
+ }
+
+ block, err := aes.NewCipher([]byte(key))
+ if err != nil {
+ return "", err
+ }
+
+ mode := cipher.NewCBCDecrypter(block, []byte(iv))
+ mode.CryptBlocks(ciphertext, ciphertext)
+
+ ciphertext = pkcs7UnPadding(ciphertext)
+
+ return string(ciphertext), nil
+}
+
+func pkcs7Padding(ciphertext []byte, blockSize int) []byte {
+ padding := blockSize - len(ciphertext)%blockSize
+ padtext := bytes.Repeat([]byte{byte(padding)}, padding)
+ return append(ciphertext, padtext...)
+}
+
+func pkcs7UnPadding(src []byte) []byte {
+ length := len(src)
+ unpadding := int(src[length-1])
+ return src[:(length - unpadding)]
+}
+
+func trimEncKey(key string) string {
+ if len(key) > 32 {
+ return key[:32]
+ }
+
+ if len(key) < 32 {
+ key = key + strings.Repeat("0", 32-len(key))
+ }
+ return key
+}
diff --git a/internal/config/database.go b/internal/config/database.go
index a336683..06554a7 100644
--- a/internal/config/database.go
+++ b/internal/config/database.go
@@ -25,4 +25,7 @@ type DatabaseConfig struct {
// For SQLite, it is the path to the database file.
// For other databases, it is the connection string, see: https://gorm.io/docs/connecting_to_the_database.html
DSN string `yaml:"dsn"`
+ // EncryptionPassphrase is the passphrase used to encrypt sensitive data (WireGuard keys) in the database.
+ // If no passphrase is provided, no encryption will be used.
+ EncryptionPassphrase string `yaml:"encryption_passphrase"`
}
diff --git a/internal/domain/crypto.go b/internal/domain/crypto.go
index 3f7f303..6e65921 100644
--- a/internal/domain/crypto.go
+++ b/internal/domain/crypto.go
@@ -7,7 +7,7 @@ import (
)
type KeyPair struct {
- PrivateKey string
+ PrivateKey string `gorm:"serializer:encstr"`
PublicKey string
}
diff --git a/internal/domain/peer.go b/internal/domain/peer.go
index 699301c..4e40a83 100644
--- a/internal/domain/peer.go
+++ b/internal/domain/peer.go
@@ -7,9 +7,9 @@ import (
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
- "github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal"
+ "github.com/h44z/wg-portal/internal/config"
)
type PeerIdentifier string
@@ -36,7 +36,7 @@ type Peer struct {
EndpointPublicKey ConfigOption[string] `gorm:"embedded;embeddedPrefix:endpoint_pubkey_"` // the endpoint public key
AllowedIPsStr ConfigOption[string] `gorm:"embedded;embeddedPrefix:allowed_ips_str_"` // all allowed ip subnets, comma seperated
ExtraAllowedIPsStr string // all allowed ip subnets on the server side, comma seperated
- PresharedKey PreSharedKey // the pre-shared Key of the peer
+ PresharedKey PreSharedKey `gorm:"serializer:encstr"` // the pre-shared Key of the peer
PersistentKeepalive ConfigOption[int] `gorm:"embedded;embeddedPrefix:persistent_keep_alive_"` // the persistent keep-alive interval
// WG Portal specific