100 lines
3.0 KiB
TypeScript
100 lines
3.0 KiB
TypeScript
const ZW_VERSION = 1
|
|
const KEY_SEED = 'JGJS2026::ZW::ARCHIVE::V1::DO_NOT_TAMPER'
|
|
const MAGIC_BYTES = new Uint8Array([0x4a, 0x47, 0x4a, 0x53, 0x5a, 0x57]) // JGJSZW
|
|
|
|
export const ZW_FILE_EXTENSION = '.zw'
|
|
|
|
const encoder = new TextEncoder()
|
|
const decoder = new TextDecoder()
|
|
let cachedKeyPromise: Promise<CryptoKey> | null = null
|
|
|
|
const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer =>
|
|
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
|
|
|
const getArchiveKey = async (): Promise<CryptoKey> => {
|
|
if (!cachedKeyPromise) {
|
|
cachedKeyPromise = (async () => {
|
|
const hash = await crypto.subtle.digest('SHA-256', encoder.encode(KEY_SEED))
|
|
return crypto.subtle.importKey('raw', hash, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'])
|
|
})()
|
|
}
|
|
return cachedKeyPromise
|
|
}
|
|
|
|
export const encodeZwArchive = async (payload: unknown): Promise<Uint8Array> => {
|
|
const key = await getArchiveKey()
|
|
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
const ivBuffer = toArrayBuffer(iv)
|
|
const plainText = encoder.encode(JSON.stringify(payload))
|
|
const cipherBuffer = await crypto.subtle.encrypt(
|
|
{ name: 'AES-GCM', iv: ivBuffer },
|
|
key,
|
|
toArrayBuffer(plainText)
|
|
)
|
|
const cipher = new Uint8Array(cipherBuffer)
|
|
const result = new Uint8Array(MAGIC_BYTES.length + 1 + 1 + iv.length + cipher.length)
|
|
let offset = 0
|
|
result.set(MAGIC_BYTES, offset)
|
|
offset += MAGIC_BYTES.length
|
|
result[offset] = ZW_VERSION
|
|
offset += 1
|
|
result[offset] = iv.length
|
|
offset += 1
|
|
result.set(iv, offset)
|
|
offset += iv.length
|
|
result.set(cipher, offset)
|
|
return result
|
|
}
|
|
|
|
export const decodeZwArchive = async <T>(raw: ArrayBuffer | Uint8Array): Promise<T> => {
|
|
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
|
|
const minLength = MAGIC_BYTES.length + 1 + 1 + 1 + 16
|
|
if (bytes.length < minLength) {
|
|
throw new Error('INVALID_ZW_PAYLOAD')
|
|
}
|
|
|
|
let offset = 0
|
|
for (let i = 0; i < MAGIC_BYTES.length; i += 1) {
|
|
if (bytes[offset + i] !== MAGIC_BYTES[i]) {
|
|
throw new Error('INVALID_ZW_HEADER')
|
|
}
|
|
}
|
|
offset += MAGIC_BYTES.length
|
|
|
|
const version = bytes[offset]
|
|
offset += 1
|
|
if (version !== ZW_VERSION) {
|
|
throw new Error('INVALID_ZW_VERSION')
|
|
}
|
|
|
|
const ivLength = bytes[offset]
|
|
offset += 1
|
|
if (ivLength <= 0 || bytes.length <= offset + ivLength) {
|
|
throw new Error('INVALID_ZW_PAYLOAD')
|
|
}
|
|
|
|
const iv = bytes.slice(offset, offset + ivLength)
|
|
offset += ivLength
|
|
const cipher = bytes.slice(offset)
|
|
if (cipher.length < 16) {
|
|
throw new Error('INVALID_ZW_PAYLOAD')
|
|
}
|
|
|
|
const key = await getArchiveKey()
|
|
const ivBuffer = toArrayBuffer(iv)
|
|
const cipherBuffer = toArrayBuffer(cipher)
|
|
|
|
let plainBuffer: ArrayBuffer
|
|
try {
|
|
plainBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivBuffer }, key, cipherBuffer)
|
|
} catch {
|
|
throw new Error('INVALID_ZW_TAMPERED')
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(decoder.decode(plainBuffer)) as T
|
|
} catch {
|
|
throw new Error('INVALID_ZW_CONTENT')
|
|
}
|
|
}
|