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

This commit is contained in:
2026-09-12 09:57:54 +02:00
parent 77b7ae1de6
commit 8871a6b90d
7 changed files with 146 additions and 60 deletions
+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";
+42
View File
@@ -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);
}
+63
View File
@@ -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;
}