From c9f440b365966fef8d46d4d3fa9cb79c16e4ca82 Mon Sep 17 00:00:00 2001 From: Azzurriii Date: Wed, 14 Oct 2026 10:27:19 +0700 Subject: [PATCH] fix: enhance security measures in installation and recovery processes --- README.md | 33 ++++---- examples/node-callback-server/server.js | 5 ++ internal/app/app_test.go | 106 +++++++++++++++++++++++- internal/app/commands.go | 5 +- internal/app/recovery.go | 37 ++++++++- scripts/install.sh.tmpl | 49 ++++++----- scripts/test-installer.sh | 32 +++++++ 7 files changed, 227 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 8c6b32c..2314d99 100644 --- a/README.md +++ b/README.md @@ -76,21 +76,9 @@ All three should print a version. If `docker` fails with a permission error on L ## Install -```bash -curl -sSL https://cosigner.fystack.io/install.sh | sh -``` - -The installer installs cosign if it's missing, then checks `cosigner-cli` against the signed release -checksums and its Sigstore signature before installing it to `/usr/local/bin`. It also checks Docker and -tells you what to fix. To pin a version or install directory: - -```bash -curl -sSL https://cosigner.fystack.io/install.sh | sh -s -- --version v1.2.3 --dir /opt/cosigner-cli/bin -``` - -[cosigner.fystack.io](https://cosigner.fystack.io) lists every release with its checksums and shows how -to verify the installer by hand before running it. Each release's `install.sh` is built and signed by -this repository's release workflow, so you can also check it with cosign before running it: +This host will hold a share of your wallet keys, so verify the installer before you run it. Each +release's `install.sh` is built from [`scripts/install.sh.tmpl`](scripts/install.sh.tmpl) and signed by +this repository's release workflow. Download it, check the signature with cosign, then run it: ```bash V=v1.2.3 @@ -102,6 +90,21 @@ cosign verify-blob install.sh --bundle install.sh.sigstore.json \ sh install.sh ``` +Run `sh install.sh` only if `cosign verify-blob` prints `Verified OK`. The installer then checks +`cosigner-cli` against the SHA-256 pinned in the script and its Sigstore signature before it installs it +to `/usr/local/bin`. It also checks Docker and tells you what to fix. To set the install directory, run +`sh install.sh --dir /opt/cosigner-cli/bin`. + +[cosigner.fystack.io](https://cosigner.fystack.io) lists every release with its checksums. + +**Quick install (not for production hosts).** The command below runs the script that +cosigner.fystack.io serves, before anything is verified. The installer's own checks cover only the binary +it downloads, not the script itself, so this trusts the web host, its DNS and its CDN: + +```bash +curl -sSL https://cosigner.fystack.io/install.sh | sh -s -- --version v1.2.3 +``` + Or build from source (Go 1.26+): ```bash diff --git a/examples/node-callback-server/server.js b/examples/node-callback-server/server.js index 0bdc0c4..a3f250f 100644 --- a/examples/node-callback-server/server.js +++ b/examples/node-callback-server/server.js @@ -18,6 +18,11 @@ app.post("/cosigner/approve", express.text({ type: "application/jwt", limit: "51 console.log("Cosigner request:", JSON.stringify(request, null, 2)); let decision = { action: "approve" }; + // A re-share with local_veto_lost moves the wallet to a committee that can sign + // without this cosigner, for good. Reject it unless you expect exactly that change. + if (request.local_veto_lost) { + decision = { action: "reject", reason: "re-share would let the new committee sign without this cosigner" }; + } // Your approval logic goes here. For example: // if (request.signing_context?.tx?.decoded?.to_address !== ALLOWED_ADDRESS) { // decision = { action: "reject", reason: "destination not allowed" }; diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 3142020..9566a95 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -310,7 +310,111 @@ func TestRecoveryRestoresIdentityIntoEmptyHome(t *testing.T) { cmd = a.Command() cmd.SetArgs([]string{"--home", target, "restore", "--in", archive}) require.ErrorContains(t, cmd.Execute(), "empty instance") - require.Empty(t, r.calls, "restore must never start a container") + for _, call := range r.calls { + require.Equal(t, "cosign", call.name, "restore must never start a container") + } +} + +// sealedArchive writes bundle as an archive a valid recovery password opens, +// like one made on a host the attacker controlled. +func sealedArchive(t *testing.T, bundle recoveryBundle, password string) string { + t.Helper() + plain, err := json.Marshal(bundle) + require.NoError(t, err) + blob, err := sealRecovery([]byte(password), plain) + require.NoError(t, err) + path := filepath.Join(t.TempDir(), "instance.backup") + require.NoError(t, os.WriteFile(path, blob, 0600)) + return path +} + +func recoveryPasswordEnv(password string) func(string) string { + return func(k string) string { + if k == "COSIGNER_RECOVERY_PASSWORD" { + return password + } + return "" + } +} + +func TestBackupLeavesComposeFileOutOfArchive(t *testing.T) { + a := newTestApp(&fakeRunner{}) + l, s := fixture(t, a) + bundle, err := snapshotInstallation(l, s) + require.NoError(t, err) + for _, f := range bundle.Files { + require.NotEqual(t, "docker-compose.yaml", f.Path) + } + require.NoError(t, validateBundle(bundle)) +} + +// CLI-1: whoever made the archive must not decide what runs on the new host. +func TestRestoreRegeneratesComposeAndIgnoresArchivedDockerCommand(t *testing.T) { + r := &fakeRunner{} + a := newTestApp(r) + l, s := fixture(t, a) + malicious := "services:\n cosigner:\n image: attacker\n volumes: [\"/:/host\"]\n" + require.NoError(t, os.WriteFile(l.compose(), []byte(malicious), 0600)) + s.Docker = "/tmp/attacker-docker" + require.NoError(t, saveSettings(l.home, s)) + bundle, err := snapshotInstallation(l, s) + require.NoError(t, err) + // Archives from older CLIs, or tampered ones, still carry a compose file. + sum := sha256.Sum256([]byte(malicious)) + bundle.Files = append(bundle.Files, recoveryFile{"docker-compose.yaml", hex.EncodeToString(sum[:]), []byte(malicious)}) + archive := sealedArchive(t, bundle, "pw") + + target := t.TempDir() + a.Getenv = recoveryPasswordEnv("pw") + cmd := a.Command() + cmd.SetArgs([]string{"--home", target, "restore", "--in", archive}) + require.NoError(t, cmd.Execute()) + + template, err := templates.ReadFile("docker-compose.yaml") + require.NoError(t, err) + restored, err := os.ReadFile(filepath.Join(target, "docker-compose.yaml")) + require.NoError(t, err) + require.Equal(t, template, restored) + settings, err := loadSettings(target) + require.NoError(t, err) + require.Equal(t, "docker", settings.Docker) +} + +// CLI-2: the archive's image must pass the same signature check as up and update. +func TestRestoreVerifiesArchivedImageSignature(t *testing.T) { + untrusted := "untrusted.example.invalid/cosigner@sha256:" + strings.Repeat("b", 64) + r := &fakeRunner{run: func(c runCall) ([]byte, error) { + if c.name == "cosign" && c.args[0] == "verify" { + return []byte("no matching signatures"), errors.New("exit status 1") + } + return nil, nil + }} + a := newTestApp(r) + l, s := fixture(t, a) + s.Image = untrusted + require.NoError(t, saveSettings(l.home, s)) + bundle, err := snapshotInstallation(l, s) + require.NoError(t, err) + archive := sealedArchive(t, bundle, "pw") + + target := t.TempDir() + a.Getenv = recoveryPasswordEnv("pw") + cmd := a.Command() + cmd.SetArgs([]string{"--home", target, "restore", "--in", archive}) + require.ErrorContains(t, cmd.Execute(), "image signature verification failed") + + var verified []string + for _, c := range r.calls { + if c.name == "cosign" && c.args[0] == "verify" { + verified = append(verified, c.args[len(c.args)-1]) + } + } + require.Equal(t, []string{untrusted}, verified) + entries, err := os.ReadDir(target) + require.NoError(t, err) + for _, entry := range entries { + require.Equal(t, ".cosigner-cli.lock", entry.Name(), "a refused archive must publish nothing") + } } func TestRecoveryRejectsTamperingTraversalAndRunningDatabase(t *testing.T) { a := newTestApp(&fakeRunner{}) diff --git a/internal/app/commands.go b/internal/app/commands.go index a802cfe..e55aec6 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -449,8 +449,9 @@ func (a *App) startService(ctx context.Context, docker string, l layout, s Setti return err } } - // The digest was signature-verified when it was installed and is content - // addressed, so startup only rechecks the runtime contract (offline). + // up, update and restore verify the digest's signature before they record + // it, and the digest is content addressed, so startup only rechecks the + // runtime contract (offline). if _, err := a.runtimeCapabilities(ctx, docker, s.Image); err != nil { return err } diff --git a/internal/app/recovery.go b/internal/app/recovery.go index f429424..dd78fc5 100644 --- a/internal/app/recovery.go +++ b/internal/app/recovery.go @@ -91,12 +91,18 @@ func openRecovery(password, blob []byte) ([]byte, error) { return plain, nil } +// legacyComposePath is the compose file that older archives carried. It is +// accepted so those archives still restore, but never installed: the compose +// file decides what runs on the host, so restore regenerates it from the +// template of the running CLI instead of trusting the archive. +const legacyComposePath = "docker-compose.yaml" + func allowedRecoveryPath(path string) bool { if !filepath.IsLocal(path) || filepath.ToSlash(filepath.Clean(path)) != path || strings.Contains(path, "\\") { return false } switch path { - case "config.yaml", "cosigner-cli.yaml", "docker-compose.yaml": + case "config.yaml", "cosigner-cli.yaml", legacyComposePath: return true } return strings.HasPrefix(path, "state/") || strings.HasPrefix(path, "secrets/") @@ -104,10 +110,11 @@ func allowedRecoveryPath(path string) bool { // snapshotInstallation requires both the operator lock and the database lock. // The latter is shared with the runtime's SQLite backend, including direct runs. +// It archives data only; the compose file is CLI output and is regenerated on restore. func snapshotInstallation(l layout, s Settings) (recoveryBundle, error) { bundle := recoveryBundle{Version: 2, CreatedAt: time.Now().UTC(), Image: s.Image} var total int64 - for _, root := range []string{"config.yaml", "cosigner-cli.yaml", "docker-compose.yaml", "state", "secrets"} { + for _, root := range []string{"config.yaml", "cosigner-cli.yaml", "state", "secrets"} { err := filepath.WalkDir(filepath.Join(l.home, root), func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr @@ -240,6 +247,11 @@ func (a *App) restoreCommand() *cobra.Command { return errors.New("restore requires an empty instance directory; existing state is never overwritten") } } + // The archive names the image that start will run, so its signature is + // checked before the restored settings are published. Fail before any prompt. + if err := a.cosignOK(c.Context()); err != nil { + return err + } input, _ := c.Flags().GetString("in") f, err := os.Open(input) if err != nil { @@ -274,10 +286,20 @@ func (a *App) restoreCommand() *cobra.Command { } defer os.RemoveAll(stage) for _, file := range bundle.Files { + if file.Path == legacyComposePath { + continue + } if err := atomicWrite(filepath.Join(stage, filepath.FromSlash(file.Path)), file.Data, 0600); err != nil { return err } } + compose, err := templates.ReadFile("docker-compose.yaml") + if err != nil { + return err + } + if err := atomicWrite(a.layout(stage).compose(), compose, 0600); err != nil { + return err + } s, err := loadSettings(stage) if err != nil { return err @@ -288,7 +310,16 @@ func (a *App) restoreCommand() *cobra.Command { if err := validatePaired(a.layout(stage), s); err != nil { return err } + if err := a.verifyImage(c.Context(), s.Image); err != nil { + return err + } s.ProjectName = projectName(home) + // The Docker command is host configuration, not recovery data: take it + // from this host, never from the archive. + s.Docker = "docker" + if docker, _ := c.Flags().GetString("docker"); docker != "" { + s.Docker = docker + } if err := saveSettings(stage, s); err != nil { return err } @@ -327,7 +358,7 @@ func validateBundle(b recoveryBundle) error { return fmt.Errorf("archive checksum mismatch: %s", f.Path) } } - for _, path := range []string{"config.yaml", "cosigner-cli.yaml", "docker-compose.yaml", "state/cosigner.key", "state/cosigner.pub", "state/pairing.yaml", "state/data/cosigner.db"} { + for _, path := range []string{"config.yaml", "cosigner-cli.yaml", "state/cosigner.key", "state/cosigner.pub", "state/pairing.yaml", "state/data/cosigner.db"} { if !seen[path] { return fmt.Errorf("archive is missing %s", path) } diff --git a/scripts/install.sh.tmpl b/scripts/install.sh.tmpl index 6f746ca..16e738a 100644 --- a/scripts/install.sh.tmpl +++ b/scripts/install.sh.tmpl @@ -294,6 +294,35 @@ check_docker() { fi } +# Another release: hand over to that release's own installer, which carries +# that release's checksums. It runs with this installer's privileges, so it must +# pass the same signature check as a binary before it runs. +hand_over() { + case "$REQUESTED_VERSION" in v*) ;; *) REQUESTED_VERSION="v$REQUESTED_VERSION" ;; esac + printf '%s' "$REQUESTED_VERSION" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + || die "invalid version $REQUESTED_VERSION (expected vX.Y.Z)" + detect_platform + choose_install_dir + ensure_cosign + + step "Fetching the installer for $REQUESTED_VERSION" + base="https://github.com/$REPOSITORY/releases/download/$REQUESTED_VERSION" + for file in install.sh install.sh.sigstore.json; do + curl --proto '=https' --proto-redir '=https' --tlsv1.2 --fail --silent --location \ + --output "$TMP/$file" "$base/$file" \ + || die "release $REQUESTED_VERSION has no signed installer. See $BASE_URL for available versions." + done + "$COSIGN" verify-blob "$TMP/install.sh" \ + --bundle "$TMP/install.sh.sigstore.json" \ + --certificate-identity "https://github.com/$REPOSITORY/.github/workflows/release.yml@refs/tags/$REQUESTED_VERSION" \ + --certificate-oidc-issuer "$OIDC_ISSUER" >/dev/null 2>"$TMP/cosign.log" \ + || { cat "$TMP/cosign.log" >&2; die "signature verification failed for the $REQUESTED_VERSION installer. Refusing to run it."; } + ok "installer signature verified (built by $REPOSITORY release workflow for $REQUESTED_VERSION)" + + # shellcheck disable=SC2086 # FORWARD is empty or a single flag + sh "$TMP/install.sh" --dir "$INSTALL_DIR" $FORWARD +} + # ------------------------------------------------------------------ main ---- main() { @@ -321,26 +350,8 @@ main() { TMP="$(mktemp -d 2>/dev/null || mktemp -d -t cosigner-cli)" trap 'rm -rf -- "$TMP"' EXIT INT TERM - # Another release: hand over to that release's own installer, which carries - # that release's checksums. if [ -n "$REQUESTED_VERSION" ] && [ "$REQUESTED_VERSION" != "$VERSION" ]; then - case "$REQUESTED_VERSION" in v*) ;; *) REQUESTED_VERSION="v$REQUESTED_VERSION" ;; esac - printf '%s' "$REQUESTED_VERSION" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \ - || die "invalid version $REQUESTED_VERSION (expected vX.Y.Z)" - step "Fetching the installer for $REQUESTED_VERSION" - curl --proto '=https' --tlsv1.2 --fail --silent --location \ - --output "$TMP/install.sh" "$BASE_URL/$REQUESTED_VERSION/install.sh" \ - || die "release $REQUESTED_VERSION not found. See $BASE_URL for available versions." - # Hosts can answer unknown paths with an HTML page. Only run the file if - # it is the installer for exactly that release. - grep -qx "VERSION=\"$REQUESTED_VERSION\"" "$TMP/install.sh" \ - || die "release $REQUESTED_VERSION not found. See $BASE_URL for available versions." - # shellcheck disable=SC2086 # FORWARD is empty or a single flag - if [ -n "$INSTALL_DIR" ]; then - sh "$TMP/install.sh" --dir "$INSTALL_DIR" $FORWARD - else - sh "$TMP/install.sh" $FORWARD - fi + hand_over exit $? fi diff --git a/scripts/test-installer.sh b/scripts/test-installer.sh index 239f878..e648839 100755 --- a/scripts/test-installer.sh +++ b/scripts/test-installer.sh @@ -117,4 +117,36 @@ for pair in Linux:x86_64:linux:amd64 Linux:aarch64:linux:arm64 Darwin:x86_64:dar [[ "$got " == "$want" ]] || fail "detect_platform $kernel/$machine: got '$got', want '$want'" done echo "ok detect_platform" + +# --- --version hands over only to a signed installer ----------------------------- +# Stub curl serves another release's installer that leaves a marker when it runs; +# stub cosign accepts or rejects its signature. +handover() { # name verify-exit-code expect(ran|refused) + local bin="$WORK/handover-$1" mark="$WORK/handover-$1.ran" out status=0 + mkdir "$bin" "$bin-dir" + cat > "$bin/curl" <<'EOF' +#!/bin/sh +out=""; url="" +while [ $# -gt 0 ]; do + case "$1" in --output) out="$2"; shift 2 ;; *) url="$1"; shift ;; esac +done +case "$url" in + */v1.0.0/install.sh) printf '#!/bin/sh\ntouch "$HANDOVER_MARK"\n' > "$out" ;; + */v1.0.0/install.sh.sigstore.json) echo '{}' > "$out" ;; + *) exit 22 ;; +esac +EOF + printf '#!/bin/sh\ncase "$1" in version) echo "GitVersion: v3.1.3";; verify-blob) exit %s;; esac\n' "$2" > "$bin/cosign" + chmod +x "$bin/curl" "$bin/cosign" + out="$(PATH="$bin:$PATH" HANDOVER_MARK="$mark" COSIGNER_INSTALL_DIR="$bin-dir" \ + "$DASH" "$INSTALLER" --version v1.0.0 --skip-docker-check 2>&1)" || status=$? + case "$3" in + ran) [[ $status -eq 0 && -e "$mark" ]] || fail "handover $1: signed installer did not run (status $status):"$'\n'"$out" ;; + refused) [[ $status -ne 0 && ! -e "$mark" ]] || fail "handover $1: unsigned installer ran (status $status):"$'\n'"$out" ;; + esac + echo "ok handover: $1" +} +handover signed 0 ran +handover unsigned 1 refused + echo "installer tests passed"