Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .claude/skills/enclosed/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 <key>`, `config delete <key>`,
`config reset`. Only key currently supported: `instance-url`.

## Create a note — `enclosed create`

| Flag | Alias | Type | Default | Notes |
| --- | --- | --- | --- | --- |
| `<content>` | — | 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: <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 <url>`

| Flag | Alias | Type | Notes |
| --- | --- | --- | --- |
| `<noteUrl>` | — | 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/<pid>/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.
2 changes: 1 addition & 1 deletion packages/app-client/uno.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
10 changes: 2 additions & 8 deletions packages/app-server/src/modules/notes/notes.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 });
Expand Down
6 changes: 3 additions & 3 deletions packages/crypto/src/web/crypto.web.usecases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ function bufferToBase64Url({ buffer }: { buffer: Uint8Array }): string {
return base64Url;
}

function base64UrlToBuffer({ base64Url }: { base64Url: string }): Uint8Array {
function base64UrlToBuffer({ base64Url }: { base64Url: string }): Uint8Array<ArrayBuffer> {
const base64 = base64Url
.padEnd(base64Url.length + (4 - base64Url.length % 4) % 4, '=')
.replace(/-/g, '+')
Expand All @@ -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<ArrayBuffer> {
const randomValues = new Uint8Array(length);
crypto.getRandomValues(randomValues);

Expand All @@ -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<ArrayBuffer>,
iterations: 100_000,
hash: 'SHA-256',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArrayBuffer>, 'AES-GCM', false, ['encrypt']);
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, buffer as Uint8Array<ArrayBuffer>);
const encryptedBuffer = new Uint8Array(encrypted);

const ivString = bufferToBase64Url({ buffer: iv });
Expand All @@ -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<ArrayBuffer>, 'AES-GCM', false, ['decrypt']);
const decryptedCryptoBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encrypted);
const decryptedBuffer = new Uint8Array(decryptedCryptoBuffer);

Expand Down
2 changes: 1 addition & 1 deletion packages/lib/src/files/files.models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArrayBuffer>], fileName, { type: fileType });
}

async function noteAssetsToFiles({ noteAssets }: { noteAssets: NoteAsset[] }): Promise<File[]> {
Expand Down
3 changes: 2 additions & 1 deletion packages/lib/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"esModuleInterop": true
"esModuleInterop": true,
"skipLibCheck": true
},
"include": [
"src"
Expand Down
Loading