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 { basename, resolve } from 'node:path'
|
||||||
import { decode } from '@msgpack/msgpack'
|
import { decode } from '@msgpack/msgpack'
|
||||||
import pretty from 'pretty-bytes'
|
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) {
|
export async function download(url: URL, all: boolean, suggestedPassword?: string) {
|
||||||
setServer(url.origin)
|
setServer(url.origin)
|
||||||
@@ -32,8 +32,7 @@ export async function download(url: URL, all: boolean, suggestedPassword?: strin
|
|||||||
const note = await get(id)
|
const note = await get(id)
|
||||||
if (!note) throw new Error('Could not load note')
|
if (!note) throw new Error('Could not load note')
|
||||||
|
|
||||||
const decrypted = decrypt(note.data, key)
|
const content = unpackContent(note.data, key)
|
||||||
const content = decode(decompress(decrypted)) as any
|
|
||||||
|
|
||||||
switch (content.type) {
|
switch (content.type) {
|
||||||
case 'files':
|
case 'files':
|
||||||
|
|||||||
@@ -1,43 +1,38 @@
|
|||||||
import { readFile } from 'node:fs/promises'
|
import { readFile } from 'node:fs/promises'
|
||||||
import { basename } from 'node:path'
|
import { basename } from 'node:path'
|
||||||
|
|
||||||
import { encode } from '@msgpack/msgpack'
|
|
||||||
import mime from 'mime'
|
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 type UploadOptions = { views?: number; expiration?: number; password?: string }
|
||||||
|
|
||||||
export async function upload(input: string | string[], options: UploadOptions): Promise<string> {
|
export async function upload(input: string | string[], options: UploadOptions): Promise<string> {
|
||||||
const { password, ...noteOptions } = options
|
const { password, ...noteOptions } = options
|
||||||
|
|
||||||
let key: Uint8Array
|
const payload = await packContent(
|
||||||
let extra = new Uint8Array()
|
typeof input === 'string'
|
||||||
if (password) {
|
? { type: 'text', text: input }
|
||||||
const salt = randomBytes(16)
|
: { type: 'files', files: await fileDTOSfromPaths(input) },
|
||||||
key = deriveKey(password, salt)
|
password
|
||||||
extra = encode({ salt, N: 32768, r: 8, p: 1 })
|
)
|
||||||
} else {
|
|
||||||
key = generateKey()
|
|
||||||
}
|
|
||||||
|
|
||||||
let inner: Uint8Array
|
const result = await create({ meta: { ...noteOptions, extra: payload.extra }, data: payload.data })
|
||||||
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 })
|
|
||||||
let url = `${getServer()}/note/${result.id}`
|
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
|
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">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
deriveKey, generateKey, encrypt, randomBytes,
|
bytesToHex,
|
||||||
bytesToHex, encode, compress,
|
|
||||||
create as apiCreate,
|
create as apiCreate,
|
||||||
type FileDTO, type ServerNote
|
packContent,
|
||||||
|
type ServerNote
|
||||||
} from '@cryptgeon/shared'
|
} from '@cryptgeon/shared'
|
||||||
import { t } from 'svelte-intl-precompile'
|
import { t } from 'svelte-intl-precompile'
|
||||||
import { blur } from 'svelte/transition'
|
import { blur } from 'svelte/transition'
|
||||||
@@ -117,42 +117,29 @@
|
|||||||
try {
|
try {
|
||||||
loading = $t('common.encrypting')
|
loading = $t('common.encrypting')
|
||||||
|
|
||||||
const salt = customPassword ? randomBytes(16) : null
|
|
||||||
const key = customPassword
|
|
||||||
? deriveKey(customPassword, salt!)
|
|
||||||
: generateKey()
|
|
||||||
|
|
||||||
let inner: Uint8Array
|
|
||||||
if (isFile) {
|
if (isFile) {
|
||||||
if (files.length === 0) throw new EmptyContentError()
|
if (files.length === 0) throw new EmptyContentError()
|
||||||
inner = encode({ type: 'files', data: files })
|
} else if (textContent === '') {
|
||||||
} else {
|
throw new EmptyContentError()
|
||||||
if (textContent === '') throw new EmptyContentError()
|
|
||||||
inner = encode({ type: 'text', data: textContent })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalSize =inner.byteLength
|
const payload = await packContent(
|
||||||
const compressed = compress(inner)
|
isFile ? { type: 'files', files } : { type: 'text', text: textContent },
|
||||||
const compresseedSize= compressed.byteLength
|
customPassword || undefined
|
||||||
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 serverNote: ServerNote = {
|
const serverNote: ServerNote = {
|
||||||
meta: {
|
meta: {
|
||||||
...(timeExpiration ? { expiration: parseInt(note.expiration as any) } : { views: parseInt(note.views as any) }),
|
...(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')
|
loading = $t('common.uploading')
|
||||||
const response = await apiCreate(serverNote)
|
const response = await apiCreate(serverNote)
|
||||||
result = {
|
result = {
|
||||||
id: response.id,
|
id: response.id,
|
||||||
password: customPassword ? undefined : bytesToHex(key),
|
password: customPassword ? undefined : bytesToHex(payload.key),
|
||||||
}
|
}
|
||||||
notify.success($t('home.messages.note_created'))
|
notify.success($t('home.messages.note_created'))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 { onMount } from 'svelte'
|
||||||
import { t } from 'svelte-intl-precompile'
|
import { t } from 'svelte-intl-precompile'
|
||||||
|
|
||||||
@@ -69,8 +69,7 @@
|
|||||||
key = hexToBytes(password!)
|
key = hexToBytes(password!)
|
||||||
}
|
}
|
||||||
|
|
||||||
const decrypted = decrypt(serverNote.data, key)
|
const content = unpackContent(serverNote.data, key)
|
||||||
const content = decode(decompress(decrypted)) as any
|
|
||||||
|
|
||||||
switch (content.type) {
|
switch (content.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export * from "./crypto.js";
|
|||||||
export * from "./types.js";
|
export * from "./types.js";
|
||||||
export * from "./api.js";
|
export * from "./api.js";
|
||||||
export * from "./compression.js";
|
export * from "./compression.js";
|
||||||
|
export * from "./payload.js";
|
||||||
export { encode, decode } from "@msgpack/msgpack";
|
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