diff --git a/docs/features/vpn.rst b/docs/features/vpn.rst
index 6db2ecc65270bcaea1d5097bb91beb2c503cdd35..88a84cbdb891d769ac85f7ff2034ea3ec4ad0f7f 100644
--- a/docs/features/vpn.rst
+++ b/docs/features/vpn.rst
@@ -191,6 +191,16 @@ negative effects. Only when a previously connected node reboots the effect
 comes into play, as the gateway still knows about the old timestamp of the gluon
 node.
 
+gluon-mesh-vpn-key-translate
+""""""""""""""""""""""""""""
+
+Many communities already possess a collection of active fastd-keys when they
+plan migrating their community to WireGuard.
+These public keys known on the server-side can be derived into their WireGuard
+equivalent using `gluon-mesh-vpn-key-translate <https://github.com/AiyionPrime/gluon-mesh-vpn-key-translate>`__.
+The routers do the necessary reencoding of the private key seamlessly
+when updating firmware from fastd to the WireGuard variant.
+
 Gateway / Supernode Configuration
 """""""""""""""""""""""""""""""""
 
diff --git a/package/gluon-mesh-vpn-wireguard/Makefile b/package/gluon-mesh-vpn-wireguard/Makefile
index 61c5333200e24744cf5aa6927228b324d075899e..9d4e9a791b6602cfbb272ca379503f096bf0387f 100644
--- a/package/gluon-mesh-vpn-wireguard/Makefile
+++ b/package/gluon-mesh-vpn-wireguard/Makefile
@@ -6,7 +6,13 @@ include ../gluon.mk
 
 define Package/gluon-mesh-vpn-wireguard
   TITLE:=Support for connecting meshes via wireguard
-  DEPENDS:=+gluon-core +libgluonutil +gluon-mesh-vpn-core +wireguard-tools +wgpeerselector +libubus
+  DEPENDS:=+gluon-core +libgluonutil +gluon-mesh-vpn-core +wireguard-tools +wgpeerselector +libubox +libubus
+endef
+
+define Package/gluon-mesh-vpn-wireguard/install
+	$(Gluon/Build/Install)
+	$(INSTALL_DIR) $(1)/usr/sbin
+	$(INSTALL_BIN) $(PKG_BUILD_DIR)/gluon-hex-to-b64 $(1)/usr/sbin/
 endef
 
 $(eval $(call BuildPackageGluon,gluon-mesh-vpn-wireguard))
diff --git a/package/gluon-mesh-vpn-wireguard/luasrc/lib/gluon/upgrade/400-mesh-vpn-wireguard b/package/gluon-mesh-vpn-wireguard/luasrc/lib/gluon/upgrade/400-mesh-vpn-wireguard
index 18b5197b4547918ad17dd4c9a208454b8064c133..b58ceb91068e8ad050077332e97a8700e50f72dd 100755
--- a/package/gluon-mesh-vpn-wireguard/luasrc/lib/gluon/upgrade/400-mesh-vpn-wireguard
+++ b/package/gluon-mesh-vpn-wireguard/luasrc/lib/gluon/upgrade/400-mesh-vpn-wireguard
@@ -1,18 +1,63 @@
 #!/usr/bin/lua
 
 local uci = require('simple-uci').cursor()
+local unistd = require 'posix.unistd'
+local util = require('gluon.util')
 local site = require 'gluon.site'
+local sp = util.subprocess
+local wait = require 'posix.sys.wait'
 
-local private_key = uci:get("network_gluon-old", 'wg_mesh', "private_key")
+local wg_private_key = uci:get("network_gluon-old", 'wg_mesh', "private_key")
 
-if not private_key or not private_key:match("^" .. ("[%a%d+/]"):rep(42) .. "[AEIMQUYcgkosw480]=$") then
-	private_key = "generate"
+local function valid_fastd_key(fastd_key)
+	return fastd_key and fastd_key:match(('%x'):rep(64))
 end
 
+local function valid_wireguard_key(wireguard_key)
+	return wireguard_key and wireguard_key:match("^" .. ("[%a%d+/]"):rep(42) .. "[AEIMQUYcgkosw480]=$")
+end
+
+local function migrate_from_fastd_secret(fastd_secret)
+	local options = {
+		stdin = sp.PIPE,
+		stdout = sp.PIPE,
+	}
+	local pid, pipe = sp.popen('gluon-hex-to-b64', {}, options)
+
+	if not pid then
+		return
+	end
+
+	local inw = pipe.stdin
+	local out = pipe.stdout
+
+	unistd.write(inw, string.format('%s\n', fastd_secret))
+	unistd.close(inw)
+
+	local wpid, status, code = wait.wait(pid)
+	if wpid and status == 'exited' and code == 0 then
+		local result = unistd.read(out, 44)
+		unistd.close(out)
+		return result
+	end
+end
+
+if not valid_wireguard_key(wg_private_key) then
+	local fastd_secret = uci:get('fastd', 'mesh_vpn', 'secret')
+	if valid_fastd_key(fastd_secret) then
+		wg_private_key = migrate_from_fastd_secret(fastd_secret)
+	end
+end
+
+if not valid_wireguard_key(wg_private_key) then
+	wg_private_key = "generate"
+end
+
+
 uci:section('network', 'interface', 'wg_mesh', {
 	proto = 'wireguard',
 	fwmark = 1,
-	private_key = private_key,
+	private_key = wg_private_key,
 })
 
 uci:section('network', 'interface', 'mesh_wg_mesh', {
diff --git a/package/gluon-mesh-vpn-wireguard/src/Makefile b/package/gluon-mesh-vpn-wireguard/src/Makefile
index 0b027848f69fab204047104bca849f1b07430738..ff847068c378076023dfb3d480a56d1cc166a6e9 100644
--- a/package/gluon-mesh-vpn-wireguard/src/Makefile
+++ b/package/gluon-mesh-vpn-wireguard/src/Makefile
@@ -1,6 +1,9 @@
-all: respondd.so
+all: respondd.so gluon-hex-to-b64
 
 CFLAGS += -Wall -Werror-implicit-function-declaration
 
+gluon-hex-to-b64: gluon-hex-to-b64.c
+	$(CC) $(CPPFLAGS) $(CFLAGS) $(LDFLAGS) -Wall -o $@ $^ $(LDLIBS) -lubox
+
 respondd.so: respondd.c
 	$(CC) $(CPPFLAGS) $(CFLAGS) $(LDFLAGS) -shared -fPIC -D_GNU_SOURCE -o $@ $^ $(LDLIBS) -lgluonutil -lubus
diff --git a/package/gluon-mesh-vpn-wireguard/src/gluon-hex-to-b64.c b/package/gluon-mesh-vpn-wireguard/src/gluon-hex-to-b64.c
new file mode 100644
index 0000000000000000000000000000000000000000..0bca245e65f0b216db45dd2f78f8b75621436ddb
--- /dev/null
+++ b/package/gluon-mesh-vpn-wireguard/src/gluon-hex-to-b64.c
@@ -0,0 +1,101 @@
+// SPDX-FileCopyrightText: 2022 Jan-Niklas Burfeind <gluon@aiyionpri.me>
+// SPDX-License-Identifier: BSD-2-Clause
+// SPDX-FileContributor: read_hex() by Matthias Schiffer <mschiffer@universe-factory.net>
+
+#include <libubox/utils.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+/**
+ * how many blocks should be encoded at once - can be configured
+ */
+#define BLOCK_AMOUNT 32
+
+/**
+ * smallest possible block size to encode in b64 without further contex
+ * is three bytes - do not change
+ */
+#define CHUNK_SIZE (3*BLOCK_AMOUNT)
+
+/** print usage info and exit as failed */
+static void usage(void) {
+	fprintf(stderr, "Usage: gluon-hex-to-b64\n");
+	exit(1);
+}
+
+/**
+ * read a string of hexadecimal characters and return them as bytes
+ * return false in case any non-hexadecimal characters are provided
+ * return true on success
+ */
+static bool read_hex(uint8_t key[CHUNK_SIZE], const char *hexstr) {
+	if (strspn(hexstr, "0123456789abcdefABCDEF") != strlen(hexstr))
+		return false;
+
+	size_t i;
+	for (i = 0; i < CHUNK_SIZE; i++)
+		sscanf(&hexstr[2 * i], "%02hhx", &key[i]);
+
+	return true;
+}
+
+int main(int argc, char *argv[]) {
+	if (argc != 1)
+		usage();
+
+	unsigned char hex_input[CHUNK_SIZE * 2 + 1];
+	uint8_t as_bytes[CHUNK_SIZE];
+	int byte_count;
+	int b64_buflen = B64_ENCODE_LEN(CHUNK_SIZE);
+	int b64_return;
+	size_t ret;
+
+	char str[b64_buflen];
+
+	do {
+		ret = fread(hex_input, 1, sizeof(hex_input) - 1, stdin);
+		hex_input[ret] = '\0';
+
+		/* in case fread did not fill six characters */
+		if (ret != sizeof(hex_input)-1) {
+			/* drop newline by replacing it with a null character */
+			hex_input[strcspn(hex_input, "\n")] = 0;
+
+			/*
+			 * count length of resulting string and make sure it's even,
+			 * as bytes are represented using pairs of hex characters
+			 */
+			ret = strlen(hex_input);
+			if (ret % 2 == 1) {
+				fprintf(stderr, "Error: Incomplete hex representation of a byte.\n");
+				exit(EXIT_FAILURE);
+			}
+		}
+
+		byte_count = ret / 2;
+		b64_buflen = B64_ENCODE_LEN(byte_count);
+
+		/* in case read_hex fails due to invalid characters */
+		if (!read_hex(as_bytes, hex_input)) {
+			fprintf(stderr, "Error: Invalid hexadecimal input.\n");
+			exit(EXIT_FAILURE);
+		}
+
+		b64_return = b64_encode(as_bytes, byte_count, str, b64_buflen);
+
+		/* trailing '\0' is not counted by b64_encode(), so we subtract one character */
+		if (b64_buflen - 1 != b64_return) {
+			fprintf(stderr, "Error: Encoding bytes as b64 failed.\n");
+			exit(EXIT_FAILURE);
+		}
+
+		printf("%s", str);
+	/* repeat until a non full block is read */
+	} while (ret == sizeof(hex_input)-1);
+	printf("\n");
+
+	exit(EXIT_SUCCESS);
+}