Skip to content

Commit d666c07

Browse files
committed
make hybrid PQC provider-agnostic
1 parent 8f046da commit d666c07

5 files changed

Lines changed: 242 additions & 121 deletions

File tree

scripts/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ artifacts.
3333
| **JDK 17** | The project's Kotlin compiler can't parse newer JDK version strings. Use Corretto/Temurin/etc. 17. On macOS: `export JAVA_HOME=$(/usr/libexec/java_home -v 17)`. |
3434
| **Maven 3.9+** | Project uses standard `mvn clean install`. |
3535
| **Buf token** | Proto generation requires auth. Either `buf registry login` once, or export `BUF_INPUT_HTTPS_USERNAME` / `BUF_INPUT_HTTPS_PASSWORD`. |
36+
| **`non-fips` Maven profile (default)** | Hybrid PQC needs `bcprov-jdk18on` at compile/runtime scope for the ML-KEM and X-Wing primitives (no JDK 11 stdlib equivalent). The default `non-fips` profile pulls it in automatically. The `fips` profile does not yet support hybrid PQC — follow-up. |
3637
| **Local platform with PQC support** | `opentdf/platform` checked out on a branch that implements `hpqt:*` KAS keys + the `hybrid-wrapped` rewrap path. See the platform repo for bring-up (`docker compose` / `make start`). |
3738
| **Hybrid KAS keys registered** | The local platform must have a KAS key registered for each `hpqt:*` algorithm you intend to test. Use `otdfctl` (or platform tooling) to register them. |
3839
| **CLI tools** | `java`, `mvn`, `unzip`, `jq` on `PATH`. `grpcurl` optional but recommended (drives the pre-flight check). |

sdk/pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,13 @@
483483
<activeByDefault>true</activeByDefault>
484484
</activation>
485485
<dependencies>
486+
<!-- Required for hybrid post-quantum key wrapping (ML-KEM, X-Wing).
487+
No JDK 11 stdlib equivalent exists; provider-agnostic JCA
488+
APIs for KEM are JDK 21+. See adr/0001 for context. -->
489+
<dependency>
490+
<groupId>org.bouncycastle</groupId>
491+
<artifactId>bcprov-jdk18on</artifactId>
492+
</dependency>
486493
<dependency>
487494
<groupId>org.bouncycastle</groupId>
488495
<artifactId>bcpkix-jdk18on</artifactId>

sdk/src/main/java/io/opentdf/platform/sdk/HybridCrypto.java

Lines changed: 116 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,12 @@
11
package io.opentdf.platform.sdk;
22

3-
import org.bouncycastle.asn1.ASN1EncodableVector;
4-
import org.bouncycastle.asn1.ASN1InputStream;
5-
import org.bouncycastle.asn1.ASN1Primitive;
6-
import org.bouncycastle.asn1.ASN1Sequence;
7-
import org.bouncycastle.asn1.ASN1TaggedObject;
8-
import org.bouncycastle.asn1.DEROctetString;
9-
import org.bouncycastle.asn1.DERSequence;
10-
import org.bouncycastle.asn1.DERTaggedObject;
11-
import org.bouncycastle.crypto.digests.SHA256Digest;
12-
import org.bouncycastle.crypto.generators.HKDFBytesGenerator;
13-
import org.bouncycastle.crypto.params.HKDFParameters;
14-
15-
import java.io.ByteArrayInputStream;
16-
import java.io.IOException;
173
import java.security.MessageDigest;
184
import java.security.NoSuchAlgorithmException;
195
import java.util.Base64;
206

217
/**
228
* Dispatcher and shared helpers for hybrid post-quantum key wrapping
23-
* (X-Wing and NIST EC + ML-KEM). Mirrors the lib/ocrypto Go package.
9+
* (X-Wing and NIST EC + ML-KEM).
2410
*
2511
* Wire format: ASN.1 DER SEQUENCE with two IMPLICIT context-tagged OCTET STRINGs
2612
* SEQUENCE { [0] IMPLICIT OCTET STRING ciphertext, [1] IMPLICIT OCTET STRING encryptedDEK }
@@ -32,6 +18,11 @@ final class HybridCrypto {
3218

3319
static final int WRAP_KEY_SIZE = 32;
3420

21+
// ASN.1 tag bytes used by the envelope.
22+
private static final int TAG_SEQUENCE = 0x30;
23+
private static final int TAG_CONTEXT_PRIMITIVE_0 = 0x80;
24+
private static final int TAG_CONTEXT_PRIMITIVE_1 = 0x81;
25+
3526
private HybridCrypto() {}
3627

3728
/**
@@ -57,59 +48,131 @@ static byte[] wrapDEK(KeyType keyType, String publicKeyPEM, byte[] dek) {
5748
* Build the ASN.1 envelope from a hybrid KEM ciphertext and the AES-GCM(iv||ct) encrypted DEK.
5849
*/
5950
static byte[] marshalEnvelope(byte[] hybridCiphertext, byte[] encryptedDEK) {
60-
ASN1EncodableVector v = new ASN1EncodableVector();
61-
v.add(new DERTaggedObject(false, 0, new DEROctetString(hybridCiphertext)));
62-
v.add(new DERTaggedObject(false, 1, new DEROctetString(encryptedDEK)));
63-
try {
64-
return new DERSequence(v).getEncoded("DER");
65-
} catch (IOException e) {
66-
throw new SDKException("failed to encode hybrid wrapped key envelope", e);
67-
}
51+
byte[] body = concat(
52+
encodeTLV(TAG_CONTEXT_PRIMITIVE_0, hybridCiphertext),
53+
encodeTLV(TAG_CONTEXT_PRIMITIVE_1, encryptedDEK));
54+
return encodeTLV(TAG_SEQUENCE, body);
6855
}
6956

7057
/**
7158
* Parse the ASN.1 envelope. Returns {@code [hybridCiphertext, encryptedDEK]}.
7259
* Rejects trailing bytes (matches the Go {@code asn1.Unmarshal} strict behaviour).
7360
*/
7461
static byte[][] unmarshalEnvelope(byte[] der) {
75-
try (ASN1InputStream in = new ASN1InputStream(new ByteArrayInputStream(der))) {
76-
ASN1Primitive prim = in.readObject();
77-
if (prim == null) {
78-
throw new SDKException("hybrid wrapped key envelope is empty");
79-
}
80-
if (in.readObject() != null) {
81-
throw new SDKException("hybrid wrapped key envelope has trailing bytes");
82-
}
83-
ASN1Sequence seq = ASN1Sequence.getInstance(prim);
84-
if (seq.size() != 2) {
85-
throw new SDKException("hybrid wrapped key envelope must have 2 elements, got " + seq.size());
86-
}
87-
byte[] hybridCt = readImplicitOctetString(seq.getObjectAt(0), 0);
88-
byte[] encDek = readImplicitOctetString(seq.getObjectAt(1), 1);
89-
return new byte[][] { hybridCt, encDek };
90-
} catch (IOException e) {
91-
throw new SDKException("failed to decode hybrid wrapped key envelope", e);
62+
Cursor c = new Cursor(der, 0);
63+
int tag = c.readByte();
64+
if (tag != TAG_SEQUENCE) {
65+
throw new SDKException("expected ASN.1 SEQUENCE (0x30), got 0x" + Integer.toHexString(tag));
66+
}
67+
int seqLen = readLength(c);
68+
int seqEnd = c.pos + seqLen;
69+
if (seqEnd > der.length) {
70+
throw new SDKException("hybrid wrapped key envelope length exceeds buffer");
71+
}
72+
if (seqEnd != der.length) {
73+
throw new SDKException("hybrid wrapped key envelope has trailing bytes");
74+
}
75+
byte[] hybridCt = readImplicitOctetString(c, 0);
76+
byte[] encDek = readImplicitOctetString(c, 1);
77+
if (c.pos != seqEnd) {
78+
throw new SDKException("hybrid wrapped key envelope SEQUENCE has trailing bytes");
79+
}
80+
return new byte[][] { hybridCt, encDek };
81+
}
82+
83+
private static byte[] readImplicitOctetString(Cursor c, int expectedTagNo) {
84+
int expectedTag = TAG_CONTEXT_PRIMITIVE_0 | expectedTagNo;
85+
int tag = c.readByte();
86+
if (tag != expectedTag) {
87+
throw new SDKException("expected context tag " + expectedTagNo
88+
+ " (0x" + Integer.toHexString(expectedTag) + ") but got 0x" + Integer.toHexString(tag));
89+
}
90+
int len = readLength(c);
91+
if (c.pos + len > c.buf.length) {
92+
throw new SDKException("context-tagged element length exceeds buffer");
93+
}
94+
byte[] out = new byte[len];
95+
System.arraycopy(c.buf, c.pos, out, 0, len);
96+
c.pos += len;
97+
return out;
98+
}
99+
100+
private static byte[] encodeTLV(int tag, byte[] content) {
101+
byte[] lenBytes = encodeLength(content.length);
102+
byte[] out = new byte[1 + lenBytes.length + content.length];
103+
out[0] = (byte) tag;
104+
System.arraycopy(lenBytes, 0, out, 1, lenBytes.length);
105+
System.arraycopy(content, 0, out, 1 + lenBytes.length, content.length);
106+
return out;
107+
}
108+
109+
private static byte[] encodeLength(int len) {
110+
if (len < 0) {
111+
throw new SDKException("negative ASN.1 length: " + len);
112+
}
113+
if (len < 0x80) {
114+
return new byte[] { (byte) len };
115+
}
116+
// Long form: 0x80 | numBytes, then big-endian length bytes.
117+
int numBytes = 0;
118+
int tmp = len;
119+
while (tmp > 0) { numBytes++; tmp >>>= 8; }
120+
byte[] out = new byte[1 + numBytes];
121+
out[0] = (byte) (0x80 | numBytes);
122+
for (int i = numBytes; i > 0; i--) {
123+
out[i] = (byte) (len & 0xFF);
124+
len >>>= 8;
92125
}
126+
return out;
93127
}
94128

95-
private static byte[] readImplicitOctetString(org.bouncycastle.asn1.ASN1Encodable enc, int expectedTag) {
96-
ASN1TaggedObject tagged = ASN1TaggedObject.getInstance(enc);
97-
if (tagged.getTagNo() != expectedTag) {
98-
throw new SDKException("expected context tag " + expectedTag + " but got " + tagged.getTagNo());
129+
private static int readLength(Cursor c) {
130+
int first = c.readByte();
131+
if ((first & 0x80) == 0) {
132+
return first;
133+
}
134+
int numBytes = first & 0x7F;
135+
if (numBytes == 0 || numBytes > 4) {
136+
// indefinite-length (numBytes == 0) is BER-only; DER rejects it.
137+
// > 4 would overflow a positive 32-bit int and is implausible for our envelope.
138+
throw new SDKException("invalid ASN.1 length encoding: numBytes=" + numBytes);
139+
}
140+
int len = 0;
141+
for (int i = 0; i < numBytes; i++) {
142+
len = (len << 8) | c.readByte();
143+
}
144+
if (len < 0) {
145+
throw new SDKException("ASN.1 length overflowed signed int");
99146
}
100-
return org.bouncycastle.asn1.ASN1OctetString.getInstance(tagged, false).getOctets();
147+
return len;
148+
}
149+
150+
private static final class Cursor {
151+
final byte[] buf;
152+
int pos;
153+
Cursor(byte[] buf, int pos) { this.buf = buf; this.pos = pos; }
154+
int readByte() {
155+
if (pos >= buf.length) {
156+
throw new SDKException("unexpected end of ASN.1 input at offset " + pos);
157+
}
158+
return buf[pos++] & 0xFF;
159+
}
160+
}
161+
162+
private static byte[] concat(byte[] a, byte[] b) {
163+
byte[] out = new byte[a.length + b.length];
164+
System.arraycopy(a, 0, out, 0, a.length);
165+
System.arraycopy(b, 0, out, a.length, b.length);
166+
return out;
101167
}
102168

103169
/**
104-
* HKDF-SHA256 → 32-byte AES wrap key. {@code salt=null} substitutes the default TDF salt.
170+
* HKDF-SHA256 → 32-byte AES wrap key. Delegates to
171+
* {@link ECKeyPair#calculateHKDF(byte[], byte[])} (HKDF-Extract + Expand,
172+
* empty info, L = 32 — the parameters all three hybrid algorithms use).
105173
*/
106-
static byte[] deriveWrapKey(byte[] combinedSecret, byte[] salt, byte[] info) {
107-
byte[] effSalt = (salt == null || salt.length == 0) ? defaultTDFSalt() : salt;
108-
HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA256Digest());
109-
hkdf.init(new HKDFParameters(combinedSecret, effSalt, info));
110-
byte[] out = new byte[WRAP_KEY_SIZE];
111-
hkdf.generateBytes(out, 0, out.length);
112-
return out;
174+
static byte[] deriveWrapKey(byte[] combinedSecret) {
175+
return ECKeyPair.calculateHKDF(defaultTDFSalt(), combinedSecret);
113176
}
114177

115178
/**

0 commit comments

Comments
 (0)