Skip to content

Commit b75fb55

Browse files
committed
feat(sdk): DSPX-3309 add hybrid post-quantum key wrapping for KAS (X-Wing, ECDH+ML-KEM)
1 parent 63b49af commit b75fb55

7 files changed

Lines changed: 896 additions & 2 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package io.opentdf.platform.sdk;
2+
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;
17+
import java.security.MessageDigest;
18+
import java.security.NoSuchAlgorithmException;
19+
import java.util.Base64;
20+
21+
/**
22+
* Dispatcher and shared helpers for hybrid post-quantum key wrapping
23+
* (X-Wing and NIST EC + ML-KEM). Mirrors the lib/ocrypto Go package.
24+
*
25+
* Wire format: ASN.1 DER SEQUENCE with two IMPLICIT context-tagged OCTET STRINGs
26+
* SEQUENCE { [0] IMPLICIT OCTET STRING ciphertext, [1] IMPLICIT OCTET STRING encryptedDEK }
27+
*
28+
* Derived AES-256 wrap key: HKDF-SHA256(combinedSecret, salt=SHA-256("TDF"), info=empty).
29+
* EncryptedDEK: AES-256-GCM(wrapKey).encrypt(DEK) with 12-byte IV prefix + 16-byte tag.
30+
*/
31+
final class HybridCrypto {
32+
33+
static final int WRAP_KEY_SIZE = 32;
34+
35+
private HybridCrypto() {}
36+
37+
/**
38+
* Wrap a DEK against a hybrid public-key PEM. Dispatches across X-Wing and NIST hybrid types.
39+
* Returns the ASN.1-encoded envelope used in {@code wrappedKey} for {@code hybrid-wrapped} key access.
40+
*/
41+
static byte[] wrapDEK(KeyType keyType, String publicKeyPEM, byte[] dek) {
42+
switch (keyType) {
43+
case HybridXWingKey:
44+
return XWingKeyPair.wrapDEK(XWingKeyPair.pubKeyFromPem(publicKeyPEM), dek);
45+
case HybridSecp256r1MLKEM768Key:
46+
return HybridNISTKeyPair.P256_MLKEM768.wrapDEK(
47+
HybridNISTKeyPair.P256_MLKEM768.pubKeyFromPem(publicKeyPEM), dek);
48+
case HybridSecp384r1MLKEM1024Key:
49+
return HybridNISTKeyPair.P384_MLKEM1024.wrapDEK(
50+
HybridNISTKeyPair.P384_MLKEM1024.pubKeyFromPem(publicKeyPEM), dek);
51+
default:
52+
throw new SDKException("unsupported hybrid key type: " + keyType);
53+
}
54+
}
55+
56+
/**
57+
* Build the ASN.1 envelope from a hybrid KEM ciphertext and the AES-GCM(iv||ct) encrypted DEK.
58+
*/
59+
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+
}
68+
}
69+
70+
/**
71+
* Parse the ASN.1 envelope. Returns {@code [hybridCiphertext, encryptedDEK]}.
72+
* Rejects trailing bytes (matches the Go {@code asn1.Unmarshal} strict behaviour).
73+
*/
74+
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);
92+
}
93+
}
94+
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());
99+
}
100+
return org.bouncycastle.asn1.ASN1OctetString.getInstance(tagged, false).getOctets();
101+
}
102+
103+
/**
104+
* HKDF-SHA256 → 32-byte AES wrap key. {@code salt=null} substitutes the default TDF salt.
105+
*/
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;
113+
}
114+
115+
/**
116+
* SHA-256("TDF") — matches the Go {@code defaultTDFSalt()} and Java {@code TDF.GLOBAL_KEY_SALT}.
117+
*/
118+
static byte[] defaultTDFSalt() {
119+
try {
120+
MessageDigest d = MessageDigest.getInstance("SHA-256");
121+
d.update("TDF".getBytes());
122+
return d.digest();
123+
} catch (NoSuchAlgorithmException e) {
124+
throw new SDKException("SHA-256 not available", e);
125+
}
126+
}
127+
128+
/**
129+
* Encode a raw key into a PEM block with the given header type.
130+
*/
131+
static String rawToPem(String blockType, byte[] raw, int expectedSize) {
132+
if (raw.length != expectedSize) {
133+
throw new SDKException("invalid " + blockType + " size: got " + raw.length + " want " + expectedSize);
134+
}
135+
String b64 = Base64.getMimeEncoder(64, new byte[] { '\n' }).encodeToString(raw);
136+
return "-----BEGIN " + blockType + "-----\n" + b64 + "\n-----END " + blockType + "-----\n";
137+
}
138+
139+
/**
140+
* Decode a PEM block of the expected type and content size. Strict on header type and size.
141+
*/
142+
static byte[] decodeSizedPemBlock(String pem, String expectedType, int expectedSize) {
143+
String header = "-----BEGIN " + expectedType + "-----";
144+
String footer = "-----END " + expectedType + "-----";
145+
int headerIdx = pem.indexOf(header);
146+
int footerIdx = pem.indexOf(footer);
147+
if (headerIdx < 0 || footerIdx < 0 || footerIdx <= headerIdx) {
148+
throw new SDKException("failed to parse PEM formatted " + expectedType);
149+
}
150+
String body = pem.substring(headerIdx + header.length(), footerIdx).replaceAll("\\s", "");
151+
byte[] raw;
152+
try {
153+
raw = Base64.getDecoder().decode(body);
154+
} catch (IllegalArgumentException e) {
155+
throw new SDKException("failed to base64-decode " + expectedType + " PEM body", e);
156+
}
157+
if (raw.length != expectedSize) {
158+
throw new SDKException("invalid " + expectedType + " size: got " + raw.length + " want " + expectedSize);
159+
}
160+
return raw;
161+
}
162+
}

0 commit comments

Comments
 (0)