Nice setup. Two things here will make or break the scheme.
You are reusing the IV across all messages.
IvParameterSpec Iv = new IvParameterSpec(SecureRandom.getSeed(16));is executed once, so everyEncrypt(…)andDecrypt(…)call uses the same IV. With AES‑CBC, that is a critical flaw. CBC requires a fresh, unpredictable IV per encryption. Reusing it leaks relationships between first blocks and enables practical attacks. Generate a new IV each time, and store or prefix it with the ciphertext.// per-encrypt call byte[] iv = new byte[16]; // 16 bytes for AES block size SecureRandom rng = new SecureRandom(); rng.nextBytes(iv); Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding"); c.init(Cipher.ENCRYPT_MODE, aesKey, new IvParameterSpec(iv)); byte[] ct = c.doFinal(plaintext); // serialize as: RSA(aesKey) || iv || ctNIST SP 800‑38A explicitly requires an unpredictable IV for CBC; predictable or reused IVs are unsafe.
Lock down OAEP parameters explicitly. You use "RSA/ECB/OAEPWithSHA-256AndMGF1Padding", which is fine, but different providers have different defaults for OAEP internals. Make the hash and MGF explicit to avoid cross‑provider mismatches and subtle downgrade bugs:
Cipher rsa = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding"); OAEPParameterSpec oaep = new OAEPParameterSpec( "SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT ); rsa.init(Cipher.ENCRYPT_MODE, rsaPublicKey, oaep); byte[] encKey = rsa.doFinal(aesKey.getEncoded());
Also define a clear message format, for example: (rsaEncAesKey, iv, ciphertext), and document lengths so you can parse it unambiguously on decrypt. The other answers already discuss hybrid encryption at a high level; this pins down the wire format and provider‑safe OAEP setup.