mirror of
https://github.com/square/certigo.git
synced 2026-10-20 00:16:40 +00:00
This commit adds a JCEKS encoder to the `jceks` package. Previously, the package was only capable of reading JCEKS files. Internally, this commmit effectively rewrites the package. These changes are backward compatible with the previous package API and thus do not require a new major version. However, there are several behavioral changes that have the potential to break existing code that relied on edge cases: - The package now returns an error when attempting to parse a JCEKS file that contains an invalid X.509 certificate (either as a trusted certificate entry, or as part of the chain for a private key entry). Previously, the package silently ignored these certificates. JCEKS files containing invalid certificates are invalid, so affected files should be repaired. - The package now returns an error by default when parsing a JCEKS file containing a certificate or private key larger than 20MiB. This limit guards against trivial memory exhaustion denial of service attacks from malicious files, but the default is very generous. Callers that need to parse larger certificates or private keys can override the defaults using the new `KeyStore.ParseWithOptions` method with appropriate options. The internal changes to the package introduced by this commit are substantial. Some of the highlights include: - Fixed a bug with modified UTF-8 parsing. - `testdata` is now generated by `go generate`, rather than shelling out to `openssl` and `keytool` at unit test time. - PKCS#5 padding is now removed when parsing. - Tests are now far more comprehensive, including fuzz tests. - General code cleanup and linter fixes.
440 lines
12 KiB
Go
440 lines
12 KiB
Go
// Copyright 2025 Block, Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package jceks
|
|
|
|
import (
|
|
"crypto"
|
|
"crypto/subtle"
|
|
"crypto/x509"
|
|
"encoding/asn1"
|
|
"errors"
|
|
"fmt"
|
|
"hash"
|
|
"io"
|
|
"maps"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidCiphertext = errors.New("invalid ciphertext")
|
|
ErrInvalidJCEKSData = errors.New("invalid JCEKS data")
|
|
ErrJCEKSDataTooLarge = errors.New("JCEKS data too large")
|
|
ErrUnsupportedJCEKSData = errors.New("unsupported JCEKS data")
|
|
ErrIntegrityProtectionViolation = errors.New("integrity protection violation")
|
|
ErrDecryptionFailed = errors.New("decryption failed with the given password")
|
|
)
|
|
|
|
const (
|
|
defaultMaxCertBytes = 20 * 1024 * 1024
|
|
defaultMaxPrivateKeyBytes = defaultMaxCertBytes
|
|
)
|
|
|
|
// KeyStore represents the contents of a parsed JCEKS keystore. It can contain a variety of entries, each identified by
|
|
// a unique alias string. Currently, we only support trusted certificate and private key entries. The zero value
|
|
// represents an empty keystore that is ready to use, but most callers should immediately call Parse, ParseWithOptions,
|
|
// or create a KeyStore using LoadFromFile or LoadFromReader.
|
|
type KeyStore struct {
|
|
trustedCerts map[string]*trustedCertEntry
|
|
privateKeys map[string]*privateKeyEntry
|
|
}
|
|
|
|
func (ks *KeyStore) clearAlias(alias string) {
|
|
// JCEKS convention is that the alias namespace is shared across all entry types, and the last entry with a
|
|
// duplicate alias wins, so we need to clear duplicate aliases from all potential types when parsing a new one.
|
|
delete(ks.trustedCerts, alias)
|
|
delete(ks.privateKeys, alias)
|
|
}
|
|
|
|
type privateKeyEntry struct {
|
|
timestamp time.Time
|
|
protectedKey []byte
|
|
certs []*x509.Certificate
|
|
}
|
|
|
|
func (e *privateKeyEntry) String() string {
|
|
return fmt.Sprintf("private-key: %s", e.timestamp)
|
|
}
|
|
|
|
type trustedCertEntry struct {
|
|
timestamp time.Time
|
|
cert *x509.Certificate
|
|
}
|
|
|
|
func (e *trustedCertEntry) String() string {
|
|
return fmt.Sprintf("trusted-cert: %s", e.timestamp)
|
|
}
|
|
|
|
type parseConfig struct {
|
|
maxCertBytes uint
|
|
maxPrivateKeyBytes uint
|
|
}
|
|
|
|
// ParseOption modifies the behavior of KeyStore.ParseWithOptions.
|
|
type ParseOption interface {
|
|
applyParseOption(cfg *parseConfig) error
|
|
}
|
|
|
|
type simpleParseOptionFunc func(cfg *parseConfig)
|
|
|
|
func (f simpleParseOptionFunc) applyParseOption(cfg *parseConfig) error {
|
|
f(cfg)
|
|
|
|
return nil
|
|
}
|
|
|
|
func makeParseConfig(opts ...ParseOption) (*parseConfig, error) {
|
|
var cfg parseConfig
|
|
for _, opt := range opts {
|
|
err := opt.applyParseOption(&cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if cfg.maxCertBytes <= 0 {
|
|
cfg.maxCertBytes = defaultMaxCertBytes
|
|
}
|
|
if cfg.maxPrivateKeyBytes <= 0 {
|
|
cfg.maxPrivateKeyBytes = defaultMaxPrivateKeyBytes
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
// WithMaxCertificateBytes sets the maximum size of certificates contained in the JCEKS file in bytes. This limit
|
|
// applies to the DER encoding of the certificates. When maxBytes is zero or the option is not provided, the parser
|
|
// uses an unspecified default that is suitable for most certificates. The default is not part of the API and may change
|
|
// in future minor release versions.
|
|
func WithMaxCertificateBytes(maxBytes uint) ParseOption {
|
|
return simpleParseOptionFunc(func(cfg *parseConfig) {
|
|
cfg.maxCertBytes = maxBytes
|
|
})
|
|
}
|
|
|
|
// WithMaxPrivateKeyBytes sets the maximum size of private keys contained in the JCEKS file in bytes. This limit applies
|
|
// to the encrypted, encoded private key data, which has a format that varies by key type. Certificates attached to the
|
|
// private key individually adhere to the certificate size limit, and are unaffected by this option. When maxBytes is
|
|
// zero or the option is not provided, the parser uses an unspecified default that is suitable for most private keys.
|
|
// The default is not part of the API and may change in future minor release versions.
|
|
func WithMaxPrivateKeyBytes(maxBytes uint) ParseOption {
|
|
return simpleParseOptionFunc(func(cfg *parseConfig) {
|
|
cfg.maxPrivateKeyBytes = maxBytes
|
|
})
|
|
}
|
|
|
|
// Parse parses the key store from the specified reader using default settings. It is equivalent to calling
|
|
// ParseWithOptions without any options.
|
|
func (ks *KeyStore) Parse(r io.Reader, password []byte) error {
|
|
return ks.ParseWithOptions(r, password)
|
|
}
|
|
|
|
// ParseWithOptions parses the key store from the specified reader.
|
|
func (ks *KeyStore) ParseWithOptions(r io.Reader, password []byte, options ...ParseOption) error {
|
|
ks.trustedCerts = make(map[string]*trustedCertEntry)
|
|
ks.privateKeys = make(map[string]*privateKeyEntry)
|
|
|
|
cfg, err := makeParseConfig(options...)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to configure parser: %w", err)
|
|
}
|
|
|
|
var md hash.Hash
|
|
if password != nil {
|
|
encodedPassword, err := encodeIntegrityPassword(string(password))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
md = makeIntegrityHash(encodedPassword)
|
|
r = io.TeeReader(r, md)
|
|
}
|
|
|
|
version, err := parseHeader(r)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %w", ErrInvalidJCEKSData, err)
|
|
}
|
|
if version != jceksVersion {
|
|
return fmt.Errorf("%w: unexpected version: %d != %d",
|
|
ErrInvalidJCEKSData, version, jceksVersion)
|
|
}
|
|
|
|
count, err := readInt32(r)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: failed to read entry count", ErrInvalidJCEKSData)
|
|
}
|
|
for i := 0; i < int(count); i++ {
|
|
tag, err := readUint32(r)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: failed to read entry %d tag", ErrInvalidJCEKSData, i)
|
|
}
|
|
switch tag {
|
|
case privateKeyEntryTag:
|
|
err := ks.parsePrivateKey(r, cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: failed to parse private key entry %d: %w", ErrInvalidJCEKSData, i, err)
|
|
}
|
|
case trustedCertEntryTag:
|
|
err := ks.parseTrustedCert(r, cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: failed to parse certificate entry %d: %w", ErrInvalidJCEKSData, i, err)
|
|
}
|
|
case secretKeyEntryTag:
|
|
return fmt.Errorf("%w: file contains a secret key entry", ErrUnsupportedJCEKSData)
|
|
default:
|
|
return fmt.Errorf("%w: unknown entry tag %d", ErrUnsupportedJCEKSData, tag)
|
|
}
|
|
}
|
|
|
|
if md != nil {
|
|
computed := md.Sum([]byte{})
|
|
actual := make([]byte, len(computed))
|
|
_, err := io.ReadFull(r, actual)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: failed to read integrity checksum: %w", ErrInvalidJCEKSData, err)
|
|
}
|
|
if subtle.ConstantTimeCompare(computed, actual) != 1 {
|
|
return fmt.Errorf("%w: keystore was tampered with or password was incorrect",
|
|
ErrIntegrityProtectionViolation)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetPrivateKeyAndCerts retrieves the specified private key. Returns nil if the private key does not exist or alias
|
|
// points to a non-private key entry.
|
|
func (ks *KeyStore) GetPrivateKeyAndCerts(alias string, password []byte) (
|
|
key crypto.PrivateKey, certs []*x509.Certificate, err error) {
|
|
|
|
entry, ok := ks.privateKeys[alias]
|
|
if !ok {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
if len(entry.certs) < 1 {
|
|
return nil, nil, fmt.Errorf("%w: key has no certificates", ErrInvalidJCEKSData)
|
|
}
|
|
key, err = entry.Recover(password)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return key, entry.certs, nil
|
|
}
|
|
|
|
// GetCert retrieves the specified certificate. Returns nil if the certificate does not exist or alias points to a
|
|
// non-certificate entry.
|
|
func (ks *KeyStore) GetCert(alias string) (*x509.Certificate, error) {
|
|
entry, ok := ks.trustedCerts[alias]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
|
|
return entry.cert, nil
|
|
}
|
|
|
|
// ListPrivateKeys lists the names of the private keys stored in the key store.
|
|
func (ks *KeyStore) ListPrivateKeys() []string {
|
|
return slices.Sorted(maps.Keys(ks.privateKeys))
|
|
}
|
|
|
|
// ListCerts lists the names of the certs stored in the key store.
|
|
func (ks *KeyStore) ListCerts() []string {
|
|
return slices.Sorted(maps.Keys(ks.trustedCerts))
|
|
}
|
|
|
|
func (ks *KeyStore) String() string {
|
|
m := make(map[string]fmt.Stringer, len(ks.trustedCerts)+len(ks.privateKeys))
|
|
for k, v := range ks.trustedCerts {
|
|
m[k] = v
|
|
}
|
|
for k, v := range ks.privateKeys {
|
|
m[k] = v
|
|
}
|
|
|
|
var buf strings.Builder
|
|
for _, k := range slices.Sorted(maps.Keys(m)) {
|
|
_, _ = fmt.Fprintf(&buf, "%s\n", k)
|
|
_, _ = fmt.Fprintf(&buf, " %s\n", m[k])
|
|
}
|
|
|
|
return buf.String()
|
|
}
|
|
|
|
// LoadFromFile loads the key store from the specified file.
|
|
func LoadFromFile(filename string, password []byte) (*KeyStore, error) {
|
|
file, err := os.Open(filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() {
|
|
if file != nil {
|
|
_ = file.Close()
|
|
}
|
|
}()
|
|
|
|
ks, err := LoadFromReader(file, password)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = file.Close()
|
|
file = nil
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return ks, nil
|
|
}
|
|
|
|
// LoadFromReader loads the key store from the specified file.
|
|
func LoadFromReader(reader io.Reader, password []byte) (*KeyStore, error) {
|
|
ks := new(KeyStore)
|
|
err := ks.Parse(reader, password)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return ks, err
|
|
}
|
|
|
|
func parseHeader(r io.Reader) (uint32, error) {
|
|
magic, err := readUint32(r)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if magic != jceksMagic && magic != jksMagic {
|
|
return 0, fmt.Errorf("unexpected magic: %08x != (%08x || %08x)",
|
|
magic, uint32(jceksMagic), uint32(jksMagic))
|
|
}
|
|
version, err := readUint32(r)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return version, nil
|
|
}
|
|
|
|
func (ks *KeyStore) parsePrivateKey(r io.Reader, cfg *parseConfig) error {
|
|
alias, err := readString(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entry := &privateKeyEntry{
|
|
certs: []*x509.Certificate{},
|
|
}
|
|
entry.timestamp, err = readDate(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entry.protectedKey, err = readBytes(r, cfg.maxPrivateKeyBytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
nCerts, err := readInt32(r) // Sun reference implementation uses a signed int here
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for j := 0; j < int(nCerts); j++ {
|
|
cert, err := readCertificate(r, cfg.maxCertBytes)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
entry.certs = append(entry.certs, cert)
|
|
}
|
|
|
|
ks.clearAlias(alias)
|
|
ks.privateKeys[alias] = entry
|
|
|
|
return nil
|
|
}
|
|
|
|
func (ks *KeyStore) parseTrustedCert(r io.Reader, cfg *parseConfig) error {
|
|
alias, err := readString(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entry := &trustedCertEntry{}
|
|
entry.timestamp, err = readDate(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
entry.cert, err = readCertificate(r, cfg.maxCertBytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ks.clearAlias(alias)
|
|
ks.trustedCerts[alias] = entry
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *privateKeyEntry) Recover(password []byte) (crypto.PrivateKey, error) {
|
|
var eKey encryptedPrivateKeyInfo
|
|
_, err := asn1.Unmarshal(e.protectedKey, &eKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: failed to parse private key as DER: %w", ErrInvalidJCEKSData, err)
|
|
}
|
|
|
|
if !eKey.Algo.Algorithm.Equal(oidPBEWithMD5AndDES3CBC) {
|
|
return nil, fmt.Errorf("%w: unsupported encrypted-private-key algorithm: %v",
|
|
ErrUnsupportedJCEKSData, eKey.Algo.Algorithm)
|
|
}
|
|
|
|
// From this point on, intentionally do not reveal detailed info from errors, for security
|
|
|
|
privKey, err := recoverPBEWithMD5AndDES3CBC(eKey, password)
|
|
if err != nil {
|
|
return nil, ErrDecryptionFailed
|
|
}
|
|
|
|
if privKey.Algo.Algorithm.Equal(oidPublicKeyRSA) {
|
|
sk, err := x509.ParsePKCS1PrivateKey(privKey.PrivateKey)
|
|
if err != nil {
|
|
return nil, ErrDecryptionFailed
|
|
}
|
|
|
|
return sk, nil
|
|
}
|
|
if privKey.Algo.Algorithm.Equal(oidPublicKeyEC) {
|
|
// In a JCEKS file, the EC private key blob contains only the key itself, without the named curve OID. Instead,
|
|
// the named curve OID is in a separate field (in the algorithm identifier for the keystore entry). However, to
|
|
// parse the EC key properly, we need the EC key blob to have the curve OID...
|
|
key := ecPrivateKey{}
|
|
oid := asn1.ObjectIdentifier{}
|
|
// Parse EC private key
|
|
_, err := asn1.Unmarshal(privKey.PrivateKey, &key)
|
|
if err != nil {
|
|
return nil, ErrDecryptionFailed
|
|
}
|
|
// Parse named curve OID from algorithm identifier
|
|
_, err = asn1.Unmarshal(privKey.Algo.Parameters.FullBytes, &oid)
|
|
if err != nil {
|
|
return nil, ErrDecryptionFailed
|
|
}
|
|
// Update key to add named curve info, re-marshal, and parse
|
|
key.NamedCurveOID = oid
|
|
raw, _ := asn1.Marshal(key)
|
|
|
|
return x509.ParseECPrivateKey(raw)
|
|
}
|
|
|
|
return nil, fmt.Errorf("%w: unsupported private-key algorithm", ErrUnsupportedJCEKSData)
|
|
}
|