1
+ import React , {
2
+ createContext , useContext , useEffect , useState , useCallback , ReactNode
3
+ } from 'react' ;
4
+ import {
5
+ startRegistration ,
6
+ startAuthentication ,
7
+ deriveKey ,
8
+ base64URLStringToBuffer ,
9
+ } from '@/webauthn' ;
10
+ import { encryptData , decryptData } from '@/webauthn' ;
11
+
12
+ type SecureContext < T > = {
13
+ isAuthenticated : boolean ;
14
+ encryptionKey : CryptoKey | null ;
15
+ values : T | null ;
16
+ login : ( ) => Promise < void > ;
17
+ logout : ( ) => void ;
18
+ setValues : ( v :T ) => Promise < void > ;
19
+ } ;
20
+
21
+ const Ctx = createContext < SecureContext < any > | undefined > ( undefined ) ;
22
+
23
+ type FallbackRender < T > = ( props : {
24
+ submit : ( values :T ) => Promise < void > ;
25
+ error ? : string ;
26
+ isLoading ? : boolean ;
27
+ } ) => ReactNode ;
28
+
29
+ interface Props < T > {
30
+ storageKey : string ;
31
+ fallback : FallbackRender < T > ;
32
+ children : ( ctx : SecureContext < T > ) => ReactNode ;
33
+ }
34
+
35
+ export function SecureFormProvider < T extends Record < string , any > > (
36
+ { storageKey, fallback, children } : Props < T >
37
+ ) {
38
+ const [ encryptionKey , setKey ] = useState < CryptoKey | null > ( null ) ;
39
+ const [ values , setVals ] = useState < T | null > ( null ) ;
40
+ const [ error , setErr ] = useState < string | null > ( null ) ;
41
+ const [ isLoading , setLoad ] = useState ( false ) ;
42
+
43
+ /* ──────────────────────────────────────────────── */
44
+ /* Helpers */
45
+ /* ──────────────────────────────────────────────── */
46
+
47
+ const decryptFromStorage = useCallback ( async ( key :CryptoKey ) => {
48
+ const cipher = localStorage . getItem ( `${ storageKey } .data` ) ;
49
+ if ( ! cipher ) return null ;
50
+ const json = await decryptData ( key , cipher ) ;
51
+ return JSON . parse ( json ) as T ;
52
+ } , [ storageKey ] ) ;
53
+
54
+ const encryptAndStore = useCallback ( async ( key :CryptoKey , v :T ) => {
55
+ const cipher = await encryptData ( key , JSON . stringify ( v ) ) ;
56
+ localStorage . setItem ( `${ storageKey } .data` , cipher ) ;
57
+ } , [ storageKey ] ) ;
58
+
59
+ const deriveAndSetKey = useCallback ( async ( rawIdBase64 :string ) => {
60
+ const buf = base64URLStringToBuffer ( rawIdBase64 ) ;
61
+ const key = await deriveKey ( buf ) ;
62
+ setKey ( key ) ;
63
+ return key ;
64
+ } , [ ] ) ;
65
+
66
+ /* ──────────────────────────────────────────────── */
67
+ /* Auto‑login on mount */
68
+ /* ──────────────────────────────────────────────── */
69
+
70
+ useEffect ( ( ) => {
71
+ const auto = async ( ) => {
72
+ const id = localStorage . getItem ( 'userIdentifier' ) ;
73
+ const hasBlob = ! ! localStorage . getItem ( `${ storageKey } .data` ) ;
74
+ if ( ! id || ! hasBlob ) return ;
75
+
76
+ setLoad ( true ) ;
77
+ try {
78
+ const assertion = await startAuthentication ( ) ;
79
+ const key = await deriveAndSetKey ( assertion . rawId ) ;
80
+ const v = await decryptFromStorage ( key ) ;
81
+ setVals ( v ) ;
82
+ } catch ( e ) { console . error ( '[SecureForm] auto‑login failed' , e ) ; }
83
+ finally { setLoad ( false ) ; }
84
+ } ;
85
+ auto ( ) ;
86
+ } , [ storageKey , decryptFromStorage , deriveAndSetKey ] ) ;
87
+
88
+ /* ──────────────────────────────────────────────── */
89
+ /* Public API */
90
+ /* ──────────────────────────────────────────────── */
91
+
92
+ const login = useCallback ( async ( ) => {
93
+ setLoad ( true ) ; setErr ( null ) ;
94
+ try {
95
+ const assertion = await startAuthentication ( ) ;
96
+ const key = await deriveAndSetKey ( assertion . rawId ) ;
97
+ const v = await decryptFromStorage ( key ) ;
98
+ setVals ( v ) ;
99
+ } catch ( e :any ) { setErr ( e . message || 'Login failed' ) ; throw e ; }
100
+ finally { setLoad ( false ) ; }
101
+ } , [ decryptFromStorage , deriveAndSetKey ] ) ;
102
+
103
+ const logout = useCallback ( ( ) => {
104
+ setKey ( null ) ;
105
+ setVals ( null ) ;
106
+ setErr ( null ) ;
107
+ } , [ ] ) ;
108
+
109
+ const setValues = useCallback ( async ( v :T ) => {
110
+ if ( ! encryptionKey ) throw new Error ( 'Not authenticated' ) ;
111
+ await encryptAndStore ( encryptionKey , v ) ;
112
+ setVals ( v ) ;
113
+ } , [ encryptAndStore , encryptionKey ] ) ;
114
+
115
+ /* ──────────────────────────────────────────────── */
116
+ /* Fallback submit handler */
117
+ /* ──────────────────────────────────────────────── */
118
+
119
+ const submit = useCallback ( async ( formVals :T ) => {
120
+ setLoad ( true ) ; setErr ( null ) ;
121
+ try {
122
+ let key = encryptionKey ;
123
+ if ( ! key ) {
124
+ // first‑time user? → register
125
+ const cred = await startRegistration ( storageKey ) ;
126
+ key = await deriveAndSetKey ( cred . rawId ) ;
127
+ localStorage . setItem ( 'userIdentifier' , cred . rawId ) ;
128
+ }
129
+ await encryptAndStore ( key ! , formVals ) ;
130
+ setVals ( formVals ) ;
131
+ } catch ( e :any ) { setErr ( e . message || 'Failed to save' ) ; throw e ; }
132
+ finally { setLoad ( false ) ; }
133
+ } , [ encryptionKey , deriveAndSetKey , encryptAndStore ] ) ;
134
+
135
+ const ctxValue : SecureContext < T > = {
136
+ isAuthenticated : ! ! encryptionKey ,
137
+ encryptionKey,
138
+ values,
139
+ login,
140
+ logout,
141
+ setValues,
142
+ } ;
143
+
144
+ /* ──────────────────────────────────────────────── */
145
+ /* Render */
146
+ /* ──────────────────────────────────────────────── */
147
+
148
+ const needFallback = ! encryptionKey || values === null ;
149
+
150
+ return (
151
+ < Ctx . Provider value = { ctxValue } >
152
+ { needFallback
153
+ ? fallback ( { submit, error : error || undefined , isLoading } )
154
+ : children ( ctxValue ) }
155
+ </ Ctx . Provider >
156
+ ) ;
157
+ }
158
+
159
+ /* Hook for consumers */
160
+ export const useSecureForm = < T , > ( ) => {
161
+ const c = useContext ( Ctx ) ;
162
+ if ( ! c ) throw new Error ( 'useSecureForm must be inside SecureFormProvider' ) ;
163
+ return c as SecureContext < T > ;
164
+ } ;
0 commit comments