Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 18 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions examples/node-callback-server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down
106 changes: 105 additions & 1 deletion internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
Expand Down
5 changes: 3 additions & 2 deletions internal/app/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
37 changes: 34 additions & 3 deletions internal/app/recovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,23 +91,30 @@ 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/")
}

// 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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down
49 changes: 30 additions & 19 deletions scripts/install.sh.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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

Expand Down
32 changes: 32 additions & 0 deletions scripts/test-installer.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading