clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

main
diagramzip / apps / editor / src / encryption.js
encryption.js 7.5K
1import { isKnownDiagramType } from './diagram-types.js'2import { normalizeMetadata, normalizePresentation } from './state.js'34export const PASSWORD_KDF_ITERATIONS = 600_0005export const MIN_PASSWORD_LENGTH = 867const encoder = new TextEncoder()8const decoder = new TextDecoder()9const CONTENT_AAD = encoder.encode('diagram.zip:content:v1')10const METADATA_AAD = encoder.encode('diagram.zip:metadata:v1')11const BUNDLE_KEY_AAD = encoder.encode('diagram.zip:bundle-key:v1')1213function renderAad(format) {14 if (format !== 'svg' && format !== 'png') throw new Error('Invalid render format.')15 return encoder.encode(`diagram.zip:render:${format}:v1`)16}1718function toBase64Url(bytes) {19 let binary = ''20 const chunkSize = 0x800021 for (let index = 0; index < bytes.length; index += chunkSize) {22 binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize))23 }24 return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')25}2627function fromBase64Url(value) {28 if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value)) {29 throw new Error('Invalid encrypted diagram.')30 }31 const normalized = value.replaceAll('-', '+').replaceAll('_', '/')32 const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4)33 const binary = atob(padded)34 const bytes = Uint8Array.from(binary, character => character.charCodeAt(0))35 if (toBase64Url(bytes) !== value) throw new Error('Invalid encrypted diagram.')36 return bytes37}3839function randomBytes(length) {40 return crypto.getRandomValues(new Uint8Array(length))41}4243async function importAesKey(rawKey, usages) {44 if (!(rawKey instanceof Uint8Array) || rawKey.byteLength !== 32) {45 throw new Error('Invalid diagram key.')46 }47 return crypto.subtle.importKey('raw', rawKey, { name: 'AES-GCM' }, false, usages)48}4950async function derivePasswordKey(password, { iterations, salt }) {51 if (typeof password !== 'string') throw new Error('Password is required.')52 const keyMaterial = await crypto.subtle.importKey(53 'raw',54 encoder.encode(password),55 'PBKDF2',56 false,57 ['deriveKey'],58 )59 return crypto.subtle.deriveKey(60 {61 name: 'PBKDF2',62 hash: 'SHA-256',63 iterations,64 salt,65 },66 keyMaterial,67 { name: 'AES-GCM', length: 256 },68 false,69 ['encrypt', 'decrypt'],70 )71}7273async function encryptBytes(bytes, key, additionalData) {74 const iv = randomBytes(12)75 const ciphertext = await crypto.subtle.encrypt(76 { name: 'AES-GCM', iv, additionalData, tagLength: 128 },77 key,78 bytes,79 )80 return {81 v: 1,82 alg: 'A256GCM',83 iv: toBase64Url(iv),84 ciphertext: toBase64Url(new Uint8Array(ciphertext)),85 }86}8788async function decryptBytes(blob, key, additionalData) {89 if (!blob || blob.v !== 1 || blob.alg !== 'A256GCM') throw new Error('Invalid encrypted diagram.')90 const iv = fromBase64Url(blob.iv)91 if (iv.byteLength !== 12) throw new Error('Invalid encrypted diagram.')92 const ciphertext = fromBase64Url(blob.ciphertext)93 if (ciphertext.byteLength < 16) throw new Error('Invalid encrypted diagram.')94 return new Uint8Array(await crypto.subtle.decrypt(95 { name: 'AES-GCM', iv, additionalData, tagLength: 128 },96 key,97 ciphertext,98 ))99}100101async function encryptJson(value, key, additionalData) {102 return encryptBytes(encoder.encode(JSON.stringify(value)), key, additionalData)103}104105async function decryptJson(blob, key, additionalData) {106 return JSON.parse(decoder.decode(await decryptBytes(blob, key, additionalData)))107}108109function normalizedState(diagram, metadata) {110 if (!diagram || typeof diagram !== 'object'111 || !isKnownDiagramType(diagram.type)112 || typeof diagram.source !== 'string'113 || !diagram.options || typeof diagram.options !== 'object' || Array.isArray(diagram.options)) {114 throw new Error('Invalid encrypted diagram.')115 }116 return {117 type: diagram.type,118 source: diagram.source,119 options: diagram.options,120 presentation: normalizePresentation(diagram.presentation),121 meta: normalizeMetadata(metadata),122 }123}124125export async function wrapBundleKey(bundleKey, password) {126 if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {127 throw new Error(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`)128 }129 const salt = randomBytes(16)130 const kdf = {131 name: 'PBKDF2',132 hash: 'SHA-256',133 iterations: PASSWORD_KDF_ITERATIONS,134 salt: toBase64Url(salt),135 }136 const passwordKey = await derivePasswordKey(password, { iterations: kdf.iterations, salt })137 return {138 v: 1,139 kdf,140 wrap: await encryptBytes(bundleKey, passwordKey, BUNDLE_KEY_AAD),141 }142}143144export async function unwrapBundleKey(keyEnvelope, password) {145 if (!keyEnvelope || keyEnvelope.v !== 1146 || keyEnvelope.kdf?.name !== 'PBKDF2'147 || keyEnvelope.kdf.hash !== 'SHA-256'148 || !Number.isInteger(keyEnvelope.kdf.iterations)149 || keyEnvelope.kdf.iterations < 100_000150 || keyEnvelope.kdf.iterations > 5_000_000) {151 throw new Error('Invalid encrypted diagram.')152 }153 const salt = fromBase64Url(keyEnvelope.kdf.salt)154 if (salt.byteLength !== 16) throw new Error('Invalid encrypted diagram.')155 const passwordKey = await derivePasswordKey(password, {156 iterations: keyEnvelope.kdf.iterations,157 salt,158 })159 const bundleKey = await decryptBytes(keyEnvelope.wrap, passwordKey, BUNDLE_KEY_AAD)160 if (bundleKey.byteLength !== 32) throw new Error('Invalid encrypted diagram.')161 return bundleKey162}163164export async function encryptLockedState(state, bundleKey, keyEnvelope) {165 const normalized = normalizedState({166 type: state.type,167 source: state.source,168 options: state.options ?? {},169 presentation: state.presentation,170 }, state.meta)171 const key = await importAesKey(bundleKey, ['encrypt'])172 const encryptedContent = await encryptJson({173 type: normalized.type,174 source: normalized.source,175 options: normalized.options,176 presentation: normalized.presentation,177 }, key, CONTENT_AAD)178 const encryptedMetadata = await encryptJson(normalized.meta, key, METADATA_AAD)179 return { mode: 'locked', encryptedContent, encryptedMetadata, keyEnvelope }180}181182export async function createLockedState(state, password) {183 const bundleKey = randomBytes(32)184 const keyEnvelope = await wrapBundleKey(bundleKey, password)185 return {186 bundleKey,187 payload: await encryptLockedState(state, bundleKey, keyEnvelope),188 }189}190191export async function unlockLockedAlias(alias, password) {192 if (!alias || alias.mode !== 'locked') throw new Error('Invalid encrypted diagram.')193 try {194 const bundleKey = await unwrapBundleKey(alias.keyEnvelope, password)195 const key = await importAesKey(bundleKey, ['decrypt'])196 const [diagram, metadata] = await Promise.all([197 decryptJson(alias.encryptedContent, key, CONTENT_AAD),198 decryptJson(alias.encryptedMetadata, key, METADATA_AAD),199 ])200 return { bundleKey, state: normalizedState(diagram, metadata) }201 } catch {202 throw new Error('Wrong password or damaged diagram.')203 }204}205206export async function encryptLockedRender(blob, bundleKey, format) {207 if (!(blob instanceof Blob)) throw new Error('Invalid rendered image.')208 const key = await importAesKey(bundleKey, ['encrypt'])209 return encryptBytes(new Uint8Array(await blob.arrayBuffer()), key, renderAad(format))210}211212export async function decryptLockedRender(encryptedRender, bundleKey, format) {213 const key = await importAesKey(bundleKey, ['decrypt'])214 const mediaType = format === 'svg' ? 'image/svg+xml' : format === 'png' ? 'image/png' : null215 if (!mediaType) throw new Error('Invalid render format.')216 return new Blob([await decryptBytes(encryptedRender, key, renderAad(format))], { type: mediaType })217}