diff --git a/.cirrus.yml b/.cirrus.yml index 15287bb47..30a91d769 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -30,8 +30,14 @@ env: SKOPEO_PR: # Google-cloud VM Images - IMAGE_SUFFIX: "c20250721t181111z-f42f41d13" + # If you are updating IMAGE_SUFFIX: We are currently using rawhide for + # the containers_image_sequoia tests because the rust-podman-sequoia + # package is not available in earlier releases; once we update to a future + # Fedora release (or if the package is backported), switch back from Rawhide + # to the latest Fedora release. + IMAGE_SUFFIX: "c20250812t173301z-f42f41d13" FEDORA_CACHE_IMAGE_NAME: "fedora-${IMAGE_SUFFIX}" + RAWHIDE_CACHE_IMAGE_NAME: "rawhide-${IMAGE_SUFFIX}" # Container FQIN's (include bleeding-edge development-level container deps.) FEDORA_CONTAINER_FQIN: "quay.io/libpod/fedora_podman:${IMAGE_SUFFIX}" @@ -55,9 +61,12 @@ validate_task: # Required to be 200gig, do not modify - has i/o performance impact # according to gcloud CLI tool warning messages. disk: 200 - image_name: ${FEDORA_CACHE_IMAGE_NAME} + # Eventually, hard-code FEDORA_CACHE_IMAGE_NAME here again and remove the + # VM_IMAGE_NAME parameter. + image_name: ${VM_IMAGE_NAME} env: HOME: "/root" # default unset, needed by golangci-lint. + VM_IMAGE_NAME: ${FEDORA_CACHE_IMAGE_NAME} script: | git remote update make tools @@ -68,6 +77,8 @@ validate_task: cross_task: only_if: ¬_docs $CIRRUS_CHANGE_TITLE !=~ '.*CI:DOCS.*' gce_instance: *fedora_vm + env: + VM_IMAGE_NAME: ${FEDORA_CACHE_IMAGE_NAME} script: make cross @@ -81,9 +92,15 @@ test_task: - name: "Test" env: BUILDTAGS: '' + VM_IMAGE_NAME: ${FEDORA_CACHE_IMAGE_NAME} - name: "Test w/ opengpg" env: BUILDTAGS: &withopengpg 'containers_image_openpgp' + VM_IMAGE_NAME: ${FEDORA_CACHE_IMAGE_NAME} + - name: "Test w/ Sequoia (currently Rawhide)" + env: + BUILDTAGS: &withsequoia 'containers_image_sequoia' + VM_IMAGE_NAME: ${RAWHIDE_CACHE_IMAGE_NAME} script: ${GOSRC}/${SCRIPT_BASE}/runner.sh image_tests @@ -102,9 +119,15 @@ test_skopeo_task: - name: "Skopeo Test" env: BUILDTAGS: '' + VM_IMAGE_NAME: ${FEDORA_CACHE_IMAGE_NAME} - name: "Skopeo Test w/ opengpg" env: BUILDTAGS: *withopengpg + VM_IMAGE_NAME: ${FEDORA_CACHE_IMAGE_NAME} + - name: "Skopeo Test w/ Sequoia (currently Rawhide)" + env: + BUILDTAGS: *withsequoia + VM_IMAGE_NAME: ${RAWHIDE_CACHE_IMAGE_NAME} setup_script: >- "${GOSRC}/${SCRIPT_BASE}/runner.sh" setup vendor_script: >- @@ -133,6 +156,7 @@ meta_task: # Space-separated list of images used by this repository state IMGNAMES: | ${FEDORA_CACHE_IMAGE_NAME} + ${RAWHIDE_CACHE_IMAGE_NAME} BUILDID: "${CIRRUS_BUILD_ID}" REPOREF: "${CIRRUS_REPO_NAME}" GCPJSON: ENCRYPTED[04306103eee1933f87deb8a5af6514a7e3164aa589d6079abc0451eb2360879430ed020d6e025ca64ef667138ce9d786] diff --git a/Makefile b/Makefile index 94a2f0d44..9f57cc2b0 100644 --- a/Makefile +++ b/Makefile @@ -9,9 +9,10 @@ endif # when cross compiling _for_ a Darwin or windows host, then we must use openpgp BUILD_TAGS_WINDOWS_CROSS = containers_image_openpgp BUILD_TAGS_DARWIN_CROSS = containers_image_openpgp +SEQUOIA_SONAME_DIR = BUILDTAGS = -BUILDFLAGS := -tags "$(BUILDTAGS)" +BUILDFLAGS := -tags "$(BUILDTAGS)" -ldflags '-X github.com/containers/image/v5/signature/internal/sequoia.sequoiaLibraryDir='"$(SEQUOIA_SONAME_DIR)" # Extra flags passed to go test TESTFLAGS := diff --git a/README.md b/README.md index 328652b13..56c66b7f3 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ or use the build tags described below to avoid the dependencies (e.g. using `go - `containers_image_docker_daemon_stub`: Don’t import the `docker-daemon:` transport in `github.com/containers/image/transports/alltransports`, to decrease the amount of required dependencies. Use a stub which reports that the transport is not supported instead. - `containers_image_openpgp`: Use a Golang-only OpenPGP implementation for signature verification instead of the default cgo/gpgme-based implementation; the primary downside is that creating new signatures with the Golang-only implementation is not supported. +- `containers_image_sequoia`: Use Sequoia-PGP for signature verification instead of the default cgo/gpgme-based or the Golang-only OpenPGP implementations, and enable the `signature/simplesequoia` subpackage. This requires a support shared library installed on the system. Install https://github.com/ueno/podman-sequoia , and potentially update build configuration to point at it (compare `SEQUOIA_SONAME_DIR` in `Makefile`). - `containers_image_storage_stub`: Don’t import the `containers-storage:` transport in `github.com/containers/image/transports/alltransports`, to decrease the amount of required dependencies. Use a stub which reports that the transport is not supported instead. ## [Contributing](CONTRIBUTING.md) diff --git a/signature/internal/sequoia/gosequoia.c b/signature/internal/sequoia/gosequoia.c new file mode 100644 index 000000000..d5314016a --- /dev/null +++ b/signature/internal/sequoia/gosequoia.c @@ -0,0 +1,200 @@ +/* + * Copying and distribution of this file, with or without modification, + * are permitted in any medium without royalty provided the copyright + * notice and this notice are preserved. This file is offered as-is, + * without any warranty. + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include "gosequoia.h" + +#if defined(GO_SEQUOIA_ENABLE_DLOPEN) && GO_SEQUOIA_ENABLE_DLOPEN + +#include +#include +#include +#include + +/* If SEQUOIA_SONAME is defined, dlopen handle can be automatically + * set; otherwise, the caller needs to call + * go_sequoia_ensure_library with soname determined at run time. + */ +#ifdef SEQUOIA_SONAME + +static void +ensure_library (void) +{ + if (go_sequoia_ensure_library (SEQUOIA_SONAME, RTLD_LAZY | RTLD_LOCAL) < 0) + abort (); +} + +#if defined(GO_SEQUOIA_ENABLE_PTHREAD) && GO_SEQUOIA_ENABLE_PTHREAD +#include + +static pthread_once_t dlopen_once = PTHREAD_ONCE_INIT; + +#define ENSURE_LIBRARY pthread_once(&dlopen_once, ensure_library) + +#else /* GO_SEQUOIA_ENABLE_PTHREAD */ + +#define ENSURE_LIBRARY do { \ + if (!go_sequoia_dlhandle) \ + ensure_library(); \ + } while (0) + +#endif /* !GO_SEQUOIA_ENABLE_PTHREAD */ + +#else /* SEQUOIA_SONAME */ + +#define ENSURE_LIBRARY do {} while (0) + +#endif /* !SEQUOIA_SONAME */ + +static void *go_sequoia_dlhandle; + +/* Define redirection symbols */ +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-macros" + +#if (2 <= __GNUC__ || (4 <= __clang_major__)) +#define FUNC(ret, name, args, cargs) \ + static __typeof__(name)(*go_sequoia_sym_##name); +#else +#define FUNC(ret, name, args, cargs) \ + static ret(*go_sequoia_sym_##name)args; +#endif +#define VOID_FUNC FUNC +#include "gosequoiafuncs.h" +#undef VOID_FUNC +#undef FUNC + +#pragma GCC diagnostic pop + +/* Define redirection wrapper functions */ +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-macros" + +#define FUNC(ret, name, args, cargs) \ +ret go_##name args \ +{ \ + ENSURE_LIBRARY; \ + assert (go_sequoia_sym_##name); \ + return go_sequoia_sym_##name cargs; \ +} +#define VOID_FUNC(ret, name, args, cargs) \ +ret go_##name args \ +{ \ + ENSURE_LIBRARY; \ + assert (go_sequoia_sym_##name); \ + go_sequoia_sym_##name cargs; \ +} +#include "gosequoiafuncs.h" +#undef VOID_FUNC +#undef FUNC + +#pragma GCC diagnostic pop + +static int +ensure_symbol (const char *name, void **symp) +{ + if (!*symp) + { + void *sym = dlsym (go_sequoia_dlhandle, name); + if (!sym) + return -EINVAL; + *symp = sym; + } + return 0; +} + +int +go_sequoia_ensure_library (const char *soname, int flags) +{ + int err; + + if (!go_sequoia_dlhandle) + { + go_sequoia_dlhandle = dlopen (soname, flags); + if (!go_sequoia_dlhandle) + return -EINVAL; + } + +#define ENSURE_SYMBOL(name) \ + ensure_symbol(#name, (void **)&go_sequoia_sym_##name) + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-macros" + +#define FUNC(ret, name, args, cargs) \ + err = ENSURE_SYMBOL(name); \ + if (err < 0) \ + { \ + dlclose (go_sequoia_dlhandle); \ + go_sequoia_dlhandle = NULL; \ + return err; \ + } +#define VOID_FUNC FUNC +#include "gosequoiafuncs.h" +#undef VOID_FUNC +#undef FUNC + +#pragma GCC diagnostic pop + +#undef ENSURE_SYMBOL + return 0; +} + +void +go_sequoia_unload_library (void) +{ + if (go_sequoia_dlhandle) + { + dlclose (go_sequoia_dlhandle); + go_sequoia_dlhandle = NULL; + } + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-macros" + +#define FUNC(ret, name, args, cargs) \ + go_sequoia_sym_##name = NULL; +#define VOID_FUNC FUNC +#include "gosequoiafuncs.h" +#undef VOID_FUNC +#undef FUNC + +#pragma GCC diagnostic pop +} + +unsigned +go_sequoia_is_usable (void) +{ + return go_sequoia_dlhandle != NULL; +} + +#else /* GO_SEQUOIA_ENABLE_DLOPEN */ + +int +go_sequoia_ensure_library (const char *soname, int flags) +{ + (void) soname; + (void) flags; + return 0; +} + +void +go_sequoia_unload_library (void) +{ +} + +unsigned +go_sequoia_is_usable (void) +{ + /* The library is linked at build time, thus always usable */ + return 1; +} + +#endif /* !GO_SEQUOIA_ENABLE_DLOPEN */ diff --git a/signature/internal/sequoia/gosequoia.h b/signature/internal/sequoia/gosequoia.h new file mode 100644 index 000000000..477b985ba --- /dev/null +++ b/signature/internal/sequoia/gosequoia.h @@ -0,0 +1,54 @@ +/* + * Copying and distribution of this file, with or without modification, + * are permitted in any medium without royalty provided the copyright + * notice and this notice are preserved. This file is offered as-is, + * without any warranty. + */ + +#ifndef GO_SEQUOIA_H_ +#define GO_SEQUOIA_H_ + +#include + +#if defined(GO_SEQUOIA_ENABLE_DLOPEN) && GO_SEQUOIA_ENABLE_DLOPEN + +#define FUNC(ret, name, args, cargs) \ + ret go_##name args; +#define VOID_FUNC FUNC +#include "gosequoiafuncs.h" +#undef VOID_FUNC +#undef FUNC + +#define GO_SEQUOIA_FUNC(name) go_##name + +#else + +#define GO_SEQUOIA_FUNC(name) name + +#endif /* GO_SEQUOIA_ENABLE_DLOPEN */ + +/* Ensure SONAME to be loaded with dlopen FLAGS, and all the necessary + * symbols are resolved. + * + * Returns 0 on success; negative error code otherwise. + * + * Note that this function is NOT thread-safe; when calling it from + * multi-threaded programs, protect it with a locking mechanism. + */ +int go_sequoia_ensure_library (const char *soname, int flags); + +/* Unload library and reset symbols. + * + * Note that this function is NOT thread-safe; when calling it from + * multi-threaded programs, protect it with a locking mechanism. + */ +void go_sequoia_unload_library (void); + +/* Return 1 if the library is loaded and usable. + * + * Note that this function is NOT thread-safe; when calling it from + * multi-threaded programs, protect it with a locking mechanism. + */ +unsigned go_sequoia_is_usable (void); + +#endif /* GO_SEQUOIA_H_ */ diff --git a/signature/internal/sequoia/gosequoiafuncs.h b/signature/internal/sequoia/gosequoiafuncs.h new file mode 100644 index 000000000..3d7ae5fac --- /dev/null +++ b/signature/internal/sequoia/gosequoiafuncs.h @@ -0,0 +1,21 @@ +/* + * This file was automatically generated from sequoia.h, + * which is covered by the following license: + * SPDX-License-Identifier: Apache-2.0 + */ +VOID_FUNC(void, sequoia_error_free, (struct SequoiaError *err_ptr), (err_ptr)) +FUNC(struct SequoiaMechanism *, sequoia_mechanism_new_from_directory, (const char *dir_ptr, struct SequoiaError **err_ptr), (dir_ptr, err_ptr)) +FUNC(struct SequoiaMechanism *, sequoia_mechanism_new_ephemeral, (struct SequoiaError **err_ptr), (err_ptr)) +VOID_FUNC(void, sequoia_mechanism_free, (struct SequoiaMechanism *mechanism_ptr), (mechanism_ptr)) +VOID_FUNC(void, sequoia_signature_free, (struct SequoiaSignature *signature_ptr), (signature_ptr)) +FUNC(const uint8_t *, sequoia_signature_get_data, (const struct SequoiaSignature *signature_ptr, size_t *data_len), (signature_ptr, data_len)) +VOID_FUNC(void, sequoia_verification_result_free, (struct SequoiaVerificationResult *result_ptr), (result_ptr)) +FUNC(const uint8_t *, sequoia_verification_result_get_content, (const struct SequoiaVerificationResult *result_ptr, size_t *data_len), (result_ptr, data_len)) +FUNC(const char *, sequoia_verification_result_get_signer, (const struct SequoiaVerificationResult *result_ptr), (result_ptr)) +FUNC(struct SequoiaSignature *, sequoia_sign, (struct SequoiaMechanism *mechanism_ptr, const char *key_handle_ptr, const char *password_ptr, const uint8_t *data_ptr, size_t data_len, struct SequoiaError **err_ptr), (mechanism_ptr, key_handle_ptr, password_ptr, data_ptr, data_len, err_ptr)) +FUNC(struct SequoiaVerificationResult *, sequoia_verify, (struct SequoiaMechanism *mechanism_ptr, const uint8_t *signature_ptr, size_t signature_len, struct SequoiaError **err_ptr), (mechanism_ptr, signature_ptr, signature_len, err_ptr)) +VOID_FUNC(void, sequoia_import_result_free, (struct SequoiaImportResult *result_ptr), (result_ptr)) +FUNC(size_t, sequoia_import_result_get_count, (const struct SequoiaImportResult *result_ptr), (result_ptr)) +FUNC(const char *, sequoia_import_result_get_content, (const struct SequoiaImportResult *result_ptr, size_t index, struct SequoiaError **err_ptr), (result_ptr, index, err_ptr)) +FUNC(struct SequoiaImportResult *, sequoia_import_keys, (struct SequoiaMechanism *mechanism_ptr, const uint8_t *blob_ptr, size_t blob_len, struct SequoiaError **err_ptr), (mechanism_ptr, blob_ptr, blob_len, err_ptr)) +FUNC(int, sequoia_set_logger_consumer, (void (*consumer)(enum SequoiaLogLevel, const char *), struct SequoiaError **err_ptr), (consumer, err_ptr)) diff --git a/signature/internal/sequoia/sequoia.go b/signature/internal/sequoia/sequoia.go new file mode 100644 index 000000000..361c1a6c7 --- /dev/null +++ b/signature/internal/sequoia/sequoia.go @@ -0,0 +1,223 @@ +//go:build containers_image_sequoia + +package sequoia + +// #cgo CFLAGS: -I. -DGO_SEQUOIA_ENABLE_DLOPEN=1 +// #include "gosequoia.h" +// #include +// #include +// typedef void (*sequoia_logger_consumer_t) (enum SequoiaLogLevel level, char *message); +// extern void sequoia_logrus_logger (enum SequoiaLogLevel level, char *message); +import "C" + +import ( + "errors" + "fmt" + "path/filepath" + "runtime" + "sync" + "unsafe" + + "github.com/sirupsen/logrus" +) + +// sequoiaLibraryDir is the path to the directory where libpodman_sequoia is installed, +// if it is not in the platform’s default library path. +// You can override this at build time with +// -ldflags '-X github.com/containers/image/v5/signature/sequoia.sequoiaLibraryDir=$your_path' +var sequoiaLibraryDir = "" + +type SigningMechanism struct { + mechanism *C.SequoiaMechanism +} + +// NewMechanismFromDirectory initializes a mechanism using (user-managed) Sequoia state +// in dir, which can be "" to indicate the default (using $SEQUOIA_HOME or the default home directory location). +func NewMechanismFromDirectory( + dir string, +) (*SigningMechanism, error) { + var cerr *C.SequoiaError + var cDir *C.char + if dir != "" { + cDir = C.CString(dir) + defer C.free(unsafe.Pointer(cDir)) + } + cMechanism := C.go_sequoia_mechanism_new_from_directory(cDir, &cerr) + if cMechanism == nil { + defer C.go_sequoia_error_free(cerr) + return nil, errors.New(C.GoString(cerr.message)) + } + return &SigningMechanism{ + mechanism: cMechanism, + }, nil +} + +func NewEphemeralMechanism() (*SigningMechanism, error) { + var cerr *C.SequoiaError + cMechanism := C.go_sequoia_mechanism_new_ephemeral(&cerr) + if cMechanism == nil { + defer C.go_sequoia_error_free(cerr) + return nil, errors.New(C.GoString(cerr.message)) + } + return &SigningMechanism{ + mechanism: cMechanism, + }, nil +} + +func (m *SigningMechanism) SignWithPassphrase( + input []byte, + keyIdentity string, + passphrase string, +) ([]byte, error) { + var cerr *C.SequoiaError + var cPassphrase *C.char + if passphrase == "" { + cPassphrase = nil + } else { + cPassphrase = C.CString(passphrase) + defer C.free(unsafe.Pointer(cPassphrase)) + } + cKeyIdentity := C.CString(keyIdentity) + defer C.free(unsafe.Pointer(cKeyIdentity)) + sig := C.go_sequoia_sign( + m.mechanism, + cKeyIdentity, + cPassphrase, + (*C.uchar)(unsafe.Pointer(unsafe.SliceData(input))), + C.size_t(len(input)), + &cerr, + ) + if sig == nil { + defer C.go_sequoia_error_free(cerr) + return nil, errors.New(C.GoString(cerr.message)) + } + defer C.go_sequoia_signature_free(sig) + var size C.size_t + cData := C.go_sequoia_signature_get_data(sig, &size) + if size > C.size_t(C.INT_MAX) { + return nil, errors.New("overflow") // Coverage: This should not reasonably happen, and we don’t want to generate gigabytes of input to test this. + } + return C.GoBytes(unsafe.Pointer(cData), C.int(size)), nil +} + +func (m *SigningMechanism) Sign( + input []byte, + keyIdentity string, +) ([]byte, error) { + return m.SignWithPassphrase(input, keyIdentity, "") +} + +func (m *SigningMechanism) Verify( + unverifiedSignature []byte, +) (contents []byte, keyIdentity string, err error) { + var cerr *C.SequoiaError + result := C.go_sequoia_verify( + m.mechanism, + (*C.uchar)(unsafe.Pointer(unsafe.SliceData(unverifiedSignature))), + C.size_t(len(unverifiedSignature)), + &cerr, + ) + if result == nil { + defer C.go_sequoia_error_free(cerr) + return nil, "", errors.New(C.GoString(cerr.message)) + } + defer C.go_sequoia_verification_result_free(result) + var size C.size_t + cContent := C.go_sequoia_verification_result_get_content(result, &size) + if size > C.size_t(C.INT_MAX) { + return nil, "", errors.New("overflow") // Coverage: This should not reasonably happen, and we don’t want to generate gigabytes of input to test this. + } + contents = C.GoBytes(unsafe.Pointer(cContent), C.int(size)) + cSigner := C.go_sequoia_verification_result_get_signer(result) + keyIdentity = C.GoString(cSigner) + return contents, keyIdentity, nil +} + +func (m *SigningMechanism) ImportKeys(blob []byte) ([]string, error) { + var cerr *C.SequoiaError + result := C.go_sequoia_import_keys( + m.mechanism, + (*C.uchar)(unsafe.Pointer(unsafe.SliceData(blob))), + C.size_t(len(blob)), + &cerr, + ) + if result == nil { + defer C.go_sequoia_error_free(cerr) + return nil, errors.New(C.GoString(cerr.message)) + } + defer C.go_sequoia_import_result_free(result) + + keyIdentities := []string{} + count := C.go_sequoia_import_result_get_count(result) + for i := C.size_t(0); i < count; i++ { + var cerr *C.SequoiaError + cKeyIdentity := C.go_sequoia_import_result_get_content(result, i, &cerr) + if cerr != nil { + defer C.go_sequoia_error_free(cerr) // Coverage: this can fail only if i is out of range. + return nil, errors.New(C.GoString(cerr.message)) + } + keyIdentities = append(keyIdentities, C.GoString(cKeyIdentity)) + } + + return keyIdentities, nil +} + +func (m *SigningMechanism) Close() error { + C.go_sequoia_mechanism_free(m.mechanism) + return nil +} + +//export sequoia_logrus_logger +func sequoia_logrus_logger(level C.enum_SequoiaLogLevel, message *C.char) { + var logrusLevel logrus.Level + switch level { // Coverage: We are not in control of whether / how the Rust code chooses to log things. + case C.SEQUOIA_LOG_LEVEL_ERROR: + logrusLevel = logrus.ErrorLevel + case C.SEQUOIA_LOG_LEVEL_WARN: + logrusLevel = logrus.WarnLevel + case C.SEQUOIA_LOG_LEVEL_INFO: + logrusLevel = logrus.InfoLevel + case C.SEQUOIA_LOG_LEVEL_DEBUG: + logrusLevel = logrus.DebugLevel + case C.SEQUOIA_LOG_LEVEL_TRACE: + logrusLevel = logrus.TraceLevel + case C.SEQUOIA_LOG_LEVEL_UNKNOWN: + fallthrough + default: + logrusLevel = logrus.ErrorLevel // Should never happen + } + logrus.StandardLogger().Log(logrusLevel, C.GoString(message)) +} + +// initOnce should only be called by Init. +func initOnce() error { + var soName string + switch runtime.GOOS { + case "linux": + soName = "libpodman_sequoia.so.0" + case "darwin": + soName = "libpodman_sequoia.dylib" + default: + return fmt.Errorf("Unhandled OS %q in sequoia initialization", runtime.GOOS) // Coverage: This is ~by definition not reached in tests. + } + if sequoiaLibraryDir != "" { + soName = filepath.Join(sequoiaLibraryDir, soName) + } + cSOName := C.CString(soName) + defer C.free(unsafe.Pointer(cSOName)) + if C.go_sequoia_ensure_library(cSOName, + C.RTLD_NOW|C.RTLD_GLOBAL) < 0 { + return fmt.Errorf("unable to load %q", soName) // Coverage: This is impractical to test in-process, with the static go_sequoia_dlhandle. + } + + var cerr *C.SequoiaError + if C.go_sequoia_set_logger_consumer(C.sequoia_logger_consumer_t(C.sequoia_logrus_logger), &cerr) != 0 { + defer C.go_sequoia_error_free(cerr) // Coverage: This is impractical to test in-process, with the static go_sequoia_dlhandle. + return fmt.Errorf("initializing logging: %s", C.GoString(cerr.message)) + } + return nil +} + +// Init ensures the libpodman_sequoia library is available. +// It is safe to call from arbitrary goroutines. +var Init = sync.OnceValue(initOnce) diff --git a/signature/internal/sequoia/sequoia.h b/signature/internal/sequoia/sequoia.h new file mode 100644 index 000000000..e0e218926 --- /dev/null +++ b/signature/internal/sequoia/sequoia.h @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include +#include +#include + +typedef enum SequoiaErrorKind { + SEQUOIA_ERROR_KIND_UNKNOWN, + SEQUOIA_ERROR_KIND_INVALID_ARGUMENT, + SEQUOIA_ERROR_KIND_IO_ERROR, +} SequoiaErrorKind; + +typedef enum SequoiaLogLevel { + SEQUOIA_LOG_LEVEL_UNKNOWN, + SEQUOIA_LOG_LEVEL_ERROR, + SEQUOIA_LOG_LEVEL_WARN, + SEQUOIA_LOG_LEVEL_INFO, + SEQUOIA_LOG_LEVEL_DEBUG, + SEQUOIA_LOG_LEVEL_TRACE, +} SequoiaLogLevel; + +typedef struct SequoiaImportResult SequoiaImportResult; + +typedef struct SequoiaMechanism SequoiaMechanism; + +typedef struct SequoiaSignature SequoiaSignature; + +typedef struct SequoiaVerificationResult SequoiaVerificationResult; + +typedef struct SequoiaError { + enum SequoiaErrorKind kind; + char *message; +} SequoiaError; + +void sequoia_error_free(struct SequoiaError *err_ptr); + +struct SequoiaMechanism *sequoia_mechanism_new_from_directory(const char *dir_ptr, + struct SequoiaError **err_ptr); + +struct SequoiaMechanism *sequoia_mechanism_new_ephemeral(struct SequoiaError **err_ptr); + +void sequoia_mechanism_free(struct SequoiaMechanism *mechanism_ptr); + +void sequoia_signature_free(struct SequoiaSignature *signature_ptr); + +const uint8_t *sequoia_signature_get_data(const struct SequoiaSignature *signature_ptr, + size_t *data_len); + +void sequoia_verification_result_free(struct SequoiaVerificationResult *result_ptr); + +const uint8_t *sequoia_verification_result_get_content(const struct SequoiaVerificationResult *result_ptr, + size_t *data_len); + +const char *sequoia_verification_result_get_signer(const struct SequoiaVerificationResult *result_ptr); + +struct SequoiaSignature *sequoia_sign(struct SequoiaMechanism *mechanism_ptr, + const char *key_handle_ptr, + const char *password_ptr, + const uint8_t *data_ptr, + size_t data_len, + struct SequoiaError **err_ptr); + +struct SequoiaVerificationResult *sequoia_verify(struct SequoiaMechanism *mechanism_ptr, + const uint8_t *signature_ptr, + size_t signature_len, + struct SequoiaError **err_ptr); + +void sequoia_import_result_free(struct SequoiaImportResult *result_ptr); + +size_t sequoia_import_result_get_count(const struct SequoiaImportResult *result_ptr); + +const char *sequoia_import_result_get_content(const struct SequoiaImportResult *result_ptr, + size_t index, + struct SequoiaError **err_ptr); + +struct SequoiaImportResult *sequoia_import_keys(struct SequoiaMechanism *mechanism_ptr, + const uint8_t *blob_ptr, + size_t blob_len, + struct SequoiaError **err_ptr); + +int sequoia_set_logger_consumer(void (*consumer)(enum SequoiaLogLevel level, const char *message), + struct SequoiaError **err_ptr); diff --git a/signature/internal/sequoia/sequoia_test.go b/signature/internal/sequoia/sequoia_test.go new file mode 100644 index 000000000..afd2db4fc --- /dev/null +++ b/signature/internal/sequoia/sequoia_test.go @@ -0,0 +1,227 @@ +//go:build containers_image_sequoia + +package sequoia + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func checkCliVersion(version string) error { + return exec.Command("sq", "--cli-version", version, "version").Run() +} + +func generateKey(t *testing.T, dir string, email, passphrase string) (string, error) { + args := []string{"--home", dir, "key", "generate", "--userid", fmt.Sprintf("<%s>", email), "--own-key"} + if passphrase != "" { + pwFile := filepath.Join(t.TempDir(), "passphrase") + err := os.WriteFile(pwFile, []byte(passphrase), 0o600) + if err != nil { + return "", err + } + args = append(args, "--new-password-file", pwFile) + } else { + args = append(args, "--without-password") + } + cmd := exec.Command("sq", args...) + stderr, err := cmd.StderrPipe() + if err != nil { + return "", err + } + + if err := cmd.Start(); err != nil { + return "", err + } + + output, err := io.ReadAll(stderr) + if err != nil { + return "", err + } + + if err := cmd.Wait(); err != nil { + return "", err + } + + re := regexp.MustCompile("(?m)^ *Fingerprint: ([0-9A-F]+)") + matches := re.FindSubmatch(output) + if matches == nil { + return "", errors.New("unable to extract fingerprint") + } + fingerprint := string(matches[1][:]) + return fingerprint, nil +} + +func exportCert(dir string, fingerprint string) ([]byte, error) { + cmd := exec.Command("sq", "--home", dir, "cert", "export", "--cert", fingerprint) + return cmd.Output() +} + +func TestNewMechanismFromDirectory(t *testing.T) { + if err := checkCliVersion("1.3.0"); err != nil { + t.Skipf("sq not usable: %v", err) + } + dir := t.TempDir() + m, err := NewMechanismFromDirectory(dir) + require.NoError(t, err) + m.Close() + _, err = generateKey(t, dir, "foo@example.org", "") + if err != nil { + t.Fatalf("unable to generate key: %v", err) + } + m, err = NewMechanismFromDirectory(dir) + require.NoError(t, err) + m.Close() + + t.Setenv("SEQUOIA_CRYPTO_POLICY", "this/does/not/exist") // Both unreadable files, and relative paths, should cause an error. + _, err = NewMechanismFromDirectory(dir) + assert.Error(t, err) +} + +func TestNewEphemeralMechanism(t *testing.T) { + if err := checkCliVersion("1.3.0"); err != nil { + t.Skipf("sq not usable: %v", err) + } + dir := t.TempDir() + fingerprint, err := generateKey(t, dir, "foo@example.org", "") + if err != nil { + t.Fatalf("unable to generate key: %v", err) + } + output, err := exportCert(dir, fingerprint) + if err != nil { + t.Fatalf("unable to export cert: %v", err) + } + m, err := NewEphemeralMechanism() + require.NoError(t, err) + defer m.Close() + keyIdentities, err := m.ImportKeys(output) + if err != nil { + t.Fatalf("unable to import keys: %v", err) + } + if len(keyIdentities) != 1 || keyIdentities[0] != fingerprint { + t.Fatalf("keyIdentity differ from the original: %v != %v", + keyIdentities[0], fingerprint) + } + + t.Setenv("SEQUOIA_CRYPTO_POLICY", "this/does/not/exist") // Both unreadable files, and relative paths, should cause an error. + _, err = NewEphemeralMechanism() + assert.Error(t, err) +} + +func TestSignWithPassphrase(t *testing.T) { + if err := checkCliVersion("1.3.0"); err != nil { + t.Skipf("sq not usable: %v", err) + } + + // Success is tested in TestGenerateSignVerify and TestSignThenVerifyEphemeral + + // Invalid passphrase + dir := t.TempDir() + fingerprint, err := generateKey(t, dir, "foo@example.org", "valid-passphrase") + require.NoError(t, err) + m, err := NewMechanismFromDirectory(dir) + require.NoError(t, err) + defer m.Close() + _, err = m.SignWithPassphrase([]byte("input"), fingerprint, "invalid-passphrase") + assert.Error(t, err) +} + +func TestGenerateSignVerify(t *testing.T) { + if err := checkCliVersion("1.3.0"); err != nil { + t.Skipf("sq not usable: %v", err) + } + for _, passphrase := range []string{"", "test-passphrase"} { + dir := t.TempDir() + fingerprint, err := generateKey(t, dir, "foo@example.org", passphrase) + if err != nil { + t.Fatalf("unable to generate key: %v", err) + } + m, err := NewMechanismFromDirectory(dir) + if err != nil { + t.Fatalf("unable to initialize a mechanism: %v", err) + } + defer m.Close() + input := []byte("Hello, world!") + var sig []byte + if passphrase != "" { + sig, err = m.SignWithPassphrase(input, fingerprint, passphrase) + } else { + sig, err = m.Sign(input, fingerprint) + } + if err != nil { + t.Fatalf("unable to sign: %v", err) + } + contents, keyIdentity, err := m.Verify(sig) + if err != nil { + t.Fatalf("unable to verify: %v", err) + } + if !bytes.Equal(contents, input) { + t.Fatalf("contents differ from the original") + } + if keyIdentity != fingerprint { + t.Fatalf("keyIdentity differ from the original") + } + } +} + +func TestSignThenVerifyEphemeral(t *testing.T) { + if err := checkCliVersion("1.3.0"); err != nil { + t.Skipf("sq not usable: %v", err) + } + dir := t.TempDir() + fingerprint, err := generateKey(t, dir, "foo@example.org", "") + require.NoError(t, err) + publicKey, err := exportCert(dir, fingerprint) + require.NoError(t, err) + m1, err := NewMechanismFromDirectory(dir) + require.NoError(t, err) + defer m1.Close() + + input := []byte("Hello, world!") + sig, err := m1.Sign(input, fingerprint) + require.NoError(t, err) + + m2, err := NewEphemeralMechanism() + require.NoError(t, err) + defer m2.Close() + + _, _, err = m2.Verify(sig) // With no public key, verification should fail + assert.Error(t, err) + + keyIdentities, err := m2.ImportKeys(publicKey) + require.NoError(t, err) + require.Len(t, keyIdentities, 1) + require.Equal(t, fingerprint, keyIdentities[0]) + contents, keyIdentity, err := m2.Verify(sig) + require.NoError(t, err) + assert.Equal(t, input, contents) + assert.Equal(t, keyIdentity, fingerprint) +} + +func TestImportKeys(t *testing.T) { + // Success is tested in TestNewEphemeralMechanism and TestSignThenVerifyEphemeral + m, err := NewEphemeralMechanism() + require.NoError(t, err) + defer m.Close() + + _, err = m.ImportKeys([]byte("This is not a key at all")) + assert.Error(t, err) +} + +func TestMain(m *testing.M) { + err := Init() + if err != nil { + panic(err) + } + status := m.Run() + os.Exit(status) +} diff --git a/signature/mechanism_gpgme.go b/signature/mechanism_gpgme.go index 53106030f..fe83661d1 100644 --- a/signature/mechanism_gpgme.go +++ b/signature/mechanism_gpgme.go @@ -2,6 +2,9 @@ package signature +// This is shared by mechanism_gpgme_only.go and mechanism_sequoia.go; in both situations +// newGPGSigningMechanismInDirectory is implemented using GPGME. + import ( "bytes" "errors" @@ -18,53 +21,24 @@ type gpgmeSigningMechanism struct { ephemeralDir string // If not "", a directory to be removed on Close() } -// newGPGSigningMechanismInDirectory returns a new GPG/OpenPGP signing mechanism, using optionalDir if not empty. -// The caller must call .Close() on the returned SigningMechanism. -func newGPGSigningMechanismInDirectory(optionalDir string) (signingMechanismWithPassphrase, error) { - ctx, err := newGPGMEContext(optionalDir) - if err != nil { - return nil, err - } +// newGPGMESigningMechanism returns a new GPG/OpenPGP signing mechanism for ctx. +// The caller must call .Close() on the returned SigningMechanism; if ephemeralDir is set, +// the .Close() call will remove its contents. +func newGPGMESigningMechanism(ctx *gpgme.Context, ephemeralDir string) signingMechanismWithPassphrase { return &gpgmeSigningMechanism{ ctx: ctx, - ephemeralDir: "", - }, nil + ephemeralDir: ephemeralDir, + } } -// newEphemeralGPGSigningMechanism returns a new GPG/OpenPGP signing mechanism which -// recognizes _only_ public keys from the supplied blobs, and returns the identities -// of these keys. +// newGPGSigningMechanismInDirectory returns a new GPG/OpenPGP signing mechanism, using optionalDir if not empty. // The caller must call .Close() on the returned SigningMechanism. -func newEphemeralGPGSigningMechanism(blobs [][]byte) (signingMechanismWithPassphrase, []string, error) { - dir, err := os.MkdirTemp("", "containers-ephemeral-gpg-") - if err != nil { - return nil, nil, err - } - removeDir := true - defer func() { - if removeDir { - os.RemoveAll(dir) - } - }() - ctx, err := newGPGMEContext(dir) +func newGPGSigningMechanismInDirectory(optionalDir string) (signingMechanismWithPassphrase, error) { + ctx, err := newGPGMEContext(optionalDir) if err != nil { - return nil, nil, err - } - mech := &gpgmeSigningMechanism{ - ctx: ctx, - ephemeralDir: dir, - } - keyIdentities := []string{} - for _, blob := range blobs { - ki, err := mech.importKeysFromBytes(blob) - if err != nil { - return nil, nil, err - } - keyIdentities = append(keyIdentities, ki...) + return nil, err } - - removeDir = false - return mech, keyIdentities, nil + return newGPGMESigningMechanism(ctx, ""), nil } // newGPGMEContext returns a new *gpgme.Context, using optionalDir if not empty. @@ -94,28 +68,6 @@ func (m *gpgmeSigningMechanism) Close() error { return nil } -// importKeysFromBytes imports public keys from the supplied blob and returns their identities. -// The blob is assumed to have an appropriate format (the caller is expected to know which one). -// NOTE: This may modify long-term state (e.g. key storage in a directory underlying the mechanism); -// but we do not make this public, it can only be used through newEphemeralGPGSigningMechanism. -func (m *gpgmeSigningMechanism) importKeysFromBytes(blob []byte) ([]string, error) { - inputData, err := gpgme.NewDataBytes(blob) - if err != nil { - return nil, err - } - res, err := m.ctx.Import(inputData) - if err != nil { - return nil, err - } - keyIdentities := []string{} - for _, i := range res.Imports { - if i.Result == nil { - keyIdentities = append(keyIdentities, i.Fingerprint) - } - } - return keyIdentities, nil -} - // SupportsSigning returns nil if the mechanism supports signing, or a SigningNotSupportedError. func (m *gpgmeSigningMechanism) SupportsSigning() error { return nil diff --git a/signature/mechanism_gpgme_only.go b/signature/mechanism_gpgme_only.go new file mode 100644 index 000000000..0f971ac6a --- /dev/null +++ b/signature/mechanism_gpgme_only.go @@ -0,0 +1,64 @@ +//go:build !containers_image_openpgp && !containers_image_sequoia + +package signature + +import ( + "os" + + "github.com/proglottis/gpgme" +) + +// newEphemeralGPGSigningMechanism returns a new GPG/OpenPGP signing mechanism which +// recognizes _only_ public keys from the supplied blobs, and returns the identities +// of these keys. +// The caller must call .Close() on the returned SigningMechanism. +func newEphemeralGPGSigningMechanism(blobs [][]byte) (signingMechanismWithPassphrase, []string, error) { + dir, err := os.MkdirTemp("", "containers-ephemeral-gpg-") + if err != nil { + return nil, nil, err + } + removeDir := true + defer func() { + if removeDir { + os.RemoveAll(dir) + } + }() + ctx, err := newGPGMEContext(dir) + if err != nil { + return nil, nil, err + } + keyIdentities := []string{} + for _, blob := range blobs { + ki, err := importKeysFromBytes(ctx, blob) + if err != nil { + return nil, nil, err + } + keyIdentities = append(keyIdentities, ki...) + } + + mech := newGPGMESigningMechanism(ctx, dir) + removeDir = false + return mech, keyIdentities, nil +} + +// importKeysFromBytes imports public keys from the supplied blob and returns their identities. +// The blob is assumed to have an appropriate format (the caller is expected to know which one). +// NOTE: This may modify long-term state (e.g. key storage in a directory underlying the mechanism); +// but we do not make this public, it can only be used through newEphemeralGPGSigningMechanism. +func importKeysFromBytes(ctx *gpgme.Context, blob []byte) ([]string, error) { + inputData, err := gpgme.NewDataBytes(blob) + if err != nil { + return nil, err + } + res, err := ctx.Import(inputData) + if err != nil { + return nil, err + } + keyIdentities := []string{} + for _, i := range res.Imports { + if i.Result == nil { + keyIdentities = append(keyIdentities, i.Fingerprint) + } + } + return keyIdentities, nil +} diff --git a/signature/mechanism_gpgme_test.go b/signature/mechanism_gpgme_test.go index 2f8e77337..095d83991 100644 --- a/signature/mechanism_gpgme_test.go +++ b/signature/mechanism_gpgme_test.go @@ -1,4 +1,4 @@ -//go:build !containers_image_openpgp +//go:build !containers_image_openpgp && !containers_image_sequoia package signature diff --git a/signature/mechanism_sequoia.go b/signature/mechanism_sequoia.go new file mode 100644 index 000000000..fea5ada67 --- /dev/null +++ b/signature/mechanism_sequoia.go @@ -0,0 +1,84 @@ +//go:build containers_image_sequoia + +package signature + +import ( + "github.com/containers/image/v5/signature/internal/sequoia" +) + +// A GPG/OpenPGP signing mechanism, implemented using Sequoia and only supporting verification. +// Legacy users who reach newGPGSigningMechanismInDirectory will use GPGME. +// Signing using Sequoia is preferable, but should happen via signature/simplesequoia.NewSigner, not using +// the legacy mechanism API. +type sequoiaEphemeralSigningMechanism struct { + inner *sequoia.SigningMechanism +} + +// newEphemeralGPGSigningMechanism returns a new GPG/OpenPGP signing mechanism which +// recognizes _only_ public keys from the supplied blobs, and returns the identities +// of these keys. +// The caller must call .Close() on the returned SigningMechanism. +func newEphemeralGPGSigningMechanism(blobs [][]byte) (signingMechanismWithPassphrase, []string, error) { + if err := sequoia.Init(); err != nil { + return nil, nil, err // Coverage: This is impractical to test in-process, with the static go_sequoia_dlhandle. + } + + mech, err := sequoia.NewEphemeralMechanism() + if err != nil { + return nil, nil, err + } + keyIdentities := []string{} + for _, blob := range blobs { + ki, err := mech.ImportKeys(blob) + if err != nil { + return nil, nil, err + } + keyIdentities = append(keyIdentities, ki...) + } + + return &sequoiaEphemeralSigningMechanism{ + inner: mech, + }, keyIdentities, nil +} + +func (m *sequoiaEphemeralSigningMechanism) Close() error { + return m.inner.Close() +} + +// SupportsSigning returns nil if the mechanism supports signing, or a SigningNotSupportedError. +func (m *sequoiaEphemeralSigningMechanism) SupportsSigning() error { + // This code is externally reachable via NewEphemeralGPGSigningMechanism(), but that API provides no way to + // import or generate a key. + return SigningNotSupportedError("caller error: Attempt to sign using a mechanism created via NewEphemeralGPGSigningMechanism().") +} + +// Sign creates a (non-detached) signature of input using keyIdentity and passphrase. +// Fails with a SigningNotSupportedError if the mechanism does not support signing. +func (m *sequoiaEphemeralSigningMechanism) SignWithPassphrase(input []byte, keyIdentity string, passphrase string) ([]byte, error) { + // This code is externally reachable via NewEphemeralGPGSigningMechanism(), but that API provides no way to + // import or generate a key. + return nil, SigningNotSupportedError("caller error: Attempt to sign using a mechanism created via NewEphemeralGPGSigningMechanism().") +} + +// Sign creates a (non-detached) signature of input using keyIdentity. +// Fails with a SigningNotSupportedError if the mechanism does not support signing. +func (m *sequoiaEphemeralSigningMechanism) Sign(input []byte, keyIdentity string) ([]byte, error) { + return m.SignWithPassphrase(input, keyIdentity, "") +} + +// Verify parses unverifiedSignature and returns the content and the signer's identity. +// For mechanisms created using NewEphemeralGPGSigningMechanism, the returned key identity +// is expected to be one of the values returned by NewEphemeralGPGSigningMechanism, +// or the mechanism should implement signingMechanismWithVerificationIdentityLookup. +func (m *sequoiaEphemeralSigningMechanism) Verify(unverifiedSignature []byte) (contents []byte, keyIdentity string, err error) { + return m.inner.Verify(unverifiedSignature) +} + +// UntrustedSignatureContents returns UNTRUSTED contents of the signature WITHOUT ANY VERIFICATION, +// along with a short identifier of the key used for signing. +// WARNING: The short key identifier (which corresponds to "Key ID" for OpenPGP keys) +// is NOT the same as a "key identity" used in other calls to this interface, and +// the values may have no recognizable relationship if the public key is not available. +func (m *sequoiaEphemeralSigningMechanism) UntrustedSignatureContents(untrustedSignature []byte) (untrustedContents []byte, shortKeyIdentifier string, err error) { + return gpgUntrustedSignatureContents(untrustedSignature) +} diff --git a/signature/mechanism_sequoia_test.go b/signature/mechanism_sequoia_test.go new file mode 100644 index 000000000..134d4d343 --- /dev/null +++ b/signature/mechanism_sequoia_test.go @@ -0,0 +1,36 @@ +//go:build containers_image_sequoia + +package signature + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSequoiaNewEphemeralGPGSigningMechanism(t *testing.T) { + // Success is tested in the generic TestNewEphemeralGPGSigningMechanism. + + t.Setenv("SEQUOIA_CRYPTO_POLICY", "this/does/not/exist") // Both unreadable files, and relative paths, should cause an error. + _, _, err := NewEphemeralGPGSigningMechanism([]byte{}) + assert.Error(t, err) +} + +func TestSequoiaSigningMechanismSupportsSigning(t *testing.T) { + mech, _, err := NewEphemeralGPGSigningMechanism([]byte{}) + require.NoError(t, err) + defer mech.Close() + err = mech.SupportsSigning() + assert.Error(t, err) + assert.IsType(t, SigningNotSupportedError(""), err) +} + +func TestSequoiaSigningMechanismSign(t *testing.T) { + mech, _, err := NewEphemeralGPGSigningMechanism([]byte{}) + require.NoError(t, err) + defer mech.Close() + _, err = mech.Sign([]byte{}, TestKeyFingerprint) + assert.Error(t, err) + assert.IsType(t, SigningNotSupportedError(""), err) +} diff --git a/signature/mechanism_test.go b/signature/mechanism_test.go index 9baedb120..7edcdea05 100644 --- a/signature/mechanism_test.go +++ b/signature/mechanism_test.go @@ -1,6 +1,6 @@ package signature -// These tests are expected to pass unmodified for _both_ mechanism_gpgme.go and mechanism_openpgp.go. +// These tests are expected to pass unmodified for _all_ of mechanism_sequoia.go, mechanism_gpgme.go, and mechanism_openpgp.go. import ( "bytes" diff --git a/signature/simplesequoia/mechanism.go b/signature/simplesequoia/mechanism.go new file mode 100644 index 000000000..a6ee4a2b8 --- /dev/null +++ b/signature/simplesequoia/mechanism.go @@ -0,0 +1,52 @@ +//go:build containers_image_sequoia + +package simplesequoia + +// This implements a signature.signingMechanismWithPassphrase that only supports signing. +// +// FIXME: Consider restructuring the simple signing signature creation code path +// not to require this indirection and all those unimplemented methods. + +import ( + "github.com/containers/image/v5/signature/internal/sequoia" +) + +// A GPG/OpenPGP signing mechanism, implemented using Sequoia. +type sequoiaSigningOnlyMechanism struct { + inner *sequoia.SigningMechanism +} + +func (m *sequoiaSigningOnlyMechanism) Close() error { + panic("Should never be called") +} + +// SupportsSigning returns nil if the mechanism supports signing, or a SigningNotSupportedError. +func (m *sequoiaSigningOnlyMechanism) SupportsSigning() error { + panic("Should never be called") +} + +// Sign creates a (non-detached) signature of input using keyIdentity and passphrase. +// Fails with a SigningNotSupportedError if the mechanism does not support signing. +func (m *sequoiaSigningOnlyMechanism) SignWithPassphrase(input []byte, keyIdentity string, passphrase string) ([]byte, error) { + return m.inner.SignWithPassphrase(input, keyIdentity, passphrase) +} + +// Sign creates a (non-detached) signature of input using keyIdentity. +// Fails with a SigningNotSupportedError if the mechanism does not support signing. +func (m *sequoiaSigningOnlyMechanism) Sign(input []byte, keyIdentity string) ([]byte, error) { + panic("Should never be called") +} + +// Verify parses unverifiedSignature and returns the content and the signer's identity +func (m *sequoiaSigningOnlyMechanism) Verify(unverifiedSignature []byte) (contents []byte, keyIdentity string, err error) { + panic("Should never be called") +} + +// UntrustedSignatureContents returns UNTRUSTED contents of the signature WITHOUT ANY VERIFICATION, +// along with a short identifier of the key used for signing. +// WARNING: The short key identifier (which corresponds to "Key ID" for OpenPGP keys) +// is NOT the same as a "key identity" used in other calls to this interface, and +// the values may have no recognizable relationship if the public key is not available. +func (m *sequoiaSigningOnlyMechanism) UntrustedSignatureContents(untrustedSignature []byte) (untrustedContents []byte, shortKeyIdentifier string, err error) { + panic("Should never be called") +} diff --git a/signature/simplesequoia/signer.go b/signature/simplesequoia/signer.go new file mode 100644 index 000000000..64b6610aa --- /dev/null +++ b/signature/simplesequoia/signer.go @@ -0,0 +1,88 @@ +//go:build containers_image_sequoia + +package simplesequoia + +import ( + "context" + "errors" + "fmt" + + "github.com/containers/image/v5/docker/reference" + internalSig "github.com/containers/image/v5/internal/signature" + internalSigner "github.com/containers/image/v5/internal/signer" + "github.com/containers/image/v5/signature" + "github.com/containers/image/v5/signature/internal/sequoia" + "github.com/containers/image/v5/signature/signer" +) + +// simpleSequoiaSigner is a signer.SignerImplementation implementation for simple signing signatures using Sequoia. +type simpleSequoiaSigner struct { + mech *sequoia.SigningMechanism + sequoiaHome string // "" if using the system’s default + keyFingerprint string + passphrase string // "" if not provided. +} + +// NewSigner returns a signature.Signer which creates “simple signing” signatures using the user’s default +// Sequoia PGP configuration. +// +// The set of options must identify a key to sign with, probably using a WithKeyFingerprint. +// +// The caller must call Close() on the returned Signer. +func NewSigner(opts ...Option) (*signer.Signer, error) { + s := simpleSequoiaSigner{} + for _, o := range opts { + if err := o(&s); err != nil { + return nil, err + } + } + if s.keyFingerprint == "" { + return nil, errors.New("no key identity provided for simple signing") + } + + if err := sequoia.Init(); err != nil { + return nil, err // Coverage: This is impractical to test in-process, with the static go_sequoia_dlhandle. + } + mech, err := sequoia.NewMechanismFromDirectory(s.sequoiaHome) + if err != nil { + return nil, fmt.Errorf("initializing Sequoia: %w", err) + } + s.mech = mech + succeeded := false + defer func() { + if !succeeded { + s.mech.Close() // Coverage: This is currently unreachable. + } + }() + + // Ideally, we should look up (and unlock?) the key at this point already. FIXME: is that possible? Anyway, low-priority. + + succeeded = true + return internalSigner.NewSigner(&s), nil +} + +// ProgressMessage returns a human-readable sentence that makes sense to write before starting to create a single signature. +func (s *simpleSequoiaSigner) ProgressMessage() string { + return "Signing image using Sequoia-PGP simple signing" +} + +// SignImageManifest creates a new signature for manifest m as dockerReference. +func (s *simpleSequoiaSigner) SignImageManifest(ctx context.Context, m []byte, dockerReference reference.Named) (internalSig.Signature, error) { + if reference.IsNameOnly(dockerReference) { + return nil, fmt.Errorf("reference %s can’t be signed, it has neither a tag nor a digest", dockerReference.String()) + } + wrapped := sequoiaSigningOnlyMechanism{ + inner: s.mech, + } + simpleSig, err := signature.SignDockerManifestWithOptions(m, dockerReference.String(), &wrapped, s.keyFingerprint, &signature.SignOptions{ + Passphrase: s.passphrase, + }) + if err != nil { + return nil, err + } + return internalSig.SimpleSigningFromBlob(simpleSig), nil +} + +func (s *simpleSequoiaSigner) Close() error { + return s.mech.Close() +} diff --git a/signature/simplesequoia/signer_stub.go b/signature/simplesequoia/signer_stub.go index 49fe487eb..8d2b56dae 100644 --- a/signature/simplesequoia/signer_stub.go +++ b/signature/simplesequoia/signer_stub.go @@ -1,3 +1,5 @@ +//go:build !containers_image_sequoia + package simplesequoia import ( diff --git a/signature/simplesequoia/signer_test.go b/signature/simplesequoia/signer_test.go new file mode 100644 index 000000000..7e58787a0 --- /dev/null +++ b/signature/simplesequoia/signer_test.go @@ -0,0 +1,201 @@ +//go:build containers_image_sequoia + +package simplesequoia + +import ( + "context" + "os" + "testing" + + "github.com/containers/image/v5/docker/reference" + internalSig "github.com/containers/image/v5/internal/signature" + internalSigner "github.com/containers/image/v5/internal/signer" + "github.com/containers/image/v5/signature" + "github.com/opencontainers/go-digest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + // testImageManifestDigest is the Docker manifest digest of "image.manifest.json" + testImageManifestDigest = digest.Digest("sha256:20bf21ed457b390829cdbeec8795a7bea1626991fda603e0d01b4e7f60427e55") + + testSequoiaHome = "./testdata" + // testKeyFingerprint is a fingerprint of a test key in testSequoiaHome, generated using + // > sq --home $(pwd)/signature/simplesequoia/testdata key generate --name 'Skopeo Sequoia testing key' --own-key --expiration=never + testKeyFingerprint = "50DDE898DF4E48755C8C2B7AF6F908B6FA48A229" + // testKeyFingerprintWithPassphrase is a fingerprint of a test key in testSequoiaHome, generated using + // > sq --home $(pwd)/signature/simplesequoia/testdata key generate --name 'Skopeo Sequoia testing key with passphrase' --own-key --expiration=never + testKeyFingerprintWithPassphrase = "1F5825285B785E1DB13BF36D2D11A19ABA41C6AE" + // testPassphrase is the passphrase for testKeyFingerprintWithPassphrase. + testPassphrase = "WithPassphrase123" +) + +func TestNewSigner(t *testing.T) { + // An option causes an error + _, err := NewSigner(WithSequoiaHome(testSequoiaHome), WithKeyFingerprint(testKeyFingerprint), WithPassphrase("\n")) + assert.Error(t, err) + + // WithKeyFingerprint is missing + _, err = NewSigner(WithSequoiaHome(testSequoiaHome), WithPassphrase("something")) + assert.Error(t, err) + + // A smoke test + s, err := NewSigner(WithSequoiaHome(testSequoiaHome), WithKeyFingerprint(testKeyFingerprint)) + require.NoError(t, err) + err = s.Close() + assert.NoError(t, err) + + t.Setenv("SEQUOIA_CRYPTO_POLICY", "this/does/not/exist") // Both unreadable files, and relative paths, should cause an error. + _, err = NewSigner(WithSequoiaHome(testSequoiaHome), WithKeyFingerprint(testKeyFingerprint)) + assert.Error(t, err) +} + +func TestSimpleSignerProgressMessage(t *testing.T) { + // Just a smoke test + s, err := NewSigner(WithSequoiaHome(testSequoiaHome), WithKeyFingerprint(testKeyFingerprint)) + require.NoError(t, err) + defer func() { + err = s.Close() + assert.NoError(t, err) + }() + + _ = internalSigner.ProgressMessage(s) +} + +func TestSimpleSignerSignImageManifest(t *testing.T) { + manifest, err := os.ReadFile("../fixtures/image.manifest.json") + require.NoError(t, err) + testImageSignatureReference, err := reference.ParseNormalizedNamed("example.com/testing/manifest:notlatest") + require.NoError(t, err) + + // Successful signing + for _, c := range []struct { + name string + publicKeyPath string + fingerprint string + opts []Option + }{ + { + name: "No passphrase", + publicKeyPath: "./testdata/no-passphrase.pub", + fingerprint: testKeyFingerprint, + }, + { + name: "With passphrase", + publicKeyPath: "./testdata/with-passphrase.pub", + fingerprint: testKeyFingerprintWithPassphrase, + opts: []Option{WithPassphrase(testPassphrase)}, + }, + } { + s, err := NewSigner(append([]Option{WithSequoiaHome(testSequoiaHome), WithKeyFingerprint(c.fingerprint)}, c.opts...)...) + require.NoError(t, err, c.name) + defer s.Close() + + sig, err := internalSigner.SignImageManifest(context.Background(), s, manifest, testImageSignatureReference) + require.NoError(t, err, c.name) + simpleSig, ok := sig.(internalSig.SimpleSigning) + require.True(t, ok) + + publicKey, err := os.ReadFile(c.publicKeyPath) + require.NoError(t, err) + mech, importedFingerprint, err := signature.NewEphemeralGPGSigningMechanism(publicKey) + require.NoError(t, err) + assert.Equal(t, []string{c.fingerprint}, importedFingerprint) + defer mech.Close() + + verified, err := signature.VerifyDockerManifestSignature(simpleSig.UntrustedSignature(), manifest, testImageSignatureReference.String(), mech, c.fingerprint) + require.NoError(t, err) + assert.Equal(t, testImageSignatureReference.String(), verified.DockerReference) + assert.Equal(t, testImageManifestDigest, verified.DockerManifestDigest) + } + + invalidManifest, err := os.ReadFile("../fixtures/v2s1-invalid-signatures.manifest.json") + require.NoError(t, err) + invalidReference, err := reference.ParseNormalizedNamed("no-tag") + require.NoError(t, err) + for _, c := range []struct { + name string + opts []Option + // NOTE: We DO NOT promise that things that don't fail during NewSigner won't start failing there. + // Actually we’d prefer failures to be identified early. This field only records current expected behavior, not the _desired_ end state. + creationFails bool + creationErrorContains string + manifest []byte + ref reference.Named + }{ + { + name: "No key to sign with", + opts: []Option{WithSequoiaHome(testSequoiaHome)}, + creationFails: true, + }, + { + name: "Invalid passphrase", + opts: []Option{ + WithSequoiaHome(testSequoiaHome), + WithKeyFingerprint(testKeyFingerprintWithPassphrase), + WithPassphrase(testPassphrase + "\n"), + }, + creationFails: true, + creationErrorContains: "invalid passphrase", + ref: testImageSignatureReference, + }, + { + name: "Wrong passphrase", + opts: []Option{ + WithSequoiaHome(testSequoiaHome), + WithKeyFingerprint(testKeyFingerprintWithPassphrase), + WithPassphrase("wrong"), + }, + ref: testImageSignatureReference, + }, + { + name: "No passphrase", + opts: []Option{WithKeyFingerprint(testKeyFingerprintWithPassphrase)}, + ref: testImageSignatureReference, + }, + { + name: "Error computing Docker manifest", + opts: []Option{ + WithSequoiaHome(testSequoiaHome), + WithKeyFingerprint(testKeyFingerprint), + }, + manifest: invalidManifest, + ref: testImageSignatureReference, + }, + { + name: "Invalid reference", + opts: []Option{ + WithSequoiaHome(testSequoiaHome), + WithKeyFingerprint(testKeyFingerprint), + }, + ref: invalidReference, + }, + { + name: "Error signing", + opts: []Option{ + WithSequoiaHome(testSequoiaHome), + WithKeyFingerprint("this fingerprint doesn't exist"), + }, + ref: testImageSignatureReference, + }, + } { + s, err := NewSigner(c.opts...) + if c.creationFails { + assert.Error(t, err, c.name) + if c.creationErrorContains != "" { + assert.ErrorContains(t, err, c.creationErrorContains, c.name) + } + } else { + require.NoError(t, err, c.name) + defer s.Close() + + m := manifest + if c.manifest != nil { + m = c.manifest + } + _, err = internalSigner.SignImageManifest(context.Background(), s, m, c.ref) + assert.Error(t, err, c.name) + } + } +} diff --git a/signature/simplesequoia/testdata/.gitignore b/signature/simplesequoia/testdata/.gitignore new file mode 100644 index 000000000..5dc6c4dd6 --- /dev/null +++ b/signature/simplesequoia/testdata/.gitignore @@ -0,0 +1 @@ +/data/pgp.cert.d/_sequoia* diff --git a/signature/simplesequoia/testdata/data/keystore/keystore.cookie b/signature/simplesequoia/testdata/data/keystore/keystore.cookie new file mode 100644 index 000000000..e69de29bb diff --git a/signature/simplesequoia/testdata/data/keystore/softkeys/1F5825285B785E1DB13BF36D2D11A19ABA41C6AE.pgp b/signature/simplesequoia/testdata/data/keystore/softkeys/1F5825285B785E1DB13BF36D2D11A19ABA41C6AE.pgp new file mode 100644 index 000000000..86462c6b6 Binary files /dev/null and b/signature/simplesequoia/testdata/data/keystore/softkeys/1F5825285B785E1DB13BF36D2D11A19ABA41C6AE.pgp differ diff --git a/signature/simplesequoia/testdata/data/keystore/softkeys/50DDE898DF4E48755C8C2B7AF6F908B6FA48A229.pgp b/signature/simplesequoia/testdata/data/keystore/softkeys/50DDE898DF4E48755C8C2B7AF6F908B6FA48A229.pgp new file mode 100644 index 000000000..4c6ad86f0 Binary files /dev/null and b/signature/simplesequoia/testdata/data/keystore/softkeys/50DDE898DF4E48755C8C2B7AF6F908B6FA48A229.pgp differ diff --git a/signature/simplesequoia/testdata/data/pgp.cert.d/1f/5825285b785e1db13bf36d2d11a19aba41c6ae b/signature/simplesequoia/testdata/data/pgp.cert.d/1f/5825285b785e1db13bf36d2d11a19aba41c6ae new file mode 100644 index 000000000..eb6dd1f4c Binary files /dev/null and b/signature/simplesequoia/testdata/data/pgp.cert.d/1f/5825285b785e1db13bf36d2d11a19aba41c6ae differ diff --git a/signature/simplesequoia/testdata/data/pgp.cert.d/4d/8bcd544b7573eefaad18c278473e5f255d10b8 b/signature/simplesequoia/testdata/data/pgp.cert.d/4d/8bcd544b7573eefaad18c278473e5f255d10b8 new file mode 100644 index 000000000..8dae8b8d8 Binary files /dev/null and b/signature/simplesequoia/testdata/data/pgp.cert.d/4d/8bcd544b7573eefaad18c278473e5f255d10b8 differ diff --git a/signature/simplesequoia/testdata/data/pgp.cert.d/50/dde898df4e48755c8c2b7af6f908b6fa48a229 b/signature/simplesequoia/testdata/data/pgp.cert.d/50/dde898df4e48755c8c2b7af6f908b6fa48a229 new file mode 100644 index 000000000..b9fee9bb2 Binary files /dev/null and b/signature/simplesequoia/testdata/data/pgp.cert.d/50/dde898df4e48755c8c2b7af6f908b6fa48a229 differ diff --git a/signature/simplesequoia/testdata/data/pgp.cert.d/68/de230c4a009f5ee5fbb27984642d0130b86046 b/signature/simplesequoia/testdata/data/pgp.cert.d/68/de230c4a009f5ee5fbb27984642d0130b86046 new file mode 100644 index 000000000..6a58a268c Binary files /dev/null and b/signature/simplesequoia/testdata/data/pgp.cert.d/68/de230c4a009f5ee5fbb27984642d0130b86046 differ diff --git a/signature/simplesequoia/testdata/data/pgp.cert.d/trust-root b/signature/simplesequoia/testdata/data/pgp.cert.d/trust-root new file mode 100644 index 000000000..addf38a56 Binary files /dev/null and b/signature/simplesequoia/testdata/data/pgp.cert.d/trust-root differ diff --git a/signature/simplesequoia/testdata/data/pgp.cert.d/writelock b/signature/simplesequoia/testdata/data/pgp.cert.d/writelock new file mode 100644 index 000000000..e69de29bb diff --git a/signature/simplesequoia/testdata/no-passphrase.pub b/signature/simplesequoia/testdata/no-passphrase.pub new file mode 100644 index 000000000..394d47ec1 --- /dev/null +++ b/signature/simplesequoia/testdata/no-passphrase.pub @@ -0,0 +1,38 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +xjMEaGwFVhYJKwYBBAHaRw8BAQdAZzfnqEAgvE3RoCtPWEOc3Xp8oMURR0qjq+Ru +PHJrc6TCwAsEHxYKAH0FgmhsBVYDCwkHCRD2+Qi2+kiiKUcUAAAAAAAeACBzYWx0 +QG5vdGF0aW9ucy5zZXF1b2lhLXBncC5vcmcEjRQtILaFnIhczxeUkcfW0KMHEZ30 +wTdJ1v1iHB7NKQMVCggCmwECHgkWIQRQ3eiY305IdVyMK3r2+Qi2+kiiKQAA86gA +/1ZkXWPHUxh3nQu/EL72ZeP9k/SLWkEuNKs6dJrmRud9AQCHbWwSUwKyt12EFVt/ +QvMFSQ95brUxsWLHgFMPpNfWAc0aU2tvcGVvIFNlcXVvaWEgdGVzdGluZyBrZXnC +wA4EExYKAIAFgmhsBVYDCwkHCRD2+Qi2+kiiKUcUAAAAAAAeACBzYWx0QG5vdGF0 +aW9ucy5zZXF1b2lhLXBncC5vcmctF7xuY06GUyedOGjd2iNKwab85gV64zEAGKgi +ExHRxgMVCggCmQECmwECHgkWIQRQ3eiY305IdVyMK3r2+Qi2+kiiKQAA3SEBAMe1 +y6rWaPjDpkeiDthLV1Umr6NsXVBv/IJTcP9RM4quAQCwmlsdQMddCsc+K3Y5KH88 +saIG0/MRZaPJdsd8vRGUCs4zBGhsBVYWCSsGAQQB2kcPAQEHQLN8yt/21QDMzcB4 +2bzFRg1LpkFZWECjkb2ty7Iju/aOwsC/BBgWCgExBYJobAVWCRD2+Qi2+kiiKUcU +AAAAAAAeACBzYWx0QG5vdGF0aW9ucy5zZXF1b2lhLXBncC5vcmce9QEurrtI24ys +vXssO/40rI5rlsNokEEFr7CVwVgWvAKbAr6gBBkWCgBvBYJobAVWCRB63Ra9Qdgp +tkcUAAAAAAAeACBzYWx0QG5vdGF0aW9ucy5zZXF1b2lhLXBncC5vcmcBWCJsdUfj +oYpld4qcYBqjxsyScwpID2vkNlYMLmS+IhYhBKyZqvZ6WI3zgaapXHrdFr1B2Cm2 +AAAEZwEA/UhpNN1XElYx6Xq+JMKlXywoIgButkQy1+H2EcRBeHsBAM7lq8BXvRKz +bDjRlgxiIAYl77p7ihVQ5NYcuZcAlH0CFiEEUN3omN9OSHVcjCt69vkItvpIoikA +AJcwAP9D4spfb28k16w2cemrWAtAE1WUgV8V+OEpE7+gpV+17gEA+0Kzf7jBHgd3 +pBAWwttuRd8OHlZZzKs3f26z28I6mgLOMwRobAVWFgkrBgEEAdpHDwEBB0DPyS14 +jQk1mSWNmuYR4P9M5zOfU2mkhwaqx1l3OWTZD8LAvwQYFgoBMQWCaGwFVgkQ9vkI +tvpIoilHFAAAAAAAHgAgc2FsdEBub3RhdGlvbnMuc2VxdW9pYS1wZ3Aub3Jn+wfK +FmPmtrsi0sY5zIq9KFmbrQyhXz/VZIw6K8D1zdECmyC+oAQZFgoAbwWCaGwFVgkQ +bwujLUxU69BHFAAAAAAAHgAgc2FsdEBub3RhdGlvbnMuc2VxdW9pYS1wZ3Aub3Jn +xF3KXB4+dN9suOhCD2XkYlAWUJ4GVBVV2wAmdQAueyEWIQTv1sMw2eUTIMQmb7Zv +C6MtTFTr0AAA/LYA/iBkRh6dGbp76VzuuHVNUNgTqvXgz9FjizZGJKnVZctXAPwL +TlHxcH6XX96AuiCy9QAMUpm8ZvMu8TAgjgOrlFPKCBYhBFDd6JjfTkh1XIwrevb5 +CLb6SKIpAAA0rQD9HWbBeSoshjH6/k5ntZjOfIAha4/TLlBrMq2w+t4LWD0A/2q5 +DEbYh6PwMidDxXteyHWf4Qnr0vH8vip9d+WHbDYEzjgEaGwFVhIKKwYBBAGXVQEF +AQEHQLxXHw9STOAhb2PLEjrl3uQDwpaXIdigg67vId0jSstVAwEIB8LAAAQYFgoA +cgWCaGwFVgkQ9vkItvpIoilHFAAAAAAAHgAgc2FsdEBub3RhdGlvbnMuc2VxdW9p +YS1wZ3Aub3Jn8bvuQCv3uEYJtK6h5y5e4AY9lJtVXx3brexR5bmFCwcCmwwWIQRQ +3eiY305IdVyMK3r2+Qi2+kiiKQAAEzkA/Az97rdlp3hf97S6a5AxU8pTry4gKI63 +lwKtBAT+uF/pAP9lAziQRlNEa1sX6qCXrQqeA/aQ0nj9gRJ1Wvi1PMxWBA== +=7jmE +-----END PGP PUBLIC KEY BLOCK----- diff --git a/signature/simplesequoia/testdata/with-passphrase.pub b/signature/simplesequoia/testdata/with-passphrase.pub new file mode 100644 index 000000000..7d266b956 --- /dev/null +++ b/signature/simplesequoia/testdata/with-passphrase.pub @@ -0,0 +1,39 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +xjMEaGwF3RYJKwYBBAHaRw8BAQdAouHF6y7foOScub78AINlTzXnEQrYrAJyH8fr +3biwuMzCwAsEHxYKAH0FgmhsBd0DCwkHCRAtEaGaukHGrkcUAAAAAAAeACBzYWx0 +QG5vdGF0aW9ucy5zZXF1b2lhLXBncC5vcmdRHdDbkndmp7Q96YisL7ezwrLxSfQj +46zFb8wob+6yvgMVCggCmwECHgkWIQQfWCUoW3heHbE7820tEaGaukHGrgAAbd8A +/3iwAF7qTVgqqCqLVIj8oJxrZr/jWbHbjO1DzFafQQjMAQDwwOuL9dhy9Q7N5UkW +x3kq3WLEIuogh+0meAwfMrJMAM0qU2tvcGVvIFNlcXVvaWEgdGVzdGluZyBrZXkg +d2l0aCBwYXNzcGhyYXNlwsAOBBMWCgCABYJobAXdAwsJBwkQLRGhmrpBxq5HFAAA +AAAAHgAgc2FsdEBub3RhdGlvbnMuc2VxdW9pYS1wZ3Aub3JnwGFwQkw9BWc963pG +lBgz8D9CbfsqoDS58GXyd24W8g4DFQoIApkBApsBAh4JFiEEH1glKFt4Xh2xO/Nt +LRGhmrpBxq4AAKTpAPsHMyzeL+fT/EdPbU/+fi/+RbGuRQH5QHtzaDfAu+ZGUwD+ +Oeoi7OOy8+bgvnEdj31TohAGEexTvhMIILglL9ymTgfOMwRobAXdFgkrBgEEAdpH +DwEBB0DNeYLgt7VaYbdJ3TyTqiYp7pEuXYVYjeqRtt055Hs60cLAvwQYFgoBMQWC +aGwF3QkQLRGhmrpBxq5HFAAAAAAAHgAgc2FsdEBub3RhdGlvbnMuc2VxdW9pYS1w +Z3Aub3Jnr08xd/fCttifQZ/b+oVq2huO6HT9zpTITLIzPLLBI6cCmwK+oAQZFgoA +bwWCaGwF3QkQVNJA3Fgs7h9HFAAAAAAAHgAgc2FsdEBub3RhdGlvbnMuc2VxdW9p +YS1wZ3Aub3JnN1Kokqv7bIxnM7EODP0bX7yuAV8OP+kCivD84d8TrkEWIQQemi42 +PEh1us0v16FU0kDcWCzuHwAAUisBAKBMLjhkVO+KCFNKxYoak/Hj7VAHwiqnEAXB +aMstWEE1AP9rVWwZ85IdlSejb475H9HGl+Nl0a5BOioR/Y+Kl15UBxYhBB9YJShb +eF4dsTvzbS0RoZq6QcauAAAKnAEAvgb1r2cteb+9wd9U5vYZ7/xXKEljojjA7CQT +QFmecoYBAO3/rNK3xYcKleni3lknNhzQap+Ed6ri2WVQCKujRgIAzjMEaGwF3RYJ +KwYBBAHaRw8BAQdA1JYMc2I192WwvCI/qFcLrwmFPwDDkHvNDDt4Kc2ziHjCwL8E +GBYKATEFgmhsBd0JEC0RoZq6QcauRxQAAAAAAB4AIHNhbHRAbm90YXRpb25zLnNl +cXVvaWEtcGdwLm9yZxDKeHct4SrN5lJ3oAkhIfwcJpCTVv9Sux05J7Pn0U6TApsg +vqAEGRYKAG8FgmhsBd0JEMb35fxT9XmfRxQAAAAAAB4AIHNhbHRAbm90YXRpb25z +LnNlcXVvaWEtcGdwLm9yZ9MZe54S5aYMdKLQmZiNN7Q1tot0zCuRp0DOMrZIsWQg +FiEEzXeihzhK/PSlCtVwxvfl/FP1eZ8AAFpUAQC1WlWjrTCL+ZiG3X9ThPO8418f +wu+p3l9jJAF1SK15QQEA6Go0+bbWOHMpkMNckSwlXhbBKVp53y2IhQnwLAfbZwoW +IQQfWCUoW3heHbE7820tEaGaukHGrgAAhYMA/iuXYUHqeXNpFCmoDFWmvwHDoPIs +8ZrgBJOfSnzg+x5wAQCFIWANcwYD/rCHTN6KQY70VI/x7SmkqKJZVrIBCB7DB844 +BGhsBd0SCisGAQQBl1UBBQEBB0CYZYh5OKFAiuKOx4MIk6pocGCdfpL/XrJVoWjT +9aDSNAMBCAfCwAAEGBYKAHIFgmhsBd0JEC0RoZq6QcauRxQAAAAAAB4AIHNhbHRA +bm90YXRpb25zLnNlcXVvaWEtcGdwLm9yZ0oyJPZxXWc2dSxHpS1UAuvCfc80DaDy +mr1nRs5/QO0aApsMFiEEH1glKFt4Xh2xO/NtLRGhmrpBxq4AANKZAP0T00LyderN +Qsdk2UgpeeoZhN4wKtlUGocUs7I90P3AhgD/WuDXAlF6b9IXyTUoG9VkLrnlemCx +Dii+5qsdk0HFcgA= +=YS7U +-----END PGP PUBLIC KEY BLOCK-----