Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 6 additions & 34 deletions lib/ocrypto/BENCHMARK_REPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ cd lib/ocrypto && go test -bench=. -benchmem -count=1 -timeout=5m
cd lib/ocrypto && go test -bench=BenchmarkKeyGeneration -benchmem
cd lib/ocrypto && go test -bench=BenchmarkWrapDEK -benchmem
cd lib/ocrypto && go test -bench=BenchmarkUnwrapDEK -benchmem
cd lib/ocrypto && go test -bench=BenchmarkHybridSubOps -benchmem

# Wrapped key size comparison table
cd lib/ocrypto && go test -v -run TestWrappedKeySizeComparison
Expand Down Expand Up @@ -93,39 +92,12 @@ These benchmarks follow the KAS unwrap paths:

## Analysis: Where Time Is Spent

The `BenchmarkHybridSubOps` benchmarks break down hybrid wrap operations into their constituent parts:

### X-Wing Sub-Operations

| Operation | Time | % of Wrap |
|-----------|-----:|----------:|
| Encapsulate (X25519 + ML-KEM-768) | 71.6 us | 92.5% |
| HKDF key derivation | 0.49 us | 0.6% |
| AES-GCM encrypt (32B DEK) | 0.37 us | 0.5% |
| ASN.1 marshal | 0.52 us | 0.7% |
| PEM parsing + overhead | ~4.4 us | 5.7% |

### P256+ML-KEM-768 Sub-Operations

| Operation | Time | % of Wrap |
|-----------|-----:|----------:|
| Encapsulate (ECDH P-256 + ML-KEM-768) | 70.0 us | 93.1% |
| HKDF key derivation | 0.51 us | 0.7% |
| AES-GCM encrypt (32B DEK) | 0.37 us | 0.5% |
| ASN.1 marshal | 0.51 us | 0.7% |
| PEM parsing + overhead | ~3.8 us | 5.1% |

### P384+ML-KEM-1024 Sub-Operations

| Operation | Time | % of Wrap |
|-----------|-----:|----------:|
| Encapsulate (ECDH P-384 + ML-KEM-1024) | 359.9 us | 97.3% |
| HKDF key derivation | 0.51 us | 0.1% |
| AES-GCM encrypt (32B DEK) | 0.37 us | 0.1% |
| ASN.1 marshal | 0.54 us | 0.1% |
| PEM parsing + overhead | ~8.6 us | 2.3% |

**Conclusion:** KEM encapsulation dominates all hybrid schemes at 93-97% of total time. HKDF, AES-GCM, and ASN.1 marshaling are all sub-microsecond and negligible. The P-384 elliptic curve ECDH is ~5x slower than P-256, which is why P384+ML-KEM-1024 is significantly slower than P256+ML-KEM-768.
KEM encapsulation dominates all hybrid schemes (~93-97% of total wrap time);
HKDF, AES-GCM, and ASN.1 marshaling are all sub-microsecond. The P-384
elliptic curve ECDH is ~5x slower than P-256, which is why P384+ML-KEM-1024
is significantly slower than P256+ML-KEM-768. Per-sub-op figures were
captured under a one-off benchmark that has since been removed; re-introduce
with `pprof` if a more granular breakdown is needed.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## Manifest Size Impact

Expand Down
249 changes: 129 additions & 120 deletions lib/ocrypto/HYBRID_NIST_KEY_WRAPPING.md

Large diffs are not rendered by default.

53 changes: 46 additions & 7 deletions lib/ocrypto/asym_decryption.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,20 @@ func FromPrivatePEMWithSalt(privateKeyInPem string, salt, info []byte) (PrivateK
if block == nil {
return AsymDecryption{}, errors.New("failed to parse PEM formatted private key")
}
switch block.Type {
case PEMBlockXWingPrivateKey:
return NewSaltedXWingDecryptor(block.Bytes, salt, info)
case PEMBlockP256MLKEM768PrivateKey:
return NewSaltedP256MLKEM768Decryptor(block.Bytes, salt, info)
case PEMBlockP384MLKEM1024PrivateKey:
return NewSaltedP384MLKEM1024Decryptor(block.Bytes, salt, info)

// Hybrid PQ/T private keys are PKCS#8-wrapped under one of our known OIDs.
// Peek at the AlgorithmIdentifier and route hybrids to their constructors;
// everything else (RSA, EC, EC PRIVATE KEY) falls through to x509.
if block.Type == pemBlockPrivateKey {
if dec, matched, err := hybridDecryptorFromPKCS8(block.Bytes, salt, info); matched {
return dec, err
}
}
// Reject CERTIFICATE blocks containing a hybrid SPKI: certificates are not
// supported as a private-key transport, but operators sometimes paste them
// here by mistake. Symmetric with the public-key path.
if block.Type == pemBlockCertificate && containsHybridOID(block.Bytes) {
return AsymDecryption{}, errors.New("certificate-wrapped hybrid keys are not supported; provide a bare PKCS#8 PRIVATE KEY")
}

priv, err := x509.ParsePKCS8PrivateKey(block.Bytes)
Expand Down Expand Up @@ -202,6 +209,38 @@ func (e ECDecryptor) DecryptWithEphemeralKey(data, ephemeral []byte) ([]byte, er
return plaintext, nil
}

// hybridDecryptorFromPKCS8 mirrors hybridEncryptorFromSPKI for PKCS#8 private
// keys. The `matched` return reports whether the dispatcher owns the result:
// when true, the caller MUST return whatever this function returns. When
// false, the caller falls through to the legacy RSA/EC PKCS#8 / PKCS#1 path.
// Salt/info are honoured only for X-Wing.
func hybridDecryptorFromPKCS8(der, salt, info []byte) (PrivateKeyDecryptor, bool, error) {
oid, raw, parseErr := parseHybridPKCS8(der)
if parseErr != nil {
// Structurally not a PKCS#8 envelope (e.g. PKCS#1 RSA or EC PRIVATE
// KEY). Fall through to the legacy decoder.
return nil, false, nil //nolint:nilerr // intentional fall-through on non-envelope input
}
switch {
case oid.Equal(oidXWing):
dec, err := NewSaltedXWingDecryptor(raw, salt, info)
return dec, true, err
case oid.Equal(oidCompositeMLKEM768P256):
dec, err := NewP256MLKEM768Decryptor(raw)
return dec, true, err
case oid.Equal(oidCompositeMLKEM1024P384):
dec, err := NewP384MLKEM1024Decryptor(raw)
return dec, true, err
}
// Valid PKCS#8 envelope with a non-hybrid OID. If the stdlib recognises it,
// fall through. Otherwise surface a precise "unknown OID" error so the
// caller doesn't end up reporting a confusing PKCS#1/EC-Private-Key error.
if _, x509Err := x509.ParsePKCS8PrivateKey(der); x509Err == nil {
return nil, false, nil
}
return nil, true, fmt.Errorf("unsupported private-key algorithm OID %s: not a known hybrid scheme and not recognised by crypto/x509", oid)
}

func convCurve(c ecdh.Curve) elliptic.Curve {
switch c {
case ecdh.P256():
Expand Down
57 changes: 49 additions & 8 deletions lib/ocrypto/asym_encryption.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,19 @@ func FromPublicPEMWithSalt(publicKeyInPem string, salt, info []byte) (PublicKeyE
if block == nil {
return nil, errors.New("failed to parse PEM formatted public key")
}
switch block.Type {
case PEMBlockXWingPublicKey:
return NewXWingEncryptor(block.Bytes, salt, info)
case PEMBlockP256MLKEM768PublicKey:
return NewP256MLKEM768Encryptor(block.Bytes, salt, info)
case PEMBlockP384MLKEM1024PublicKey:
return NewP384MLKEM1024Encryptor(block.Bytes, salt, info)

// Hybrid PQ/T public keys are SPKI-wrapped under one of our known OIDs.
// Peek at the AlgorithmIdentifier and route hybrids to their constructors;
// everything else (RSA, EC, CERTIFICATE) falls through to the x509 path.
if block.Type == pemBlockPublicKey {
if enc, matched, err := hybridEncryptorFromSPKI(block.Bytes, salt, info); matched {
return enc, err
}
}
// X.509 certificates carrying a hybrid SPKI are out of scope; reject them
// with a clear message so operators don't see a confusing x509 parse error.
if block.Type == pemBlockCertificate && containsHybridOID(block.Bytes) {
return nil, errors.New("certificate-wrapped hybrid keys are not supported; provide a bare SPKI PUBLIC KEY")
}

pub, err := getPublicPart(publicKeyInPem)
Expand Down Expand Up @@ -237,7 +243,7 @@ func publicKeyInPemFormat(pk any) (string, error) {

publicKeyPem := pem.EncodeToMemory(
&pem.Block{
Type: "PUBLIC KEY",
Type: pemBlockPublicKey,
Bytes: publicKeyBytes,
},
)
Expand Down Expand Up @@ -282,3 +288,38 @@ func (e ECEncryptor) Encrypt(data []byte) ([]byte, error) {
func (e ECEncryptor) PublicKeyInPemFormat() (string, error) {
return publicKeyInPemFormat(e.ek.Public())
}

// hybridEncryptorFromSPKI tries to decode `der` as a hybrid PQ/T
// SubjectPublicKeyInfo. The `matched` return reports whether the dispatcher
// owns the result: when true, the caller MUST return whatever this function
// returns (encryptor or error) without trying the legacy x509 path. When
// false, the caller falls through to the standard RSA/EC handling.
// Salt/info are honoured only for X-Wing (the NIST composite-KEM hybrids
// derive their wrap key without them).
func hybridEncryptorFromSPKI(der, salt, info []byte) (PublicKeyEncryptor, bool, error) {
oid, raw, parseErr := parseHybridSPKI(der)
if parseErr != nil {
// Structurally not an SPKI envelope. Fall through to the legacy path,
// which handles PKCS#1 keys, certificates, and stdlib-recognised SPKI.
return nil, false, nil //nolint:nilerr // intentional fall-through on non-envelope input
}
switch {
case oid.Equal(oidXWing):
enc, err := NewXWingEncryptor(raw, salt, info)
return enc, true, err
case oid.Equal(oidCompositeMLKEM768P256):
enc, err := NewP256MLKEM768Encryptor(raw)
return enc, true, err
case oid.Equal(oidCompositeMLKEM1024P384):
enc, err := NewP384MLKEM1024Encryptor(raw)
return enc, true, err
}
// Valid SPKI envelope with a non-hybrid OID. If the stdlib recognises it,
// fall through so the legacy RSA/EC path can handle it. Otherwise, surface
// a precise error rather than letting x509 return its generic message —
// that prevents an unknown OID from being silently retried as RSA/EC.
if _, x509Err := x509.ParsePKIXPublicKey(der); x509Err == nil {
return nil, false, nil
}
return nil, true, fmt.Errorf("unsupported public-key algorithm OID %s: not a known hybrid scheme and not recognised by crypto/x509", oid)
}
Loading
Loading