Compare commits

...
24 Commits
Author SHA1 Message Date
cupcakearmy 7653409473 chore: bump to 3.0.0-rc.3 2026-09-12 16:13:41 +02:00
cupcakearmy f05707e033 update deps 2026-09-12 16:13:02 +02:00
cupcakearmy 8df6b6c7b5 docs: use v3 image tag instead of latest in compose 2026-09-12 15:56:00 +02:00
cupcakearmy 2dc9d6aa16 chore: bump to 3.0.0-rc.2 2026-09-12 15:32:32 +02:00
cupcakearmy 98c56f6fb9 fix(cli): replace removed run-s in prepublishOnly 2026-09-12 15:32:02 +02:00
cupcakearmy 3f7bb6f32e chore: bump to 3.0.0-rc.1 2026-09-12 15:29:49 +02:00
cupcakearmy 6de97039d3 ci: publish with --no-git-checks and drop done cli-deps todo 2026-09-12 15:27:29 +02:00
cupcakearmy a0bca0a1a7 chore(cli): move all deps to devDependencies (bundled into single output) 2026-09-12 15:17:56 +02:00
cupcakearmy 41d0f0bfa2 chore: bump to 3.0.0-rc.0 and publish npm prereleases under rc tag 2026-09-12 15:10:51 +02:00
cupcakearmy ba7669514e Delete Cryptgeon.postman_collection.json 2026-09-12 15:05:36 +02:00
cupcakearmy 8a3831fd05 chore: formatting 2026-09-12 15:04:04 +02:00
cupcakearmy 56f8498530 docs: drop deprecated compose version tag 2026-09-12 15:03:15 +02:00
cupcakearmy c3c6e96774 docs(examples): rename cache service in compose examples to match CACHE env 2026-09-12 15:02:20 +02:00
cupcakearmy b5c5629cca chore: formatting 2026-09-12 15:01:47 +02:00
cupcakearmy 74f387f920 docs: align ES and zh readmes with v3 (cache, healthz, msgpack, env) 2026-09-12 15:00:53 +02:00
cupcakearmy b7c20d6f59 fix(web): drop stale base64 overhead from max size display 2026-09-12 14:59:16 +02:00
cupcakearmy b0737030b2 docs: correct v2.9.3 docker registry changelog entry 2026-09-12 14:55:48 +02:00
cupcakearmy 918640e54c docs: expand changelog with missing releases and add v3 unreleased notes 2026-09-12 14:51:56 +02:00
cupcakearmy 7a68422d67 docs: fold todo into roadmap and unify naming 2026-09-12 14:45:17 +02:00
cupcakearmy 8162db4364 docs: replace v3 plan with roadmap and drop stale plan file 2026-09-12 14:40:14 +02:00
cupcakearmy f36ae73b42 fix(web): pass FileDTO array directly to packContent (files already byte-converted) 2026-09-12 11:06:09 +02:00
cupcakearmy 2ded578141 Merge branch 'v3' of github.com:cupcakearmy/cryptgeon into v3
# Conflicts:
#	packages/cli/src/actions/upload.ts
#	packages/frontend/src/lib/views/Create.svelte
#	packages/shared/src/payload.test.ts
#	packages/shared/src/payload.ts
2026-09-12 10:06:12 +02:00
cupcakearmy 82900adef8 refactor: extract payload pack/unpack pipeline into @cryptgeon/shared 2026-09-12 10:01:49 +02:00
cupcakearmy 8871a6b90d refactor: extract payload pack/unpack pipeline into @cryptgeon/shared 2026-09-12 09:57:54 +02:00
26 changed files with 927 additions and 1589 deletions
+7 -1
View File
@@ -23,7 +23,13 @@ jobs:
pnpm install pnpm install
pnpm --filter cryptgeon build pnpm --filter cryptgeon build
- run: pnpm publish --filter cryptgeon - name: Publish to npm
run: |
DIST_TAG=latest
if [[ "${GITHUB_REF_NAME}" == *"-"* ]]; then
DIST_TAG=rc
fi
pnpm publish --filter cryptgeon --tag "${DIST_TAG}" --no-git-checks
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+196
View File
@@ -5,6 +5,183 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] — v3 (major rewrite)
### Added
- New shared TypeScript package `@cryptgeon/shared` as single source of truth for crypto, content codec and API client (crypto + compression + payload + types).
- Shared payload codec: `packContent` / `unpackContent` (encode → LZ4 → XChaCha20-Poly1305 and reverse).
- New `pg`-backend storage of note hashes in the cache.
### Changed
- Encryption from AES to **XChaCha20-Poly1305** (client-side); dropped `occulto`.
- All API bodies switched to **MessagePack**.
- Frontend migrated to SvelteKit + `@cryptgeon/shared`.
- CLI rebuilt with `vite-plus` (bundles all deps) and imports from `@cryptgeon/shared`.
### Breaking changes
- Endpoints moved to `/api/v3/notes/` and `/api/v3/status`; health check to `/healthz`.
- `meta.extra` holds client-opaque data (e.g. scrypt derivation params), size-limited (`EXTRA_SIZE_LIMIT`, default 512 bytes).
- Inner payload is msgpack: `{ type: "text", data }` or `{ type: "files", data: [{ name, mime, size, data }] }`.
- Env renames: `REDIS` → `CACHE`, `REDIS_PREFIX` → `CACHE_PREFIX`; new `EXTRA_SIZE_LIMIT`.
- Docker `redis` service → `cache`; healthcheck → `http://127.0.0.1:8000/healthz`; image stays `valkey/valkey:7-alpine` (swap for any RESP-compatible).
- Storage switched to cache hashes with atomic `HINCRBY` view counting; the per-note lock (`lock.rs`) is removed.
- Notes can have **both** `views` and `expiration` set simultaneously.
- v2 notes are **not migrated**: flush the cache before deploying v3; v2/v3 notes are not interoperable.
## [2.9.3] - 2026-06-25
### Added
- Basic file drag-and-drop support.
### Changed
- Publish the Docker image to GitHub Container Registry (ghcr).
### Fixed
- #207: keep audio/other file mime types intact.
- Localization key typo `note_to_big` → `note_too_big`.
## [2.9.2] - 2026-06-07
### Added
- Image paste support.
- Czech translation.
- `THEME_HOME_LINK` environment variable.
- Docker compose: prevent anonymous volume creation.
### Changed
- Replace Redis with Valkey in docker-compose files.
- Rust 2024 edition compat, watchexec and axum 0.8 updates.
- Switched license checker package.
- Frontend cleanup and readme/docs cleanup.
### Security
- Updated dependencies (ring, npm_and_yarn group).
## [2.9.1] - 2025-02-27
### Added
- Docs about running Redis in RAM-only mode.
### Fixed
- Password eye toggle not working.
### Security
- Updated dependencies.
## [2.9.0] - 2025-01-18
### Changed
- Frontend rework: migrate to Svelte 5.
- Update Redis documentation link in compose.
### Fixed
- Fix race condition on the delete endpoint by introducing locks to guarantee the view counter.
## [2.8.4] - 2025-01-02
### Added
- Chinese (zh-TW) translations.
- Basic auth example (nginx).
## [2.8.3] - 2024-09-27
### Added
- Options to add an imprint: `IMPRINT_URL`, `IMPRINT_HTML`.
## [2.8.2] - 2024-09-20
### Added
- Raycast extension links.
### Changed
- Add `type="button"` to form elements.
- Bump pnpm version.
## [2.8.1] - 2024-09-02
### Changed
- Move shared package into the CLI.
- Add a guide.
## [2.8.0] - 2024-08-27
### Changed
- Migrate backend from actix to axum (major refactor).
- More robust config, body limit via axum.
- Use container for test pipeline; skip size/expiration quirks in Safari.
### Fixed
- Typos in English localization.
## [2.7.0] - 2024-08-23
### Added
- Better programmatic access to the shared client.
- Redis TLS feature, dynamically-linked and native musl targets.
- French blog post and improved French translations.
### Changed
- Bump redis crate to 0.25.2.
## [2.6.1] - 2024-05-04
### Added
- Polish translation.
## [2.6.0] - 2024-03-24
### Added
- `ALLOW_FILES` flag.
- `NEW_NOTE_NOTICE` → `THEME_NEW_NOTE_NOTICE` theme flag.
- French translation update.
### Changed
- Reset form when clicking the logo after creating a note.
## [2.5.1] - 2024-03-04
### Changed
- Reset translation.
- German (`de`) translation update.
## [2.5.0] - 2024-03-04
### Added
- Expose internal shared functionality for external/programmatic usage.
- German translation updates.
### Security
- Updated dependencies (zerocopy).
## [2.4.0] - 2023-11-01 ## [2.4.0] - 2023-11-01
### Changed ### Changed
@@ -12,6 +189,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Removed HTML sanitation, display the original message as string - Removed HTML sanitation, display the original message as string
- Links are now displayed under the note in a separate section - Links are now displayed under the note in a separate section
## [2.3.3] - 2023-08-15
### Changed
- Maintenance.
- Updated dependencies.
## [2.3.2] - 2023-08-04
### Added
- Spanish readme (`README_ES.md`).
### Changed
- Translation and grammar fixes (en, de, de, es).
## [2.3.1] - 2023-06-23 ## [2.3.1] - 2023-06-23
### Added ### Added
@@ -30,6 +224,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Moved to monorepo. - Moved to monorepo.
## [2.2.0] - 2023-01-14
### Changed ### Changed
- Default port is now 8000, not 5000. - Default port is now 8000, not 5000.
-614
View File
@@ -1,614 +0,0 @@
{
"info": {
"_postman_id": "3aaeac19-4eac-4911-b3c8-912b17a48634",
"name": "Cryptgeon",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Notes",
"item": [
{
"name": "Preview",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/notes/:id",
"host": ["{{BASE}}"],
"path": ["notes", ":id"],
"variable": [
{
"key": "id",
"value": "{{NOTE_ID}}",
"description": "Id of the Note"
}
]
},
"description": "This endpoint is to query wether a note exists, without actually opening it. No view limits are used here, as contents of the note are not available, only the `meta` field is returned, which is public."
},
"response": [
{
"name": "200",
"originalRequest": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/notes/:id",
"host": ["{{BASE}}"],
"path": ["notes", ":id"],
"variable": [
{
"key": "id",
"value": "{{NOTE_ID}}",
"description": "Id of the Note"
}
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "content-type",
"value": "application/json"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:24:29 GMT"
}
],
"cookie": [],
"body": "{}"
},
{
"name": "404",
"originalRequest": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/notes/:id",
"host": ["{{BASE}}"],
"path": ["notes", ":id"],
"variable": [
{
"key": "id",
"value": "{{NOTE_ID}}",
"description": "Id of the Note"
}
]
}
},
"status": "Not Found",
"code": 404,
"_postman_previewlanguage": "plain",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:25:26 GMT"
}
],
"cookie": [],
"body": ""
}
]
},
{
"name": "Create",
"event": [
{
"listen": "test",
"script": {
"exec": [
"const jsonData = pm.response.json();",
"pm.collectionVariables.set('NOTE_ID', jsonData.id)",
""
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"contents\": \"Some encrypted content\",\n \"views\": 1,\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE}}/notes/",
"host": ["{{BASE}}"],
"path": ["notes", ""]
}
},
"response": [
{
"name": "Simple",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"contents\": \"Some encrypted content\",\n \"views\": 1,\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE}}/notes/",
"host": ["{{BASE}}"],
"path": ["notes", ""]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "content-type",
"value": "application/json"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:31:54 GMT"
}
],
"cookie": [],
"body": "{\n \"id\": \"1QeEWDQbQY9dOo8cDDQjykaEjouqugTR6A78sjgn4VMv\"\n}"
},
{
"name": "5 Minutes",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"contents\": \"Some encrypted content\",\n \"expiration\": 5,\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE}}/notes/",
"host": ["{{BASE}}"],
"path": ["notes", ""]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "content-type",
"value": "application/json"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:31:54 GMT"
}
],
"cookie": [],
"body": "{\n \"id\": \"1QeEWDQbQY9dOo8cDDQjykaEjouqugTR6A78sjgn4VMv\"\n}"
},
{
"name": "3 Views",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"contents\": \"Some encrypted content\",\n \"views\": 3,\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE}}/notes/",
"host": ["{{BASE}}"],
"path": ["notes", ""]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "content-type",
"value": "application/json"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:31:54 GMT"
}
],
"cookie": [],
"body": "{\n \"id\": \"1QeEWDQbQY9dOo8cDDQjykaEjouqugTR6A78sjgn4VMv\"\n}"
}
]
},
{
"name": "Read",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{BASE}}/notes/:id",
"host": ["{{BASE}}"],
"path": ["notes", ":id"],
"variable": [
{
"key": "id",
"value": "{{NOTE_ID}}"
}
]
},
"description": "This endpoint gets the actual contents of a note. It's a `DELETE` endpoint, es it decreases the `view` counter, and deletes the note if `0` is reached."
},
"response": [
{
"name": "200",
"originalRequest": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{BASE}}/notes/:id",
"host": ["{{BASE}}"],
"path": ["notes", ":id"],
"variable": [
{
"key": "id",
"value": "{{NOTE_ID}}"
}
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "content-type",
"value": "application/json"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:59:07 GMT"
}
],
"cookie": [],
"body": "{\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\",\n \"contents\": \"Some encrypted content\"\n}"
},
{
"name": "404",
"originalRequest": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{BASE}}/notes/:id",
"host": ["{{BASE}}"],
"path": ["notes", ":id"],
"variable": [
{
"key": "id",
"value": "{{NOTE_ID}}"
}
]
}
},
"status": "Not Found",
"code": 404,
"_postman_previewlanguage": "plain",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:59:15 GMT"
}
],
"cookie": [],
"body": ""
}
]
}
]
},
{
"name": "Status",
"item": [
{
"name": "Get server status",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/status/",
"host": ["{{BASE}}"],
"path": ["status", ""]
}
},
"response": [
{
"name": "200",
"originalRequest": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/status/",
"host": ["{{BASE}}"],
"path": ["status", ""]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "content-type",
"value": "application/json"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:56:45 GMT"
}
],
"cookie": [],
"body": "{\n \"version\": \"2.3.0-beta.4\",\n \"max_size\": 10485760,\n \"max_views\": 100,\n \"max_expiration\": 360,\n \"allow_advanced\": true,\n \"theme_image\": \"\",\n \"theme_text\": \"\",\n \"theme_page_title\": \"\",\n \"theme_favicon\": \"\"\n}"
}
]
},
{
"name": "Health Check",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/live/",
"host": ["{{BASE}}"],
"path": ["live", ""]
},
"description": "Return `200` for healthy service. `503` if service is unavailable."
},
"response": [
{
"name": "Healthy",
"originalRequest": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/live/",
"host": ["{{BASE}}"],
"path": ["live", ""]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "plain",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "date",
"value": "Thu, 22 Jun 2023 20:17:58 GMT"
}
],
"cookie": [],
"body": null
},
{
"name": "Service Unavilable",
"originalRequest": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/live/",
"host": ["{{BASE}}"],
"path": ["live", ""]
}
},
"status": "Service Unavailable",
"code": 503,
"_postman_previewlanguage": "plain",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "date",
"value": "Thu, 22 Jun 2023 20:18:55 GMT"
}
],
"cookie": [],
"body": null
}
]
}
]
}
],
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"exec": [""]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [""]
}
}
],
"variable": [
{
"key": "BASE",
"value": "http://localhost:3000/api",
"type": "default"
},
{
"key": "NOTE_ID",
"value": "",
"type": "default"
}
]
}
+23 -24
View File
@@ -55,7 +55,7 @@ There is an [official Raycast extension](https://www.raycast.com/cupcakearmy/cry
each note has a generated <code>id (256bit)</code> and <code>key 256(bit)</code>. The each note has a generated <code>id (256bit)</code> and <code>key 256(bit)</code>. The
<code>id</code> <code>id</code>
is used to save & retrieve the note. the note is then encrypted with aes in gcm mode on the is used to save & retrieve the note. the note is then encrypted with XChaCha20-Poly1305 on the
client side with the <code>key</code> and then sent to the server. data is stored in memory and client side with the <code>key</code> and then sent to the server. data is stored in memory and
never persisted to disk. the server never sees the encryption key and cannot decrypt the contents never persisted to disk. the server never sees the encryption key and cannot decrypt the contents
of the notes even if it tried to. of the notes even if it tried to.
@@ -68,31 +68,32 @@ of the notes even if it tried to.
## Environment Variables ## Environment Variables
| Variable | Default | Description | | Variable | Default | Description |
| ----------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `CACHE` | `redis://cache/` | Cache URL (valkey or redis) to connect to. [According to format](https://docs.rs/redis/latest/redis/#connection-parameters) | | `CACHE` | `redis://cache/` | Cache URL (valkey or redis) to connect to. [According to format](https://docs.rs/redis/latest/redis/#connection-parameters) |
| `SIZE_LIMIT` | `1 KiB` | Max size for body. Accepted values according to [byte-unit](https://docs.rs/byte-unit/). <br> `512 MiB` is the maximum allowed. <br> The frontend will show that number including the ~35% encoding overhead. | | `SIZE_LIMIT` | `1 KiB` | Max size for body. Accepted values according to [byte-unit](https://docs.rs/byte-unit/). <br> `512 MiB` is the maximum allowed. <br> Payloads are raw bytes (msgpack + cipher), so the frontend shows the full limit. |
| `MAX_VIEWS` | `100` | Maximal number of views. | | `MAX_VIEWS` | `100` | Maximal number of views. |
| `MAX_EXPIRATION` | `360` | Maximal expiration in minutes. | | `MAX_EXPIRATION` | `360` | Maximal expiration in minutes. |
| `ALLOW_ADVANCED` | `true` | Allow custom configuration. If set to `false` all notes will be one view only. | | `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. | | `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_. | | `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_. |
| `CACHE_PREFIX` | `""` | Optional prefix for all cache keys. Useful when sharing a cache instance with other apps via ACL namespaces. | | `CACHE_PREFIX` | `""` | Optional prefix for all cache keys. Useful when sharing a cache 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` | | `EXTRA_SIZE_LIMIT` | `512` | Maximum size in bytes of the opaque `extra` payload (e.g. key derivation params) stored on the note metadata. |
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable | | `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_TEXT` | `""` | Custom text for replacing the description below the logo | | `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
| `THEME_PAGE_TITLE` | `""` | Custom text the page title | | `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
| `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable | | `THEME_PAGE_TITLE` | `""` | Custom text the page title |
| `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_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable |
| `THEME_HOME_LINK` | `true` | Show the `/home` link in the footer. Defaults to `true`. | | `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`. |
| `IMPRINT_URL` | `""` | Custom url for an Imprint hosted somewhere else. Must be publicly reachable. Takes precedence above `IMPRINT_HTML`. | | `THEME_HOME_LINK` | `true` | Show the `/home` link in the footer. Defaults to `true`. |
| `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. | | `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 ## Deployment
> ℹ️ `https` is required otherwise browsers will not support the cryptographic functions. > ℹ️ `https` is required otherwise browsers will not support the cryptographic functions.
> ℹ️ There is a health endpoint available at `/api/health/`. It returns either 200 or 503. > ℹ️ There is a health endpoint available at `/healthz`. It returns either 200 or 503.
### Docker ### Docker
@@ -101,8 +102,6 @@ Docker is the easiest way. There is the [official image here](https://hub.docker
```yaml ```yaml
# docker-compose.yml # docker-compose.yml
version: "3.8"
services: services:
cache: cache:
image: valkey/valkey:7-alpine image: valkey/valkey:7-alpine
@@ -116,7 +115,7 @@ services:
- /data - /data
app: app:
image: cupcakearmy/cryptgeon:latest image: cupcakearmy/cryptgeon:v3
depends_on: depends_on:
- cache - cache
environment: environment:
+30 -26
View File
@@ -48,7 +48,7 @@ Puedes revisar la documentación sobre el CLI en este [readme](./packages/cli/RE
Se genera una <code>id (256bit)</code> y una <code>llave 256(bit)</code> para cada nota. La Se genera una <code>id (256bit)</code> y una <code>llave 256(bit)</code> para cada nota. La
<code>id</code> <code>id</code>
se usa para guardar y recuperar la nota. Después la nota es encriptada con la <code>llave</code> y con aes en modo gcm del lado del cliente y por último se envía al servidor. La información es almacenada en memoria y nunca persiste en el disco. El servidor nunca ve la llave de encriptación por lo que no puede desencriptar el contenido de las notas aunque lo intentara. se usa para guardar y recuperar la nota. Después la nota es encriptada con XChaCha20-Poly1305 del lado del cliente y por último se envía al servidor. La información es almacenada en memoria y nunca persiste en el disco. El servidor nunca ve la llave de encriptación por lo que no puede desencriptar el contenido de las notas aunque lo intentara.
## Capturas de pantalla ## Capturas de pantalla
@@ -56,26 +56,32 @@ se usa para guardar y recuperar la nota. Después la nota es encriptada con la <
## Variables de entorno ## Variables de entorno
| Variable | Default | Descripción | | Variable | Default | Descripción |
| ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `REDIS` | `redis://redis/` | Redis URL a la que conectarse. [Según el formato](https://docs.rs/redis/latest/redis/#connection-parameters) | | `CACHE` | `redis://cache/` | URL de caché (valkey o redis) a la que conectarse. [Según el formato](https://docs.rs/redis/latest/redis/#connection-parameters) |
| `SIZE_LIMIT` | `1 KiB` | Tamaño máximo. Valores aceptados según la [unidad byte](https://docs.rs/byte-unit/). <br> `512 MiB` es el máximo permitido. <br> El frontend mostrará ese número, incluyendo el ~35% de sobrecarga de codificación. | | `SIZE_LIMIT` | `1 KiB` | Tamaño máximo del cuerpo. Valores aceptados según [byte-unit](https://docs.rs/byte-unit/). <br> `512 MiB` es el máximo permitido. <br> Los payloads son bytes crudos (msgpack + cifrado), por lo que el frontend muestra el límite completo. |
| `MAX_VIEWS` | `100` | Número máximo de vistas. | | `MAX_VIEWS` | `100` | Número máximo de vistas. |
| `MAX_EXPIRATION` | `360` | Tiempo máximo de expiración en minutos. | | `MAX_EXPIRATION` | `360` | Tiempo máximo de expiración en minutos. |
| `ALLOW_ADVANCED` | `true` | Permitir configuración personalizada. Si se establece en `false` todas las notas serán de una sola vista. | | `ALLOW_ADVANCED` | `true` | Permitir configuración personalizada. Si se establece en `false` todas las notas serán de una sola vista. |
| `ID_LENGTH` | `32` | Establece el tamaño en bytes de la `id` de la nota. Por defecto es de `32` bytes. Esto es útil para reducir el tamaño del link. _Esta configuración no afecta el nivel de encriptación_. | | `ALLOW_FILES` | `true` | Permitir subir archivos. Si es `false`, los usuarios solo podrán crear notas de texto. |
| `VERBOSITY` | `warn` | Nivel de verbosidad del backend. [Posibles valores](https://docs.rs/env_logger/latest/env_logger/#enabling-logging): `error`, `warn`, `info`, `debug`, `trace` | | `ID_LENGTH` | `32` | Establece el tamaño en bytes de la `id` de la nota. Por defecto es de `32` bytes. Útil para reducir el tamaño del link. _No afecta el nivel de encriptación_. |
| `THEME_IMAGE` | `""` | Imagen personalizada para reemplazar el logo. Debe ser accesible públicamente. | | `CACHE_PREFIX` | `""` | Prefijo opcional para las claves de caché. Útil al compartir una instancia con otras apps vía namespaces ACL. |
| `THEME_TEXT` | `""` | Texto personalizado para reemplazar la descripción bajo el logo. | | `EXTRA_SIZE_LIMIT` | `512` | Tamaño máximo en bytes del payload `extra` opaco (p. ej. parámetros de derivación de clave) guardado en los metadatos de la nota. |
| `THEME_PAGE_TITLE` | `""` | Texto personalizado para el título | | `VERBOSITY` | `warn` | Nivel de verbosidad del backend. [Posibles valores](https://docs.rs/env_logger/latest/env_logger/#enabling-logging): `error`, `warn`, `info`, `debug`, `trace` |
| `THEME_FAVICON` | `""` | Url personalizada para el favicon. Debe ser accesible públicamente. | | `THEME_IMAGE` | `""` | Imagen personalizada para reemplazar el logo. Debe ser accesible públicamente. |
| `THEME_HOME_LINK` | `true` | Mostrar el enlace `/home` en el pie de página. El valor predeterminado es `true`. | | `THEME_TEXT` | `""` | Texto personalizado para reemplazar la descripción bajo el logo. |
| `THEME_PAGE_TITLE` | `""` | Texto personalizado para el título. |
| `THEME_FAVICON` | `""` | Url personalizada para el favicon. Debe ser accesible públicamente. |
| `THEME_NEW_NOTE_NOTICE` | `true` | Mostrar el mensaje sobre cómo se almacenan las notas en memoria (pueden ser expulsadas) al crear una nueva nota. |
| `THEME_HOME_LINK` | `true` | Mostrar el enlace `/home` en el pie de página. El valor predeterminado es `true`. |
| `IMPRINT_URL` | `""` | URL personalizada para un imprint alojado en otro sitio. Debe ser accesible públicamente. Tiene prioridad sobre `IMPRINT_HTML`. |
| `IMPRINT_HTML` | `""` | Alternativa a `IMPRINT_URL` para especificar el HTML a mostrar en `/imprint`. Usa solo `IMPRINT_HTML` o `IMPRINT_URL`, no ambos. |
## Despliegue ## Despliegue
> ℹ️ Se requiere `https` de lo contrario el navegador no soportará las funciones de encriptación. > ℹ️ Se requiere `https` de lo contrario el navegador no soportará las funciones de encriptación.
> ℹ️ Hay un endpoint para verificar el estado, lo encontramos en `/api/health/`. Regresa un código 200 o 503. > ℹ️ Hay un endpoint para verificar el estado, lo encontramos en `/healthz`. Regresa un código 200 o 503.
### Docker ### Docker
@@ -84,24 +90,22 @@ Docker es la manera más fácil. Aquí encontramos [la imagen oficial](https://h
```yaml ```yaml
# docker-compose.yml # docker-compose.yml
version: "3.8"
services: services:
redis: cache:
image: redis:7-alpine image: valkey/valkey:7-alpine
# This is required to stay in RAM only. # This is required to stay in RAM only.
command: redis-server --save "" --appendonly no command: valkey-server --save "" --appendonly no
# Set a size limit. See link below on how to customise. # Set a size limit. See link below on how to customise.
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/ # https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine # --maxmemory 1g --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume. # This prevents the creation of an anonymous volume.
tmpfs: tmpfs:
- /data - /data
app: app:
image: cupcakearmy/cryptgeon:latest image: cupcakearmy/cryptgeon:v3
depends_on: depends_on:
- redis - cache
environment: environment:
# Size limit for a single note. # Size limit for a single note.
SIZE_LIMIT: 4 MiB SIZE_LIMIT: 4 MiB
@@ -110,7 +114,7 @@ services:
# Optional health checks # Optional health checks
# healthcheck: # healthcheck:
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/api/live/"] # test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/healthz"]
# interval: 1m # interval: 1m
# timeout: 3s # timeout: 3s
# retries: 2 # retries: 2
+33 -26
View File
@@ -36,7 +36,7 @@ _加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全
加密鸽会为每条笔记都生成一个独立的 <code>id (256bit)</code> 和 <code>key 256(bit)</code>。 加密鸽会为每条笔记都生成一个独立的 <code>id (256bit)</code> 和 <code>key 256(bit)</code>。
其中<code>id</code>用于保存和提取密信, 在这之后这封密信将会被客户端使用 AES 算法的 GCM 模式和`key`进行加密然后发送至服务器,数据将会保存在服务器的内存中且永远不会被持久化到硬盘上,服务端永远不会得到密钥并且无法解读密信的内容。 其中<code>id</code>用于保存和提取密信, 在这之后这封密信将会被客户端使用 XChaCha20-Poly1305 加密算法和`key`进行加密然后发送至服务器,数据将会保存在服务器的内存中且永远不会被持久化到硬盘上,服务端永远不会得到密钥并且无法解读密信的内容。
## 屏幕截图 ## 屏幕截图
@@ -44,16 +44,26 @@ _加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全
## 环境变量 ## 环境变量
| 变量名称 | 默认值 | 描述 | | 变量名称 | 默认值 | 描述 |
| ---------------- | ---------------- | --------------------------------------------------------------------------------- | | ----------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `REDIS` | `redis://redis/` | Redis 连接 URL。 | | `CACHE` | `redis://cache/` | 缓存(valkey 或 redis)连接 URL。[连接参数](https://docs.rs/redis/latest/redis/#connection-parameters) |
| `SIZE_LIMIT` | `1 KiB` | 最大请求体(body)限制。有关支持的数值请查看 [字节单位](https://docs.rs/byte-unit/) | | `SIZE_LIMIT` | `1 KiB` | 最大请求体(body)限制。可通过 [字节单位](https://docs.rs/byte-unit/) 查看支持的值。负载是原始字节(msgpack + 加密),因此前端显示完整限制。 |
| `MAX_VIEWS` | `100` | 密信最多查看次数限制 | | `MAX_VIEWS` | `100` | 密信最多查看次数限制。 |
| `MAX_EXPIRATION` | `360` | 密信最长过期时间限制(分钟) | | `MAX_EXPIRATION` | `360` | 密信最长过期时间限制(分钟)。 |
| `ALLOW_ADVANCED` | `true` | 是否允许自定义设置,该项如果设为`false`,则不会显示自定义设置模块 | | `ALLOW_ADVANCED` | `true` | 是否允许自定义设置,该项如果设为`false`,则不会显示自定义设置模块。 |
| `THEME_IMAGE` | `""` | 自定义 Logo 图片,你在这里填写的的图片链接必须是可以公开访问的。 | | `ALLOW_FILES` | `true` | 是否允许上传文件。为 `false` 时用户只能创建文本密信。 |
| `THEME_TEXT` | `""` | 自定义在 Logo 下方的文本。 | | `ID_LENGTH` | `32` | 设置密信 `id` 的字节大小。默认 `32` 字节,可用于缩短链接长度。_不影响加密强度_。 |
| `THEME_HOME_LINK` | `true` | 是否在页脚显示 `/home` 链接。默认为 `true`。 | | `CACHE_PREFIX` | `""` | 缓存键可选前缀。与其它应用通过 ACL namespace 共享缓存实例时有用。 |
| `EXTRA_SIZE_LIMIT` | `512` | 不透明 `extra` 负载(如密钥派生参数)的最大字节数,存于密信元数据。 |
| `VERBOSITY` | `warn` | 后端日志级别。可能值见 [env_logger](https://docs.rs/env_logger/latest/env_logger/#enabling-logging)。 |
| `THEME_IMAGE` | `""` | 自定义 Logo 图片,需可公开访问。 |
| `THEME_TEXT` | `""` | 自定义在 Logo 下方的文本。 |
| `THEME_PAGE_TITLE` | `""` | 自定义页面标题。 |
| `THEME_FAVICON` | `""` | 自定义 favicon 地址,需可公开访问。 |
| `THEME_NEW_NOTE_NOTICE` | `true` | 创建新笔记后显示“笔记存于内存可能被清除”的提示。 |
| `THEME_HOME_LINK` | `true` | 是否在页脚显示 `/home` 链接。默认为 `true`。 |
| `IMPRINT_URL` | `""` | 托管在其它位置的印页 URL,需可公开访问。优先于 `IMPRINT_HTML`。 |
| `IMPRINT_HTML` | `""` | `IMPRINT_URL` 的替代:指定 `/imprint` 展示的 HTML。`IMPRINT_HTML` 与 `IMPRINT_URL` 只应指定其一。 | |
## 部署 ## 部署
@@ -67,24 +77,23 @@ Docker 是最简单的部署方式。这里是[官方镜像的地址](https://hu
```yaml ```yaml
# docker-compose.yml # docker-compose.yml
version: "3.8"
services: services:
redis: cache:
image: redis:7-alpine image: valkey/valkey:7-alpine
# This is required to stay in RAM only. # This is required to stay in RAM only.
command: redis-server --save "" --appendonly no command: valkey-server --save "" --appendonly no
# Set a size limit. See link below on how to customise. # Set a size limit. See link below on how to customise.
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/ # https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine # --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume. # This prevents the creation of an anonymous volume.
tmpfs: tmpfs:
- /data - /data
app: app:
image: cupcakearmy/cryptgeon:latest image: cupcakearmy/cryptgeon:v3
depends_on: depends_on:
- redis - cache
environment: environment:
SIZE_LIMIT: 4 MiB SIZE_LIMIT: 4 MiB
ports: ports:
@@ -105,29 +114,27 @@ services:
- 域名 `example.org` - 域名 `example.org`
```yaml ```yaml
version: "3.8"
networks: networks:
proxy: proxy:
external: true external: true
services: services:
redis: cache:
image: redis:7-alpine image: valkey/valkey:7-alpine
# This is required to stay in RAM only. # This is required to stay in RAM only.
command: redis-server --save "" --appendonly no command: valkey-server --save "" --appendonly no
# Set a size limit. See link below on how to customise. # Set a size limit. See link below on how to customise.
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/ # https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine # --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume. # This prevents the creation of an anonymous volume.
tmpfs: tmpfs:
- /data - /data
app: app:
image: cupcakearmy/cryptgeon:latest image: cupcakearmy/cryptgeon:v3
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- redis - cache
networks: networks:
- default - default
- proxy - proxy
+1 -1
View File
@@ -11,7 +11,7 @@ services:
- /data - /data
app: app:
image: cupcakearmy/cryptgeon:latest image: cupcakearmy/cryptgeon:v3
depends_on: depends_on:
- cache - cache
environment: environment:
+76
View File
@@ -0,0 +1,76 @@
# Roadmap
## Todo
- Add remaining shared tooling to the pnpm catalog (`vite`, `tsdown`).
- Move formatting, linting and type-checking + git hooks onto `vite-plus` (oxlint, oxfmt, vitest).
- Re-add CSP (`Content-Security-Policy`) wired into the axum router (was in `csp.rs`, removed as unused).
## Unified payload (drop the text/file union)
> Follow-up iteration of the inner payload, parked as `v3.1` (not part of core v3).
Everything becomes a **file**. Text is just a `FileDTO` with `inline: true`. The `{ type: "text" } | { type: "files" }` union is removed.
```
# Inner layer (encrypted, client-only)
{ files: [
{ name: string, mime: string, size: number, data: bytes, inline?: boolean }
] }
```
- `FileDTO` gains `inline?: boolean` (default `false`).
- `inline: true` = the file was authored inline at compose time (e.g. an empty text file the user typed into). Purely a **client/UI hint** — rides inside the encrypted inner payload, the server never sees it.
- `inline: true` files render as an **editable text editor**; the rest render as binary file cards (upload/download).
- Default composer state = one empty `inline:true` text file the user edits. No `isFile` toggle.
### Impact by area
- **Server / wire protocol / `api.ts`**: unchanged. Still an opaque encrypted `data` blob in the outer msgpack envelope.
- **Shared codec**: `NoteContent` becomes `{ files: FileDTO[] }`; drop the union + `switch(type)` in `unpackContent`. `packContent(input: FileDTO[], password?)`. Breaking inner-msgpack schema → ok, pre-release.
- **Frontend (`Create.svelte` — biggest)**: one `files: FileDTO[]` model; default empty `inline` text file; editor binds a string, encodes to bytes on submit; add real files via upload (`inline:false`).
- **CLI**: `send text "x"` → `files:[{ name:'note.txt', mime:'text/plain', data:utf8, inline:true }]`. `send file a b` → drop `type` union. `open`/download prints text files, saves the rest.
- **Tests**: `payload.test.ts` rewritten to `{ files:[...] }`; playwright `switch-file`/`text-field` composer specs collapse + rework.
### Watches
- text↔bytes round-trip in the editor (encoding, line-endings);
- `size` must be set from the _encoded_ bytes (matches `SIZE_LIMIT` / preview) — recompute after text→bytes;
- pasted binary files keep `inline:false`; only inline-authored text is `inline:true`.
### Payload pipeline
```mermaid
flowchart TD
subgraph WRITE["CLIENT — encode / compress / encrypt"]
A[Text or Files] --> B{password?}
B -->|yes| C1[deriveKey password+salt<br>extra=encode salt,N,r,p]
B -->|no| C2[generateKey random 32B]
C2 --> D[URL fragment hex key]
C1 --> E
D --> E
A --> F[encode inner files]
F -->|encode content| G[inner msgpack]
G --> H[LZ4 compress]
H --> I[XChaCha20 encrypt]
I -->|data| J[POST msgpack meta+data]
C1 -->|extra| J
end
subgraph SERVER["SERVER — agnostic"]
J --> K{hash store: views, expiration, extra, data}
end
subgraph READ["CLIENT — read"]
L[meta/extra from PREVIEW] --> M{extra present?}
M -->|yes| N[deriveKey pw+salt]
M -->|no| O[key from URL hex fragment]
N --> P[DELETE get envelope data]
O --> P
P --> Q[XChaCha20 decrypt]
Q --> R[LZ4 decompress]
R --> S[msgpack decode files]
S -->|inline:true| T[edit / render text]
S -->|inline:false| U[save files]
end
```
-6
View File
@@ -1,6 +0,0 @@
# Todo
- also update readmes in other languages
- use catalog install for common deps (typescript, vite, tsdown, etc. ) please suggest.
- move formatting, linting, type checking and git hooks to vite-plus (uses oxlint, oxfmt, vitest and git hook dispatcher)
- re-add CSP (Content-Security-Policy) into axum router ( (was in csp.rs, removed as unused)
-66
View File
@@ -1,66 +0,0 @@
# v3 Breaking Changes
A list of changes users and operators need to consider when upgrading from v2 to v3.
## API
- All note endpoints moved under `/api/v3/notes/` (was `/api/notes/`)
- Status endpoint moved to `/api/v3/status` (was `/api/status`)
- All request/response bodies are now **MessagePack** (`Content-Type: application/msgpack`), not JSON
- Health check moved to `/healthz` (was `/api/live`)
- Notes can now have **both** `views` and `expiration` set simultaneously (previously mutually exclusive)
## API payload structure
The wire format changed entirely. v2 used:
```json
{ "contents": "<encrypted string>", "meta": "<stringified JSON>", "views": 5, "expiration": 30 }
```
v3 uses msgpack:
```
{ meta: { views?, expiration?, extra? }, data: <encrypted bytes> }
```
- `meta.extra` holds client-opaque data (e.g. scrypt derivation params), size-limited (default 512 bytes)
- `data` is the encrypted blob — the server never inspects its contents
- The encrypted inner payload is itself msgpack: `{ type: "text", data: string }` or `{ type: "files", data: [{ name, mime, size, data }] }`
## Environment variables
| v2 | v3 |
| ------------------- | -------------------- |
| `REDIS` | `CACHE` |
| `REDIS_PREFIX` | `CACHE_PREFIX` |
| _(new)_ | `EXTRA_SIZE_LIMIT` |
The `CACHE` env var accepts any RESP-compatible URL (valkey or redis).
`EXTRA_SIZE_LIMIT` (default `512`) limits the `extra` field size in bytes.
## Docker / Compose
- The `redis` service in docker-compose is renamed to `cache`
- Healthcheck URL updated: `http://127.0.0.1:8000/api/live/` → `http://127.0.0.1:8000/healthz`
- The default image stays `valkey/valkey:7-alpine` but operators can swap for any redis-compatible image
## CLI (`cryptgeon` npm package)
- Dropped `occulto` dependency — now uses `@noble/ciphers` + `@noble/hashes` internally
- Encryption changed from AES to **XChaCha20-Poly1305**
- The local `shared/` module removed — now imports from `@cryptgeon/shared` (workspace-internal)
- Notes created with v2 (AES) are **not readable** by v3 and vice versa
## Frontend
- Dropped `occulto` dependency
- Package import changed from `cryptgeon/shared` to `@cryptgeon/shared`
- Notes created in v2 are not accessible from the v3 frontend
## Storage
- Cache storage format changed from JSON blobs to hashes with atomic `HINCRBY` for view counting
- The per-note lock (`lock.rs`) is removed — no longer needed
- Existing v2 notes in cache are **not migrated** and will be inaccessible after upgrade
- Ensure cache is empty (or flush) before deploying v3
-323
View File
@@ -1,323 +0,0 @@
# v3 Plan
> Status: **Draft** — agreed on architecture, schema open for iteration.
>
> See also: [v3 Breaking Changes](./v3-breaking-changes.md) for the upgrade guide.
## Goals
- **XChaCha20-Poly1305** for encryption (replaces AES/`occulto`)
- **MessagePack** for all API request/response bodies (replaces JSON)
- **LZ4 compression** for note payloads (client-side, before encryption — pure JS, no wasm)
- **Cache hashes** (valkey or redis, both speak RESP) for storage — replaces JSON-blob-per-key
- **Clean break** from v1 — no backward compatibility, no v1 routes
- Remove all Redis references (env vars, service names, docs) in favor of the generic "cache" naming, so operators can choose valkey or redis
- Shared TypeScript package as the single source of truth for crypto + API client + types
## Non-goals
- Keeping v1 alive alongside v3
- Changing the backend language/framework (stays Rust + axum)
- Changing storage backend (stays valkey or redis via the `redis` crate — no separate crate)
- Publishing `@cryptgeon/shared` as a standalone npm package (workspace-internal for now)
---
## 1. Shared package — `@cryptgeon/shared`
Location: `packages/shared` (currently empty).
ESM-only, TypeScript-only. Consumed by both `packages/cli` and `packages/frontend` via workspace dependency.
### Dependencies
- `@noble/ciphers` — XChaCha20-Poly1305
- `@noble/hashes` — scrypt
- `@msgpack/msgpack` — encode/decode
- `lz4js` — LZ4 compression (pure JS, no wasm)
- `ky` — HTTP client
### Structure
```
packages/shared/src/
index.ts # re-exports
crypto.ts # key derivation, encrypt, decrypt
compression.ts # LZ4 compress / decompress
types.ts # Note, NoteMetadata, FileDTO, Status, etc.
api.ts # high-level client: create, info, view, status
api.test.ts # tests
crypto.test.ts # tests
compression.test.ts # tests
```
### `crypto.ts`
- `deriveKey(password: string): Uint8Array` — scrypt, N=2^15, r=8, p=1, dkLen=32, fixed app-specific salt
- `generateKey(): Uint8Array` — `randomBytes(32)`
- `encrypt(data: Uint8Array, key: Uint8Array): Uint8Array` — `managedNonce(xchacha20poly1305)(key).encrypt(data)`
- `decrypt(ciphertext: Uint8Array, key: Uint8Array): Uint8Array` — `managedNonce(xchacha20poly1305)(key).decrypt(ciphertext)`
> **Note (carried over from msgpack branch):** the v2 stub had a bug — `decrypt` passed `key` as a second arg to `chacha.decrypt`, which only takes ciphertext. v3 must not repeat this.
### `compression.ts`
- `compress(data: Uint8Array): Uint8Array` — LZ4 block format
- `decompress(data: Uint8Array): Uint8Array` — LZ4 block format
Compression is a **client-only** concern. The server never sees or knows about compression — it stores the encrypted `data` blob as opaque bytes. The pipeline is:
```
msgpack encode → LZ4 compress → XChaCha20-Poly1305 encrypt
XChaCha20-Poly1305 decrypt → LZ4 decompress → msgpack decode
```
Compression runs on the plaintext (inner msgpack), never on ciphertext — encrypted data is high-entropy and incompressible. Always-on for v3 (all clients share the same package, no interop flag needed).
### `api.ts`
High-level client. All requests/responses are msgpack (`Content-Type: application/msgpack`). Methods:
- `setOptions({ server })` / `getOptions()`
- `create(note, key): Promise<{ id: string }>` — encodes msgpack, compresses (LZ4), encrypts, POST `/api/v3/notes/`
- `info(id): Promise<NoteInfo>` — GET `/api/v3/notes/{id}`, returns metadata only (no `data`)
- `view(id, key): Promise<NotePublic>` — DELETE `/api/v3/notes/{id}`, decrypts `data`, decompresses (LZ4), decodes msgpack
- `status(): Promise<Status>` — GET `/api/v3/status` (still JSON — server config, not note data)
---
## 2. Backend (Rust)
### 2.1 Storage — `store.rs` rewrite
Switch from JSON-blob-per-key to **cache hashes** (valkey or redis, both speak RESP):
```
Key: {CACHE_PREFIX}{id}
Fields:
views (i32) # remaining views, or absent
expiration (u32) # unix timestamp, or absent
type ("text"|"file")
derivation (msgpack bytes, optional) # scrypt salt+params if password-based
data (bytes) # encrypted msgpack blob
```
Functions:
- `set(id, note)` → `HSET` all fields + `EXPIRE` (if time-limited)
- `get_meta(id)` → `HMGET views expiration type derivation` — never touches `data` (cheap preview)
- `get_data(id)` → `HGET data` — only when consuming
- `decrement_view(id)` → `HINCRBY views -1` — **atomic**, see 2.2
- `del(id)` → `DEL`
- `can_reach_cache()` — health check (renamed from `can_reach_redis`)
Use `rmp-serde` for msgpack (de)serialization of note structs.
### 2.2 Remove `lock.rs`
The per-id `Mutex` map in `SharedState` existed only because the consume endpoint did non-atomic read-modify-write on `views`. With `HINCRBY` this is atomic at the cache level.
- Delete `packages/backend/src/lock.rs`
- Remove `SharedState` from `main.rs` (the `.with_state(shared_state)` call)
- Remove the lock map + `Arc`/`Mutex` imports
### 2.3 Rewrite `note/`
- `model.rs` — msgpack-compatible structs (derive `Serialize`/`Deserialize` for `rmp-serde`)
- `routes.rs` — three handlers:
#### `create` — `POST /api/v3/notes/`
- Accepts `application/msgpack` body (raw `Bytes`)
- Deserialize with rmp-serde
- Validate:
- At least one of `views`/`expiration` must be set
- `views` ≤ `MAX_VIEWS` and ≥ 1
- `expiration` ≤ `MAX_EXPIRATION` (minutes) and ≥ 1
- If `ALLOW_ADVANCED=false`: force `views=1, expiration=None`
- Store via `store::set`
- Return `{ id }` as msgpack
#### `preview` (info) — `GET /api/v3/notes/{id}`
- `store::get_meta(id)` — does not load `data`
- Return metadata as msgpack (no `data` field)
- `404` if not found
#### `view` (consume) — `DELETE /api/v3/notes/{id}`
- If `views` is set:
- `HINCRBY views -1` (atomic)
- If result ≤ 0: `HGET data`, `DEL` key, return data
- If result > 0: `HGET data`, return data (note survives for remaining views)
- If `views` is not set (time-only):
- `HGET data`, `DEL` key, return data
- Expiration handled lazily by cache (`EXPIRE` on the key) — no manual `if e < n` check on read
### 2.4 Config — `config.rs`
Rename:
- `REDIS` env → `CACHE`
- `REDIS_PREFIX` → `CACHE_PREFIX`
- `REDIS_CLIENT` static → `CACHE_CLIENT`
Everything else stays.
### 2.5 Health — `health/mod.rs`
- Rename `can_reach_redis` → `can_reach_cache`. Update panic message in `main.rs`.
- Move route from `/api/live` to `/healthz` (k8s standard). Not under `/api/v3/` — health checks are infrastructure, not API surface.
### 2.6 Status — `status/mod.rs`
Keep as JSON. It's server configuration, not note data — msgpack adds nothing. Frontend fetches once on load.
### 2.7 Dependencies — `Cargo.toml`
- Add `rmp-serde` (msgpack)
- Remove `serde_json` if no longer used (status endpoint still uses `Json<T>` which needs `serde_json` — keep)
- Keep `redis` crate (RESP client, works with valkey and redis)
---
## 3. "Both" constraint (views AND expiration)
New in v3: a note can have **both** `views` and `expiration` set simultaneously.
Implementation:
- `views` decremented via `HINCRBY views -1` on each consume
- `expiration` set via key-level `EXPIRE` (unix timestamp → seconds remaining)
- Whichever trips first removes the note:
- Views hit 0 → we `DEL` on the last consume
- Time expires → cache lazily removes the key
- No read-time expiration check needed in application code
---
## 4. Infra / docs cleanup
- `docker-compose.dev.yaml`: rename `redis` service → `cache` (image stays `valkey/valkey:7-alpine`, operators can swap for redis)
- `docker-compose.yaml` (if present): same
- `Dockerfile`: `ENV REDIS=...` → `ENV CACHE=...`
- `package.json` (root): `dev:docker` script service name
- `README.md`, `README_ES.md`, `README_zh-CN.md`, `CONTRIBUTING.md`, `CHANGELOG.md`, `examples/*` — replace "redis" with "cache" (or "valkey/redis" where context calls for naming the implementation)
- `Cryptgeon.postman_collection.json` — update content types to `application/msgpack`
- `.env.dev` — update `REDIS` → `CACHE` if present
- Healthcheck URLs: update all `/api/live` references → `/healthz` (docker-compose files, README, postman collection)
---
## 5. CLI (`packages/cli`)
- Drop `occulto` dependency
- Delete `packages/cli/src/shared/` (api.ts, adapters.ts, shared.ts) — replaced by `@cryptgeon/shared`
- `actions/upload.ts` and `actions/download.ts` call into `@cryptgeon/shared` API client
- Package still published as `cryptgeon` on npm
- `@cryptgeon/shared` stays workspace-internal (not published) for now
---
## 6. Frontend (`packages/frontend`)
- Drop `occulto` dependency
- Import from `@cryptgeon/shared` instead of `cryptgeon/shared`
- Update `package.json`: `"cryptgeon": "workspace:*"` → `"@cryptgeon/shared": "workspace:*"`
- Update files:
- `src/lib/views/Create.svelte`
- `src/lib/ui/ShowNote.svelte`
- `src/lib/ui/FileUpload.svelte`
- `src/lib/ui/PastedFilesPreview.svelte`
- `src/lib/ui/AdvancedParameters.svelte`
- `src/lib/stores/status.ts`
- `src/routes/note/[id]/+page.svelte`
---
## 7. msgpack note schema — "matrioshka" design
The server is **agnostic to the content**. It only sees an outer envelope with metadata and an opaque encrypted blob. The content type (text vs. files) lives inside the encrypted inner layer, invisible to the server.
### Outer layer (server-visible)
```
{
meta: {
expiration: u32? # optional, unix timestamp
views: u32? # optional, remaining view count
extra: bytes? # optional, client-opaque, size-limited
}
data: bytes # encrypted inner msgpack blob
}
```
- `meta.expiration` / `meta.views`: at least one must be set; both can be set simultaneously (see section 3)
- `meta.extra`: opaque client-owned data the server stores and returns verbatim in preview, but never interprets. Used for `derivation` (scrypt salt + params) so the client knows at preview time whether to prompt for a password. Size-limited (e.g. 512 bytes) to prevent abuse.
### Inner layer (encrypted, client-only)
Inside the encrypted `data` blob, after decryption, is a msgpack union:
```
# Text note
{ type: "text", data: string }
# File note
{ type: "files", data: [{ name: string, mime: string, data: bytes }] }
```
The server never sees this structure — it stores/retrieves `data` as opaque bytes.
The inner msgpack blob is **LZ4-compressed before encryption** (see `compression.ts`). Full client pipeline: `msgpack encode → lz4 compress → xchacha20poly1305 encrypt`, reversed on consume. The server is unaware of compression — it only ever handles the encrypted `data` bytes.
### Endpoints
#### `POST /api/v3/notes/` — create
**Request** (msgpack): outer layer `{ meta: { expiration?, views?, extra? }, data }`
**Response** (msgpack): `{ id: string }`
#### `GET /api/v3/notes/{id}` — preview / info
**Response** (msgpack): `{ meta: { expiration?, views?, extra? } }`
Returns metadata only — does not load `data` from cache. Client inspects `meta.extra` to determine key derivation strategy (password vs. URL-fragment key) before consuming.
#### `DELETE /api/v3/notes/{id}` — view / consume
**Response** (msgpack): `{ meta: { expiration?, views?, extra? }, data: bytes }`
Returns the full envelope. Client decrypts `data` using key derived from `meta.extra` (if present) or URL fragment, then decodes the inner msgpack to get text/files.
#### `GET /api/v3/status` — server config
**Response** (JSON, not msgpack): server configuration, not note data. Kept as JSON for simplicity.
### Valkey hash field layout
```
Key: {CACHE_PREFIX}{id}
Fields:
views (i32) # remaining views, or absent
expiration (u32) # unix timestamp, or absent
extra (bytes) # client-opaque, size-limited
data (bytes) # encrypted msgpack blob
```
`get_meta(id)` does `HMGET views expiration extra` — never touches `data`.
---
## 8. Implementation order
1. Shared package scaffolding (package.json, tsconfig, vitest config)
2. `crypto.ts` + tests
3. `compression.ts` + tests
4. `types.ts`
5. Backend: config rename + store rewrite + remove lock.rs
6. Backend: note routes rewrite (msgpack)
7. `api.ts` in shared (client) + tests
8. CLI rewrite (drop shared/, use @cryptgeon/shared)
9. Frontend migration
10. Infra/docs cleanup (cache rename, compose, Dockerfile)
11. Integration tests (playwright)
+3 -4
View File
@@ -1,7 +1,6 @@
version: '3.8'
services: services:
redis: cache:
image: valkey/valkey:7-alpine image: valkey/valkey:7-alpine
# This is required to stay in RAM only. # This is required to stay in RAM only.
command: valkey-server --save "" --appendonly no command: valkey-server --save "" --appendonly no
@@ -13,9 +12,9 @@ services:
- /data - /data
app: app:
image: cupcakearmy/cryptgeon:latest image: cupcakearmy/cryptgeon:v3
depends_on: depends_on:
- redis - cache
proxy: proxy:
image: nginx:alpine image: nginx:alpine
+15 -20
View File
@@ -25,27 +25,26 @@ This is a tiny guide to install cryptgeon on (probably) any unix system (and may
```yaml ```yaml
# docker-compose.yaml # docker-compose.yaml
version: '3.8'
services: services:
traefik: traefik:
image: traefik:2.6 image: traefik:2.6
restart: unless-stopped restart: unless-stopped
ports: ports:
- '80:80' - "80:80"
- '443:443' - "443:443"
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./traefik.yaml:/etc/traefik/traefik.yaml:ro - ./traefik.yaml:/etc/traefik/traefik.yaml:ro
- ./data:/data - ./data:/data
labels: labels:
- 'traefik.enable=true' - "traefik.enable=true"
# HTTP to HTTPS redirection # HTTP to HTTPS redirection
- 'traefik.http.routers.http_catchall.rule=HostRegexp(`{any:.+}`)' - "traefik.http.routers.http_catchall.rule=HostRegexp(`{any:.+}`)"
- 'traefik.http.routers.http_catchall.entrypoints=insecure' - "traefik.http.routers.http_catchall.entrypoints=insecure"
- 'traefik.http.routers.http_catchall.middlewares=https_redirect' - "traefik.http.routers.http_catchall.middlewares=https_redirect"
- 'traefik.http.middlewares.https_redirect.redirectscheme.scheme=https' - "traefik.http.middlewares.https_redirect.redirectscheme.scheme=https"
- 'traefik.http.middlewares.https_redirect.redirectscheme.permanent=true' - "traefik.http.middlewares.https_redirect.redirectscheme.permanent=true"
networks: networks:
default: default:
@@ -62,15 +61,15 @@ api:
# Define HTTP and HTTPS entrypoint # Define HTTP and HTTPS entrypoint
entryPoints: entryPoints:
insecure: insecure:
address: ':80' address: ":80"
secure: secure:
address: ':443' address: ":443"
# Dynamic configuration will come from docker labels # Dynamic configuration will come from docker labels
providers: providers:
docker: docker:
endpoint: 'unix:///var/run/docker.sock' endpoint: "unix:///var/run/docker.sock"
network: 'proxy' network: "proxy"
exposedByDefault: false exposedByDefault: false
# Enable acme with http file challenge # Enable acme with http file challenge
@@ -100,14 +99,12 @@ Create another docker-compose.yaml file in another folder. We will assume that t
``` ```
```yaml ```yaml
version: '3.8'
networks: networks:
proxy: proxy:
external: true external: true
services: services:
redis: cache:
image: valkey/valkey:7-alpine image: valkey/valkey:7-alpine
# This is required to stay in RAM only. # This is required to stay in RAM only.
command: valkey-server --save "" --appendonly no command: valkey-server --save "" --appendonly no
@@ -119,10 +116,10 @@ services:
- /data - /data
app: app:
image: cupcakearmy/cryptgeon:latest image: cupcakearmy/cryptgeon:v3
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- redis - cache
environment: environment:
SIZE_LIMIT: 4 MiB SIZE_LIMIT: 4 MiB
networks: networks:
@@ -155,8 +152,6 @@ docker-compose up -d
```yaml ```yaml
# docker-compose.yaml # docker-compose.yaml
version: '3.8'
services: services:
watchtower: watchtower:
image: containrrr/watchtower image: containrrr/watchtower
+6 -8
View File
@@ -9,14 +9,12 @@ Assumptions:
- Domain name `example.org`. - Domain name `example.org`.
```yaml ```yaml
version: '3.8'
networks: networks:
proxy: proxy:
external: true external: true
services: services:
redis: cache:
image: valkey/valkey:7-alpine image: valkey/valkey:7-alpine
# This is required to stay in RAM only. # This is required to stay in RAM only.
command: valkey-server --save "" --appendonly no command: valkey-server --save "" --appendonly no
@@ -28,10 +26,10 @@ services:
- /data - /data
app: app:
image: cupcakearmy/cryptgeon:latest image: cupcakearmy/cryptgeon:v3
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- redis - cache
networks: networks:
- default - default
- proxy - proxy
@@ -60,7 +58,7 @@ services:
volumes: volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro" - "/var/run/docker.sock:/var/run/docker.sock:ro"
redis: cache:
image: valkey/valkey:7-alpine image: valkey/valkey:7-alpine
# This is required to stay in RAM only. # This is required to stay in RAM only.
command: valkey-server --save "" --appendonly no command: valkey-server --save "" --appendonly no
@@ -72,9 +70,9 @@ services:
- /data - /data
cryptgeon: cryptgeon:
image: cupcakearmy/cryptgeon image: cupcakearmy/cryptgeon:v3
depends_on: depends_on:
- redis - cache
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.cryptgeon.rule=Host(`cryptgeon.localhost`)" - "traefik.http.routers.cryptgeon.rule=Host(`cryptgeon.localhost`)"
+1 -1
View File
@@ -252,7 +252,7 @@ dependencies = [
[[package]] [[package]]
name = "cryptgeon" name = "cryptgeon"
version = "3.0.0" version = "3.0.0-rc.3"
dependencies = [ dependencies = [
"axum", "axum",
"bs62", "bs62",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "cryptgeon" name = "cryptgeon"
version = "3.0.0" version = "3.0.0-rc.3"
authors = ["cupcakearmy <hi@nicco.io>"] authors = ["cupcakearmy <hi@nicco.io>"]
edition = "2024" edition = "2024"
rust-version = "1.95" rust-version = "1.95"
+11 -13
View File
@@ -1,6 +1,6 @@
{ {
"name": "cryptgeon", "name": "cryptgeon",
"version": "3.0.0", "version": "3.0.0-rc.3",
"homepage": "https://github.com/cupcakearmy/cryptgeon", "homepage": "https://github.com/cupcakearmy/cryptgeon",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -21,21 +21,19 @@
"scripts": { "scripts": {
"build": "vp pack", "build": "vp pack",
"dev": "vp pack --watch", "dev": "vp pack --watch",
"prepublishOnly": "run-s build" "prepublishOnly": "pnpm run build"
},
"dependencies": {
"@cryptgeon/shared": "workspace:*",
"@msgpack/msgpack": "^3.1.3",
"@commander-js/extra-typings": "^12.1.0",
"inquirer": "^9.3.8",
"mime": "^4.1.0",
"pretty-bytes": "^6.1.1"
}, },
"devDependencies": { "devDependencies": {
"@commander-js/extra-typings": "^15.0.0",
"@cryptgeon/shared": "workspace:*",
"@msgpack/msgpack": "^3.1.3",
"@tsconfig/strictest": "catalog:", "@tsconfig/strictest": "catalog:",
"@types/inquirer": "^9.0.9", "@types/inquirer": "^9.0.10",
"@types/node": "^22.15.3", "@types/node": "^22.20.1",
"commander": "^12.1.0", "commander": "^15.0.0",
"inquirer": "^14.2.1",
"mime": "^4.1.0",
"pretty-bytes": "^7.1.3",
"typescript": "catalog:", "typescript": "catalog:",
"vite-plus": "catalog:" "vite-plus": "catalog:"
}, },
+2 -3
View File
@@ -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':
+24 -29
View File
@@ -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 = 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,
}
})
)
}
+2 -4
View File
@@ -4,14 +4,12 @@
import { status } from '$lib/stores/status' import { status } from '$lib/stores/status'
// Due to encoding overhead (~35%) with base64 // Payload is raw bytes (msgpack + cipher), no base64 padding overhead.
// https://en.wikipedia.org/wiki/Base64
const overhead = 1 / 1.35
</script> </script>
<span> <span>
{#if $status !== null} {#if $status !== null}
{prettyBytes($status.max_size * overhead, { binary: true })} {prettyBytes($status.max_size, { binary: true })}
{:else} {:else}
{$_('common.loading')} {$_('common.loading')}
{/if} {/if}
+12 -25
View File
@@ -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 = 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':
+1
View File
@@ -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";
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { packContent, unpackContent } from "./payload";
import { bytesToUtf8, utf8ToBytes } from "./crypto";
describe("payload", () => {
it("round-trips a text note through the full pipeline", () => {
const { data, extra, key } = 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", () => {
const { data, extra, key } = packContent({ type: "text", text: "secret" }, "pw123");
expect(extra.length).toBeGreaterThan(0);
const content = unpackContent(data, key);
expect(content).toEqual({ type: "text", data: "secret" });
});
it("round-trips files (FileDTO)", () => {
const file = { name: "a.txt", mime: "text/plain", size: 5, data: utf8ToBytes("hello") };
const { data, key } = 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");
}
});
});
+49
View File
@@ -0,0 +1,49 @@
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: FileDTO[] };
export type PackResult = {
data: Uint8Array;
extra: Uint8Array;
key: Uint8Array;
};
export function packContent(input: NoteInput, password?: string): PackResult {
let key: Uint8Array;
let extra: Uint8Array;
if (password) {
const salt = randomBytes(16);
key = deriveKey(password, salt);
extra = encode({ salt, N: 32768, r: 8, p: 1 });
} else {
key = generateKey();
extra = new Uint8Array();
}
const content: NoteContent =
input.type === "text"
? { type: "text", data: input.text }
: { type: "files", data: input.files };
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;
}
+400 -390
View File
File diff suppressed because it is too large Load Diff