diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 02f050d72e..e7e4ead93e 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -346,6 +346,17 @@ jobs: rbe: false runner: ubuntu-24.04-arm targets: //compile/test:cross_compile_x86_64_no_unwind_test + # Live counterpart of //pgp/test:audit_test - re-runs the audit + # against a real `bazel aquery` of //pgp/test's example targets + # rather than captured JSON (see bazel/pgp/README.md#auditing). + - name: pgp-live-audit + action: run + bazel_args: --config=ci + bazel_mode: bzlmod + privileged: false + rbe: false + runner: ubuntu-24.04 + targets: //pgp/test:live_audit status: runs-on: ubuntu-24.04 diff --git a/bazel/MODULE.bazel b/bazel/MODULE.bazel index d5bd00d857..716baa5e38 100644 --- a/bazel/MODULE.bazel +++ b/bazel/MODULE.bazel @@ -109,6 +109,17 @@ use_repo(wee8_prebuilt_ext, "wee8_prebuilt_x86_64", "wee8_prebuilt_x86_64_libstd # libcxx_libs_ext.setup() # use_repo(libcxx_libs_ext, "libcxx_libs_aarch64", "libcxx_libs_x86_64") +# Setup the OpenPGP signer (`sq`) toolchain - example for downstream consumers +# Uncomment to use in your MODULE.bazel, supplying sha256s you have verified: +# pgp_ext = use_extension("@envoy_toolshed//pgp:extensions.bzl", "pgp_extension") +# pgp_ext.setup( +# sha256s = { +# "linux_x86_64": "", +# }, +# ) +# use_repo(pgp_ext, "sq_linux_x86_64") +# register_toolchains("@sq_linux_x86_64//:toolchain") + # Setup grcov for code coverage - example for downstream consumers # Uncomment to use in your MODULE.bazel: # grcov_ext = use_extension("@envoy_toolshed//coverage/grcov:extensions.bzl", "grcov_extension") @@ -171,6 +182,10 @@ use_repo(llvm, "llvm_toolchain") register_toolchains("@llvm_toolchain//:all", dev_dependency = True) +# Stub OpenPGP signer used by //pgp/test analysis tests. Real signing requires +# a `sq` toolchain, see //pgp:extensions.bzl. +register_toolchains("//pgp/test:stub_toolchain", dev_dependency = True) + libcxx_ext = use_extension("//compile:extensions.bzl", "libcxx_extension", dev_dependency = True) use_repo(libcxx_ext, "llvm_libcxx_aarch64", "llvm_libcxx_x86_64") diff --git a/bazel/MODULE.bazel.lock b/bazel/MODULE.bazel.lock index 13d985e422..8e56cc16c7 100644 --- a/bazel/MODULE.bazel.lock +++ b/bazel/MODULE.bazel.lock @@ -293,22 +293,22 @@ "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca", "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/source.json": "22bc55c47af97246cfc093d0acf683a7869377de362b5d1c552c2c2e16b7a806", "https://bcr.bazel.build/modules/zlib/1.3.1/MODULE.bazel": "751c9940dcfe869f5f7274e1295422a34623555916eb98c174c1e945594bf198", - "https://raw.githubusercontent.com/envoyproxy/bazel-registry/a126e386f21fe8c74dce2515d87c302596b5d133/bazel_registry.json": "51bd3a0b193753e419c5d3db38e92fbcb9f7f97fd992ef2e24a0addd7274b37a", - "https://raw.githubusercontent.com/envoyproxy/bazel-registry/a126e386f21fe8c74dce2515d87c302596b5d133/modules/dragonbox/0.0.0-241028-6c7c925.envoy/MODULE.bazel": "56dd26c839325bc2c40cc1879c00cab3397cd83fe3cf6ab8ccaf7fd6ad1b890d", - "https://raw.githubusercontent.com/envoyproxy/bazel-registry/a126e386f21fe8c74dce2515d87c302596b5d133/modules/dragonbox/0.0.0-241028-6c7c925.envoy/source.json": "642addb5bea17b6570a138ccfb4f50f34c810c9e3416748a09e1ac723b01021f", - "https://raw.githubusercontent.com/envoyproxy/bazel-registry/a126e386f21fe8c74dce2515d87c302596b5d133/modules/fp16/0.0.0-260704-3d2de18.envoy/MODULE.bazel": "27e5020ea158fdc725eafed3443b6fa9a1555d52505dafcdc3e6ea74d9e112c1", - "https://raw.githubusercontent.com/envoyproxy/bazel-registry/a126e386f21fe8c74dce2515d87c302596b5d133/modules/fp16/0.0.0-260704-3d2de18.envoy/source.json": "ccc308b4d5afbba59ce93809243da68a5402414bb7f707d2b691df68ab896250", - "https://raw.githubusercontent.com/envoyproxy/bazel-registry/a126e386f21fe8c74dce2515d87c302596b5d133/modules/icu/78.2.envoy/MODULE.bazel": "bed492d3dffffca822867b4b034191069ea917cd5c1b98ad3c5e609227d0f387", - "https://raw.githubusercontent.com/envoyproxy/bazel-registry/a126e386f21fe8c74dce2515d87c302596b5d133/modules/icu/78.2.envoy/source.json": "d3c0d7c00cdb28b6294356ec253a0ee994e2d65f912b92075ea5ddd049df283b", - "https://raw.githubusercontent.com/envoyproxy/bazel-registry/a126e386f21fe8c74dce2515d87c302596b5d133/modules/protobuf/35.1.bcr.envoy/MODULE.bazel": "a42d2e15b0ffb57474df57e2cc274aa9ad2e52e6e9e7bc19e9c529b0d4093d5f", - "https://raw.githubusercontent.com/envoyproxy/bazel-registry/a126e386f21fe8c74dce2515d87c302596b5d133/modules/protobuf/35.1.bcr.envoy/source.json": "5ee2c32f315c4be232f955a963f37e631f70a4d32cd3dd536b46c48920a98944", - "https://raw.githubusercontent.com/envoyproxy/bazel-registry/a126e386f21fe8c74dce2515d87c302596b5d133/modules/simdutf/8.1.0.envoy/MODULE.bazel": "d7288f3bd5168bc92aff3591e5f7a3a58277bddcc7fc70fa125f39ac9445f4b7", - "https://raw.githubusercontent.com/envoyproxy/bazel-registry/a126e386f21fe8c74dce2515d87c302596b5d133/modules/simdutf/8.1.0.envoy/source.json": "b95d6bfb222cf92c83d3352ec0c550394d801e2b2700b160b692cbe4b40b8342", - "https://raw.githubusercontent.com/envoyproxy/bazel-registry/a126e386f21fe8c74dce2515d87c302596b5d133/modules/toolchains_llvm/1.8.0.envoy/MODULE.bazel": "93909b69ee77410306b1b591653f7f89ecda4c7d88d42c468dd1e5e7afc6fe65", - "https://raw.githubusercontent.com/envoyproxy/bazel-registry/a126e386f21fe8c74dce2515d87c302596b5d133/modules/toolchains_llvm/1.9.0.envoy/MODULE.bazel": "f3a85560662c37a739cbe00f20a527baf0caa9508c0541840877481479c0c825", - "https://raw.githubusercontent.com/envoyproxy/bazel-registry/a126e386f21fe8c74dce2515d87c302596b5d133/modules/toolchains_llvm/1.9.0.envoy/source.json": "67e2d7bbdb368463c599a87fcc2c1884ca6ac0a339d5ac999ca8b5c34ec0911d", - "https://raw.githubusercontent.com/envoyproxy/bazel-registry/a126e386f21fe8c74dce2515d87c302596b5d133/modules/v8/14.6.202.10.envoy/MODULE.bazel": "57fa395049458e9768b26e4789236ee620bd177414bf979cf583887f06c71749", - "https://raw.githubusercontent.com/envoyproxy/bazel-registry/a126e386f21fe8c74dce2515d87c302596b5d133/modules/v8/14.6.202.10.envoy/source.json": "068a8822f21c1c488982f6b3fd5c52858e8db025ebc3458170d3ed940b425a57" + "https://raw.githubusercontent.com/envoyproxy/bazel-registry/76440e57c1495e6e6b26e30069d42050a8ca5acb/bazel_registry.json": "51bd3a0b193753e419c5d3db38e92fbcb9f7f97fd992ef2e24a0addd7274b37a", + "https://raw.githubusercontent.com/envoyproxy/bazel-registry/76440e57c1495e6e6b26e30069d42050a8ca5acb/modules/dragonbox/0.0.0-241028-6c7c925.envoy/MODULE.bazel": "56dd26c839325bc2c40cc1879c00cab3397cd83fe3cf6ab8ccaf7fd6ad1b890d", + "https://raw.githubusercontent.com/envoyproxy/bazel-registry/76440e57c1495e6e6b26e30069d42050a8ca5acb/modules/dragonbox/0.0.0-241028-6c7c925.envoy/source.json": "642addb5bea17b6570a138ccfb4f50f34c810c9e3416748a09e1ac723b01021f", + "https://raw.githubusercontent.com/envoyproxy/bazel-registry/76440e57c1495e6e6b26e30069d42050a8ca5acb/modules/fp16/0.0.0-260704-3d2de18.envoy/MODULE.bazel": "27e5020ea158fdc725eafed3443b6fa9a1555d52505dafcdc3e6ea74d9e112c1", + "https://raw.githubusercontent.com/envoyproxy/bazel-registry/76440e57c1495e6e6b26e30069d42050a8ca5acb/modules/fp16/0.0.0-260704-3d2de18.envoy/source.json": "ccc308b4d5afbba59ce93809243da68a5402414bb7f707d2b691df68ab896250", + "https://raw.githubusercontent.com/envoyproxy/bazel-registry/76440e57c1495e6e6b26e30069d42050a8ca5acb/modules/icu/78.2.envoy/MODULE.bazel": "bed492d3dffffca822867b4b034191069ea917cd5c1b98ad3c5e609227d0f387", + "https://raw.githubusercontent.com/envoyproxy/bazel-registry/76440e57c1495e6e6b26e30069d42050a8ca5acb/modules/icu/78.2.envoy/source.json": "d3c0d7c00cdb28b6294356ec253a0ee994e2d65f912b92075ea5ddd049df283b", + "https://raw.githubusercontent.com/envoyproxy/bazel-registry/76440e57c1495e6e6b26e30069d42050a8ca5acb/modules/protobuf/35.1.bcr.envoy/MODULE.bazel": "a42d2e15b0ffb57474df57e2cc274aa9ad2e52e6e9e7bc19e9c529b0d4093d5f", + "https://raw.githubusercontent.com/envoyproxy/bazel-registry/76440e57c1495e6e6b26e30069d42050a8ca5acb/modules/protobuf/35.1.bcr.envoy/source.json": "5ee2c32f315c4be232f955a963f37e631f70a4d32cd3dd536b46c48920a98944", + "https://raw.githubusercontent.com/envoyproxy/bazel-registry/76440e57c1495e6e6b26e30069d42050a8ca5acb/modules/simdutf/8.1.0.envoy/MODULE.bazel": "d7288f3bd5168bc92aff3591e5f7a3a58277bddcc7fc70fa125f39ac9445f4b7", + "https://raw.githubusercontent.com/envoyproxy/bazel-registry/76440e57c1495e6e6b26e30069d42050a8ca5acb/modules/simdutf/8.1.0.envoy/source.json": "b95d6bfb222cf92c83d3352ec0c550394d801e2b2700b160b692cbe4b40b8342", + "https://raw.githubusercontent.com/envoyproxy/bazel-registry/76440e57c1495e6e6b26e30069d42050a8ca5acb/modules/toolchains_llvm/1.8.0.envoy/MODULE.bazel": "93909b69ee77410306b1b591653f7f89ecda4c7d88d42c468dd1e5e7afc6fe65", + "https://raw.githubusercontent.com/envoyproxy/bazel-registry/76440e57c1495e6e6b26e30069d42050a8ca5acb/modules/toolchains_llvm/1.9.0.envoy/MODULE.bazel": "f3a85560662c37a739cbe00f20a527baf0caa9508c0541840877481479c0c825", + "https://raw.githubusercontent.com/envoyproxy/bazel-registry/76440e57c1495e6e6b26e30069d42050a8ca5acb/modules/toolchains_llvm/1.9.0.envoy/source.json": "67e2d7bbdb368463c599a87fcc2c1884ca6ac0a339d5ac999ca8b5c34ec0911d", + "https://raw.githubusercontent.com/envoyproxy/bazel-registry/76440e57c1495e6e6b26e30069d42050a8ca5acb/modules/v8/14.6.202.10.envoy/MODULE.bazel": "57fa395049458e9768b26e4789236ee620bd177414bf979cf583887f06c71749", + "https://raw.githubusercontent.com/envoyproxy/bazel-registry/76440e57c1495e6e6b26e30069d42050a8ca5acb/modules/v8/14.6.202.10.envoy/source.json": "068a8822f21c1c488982f6b3fd5c52858e8db025ebc3458170d3ed940b425a57" }, "selectedYankedVersions": {}, "moduleExtensions": { @@ -409,8 +409,8 @@ }, "//compile:extensions.bzl%llvm_minimal_extension": { "general": { - "bzlTransitiveDigest": "iGhoyv/rCamuOdT7NVhoofJc2Gsxlkdl9ymm3SgqLFo=", - "usagesDigest": "ZqLfAjRhUAMAeJiS/eI2hiP3v3uIOtSBvc703K/qOb0=", + "bzlTransitiveDigest": "x8brdUaqutwZNWU2C3GzA+Sd3OjgtdDV37agyDaPhi0=", + "usagesDigest": "DV2AgZy5Sn1W2An5MbFce8gCvOt7LZOKqmViARTw+s4=", "recordedFileInputs": {}, "recordedDirentsInputs": {}, "envVariables": {}, @@ -418,7 +418,7 @@ "llvm_minimal_linux_x64": { "repoRuleId": "@@//compile:llvm_minimal.bzl%llvm_minimal_repo", "attributes": { - "url": "https://github.com/envoyproxy/toolshed/releases/download/bins-v0.2.11/llvm-minimal-22.1.8-Linux-X64.tar.zst", + "url": "https://github.com/envoyproxy/toolshed/releases/download/bins-v0.2.14/llvm-minimal-22.1.8-Linux-X64.tar.zst", "sha256": "6cb4cca6df33be00c80fa1639062c973d1cafe4e7ad98a9b4bdf21bf9dec5806", "strip_prefix": "llvm-minimal-22.1.8-Linux-X64" } @@ -426,7 +426,7 @@ "llvm_minimal_linux_arm64": { "repoRuleId": "@@//compile:llvm_minimal.bzl%llvm_minimal_repo", "attributes": { - "url": "https://github.com/envoyproxy/toolshed/releases/download/bins-v0.2.11/llvm-minimal-22.1.8-Linux-ARM64.tar.zst", + "url": "https://github.com/envoyproxy/toolshed/releases/download/bins-v0.2.14/llvm-minimal-22.1.8-Linux-ARM64.tar.zst", "sha256": "9a6cc0a84d524342e578db739b04e8a3875adb40b38887e4e074661e925f8a9c", "strip_prefix": "llvm-minimal-22.1.8-Linux-ARM64" } @@ -434,7 +434,7 @@ "llvm_minimal_macos_arm64": { "repoRuleId": "@@//compile:llvm_minimal.bzl%llvm_minimal_repo", "attributes": { - "url": "https://github.com/envoyproxy/toolshed/releases/download/bins-v0.2.11/llvm-minimal-22.1.8-macOS-ARM64.tar.zst", + "url": "https://github.com/envoyproxy/toolshed/releases/download/bins-v0.2.14/llvm-minimal-22.1.8-macOS-ARM64.tar.zst", "sha256": "928e51aa7c97fbb8c5c50075118f4b36e36363b1a2c3af2dfef9aea1ef526ade", "strip_prefix": "llvm-minimal-22.1.8-macOS-ARM64" } @@ -490,7 +490,7 @@ "//dependency/test:reachability_test_extension.bzl%reachability_test_extension": { "general": { "bzlTransitiveDigest": "NqxwhacnUCjbNmVUwIbYGp2Qbml8/4TK6imuNETJWnY=", - "usagesDigest": "JpYNilrJt8/GsPfYlEGn1o5zRlvR7SKmfkifD8YCuzs=", + "usagesDigest": "PofClPiRmRdQd49PXYF9CHN/RpQDShIMwtWonDS8GDY=", "recordedFileInputs": {}, "recordedDirentsInputs": {}, "envVariables": {}, @@ -545,8 +545,8 @@ }, "//sysroot:extensions.bzl%sysroot_extension": { "general": { - "bzlTransitiveDigest": "PRext/1KRDZnD3QPsbnKQMaXacghUYN2mdYPFjOMm/Q=", - "usagesDigest": "S9rr2cGiXVc4Inmt9v2uEbWMw9zQJ7m6K554kfMS8i0=", + "bzlTransitiveDigest": "CQlHTk2wRBJ3aOM7jzKA9zRO6JoNhMR68mo4qJZ5uZw=", + "usagesDigest": "8IWELKvFYFZAAKiu9NSRKV5H9Tbi6ipx6JmQR/fiLQE=", "recordedFileInputs": {}, "recordedDirentsInputs": {}, "envVariables": {}, @@ -554,8 +554,8 @@ "sysroot_linux_amd64": { "repoRuleId": "@@//sysroot:sysroot.bzl%sysroot", "attributes": { - "version": "0.2.11", - "sha256": "63b3b31f7e1a0182816bde18513d72f734e02cc5d2ec50336f04c72c720e4484", + "version": "0.2.14", + "sha256": "e3eba94f01ab0eba6da81fce712f645b2305557f052792b18ab14ec16d665d93", "arch": "amd64", "glibc_version": "2.31", "stdcc_version": "13" @@ -564,8 +564,8 @@ "sysroot_linux_arm64": { "repoRuleId": "@@//sysroot:sysroot.bzl%sysroot", "attributes": { - "version": "0.2.11", - "sha256": "cea58c40b0ced7ff83e1de70879ab904142167d4e0f3caf9e93aecd67845d1c5", + "version": "0.2.14", + "sha256": "40f1f1fa9a8342be444bf94fe1a46ca5eca6091cce83870099ff186ba286e65c", "arch": "arm64", "glibc_version": "2.31", "stdcc_version": "13" @@ -730,8 +730,8 @@ }, "@@rules_python+//python/uv:uv.bzl%uv": { "general": { - "bzlTransitiveDigest": "ijW9KS7qsIY+yBVvJ+Nr1mzwQox09j13DnE3iIwaeTM=", - "usagesDigest": "H8dQoNZcoqP+Mu0tHZTi4KHATzvNkM5ePuEqoQdklIU=", + "bzlTransitiveDigest": "ELjwPp2kLku5M3S/gpjjVjy3TwT760/zVEQ70nJreHU=", + "usagesDigest": "LCPgc6OYAryd0HQJS9CtnCxvca1YfrjIZ67iPax2aRs=", "recordedFileInputs": {}, "recordedDirentsInputs": {}, "envVariables": {}, @@ -1298,8 +1298,8 @@ }, "@@toolchains_llvm+//toolchain/extensions:distributions.bzl%llvm_distributions": { "general": { - "bzlTransitiveDigest": "gCdXpBt3HBBc280OILOFheHmO7lXWXJYnPgYiTtyapI=", - "usagesDigest": "Gxd/8VJQJ2gEen1n0zokZ5b2dU+EtCFsb0NpgAJkCaw=", + "bzlTransitiveDigest": "UjfWy+RWdwj4BIiZFchPZA3MIBFUkIKzj19B8/LDGko=", + "usagesDigest": "RMQmnjhQu0/s1mluVFIa5TjoFcqct9XtWtNP5Bjy1Ho=", "recordedFileInputs": {}, "recordedDirentsInputs": {}, "envVariables": {}, diff --git a/bazel/WORKSPACE b/bazel/WORKSPACE index 07f3517a02..aeef57301d 100644 --- a/bazel/WORKSPACE +++ b/bazel/WORKSPACE @@ -40,3 +40,7 @@ website_install_deps() load("//dependency/test:reachability_test_extension.bzl", "reachability_test_repos") reachability_test_repos() + +# Stub OpenPGP signer used by //pgp/test analysis tests. Real signing requires +# a `sq` toolchain, see //pgp:extensions.bzl. +register_toolchains("//pgp/test:stub_toolchain") diff --git a/bazel/pgp/BUILD b/bazel/pgp/BUILD new file mode 100644 index 0000000000..bf7ab580aa --- /dev/null +++ b/bazel/pgp/BUILD @@ -0,0 +1,63 @@ +load("@bazel_skylib//:bzl_library.bzl", "bzl_library") +load("@bazel_skylib//rules:common_settings.bzl", "string_flag") + +exports_files([ + "defs.bzl", + "toolchain.bzl", + "extensions.bzl", +]) + +# Ensures //pgp:extensions.bzl (not otherwise loaded - no `sq` platform is +# enabled by default) and the rest of the public/private starlark surface +# stay loadable and are covered by `bazel build //pgp/...`. +bzl_library( + name = "pgp_bzl", + srcs = [ + "defs.bzl", + "extensions.bzl", + "toolchain.bzl", + "//pgp/private:sign.bzl", + "//pgp/private:sq.bzl", + ], + visibility = ["//visibility:private"], + deps = [ + "@bazel_skylib//rules:common_settings", + ], +) + +# Toolchain type for OpenPGP signer implementations. +# +# The default implementation wraps Sequoia PGP's `sq` (see extensions.bzl), +# but any binary implementing the signer CLI contract documented in +# toolchain.bzl can be registered instead. +toolchain_type( + name = "toolchain_type", + visibility = ["//visibility:public"], +) + +# Absolute host path of the passphrase-encrypted secret key. +# +# The key is a host path, not an artifact in the build graph: Bazel only ever +# sees the path, never the key file contents. The file is read by the signer at +# execution time. +# +# It must not live under the Bazel output tree or any artifact upload path. +string_flag( + name = "key_path", + build_setting_default = "", + visibility = ["//visibility:public"], +) + +# Absolute host path of the file containing the passphrase for the signing +# key. +# +# The passphrase is deliberately *not* part of the build graph: only this +# path is seen by Bazel, and only the path (never the passphrase) appears on +# the signer command line. The file is read by the signer at execution time. +# +# It must not live under the Bazel output tree or any artifact upload path. +string_flag( + name = "passphrase_path", + build_setting_default = "", + visibility = ["//visibility:public"], +) diff --git a/bazel/pgp/README.md b/bazel/pgp/README.md new file mode 100644 index 0000000000..dca0bd026f --- /dev/null +++ b/bazel/pgp/README.md @@ -0,0 +1,212 @@ +# OpenPGP signing (`//pgp`) + +Hermetic, secret-safe OpenPGP signing rules. + +Bazel has no notion of a secret: anything that is an action input is just +bytes that Bazel may hash, cache, upload to a CAS, or record in a build event +stream. These rules therefore make the safe configuration the *only* +configuration - the signing key must be passphrase-encrypted, the passphrase +is deliberately kept out of the build graph, and every signing action is +pinned to the local machine. + +## Security model + +### What these rules guarantee + +| Guarantee | How | +| --- | --- | +| **No key material ever enters the build graph** | The key is a host path, not an artifact; the signer additionally refuses unencrypted keys (`--require-encrypted-key`) so even the host file is ciphertext | +| Key/passphrase content changes are not silently ignored | `key_path` accepts `#sha256=` so content is part of the action key and is verified by the signer; passphrase content is never hashed (oracle risk) and signing actions are `no-cache` so they always re-run | +| The passphrase is never an action input, and is never hashed, cached or uploaded by Bazel | It is provided as an absolute host path via `--@envoy_toolshed//pgp:passphrase_path`; Bazel only ever sees the path | +| The passphrase never appears on a command line (`ps`, `--subcommands`, execution log) | Only `--passphrase-file ` is passed | +| The passphrase is never written to disk a second time | The signer reads it via process substitution (`--password-file <(...)`) rather than copying it to a temporary file | +| Signing actions never leave the machine | `no-remote`, `no-remote-exec`, `no-remote-cache`, `no-remote-cache-upload` and `no-cache` are hardcoded in the rule and are not user-overridable | +| Signing actions stay sandboxed | Unlike a `local`-tagged action, sandboxing is never disabled, so the signer cannot see anything on disk it was not explicitly given as an input | +| Signing actions are greppable in `aquery` | `mnemonic = "OpenPGPSign"` | +| No ambient environment reaches the signer | `use_default_shell_env = False` and an explicit, minimal `env = {"PATH": "/usr/bin:/bin"}`; the signer never consults `HOME`, `GNUPGHOME`, `SSH_AUTH_SOCK`, a gpg-agent socket, or any keyring/cert store on disk (`sq` is invoked with `--home none --cert-store none --key-store none --batch`) | +| The key is never copied into an output | The action writes only its declared output | +| A build without a configured passphrase or key fails loudly | The rule `fail()`s at analysis time | + +### What you still own + +| Concern | Why it is yours | +| --- | --- | +| The encrypted key file on the host | Put it somewhere dedicated - eg `${runner.temp}/gpg/signing-key.asc`, `chmod 600`, deleted when the job ends - and **never** under the Bazel output tree, `--disk_cache`, or any artifact upload path | +| Computing key digest | Compute the `#sha256=` fragment yourself (eg `sha256sum`) — the rules cannot | +| The passphrase file contents | Trailing newlines are stripped (matching `gpg --passphrase-file`); everything else is used verbatim | +| The plaintext passphrase file on the host | It must exist in plaintext during the build. Put it somewhere dedicated - eg `${runner.temp}/gpg/passphrase`, `chmod 600`, deleted when the job ends - and **never** under the Bazel output tree, `--disk_cache`, or any artifact upload path | +| `--sandbox_debug` | It leaves sandbox directories (including the action's inputs) on disk | +| Where the encrypted key comes from | The rules verify it is encrypted, not that it is *your* key | + +### Why not a label / why not `path_flag` + +Hermeticity is about action inputs; the key and passphrase are deliberately host capabilities (like `SSH_AUTH_SOCK`), invisible to remote executors because `no-remote-exec` guarantees they never see the action; a label or path-flag would pull content into the graph, which is exactly what we are avoiding. + +## Usage + +```starlark +load("@envoy_toolshed//pgp:defs.bzl", "pgp_sign_checksums", "pgp_sign_detached") + +pgp_sign_detached( + name = "signed_tarball", + src = ":tarball", +) + +pgp_sign_checksums( + name = "signed_checksums", + srcs = [":tarball", ":package"], +) +``` + +```console +$ bazel build //:signed_tarball \ + --@envoy_toolshed//pgp:key_path=/run/user/1000/gpg/signing-key.asc#sha256=... \ + --@envoy_toolshed//pgp:passphrase_path=/run/user/1000/gpg/passphrase +``` + +Without the flags the build fails at analysis time: + +``` +No key path configured for //:signed_tarball. +``` + +**CI wiring recommendation:** A setup step writes the encrypted key and passphrase to `${runner.temp}/gpg/`, `chmod 600`, computes the key digest, and passes both flags to bazel; no `~/.gnupg`, no agent, no `HOME` mount required. + +### Rules + +| Rule | Purpose | +| --- | --- | +| `pgp_sign(name, src, mode, out, armor)` | Core rule. Signs a single `src`. `mode` is one of `detached`, `cleartext`, `inline` | +| `pgp_sign_detached(name, src, out)` | Detached, armored signature (`.asc`) | +| `pgp_sign_cleartext(name, src, out)` | Cleartext signature - what `debsign` produces for `.changes`/`.dsc`, and what an apt `InRelease` is | +| `pgp_sign_checksums(name, srcs, algorithm, out)` | `shasum`-format checksums file for `srcs`, cleartext signed. Checksum generation is a separate, cacheable action - only signing handles secrets | +| `pgp_sign_changes_file(name, changes, out)` | Cleartext sign a Debian `.changes`/`.dsc` file itself. **Not** a full `debsign`: it does not sign referenced `.dsc`/`.buildinfo` files or rewrite their checksums - see the `TODO` on the rule | +| `pgp_toolchain(name, signer)` | Register a signer implementation for `//pgp:toolchain_type` | + +RPM header signing is **not** implemented here. + +> TODO(pgp): RPM header signing needs an OpenPGP implementation that can +> insert a signature into the RPM header rather than produce a standalone +> signature. It is intended to arrive via the toolchain swap path below (a +> purpose-built `sequoia-openpgp` + `rpm-rs` signer in `rust/`), without any +> change to these rules. + +## Toolchain + +`//pgp:toolchain_type` is implemented by any executable satisfying the signer +CLI contract: + +``` +signer --mode {detached|cleartext|inline} \ + --key \ + [--key-sha256 ] \ + --passphrase-file \ + --require-encrypted-key \ + --out \ + [--armor] \ + +``` + +The default implementation is a thin wrapper around Sequoia PGP's +[`sq`](https://sequoia-pgp.org) - a Rust OpenPGP implementation with no agent, +home directory or keyring state, used as the OpenPGP backend for `rpm` on +Fedora/RHEL and as `sqv` in apt >= 3.0. + +Upstream does not publish sha256-verifiable release binaries that could be +pinned here, so **no `sq` platform is fetched by default**. Enable the +platform(s) you need by supplying sha256s you have verified yourself: + +```starlark +pgp_ext = use_extension("@envoy_toolshed//pgp:extensions.bzl", "pgp_extension") +pgp_ext.setup( + sha256s = { + "linux_x86_64": "", + }, +) +use_repo(pgp_ext, "sq_linux_x86_64") + +register_toolchains("@sq_linux_x86_64//:toolchain") +``` + +`urls` can be used to point at your own audited mirror of the binary. + +The intended toolshed approach for this is to build and publish a pinned, +static `sq` in the `bins-v*` release, the same way `sysroot`/`llvm_minimal` +are, so `pgp_ext.setup()` can work with no consumer-supplied sha256 - that is +a follow-up, not part of this rule set. + +Swapping in a different signer (for example a purpose-built Rust signer) is a +matter of registering another toolchain - the rules do not change: + +```starlark +pgp_toolchain( + name = "my_signer_toolchain", + signer = "//my/signer", +) + +toolchain( + name = "my_toolchain", + toolchain = ":my_signer_toolchain", + toolchain_type = "@envoy_toolshed//pgp:toolchain_type", +) +``` + +## Auditing your own targets + +`//pgp/test:audit` runs `bazel aquery` over target patterns you give it and +asserts that: + +1. every `OpenPGPSign` action carries all of the required execution + requirements, +2. no input reachable from an `OpenPGPSign` action (resolved transitively via + `inputDepSetIds`) has a path matching + `(^|/)\.gnupg(/|$)|private-keys-v1\.d|passphrase|secret|\.(asc|pgp|gpg|key)$`, +3. no `OpenPGPSign` action has `HOME`, `GNUPGHOME` or `SSH_AUTH_SOCK` in its + environment, +4. no `OpenPGPSign` action passes a forbidden string (eg your passphrase) on + the command line, +5. every `OpenPGPSign` action has exactly one non-tool input artifact (the file being signed). + +```console +$ bazel run @envoy_toolshed//pgp/test:audit -- \ + --forbid "$(cat /run/user/1000/gpg/passphrase)" \ + --@envoy_toolshed//pgp:key_path=/run/user/1000/gpg/signing-key.asc#sha256=... \ + --@envoy_toolshed//pgp:passphrase_path=/run/user/1000/gpg/passphrase \ + "deps(//distribution:signed)" +``` + +Any other option is passed through to `bazel aquery`, so the targets can be +audited in the configuration they are actually built in. + +Use `deps(...)` to audit the whole universe reachable from a target rather +than only the actions the target itself owns. + +The script also accepts previously captured output: + +```console +$ bazel aquery --output=jsonproto "deps(//distribution:signed)" > aquery.json +$ .../audit_test.sh --aquery-json aquery.json +``` + +`//pgp/test:audit_test` runs the audit against captured `aquery` output +(`fixtures/audit.json`, the real output for the example targets in +`//pgp/test`) together with deliberately broken variants derived from it at +test time with `jq` (a removed execution requirement, a leaked environment +variable, key material as an action input, a passphrase on the command +line), each of which the audit must reject. + +`//pgp/test:live_audit` (`bazel run //pgp/test:live_audit`) is the live +counterpart: it re-invokes `bazel aquery` against the real dependency graph +of the same example targets, rather than captured JSON, so a regression that +only shows up in the real graph is caught too. It cannot run as a sandboxed +`bazel test` since it shells out to `bazel`. + +## Alternative: signing after the build + +The most conservative option remains signing *outside* the build graph: +`bazel build` produces the unsigned artifacts and a separate `bazel run` +target signs them on the host. Nothing that happens in `bazel run` is an +action, so nothing is hashed, cached or uploaded. + +The toolchain here is deliberately reusable for that: the same signer binary +can be driven from a `bazel run` wrapper via the CLI contract above. diff --git a/bazel/pgp/defs.bzl b/bazel/pgp/defs.bzl new file mode 100644 index 0000000000..5597ad11d8 --- /dev/null +++ b/bazel/pgp/defs.bzl @@ -0,0 +1,138 @@ +"""Hermetic, secret-safe OpenPGP signing rules. + +See `//pgp:README.md` for the security model. + +Usage: + +```starlark +load("@envoy_toolshed//pgp:defs.bzl", "pgp_sign_detached") + +pgp_sign_detached( + name = "sign_tarball", + src = ":tarball", +) +``` + +```console +$ bazel build //:sign_tarball \\ + --@envoy_toolshed//pgp:key_path=/abs/path/to/key.asc \\ + --@envoy_toolshed//pgp:passphrase_path=/abs/path/to/passphrase +``` +""" + +load("//pgp/private:sign.bzl", _pgp_checksums = "pgp_checksums", _pgp_sign = "pgp_sign") +load("//pgp/private:sq.bzl", _sq_signer = "sq_signer") +load("//pgp:toolchain.bzl", _PgpSignerInfo = "PgpSignerInfo", _pgp_toolchain = "pgp_toolchain") + +PgpSignerInfo = _PgpSignerInfo +pgp_sign = _pgp_sign +pgp_checksums = _pgp_checksums +pgp_toolchain = _pgp_toolchain +sq_signer = _sq_signer + +def pgp_sign_detached(name, src, out = None, armor = True, **kwargs): + """Create a detached signature for `src`. + + Args: + name: Name of the target. + src: File to sign. + out: Output file, defaults to `.asc`. + armor: Emit ASCII armored output. + **kwargs: Additional arguments to the underlying rule. + """ + pgp_sign( + name = name, + src = src, + mode = "detached", + armor = armor, + out = out or "%s.asc" % _basename(src), + **kwargs + ) + +def pgp_sign_cleartext(name, src, out = None, **kwargs): + """Create a cleartext signed version of `src`. + + This is what `debsign` does to `.changes`/`.dsc` files, and what an apt + `InRelease` file is. + + Args: + name: Name of the target. + src: File to sign. + out: Output file, defaults to `.asc`. + **kwargs: Additional arguments to the underlying rule. + """ + pgp_sign( + name = name, + src = src, + mode = "cleartext", + out = out or "%s.asc" % _basename(src), + **kwargs + ) + +def pgp_sign_checksums( + name, + srcs, + algorithm = "sha256", + out = None, + checksums_out = None, + **kwargs): + """Generate a checksums file for `srcs` and cleartext sign it. + + Checksum generation is a separate, cacheable action - only the signing + step handles secrets. + + Args: + name: Name of the target. + srcs: Files to checksum. + algorithm: Checksum algorithm (`sha256` or `sha512`). + out: Output file, defaults to `checksums.txt.asc`. + checksums_out: Unsigned checksums file, defaults to `checksums.txt`. + **kwargs: Additional arguments to the underlying signing rule. + """ + checksums_out = checksums_out or "%s.checksums.txt" % name + pgp_checksums( + name = "%s_checksums" % name, + srcs = srcs, + algorithm = algorithm, + out = checksums_out, + tags = kwargs.get("tags"), + visibility = kwargs.get("visibility"), + ) + pgp_sign( + name = name, + src = "%s_checksums" % name, + mode = "cleartext", + out = out or "%s.asc" % checksums_out, + **kwargs + ) + +# TODO(pgp): this only clearsigns `changes` itself. Real `debsign` also signs +# the `.dsc`/`.buildinfo` files referenced by a `.changes` file and rewrites +# their sizes/hashes in the `Files:`/`Checksums-*` sections of the `.changes` +# before clearsigning it. Neither of those happen here - do not use this to +# produce a `.changes` file that itself references unsigned `.dsc`/ +# `.buildinfo` files that need re-signing. +def pgp_sign_changes_file(name, changes, out = None, **kwargs): + """Cleartext sign a single Debian `.changes` (or `.dsc`) file. + + This clearsigns `changes` itself - see the TODO above for what real + `debsign` additionally does that this does not. The signed file replaces + the original, so the output keeps the original basename (in a directory + named after the target). + + Args: + name: Name of the target. + changes: The `.changes`/`.dsc` file to clearsign. + out: Output file, defaults to `/`. + **kwargs: Additional arguments to the underlying rule. + """ + pgp_sign( + name = name, + src = changes, + mode = "cleartext", + out = out or "%s/%s" % (name, _basename(changes)), + **kwargs + ) + +def _basename(label): + return str(label).split(":")[-1].split("/")[-1] diff --git a/bazel/pgp/extensions.bzl b/bazel/pgp/extensions.bzl new file mode 100644 index 0000000000..1c99bd286a --- /dev/null +++ b/bazel/pgp/extensions.bzl @@ -0,0 +1,165 @@ +"""Module extension fetching the default OpenPGP signer binary (`sq`). + +Sequoia PGP's `sq` is a single, statically linkable Rust OpenPGP +implementation with no agent, home directory or keyring state to fight - the +same OpenPGP implementation used as the backend for `rpm` on Fedora/RHEL and +as `sqv` in apt >= 3.0. + +Upstream does not publish sha256-verifiable release binaries that could be +pinned here, so **no platform is fetched by default** - each platform must be +enabled by passing a sha256 you have verified yourself: + +```starlark +sq = use_extension("@envoy_toolshed//pgp:extensions.bzl", "pgp_extension") +sq.setup( + version = "1.4.0", + sha256s = { + "linux_x86_64": "", + }, +) +use_repo(sq, "sq_linux_x86_64") +register_toolchains("@sq_linux_x86_64//:toolchain") +``` + +`urls` can be used to point at your own (audited, mirrored) copy of the +binary. +""" + +# TODO(pgp): pin verified sha256s here once upstream publishes signed, +# reproducible release binaries. The URLs below are the upstream release +# artifact locations - they are unverified, so the corresponding platforms +# are disabled until a sha256 is supplied by the consumer. +SQ_VERSION = "1.4.0" + +SQ_PLATFORMS = { + "darwin_aarch64": struct( + # TODO(pgp): unverified, sha256 required to enable. + url = "https://gitlab.com/sequoia-pgp/sequoia-sq/-/releases/v{version}/downloads/sq-{version}-aarch64-apple-darwin", + exec_compatible_with = [ + "@platforms//os:macos", + "@platforms//cpu:aarch64", + ], + ), + "linux_aarch64": struct( + # TODO(pgp): unverified, sha256 required to enable. + url = "https://gitlab.com/sequoia-pgp/sequoia-sq/-/releases/v{version}/downloads/sq-{version}-aarch64-unknown-linux-musl", + exec_compatible_with = [ + "@platforms//os:linux", + "@platforms//cpu:aarch64", + ], + ), + "linux_x86_64": struct( + # TODO(pgp): unverified, sha256 required to enable. + url = "https://gitlab.com/sequoia-pgp/sequoia-sq/-/releases/v{version}/downloads/sq-{version}-x86_64-unknown-linux-musl", + exec_compatible_with = [ + "@platforms//os:linux", + "@platforms//cpu:x86_64", + ], + ), +} + +_BUILD_FILE = """ +load("@envoy_toolshed//pgp:defs.bzl", "pgp_toolchain", "sq_signer") + +package(default_visibility = ["//visibility:public"]) + +exports_files(["sq"]) + +sq_signer( + name = "signer", + sq = "sq", +) + +pgp_toolchain( + name = "signer_toolchain", + signer = ":signer", +) + +toolchain( + name = "toolchain", + exec_compatible_with = {exec_compatible_with}, + toolchain = ":signer_toolchain", + toolchain_type = "@envoy_toolshed//pgp:toolchain_type", +) +""" + +def _sq_repository_impl(ctx): + ctx.download( + url = ctx.attr.urls, + sha256 = ctx.attr.sha256, + output = "sq", + executable = True, + ) + ctx.file( + "BUILD", + _BUILD_FILE.format(exec_compatible_with = str(ctx.attr.exec_compatible_with)), + executable = False, + ) + +sq_repository = repository_rule( + implementation = _sq_repository_impl, + doc = "Downloads a `sq` binary and exposes it as an OpenPGP signer toolchain.", + attrs = { + "exec_compatible_with": attr.string_list( + doc = "Execution platform constraints for the toolchain.", + mandatory = True, + ), + "sha256": attr.string( + doc = "Verified sha256 of the `sq` binary.", + mandatory = True, + ), + "urls": attr.string_list( + doc = "URLs to download the `sq` binary from.", + mandatory = True, + ), + }, +) + +def _sq_repo(platform, version, url, sha256): + sq_repository( + name = "sq_%s" % platform, + urls = [url.format(version = version)], + sha256 = sha256, + exec_compatible_with = SQ_PLATFORMS[platform].exec_compatible_with, + ) + +def _pgp_extension_impl(module_ctx): + for mod in module_ctx.modules: + for tag in mod.tags.setup: + version = tag.version or SQ_VERSION + for platform, sha256 in tag.sha256s.items(): + if platform not in SQ_PLATFORMS: + fail("Unknown `sq` platform: %s (expected one of %s)" % ( + platform, + sorted(SQ_PLATFORMS), + )) + if not sha256: + fail("No sha256 given for `sq` platform: %s" % platform) + _sq_repo( + platform, + version, + tag.urls.get(platform) or SQ_PLATFORMS[platform].url, + sha256, + ) + +_setup = tag_class( + attrs = { + "sha256s": attr.string_dict( + doc = "Verified sha256 of the `sq` binary, keyed by platform.", + ), + "urls": attr.string_dict( + doc = "Override download URL, keyed by platform.", + ), + "version": attr.string( + doc = "`sq` version to fetch.", + default = SQ_VERSION, + ), + }, +) + +pgp_extension = module_extension( + implementation = _pgp_extension_impl, + tag_classes = { + "setup": _setup, + }, +) diff --git a/bazel/pgp/private/BUILD b/bazel/pgp/private/BUILD new file mode 100644 index 0000000000..dd3d4340cc --- /dev/null +++ b/bazel/pgp/private/BUILD @@ -0,0 +1,14 @@ +load("@rules_shell//shell:sh_binary.bzl", "sh_binary") + +exports_files([ + "checksums.sh", + "signer.sh", + "sign.bzl", + "sq.bzl", +]) + +sh_binary( + name = "checksums", + srcs = ["checksums.sh"], + visibility = ["//visibility:public"], +) diff --git a/bazel/pgp/private/checksums.sh b/bazel/pgp/private/checksums.sh new file mode 100755 index 0000000000..5d1f84c895 --- /dev/null +++ b/bazel/pgp/private/checksums.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# Generate a `shasum`-format checksums file. +# +# checksums.sh ... +# +# Checksums are emitted with the basename of each input, matching the +# `sha256sum`/`shasum` output format used by the toolshed gpg actions. + +set -euo pipefail + +if [[ $# -lt 3 ]]; then + echo "usage: $0 ALGORITHM OUT INPUT..." >&2 + exit 2 +fi + +ALGORITHM="$1" +OUT="$2" +shift 2 + +case "$ALGORITHM" in + sha256|sha512) + ;; + *) + echo "unknown algorithm: ${ALGORITHM}" >&2 + exit 2 + ;; +esac + +BITS="${ALGORITHM#sha}" + +checksum () { + local file="$1" + if command -v "${ALGORITHM}sum" > /dev/null 2>&1; then + "${ALGORITHM}sum" "$file" + elif command -v shasum > /dev/null 2>&1; then + shasum -a "$BITS" "$file" + else + echo "no ${ALGORITHM} implementation found" >&2 + exit 1 + fi +} + +: > "$OUT" + +for input in "$@"; do + ( + cd "$(dirname "$input")" + checksum "$(basename "$input")" + ) >> "$OUT" +done diff --git a/bazel/pgp/private/sign.bzl b/bazel/pgp/private/sign.bzl new file mode 100644 index 0000000000..27fd04c3d7 --- /dev/null +++ b/bazel/pgp/private/sign.bzl @@ -0,0 +1,248 @@ +"""Implementation of the OpenPGP signing rules. + +Security model (see `//pgp:README.md`): + +- the secret key *must* be passphrase-encrypted, and the signer is always + invoked with `--require-encrypted-key` so it fails hard on unprotected + secret key packets. The only key material Bazel ever sees is ciphertext. +- the passphrase is deliberately not part of the build graph. It is provided + as an absolute host path via `--@envoy_toolshed//pgp:passphrase_path`, so + only the *path* is ever hashed, logged or cached. +- every signing action carries the full set of execution requirements below, + hardcoded here rather than left to the caller. +- no ambient environment is inherited (`use_default_shell_env = False`); only + an explicit, minimal `PATH` is set. +""" + +load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo") + +TOOLCHAIN_TYPE = "//pgp:toolchain_type" + +MODES = [ + "cleartext", + "detached", + "inline", +] + +# Hardcoded, not user-overridable: a signing action must never be shipped to, +# or cached in, anything other than the machine it runs on. The action stays +# sandboxed and local - `local` (no-sandbox) is deliberately not one of these. +EXECUTION_REQUIREMENTS = { + "no-cache": "1", + "no-remote": "1", + "no-remote-cache": "1", + "no-remote-cache-upload": "1", + "no-remote-exec": "1", +} + +# Minimal, explicit `PATH` for signing/checksums actions. `env = {}` alone +# only works because bash/glibc fall back to compiled-in default paths - +# setting `PATH` explicitly makes that dependency visible and pinned rather +# than implicit. +_ACTION_ENV = {"PATH": "/usr/bin:/bin"} + +MNEMONIC = "OpenPGPSign" + +_NO_PATH = """ +No {what} path configured for {label}. + +OpenPGP signing requires an absolute host path to the {what_desc}. +Pass the absolute path: + + bazel build {label} --@envoy_toolshed//pgp:{setting_flag}=/abs/path + +The file is read by the signer at execution time. Bazel never reads it, and it +must not live under the Bazel output tree or any artifact upload path. +""" + +_RELATIVE_PATH = """ +{what_cap} path for {label} is not absolute: {path} + +Bazel actions do not run in your working directory, so the {what} path must +be absolute. +""" + +_PASSPHRASE_FRAGMENT = """ +Passphrase path for {label} specifies a fragment: {path} + +Specifying a sha256 fragment on passphrase_path is forbidden: a passphrase digest is an oracle for weak passphrases. +""" + +_BAD_FRAGMENT = """ +Invalid sha256 fragment for {label}: {frag} + +Expected a fragment in the form #sha256=<64 lowercase hex characters>. +""" + +def _host_path(ctx, setting, what): + value = setting[BuildSettingInfo].value + what_cap = "Key" if what == "key" else "Passphrase" + what_desc = "passphrase-encrypted secret key" if what == "key" else "passphrase file" + setting_flag = "key_path" if what == "key" else "passphrase_path" + + if not value: + fail(_NO_PATH.format( + what = what, + what_desc = what_desc, + setting_flag = setting_flag, + label = ctx.label, + )) + + parts = value.split("#") + if len(parts) > 2: + fail("Invalid {what} path for {label}: contains multiple '#' characters".format( + what = what, + label = ctx.label, + )) + + path = parts[0] + if not path.startswith("/"): + fail(_RELATIVE_PATH.format( + what = what, + what_cap = what_cap, + label = ctx.label, + path = path, + )) + + sha256 = None + if len(parts) == 2: + frag = parts[1] + if what == "passphrase": + fail(_PASSPHRASE_FRAGMENT.format(label = ctx.label, path = value)) + + if not frag.startswith("sha256=") or len(frag) != 71: + fail(_BAD_FRAGMENT.format(label = ctx.label, frag = frag)) + + digest = frag[7:] + for c in digest.elems(): + if c not in "0123456789abcdef": + fail(_BAD_FRAGMENT.format(label = ctx.label, frag = frag)) + sha256 = digest + + return struct(path = path, sha256 = sha256) + +def _sign(ctx, mode, src, out, armor): + key_info = _host_path(ctx, ctx.attr._key_path, "key") + passphrase_info = _host_path(ctx, ctx.attr._passphrase_path, "passphrase") + signer = ctx.toolchains[TOOLCHAIN_TYPE].pgp_signer + args = ctx.actions.args() + args.add("--mode", mode) + args.add("--key", key_info.path) + if key_info.sha256: + args.add("--key-sha256", key_info.sha256) + + # Only the *path* is passed - never the passphrase itself, so it cannot + # show up in `ps`, `--subcommands` or an execution log. + args.add("--passphrase-file", passphrase_info.path) + args.add("--require-encrypted-key") + args.add("--out", out) + if armor: + args.add("--armor") + args.add(src) + ctx.actions.run( + executable = signer.signer, + arguments = [args], + inputs = [src], + outputs = [out], + tools = depset([signer.signer], transitive = [signer.runfiles.files]), + mnemonic = MNEMONIC, + progress_message = "Signing %s" % out.short_path, + execution_requirements = EXECUTION_REQUIREMENTS, + use_default_shell_env = False, + env = _ACTION_ENV, + ) + +def _pgp_sign_impl(ctx): + out = ctx.outputs.out + _sign( + ctx, + mode = ctx.attr.mode, + src = ctx.file.src, + out = out, + armor = ctx.attr.armor, + ) + return [DefaultInfo(files = depset([out]))] + +pgp_sign = rule( + implementation = _pgp_sign_impl, + doc = """Sign `src` with key specified via `--@envoy_toolshed//pgp:key_path`. + +The key must be a passphrase-encrypted OpenPGP secret key host path. The +passphrase host path is provided via `--@envoy_toolshed//pgp:passphrase_path`. +""", + attrs = { + "armor": attr.bool( + doc = "Emit ASCII armored output.", + default = True, + ), + "mode": attr.string( + doc = "Signature mode.", + default = "detached", + values = MODES, + ), + "out": attr.output( + doc = "Output file.", + mandatory = True, + ), + "src": attr.label( + doc = "The single file to sign.", + mandatory = True, + allow_single_file = True, + ), + "_key_path": attr.label( + default = "//pgp:key_path", + ), + "_passphrase_path": attr.label( + default = "//pgp:passphrase_path", + ), + }, + toolchains = [TOOLCHAIN_TYPE], +) + +def _pgp_checksums_impl(ctx): + out = ctx.outputs.out + args = ctx.actions.args() + args.add(ctx.attr.algorithm) + args.add(out) + args.add_all(ctx.files.srcs) + ctx.actions.run( + executable = ctx.executable._checksums, + arguments = [args], + inputs = ctx.files.srcs, + outputs = [out], + mnemonic = "OpenPGPChecksums", + progress_message = "Generating checksums %s" % out.short_path, + use_default_shell_env = False, + env = _ACTION_ENV, + ) + return [DefaultInfo(files = depset([out]))] + +pgp_checksums = rule( + implementation = _pgp_checksums_impl, + doc = """Generate a `shasum`-format checksums file for `srcs`. + +This action holds no secrets and is deliberately cacheable - only the +signing of the resulting file is a secret action. +""", + attrs = { + "algorithm": attr.string( + doc = "Checksum algorithm.", + default = "sha256", + values = ["sha256", "sha512"], + ), + "out": attr.output( + doc = "Output file.", + mandatory = True, + ), + "srcs": attr.label_list( + doc = "Files to checksum.", + mandatory = True, + allow_files = True, + ), + "_checksums": attr.label( + default = "//pgp/private:checksums", + executable = True, + cfg = "exec", + ), + }, +) diff --git a/bazel/pgp/private/signer.sh b/bazel/pgp/private/signer.sh new file mode 100755 index 0000000000..c1e2e70376 --- /dev/null +++ b/bazel/pgp/private/signer.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +# +# Thin wrapper translating the toolshed signer CLI contract to Sequoia PGP +# (`sq`) invocations. +# +# signer --mode {detached|cleartext|inline} \ +# --key \ +# [--key-sha256 ] \ +# --passphrase-file \ +# --require-encrypted-key \ +# --out \ +# [--armor] \ +# +# +# The wrapper deliberately never consults `HOME`, `GNUPGHOME`, a gpg-agent +# socket, or any on-disk keyring/cert store - `sq` is invoked with its own +# state directories disabled. It operates purely on the key file, the +# passphrase file and the declared inputs. + +set -euo pipefail + +# Baked in at build time by `sq_signer`. `SQ` can be set when running this +# script outside of Bazel (eg the integration test). +SQ="${SQ:-@SQ@}" + +MODE= +KEY= +KEY_SHA256= +PASSPHRASE_FILE= +OUT= +ARMOR=0 +REQUIRE_ENCRYPTED_KEY=0 +INPUTS=() + +usage () { + echo "usage: $0 --mode {detached|cleartext|inline} --key KEY" \ + "[--key-sha256 HEX] --passphrase-file PATH --out OUT [--armor]" \ + "[--require-encrypted-key] INPUT" >&2 + exit 2 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --mode) + MODE="$2" + shift 2 + ;; + --key) + KEY="$2" + shift 2 + ;; + --key-sha256) + KEY_SHA256="$2" + shift 2 + ;; + --passphrase-file) + PASSPHRASE_FILE="$2" + shift 2 + ;; + --out) + OUT="$2" + shift 2 + ;; + --armor) + ARMOR=1 + shift + ;; + --require-encrypted-key) + REQUIRE_ENCRYPTED_KEY=1 + shift + ;; + --) + shift + INPUTS+=("$@") + break + ;; + -*) + echo "unknown option: $1" >&2 + usage + ;; + *) + INPUTS+=("$1") + shift + ;; + esac +done + +if [[ -z "$MODE" || -z "$KEY" || -z "$PASSPHRASE_FILE" || -z "$OUT" ]]; then + usage +fi + +if [[ ${#INPUTS[@]} -eq 0 ]]; then + echo "no input files given" >&2 + usage +fi + +# The placeholder is split so that `sq_signer` template expansion does not +# rewrite this check too. +if [[ "$SQ" == "@""SQ""@" ]]; then + echo "no \`sq\` binary configured (SQ is unset and the wrapper was not" \ + "expanded by \`sq_signer\`)" >&2 + exit 1 +fi + +if [[ ${#INPUTS[@]} -gt 1 ]]; then + echo "the signer accepts a single input file, got ${#INPUTS[@]}" >&2 + exit 2 +fi + +if [[ "$KEY" != /* ]]; then + echo "key path must be absolute: $KEY (use --@envoy_toolshed//pgp:key_path)" >&2 + exit 1 +fi + +if [[ ! -f "$KEY" ]]; then + echo "key file not found: $KEY" >&2 + exit 1 +fi + +calc_sha256 () { + if command -v sha256sum > /dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum > /dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + echo "neither sha256sum nor shasum found" >&2 + exit 1 + fi +} + +if [[ -n "$KEY_SHA256" ]]; then + actual_sha256="$(calc_sha256 "$KEY")" + if [[ "$actual_sha256" != "$KEY_SHA256" ]]; then + echo "key digest mismatch for $KEY: expected $KEY_SHA256, got $actual_sha256" >&2 + exit 1 + fi +fi + +# `sq` state directories are disabled unconditionally: no home, no cert store, +# no key store, and no prompting. Nothing outside the arguments is consulted. +sq () { + "$SQ" \ + --batch \ + --overwrite \ + --home none \ + --cert-store none \ + --key-store none \ + "$@" +} + +# Fail hard unless *every* secret key packet in the key file is protected. +# +# `sq inspect` prints `Secret key: Encrypted` or `Secret key: Unencrypted` for +# each secret key packet. A key with no secret key packets at all cannot sign, +# and is rejected here rather than producing a confusing error later. +require_encrypted_key () { + local inspected secret unencrypted + if ! inspected="$(sq inspect "$KEY" 2>&1)"; then + echo "unable to inspect key: $KEY" >&2 + echo "$inspected" >&2 + exit 1 + fi + secret="$(printf '%s\n' "$inspected" | grep -c 'Secret key: ' || true)" + unencrypted="$( + printf '%s\n' "$inspected" | grep -c 'Secret key: Unencrypted' || true)" + if [[ "$secret" -eq 0 ]]; then + echo "no secret key material found in ${KEY}" >&2 + exit 1 + fi + if [[ "$unencrypted" -ne 0 ]]; then + echo "REFUSING TO SIGN: ${KEY} contains unprotected secret key" \ + "material." >&2 + echo "The key given to the signing rules must be" \ + "passphrase-encrypted, so that the only key material Bazel can" \ + "hash, cache or upload is ciphertext." >&2 + exit 1 + fi +} + +if [[ "$REQUIRE_ENCRYPTED_KEY" -eq 1 ]]; then + require_encrypted_key +fi + +if [[ ! -f "$PASSPHRASE_FILE" ]]; then + echo "passphrase file not found: ${PASSPHRASE_FILE}" >&2 + echo "the passphrase file must exist on the host running the build," \ + "see --@envoy_toolshed//pgp:passphrase_path" >&2 + exit 1 +fi + +# `sq` uses the entire contents of the password file, including any trailing +# newline, whereas `gpg --passphrase-file` strips it. Normalize on the `gpg` +# behaviour without ever writing a second plaintext copy of the passphrase to +# disk: `sq` reads `--password-file` from an anonymous pipe created by +# process substitution (`/dev/fd/N` under bash), so the stripped passphrase +# exists only in memory/in-pipe, never as a file. +args=( + --password-file <(printf %s "$(cat "$PASSPHRASE_FILE")") + sign + --signer-file "$KEY") + +case "$MODE" in + detached) + args+=(--signature-file "$OUT") + ;; + cleartext) + args+=(--cleartext --output "$OUT") + ;; + inline) + args+=(--message --output "$OUT") + ;; + *) + echo "unknown mode: $MODE" >&2 + usage + ;; +esac + +# Cleartext signatures are armored by definition. +if [[ "$MODE" != "cleartext" && "$ARMOR" -eq 0 ]]; then + args+=(--binary) +fi + +sq "${args[@]}" "${INPUTS[0]}" diff --git a/bazel/pgp/private/sq.bzl b/bazel/pgp/private/sq.bzl new file mode 100644 index 0000000000..ba30f7ef64 --- /dev/null +++ b/bazel/pgp/private/sq.bzl @@ -0,0 +1,37 @@ +"""Rule wrapping a `sq` binary as a toolshed OpenPGP signer. + +The path of the `sq` binary is baked into the wrapper at analysis time so +that the signer does not need to resolve runfiles - signing actions run with +an empty environment. +""" + +def _sq_signer_impl(ctx): + out = ctx.actions.declare_file("%s.sh" % ctx.label.name) + ctx.actions.expand_template( + template = ctx.file._template, + output = out, + substitutions = {"@SQ@": ctx.file.sq.path}, + is_executable = True, + ) + return [DefaultInfo( + executable = out, + files = depset([out]), + runfiles = ctx.runfiles(files = [ctx.file.sq]), + )] + +sq_signer = rule( + implementation = _sq_signer_impl, + doc = "Wraps a `sq` binary as an implementation of the signer CLI contract.", + attrs = { + "sq": attr.label( + doc = "The `sq` binary.", + mandatory = True, + allow_single_file = True, + ), + "_template": attr.label( + default = "//pgp/private:signer.sh", + allow_single_file = True, + ), + }, + executable = True, +) diff --git a/bazel/pgp/test/BUILD b/bazel/pgp/test/BUILD new file mode 100644 index 0000000000..7bb517b269 --- /dev/null +++ b/bazel/pgp/test/BUILD @@ -0,0 +1,183 @@ +load("@rules_shell//shell:sh_binary.bzl", "sh_binary") +load("@rules_shell//shell:sh_test.bzl", "sh_test") +load("//pgp:defs.bzl", "pgp_sign_changes_file", "pgp_sign_checksums", "pgp_sign_cleartext", "pgp_sign_detached", "pgp_toolchain") +load(":sign_test.bzl", "KEY_FRAGMENT_DIGEST", "args_test", "args_with_fragment_test", "bad_key_fragment_test", "env_test", "execution_requirements_test", "inputs_test", "no_key_test", "no_passphrase_test", "passphrase_fragment_test", "relative_key_test", "relative_passphrase_test") + +# Stub signer/toolchain for the analysis tests. Real signing requires a +# registered `sq` toolchain, see //pgp:extensions.bzl. +sh_binary( + name = "stub_signer", + srcs = ["stub_signer.sh"], +) + +pgp_toolchain( + name = "stub_signer_toolchain", + signer = ":stub_signer", +) + +toolchain( + name = "stub_toolchain", + toolchain = ":stub_signer_toolchain", + toolchain_type = "//pgp:toolchain_type", +) + +# Example signing targets. They are `manual` because building them requires +# `--@envoy_toolshed//pgp:key_path`, `--@envoy_toolshed//pgp:passphrase_path` +# and a real signer toolchain. +pgp_sign_detached( + name = "example_detached", + src = "fixtures/example.txt", + tags = ["manual"], +) + +pgp_sign_cleartext( + name = "example_cleartext", + src = "fixtures/example.txt", + out = "example.txt.cleartext.asc", + tags = ["manual"], +) + +pgp_sign_checksums( + name = "example_checksums", + srcs = [ + "fixtures/example.changes", + "fixtures/example.txt", + ], + tags = ["manual"], +) + +pgp_sign_changes_file( + name = "example_deb_changes", + changes = "fixtures/example.changes", + tags = ["manual"], +) + +execution_requirements_test( + name = "execution_requirements_test", +) + +env_test( + name = "env_test", + target_under_test = ":example_detached", +) + +args_test( + name = "detached_args_test", + mode = "detached", + target_under_test = ":example_detached", +) + +args_with_fragment_test( + name = "detached_args_with_fragment_test", + expect_key_sha256 = KEY_FRAGMENT_DIGEST, + mode = "detached", + target_under_test = ":example_detached", +) + +args_test( + name = "cleartext_args_test", + mode = "cleartext", + target_under_test = ":example_cleartext", +) + +args_test( + name = "checksums_args_test", + mode = "cleartext", + target_under_test = ":example_checksums", +) + +args_test( + name = "deb_changes_args_test", + mode = "cleartext", + target_under_test = ":example_deb_changes", +) + +inputs_test( + name = "inputs_test", + src = "fixtures/example.txt", + target_under_test = ":example_detached", +) + +no_key_test( + name = "no_key_test", + target_under_test = ":example_detached", +) + +relative_key_test( + name = "relative_key_test", + target_under_test = ":example_detached", +) + +bad_key_fragment_test( + name = "bad_key_fragment_test", + target_under_test = ":example_detached", +) + +no_passphrase_test( + name = "no_passphrase_test", + target_under_test = ":example_detached", +) + +relative_passphrase_test( + name = "relative_passphrase_test", + target_under_test = ":example_detached", +) + +passphrase_fragment_test( + name = "passphrase_fragment_test", + target_under_test = ":example_detached", +) + +# Audits captured `bazel aquery` output. This is a unit test of the audit +# script itself: `fixtures/audit.json` is the real aquery output for the +# example signing targets in this package (must pass); the broken variants +# (a removed execution requirement, a leaked environment variable, key +# material as an action input, a passphrase on the command line) are derived +# from it with `jq` at test time rather than committed, so this package does +# not carry several ~1000-line JSON fixtures. +# +# Run the same script against your own targets with: +# +# bazel run //pgp/test:audit -- //your/targets/... +# +# See `//pgp/test:live_audit` for the live counterpart that actually +# re-invokes `bazel aquery` against this package's real dependency graph. +sh_test( + name = "audit_test", + size = "small", + srcs = ["audit_fixtures_test.sh"], + data = [ + "audit_test.sh", + "fixtures/audit.json", + "@jq_toolchains//:resolved_toolchain", + ], + env = { + "JQ_BIN": "$(JQ_BIN)", + }, + toolchains = ["@jq_toolchains//:resolved_toolchain"], +) + +sh_binary( + name = "audit", + srcs = ["audit_test.sh"], + visibility = ["//visibility:public"], +) + +# Runs the audit against a real `bazel aquery` of this package's example +# targets, rather than captured JSON (see `//pgp/test:audit_test` for that). +# `manual`: it shells out to `bazel`, so it cannot run as a sandboxed test - +# invoke explicitly with `bazel run //pgp/test:live_audit`, including in CI. +sh_binary( + name = "live_audit", + srcs = ["live_audit.sh"], + tags = ["manual"], +) + +# Signs with a real `sq`, if one is available (`SQ` or `$PATH`), and asserts +# that unencrypted keys are rejected. Skipped when `sq` is not installed. +sh_test( + name = "signer_test", + size = "medium", + srcs = ["signer_test.sh"], + data = ["//pgp/private:signer.sh"], +) diff --git a/bazel/pgp/test/audit_fixtures_test.sh b/bazel/pgp/test/audit_fixtures_test.sh new file mode 100755 index 0000000000..611ee8a776 --- /dev/null +++ b/bazel/pgp/test/audit_fixtures_test.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# +# Exercises `audit_test.sh` against captured `bazel aquery` output. +# +# `fixtures/audit.json` is the real aquery output for the example signing +# targets in this package and must pass. The broken variants below each +# break one of the guarantees the audit checks for - a removed execution +# requirement, a leaked environment variable, key material as an action +# input, a passphrase on the command line - and must be rejected. They are +# derived from `fixtures/audit.json` with `jq` at test time rather than +# committed as separate ~1000-line JSON fixtures. + +set -euo pipefail + +JQ="${JQ_BIN:-jq}" +AUDIT="$(dirname "$0")/audit_test.sh" +FIXTURE="$(dirname "$0")/fixtures/audit.json" +PASSPHRASE="correct-horse-battery-staple" + +if [[ ! -x "$AUDIT" ]]; then + AUDIT="bash ${AUDIT}" +fi + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# Remove one required execution requirement from every `OpenPGPSign` action. +MISSING_EXECUTION_REQUIREMENT="${TMP}/audit-missing-execution-requirement.json" +"$JQ" ' + .actions |= map( + if .mnemonic == "OpenPGPSign" then + .executionInfo |= map(select(.key != "no-cache")) + else . end) + ' "$FIXTURE" > "$MISSING_EXECUTION_REQUIREMENT" + +# Leak `HOME` into the environment of every `OpenPGPSign` action. +ENVIRONMENT_LEAK="${TMP}/audit-environment-leak.json" +"$JQ" ' + .actions |= map( + if .mnemonic == "OpenPGPSign" then + .environmentVariables = ((.environmentVariables // []) + [{"key": "HOME", "value": "/x"}]) + else . end) + ' "$FIXTURE" > "$ENVIRONMENT_LEAK" + +# Append a `--passphrase=...` argument to every `OpenPGPSign` action's argv. +PASSPHRASE_ARGV="${TMP}/audit-passphrase-argv.json" +"$JQ" --arg passphrase "$PASSPHRASE" ' + .actions |= map( + if .mnemonic == "OpenPGPSign" then + .arguments += ["--passphrase=" + $passphrase] + else . end) + ' "$FIXTURE" > "$PASSPHRASE_ARGV" + +# Add an input that looks like private key material (a `.gnupg` keyring +# file), reachable from an `OpenPGPSign` action's `inputDepSetIds` via a new +# `depSetOfFiles` entry - matching the shape real `aquery` output has, rather +# than hand-inserting a field (eg `execPath`) that real output never has. +KEY_MATERIAL_INPUT="${TMP}/audit-key-material-input.json" +"$JQ" ' + ([.pathFragments[].id] | max) as $f0 + | ([.artifacts[].id] | max) as $a0 + | ([.depSetOfFiles[].id] | max) as $d0 + | ($f0 + 1) as $f1 + | ($f0 + 2) as $f2 + | ($f0 + 3) as $f3 + | ($a0 + 1) as $art + | ($d0 + 1) as $ds + | .pathFragments += [ + {"id": $f1, "label": ".gnupg"}, + {"id": $f2, "label": "private-keys-v1.d", "parentId": $f1}, + {"id": $f3, "label": "DEADBEEF.key", "parentId": $f2} + ] + | .artifacts += [{"id": $art, "pathFragmentId": $f3}] + | .depSetOfFiles += [{"id": $ds, "directArtifactIds": [$art]}] + | (.actions | map(.mnemonic == "OpenPGPSign") | index(true)) as $idx + | .actions[$idx].inputDepSetIds += [$ds] + ' "$FIXTURE" > "$KEY_MATERIAL_INPUT" + +# Add a key as an artifact input (`signing-key.asc`) to an `OpenPGPSign` action. +KEY_ARTIFACT_INPUT="${TMP}/audit-key-artifact-input.json" +"$JQ" ' + ([.pathFragments[].id] | max) as $f0 + | ([.artifacts[].id] | max) as $a0 + | ([.depSetOfFiles[].id] | max) as $d0 + | ($f0 + 1) as $f1 + | ($a0 + 1) as $art + | ($d0 + 1) as $ds + | .pathFragments += [ + {"id": $f1, "label": "signing-key.asc"} + ] + | .artifacts += [{"id": $art, "pathFragmentId": $f1}] + | .depSetOfFiles += [{"id": $ds, "directArtifactIds": [$art]}] + | (.actions | map(.mnemonic == "OpenPGPSign") | index(true)) as $idx + | .actions[$idx].inputDepSetIds += [$ds] + ' "$FIXTURE" > "$KEY_ARTIFACT_INPUT" + +failed=0 + +audit () { + $AUDIT --forbid "$PASSPHRASE" --aquery-json "$1" +} + +echo "# audit passes for compliant actions" +if ! audit "$FIXTURE"; then + echo "FAIL: audit rejected compliant actions" >&2 + failed=1 +fi + +for fixture in \ + "$MISSING_EXECUTION_REQUIREMENT" \ + "$ENVIRONMENT_LEAK" \ + "$PASSPHRASE_ARGV" \ + "$KEY_MATERIAL_INPUT" \ + "$KEY_ARTIFACT_INPUT"; do + echo "# audit fails for $(basename "$fixture")" + if audit "$fixture"; then + echo "FAIL: audit accepted $(basename "$fixture")" >&2 + failed=1 + fi +done + +if [[ "$failed" -ne 0 ]]; then + exit 1 +fi + +echo "audit fixtures test passed" diff --git a/bazel/pgp/test/audit_test.sh b/bazel/pgp/test/audit_test.sh new file mode 100755 index 0000000000..d48a49b2f6 --- /dev/null +++ b/bazel/pgp/test/audit_test.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +# +# Audit OpenPGP signing actions. +# +# Asserts that, for the given target patterns: +# +# 1. every action with mnemonic `OpenPGPSign` carries all of the required +# execution requirements, +# 2. no input reachable from an `OpenPGPSign` action's `inputDepSetIds` +# (i.e. anything that action could actually read) has a path that looks +# like private key material or a passphrase, +# 3. no `OpenPGPSign` action has `HOME`, `GNUPGHOME` or `SSH_AUTH_SOCK` in +# its environment, +# 4. no `OpenPGPSign` action's argv contains a forbidden string (use +# `--forbid` to check that a passphrase never reaches a command line), +# 5. every `OpenPGPSign` action has exactly one non-tool input artifact +# (the file being signed). +# +# Usage: +# +# audit_test.sh [--forbid STRING]... [BAZEL OPTION]... //your/targets/... +# audit_test.sh [--forbid STRING]... --aquery-json aquery.json +# +# The second form audits a previously captured +# `bazel aquery --output=jsonproto` result, which is how this script is +# exercised in tests. + +# jq filters are single-quoted on purpose - `$mnemonic` etc are jq +# variables passed with `--arg`, not shell variables. +# shellcheck disable=SC2016 + +set -euo pipefail + +BAZEL="${BAZEL:-bazel}" +JQ="${JQ_BIN:-jq}" + +REQUIRED_EXECUTION_REQUIREMENTS=( + no-cache + no-remote + no-remote-cache + no-remote-cache-upload + no-remote-exec) +FORBIDDEN_ENV=( + GNUPGHOME + HOME + SSH_AUTH_SOCK) +FORBIDDEN_INPUTS='(^|/)\.gnupg(/|$)|private-keys-v1\.d|passphrase|secret|\.(asc|pgp|gpg|key)$' +MNEMONIC=OpenPGPSign + +AQUERY_JSON= +FORBIDDEN_STRINGS=() +BAZEL_OPTS=() +TARGETS=() + +usage () { + echo "usage: $0 [--forbid STRING]... [--aquery-json FILE]" \ + "[BAZEL OPTION]... [TARGET...]" >&2 + exit 2 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --aquery-json) + AQUERY_JSON="$2" + shift 2 + ;; + --forbid) + FORBIDDEN_STRINGS+=("$2") + shift 2 + ;; + -h|--help) + usage + ;; + -*) + # Anything else is passed through to `bazel aquery`, eg + # `--@envoy_toolshed//pgp:passphrase_path=...`. + BAZEL_OPTS+=("$1") + shift + ;; + *) + TARGETS+=("$1") + shift + ;; + esac +done + +if [[ -z "$AQUERY_JSON" && ${#TARGETS[@]} -eq 0 ]]; then + usage +fi + +if ! command -v "$JQ" > /dev/null 2>&1; then + echo "jq not found (set JQ_BIN)" >&2 + exit 1 +fi + +if [[ -z "$AQUERY_JSON" ]]; then + AQUERY_JSON="$(mktemp)" + # shellcheck disable=SC2064 + trap "rm -f \"$AQUERY_JSON\"" EXIT + "$BAZEL" aquery --output=jsonproto --include_artifacts=true \ + ${BAZEL_OPTS[@]+"${BAZEL_OPTS[@]}"} \ + "${TARGETS[@]}" > "$AQUERY_JSON" +fi + +FAILED=0 + +fail () { + echo "AUDIT FAILURE: $*" >&2 + FAILED=1 +} + +jqq () { + "$JQ" -r "$@" "$AQUERY_JSON" +} + +signing_actions="$(jqq --arg mnemonic "$MNEMONIC" \ + '[.actions[]? | select(.mnemonic == $mnemonic)] | length')" + +echo "Auditing ${signing_actions} ${MNEMONIC} action(s) in ${AQUERY_JSON}" + +# 1. Execution requirements. +for requirement in "${REQUIRED_EXECUTION_REQUIREMENTS[@]}"; do + missing="$(jqq --arg mnemonic "$MNEMONIC" --arg key "$requirement" ' + [.actions[]? + | select(.mnemonic == $mnemonic) + | select([.executionInfo[]?.key] | index($key) | not) + | .targetId // .mnemonic] + | join(" ")')" + if [[ -n "$missing" ]]; then + fail "${MNEMONIC} action(s) missing execution requirement" \ + "\`${requirement}\`: ${missing}" + fi +done + +# 2. Suspicious inputs reachable from `OpenPGPSign` actions. +# +# `bazel aquery --output=jsonproto` artifacts only carry an `id` and a +# `pathFragmentId` - there is no `execPath` field to read directly. The full +# path has to be reconstructed by walking `pathFragments` via `parentId`. +# +# Scope is restricted to artifacts reachable from `OpenPGPSign` actions' +# `inputDepSetIds` (resolved transitively through `depSetOfFiles`), rather +# than every artifact in the queried universe, since that is what the +# signing action can actually read. +suspicious="$(jqq --arg mnemonic "$MNEMONIC" --arg re "$FORBIDDEN_INPUTS" ' + def frag_path($frags): + . as $id + | $frags[$id | tostring] as $f + | if ($f.parentId // null) != null then + ($f.parentId | frag_path($frags)) + "/" + $f.label + else + $f.label + end; + + def depset_artifact_ids($depsets): + . as $ids + | ($ids // []) + | map( + ($depsets[(. | tostring)] // {}) as $ds + | (($ds.directArtifactIds // []) + + (($ds.transitiveDepSetIds // []) | depset_artifact_ids($depsets))) + ) + | add // []; + + (INDEX(.pathFragments[]?; .id | tostring)) as $frags + | (INDEX(.depSetOfFiles[]?; .id | tostring)) as $depsets + | (INDEX(.artifacts[]?; .id | tostring)) as $arts + | ([.actions[]? | select(.mnemonic == $mnemonic) | (.inputDepSetIds // [])] + | add // [] + | depset_artifact_ids($depsets) + | unique) as $signing_input_ids + | [$signing_input_ids[] + | ($arts[(. | tostring)] // empty) as $art + | select($art != null and ($art.pathFragmentId != null)) + | ($art.pathFragmentId | frag_path($frags)) as $path + | select($path | test($re; "i")) + | $path] + | unique | join(" ")')" +if [[ -n "$suspicious" ]]; then + fail "${MNEMONIC} action input(s) look like key material or" \ + "passphrases: ${suspicious}" +fi + +# 3. Forbidden environment variables. +for name in "${FORBIDDEN_ENV[@]}"; do + leaked="$(jqq --arg mnemonic "$MNEMONIC" --arg key "$name" ' + [.actions[]? + | select(.mnemonic == $mnemonic) + | select([.environmentVariables[]?.key] | index($key)) + | .targetId // .mnemonic] + | join(" ")')" + if [[ -n "$leaked" ]]; then + fail "${MNEMONIC} action(s) leak \`${name}\` into the environment:" \ + "${leaked}" + fi +done + +# 4. Forbidden strings (eg the passphrase) in argv. +for forbidden in ${FORBIDDEN_STRINGS[@]+"${FORBIDDEN_STRINGS[@]}"}; do + found="$(jqq --arg mnemonic "$MNEMONIC" --arg forbidden "$forbidden" ' + [.actions[]? + | select(.mnemonic == $mnemonic) + | select([.arguments[]? | select(contains($forbidden))] | length > 0) + | .targetId // .mnemonic] + | join(" ")')" + if [[ -n "$found" ]]; then + fail "${MNEMONIC} action(s) pass a forbidden string on the command" \ + "line: ${found}" + fi +done + +# 5. Verify every OpenPGPSign action has exactly one non-tool input artifact (the file being signed). +# +# Heuristic for filtering out tool runfiles: +# Any artifact path under the exec configuration (`bazel-out/*-exec-*/`), +# or matching the tool path in argv[0], its `.runfiles`, or `_middlemen` is +# treated as a tool file. The remaining set of non-tool inputs must contain +# exactly one artifact matching the file being signed (argv's last element). +unexpected_inputs="$(jqq --arg mnemonic "$MNEMONIC" ' + def frag_path($frags): + . as $id + | $frags[$id | tostring] as $f + | if ($f.parentId // null) != null then + ($f.parentId | frag_path($frags)) + "/" + $f.label + else + $f.label + end; + + def depset_artifact_ids($depsets): + . as $ids + | ($ids // []) + | map( + ($depsets[(. | tostring)] // {}) as $ds + | (($ds.directArtifactIds // []) + + (($ds.transitiveDepSetIds // []) | depset_artifact_ids($depsets))) + ) + | add // []; + + (INDEX(.pathFragments[]?; .id | tostring)) as $frags + | (INDEX(.depSetOfFiles[]?; .id | tostring)) as $depsets + | (INDEX(.artifacts[]?; .id | tostring)) as $arts + | [.actions[]? + | select(.mnemonic == $mnemonic) + | . as $action + | ($action.arguments[0]) as $tool + | ($tool | split("/") | last) as $tool_base + | ($action.arguments[-1]) as $expected_src + | (($action.inputDepSetIds // []) + | depset_artifact_ids($depsets) + | unique) as $input_ids + | [$input_ids[] + | ($arts[(. | tostring)] // empty) as $art + | select($art != null and ($art.pathFragmentId != null)) + | ($art.pathFragmentId | frag_path($frags)) as $path + | select( + ($path | test("bazel-out/[^/]+-exec-[^/]+/") | not) + and ($path | test("-exec-") | not) + and $path != $tool + and ($path | contains($tool + ".runfiles") | not) + and ($path | contains("_middlemen") | not) + and ($path | test("(^|/)" + $tool_base + "(\\.[a-zA-Z0-9]+)?$") | not) + ) + | $path] as $non_tool_inputs + | select($non_tool_inputs != [$expected_src]) + | ($action.targetId // $action.mnemonic | tostring) + ": expected [" + $expected_src + "], got [" + ($non_tool_inputs | join(", ")) + "]"] + | join("; ")')" +if [[ -n "$unexpected_inputs" ]]; then + fail "${MNEMONIC} action(s) have unexpected inputs: ${unexpected_inputs}" +fi + +if [[ "$FAILED" -ne 0 ]]; then + echo "OpenPGP signing audit FAILED" >&2 + exit 1 +fi + +echo "OpenPGP signing audit passed" diff --git a/bazel/pgp/test/fixtures/audit.json b/bazel/pgp/test/fixtures/audit.json new file mode 100644 index 0000000000..ee1ab22c23 --- /dev/null +++ b/bazel/pgp/test/fixtures/audit.json @@ -0,0 +1,556 @@ +{ + "artifacts": [{ + "id": 1, + "pathFragmentId": 1 + }, { + "id": 2, + "pathFragmentId": 7 + }, { + "id": 3, + "pathFragmentId": 10 + }, { + "id": 4, + "pathFragmentId": 12 + }, { + "id": 5, + "pathFragmentId": 17 + }, { + "id": 6, + "pathFragmentId": 18 + }, { + "id": 7, + "pathFragmentId": 19 + }, { + "id": 8, + "pathFragmentId": 21 + }, { + "id": 9, + "pathFragmentId": 24 + }, { + "id": 10, + "pathFragmentId": 25 + }, { + "id": 11, + "pathFragmentId": 26 + }, { + "id": 12, + "pathFragmentId": 27 + }, { + "id": 13, + "pathFragmentId": 28 + }, { + "id": 14, + "pathFragmentId": 30 + }, { + "id": 15, + "pathFragmentId": 32 + }, { + "id": 16, + "pathFragmentId": 33 + }, { + "id": 17, + "pathFragmentId": 34 + }, { + "id": 18, + "pathFragmentId": 35 + }, { + "id": 19, + "pathFragmentId": 37 + }], + "actions": [{ + "targetId": 1, + "actionKey": "2468ab54784a3380723c3780c81dd71995006585e8d4711e58b18ca494c11613", + "mnemonic": "OpenPGPSign", + "configurationId": 1, + "arguments": ["bazel-out/k8-opt-exec-ST-d57f47055a04/bin/pgp/test/stub_signer", "--mode", "detached", "--key", "/tmp/nonexistent-key", "--key-sha256", "0000000000000000000000000000000000000000000000000000000000000000", "--passphrase-file", "/tmp/nonexistent", "--require-encrypted-key", "--out", "bazel-out/k8-fastbuild/bin/pgp/test/example.txt.asc", "--armor", "pgp/test/fixtures/example.txt"], + "environmentVariables": [{ + "key": "PATH", + "value": "/usr/bin:/bin" + }], + "inputDepSetIds": [1], + "outputIds": [4], + "executionInfo": [{ + "key": "no-cache", + "value": "1" + }, { + "key": "no-remote", + "value": "1" + }, { + "key": "no-remote-cache", + "value": "1" + }, { + "key": "no-remote-cache-upload", + "value": "1" + }, { + "key": "no-remote-exec", + "value": "1" + }], + "primaryOutputId": 4, + "executionPlatform": "@@platforms//host:host" + }, { + "targetId": 2, + "actionKey": "7a39b61d5744a8903119e17b3ddc7e56e330f240d2bead98c3ba081a81b5c9ac", + "mnemonic": "ExecutableSymlink", + "configurationId": 2, + "inputDepSetIds": [3], + "outputIds": [1], + "primaryOutputId": 1, + "executionPlatform": "@@platforms//host:host" + }, { + "targetId": 2, + "actionKey": "eaf13d83a5a688fa5b315b94d7c186b51fe8ddc267c803905bf1de21a82abbc4", + "mnemonic": "RepoMappingManifest", + "configurationId": 2, + "outputIds": [5], + "primaryOutputId": 5, + "executionPlatform": "@@platforms//host:host" + }, { + "targetId": 2, + "actionKey": "7d46f811cc2a7b79fb8853e495b8a1218ac20c0f2cf80486de9a8281b87d0759", + "mnemonic": "SourceSymlinkManifest", + "configurationId": 2, + "outputIds": [6], + "primaryOutputId": 6, + "executionPlatform": "@@platforms//host:host" + }, { + "targetId": 2, + "actionKey": "9c20914ea2c513df3992398d5792327fdad2ae7934a575ae8998294bde3f5b5c", + "mnemonic": "SymlinkTree", + "configurationId": 2, + "inputDepSetIds": [4], + "outputIds": [7], + "primaryOutputId": 7, + "executionPlatform": "@@platforms//host:host" + }, { + "targetId": 2, + "actionKey": "709e80c88487a2411e1ee4dfb9f22a861492d20c4765150c0c794abd70f8147c", + "mnemonic": "Middleman", + "configurationId": 2, + "inputDepSetIds": [5], + "outputIds": [8], + "primaryOutputId": 8, + "executionPlatform": "@@platforms//host:host" + }, { + "targetId": 3, + "actionKey": "795631eb42ec0f2d1fca807e9cdacf333b7c7328fbe6e3794edd71df80763bb2", + "mnemonic": "OpenPGPSign", + "configurationId": 1, + "arguments": ["bazel-out/k8-opt-exec-ST-d57f47055a04/bin/pgp/test/stub_signer", "--mode", "cleartext", "--key", "/tmp/nonexistent-key", "--key-sha256", "0000000000000000000000000000000000000000000000000000000000000000", "--passphrase-file", "/tmp/nonexistent", "--require-encrypted-key", "--out", "bazel-out/k8-fastbuild/bin/pgp/test/example.txt.cleartext.asc", "--armor", "pgp/test/fixtures/example.txt"], + "environmentVariables": [{ + "key": "PATH", + "value": "/usr/bin:/bin" + }], + "inputDepSetIds": [8], + "outputIds": [9], + "executionInfo": [{ + "key": "no-cache", + "value": "1" + }, { + "key": "no-remote", + "value": "1" + }, { + "key": "no-remote-cache", + "value": "1" + }, { + "key": "no-remote-cache-upload", + "value": "1" + }, { + "key": "no-remote-exec", + "value": "1" + }], + "primaryOutputId": 9, + "executionPlatform": "@@platforms//host:host" + }, { + "targetId": 4, + "actionKey": "8b8557eadd8da99d1d89aaa0db840d3b32709ac2e4dd61d9f6f27cfb1d736d32", + "mnemonic": "OpenPGPSign", + "configurationId": 1, + "arguments": ["bazel-out/k8-opt-exec-ST-d57f47055a04/bin/pgp/test/stub_signer", "--mode", "cleartext", "--key", "/tmp/nonexistent-key", "--key-sha256", "0000000000000000000000000000000000000000000000000000000000000000", "--passphrase-file", "/tmp/nonexistent", "--require-encrypted-key", "--out", "bazel-out/k8-fastbuild/bin/pgp/test/example_checksums.checksums.txt.asc", "--armor", "bazel-out/k8-fastbuild/bin/pgp/test/example_checksums.checksums.txt"], + "environmentVariables": [{ + "key": "PATH", + "value": "/usr/bin:/bin" + }], + "inputDepSetIds": [10], + "outputIds": [11], + "executionInfo": [{ + "key": "no-cache", + "value": "1" + }, { + "key": "no-remote", + "value": "1" + }, { + "key": "no-remote-cache", + "value": "1" + }, { + "key": "no-remote-cache-upload", + "value": "1" + }, { + "key": "no-remote-exec", + "value": "1" + }], + "primaryOutputId": 11, + "executionPlatform": "@@platforms//host:host" + }, { + "targetId": 5, + "actionKey": "2a271376d24e8b8a039d2b2b64548240cf160c46dc5881fdcaf829c5adf5101c", + "mnemonic": "OpenPGPChecksums", + "configurationId": 1, + "arguments": ["bazel-out/k8-opt-exec-ST-d57f47055a04/bin/pgp/private/checksums", "sha256", "bazel-out/k8-fastbuild/bin/pgp/test/example_checksums.checksums.txt", "pgp/test/fixtures/example.changes", "pgp/test/fixtures/example.txt"], + "environmentVariables": [{ + "key": "PATH", + "value": "/usr/bin:/bin" + }], + "inputDepSetIds": [12], + "outputIds": [10], + "primaryOutputId": 10, + "executionPlatform": "@@platforms//host:host" + }, { + "targetId": 6, + "actionKey": "7a39b61d5744a8903119e17b3ddc7e56e330f240d2bead98c3ba081a81b5c9ac", + "mnemonic": "ExecutableSymlink", + "configurationId": 2, + "inputDepSetIds": [16], + "outputIds": [14], + "primaryOutputId": 14, + "executionPlatform": "@@platforms//host:host" + }, { + "targetId": 6, + "actionKey": "eaf13d83a5a688fa5b315b94d7c186b51fe8ddc267c803905bf1de21a82abbc4", + "mnemonic": "RepoMappingManifest", + "configurationId": 2, + "outputIds": [16], + "primaryOutputId": 16, + "executionPlatform": "@@platforms//host:host" + }, { + "targetId": 6, + "actionKey": "fb28a1a086c02d9bd606f77abe0fad0d248758c2050cf4b5409a3f302a1608d6", + "mnemonic": "SourceSymlinkManifest", + "configurationId": 2, + "outputIds": [17], + "primaryOutputId": 17, + "executionPlatform": "@@platforms//host:host" + }, { + "targetId": 6, + "actionKey": "47bcad3fab0dc6d93e8a4cf2069b37456f8bedd7b8304f1fb315ae423c557ee1", + "mnemonic": "SymlinkTree", + "configurationId": 2, + "inputDepSetIds": [17], + "outputIds": [18], + "primaryOutputId": 18, + "executionPlatform": "@@platforms//host:host" + }, { + "targetId": 6, + "actionKey": "709e80c88487a2411e1ee4dfb9f22a861492d20c4765150c0c794abd70f8147c", + "mnemonic": "Middleman", + "configurationId": 2, + "inputDepSetIds": [18], + "outputIds": [15], + "primaryOutputId": 15, + "executionPlatform": "@@platforms//host:host" + }, { + "targetId": 7, + "actionKey": "460abfc5256a43bbe04fff6f7ea4846c3deeede48c175d07fb3117ac9e70e70b", + "mnemonic": "OpenPGPSign", + "configurationId": 1, + "arguments": ["bazel-out/k8-opt-exec-ST-d57f47055a04/bin/pgp/test/stub_signer", "--mode", "cleartext", "--key", "/tmp/nonexistent-key", "--key-sha256", "0000000000000000000000000000000000000000000000000000000000000000", "--passphrase-file", "/tmp/nonexistent", "--require-encrypted-key", "--out", "bazel-out/k8-fastbuild/bin/pgp/test/example_deb_changes/example.changes", "--armor", "pgp/test/fixtures/example.changes"], + "environmentVariables": [{ + "key": "PATH", + "value": "/usr/bin:/bin" + }], + "inputDepSetIds": [20], + "outputIds": [19], + "executionInfo": [{ + "key": "no-cache", + "value": "1" + }, { + "key": "no-remote", + "value": "1" + }, { + "key": "no-remote-cache", + "value": "1" + }, { + "key": "no-remote-cache-upload", + "value": "1" + }, { + "key": "no-remote-exec", + "value": "1" + }], + "primaryOutputId": 19, + "executionPlatform": "@@platforms//host:host" + }], + "targets": [{ + "id": 1, + "label": "//pgp/test:example_detached", + "ruleClassId": 1 + }, { + "id": 2, + "label": "//pgp/test:stub_signer", + "ruleClassId": 2 + }, { + "id": 3, + "label": "//pgp/test:example_cleartext", + "ruleClassId": 1 + }, { + "id": 4, + "label": "//pgp/test:example_checksums", + "ruleClassId": 1 + }, { + "id": 5, + "label": "//pgp/test:example_checksums_checksums", + "ruleClassId": 3 + }, { + "id": 6, + "label": "//pgp/private:checksums", + "ruleClassId": 2 + }, { + "id": 7, + "label": "//pgp/test:example_deb_changes", + "ruleClassId": 1 + }], + "depSetOfFiles": [{ + "id": 2, + "directArtifactIds": [1, 2] + }, { + "id": 1, + "transitiveDepSetIds": [2], + "directArtifactIds": [3] + }, { + "id": 3, + "directArtifactIds": [2] + }, { + "id": 4, + "directArtifactIds": [6] + }, { + "id": 7, + "directArtifactIds": [2, 1] + }, { + "id": 6, + "transitiveDepSetIds": [7], + "directArtifactIds": [1] + }, { + "id": 5, + "transitiveDepSetIds": [6], + "directArtifactIds": [7, 5] + }, { + "id": 9, + "directArtifactIds": [1, 2] + }, { + "id": 8, + "transitiveDepSetIds": [9], + "directArtifactIds": [3] + }, { + "id": 11, + "directArtifactIds": [1, 2] + }, { + "id": 10, + "transitiveDepSetIds": [11], + "directArtifactIds": [10] + }, { + "id": 13, + "directArtifactIds": [12, 3] + }, { + "id": 15, + "directArtifactIds": [13, 14] + }, { + "id": 14, + "transitiveDepSetIds": [15], + "directArtifactIds": [15, 14] + }, { + "id": 12, + "transitiveDepSetIds": [13, 14] + }, { + "id": 16, + "directArtifactIds": [13] + }, { + "id": 17, + "directArtifactIds": [17] + }, { + "id": 19, + "transitiveDepSetIds": [15], + "directArtifactIds": [14] + }, { + "id": 18, + "transitiveDepSetIds": [19], + "directArtifactIds": [18, 16] + }, { + "id": 21, + "directArtifactIds": [1, 2] + }, { + "id": 20, + "transitiveDepSetIds": [21], + "directArtifactIds": [12] + }], + "configuration": [{ + "id": 1, + "mnemonic": "k8-fastbuild", + "platformName": "k8", + "checksum": "8ccb9e558e20c7159868e719a2666da8cd26251838da34ccc0e31e6bd46133ed" + }, { + "id": 2, + "mnemonic": "k8-opt-exec-ST-d57f47055a04", + "platformName": "k8", + "checksum": "34af391bb0ee1e2ba258cc21b67b093d0e298f56dd6cde7ba6d109a72017ecd1", + "isTool": true + }], + "ruleClasses": [{ + "id": 1, + "name": "pgp_sign" + }, { + "id": 2, + "name": "sh_binary" + }, { + "id": 3, + "name": "pgp_checksums" + }], + "pathFragments": [{ + "id": 6, + "label": "bazel-out" + }, { + "id": 5, + "label": "k8-opt-exec-ST-d57f47055a04", + "parentId": 6 + }, { + "id": 4, + "label": "bin", + "parentId": 5 + }, { + "id": 3, + "label": "pgp", + "parentId": 4 + }, { + "id": 2, + "label": "test", + "parentId": 3 + }, { + "id": 1, + "label": "stub_signer", + "parentId": 2 + }, { + "id": 9, + "label": "pgp" + }, { + "id": 8, + "label": "test", + "parentId": 9 + }, { + "id": 7, + "label": "stub_signer.sh", + "parentId": 8 + }, { + "id": 11, + "label": "fixtures", + "parentId": 8 + }, { + "id": 10, + "label": "example.txt", + "parentId": 11 + }, { + "id": 16, + "label": "k8-fastbuild", + "parentId": 6 + }, { + "id": 15, + "label": "bin", + "parentId": 16 + }, { + "id": 14, + "label": "pgp", + "parentId": 15 + }, { + "id": 13, + "label": "test", + "parentId": 14 + }, { + "id": 12, + "label": "example.txt.asc", + "parentId": 13 + }, { + "id": 17, + "label": "stub_signer.repo_mapping", + "parentId": 2 + }, { + "id": 18, + "label": "stub_signer.runfiles_manifest", + "parentId": 2 + }, { + "id": 20, + "label": "stub_signer.runfiles", + "parentId": 2 + }, { + "id": 19, + "label": "MANIFEST", + "parentId": 20 + }, { + "id": 23, + "label": "internal", + "parentId": 5 + }, { + "id": 22, + "label": "_middlemen", + "parentId": 23 + }, { + "id": 21, + "label": "pgp_Stest_Sstub_Usigner-runfiles", + "parentId": 22 + }, { + "id": 24, + "label": "example.txt.cleartext.asc", + "parentId": 13 + }, { + "id": 25, + "label": "example_checksums.checksums.txt", + "parentId": 13 + }, { + "id": 26, + "label": "example_checksums.checksums.txt.asc", + "parentId": 13 + }, { + "id": 27, + "label": "example.changes", + "parentId": 11 + }, { + "id": 29, + "label": "private", + "parentId": 9 + }, { + "id": 28, + "label": "checksums.sh", + "parentId": 29 + }, { + "id": 31, + "label": "private", + "parentId": 3 + }, { + "id": 30, + "label": "checksums", + "parentId": 31 + }, { + "id": 32, + "label": "pgp_Sprivate_Schecksums-runfiles", + "parentId": 22 + }, { + "id": 33, + "label": "checksums.repo_mapping", + "parentId": 31 + }, { + "id": 34, + "label": "checksums.runfiles_manifest", + "parentId": 31 + }, { + "id": 36, + "label": "checksums.runfiles", + "parentId": 31 + }, { + "id": 35, + "label": "MANIFEST", + "parentId": 36 + }, { + "id": 38, + "label": "example_deb_changes", + "parentId": 13 + }, { + "id": 37, + "label": "example.changes", + "parentId": 38 + }] +} diff --git a/bazel/pgp/test/fixtures/example.changes b/bazel/pgp/test/fixtures/example.changes new file mode 100644 index 0000000000..a50837839f --- /dev/null +++ b/bazel/pgp/test/fixtures/example.changes @@ -0,0 +1 @@ +Format: 1.8 diff --git a/bazel/pgp/test/fixtures/example.txt b/bazel/pgp/test/fixtures/example.txt new file mode 100644 index 0000000000..33a9488b16 --- /dev/null +++ b/bazel/pgp/test/fixtures/example.txt @@ -0,0 +1 @@ +example diff --git a/bazel/pgp/test/live_audit.sh b/bazel/pgp/test/live_audit.sh new file mode 100755 index 0000000000..af0ce2e40b --- /dev/null +++ b/bazel/pgp/test/live_audit.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# +# Runs `//pgp/test:audit` against the real dependency graph of the example +# signing targets in this package, rather than captured `aquery` JSON. +# +# This is the live counterpart to `//pgp/test:audit_test` (which only +# exercises the audit script against fixtures): it actually re-invokes +# `bazel aquery` so a regression that only shows up in the real graph (eg a +# dropped execution requirement, or a leaked `HOME`) is caught in CI. +# +# Intended to be run with `bazel run //pgp/test:live_audit` from the +# workspace root - it shells out to a fresh `bazel aquery` invocation, so it +# cannot run as a sandboxed `bazel test`. + +set -euo pipefail + +cd "${BUILD_WORKSPACE_DIRECTORY:?must be run with \`bazel run\`}" + +exec pgp/test/audit_test.sh \ + --@envoy_toolshed//pgp:key_path=/tmp/nonexistent-key#sha256=0000000000000000000000000000000000000000000000000000000000000000 \ + --@envoy_toolshed//pgp:passphrase_path=/tmp/nonexistent \ + "deps(//pgp/test:example_detached) + deps(//pgp/test:example_cleartext) + deps(//pgp/test:example_checksums) + deps(//pgp/test:example_deb_changes)" diff --git a/bazel/pgp/test/sign_test.bzl b/bazel/pgp/test/sign_test.bzl new file mode 100644 index 0000000000..7891e299e3 --- /dev/null +++ b/bazel/pgp/test/sign_test.bzl @@ -0,0 +1,272 @@ +"""Analysis tests for the OpenPGP signing rules.""" + +load("@bazel_skylib//lib:unittest.bzl", "analysistest", "asserts", "unittest") +load("//pgp/private:sign.bzl", "EXECUTION_REQUIREMENTS", "MNEMONIC") + +KEY_PATH = "/tmp/envoy-toolshed-pgp-test/key.pgp" +KEY_PATH_WITH_FRAGMENT = "/tmp/envoy-toolshed-pgp-test/key.pgp#sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +KEY_FRAGMENT_DIGEST = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +PASSPHRASE_PATH = "/tmp/envoy-toolshed-pgp-test/passphrase" + +# Canonical labels - `config_settings` keys are resolved in the repo mapping of +# bazel_skylib, so an apparent label would not resolve. +KEY_FLAG = str(Label("//pgp:key_path")) +PASSPHRASE_FLAG = str(Label("//pgp:passphrase_path")) + +# Execution requirements every signing action must carry. Listed here +# independently of the rule, so that dropping one from the rule fails this +# test. `//pgp/test:audit_test` additionally checks captured `aquery` output. +REQUIRED_EXECUTION_REQUIREMENTS = [ + "no-cache", + "no-remote", + "no-remote-cache", + "no-remote-cache-upload", + "no-remote-exec", +] + +# Environment variables that must never reach a signing action. +_FORBIDDEN_ENV = [ + "GNUPGHOME", + "HOME", + "SSH_AUTH_SOCK", +] + +def _sign_action(env): + actions = [ + action + for action in analysistest.target_actions(env) + if action.mnemonic == MNEMONIC + ] + asserts.equals(env, 1, len(actions), "expected a single %s action" % MNEMONIC) + return actions[0] + +def _execution_requirements_test_impl(ctx): + env = unittest.begin(ctx) + for requirement in REQUIRED_EXECUTION_REQUIREMENTS: + asserts.true( + env, + requirement in EXECUTION_REQUIREMENTS, + "signing actions are missing execution requirement `%s`" % requirement, + ) + return unittest.end(env) + +execution_requirements_test = unittest.make(_execution_requirements_test_impl) + +def _env_test_impl(ctx): + env = analysistest.begin(ctx) + action = _sign_action(env) + for name in _FORBIDDEN_ENV: + asserts.true( + env, + name not in action.env, + "%s action leaks `%s` into its environment" % (MNEMONIC, name), + ) + asserts.equals( + env, + {"PATH": "/usr/bin:/bin"}, + action.env, + "%s action environment is not the expected minimal PATH" % MNEMONIC, + ) + return analysistest.end(env) + +env_test = analysistest.make( + _env_test_impl, + config_settings = { + KEY_FLAG: KEY_PATH, + PASSPHRASE_FLAG: PASSPHRASE_PATH, + }, +) + +def _args_test_impl(ctx): + env = analysistest.begin(ctx) + action = _sign_action(env) + argv = action.argv + asserts.true( + env, + "--require-encrypted-key" in argv, + "%s action does not require an encrypted key" % MNEMONIC, + ) + asserts.true( + env, + "--passphrase-file" in argv, + "%s action does not pass a passphrase file" % MNEMONIC, + ) + asserts.true( + env, + PASSPHRASE_PATH in argv, + "%s action does not pass the configured passphrase path" % MNEMONIC, + ) + asserts.true( + env, + "--key" in argv, + "%s action does not pass a key path" % MNEMONIC, + ) + asserts.equals( + env, + KEY_PATH, + argv[argv.index("--key") + 1], + "%s action does not pass the configured key path" % MNEMONIC, + ) + if ctx.attr.expect_key_sha256: + asserts.true( + env, + "--key-sha256" in argv, + "%s action expected --key-sha256" % MNEMONIC, + ) + asserts.equals( + env, + ctx.attr.expect_key_sha256, + argv[argv.index("--key-sha256") + 1], + "%s action passed wrong key sha256" % MNEMONIC, + ) + else: + asserts.false( + env, + "--key-sha256" in argv, + "%s action should not pass --key-sha256 when no fragment is configured" % MNEMONIC, + ) + asserts.equals( + env, + ctx.attr.mode, + argv[argv.index("--mode") + 1], + "%s action signs in the wrong mode" % MNEMONIC, + ) + return analysistest.end(env) + +args_test = analysistest.make( + _args_test_impl, + attrs = { + "expect_key_sha256": attr.string(), + "mode": attr.string(mandatory = True), + }, + config_settings = { + KEY_FLAG: KEY_PATH, + PASSPHRASE_FLAG: PASSPHRASE_PATH, + }, +) + +args_with_fragment_test = analysistest.make( + _args_test_impl, + attrs = { + "expect_key_sha256": attr.string(), + "mode": attr.string(mandatory = True), + }, + config_settings = { + KEY_FLAG: KEY_PATH_WITH_FRAGMENT, + PASSPHRASE_FLAG: PASSPHRASE_PATH, + }, +) + +def _inputs_test_impl(ctx): + env = analysistest.begin(ctx) + action = _sign_action(env) + inputs = [f.short_path for f in action.inputs.to_list()] + src_short_path = ctx.file.src.short_path + asserts.true( + env, + src_short_path in inputs, + "expected %s in action inputs: %s" % (src_short_path, inputs), + ) + non_tool_inputs = [f for f in inputs if "stub_signer" not in f] + asserts.equals( + env, + [src_short_path], + non_tool_inputs, + "signing action inputs must contain exactly src and tool files (no key): got %s" % inputs, + ) + return analysistest.end(env) + +inputs_test = analysistest.make( + _inputs_test_impl, + attrs = { + "src": attr.label(mandatory = True, allow_single_file = True), + }, + config_settings = { + KEY_FLAG: KEY_PATH, + PASSPHRASE_FLAG: PASSPHRASE_PATH, + }, +) + +def _no_key_test_impl(ctx): + env = analysistest.begin(ctx) + asserts.expect_failure(env, "No key path configured") + return analysistest.end(env) + +no_key_test = analysistest.make( + _no_key_test_impl, + expect_failure = True, + config_settings = { + KEY_FLAG: "", + PASSPHRASE_FLAG: PASSPHRASE_PATH, + }, +) + +def _relative_key_test_impl(ctx): + env = analysistest.begin(ctx) + asserts.expect_failure(env, "is not absolute") + return analysistest.end(env) + +relative_key_test = analysistest.make( + _relative_key_test_impl, + expect_failure = True, + config_settings = { + KEY_FLAG: "relative/key.pgp", + PASSPHRASE_FLAG: PASSPHRASE_PATH, + }, +) + +def _bad_key_fragment_test_impl(ctx): + env = analysistest.begin(ctx) + asserts.expect_failure(env, "Invalid sha256 fragment") + return analysistest.end(env) + +bad_key_fragment_test = analysistest.make( + _bad_key_fragment_test_impl, + expect_failure = True, + config_settings = { + KEY_FLAG: "/tmp/key#md5=1234", + PASSPHRASE_FLAG: PASSPHRASE_PATH, + }, +) + +def _no_passphrase_test_impl(ctx): + env = analysistest.begin(ctx) + asserts.expect_failure(env, "No passphrase path configured") + return analysistest.end(env) + +no_passphrase_test = analysistest.make( + _no_passphrase_test_impl, + expect_failure = True, + config_settings = { + KEY_FLAG: KEY_PATH, + PASSPHRASE_FLAG: "", + }, +) + +def _relative_passphrase_test_impl(ctx): + env = analysistest.begin(ctx) + asserts.expect_failure(env, "is not absolute") + return analysistest.end(env) + +relative_passphrase_test = analysistest.make( + _relative_passphrase_test_impl, + expect_failure = True, + config_settings = { + KEY_FLAG: KEY_PATH, + PASSPHRASE_FLAG: "passphrase", + }, +) + +def _passphrase_fragment_test_impl(ctx): + env = analysistest.begin(ctx) + asserts.expect_failure(env, "passphrase digest is an oracle") + return analysistest.end(env) + +passphrase_fragment_test = analysistest.make( + _passphrase_fragment_test_impl, + expect_failure = True, + config_settings = { + KEY_FLAG: KEY_PATH, + PASSPHRASE_FLAG: "/tmp/passphrase#sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }, +) diff --git a/bazel/pgp/test/signer_test.sh b/bazel/pgp/test/signer_test.sh new file mode 100755 index 0000000000..722c14aaff --- /dev/null +++ b/bazel/pgp/test/signer_test.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# +# Integration test for the signer CLI contract. +# +# Generates a passphrase-encrypted key with `sq` at test time (no key material +# is ever committed), signs in each mode, verifies the signatures, and asserts +# that the signer refuses to use an unencrypted key. +# +# Skipped if no `sq` is available - the default toolchain binary is not +# fetched by default (see //pgp:extensions.bzl). + +set -euo pipefail + +SQ="${SQ:-}" +if [[ -z "$SQ" ]]; then + SQ="$(command -v sq || true)" +fi + +if [[ -z "$SQ" ]]; then + echo "SKIP: no \`sq\` binary found (set SQ to run this test)" + exit 0 +fi + +SIGNER="$(dirname "$0")/../private/signer.sh" +if [[ ! -f "$SIGNER" ]]; then + SIGNER="pgp/private/signer.sh" +fi + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +PASSPHRASE_FILE="${TMP}/passphrase" +KEY="${TMP}/key.pgp" +UNENCRYPTED_KEY="${TMP}/unencrypted-key.pgp" +DATA="${TMP}/data.txt" + +sq () { + "$SQ" --batch --home none --cert-store none --key-store none "$@" +} + +signer () { + SQ="$SQ" bash "$SIGNER" "$@" +} + +printf %s "test-passphrase-$$" > "$PASSPHRASE_FILE" +chmod 600 "$PASSPHRASE_FILE" +echo "some data" > "$DATA" + +sq key generate --own-key --without-password --no-userids \ + --rev-cert "${UNENCRYPTED_KEY}.rev" --output "$UNENCRYPTED_KEY" > /dev/null +sq key generate --own-key --new-password-file "$PASSPHRASE_FILE" \ + --no-userids --rev-cert "${KEY}.rev" --output "$KEY" > /dev/null +failed=0 + +check () { + local msg="$1" + shift + if "$@"; then + echo "ok: ${msg}" + else + echo "FAIL: ${msg}" >&2 + failed=1 + fi +} + +check_fails () { + local msg="$1" + shift + if "$@" > "${TMP}/output" 2>&1; then + echo "FAIL: ${msg}" >&2 + failed=1 + else + echo "ok: ${msg}" + fi +} + +# Detached signature. +check "detached signature created" \ + signer --mode detached --key "$KEY" \ + --passphrase-file "$PASSPHRASE_FILE" --require-encrypted-key \ + --armor --out "${TMP}/data.txt.asc" "$DATA" +check "detached signature verifies" \ + sq verify --signature-file "${TMP}/data.txt.asc" \ + --signer-file "$KEY" "$DATA" + +# Cleartext signature. +check "cleartext signature created" \ + signer --mode cleartext --key "$KEY" \ + --passphrase-file "$PASSPHRASE_FILE" --require-encrypted-key \ + --out "${TMP}/data.txt.cleartext" "$DATA" +check "cleartext signature is a cleartext signed message" \ + grep -q -- "-----BEGIN PGP SIGNED MESSAGE-----" \ + "${TMP}/data.txt.cleartext" +check "cleartext signature verifies" \ + sq verify --cleartext --signer-file "$KEY" \ + "${TMP}/data.txt.cleartext" + +# Inline signature. +check "inline signature created" \ + signer --mode inline --key "$KEY" \ + --passphrase-file "$PASSPHRASE_FILE" --require-encrypted-key \ + --armor --out "${TMP}/data.txt.inline" "$DATA" +check "inline signature verifies" \ + sq verify --message --signer-file "$KEY" \ + --output /dev/null "${TMP}/data.txt.inline" + +# --key-sha256 matching. +calc_sha256 () { + if command -v sha256sum > /dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} +KEY_SHA256="$(calc_sha256 "$KEY")" + +check "signature created with matching --key-sha256" \ + signer --mode detached --key "$KEY" --key-sha256 "$KEY_SHA256" \ + --passphrase-file "$PASSPHRASE_FILE" --require-encrypted-key \ + --armor --out "${TMP}/data_sha.txt.asc" "$DATA" + +# --key-sha256 mismatching. +BAD_SHA256="0000000000000000000000000000000000000000000000000000000000000000" +check_fails "mismatched --key-sha256 is rejected" \ + signer --mode detached --key "$KEY" --key-sha256 "$BAD_SHA256" \ + --passphrase-file "$PASSPHRASE_FILE" --require-encrypted-key \ + --armor --out "${TMP}/rejected_sha.asc" "$DATA" +check "mismatch error is explicit" \ + grep -q "key digest mismatch" "${TMP}/output" +check "no output written for mismatched sha256" \ + test ! -e "${TMP}/rejected_sha.asc" + +# Relative --key path. +check_fails "relative --key path is rejected" \ + signer --mode detached --key "relative/path/key.pgp" \ + --passphrase-file "$PASSPHRASE_FILE" --require-encrypted-key \ + --armor --out "${TMP}/rejected_relative.asc" "$DATA" +check "relative key path error points at --@envoy_toolshed//pgp:key_path" \ + grep -q "key_path" "${TMP}/output" +check "no output written for relative key path" \ + test ! -e "${TMP}/rejected_relative.asc" + +# Unencrypted keys must be rejected. +check_fails "unencrypted key is rejected" \ + signer --mode detached --key "$UNENCRYPTED_KEY" \ + --passphrase-file "$PASSPHRASE_FILE" --require-encrypted-key \ + --armor --out "${TMP}/rejected.asc" "$DATA" +check "rejection is explicit" \ + grep -q "REFUSING TO SIGN" "${TMP}/output" +check "no output was written for a rejected key" \ + test ! -e "${TMP}/rejected.asc" + +if [[ "$failed" -ne 0 ]]; then + exit 1 +fi + +echo "signer integration test passed" diff --git a/bazel/pgp/test/stub_signer.sh b/bazel/pgp/test/stub_signer.sh new file mode 100755 index 0000000000..7fbeaeed97 --- /dev/null +++ b/bazel/pgp/test/stub_signer.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# +# Stub signer used by the analysis tests. +# +# It implements the argument contract of the real signer but performs no +# cryptography - the analysis tests only need a registered toolchain. + +set -euo pipefail + +MODE= +KEY= +KEY_SHA256= +PASSPHRASE_FILE= +OUT= + +while [[ $# -gt 0 ]]; do + case "$1" in + --mode) + MODE="$2" + shift 2 + ;; + --key) + KEY="$2" + shift 2 + ;; + --key-sha256) + KEY_SHA256="$2" + shift 2 + ;; + --passphrase-file) + PASSPHRASE_FILE="$2" + shift 2 + ;; + --out) + OUT="$2" + shift 2 + ;; + --armor|--require-encrypted-key) + shift + ;; + *) + shift + ;; + esac +done + +if [[ -z "$MODE" || -z "$KEY" || -z "$PASSPHRASE_FILE" || -z "$OUT" ]]; then + echo "stub signer: incomplete arguments" >&2 + exit 2 +fi + +echo "STUB SIGNATURE (${MODE})" > "$OUT" diff --git a/bazel/pgp/toolchain.bzl b/bazel/pgp/toolchain.bzl new file mode 100644 index 0000000000..5979627c68 --- /dev/null +++ b/bazel/pgp/toolchain.bzl @@ -0,0 +1,52 @@ +"""Toolchain for hermetic OpenPGP signing. + +The toolchain provides an executable implementing the signer CLI contract +documented in `//pgp:README.md`: + +``` +signer --mode {detached|cleartext|inline} \\ + --key \\ + [--key-sha256 ] \\ + --passphrase-file \\ + --require-encrypted-key \\ + --out \\ + [--armor] \\ + +``` + +Any implementation of that contract can be dropped in without changing the +rules - the default implementation wraps Sequoia PGP's `sq`. +""" + +PgpSignerInfo = provider( + doc = "Information about an OpenPGP signer implementation.", + fields = { + "runfiles": "runfiles required by the signer executable.", + "signer": "File: executable implementing the signer CLI contract.", + }, +) + +def _pgp_toolchain_impl(ctx): + default = ctx.attr.signer[DefaultInfo] + executable = default.files_to_run.executable + if not executable: + fail("`signer` (%s) does not provide an executable" % ctx.attr.signer.label) + return [platform_common.ToolchainInfo( + pgp_signer = PgpSignerInfo( + signer = executable, + runfiles = default.default_runfiles, + ), + )] + +pgp_toolchain = rule( + implementation = _pgp_toolchain_impl, + doc = "Declares an OpenPGP signer implementation for `//pgp:toolchain_type`.", + attrs = { + "signer": attr.label( + doc = "Executable implementing the signer CLI contract.", + mandatory = True, + executable = True, + cfg = "exec", + ), + }, +)