@@ -608,6 +608,218 @@ pub fn get_version() -> String {
608608 env ! ( "CARGO_PKG_VERSION" ) . to_string ( )
609609}
610610
611+ // ============================================================================
612+ // HPKE Test Functions (for cross-platform verification)
613+ // ============================================================================
614+
615+ /// Encrypt a DEK using HPKE with the given public key
616+ ///
617+ /// This simulates what FxFiles does when encrypting a file.
618+ /// Returns JSON string containing the EncryptedData (wrapped DEK).
619+ ///
620+ /// @param publicKeyBytes - 32-byte X25519 public key
621+ /// @param dekBytes - 32-byte DEK to encrypt
622+ /// @returns JSON string with {version, encapsulated_key, ciphertext}
623+ #[ wasm_bindgen( js_name = testHpkeEncryptDek) ]
624+ pub fn test_hpke_encrypt_dek (
625+ public_key_bytes : & [ u8 ] ,
626+ dek_bytes : & [ u8 ] ,
627+ ) -> Result < String , JsError > {
628+ if public_key_bytes. len ( ) != 32 {
629+ return Err ( JsError :: new ( & format ! (
630+ "Public key must be 32 bytes, got {}" , public_key_bytes. len( )
631+ ) ) ) ;
632+ }
633+ if dek_bytes. len ( ) != 32 {
634+ return Err ( JsError :: new ( & format ! (
635+ "DEK must be 32 bytes, got {}" , dek_bytes. len( )
636+ ) ) ) ;
637+ }
638+
639+ let mut pk_arr = [ 0u8 ; 32 ] ;
640+ pk_arr. copy_from_slice ( public_key_bytes) ;
641+ let public_key = fula_crypto:: PublicKey :: from_bytes ( & pk_arr)
642+ . map_err ( |e| JsError :: new ( & format ! ( "Invalid public key: {}" , e) ) ) ?;
643+
644+ let mut dek_arr = [ 0u8 ; 32 ] ;
645+ dek_arr. copy_from_slice ( dek_bytes) ;
646+ let dek = fula_crypto:: keys:: DekKey :: from_bytes ( & dek_arr)
647+ . map_err ( |e| JsError :: new ( & format ! ( "Invalid DEK: {}" , e) ) ) ?;
648+
649+ let encryptor = fula_crypto:: hpke:: Encryptor :: new ( & public_key) ;
650+ let wrapped = encryptor. encrypt_dek ( & dek)
651+ . map_err ( |e| JsError :: new ( & format ! ( "HPKE encryption failed: {}" , e) ) ) ?;
652+
653+ serde_json:: to_string ( & wrapped)
654+ . map_err ( |e| JsError :: new ( & format ! ( "JSON serialization failed: {}" , e) ) )
655+ }
656+
657+ /// Decrypt a wrapped DEK using HPKE with the given secret key
658+ ///
659+ /// This simulates what WebUI WASM should do when decrypting a file.
660+ ///
661+ /// @param secretKeyBytes - 32-byte X25519 secret key
662+ /// @param wrappedDekJson - JSON string from testHpkeEncryptDek
663+ /// @returns 32-byte decrypted DEK
664+ #[ wasm_bindgen( js_name = testHpkeDecryptDek) ]
665+ pub fn test_hpke_decrypt_dek (
666+ secret_key_bytes : & [ u8 ] ,
667+ wrapped_dek_json : & str ,
668+ ) -> Result < Vec < u8 > , JsError > {
669+ if secret_key_bytes. len ( ) != 32 {
670+ return Err ( JsError :: new ( & format ! (
671+ "Secret key must be 32 bytes, got {}" , secret_key_bytes. len( )
672+ ) ) ) ;
673+ }
674+
675+ let mut sk_arr = [ 0u8 ; 32 ] ;
676+ sk_arr. copy_from_slice ( secret_key_bytes) ;
677+ let secret = fula_crypto:: SecretKey :: from_bytes ( & sk_arr)
678+ . map_err ( |e| JsError :: new ( & format ! ( "Invalid secret key: {}" , e) ) ) ?;
679+
680+ let key_manager = fula_crypto:: keys:: KeyManager :: from_secret_key ( secret) ;
681+ let wrapped: fula_crypto:: hpke:: EncryptedData = serde_json:: from_str ( wrapped_dek_json)
682+ . map_err ( |e| JsError :: new ( & format ! ( "Invalid wrapped DEK JSON: {}" , e) ) ) ?;
683+
684+ let decryptor = fula_crypto:: hpke:: Decryptor :: new ( key_manager. keypair ( ) ) ;
685+ let dek = decryptor. decrypt_dek ( & wrapped)
686+ . map_err ( |e| JsError :: new ( & format ! ( "HPKE decryption failed: {}" , e) ) ) ?;
687+
688+ Ok ( dek. as_bytes ( ) . to_vec ( ) )
689+ }
690+
691+ /// AES-256-GCM encrypt data with a DEK
692+ ///
693+ /// @param dekBytes - 32-byte DEK
694+ /// @param plaintext - Data to encrypt
695+ /// @returns JSON string with {nonce, ciphertext} (both base64 encoded)
696+ #[ wasm_bindgen( js_name = testAesGcmEncrypt) ]
697+ pub fn test_aes_gcm_encrypt (
698+ dek_bytes : & [ u8 ] ,
699+ plaintext : & [ u8 ] ,
700+ ) -> Result < String , JsError > {
701+ if dek_bytes. len ( ) != 32 {
702+ return Err ( JsError :: new ( & format ! (
703+ "DEK must be 32 bytes, got {}" , dek_bytes. len( )
704+ ) ) ) ;
705+ }
706+
707+ let mut dek_arr = [ 0u8 ; 32 ] ;
708+ dek_arr. copy_from_slice ( dek_bytes) ;
709+ let dek = fula_crypto:: keys:: DekKey :: from_bytes ( & dek_arr)
710+ . map_err ( |e| JsError :: new ( & format ! ( "Invalid DEK: {}" , e) ) ) ?;
711+
712+ let nonce = fula_crypto:: symmetric:: Nonce :: generate ( ) ;
713+ let aead = fula_crypto:: symmetric:: Aead :: new_default ( & dek) ;
714+ let ciphertext = aead. encrypt ( & nonce, plaintext)
715+ . map_err ( |e| JsError :: new ( & format ! ( "AES-GCM encryption failed: {}" , e) ) ) ?;
716+
717+ use base64:: Engine ;
718+ let result = serde_json:: json!( {
719+ "nonce" : base64:: engine:: general_purpose:: STANDARD . encode( nonce. as_bytes( ) ) ,
720+ "ciphertext" : base64:: engine:: general_purpose:: STANDARD . encode( & ciphertext) ,
721+ } ) ;
722+
723+ serde_json:: to_string ( & result)
724+ . map_err ( |e| JsError :: new ( & format ! ( "JSON serialization failed: {}" , e) ) )
725+ }
726+
727+ /// AES-256-GCM decrypt data with a DEK
728+ ///
729+ /// @param dekBytes - 32-byte DEK
730+ /// @param nonceBase64 - Base64 encoded 12-byte nonce
731+ /// @param ciphertextBase64 - Base64 encoded ciphertext
732+ /// @returns Decrypted plaintext
733+ #[ wasm_bindgen( js_name = testAesGcmDecrypt) ]
734+ pub fn test_aes_gcm_decrypt (
735+ dek_bytes : & [ u8 ] ,
736+ nonce_base64 : & str ,
737+ ciphertext_base64 : & str ,
738+ ) -> Result < Vec < u8 > , JsError > {
739+ if dek_bytes. len ( ) != 32 {
740+ return Err ( JsError :: new ( & format ! (
741+ "DEK must be 32 bytes, got {}" , dek_bytes. len( )
742+ ) ) ) ;
743+ }
744+
745+ use base64:: Engine ;
746+ let nonce_bytes = base64:: engine:: general_purpose:: STANDARD . decode ( nonce_base64)
747+ . map_err ( |e| JsError :: new ( & format ! ( "Invalid nonce base64: {}" , e) ) ) ?;
748+ let ciphertext = base64:: engine:: general_purpose:: STANDARD . decode ( ciphertext_base64)
749+ . map_err ( |e| JsError :: new ( & format ! ( "Invalid ciphertext base64: {}" , e) ) ) ?;
750+
751+ let mut dek_arr = [ 0u8 ; 32 ] ;
752+ dek_arr. copy_from_slice ( dek_bytes) ;
753+ let dek = fula_crypto:: keys:: DekKey :: from_bytes ( & dek_arr)
754+ . map_err ( |e| JsError :: new ( & format ! ( "Invalid DEK: {}" , e) ) ) ?;
755+
756+ let nonce = fula_crypto:: symmetric:: Nonce :: from_bytes ( & nonce_bytes)
757+ . map_err ( |e| JsError :: new ( & format ! ( "Invalid nonce: {}" , e) ) ) ?;
758+ let aead = fula_crypto:: symmetric:: Aead :: new_default ( & dek) ;
759+ let plaintext = aead. decrypt ( & nonce, & ciphertext)
760+ . map_err ( |e| JsError :: new ( & format ! ( "AES-GCM decryption failed: {}" , e) ) ) ?;
761+
762+ Ok ( plaintext)
763+ }
764+
765+ /// Full encryption round-trip test
766+ ///
767+ /// This tests the EXACT flow: derive key -> encrypt DEK with HPKE -> encrypt data with AES-GCM
768+ /// then decrypt in reverse order. If this works, the WASM crypto is correct.
769+ ///
770+ /// @param context - Key derivation context (e.g., "fula-files-v1")
771+ /// @param input - Key derivation input (e.g., credentials)
772+ /// @param plaintext - Data to encrypt and decrypt
773+ /// @returns Original plaintext if successful (proves round-trip works)
774+ #[ wasm_bindgen( js_name = testFullEncryptionRoundtrip) ]
775+ pub fn test_full_encryption_roundtrip (
776+ context : & str ,
777+ input : & [ u8 ] ,
778+ plaintext : & [ u8 ] ,
779+ ) -> Result < Vec < u8 > , JsError > {
780+ // Step 1: Derive key using Argon2id
781+ let secret_bytes = fula_crypto:: hashing:: derive_key_argon2id ( context, input) ;
782+
783+ // Step 2: Create keypair
784+ let secret = fula_crypto:: SecretKey :: from_bytes ( & secret_bytes)
785+ . map_err ( |e| JsError :: new ( & format ! ( "Invalid derived key: {}" , e) ) ) ?;
786+ let public = secret. public_key ( ) ;
787+
788+ // Step 3: Generate random DEK
789+ let dek = fula_crypto:: keys:: DekKey :: generate ( ) ;
790+
791+ // Step 4: Encrypt DEK with HPKE (simulating FxFiles upload)
792+ let encryptor = fula_crypto:: hpke:: Encryptor :: new ( & public) ;
793+ let wrapped_dek = encryptor. encrypt_dek ( & dek)
794+ . map_err ( |e| JsError :: new ( & format ! ( "HPKE DEK encryption failed: {}" , e) ) ) ?;
795+
796+ // Step 5: Encrypt plaintext with AES-GCM
797+ let nonce = fula_crypto:: symmetric:: Nonce :: generate ( ) ;
798+ let aead = fula_crypto:: symmetric:: Aead :: new_default ( & dek) ;
799+ let ciphertext = aead. encrypt ( & nonce, plaintext)
800+ . map_err ( |e| JsError :: new ( & format ! ( "AES-GCM encryption failed: {}" , e) ) ) ?;
801+
802+ // --- Simulating WebUI decryption ---
803+
804+ // Step 6: Derive key again (same as WebUI would do)
805+ let secret_bytes_2 = fula_crypto:: hashing:: derive_key_argon2id ( context, input) ;
806+ let secret_2 = fula_crypto:: SecretKey :: from_bytes ( & secret_bytes_2)
807+ . map_err ( |e| JsError :: new ( & format ! ( "Invalid derived key on decrypt: {}" , e) ) ) ?;
808+
809+ // Step 7: Decrypt DEK with HPKE (simulating WebUI WASM)
810+ let key_manager = fula_crypto:: keys:: KeyManager :: from_secret_key ( secret_2) ;
811+ let decryptor = fula_crypto:: hpke:: Decryptor :: new ( key_manager. keypair ( ) ) ;
812+ let decrypted_dek = decryptor. decrypt_dek ( & wrapped_dek)
813+ . map_err ( |e| JsError :: new ( & format ! ( "HPKE DEK decryption failed: {}" , e) ) ) ?;
814+
815+ // Step 8: Decrypt plaintext with AES-GCM
816+ let aead_decrypt = fula_crypto:: symmetric:: Aead :: new_default ( & decrypted_dek) ;
817+ let decrypted = aead_decrypt. decrypt ( & nonce, & ciphertext)
818+ . map_err ( |e| JsError :: new ( & format ! ( "AES-GCM decryption failed: {}" , e) ) ) ?;
819+
820+ Ok ( decrypted)
821+ }
822+
611823// ============================================================================
612824// Tests
613825// ============================================================================
0 commit comments