refactor: extract payload pack/unpack pipeline into @cryptgeon/shared

This commit is contained in:
2026-09-12 10:01:49 +02:00
parent 77b7ae1de6
commit 82900adef8
7 changed files with 133 additions and 60 deletions
+2 -3
View File
@@ -3,7 +3,7 @@ import { access, constants, writeFile } from 'node:fs/promises'
import { basename, resolve } from 'node:path'
import { decode } from '@msgpack/msgpack'
import pretty from 'pretty-bytes'
import { decrypt, deriveKey, setServer, info, get, decompress } from '@cryptgeon/shared'
import { deriveKey, setServer, info, get, unpackContent } from '@cryptgeon/shared'
export async function download(url: URL, all: boolean, suggestedPassword?: string) {
setServer(url.origin)
@@ -32,8 +32,7 @@ export async function download(url: URL, all: boolean, suggestedPassword?: strin
const note = await get(id)
if (!note) throw new Error('Could not load note')
const decrypted = decrypt(note.data, key)
const content = decode(decompress(decrypted)) as any
const content = unpackContent(note.data, key)
switch (content.type) {
case 'files':
+24 -29
View File
@@ -1,43 +1,38 @@
import { readFile } from 'node:fs/promises'
import { basename } from 'node:path'
import { encode } from '@msgpack/msgpack'
import mime from 'mime'
import { encrypt, generateKey, deriveKey, randomBytes, getServer, create, compress } from '@cryptgeon/shared'
import { getServer, create, packContent, type FileDTO } from '@cryptgeon/shared'
export type UploadOptions = { views?: number; expiration?: number; password?: string }
export async function upload(input: string | string[], options: UploadOptions): Promise<string> {
const { password, ...noteOptions } = options
let key: Uint8Array
let extra = new Uint8Array()
if (password) {
const salt = randomBytes(16)
key = deriveKey(password, salt)
extra = encode({ salt, N: 32768, r: 8, p: 1 })
} else {
key = generateKey()
}
const payload = packContent(
typeof input === 'string'
? { type: 'text', text: input }
: { type: 'files', files: await fileDTOSfromPaths(input) },
password
)
let inner: Uint8Array
if (typeof input === 'string') {
inner = encode({ type: 'text', data: input })
} else {
const files = await Promise.all(
input.map(async (path) => {
const data = new Uint8Array(await readFile(path))
const extension = path.substring(path.indexOf('.') + 1)
const type = mime.getType(extension) ?? 'application/octet-stream'
return { name: basename(path), mime: type, size: data.length, data }
})
)
inner = encode({ type: 'files', data: files })
}
const data = encrypt(compress(inner), key)
const result = await create({ meta: { ...noteOptions, extra }, data })
const result = await create({ meta: { ...noteOptions, extra: payload.extra }, data: payload.data })
let url = `${getServer()}/note/${result.id}`
if (!password) url += `#${Buffer.from(key).toString('hex')}`
if (!password) url += `#${Buffer.from(payload.key).toString('hex')}`
return url
}
async function fileDTOSfromPaths(paths: string[]): Promise<FileDTO[]> {
return Promise.all(
paths.map(async (path) => {
const extension = path.substring(path.indexOf('.') + 1)
const data = new Uint8Array(await readFile(path))
return {
name: basename(path),
mime: mime.getType(extension) ?? 'application/octet-stream',
size: data.length,
data,
}
})
)
}
+24 -25
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import {
deriveKey, generateKey, encrypt, randomBytes,
bytesToHex, encode, compress,
bytesToHex,
create as apiCreate,
type FileDTO, type ServerNote
packContent,
type ServerNote
} from '@cryptgeon/shared'
import { t } from 'svelte-intl-precompile'
import { blur } from 'svelte/transition'
@@ -117,42 +117,41 @@
try {
loading = $t('common.encrypting')
const salt = customPassword ? randomBytes(16) : null
const key = customPassword
? deriveKey(customPassword, salt!)
: generateKey()
let inner: Uint8Array
if (isFile) {
if (files.length === 0) throw new EmptyContentError()
inner = encode({ type: 'files', data: files })
} else {
if (textContent === '') throw new EmptyContentError()
inner = encode({ type: 'text', data: textContent })
} else if (textContent === '') {
throw new EmptyContentError()
}
const originalSize =inner.byteLength
const compressed = compress(inner)
const compresseedSize= compressed.byteLength
console.debug({originalSize, compresseedSize, ratio: originalSize/compresseedSize})
const data = encrypt(compress(inner), key)
const extra = customPassword
? encode({ salt: salt!, N: 32768, r: 8, p: 1 })
: new Uint8Array()
const payload = packContent(
isFile
? {
type: 'files',
files: await Promise.all(
files.map(async (file) => ({
name: file.name,
mime: file.type,
size: file.size,
data: new Uint8Array(await file.arrayBuffer()),
}))
),
}
: { type: 'text', text: textContent },
customPassword || undefined
)
const serverNote: ServerNote = {
meta: {
...(timeExpiration ? { expiration: parseInt(note.expiration as any) } : { views: parseInt(note.views as any) }),
extra,
extra: payload.extra,
},
data,
data: payload.data,
}
loading = $t('common.uploading')
const response = await apiCreate(serverNote)
result = {
id: response.id,
password: customPassword ? undefined : bytesToHex(key),
password: customPassword ? undefined : bytesToHex(payload.key),
}
notify.success($t('home.messages.note_created'))
} catch (e) {
@@ -1,5 +1,5 @@
<script lang="ts">
import { deriveKey, hexToBytes, decrypt, decode, decompress, info, get as apiGet, type FileDTO } from '@cryptgeon/shared'
import { deriveKey, hexToBytes, decode, info, get as apiGet, unpackContent, type FileDTO } from '@cryptgeon/shared'
import { onMount } from 'svelte'
import { t } from 'svelte-intl-precompile'
@@ -69,8 +69,7 @@
key = hexToBytes(password!)
}
const decrypted = decrypt(serverNote.data, key)
const content = decode(decompress(decrypted)) as any
const content = unpackContent(serverNote.data, key)
switch (content.type) {
case 'text':
+1
View File
@@ -2,4 +2,5 @@ export * from "./crypto.js";
export * from "./types.js";
export * from "./api.js";
export * from "./compression.js";
export * from "./payload.js";
export { encode, decode } from "@msgpack/msgpack";
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { packContent, unpackContent } from "./payload";
import { bytesToUtf8, utf8ToBytes } from "./crypto";
describe("payload", () => {
it("round-trips a text note through the full pipeline", () => {
const { data, extra, key } = packContent({ type: "text", text: "hello world" });
expect(extra.length).toBe(0);
const content = unpackContent(data, key);
expect(content).toEqual({ type: "text", data: "hello world" });
});
it("round-trips with a password and sets extra", () => {
const { data, extra, key } = packContent({ type: "text", text: "secret" }, "pw123");
expect(extra.length).toBeGreaterThan(0);
const content = unpackContent(data, key);
expect(content).toEqual({ type: "text", data: "secret" });
});
it("round-trips files (FileDTO)", () => {
const file = { name: "a.txt", mime: "text/plain", size: 5, data: utf8ToBytes("hello") };
const { data, key } = packContent({ type: "files", files: [file] });
const content = unpackContent(data, key);
expect(content.type).toBe("files");
if (content.type === "files") {
expect(content.data).toHaveLength(1);
expect(content.data[0]!.name).toBe("a.txt");
expect(bytesToUtf8(content.data[0]!.data)).toBe("hello");
}
});
});
+49
View File
@@ -0,0 +1,49 @@
import { encode, decode } from "@msgpack/msgpack";
import { compress, decompress } from "./compression.js";
import {
deriveKey,
encrypt,
decrypt,
generateKey,
randomBytes,
} from "./crypto.js";
import type { FileDTO, NoteContent } from "./types.js";
export type NoteInput =
| { type: "text"; text: string }
| { type: "files"; files: FileDTO[] };
export type PackResult = {
data: Uint8Array;
extra: Uint8Array;
key: Uint8Array;
};
export function packContent(input: NoteInput, password?: string): PackResult {
let key: Uint8Array;
let extra: Uint8Array;
if (password) {
const salt = randomBytes(16);
key = deriveKey(password, salt);
extra = encode({ salt, N: 32768, r: 8, p: 1 });
} else {
key = generateKey();
extra = new Uint8Array();
}
const content: NoteContent =
input.type === "text"
? { type: "text", data: input.text }
: { type: "files", data: input.files };
const encoded = encrypt(compress(encode(content)), key);
return { data: encoded, extra, key };
}
export function unpackContent(data: Uint8Array, key: Uint8Array): NoteContent {
const content = decode(decompress(decrypt(data, key))) as NoteContent;
if (content.type !== "text" && content.type !== "files") {
throw new Error("Unknown content type");
}
return content;
}