diff --git a/packages/utils/compression.ts b/packages/utils/compression.ts index 25d957a..24c3d04 100644 --- a/packages/utils/compression.ts +++ b/packages/utils/compression.ts @@ -18,6 +18,10 @@ export const processRawContent = (raw: string) => { if (raw.startsWith("br:")) { version = "br"; decompressed = decompressBrotli(raw.slice(3)); + } else if (raw.startsWith("enc:v3:br:")) { + version = "enc:v3:br"; + } else if (raw.startsWith("enc:v2:br:")) { + version = "enc:v2:br"; } else if (raw.startsWith("enc:br:")) { version = "enc:br"; } diff --git a/packages/web/public/DOCS.md b/packages/web/public/DOCS.md index b252261..9fbd79e 100644 --- a/packages/web/public/DOCS.md +++ b/packages/web/public/DOCS.md @@ -427,3 +427,77 @@ Get user data for an address. | `address` | `address` | User wallet address | **Response:** `{ user: User }` + +--- + +## Content Encoding + +Entry content stored onchain goes through a multi-step encoding pipeline before being passed to the API. The `content` / `chunkContent` fields in create and update requests contain the final encoded string — not raw markdown. + +### Pipeline + +``` +Markdown → Compress (Brotli) → Encrypt (optional) → Prefix → Store +``` + +1. **Write** — Author composes content in markdown +2. **Compress** — Content is Brotli-compressed (quality 11) and Base64-encoded +3. **Encrypt** (private entries only) — Compressed content is encrypted with AES-GCM and Base64-encoded +4. **Prefix** — A version prefix is prepended to indicate the encoding format +5. **Store** — The prefixed string is signed (EIP-712) and stored onchain as the entry content + +### Format Prefixes + +The version prefix at the start of the stored content string indicates how to decode it: + +| Prefix | Encryption | Compression | Description | +|--------|------------|-------------|-------------| +| `br:` | None | Brotli | Public entry, compressed only | +| `enc:v3:br:` | AES-GCM (v3 key) | Brotli | Private entry, current format | +| `enc:v2:br:` | AES-GCM (v2 key) | Brotli | Deprecated | +| `enc:br:` | AES-GCM (v1 key) | Brotli | Deprecated | + +**Examples:** +- Public: `br:GxoAAI2pVgqN...` (Brotli-compressed, Base64-encoded markdown) +- Private: `enc:v3:br:A7f3kQ9x...` (encrypted + compressed) + +### Compression + +All content is compressed with [Brotli](https://github.com/nicolo-ribaudo/brotli-wasm) at quality level 11 (maximum), then Base64-encoded. This reduces onchain storage costs. + +``` +markdown → UTF-8 encode → brotli compress → base64 encode +``` + +### Encryption + +Private entries are encrypted **after** compression using [AES-GCM](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/encrypt#aes-gcm) (128-bit key, 12-byte random IV). + +``` +compressed content → AES-GCM encrypt → prepend IV → Base64 encode +``` + +The encryption key is deterministically derived from a wallet signature: + +1. User signs a fixed message with `personal_sign` +2. Signature is hashed with Keccak-256 +3. First 16 bytes of the hash become the AES key + +The key never leaves the client. Only the entry author can decrypt their private entries — the server and contract store opaque ciphertext. + +**V1, V2, and V3 keys** differ only in the message signed during key derivation. V3 is the current default and includes a security warning to only sign on writer.place. V1 and V2 are supported for backward compatibility with older entries. A migration tool is available in the app to re-encrypt legacy entries with the V3 key. + +**Important:** Because the encryption key is derived from signing a specific message, anyone who tricks you into signing that same message on a different site could derive the same key and decrypt your private entries. The V3 message explicitly states to only sign on `https://writer.place`. Always verify the requesting site before signing. + +### Decoding + +To read an entry, reverse the pipeline based on the prefix: + +| Prefix | Steps | +|--------|-------| +| `br:` | Strip prefix → Base64 decode → Brotli decompress | +| `enc:v3:br:` | Strip prefix → Base64 decode → AES-GCM decrypt (v3 key) → Brotli decompress | +| `enc:v2:br:` | Strip prefix → Base64 decode → AES-GCM decrypt (v2 key) → Brotli decompress | +| `enc:br:` | Strip prefix → Base64 decode → AES-GCM decrypt (v1 key) → Brotli decompress | + +Public entries are decoded server-side and returned as plaintext in the `decompressed` field. Private entries are returned as the raw encoded string and decrypted client-side using the author's wallet. diff --git a/packages/web/src/app/docs/page.tsx b/packages/web/src/app/docs/page.tsx index a264243..9322430 100644 --- a/packages/web/src/app/docs/page.tsx +++ b/packages/web/src/app/docs/page.tsx @@ -1,6 +1,9 @@ "use client"; +import { Check } from "@/components/icons/Check"; +import { Copy } from "@/components/icons/Copy"; import Image from "next/image"; +import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { MAX_CONTENT_LENGTH, MAX_TITLE_LENGTH } from "utils/constants"; @@ -33,38 +36,9 @@ function CopyButton({ value }: { value: string }) { className="text-neutral-500 hover:text-primary transition-colors align-middle" > {copied ? ( - + ) : ( - + )} ); @@ -114,7 +88,7 @@ function AnchorHeading({ function RelicDivider({ seed }: { seed: string }) { const logo = getLogoForSection(seed); return ( -
+
- +
+ {title} - {children} + {description &&

{description}

} +
{children}
); } @@ -169,39 +145,43 @@ function Endpoint({ return (
-
copyAnchor(id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - copyAnchor(id); - } - }} - role="button" - tabIndex={0} - aria-label="Copy anchor" - > - {method} - {path} - - § - +
+
copyAnchor(id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + copyAnchor(id); + } + }} + role="button" + tabIndex={0} + aria-label="Copy anchor" + > + + {method} + + {path} + + § + +
+

{description}

+ {auth &&

Auth: {auth}

}
-

{description}

- {auth &&

Auth: {auth}

} {params && params.length > 0 && ( -
+

Parameters

-
+
{params.map((p) => ( -
+
{p.name} : {p.type} @@ -214,13 +194,13 @@ function Endpoint({
)} {body && body.length > 0 && ( -
+

Request Body

-
+
{body.map((b) => (
{b.name} @@ -236,9 +216,7 @@ function Endpoint({ )} {response && (
-

+

Response

{response} @@ -267,21 +245,23 @@ function ContractFunction({ return (
- - {name} - -

{description}

- {access &&

Access: {access}

} +
+ + {name} + +

{description}

+ {access &&

Access: {access}

} +
{params && params.length > 0 && ( -
+

Parameters

-
+
{params.map((p) => (
{p.name} @@ -296,9 +276,9 @@ function ContractFunction({
)} {returns && ( -
+

Returns

@@ -308,11 +288,11 @@ function ContractFunction({ {events && events.length > 0 && (

Events

-
+
{events.map((e) => ( {e} @@ -339,6 +319,11 @@ const tocItems = [ { id: "entries", label: "Entries", depth: 1 }, { id: "color", label: "Color", depth: 1 }, { id: "user", label: "User", depth: 1 }, + { id: "content-encoding", label: "Content Encoding", depth: 0 }, + { id: "format-prefixes", label: "Format Prefixes", depth: 1 }, + { id: "compression", label: "Compression", depth: 1 }, + { id: "encryption", label: "Encryption", depth: 1 }, + { id: "decoding", label: "Decoding", depth: 1 }, ]; function TableOfContents() { @@ -417,7 +402,7 @@ export default function DocsPage() { return (
-

+

Writer is an onchain writing platform. Content is permanently stored on Optimism through smart contracts.

@@ -426,47 +411,45 @@ export default function DocsPage() {
Smart Contracts -
+ -
-

- Factory contract that deploys Writer + WriterStorage pairs using - CREATE2 for deterministic addresses. -

- +
-
-

- Main logic contract for managing entries with role-based access - control. -

- +
Reading @@ -600,7 +581,7 @@ export default function DocsPage() { Writing @@ -795,7 +776,7 @@ export default function DocsPage() { Administration @@ -844,12 +825,10 @@ export default function DocsPage() { />
-
-

- Storage contract that holds all entry data. Only the Writer logic - contract can modify state, enforced by the onlyLogic modifier. -

- +
-
-

- Simple registry mapping user addresses to their chosen hex color. -

- +
{/* API */} -
+
API @@ -954,7 +932,7 @@ export default function DocsPage() {

Base URL: -
+

https://api.writer.place @@ -1192,6 +1170,170 @@ export default function DocsPage() { />

+ + {/* CONTENT ENCODING */} +
+ + Content Encoding + +

+ Entry content goes through a multi-step encoding pipeline before + being stored onchain. The content{" "} + & chunkContent fields in API + requests contain the final encoded string, not raw markdown. +

+ +
+

+ markdown → compress → encrypt (optional) → prefix + → store +

+
+ +
+
+
+ br: +

+ Public entry. Brotli compressed, Base64 encoded. No + encryption. +

+

+ br:GxoAAI2pVgqN... +

+
+ +
+ + enc:v3:br: + +

+ Private entry, current format. AES-GCM encrypted with v3 key, + Brotli compressed. +

+

+ enc:v3:br:A7f3kQ9x... +

+
+ +
+ enc:v2:br: +

Deprecated

+
+ +
+ enc:br: +

Deprecated

+
+
+
+ +
+
+

+ markdown → UTF-8 encode → brotli compress (quality 11) + → base64 encode +

+
+
+ +
+
+

+ compressed content → AES-GCM encrypt → prepend IV + → base64 encode +

+
+ +

+ The encryption key is deterministically derived from a wallet + signature: +

+ +
    +
  1. + User signs a fixed message with{" "} + personal_sign +
  2. +
  3. Signature is hashed with Keccak-256
  4. +
  5. First 16 bytes of the hash become the AES key
  6. +
+ +

+ The key never leaves the client. Only the entry author can decrypt + their private entries — the server and contract store opaque + ciphertext. +

+ +

+ V1, V2, and V3 keys differ only in the message signed during key + derivation. V3 is the current default and includes a security + warning to only sign on writer.place. V1 and V2 are supported for + backward compatibility with older entries. A migration tool is + available in the app to re-encrypt legacy entries with the V3 key. +

+
+ +
+
+
+ br: +

+ strip prefix → base64 decode → brotli decompress +

+
+ +
+ + enc:v3:br: + +

+ strip prefix → base64 decode → AES-GCM decrypt (v3 + key) → brotli decompress +

+
+ +
+ enc:v2:br: +

Deprecated

+
+ +
+ enc:br: +

Deprecated

+
+
+ +

+ Public entries are decoded server-side and returned as plaintext + in the{" "} + decompressed{" "} + field. Private entries are returned as the raw encoded string and + decrypted client-side using the author's wallet. +

+
+
diff --git a/packages/web/src/app/fund/page.tsx b/packages/web/src/app/fund/page.tsx index 4075fde..96b87c6 100644 --- a/packages/web/src/app/fund/page.tsx +++ b/packages/web/src/app/fund/page.tsx @@ -1,5 +1,7 @@ "use client"; +import { Check } from "@/components/icons/Check"; +import { Copy } from "@/components/icons/Copy"; import { type RelayWallet, getRelayWallets } from "@/utils/api"; import { useQuery } from "@tanstack/react-query"; import Image from "next/image"; @@ -40,39 +42,7 @@ function WalletRow({ wallet }: { wallet: RelayWallet }) { aria-label="Copy address" className="text-neutral-500 hover:text-primary cursor-pointer" > - {copied ? ( - - ) : ( - - )} + {copied ? : }
{displayBalance} ETH diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 691a741..734619f 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -49,6 +49,10 @@ color: var(--color-white); background-color: var(--color-background); } + + .font-mono { + font-size: 15px; + } } ::-webkit-scrollbar { diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 3c7ae59..5db8321 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -69,20 +69,20 @@ export default async function RootLayout({ className="min-h-dvh" suppressHydrationWarning > - +