mirror of
https://github.com/cupcakearmy/cryptgeon.git
synced 2026-09-26 20:41:45 +00:00
refactor: extract payload pack/unpack pipeline into @cryptgeon/shared
This commit is contained in:
@@ -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':
|
||||
|
||||
@@ -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 = await 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,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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,29 @@
|
||||
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 = await packContent(
|
||||
isFile ? { type: 'files', files } : { 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':
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { packContent, unpackContent } from "./payload";
|
||||
import { deriveKey, generateKey, bytesToUtf8, utf8ToBytes } from "./crypto";
|
||||
|
||||
describe("payload", () => {
|
||||
it("round-trips a text note through the full pipeline", async () => {
|
||||
const { data, extra, key } = await 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", async () => {
|
||||
const { data, extra, key } = await packContent({ type: "text", text: "secret" }, "pw123");
|
||||
expect(extra.length).toBeGreaterThan(0);
|
||||
const content = unpackContent(data, key);
|
||||
expect(content).toEqual({ type: "text", data: "secret" });
|
||||
});
|
||||
|
||||
it("derives a deterministic key when password is set", async () => {
|
||||
const salt = utf8ToBytes("fixed-salt");
|
||||
const expected = deriveKey("pw", salt);
|
||||
const first = deriveKey("pw", salt);
|
||||
expect(first).toEqual(expected);
|
||||
});
|
||||
|
||||
it("round-trips files (FileDTO)", async () => {
|
||||
const file = { name: "a.txt", mime: "text/plain", size: 5, data: utf8ToBytes("hello") };
|
||||
const { data, key } = await 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function deriveKeyContent(password: string, salt: Uint8Array): Uint8Array {
|
||||
return deriveKey(password, salt);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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: (File | FileDTO)[] };
|
||||
|
||||
export type PackResult = {
|
||||
data: Uint8Array;
|
||||
extra: Uint8Array;
|
||||
key: Uint8Array;
|
||||
};
|
||||
|
||||
export async function packContent(
|
||||
input: NoteInput,
|
||||
password?: string,
|
||||
): Promise<PackResult> {
|
||||
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();
|
||||
}
|
||||
|
||||
let content: NoteContent;
|
||||
if (input.type === "text") {
|
||||
content = { type: "text", data: input.text };
|
||||
} else {
|
||||
content = {
|
||||
type: "files",
|
||||
data: await Promise.all(
|
||||
input.files.map(async (file) => {
|
||||
const name = file instanceof File ? file.name : file.name;
|
||||
const mime = file instanceof File ? file.type : file.mime;
|
||||
const data = file instanceof File ? new Uint8Array(await file.arrayBuffer()) : file.data;
|
||||
return { name, mime, size: data.length, data };
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user