@@ -22,13 +22,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
2222
2323// Zod Schemas
2424const genSchema = z . object ( {
25- name : z . string ( ) . min ( 2 , "Name is too short" ) ,
26- email : z . string ( ) . email ( "Invalid email address" ) ,
25+ curve : z . enum ( [ "P-256" , "P-384" ] ) ,
2726} ) ;
2827
2928const submitSchema = z . object ( {
3029 username : z . string ( ) . min ( 1 , "Username required" ) ,
31- csr : z . string ( ) . includes ( "BEGIN CERTIFICATE REQUEST" , { message : "Invalid CSR" } ) ,
30+ csr : z . string ( ) . min ( 100 , "Public key is required" ) ,
3231} ) ;
3332
3433const revokeSchema = z . object ( {
@@ -95,9 +94,12 @@ const parseCertFile = async (file: File): Promise<{ fingerprint: string, text: s
9594
9695export function KeyringApp ( ) {
9796 const [ lang , setLang ] = useState < LocaleKey > ( "en" ) ;
97+ const [ generatedPublicKey , setGeneratedPublicKey ] = useState < string > ( "" ) ;
98+ const [ mounted , setMounted ] = useState ( false ) ;
9899 const t = locales [ lang ] ;
99100
100101 useEffect ( ( ) => {
102+ setMounted ( true ) ;
101103 const defaultLang = navigator . language . startsWith ( "zh" ) ? "zh" : "en" ;
102104 setLang ( defaultLang ) ;
103105 } , [ ] ) ;
@@ -107,8 +109,8 @@ export function KeyringApp() {
107109 { /* Header */ }
108110 < div className = "max-w-4xl w-full flex justify-between items-center mb-8" >
109111 < div className = "flex items-center gap-3" >
110- < div className = "p-2 bg-indigo-600 rounded-lg shadow-lg shadow-indigo-500/20 " >
111- < ShieldCheck className = "w-6 h-6 text-white " />
112+ < div className = "p-2 bg-white dark:bg-slate-800 rounded-lg shadow-lg" >
113+ < img src = "/logo.svg" alt = "KernelSU Logo" className = "w-8 h-8 " />
112114 </ div >
113115 < div >
114116 < h1 className = "text-2xl font-bold tracking-tight" > { t . title } </ h1 >
@@ -123,23 +125,37 @@ export function KeyringApp() {
123125
124126 { /* Main Content */ }
125127 < Card className = "max-w-4xl w-full shadow-xl border-slate-200 dark:border-slate-800" >
126- < Tabs defaultValue = "generate" className = "w-full" >
127- < div className = "border-b px-6 py-2 bg-slate-50/50 dark:bg-slate-900/50" >
128- < TabsList className = "grid w-full grid-cols-4 bg-slate-200/50 dark:bg-slate-800/50" >
129- < TabsTrigger value = "generate" > { t . tabs . generate } </ TabsTrigger >
130- < TabsTrigger value = "submit" > { t . tabs . submit } </ TabsTrigger >
131- < TabsTrigger value = "query" > { t . tabs . query } </ TabsTrigger >
132- < TabsTrigger value = "revoke" > { t . tabs . revoke } </ TabsTrigger >
133- </ TabsList >
128+ { mounted ? (
129+ < Tabs defaultValue = "generate" className = "w-full" >
130+ < div className = "border-b px-6 py-2 bg-slate-50/50 dark:bg-slate-900/50" >
131+ < TabsList className = "grid w-full grid-cols-4 bg-slate-200/50 dark:bg-slate-800/50" >
132+ < TabsTrigger value = "generate" > { t . tabs . generate } </ TabsTrigger >
133+ < TabsTrigger value = "submit" > { t . tabs . submit } </ TabsTrigger >
134+ < TabsTrigger value = "query" > { t . tabs . query } </ TabsTrigger >
135+ < TabsTrigger value = "revoke" > { t . tabs . revoke } </ TabsTrigger >
136+ </ TabsList >
137+ </ div >
138+
139+ < div className = "p-6" >
140+ < TabsContent value = "generate" forceMount className = "data-[state=inactive]:hidden" >
141+ < GenerateForm t = { t } onGenerated = { setGeneratedPublicKey } />
142+ </ TabsContent >
143+ < TabsContent value = "submit" forceMount className = "data-[state=inactive]:hidden" >
144+ < SubmitForm t = { t } initialPublicKey = { generatedPublicKey } />
145+ </ TabsContent >
146+ < TabsContent value = "query" forceMount className = "data-[state=inactive]:hidden" >
147+ < QueryForm t = { t } />
148+ </ TabsContent >
149+ < TabsContent value = "revoke" forceMount className = "data-[state=inactive]:hidden" >
150+ < RevokeForm t = { t } />
151+ </ TabsContent >
152+ </ div >
153+ </ Tabs >
154+ ) : (
155+ < div className = "p-6 min-h-[400px] flex items-center justify-center" >
156+ < Loader2 className = "w-8 h-8 animate-spin text-indigo-600" />
134157 </ div >
135-
136- < div className = "p-6" >
137- < TabsContent value = "generate" > < GenerateForm t = { t } /> </ TabsContent >
138- < TabsContent value = "submit" > < SubmitForm t = { t } /> </ TabsContent >
139- < TabsContent value = "query" > < QueryForm t = { t } /> </ TabsContent >
140- < TabsContent value = "revoke" > < RevokeForm t = { t } /> </ TabsContent >
141- </ div >
142- </ Tabs >
158+ ) }
143159 </ Card >
144160
145161 < footer className = "mt-12 text-center text-sm text-slate-400" >
@@ -151,50 +167,75 @@ export function KeyringApp() {
151167
152168// --- Sub Components ---
153169
154- function GenerateForm ( { t } : { t : typeof locales . en } ) {
170+ function GenerateForm ( { t, onGenerated } : { t : typeof locales . en ; onGenerated : ( publicKey : string ) => void } ) {
155171 const [ keys , setKeys ] = useState < {
156172 privateKey : string ;
157- csr : string ;
173+ publicKey : string ;
158174 fingerprint : string ;
159- name : string
160175 } | null > ( null ) ;
161- const form = useForm < z . infer < typeof genSchema > > ( { resolver : zodResolver ( genSchema ) } ) ;
176+ const form = useForm < z . infer < typeof genSchema > > ( {
177+ resolver : zodResolver ( genSchema ) ,
178+ defaultValues : {
179+ curve : "P-256"
180+ }
181+ } ) ;
162182 const [ loading , setLoading ] = useState ( false ) ;
163183
164184 const onSubmit = async ( data : z . infer < typeof genSchema > ) => {
165185 setLoading ( true ) ;
166186 try {
167- // Generate RSA key pair (2048 bits for compatibility)
168- const keypair = forge . pki . rsa . generateKeyPair ( { bits : 2048 , workers : - 1 } ) ;
169-
170- // Create CSR
171- const csr = forge . pki . createCertificationRequest ( ) ;
172- csr . publicKey = keypair . publicKey ;
173- csr . setSubject ( [
174- { name : 'commonName' , value : data . name } ,
175- { name : 'emailAddress' , value : data . email } ,
176- { name : 'organizationName' , value : 'KernelSU Module Developers' }
177- ] ) ;
178-
179- // Sign CSR with private key
180- csr . sign ( keypair . privateKey , forge . md . sha256 . create ( ) ) ;
181-
182- // Convert to PEM format
183- const privateKeyPem = forge . pki . privateKeyToPem ( keypair . privateKey ) ;
184- const csrPem = forge . pki . certificationRequestToPem ( csr ) ;
185-
186- // Generate a fingerprint from public key for identification
187- const publicKeyDer = forge . asn1 . toDer ( forge . pki . publicKeyToAsn1 ( keypair . publicKey ) ) . getBytes ( ) ;
188- const md = forge . md . sha256 . create ( ) ;
189- md . update ( publicKeyDer ) ;
190- const fingerprint = md . digest ( ) . toHex ( ) . toUpperCase ( ) ;
187+ let privateKeyPem : string ;
188+ let publicKeyPem : string ;
189+
190+ // Generate EC key pair based on selected curve
191+ if ( data . curve === "P-256" || data . curve === "P-384" ) {
192+ // Generate EC keys using Web Crypto API
193+ const curveName = data . curve === "P-256" ? "P-256" : "P-384" ;
194+ const cryptoKeypair = await window . crypto . subtle . generateKey (
195+ {
196+ name : "ECDSA" ,
197+ namedCurve : curveName ,
198+ } ,
199+ true ,
200+ [ "sign" , "verify" ]
201+ ) ;
202+
203+ // Export private key to PKCS#8 format
204+ const privateKeyBuffer = await window . crypto . subtle . exportKey ( "pkcs8" , cryptoKeypair . privateKey ) ;
205+ const privateKeyBase64 = btoa ( String . fromCharCode ( ...new Uint8Array ( privateKeyBuffer ) ) ) ;
206+ privateKeyPem = `-----BEGIN PRIVATE KEY-----\n${ privateKeyBase64 . match ( / .{ 1 , 64 } / g) ?. join ( '\n' ) } \n-----END PRIVATE KEY-----` ;
207+
208+ // Export public key to SPKI format
209+ const publicKeyBuffer = await window . crypto . subtle . exportKey ( "spki" , cryptoKeypair . publicKey ) ;
210+ const publicKeyBase64 = btoa ( String . fromCharCode ( ...new Uint8Array ( publicKeyBuffer ) ) ) ;
211+ publicKeyPem = `-----BEGIN PUBLIC KEY-----\n${ publicKeyBase64 . match ( / .{ 1 , 64 } / g) ?. join ( '\n' ) } \n-----END PUBLIC KEY-----` ;
212+ } else {
213+ throw new Error ( "Unsupported curve type" ) ;
214+ }
215+
216+ // Calculate fingerprint from public key
217+ const publicKeyBuffer = await window . crypto . subtle . digest (
218+ 'SHA-256' ,
219+ new TextEncoder ( ) . encode ( publicKeyPem )
220+ ) ;
221+ const fingerprintArray = Array . from ( new Uint8Array ( publicKeyBuffer ) ) ;
222+ const fingerprint = fingerprintArray . map ( b => b . toString ( 16 ) . padStart ( 2 , '0' ) ) . join ( '' ) . toUpperCase ( ) ;
223+
224+ const timestamp = Date . now ( ) ;
225+ const fileName = `keypair_${ timestamp } ` ;
191226
192227 setKeys ( {
193228 privateKey : privateKeyPem ,
194- csr : csrPem ,
229+ publicKey : publicKeyPem ,
195230 fingerprint,
196- name : data . name . replace ( / \s + / g, '_' )
197231 } ) ;
232+
233+ // Auto download private key
234+ downloadFile ( privateKeyPem , `${ fileName } .key.pem` ) ;
235+
236+ // Auto fill public key to submit form
237+ onGenerated ( publicKeyPem ) ;
238+
198239 toast . success ( t . gen . success ) ;
199240 } catch ( e ) {
200241 toast . error ( e instanceof Error ? e . message : String ( e ) ) ;
@@ -210,17 +251,18 @@ function GenerateForm({ t }: { t: typeof locales.en }) {
210251 < p className = "text-sm text-slate-500" > { t . gen . desc } </ p >
211252 </ div >
212253
213- < div className = "grid gap-4 md:grid-cols-2" >
214- < div className = "space-y-2" >
215- < Label > { t . gen . name } </ Label >
216- < Input { ...form . register ( "name" ) } placeholder = "Linus Torvalds" />
217- { form . formState . errors . name && < p className = "text-xs text-red-500" > { form . formState . errors . name . message } </ p > }
218- </ div >
219- < div className = "space-y-2" >
220- < Label > { t . gen . email } </ Label >
221- < Input { ...form . register ( "email" ) } placeholder = "linus@kernel.org" />
222- { form . formState . errors . email && < p className = "text-xs text-red-500" > { form . formState . errors . email . message } </ p > }
223- </ div >
254+ < div className = "space-y-2" >
255+ < Label > { t . gen . curve } </ Label >
256+ < Select onValueChange = { v => form . setValue ( "curve" , v as "P-256" | "P-384" ) } defaultValue = "P-256" >
257+ < SelectTrigger >
258+ < SelectValue placeholder = { t . gen . curve } />
259+ </ SelectTrigger >
260+ < SelectContent >
261+ < SelectItem value = "P-256" > P-256 (NIST P-256 / secp256r1)</ SelectItem >
262+ < SelectItem value = "P-384" > P-384 (NIST P-384 / secp384r1)</ SelectItem >
263+ </ SelectContent >
264+ </ Select >
265+ { form . formState . errors . curve && < p className = "text-xs text-red-500" > { form . formState . errors . curve . message } </ p > }
224266 </ div >
225267
226268 < Button onClick = { form . handleSubmit ( onSubmit ) } disabled = { loading } className = "w-full bg-indigo-600 hover:bg-indigo-700 text-white" >
@@ -259,13 +301,13 @@ function GenerateForm({ t }: { t: typeof locales.en }) {
259301 title = { t . gen . priv_warn }
260302 content = { keys . privateKey }
261303 isSecret
262- downloadName = { `${ keys . name } .key.pem` }
304+ downloadName = { `keypair_ ${ Date . now ( ) } .key.pem` }
263305 downloadText = { t . gen . download_priv }
264306 />
265307 < KeyDisplay
266308 title = { t . gen . pub_label }
267- content = { keys . csr }
268- downloadName = { `${ keys . name } .csr .pem` }
309+ content = { keys . publicKey }
310+ downloadName = { `keypair_ ${ Date . now ( ) } .pub .pem` }
269311 downloadText = { t . gen . download_pub }
270312 />
271313 </ div >
@@ -275,26 +317,42 @@ function GenerateForm({ t }: { t: typeof locales.en }) {
275317 ) ;
276318}
277319
278- function SubmitForm ( { t } : { t : typeof locales . en } ) {
279- const form = useForm < z . infer < typeof submitSchema > > ( { resolver : zodResolver ( submitSchema ) } ) ;
320+ function SubmitForm ( { t, initialPublicKey } : { t : typeof locales . en ; initialPublicKey : string } ) {
321+ const form = useForm < z . infer < typeof submitSchema > > ( {
322+ resolver : zodResolver ( submitSchema ) ,
323+ defaultValues : {
324+ csr : initialPublicKey
325+ }
326+ } ) ;
327+
328+ // Update form when initialPublicKey changes
329+ useEffect ( ( ) => {
330+ if ( initialPublicKey ) {
331+ form . setValue ( "csr" , initialPublicKey ) ;
332+ }
333+ } , [ initialPublicKey , form ] ) ;
280334
281335 const handleFileImport = async ( e : React . ChangeEvent < HTMLInputElement > ) => {
282336 const file = e . target . files ?. [ 0 ] ;
283337 if ( ! file ) return ;
284338
285- const result = await parseCertFile ( file ) ;
286- if ( result && result . text . includes ( 'BEGIN CERTIFICATE REQUEST' ) ) {
287- form . setValue ( "csr" , result . text ) ;
288- toast . success ( t . common . import_success ) ;
289- } else {
339+ try {
340+ const text = await file . text ( ) ;
341+ if ( text . includes ( 'BEGIN PUBLIC KEY' ) || text . includes ( 'BEGIN CERTIFICATE REQUEST' ) ) {
342+ form . setValue ( "csr" , text ) ;
343+ toast . success ( t . common . import_success ) ;
344+ } else {
345+ toast . error ( t . common . import_error ) ;
346+ }
347+ } catch ( e ) {
290348 toast . error ( t . common . import_error ) ;
291349 }
292350 e . target . value = "" ;
293351 } ;
294352
295353 const onSubmit = ( data : z . infer < typeof submitSchema > ) => {
296354 const title = `[keyring] ${ data . username } ` ;
297- const body = `## Submit Developer CSR (Certificate Signing Request) \n\n**CSR **:\n\n\`\`\`\n${ data . csr } \n\`\`\`\n\n---\nPlease review and add \`approved\` label to issue certificate.` ;
355+ const body = `## Submit Developer Public Key \n\n**Public Key **:\n\n\`\`\`\n${ data . csr } \n\`\`\`\n\n---\nPlease review and add \`approved\` label to issue certificate.` ;
298356 window . open ( `https://github.com/KernelSU-Modules-Repo/developers/issues/new?title=${ encodeURIComponent ( title ) } &body=${ encodeURIComponent ( body ) } ` , "_blank" ) ;
299357 } ;
300358
@@ -323,7 +381,7 @@ function SubmitForm({ t }: { t: typeof locales.en }) {
323381 < Input id = "import-submit" type = "file" className = "hidden" accept = ".pem,.csr,.txt" onChange = { handleFileImport } />
324382 </ div >
325383 </ div >
326- < Textarea { ...form . register ( "csr" ) } className = "font-mono text-xs h-32" placeholder = "-----BEGIN CERTIFICATE REQUEST -----" />
384+ < Textarea { ...form . register ( "csr" ) } className = "font-mono text-xs h-32" placeholder = "-----BEGIN PUBLIC KEY -----" />
327385 { form . formState . errors . csr && < p className = "text-xs text-red-500" > { form . formState . errors . csr . message } </ p > }
328386 </ div >
329387
0 commit comments