@@ -7,6 +7,8 @@ import { auth } from "@/database/firebase";
77import { useTranslations } from "next-intl" ;
88import { fetchAllPages } from "@/lib/fetch-all-pages" ;
99import { proxyJsonAuthed } from "@/lib/backend-auth" ;
10+ import { useCipherKey , cipherKeyErrorMessage } from "@/lib/use-cipher-key" ;
11+ import { encryptField , decryptField , isEnvelope , type ContentEnvelope } from "@/lib/content-envelope" ;
1012
1113const BACKEND_BASE_URL : string =
1214 process . env . NEXT_PUBLIC_FASTAPI_BASE_URL ||
@@ -52,14 +54,49 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
5254 const [ activeNoteId , setActiveNoteId ] = useState < string | null > ( null ) ;
5355 const [ focusMode , setFocusMode ] = useState ( false ) ;
5456 const contentLoadedIds = useRef < Set < string > > ( new Set ( ) ) ;
57+ // Notes whose content is an envelope we couldn't decrypt (vault locked). Their
58+ // in-state `content` is a null placeholder — writing it back would clobber the
59+ // real body, so content writes are blocked until unlock.
60+ const lockedIds = useRef < Set < string > > ( new Set ( ) ) ;
61+
62+ // Zero-knowledge: note bodies are AES-GCM envelopes on the wire; `notes` state
63+ // always holds decrypted content. Key comes from the master-password gate.
64+ const cipherKey = useCipherKey ( ) ;
5565
5666 // Refs to read latest state inside stable action callbacks
5767 const notesRef = useRef < Note [ ] > ( notes ) ;
5868 const activeNoteIdRef = useRef < string | null > ( activeNoteId ) ;
5969 const userRef = useRef ( user ) ;
70+ const cipherKeyRef = useRef < CryptoKey | null > ( cipherKey ) ;
6071 useEffect ( ( ) => { notesRef . current = notes ; } , [ notes ] ) ;
6172 useEffect ( ( ) => { activeNoteIdRef . current = activeNoteId ; } , [ activeNoteId ] ) ;
6273 useEffect ( ( ) => { userRef . current = user ; } , [ user ] ) ;
74+ useEffect ( ( ) => { cipherKeyRef . current = cipherKey ; } , [ cipherKey ] ) ;
75+
76+ // Decrypt a note's content envelope into plaintext for in-state use. Legacy
77+ // plaintext notes (pre-encryption) pass through untouched. When locked, the
78+ // body becomes a null placeholder and the note is flagged in `lockedIds`.
79+ const decryptNote = useCallback ( async ( n : Note ) : Promise < Note > => {
80+ if ( ! isEnvelope ( n . content ) ) { lockedIds . current . delete ( n . id ) ; return n ; }
81+ const key = cipherKeyRef . current ;
82+ if ( ! key ) { lockedIds . current . add ( n . id ) ; return { ...n , content : null } ; }
83+ try {
84+ const content = await decryptField ( key , n . content as ContentEnvelope ) ;
85+ lockedIds . current . delete ( n . id ) ;
86+ return { ...n , content } ;
87+ } catch {
88+ lockedIds . current . add ( n . id ) ;
89+ return { ...n , content : null } ;
90+ }
91+ } , [ ] ) ;
92+
93+ // Encrypt a plaintext body into an envelope for the wire. Throws when locked
94+ // so callers surface the reason instead of silently persisting an empty body.
95+ const encryptContent = useCallback ( async ( content : unknown ) : Promise < ContentEnvelope > => {
96+ const key = cipherKeyRef . current ;
97+ if ( ! key ) throw new Error ( cipherKeyErrorMessage ( ) ) ;
98+ return encryptField ( key , content ) ;
99+ } , [ ] ) ;
63100
64101 const apiRequest = useCallback (
65102 async < T , > ( method : string , path : string , body ?: unknown ) : Promise < T > => {
@@ -86,24 +123,27 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
86123 ) ,
87124 } ) ;
88125 contentLoadedIds . current . clear ( ) ;
89- setNotes ( [ ...allNotes ] . sort ( ( a , b ) => a . createdAt . localeCompare ( b . createdAt ) ) ) ;
90- } , [ apiRequest ] ) ;
126+ const decrypted = await Promise . all ( allNotes . map ( decryptNote ) ) ;
127+ setNotes ( [ ...decrypted ] . sort ( ( a , b ) => a . createdAt . localeCompare ( b . createdAt ) ) ) ;
128+ } , [ apiRequest , decryptNote ] ) ;
91129
92130 useEffect ( ( ) => {
93131 if ( ! activeNoteId || contentLoadedIds . current . has ( activeNoteId ) ) return ;
94132 let cancelled = false ;
95133 setIsContentLoading ( true ) ;
96134 apiRequest < Note > ( "GET" , `/api/v1/notes/${ activeNoteId } ` )
97- . then ( ( full ) => {
98- if ( cancelled ) return ;
135+ . then ( async ( full ) => {
99136 if ( ! full ?. id ) return ;
100- contentLoadedIds . current . add ( activeNoteId ) ;
101- setNotes ( ( prev ) => prev . map ( ( n ) => ( n . id === full . id ? full : n ) ) ) ;
137+ const dec = await decryptNote ( full ) ;
138+ if ( cancelled ) return ;
139+ // Only mark loaded once the body is actually available (unlocked).
140+ if ( ! lockedIds . current . has ( dec . id ) ) contentLoadedIds . current . add ( activeNoteId ) ;
141+ setNotes ( ( prev ) => prev . map ( ( n ) => ( n . id === dec . id ? dec : n ) ) ) ;
102142 } )
103143 . catch ( ( ) => { /* note may have been deleted */ } )
104144 . finally ( ( ) => { if ( ! cancelled ) setIsContentLoading ( false ) ; } ) ;
105145 return ( ) => { cancelled = true ; } ;
106- } , [ activeNoteId , apiRequest ] ) ;
146+ } , [ activeNoteId , apiRequest , decryptNote ] ) ;
107147
108148 useEffect ( ( ) => {
109149 if ( ! user ) {
@@ -121,28 +161,38 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
121161 setIsLoading ( false ) ;
122162 }
123163 } ) ( ) ;
124- } , [ refreshNotes , user ] ) ;
164+ // cipherKey in deps: re-fetch + decrypt bodies once the vault unlocks.
165+ } , [ refreshNotes , user , cipherKey ] ) ;
125166
126167 const createNote = useCallback ( async ( parentId : string | null = null ) => {
127168 if ( ! userRef . current ) throw new Error ( t ( "authRequiredError" ) ) ;
128169 const created = await apiRequest < Note > ( "POST" , "/api/v1/notes" , {
129170 title : t ( "defaultTitle" ) ,
130- content : { } ,
171+ content : await encryptContent ( { } ) ,
131172 parentId,
132173 icon : undefined ,
133174 } ) ;
134175 if ( ! created ?. id ) throw new Error ( "Note create failed" ) ;
135176 contentLoadedIds . current . add ( created . id ) ;
136177 setActiveNoteId ( created . id ) ;
137- setNotes ( ( prev ) => [ ...prev , created ] . sort ( ( a , b ) => a . createdAt . localeCompare ( b . createdAt ) ) ) ;
178+ // Server echoes the envelope back; keep decrypted (empty) body in state.
179+ const local : Note = { ...created , content : { } } ;
180+ setNotes ( ( prev ) => [ ...prev , local ] . sort ( ( a , b ) => a . createdAt . localeCompare ( b . createdAt ) ) ) ;
138181 return created . id ;
139- } , [ apiRequest , t ] ) ;
182+ } , [ apiRequest , t , encryptContent ] ) ;
140183
141184 const updateNote = useCallback ( async ( id : string , updates : Partial < Note > ) => {
142185 if ( ! userRef . current ) return ;
143- const payload : Partial < Pick < Note , "title" | "content" | "parentId" | "icon" | "pinned" | "tags" > > = { } ;
186+ // Block body writes while locked — persisting would clobber the real
187+ // (undecryptable) content with an empty one.
188+ if ( updates . content !== undefined && ( lockedIds . current . has ( id ) || ! cipherKeyRef . current ) ) {
189+ throw new Error ( cipherKeyErrorMessage ( ) ) ;
190+ }
191+
192+ // Plaintext metadata for the wire; encrypted body swapped in below.
193+ const payload : Record < string , unknown > = { } ;
144194 if ( updates . title !== undefined ) payload . title = updates . title ;
145- if ( updates . content !== undefined ) payload . content = updates . content ;
195+ if ( updates . content !== undefined ) payload . content = await encryptContent ( updates . content ) ;
146196 if ( updates . parentId !== undefined ) payload . parentId = updates . parentId ;
147197 if ( updates . icon !== undefined ) payload . icon = updates . icon ;
148198 if ( updates . pinned !== undefined ) payload . pinned = updates . pinned ;
@@ -151,8 +201,11 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
151201 const updated = await apiRequest < Note > ( "PATCH" , `/api/v1/notes/${ id } ` , payload ) ;
152202 if ( ! updated ?. id ) throw new Error ( "Update failed: empty response" ) ;
153203 if ( updates . content !== undefined ) contentLoadedIds . current . add ( id ) ;
154- setNotes ( ( prev ) => prev . map ( ( n ) => ( n . id === updated . id ? updated : n ) ) ) ;
155- } , [ apiRequest ] ) ;
204+ // Merge server echo but keep the plaintext body we already hold in memory.
205+ setNotes ( ( prev ) => prev . map ( ( n ) => ( n . id === updated . id
206+ ? { ...updated , content : updates . content !== undefined ? updates . content : n . content }
207+ : n ) ) ) ;
208+ } , [ apiRequest , encryptContent ] ) ;
156209
157210 const pinNote = useCallback ( async ( id : string , pinned : boolean ) => {
158211 await updateNote ( id , { pinned } ) ;
@@ -165,23 +218,26 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
165218 if ( ! contentLoadedIds . current . has ( id ) ) {
166219 const full = await apiRequest < Note > ( "GET" , `/api/v1/notes/${ id } ` ) ;
167220 if ( ! full ?. id ) throw new Error ( "Note fetch failed" ) ;
168- contentLoadedIds . current . add ( id ) ;
169- setNotes ( ( prev ) => prev . map ( ( n ) => ( n . id === full . id ? full : n ) ) ) ;
170- content = full . content ;
221+ const dec = await decryptNote ( full ) ;
222+ if ( ! lockedIds . current . has ( dec . id ) ) contentLoadedIds . current . add ( id ) ;
223+ setNotes ( ( prev ) => prev . map ( ( n ) => ( n . id === dec . id ? dec : n ) ) ) ;
224+ content = dec . content ;
171225 }
226+ if ( lockedIds . current . has ( id ) || ! cipherKeyRef . current ) throw new Error ( cipherKeyErrorMessage ( ) ) ;
172227 const created = await apiRequest < Note > ( "POST" , "/api/v1/notes" , {
173228 title : `${ src . title || t ( "defaultTitle" ) } (copy)` ,
174- content,
229+ content : await encryptContent ( content ) ,
175230 parentId : src . parentId ?? null ,
176231 icon : src . icon ,
177232 tags : src . tags ,
178233 } ) ;
179234 if ( ! created ?. id ) throw new Error ( "Note duplicate failed" ) ;
180235 contentLoadedIds . current . add ( created . id ) ;
181236 setActiveNoteId ( created . id ) ;
182- setNotes ( ( prev ) => [ ...prev , created ] . sort ( ( a , b ) => a . createdAt . localeCompare ( b . createdAt ) ) ) ;
237+ const local : Note = { ...created , content } ;
238+ setNotes ( ( prev ) => [ ...prev , local ] . sort ( ( a , b ) => a . createdAt . localeCompare ( b . createdAt ) ) ) ;
183239 return created . id ;
184- } , [ apiRequest , t ] ) ;
240+ } , [ apiRequest , t , decryptNote , encryptContent ] ) ;
185241
186242 const moveNote = useCallback ( async ( id : string , newParentId : string | null ) => {
187243 await updateNote ( id , { parentId : newParentId } ) ;
0 commit comments