Files
wrapped/app/static/js/crypto.js
T
Sergey Antropoff e4240a7be5 Init
2026-07-17 15:57:36 +03:00

200 lines
5.5 KiB
JavaScript

(() => {
const te = new TextEncoder();
const td = new TextDecoder();
function b64encode(buf) {
const bytes = buf instanceof ArrayBuffer ? new Uint8Array(buf) : buf;
let s = "";
for (let i = 0; i < bytes.length; i += 0x8000) {
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(s);
}
function b64decode(str) {
const bin = atob(str);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function b64urlEncode(buf) {
return b64encode(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function b64urlDecode(str) {
const pad = "=".repeat((4 - (str.length % 4)) % 4);
return b64decode(str.replace(/-/g, "+").replace(/_/g, "/") + pad);
}
async function importAesKey(raw) {
return crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, [
"encrypt",
"decrypt",
]);
}
async function derivePasswordKey(password, salt) {
const base = await crypto.subtle.importKey(
"raw",
te.encode(password),
"PBKDF2",
false,
["deriveKey"]
);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 210000, hash: "SHA-256" },
base,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
async function encryptPackage(packageObj, password) {
const dek = crypto.getRandomValues(new Uint8Array(32));
const payload = te.encode(JSON.stringify(packageObj));
const nonce = crypto.getRandomValues(new Uint8Array(12));
const key = await importAesKey(dek);
const encrypted = new Uint8Array(
await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, key, payload)
);
let body = encrypted;
let bodyNonce = nonce;
let flags = 0;
let salt = new Uint8Array(16);
let outerNonce = new Uint8Array(12);
if (password) {
flags = 1;
salt = crypto.getRandomValues(new Uint8Array(16));
outerNonce = crypto.getRandomValues(new Uint8Array(12));
const pwKey = await derivePasswordKey(password, salt);
const inner = new Uint8Array(nonce.length + encrypted.length);
inner.set(nonce, 0);
inner.set(encrypted, nonce.length);
body = new Uint8Array(
await crypto.subtle.encrypt({ name: "AES-GCM", iv: outerNonce }, pwKey, inner)
);
bodyNonce = outerNonce;
}
// header: magic(4) version(1) flags(1) salt(16) nonce(12) + ciphertext
const header = new Uint8Array(4 + 1 + 1 + 16 + 12);
header.set(te.encode("WRPD"), 0);
header[4] = 1;
header[5] = flags;
header.set(salt, 6);
header.set(bodyNonce, 22);
const out = new Uint8Array(header.length + body.length);
out.set(header, 0);
out.set(body, header.length);
return {
ciphertext: out,
keyB64url: b64urlEncode(dek),
};
}
async function decryptPackage(ciphertext, keyB64url, password) {
if (ciphertext.length < 34) throw new Error("truncated");
const magic = td.decode(ciphertext.subarray(0, 4));
if (magic !== "WRPD") throw new Error("bad_magic");
const flags = ciphertext[5];
const salt = ciphertext.subarray(6, 22);
const nonce = ciphertext.subarray(22, 34);
const body = ciphertext.subarray(34);
const dek = b64urlDecode(keyB64url);
let plainEncrypted;
let innerNonce;
if (flags & 1) {
if (!password) throw new Error("password_required");
const pwKey = await derivePasswordKey(password, salt);
let inner;
try {
inner = new Uint8Array(
await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce }, pwKey, body)
);
} catch {
throw new Error("bad_password");
}
innerNonce = inner.subarray(0, 12);
plainEncrypted = inner.subarray(12);
} else {
innerNonce = nonce;
plainEncrypted = body;
}
const key = await importAesKey(dek);
let payload;
try {
payload = new Uint8Array(
await crypto.subtle.decrypt({ name: "AES-GCM", iv: innerNonce }, key, plainEncrypted)
);
} catch {
throw new Error("decrypt_failed");
}
return JSON.parse(td.decode(payload));
}
function buildToken(wrapId, keyB64url) {
return `wrapped_v1.${wrapId}.${keyB64url}`;
}
function parseToken(input) {
const raw = (input || "").trim();
if (!raw) return null;
if (raw.startsWith("wrapped_v1.")) {
const parts = raw.split(".");
if (parts.length < 3) return null;
return { wrapId: parts[1], key: parts.slice(2).join(".") };
}
// bare id — key may come from location.hash
if (/^[A-Za-z0-9]+$/.test(raw) && raw.length >= 16) {
return { wrapId: raw, key: null };
}
try {
const url = new URL(raw);
const m = url.pathname.match(/\/w\/([A-Za-z0-9]+)/);
if (m) {
return { wrapId: m[1], key: url.hash ? url.hash.slice(1) : null };
}
} catch {
/* ignore */
}
return null;
}
function bytesToBase64(bytes) {
return b64encode(bytes);
}
function base64ToBytes(b64) {
return b64decode(b64);
}
async function fileToItem(file) {
const buf = new Uint8Array(await file.arrayBuffer());
return {
type: "file",
name: file.name || "file",
mime: file.type || "application/octet-stream",
data_b64: b64encode(buf),
};
}
window.WrappedCrypto = {
encryptPackage,
decryptPackage,
buildToken,
parseToken,
bytesToBase64,
base64ToBytes,
fileToItem,
b64urlEncode,
};
})();