mirror of
https://github.com/enix/x509-certificate-exporter.git
synced 2026-08-23 22:16:39 +00:00
feat(cert/jks): implement native JCEKS reader (keystore-go rejects JCEKS magic)
This commit is contained in:
committed by
Thibault VINCENT
parent
eb9ce37612
commit
58b2ec8d21
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user