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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,14 @@ jobs:
- uses: rustsec/audit-check@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}

deny:
name: Supply chain (cargo-deny)
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check advisories bans licenses sources
106 changes: 106 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
name: Release

# Build portable release binaries and publish them to a GitHub Release
# whenever a v* tag is pushed. Each artifact is accompanied by a SHA256
# checksum so downstream installers (Homebrew formula, install.sh) can verify
# integrity.
on:
push:
tags:
- "v*"

permissions:
contents: write

env:
CARGO_TERM_COLOR: always
# The repo's .cargo/config.toml pins `target-cpu=native`, which would bake
# the CI runner's CPU features into the binary and break on older user
# machines. Force a portable baseline for distributable artifacts.
RUSTFLAGS: "-C target-cpu=generic"

jobs:
build:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
- os: macos-latest
target: x86_64-apple-darwin
- os: macos-latest
target: aarch64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}

- name: Install musl tools
if: matrix.target == 'x86_64-unknown-linux-musl'
run: sudo apt-get update && sudo apt-get install -y musl-tools

- name: Build release binary
run: cargo build --release --locked --target ${{ matrix.target }}

- name: Package (unix)
if: matrix.os != 'windows-latest'
shell: bash
run: |
set -euo pipefail
bin="target/${{ matrix.target }}/release/dota"
name="dota-${{ github.ref_name }}-${{ matrix.target }}"
mkdir -p "dist/$name"
cp "$bin" "dist/$name/"
cp README.md LICENSE "dist/$name/"
tar -C dist -czf "dist/$name.tar.gz" "$name"
( cd dist && shasum -a 256 "$name.tar.gz" > "$name.tar.gz.sha256" )

- name: Package (windows)
if: matrix.os == 'windows-latest'
shell: bash
run: |
set -euo pipefail
bin="target/${{ matrix.target }}/release/dota.exe"
name="dota-${{ github.ref_name }}-${{ matrix.target }}"
mkdir -p "dist/$name"
cp "$bin" "dist/$name/"
cp README.md LICENSE "dist/$name/"
( cd dist && 7z a "$name.zip" "$name" >/dev/null )
( cd dist && certutil -hashfile "$name.zip" SHA256 > "$name.zip.sha256" )

- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dota-${{ matrix.target }}
path: |
dist/*.tar.gz
dist/*.zip
dist/*.sha256
retention-days: 7

release:
name: Publish GitHub Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Publish
uses: softprops/action-gh-release@v2
with:
files: artifacts/**/*
generate_release_notes: true
fail_on_unmatched_files: true
14 changes: 14 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ edition = "2024"
authors = ["zack"]
description = "Defense of the Artifacts - Post-quantum secure secrets manager with v7 TC-HKEM (ML-KEM-768 + X25519) vaults"
license = "MIT"
repository = "https://github.com/johnzfitch/dota"
readme = "README.md"
keywords = ["cryptography", "post-quantum", "secrets", "ml-kem", "vault"]
categories = ["command-line-utilities", "cryptography", "authentication"]
# Keep the published crate lean: the paper media, the historical v7 source
# snapshot/patch, and the audit doc are not needed to build or use the binary.
exclude = [
"dotav7-paper/",
"dotav7/",
"SECURITY-AUDIT.md",
".github/",
".claude/",
".cargo/",
]

[features]
default = ["legacy-migration"]
Expand Down
105 changes: 105 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# cargo-deny configuration for dota.
#
# Supplements the existing `rustsec/audit-check` CI job with three guarantees
# that matter for a security tool:
# 1. Licenses are limited to a vetted permissive allowlist.
# 2. The core cryptographic crates may never appear at two different
# versions in the dependency graph (a duplicate `ml-kem`/`aes-gcm`/etc.
# would mean secrets could be processed by an unaudited copy).
# 3. Crates may only come from crates.io -- no git or unknown registries.
#
# Run locally with: cargo deny check

[graph]
all-features = true

[advisories]
# RUSTSEC advisory database. Vulnerabilities, unmaintained, and unsound
# advisories are denied by default (cargo-deny v2); the ignores below are the
# explicitly-accepted exceptions, each justified. This extends the existing
# `rustsec/audit-check` job (which only hard-fails on vulnerabilities and
# reads `.cargo/audit.toml`).
version = 2
# A genuinely-yanked *direct* dependency is worth a hard stop, but the only
# yanked crate in our tree (digest 0.11.1) is pulled in transitively by the
# pinned `ml-kem 0.3.2`, whose byte layout is part of the v7 on-disk contract
# and therefore cannot be bumped here. Warn rather than block CI on a pin we
# do not control.
yanked = "warn"
ignore = [
# --- Legacy v1-v5 Kyber migration path only (feature = "legacy-migration").
# The pqcrypto-* ecosystem wraps PQClean, which upstream is archiving. v6+
# vaults use the actively-maintained `ml-kem` crate; these deps exist only
# to read forward pre-v6 vaults. Migration to `pqcrypto-mlkem` is tracked.
{ id = "RUSTSEC-2024-0381", reason = "pqcrypto-kyber: legacy-migration only; v6+ uses ml-kem" },
{ id = "RUSTSEC-2026-0162", reason = "pqcrypto-traits unmaintained: legacy-migration path only" },
{ id = "RUSTSEC-2026-0163", reason = "pqcrypto-internals unmaintained: legacy-migration path only" },
# --- rand 0.8.5 unsoundness requires a custom `log` logger that calls
# `thread_rng()` and reseeds mid-log. dota uses `OsRng`/getrandom directly
# and defines no such logger, so the unsound path is unreachable. A patch
# bump to rand >=0.8.6 is a tracked follow-up.
{ id = "RUSTSEC-2026-0097", reason = "rand thread_rng+log unsoundness; dota uses OsRng, not thread_rng" },
]

[licenses]
# Vetted permissive licenses. Every expression in the current tree resolves
# to one (or a permitted AND-combination) of these.
allow = [
"MIT",
"MIT-0",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-1-Clause",
"BSD-2-Clause",
"BSD-3-Clause",
"BSL-1.0",
"CC0-1.0",
"ISC",
"Zlib",
"Unlicense",
"Unicode-3.0",
"MPL-2.0",
]
confidence-threshold = 0.9
version = 2

[bans]
# Transitive duplicate versions are common and not inherently dangerous, so
# warn globally -- but hard-deny duplicates of the crypto crates below, where
# a second version would silently widen the trusted-code surface.
multiple-versions = "warn"
wildcards = "deny"

# Core cryptographic dependencies: exactly one version each, always.
[[bans.deny]]
name = "ml-kem"
deny-multiple-versions = true

[[bans.deny]]
name = "x25519-dalek"
deny-multiple-versions = true

[[bans.deny]]
name = "aes-gcm"
deny-multiple-versions = true

[[bans.deny]]
name = "argon2"
deny-multiple-versions = true

[[bans.deny]]
name = "sha2"
deny-multiple-versions = true

[[bans.deny]]
name = "hmac"
deny-multiple-versions = true

[[bans.deny]]
name = "hkdf"
deny-multiple-versions = true

[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
88 changes: 88 additions & 0 deletions tests/argon2_dos.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//! Regression: a hostile vault that asks for an enormous Argon2 memory cost
//! must be rejected by `validate_kdf_params` BEFORE the KDF runs, so a
//! planted vault cannot turn an unlock attempt into a multi-gigabyte
//! allocation / DoS. `validate_v7_vault` runs `validate_kdf_params` first in
//! the v7 unlock path, so the failure must be the validation bail -- which
//! structurally proves Argon2 never executed.

use dota::vault::ops::{create_vault, unlock_vault};
use serde_json::Value;
use std::fs;
use std::time::Instant;
use tempfile::tempdir;

const PASS: &str = "correct horse battery staple";

/// Tamper one KDF field on a real v7 vault and assert unlock fails with the
/// expected validation message, fast (no Argon2 execution).
fn assert_rejected_fast(tag: &str, field: &str, value: Value, expect_substr: &str) {
let dir = tempdir().unwrap();
let base = dir.path().join("base.json");
create_vault(PASS, base.to_str().unwrap()).unwrap();

let mut doc: Value = serde_json::from_str(&fs::read_to_string(&base).unwrap()).unwrap();
doc["kdf"][field] = value;

let hostile = dir.path().join(format!("hostile_{tag}.json"));
fs::write(&hostile, serde_json::to_string_pretty(&doc).unwrap()).unwrap();

let start = Instant::now();
let err = unlock_vault(PASS, hostile.to_str().unwrap())
.err()
.unwrap_or_else(|| panic!("hostile KDF '{tag}' must be rejected"));
let elapsed = start.elapsed();

let msg = format!("{err:#}");
assert!(
msg.contains(expect_substr),
"expected '{expect_substr}' for '{tag}', got: {msg}"
);
// The legitimate Argon2 (64 MiB, 3 passes) takes ~1s; a pre-KDF
// validation bail returns in milliseconds. A generous ceiling keeps the
// assertion meaningful without being flaky on slow CI.
assert!(
elapsed.as_secs() < 1,
"rejection for '{tag}' took {elapsed:?}; validation should bail before Argon2 runs"
);
}

#[test]
fn excessive_memory_cost_is_rejected_before_argon2() {
// 1 GiB requested (memory_cost is in KiB) -- far above the 256 MiB ceiling.
assert_rejected_fast(
"memory",
"memory_cost",
Value::from(1_048_576u32),
"Invalid Argon2 memory cost",
);
}

#[test]
fn excessive_time_cost_is_rejected_before_argon2() {
assert_rejected_fast(
"time",
"time_cost",
Value::from(1_000u32),
"Invalid Argon2 time cost",
);
}

#[test]
fn excessive_parallelism_is_rejected_before_argon2() {
assert_rejected_fast(
"parallelism",
"parallelism",
Value::from(1_000u32),
"Invalid Argon2 parallelism",
);
}

#[test]
fn unknown_kdf_algorithm_is_rejected_before_argon2() {
assert_rejected_fast(
"algorithm",
"algorithm",
Value::from("scrypt"),
"Unsupported KDF algorithm",
);
}
55 changes: 55 additions & 0 deletions tests/downgrade_rejected.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//! Regression: anti-rollback. A genuine v7 vault whose `version` field is
//! rewritten to claim an older format must NOT be silently accepted on the
//! legacy migration path. The v7 commitment covers `version`, so the
//! downgrade either fails the legacy parse/migration or trips the commitment
//! -- it must never unlock as if it were the claimed older version.

use dota::vault::ops::{create_vault, unlock_vault};
use serde_json::Value;
use std::fs;
use tempfile::tempdir;

const PASS: &str = "correct horse battery staple";

fn assert_downgrade_rejected(claimed_version: u32) {
let dir = tempdir().unwrap();
let base = dir.path().join("base.json");
create_vault(PASS, base.to_str().unwrap()).unwrap();

let mut doc: Value = serde_json::from_str(&fs::read_to_string(&base).unwrap()).unwrap();
// Keep the entire v7 body intact; only lie about the version number.
doc["version"] = Value::from(claimed_version);

let forged = dir.path().join("forged.json");
fs::write(&forged, serde_json::to_string_pretty(&doc).unwrap()).unwrap();

let err = unlock_vault(PASS, forged.to_str().unwrap())
.err()
.unwrap_or_else(|| panic!("v7 body claiming version {claimed_version} must be rejected"));
let msg = format!("{err:#}").to_lowercase();
assert!(
msg.contains("commitment")
|| msg.contains("tamper")
|| msg.contains("mismatch")
|| msg.contains("decrypt")
|| msg.contains("parse")
|| msg.contains("invalid")
|| msg.contains("unsupported"),
"downgrade to v{claimed_version} rejected, but message is unexpected: {msg}"
);
}

#[test]
fn v7_body_claiming_v6_is_rejected() {
assert_downgrade_rejected(6);
}

#[test]
fn v7_body_claiming_v5_is_rejected() {
assert_downgrade_rejected(5);
}

#[test]
fn v7_body_claiming_v1_is_rejected() {
assert_downgrade_rejected(1);
}
Loading
Loading