diff --git a/html/src/security.js b/html/src/security.js
new file mode 100644
index 00000000..268eb0eb
--- /dev/null
+++ b/html/src/security.js
@@ -0,0 +1,67 @@
+const defaultAESKey = new TextEncoder().encode(
+ 'https://github.com/pypy-vrc/VRCX'
+)
+
+const hexToUint8Array = (hexStr) => {
+ const r = hexStr.match(/.{1,2}/g)
+ if (!r) return null
+ return new Uint8Array(r.map((b) => parseInt(b, 16)))
+}
+
+const uint8ArrayToHex = (arr) =>
+ arr.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '')
+
+function stdAESKey(key) {
+ const tKey = new TextEncoder().encode(key)
+ let sk = tKey
+ if (key.length < 32) {
+ sk = new Uint8Array(32)
+ sk.set(tKey)
+ sk.set(defaultAESKey.slice(key.length, 32), key.length)
+ }
+ return sk.slice(0, 32)
+}
+
+async function encrypt(plaintext, key) {
+ let iv = window.crypto.getRandomValues(new Uint8Array(12))
+ let sharedKey = await window.crypto.subtle.importKey(
+ 'raw',
+ stdAESKey(key),
+ { name: 'AES-GCM', length: 256 },
+ true,
+ ['encrypt']
+ )
+ let cipher = await window.crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv },
+ sharedKey,
+ new TextEncoder().encode(plaintext)
+ )
+ let ciphertext = new Uint8Array(cipher)
+ let encrypted = new Uint8Array(iv.length + ciphertext.byteLength)
+ encrypted.set(iv, 0)
+ encrypted.set(ciphertext, iv.length)
+ return uint8ArrayToHex(encrypted)
+}
+
+async function decrypt(ciphertext, key) {
+ let text = hexToUint8Array(ciphertext)
+ if (!text) return ''
+ let sharedKey = await window.crypto.subtle.importKey(
+ 'raw',
+ stdAESKey(key),
+ { name: 'AES-GCM', length: 256 },
+ true,
+ ['decrypt']
+ )
+ let plaintext = await window.crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv: text.slice(0, 12) },
+ sharedKey,
+ text.slice(12)
+ )
+ return new TextDecoder().decode(new Uint8Array(plaintext))
+}
+
+export default {
+ decrypt,
+ encrypt,
+}