diff --git a/packages/backend/src/note_v2/mod.rs b/packages/backend/src/note_v2/mod.rs new file mode 100644 index 0000000..13bc006 --- /dev/null +++ b/packages/backend/src/note_v2/mod.rs @@ -0,0 +1,5 @@ +mod model; +mod routes; + +pub use model::*; +pub use routes::*; diff --git a/packages/backend/src/note_v2/model.rs b/packages/backend/src/note_v2/model.rs new file mode 100644 index 0000000..4e61638 --- /dev/null +++ b/packages/backend/src/note_v2/model.rs @@ -0,0 +1,41 @@ +use bs62; +use ring::rand::SecureRandom; +use serde::{Deserialize, Serialize}; + +use crate::config; + +#[derive(Serialize, Deserialize, Clone)] +pub struct NoteV2Metadata { + pub views: Option, + pub expiration: Option, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct NoteV2 { + pub metadata: NoteV2Metadata, + pub public: String, + pub content: String, +} + +#[derive(Serialize)] +pub struct NoteInfoV2 { + pub public: String, +} + +#[derive(Serialize)] +pub struct NotePublicV2 { + pub public: String, + pub content: String, +} + +pub fn generate_id_v2() -> String { + let mut result = "".to_owned(); + let mut id: [u8; 1] = [0; 1]; + let sr = ring::rand::SystemRandom::new(); + + for _ in 0..*config::ID_LENGTH { + let _ = sr.fill(&mut id); + result.push_str(&bs62::encode_data(&id)); + } + return result; +} diff --git a/packages/backend/src/note_v2/routes.rs b/packages/backend/src/note_v2/routes.rs new file mode 100644 index 0000000..f12af2c --- /dev/null +++ b/packages/backend/src/note_v2/routes.rs @@ -0,0 +1,126 @@ +use axum::{ + Json, + body::Bytes, + extract::Path, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; +use std::{sync::Arc, time::SystemTime}; + +use crate::note_v2::{NoteV2, generate_id_v2}; +use crate::store; +use crate::{config, lock::SharedState}; + +use super::NotePublicV2; + +pub fn now() -> u32 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() as u32 +} + +// #[derive(Deserialize)] +// pub struct OneNoteParams { +// id: String, +// } + +// pub async fn preview(Path(OneNoteParams { id }): Path) -> Response { +// let note = store::get(&id); + +// match note { +// Ok(Some(n)) => (StatusCode::OK, Json(NoteInfoV2 { meta: n.meta })).into_response(), +// Ok(None) => (StatusCode::NOT_FOUND).into_response(), +// Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), +// } +// } + +#[derive(Serialize, Deserialize)] +struct CreateResponse { + id: String, +} + +pub async fn create(body: Bytes) -> Response { + let id = generate_id_v2(); + println!("{}", body.len()); + (StatusCode::OK, Json(CreateResponse { id })).into_response() + // match store::set(&id.clone(), &n.clone()) { + // Ok(_) => (StatusCode::OK, Json(CreateResponse { id })).into_response(), + // Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + // } +} + +// pub async fn delete( +// Path(OneNoteParams { id }): Path, +// state: axum::extract::State, +// ) -> Response { +// let mut locks_map = state.locks.lock().await; +// let lock = locks_map +// .entry(id.clone()) +// .or_insert_with(|| Arc::new(Mutex::new(()))) +// .clone(); +// drop(locks_map); +// let _guard = lock.lock().await; + +// let note = store::get(&id); +// match note { +// Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), +// Ok(None) => (StatusCode::NOT_FOUND).into_response(), +// Ok(Some(note)) => { +// let mut changed = note.clone(); +// if changed.views == None && changed.expiration == None { +// return (StatusCode::BAD_REQUEST).into_response(); +// } +// match changed.views { +// Some(v) => { +// changed.views = Some(v - 1); +// let id = id.clone(); +// if v <= 1 { +// match store::del(&id) { +// Err(e) => { +// return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) +// .into_response(); +// } +// _ => {} +// } +// } else { +// match store::set(&id, &changed.clone()) { +// Err(e) => { +// return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) +// .into_response(); +// } +// _ => {} +// } +// } +// } +// _ => {} +// } + +// let n = now(); +// match changed.expiration { +// Some(e) => { +// if e < n { +// match store::del(&id.clone()) { +// Ok(_) => return (StatusCode::BAD_REQUEST).into_response(), +// Err(e) => { +// return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) +// .into_response(); +// } +// } +// } +// } +// _ => {} +// } + +// return ( +// StatusCode::OK, +// Json(NotePublicV2 { +// content: changed.contents, +// public: changed.meta, +// }), +// ) +// .into_response(); +// } +// } +// } diff --git a/packages/shared/.vitest-attachments/21e2c6a23db1a9cbb7a8dc36d655b68210031523.png b/packages/shared/.vitest-attachments/21e2c6a23db1a9cbb7a8dc36d655b68210031523.png new file mode 100644 index 0000000..639feaa Binary files /dev/null and b/packages/shared/.vitest-attachments/21e2c6a23db1a9cbb7a8dc36d655b68210031523.png differ diff --git a/packages/shared/.vitest-attachments/7bf88800fffbef6303a3c7ac9a6cf2d4147438de.png b/packages/shared/.vitest-attachments/7bf88800fffbef6303a3c7ac9a6cf2d4147438de.png new file mode 100644 index 0000000..639feaa Binary files /dev/null and b/packages/shared/.vitest-attachments/7bf88800fffbef6303a3c7ac9a6cf2d4147438de.png differ diff --git a/packages/shared/.vitest-attachments/a374c6ec2923b6c4c2b4a348cce935e3f36b2f34.png b/packages/shared/.vitest-attachments/a374c6ec2923b6c4c2b4a348cce935e3f36b2f34.png new file mode 100644 index 0000000..639feaa Binary files /dev/null and b/packages/shared/.vitest-attachments/a374c6ec2923b6c4c2b4a348cce935e3f36b2f34.png differ diff --git a/packages/shared/.vitest-attachments/b3beeeff67639e90b9b4801f98e3d0aca42fd7f5.png b/packages/shared/.vitest-attachments/b3beeeff67639e90b9b4801f98e3d0aca42fd7f5.png new file mode 100644 index 0000000..639feaa Binary files /dev/null and b/packages/shared/.vitest-attachments/b3beeeff67639e90b9b4801f98e3d0aca42fd7f5.png differ diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..4aa9d30 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,24 @@ +{ + "name": "@cryptgeon/shared", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@msgpack/msgpack": "^3.1.3", + "@noble/ciphers": "^2.2.0", + "@noble/hashes": "^2.2.0", + "ky": "^2.0.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "@tsconfig/strictest": "catalog:", + "@vitest/browser-playwright": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "scripts": { + "test:browser": "vitest" + } +} diff --git a/packages/shared/src/__screenshots__/actions.test.ts/actions-upload-upload-1.png b/packages/shared/src/__screenshots__/actions.test.ts/actions-upload-upload-1.png new file mode 100644 index 0000000..639feaa Binary files /dev/null and b/packages/shared/src/__screenshots__/actions.test.ts/actions-upload-upload-1.png differ diff --git a/packages/shared/src/__screenshots__/encryption.test.ts/encryption-chacha20-auto-key-1.png b/packages/shared/src/__screenshots__/encryption.test.ts/encryption-chacha20-auto-key-1.png new file mode 100644 index 0000000..639feaa Binary files /dev/null and b/packages/shared/src/__screenshots__/encryption.test.ts/encryption-chacha20-auto-key-1.png differ diff --git a/packages/shared/src/__screenshots__/encryption.test.ts/encryption-chacha20-custom-key-1.png b/packages/shared/src/__screenshots__/encryption.test.ts/encryption-chacha20-custom-key-1.png new file mode 100644 index 0000000..639feaa Binary files /dev/null and b/packages/shared/src/__screenshots__/encryption.test.ts/encryption-chacha20-custom-key-1.png differ diff --git a/packages/shared/src/__screenshots__/encryption.test.ts/some-test-chacha20-1.png b/packages/shared/src/__screenshots__/encryption.test.ts/some-test-chacha20-1.png new file mode 100644 index 0000000..639feaa Binary files /dev/null and b/packages/shared/src/__screenshots__/encryption.test.ts/some-test-chacha20-1.png differ diff --git a/packages/shared/src/__snapshots__/encryption.test.ts.snap b/packages/shared/src/__snapshots__/encryption.test.ts.snap new file mode 100644 index 0000000..9beb11f --- /dev/null +++ b/packages/shared/src/__snapshots__/encryption.test.ts.snap @@ -0,0 +1,38 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`encryption > stable derive key 1`] = ` +Uint8Array [ + 107, + 115, + 150, + 59, + 243, + 162, + 229, + 9, + 221, + 235, + 124, + 184, + 124, + 51, + 96, + 32, + 183, + 240, + 114, + 43, + 221, + 208, + 248, + 142, + 16, + 45, + 163, + 137, + 102, + 240, + 245, + 198, +] +`; diff --git a/packages/shared/src/actions.test.ts b/packages/shared/src/actions.test.ts new file mode 100644 index 0000000..0d58a59 --- /dev/null +++ b/packages/shared/src/actions.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { upload } from "./actions"; +import { deriveKey } from "./encryption"; + +describe("actions", () => { + describe("upload", () => { + it("upload", async () => { + const key = deriveKey("abc"); + await expect( + upload({ public: "foo", metadata: { views: 1 }, files: [] }, key), + ).resolves.toBe({}); + }); + }); +}); diff --git a/packages/shared/src/actions.ts b/packages/shared/src/actions.ts new file mode 100644 index 0000000..a949200 --- /dev/null +++ b/packages/shared/src/actions.ts @@ -0,0 +1,39 @@ +import { encode } from "@msgpack/msgpack"; +import { encrypt } from "./encryption"; +import { ClientNote, ServerNote } from "./types"; +import ky from "ky"; +import z from "zod"; + +const client = ky.extend({ baseUrl: "http://localhost:8000/api" }); + +export async function upload( + note: ClientNote, + key: Uint8Array, +): Promise { + const data = encode(note.files); + const encrypted = encrypt(data, key); + const serverNote: ServerNote = { + metadata: structuredClone(note.metadata), + public: "", + data: encrypted, + }; + const payload = encode(serverNote); + const response = await client + .post("/notes/v2", { + headers: { + "content-type": "application/msgpack", + }, + body: new Blob([payload]).stream(), + }) + .json( + z.object({ + id: z.string().nonempty(), + }), + ); + console.info("created note", response); + throw new Error("not implemented"); +} + +export function view(noteId: string): Promise { + throw new Error("not implemented"); +} diff --git a/packages/shared/src/encryption.test.ts b/packages/shared/src/encryption.test.ts new file mode 100644 index 0000000..1dafcdb --- /dev/null +++ b/packages/shared/src/encryption.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + decrypt, + deriveKey, + encrypt, + generateKey, + utf8ToBytes, +} from "./encryption"; + +describe("encryption", () => { + it("chacha20 custom key", () => { + const password = "abc"; + const payload = utf8ToBytes("libero iste qui"); + + const key = deriveKey(password); + const encrypted = encrypt(payload, key); + const decrypted = decrypt(encrypted, key); + expect(decrypted).toEqual(payload); + }); + + it("chacha20 auto key", () => { + const payload = utf8ToBytes( + "Earum id inventore debitis rerum minima necessitatibus consequuntur.", + ); + const key = generateKey(); + const encrypted = encrypt(payload, key); + const decrypted = decrypt(encrypted, key); + expect(decrypted).toEqual(payload); + }); + + it("stable derive key", () => { + const password = "alias laboriosam porro"; + expect(deriveKey(password)).toMatchSnapshot(); + }); + + it("derived key has same length as generated", () => { + const derived = deriveKey("ipsam esse asperiores"); + const generated = generateKey(); + + expect(derived.length).toEqual(generated.length); + }); +}); diff --git a/packages/shared/src/encryption.ts b/packages/shared/src/encryption.ts new file mode 100644 index 0000000..b8b46e0 --- /dev/null +++ b/packages/shared/src/encryption.ts @@ -0,0 +1,40 @@ +import { xchacha20poly1305 } from "@noble/ciphers/chacha.js"; +import { + managedNonce, + randomBytes, + utf8ToBytes, +} from "@noble/ciphers/utils.js"; +import { scrypt } from "@noble/hashes/scrypt.js"; + +export { bytesToUtf8, utf8ToBytes } from "@noble/ciphers/utils.js"; + +const APP_SPECIFIC_SECRET = "758ac0b9d5f04efca13f57909d3d0fc0"; +const SECURITY_LEVEL = 2 ** 15; +const KEY_SIZE = 32; + +export function deriveKey(password: string) { + const key = scrypt(password, APP_SPECIFIC_SECRET, { + N: SECURITY_LEVEL, + r: 8, + p: 1, + dkLen: KEY_SIZE, + }); + return key; +} + +export function generateKey(): Uint8Array { + return randomBytes(KEY_SIZE); +} + +export function encrypt(payload: Uint8Array, key: Uint8Array): Uint8Array { + const chacha = managedNonce(xchacha20poly1305)(key); + const data = payload instanceof Uint8Array ? payload : utf8ToBytes(payload); + const ciphertext = chacha.encrypt(data); + return ciphertext; +} + +export function decrypt(ciphertext: Uint8Array, key: Uint8Array): Uint8Array { + const chacha = managedNonce(xchacha20poly1305)(key); + const decrypted = chacha.decrypt(ciphertext, key); + return decrypted; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..f994c31 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1 @@ +export * as encryption from "./encryption"; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts new file mode 100644 index 0000000..271f286 --- /dev/null +++ b/packages/shared/src/types.ts @@ -0,0 +1,39 @@ +/** + * `metadata` is public, and NOT encrypted + */ +export type NoteMetadata = { + expiration?: number; + views?: number; +}; + +export type ClientNote = { + metadata: NoteMetadata; + + /** + * public information + */ + public: string; + + /** + * `files` are encrypted + */ + files: { + name: string; + type: string; + content: Uint8Array; + }[]; +}; + +export type ServerNote = { + metadata: NoteMetadata; + + /** + * Publicly available information + */ + public: string; + + /** + * Encrypted binary blob + */ + data: Uint8Array; +}; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..21e60e9 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": ["@tsconfig/strictest"], + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + }, +} diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts new file mode 100644 index 0000000..bfdfd86 --- /dev/null +++ b/packages/shared/vitest.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "vitest/config"; +import { playwright } from "@vitest/browser-playwright"; + +export default defineConfig({ + test: { + projects: [ + { test: { name: "node", environment: "node" } }, + { + test: { + name: "Browser", + browser: { + enabled: true, + provider: playwright(), + headless: true, + // https://vitest.dev/config/browser/playwright + instances: [ + { browser: "chromium" }, + { browser: "firefox" }, + { browser: "webkit" }, + ], + }, + }, + }, + ], + }, +});