dig-keystore 0.13.0

Encrypted secret-key storage for DIG Network binaries (BLS signing + L1 wallet keys). AES-256-GCM + Argon2id, typed per-scheme magic files, zeroizing memory hygiene.
Documentation
name: Publish to crates.io

on:
  push:
    branches:
      - main
    tags:
      - 'v*'
  pull_request:
    branches:
      - main
  workflow_dispatch:
    inputs:
      version:
        description: 'Version to publish (e.g., v0.1.0)'
        required: true
        type: string

env:
  CARGO_TERM_COLOR: always

jobs:
  test:
    name: Test Suite
    runs-on: ubuntu-latest
    # A tag push lands on a commit that already merged to `main` and was gated there
    # (this same job, via the `branches: [main]` trigger above) — re-running the full
    # suite again at tag/deploy time is redundant and a pure flake-exposure surface
    # (dig_ecosystem #488). Deploy = build + package + publish only (see `publish` job).
    if: "!startsWith(github.ref, 'refs/tags/v')"
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: stable

      - name: Cache dependencies
        uses: Swatinem/rust-cache@v2

      - name: Check formatting
        run: cargo fmt --all -- --check

      - name: Check clippy
        run: cargo clippy --all-targets --all-features -- -D warnings

      # `--all-features` above turns the `custody` / `hd-derivation` gates ON, so
      # neither it nor the coverage run below can observe a broken gate, and neither
      # can they observe a `missing_docs` or type error that only appears when the
      # custody module is compiled OUT. These two steps are the only ones that build
      # the crate in a configuration a gate-respecting consumer actually uses.
      - name: Clippy (core-only, custody compiled out)
        run: cargo clippy --no-default-features --features file-backend,testing --all-targets -- -D warnings

      - name: Clippy (custody without hd-derivation)
        run: cargo clippy --no-default-features --features file-backend,testing,custody --all-targets -- -D warnings

      # Backwards-compatibility (SPEC.md §5.1 / dig_ecosystem CLAUDE.md §5.1): the
      # opaque golden vectors must still decode in a build with no custody surface at
      # all. `dig-node`'s seed_store.rs classifies old blobs through this exact
      # configuration, so a DIGVK1/DIGLW1 magic that stopped being recognised here
      # would silently change how it reads content written by earlier versions.
      - name: Golden opaque vectors (core-only build)
        run: cargo test --no-default-features --features file-backend,testing --test opaque_vectors

      # Proves the feature gates by compiling throwaway consumers under each
      # configuration and asserting the exact rustc diagnostic. See the script header
      # for why this cannot be an ordinary #[test].
      - name: Public-surface feature-gate check
        run: bash scripts/surface-check.sh

      # Coverage IS the test run: cargo-llvm-cov drives nextest under instrumentation,
      # gates at >=80% line coverage (build fails below the floor), and emits lcov for
      # visibility. nextest retries a failing test up to 2x (.config/nextest.toml) and
      # natively reports "failed then passed on retry" as flaky (dig_ecosystem #489) —
      # no separate flaky-tracking tooling needed. Single-threaded to match the
      # tempfile/filesystem-backed tests' expectations.
      - name: Install cargo-llvm-cov + cargo-nextest
        uses: taiki-e/install-action@v2
        with:
          tool: cargo-llvm-cov,nextest

      - name: Run tests with coverage (gate >=80% lines; nextest retries=2, flaky surfaced)
        run: cargo llvm-cov nextest --all-features --test-threads=1 --fail-under-lines 80 --lcov --output-path lcov.info

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: lcov-coverage
          path: lcov.info
          if-no-files-found: error

      # `default-members = ["."]`, so every step above operates on the root package
      # ONLY — the `hardware/` member is invisible to all of them. Its ladder is
      # deliberately platform-independent (the tier decision is the code that runs on
      # hosts WITHOUT a trusted component, which is most of them), so it is gated here
      # on ubuntu as well as on the platform matrix below. Omitting this would leave
      # the degrade ladder tested only where the hardware exists, which is exactly
      # backwards.
      - name: Clippy dig-keystore-hardware (ladder; no trusted component here)
        run: cargo clippy -p dig-keystore-hardware --all-targets -- -D warnings

      # Gated at the same >=80% floor as the root crate (dig_ecosystem CLAUDE.md
      # §2.3). Measured here rather than on the platform matrix because ubuntu
      # compiles ONLY the platform-independent half — the ladder, the tier
      # reporting, the probe classification and the TPM command codec — which is
      # precisely the half a floor should bind: a device-conversation arm is
      # unreachable without working (or malfunctioning) hardware, and including
      # one would set the floor by what the runner happens to have in it.
      #
      # The three excluded files are exactly the device conversations. Two of
      # them, `windows.rs` and `macos.rs`, were already excluded here by simply
      # not compiling off their platforms; `linux/session.rs` is named because
      # the Linux binding is pure `std` and therefore DOES compile everywhere,
      # which is deliberate — it is what lets its refusal arms be tested on every
      # host rather than on one CI leg. Excluding it from the FLOOR keeps that
      # choice from importing unreachable lines into the gate.
      #
      # What stays inside the floor is the part that decides what happens on a
      # machine with no trusted component, which is almost every machine:
      # `platform/linux.rs` (probe classification), `platform/tpm2.rs` (the
      # command codec, testable byte-for-byte on any host), `platform/content_key.rs`,
      # `ladder.rs`, `lib.rs` and `platform.rs`. Every excluded file is still
      # compiled, clippied with `-D warnings`, and has its reachable arms tested
      # on every leg of the matrix below.
      - name: Coverage for dig-keystore-hardware (gate >=80% lines)
        run: |
          cargo llvm-cov -p dig-keystore-hardware \
            --ignore-filename-regex 'platform.(windows|macos).rs|linux.session.rs' \
            --fail-under-lines 80

      - name: Check documentation
        run: cargo doc --no-deps --all-features

  # The `test` job above runs on ubuntu only, where `OsKeychainBackend`'s real
  # implementation and its `os_integration` test are both compiled OUT — the
  # `keyring` dependency is target-gated to Windows/macOS. That left the whole
  # OS-credential-store backend (dig_ecosystem #1024) shipping to crates.io
  # without ever being compiled by CI, let alone exercised, and it is the same
  # blind spot that blocks the platform hardware providers (dig_ecosystem
  # #1693) from being added at all.
  #
  # This job is the narrow fix: build and test the platform-gated code on the
  # platforms that have it. Coverage stays on ubuntu — running it three times
  # measures the same lines, and the floor is a property of the crate, not of
  # the runner.
  platform:
    name: Platform-gated code (${{ matrix.os }})
    runs-on: ${{ matrix.os }}
    # Same rationale as `test` above: this commit was already gated on its PR /
    # main-branch push before being tagged — skip the redundant re-run on tag push.
    if: "!startsWith(github.ref, 'refs/tags/v')"
    strategy:
      # One platform's failure must not cancel the other's run: when a
      # target-gated regression appears, "which platforms are affected?" is the
      # first question, and fail-fast answers it for only one of them.
      fail-fast: false
      matrix:
        # ubuntu is here for the Linux TPM provider, not for the root crate:
        # `default-members = ["."]` means the ubuntu `test` job above never
        # builds `hardware/` at all, so without this entry the Linux arm of
        # `platform::candidates` and the `unsupported_reason` agreement test
        # would ship having been compiled by nothing.
        os: [ubuntu-latest, windows-latest, macos-latest]
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: stable

      - name: Cache dependencies
        uses: Swatinem/rust-cache@v2

      - name: Check clippy
        run: cargo clippy --all-targets --all-features -- -D warnings

      # `os_integration::real_os_store_round_trips_where_available` drives the
      # host's actual credential store and self-skips where none is usable, so
      # this stays deterministic on a runner whose keychain may be locked.
      - name: Run tests
        run: cargo test --all-features

      # The whole reason `hardware/` is a separate member: its FFI cannot compile
      # under the root crate's `unsafe_code = "forbid"`. This is the only job that
      # compiles it against a real platform SDK.
      #
      # `DIG_KEYSTORE_REQUIRE_TPM` is deliberately NOT set. A GitHub runner is not
      # documented to expose a TPM, and setting it would turn an unproven assumption
      # about the runner into a red build. Unset, the hardware tests still run and
      # report which properties they did NOT exercise — a pass here means "the
      # degraded path is correct", never "the TPM path is correct". Set the variable
      # on a self-hosted runner that genuinely has one.
      - name: Clippy + test dig-keystore-hardware (platform FFI)
        run: |
          cargo clippy -p dig-keystore-hardware --all-targets -- -D warnings
          cargo test -p dig-keystore-hardware -- --nocapture

  wasm:
    name: wasm build + clippy + wasm-bindgen-test
    runs-on: ubuntu-latest
    # Same rationale as `test` above: this commit was already gated on its PR /
    # main-branch push before being tagged — skip the redundant re-run on tag push.
    if: "!startsWith(github.ref, 'refs/tags/v')"
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: stable
          components: clippy
          targets: wasm32-unknown-unknown

      - name: Cache dependencies
        uses: Swatinem/rust-cache@v2

      - name: install wasm-pack (official installer)
        run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh

      - name: clippy (dig-keystore-wasm, wasm32 target)
        run: cargo clippy -p dig-keystore-wasm --target wasm32-unknown-unknown --all-targets -- -D warnings

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      # `wasm-pack test --node` runs the wasm-bindgen-test suite (wasm/tests/opaque_wasm.rs),
      # including the native<->wasm byte-compatibility KAT vectors (dig_ecosystem #147 Phase A).
      - name: wasm-bindgen-test (Node)
        working-directory: wasm
        run: npm test

  publish:
    name: Publish to crates.io
    runs-on: ubuntu-latest
    needs: [test, platform, wasm]
    # `test`/`platform`/`wasm` are skipped (not failed) on a tag push by design (see
    # their `if:` above) — a tagged commit already passed those gates on its
    # PR/main-branch push, so each is tolerated as `skipped` as well as `success`.
    #
    # `platform` is in this list because it is the only job that compiles the
    # target-gated `OsKeychainBackend`; leaving it out let a `workflow_dispatch`
    # publish proceed over a RED platform job, which is the same blind spot that hid
    # that backend from CI for five releases.
    if: |
      always() &&
      (needs.test.result == 'success' || needs.test.result == 'skipped') &&
      (needs.platform.result == 'success' || needs.platform.result == 'skipped') &&
      (needs.wasm.result == 'success' || needs.wasm.result == 'skipped') &&
      (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch')
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: stable

      - name: Cache dependencies
        uses: Swatinem/rust-cache@v2

      - name: Verify package can be built
        run: cargo build --release

      - name: Verify package can be packaged
        run: cargo package --allow-dirty

      - name: Check if CARGO_REGISTRY_TOKEN is available
        run: |
          if [ -z "${{ secrets.CARGO_REGISTRY_TOKEN }}" ]; then
            echo "CARGO_REGISTRY_TOKEN secret is not set in repository settings"
            exit 1
          fi

      - name: Publish to crates.io
        run: cargo publish --allow-dirty --token ${{ secrets.CARGO_REGISTRY_TOKEN }}
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

      # `dig-keystore-hardware` depends on `dig-keystore` by VERSION (the `path` is
      # stripped on publish), so it cannot be published until the version just pushed
      # is resolvable from the index. The index lags the publish by seconds to minutes,
      # and a `cargo publish` that runs too early fails with what reads exactly like
      # "that version does not exist" — so this waits rather than guessing.
      #
      # The `User-Agent` header is REQUIRED: without it crates.io answers with
      # something indistinguishable from "not published", which would make this loop
      # time out on a perfectly good release.
      - name: Wait for dig-keystore to appear in the crates.io index
        run: |
          set -euo pipefail
          VERSION=$(python3 -c 'import tomllib;print(tomllib.load(open("Cargo.toml","rb"))["package"]["version"])')
          for attempt in $(seq 1 60); do
            if curl -sfH 'User-Agent: dig-keystore-ci'                  https://index.crates.io/di/g-/dig-keystore                | grep -q "\"vers\":\"${VERSION}\""; then
              echo "dig-keystore ${VERSION} is resolvable"
              exit 0
            fi
            echo "attempt ${attempt}: dig-keystore ${VERSION} not in the index yet"
            sleep 10
          done
          echo "dig-keystore ${VERSION} never appeared in the index" >&2
          exit 1

      - name: Publish dig-keystore-hardware to crates.io
        working-directory: hardware
        run: cargo publish --allow-dirty --token ${{ secrets.CARGO_REGISTRY_TOKEN }}
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

  create-release:
    name: Create GitHub Release
    runs-on: ubuntu-latest
    needs: publish
    if: startsWith(github.ref, 'refs/tags/v')
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Extract version from tag
        id: extract_version
        run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT

      - name: Create GitHub Release
        uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
        with:
          tag_name: ${{ github.ref }}
          release_name: "dig-keystore v${{ steps.extract_version.outputs.VERSION }}"
          body: |
            ## dig-keystore v${{ steps.extract_version.outputs.VERSION }}

            Encrypted secret-key storage for DIG Network binaries (BLS signing + L1 wallet keys):
            typed per-scheme magic files, AES-256-GCM + Argon2id, zeroizing memory hygiene and
            filesystem-atomic writes. The single audit surface for where validator and wallet
            keys live.

            ### Installation
            ```toml
            [dependencies]
            dig-keystore = "${{ steps.extract_version.outputs.VERSION }}"
            ```
          draft: false
          prerelease: false