diff --git a/pkg/cert/jks/jceks_test.go b/pkg/cert/jks/jceks_test.go new file mode 100644 index 0000000..d2995f0 --- /dev/null +++ b/pkg/cert/jks/jceks_test.go @@ -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) + } +} diff --git a/pkg/cert/jks/jks.go b/pkg/cert/jks/jks.go index f626bfa..60a0979 100644 --- a/pkg/cert/jks/jks.go +++ b/pkg/cert/jks/jks.go @@ -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