sipher/src/components/main/CreateIdentity.tsx
Nixyan 660c17b319 feat: add client-side identity system, rate limiting, proxy hardening, and full test suite
### Major changes

- **Client-side identity** — New session key store (`sessionKey.ts`) backed by
  `sessionStorage` with a module-level caching, a `crypto.subtle` cache, a `useIdentityLock`
  hook for decrypt-once signing, `followSignature.ts` for signed follows, and
  two new UI modals (`IdentityBackup.tsx`, `UnlockIdentityModal.tsx`).
  `CreateIdentity.tsx` is rewritten to generate BIP-39 mnemonics and encrypt the
  Ed25519 keypair with AES-256-GCM via PBKDF2 (600k iterations) before storing
  in IndexedDB.

- **Rate limiting** — New `rate-limit-config.ts` and `rate-limit.ts` provide a
  per-IP sliding-window rate limiter backed by Redis. All external-facing routes
  (`/discover`, `/discover/rotate/*`, `/proxy`, social API endpoints) now have
  conservative defaults wired into the custom HTTP server before requests reach
  Next.js handlers.

- **Proxy route hardening** — The `/proxy` route now enforces a 256 KB payload
  limit (HTTP 413), validates JSON before parsing, applies a per-origin rate
  limit (100 req/min), and imports the `blocks` table to reject requests from
  blocked servers.

- **Docker integration-test cluster** — New `Dockerfile`, `.dockerignore`, and
  `tests/docker-compose.yml` orchestrate three SiPher instances (A, B, C) plus
  shared PostgreSQL and Redis. Key generation (`generate-keys.ts`) and discovery
  setup (`setup-discovery.ts`) scripts automate cluster bootstrap. Three example
  env files document required per-instance configuration.

- **Full test suite overhaul** — Replaces the old attack/auth/discover/key/proxy
  tests with a structured suite:
  * `tests/federation/` — Keytools unit tests + key-rotation e2e test
  * `tests/proxy/` — Proxy relay e2e tests (single-server validation)
  * `tests/integration/` — Multi-instance integration tests for discover,
    proxy-chain relay, and federated post delivery via BullMQ
  * `tests/helpers/` — Reusable DB, identity, and auth-user utilities
  * Playwright config updated to match new file conventions
  * Unused helpers (`tests/helpers/queue.ts`) removed

- **Social plugin endpoints** — Rewritten `follows.ts`, `blocks.ts`, `mutes.ts`,
  and `posts.ts` with proper federation integration. `social.ts` gains helpers
  for looking up posts by federation URL.

### Minor changes

- **README** — Expanded from a 42-line stub to a full architecture guide with
  tables for every layer (auth, DB, queues, storage, real-time), API route
  documentation, setup instructions, environment variables, test coverage, and
  the updated roadmap.

- **Federation helpers** — `keytools.ts` refactors imports and cleans up the public surface.
  `fetch.ts`, `registry.ts`, and `proxy-helpers/federated-post.ts` pick up small
  improvements. `PostFederationSchema` simplifies its encryption type assertion.

- **Plugin infrastructure** — Oven plugin schema and server index gain minor
  refactors. Social client adds a `muteUser` method.

- **UI components** — `switch.tsx` and `tooltip.tsx` rewritten for Radix v2 /
  Tailwind 4; `accordion.tsx`, `dropdown-menu.tsx`, `form`, `button`, `card` get
  minor consistency fixes. `dialog.tsx` removes unused `DialogHeader`.

- **Server bootstrap** — `server.ts` imports DB schema before `instrumentation`
  for correct Drizzle initialization, rate-limiting routes are wired, and CORS
  allows federation origins. `auth.ts` regenerates Oven and social plugin schemas.

- **Dependencies** — Added `@noble/ciphers` and `@noble/hashes` (crypto
  primitives). Removed `@signalapp/libsignal-client`, `base58-js`, `nanostores`,
  `tweetnacl-util`, `dexie-react-hooks`, `socket.io-client`. Updated all Better
  Auth packages to 1.6.11, BullMQ to 5.76.10, and various dev deps across the
  board.

- **.gitignore** — Added `/audits` and `tests/docker/*.env` to prevent secret
  leakage.

- **DB schema** — `blocks` table imported in `src/lib/db/schema/index.ts`.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 09:48:42 -03:00

217 lines
No EOL
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { authClient } from "@/lib/auth-client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Eye, EyeOff, KeyRound, Loader2, TriangleAlert } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "../ui/accordion";
import { Button } from "../ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "../ui/form";
import { Input } from "../ui/input";
import IdentityBackup from "./IdentityBackup";
const createIdentityFormSchema = z.object({
password: z
.string()
.min(8, "Password must be at least 8 characters")
.max(64, "Password cannot exceed 64 characters")
.refine((val) => /[A-Z]/.test(val), {
message: "Must contain at least one uppercase letter",
})
.refine((val) => /[a-z]/.test(val), {
message: "Must contain at least one lowercase letter",
})
.refine((val) => /[0-9]/.test(val), {
message: "Must contain at least one number",
})
.refine((val) => /[^a-zA-Z0-9]/.test(val), {
message: "Must contain at least one special character",
})
});
const requirements = [
{ label: "864 characters", test: (v: string) => v.length >= 8 && v.length <= 64 },
{ label: "Uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
{ label: "Lowercase letter", test: (v: string) => /[a-z]/.test(v) },
{ label: "Number", test: (v: string) => /[0-9]/.test(v) },
{ label: "Special character", test: (v: string) => /[^a-zA-Z0-9]/.test(v) },
];
export default function CreateIdentity() {
const [isOpen, setIsOpen] = useState(true);
const [showPassword, setShowPassword] = useState(false);
const [mnemonic, setMnemonic] = useState<string | null>(null);
const { data: session } = authClient.useSession();
const router = useRouter();
const form = useForm<z.infer<typeof createIdentityFormSchema>>({
resolver: zodResolver(createIdentityFormSchema),
defaultValues: {
password: "",
},
});
const password = form.watch("password");
const passwordRequirementsMet = requirements.every((req) => req.test(password));
async function onSubmit(values: z.infer<typeof createIdentityFormSchema>) {
const userId = session?.user.id;
if (!userId) return;
try {
const result = await authClient.createOvenIdentity(userId, values.password);
setMnemonic(result.mnemonic);
} catch (err) {
console.error("[CreateIdentity]", err);
toast.error("Failed to create identity. Please try again.");
}
}
function handleBackupConfirmed() {
setIsOpen(false);
router.refresh();
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent onInteractOutside={(e) => e.preventDefault()} className="sm:max-w-md border-border bg-card p-0 overflow-hidden [&>button]:hidden">
{mnemonic ? (
<IdentityBackup mnemonic={mnemonic} onConfirmed={handleBackupConfirmed} />
) : (
<>
<div className="px-6 pt-6 pb-2 border-b border-border/60">
<div className="flex items-center gap-3 mb-3">
<div className="flex items-center justify-center w-8 h-8 rounded bg-primary/10 border border-primary/20">
<KeyRound className="w-4 h-4 text-primary" />
</div>
<span className="font-mono text-[10px] text-muted-foreground tracking-[0.25em] uppercase">
Identity Setup
</span>
</div>
<DialogHeader className="text-left space-y-1">
<DialogTitle className="font-display text-3xl tracking-[0.06em] text-foreground">
Create your Sipher identity
</DialogTitle>
<p className="text-sm text-muted-foreground leading-relaxed">
This password encrypts your local identity key. It never leaves your device. <span className="font-bold">DO NOT FORGET THIS PASSWORD.</span>
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
You may use the same password for your Sipher account and your identity, although it is not recommended.
</p>
</DialogHeader>
</div>
<div className="px-6 py-5">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="space-y-1.5">
<FormLabel className="font-mono text-[11px] tracking-[0.15em] uppercase text-muted-foreground">
Master Password
</FormLabel>
<FormControl>
<div className="relative">
<Input
{...field}
type={showPassword ? "text" : "password"}
className="h-11 text-base bg-background border-border/60 pr-10 focus-visible:ring-primary/50 focus-visible:border-primary/50"
placeholder="••••••••••••"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
tabIndex={-1}
>
{showPassword
? <EyeOff className="w-4 h-4" />
: <Eye className="w-4 h-4" />
}
</button>
</div>
</FormControl>
<FormMessage className="font-mono text-[10px] tracking-wide" />
</FormItem>
)}
/>
{password.length > 0 && (
<div className="space-y-1.5">
<span className="font-mono text-[10px] tracking-[0.15em] uppercase text-muted-foreground">
Requirements
</span>
<ul className="grid grid-cols-2 gap-x-4 gap-y-1">
{requirements.map((req) => {
const met = req.test(password);
return (
<li
key={req.label}
className={`font-mono text-[10px] tracking-wide flex items-center gap-1.5 transition-colors ${met ? "text-primary" : "text-muted-foreground/60"}`}
>
<span className={`inline-block w-1 h-1 rounded-full shrink-0 ${met ? "bg-primary" : "bg-border"}`} />
{req.label}
</li>
);
})}
</ul>
</div>
)}
<Accordion type="single" collapsible className="border border-destructive/30 rounded bg-destructive/5">
<AccordionItem value="lost-password" className="border-none">
<AccordionTrigger className="px-3 py-2.5 hover:no-underline hover:bg-destructive/10 rounded transition-colors">
<span className="flex items-center gap-2 font-mono text-[10px] tracking-[0.15em] uppercase text-destructive/80">
<TriangleAlert className="w-3.5 h-3.5 shrink-0" />
What if I lose my password?
</span>
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 pt-0">
<ul className="space-y-1.5 font-mono text-[10px] tracking-wide text-muted-foreground leading-relaxed">
<li className="flex gap-2">
<span className="text-destructive/60 shrink-0"></span>
Your identity key is encrypted locally with this password. There is no recovery mechanism.
</li>
<li className="flex gap-2">
<span className="text-destructive/60 shrink-0"></span>
Losing it means permanent loss of access to your encrypted messages and posts.
</li>
<li className="flex gap-2">
<span className="text-destructive/60 shrink-0"></span>
Store it somewhere safe a password manager or written offline.
</li>
<li className="flex gap-2">
<span className="text-destructive/60 shrink-0"></span>
Losing your identity means that all your messages are permanently lost and your old posts won't hold a valid signature.
</li>
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
<Button
type="submit"
className="w-full h-11 font-mono text-[11px] tracking-[0.2em] uppercase"
disabled={form.formState.isSubmitting || !passwordRequirementsMet}
>
{form.formState.isSubmitting
? <Loader2 className="w-4 h-4 animate-spin" />
: "Generate Identity"
}
</Button>
</form>
</Form>
</div>
</>
)}
</DialogContent>
</Dialog>
)
}