Compare commits

..

10 Commits

Author SHA1 Message Date
cupcakearmy ea561f635a Merge remote-tracking branch 'origin/main' into msgpack 2026-06-28 18:51:40 +02:00
cupcakearmy 1f180a2e53 Merge pull request #213 from cupcakearmy/feat/redis-key-prefix
feat: add REDIS_PREFIX env var for sharing a Redis instance
2026-06-25 22:30:59 +02:00
cupcakearmy 6b29c6f069 feat: add REDIS_PREFIX env var for shared Redis instance namespace 2026-06-25 21:21:17 +01:00
cupcakearmy 40f3559533 chore:bump version 2026-06-25 21:01:10 +01:00
cupcakearmy ac686e1935 Merge pull request #209 from unambient/typo-fix
fix typo in localization key
2026-06-25 21:42:38 +02:00
cupcakearmy 2cb984405f Merge pull request #208 from fwa-wup/main
fix #207
2026-06-25 21:40:46 +02:00
maddie 8a000ce131 change localization instances of note_to_big to note_too_big 2026-06-09 10:43:05 +00:00
Fabian W 38987efc8a fix #207 2026-06-09 12:17:58 +02:00
cupcakearmy 781231e414 msgpack 2026-06-07 10:46:15 +02:00
cupcakearmy bb422fdd8d msgpack 2026-06-07 10:46:11 +02:00
42 changed files with 1002 additions and 156 deletions
+4 -2
View File
@@ -80,15 +80,17 @@ of the notes even if it tried to.
| `ALLOW_ADVANCED` | `true` | Allow custom configuration. If set to `false` all notes will be one view only. |
| `ALLOW_FILES` | `true` | Allow uploading files. If set to `false`, users will only be allowed to create text notes. |
| `ID_LENGTH` | `32` | Set the size of the note `id` in bytes. By default this is `32` bytes. This is useful for reducing link size. _This setting does not affect encryption strength_. |
| `REDIS_PREFIX` | `""` | Optional prefix for all Redis keys. Useful when sharing a Redis instance with other apps via ACL namespaces. |
| `VERBOSITY` | `warn` | Verbosity level for the backend. [Possible values](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) are: `error`, `warn`, `info`, `debug`, `trace` |
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
| `THEME_PAGE_TITLE` | `""` | Custom text the page title |
| `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable |
| `THEME_NEW_NOTE_NOTICE` | `true` | Show the message about how notes are stored in the memory and may be evicted after creating a new note. Defaults to `true`. |
| `THEME_HOME_LINK` | `true` | Show the `/home` link in the footer. Defaults to `true`. |
| `THEME_HOME_LINK` | `true` | Show the `/home` link in the footer. Defaults to `true`. |
| `IMPRINT_URL` | `""` | Custom url for an Imprint hosted somewhere else. Must be publicly reachable. Takes precedence above `IMPRINT_HTML`. |
| `IMPRINT_HTML` | `""` | Alternative to `IMPRINT_URL`, this can be used to specify the HTML code to show on `/imprint`. Only `IMPRINT_HTML` or `IMPRINT_URL` should be specified, not both. |
## Deployment
> ️ `https` is required otherwise browsers will not support the cryptographic functions.
@@ -102,7 +104,7 @@ Docker is the easiest way. There is the [official image here](https://hub.docker
```yaml
# docker-compose.yml
version: '3.8'
version: "3.8"
services:
redis:
+21 -1
View File
@@ -252,7 +252,7 @@ dependencies = [
[[package]]
name = "cryptgeon"
version = "2.9.2"
version = "2.9.3"
dependencies = [
"axum",
"bs62",
@@ -261,6 +261,7 @@ dependencies = [
"lazy_static",
"redis",
"ring",
"rmp-serde",
"serde",
"serde_json",
"tokio",
@@ -1004,6 +1005,25 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rmp"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
dependencies = [
"num-traits",
]
[[package]]
name = "rmp-serde"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
dependencies = [
"rmp",
"serde",
]
[[package]]
name = "rustix"
version = "1.1.4"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cryptgeon"
version = "2.9.2"
version = "2.9.3"
authors = ["cupcakearmy <hi@nicco.io>"]
edition = "2024"
rust-version = "1.95"
@@ -25,3 +25,4 @@ ring = "0.17"
bs62 = "0.1"
byte-unit = "4"
dotenv = "0.15"
rmp-serde = "1.3.1"
+4
View File
@@ -34,6 +34,10 @@ pub static ref ID_LENGTH: u32 = std::env::var("ID_LENGTH")
.unwrap_or("32".to_string())
.parse()
.unwrap();
pub static ref REDIS_PREFIX: String = std::env::var("REDIS_PREFIX")
.unwrap_or("".to_string())
.parse()
.unwrap();
pub static ref ALLOW_FILES: bool = std::env::var("ALLOW_FILES")
.unwrap_or("true".to_string())
.parse()
+3 -1
View File
@@ -23,6 +23,7 @@ mod csp;
mod health;
mod lock;
mod note;
mod note_v2;
mod status;
mod store;
@@ -42,7 +43,8 @@ async fn main() {
let notes_routes = Router::new()
.route("/", post(note::create))
.route("/{id}", delete(note::delete))
.route("/{id}", get(note::preview));
.route("/{id}", get(note::preview))
.route("/v2/", post(note_v2::create));
let health_routes = Router::new().route("/live", get(health::report_health));
let status_routes = Router::new().route("/status", get(status::get_status));
let api_routes = Router::new()
+5
View File
@@ -0,0 +1,5 @@
mod model;
mod routes;
pub use model::*;
pub use routes::*;
+41
View File
@@ -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<u32>,
pub expiration: Option<u32>,
}
#[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;
}
+126
View File
@@ -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<OneNoteParams>) -> 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<OneNoteParams>,
// state: axum::extract::State<SharedState>,
// ) -> 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();
// }
// }
// }
+12 -4
View File
@@ -1,6 +1,7 @@
use redis;
use redis::Commands;
use crate::config;
use crate::note::now;
use crate::note::Note;
@@ -11,6 +12,10 @@ lazy_static! {
.unwrap();
}
fn prefixed(id: &String) -> String {
format!("{}{}", config::REDIS_PREFIX.as_str(), id)
}
fn get_connection() -> Result<redis::Connection, &'static str> {
let client =
redis::Client::open(REDIS_CLIENT.to_string()).map_err(|_| "Unable to connect to redis")?;
@@ -28,15 +33,16 @@ pub fn can_reach_redis() -> bool {
}
pub fn set(id: &String, note: &Note) -> Result<(), &'static str> {
let key = prefixed(id);
let serialized = serde_json::to_string(&note.clone()).unwrap();
let mut conn = get_connection()?;
conn.set::<_, _, ()>(id, serialized)
conn.set::<_, _, ()>(key.as_str(), serialized)
.map_err(|_| "Unable to set note in redis")?;
match note.expiration {
Some(e) => {
let seconds = e - now();
conn.expire::<_, ()>(id, seconds as i64)
conn.expire::<_, ()>(key.as_str(), seconds as i64)
.map_err(|_| "Unable to set expiration on note")?
}
None => {}
@@ -45,8 +51,9 @@ pub fn set(id: &String, note: &Note) -> Result<(), &'static str> {
}
pub fn get(id: &String) -> Result<Option<Note>, &'static str> {
let key = prefixed(id);
let mut conn = get_connection()?;
let value: Option<String> = conn.get(id).map_err(|_| "Could not load note in redis")?;
let value: Option<String> = conn.get(key.as_str()).map_err(|_| "Could not load note in redis")?;
match value {
None => return Ok(None),
Some(s) => {
@@ -57,7 +64,8 @@ pub fn get(id: &String) -> Result<Option<Note>, &'static str> {
}
pub fn del(id: &String) -> Result<(), &'static str> {
let key = prefixed(id);
let mut conn = get_connection()?;
conn.del::<_, ()>(id).map_err(|_| "Unable to delete note in redis")?;
conn.del::<_, ()>(key.as_str()).map_err(|_| "Unable to delete note in redis")?;
Ok(())
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cryptgeon",
"version": "2.9.2",
"version": "2.9.3",
"homepage": "https://github.com/cupcakearmy/cryptgeon",
"repository": {
"type": "git",
+1 -1
View File
@@ -25,7 +25,7 @@
"new_note": "Nová poznámka",
"new_note_notice": "<b>Dostupnost:</b><br />Poznámka není zaručeně uchována, protože je uložena pouze v paměti RAM. Pokud se paměť zaplní, nejstarší poznámky budou automaticky smazány.<br />(Obvykle to nebývá problém, ale je dobré o tom vědět.)",
"errors": {
"note_to_big": "Poznámku nelze vytvořit. Je příliš velká.",
"note_too_big": "Poznámku nelze vytvořit. Je příliš velká.",
"note_error": "Poznámku nelze vytvořit. Zkuste to prosím znovu.",
"max": "Max: {n}",
"empty_content": "Poznámka je prázdná."
+1 -1
View File
@@ -25,7 +25,7 @@
"new_note": "Neue Notiz",
"new_note_notice": "<b>Wichtiger Hinweis zur Verfügbarkeit:</b><br />Es kann nicht garantiert werden, dass diese Notiz gespeichert wird, da diese <b>ausschließlich im Speicher</b> gehalten werden. Ist dieser voll, werden die ältesten Notizen entfernt.<br />(Wahrscheinlich gibt es keine derartigen Probleme, seien Sie nur vorgewarnt).",
"errors": {
"note_to_big": "Notiz konnte nicht erstellt werden, da sie zu groß ist.",
"note_too_big": "Notiz konnte nicht erstellt werden, da sie zu groß ist.",
"note_error": "Notiz konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"max": "max: {n}",
"empty_content": "Notiz ist leer."
+1 -1
View File
@@ -25,7 +25,7 @@
"new_note": "new note",
"new_note_notice": "<b>availability:</b><br />the note is not guaranteed to be stored as everything is kept in ram, if it fills up the oldest notes will be removed.<br />(you probably will be fine, just be warned.)",
"errors": {
"note_to_big": "could not create note. note is too big",
"note_too_big": "could not create note. note is too big",
"note_error": "could not create note. please try again.",
"max": "max: {n}",
"empty_content": "note is empty."
+1 -1
View File
@@ -25,7 +25,7 @@
"new_note": "nueva nota",
"new_note_notice": "<b>disponibilidad:</b><br />no se garantiza que la nota se almacene, ya que todo se guarda en la memoria RAM, si se llena se eliminarán las notas más antiguas.<br />(probablemente estará bien, solo está advertido.)",
"errors": {
"note_to_big": "no se pudo crear la nota. la nota es demasiado grande",
"note_too_big": "no se pudo crear la nota. la nota es demasiado grande",
"note_error": "No se ha podido crear la nota. Por favor, inténtelo de nuevo.",
"max": "max: {n}",
"empty_content": "la nota está vacía."
+1 -1
View File
@@ -25,7 +25,7 @@
"new_note": "nouvelle note",
"new_note_notice": "<b>disponibilité :</b><br />il n'est pas garanti que la note reste stockée car tout est conservé dans la mémoire vive; si elle se remplit, les notes les plus anciennes seront supprimées.<br />(tout ira probablement bien, soyez juste averti.)",
"errors": {
"note_to_big": "Impossible de créer une note. La note est trop grande.",
"note_too_big": "Impossible de créer une note. La note est trop grande.",
"note_error": "n'a pas pu créer de note. Veuillez réessayer.",
"max": "max: {n}",
"empty_content": "La note est vide."
+1 -1
View File
@@ -25,7 +25,7 @@
"new_note": "nuova nota",
"new_note_notice": "<b>disponibilità:</b><br />la nota non è garantita per essere memorizzata come tutto è tenuto in ram, se si riempie le note più vecchie saranno rimosse.<br />(probabilmente andrà bene, basta essere avvertiti).",
"errors": {
"note_to_big": "impossibile creare una nota. la nota è troppo grande",
"note_too_big": "impossibile creare una nota. la nota è troppo grande",
"note_error": "Impossibile creare la nota. Riprova.",
"max": "max: {n}",
"empty_content": "la nota è vuota."
+1 -1
View File
@@ -25,7 +25,7 @@
"new_note": "新しいメモ",
"new_note_notice": "<b>可用性: </b> <br />すべてが RAM に保持されるため、メモが保存されるとは限りません。いっぱいになると、最も古いメモが削除されます。 <br /> (大丈夫だと思いますが、ご了承ください。)",
"errors": {
"note_to_big": "メモを作成できませんでした。メモが大きすぎる",
"note_too_big": "メモを作成できませんでした。メモが大きすぎる",
"note_error": "メモを作成できませんでした。もう一度お試しください。",
"max": "最大ファイルサイズ: {n}",
"empty_content": "メモは空です。"
+1 -1
View File
@@ -25,7 +25,7 @@
"new_note": "nowa notatka",
"new_note_notice": "<b>dostępność:</b><br />nie ma gwarancji, że notatka będzie przechowywana, ponieważ wszystko jest przechowywane w pamięci RAM, jeśli się zapełni, najstarsze notatki zostaną usunięte.<br />(prawdopodobnie nic się nie stanie, ale warto ostrzec.)",
"errors": {
"note_to_big": "nie można utworzyć notatki. notatka jest za duża",
"note_too_big": "nie można utworzyć notatki. notatka jest za duża",
"note_error": "nie można utworzyć notatki. spróbuj ponownie.",
"max": "maks.: {n}",
"empty_content": "notatka jest pusta."
+1 -1
View File
@@ -25,7 +25,7 @@
"new_note": "новая заметка",
"new_note_notice": "<b>доступность:</b><br />сохранение заметки не гарантируется, поскольку все хранится в оперативной памяти; если она заполнится, самые старые заметки будут удалены.<br />( вероятно, все будет в порядке, просто будьте осторожны.)",
"errors": {
"note_to_big": "нельзя создать новую заметку. заметка слишком большая",
"note_too_big": "нельзя создать новую заметку. заметка слишком большая",
"note_error": "нельзя создать новую заметку. пожалуйста попробуйте позже.",
"max": "макс: {n}",
"empty_content": "пустая заметка."
+1 -1
View File
@@ -25,7 +25,7 @@
"new_note": "新筆記",
"new_note_notice": "<b>可用性:</b><br />筆記不保證被儲存,因為所有內容都保留在 RAM 中,如果 RAM 填滿,最舊的筆記將被移除。<br />(您可能會沒事,只是提醒一下。)",
"errors": {
"note_to_big": "無法創建筆記。筆記過大",
"note_too_big": "無法創建筆記。筆記過大",
"note_error": "無法創建筆記。請再試一次。",
"max": "最大值:{n}",
"empty_content": "筆記內容為空。"
+1 -1
View File
@@ -25,7 +25,7 @@
"new_note": "新建密信",
"new_note_notice": "<b>提醒:</b><br>密信保存在内存中,如果内存满了,则最早的密信将被删除以释放内存,因此不保证该密信的可用性。一般不会出现这种情况,无需担心。",
"errors": {
"note_to_big": "创建失败,密信过大。",
"note_too_big": "创建失败,密信过大。",
"note_error": "创建失败,请稍后重试。",
"max": "次数上限:{n}",
"empty_content": "密信不能为空。"
@@ -60,7 +60,6 @@
})
async function handlePaste(e: ClipboardEvent) {
e.preventDefault()
const data = e.clipboardData
if (!data) return
@@ -79,6 +78,7 @@
}
if (raw.length === 0) return
e.preventDefault()
const seen = new Set<string>()
const pasted: File[] = []
@@ -149,7 +149,7 @@
notify.success($t('home.messages.note_created'))
} catch (e) {
if (e instanceof PayloadToLargeError) {
notify.error($t('home.errors.note_to_big'))
notify.error($t('home.errors.note_too_big'))
} else if (e instanceof EmptyContentError) {
notify.error($t('home.errors.empty_content'))
} else {
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

+24
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

@@ -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,
]
`;
+14
View File
@@ -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({});
});
});
});
+39
View File
@@ -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<string> {
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<ClientNote> {
throw new Error("not implemented");
}
+42
View File
@@ -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);
});
});
+40
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
export * as encryption from "./encryption";
+39
View File
@@ -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;
};
+8
View File
@@ -0,0 +1,8 @@
{
"extends": ["@tsconfig/strictest"],
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
},
}
+26
View File
@@ -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" },
],
},
},
},
],
},
});
+493 -133
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -5,3 +5,9 @@ allowBuilds:
esbuild: true
minimumReleaseAge: 10080 # One week
catalog:
"@tsconfig/strictest": "^2.0.8"
"typescript": "^6.0.3"
"vitest": "^4.1.7"
"@vitest/browser-playwright": "^4.1.7"