Conversation
The implementation supports all KEM, HKDF and AEAD variants defined in
the RFC. Four additional custom KEMs based on Bouncy Castles RSA-KEM
from ISO 18033-2 are supported:
- RSAKEM_1024_KDF2_SHA256 ID: 0xFF10
- RSAKEM_2048_KDF2_SHA256 ID: 0xFF11
- RSAKEM_3072_KDF2_SHA512 ID: 0xFF12
- RSAKEM_4096_KDF2_SHA512 ID: 0xFF13
The implementation defines a chunked wire-format for HPKE with the
general structure: [header][encapsulation][chunk0]...[chunkN].
The header is defined as: ["HPKEF"][version, 1 byte][mode (base, pks), 1
byte][kem-id, 2 bytes][kdf-id, 2 byte][aead-id, 2 byte][chunkLengthLevel
((chunkLength / 4096) - 1), 1 byte][receiver-key-id, 32
bytes][pre-shared-key-id 32 bytes, only if mode = psk].
The AAD Tag follows the form: [header][sequence, 12
bytes][final-chunk-flag, 1 byte]. The header is is also used as the info
value for the key-schedule.
Chunk initialization vectors are calculated by {baseNonce XOR sequence}
the sequence in incremented for every chunk.
- PskProvider renamed to PreSharedKeyProvider, no acronym in interface name - PreSharedKeyProvider and ReceiverKeyProvider now extends KeyProvider and have identical convenience methods that delegated to implementations in KeyProvider - Implementation of the old PskProvider.fromMap fixed, renamed and moved to KeyProvider
- Fixed of by one error in i2osp2. - Fixed return signature of os2ip method: int -> long. - Fixed integer wrap around error in os2ip for input 0xFFFFFFFF. - Reworked ByteEncoding implementation to define all edge cases.
- Fixed the iv generation error in the ChunkedInputStreamEnumeration, sequence for the first chunk was 1 and not 0 as the initial sequence value was never used. - Refactored and improved the ChunkedInputStreamEnumeration: As we are reading one chunk ahead IOExcpetions thrown by the read operation of the next chunk are now thrown by the next execution of nextElement() and not immediately so that the current chunk is always emitted even if the next has an error. - Added tests for ChunkedInputStreamEnumeration including a test against the RFC 9180 A.1.1. test vectors. - Some code cleanup, improved javadoc, renamed variables.
Replace Bouncy-Castle code in RsaKemWrapper for the doGetSecretKey implementation with standard JCE code, so that the access to the private exponent is not required.
Chunk length exponent added to key-schedule info field as it was the only missing value from the ProtocolV1 header that was not already part of the key-schedule via the RFC inputs or the previously used info [HPKEF][0x01]. Info for ProtocolV1 now defined as [HPKEF][0x01][chunkLengthExponent, 1 Byte].
New test to check if modifications to the header result in failed decryption
|
You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool. What Enabling Code Scanning Means:
For more information about GitHub Code Scanning, check out the documentation. |
There was a problem hiding this comment.
Claude says:
Architecture overview
An RFC 9180 implementation (modes 0 and 1) with a custom chunk-based wire format for streaming large payloads:
[MAGIC "HPKEF"][version 0x01][ProtocolV1 header][encapsulation][chunk0]...[chunkN]
| Layer | Files | Responsibility |
|---|---|---|
| Wire format | ProtocolFactory, ProtocolV1 |
versioned header with magic |
| KEM | KemId, DhKemWrapper, RsaKemWrapper, AbstractKemWrapper |
encap/decap |
| Key schedule | KeySchedule |
RFC 9180 §5.1 |
| AEAD + chunking | AeadId, ChunkedInputStreamEnumeration, ChunkLength |
per-chunk crypto |
| Streaming | SequenceInputStreamForRuntimeIOException, RuntimeIOException |
exception bridging |
| Key lookup | KeyProvider, PreSharedKeyProvider, ReceiverPrivateKeyProvider |
ID → key |
Strengths
- RFC 9180 §5.1 key schedule is faithful (
KeySchedule.java:54-88): correctsuite_idconstruction, correct labels (HPKE-v1,psk_id_hash,info_hash,secret,key,base_nonce), andkey_schedule_context = mode ‖ psk_id_hash ‖ info_hash. The RFC test-vector test (commitef1acb1) is very valuable. - Defense-in-depth length validation in
AbstractKemWrapper.java:32-36,56-57— encapsulation and shared-secret lengths are checked againstKemIdafter every KEM call. - Truncation protection via AAD =
[sequence][finished flag]. Thefinishedflag cryptographically binds the fact that the final chunk really is the last one; if the tail is dropped, the tag check fails rather than silently truncating.HpkeTest.decryptionTruncatedStreamTestexercises this systematically. - Empty-plaintext handling (
ChunkedInputStreamEnumeration.java:86-90,110-111): emits a single empty final chunk so that even empty messages produce an AEAD tag — prevents silent acceptance of a zero-byte stream. - Chunk length bound to the key schedule (commit
7dd5a0d,ProtocolV1.getKdfInfo()): different chunk sizes derive different keys, so an attacker cannot reinterpret a stream encrypted with 1 KiB chunks as 32 MiB chunks. - Sequence overflow raises
RuntimeIOException("Message limit reached")(ChunkedInputStreamEnumeration.java:212) — critical, because nonce reuse would be catastrophic for GCM. - Versioned wire format via
ProtocolFactory.ProtocolSerializerwith a mandatory "no duplicate version" check. - PSK-id and receiver-key-id fixed at exactly 32 bytes and stored in the header — clear, unambiguous selection.
Findings
🟡 MEDIUM — Hpke.decrypt reads the encapsulation with read(byte[]) instead of readNBytes
Hpke.java:197-198:
byte[] encapsulation = new byte[protocol.getKemId().getEncapsulationLength()];
ByteEncoding.expectRead(protocol.getKemId().getEncapsulationLength(), encrypted.read(encapsulation));InputStream.read(byte[]) is allowed to return fewer bytes than the buffer length (e.g. on slow network streams) without indicating EOF. That triggers a false-positive "Truncated stream". ProtocolV1.from already does this correctly with readNBytes. Fix: use the same pattern here:
byte[] encapsulation = encrypted.readNBytes(protocol.getKemId().getEncapsulationLength());
ByteEncoding.expectRead(protocol.getKemId().getEncapsulationLength(), encapsulation.length);🟡 MEDIUM — RSA-KEM uses private-use IDs and should be documented explicitly
KemId uses IDs 0xFF10–0xFF13 for RSA-KEM. RFC 9180 §7.1 reserves 0xFF00–0xFFFF as private use and does not define an RSA-KEM — another HPKE implementation will not interoperate. In addition, the RSA decapsulation via RSA/ECB/NoPadding (raw RSA) plus KDF2 (RsaKemWrapper.java:72-83) is a deliberate custom construction that only becomes IND-CCA2 through the downstream AEAD tag. Both points should be called out explicitly in the Hpke / KemId Javadoc — otherwise a future consumer may assume RFC conformance where there is none.
🟡 MEDIUM — RuntimeIOException.getCause() violates the Throwable contract
RuntimeIOException.java:20-26:
@Override
public synchronized IOException getCause() {
if (super.getCause() != null)
return (IOException) super.getCause();
else
return new IOException(getMessage());
}Throwable.getCause() is allowed to return null per its contract; manufacturing a fresh IOException on every call surprises tooling (logger stack traces, test assertions on identical causes). Recommendation: introduce a dedicated asIOException() method and leave getCause() with its default behavior.
🟡 MEDIUM — KemId.isKeySupported checks RSA bitLength() for exact equality
KemId.java:46:
key instanceof RSAKey rsaKey && rsaKey.getModulus().bitLength() == lengthA nominally 2048-bit key can have bitLength() == 2047 whenever the top bit of the modulus happens to be zero (roughly 50% probability when the second-highest bit is not forced during generation). Keys produced locally via KeyPairGeneratorFactory are fine; externally imported keys (PEM, HSM) may be rejected incorrectly. A more lenient check would be >= length-1 && <= length, or compare ((length+7)/8) * 8 to ((bitLength+7)/8) * 8.
🟢 LOW — Test cosmetic: duplicated unit in display name
HpkeTest.java:58: protocol.getChunkLength() + " KiB" produces e.g. "MiB_32 KiB" — the enum constant already carries the unit in its name.
🟢 LOW — Mode.from(BASE_VALUE, somePsk) silently ignores the supplied pskId
If a caller accidentally constructs BASE mode with a pskId there is no signal. Not reachable through the current reader path, but the API is permissive. Not urgent.
🟢 LOW — ChunkedInputStreamEnumeration.loadNextChunk always allocates a full byte[chunkLength]
Even when the final chunk is small, the full buffer is allocated and copied to lastChunk (:167-168). Noticeable for 32 MiB chunks and small payloads. Would not optimize without a concrete need.
🟢 LOW — SequenceInputStreamForRuntimeIOException does not cover all methods
available(), skip(long), close() are not overridden. In practice these don't raise RuntimeIOException because they only inspect the current InputStream's state. Just noting it.
🟢 LOW — KeySchedule.executeKeySchedule instantiates KDF twice
KeySchedule.java:60,71: kdfId.toKdf() is called twice. Presumably because of JCE KDF internal-state semantics. Not a bug — micro-overhead.
Security argument (cryptographic binding of the header)
Header tampering is detected as follows:
| Tampered field | Consequence |
|---|---|
MAGIC HPKEF |
IOException("Protocol not supported") in ProtocolFactory.read |
| Version | IOException("Protocol not supported") |
| Mode | key-schedule context byte changes → different keys → AEAD tag fails |
| KEM/KDF/AEAD id | feeds into suite_id → different keys → tag fails |
| chunkLengthExponent | now in KDF info (commit 7dd5a0d) → different keys → tag fails |
| receiverKeyId | key lookup fails or returns a different key → decapsulation/tag fails |
| PSK id (mode 1) | feeds into psk_id_hash → tag fails |
testModifiedMessages exercises these paths systematically — very good.
Summary
The feature is solidly built: RFC conformance, defense-in-depth length checks, truncation protection, clean separation of wire format and crypto, good test coverage. The custom extensions (chunked format, RSA-KEM suite) are deliberate. Highest-priority concrete action: the read(byte[]) vs readNBytes bug in Hpke.java:197-198, which breaks streamed input. After that: a Javadoc note about RSA-KEM being private-use, and the getCause() contract violation in RuntimeIOException.
No description provided.