diff --git a/deno.json b/deno.json index b434e4d..bd199f0 100644 --- a/deno.json +++ b/deno.json @@ -39,7 +39,8 @@ "react-router-dom": "npm:react-router-dom@^7.5.1", "vite": "npm:vite@^6.3.2", "zod": "npm:zod@3", - "zustand": "npm:zustand@^5.0.5" + "zustand": "npm:zustand@^5.0.5", + "zustand-slices": "npm:zustand-slices@^0.4.0" }, "nodeModulesDir": "auto", diff --git a/deno.lock b/deno.lock index 27f68ff..057d788 100644 --- a/deno.lock +++ b/deno.lock @@ -29,8 +29,10 @@ "npm:react-dom@^19.1.0": "19.1.0_react@19.1.0", "npm:react-router-dom@^7.5.1": "7.6.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:react@^19.1.0": "19.1.0", + "npm:vite@*": "6.3.5_picomatch@4.0.2_@types+node@22.15.15", "npm:vite@^6.3.2": "6.3.5_picomatch@4.0.2_@types+node@22.15.15", "npm:zod@3": "3.25.53", + "npm:zustand-slices@0.4": "0.4.0_react@19.1.0_zustand@5.0.5__@types+react@19.1.6__react@19.1.0_@types+react@19.1.6", "npm:zustand@^5.0.5": "5.0.5_@types+react@19.1.6_react@19.1.0" }, "jsr": { @@ -1642,6 +1644,13 @@ "zod@3.25.53": { "integrity": "sha512-BKOKoY3XcGUVkqaalCtFK15LhwR0G0i65AClFpWSXLN2gJNBGlTktukHgwexCTa/dAacPPp9ReryXPWyeZF4LQ==" }, + "zustand-slices@0.4.0_react@19.1.0_zustand@5.0.5__@types+react@19.1.6__react@19.1.0_@types+react@19.1.6": { + "integrity": "sha512-Hs+JJc6wIFWwzefe/RLeGuWQgE3ZHQubLRaWyeJ3fFIKrM4gl2VeRZEunbIA33R5ashZyRbZ36ZrPybEUL+yJA==", + "dependencies": [ + "react", + "zustand" + ] + }, "zustand@5.0.5_@types+react@19.1.6_react@19.1.0": { "integrity": "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==", "dependencies": [ @@ -1670,6 +1679,7 @@ "npm:react@^19.1.0", "npm:vite@^6.3.2", "npm:zod@3", + "npm:zustand-slices@0.4", "npm:zustand@^5.0.5" ] } diff --git a/src/client/src/App.tsx b/src/client/src/App.tsx index 7d23113..7d212da 100644 --- a/src/client/src/App.tsx +++ b/src/client/src/App.tsx @@ -1,25 +1,41 @@ import "./App.css"; -import { useState } from "react"; +import { useSkypodStore } from "@repo/client/store.ts"; +import { startTransition, useCallback } from "react"; + +import { generateIdentity } from "../store-ident.ts"; function App() { - const [count, setCount] = useState(0); + const ident = useSkypodStore((s) => s.identity); + const setIdentity = useSkypodStore((s) => s.setIdentity); + + const signin = useCallback(() => { + startTransition(async () => { + setIdentity(await generateIdentity()); + }); + }, [setIdentity]); - return ( - <> -
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- - ); + return ident.initialized + ? ident.anonymous + ? ( + <> +

anonymous!

+ + + ) + : ( + <> +

+ {ident.authenticated.identid} +

+ + + ) + : ( + <> +

Initializing...

+ + ); } export default App; diff --git a/src/client/storage-install-bound.ts b/src/client/storage-install-bound.ts new file mode 100644 index 0000000..cb63afd --- /dev/null +++ b/src/client/storage-install-bound.ts @@ -0,0 +1,60 @@ +import { Cipher, deriveCipher } from "@repo/common/crypto-enc.ts"; +import { nanoid } from "@sitnik/nanoid"; +import type { StateStorage } from "zustand/middleware"; + +function isEncryptionEnabled(enabled?: boolean) { + return enabled ?? !import.meta.env.DEV; +} + +function isEncryptedValue(value?: unknown): value is string { + return value != null && typeof value === "string" && value.startsWith("enc:"); +} + +export function makeInstallBoundStorage( + name: string, + encrypt: boolean | undefined, + baseStorage: StateStorage, +): StateStorage { + if (!isEncryptionEnabled(encrypt)) return baseStorage; + + async function ensureNonce() { + const key = `nonce:${name}`; + let nonce = await baseStorage.getItem(key); + if (!nonce) { + nonce = nanoid(32); + await baseStorage.setItem(key, nonce); + } + + return nonce; + } + + let crypto: Cipher | undefined; + async function ensureCrypto() { + return (crypto ??= deriveCipher( + name, + location.origin, + await ensureNonce(), + )); + } + + return { + async getItem(name: string) { + const value = await Promise.resolve(baseStorage.getItem(name)); + if (!isEncryptedValue(value)) { + return null; + } + + const crypto = await ensureCrypto(); + return await crypto.decrypt(value.slice(4)); + }, + + async setItem(name: string, value: string) { + const crypto = await ensureCrypto(); + baseStorage.setItem(name, `enc:${await crypto.encrypt(value)}`); + }, + + removeItem(name: string) { + baseStorage.removeItem(name); + }, + }; +} diff --git a/src/client/storage-serializers.ts b/src/client/storage-serializers.ts new file mode 100644 index 0000000..20e0afd --- /dev/null +++ b/src/client/storage-serializers.ts @@ -0,0 +1,85 @@ +// deno-lint-ignore-file no-explicit-any + +import type { PersistStorage, StateStorage } from "zustand/middleware"; + +type MaybeAsync = U | Promise; + +export type SerializerConfig = { + serializer: (value: TValue) => any | Promise; + deserializer: (value: unknown) => TValue | Promise; + initializer?: () => MaybeAsync; +}; + +export type SerializerMap = { + [K in keyof T]?: SerializerConfig; +}; + +export function makeSlicedSerializerStorage( + storage: StateStorage, + serializers: SerializerMap, +): PersistStorage { + // serialize by looping through the state and using registered serializers per key + async function serializeState(state: T) { + const output = {} as Record; + for (const key in state) { + let value = await state[key]; + if (serializers[key]) { + value = await serializers[key].serializer(value); + } + + output[key] = value; + } + + return output; + } + + // deserialize by looping through the state and using registered deserializers per key + async function deserializeState(state: any) { + const output = {} as T; + for (const key_ in state) { + const key = key_ as keyof T; + output[key] = state[key]; + if (serializers[key]) { + output[key] = await serializers[key].deserializer(output[key]); + } + } + + return output; + } + + // initialize also run here, so we can provide default initial content as if it's coming from the store + async function initializeState() { + const state = {} as T; + for (const key in serializers) { + const initializer = serializers[key]?.initializer; + if (initializer) { + state[key] = await initializer(); + } + } + + return state as T; + } + + return { + async getItem(name) { + const json = await storage.getItem(name); + if (json == null) { + return { version: 0, state: await initializeState() }; + } + + const { version, state } = JSON.parse(json); + return { version, state: await deserializeState(state) }; + }, + + async setItem(name, input) { + const state = await serializeState(input.state); + const json = JSON.stringify({ version: input.version, state }); + + storage.setItem(name, json); + }, + + removeItem(name) { + storage.removeItem(name); + }, + }; +} diff --git a/src/client/store-ident.ts b/src/client/store-ident.ts new file mode 100644 index 0000000..bdf5ac7 --- /dev/null +++ b/src/client/store-ident.ts @@ -0,0 +1,119 @@ +import * as jose from "@panva/jose"; +import { signAlgo } from "@repo/common/crypto-algos.ts"; +import { generateJwkPair, jwkPairSchema, serializeJwkPair } from "@repo/common/crypto-sig.ts"; +import { generateIdentId, identIdSchema } from "@repo/schema/state.ts"; +import { z } from "zod/v4"; +import { createSlice } from "zustand-slices"; + +import { sleep } from "../common/sleep.ts"; +import { SerializerConfig } from "./storage-serializers.ts"; + +// the state object + +export type IdentityData = z.infer; + +type InitializedData = IdentityData & { initialized: true }; +type AuthenticatedData = InitializedData & { anonymous: false }; +type AnonymousData = InitializedData & { anonymous: true }; + +export const identityDataSchema = z.union([ + z.object({ + initialized: z.literal(false), + }), + z.union([ + z.object({ + initialized: z.literal(true), + anonymous: z.literal(true), + }), + z.object({ + initialized: z.literal(true), + anonymous: z.literal(false), + version: z.number(), + authenticated: z.object({ + identid: identIdSchema, + keypair: jwkPairSchema, + }), + }), + ]), +]); + +export const identitySlice = createSlice({ + name: "identity", + value: { initialized: false } as IdentityData, + actions: { + setIdentity: (identity: IdentityData) => () => identity, + }, +}); + +export const identitySliceSerializer: SerializerConfig = { + initializer: async () => { + await sleep(1500); + + return { + initialized: true, + anonymous: true, + }; + }, + + serializer: async (state) => { + if (!state.initialized) return state; + if (state.anonymous) return state; + + const { + authenticated: { + keypair, + ...authrest + }, + ...staterest + } = state; + + return { + ...staterest, + authenticated: { + ...authrest, + keypair: await serializeJwkPair(keypair), + }, + }; + }, + + deserializer: (state) => identityDataSchema.parseAsync(state), +}; + +// helpers + +export function isInitialized(ident?: IdentityData): ident is InitializedData { + return ident?.initialized ?? false; +} + +export function isAuthenticated(ident?: IdentityData): ident is AuthenticatedData { + return (ident?.initialized && !ident?.anonymous) === true; +} + +export async function generateIdentity() { + return { + version: 0, + initialized: true, + anonymous: false, + authenticated: { + identid: generateIdentId(), + keypair: await generateJwkPair(), + }, + } satisfies IdentityData; +} + +export function signJwt(ident: IdentityData, payload: jose.JWTPayload) { + if (!isAuthenticated(ident)) { + throw new TypeError("cannot sign JWTs with an anonymous identity!"); + } + + const jwt = new jose.SignJWT(payload) + .setProtectedHeader({ alg: signAlgo.name }) + .setIssuer(ident.authenticated.identid) + .setIssuedAt(); + + if (!payload.exp) { + jwt.setExpirationTime("1m"); + } + + return jwt.sign(ident.authenticated.keypair.privateKey); +} diff --git a/src/client/store.ts b/src/client/store.ts new file mode 100644 index 0000000..781a695 --- /dev/null +++ b/src/client/store.ts @@ -0,0 +1,29 @@ +import { create } from "zustand"; +import { devtools, persist } from "zustand/middleware"; +import { withSlices } from "zustand-slices"; + +import { makeSlicedSerializerStorage } from "./storage-serializers.ts"; +import { identitySlice, identitySliceSerializer } from "./store-ident.ts"; + +type SkypodState = ReturnType; + +const skypodStore = withSlices(identitySlice); +const skypodStorage = makeSlicedSerializerStorage(localStorage, { + identity: identitySliceSerializer, +}); + +export const useSkypodStore = create( + devtools( + persist( + skypodStore, + { + name: "skypod", + storage: skypodStorage, + }, + ), + { + anonymousActionType: "@zustand/action", + name: "skypod", + }, + ), +);