feat(cert/jks): implement native JCEKS reader (keystore-go rejects JCEKS magic)

This commit is contained in:
Thibault VINCENT
2026-05-15 16:54:25 +02:00
committed by Thibault VINCENT
parent eb9ce37612
commit 58b2ec8d21
2 changed files with 339 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
package jks
import (
"bytes"
"crypto/sha1"
"crypto/x509"
"encoding/binary"
"testing"
"time"
"github.com/enix/x509-certificate-exporter/v4/pkg/cert"
)
// buildJCEKSTrustStore writes a JCEKS truststore by hand. The wire format
// matches OpenJDK's com.sun.crypto.provider.JceKeyStore: only the leading
// magic differs from JKS, and the trailing HMAC algorithm is identical.
//
// Kept self-contained (no library dependency for the encoder) precisely so
// it cannot mask bugs in the decoder under test.
func buildJCEKSTrustStore(t *testing.T, password string, certs ...*x509.Certificate) []byte {
t.Helper()
var buf bytes.Buffer
put := func(v interface{}) {
if err := binary.Write(&buf, binary.BigEndian, v); err != nil {
t.Fatal(err)
}
}
putUTF := func(s string) {
put(uint16(len(s)))
buf.WriteString(s)
}
put(uint32(0xCECECECE)) // JCEKS magic
put(uint32(2)) // version
put(uint32(len(certs)))
nowMS := time.Now().UnixMilli()
for i, c := range certs {
put(uint32(2)) // TrustedCertificateEntry
putUTF("ca" + string(rune('a'+i)))
put(nowMS)
putUTF("X.509")
put(uint32(len(c.Raw)))
buf.Write(c.Raw)
}
h := sha1.New()
for _, b := range []byte(password) {
h.Write([]byte{0x00, b})
}
h.Write([]byte("Mighty Aphrodite"))
h.Write(buf.Bytes())
buf.Write(h.Sum(nil))
return buf.Bytes()
}
// buildJCEKSWithSecretKey returns a JCEKS payload that announces one
// SecretKeyEntry. The body is intentionally truncated — the test only
// verifies the parser rejects the entry type before reading body bytes.
func buildJCEKSWithSecretKey(t *testing.T, password string) []byte {
t.Helper()
var buf bytes.Buffer
put := func(v interface{}) {
if err := binary.Write(&buf, binary.BigEndian, v); err != nil {
t.Fatal(err)
}
}
putUTF := func(s string) {
put(uint16(len(s)))
buf.WriteString(s)
}
put(uint32(0xCECECECE))
put(uint32(2))
put(uint32(1))
put(uint32(3)) // SecretKeyEntry
putUTF("secret-1")
put(int64(time.Now().UnixMilli()))
// No body — decoder must refuse before reading further.
h := sha1.New()
for _, b := range []byte(password) {
h.Write([]byte{0x00, b})
}
h.Write([]byte("Mighty Aphrodite"))
h.Write(buf.Bytes())
buf.Write(h.Sum(nil))
return buf.Bytes()
}
func TestParseJCEKSTrustStore(t *testing.T) {
ca1, _, _ := makeCert(t, "JCEKS Trust One", true)
ca2, _, _ := makeCert(t, "JCEKS Trust Two", true)
data := buildJCEKSTrustStore(t, "changeit", ca1, ca2)
b := New().Parse(data, cert.SourceRef{Kind: "file"}, cert.ParseOptions{JksPassphrase: "changeit"})
if len(b.Errors) != 0 {
t.Fatalf("unexpected errors: %v", b.Errors)
}
if len(b.Items) != 2 {
t.Fatalf("want 2 items, got %d", len(b.Items))
}
for i, want := range []string{"JCEKS Trust One", "JCEKS Trust Two"} {
if got := b.Items[i].Cert.Subject.CommonName; got != want {
t.Errorf("item %d CN = %q, want %q", i, got, want)
}
if b.Items[i].Role != cert.RoleCA {
t.Errorf("item %d role = %s, want ca", i, b.Items[i].Role)
}
}
}
func TestParseJCEKSWrongPassphraseReportsBadPassphrase(t *testing.T) {
ca, _, _ := makeCert(t, "JCEKS PW", true)
data := buildJCEKSTrustStore(t, "right-pass", ca)
b := New().Parse(data, cert.SourceRef{Kind: "file"}, cert.ParseOptions{JksPassphrase: "wrong-pass"})
if !b.HasFatalError() {
t.Fatalf("wrong passphrase must produce a fatal error")
}
if got := b.Errors[0].Reason; got != cert.ReasonBadPassphrase {
t.Fatalf("reason = %q, want bad_passphrase", got)
}
}
func TestParseJCEKSTryEmptyFallback(t *testing.T) {
ca, _, _ := makeCert(t, "JCEKS Empty", true)
data := buildJCEKSTrustStore(t, "", ca)
b := New().Parse(data, cert.SourceRef{Kind: "file"}, cert.ParseOptions{
JksPassphrase: "definitely-wrong",
JksTryEmpty: true,
})
if len(b.Errors) != 0 {
t.Fatalf("expected fallback to succeed: %v", b.Errors)
}
if len(b.Items) != 1 {
t.Fatalf("want 1 item, got %d", len(b.Items))
}
}
func TestParseJCEKSRejectsSecretKeyEntry(t *testing.T) {
// JCEKS SecretKey entries wrap a serialized Java SealedObject whose
// length isn't directly encoded, so the parser cannot skip them and
// keep its position aligned. The store is refused with a clear error.
data := buildJCEKSWithSecretKey(t, "changeit")
b := New().Parse(data, cert.SourceRef{Kind: "file"}, cert.ParseOptions{JksPassphrase: "changeit"})
if !b.HasFatalError() {
t.Fatalf("SecretKey entry must produce a fatal error")
}
if got := b.Errors[0].Reason; got != cert.ReasonBadJKS {
t.Fatalf("reason = %q, want bad_jks", got)
}
}
+189
View File
@@ -27,9 +27,13 @@ package jks
import (
"bytes"
"crypto/hmac"
"crypto/sha1"
"crypto/x509"
"encoding/binary"
"errors"
"fmt"
"io"
"strings"
"github.com/pavlo-v-chernykh/keystore-go/v4"
@@ -121,7 +125,25 @@ type itemWithRole struct {
role cert.Role
}
// decode dispatches to the appropriate format-specific decoder based on
// the leading magic bytes. JKS is handled by keystore-go (proven, fuzzed
// upstream); JCEKS is parsed natively because keystore-go v4 hard-codes
// the JKS magic in its Load() and rejects JCEKS payloads outright.
func decode(data []byte, pass string) ([]itemWithRole, error) {
if len(data) < 4 {
return nil, fmt.Errorf("keystore too short")
}
switch binary.BigEndian.Uint32(data[:4]) {
case magicJKS:
return decodeJKS(data, pass)
case magicJCEKS:
return decodeJCEKS(data, pass)
default:
return nil, fmt.Errorf("unrecognised magic")
}
}
func decodeJKS(data []byte, pass string) ([]itemWithRole, error) {
ks := keystore.New()
if err := ks.Load(bytes.NewReader(data), []byte(pass)); err != nil {
return nil, err
@@ -166,6 +188,173 @@ func decode(data []byte, pass string) ([]itemWithRole, error) {
return out, nil
}
// JCEKS entry types (RFC-equivalent: see com.sun.crypto.provider.JceKeyStore).
// Type 3 (SecretKeyEntry) wraps a Java SealedObject and cannot be skipped
// without implementing Java ObjectStream deserialization — we reject the
// whole store rather than misalign on subsequent entries.
const (
jceksEntryPrivateKey = uint32(1)
jceksEntryTrustedCert = uint32(2)
jceksEntrySecretKey = uint32(3)
jceksTrailingHMACLength = 20 // SHA-1
jceksMightyAphrodite = "Mighty Aphrodite"
)
// errJCEKSBadDigest mirrors the substring keystore-go surfaces for a wrong
// JKS passphrase, so isPasswordErr classifies both paths identically.
var errJCEKSBadDigest = errors.New("invalid digest")
// decodeJCEKS parses a JCEKS keystore natively. The wire format is byte-
// identical to JKS for our purposes — same `version=2` header, same
// TrustedCertificateEntry (type=2) and PrivateKeyEntry (type=1) layout,
// same trailing SHA-1 HMAC over `utf16be(pass)+"Mighty Aphrodite"+payload`.
// The only meaningful difference is the leading magic (0xCECECECE) and an
// additional entry kind (SecretKeyEntry, type=3) which we refuse rather
// than try to skip past — its length isn't encoded directly, it ends inside
// a serialized Java SealedObject.
//
// Private-key blobs are skipped (length-prefixed) without any decryption
// attempt, matching the JKS path: the exporter only ever reads cert chains.
func decodeJCEKS(data []byte, pass string) ([]itemWithRole, error) {
if len(data) < 12+jceksTrailingHMACLength {
return nil, fmt.Errorf("keystore too short")
}
payload := data[:len(data)-jceksTrailingHMACLength]
storedMAC := data[len(data)-jceksTrailingHMACLength:]
// Verify HMAC before trusting any bytes we're about to interpret.
if !hmac.Equal(jceksHMAC(pass, payload), storedMAC) {
return nil, errJCEKSBadDigest
}
r := bytes.NewReader(payload)
skip := func(uint32) error { return nil }
var magic, version, count uint32
for _, dst := range []*uint32{&magic, &version, &count} {
if err := binary.Read(r, binary.BigEndian, dst); err != nil {
return nil, fmt.Errorf("header: %w", err)
}
}
if magic != magicJCEKS {
return nil, fmt.Errorf("unexpected magic 0x%X after pre-filter", magic)
}
if version != 2 {
return nil, fmt.Errorf("unsupported JCEKS version %d", version)
}
skip = func(n uint32) error {
_, err := r.Seek(int64(n), io.SeekCurrent)
return err
}
var out []itemWithRole
for i := uint32(0); i < count; i++ {
var entryType uint32
if err := binary.Read(r, binary.BigEndian, &entryType); err != nil {
return nil, fmt.Errorf("entry %d: %w", i, err)
}
if _, err := readJavaUTF(r); err != nil { // alias
return nil, fmt.Errorf("entry %d alias: %w", i, err)
}
var ts int64
if err := binary.Read(r, binary.BigEndian, &ts); err != nil {
return nil, fmt.Errorf("entry %d ts: %w", i, err)
}
switch entryType {
case jceksEntryTrustedCert:
c, err := readJCEKSCert(r)
if err != nil {
return nil, fmt.Errorf("entry %d trusted cert: %w", i, err)
}
out = append(out, itemWithRole{cert: c, role: classifyTrustStore(c)})
case jceksEntryPrivateKey:
var keyLen uint32
if err := binary.Read(r, binary.BigEndian, &keyLen); err != nil {
return nil, fmt.Errorf("entry %d key length: %w", i, err)
}
if err := skip(keyLen); err != nil {
return nil, fmt.Errorf("entry %d skip key: %w", i, err)
}
var chainLen uint32
if err := binary.Read(r, binary.BigEndian, &chainLen); err != nil {
return nil, fmt.Errorf("entry %d chain length: %w", i, err)
}
for j := uint32(0); j < chainLen; j++ {
c, err := readJCEKSCert(r)
if err != nil {
return nil, fmt.Errorf("entry %d chain[%d]: %w", i, j, err)
}
role := cert.RoleLeaf
if j > 0 {
role = classifyChain(c)
}
out = append(out, itemWithRole{cert: c, role: role})
}
case jceksEntrySecretKey:
return nil, fmt.Errorf("entry %d: JCEKS SecretKey entries are not supported (Java SealedObject deserialization required to locate subsequent entries)", i)
default:
return nil, fmt.Errorf("entry %d: unknown entry type %d", i, entryType)
}
}
return out, nil
}
// readJCEKSCert reads a single X.509 cert: "X.509" UTF tag + uint32 length + DER.
func readJCEKSCert(r *bytes.Reader) (*x509.Certificate, error) {
certType, err := readJavaUTF(r)
if err != nil {
return nil, fmt.Errorf("cert type: %w", err)
}
if certType != "X.509" {
return nil, fmt.Errorf("unsupported cert type %q", certType)
}
var certLen uint32
if err := binary.Read(r, binary.BigEndian, &certLen); err != nil {
return nil, fmt.Errorf("cert length: %w", err)
}
der := make([]byte, certLen)
if _, err := io.ReadFull(r, der); err != nil {
return nil, fmt.Errorf("cert body: %w", err)
}
c, err := x509.ParseCertificate(der)
if err != nil {
return nil, fmt.Errorf("cert parse: %w", err)
}
return c, nil
}
// readJavaUTF reads a Java DataOutputStream.writeUTF()-encoded string:
// uint16 length + UTF-8-ish bytes (technically Java's modified UTF-8, but
// JKS/JCEKS aliases and the literal "X.509" never hit the modified
// encoding's quirks because they are pure ASCII).
func readJavaUTF(r *bytes.Reader) (string, error) {
var n uint16
if err := binary.Read(r, binary.BigEndian, &n); err != nil {
return "", err
}
buf := make([]byte, n)
if _, err := io.ReadFull(r, buf); err != nil {
return "", err
}
return string(buf), nil
}
// jceksHMAC computes SHA1( utf16be(pass) || "Mighty Aphrodite" || payload ).
// Matches the algorithm shared by JKS and JCEKS (com.sun.crypto.provider.
// JceKeyStore.getPreKeyedHash). Each input byte is widened to UTF-16BE by
// prepending 0x00 — strictly correct for ASCII passphrases, which mirrors
// keystore-go's behaviour and the universe of passphrases configured in
// real Java apps.
func jceksHMAC(pass string, payload []byte) []byte {
h := sha1.New()
for _, b := range []byte(pass) {
h.Write([]byte{0x00, b})
}
h.Write([]byte(jceksMightyAphrodite))
h.Write(payload)
return h.Sum(nil)
}
// isPasswordErr distinguishes a wrong-passphrase outcome from other
// failure modes. keystore-go does not export a sentinel for the
// HMAC-based store-integrity check, so we match on the substring