Files
calculator2026/src/lib/zwArchive.ts
T
2026-02-28 16:34:36 +08:00

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')
}
}