diff --git a/.claude/skills/enclosed/SKILL.md b/.claude/skills/enclosed/SKILL.md new file mode 100644 index 00000000..d28c12c8 --- /dev/null +++ b/.claude/skills/enclosed/SKILL.md @@ -0,0 +1,97 @@ +--- +name: enclosed +description: Create and read end-to-end encrypted notes via the Enclosed CLI (enclosed.cc or a self-hosted instance). Use when the user asks to share a secret, send an expiring or one-time-read note, attach files to a private note, or open/decrypt an Enclosed note URL. +--- + +# Enclosed + +Enclosed is an E2E-encrypted note-sharing service. Notes are encrypted +client-side (AES-GCM, PBKDF2) before leaving the machine — the server never +sees plaintext. **Always prefer the CLI over raw HTTP**: calling the API +directly returns ciphertext and skips key derivation. + +## Setup check + +Run once at the start of a task that needs Enclosed: + +```bash +enclosed --help || npm i -g @enclosed/cli +``` + +If global install is not desired, every command below can be prefixed with +`npx -y @enclosed/cli` (or `pnpm dlx @enclosed/cli`). Requires Node ≥ 22. + +Default instance is `https://enclosed.cc`. To target a self-hosted instance: + +```bash +enclosed config set instance-url https://enclosed.example.com +``` + +Other config subcommands: `config get `, `config delete `, +`config reset`. Only key currently supported: `instance-url`. + +## Create a note — `enclosed create` + +| Flag | Alias | Type | Default | Notes | +| --- | --- | --- | --- | --- | +| `` | — | positional | — | Omit to use `--stdin` or attach-only | +| `--password` | `-p` | string | none | Optional passphrase on top of the URL key | +| `--ttl` | `-t` | seconds | `3600` (1h) | Lifetime before server deletes the blob | +| `--deleteAfterReading` | `-d` | bool | `false` | Self-destruct on first read | +| `--file` | `-f` | path | — | Repeatable, attaches files | +| `--stdin` | `-s` | bool | `false` | Read content from stdin | + +Examples: + +```bash +enclosed create "hello" +echo "secret" | enclosed create --stdin +enclosed create -d -t 600 -p "hunter2" "one-time secret" +enclosed create -f ./key.pem -f ./cert.pem "certs attached" +``` + +On success prints `Note url: `. Parse with: + +```bash +url=$(enclosed create --stdin <<<"$secret" | awk '/^Note url:/ {print $NF}') +``` + +The URL fragment contains the encryption key — **treat the whole URL as a +secret**. Never paste it in PR descriptions, issue comments, logs, or +anywhere it could be cached or indexed. + +## View a note — `enclosed view ` + +| Flag | Alias | Type | Notes | +| --- | --- | --- | --- | +| `` | — | positional | Required | +| `--password` | `-p` | string | Prompted interactively if needed and omitted | + +Prints decrypted content to stdout. Exits with a red error for 404 +(expired/consumed/unknown) or 429 (rate-limited). For non-interactive use +on password-protected notes, pass `-p` — but see the warning below. + +## Security rules when invoking this skill + +1. **Never log or echo** the created note URL, the `--password` value, or + the decrypted content back to the user unless they explicitly asked for + it in this turn. Show only what's needed. +2. **Avoid `--password` on the command line when possible** — it lands in + shell history and `/proc//cmdline`. Prefer reading the password + from an env var the user has already set, e.g. + `enclosed create -p "$ENCLOSED_PW" ...`, or omit it and let `view` + prompt. +3. **Do not commit** note URLs, passwords, or decrypted content to git. +4. **Do not send** note URLs to third-party tools (pastebins, diagram + renderers, remote LLM APIs outside this session). +5. If the user wants to share the URL somewhere, hand it to them directly + — don't post it on their behalf. + +## When not to use this skill + +- User wants plaintext-visible or signed messages (Enclosed is opaque to + the server by design; you cannot search, list, or index notes). +- User needs server-side features (webhooks, audit log, multi-recipient). + Enclosed doesn't expose these. +- The task is unrelated to sharing a secret or reading an `enclosed.cc` / + self-hosted Enclosed URL. diff --git a/packages/app-client/uno.config.ts b/packages/app-client/uno.config.ts index 63577e42..88b4aae0 100644 --- a/packages/app-client/uno.config.ts +++ b/packages/app-client/uno.config.ts @@ -7,7 +7,7 @@ import { transformerDirectives, transformerVariantGroup, } from 'unocss'; -import presetAnimations from 'unocss-preset-animations'; +import { presetAnimations } from 'unocss-preset-animations'; import { iconByFileType } from './src/modules/files/files.models'; export default defineConfig({ diff --git a/packages/app-server/src/modules/notes/notes.routes.ts b/packages/app-server/src/modules/notes/notes.routes.ts index 9eb51d3c..7edf4576 100644 --- a/packages/app-server/src/modules/notes/notes.routes.ts +++ b/packages/app-server/src/modules/notes/notes.routes.ts @@ -105,9 +105,9 @@ function setupCreateNoteRoute({ app }: { app: ServerInstance }) { }), ), - async (context, next) => { + async (context) => { const config = context.get('config'); - const { payload, isPublic, ttlInSeconds } = context.req.valid('json'); + const { payload, ttlInSeconds, deleteAfterReading, encryptionAlgorithm, serializationFormat, isPublic } = context.req.valid('json'); if (payload.length > config.notes.maxEncryptedPayloadLength) { throw createNotePayloadTooLargeError(); @@ -121,13 +121,7 @@ function setupCreateNoteRoute({ app }: { app: ServerInstance }) { throw createExpirationDelayRequiredError(); } - await next(); - }, - - async (context) => { - const { payload, ttlInSeconds, deleteAfterReading, encryptionAlgorithm, serializationFormat, isPublic } = context.req.valid('json'); const storage = context.get('storage'); - const notesRepository = createNoteRepository({ storage }); const { noteId } = await notesRepository.saveNote({ payload, ttlInSeconds, deleteAfterReading, encryptionAlgorithm, serializationFormat, isPublic }); diff --git a/packages/crypto/src/web/crypto.web.usecases.ts b/packages/crypto/src/web/crypto.web.usecases.ts index 13c4fb6e..c922722c 100644 --- a/packages/crypto/src/web/crypto.web.usecases.ts +++ b/packages/crypto/src/web/crypto.web.usecases.ts @@ -17,7 +17,7 @@ function bufferToBase64Url({ buffer }: { buffer: Uint8Array }): string { return base64Url; } -function base64UrlToBuffer({ base64Url }: { base64Url: string }): Uint8Array { +function base64UrlToBuffer({ base64Url }: { base64Url: string }): Uint8Array { const base64 = base64Url .padEnd(base64Url.length + (4 - base64Url.length % 4) % 4, '=') .replace(/-/g, '+') @@ -28,7 +28,7 @@ function base64UrlToBuffer({ base64Url }: { base64Url: string }): Uint8Array { return buffer; } -function createRandomBuffer({ length = 16 }: { length?: number } = {}): Uint8Array { +function createRandomBuffer({ length = 16 }: { length?: number } = {}): Uint8Array { const randomValues = new Uint8Array(length); crypto.getRandomValues(randomValues); @@ -48,7 +48,7 @@ async function deriveMasterKey({ baseKey, password = '' }: { baseKey: Uint8Array const derivedKey = await crypto.subtle.deriveKey( { name: 'PBKDF2', - salt: baseKey, + salt: baseKey as Uint8Array, iterations: 100_000, hash: 'SHA-256', }, diff --git a/packages/crypto/src/web/encryption-algorithms/crypto.web.aes-256-gcm.ts b/packages/crypto/src/web/encryption-algorithms/crypto.web.aes-256-gcm.ts index 744b0ad0..66e3223a 100644 --- a/packages/crypto/src/web/encryption-algorithms/crypto.web.aes-256-gcm.ts +++ b/packages/crypto/src/web/encryption-algorithms/crypto.web.aes-256-gcm.ts @@ -6,8 +6,8 @@ export const aes256GcmEncryptionAlgorithmDefinition = defineEncryptionMethods({ encryptBuffer: async ({ buffer, encryptionKey }) => { const iv = createRandomBuffer({ length: 12 }); - const key = await crypto.subtle.importKey('raw', encryptionKey, 'AES-GCM', false, ['encrypt']); - const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, buffer); + const key = await crypto.subtle.importKey('raw', encryptionKey as Uint8Array, 'AES-GCM', false, ['encrypt']); + const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, buffer as Uint8Array); const encryptedBuffer = new Uint8Array(encrypted); const ivString = bufferToBase64Url({ buffer: iv }); @@ -29,7 +29,7 @@ export const aes256GcmEncryptionAlgorithmDefinition = defineEncryptionMethods({ const iv = base64UrlToBuffer({ base64Url: ivString }); const encrypted = base64UrlToBuffer({ base64Url: encryptedContentString }); - const key = await crypto.subtle.importKey('raw', encryptionKey, 'AES-GCM', false, ['decrypt']); + const key = await crypto.subtle.importKey('raw', encryptionKey as Uint8Array, 'AES-GCM', false, ['decrypt']); const decryptedCryptoBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encrypted); const decryptedBuffer = new Uint8Array(decryptedCryptoBuffer); diff --git a/packages/lib/src/files/files.models.ts b/packages/lib/src/files/files.models.ts index 1c61045a..3c7290c8 100644 --- a/packages/lib/src/files/files.models.ts +++ b/packages/lib/src/files/files.models.ts @@ -28,7 +28,7 @@ async function noteAssetToFile({ noteAsset }: { noteAsset: NoteAsset }): Promise const fileName = get(noteAsset, 'metadata.name', 'file') as string; const fileType = get(noteAsset, 'metadata.fileType', 'application/octet-stream') as string; - return new File([noteAsset.content], fileName, { type: fileType }); + return new File([noteAsset.content as Uint8Array], fileName, { type: fileType }); } async function noteAssetsToFiles({ noteAssets }: { noteAssets: NoteAsset[] }): Promise { diff --git a/packages/lib/tsconfig.json b/packages/lib/tsconfig.json index 3fd43123..b88c8b0c 100644 --- a/packages/lib/tsconfig.json +++ b/packages/lib/tsconfig.json @@ -4,7 +4,8 @@ "module": "ESNext", "moduleResolution": "Node", "resolveJsonModule": true, - "esModuleInterop": true + "esModuleInterop": true, + "skipLibCheck": true }, "include": [ "src"