200 lines
5.5 KiB
JavaScript
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,
|
|
};
|
|
})();
|