diff --git a/.changeset/calm-dingos-hide.md b/.changeset/calm-dingos-hide.md new file mode 100644 index 0000000..95a622d --- /dev/null +++ b/.changeset/calm-dingos-hide.md @@ -0,0 +1,6 @@ +--- +'@inflowpayai/inflow': patch +--- + +Use the latest AEP libraries and keep the internal user profile command out of the CLI, MCP, and agent documentation +surfaces. diff --git a/.changeset/clear-aep-status.md b/.changeset/clear-aep-status.md new file mode 100644 index 0000000..47ad3b4 --- /dev/null +++ b/.changeset/clear-aep-status.md @@ -0,0 +1,6 @@ +--- +'@inflowpayai/inflow': patch +--- + +Report locally stored active session credentials in AEP Status output and return an actionable not-enrolled error when +the Service no longer recognizes the locally stored Agent identity. diff --git a/.changeset/friendly-vault-launcher.md b/.changeset/friendly-vault-launcher.md new file mode 100644 index 0000000..1f9576c --- /dev/null +++ b/.changeset/friendly-vault-launcher.md @@ -0,0 +1,5 @@ +--- +'@inflowpayai/inflow': patch +--- + +Recognize the packaged command-line symlink as the same executable as its signed local vault daemon. diff --git a/.changeset/friendly-vault-unlock.md b/.changeset/friendly-vault-unlock.md new file mode 100644 index 0000000..cd82e16 --- /dev/null +++ b/.changeset/friendly-vault-unlock.md @@ -0,0 +1,6 @@ +--- +'@inflowpayai/inflow': patch +--- + +Make vault unlock idempotent when the vault is already unlocked and report rejected PINs or passphrases as unlock +failures. diff --git a/.changeset/npm-package-deprecation.md b/.changeset/npm-package-deprecation.md index 6015414..5b8c1c9 100644 --- a/.changeset/npm-package-deprecation.md +++ b/.changeset/npm-package-deprecation.md @@ -6,4 +6,9 @@ Publish the npm package as a compatibility notice that points users and agents t of running commands or managing credentials. Check GitHub Releases for advisory signed-binary updates, surface a one-line human notice, return structured -current/latest version metadata to agents, and send the installed CLI version on InFlow API requests. +current/latest version metadata to agents, bound release checks to a short deadline, provide Homebrew and hosted +installer upgrade guidance, and send the installed CLI version on InFlow API requests. + +Prepare native x64 and ARM64 Windows release payloads for Azure Artifact Signing, sign the executable before building +the MSI, sign the MSI before generating checksums and WinGet manifests, and keep unsigned nonpublishing workflow +validation available without production credentials. diff --git a/.changeset/secure-macos-vault-transitions.md b/.changeset/secure-macos-vault-transitions.md new file mode 100644 index 0000000..81a659a --- /dev/null +++ b/.changeset/secure-macos-vault-transitions.md @@ -0,0 +1,6 @@ +--- +'@inflowpayai/inflow': patch +--- + +Replace incompatible running macOS vault daemons before commands access vault-backed state after an in-place application +update. diff --git a/.changeset/steady-vault-boundaries.md b/.changeset/steady-vault-boundaries.md new file mode 100644 index 0000000..1fcac4b --- /dev/null +++ b/.changeset/steady-vault-boundaries.md @@ -0,0 +1,6 @@ +--- +'@inflowpayai/inflow': patch +--- + +Harden local vault transport limits, secret-reference integrity, locked-state reporting, terminal passphrase handling, +credential cleanup, and signed-build compatibility checks. diff --git a/.changeset/steady-vault-daemon.md b/.changeset/steady-vault-daemon.md new file mode 100644 index 0000000..0d2056d --- /dev/null +++ b/.changeset/steady-vault-daemon.md @@ -0,0 +1,7 @@ +--- +'@inflowpayai/inflow': patch +--- + +Prepare the signed native CLI for local encrypted vault storage by packaging the vault daemon runtime, exposing vault +lifecycle commands, enforcing daemon sleep-lock policy, adding macOS peer-verification plumbing, and wiring native +dependencies. diff --git a/.changeset/strong-tools-arrive.md b/.changeset/strong-tools-arrive.md new file mode 100644 index 0000000..3053834 --- /dev/null +++ b/.changeset/strong-tools-arrive.md @@ -0,0 +1,7 @@ +--- +'@inflowpayai/inflow': minor +--- + +Add self-contained Linux archives, Debian and RPM system-service packages, and a checksummed hosted installer with +encrypted local vault storage, same-executable Unix socket peer verification, and signed APT and RPM repository +metadata. diff --git a/.changeset/tidy-packaged-vault-smoke.md b/.changeset/tidy-packaged-vault-smoke.md new file mode 100644 index 0000000..eef180f --- /dev/null +++ b/.changeset/tidy-packaged-vault-smoke.md @@ -0,0 +1,6 @@ +--- +'@inflowpayai/inflow': patch +--- + +Verify the Developer-ID-signed macOS package across vault initialization, credential persistence, process reuse, +locking, peer rejection, logout, and daemon shutdown. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c6fb61..0d88d26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,17 @@ jobs: - name: Install run: pnpm install --frozen-lockfile + - name: Install Linux packaging tools + run: sudo apt-get update && sudo apt-get install --yes cpio rpm + + - name: Prepare pinned Argon2 source + run: | + node scripts/prepare-argon2-source.mjs "$RUNNER_TEMP/argon2-20190702" + echo "INFLOW_ARGON2_SOURCE_DIR=$RUNNER_TEMP/argon2-20190702" >> "$GITHUB_ENV" + + - name: Build vault peer verifier + run: node scripts/build-vault-peer-native.mjs + - name: Typecheck run: pnpm typecheck @@ -52,6 +63,23 @@ jobs: - name: Build run: pnpm build + - name: Build Linux package + run: sudo env "PATH=$PATH" "INFLOW_ARGON2_SOURCE_DIR=$INFLOW_ARGON2_SOURCE_DIR" pnpm build:linux-package + + - name: Test packaged Linux vault + run: | + sudo rm -rf /opt/inflow-package-smoke + sudo mkdir /opt/inflow-package-smoke + sudo tar -xzf dist/linux/inflow-*-linux-*.tar.gz -C /opt/inflow-package-smoke --strip-components=1 + sudo env "PATH=$PATH" CI=1 INFLOW_PACKAGED_EXECUTABLE=/opt/inflow-package-smoke/bin/inflow \ + pnpm smoke:linux-packaged-vault + + - name: Test Debian system vault + run: | + sudo dpkg -i dist/linux/inflow_*_amd64.deb + sudo swapoff -a + sudo env "PATH=$PATH" CI=1 INFLOW_PACKAGED_EXECUTABLE=/usr/bin/inflow pnpm smoke:linux-system-vault + - name: Publish dry-run if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: pnpm --filter @inflowpayai/inflow publish --dry-run --no-git-checks diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml new file mode 100644 index 0000000..8c21ce7 --- /dev/null +++ b/.github/workflows/linux-release.yml @@ -0,0 +1,529 @@ +name: linux-release + +on: + workflow_dispatch: + inputs: + publish: + description: Upload artifacts to the matching GitHub release + required: true + type: boolean + default: false + verify_production_signing: + description: Verify the protected production signing subkey without publishing + required: true + type: boolean + default: false + tag: + description: Existing GitHub release tag + required: false + type: string + +permissions: + contents: read + +jobs: + validate-inputs: + runs-on: ubuntu-24.04 + steps: + - name: Validate workflow inputs + env: + PUBLISH: ${{ inputs.publish }} + VERIFY_PRODUCTION_SIGNING: ${{ inputs.verify_production_signing }} + run: | + if [ "$PUBLISH" = 'true' ] && [ "$VERIFY_PRODUCTION_SIGNING" = 'true' ]; then + echo "::error::publish and verify_production_signing cannot both be enabled" + exit 1 + fi + if [ "$VERIFY_PRODUCTION_SIGNING" = 'true' ] && [[ "$GITHUB_REF" != refs/tags/v* ]]; then + echo "::error::Production signing verification must run from a v* tag" + exit 1 + fi + + package: + needs: validate-inputs + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + architecture: x64 + - runner: ubuntu-24.04-arm + architecture: arm64 + runs-on: ${{ matrix.runner }} + permissions: + attestations: write + contents: write + id-token: write + steps: + - uses: actions/checkout@v5 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24.15.0 + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Install Linux packaging tools + run: sudo apt-get update && sudo apt-get install --yes cpio rpm + + - name: Prepare pinned Argon2 source + run: | + node scripts/prepare-argon2-source.mjs "$RUNNER_TEMP/argon2-20190702" + echo "INFLOW_ARGON2_SOURCE_DIR=$RUNNER_TEMP/argon2-20190702" >> "$GITHUB_ENV" + + - name: Build Linux package + run: sudo env "PATH=$PATH" "INFLOW_ARGON2_SOURCE_DIR=$INFLOW_ARGON2_SOURCE_DIR" pnpm build:linux-package + + - name: Test packaged vault + run: | + sudo rm -rf /opt/inflow-package-smoke + sudo mkdir /opt/inflow-package-smoke + sudo tar -xzf dist/linux/inflow-*-linux-*.tar.gz -C /opt/inflow-package-smoke --strip-components=1 + sudo env "PATH=$PATH" CI=1 INFLOW_PACKAGED_EXECUTABLE=/opt/inflow-package-smoke/bin/inflow \ + pnpm smoke:linux-packaged-vault + + - name: Test Debian system vault + run: | + sudo env DEBIAN_FRONTEND=noninteractive apt-get install --yes "$PWD"/dist/linux/inflow_*.deb + sudo swapoff -a + sudo env "PATH=$PATH" CI=1 INFLOW_PACKAGED_EXECUTABLE=/usr/bin/inflow pnpm smoke:linux-system-vault + + - name: Attest archive + if: github.event_name != 'pull_request' + uses: actions/attest@v4 + with: + subject-path: | + dist/linux/inflow-*-linux-${{ matrix.architecture }}.tar.gz + dist/linux/*.deb + + - name: Upload workflow artifact + uses: actions/upload-artifact@v6 + with: + name: inflow-linux-${{ matrix.architecture }} + path: | + dist/linux/inflow-*-linux-${{ matrix.architecture }}.tar.gz + dist/linux/inflow-*-linux-${{ matrix.architecture }}.tar.gz.sha256 + dist/linux/*.deb + dist/linux/*.deb.sha256 + dist/linux/*.rpm + dist/linux/*.rpm.sha256 + if-no-files-found: error + + apt-repository: + name: signed APT repository + needs: package + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24.15.0 + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Install APT repository tools + run: sudo apt-get update && sudo apt-get install --yes apt-utils dpkg-dev + + - name: Download Linux packages + uses: actions/download-artifact@v7 + with: + pattern: inflow-linux-* + path: dist/linux/packages + merge-multiple: true + + - name: Create disposable repository signing key + run: | + export GNUPGHOME="$RUNNER_TEMP/inflow-apt-gpg" + mkdir --mode=0700 "$GNUPGHOME" + gpg --batch --passphrase '' --quick-gen-key \ + 'InFlow Linux CI Signing ' rsa3072 sign 1d + key_id="$(gpg --batch --with-colons --list-secret-keys | awk -F: '$1 == "sec" { print $5; exit }')" + test -n "$key_id" + echo "GNUPGHOME=$GNUPGHOME" >> "$GITHUB_ENV" + echo "INFLOW_LINUX_SIGNING_KEY_ID=$key_id" >> "$GITHUB_ENV" + + - name: Build signed APT repository + run: pnpm build:linux-apt-repository + + - name: Verify APT repository signature + run: | + gpgv --keyring dist/linux/apt/inflow-archive-keyring.gpg \ + dist/linux/apt/dists/stable/InRelease + cp dist/linux/apt/inflow-archive-keyring.gpg "$RUNNER_TEMP/inflow-archive-keyring.gpg" + mkdir "$RUNNER_TEMP/inflow-apt-state" "$RUNNER_TEMP/inflow-apt-cache" + printf 'deb [arch=amd64 signed-by=%s] file:%s stable main\n' \ + "$RUNNER_TEMP/inflow-archive-keyring.gpg" "$PWD/dist/linux/apt" \ + > "$RUNNER_TEMP/inflow.list" + sudo apt-get \ + -o "Dir::Etc::sourcelist=$RUNNER_TEMP/inflow.list" \ + -o "Dir::Etc::sourceparts=-" \ + -o "Dir::State=$RUNNER_TEMP/inflow-apt-state" \ + -o "Dir::Cache=$RUNNER_TEMP/inflow-apt-cache" \ + update + + - name: Reject a modified APT repository + run: | + printf '\n' >> dist/linux/apt/dists/stable/Release + if gpgv --keyring dist/linux/apt/inflow-archive-keyring.gpg \ + dist/linux/apt/dists/stable/Release.gpg \ + dist/linux/apt/dists/stable/Release; then + echo "::error::The modified APT repository passed signature verification" + exit 1 + fi + + - name: Upload APT repository + uses: actions/upload-artifact@v6 + with: + name: inflow-linux-apt-repository + path: dist/linux/apt + if-no-files-found: error + + rpm-repository: + name: signed RPM repository + needs: package + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24.15.0 + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Install RPM repository tools + run: sudo apt-get update && sudo apt-get install --yes createrepo-c gnupg rpm + + - name: Download Linux packages + uses: actions/download-artifact@v7 + with: + pattern: inflow-linux-* + path: dist/linux/packages + merge-multiple: true + + - name: Create disposable repository signing key + run: | + export GNUPGHOME="$RUNNER_TEMP/inflow-rpm-gpg" + mkdir --mode=0700 "$GNUPGHOME" + gpg --batch --passphrase '' --quick-gen-key \ + 'InFlow Linux CI Signing ' rsa3072 sign 1d + key_id="$(gpg --batch --with-colons --list-secret-keys | awk -F: '$1 == "sec" { print $5; exit }')" + test -n "$key_id" + echo "GNUPGHOME=$GNUPGHOME" >> "$GITHUB_ENV" + echo "INFLOW_LINUX_SIGNING_KEY_ID=$key_id" >> "$GITHUB_ENV" + + - name: Build signed RPM repository + run: pnpm build:linux-rpm-repository + + - name: Verify RPM package and repository signatures + run: | + sudo rpmkeys --import dist/linux/rpm/inflow-signing-key.asc + sudo rpmkeys --checksig dist/linux/rpm/packages/*.rpm + gpgv --keyring dist/linux/rpm/inflow-signing-key.gpg \ + dist/linux/rpm/repodata/repomd.xml.asc \ + dist/linux/rpm/repodata/repomd.xml + + - name: Reject modified RPM package and repository metadata + run: | + cp dist/linux/rpm/packages/*."$(uname -m)".rpm "$RUNNER_TEMP/tampered.rpm" + package_size="$(stat --format=%s "$RUNNER_TEMP/tampered.rpm")" + printf 'X' | dd of="$RUNNER_TEMP/tampered.rpm" bs=1 \ + seek="$((package_size - 4096))" conv=notrunc status=none + if sudo rpmkeys --checksig "$RUNNER_TEMP/tampered.rpm"; then + echo "::error::The modified RPM package passed signature verification" + exit 1 + fi + cp dist/linux/rpm/repodata/repomd.xml "$RUNNER_TEMP/repomd.xml" + printf '\n' >> "$RUNNER_TEMP/repomd.xml" + if gpgv --keyring dist/linux/rpm/inflow-signing-key.gpg \ + dist/linux/rpm/repodata/repomd.xml.asc \ + "$RUNNER_TEMP/repomd.xml"; then + echo "::error::The modified RPM repository passed signature verification" + exit 1 + fi + + - name: Install from signed RPM repository + run: | + docker run --rm \ + --volume "$PWD/dist/linux/rpm:/repository:ro" \ + fedora:42 \ + sh -c ' + printf "%s\n" \ + "[inflow]" \ + "name=InFlow" \ + "baseurl=file:///repository" \ + "enabled=1" \ + "gpgcheck=1" \ + "repo_gpgcheck=1" \ + "gpgkey=file:///repository/inflow-signing-key.asc" \ + > /etc/yum.repos.d/inflow.repo + dnf --assumeyes --disablerepo="*" --enablerepo=inflow install inflow + inflow --version + dnf --assumeyes remove inflow + ' + + - name: Upload RPM repository + uses: actions/upload-artifact@v6 + with: + name: inflow-linux-rpm-repository + path: dist/linux/rpm + if-no-files-found: error + + release-dry-run: + name: signed GitHub Release dry run + if: github.event_name == 'pull_request' || (!inputs.publish && !inputs.verify_production_signing) + needs: package + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24.15.0 + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Install signing tools + run: sudo apt-get update && sudo apt-get install --yes gnupg rpm + + - name: Download Linux packages + uses: actions/download-artifact@v7 + with: + pattern: inflow-linux-* + path: dist/linux/release + merge-multiple: true + + - name: Create disposable release signing key + run: | + export GNUPGHOME="$RUNNER_TEMP/inflow-release-gpg" + mkdir --mode=0700 "$GNUPGHOME" + gpg --batch --passphrase '' --quick-gen-key \ + 'InFlow Linux CI Signing ' rsa3072 sign 1d + fingerprint="$(gpg --batch --with-colons --fingerprint | awk -F: '$1 == "fpr" { print $10; exit }')" + test -n "$fingerprint" + echo "GNUPGHOME=$GNUPGHOME" >> "$GITHUB_ENV" + echo "INFLOW_LINUX_SIGNING_FINGERPRINT=$fingerprint" >> "$GITHUB_ENV" + echo "INFLOW_LINUX_SIGNING_KEY_ID=$fingerprint" >> "$GITHUB_ENV" + + - name: Sign release and render installer + run: | + pnpm build:linux-release-signatures + pnpm build:linux-installer dist/linux/release/install.sh + + - name: Verify signed release + run: | + gpg --batch --yes --dearmor \ + --output "$RUNNER_TEMP/inflow-linux-signing-key.gpg" \ + dist/linux/release/inflow-linux-signing-key.asc + gpgv --keyring "$RUNNER_TEMP/inflow-linux-signing-key.gpg" \ + dist/linux/release/SHA256SUMS.asc \ + dist/linux/release/SHA256SUMS + sudo rpmkeys --import dist/linux/release/inflow-linux-signing-key.asc + sudo rpmkeys --checksig dist/linux/release/*.rpm + + - name: Reject modified release metadata and package + run: | + cp -R dist/linux/release "$RUNNER_TEMP/tampered-metadata" + printf '\n' >> "$RUNNER_TEMP/tampered-metadata/SHA256SUMS" + if INFLOW_RELEASE_BASE_URL="file://$RUNNER_TEMP/tampered-metadata" \ + sh "$RUNNER_TEMP/tampered-metadata/install.sh"; then + echo "::error::The Linux installer accepted modified release metadata" + exit 1 + fi + cp -R dist/linux/release "$RUNNER_TEMP/tampered-package" + package="$(find "$RUNNER_TEMP/tampered-package" -maxdepth 1 -name 'inflow_*_amd64.deb' -print -quit)" + test -n "$package" + printf '\n' >> "$package" + if INFLOW_RELEASE_BASE_URL="file://$RUNNER_TEMP/tampered-package" \ + sh "$RUNNER_TEMP/tampered-package/install.sh"; then + echo "::error::The Linux installer accepted a modified package" + exit 1 + fi + + - name: Install from signed release + run: INFLOW_RELEASE_BASE_URL="file://$PWD/dist/linux/release" sh dist/linux/release/install.sh + + - name: Upload signed release dry run + uses: actions/upload-artifact@v6 + with: + name: inflow-linux-signed-release-dry-run + path: dist/linux/release + if-no-files-found: error + + release-production-verification: + name: protected production signing verification + if: github.event_name == 'workflow_dispatch' && inputs.verify_production_signing && !inputs.publish + needs: package + runs-on: ubuntu-24.04 + environment: linux-production + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v6 + with: + node-version: 24.15.0 + + - name: Install signing tools + run: sudo apt-get update && sudo apt-get install --yes gnupg rpm + + - name: Download Linux packages + uses: actions/download-artifact@v7 + with: + pattern: inflow-linux-* + path: dist/linux/release + merge-multiple: true + + - name: Import and verify production signing subkey + env: + EXPECTED_FINGERPRINT: ${{ vars.LINUX_OPENPGP_SIGNING_FINGERPRINT }} + SIGNING_SUBKEY_BASE64: ${{ secrets.LINUX_OPENPGP_SIGNING_SUBKEY_BASE64 }} + run: | + export GNUPGHOME="$RUNNER_TEMP/inflow-release-gpg" + mkdir --mode=0700 "$GNUPGHOME" + test -n "$EXPECTED_FINGERPRINT" + test -n "$SIGNING_SUBKEY_BASE64" + printf '%s' "$SIGNING_SUBKEY_BASE64" | base64 --decode | gpg --batch --import + node scripts/verify-linux-automation-key.mjs "$EXPECTED_FINGERPRINT" + echo "GNUPGHOME=$GNUPGHOME" >> "$GITHUB_ENV" + echo "INFLOW_LINUX_SIGNING_FINGERPRINT=$EXPECTED_FINGERPRINT" >> "$GITHUB_ENV" + echo "INFLOW_LINUX_SIGNING_KEY_ID=$EXPECTED_FINGERPRINT" >> "$GITHUB_ENV" + + - name: Sign release and render installer + run: | + node scripts/sign-linux-release.mjs + node scripts/render-linux-installer.mjs dist/linux/release/install.sh + + - name: Verify signed release + run: | + gpg --batch --yes --dearmor \ + --output "$RUNNER_TEMP/inflow-linux-signing-key.gpg" \ + dist/linux/release/inflow-linux-signing-key.asc + gpgv --keyring "$RUNNER_TEMP/inflow-linux-signing-key.gpg" \ + dist/linux/release/SHA256SUMS.asc \ + dist/linux/release/SHA256SUMS + sudo rpmkeys --import dist/linux/release/inflow-linux-signing-key.asc + sudo rpmkeys --checksig dist/linux/release/*.rpm + INFLOW_RELEASE_BASE_URL="file://$PWD/dist/linux/release" \ + sh dist/linux/release/install.sh + + - name: Remove temporary signing material + if: always() + run: | + rm -rf "$RUNNER_TEMP/inflow-release-gpg" + rm -f "$RUNNER_TEMP/inflow-linux-signing-key.gpg" + + release-production: + name: signed GitHub Release + if: github.event_name == 'workflow_dispatch' && inputs.publish && !inputs.verify_production_signing + needs: + - apt-repository + - package + - rpm-repository + runs-on: ubuntu-24.04 + environment: linux-production + permissions: + attestations: write + contents: write + id-token: write + steps: + - uses: actions/checkout@v5 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24.15.0 + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Install signing tools + run: sudo apt-get update && sudo apt-get install --yes gnupg rpm + + - name: Download Linux packages + uses: actions/download-artifact@v7 + with: + pattern: inflow-linux-* + path: dist/linux/release + merge-multiple: true + + - name: Import production signing subkey + env: + SIGNING_SUBKEY_BASE64: ${{ secrets.LINUX_OPENPGP_SIGNING_SUBKEY_BASE64 }} + run: | + export GNUPGHOME="$RUNNER_TEMP/inflow-release-gpg" + mkdir --mode=0700 "$GNUPGHOME" + printf '%s' "$SIGNING_SUBKEY_BASE64" | base64 --decode | gpg --batch --import + fingerprint="${{ vars.LINUX_OPENPGP_SIGNING_FINGERPRINT }}" + test -n "$fingerprint" + node scripts/verify-linux-automation-key.mjs "$fingerprint" + echo "GNUPGHOME=$GNUPGHOME" >> "$GITHUB_ENV" + echo "INFLOW_LINUX_SIGNING_FINGERPRINT=$fingerprint" >> "$GITHUB_ENV" + echo "INFLOW_LINUX_SIGNING_KEY_ID=$fingerprint" >> "$GITHUB_ENV" + + - name: Sign release and render installer + run: | + pnpm build:linux-release-signatures + pnpm build:linux-installer dist/linux/release/install.sh + + - name: Verify production release + run: | + expected_tag="v$(node -p "require('./packages/cli/package.json').version")" + test "${{ inputs.tag }}" = "$expected_tag" + gh release view "${{ inputs.tag }}" + gpg --batch --yes --dearmor \ + --output "$RUNNER_TEMP/inflow-linux-signing-key.gpg" \ + dist/linux/release/inflow-linux-signing-key.asc + gpgv --keyring "$RUNNER_TEMP/inflow-linux-signing-key.gpg" \ + dist/linux/release/SHA256SUMS.asc \ + dist/linux/release/SHA256SUMS + sudo rpmkeys --import dist/linux/release/inflow-linux-signing-key.asc + sudo rpmkeys --checksig dist/linux/release/*.rpm + env: + GH_TOKEN: ${{ github.token }} + + - name: Attest release assets + uses: actions/attest@v4 + with: + subject-path: | + dist/linux/release/*.deb + dist/linux/release/*.rpm + dist/linux/release/*.tar.gz + dist/linux/release/SHA256SUMS + + - name: Upload production release assets + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${{ inputs.tag }}" \ + dist/linux/release/*.deb \ + dist/linux/release/*.deb.sha256 \ + dist/linux/release/*.rpm \ + dist/linux/release/*.rpm.sha256 \ + dist/linux/release/*.tar.gz \ + dist/linux/release/*.tar.gz.sha256 \ + dist/linux/release/SHA256SUMS \ + dist/linux/release/SHA256SUMS.asc \ + dist/linux/release/inflow-linux-signing-key.asc \ + dist/linux/release/install.sh \ + --clobber diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml new file mode 100644 index 0000000..48d617f --- /dev/null +++ b/.github/workflows/windows-release.yml @@ -0,0 +1,394 @@ +name: windows release + +on: + workflow_dispatch: + inputs: + sign: + description: Sign with the production Azure Artifact Signing profile. + required: true + type: boolean + default: false + publish: + description: Upload signed artifacts to the matching GitHub release. + required: true + type: boolean + default: false + tag: + description: Existing GitHub release tag. Required when publishing. + required: false + type: string + +concurrency: + group: windows-release + cancel-in-progress: false + +permissions: + contents: read + +jobs: + validate-inputs: + runs-on: windows-2025 + steps: + - name: Validate release inputs + shell: pwsh + env: + PUBLISH: ${{ inputs.publish }} + SIGN: ${{ inputs.sign }} + TAG: ${{ inputs.tag }} + run: | + if ($env:PUBLISH -eq 'true' -and $env:SIGN -ne 'true') { + throw 'Publishing requires production signing.' + } + if ($env:PUBLISH -eq 'true' -and [string]::IsNullOrWhiteSpace($env:TAG)) { + throw 'Publishing requires an existing GitHub release tag.' + } + + build-payload: + needs: validate-inputs + strategy: + fail-fast: false + matrix: + include: + - architecture: x64 + runner: windows-2025 + - architecture: arm64 + runner: windows-11-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v5 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24.15.0 + cache: pnpm + + - name: Configure Visual C++ tools + uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 + with: + arch: ${{ matrix.architecture }} + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Prepare pinned native build dependencies + shell: pwsh + run: | + $argon2 = Join-Path $env:RUNNER_TEMP 'argon2-20190702' + node scripts/prepare-argon2-source.mjs $argon2 + + $headersArchive = Join-Path $env:RUNNER_TEMP 'node-v24.15.0-headers.tar.gz' + $headersRoot = Join-Path $env:RUNNER_TEMP 'node-headers' + Invoke-WebRequest ` + -Uri 'https://nodejs.org/dist/v24.15.0/node-v24.15.0-headers.tar.gz' ` + -OutFile $headersArchive + $headersSha256 = (Get-FileHash -Algorithm SHA256 $headersArchive).Hash.ToLowerInvariant() + if ($headersSha256 -ne '9a9250039fef572d0bcc110ac5e78055faf5604421211d2d5f578ebc5a34e703') { + throw "Node header checksum mismatch: $headersSha256" + } + New-Item -ItemType Directory -Force $headersRoot | Out-Null + tar -xzf $headersArchive -C $headersRoot + + "INFLOW_ARGON2_SOURCE_DIR=$argon2" | Out-File -FilePath $env:GITHUB_ENV -Append + "INFLOW_NODE_INCLUDE_DIR=$(Join-Path $headersRoot 'node-v24.15.0/include/node')" | + Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Prepare unsigned executable payload + run: pnpm build:windows-external-prepare + + - name: Verify executable architecture + shell: pwsh + env: + EXPECTED_ARCHITECTURE: ${{ matrix.architecture }} + run: | + $headers = (& dumpbin.exe /headers dist/windows/payload/inflow.exe) -join "`n" + $expected = if ($env:EXPECTED_ARCHITECTURE -eq 'arm64') { 'AA64 machine' } else { '8664 machine' } + if ($headers -notmatch [regex]::Escape($expected)) { + throw "The executable does not target $($env:EXPECTED_ARCHITECTURE)." + } + + - name: Upload executable payload + uses: actions/upload-artifact@v6 + with: + name: inflow-windows-payload-${{ matrix.architecture }} + path: dist/windows/payload + if-no-files-found: error + + package-dry-run: + if: github.event_name == 'pull_request' || inputs.sign == false + needs: build-payload + strategy: + fail-fast: false + matrix: + architecture: + - x64 + - arm64 + runs-on: windows-2025 + steps: + - uses: actions/checkout@v5 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24.15.0 + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Install WiX + run: dotnet tool install --global wix --version 5.0.2 + + - name: Download executable payload + uses: actions/download-artifact@v7 + with: + name: inflow-windows-payload-${{ matrix.architecture }} + path: dist/windows/payload + + - name: Build unsigned MSI + env: + INFLOW_WINDOWS_TARGET_ARCH: ${{ matrix.architecture }} + run: node scripts/build-windows-package.mjs --build-msi-from-prepared-payload + + - name: Render unsigned release metadata + env: + INFLOW_WINDOWS_TARGET_ARCH: ${{ matrix.architecture }} + run: node scripts/build-windows-package.mjs --write-unsigned-release-metadata + + - name: Verify MSI architecture + shell: pwsh + run: | + $msi = Get-ChildItem dist/windows/inflow-*-windows-${{ matrix.architecture }}.msi + if ($msi.Count -ne 1) { + throw 'Expected exactly one MSI.' + } + + - name: Upload dry-run package + uses: actions/upload-artifact@v6 + with: + name: inflow-windows-dry-run-${{ matrix.architecture }} + path: | + dist/windows/inflow-*-windows-${{ matrix.architecture }}.msi + dist/windows/inflow-*-windows-${{ matrix.architecture }}.msi.sha256 + dist/windows/manifest.json + if-no-files-found: error + + package-signed: + if: github.event_name == 'workflow_dispatch' && inputs.sign + needs: build-payload + runs-on: windows-2025 + environment: windows-production + permissions: + contents: write + id-token: write + strategy: + fail-fast: false + matrix: + architecture: + - x64 + - arm64 + steps: + - uses: actions/checkout@v5 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24.15.0 + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Install WiX + run: dotnet tool install --global wix --version 5.0.2 + + - name: Locate SignTool + shell: pwsh + run: | + $signTool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" | + Sort-Object FullName -Descending | + Select-Object -First 1 + if ($null -eq $signTool) { + throw 'SignTool is unavailable.' + } + "INFLOW_SIGNTOOL_PATH=$($signTool.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Download executable payload + uses: actions/download-artifact@v7 + with: + name: inflow-windows-payload-${{ matrix.architecture }} + path: dist/windows/payload + + - name: Authenticate to Azure + uses: azure/login@v3 + with: + client-id: ${{ vars.AZURE_ARTIFACT_SIGNING_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_ARTIFACT_SIGNING_TENANT_ID }} + subscription-id: ${{ vars.AZURE_ARTIFACT_SIGNING_SUBSCRIPTION_ID }} + + - name: Sign executable + uses: azure/artifact-signing-action@v2 + with: + endpoint: ${{ vars.AZURE_ARTIFACT_SIGNING_ENDPOINT }} + signing-account-name: ${{ vars.AZURE_ARTIFACT_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ vars.AZURE_ARTIFACT_SIGNING_PROFILE }} + files: ${{ github.workspace }}\dist\windows\payload\inflow.exe + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Build MSI from signed executable + env: + INFLOW_WINDOWS_TARGET_ARCH: ${{ matrix.architecture }} + run: pnpm build:windows-external-msi + + - name: Sign MSI + uses: azure/artifact-signing-action@v2 + with: + endpoint: ${{ vars.AZURE_ARTIFACT_SIGNING_ENDPOINT }} + signing-account-name: ${{ vars.AZURE_ARTIFACT_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ vars.AZURE_ARTIFACT_SIGNING_PROFILE }} + files-folder: ${{ github.workspace }}\dist\windows + files-folder-filter: inflow-*-windows-${{ matrix.architecture }}.msi + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Render signed release metadata + env: + INFLOW_WINDOWS_SIGNING_SUBJECT: ${{ vars.AZURE_ARTIFACT_SIGNING_SUBJECT }} + INFLOW_WINDOWS_TARGET_ARCH: ${{ matrix.architecture }} + run: pnpm build:windows-external-metadata + + - name: Verify signed artifacts + shell: pwsh + env: + EXPECTED_SUBJECT: ${{ vars.AZURE_ARTIFACT_SIGNING_SUBJECT }} + run: | + $files = @( + 'dist/windows/payload/inflow.exe', + (Get-ChildItem dist/windows/inflow-*-windows-${{ matrix.architecture }}.msi).FullName + ) + foreach ($file in $files) { + $signature = Get-AuthenticodeSignature -LiteralPath $file + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { + throw "Invalid Authenticode signature for ${file}: $($signature.StatusMessage)" + } + if ($signature.SignerCertificate.Subject -ne $env:EXPECTED_SUBJECT) { + throw "Unexpected Authenticode subject for ${file}: $($signature.SignerCertificate.Subject)" + } + if ($null -eq $signature.TimeStamperCertificate) { + throw "Missing trusted timestamp for ${file}." + } + } + + - name: Upload signed package + uses: actions/upload-artifact@v6 + with: + name: inflow-windows-signed-${{ matrix.architecture }} + path: | + dist/windows/inflow-*-windows-${{ matrix.architecture }}.msi + dist/windows/inflow-*-windows-${{ matrix.architecture }}.msi.sha256 + dist/windows/manifest.json + if-no-files-found: error + + aggregate-dry-run: + if: github.event_name == 'pull_request' || inputs.sign == false + needs: package-dry-run + runs-on: windows-2025 + steps: + - uses: actions/checkout@v5 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24.15.0 + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Download dry-run packages + uses: actions/download-artifact@v7 + with: + pattern: inflow-windows-dry-run-* + path: dist/windows + merge-multiple: true + + - name: Render combined release metadata + env: + INFLOW_WINDOWS_SIGNING_SUBJECT: CN=Unsigned InFlow Dry Run + run: pnpm build:windows-release-metadata + + - name: Upload combined dry-run release + uses: actions/upload-artifact@v6 + with: + name: inflow-windows-dry-run-release + path: | + dist/windows/inflow-*-windows-*.msi + dist/windows/inflow-*-windows-*.msi.sha256 + dist/windows/install.ps1 + dist/windows/winget + if-no-files-found: error + + aggregate-signed: + if: github.event_name == 'workflow_dispatch' && inputs.sign + needs: package-signed + runs-on: windows-2025 + environment: windows-production + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24.15.0 + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Download signed packages + uses: actions/download-artifact@v7 + with: + pattern: inflow-windows-signed-* + path: dist/windows + merge-multiple: true + + - name: Render combined release metadata + env: + INFLOW_WINDOWS_SIGNING_SUBJECT: ${{ vars.AZURE_ARTIFACT_SIGNING_SUBJECT }} + run: pnpm build:windows-release-metadata + + - name: Upload combined signed release + uses: actions/upload-artifact@v6 + with: + name: inflow-windows-signed-release + path: | + dist/windows/inflow-*-windows-*.msi + dist/windows/inflow-*-windows-*.msi.sha256 + dist/windows/install.ps1 + dist/windows/winget + if-no-files-found: error + + - name: Upload release assets + if: inputs.publish + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + $assets = @( + Get-ChildItem dist/windows/inflow-*-windows-*.msi + Get-ChildItem dist/windows/inflow-*-windows-*.msi.sha256 + ) + gh release upload $env:RELEASE_TAG ` + $assets.FullName ` + --clobber diff --git a/.gitignore b/.gitignore index 1e75c2a..33c2258 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ # Build output dist/ +packages/core/native/build/ *.tsbuildinfo # Turborepo diff --git a/README.md b/README.md index 69594e5..5d74a7d 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,12 @@ Installing into an agent host? Use the per-surface guide: ## Install -The signed native `inflow` binary is distributed for Apple Silicon and Intel Macs. +InFlow is distributed as a signed native application. The npm package is a compatibility notice and does not run +commands, start MCP, or manage credentials. + +Public installation currently targets Apple Silicon and Intel Macs. Windows ARM64 MSI and Linux ARM64/AMD64 package +workflows are validated, but those channels remain unavailable until their production signing and repository +infrastructure is published. ### Homebrew Cask @@ -40,7 +45,7 @@ brew upgrade --cask inflow brew uninstall --cask inflow ``` -### Hosted installer +### macOS hosted installer ```bash curl -fsSL https://inflowcli.ai/install.sh | bash @@ -56,7 +61,7 @@ Run the installer again to upgrade to the latest GitHub Release. Uninstall with: curl -fsSL https://inflowcli.ai/install.sh | bash -s -- --uninstall ``` -PowerShell on macOS can use the same hosted installer surface: +PowerShell on macOS can use the same hosted installer: ```powershell iwr -useb https://inflowcli.ai/install.ps1 | iex @@ -77,6 +82,19 @@ Download the matching zip from the `inflowpayai/inflow-cli` GitHub Release for t The zip contains `InFlow.app`; the executable is inside the app bundle at `InFlow.app/Contents/MacOS/inflow`. +### Initialize the credential vault + +Credential-bearing commands use the encrypted local InFlow vault. Initialize or unlock it in a human-controlled +terminal: + +```bash +inflow vault unlock +``` + +The PIN or passphrase is read only from the terminal. It is not an MCP argument, command-line flag, environment +variable, or structured agent input. `inflow vault status`, `inflow vault lock`, and `inflow vault policy` do not +require the unlock factor. + ### Use with agents Install the `agentic-enrollment` and `agentic-payments` skills into a skills-aware agent: @@ -103,6 +121,36 @@ The repo also ships as an installable plugin (skill + MCP server bundled) for pl In every case the plugin bundles the skill and the `inflow` MCP server (`.mcp.json`). The default MCP entry runs `inflow --mcp`; install the signed native binary before using the MCP server. +## Security and local data + +- OAuth tokens, API keys, and Agent Enrollment Protocol credentials are encrypted in the local SQLite vault. +- The vault daemon accepts only authenticated local InFlow clients and exposes exact-reference secret operations, not + payment, network, signing, or command-execution operations. +- The daemon authenticates clients, and clients authenticate the daemon before transmitting requests. +- Vault unlock factors are entered only in a human terminal. Agent and MCP executions fail closed while the vault is + locked. +- macOS uses the signed application identity. Windows uses Authenticode-signed application and Windows service + identities. Linux packages install a system service and enforce executable, socket, peer, tenant, and package identity + checks. +- `inflow auth logout` and `inflow vault reset` remove local credentials and vault state. Package uninstall preserves + encrypted vault data unless the platform-specific purge operation is requested. + +InFlow sends authenticated API requests, seller-resource requests requested by the user, and an advisory GitHub Release +version check. Set `NO_UPDATE_NOTIFIER=1` to disable the version check. The check has a two-second deadline and does not +send credentials. + +## Upgrade and troubleshooting + +Upgrade a Homebrew installation with `brew upgrade --cask inflow`. For a hosted installation, rerun the hosted +installer. The CLI may report a newer signed release, but continues unless the API returns `VERSION_UNSUPPORTED`. + +If an agent or MCP tool reports that the vault is locked, run `inflow vault unlock` yourself in a terminal and retry the +operation. Never paste the PIN or passphrase into a prompt or MCP tool input. + +Use `inflow auth status --format json` to check authentication and environment state, and `inflow vault status` to check +the local vault and daemon. See the [surface install and testing guide](./docs/development/surfaces-and-testing.md) for +host-specific MCP troubleshooting. + ## Development This is a pnpm + Turborepo monorepo. Node >= 24.15.0 required. @@ -144,6 +192,26 @@ Real release runs also require this repository variable: The published macOS artifacts are attached to the `inflowpayai/inflow-cli` GitHub Release for the package version, and the Homebrew Cask in `inflowpayai/homebrew-tap` points at those release assets for Apple Silicon and Intel Macs. +## Linux release automation + +The `linux-release` workflow builds native AMD64 and ARM64 archives, Debian packages, and RPM packages. Pull requests +use a disposable OpenPGP key to sign a consolidated `SHA256SUMS` release manifest, sign both RPM packages, verify the +result, reject modified metadata and packages, and install through the rendered Linux installer. + +Production runs use the protected `linux-production` GitHub environment. That environment permits approval by the +initiating sole release operator, is restricted to release tags, and contains only the exportable automation signing +subkey: + +- Environment secret: `LINUX_OPENPGP_SIGNING_SUBKEY_BASE64` +- Environment variable: `LINUX_OPENPGP_SIGNING_FINGERPRINT` + +The primary certification key remains offline. It has no expiration. The automation signing subkey has a two-year +lifetime, is reviewed annually, and is replaced approximately 90 days before expiration. The production workflow signs +and verifies the release before uploading versioned assets to the matching GitHub Release. + +See [`docs/development/linux-release-signing.md`](./docs/development/linux-release-signing.md) for the offline key +ceremony, GitHub environment setup, release process, and recovery procedure. + ## Packages - `@inflowpayai/inflow` (`packages/cli`) — the published binary, and the entry point for anyone integrating InFlow. Thin diff --git a/codecov.yml b/codecov.yml index fb7e7f0..b44b7f3 100644 --- a/codecov.yml +++ b/codecov.yml @@ -14,6 +14,7 @@ ignore: - '.github/**' - '.changeset/**' - 'docs/**' + - 'packages/cli/src/cli.tsx' - 'scripts/**' - '**/*.md' - '**/*.yml' diff --git a/docs/development/linux-release-signing.md b/docs/development/linux-release-signing.md new file mode 100644 index 0000000..82e2566 --- /dev/null +++ b/docs/development/linux-release-signing.md @@ -0,0 +1,210 @@ +# Linux Release Signing + +The Linux production trust root is an offline OpenPGP primary certification key. GitHub receives only an automation +signing subkey. GitHub Releases is the initial production origin for versioned Linux assets; native APT and RPM +repositories are outside this baseline. + +## Timing + +Create the production keys after the disposable-key workflow passes and before the first production workflow run. Do not +create production keys merely to test the workflow. Do not generate or import the primary key in a development +workspace, ordinary daily-use keyring, GitHub runner, or cloud shell. + +## Offline key generation + +Use a clean offline computer started from trusted media. Disconnect networking before key generation. Attach the first +encrypted backup device and create a dedicated GnuPG home on it. Generate the primary key, capture its fingerprint, add +the signing subkey, and create the initial exports: + +```sh +export GNUPGHOME=/path/to/encrypted-device/inflow-linux-gnupg +mkdir -m 0700 "$GNUPGHOME" +gpg --quick-generate-key 'InFlow Linux Release ' rsa4096 cert 0 +PRIMARY_FINGERPRINT="$( + gpg --batch --with-colons --fingerprint \ + 'InFlow Linux Release ' | + awk -F: '$1 == "fpr" { print $10; exit }' +)" +printf 'Primary fingerprint: %s\n' "$PRIMARY_FINGERPRINT" +gpg --quick-add-key "$PRIMARY_FINGERPRINT" rsa4096 sign 2y +gpg --armor --export "$PRIMARY_FINGERPRINT" > inflow-linux-signing-key.asc +gpg --armor --export-secret-keys "$PRIMARY_FINGERPRINT" > inflow-linux-primary-backup.asc +gpg --armor --export-secret-subkeys "$PRIMARY_FINGERPRINT" > inflow-linux-automation-subkey.asc +gpg --armor --output inflow-linux-revocation.asc --gen-revoke "$PRIMARY_FINGERPRINT" +``` + +For the revocation reason, select `0` (`No reason specified`), leave the optional description empty, and confirm +creation. Creating the certificate does not revoke the key. Revocation occurs only if the certificate is imported and +the resulting revoked public key is published. + +Record the fingerprint on a second offline medium. Store two encrypted copies of the complete primary backup in separate +physical locations. Store the revocation certificate separately from both primary-key backups. Use a strong primary-key +passphrase and never remove protection from either complete primary backup. + +## Automation-only export + +Keep the key-custody computer offline. Re-establish the primary keyring location and fingerprint if the original shell +session ended: + +```sh +export GNUPGHOME=/path/to/encrypted-device/inflow-linux-gnupg +PRIMARY_FINGERPRINT="$( + gpg --batch --with-colons --fingerprint \ + 'InFlow Linux Release ' | + awk -F: '$1 == "fpr" { print $10; exit }' +)" +printf 'Primary fingerprint: %s\n' "$PRIMARY_FINGERPRINT" +test -s inflow-linux-automation-subkey.asc +``` + +Import the protected automation-subkey export into a separate GnuPG home on encrypted removable storage: + +```sh +export AUTOMATION_GNUPGHOME=/path/to/encrypted-device/inflow-linux-automation-gnupg +mkdir -m 0700 "$AUTOMATION_GNUPGHOME" +GNUPGHOME="$AUTOMATION_GNUPGHOME" \ + gpg --import inflow-linux-automation-subkey.asc +GNUPGHOME="$AUTOMATION_GNUPGHOME" \ + gpg --edit-key "$PRIMARY_FINGERPRINT" +``` + +At the `gpg>` prompt: + +1. Enter `passwd`. +2. Enter the current subkey passphrase. +3. Leave the new passphrase empty. +4. Confirm the warning about an empty passphrase. +5. Enter `quit` after GnuPG reports that the passphrase changed. + +Export that isolated copy to encrypted transfer storage: + +```sh +GNUPGHOME="$AUTOMATION_GNUPGHOME" \ + gpg --armor --export-secret-subkeys "$PRIMARY_FINGERPRINT" \ + > /path/to/encrypted-transfer/inflow-linux-automation-subkey-unencrypted.asc +test -s /path/to/encrypted-transfer/inflow-linux-automation-subkey-unencrypted.asc +``` + +Confirm that the primary line is marked `sec#` and the signing-subkey line is marked `ssb`. The `#` means the primary +secret key is unavailable: + +```sh +GNUPGHOME="$AUTOMATION_GNUPGHOME" \ + gpg --list-secret-keys "$PRIMARY_FINGERPRINT" +``` + +Do not continue unless those markers are present. + +## GitHub environment + +Move only `inflow-linux-automation-subkey-unencrypted.asc` and the recorded public fingerprint to a networked +administration computer. Do not reconnect the offline key-custody computer. + +Authenticate GitHub CLI as a repository administrator and create the environment: + +```sh +gh auth status +gh api --method PUT \ + repos/inflowpayai/inflow-cli/environments/linux-production \ + >/dev/null +``` + +In repository settings, configure `linux-production` before uploading the secret: + +- Restrict deployments to release tags. +- Require the sole release operator's approval. +- Permit the initiating operator to approve the deployment. +- Do not allow administrator bypass. + +## GitHub entries + +Run these commands from the directory containing the unencrypted automation export: + +```sh +set -o pipefail +export PRIMARY_FINGERPRINT='PASTE_THE_RECORDED_PRIMARY_FINGERPRINT' +test -s inflow-linux-automation-subkey-unencrypted.asc + +base64 < inflow-linux-automation-subkey-unencrypted.asc | + tr -d '\n' | + gh secret set LINUX_OPENPGP_SIGNING_SUBKEY_BASE64 \ + --env linux-production \ + --repo inflowpayai/inflow-cli + +printf '%s' "$PRIMARY_FINGERPRINT" | + gh variable set LINUX_OPENPGP_SIGNING_FINGERPRINT \ + --env linux-production \ + --repo inflowpayai/inflow-cli +``` + +Verify both entries. GitHub lists the secret name but does not reveal its value: + +```sh +gh secret list --env linux-production --repo inflowpayai/inflow-cli +gh variable get LINUX_OPENPGP_SIGNING_FINGERPRINT \ + --env linux-production \ + --repo inflowpayai/inflow-cli +``` + +Remove the unencrypted transfer copy from the networked administration computer after GitHub confirms the secret. + +The private primary key and revocation certificate must not be stored in GitHub secrets, Actions artifacts, repository +files, release assets, password managers synchronized to daily-use machines, or this workspace. + +## Production release + +Create the version tag and matching GitHub Release before dispatching `linux-release`. Run the workflow with: + +- `publish`: `true` +- `tag`: the exact `v` tag + +The protected job imports the automation subkey, compares the complete expected fingerprint, signs both RPM packages, +creates and signs `SHA256SUMS`, verifies every signature, attests the final package bytes, renders an installer pinned +to the fingerprint, and uploads the versioned assets. + +Before the first production release, create a temporary `v*` tag for the reviewed commit and dispatch the verification +mode. This mode uses read-only repository permissions, requires approval through `linux-production`, rejects an export +with a usable primary secret key, signs and verifies both architectures, installs from the signed assets, and publishes +nothing: + +```sh +git tag v0.9.0-signing-test.1 REVIEWED_COMMIT +git push origin v0.9.0-signing-test.1 +gh workflow run linux-release.yml \ + --repo inflowpayai/inflow-cli \ + --ref v0.9.0-signing-test.1 \ + -f publish=false \ + -f verify_production_signing=true +``` + +Delete the temporary local and remote tag after the verification workflow passes: + +```sh +git tag --delete v0.9.0-signing-test.1 +git push origin --delete v0.9.0-signing-test.1 +``` + +The installer downloads the selected package, `SHA256SUMS`, `SHA256SUMS.asc`, and the public key. It compares the +public-key fingerprint with its pinned fingerprint, verifies the manifest with `gpgv`, selects the exact package entry, +and verifies the package checksum before installation. + +## Review, replacement, and revocation + +Review access, backups, the published fingerprint, and the signing subkey annually. Generate a replacement signing +subkey on the offline machine approximately 90 days before the current subkey expires. Publish a release containing the +updated public key before using the replacement subkey. Every Linux release publishes `inflow-linux-signing-key.asc`; +the stable recovery URL is +`https://github.com/inflowpayai/inflow-cli/releases/latest/download/inflow-linux-signing-key.asc`. + +If the automation subkey may be compromised: + +1. Disable the `linux-production` environment and stop Linux publication. +2. Use the offline primary key to revoke the automation subkey. +3. Generate a replacement signing subkey. +4. Publish the updated public key in a new GitHub Release and publish the incident notice through a GitHub Security + Advisory and that release's notes. +5. Replace the GitHub environment secret and verify its fingerprint. +6. Rebuild affected releases from reviewed source; do not merely re-sign existing untrusted artifacts. + +If the offline primary key may be compromised, stop publication and establish a new trust root. Existing installers must +not silently accept that replacement fingerprint. diff --git a/docs/development/surfaces-and-testing.md b/docs/development/surfaces-and-testing.md index 603241c..52978ed 100644 --- a/docs/development/surfaces-and-testing.md +++ b/docs/development/surfaces-and-testing.md @@ -1,652 +1,315 @@ # InFlow Surfaces And Testing -This guide is the per-surface install, smoke-test, and update playbook for every place the `inflow` CLI bundle runs. - -It covers the published `@inflowpayai/inflow` npm package, the `agentic-payments` skill, and the CLI-backed MCP server -started with `inflow --mcp` or `npx -y @inflowpayai/inflow --mcp`. +This guide covers installation and smoke testing for the signed native `inflow` command-line application, its bundled +skills, and its local MCP server. The npm package is a compatibility notice and cannot run commands, start MCP, or +manage credentials. This guide does not cover the separate direct InFlow API MCP server used for account management, policies, approvals, -withdrawals, sellers, and users. That server exposes a different tool set and should be tested from its own API -contract. - -## What To Verify - -Each skill-aware surface has two artifacts: - -- The `agentic-payments` skill, which teaches the agent the payment flow. -- The `inflow` MCP server, which exposes the CLI commands as tools. - -MCP-only surfaces have only the MCP server unless you paste the skill body into the host's instructions surface. - -Use the CLI as the source of truth for exact commands, flags, schemas, and MCP tools: - -```bash -inflow --llms --format json -inflow --llms-full --format json -inflow --schema --format json -``` - -The current CLI command inventory is: - -- `auth login` -- `auth logout` -- `auth status` -- `balances list` -- `deposit-addresses list` -- `inspect` -- `user get` -- `x402 inspect` -- `x402 pay` -- `x402 status` -- `x402 cancel` -- `x402 decode` -- `x402 supported` -- `mpp inspect` -- `mpp pay` -- `mpp status` -- `mpp cancel` -- `mpp decode` -- `mpp supported` - -The MCP tool names are derived from those command names by replacing spaces with underscores; hyphens inside command -words are preserved. The current tool inventory is: - -- `auth_login` -- `auth_logout` -- `auth_status` -- `balances_list` -- `deposit-addresses_list` -- `inspect` -- `user_get` -- `x402_inspect` -- `x402_pay` -- `x402_status` -- `x402_cancel` -- `x402_decode` -- `x402_supported` -- `mpp_inspect` -- `mpp_pay` -- `mpp_status` -- `mpp_cancel` -- `mpp_decode` -- `mpp_supported` - -Call `tools/list` on the MCP server for the authoritative live inventory. - -## Shared MCP Config - -Use this npx-backed entry when the host accepts JSON MCP configuration: - -```json -{ - "mcpServers": { - "inflow": { - "command": "npx", - "args": ["-y", "@inflowpayai/inflow", "--mcp"] - } - } -} -``` - -Keep `-y`. Without it, `npx` can wait for install confirmation and the MCP host can report that the server failed to -start. - -If the CLI is installed globally and available on the host's `PATH`, this equivalent entry also works: - -```json -{ - "mcpServers": { - "inflow": { - "command": "inflow", - "args": ["--mcp"] - } - } -} -``` - -To run against sandbox, add the environment variable if the host supports per-server environment configuration: - -```json -{ - "mcpServers": { - "inflow": { - "command": "npx", - "args": ["-y", "@inflowpayai/inflow", "--mcp"], - "env": { - "INFLOW_ENVIRONMENT": "sandbox" - } - } - } -} -``` - -Not every MCP host honors an `env` block. Check the host after installation with `inflow auth status` or the -`auth_status` MCP tool. - -## Claude Desktop - -Plain Claude Desktop does not run the Claude Code plugin manager, so the skill and MCP are wired separately. - -### Install - -Install the CLI so the skill body is available: - -```bash -npm install -g @inflowpayai/inflow -``` - -Copy the skill body: - -```bash -inflow --skill | pbcopy -``` - -On Linux, use `wl-copy` or `xclip`; on Windows, use `clip`. - -In Claude Desktop, create a project named `InFlow`, open the project instructions, paste the skill body, and save. - -Then edit Claude Desktop's MCP config: - -- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` -- Windows: `%APPDATA%\Claude\claude_desktop_config.json` -- Linux: `~/.config/Claude/claude_desktop_config.json` - -Add or merge the shared `inflow` MCP entry, then fully quit and reopen Claude Desktop. - -### Test - -Use the `InFlow` project so the pasted skill is in context. - -Ask: - -```text -Use InFlow to authenticate me. -``` - -Expected: the agent starts the device flow, shows the verification URL, and polls until authentication completes. - -Ask: - -```text -Use InFlow to list my balances. -``` - -Expected: the agent calls `balances_list`. If balances are empty, the skill should lead it to surface deposit addresses. - -Ask against a known 402-protected URL: - -```text -Use InFlow to inspect and pay https://demo.x402.io/widgets. -``` - -Expected: the agent detects the protocol, runs the matching read-only inspect step first, then starts the payment flow. - -### Update - -Refresh the skill body and MCP binary separately: - -```bash -npm install -g @inflowpayai/inflow@latest -inflow --skill | pbcopy -``` - -Paste the refreshed skill body into the Claude project instructions. Restart Claude Desktop so the `npx` MCP entry -resolves the current npm `latest`. - -To pin a version, replace `@inflowpayai/inflow` in the MCP args with `@inflowpayai/inflow@`. - -## Claude Code And Cowork - -Claude Code and Cowork consume the Claude Code plugin bundle. The plugin installs the skill and MCP together. - -### Install - -From a Claude Code session: - -```text -/plugin marketplace add inflowpayai/inflow-cli -/plugin install inflow@inflow-cli -``` - -If your installed Claude Code CLI supports direct repository installation, this form is equivalent: - -```bash -claude plugin install inflowpayai/inflow-cli -``` - -The marketplace entry points at `plugins/inflow`, whose plugin manifest references `./skills/` and `./.mcp.json`. - -### Test - -Trigger the skill: - -```text -/agentic-payments -``` - -Expected: the skill loads and the agent responds with the InFlow payment playbook, not generic payment advice. - -Ask: - -```text -Use InFlow to list the available InFlow MCP tools. -``` - -Expected: the agent lists the live MCP tools or calls `tools/list` through the host. - -Run the skill-driven flow: - -```text -Authenticate me with InFlow, then inspect and pay https://demo.x402.io/widgets. -``` +withdrawals, sellers, and users. -Expected: `auth_login` returns a verification URL, polling completes after approval, then the agent runs the matching -inspect and pay tools. +## Install the native application -### Update - -Reinstall the plugin to refresh the skill body and manifests: +Install InFlow before configuring an agent host. Public installation currently supports Apple Silicon and Intel macOS: ```bash -claude plugin install inflowpayai/inflow-cli --force +brew tap inflowpayai/tap +brew install --cask inflow ``` -Restart Claude Code or Cowork so the unpinned `npx` MCP entry resolves npm `latest`. - -For pinned MCP installs, edit the local plugin `.mcp.json` and pin `@inflowpayai/inflow@`. - -## Codex - -Codex consumes the Codex plugin manifest. The plugin installs the skill and MCP together. - -### Install - -Use Codex's plugin browser and install `inflowpayai/inflow-cli`, or use the CLI when available: +The hosted macOS installer is: ```bash -codex plugin install inflowpayai/inflow-cli +curl -fsSL https://inflowcli.ai/install.sh | bash ``` -The Codex manifest is `.codex-plugin/plugin.json`; it points to `./skills/` and `./.mcp.json`. The per-plugin mirror at -`plugins/inflow/.codex-plugin/plugin.json` has the same shape for hosts that prefer per-plugin discovery. +Windows ARM64 MSI and Linux ARM64/AMD64 packages have passed their platform workflows. They are not public install +channels until production Windows signing and Linux repository trust roots are published. -### Test +### Windows release signing -Trigger the skill: +The `windows release` GitHub Actions workflow builds native x64 and ARM64 executable payloads. Pull requests and manual +runs with signing disabled build unsigned MSI files and release metadata without publishing them. -```text -/agentic-payments -``` +Production runs use Azure Artifact Signing in this order: -Ask: +1. Build the unsigned executable on its native architecture. +2. Sign `inflow.exe` on the supported x64 signing runner. +3. Build the architecture-specific MSI around the signed executable. +4. Sign the MSI. +5. Verify both Authenticode chains, publishers, and timestamps. +6. Render the checksum, hosted installer, and WinGet manifests from the signed MSI. -```text -Use InFlow to authenticate me, then list my balances. -``` +Create a protected GitHub environment named `windows-production`, require deployment approval, and configure these +environment variables: -Expected: the skill drives `auth_login` first when needed, then calls `balances_list`. +- `AZURE_ARTIFACT_SIGNING_CLIENT_ID` +- `AZURE_ARTIFACT_SIGNING_TENANT_ID` +- `AZURE_ARTIFACT_SIGNING_SUBSCRIPTION_ID` +- `AZURE_ARTIFACT_SIGNING_ENDPOINT` +- `AZURE_ARTIFACT_SIGNING_ACCOUNT` +- `AZURE_ARTIFACT_SIGNING_PROFILE` +- `AZURE_ARTIFACT_SIGNING_SUBJECT` -### Update +The Azure application must have an OpenID Connect federated credential scoped to the `windows-production` GitHub +environment and the `Artifact Signing Certificate Profile Signer` role on the certificate profile. Do not create or +store an Azure client secret or certificate private key in GitHub. -Reinstall the plugin: +Verify the installed executable: ```bash -codex plugin install inflowpayai/inflow-cli --force -``` - -Restart Codex so the MCP server resolves npm `latest`. Pin by editing the local `.mcp.json` to -`@inflowpayai/inflow@`. - -## OpenClaw - -OpenClaw is skill-first. The `agentic-payments` skill frontmatter declares `metadata.openclaw.requires.bins: ["inflow"]` -and an npm install recipe for `@inflowpayai/inflow`. - -### Install - -From ClawHub, once published: - -```bash -openclaw skills install agentic-payments -``` - -If `inflow` is not on `PATH`, OpenClaw should offer to install the binary with npm. - -Before ClawHub publication, install from git if your OpenClaw build supports plugin installs: - -```bash -openclaw plugins install git:github.com/inflowpayai/inflow-cli@ -``` - -### Test - -Trigger the skill: - -```text -/agentic-payments -``` - -Ask: - -```text -Pay https://demo.x402.io/widgets with InFlow. -``` - -Expected: the skill drives a read-only protocol check before payment, authenticates if needed, then runs the matching -MCP tools. - -### Update - -For ClawHub installs: - -```bash -openclaw skills update agentic-payments -npm install -g @inflowpayai/inflow@latest -``` - -For git plugin installs: - -```bash -openclaw plugins install git:github.com/inflowpayai/inflow-cli@ --force +inflow --version +inflow auth status --format json +inflow vault status ``` -## Hermes +## Credential vault boundary -Hermes is MCP-only. It has no native InFlow skill manager, so the MCP server can run without the agent knowing the -payment playbook. - -### Install +OAuth tokens, API keys, and Agent Enrollment Protocol credentials are encrypted in the local InFlow vault. Initialize or +unlock it from a human-controlled terminal: ```bash -hermes mcp add inflow --command npx --args "-y,@inflowpayai/inflow,--mcp" +inflow vault unlock ``` -Optionally paste the skill body into Hermes instructions: - -```bash -npm install -g @inflowpayai/inflow -inflow --skill | pbcopy -``` +Do not put the PIN or passphrase in an agent prompt, MCP input, command-line flag, environment variable, or redirected +standard input. The structured CLI and MCP server deliberately expose no unlock-factor field. When the vault is locked, +the agent receives a human-action error; unlock it manually and retry the original request. -### Test +Useful lifecycle commands: ```bash -hermes mcp list -hermes mcp test inflow -``` - -Expected: Hermes shows the `inflow` server and a live tool list matching `tools/list`. - -In a Hermes agent session, ask for an explicit tool call if the skill body was not pasted: - -```text -Call the InFlow x402_inspect tool for https://demo.x402.io/widgets. +inflow vault status +inflow vault policy +inflow vault lock +inflow vault change-passphrase +inflow vault reset ``` -### Update +`inflow auth logout` removes local authentication and vault state after attempting eligible remote cleanup. Application +uninstall preserves encrypted vault data. Use the platform-specific purge operation only when the data must also be +removed. -The npx entry resolves npm `latest` each time the server starts. Pin by editing the args to: +## Shared MCP configuration -```text --y,@inflowpayai/inflow@,--mcp -``` - -## Cursor - -Cursor is MCP-only unless you add the skill body to project rules. - -### Install - -Edit `~/.cursor/mcp.json` or a project-local `.cursor/mcp.json`: +The signed binary contains the CLI, MCP, and daemon entry points. Configure every host to execute that installed binary: ```json { "mcpServers": { "inflow": { - "command": "npx", - "args": ["-y", "@inflowpayai/inflow", "--mcp"] + "command": "inflow", + "args": ["--mcp"] } } } ``` -Restart Cursor. - -Optional skill paste: +If a graphical host does not inherit the shell `PATH`, use the absolute path returned by: ```bash -npm install -g @inflowpayai/inflow -mkdir -p .cursor/rules -inflow --skill > .cursor/rules/inflow.md +command -v inflow ``` -### Test - -Open Cursor's MCP tools panel and confirm the `inflow` tools appear. Ask Cursor to call `auth_status` or `x402_inspect` -against a known test URL. - -Without the skill body, prompt the agent with explicit tool names and expected ordering. With the skill body, a normal -request such as "authenticate me with InFlow" should follow the playbook. - -### Update - -Restart Cursor for the npx entry to resolve npm `latest`. Pin by changing the package arg to -`@inflowpayai/inflow@`. - -## Cline - -Cline is MCP-only unless you paste the skill body into custom instructions. - -### Install - -Edit Cline's MCP settings file, commonly `~/.cline/cline_mcp_settings.json`: +To use the sandbox environment in a host that supports per-server environment variables: ```json { "mcpServers": { "inflow": { - "command": "npx", - "args": ["-y", "@inflowpayai/inflow", "--mcp"] + "command": "inflow", + "args": ["--mcp"], + "env": { + "INFLOW_ENVIRONMENT": "sandbox" + } } } } ``` -Restart the editor or Cline extension. +Do not replace `inflow` with `npx`, Node, a repository checkout, or a copied JavaScript bundle. Those paths do not carry +the installed application identity required by the vault boundary. -Optional skill paste: +## Skills and plugins + +The repository contains the `agentic-enrollment` and `agentic-payments` skills. A skills-aware host can install them +with: ```bash -npm install -g @inflowpayai/inflow -inflow --skill | pbcopy +npx skills add inflowpayai/inflow-cli ``` -Paste into Cline custom instructions. - -### Test - -Confirm Cline lists the `inflow` MCP server and tools. Ask Cline to call `auth_status`, then `x402_inspect` or -`mpp_inspect` for a known 402-protected URL. +The repository also contains Claude Code, Cursor, Codex, and generic agent plugin manifests. Each plugin points its MCP +entry at `inflow --mcp`; the signed native application must already be installed on the host. -### Update +For an MCP-only host, print a bundled skill body and paste it into that host's instructions: -Restart Cline for npx to resolve npm `latest`. Pin by changing the package arg to `@inflowpayai/inflow@`. - -## Continue.dev - -Continue.dev is MCP-only unless you paste the skill body into the configured system message. - -### Install - -Edit `~/.continue/config.json`: - -```json -{ - "experimental": { - "modelContextProtocolServers": [ - { - "transport": { - "type": "stdio", - "command": "npx", - "args": ["-y", "@inflowpayai/inflow", "--mcp"] - } - } - ] - } -} +```bash +inflow --skill +inflow --skill agentic-enrollment ``` -Restart the editor or Continue.dev extension. - -Optional skill paste: +On macOS, `inflow --skill | pbcopy` copies the default payment playbook. Use `wl-copy` or `xclip` on Linux and `clip` on +Windows. -```bash -npm install -g @inflowpayai/inflow -inflow --skill | pbcopy -``` +## Claude Desktop -Paste into Continue.dev's system message. +Plain Claude Desktop does not use the Claude Code plugin manager. Paste the desired skill into project instructions, +then add the shared MCP configuration to: -### Test +- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` +- Windows: `%APPDATA%\Claude\claude_desktop_config.json` +- Linux: `~/.config/Claude/claude_desktop_config.json` -Ask Continue.dev to call an `inflow` tool and verify the response. If the skill body is not loaded, ask for explicit -tools such as `auth_status`, `x402_inspect`, or `mpp_inspect`. +Fully quit and reopen Claude Desktop. Confirm the MCP server starts, then ask it to call `auth_status`. -### Update +## Claude Code and local Cowork -Restart Continue.dev for npx to resolve npm `latest`. Pin by changing the package arg to -`@inflowpayai/inflow@`. +Install the plugin from a Claude Code session: -## Raw Stdio MCP +```text +/plugin marketplace add inflowpayai/inflow-cli +/plugin install inflow@inflow +``` -Use this for any MCP-spec-compliant client. +The plugin installs the skill and MCP manifest together. Local Cowork launches the MCP server on the host; do not copy +the binary, vault database, or credentials into its Linux guest. Remote Cowork cannot use a local MCP server unless the +product provides an explicit host bridge. -### Install +After plugin installation, trigger `/agentic-payments` and ask the host to list InFlow MCP tools. Unlock the vault in a +separate host terminal before testing a credential-bearing tool. -Use the shared MCP config, or run directly: +## Codex and Cursor -```bash -npx -y @inflowpayai/inflow --mcp -``` +Install the repository plugin through the host's plugin browser. Codex discovers `.codex-plugin/plugin.json`; Cursor +discovers `.cursor-plugin/marketplace.json`. Both ultimately execute the shared `inflow --mcp` entry. -If the host has a system-prompt or custom-instructions field, paste the skill body: +For a manual Cursor setup, place the shared MCP configuration in `~/.cursor/mcp.json` or `.cursor/mcp.json`, restart +Cursor, and confirm the tools panel shows InFlow. -```bash -npm install -g @inflowpayai/inflow -inflow --skill | pbcopy -``` +## Hermes, Cline, Continue.dev, and raw MCP -### Test +These hosts can use the shared MCP configuration if they accept a standard-input/output MCP server. Use the exact +configuration shape required by the host, with `command` set to the installed `inflow` path and arguments set to +`["--mcp"]`. -Use a full initialize handshake for portable MCP smoke tests: +For a raw protocol smoke test: ```bash { printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}' printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}' printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' -} | npx -y @inflowpayai/inflow --mcp +} | inflow --mcp ``` -Expected: `initialize` returns `serverInfo.name: "inflow"` and `tools/list` returns the tool array. - -The current binary also answers a direct `tools/list` request in the test suite, but the initialize handshake is the -portable check to use across MCP hosts. - -### Update +Expected: initialization reports `serverInfo.name` as `inflow`, and `tools/list` returns the installed tool inventory. -Restart the MCP server to resolve npm `latest`, or pin the package arg to `@inflowpayai/inflow@`. +## What to test -## Shared Troubleshooting +The live CLI is the source of truth for commands, schemas, and MCP tools: -### npx stalls the MCP host - -The MCP host logs show `npx` waiting for package install confirmation. +```bash +inflow --llms --format json +inflow --llms-full --format json +inflow --schema --format json +``` -Fix: keep `-y` in every npx args array: +For each agent host: -```json -"args": ["-y", "@inflowpayai/inflow", "--mcp"] -``` +1. Confirm the installed executable reports the expected version. +2. Confirm the skill loads when the host supports skills. +3. Confirm MCP `tools/list` returns the current inventory. +4. Confirm `auth_status` reports locked state without exposing secrets when the vault is locked. +5. Unlock the vault manually and confirm `auth_login` reaches a verification URL. +6. Confirm `balances_list` or `deposit-addresses_list` returns structured data after authentication. +7. Confirm a read-only `x402_inspect` or `mpp_inspect` call. +8. Run a payment only against an approved test endpoint and funded test account. -### Node version mismatch +Do not use direct InFlow API MCP results as evidence for this local CLI-backed MCP surface. -The CLI requires Node.js 22 or newer. +## Upgrade -Fix: +For Homebrew: ```bash -nvm install 22 && nvm use 22 +brew upgrade --cask inflow ``` -or use the equivalent command for your Node version manager. +For a hosted installation, rerun the hosted installer. Reinstall or refresh the plugin separately when its skill or +manifest changes, then restart the host. -### inflow is not on PATH after global install +The CLI checks the latest GitHub Release with a two-second deadline. Human executions may print an advisory update +notice. `auth status` structured output may include `current_version` and `latest_version`. An advisory does not block +the current command; `VERSION_UNSUPPORTED` means the upgrade is mandatory. Set `NO_UPDATE_NOTIFIER=1` to disable the +advisory check. -Check npm's global bin directory: +Upgrades preserve the encrypted vault but leave it locked after daemon replacement. Unlock it manually before the next +credential-bearing request. -```bash -echo "$(npm config get prefix)/bin" -``` +## Uninstall and purge -Add that directory to `PATH` in the shell config used by the MCP host. +Homebrew uninstall: -### MCP starts but tools do not appear +```bash +brew uninstall --cask inflow +``` -Upgrade to the current npm release: +Hosted macOS uninstall: ```bash -npm install -g @inflowpayai/inflow@latest +curl -fsSL https://inflowcli.ai/install.sh | bash -s -- --uninstall ``` -Then restart the MCP host and confirm with `tools/list`. +Windows Installer and Linux package uninstall preserve encrypted vault data. Linux `install.sh --purge` removes the +package and system vault data. `inflow vault reset` is the cross-platform command for intentionally removing local vault +state before uninstall. -### MCP works but the agent guesses the payment flow +## Troubleshooting -The skill is not loaded. On skill-aware surfaces, reinstall or refresh the plugin. On MCP-only surfaces, paste the skill -body into the host's instruction surface: +### MCP process does not start -```bash -inflow --skill | pbcopy -``` +Run `command -v inflow` in the same environment as the host. If the graphical host has a different `PATH`, configure the +absolute path. Run `inflow --mcp` in a terminal and inspect the host's MCP logs. -### Skill body drifts from the binary +### MCP tools are present but credential operations fail -The CLI-bundled skill is the source of truth for the released binary: +Run: ```bash -inflow --skill | head -20 +inflow vault status +inflow auth status --format json ``` -Maintainers should sync external skill channels from `skills/agentic-payments/SKILL.md` in this repo and republish the -binary from the same commit. +If the vault is locked, run `inflow vault unlock` in a human terminal. Do not add the factor to MCP configuration. -### Sandbox and production are confused +### The agent guesses the payment flow -Check the resolved environment: +The skill is missing. Refresh the plugin on skill-aware hosts, or paste the output of `inflow --skill` into the host's +instructions. -```bash -inflow auth status --format json -``` +### Environment is wrong -Use `--sandbox`, `--environment sandbox`, or `INFLOW_ENVIRONMENT=sandbox`. For MCP hosts, add an `env` block when the -host supports it. +Check `inflow auth status --format json`. Use `--sandbox`, `--environment sandbox`, or `INFLOW_ENVIRONMENT=sandbox`. Add +an `env` block only when the MCP host supports it. -## Release Verification +### A custom legacy credential file remains -Before a release that changes the install footprint, manifests, MCP behavior, or skill content, verify: +The signed application deletes the standard legacy plaintext configuration and any path explicitly supplied through +`--auth` or `INFLOW_AUTH_FILE`. It cannot discover arbitrary paths that are no longer supplied. If an older installation +used a custom path, remove that file manually after confirming the signed application is using the encrypted vault. Do +not pass the legacy file to another application or attempt to import it into the vault. -- One skill-aware surface: Claude Code, Cowork, Codex, or OpenClaw. -- One MCP-only surface: Cursor, Hermes, or raw stdio MCP. +### Installed binary appears modified or mismatched -For each tested surface: +Reinstall through the signed platform channel. Do not copy an executable between installations or invoke a development +bundle as a substitute. On macOS, verify with `codesign --verify --deep --strict`; on Windows, inspect the Authenticode +signature; on Linux, reinstall through the signed package or repository once the production repository is published. -- Confirm the skill loads when the surface supports skills. -- Confirm `tools/list` returns the current MCP tool inventory. -- Confirm `auth_login` reaches a verification URL and polling can complete after approval. -- Confirm `balances_list` or `deposit-addresses_list` returns structured data after authentication. -- Confirm a read-only protocol check with `x402_inspect` or `mpp_inspect`. -- Confirm a full payment flow only against a safe test endpoint and funded test account. +## Privacy and security checks -Do not substitute the direct InFlow API MCP test results for this CLI-backed MCP surface. They are different products -with different tool contracts. +- Confirm command and MCP output never contains access tokens, refresh tokens, API keys, credentials, or unlock factors. +- Confirm agent and MCP schemas have no PIN, passphrase, password, secret, or raw authorization input. +- Confirm locked and unavailable vault states fail closed. +- Confirm the MCP host launches the installed signed binary, not npm or a development checkout. +- Confirm update checks contain no credentials and can be disabled with `NO_UPDATE_NOTIFIER=1`. +- Confirm reset, logout, uninstall, and purge behavior matches the user's intended data-retention choice. diff --git a/package.json b/package.json index 8777073..57a7cd5 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,24 @@ "cli": "node packages/cli/dist/cli.js", "build": "turbo run build && node scripts/align-skill-version.js && node scripts/publish-skills.mjs", "build:homebrew-cask": "node scripts/render-homebrew-cask.mjs", + "build:linux-apt-repository": "node scripts/build-linux-apt-repository.mjs", + "build:linux-installer": "node scripts/render-linux-installer.mjs", + "build:linux-package": "node scripts/build-linux-package.mjs", + "build:linux-release-signatures": "node scripts/sign-linux-release.mjs", + "build:linux-rpm-repository": "node scripts/build-linux-rpm-repository.mjs", "build:macos-app": "node scripts/build-macos-app.mjs --skip-notarize", + "build:macos-debug-signed": "node scripts/build-macos-app.mjs --signed-debug --skip-notarize", "build:macos-release": "node scripts/build-macos-app.mjs --release", + "build:windows-package": "node scripts/build-windows-package.mjs", + "build:windows-debug-signed": "node scripts/build-windows-package.mjs --signed-debug", + "build:windows-release": "node scripts/build-windows-package.mjs --release", + "build:windows-external-prepare": "node scripts/build-windows-package.mjs --prepare-external-signing", + "build:windows-external-msi": "node scripts/build-windows-package.mjs --build-msi-from-signed-payload", + "build:windows-external-metadata": "node scripts/build-windows-package.mjs --write-signed-release-metadata", + "build:windows-release-metadata": "node scripts/render-windows-release-metadata.mjs", + "smoke:macos-packaged-vault": "node scripts/smoke-macos-packaged-vault.mjs", + "smoke:linux-packaged-vault": "node scripts/smoke-linux-packaged-vault.mjs", + "smoke:linux-system-vault": "node scripts/smoke-linux-system-vault.mjs", "verify": "node scripts/clean-bundle-artifacts.mjs && pnpm format && pnpm typecheck && pnpm lint && pnpm test && pnpm typedoc && pnpm build", "dev": "turbo run dev --parallel", "test": "turbo run test", diff --git a/packages/cli/README.md b/packages/cli/README.md index fbf1564..e2006cd 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -17,9 +17,15 @@ For host-specific skill and MCP installation, see the repository's | Command | Purpose | | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | | `inflow auth login` | Run the OAuth device flow to authenticate. Saves a refreshable access token. | -| `inflow auth logout` | Clear the saved access token and API key from local config. | +| `inflow auth logout` | Revoke eligible remote credentials and reset local authentication, vault, and cached state. | | `inflow auth status` | Show which credential the CLI would use, plus the active environment and resolved API URL. | -| `inflow user get` | Fetch the authenticated user's profile. | +| `inflow vault status` | Show whether the encrypted local credential vault is initialized, locked, and available. | +| `inflow vault unlock` | Initialize or unlock the local vault from a human-controlled terminal. | +| `inflow vault lock` | Lock the local vault. | +| `inflow vault policy` | Show the vault idle and sleep-lock policy. | +| `inflow vault set-policy` | Change the vault idle and sleep-lock policy. | +| `inflow vault change-passphrase` | Rewrap the vault data key under a new PIN or passphrase. | +| `inflow vault reset` | Remove the local vault database, bootstrap material, policy, and runtime state. | | `inflow balances list` | List the authenticated user's balances. | | `inflow deposit-addresses list` | List the user's configured deposit addresses, grouped by network. | | `inflow inspect ` | Detect a URL's payment protocol(s) and show MPP and x402 challenges together. Read-only probe — no auth, no payment. | @@ -53,7 +59,7 @@ These flags are pre-extracted from `process.argv` before subcommand dispatch, so | Flag | Env var | Notes | | -------------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--api-key ` | `INFLOW_API_KEY` | Use an API key instead of the saved OAuth access token. When both are present, the flag wins for this invocation; `auth login` persists what it saw. | -| `--auth ` | `INFLOW_AUTH_FILE` | Path to the credentials file. Defaults to the platform's standard config dir. | +| `--auth ` | `INFLOW_AUTH_FILE` | Identify a legacy plaintext credentials file for deletion during secure-storage cutover. It is not a credential backend. | | `--auth-base-url ` | `INFLOW_AUTH_BASE_URL` | Override the OAuth endpoint. | | `--base-url ` (alias: `--api-base-url`) | `INFLOW_BASE_URL` | Override the environment-derived API URL. Takes precedence over `--environment`. | | `--bootstrap` | — | Print the agent setup guide (install, authenticate, load a playbook) to stdout and exit. The same text is served at `https://inflowcli.ai/skill.md`. | @@ -66,13 +72,17 @@ These flags are pre-extracted from `process.argv` before subcommand dispatch, so ## `auth` -The CLI authenticates via the OAuth device-authorization flow. After `auth login` completes, the access + refresh tokens -are stored in the platform's standard config dir (or wherever `--auth` / `INFLOW_AUTH_FILE` points). The `inflow-core` -access-token provider refreshes automatically when the access token expires. +The CLI authenticates via the OAuth device-authorization flow. After `auth login` completes, access and refresh tokens +are encrypted in the local InFlow vault. The `inflow-core` access-token provider refreshes automatically when the access +token expires. API keys are an alternative: pass `--api-key` or set `INFLOW_API_KEY` once and the CLI sends `X-API-KEY` on every request, bypassing the device flow entirely. Mutually exclusive with the OAuth path on a given invocation. +Before the first credential-bearing command, initialize the vault with `inflow vault unlock`. The unlock factor is +accepted only from a human-controlled terminal. Agent, structured, and MCP executions return a human-action error when +the vault is uninitialized or locked; they do not accept the factor as input. + ### `auth login` ```bash @@ -97,8 +107,8 @@ URL. Paste it into a browser by hand to continue. inflow auth logout ``` -Clears the saved access token, refresh token, and any saved `--api-key` from local config. Idempotent: safe to call when -already logged out. +Attempts eligible remote credential revocation, stops the local vault daemon, and removes local authentication, +encrypted vault, user metadata, and public cache state. Idempotent: safe to call when already logged out. ### `auth status` @@ -111,17 +121,6 @@ inflow auth status --probe # validate the token via GET /v1/users/self Reports which credential the CLI would use (OAuth access token, API key, or none), the active environment, and the resolved API URL — including the SDK's built-in defaults when nothing is overridden. -## `user` - -### `user get` - -```bash -inflow user get -inflow user get --format json -``` - -Fetches the authenticated user's profile (`GET /v1/users/self`). Requires authentication. - ## `balances` ### `balances list` @@ -160,9 +159,9 @@ inflow aep grant service.example --scope read:resource inflow aep revoke service.example ``` -The CLI stores Service-scoped identities and complete credential material in the existing mode-`0o600` credentials file. -Stored state belongs to the canonical InFlow Platform origin and authenticated user. Logout clears it. Agent output -never exposes credential secrets: `grant` reports only credential metadata, and `status` reports only usable local grant +The CLI stores Service-scoped identities in SQLite and encrypts complete credential material in the local vault. Stored +state belongs to the canonical InFlow Platform origin and authenticated user. Logout clears it. Agent output never +exposes credential secrets: `grant` reports only credential metadata, and `status` reports only usable local grant summaries. `aep inspect` probes an exact URL when one is supplied and reports `resource_authentication` as `not-required`, diff --git a/packages/cli/package.json b/packages/cli/package.json index 345cd64..357f627 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -42,11 +42,13 @@ "url": "https://github.com/inflowpayai/inflow-cli/issues" }, "dependencies": { - "@aep-foundation/agent": "^0.2.0", + "@aep-foundation/agent": "^0.3.0", "@inflowpayai/mpp": "^0.7.0", "@inflowpayai/mpp-buyer": "^0.6.1", "@inflowpayai/x402": "^0.8.0", "@inflowpayai/x402-buyer": "^0.7.0", + "@modelcontextprotocol/server": "2.0.0-alpha.4", + "@node-rs/argon2": "^2.0.2", "@x402/core": "^2.12.0", "incur": "^0.4.5", "ink": "^5.2.1", diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 69cc4d5..7b5b0fa 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -1,5 +1,17 @@ import process from 'node:process'; -import { type AuthStorage, Inflow, Storage, storage } from '@inflowpayai/inflow-core'; +import { isMainThread, workerData } from 'node:worker_threads'; +import { + type AuthStorage, + Inflow, + runLinuxTransferredVaultService, + runLinuxVaultBroker, + runLocalVaultDaemon, + isWindowsVaultWorkerData, + runWindowsVaultService, + runWindowsVaultWorker, + Storage, + SyncVaultSecretStore, +} from '@inflowpayai/inflow-core'; import { Cli, Help } from 'incur'; import { createAuthCli } from './commands/auth/index.js'; import { createAepCli } from './commands/aep/index.js'; @@ -7,7 +19,13 @@ import { createBalancesCli } from './commands/balances/index.js'; import { createDepositAddressesCli } from './commands/deposit-addresses/index.js'; import { createInspectCommand } from './commands/inspect/index.js'; import { createMppCli } from './commands/mpp/index.js'; -import { createUserCli } from './commands/user/index.js'; +import { + createVaultCli, + ensureLocalVaultDaemon, + ensureLocalVaultUnlocked, + readVaultStatusWithoutStarting, + type LocalVaultDaemonClientOptions, +} from './commands/vault/index.js'; import { createX402Cli } from './commands/x402/index.js'; import { formatUpdateNotice, @@ -15,13 +33,16 @@ import { makeFrozenUpdateProbe, type UpdateProbe, } from './utils/update-probe.js'; +import { shouldReconcileVaultDaemon, shouldStartVaultDaemon, shouldUnlockVault } from './startup-vault.js'; declare const __CLI_VERSION__: string; +declare const __CLI_BUILD_ID__: string; declare const __CLI_NAME__: string; declare const __BOOTSTRAP_BODY__: string; declare const __SKILL_BODIES__: Record; const cliVersion = __CLI_VERSION__; +const cliBuildId = __CLI_BUILD_ID__; const cliName = __CLI_NAME__; const bootstrapBody = __BOOTSTRAP_BODY__; const skillBodies = __SKILL_BODIES__; @@ -44,6 +65,33 @@ async function printBody(body: string): Promise { } async function main(): Promise { + if (!isMainThread && isWindowsVaultWorkerData(workerData)) { + await runWindowsVaultWorker(workerData, new URL(import.meta.url)); + return; + } + const daemonMode = extractHiddenDaemonMode(); + if (daemonMode !== undefined) { + if ( + daemonMode !== 'vault' && + daemonMode !== 'vault-broker' && + daemonMode !== 'vault-service' && + daemonMode !== 'vault-windows-service' + ) { + process.stderr.write(`Unknown daemon mode: ${daemonMode}\n`); + process.exit(2); + } + const daemonOptions = { + cliVersion, + buildId: cliBuildId, + }; + if (daemonMode === 'vault-broker') await runLinuxVaultBroker(daemonOptions); + else if (daemonMode === 'vault-service') await runLinuxTransferredVaultService(daemonOptions); + else if (daemonMode === 'vault-windows-service') { + await runWindowsVaultService(new URL(import.meta.url), daemonOptions); + } else await runLocalVaultDaemon(daemonOptions); + return; + } + if (process.argv.includes('--bootstrap')) { await printBody(bootstrapBody); } @@ -110,8 +158,25 @@ async function main(): Promise { const sandboxFlag = extractBooleanFlag('--sandbox'); const apiKeyFromFlag = extractFlag('--api-key'); const verbose = extractBooleanFlag('--verbose'); + const isAgent = process.argv.includes('--format') || process.argv.includes('--mcp') || !process.stdout.isTTY; + const vaultOptions: LocalVaultDaemonClientOptions = { buildId: cliBuildId, cliVersion }; + const hasDirectApiKey = apiKeyFromFlag !== undefined || process.env['INFLOW_API_KEY'] !== undefined; + if (shouldReconcileVaultDaemon(process.argv, hasDirectApiKey)) { + const status = await readVaultStatusWithoutStarting(vaultOptions); + if (status.daemonRunning) await ensureLocalVaultDaemon(vaultOptions); + } + if (shouldStartVaultDaemon(process.argv, hasDirectApiKey)) { + await ensureLocalVaultDaemon(vaultOptions); + } + if (shouldUnlockVault(process.argv, { hasDirectApiKey, isAgent })) { + await ensureLocalVaultUnlocked({ mode: isAgent ? 'agent' : 'human', vaultOptions }); + } - const authStorage: AuthStorage = credentialFilePath ? new Storage({ configPath: credentialFilePath }) : storage; + const secretStore = new SyncVaultSecretStore(vaultOptions); + const authStorage: AuthStorage = new Storage({ + ...(credentialFilePath === undefined ? {} : { configPath: credentialFilePath }), + secretStore, + }); const apiKeyFromEnv = process.env['INFLOW_API_KEY']; function readSavedApiKey(): string | undefined { @@ -132,7 +197,7 @@ async function main(): Promise { return {}; } } - const apiKeyFromSaved = readSavedApiKey(); + const apiKeyFromSaved = apiKeyFromFlag !== undefined || apiKeyFromEnv !== undefined ? undefined : readSavedApiKey(); const apiKey = apiKeyFromFlag ?? apiKeyFromEnv ?? apiKeyFromSaved; const apiKeySource: 'flag' | 'env' | 'saved' | undefined = apiKeyFromFlag !== undefined && apiKeyFromFlag.length > 0 @@ -145,8 +210,6 @@ async function main(): Promise { const savedConnection = readSavedConnection(); - const isAgent = process.argv.includes('--format') || process.argv.includes('--mcp') || !process.stdout.isTTY; - const rawEnvironment = environmentFromFlag ?? (sandboxFlag ? 'sandbox' : undefined) ?? @@ -231,11 +294,12 @@ async function main(): Promise { ...(authBaseUrl !== undefined ? { authBaseUrl } : {}), resolvedApiBaseUrl, verbose, + vaultOptions, }), ); - cli.command(createUserCli(inflow.user, authStorage, inflow)); cli.command(createBalancesCli(inflow.balances, authStorage, inflow)); cli.command(createDepositAddressesCli(inflow.depositAddresses, authStorage, inflow)); + cli.command(createVaultCli(vaultOptions)); cli.command(createX402Cli(inflow, authStorage, resolvedApiBaseUrl)); cli.command(createMppCli(inflow, authStorage, resolvedApiBaseUrl)); cli.command(createAepCli(inflow, authStorage)); @@ -244,6 +308,20 @@ async function main(): Promise { await cli.serve(); } +function extractHiddenDaemonMode(): string | undefined { + const assignmentIndex = process.argv.findIndex((arg) => arg.startsWith('--daemon=')); + if (assignmentIndex !== -1) { + const [arg] = process.argv.splice(assignmentIndex, 1); + return arg?.slice('--daemon='.length); + } + + const index = process.argv.indexOf('--daemon'); + if (index === -1) return undefined; + const value = process.argv[index + 1]; + process.argv.splice(index, value === undefined ? 1 : 2); + return value ?? ''; +} + main().catch((cause: unknown) => { const message = cause instanceof Error ? cause.message : String(cause); process.stderr.write(`${message}\n`); diff --git a/packages/cli/src/commands/aep/index.tsx b/packages/cli/src/commands/aep/index.tsx index 47db2c0..e03ccb0 100644 --- a/packages/cli/src/commands/aep/index.tsx +++ b/packages/cli/src/commands/aep/index.tsx @@ -29,6 +29,7 @@ import { approvalUrlFor, parseHeaderFlags, runAepFetch, + SecureStorageError, type AepStateStorage, type AuthStorage, type Inflow, @@ -39,6 +40,7 @@ import { render } from 'ink'; import React from 'react'; import { assertSessionGuard } from '../../utils/assert-session.js'; import { persistedAepPublicDocumentCache } from '../../utils/aep-public-document-cache.js'; +import { mcpTool } from '../../mcp-metadata.js'; import { renderInkUntilExit } from '../../utils/render-ink-until-exit.js'; import { shellArg } from '../../utils/payment-fetch-command.js'; import { @@ -162,6 +164,18 @@ function commandError(error: unknown): ErrorOptions { return { code: 'AEP_APPROVAL_DENIED', message: 'The InFlow approval was denied.' }; return { code: typeof code === 'string' ? code : 'AEP_SIGN_FAILED', message: 'The AEP command failed.' }; } + if (error instanceof SecureStorageError) { + const vaultCode = + error.secureStorageCode === 'vault_locked' || error.secureStorageCode === 'secure_storage_secret_missing' + ? 'VAULT_LOCKED' + : error.secureStorageCode === 'vault_not_initialized' + ? 'VAULT_NOT_INITIALIZED' + : error.secureStorageCode; + return { + code: vaultCode, + message: error.message, + }; + } return { code: 'AEP_INTERNAL_ERROR', message: 'The AEP command failed unexpectedly.' }; } @@ -190,6 +204,14 @@ async function existingIdentity(storage: AepStorage, inspect: InspectServiceResu class MissingIdentityError extends Error {} class ApprovalCancelledError extends Error {} class ApprovalDeniedError extends Error {} +class PendingAepApproval extends Error { + constructor( + readonly approvalId: string, + readonly retryAfterSeconds: number, + ) { + super('AEP approval is pending.'); + } +} class CliInputError extends Error { constructor( readonly code: string, @@ -239,34 +261,47 @@ async function approvedAssertion( inspect: InspectServiceResult, command: 'enroll' | 'grant', platformContext: Record, - options: { interval?: number; maxAttempts: number; signal?: AbortSignal; timeout: number }, + options: { + approvalId?: string; + deferPending?: boolean; + interval?: number; + maxAttempts: number; + signal?: AbortSignal; + timeout: number; + }, publicDocumentCache?: AepPublicDocumentCache, onPending?: (approvalId: string) => void, ): Promise<{ assertion: string; context: Record }> { const signer = await provider(inflow, publicDocumentCache).signerFor(identity); - const initial = buildClientAssertionClaims({ - agentDid: identity.agentDid, - command, - jti: randomUUID, - serviceDid: inspect.document.service.did, - }); - const result = await signer(initial, { - command, - idempotencyKey: randomUUID(), - platformContext, - serviceDid: inspect.document.service.did, - signingAlgorithms: identity.signingAlgorithms, - }); - if (typeof result === 'string' || result.status === 'completed') { - return { - assertion: typeof result === 'string' ? result : result.clientAssertion, - context: typeof result === 'string' ? {} : (result.platformContext ?? {}), - }; + let approvalId = options.approvalId; + let interval = options.interval ?? 5; + if (approvalId === undefined) { + const initial = buildClientAssertionClaims({ + agentDid: identity.agentDid, + command, + jti: randomUUID, + serviceDid: inspect.document.service.did, + }); + const result = await signer(initial, { + command, + idempotencyKey: randomUUID(), + platformContext, + serviceDid: inspect.document.service.did, + signingAlgorithms: identity.signingAlgorithms, + }); + if (typeof result === 'string' || result.status === 'completed') { + return { + assertion: typeof result === 'string' ? result : result.clientAssertion, + context: typeof result === 'string' ? {} : (result.platformContext ?? {}), + }; + } + const pendingApprovalId = result.platformContext?.['approval_id']; + if (typeof pendingApprovalId !== 'string') throw new Error('Platform Sign did not return an approval identifier.'); + if (options.deferPending === true) throw new PendingAepApproval(pendingApprovalId, result.retryAfterSeconds); + approvalId = pendingApprovalId; + interval = options.interval ?? result.retryAfterSeconds; } - const approvalId = result.platformContext?.['approval_id']; - if (typeof approvalId !== 'string') throw new Error('Platform Sign did not return an approval identifier.'); onPending?.(approvalId); - const interval = options.interval ?? result.retryAfterSeconds; if (!Number.isFinite(interval) || interval <= 0) throw new RangeError('Approval interval must be positive.'); const deadline = Date.now() + options.timeout * 1000; let attempts = 0; @@ -321,6 +356,50 @@ function openApiPolicyFrame(policy: OpenApiPolicy): Record { }; } +function pendingEnrollFrame( + approval: { approvalId: string; retryAfterSeconds: number }, + inflow: Inflow, + serviceDid: string, +): Record { + const interval = approval.retryAfterSeconds; + return sanitizeDeep({ + approval_id: approval.approvalId, + approval_url: approvalUrlFor(inflow.resolvedApiBaseUrl, approval.approvalId), + instruction: + 'Present the approval_url to the user and ask them to approve in the InFlow mobile app or dashboard. Then call AEP enroll again with the approval id.', + service_did: serviceDid, + state: 'pending', + retry_after_seconds: interval, + _next: { + poll_interval_seconds: interval, + until: 'enrollment completes', + }, + }); +} + +function pendingGrantFrame( + approval: { approvalId: string; retryAfterSeconds: number }, + inflow: Inflow, + serviceDid: string, + grantType: string, +): Record { + const interval = approval.retryAfterSeconds; + return sanitizeDeep({ + approval_id: approval.approvalId, + approval_url: approvalUrlFor(inflow.resolvedApiBaseUrl, approval.approvalId), + grant_type: grantType, + instruction: + 'Present the approval_url to the user and ask them to approve in the InFlow mobile app or dashboard. Then call AEP grant again with the approval id.', + service_did: serviceDid, + state: 'pending', + retry_after_seconds: interval, + _next: { + poll_interval_seconds: interval, + until: 'credential grant completes', + }, + }); +} + interface FetchContext extends Omit { args: { resourceUrl: string }; } @@ -693,7 +772,7 @@ async function runEnroll(c: Context, inflow: Inflow, authStorage: AuthStorage): let waitingView: PendingApprovalRenderer | undefined; const approvalController = new AbortController(); try { - const options = c.options as { interval?: number; maxAttempts: number; timeout: number }; + const options = c.options as { approvalId?: string; interval?: number; maxAttempts: number; timeout: number }; if (options.interval !== undefined && options.interval <= 0) throw new CliInputError('AEP_INTERNAL_ERROR', 'Approval interval must be positive.'); if (options.timeout <= 0 || options.timeout > 900) @@ -750,30 +829,55 @@ async function runEnroll(c: Context, inflow: Inflow, authStorage: AuthStorage): preferred: inspect.document.claims?.preferred ?? [], required: inspect.document.claims?.required ?? [], }; - const signed = await approvedAssertion( - inflow, - identity, - inspect, - 'enroll', - { claims }, - { ...options, signal: approvalController.signal }, - publicDocumentCache, - (approvalId) => { - if (!c.agent && !c.formatExplicit) { - waitingView = render( - { - approvalController.abort(); - return cancelApproval(inflow, approvalId); - }} - />, - { exitOnCtrlC: false }, - ); - } - }, - ); + let signed: { assertion: string; context: Record }; + try { + signed = await approvedAssertion( + inflow, + identity, + inspect, + 'enroll', + { claims }, + { + ...options, + deferPending: + (c.agent || c.formatExplicit) && options.approvalId === undefined && options.interval === undefined, + signal: approvalController.signal, + }, + publicDocumentCache, + (approvalId) => { + if (!c.agent && !c.formatExplicit) { + waitingView = render( + { + approvalController.abort(); + return cancelApproval(inflow, approvalId); + }} + />, + { exitOnCtrlC: false }, + ); + } + }, + ); + } catch (error) { + if (error instanceof PendingAepApproval) { + return present( + c, + cancelApproval(inflow, error.approvalId)} + />, + pendingEnrollFrame( + { approvalId: error.approvalId, retryAfterSeconds: error.retryAfterSeconds }, + inflow, + inspect.document.service.did, + ), + ); + } + throw error; + } closePendingApprovalView(waitingView); waitingView = undefined; const approvedClaims = signed.context['approved_claims']; @@ -838,6 +942,7 @@ async function runStatus(c: Context, inflow: Inflow, authStorage: AuthStorage): ...(grant.expiresAt === undefined ? {} : { expires_at: grant.expiresAt }), grant_type: grant.grantType, scopes: Array.isArray(grant.credential['scopes']) ? grant.credential['scopes'] : [], + status: 'active' as const, usable: true, })) .sort((left, right) => @@ -860,6 +965,15 @@ async function runStatus(c: Context, inflow: Inflow, authStorage: AuthStorage): frame, ); } catch (error) { + if ( + error instanceof AepCommandError && + (error.problem?.code === 'agent_identity_not_found' || error.problem?.code === 'not_recognized') + ) + return c.error({ + code: 'AEP_NOT_ENROLLED', + message: + 'The Service does not recognize the locally stored Agent identity. Use `inflow aep enroll ` to enroll again.', + }); return c.error(commandError(error)); } } @@ -869,7 +983,13 @@ async function runGrant(c: Context, inflow: Inflow, authStorage: AuthStorage): P let waitingView: PendingApprovalRenderer | undefined; const approvalController = new AbortController(); try { - const options = c.options as { grantType?: string; interval?: number; scope: string[]; timeout?: number }; + const options = c.options as { + approvalId?: string; + grantType?: string; + interval?: number; + scope: string[]; + timeout?: number; + }; const timeout = options.timeout ?? 900; if (options.interval !== undefined && options.interval <= 0) throw new CliInputError('AEP_INTERNAL_ERROR', 'Approval interval must be positive.'); @@ -915,35 +1035,59 @@ async function runGrant(c: Context, inflow: Inflow, authStorage: AuthStorage): P }); } const scopes = [...new Set(options.scope)]; - const signed = await approvedAssertion( - inflow, - identity, - inspect, - 'grant', - { grant_type: grantType, ...(scopes.length === 0 ? {} : { requested_scopes: scopes }) }, - { - ...(options.interval === undefined ? {} : { interval: options.interval }), - maxAttempts: 0, - signal: approvalController.signal, - timeout, - }, - publicDocumentCache, - (approvalId) => { - if (!c.agent && !c.formatExplicit) { - waitingView = render( - { - approvalController.abort(); - return cancelApproval(inflow, approvalId); - }} - />, - { exitOnCtrlC: false }, - ); - } - }, - ); + let signed: { assertion: string; context: Record }; + try { + signed = await approvedAssertion( + inflow, + identity, + inspect, + 'grant', + { grant_type: grantType, ...(scopes.length === 0 ? {} : { requested_scopes: scopes }) }, + { + ...(options.interval === undefined ? {} : { interval: options.interval }), + ...(options.approvalId === undefined ? {} : { approvalId: options.approvalId }), + deferPending: + (c.agent || c.formatExplicit) && options.approvalId === undefined && options.interval === undefined, + maxAttempts: 0, + signal: approvalController.signal, + timeout, + }, + publicDocumentCache, + (approvalId) => { + if (!c.agent && !c.formatExplicit) { + waitingView = render( + { + approvalController.abort(); + return cancelApproval(inflow, approvalId); + }} + />, + { exitOnCtrlC: false }, + ); + } + }, + ); + } catch (error) { + if (error instanceof PendingAepApproval) { + return present( + c, + cancelApproval(inflow, error.approvalId)} + />, + pendingGrantFrame( + { approvalId: error.approvalId, retryAfterSeconds: error.retryAfterSeconds }, + inflow, + inspect.document.service.did, + grantType, + ), + ); + } + throw error; + } closePendingApprovalView(waitingView); waitingView = undefined; const response = await grantService({ @@ -1020,15 +1164,7 @@ async function runRevoke(c: Context, inflow: Inflow, authStorage: AuthStorage): if ('allGrantTypes' in selector) await revokeService({ ...base, allGrantTypes: true }); else if ('credentialId' in selector) await revokeService({ ...base, credentialId: selector.credentialId }); else await revokeService({ ...base, grantType: selector.grantType }); - const credentials = aepStorage.credentials(); - for (const credential of await credentials.listCredentials(inspect.document.service.did)) { - if ( - 'allGrantTypes' in selector || - ('credentialId' in selector && credential.credentialId === selector.credentialId) || - ('grantType' in selector && credential.grantType === selector.grantType) - ) - await credentials.deleteCredential(inspect.document.service.did, credential.credentialId); - } + aepStorage.deleteCredentials(inspect.document.service.did, selector); const frame = sanitizeDeep({ revoked: true, ...('allGrantTypes' in selector @@ -1056,6 +1192,7 @@ export function createAepCli(inflow: Inflow, authStorage: AuthStorage) { cli.command('inspect', { args: serviceReferenceArgs, description: 'Inspect an AEP Service without authentication.', + mcp: mcpTool('aep_inspect'), options: inspectOptions, outputPolicy: 'agent-only' as const, run: (c) => runInspect(c, inflow, authStorage), @@ -1063,6 +1200,7 @@ export function createAepCli(inflow: Inflow, authStorage: AuthStorage) { cli.command('enroll', { args: serviceReferenceArgs, description: 'Enroll with an AEP Service.', + mcp: mcpTool('aep_enroll'), options: enrollOptions, outputPolicy: 'agent-only' as const, run: (c) => runEnroll(c, inflow, authStorage), @@ -1070,6 +1208,7 @@ export function createAepCli(inflow: Inflow, authStorage: AuthStorage) { cli.command('fetch', { args: fetchArgs, description: 'Fetch a resource with AEP authentication when challenged.', + mcp: mcpTool('aep_fetch'), options: fetchOptions, outputPolicy: 'agent-only' as const, run: (c) => runFetch(c, inflow, authStorage), @@ -1077,12 +1216,14 @@ export function createAepCli(inflow: Inflow, authStorage: AuthStorage) { cli.command('status', { args: serviceReferenceArgs, description: 'Get AEP Service lifecycle status.', + mcp: mcpTool('aep_status'), outputPolicy: 'agent-only' as const, run: (c) => runStatus(c, inflow, authStorage), }); cli.command('grant', { args: serviceReferenceArgs, description: 'Request and store a Service credential.', + mcp: mcpTool('aep_grant'), options: grantOptions, outputPolicy: 'agent-only' as const, run: (c) => runGrant(c, inflow, authStorage), @@ -1090,6 +1231,7 @@ export function createAepCli(inflow: Inflow, authStorage: AuthStorage) { cli.command('revoke', { args: serviceReferenceArgs, description: 'Revoke Service credentials.', + mcp: mcpTool('aep_revoke'), options: revokeOptions, outputPolicy: 'agent-only' as const, run: (c) => runRevoke(c, inflow, authStorage), diff --git a/packages/cli/src/commands/aep/runtime.tsx b/packages/cli/src/commands/aep/runtime.tsx index 432e008..c7f648c 100644 --- a/packages/cli/src/commands/aep/runtime.tsx +++ b/packages/cli/src/commands/aep/runtime.tsx @@ -30,6 +30,7 @@ import type { SellerProbeOptions, SellerProbeResult } from '@inflowpayai/x402-bu import { render } from 'ink'; import React from 'react'; import { persistedAepPublicDocumentCache } from '../../utils/aep-public-document-cache.js'; +import type { AuthenticationApprovalDisplay } from '../payment-authentication-approval.js'; import { PendingApprovalView } from './views.js'; type PendingApprovalRenderer = Pick, 'clear' | 'unmount'>; @@ -41,6 +42,7 @@ export type AepRuntimeContext = { }; export interface AepRuntimeOptions { + approvalDisplay?: AepApprovalDisplay; authStorage: AuthStorage; context: AepRuntimeContext; inflow: Inflow; @@ -54,6 +56,11 @@ interface AepRuntimeTrace { markAuthenticated(): void; } +export interface AepApprovalDisplay { + clearPendingApproval(): void; + showPendingApproval(approval: AuthenticationApprovalDisplay): boolean; +} + class MissingIdentityError extends Error {} class ApprovalCancelledError extends Error {} class ApprovalDeniedError extends Error {} @@ -160,18 +167,23 @@ async function cancelApproval(inflow: Inflow, approvalId: string): Promise }).catch(() => undefined); } -async function approvalSleep(milliseconds: number, signal?: AbortSignal): Promise { - if (signal?.aborted === true) throw new ApprovalCancelledError(); +async function approvalSleep(milliseconds: number, signals: readonly (AbortSignal | undefined)[]): Promise { + if (signals.some((signal) => signal?.aborted === true)) throw new ApprovalCancelledError(); await new Promise((resolve, reject) => { - const timeout = setTimeout(resolve, milliseconds); - signal?.addEventListener( - 'abort', - () => { - clearTimeout(timeout); - reject(new ApprovalCancelledError()); - }, - { once: true }, - ); + const cleanup = (): void => { + for (const signal of signals) signal?.removeEventListener('abort', onAbort); + }; + const onAbort = (): void => { + clearTimeout(timeout); + cleanup(); + reject(new ApprovalCancelledError()); + }; + const timeout: ReturnType = setTimeout(() => { + cleanup(); + resolve(); + }, milliseconds); + timeout.unref(); + for (const signal of signals) signal?.addEventListener('abort', onAbort, { once: true }); }); } @@ -214,7 +226,6 @@ export function createCliAepAgentOptions(options: AepRuntimeOptions): AepAgentOp const publicDocumentCache = persistedAepPublicDocumentCache(options.authStorage); const platform = platformProvider(options.inflow, publicDocumentCache); const storage = lazyStores(options.inflow, options.authStorage, options.trace); - const controller = new AbortController(); let waitingView: PendingApprovalRenderer | undefined; return { credentialStore: storage.credentials, @@ -252,49 +263,58 @@ export function createCliAepAgentOptions(options: AepRuntimeOptions): AepAgentOp if (typeof approvalId !== 'string') { throw new AepPendingSignResolverError('Platform Sign omitted the approval identifier.', 'server_error'); } + const controller = new AbortController(); + const approvalUrl = approvalUrlFor(options.inflow.resolvedApiBaseUrl, approvalId); + let displayHandled = false; /* v8 ignore start -- Ink rendering is exercised through command TTY tests; resolver unit tests run agent-mode. */ if (!options.context.agent && !options.context.formatExplicit) { - waitingView = render( - { - controller.abort(); - return cancelApproval(options.inflow, approvalId); - }} - />, - { exitOnCtrlC: false }, - ); + const approval = { + approvalId, + approvalUrl, + cancel: () => { + controller.abort(); + return cancelApproval(options.inflow, approvalId); + }, + }; + displayHandled = options.approvalDisplay?.showPendingApproval(approval) === true; + if (!displayHandled) { + waitingView = render( + , + { exitOnCtrlC: false }, + ); + } } /* v8 ignore stop */ - const interval = options.interval ?? input.pending.retryAfterSeconds; - if (!Number.isFinite(interval) || interval <= 0) { - throw new AepPendingSignResolverError('Approval interval must be positive.', 'server_error'); - } - const deadline = Date.now() + options.timeout * 1000; - while (Date.now() < deadline) { - if (input.signal?.aborted === true || controller.signal.aborted) throw new ApprovalCancelledError(); - let status: string; - try { - status = await approvalStatus(options.inflow, approvalId); - } catch (error) { - throw new ApprovalServerError(error instanceof Error ? error.message : String(error)); + try { + const interval = options.interval ?? input.pending.retryAfterSeconds; + if (!Number.isFinite(interval) || interval <= 0) { + throw new AepPendingSignResolverError('Approval interval must be positive.', 'server_error'); } - if (status === 'APPROVED') { - const completed = await input.continueSign(); - if (completed.status === 'completed') { - closePendingApprovalView(waitingView); - return completed; + const deadline = Date.now() + options.timeout * 1000; + while (Date.now() < deadline) { + if (input.signal?.aborted === true || controller.signal.aborted) throw new ApprovalCancelledError(); + let status: string; + try { + status = await approvalStatus(options.inflow, approvalId); + } catch (error) { + throw new ApprovalServerError(error instanceof Error ? error.message : String(error)); + } + if (status === 'APPROVED') { + const completed = await input.continueSign(); + if (completed.status === 'completed') return completed; + } else if (status === 'DECLINED') { + throw new ApprovalDeniedError(); + } else if (status === 'CANCELLED') { + controller.abort(); + throw new ApprovalCancelledError(); } - } else if (status === 'DECLINED') { - throw new ApprovalDeniedError(); - } else if (status === 'CANCELLED') { - controller.abort(); - throw new ApprovalCancelledError(); + await approvalSleep(interval * 1000, [input.signal, controller.signal]); } - await approvalSleep(interval * 1000, input.signal); + throw new ApprovalTimeoutError(); + } finally { + if (displayHandled) options.approvalDisplay?.clearPendingApproval(); + closePendingApprovalView(waitingView); } - throw new ApprovalTimeoutError(); }, }; } diff --git a/packages/cli/src/commands/aep/schema.ts b/packages/cli/src/commands/aep/schema.ts index 5a3ab2a..1fb4880 100644 --- a/packages/cli/src/commands/aep/schema.ts +++ b/packages/cli/src/commands/aep/schema.ts @@ -30,6 +30,7 @@ export const inspectOptions = z.object({ }); export const enrollOptions = z.object({ + approvalId: z.string().optional().describe('Continue an enrollment approval returned by a previous enroll call.'), interval: z.coerce .number() .optional() @@ -39,6 +40,7 @@ export const enrollOptions = z.object({ }); export const grantOptions = z.object({ + approvalId: z.string().optional().describe('Continue a grant approval returned by a previous grant call.'), grantType: z .string() .optional() diff --git a/packages/cli/src/commands/aep/views.tsx b/packages/cli/src/commands/aep/views.tsx index c2a2912..08a1fe2 100644 --- a/packages/cli/src/commands/aep/views.tsx +++ b/packages/cli/src/commands/aep/views.tsx @@ -227,7 +227,7 @@ export const RevokeView: React.FC = ({ onComplete, selector }) interface StatusViewProps extends ViewProps { availableGrantTypes: string[]; - grants: Array<{ credential_id: string; expires_at?: string; grant_type: string; scopes: unknown }>; + grants: StoredCredentialRow[]; service: { owner_action_required?: string; requirements_pending?: string[]; @@ -237,6 +237,26 @@ interface StatusViewProps extends ViewProps { serviceDid: string; } +interface StoredCredentialRow { + credential_id: string; + expires_at?: string; + grant_type: string; + scopes: unknown; + status: 'active'; +} + +function listedScopes(scopes: unknown): string { + return Array.isArray(scopes) ? listed(scopes.filter((scope): scope is string => typeof scope === 'string')) : 'None'; +} + +const STORED_CREDENTIAL_COLUMNS: ReadonlyArray> = [ + { header: 'Grant Type', cell: (row) => row.grant_type }, + { header: 'Credential ID', cell: (row) => row.credential_id }, + { header: 'Status', cell: (row) => row.status }, + { header: 'Scopes', cell: (row) => listedScopes(row.scopes) }, + { header: 'Expires', cell: (row) => row.expires_at ?? 'None' }, +]; + export const StatusView: React.FC = ({ availableGrantTypes, grants, @@ -247,7 +267,6 @@ export const StatusView: React.FC = ({ useComplete(() => onComplete()); const rows: DetailRow[] = [ { field: 'Service DID', value: serviceDid }, - { field: 'Enrollment', value: service.status }, { field: 'Authentication', value: 'AEP JWT' }, { field: 'Stored credentials', value: grants.length === 0 ? 'None' : String(grants.length) }, { field: 'Available credential types', value: listed(availableGrantTypes) }, @@ -260,14 +279,9 @@ export const StatusView: React.FC = ({ AEP Service Status {grants.length > 0 && ( - - Stored session credentials - {grants.map((grant) => ( - - {grant.grant_type} · {grant.credential_id} - {grant.expires_at === undefined ? '' : ` · expires ${grant.expires_at}`} - - ))} + + Stored Session Credentials +
)} diff --git a/packages/cli/src/commands/auth/index.tsx b/packages/cli/src/commands/auth/index.tsx index 7ae56d6..edcaa7e 100644 --- a/packages/cli/src/commands/auth/index.tsx +++ b/packages/cli/src/commands/auth/index.tsx @@ -5,6 +5,7 @@ import { type DeviceAuthRequest, type IAuth, InflowApiError, + SecureStorageError, type IUserResource, probeSession, sanitizeDeep, @@ -15,6 +16,7 @@ import { Cli } from 'incur'; import { Text } from 'ink'; import React, { useEffect, useState } from 'react'; import { useFlowExit } from '../../hooks/use-flow-exit.js'; +import { mcpTool } from '../../mcp-metadata.js'; import { renderInkUntilExit } from '../../utils/render-ink-until-exit.js'; import type { UpdateInfo, UpdateProbe } from '../../utils/update-probe.js'; import { Login } from './login.js'; @@ -23,6 +25,7 @@ import { LoginPrompt } from './login-prompt.js'; import { Logout } from './logout.js'; import { loginOptions, statusOptions } from './schema.js'; import { AuthStatus } from './status.js'; +import { ensureLocalVaultUnlocked, resetLocalVault, type LocalVaultDaemonClientOptions } from '../vault/index.js'; export interface AuthCommandContext { apiKey: string | undefined; @@ -31,6 +34,7 @@ export interface AuthCommandContext { apiBaseUrl?: string; authBaseUrl?: string; resolvedApiBaseUrl: string; + vaultOptions?: LocalVaultDaemonClientOptions; verbose: boolean; } @@ -269,6 +273,7 @@ async function* runAuthLogin( authResource: IAuth; userResource: IUserResource; authStorage: AuthStorage; + ensureVaultUnlocked?: (mode: 'agent' | 'human') => Promise; }, ctx: AuthCommandContext, ): AsyncGenerator { @@ -281,6 +286,7 @@ async function* runAuthLogin( } const connection = connectionFromContext(ctx); + await deps.ensureVaultUnlocked?.(c.agent || c.formatExplicit ? 'agent' : 'human'); if (ctx.apiKey !== undefined && ctx.apiKey.length > 0) { if (!c.agent && !c.formatExplicit) { @@ -390,17 +396,33 @@ async function* runAuthLogin( async function runAuthLogout( c: AuthLogoutContext, - deps: { authResource: IAuth; authStorage: AuthStorage }, + deps: { authResource: IAuth; authStorage: AuthStorage; resetVault: () => Promise }, ): Promise<{ authenticated: false }> { if (!c.agent && !c.formatExplicit) { - await renderInkUntilExit( undefined} />); + await logoutThenReset( + () => renderInkUntilExit( undefined} />), + deps, + ); return { authenticated: false }; } - await deps.authResource.logout(); + await logoutThenReset(() => deps.authResource.logout(), deps); return { authenticated: false }; } +async function logoutThenReset(run: () => Promise, deps: { resetVault: () => Promise }): Promise { + let logoutFailure: unknown; + try { + await run(); + } catch (cause) { + logoutFailure = cause; + } + await deps.resetVault(); + if (logoutFailure instanceof SecureStorageError) return; + if (logoutFailure instanceof Error) throw logoutFailure; + if (logoutFailure !== undefined) throw new Error('The InFlow logout operation failed.'); +} + async function* runAuthStatus( c: AuthStatusContext, deps: { @@ -415,7 +437,7 @@ async function* runAuthStatus( polling: c.options.interval > 0, }); const update = toUpdateBlock(updateInfo); - const storedConnection = deps.authStorage.getConnection(); + const storedConnection = readStoredConnection(deps.authStorage); const effectiveConnection = displayConnection(storedConnection, ctx); const composeOptions = { ...(update !== undefined ? { update } : {}), @@ -485,23 +507,35 @@ export function createAuthCli( cli.command('login', { description: 'Authenticate with InFlow', + mcp: mcpTool('auth_login'), options: loginOptions, outputPolicy: 'agent-only' as const, async *run(c) { - return yield* runAuthLogin(c, { authResource, userResource, authStorage }, ctx); + return yield* runAuthLogin( + c, + { + authResource, + userResource, + authStorage, + ensureVaultUnlocked: (mode) => ensureLocalVaultUnlocked({ mode, vaultOptions: ctx.vaultOptions ?? {} }), + }, + ctx, + ); }, }); cli.command('logout', { description: 'Log out from InFlow', + mcp: mcpTool('auth_logout'), outputPolicy: 'agent-only' as const, async run(c) { - return runAuthLogout(c, { authResource, authStorage }); + return runAuthLogout(c, { authResource, authStorage, resetVault: () => resetLocalVault(ctx.vaultOptions ?? {}) }); }, }); cli.command('status', { description: 'Check authentication status', + mcp: mcpTool('auth_status'), options: statusOptions, outputPolicy: 'agent-only' as const, async *run(c) { @@ -512,6 +546,15 @@ export function createAuthCli( return cli; } +function readStoredConnection(authStorage: AuthStorage): ConnectionSettings | null { + try { + return authStorage.getConnection(); + } catch (cause) { + if (cause instanceof SecureStorageError && cause.secureStorageCode === 'secure_storage_secret_missing') return null; + throw cause; + } +} + export const __testing = { runAuthLogin, runAuthLogout, diff --git a/packages/cli/src/commands/auth/status.tsx b/packages/cli/src/commands/auth/status.tsx index 0755182..9706462 100644 --- a/packages/cli/src/commands/auth/status.tsx +++ b/packages/cli/src/commands/auth/status.tsx @@ -12,6 +12,7 @@ import Spinner from 'ink-spinner'; import type React from 'react'; import { useEffect, useReducer } from 'react'; import { useFlowExit } from '../../hooks/use-flow-exit.js'; +import { INSTALL_INSTRUCTIONS_URL } from '../../utils/user-display.js'; type View = | { kind: 'loading' } @@ -135,6 +136,7 @@ export const AuthStatus: React.FC = ({ const updateFooter = updateNotice ? ( {`A newer InFlow CLI is available: ${updateNotice.latest}.`} + {`Upgrade with Homebrew or rerun the hosted installer: ${INSTALL_INSTRUCTIONS_URL}`} ) : null; diff --git a/packages/cli/src/commands/balances/index.tsx b/packages/cli/src/commands/balances/index.tsx index 0d92943..da92cf1 100644 --- a/packages/cli/src/commands/balances/index.tsx +++ b/packages/cli/src/commands/balances/index.tsx @@ -8,6 +8,7 @@ import { } from '@inflowpayai/inflow-core'; import { Cli } from 'incur'; import React from 'react'; +import { mcpTool } from '../../mcp-metadata.js'; import { assertSessionGuard } from '../../utils/assert-session.js'; import { authenticatedApiError } from '../../utils/api-error.js'; import { renderInkUntilExit } from '../../utils/render-ink-until-exit.js'; @@ -63,6 +64,7 @@ export function createBalancesCli(balanceResource: IBalanceResource, authStorage cli.command('list', { description: "List the authenticated user's balances", + mcp: mcpTool('balances_list'), options: listOptions, outputPolicy: 'agent-only' as const, async run(c) { diff --git a/packages/cli/src/commands/deposit-addresses/index.tsx b/packages/cli/src/commands/deposit-addresses/index.tsx index 0607a63..a5749d2 100644 --- a/packages/cli/src/commands/deposit-addresses/index.tsx +++ b/packages/cli/src/commands/deposit-addresses/index.tsx @@ -8,6 +8,7 @@ import { } from '@inflowpayai/inflow-core'; import { Cli } from 'incur'; import React from 'react'; +import { mcpTool } from '../../mcp-metadata.js'; import { assertSessionGuard } from '../../utils/assert-session.js'; import { authenticatedApiError } from '../../utils/api-error.js'; import { renderInkUntilExit } from '../../utils/render-ink-until-exit.js'; @@ -72,6 +73,7 @@ export function createDepositAddressesCli( cli.command('list', { description: "List the authenticated user's configured deposit addresses", + mcp: mcpTool('deposit-addresses_list'), options: listOptions, outputPolicy: 'agent-only' as const, async run(c) { diff --git a/packages/cli/src/commands/inspect/index.tsx b/packages/cli/src/commands/inspect/index.tsx index 40f69a6..d805de8 100644 --- a/packages/cli/src/commands/inspect/index.tsx +++ b/packages/cli/src/commands/inspect/index.tsx @@ -13,6 +13,7 @@ import { type SellerProbeOptions, } from '@inflowpayai/inflow-core'; import { renderInkUntilExit } from '../../utils/render-ink-until-exit.js'; +import { mcpTool } from '../../mcp-metadata.js'; import { persistedAepPublicDocumentCache } from '../../utils/aep-public-document-cache.js'; import { storedAepCredentialAuthenticationHeaders } from '../aep/runtime.js'; import { challengeToFrame } from '../mpp/inspect.js'; @@ -34,6 +35,7 @@ interface InspectCommandContext { interface InspectCommandDefinition { description: string; + mcp: ReturnType; args: typeof inspectArgs; options: typeof inspectOptions; outputPolicy: 'agent-only'; @@ -315,6 +317,7 @@ export function createInspectCommand(inflow: Inflow, authStorage?: AuthStorage): return { description: "Detect a URL's AEP authentication and payment requirements. Read-only probe - no authentication and no payment.", + mcp: mcpTool('inspect'), args: inspectArgs, options: inspectOptions, outputPolicy: 'agent-only' as const, diff --git a/packages/cli/src/commands/mpp/index.tsx b/packages/cli/src/commands/mpp/index.tsx index 352bddf..8d82f32 100644 --- a/packages/cli/src/commands/mpp/index.tsx +++ b/packages/cli/src/commands/mpp/index.tsx @@ -4,6 +4,7 @@ import { type AuthStorage, type DecodedChallenge, type Inflow, + type MppFetchEvent, type MppFetchRejected, type MppFetchSuccess, type MppPayCreated, @@ -18,12 +19,16 @@ import { } from '@inflowpayai/inflow-core'; import type { MppSupportedResponse, MppTransactionResponse } from '@inflowpayai/mpp'; import { Cli } from 'incur'; +import type React from 'react'; +import { useMemo } from 'react'; import { assertSessionGuard } from '../../utils/assert-session.js'; import { authenticatedApiError } from '../../utils/api-error.js'; +import { mcpTool } from '../../mcp-metadata.js'; import { buildPaymentFetchNextCommand } from '../../utils/payment-fetch-command.js'; import { renderInkUntilExit } from '../../utils/render-ink-until-exit.js'; -import { createAepAwareInspectProbe, createAepAwareSellerTransport } from '../aep/runtime.js'; +import { type AepApprovalDisplay, createAepAwareInspectProbe, createAepAwareSellerTransport } from '../aep/runtime.js'; import { PaymentFetchView, type PaymentFetchPhase } from '../payment-fetch.js'; +import { useAuthenticationApprovalDisplay } from '../payment-authentication-approval.js'; import { CancelView } from './cancel.js'; import { DecodeView, decodeMppValue } from './decode.js'; import { @@ -192,8 +197,14 @@ function fetchProbeOptionsFrom(c: FetchCommandContext): SellerProbeOptions { }; } -function createSellerTransport(c: PayContext | FetchCommandContext, inflow: Inflow, authStorage: AuthStorage) { +function createSellerTransport( + c: PayContext | FetchCommandContext, + inflow: Inflow, + authStorage: AuthStorage, + approvalDisplay?: AepApprovalDisplay, +) { return createAepAwareSellerTransport({ + ...(approvalDisplay === undefined ? {} : { approvalDisplay }), authStorage, context: c, inflow, @@ -202,6 +213,93 @@ function createSellerTransport(c: PayContext | FetchCommandContext, inflow: Infl }); } +interface PayViewWithAuthenticationProps { + apiBaseUrl: string; + authStorage: AuthStorage; + c: PayContext; + client: MppPayPipelineDeps['client']; + inflow: Inflow; + onCancel: (approvalId: string) => Promise | void; + onComplete: (phase: MppPayPhase) => void; + probeOptions: SellerProbeOptions; +} + +const PayViewWithAuthentication: React.FC = ({ + apiBaseUrl, + authStorage, + c, + client, + inflow, + onCancel, + onComplete, + probeOptions, +}) => { + const { approvalDisplay, authenticationApproval } = useAuthenticationApprovalDisplay(); + const sellerTransport = useMemo( + () => createSellerTransport(c, inflow, authStorage, approvalDisplay), + [approvalDisplay, authStorage, c, inflow], + ); + const deps = useMemo( + () => ({ + ...buildPayPipelineInput(c, probeOptions), + client, + apiBaseUrl, + awaitPayment: true, + sellerTransport, + }), + [apiBaseUrl, c, client, probeOptions, sellerTransport], + ); + return ( + + ); +}; + +interface FetchViewWithAuthenticationProps { + authStorage: AuthStorage; + c: FetchCommandContext; + events: (sellerTransport: ReturnType) => AsyncIterable; + inflow: Inflow; + onComplete: (phase: PaymentFetchPhase) => void; + paymentHeader: string; + protocol: 'MPP'; +} + +const FetchViewWithAuthentication: React.FC = ({ + authStorage, + c, + events, + inflow, + onComplete, + paymentHeader, + protocol, +}) => { + const { approvalDisplay, authenticationApproval } = useAuthenticationApprovalDisplay(); + const sellerTransport = useMemo( + () => createSellerTransport(c, inflow, authStorage, approvalDisplay), + [approvalDisplay, authStorage, c, inflow], + ); + const iterable = useMemo(() => () => events(sellerTransport), [events, sellerTransport]); + return ( + + ); +}; + function buildPayPipelineInput( c: PayContext, probeOptions: SellerProbeOptions, @@ -370,21 +468,17 @@ async function* runPayCommand( return c.error(invalidHeaderError(err)); } - const sellerTransport = createSellerTransport(c, inflow, authStorage); if (!c.agent && !c.formatExplicit) { const client = await inflow.mpp.client(); const captured: { finalPhase: MppPayPhase | null } = { finalPhase: null }; await renderInkUntilExit( - { captured.finalPhase = phase; }} @@ -406,6 +500,7 @@ async function* runPayCommand( return; } + const sellerTransport = createSellerTransport(c, inflow, authStorage); const run = inflow.mpp.pay({ ...buildPayPipelineInput(c, probeOptions), awaitPayment: c.options.interval > 0, @@ -453,17 +548,16 @@ async function* runFetchCommand( return c.error(invalidHeaderError(err)); } - const sellerTransport = createSellerTransport(c, inflow, authStorage); if (!c.agent && !c.formatExplicit) { const captured: { finalPhase: PaymentFetchPhase | null } = { finalPhase: null }; await renderInkUntilExit( - + events={(sellerTransport) => inflow.mpp.fetch({ transactionId: c.args.transactionId, url: c.args.resourceUrl, @@ -500,6 +594,7 @@ async function* runFetchCommand( return; } + const sellerTransport = createSellerTransport(c, inflow, authStorage); const run = inflow.mpp.fetch({ transactionId: c.args.transactionId, url: c.args.resourceUrl, @@ -763,6 +858,7 @@ export function createMppCli(inflow: Inflow, authStorage: AuthStorage, apiBaseUr cli.command('pay', { description: 'Pay an MPP-protected resource and return the seller response.', + mcp: mcpTool('mpp_pay'), args: payArgs, options: payOptions, outputPolicy: 'agent-only' as const, @@ -773,6 +869,7 @@ export function createMppCli(inflow: Inflow, authStorage: AuthStorage, apiBaseUr cli.command('status', { description: 'Poll the buyer-side state of an in-flight MPP transaction.', + mcp: mcpTool('mpp_status'), args: statusArgs, options: statusOptions, outputPolicy: 'agent-only' as const, @@ -783,6 +880,7 @@ export function createMppCli(inflow: Inflow, authStorage: AuthStorage, apiBaseUr cli.command('fetch', { description: 'Fetch an MPP-protected resource for a ready or pending payment transaction.', + mcp: mcpTool('mpp_fetch'), args: fetchArgs, options: fetchOptions, outputPolicy: 'agent-only' as const, @@ -793,6 +891,7 @@ export function createMppCli(inflow: Inflow, authStorage: AuthStorage, apiBaseUr cli.command('cancel', { description: 'Best-effort cancel of an MPP approval.', + mcp: mcpTool('mpp_cancel'), args: cancelArgs, outputPolicy: 'agent-only' as const, async run(c) { @@ -802,6 +901,7 @@ export function createMppCli(inflow: Inflow, authStorage: AuthStorage, apiBaseUr cli.command('decode', { description: 'Decode a raw WWW-Authenticate: Payment header, or a base64url credential / receipt.', + mcp: mcpTool('mpp_decode'), args: decodeArgs, outputPolicy: 'agent-only' as const, async run(c) { @@ -811,6 +911,7 @@ export function createMppCli(inflow: Inflow, authStorage: AuthStorage, apiBaseUr cli.command('supported', { description: 'List the methods the buyer can pay with - by intent, settlement rail, and currency.', + mcp: mcpTool('mpp_supported'), outputPolicy: 'agent-only' as const, async run(c) { return runSupportedCommand(c, inflow, authStorage); @@ -819,6 +920,7 @@ export function createMppCli(inflow: Inflow, authStorage: AuthStorage, apiBaseUr cli.command('inspect', { description: "Show the seller's MPP challenge(s) for a URL. Read-only probe - no auth, no payment.", + mcp: mcpTool('mpp_inspect'), args: inspectArgs, options: inspectOptions, outputPolicy: 'agent-only' as const, diff --git a/packages/cli/src/commands/mpp/pay.tsx b/packages/cli/src/commands/mpp/pay.tsx index 9c9a2f2..acda86c 100644 --- a/packages/cli/src/commands/mpp/pay.tsx +++ b/packages/cli/src/commands/mpp/pay.tsx @@ -25,6 +25,7 @@ import type React from 'react'; import { useEffect, useReducer, useState } from 'react'; import { useFlowExit } from '../../hooks/use-flow-exit.js'; import { openUrl } from '../../utils/open-url.js'; +import { AuthenticationApprovalView, type AuthenticationApprovalDisplay } from '../payment-authentication-approval.js'; export { buildBodyAttachment, @@ -54,9 +55,17 @@ export interface PayViewProps { onComplete: (final: MppPayPhase) => void; /** Best-effort cancel of the pending approval when the user presses Escape. */ onCancel?: (approvalId: string) => Promise | void; + authenticationApproval?: AuthenticationApprovalDisplay | undefined; } -export const PayView: React.FC = ({ url, method, deps, onComplete, onCancel }) => { +export const PayView: React.FC = ({ + url, + method, + deps, + onComplete, + onCancel, + authenticationApproval, +}) => { const initial: MppPayPhase = { kind: 'probing' }; const [phase, dispatch] = useReducer(reduceMppPay, initial); const [cancelling, setCancelling] = useState(false); @@ -81,7 +90,7 @@ export const PayView: React.FC = ({ url, method, deps, onComplete, }); } }, - { isActive: approvalUrl !== undefined && !cancelling }, + { isActive: authenticationApproval === undefined && approvalUrl !== undefined && !cancelling }, ); useEffect(() => { @@ -120,6 +129,10 @@ export const PayView: React.FC = ({ url, method, deps, onComplete, ); } + if (authenticationApproval !== undefined) { + return ; + } + if (phase.kind === 'probing') { return ( diff --git a/packages/cli/src/commands/payment-authentication-approval.tsx b/packages/cli/src/commands/payment-authentication-approval.tsx new file mode 100644 index 0000000..18549cf --- /dev/null +++ b/packages/cli/src/commands/payment-authentication-approval.tsx @@ -0,0 +1,87 @@ +import { Box, Text, useInput } from 'ink'; +import Spinner from 'ink-spinner'; +import type React from 'react'; +import { useMemo, useState } from 'react'; +import { openUrl } from '../utils/open-url.js'; + +export interface AuthenticationApprovalDisplay { + approvalId: string; + approvalUrl: string; + cancel: () => Promise | void; +} + +export interface AuthenticationApprovalController { + clearPendingApproval(): void; + showPendingApproval(approval: AuthenticationApprovalDisplay): boolean; +} + +export function useAuthenticationApprovalDisplay(): { + approvalDisplay: AuthenticationApprovalController; + authenticationApproval: AuthenticationApprovalDisplay | undefined; +} { + const [authenticationApproval, setAuthenticationApproval] = useState(); + const approvalDisplay = useMemo( + () => ({ + clearPendingApproval: () => setAuthenticationApproval(undefined), + showPendingApproval: (approval) => { + setAuthenticationApproval(approval); + return true; + }, + }), + [], + ); + return { approvalDisplay, authenticationApproval }; +} + +interface AuthenticationApprovalViewProps { + approval: AuthenticationApprovalDisplay; +} + +export const AuthenticationApprovalView: React.FC = ({ approval }) => { + const [cancelling, setCancelling] = useState(false); + useInput( + (_input, key) => { + if (key.return) { + openUrl(approval.approvalUrl); + return; + } + if (key.escape) { + setCancelling(true); + void approval.cancel(); + } + }, + { isActive: !cancelling }, + ); + + if (cancelling) { + return ( + + Cancelling authentication approval... + + ); + } + + return ( + + + Authentication approval required + + + {`approval: ${approval.approvalId}`} + + {'Open: '} + + {approval.approvalUrl} + + + Press Enter to open in browser. + Press Escape to cancel. + + + + Waiting for authentication approval... + + + + ); +}; diff --git a/packages/cli/src/commands/payment-fetch.tsx b/packages/cli/src/commands/payment-fetch.tsx index d4f50d1..7a2eb3e 100644 --- a/packages/cli/src/commands/payment-fetch.tsx +++ b/packages/cli/src/commands/payment-fetch.tsx @@ -11,6 +11,7 @@ import Spinner from 'ink-spinner'; import type React from 'react'; import { useEffect, useRef, useState } from 'react'; import { useFlowExit } from '../hooks/use-flow-exit.js'; +import { AuthenticationApprovalView, type AuthenticationApprovalDisplay } from './payment-authentication-approval.js'; export type PaymentFetchResult = MppFetchSuccess | MppFetchRejected | X402FetchSuccess | X402FetchRejected; @@ -29,6 +30,7 @@ export interface PaymentFetchViewProps { paymentHeader: string; events: () => AsyncIterable; onComplete: (final: PaymentFetchPhase) => void; + authenticationApproval?: AuthenticationApprovalDisplay | undefined; } function settlementLine(result: MppFetchSuccess | X402FetchSuccess): string | undefined { @@ -68,6 +70,7 @@ export const PaymentFetchView: React.FC = ({ paymentHeader, events, onComplete, + authenticationApproval, }) => { const [phase, setPhase] = useState({ kind: 'waiting' }); const cancelledRef = useRef(false); @@ -121,6 +124,9 @@ export const PaymentFetchView: React.FC = ({ }, [phase, finish]); if (phase.kind === 'waiting') { + if (authenticationApproval !== undefined) { + return ; + } return ( diff --git a/packages/cli/src/commands/vault/index.ts b/packages/cli/src/commands/vault/index.ts new file mode 100644 index 0000000..3a4e9c0 --- /dev/null +++ b/packages/cli/src/commands/vault/index.ts @@ -0,0 +1,693 @@ +import { Buffer } from 'node:buffer'; +import { spawn } from 'node:child_process'; +import { realpathSync } from 'node:fs'; +import { access } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import process from 'node:process'; +import { + LocalVaultClient, + removeVaultLocalState, + type LocalVaultDaemonInfo, + SecureStorageError, + type VaultLockState, + type VaultPolicy, + type VaultStatus, + usesLinuxVaultService, + vaultFilePaths, +} from '@inflowpayai/inflow-core'; +import { Cli } from 'incur'; +import { mcpTool } from '../../mcp-metadata.js'; +import { emptyOptions, policySetOptions, resetOptions } from './schema.js'; + +interface VaultCommandContext { + agent: boolean; + formatExplicit: boolean; + options: Record; + error: (err: { code: string; message: string }) => never; +} + +interface VaultDeps { + ensureDaemon: () => Promise; + readPassphrase: (prompt: string, context: VaultCommandContext) => Promise; + readVaultStatus: () => Promise; + write: (text: string) => void; +} + +type VaultClientLike = Pick< + LocalVaultClient, + 'changePassphrase' | 'getPolicy' | 'lock' | 'reset' | 'setPolicy' | 'status' | 'unlock' +>; +type ResetVaultClientLike = Pick; +type UnlockVaultClientLike = Pick; +type UnlockVaultDeps = { + ensureDaemon: () => Promise; + readPassphrase: (prompt: string, context: VaultCommandContext) => Promise; +}; +type ResetVaultDeps = { + client: ResetVaultClientLike; + executablePath: string; + now: () => number; + removeLocalState: (paths: ReturnType) => Promise; + sleep: (milliseconds: number) => Promise; +}; + +type VaultStatusFrame = { + lock_state: VaultLockState; +}; + +type VaultPolicyFrame = { + idle_timeout_seconds: number | null; + lock_on_sleep: boolean; +}; + +const MIN_PASSPHRASE_LENGTH = 6; + +/* v8 ignore start */ +function defaultDeps(options: LocalVaultDaemonClientOptions = {}): VaultDeps { + return { + ensureDaemon: () => ensureLocalVaultDaemon(options), + readPassphrase, + readVaultStatus: () => readVaultStatusWithoutStarting(options), + write: (text) => { + process.stdout.write(text); + }, + }; +} +/* v8 ignore stop */ + +async function runVaultStatus(c: VaultCommandContext, deps: VaultDeps = defaultDeps()): Promise { + const status = await mapSecureStorageError(c, () => deps.readVaultStatus()); + const frame = statusFrame(status); + if (!c.agent && !c.formatExplicit) deps.write(renderStatus(frame)); + return frame; +} + +async function runVaultUnlock(c: VaultCommandContext, deps: VaultDeps = defaultDeps()): Promise { + if (c.agent || c.formatExplicit) { + const status = await mapSecureStorageError(c, () => deps.readVaultStatus()); + if (status.lockState === 'unlocked') return statusFrame(status); + return c.error({ + code: 'VAULT_UNLOCK_REQUIRES_HUMAN', + message: 'A human must run `inflow vault unlock` in a terminal to unlock the local credential vault.', + }); + } + const client = await deps.ensureDaemon(); + const before = await mapSecureStorageError(c, () => client.status()); + if (before.lockState === 'unlocked') { + const frame = statusFrame(before); + deps.write('Vault already unlocked.\n'); + return frame; + } + const passphrase = await deps.readPassphrase( + before.lockState === 'not_initialized' + ? 'Create an InFlow vault PIN or passphrase: ' + : 'Enter the InFlow vault PIN or passphrase: ', + c, + ); + validatePassphrase(c, passphrase); + let status: VaultStatus; + try { + status = await mapVaultUnlockError(c, () => client.unlock(passphrase)); + } finally { + passphrase.fill(0); + } + const confirmed = await mapSecureStorageError(c, () => deps.readVaultStatus()); + if (status.lockState !== 'unlocked' || confirmed.lockState !== 'unlocked') { + return c.error({ + code: 'VAULT_UNLOCK_FAILED', + message: 'The vault could not be unlocked. Check the PIN or passphrase and try again.', + }); + } + const frame = statusFrame(status); + deps.write(before.lockState === 'not_initialized' ? 'Vault initialized and unlocked.\n' : 'Vault unlocked.\n'); + return frame; +} + +async function runVaultLock(c: VaultCommandContext, deps: VaultDeps = defaultDeps()): Promise<{ locked: true }> { + const client = await deps.ensureDaemon(); + await mapSecureStorageError(c, () => client.lock()); + if (!c.agent && !c.formatExplicit) deps.write('Vault locked.\n'); + return { locked: true }; +} + +async function runVaultPolicy(c: VaultCommandContext, deps: VaultDeps = defaultDeps()): Promise { + const client = await deps.ensureDaemon(); + const policy = await mapSecureStorageError(c, () => client.getPolicy()); + const frame = policyFrame(policy); + if (!c.agent && !c.formatExplicit) deps.write(renderPolicy(frame)); + return frame; +} + +async function runVaultPolicySet(c: VaultCommandContext, deps: VaultDeps = defaultDeps()): Promise { + const client = await deps.ensureDaemon(); + const current = await mapSecureStorageError(c, () => client.getPolicy()); + const options = parsePolicySetOptions(c.options); + const next = { + idleTimeoutSeconds: + options.idleTimeoutSeconds === undefined + ? current.idleTimeoutSeconds + : options.idleTimeoutSeconds === 0 + ? null + : options.idleTimeoutSeconds, + lockOnSleep: options.lockOnSleep ?? current.lockOnSleep, + }; + const frame = policyFrame(await mapSecureStorageError(c, () => client.setPolicy(next))); + if (!c.agent && !c.formatExplicit) deps.write(renderPolicy(frame)); + return frame; +} + +async function runVaultChangePassphrase( + c: VaultCommandContext, + deps: VaultDeps = defaultDeps(), +): Promise<{ changed: true }> { + if (c.agent || c.formatExplicit) { + return c.error({ + code: 'VAULT_CHANGE_PASSPHRASE_REQUIRES_HUMAN', + message: 'A human must run `inflow vault change-passphrase` in a terminal to change the vault passphrase.', + }); + } + const client = await deps.ensureDaemon(); + const current = await deps.readPassphrase('Enter the current InFlow vault PIN or passphrase: ', c); + const next = await deps.readPassphrase('Enter the new InFlow vault PIN or passphrase: ', c); + let confirmation: Buffer | undefined; + try { + validatePassphrase(c, next); + confirmation = await deps.readPassphrase('Confirm the new InFlow vault PIN or passphrase: ', c); + if (!confirmation.equals(next)) { + return c.error({ code: 'VAULT_PASSPHRASE_MISMATCH', message: 'The passphrases did not match.' }); + } + await mapSecureStorageError(c, () => client.changePassphrase(current, next)); + } finally { + current.fill(0); + next.fill(0); + confirmation?.fill(0); + } + deps.write('Vault passphrase changed.\n'); + return { changed: true }; +} + +async function runVaultReset(c: VaultCommandContext, deps: VaultDeps = defaultDeps()): Promise<{ reset: true }> { + const force = c.options['force']; + if (force !== true) { + return c.error({ + code: 'VAULT_RESET_REQUIRES_FORCE', + message: 'Run `inflow vault reset --force` to remove the local vault.', + }); + } + const client = await deps.ensureDaemon(); + await mapSecureStorageError(c, () => client.reset()); + if (!c.agent && !c.formatExplicit) deps.write('Vault reset complete.\n'); + return { reset: true }; +} + +export interface LocalVaultDaemonClientOptions { + buildId?: string; + cliVersion?: string; + rootDirectory?: string; +} + +export type LocalVaultUnlockMode = 'agent' | 'human'; + +/* v8 ignore next */ +export async function resetLocalVault(options: LocalVaultDaemonClientOptions = {}): Promise { + await resetLocalVaultWithDeps(options, { + client: new LocalVaultClient(options), + executablePath: process.execPath, + now: Date.now, + removeLocalState: removeVaultLocalState, + sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + }); +} + +export async function ensureLocalVaultUnlocked(options: { + mode: LocalVaultUnlockMode; + vaultOptions?: LocalVaultDaemonClientOptions; +}): Promise { + await ensureLocalVaultUnlockedWithDeps(options.mode, { + ensureDaemon: () => ensureLocalVaultDaemon(options.vaultOptions ?? {}), + readPassphrase, + }); +} + +async function ensureLocalVaultUnlockedWithDeps(mode: LocalVaultUnlockMode, deps: UnlockVaultDeps): Promise { + const client = await deps.ensureDaemon(); + const before = await client.status(); + if (before.lockState === 'unlocked') return; + if (mode === 'agent') { + throw new SecureStorageError( + before.lockState === 'not_initialized' ? 'vault_not_initialized' : 'vault_locked', + before.lockState === 'not_initialized' + ? 'The InFlow vault is not initialized. A human must run `inflow vault unlock` first.' + : 'The InFlow vault is locked. A human must run `inflow vault unlock` first.', + ); + } + const passphrase = await deps.readPassphrase( + before.lockState === 'not_initialized' + ? 'Create an InFlow vault PIN or passphrase: ' + : 'Enter the InFlow vault PIN or passphrase: ', + { + agent: false, + error(error): never { + throw new SecureStorageError('secure_storage_unavailable', error.message); + }, + formatExplicit: false, + options: {}, + }, + ); + validatePassphrase( + { + agent: false, + error(error): never { + throw new SecureStorageError('secure_storage_invalid_path', error.message); + }, + formatExplicit: false, + options: {}, + }, + passphrase, + ); + try { + await client.unlock(passphrase); + } finally { + passphrase.fill(0); + } +} + +export function createVaultCli(options: LocalVaultDaemonClientOptions = {}) { + const cli = Cli.create('vault', { description: 'Local credential vault commands' }); + + cli.command('status', { + description: 'Show local vault status.', + mcp: mcpTool('vault_status'), + options: emptyOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + return runVaultStatus(c, defaultDeps(options)); + }, + }); + + cli.command('unlock', { + description: 'Unlock or initialize the local vault.', + mcp: mcpTool('vault_unlock'), + options: emptyOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + return runVaultUnlock(c, defaultDeps(options)); + }, + }); + + cli.command('lock', { + description: 'Lock the local vault.', + mcp: mcpTool('vault_lock'), + options: emptyOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + return runVaultLock(c, defaultDeps(options)); + }, + }); + + cli.command('policy', { + description: 'Show the local vault lock policy.', + mcp: mcpTool('vault_policy'), + options: emptyOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + return runVaultPolicy(c, defaultDeps(options)); + }, + }); + + cli.command('set-policy', { + description: 'Update the local vault lock policy.', + mcp: mcpTool('vault_set-policy'), + options: policySetOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + return runVaultPolicySet(c, defaultDeps(options)); + }, + }); + + cli.command('change-passphrase', { + description: 'Change the local vault PIN or passphrase.', + mcp: mcpTool('vault_change-passphrase'), + options: emptyOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + return runVaultChangePassphrase(c, defaultDeps(options)); + }, + }); + + cli.command('reset', { + description: 'Remove the local vault database, sidecar, and runtime files.', + mcp: mcpTool('vault_reset'), + options: resetOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + return runVaultReset(c, defaultDeps(options)); + }, + }); + + return cli; +} + +function isCompatibleDaemon( + info: LocalVaultDaemonInfo, + options: LocalVaultDaemonClientOptions, + executablePath: string, +): boolean { + return ( + executableIdentityPath(info.executablePath) === executableIdentityPath(executablePath) && + info.cliVersion === (options.cliVersion ?? null) && + info.buildId === (options.buildId ?? null) + ); +} + +function executableIdentityPath(executablePath: string): string { + try { + return realpathSync.native(executablePath); + } catch { + return resolve(executablePath); + } +} + +async function resetLocalVaultWithDeps(options: LocalVaultDaemonClientOptions, deps: ResetVaultDeps): Promise { + const daemon = await existingDaemonState(options, deps.client, deps.executablePath); + if (daemon === 'compatible') { + await deps.client.reset(); + return; + } + if (daemon === 'incompatible') await shutdownReachableDaemon(deps); + await deps.removeLocalState(vaultFilePaths(options.rootDirectory)); +} + +async function existingDaemonState( + options: LocalVaultDaemonClientOptions, + client: ResetVaultClientLike, + executablePath: string, +): Promise<'compatible' | 'incompatible' | 'none'> { + try { + await client.status(); + const info = await client.info(); + return isCompatibleDaemon(info, options, executablePath) ? 'compatible' : 'incompatible'; + } catch (cause) { + if (isVaultDaemonUnavailable(cause)) return 'none'; + throw cause; + } +} + +async function shutdownReachableDaemon(deps: ResetVaultDeps): Promise { + try { + await deps.client.shutdown(); + } catch (cause) { + if (isVaultDaemonUnavailable(cause)) return; + throw cause; + } + const deadline = deps.now() + 2_000; + while (deps.now() < deadline) { + try { + await deps.client.status(); + } catch (cause) { + if (isVaultDaemonUnavailable(cause)) return; + throw cause; + } + await deps.sleep(25); + } + throw new SecureStorageError('vault_daemon_busy', 'The previous InFlow vault daemon did not stop.'); +} + +export async function readVaultStatusWithoutStarting( + options: LocalVaultDaemonClientOptions = {}, +): Promise { + const client = new LocalVaultClient(options); + try { + return await client.status(); + } catch (cause) { + if (!isVaultDaemonUnavailable(cause)) throw cause; + } + return { + daemonRunning: false, + lockState: (await vaultSidecarExists(vaultFilePaths(options.rootDirectory).sidecar)) ? 'locked' : 'not_initialized', + }; +} + +async function vaultSidecarExists(path: string): Promise { + try { + await access(path); + return true; + } catch (cause) { + if (isMissingPath(cause)) return false; + throw cause; + } +} + +/* v8 ignore start */ +export async function ensureLocalVaultDaemon(options: LocalVaultDaemonClientOptions = {}): Promise { + const client = new LocalVaultClient(options); + if (await canUseDaemon(client, options)) return client; + if (options.rootDirectory === undefined && (usesLinuxVaultService() || process.platform === 'win32')) { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + if (await canUseDaemon(client, options)) return client; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new SecureStorageError('secure_storage_unavailable', 'The InFlow vault service did not start.'); + } + await shutdownStaleDaemon(client); + const command = daemonCommand(); + const child = spawn(command.file, command.args, { + detached: true, + env: daemonEnvironment(process.env), + stdio: 'ignore', + }); + const childState = { stopped: false }; + child.once('error', () => { + childState.stopped = true; + }); + child.once('exit', () => { + childState.stopped = true; + }); + child.unref(); + const deadline = Date.now() + 60_000; + while (!childState.stopped && Date.now() < deadline) { + if (await canUseDaemon(client, options)) return client; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new SecureStorageError('secure_storage_unavailable', 'The InFlow vault daemon did not start.'); +} + +function daemonEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const result: NodeJS.ProcessEnv = {}; + for (const name of [ + 'APPDATA', + 'HOME', + 'LOCALAPPDATA', + 'SystemDrive', + 'SystemRoot', + 'TEMP', + 'TMP', + 'TMPDIR', + 'USERPROFILE', + 'WINDIR', + 'XDG_DATA_HOME', + ]) { + const value = environment[name]; + if (value !== undefined) result[name] = value; + } + return result; +} + +async function canUseDaemon(client: LocalVaultClient, options: LocalVaultDaemonClientOptions): Promise { + try { + await client.status(); + const info = await client.info(); + return isCompatibleDaemon(info, options, process.execPath); + } catch (cause) { + if (isVaultDaemonUnavailable(cause)) return false; + throw cause; + } +} + +async function shutdownStaleDaemon(client: LocalVaultClient): Promise { + try { + await client.shutdown(); + } catch (cause) { + if (isVaultDaemonUnavailable(cause)) return; + throw cause; + } + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + try { + await client.status(); + } catch { + return; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new SecureStorageError('vault_daemon_busy', 'The previous InFlow vault daemon did not stop.'); +} + +function daemonCommand(): { args: string[]; file: string } { + const script = process.argv[1]; + if (script !== undefined && script.endsWith('.js')) { + return { args: [resolve(script), '--daemon', 'vault'], file: process.execPath }; + } + return { args: ['--daemon', 'vault'], file: process.execPath }; +} +/* v8 ignore stop */ + +/* v8 ignore start */ +async function readPassphrase(prompt: string, c: VaultCommandContext): Promise { + if (process.stdin.isTTY && process.stdout.isTTY && !c.agent && !c.formatExplicit) { + return readHiddenLine(prompt); + } + return c.error({ + code: 'VAULT_UNLOCK_REQUIRES_HUMAN', + message: 'A human must run `inflow vault unlock` in a terminal to unlock the local credential vault.', + }); +} + +function readHiddenLine(prompt: string): Promise { + process.stderr.write(prompt); + return new Promise((resolve, reject) => { + const input = process.stdin; + const chars: string[] = []; + const restore = (): void => { + input.off('data', onData); + if (typeof input.setRawMode === 'function') input.setRawMode(false); + input.pause(); + process.stderr.write('\n'); + }; + const finish = (): void => { + restore(); + resolve(Buffer.from(chars.join(''), 'utf8')); + }; + const fail = (cause: Error): void => { + restore(); + reject(cause); + }; + const onData = (chunk: Buffer): void => { + const text = chunk.toString('utf8'); + for (const char of text) { + if (char === '\u0003') { + fail(new SecureStorageError('secure_storage_unavailable', 'Vault unlock cancelled.')); + return; + } + if (char === '\r' || char === '\n') { + finish(); + return; + } + if (char === '\u007f' || char === '\b') { + chars.pop(); + continue; + } + if (char >= ' ') chars.push(char); + } + }; + input.on('data', onData); + if (typeof input.setRawMode === 'function') input.setRawMode(true); + input.resume(); + }); +} + +/* v8 ignore stop */ + +function validatePassphrase(c: VaultCommandContext, passphrase: Buffer): void { + if (passphrase.toString('utf8').length >= MIN_PASSPHRASE_LENGTH) return; + passphrase.fill(0); + c.error({ + code: 'VAULT_PASSPHRASE_TOO_SHORT', + message: 'The InFlow vault PIN or passphrase must be at least 6 characters.', + }); +} + +async function mapSecureStorageError(c: VaultCommandContext, run: () => Promise): Promise { + try { + return await run(); + } catch (cause) { + if (cause instanceof SecureStorageError) { + return c.error({ code: cause.secureStorageCode, message: cause.message }); + } + throw cause; + } +} + +async function mapVaultUnlockError(c: VaultCommandContext, run: () => Promise): Promise { + try { + return await run(); + } catch (cause) { + if (cause instanceof SecureStorageError && cause.secureStorageCode === 'secure_storage_corrupt') { + return c.error({ + code: 'VAULT_UNLOCK_FAILED', + message: 'The vault could not be unlocked. Check the PIN or passphrase and try again.', + }); + } + if (cause instanceof SecureStorageError) { + return c.error({ code: cause.secureStorageCode, message: cause.message }); + } + throw cause; + } +} + +function parsePolicySetOptions(options: Record) { + return { + idleTimeoutSeconds: typeof options['idleTimeoutSeconds'] === 'number' ? options['idleTimeoutSeconds'] : undefined, + lockOnSleep: typeof options['lockOnSleep'] === 'boolean' ? options['lockOnSleep'] : undefined, + }; +} + +function statusFrame(status: VaultStatus): VaultStatusFrame { + return { + lock_state: status.lockState, + }; +} + +function policyFrame(policy: VaultPolicy): VaultPolicyFrame { + return { + idle_timeout_seconds: policy.idleTimeoutSeconds, + lock_on_sleep: policy.lockOnSleep, + }; +} + +function renderStatus(frame: VaultStatusFrame): string { + return `Vault status: ${frame.lock_state}\n`; +} + +function renderPolicy(frame: VaultPolicyFrame): string { + return [ + `Idle timeout: ${frame.idle_timeout_seconds === null ? 'disabled' : `${frame.idle_timeout_seconds}s`}`, + `Lock on sleep: ${frame.lock_on_sleep ? 'yes' : 'no'}`, + '', + ].join('\n'); +} + +function isVaultDaemonUnavailable(cause: unknown): boolean { + if (cause instanceof SecureStorageError) return cause.secureStorageCode === 'secure_storage_unavailable'; + if (!hasErrorCode(cause)) return false; + return cause.code === 'ECONNREFUSED' || cause.code === 'EINVAL' || cause.code === 'ENOENT'; +} + +function isMissingPath(cause: unknown): boolean { + return hasErrorCode(cause) && cause.code === 'ENOENT'; +} + +function hasErrorCode(value: unknown): value is { code: unknown } { + return typeof value === 'object' && value !== null && 'code' in value; +} + +export const __testing = { + runVaultChangePassphrase, + runVaultLock, + runVaultPolicy, + runVaultPolicySet, + runVaultReset, + runVaultStatus, + runVaultUnlock, + ensureLocalVaultUnlockedWithDeps, + isCompatibleDaemon, + isVaultDaemonUnavailable, + mapSecureStorageError, + mapVaultUnlockError, + parsePolicySetOptions, + readVaultStatusWithoutStarting, + renderPolicy, + resetLocalVaultWithDeps, + shutdownReachableDaemon, +}; diff --git a/packages/cli/src/commands/vault/schema.ts b/packages/cli/src/commands/vault/schema.ts new file mode 100644 index 0000000..218de46 --- /dev/null +++ b/packages/cli/src/commands/vault/schema.ts @@ -0,0 +1,20 @@ +import { z } from 'incur'; + +export const emptyOptions = z.object({}); + +export const resetOptions = z.object({ + force: z + .boolean() + .default(false) + .describe('Confirm deletion of the local vault database, sidecar, and runtime files.'), +}); + +export const policySetOptions = z.object({ + idleTimeoutSeconds: z.coerce + .number() + .int() + .min(0) + .optional() + .describe('Lock the vault after this many idle seconds. 0 disables the idle timeout.'), + lockOnSleep: z.boolean().optional().describe('Lock the vault when the computer sleeps.'), +}); diff --git a/packages/cli/src/commands/x402/index.tsx b/packages/cli/src/commands/x402/index.tsx index 222ddc1..76dd566 100644 --- a/packages/cli/src/commands/x402/index.tsx +++ b/packages/cli/src/commands/x402/index.tsx @@ -11,18 +11,23 @@ import { type PayResultSuccess, sanitizeDeep, type SellerProbeOptions, + type X402FetchEvent, type X402FetchRejected, type X402FetchSuccess, } from '@inflowpayai/inflow-core'; import type { X402BuyerSupportedResponse } from '@inflowpayai/x402'; import type { SignOptions, X402PayloadResponse } from '@inflowpayai/x402-buyer'; import { Cli } from 'incur'; +import type React from 'react'; +import { useMemo } from 'react'; import { assertSessionGuard } from '../../utils/assert-session.js'; import { authenticatedApiError } from '../../utils/api-error.js'; +import { mcpTool } from '../../mcp-metadata.js'; import { buildPaymentFetchNextCommand } from '../../utils/payment-fetch-command.js'; import { renderInkUntilExit } from '../../utils/render-ink-until-exit.js'; -import { createAepAwareInspectProbe, createAepAwareSellerTransport } from '../aep/runtime.js'; +import { type AepApprovalDisplay, createAepAwareInspectProbe, createAepAwareSellerTransport } from '../aep/runtime.js'; import { PaymentFetchView, type PaymentFetchPhase } from '../payment-fetch.js'; +import { useAuthenticationApprovalDisplay } from '../payment-authentication-approval.js'; import { CancelView } from './cancel.js'; import { DecodeView, decodeHeader, type DecodedHeader } from './decode.js'; import { @@ -194,8 +199,14 @@ function fetchProbeOptionsFrom(c: FetchCommandContext): SellerProbeOptions { }; } -function createSellerTransport(c: PayContext | FetchCommandContext, inflow: Inflow, authStorage: AuthStorage) { +function createSellerTransport( + c: PayContext | FetchCommandContext, + inflow: Inflow, + authStorage: AuthStorage, + approvalDisplay?: AepApprovalDisplay, +) { return createAepAwareSellerTransport({ + ...(approvalDisplay === undefined ? {} : { approvalDisplay }), authStorage, context: c, inflow, @@ -204,6 +215,92 @@ function createSellerTransport(c: PayContext | FetchCommandContext, inflow: Infl }); } +interface PayViewWithAuthenticationProps { + apiBaseUrl: string; + authStorage: AuthStorage; + c: PayContext; + client: PayPipelineDeps['client']; + inflow: Inflow; + onCancel: (approvalId: string) => Promise | void; + onComplete: (phase: PayPhase) => void; + probeOptions: SellerProbeOptions; +} + +const PayViewWithAuthentication: React.FC = ({ + apiBaseUrl, + authStorage, + c, + client, + inflow, + onCancel, + onComplete, + probeOptions, +}) => { + const { approvalDisplay, authenticationApproval } = useAuthenticationApprovalDisplay(); + const sellerTransport = useMemo( + () => createSellerTransport(c, inflow, authStorage, approvalDisplay), + [approvalDisplay, authStorage, c, inflow], + ); + const deps = useMemo( + () => ({ + client, + apiBaseUrl, + ...buildPayPipelineInput(c, probeOptions), + sellerTransport, + }), + [apiBaseUrl, c, client, probeOptions, sellerTransport], + ); + return ( + + ); +}; + +interface FetchViewWithAuthenticationProps { + authStorage: AuthStorage; + c: FetchCommandContext; + events: (sellerTransport: ReturnType) => AsyncIterable; + inflow: Inflow; + onComplete: (phase: PaymentFetchPhase) => void; + paymentHeader: string; + protocol: 'x402'; +} + +const FetchViewWithAuthentication: React.FC = ({ + authStorage, + c, + events, + inflow, + onComplete, + paymentHeader, + protocol, +}) => { + const { approvalDisplay, authenticationApproval } = useAuthenticationApprovalDisplay(); + const sellerTransport = useMemo( + () => createSellerTransport(c, inflow, authStorage, approvalDisplay), + [approvalDisplay, authStorage, c, inflow], + ); + const iterable = useMemo(() => () => events(sellerTransport), [events, sellerTransport]); + return ( + + ); +}; + function buildPayPipelineInput( c: PayContext, probeOptions: SellerProbeOptions, @@ -347,28 +444,17 @@ async function* runPayCommand( return c.error(invalidHeaderError(err)); } - const sellerTransport = createSellerTransport(c, inflow, authStorage); if (!c.agent && !c.formatExplicit) { const client = await inflow.x402.client(); const captured: { finalPhase: PayPhase | null } = { finalPhase: null }; await renderInkUntilExit( - { captured.finalPhase = phase; }} @@ -390,6 +476,7 @@ async function* runPayCommand( return; } + const sellerTransport = createSellerTransport(c, inflow, authStorage); const run = inflow.x402.pay({ ...buildPayPipelineInput(c, probeOptions), awaitPayment: c.options.interval > 0, @@ -437,17 +524,16 @@ async function* runFetchCommand( return c.error(invalidHeaderError(err)); } - const sellerTransport = createSellerTransport(c, inflow, authStorage); if (!c.agent && !c.formatExplicit) { const captured: { finalPhase: PaymentFetchPhase | null } = { finalPhase: null }; await renderInkUntilExit( - + events={(sellerTransport) => inflow.x402.fetch({ transactionId: c.args.transactionId, url: c.args.resourceUrl, @@ -484,6 +570,7 @@ async function* runFetchCommand( return; } + const sellerTransport = createSellerTransport(c, inflow, authStorage); const run = inflow.x402.fetch({ transactionId: c.args.transactionId, url: c.args.resourceUrl, @@ -762,6 +849,7 @@ export function createX402Cli(inflow: Inflow, authStorage: AuthStorage, apiBaseU cli.command('pay', { description: 'Pay an x402-protected resource and return the seller response.', + mcp: mcpTool('x402_pay'), args: payArgs, options: payOptions, outputPolicy: 'agent-only' as const, @@ -772,6 +860,7 @@ export function createX402Cli(inflow: Inflow, authStorage: AuthStorage, apiBaseU cli.command('status', { description: 'Poll the signing state of an in-flight x402 transaction.', + mcp: mcpTool('x402_status'), args: statusArgs, options: statusOptions, outputPolicy: 'agent-only' as const, @@ -782,6 +871,7 @@ export function createX402Cli(inflow: Inflow, authStorage: AuthStorage, apiBaseU cli.command('fetch', { description: 'Fetch an x402-protected resource for a signed or pending payment transaction.', + mcp: mcpTool('x402_fetch'), args: fetchArgs, options: fetchOptions, outputPolicy: 'agent-only' as const, @@ -792,6 +882,7 @@ export function createX402Cli(inflow: Inflow, authStorage: AuthStorage, apiBaseU cli.command('cancel', { description: 'Best-effort cancel of an x402 approval.', + mcp: mcpTool('x402_cancel'), args: cancelArgs, outputPolicy: 'agent-only' as const, async run(c) { @@ -801,6 +892,7 @@ export function createX402Cli(inflow: Inflow, authStorage: AuthStorage, apiBaseU cli.command('decode', { description: 'Decode a raw PAYMENT-REQUIRED header value.', + mcp: mcpTool('x402_decode'), args: decodeArgs, outputPolicy: 'agent-only' as const, async run(c) { @@ -810,6 +902,7 @@ export function createX402Cli(inflow: Inflow, authStorage: AuthStorage, apiBaseU cli.command('supported', { description: 'List the buyer-side capability cache (scheme x network).', + mcp: mcpTool('x402_supported'), outputPolicy: 'agent-only' as const, async run(c) { return runSupportedCommand(c, inflow, authStorage); @@ -818,6 +911,7 @@ export function createX402Cli(inflow: Inflow, authStorage: AuthStorage, apiBaseU cli.command('inspect', { description: "Show the seller's PAYMENT-REQUIRED accepts for a URL. Read-only probe - no auth, no payment.", + mcp: mcpTool('x402_inspect'), args: inspectArgs, options: inspectOptions, outputPolicy: 'agent-only' as const, diff --git a/packages/cli/src/commands/x402/pay.tsx b/packages/cli/src/commands/x402/pay.tsx index a82dcc7..4d473e7 100644 --- a/packages/cli/src/commands/x402/pay.tsx +++ b/packages/cli/src/commands/x402/pay.tsx @@ -27,6 +27,7 @@ import type React from 'react'; import { useEffect, useReducer, useState } from 'react'; import { useFlowExit } from '../../hooks/use-flow-exit.js'; import { openUrl } from '../../utils/open-url.js'; +import { AuthenticationApprovalView, type AuthenticationApprovalDisplay } from '../payment-authentication-approval.js'; import { summarizeAccepts } from './decode.js'; export { @@ -59,9 +60,17 @@ export interface PayViewProps { onComplete: (final: PayPhase) => void; /** Best-effort cancel of the pending approval when the user presses Escape. */ onCancel?: (approvalId: string) => Promise | void; + authenticationApproval?: AuthenticationApprovalDisplay | undefined; } -export const PayView: React.FC = ({ url, method, deps, onComplete, onCancel }) => { +export const PayView: React.FC = ({ + url, + method, + deps, + onComplete, + onCancel, + authenticationApproval, +}) => { const initial: PayPhase = { kind: 'probing' }; const [phase, dispatch] = useReducer(reducePay, initial); const [cancelling, setCancelling] = useState(false); @@ -84,7 +93,7 @@ export const PayView: React.FC = ({ url, method, deps, onComplete, }); } }, - { isActive: phase.kind === 'awaiting-approval' && !cancelling }, + { isActive: authenticationApproval === undefined && phase.kind === 'awaiting-approval' && !cancelling }, ); useEffect(() => { @@ -124,6 +133,10 @@ export const PayView: React.FC = ({ url, method, deps, onComplete, ); } + if (authenticationApproval !== undefined) { + return ; + } + if (phase.kind === 'probing') { return ( diff --git a/packages/cli/src/mcp-metadata.ts b/packages/cli/src/mcp-metadata.ts new file mode 100644 index 0000000..b6d9459 --- /dev/null +++ b/packages/cli/src/mcp-metadata.ts @@ -0,0 +1,129 @@ +import type { Mcp } from 'incur'; + +type ToolName = + | 'aep_enroll' + | 'aep_fetch' + | 'aep_grant' + | 'aep_inspect' + | 'aep_revoke' + | 'aep_status' + | 'auth_login' + | 'auth_logout' + | 'auth_status' + | 'balances_list' + | 'deposit-addresses_list' + | 'inspect' + | 'mpp_cancel' + | 'mpp_decode' + | 'mpp_fetch' + | 'mpp_inspect' + | 'mpp_pay' + | 'mpp_status' + | 'mpp_supported' + | 'vault_change-passphrase' + | 'vault_lock' + | 'vault_policy' + | 'vault_reset' + | 'vault_set-policy' + | 'vault_status' + | 'vault_unlock' + | 'x402_cancel' + | 'x402_decode' + | 'x402_fetch' + | 'x402_inspect' + | 'x402_pay' + | 'x402_status' + | 'x402_supported'; + +type ToolMetadata = { + description: string; + annotations: Mcp.ToolAnnotations & { title: string }; +}; + +function read(title: string, description: string, openWorldHint = true): ToolMetadata { + return { + description, + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint, + readOnlyHint: true, + title, + }, + }; +} + +function write( + title: string, + description: string, + options: { destructive?: boolean; idempotent?: boolean; openWorld?: boolean } = {}, +): ToolMetadata { + return { + description, + annotations: { + destructiveHint: options.destructive ?? false, + idempotentHint: options.idempotent ?? false, + openWorldHint: options.openWorld ?? true, + readOnlyHint: false, + title, + }, + }; +} + +const TOOLS: Record = { + aep_enroll: write('AEP: Enroll Service', 'Enroll the local agent with an AEP Service.'), + aep_fetch: write('AEP: Fetch Resource', 'Fetch a resource using AEP authentication when the Service requires it.'), + aep_grant: write('AEP: Grant Credential', 'Request and store an AEP Service credential.'), + aep_inspect: read('AEP: Inspect Service', 'Inspect an AEP Service without authentication.'), + aep_revoke: write('AEP: Revoke Credentials', 'Revoke stored AEP Service credentials.', { destructive: true }), + aep_status: read('AEP: Check Status', 'Read the local and Service lifecycle status for an AEP Service.'), + auth_login: write('InFlow Account: Log In', 'Authenticate this machine with InFlow.'), + auth_logout: write('InFlow Account: Log Out', 'Log out and clear local InFlow state.', { destructive: true }), + auth_status: read('InFlow Account: Check Login', 'Check whether this machine is authenticated with InFlow.', false), + balances_list: read('Balances: List Balances', "List the authenticated user's InFlow balances."), + 'deposit-addresses_list': read( + 'Deposit Addresses: List Deposit Addresses', + "List the authenticated user's configured deposit addresses.", + ), + inspect: read('Inspect: Inspect Resource', 'Inspect a resource for AEP, MPP, and x402 requirements.'), + mpp_cancel: write('MPP: Cancel Approval', 'Cancel an MPP approval.', { destructive: true, idempotent: true }), + mpp_decode: read('MPP: Decode Header', 'Decode an MPP Payment header, credential, or receipt.', false), + mpp_fetch: write('MPP: Fetch Resource', 'Fetch an MPP resource using an existing or pending payment transaction.'), + mpp_inspect: read('MPP: Inspect Resource', 'Inspect a resource for MPP payment requirements.'), + mpp_pay: write('MPP: Pay Resource', 'Pay for an MPP-protected resource and return the seller response.', { + destructive: true, + }), + mpp_status: read('MPP: Check Payment', 'Poll the status of an MPP payment transaction.'), + mpp_supported: read('MPP: List Payment Methods', 'List MPP payment methods available to the buyer.'), + 'vault_change-passphrase': write('Vault: Change Passphrase', 'Change the local vault PIN or passphrase.', { + destructive: true, + }), + vault_lock: write('Vault: Lock Vault', 'Lock the local credential vault.', { + idempotent: true, + openWorld: false, + }), + vault_policy: read('Vault: Show Policy', 'Show the local vault lock policy.', false), + vault_reset: write('Vault: Reset Vault', 'Remove the local vault database, sidecar, and runtime files.', { + destructive: true, + idempotent: true, + openWorld: false, + }), + 'vault_set-policy': write('Vault: Set Policy', 'Update the local vault lock policy.', { openWorld: false }), + vault_status: read('Vault: Check Status', 'Check whether the local credential vault is locked.', false), + vault_unlock: write('Vault: Unlock Vault', 'Unlock or initialize the local credential vault.', { + openWorld: false, + }), + x402_cancel: write('x402: Cancel Approval', 'Cancel an x402 approval.', { destructive: true, idempotent: true }), + x402_decode: read('x402: Decode Header', 'Decode a PAYMENT-REQUIRED header value.', false), + x402_fetch: write('x402: Fetch Resource', 'Fetch an x402 resource using an existing or pending payment transaction.'), + x402_inspect: read('x402: Inspect Resource', 'Inspect a resource for x402 payment requirements.'), + x402_pay: write('x402: Pay Resource', 'Pay for an x402-protected resource and return the seller response.', { + destructive: true, + }), + x402_status: read('x402: Check Payment', 'Poll the status of an x402 payment transaction.'), + x402_supported: read('x402: List Payment Methods', 'List x402 payment methods available to the buyer.'), +}; + +export function mcpTool(name: ToolName): ToolMetadata { + return TOOLS[name]; +} diff --git a/packages/cli/src/startup-vault.ts b/packages/cli/src/startup-vault.ts new file mode 100644 index 0000000..6c40b1d --- /dev/null +++ b/packages/cli/src/startup-vault.ts @@ -0,0 +1,66 @@ +export function commandPath(argv: readonly string[]): string[] { + const out: string[] = []; + const valueFlags = new Set([ + '--api-base-url', + '--api-key', + '--auth', + '--auth-base-url', + '--base-url', + '--environment', + '--format', + '--output', + '--skill', + ]); + for (let index = 2; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === undefined) continue; + if (arg === '--') break; + if (arg.startsWith('-')) { + if (!arg.includes('=') && valueFlags.has(arg)) { + const next = argv[index + 1]; + if (next !== undefined && !next.startsWith('-')) index += 1; + } + continue; + } + out.push(arg); + if (out.length >= 2) break; + } + return out; +} + +export function shouldStartVaultDaemon(argv: readonly string[], hasDirectApiKey = false): boolean { + if (hasDirectApiKey || argv.includes('--schema')) return false; + const [group, subcommand] = commandPath(argv); + if (group === 'auth') return subcommand === 'login' || subcommand === 'logout'; + if (group === 'aep') return subcommand !== 'inspect'; + if (group === 'mpp' || group === 'x402') { + return subcommand === 'pay' || subcommand === 'fetch' || subcommand === 'status' || subcommand === 'supported'; + } + return false; +} + +export function shouldReconcileVaultDaemon(argv: readonly string[], hasDirectApiKey = false): boolean { + if (hasDirectApiKey || argv.includes('--schema')) return false; + const [group, subcommand] = commandPath(argv); + if (group === 'auth') return subcommand === 'login' || subcommand === 'logout' || subcommand === 'status'; + if (group === 'aep') return subcommand !== 'inspect'; + if (group === 'mpp' || group === 'x402') { + return subcommand === 'pay' || subcommand === 'fetch' || subcommand === 'status' || subcommand === 'supported'; + } + return group === 'balances' || group === 'deposit-addresses' || group === 'user'; +} + +export function shouldUnlockVault( + argv: readonly string[], + options: { hasDirectApiKey?: boolean; isAgent?: boolean } = {}, +): boolean { + if (options.hasDirectApiKey === true || options.isAgent === true || argv.includes('--schema')) return false; + const [group, subcommand] = commandPath(argv); + if (group === 'auth') return subcommand === 'login'; + if (group === 'aep') return subcommand !== 'inspect'; + if (group === 'mpp' || group === 'x402') { + return subcommand === 'pay' || subcommand === 'fetch' || subcommand === 'status' || subcommand === 'supported'; + } + if (group === 'balances' || group === 'deposit-addresses' || group === 'user') return true; + return false; +} diff --git a/packages/cli/src/utils/update-probe.ts b/packages/cli/src/utils/update-probe.ts index 91cc622..a3bf62a 100644 --- a/packages/cli/src/utils/update-probe.ts +++ b/packages/cli/src/utils/update-probe.ts @@ -1,3 +1,5 @@ +import { INSTALL_INSTRUCTIONS_URL } from './user-display.js'; + export interface UpdateInfo { current: string; latest: string; @@ -10,6 +12,7 @@ export interface UpdateProbeRequest { export type UpdateProbe = (request: UpdateProbeRequest) => Promise; const RELEASES_LATEST_URL = 'https://api.github.com/repos/inflowpayai/inflow-cli/releases/latest'; +const REQUEST_TIMEOUT_MS = 2_000; const FRESH_TTL_MS = 24 * 60 * 60 * 1000; const STALE_TTL_MS = 60 * 1000; @@ -73,6 +76,7 @@ export function makeBackgroundUpdateProbe( Accept: 'application/vnd.github+json', 'User-Agent': `inflow/${cliVersion}`, }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!response.ok) { writeCache(undefined, STALE_TTL_MS); @@ -113,5 +117,9 @@ export function makeFrozenUpdateProbe(snapshot?: UpdateInfo): UpdateProbe { } export function formatUpdateNotice(info: UpdateInfo): string { - return `A newer InFlow CLI is available: ${info.latest}.\n`; + return [ + `A newer InFlow CLI is available: ${info.latest}.`, + `Upgrade with Homebrew or rerun the hosted installer: ${INSTALL_INSTRUCTIONS_URL}`, + '', + ].join('\n'); } diff --git a/packages/cli/test/integration/cli-smoke.test.ts b/packages/cli/test/integration/cli-smoke.test.ts index 0685185..44331b0 100644 --- a/packages/cli/test/integration/cli-smoke.test.ts +++ b/packages/cli/test/integration/cli-smoke.test.ts @@ -5,7 +5,7 @@ * `dist/cli.js`). * * If you want a live-sandbox smoke run, set `INFLOW_API_KEY` and `INFLOW_SMOKE_SANDBOX=1` — the gated `live sandbox` - * block below hits `user get` and `balances list` against `sandbox.inflowpay.ai`. + * block below hits `balances list` against `sandbox.inflowpay.ai`. */ import { spawn } from 'node:child_process'; import { existsSync, mkdtempSync, rmSync } from 'node:fs'; @@ -230,21 +230,6 @@ describe('cli smoke', () => { }); describe.skipIf(process.env['INFLOW_SMOKE_SANDBOX'] !== '1')('live sandbox', () => { - it('user get --format json returns a userId when INFLOW_API_KEY is valid', async () => { - const apiKey = process.env['INFLOW_API_KEY']; - if (apiKey === undefined || apiKey.length === 0) { - throw new Error('INFLOW_SMOKE_SANDBOX=1 requires INFLOW_API_KEY to be set'); - } - const result = await run(['--sandbox', 'user', 'get', '--format', 'json'], { - INFLOW_API_KEY: apiKey, - INFLOW_BASE_URL: 'https://sandbox.inflowpay.ai', - }); - expect(result.exitCode).toBe(0); - const user = parseAgentJson(result.stdout) as { userId: string }; - expect(typeof user.userId).toBe('string'); - expect(user.userId.length).toBeGreaterThan(0); - }); - it('balances list --format json returns an array', async () => { const apiKey = process.env['INFLOW_API_KEY']; if (apiKey === undefined || apiKey.length === 0) { diff --git a/packages/cli/test/unit/cli.test.ts b/packages/cli/test/unit/cli.test.ts index aa71c3f..1b10609 100644 --- a/packages/cli/test/unit/cli.test.ts +++ b/packages/cli/test/unit/cli.test.ts @@ -1,5 +1,5 @@ import { spawn, type SpawnOptionsWithoutStdio } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it, vi } from 'vitest'; @@ -22,6 +22,86 @@ const PKG_VERSION: string = ( const AEP_COMMANDS = ['aep enroll', 'aep fetch', 'aep grant', 'aep inspect', 'aep revoke', 'aep status'] as const; const AEP_MCP_TOOLS = AEP_COMMANDS.map((command) => command.replace(' ', '_')); const PAYMENT_FETCH_MCP_TOOLS = ['mpp_fetch', 'x402_fetch']; +const VAULT_COMMANDS = [ + 'vault change-passphrase', + 'vault lock', + 'vault policy', + 'vault reset', + 'vault set-policy', + 'vault status', + 'vault unlock', +] as const; +const VAULT_MCP_TOOLS = VAULT_COMMANDS.map((command) => command.replace(' ', '_')); +const MCP_TOOL_EXPECTATIONS = [ + ['aep_enroll', 'AEP: Enroll Service', false, false], + ['aep_fetch', 'AEP: Fetch Resource', false, false], + ['aep_grant', 'AEP: Grant Credential', false, false], + ['aep_inspect', 'AEP: Inspect Service', true, false], + ['aep_revoke', 'AEP: Revoke Credentials', false, true], + ['aep_status', 'AEP: Check Status', true, false], + ['auth_login', 'InFlow Account: Log In', false, false], + ['auth_logout', 'InFlow Account: Log Out', false, true], + ['auth_status', 'InFlow Account: Check Login', true, false], + ['balances_list', 'Balances: List Balances', true, false], + ['deposit-addresses_list', 'Deposit Addresses: List Deposit Addresses', true, false], + ['inspect', 'Inspect: Inspect Resource', true, false], + ['mpp_cancel', 'MPP: Cancel Approval', false, true], + ['mpp_decode', 'MPP: Decode Header', true, false], + ['mpp_fetch', 'MPP: Fetch Resource', false, false], + ['mpp_inspect', 'MPP: Inspect Resource', true, false], + ['mpp_pay', 'MPP: Pay Resource', false, true], + ['mpp_status', 'MPP: Check Payment', true, false], + ['mpp_supported', 'MPP: List Payment Methods', true, false], + ['vault_change-passphrase', 'Vault: Change Passphrase', false, true], + ['vault_lock', 'Vault: Lock Vault', false, false], + ['vault_policy', 'Vault: Show Policy', true, false], + ['vault_reset', 'Vault: Reset Vault', false, true], + ['vault_set-policy', 'Vault: Set Policy', false, false], + ['vault_status', 'Vault: Check Status', true, false], + ['vault_unlock', 'Vault: Unlock Vault', false, false], + ['x402_cancel', 'x402: Cancel Approval', false, true], + ['x402_decode', 'x402: Decode Header', true, false], + ['x402_fetch', 'x402: Fetch Resource', false, false], + ['x402_inspect', 'x402: Inspect Resource', true, false], + ['x402_pay', 'x402: Pay Resource', false, true], + ['x402_status', 'x402: Check Payment', true, false], + ['x402_supported', 'x402: List Payment Methods', true, false], +] as const; +const CLI_SCHEMA_COMMANDS = [ + 'aep enroll', + 'aep fetch', + 'aep grant', + 'aep inspect', + 'aep revoke', + 'aep status', + 'auth login', + 'auth logout', + 'auth status', + 'balances list', + 'deposit-addresses list', + 'inspect', + 'mpp cancel', + 'mpp decode', + 'mpp fetch', + 'mpp inspect', + 'mpp pay', + 'mpp status', + 'mpp supported', + 'vault change-passphrase', + 'vault lock', + 'vault policy', + 'vault reset', + 'vault set-policy', + 'vault status', + 'vault unlock', + 'x402 cancel', + 'x402 decode', + 'x402 fetch', + 'x402 inspect', + 'x402 pay', + 'x402 status', + 'x402 supported', +] as const; const RAW_SECRET_SCHEMA_FIELDS = new Set([ 'apiKey', 'api_key', @@ -229,6 +309,24 @@ describe.skipIf(!existsSync(DIST_CLI))( const combined = stdout; expect(combined).toContain('inflow'); expect(combined).toContain('agent enrollment and agentic payments'); + expect(combined).not.toContain('--daemon'); + expect(combined).not.toMatch(/^\s+user\b/m); + }); + + it('does not dispatch the internal user command', async () => { + const { exitCode, stdout, stderr } = await run(['user', 'get', '--format', 'json'], { + env: { ...process.env, NO_UPDATE_NOTIFIER: '1' }, + }); + expect(exitCode).not.toBe(0); + expect(`${stdout}${stderr}`).not.toContain('"userId"'); + }); + + it('rejects invalid hidden daemon modes before command registration', async () => { + const { exitCode, stderr } = await run(['--daemon', 'nope'], { + env: { ...process.env, NO_UPDATE_NOTIFIER: '1' }, + }); + expect(exitCode).toBe(2); + expect(stderr).toContain('Unknown daemon mode: nope'); }); it('--version prints the package.json version', async () => { @@ -305,6 +403,20 @@ describe.skipIf(!existsSync(DIST_CLI))( expect(frames[0]?.auth_method).toBe('api_key'); }); + it('auth status uses platform-local vault storage on cold start', async () => { + const root = mkdtempSync('/tmp/inflow-home-'); + try { + const { exitCode, stdout } = await run(['auth', 'status', '--format', 'json'], { + env: { ...process.env, HOME: root, NO_UPDATE_NOTIFIER: '1' }, + }); + expect(exitCode).toBe(0); + const frames = JSON.parse(stdout) as { authenticated?: boolean }[]; + expect(frames[0]?.authenticated).toBe(false); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + it('strips --verbose before incur sees it in prefix and suffix position', async () => { const cases = [ ['--verbose', '--auth', `/tmp/inflow-test-verbose-prefix-${String(process.pid)}.json`, 'auth', 'status'], @@ -436,18 +548,14 @@ describe.skipIf(!existsSync(DIST_CLI))( expect(stdout.endsWith('\n')).toBe(true); }); - it('--llms manifest lists the user get command', async () => { - const { exitCode, stdout } = await run(['--llms', '--format', 'json'], { + it.each(['--llms', '--llms-full'] as const)('%s omits the user command', async (flag) => { + const { exitCode, stdout } = await run([flag, '--format', 'json'], { env: { ...process.env, NO_UPDATE_NOTIFIER: '1' }, }); expect(exitCode).toBe(0); - const manifest = JSON.parse(stdout) as { - commands: { name: string; description?: string }[]; - }; + const manifest = JSON.parse(stdout) as { commands: { name: string }[] }; const names = manifest.commands.map((c) => c.name); - expect(names).toContain('user get'); - const userGet = manifest.commands.find((c) => c.name === 'user get'); - expect(userGet?.description).toBe('Retrieve the current authenticated user'); + expect(names).not.toContain('user get'); }); it('--llms manifest lists the balances list command', async () => { @@ -486,16 +594,14 @@ describe.skipIf(!existsSync(DIST_CLI))( expect(names).toEqual(expect.arrayContaining([...AEP_COMMANDS])); }); - it('user get --schema returns an empty-properties JSON Schema', async () => { - const { exitCode, stdout } = await run(['user', 'get', '--schema', '--format', 'json'], { + it.each(['--llms', '--llms-full'] as const)('%s lists every vault command', async (flag) => { + const { exitCode, stdout } = await run([flag, '--format', 'json'], { env: { ...process.env, NO_UPDATE_NOTIFIER: '1' }, }); expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout) as { - options?: { type?: string; properties?: Record }; - }; - expect(parsed.options?.type).toBe('object'); - expect(parsed.options?.properties ?? {}).toEqual({}); + const manifest = JSON.parse(stdout) as { commands: { name: string }[] }; + const names = manifest.commands.map((command) => command.name); + expect(names).toEqual(expect.arrayContaining([...VAULT_COMMANDS])); }); it('balances list --schema returns an empty-properties JSON Schema', async () => { @@ -522,7 +628,26 @@ describe.skipIf(!existsSync(DIST_CLI))( expect(parsed.options?.properties ?? {}).toEqual({}); }); - it('--mcp tools/list exposes user_get with an empty input schema', async () => { + it.each(CLI_SCHEMA_COMMANDS)('%s exposes a JSON Schema without starting runtime work', async (command) => { + const { exitCode, stdout, stderr } = await run([...command.split(' '), '--schema', '--format', 'json'], { + env: { ...process.env, NO_UPDATE_NOTIFIER: '1' }, + }); + expect(stderr).toBe(''); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout) as { + args?: { type?: string; properties?: Record }; + options?: { type?: string; properties?: Record }; + }; + const schema = parsed.options ?? parsed.args; + if (schema === undefined) { + expect(parsed).toEqual({}); + } else { + expect(schema.type).toBe('object'); + expect(schema.properties ?? {}).toBeTypeOf('object'); + } + }); + + it('--mcp tools/list exposes the expected public tools and omits user_get', async () => { const request = JSON.stringify({ jsonrpc: '2.0', @@ -541,19 +666,54 @@ describe.skipIf(!existsSync(DIST_CLI))( result?: { tools?: { name: string; + title?: string; + annotations?: { + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + readOnlyHint?: boolean; + title?: string; + }; inputSchema?: { type?: string; properties?: Record }; }[]; }; }; const tools = response.result?.tools ?? []; - const tool = tools.find((t) => t.name === 'user_get'); - expect(tool).toBeDefined(); - expect(tool?.inputSchema?.type).toBe('object'); - expect(tool?.inputSchema?.properties ?? {}).toEqual({}); + expect(tools.map((entry) => entry.name).sort()).toEqual(MCP_TOOL_EXPECTATIONS.map(([name]) => name).sort()); + for (const [name, title, readOnlyHint, destructiveHint] of MCP_TOOL_EXPECTATIONS) { + expect( + tools.find((entry) => entry.name === name), + name, + ).toMatchObject({ + title, + annotations: { destructiveHint, readOnlyHint, title }, + inputSchema: { type: 'object' }, + }); + } + expect(tools.map((entry) => entry.name)).not.toContain('user_get'); + expect(tools.find((entry) => entry.name === 'auth_status')).toMatchObject({ + title: 'InFlow Account: Check Login', + annotations: { readOnlyHint: true, title: 'InFlow Account: Check Login' }, + }); + expect(tools.find((entry) => entry.name === 'mpp_pay')).toMatchObject({ + title: 'MPP: Pay Resource', + annotations: { destructiveHint: true, readOnlyHint: false, title: 'MPP: Pay Resource' }, + }); + expect(tools.find((entry) => entry.name === 'vault_unlock')).toMatchObject({ + title: 'Vault: Unlock Vault', + annotations: { openWorldHint: false, readOnlyHint: false, title: 'Vault: Unlock Vault' }, + }); + const titles = tools.map((entry) => entry.title ?? entry.name); + expect(titles).toEqual([...titles].sort((a, b) => a.localeCompare(b))); + expect(titles.filter((title) => /^\d+:/.test(title))).toEqual([]); expect(tools.map((entry) => entry.name)).toEqual(expect.arrayContaining(AEP_MCP_TOOLS)); for (const name of AEP_MCP_TOOLS) { expect(tools.find((entry) => entry.name === name)?.inputSchema?.type, name).toBe('object'); } + expect(tools.map((entry) => entry.name)).toEqual(expect.arrayContaining(VAULT_MCP_TOOLS)); + for (const name of VAULT_MCP_TOOLS) { + expect(tools.find((entry) => entry.name === name)?.inputSchema?.type, name).toBe('object'); + } expect(tools.map((entry) => entry.name)).toEqual(expect.arrayContaining(PAYMENT_FETCH_MCP_TOOLS)); for (const name of PAYMENT_FETCH_MCP_TOOLS) { const fetchTool = tools.find((entry) => entry.name === name); @@ -582,27 +742,37 @@ describe.skipIf(!existsSync(DIST_CLI))( expect(rawSecretFields).toEqual([]); }); - it('user get --format json without auth emits NOT_AUTHENTICATED and exits 1', async () => { - const cleanEnv: Record = { - ...process.env, - NO_UPDATE_NOTIFIER: '1', - INFLOW_API_KEY: undefined, - INFLOW_AUTH_FILE: undefined, - }; - for (const key of Object.keys(cleanEnv)) { - if (cleanEnv[key] === undefined) delete cleanEnv[key]; + it('--mcp vault tools hide daemon details and do not prompt for unlock input', async () => { + const root = mkdtempSync('/tmp/inflow-mcp-home-'); + const request = [ + { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'vault_status', arguments: {} } }, + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'vault_unlock', arguments: {} } }, + ] + .map((entry) => JSON.stringify(entry)) + .join('\n'); + try { + const { exitCode, stdout } = await run(['--mcp'], { + env: { ...process.env, HOME: root, NO_UPDATE_NOTIFIER: '1' }, + stdin: `${request}\n`, + }); + expect(exitCode).toBe(0); + const responses = stdout + .split('\n') + .filter((line) => line.trim().length > 0) + .map( + (line) => + JSON.parse(line) as { id?: number; result?: { content?: { text?: string }[]; isError?: boolean } }, + ); + const status = responses.find((entry) => entry.id === 1); + const unlock = responses.find((entry) => entry.id === 2); + const statusText = status?.result?.content?.[0]?.text ?? ''; + expect(JSON.parse(statusText)).toEqual({ lock_state: 'not_initialized' }); + expect(statusText).not.toContain('daemon'); + expect(unlock?.result?.isError).toBe(true); + expect(unlock?.result?.content?.[0]?.text).toContain('A human must run `inflow vault unlock`'); + } finally { + rmSync(root, { force: true, recursive: true }); } - const { exitCode, stdout } = await run( - ['--auth', '/tmp/inflow-test-no-auth.json', 'user', 'get', '--format', 'json'], - { env: cleanEnv }, - ); - expect(exitCode).toBe(1); - const payload = JSON.parse(stdout) as { - code?: string; - message?: string; - }; - expect(payload.code).toBe('NOT_AUTHENTICATED'); - expect(payload.message).toContain('Not authenticated.'); }); }, ); diff --git a/packages/cli/test/unit/commands/aep/index.test.ts b/packages/cli/test/unit/commands/aep/index.test.ts index c824681..4861327 100644 --- a/packages/cli/test/unit/commands/aep/index.test.ts +++ b/packages/cli/test/unit/commands/aep/index.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { AepFetchError, AepStorage, MemoryStorage } from '@inflowpayai/inflow-core'; +import { AepFetchError, AepStorage, MemoryStorage, SecureStorageError } from '@inflowpayai/inflow-core'; import { AepCommandError, AepInspectError } from '@aep-foundation/agent'; import type { InspectServiceResult } from '@aep-foundation/agent'; import type * as InflowCore from '@inflowpayai/inflow-core'; @@ -7,13 +7,17 @@ import type * as InflowCore from '@inflowpayai/inflow-core'; const platformRecovery = vi.hoisted<{ identity: unknown; identityNotFoundOnSign: boolean; + immediateSignResult: 'completed' | 'string' | undefined; notRecognized: boolean; provisioned: boolean; + statusErrorCode: string | undefined; }>(() => ({ identity: undefined, identityNotFoundOnSign: false, + immediateSignResult: undefined, notRecognized: false, provisioned: false, + statusErrorCode: undefined, })); const fetchScenario = vi.hoisted(() => ({ @@ -106,6 +110,14 @@ vi.mock('@aep-foundation/agent', () => { error.problem = { code: 'agent_identity_not_found' }; throw error; } + if (platformRecovery.immediateSignResult === 'string') return 'immediate-assertion'; + if (platformRecovery.immediateSignResult === 'completed') { + return { + clientAssertion: 'immediate-assertion', + platformContext: { approved_claims: { 'contact.email': 'agent@example.test' } }, + status: 'completed' as const, + }; + } if (context.platformContext?.['claims'] !== undefined || context.platformContext?.['grant_type'] !== undefined) { return { platformContext: { approval_id: 'approval-1' }, retryAfterSeconds: 1, status: 'pending' as const }; } @@ -158,10 +170,14 @@ vi.mock('@aep-foundation/agent', () => { serviceDid: identity.serviceDid, }), statusService: () => { - if (platformRecovery.notRecognized || platformRecovery.provisioned) { + if ( + platformRecovery.notRecognized || + platformRecovery.provisioned || + platformRecovery.statusErrorCode !== undefined + ) { platformRecovery.provisioned = false; const error = new AepCommandError(); - error.problem = { code: 'not_recognized' }; + error.problem = { code: platformRecovery.statusErrorCode ?? 'not_recognized' }; throw error; } return { body: { status: 'active' } }; @@ -196,8 +212,10 @@ function inflow() { afterEach(() => { platformRecovery.identity = undefined; platformRecovery.identityNotFoundOnSign = false; + platformRecovery.immediateSignResult = undefined; platformRecovery.notRecognized = false; platformRecovery.provisioned = false; + platformRecovery.statusErrorCode = undefined; probeScenario.classification = 'success'; probeScenario.calls = 0; probeScenario.status = 204; @@ -334,6 +352,18 @@ describe('aep commands', () => { code: 'AEP_INTERNAL_ERROR', message: 'The AEP command failed unexpectedly.', }); + expect( + __testing.commandError(new SecureStorageError('secure_storage_secret_missing', 'The InFlow vault is locked.')), + ).toEqual({ + code: 'VAULT_LOCKED', + message: 'The InFlow vault is locked.', + }); + expect( + __testing.commandError(new SecureStorageError('vault_not_initialized', 'The InFlow vault is not initialized.')), + ).toEqual({ + code: 'VAULT_NOT_INITIALIZED', + message: 'The InFlow vault is not initialized.', + }); }); it('returns the complete anonymous fetch JSON contract without requiring a session', async () => { @@ -727,10 +757,15 @@ describe('aep commands', () => { __testing.runEnroll(context({ interval: 1, maxAttempts: 1, timeout: 1 }), client, storage), ).resolves.toEqual({ status: 'active' }); await expect(__testing.runStatus(context(), client, storage)).resolves.toMatchObject({ - local: { grants: [] }, + local: { + available_grant_types: ['oauth-bearer'], + grants: [], + }, service: { status: 'active' }, }); - await expect(__testing.runGrant(context({ scope: ['read', 'read'] }), client, storage)).resolves.toEqual({ + await expect( + __testing.runGrant(context({ interval: 1, scope: ['read', 'read'] }), client, storage), + ).resolves.toEqual({ credential_id: 'credential-1', expires_at: '2999-01-01T00:00:00.000Z', grant_type: 'oauth-bearer', @@ -738,12 +773,46 @@ describe('aep commands', () => { service_did: 'did:web:service.example', scopes: ['read'], }); + await expect(__testing.runStatus(context(), client, storage)).resolves.toMatchObject({ + local: { + grants: [ + { + credential_id: 'credential-1', + grant_type: 'oauth-bearer', + scopes: ['read'], + status: 'active', + usable: true, + }, + ], + }, + service: { status: 'active' }, + }); await expect(__testing.runRevoke(context(), client, storage)).resolves.toEqual({ all_grant_types: true, revoked: true, }); + await expect(__testing.runStatus(context(), client, storage)).resolves.toMatchObject({ + local: { grants: [] }, + service: { status: 'active' }, + }); }); + it.each(['agent_identity_not_found', 'not_recognized'])( + 'maps Status %s failures to an actionable not-enrolled error', + async (code) => { + const storage = new MemoryStorage(); + storage.setApiKey('key'); + const aepStorage = new AepStorage(storage, { + platformOrigin: 'https://platform.example', + userId: 'user-1', + }); + await aepStorage.identities().saveIdentity(identity); + platformRecovery.statusErrorCode = code; + + await expect(__testing.runStatus(context(), inflow(), storage)).rejects.toThrow('AEP_NOT_ENROLLED'); + }, + ); + it('reports AEP and unrelated authentication classifications for the exact resource', async () => { probeScenario.classification = 'aep-challenge'; probeScenario.status = 401; @@ -810,6 +879,124 @@ describe('aep commands', () => { expect(approvalFetch).toHaveBeenCalledTimes(approvalRequests); }); + it.each(['completed', 'string'] as const)('accepts an immediate %s Platform signature', async (resultKind) => { + platformRecovery.immediateSignResult = resultKind; + const storage = new MemoryStorage(); + storage.setApiKey('key'); + + await expect(__testing.runEnroll(context({ maxAttempts: 1, timeout: 1 }), inflow(), storage)).resolves.toEqual({ + status: 'active', + }); + }); + + it('returns an agentic pending approval frame for a new enrollment without polling inline', async () => { + const approvalFetch = vi.fn(); + vi.stubGlobal('fetch', approvalFetch); + const storage = new MemoryStorage(); + storage.setApiKey('key'); + + await expect(__testing.runEnroll(context({ maxAttempts: 0, timeout: 900 }), inflow(), storage)).resolves.toEqual({ + _next: { + poll_interval_seconds: 1, + until: 'enrollment completes', + }, + approval_id: 'approval-1', + approval_url: 'https://platform.example/approvals/approval-1/view/', + instruction: + 'Present the approval_url to the user and ask them to approve in the InFlow mobile app or dashboard. Then call AEP enroll again with the approval id.', + service_did: 'did:web:service.example', + state: 'pending', + retry_after_seconds: 1, + }); + expect(approvalFetch).not.toHaveBeenCalled(); + }); + + it('continues an agentic enrollment approval and returns the Service response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response(JSON.stringify({ status: 'APPROVED' }), { status: 200 }))), + ); + platformRecovery.notRecognized = true; + const storage = new MemoryStorage(); + storage.setApiKey('key'); + const persisted = new AepStorage(storage, { + platformOrigin: 'https://platform.example', + userId: 'user-1', + }); + await persisted.identities().saveIdentity(identity); + + await expect( + __testing.runEnroll( + context({ approvalId: 'approval-1', interval: 1, maxAttempts: 1, timeout: 1 }), + inflow(), + storage, + ), + ).resolves.toEqual({ status: 'active' }); + }); + + it('returns an agentic pending approval frame for Grant without polling inline', async () => { + const approvalFetch = vi.fn(); + vi.stubGlobal('fetch', approvalFetch); + const storage = new MemoryStorage(); + storage.setApiKey('key'); + const persisted = new AepStorage(storage, { + platformOrigin: 'https://platform.example', + userId: 'user-1', + }); + await persisted.identities().saveIdentity(identity); + + await expect(__testing.runGrant(context({ scope: ['read'], timeout: 900 }), inflow(), storage)).resolves.toEqual({ + _next: { + poll_interval_seconds: 1, + until: 'credential grant completes', + }, + approval_id: 'approval-1', + approval_url: 'https://platform.example/approvals/approval-1/view/', + grant_type: 'oauth-bearer', + instruction: + 'Present the approval_url to the user and ask them to approve in the InFlow mobile app or dashboard. Then call AEP grant again with the approval id.', + service_did: 'did:web:service.example', + state: 'pending', + retry_after_seconds: 1, + }); + expect(approvalFetch).not.toHaveBeenCalled(); + }); + + it('continues an agentic Grant approval and stores the issued credential', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response(JSON.stringify({ status: 'APPROVED' }), { status: 200 }))), + ); + const storage = new MemoryStorage(); + storage.setApiKey('key'); + const persisted = new AepStorage(storage, { + platformOrigin: 'https://platform.example', + userId: 'user-1', + }); + await persisted.identities().saveIdentity(identity); + + await expect( + __testing.runGrant( + context({ + approvalId: 'approval-1', + grantType: 'oauth-bearer', + interval: 1, + scope: ['read'], + timeout: 1, + }), + inflow(), + storage, + ), + ).resolves.toEqual({ + credential_id: 'credential-1', + expires_at: '2999-01-01T00:00:00.000Z', + grant_type: 'oauth-bearer', + granted: true, + service_did: 'did:web:service.example', + scopes: ['read'], + }); + }); + it('rehydrates a missing local identity from the Platform before checking Status', async () => { platformRecovery.identity = identity; const approvalFetch = vi.fn(); diff --git a/packages/cli/test/unit/commands/aep/runtime.test.ts b/packages/cli/test/unit/commands/aep/runtime.test.ts index 042a533..ad1e438 100644 --- a/packages/cli/test/unit/commands/aep/runtime.test.ts +++ b/packages/cli/test/unit/commands/aep/runtime.test.ts @@ -537,6 +537,38 @@ describe('CLI AEP approval resolver', () => { }); }); + it('lets an outer human payment view own the pending approval display', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(Response.json({ status: 'APPROVED' })); + const showPendingApproval = vi.fn(() => true); + const clearPendingApproval = vi.fn(); + const options = createCliAepAgentOptions({ + approvalDisplay: { clearPendingApproval, showPendingApproval }, + authStorage: new MemoryStorage(), + context: { + agent: false, + error: (error: { code: string }): never => { + throw new Error(error.code); + }, + formatExplicit: false, + }, + inflow: inflow(), + interval: 0.001, + timeout: 1, + }); + + const result = await options.pendingSignResolver?.( + resolverInput(() => Promise.resolve({ clientAssertion: 'jwt', status: 'completed' })), + ); + + expect(result).toEqual({ clientAssertion: 'jwt', status: 'completed' }); + expect(showPendingApproval).toHaveBeenCalledWith({ + approvalId: 'approval-1', + approvalUrl: 'https://platform.example/approvals/approval-1/view/', + cancel: expect.any(Function) as () => Promise, + }); + expect(clearPendingApproval).toHaveBeenCalledOnce(); + }); + it('maps declined approval to the stable AEP denial error', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(Response.json({ status: 'DECLINED' })); const options = createCliAepAgentOptions({ diff --git a/packages/cli/test/unit/commands/aep/views.test.tsx b/packages/cli/test/unit/commands/aep/views.test.tsx index c4788f2..03c52d4 100644 --- a/packages/cli/test/unit/commands/aep/views.test.tsx +++ b/packages/cli/test/unit/commands/aep/views.test.tsx @@ -99,7 +99,7 @@ describe('AEP views', () => { it('explains JWT authentication when no session credentials are stored', () => { const view = render( undefined} service={{ status: 'active' }} @@ -109,6 +109,10 @@ describe('AEP views', () => { expect(view.lastFrame()).toContain('Authentication'); expect(view.lastFrame()).toContain('AEP JWT'); expect(view.lastFrame()).toContain('None'); + expect(view.lastFrame()).toContain('oauth-bearer'); + expect(view.lastFrame()).not.toContain('Stored Session Credentials'); + expect(view.lastFrame()).not.toContain('Credential ID'); + expect(view.lastFrame()).not.toContain('Enrollment'); expect(view.lastFrame()).not.toContain('Local grants'); view.unmount(); }); @@ -147,7 +151,7 @@ describe('AEP views', () => { const status = render( { expect(status.lastFrame()).toContain('Authentication'); expect(status.lastFrame()).toContain('Available credential types'); expect(status.lastFrame()).toContain('oauth-bearer'); + expect(status.lastFrame()).toContain('Stored Session Credentials'); + expect(status.lastFrame()).toMatch(/\n\nStored Session Credentials/); + expect(status.lastFrame()).toContain('Grant Type'); + expect(status.lastFrame()).toContain('Credential ID'); + expect(status.lastFrame()).toContain('Status'); + expect(status.lastFrame()).toContain('Scopes'); + expect(status.lastFrame()).toContain('Expires'); + expect(status.lastFrame()).toContain('credential-1'); + expect(status.lastFrame()).toContain('active'); + expect(status.lastFrame()).not.toContain('Enrollment'); status.unmount(); const granted = render( { renderMock.mockResolvedValueOnce(undefined); const storage = new MemoryStorage(sampleTokens); const ctx = { agent: false, formatExplicit: false }; + const resetVault = vi.fn(() => Promise.resolve()); const result = await runAuthLogout(ctx, { authResource: authStub(), authStorage: storage, + resetVault, }); expect(result).toEqual({ authenticated: false }); expect(renderMock).toHaveBeenCalledOnce(); + expect(resetVault).toHaveBeenCalledOnce(); }); }); diff --git a/packages/cli/test/unit/commands/auth/index.test.ts b/packages/cli/test/unit/commands/auth/index.test.ts index ddac0fa..389f1cf 100644 --- a/packages/cli/test/unit/commands/auth/index.test.ts +++ b/packages/cli/test/unit/commands/auth/index.test.ts @@ -2,12 +2,15 @@ import { augmentAuth, type AuthStorage, type AuthTokens, + type ConnectionSettings, type DeviceAuthRequest, type IAuth, type IAuthResource, InflowApiError, type IUserResource, MemoryStorage, + type PendingDeviceAuth, + SecureStorageError, type User, } from '@inflowpayai/inflow-core'; import { describe, expect, it, vi } from 'vitest'; @@ -115,6 +118,24 @@ async function drainGenerator(gen: AsyncGenerator): Promise { return out; } +class LockedStorage extends MemoryStorage { + override getAuth(): AuthTokens | null { + throw new SecureStorageError('vault_locked', 'The InFlow vault is locked.'); + } + + override getApiKey(): string | null { + throw new SecureStorageError('vault_locked', 'The InFlow vault is locked.'); + } + + override getConnection(): ConnectionSettings | null { + throw new SecureStorageError('vault_locked', 'The InFlow vault is locked.'); + } + + override getPendingDeviceAuth(): PendingDeviceAuth | null { + throw new SecureStorageError('vault_locked', 'The InFlow vault is locked.'); + } +} + describe('runAuthLogin (agent mode)', () => { it('rejects empty client-name with INVALID_INPUT', async () => { const ctx = makeContext({ @@ -173,6 +194,37 @@ describe('runAuthLogin (agent mode)', () => { expect(storage.getPendingDeviceAuth()?.device_code).toBe(baseRequest.device_code); }); + it('unlocks the local vault before initiating device auth', async () => { + const ctx = makeContext({ + clientName: 'Test', + interval: 0, + maxAttempts: 0, + timeout: 300, + }); + const storage = new MemoryStorage(); + const auth = makeAuthResource(storage); + const user = makeUserResource(); + const ensureVaultUnlocked = vi.fn(() => Promise.resolve()); + + await drainGenerator( + runAuthLogin( + ctx, + { + authResource: auth.resource, + userResource: user.resource, + authStorage: storage, + ensureVaultUnlocked, + }, + defaultAuthCtx, + ), + ); + + expect(ensureVaultUnlocked).toHaveBeenCalledWith('agent'); + expect(ensureVaultUnlocked.mock.invocationCallOrder[0]).toBeLessThan( + auth.initiateDeviceAuth.mock.invocationCallOrder[0] ?? 0, + ); + }); + it('initiateDeviceAuth failure surfaces DEVICE_AUTH_INITIATE_FAILED', async () => { const ctx = makeContext({ clientName: 'Test', @@ -282,13 +334,16 @@ describe('runAuthLogout (agent mode)', () => { expires_in: 3600, }); const auth = makeAuthResource(storage); + const resetVault = vi.fn(() => Promise.resolve()); const result = await runAuthLogout(ctx, { authResource: auth.resource, authStorage: storage, + resetVault, }); expect(result).toEqual({ authenticated: false }); expect(auth.revokeToken).toHaveBeenCalledWith('r'); expect(storage.getAuth()).toBeNull(); + expect(resetVault).toHaveBeenCalledOnce(); }); it('still succeeds when revokeToken rejects', async () => { @@ -302,12 +357,59 @@ describe('runAuthLogout (agent mode)', () => { const auth = makeAuthResource(storage, { revokeToken: vi.fn(() => Promise.reject(new Error('network'))), }); + const resetVault = vi.fn(() => Promise.resolve()); const result = await runAuthLogout(ctx, { authResource: auth.resource, authStorage: storage, + resetVault, }); expect(result).toEqual({ authenticated: false }); expect(storage.getAuth()).toBeNull(); + expect(resetVault).toHaveBeenCalledOnce(); + }); + + it('still resets the vault when stored secure material is unreadable', async () => { + const ctx = makeContext({}); + const storage = new MemoryStorage(); + const authResource = { + logout: vi.fn(() => + Promise.reject(new SecureStorageError('secure_storage_secret_missing', 'The InFlow vault is locked.')), + ), + } as unknown as IAuth; + const resetVault = vi.fn(() => Promise.resolve()); + + const result = await runAuthLogout(ctx, { + authResource, + authStorage: storage, + resetVault, + }); + + expect(result).toEqual({ authenticated: false }); + expect(resetVault).toHaveBeenCalledOnce(); + }); + + it('reports logout failure when vault cleanup cannot reach the daemon', async () => { + const ctx = makeContext({}); + const storage = new MemoryStorage({ + access_token: 'a', + refresh_token: 'r', + token_type: 'Bearer', + expires_in: 3600, + }); + const auth = makeAuthResource(storage); + const resetVault = vi.fn(() => + Promise.reject(new SecureStorageError('secure_storage_unavailable', 'The InFlow vault daemon did not start.')), + ); + + await expect( + runAuthLogout(ctx, { + authResource: auth.resource, + authStorage: storage, + resetVault, + }), + ).rejects.toMatchObject({ secureStorageCode: 'secure_storage_unavailable' }); + expect(storage.getAuth()).toBeNull(); + expect(resetVault).toHaveBeenCalledOnce(); }); it('clears api key and connection in addition to tokens (full reset)', async () => { @@ -324,13 +426,16 @@ describe('runAuthLogout (agent mode)', () => { apiBaseUrl: 'https://dev.inflowpay.ai', }); const auth = makeAuthResource(storage); + const resetVault = vi.fn(() => Promise.resolve()); await runAuthLogout(ctx, { authResource: auth.resource, authStorage: storage, + resetVault, }); expect(storage.getAuth()).toBeNull(); expect(storage.getApiKey()).toBeNull(); expect(storage.getConnection()).toBeNull(); + expect(resetVault).toHaveBeenCalledOnce(); }); }); @@ -487,6 +592,32 @@ describe('runAuthStatus (agent mode)', () => { expect(yields[0]).not.toHaveProperty('credentials_path'); }); + it('reports the locked vault instead of claiming the user is unauthenticated', async () => { + const ctx = makeContext({ + interval: 0, + maxAttempts: 0, + timeout: 60, + probe: false, + }); + const storage = new LockedStorage(); + const auth = makeAuthResource(storage); + const user = makeUserResource(); + await expect( + drainGenerator( + runAuthStatus( + ctx, + { + authResource: auth.resource, + userResource: user.resource, + authStorage: storage, + updateProbe: undefined, + }, + defaultAuthCtx, + ), + ), + ).rejects.toMatchObject({ secureStorageCode: 'vault_locked' }); + }); + it('includes credentials_path in the agent payload when ctx.verbose=true', async () => { const ctx = makeContext({ interval: 0, diff --git a/packages/cli/test/unit/commands/auth/status.test.tsx b/packages/cli/test/unit/commands/auth/status.test.tsx index 8ca8edc..8379aad 100644 --- a/packages/cli/test/unit/commands/auth/status.test.tsx +++ b/packages/cli/test/unit/commands/auth/status.test.tsx @@ -137,6 +137,7 @@ describe('AuthStatus (TTY component)', () => { ); await vi.waitFor(() => { expect(lastFrame()).toContain('A newer InFlow CLI is available: 0.2.0.'); + expect(lastFrame()).toContain('Upgrade with Homebrew or rerun the hosted installer: https://inflowcli.ai/'); }); unmount(); }); diff --git a/packages/cli/test/unit/commands/mpp/pay-view.test.tsx b/packages/cli/test/unit/commands/mpp/pay-view.test.tsx index d360833..d03678e 100644 --- a/packages/cli/test/unit/commands/mpp/pay-view.test.tsx +++ b/packages/cli/test/unit/commands/mpp/pay-view.test.tsx @@ -165,4 +165,45 @@ describe('PayView', () => { expect(vi.mocked(openUrl)).toHaveBeenCalledWith(expect.stringContaining('ap-9')); unmount(); }); + + it('renders an authentication approval before payment approval phases', async () => { + const delayedProbe = new Promise<{ + bytes: Uint8Array; + contentType: string | undefined; + headers: Headers; + status: number; + }>((resolve) => { + setTimeout( + () => resolve({ bytes: new Uint8Array(), contentType: undefined, headers: new Headers(), status: 200 }), + 100, + ); + }); + const { lastFrame, stdin, unmount } = render( + delayedProbe, + }, + }} + authenticationApproval={{ + approvalId: 'auth-1', + approvalUrl: 'https://mpp.test/approvals/auth-1/view/', + cancel: vi.fn(), + }} + onComplete={vi.fn()} + />, + ); + + await new Promise((r) => setTimeout(r, 20)); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Authentication approval required'); + expect(frame).toContain('auth-1'); + stdin.write('\r'); + await new Promise((r) => setTimeout(r, 20)); + expect(vi.mocked(openUrl)).toHaveBeenCalledWith('https://mpp.test/approvals/auth-1/view/'); + unmount(); + }); }); diff --git a/packages/cli/test/unit/commands/vault/index.test.ts b/packages/cli/test/unit/commands/vault/index.test.ts new file mode 100644 index 0000000..5c2ba85 --- /dev/null +++ b/packages/cli/test/unit/commands/vault/index.test.ts @@ -0,0 +1,746 @@ +import { Buffer } from 'node:buffer'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { __testing, createVaultCli } from '../../../../src/commands/vault/index.js'; +import { SecureStorageError, type VaultPolicy, type VaultStatus } from '@inflowpayai/inflow-core'; + +type ErrorShape = { code: string; message: string }; +type VaultTestClient = { + changePassphrase: ReturnType; + getPolicy: ReturnType; + lock: ReturnType; + reset: ReturnType; + setPolicy: ReturnType; + status: ReturnType; + unlock: ReturnType; +}; +type VaultTestDeps = { + client: VaultTestClient; + ensureDaemon: ReturnType; + readPassphrase: ReturnType; + readVaultStatus: ReturnType; + write: ReturnType; +}; +type ResetVaultTestDeps = { + client: { + info: ReturnType; + reset: ReturnType; + shutdown: ReturnType; + status: ReturnType; + }; + executablePath: string; + now: ReturnType; + removeLocalState: ReturnType; + sleep: ReturnType; +}; + +function context(options: Record = {}, agent = true) { + return { + agent, + error(error: ErrorShape): never { + throw Object.assign(new Error(error.message), error); + }, + formatExplicit: agent, + options, + }; +} + +function deps(overrides: Partial = {}): VaultTestDeps { + const client = { + changePassphrase: vi.fn(() => Promise.resolve()), + getPolicy: vi.fn(() => + Promise.resolve({ + idleTimeoutSeconds: 28_800, + lockOnSleep: true, + } satisfies VaultPolicy), + ), + lock: vi.fn(() => Promise.resolve()), + reset: vi.fn(() => Promise.resolve()), + setPolicy: vi.fn((policy: VaultPolicy) => Promise.resolve(policy)), + status: vi.fn(() => + Promise.resolve({ + daemonRunning: true, + lockState: 'locked', + } satisfies VaultStatus), + ), + unlock: vi.fn(() => + Promise.resolve({ + daemonRunning: true, + lockState: 'unlocked', + } satisfies VaultStatus), + ), + }; + return { + client, + ensureDaemon: vi.fn(() => Promise.resolve(client)), + readPassphrase: vi.fn(() => Promise.resolve(Buffer.from('123456'))), + readVaultStatus: vi.fn(() => client.status()), + write: vi.fn(), + ...overrides, + }; +} + +function resetDeps(overrides: Partial = {}): ResetVaultTestDeps { + const executablePath = '/Applications/InFlow.app/Contents/MacOS/inflow'; + const client = { + info: vi.fn(() => + Promise.resolve({ + buildId: 'build-1', + cliVersion: '0.9.0', + executablePath, + pid: 123, + }), + ), + reset: vi.fn(() => Promise.resolve()), + shutdown: vi.fn(() => Promise.resolve()), + status: vi.fn(() => + Promise.resolve({ + daemonRunning: true, + lockState: 'unlocked', + } satisfies VaultStatus), + ), + }; + return { + client, + executablePath, + now: vi.fn(() => 0), + removeLocalState: vi.fn(() => Promise.resolve()), + sleep: vi.fn(() => Promise.resolve()), + ...overrides, + }; +} + +describe('vault command runners', () => { + it('reports vault status in agent shape', async () => { + const harness = deps(); + + await expect(__testing.runVaultStatus(context(), harness)).resolves.toEqual({ + lock_state: 'locked', + }); + expect(harness.ensureDaemon).not.toHaveBeenCalled(); + }); + + it('renders status in human mode', async () => { + const harness = deps(); + + await expect(__testing.runVaultStatus(context({}, false), harness)).resolves.toEqual({ + lock_state: 'locked', + }); + + expect(harness.ensureDaemon).not.toHaveBeenCalled(); + expect(harness.write).toHaveBeenCalledWith('Vault status: locked\n'); + }); + + it('reads local status without starting a daemon', async (testContext) => { + await expect( + __testing.readVaultStatusWithoutStarting({ rootDirectory: testContext.task.name }), + ).resolves.toMatchObject({ + daemonRunning: false, + lockState: 'not_initialized', + }); + }); + + it('treats an overlong socket path as an unavailable daemon for status reads', async () => { + const rootDirectory = join(tmpdir(), `inflow-${'x'.repeat(120)}`); + + await expect(__testing.readVaultStatusWithoutStarting({ rootDirectory })).resolves.toMatchObject({ + daemonRunning: false, + lockState: 'not_initialized', + }); + }); + + it('reports locked from local sidecar state when no daemon is reachable', async (testContext) => { + const rootDirectory = join(tmpdir(), testContext.task.id.replaceAll(/[^a-z0-9_-]/giu, '_')); + await mkdir(rootDirectory, { recursive: true }); + await writeFile(join(rootDirectory, 'inflow.vault'), Buffer.from('vault sidecar')); + + await expect(__testing.readVaultStatusWithoutStarting({ rootDirectory })).resolves.toMatchObject({ + daemonRunning: false, + lockState: 'locked', + }); + }); + + it('reports unlocked status for agent unlock when the vault is already unlocked', async () => { + const harness = deps(); + harness.client.status.mockResolvedValueOnce({ daemonRunning: true, lockState: 'unlocked' }); + + await expect(__testing.runVaultUnlock(context(), harness)).resolves.toEqual({ + lock_state: 'unlocked', + }); + expect(harness.ensureDaemon).not.toHaveBeenCalled(); + expect(harness.readPassphrase).not.toHaveBeenCalled(); + expect(harness.client.unlock).not.toHaveBeenCalled(); + }); + + it('rejects agent unlock without reading a passphrase when the vault is locked', async () => { + const harness = deps(); + + await expect(__testing.runVaultUnlock(context(), harness)).rejects.toMatchObject({ + code: 'VAULT_UNLOCK_REQUIRES_HUMAN', + }); + expect(harness.ensureDaemon).not.toHaveBeenCalled(); + expect(harness.readVaultStatus).toHaveBeenCalled(); + expect(harness.readPassphrase).not.toHaveBeenCalled(); + expect(harness.client.unlock).not.toHaveBeenCalled(); + }); + + it('unlocks and initializes with the first-run prompt', async () => { + const harness = deps({ + readVaultStatus: vi.fn(() => Promise.resolve({ daemonRunning: true, lockState: 'unlocked' })), + }); + harness.client.status.mockResolvedValueOnce({ daemonRunning: true, lockState: 'not_initialized' }); + + await expect(__testing.runVaultUnlock(context({}, false), harness)).resolves.toEqual({ + lock_state: 'unlocked', + }); + + expect(harness.readPassphrase).toHaveBeenCalledWith( + 'Create an InFlow vault PIN or passphrase: ', + expect.anything(), + ); + expect(harness.client.unlock).toHaveBeenCalledWith(Buffer.from([0, 0, 0, 0, 0, 0])); + }); + + it('unlocks an existing vault with the returning prompt and human message', async () => { + const harness = deps({ + readVaultStatus: vi.fn(() => Promise.resolve({ daemonRunning: true, lockState: 'unlocked' })), + }); + + await expect(__testing.runVaultUnlock(context({}, false), harness)).resolves.toEqual({ + lock_state: 'unlocked', + }); + + expect(harness.readPassphrase).toHaveBeenCalledWith( + 'Enter the InFlow vault PIN or passphrase: ', + expect.anything(), + ); + expect(harness.write).toHaveBeenCalledWith('Vault unlocked.\n'); + }); + + it('does not prompt when a human unlocks an already-unlocked vault', async () => { + const harness = deps(); + harness.client.status.mockResolvedValueOnce({ daemonRunning: true, lockState: 'unlocked' }); + + await expect(__testing.runVaultUnlock(context({}, false), harness)).resolves.toEqual({ + lock_state: 'unlocked', + }); + + expect(harness.readPassphrase).not.toHaveBeenCalled(); + expect(harness.client.unlock).not.toHaveBeenCalled(); + expect(harness.write).toHaveBeenCalledWith('Vault already unlocked.\n'); + }); + + it('reports an authentication failure for a rejected passphrase', async () => { + const harness = deps(); + harness.client.unlock.mockRejectedValue( + new SecureStorageError('secure_storage_corrupt', 'Vault material could not be unwrapped.'), + ); + + await expect(__testing.runVaultUnlock(context({}, false), harness)).rejects.toMatchObject({ + code: 'VAULT_UNLOCK_FAILED', + message: 'The vault could not be unlocked. Check the PIN or passphrase and try again.', + }); + + expect(harness.write).not.toHaveBeenCalled(); + }); + + it('does not print success when an independent status check remains locked', async () => { + const harness = deps(); + + await expect(__testing.runVaultUnlock(context({}, false), harness)).rejects.toMatchObject({ + code: 'VAULT_UNLOCK_FAILED', + message: 'The vault could not be unlocked. Check the PIN or passphrase and try again.', + }); + + expect(harness.write).not.toHaveBeenCalled(); + }); + + it('rejects short unlock passphrases before calling the daemon', async () => { + const harness = deps({ + readPassphrase: vi.fn(() => Promise.resolve(Buffer.from('123'))), + }); + + await expect(__testing.runVaultUnlock(context({}, false), harness)).rejects.toMatchObject({ + code: 'VAULT_PASSPHRASE_TOO_SHORT', + }); + expect(harness.client.unlock).not.toHaveBeenCalled(); + }); + + it('updates only supplied policy fields', async () => { + const harness = deps(); + + await expect( + __testing.runVaultPolicySet( + context({ + idleTimeoutSeconds: 0, + lockOnSleep: false, + }), + harness, + ), + ).resolves.toEqual({ + idle_timeout_seconds: null, + lock_on_sleep: false, + }); + + expect(harness.client.setPolicy).toHaveBeenCalledWith({ + idleTimeoutSeconds: null, + lockOnSleep: false, + }); + }); + + it('preserves policy fields when no recognized options are supplied', async () => { + const harness = deps(); + + await expect(__testing.runVaultPolicySet(context({ ignored: true }), harness)).resolves.toEqual({ + idle_timeout_seconds: 28_800, + lock_on_sleep: true, + }); + expect(__testing.parsePolicySetOptions({ idleTimeoutSeconds: '60', lockOnSleep: 'yes' })).toEqual({ + idleTimeoutSeconds: undefined, + lockOnSleep: undefined, + }); + expect(harness.client.setPolicy).toHaveBeenCalledWith({ + idleTimeoutSeconds: 28_800, + lockOnSleep: true, + }); + }); + + it('renders a disabled policy', () => { + expect(__testing.renderPolicy({ idle_timeout_seconds: null, lock_on_sleep: false })).toBe( + 'Idle timeout: disabled\nLock on sleep: no\n', + ); + }); + + it('renders and updates vault policy in human mode', async () => { + const harness = deps(); + + await expect(__testing.runVaultPolicy(context({}, false), harness)).resolves.toEqual({ + idle_timeout_seconds: 28_800, + lock_on_sleep: true, + }); + await expect(__testing.runVaultPolicySet(context({ idleTimeoutSeconds: 60 }, false), harness)).resolves.toEqual({ + idle_timeout_seconds: 60, + lock_on_sleep: true, + }); + + expect(harness.write).toHaveBeenCalledWith('Idle timeout: 28800s\nLock on sleep: yes\n'); + expect(harness.write).toHaveBeenCalledWith('Idle timeout: 60s\nLock on sleep: yes\n'); + }); + + it('locks and resets the vault in human mode', async () => { + const harness = deps(); + + await expect(__testing.runVaultLock(context({}, false), harness)).resolves.toEqual({ locked: true }); + await expect(__testing.runVaultReset(context({ force: true }, false), harness)).resolves.toEqual({ reset: true }); + + expect(harness.client.lock).toHaveBeenCalledOnce(); + expect(harness.client.reset).toHaveBeenCalledOnce(); + expect(harness.write).toHaveBeenCalledWith('Vault locked.\n'); + expect(harness.write).toHaveBeenCalledWith('Vault reset complete.\n'); + }); + + it('requires the same executable, version, and build when reusing a daemon', () => { + expect( + __testing.isCompatibleDaemon( + { + buildId: 'build-1', + cliVersion: '0.9.0', + executablePath: '/Applications/InFlow.app/Contents/MacOS/inflow', + pid: 123, + }, + { buildId: 'build-1', cliVersion: '0.9.0' }, + '/Applications/InFlow.app/Contents/MacOS/inflow', + ), + ).toBe(true); + expect( + __testing.isCompatibleDaemon( + { + buildId: 'build-2', + cliVersion: '0.9.0', + executablePath: '/Applications/InFlow.app/Contents/MacOS/inflow', + pid: 123, + }, + { buildId: 'build-1', cliVersion: '0.9.0' }, + '/Applications/InFlow.app/Contents/MacOS/inflow', + ), + ).toBe(false); + expect( + __testing.isCompatibleDaemon( + { + buildId: 'build-1', + cliVersion: '0.9.1', + executablePath: '/Applications/InFlow.app/Contents/MacOS/inflow', + pid: 123, + }, + { buildId: 'build-1', cliVersion: '0.9.0' }, + '/Applications/InFlow.app/Contents/MacOS/inflow', + ), + ).toBe(false); + expect( + __testing.isCompatibleDaemon( + { + buildId: 'build-1', + cliVersion: '0.9.0', + executablePath: '/tmp/inflow', + pid: 123, + }, + { buildId: 'build-1', cliVersion: '0.9.0' }, + '/Applications/InFlow.app/Contents/MacOS/inflow', + ), + ).toBe(false); + }); + + it('treats a symlinked packaged launcher as the same daemon executable', async () => { + const root = mkdtempSync(join(tmpdir(), 'inflow-vault-executable-')); + const executable = join(root, 'InFlow.app', 'Contents', 'MacOS', 'inflow'); + const launcher = join(root, 'bin', 'inflow'); + try { + await mkdir(join(root, 'InFlow.app', 'Contents', 'MacOS'), { recursive: true }); + await mkdir(join(root, 'bin'), { recursive: true }); + await writeFile(executable, ''); + await symlink('../InFlow.app/Contents/MacOS/inflow', launcher); + + expect( + __testing.isCompatibleDaemon( + { + buildId: 'build-1', + cliVersion: '0.9.0', + executablePath: executable, + pid: 123, + }, + { buildId: 'build-1', cliVersion: '0.9.0' }, + launcher, + ), + ).toBe(true); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it('resets through a compatible daemon without deleting underneath it', async () => { + const harness = resetDeps(); + + await expect( + __testing.resetLocalVaultWithDeps({ buildId: 'build-1', cliVersion: '0.9.0' }, harness), + ).resolves.toBeUndefined(); + + expect(harness.client.reset).toHaveBeenCalledOnce(); + expect(harness.client.shutdown).not.toHaveBeenCalled(); + expect(harness.removeLocalState).not.toHaveBeenCalled(); + }); + + it('removes local vault files directly when no daemon is reachable', async () => { + const harness = resetDeps(); + harness.client.status.mockRejectedValueOnce( + new SecureStorageError('secure_storage_unavailable', 'The InFlow vault daemon is unavailable.'), + ); + + await expect( + __testing.resetLocalVaultWithDeps({ rootDirectory: '/tmp/inflow-vault-test' }, harness), + ).resolves.toBeUndefined(); + + expect(harness.client.reset).not.toHaveBeenCalled(); + expect(harness.client.shutdown).not.toHaveBeenCalled(); + expect(harness.removeLocalState).toHaveBeenCalledWith( + expect.objectContaining({ + database: '/tmp/inflow-vault-test/inflow.sqlite3', + }), + ); + }); + + it('treats a missing daemon socket as direct cleanup', async () => { + const harness = resetDeps(); + harness.client.status.mockRejectedValueOnce( + Object.assign(new Error('connect ENOENT vault.sock'), { code: 'ENOENT' }), + ); + + await expect( + __testing.resetLocalVaultWithDeps({ rootDirectory: '/tmp/inflow-vault-test' }, harness), + ).resolves.toBeUndefined(); + + expect(harness.client.reset).not.toHaveBeenCalled(); + expect(harness.client.shutdown).not.toHaveBeenCalled(); + expect(harness.removeLocalState).toHaveBeenCalledOnce(); + }); + + it('stops a stale daemon before removing local vault files', async () => { + const harness = resetDeps(); + harness.client.info.mockResolvedValueOnce({ + buildId: 'build-2', + cliVersion: '0.9.0', + executablePath: harness.executablePath, + pid: 123, + }); + harness.client.status + .mockResolvedValueOnce({ daemonRunning: true, lockState: 'unlocked' }) + .mockRejectedValueOnce( + new SecureStorageError('secure_storage_unavailable', 'The InFlow vault daemon is unavailable.'), + ); + + await expect( + __testing.resetLocalVaultWithDeps({ buildId: 'build-1', cliVersion: '0.9.0' }, harness), + ).resolves.toBeUndefined(); + + expect(harness.client.reset).not.toHaveBeenCalled(); + expect(harness.client.shutdown).toHaveBeenCalledOnce(); + expect(harness.removeLocalState).toHaveBeenCalledOnce(); + }); + + it('does not remove local vault files when a reachable daemon cannot shut down', async () => { + const harness = resetDeps(); + harness.client.info.mockResolvedValueOnce({ + buildId: 'build-2', + cliVersion: '0.9.0', + executablePath: harness.executablePath, + pid: 123, + }); + harness.client.shutdown.mockRejectedValueOnce( + new SecureStorageError('secure_storage_corrupt', 'The InFlow vault daemon returned invalid data.'), + ); + + await expect( + __testing.resetLocalVaultWithDeps({ buildId: 'build-1', cliVersion: '0.9.0' }, harness), + ).rejects.toMatchObject({ + secureStorageCode: 'secure_storage_corrupt', + }); + + expect(harness.removeLocalState).not.toHaveBeenCalled(); + }); + + it('does not remove local state when daemon discovery fails unexpectedly', async () => { + const harness = resetDeps(); + harness.client.status.mockRejectedValueOnce(new Error('status failed')); + + await expect(__testing.resetLocalVaultWithDeps({}, harness)).rejects.toThrow('status failed'); + expect(harness.removeLocalState).not.toHaveBeenCalled(); + }); + + it('continues cleanup when a stale daemon disappears during shutdown', async () => { + const harness = resetDeps(); + harness.client.info.mockResolvedValueOnce({ + buildId: 'other', + cliVersion: '0.9.0', + executablePath: harness.executablePath, + pid: 123, + }); + harness.client.shutdown.mockRejectedValueOnce( + new SecureStorageError('secure_storage_unavailable', 'The daemon stopped.'), + ); + + await expect(__testing.resetLocalVaultWithDeps({ buildId: 'build-1' }, harness)).resolves.toBeUndefined(); + expect(harness.removeLocalState).toHaveBeenCalledOnce(); + }); + + it('fails closed when a stale daemon remains reachable past the deadline', async () => { + const harness = resetDeps({ + now: vi.fn().mockReturnValueOnce(0).mockReturnValueOnce(1).mockReturnValueOnce(2_000), + }); + harness.client.info.mockResolvedValueOnce({ + buildId: 'other', + cliVersion: '0.9.0', + executablePath: harness.executablePath, + pid: 123, + }); + + await expect(__testing.resetLocalVaultWithDeps({ buildId: 'build-1' }, harness)).rejects.toMatchObject({ + secureStorageCode: 'vault_daemon_busy', + }); + expect(harness.sleep).toHaveBeenCalledWith(25); + expect(harness.removeLocalState).not.toHaveBeenCalled(); + }); + + it('propagates unexpected status failures while waiting for shutdown', async () => { + const harness = resetDeps(); + harness.client.status.mockRejectedValueOnce(new Error('status failed')); + + await expect(__testing.shutdownReachableDaemon(harness)).rejects.toThrow('status failed'); + }); + + it('does not prompt when ensuring an unlocked vault', async () => { + const harness = deps(); + harness.client.status.mockResolvedValueOnce({ daemonRunning: true, lockState: 'unlocked' }); + + await expect( + __testing.ensureLocalVaultUnlockedWithDeps('human', { + ensureDaemon: harness.ensureDaemon, + readPassphrase: harness.readPassphrase, + }), + ).resolves.toBeUndefined(); + + expect(harness.readPassphrase).not.toHaveBeenCalled(); + expect(harness.client.unlock).not.toHaveBeenCalled(); + }); + + it('tells agents to ask a human when the vault is unavailable for secrets', async () => { + const harness = deps(); + harness.client.status.mockResolvedValueOnce({ daemonRunning: true, lockState: 'not_initialized' }); + + await expect( + __testing.ensureLocalVaultUnlockedWithDeps('agent', { + ensureDaemon: harness.ensureDaemon, + readPassphrase: harness.readPassphrase, + }), + ).rejects.toMatchObject({ + secureStorageCode: 'vault_not_initialized', + message: 'The InFlow vault is not initialized. A human must run `inflow vault unlock` first.', + }); + + harness.client.status.mockResolvedValueOnce({ daemonRunning: true, lockState: 'locked' }); + await expect( + __testing.ensureLocalVaultUnlockedWithDeps('agent', { + ensureDaemon: harness.ensureDaemon, + readPassphrase: harness.readPassphrase, + }), + ).rejects.toMatchObject({ + secureStorageCode: 'vault_locked', + message: 'The InFlow vault is locked. A human must run `inflow vault unlock` first.', + }); + }); + + it('prompts humans and unlocks before secret-backed commands continue', async () => { + const harness = deps(); + harness.client.status.mockResolvedValueOnce({ daemonRunning: true, lockState: 'locked' }); + + await expect( + __testing.ensureLocalVaultUnlockedWithDeps('human', { + ensureDaemon: harness.ensureDaemon, + readPassphrase: harness.readPassphrase, + }), + ).resolves.toBeUndefined(); + + expect(harness.readPassphrase).toHaveBeenCalledWith( + 'Enter the InFlow vault PIN or passphrase: ', + expect.anything(), + ); + expect(harness.client.unlock).toHaveBeenCalledWith(Buffer.from([0, 0, 0, 0, 0, 0])); + }); + + it('requires force before resetting the vault', async () => { + const harness = deps(); + + await expect(__testing.runVaultReset(context({ force: false }), harness)).rejects.toMatchObject({ + code: 'VAULT_RESET_REQUIRES_FORCE', + }); + expect(harness.client.reset).not.toHaveBeenCalled(); + }); + + it('rejects agent passphrase changes without reading passphrases', async () => { + const harness = deps(); + + await expect(__testing.runVaultChangePassphrase(context(), harness)).rejects.toMatchObject({ + code: 'VAULT_CHANGE_PASSPHRASE_REQUIRES_HUMAN', + }); + expect(harness.ensureDaemon).not.toHaveBeenCalled(); + expect(harness.readPassphrase).not.toHaveBeenCalled(); + expect(harness.client.changePassphrase).not.toHaveBeenCalled(); + }); + + it('changes passphrase after confirming the human prompt', async () => { + const harness = deps({ + readPassphrase: vi + .fn() + .mockResolvedValueOnce(Buffer.from('current1')) + .mockResolvedValueOnce(Buffer.from('next123')) + .mockResolvedValueOnce(Buffer.from('next123')), + }); + + await expect(__testing.runVaultChangePassphrase(context({}, false), harness)).resolves.toEqual({ changed: true }); + expect(harness.client.changePassphrase).toHaveBeenCalledWith( + Buffer.from([0, 0, 0, 0, 0, 0, 0, 0]), + Buffer.from([0, 0, 0, 0, 0, 0, 0]), + ); + }); + + it('rejects mismatched human passphrase confirmation', async () => { + const harness = deps({ + readPassphrase: vi + .fn() + .mockResolvedValueOnce(Buffer.from('current1')) + .mockResolvedValueOnce(Buffer.from('next123')) + .mockResolvedValueOnce(Buffer.from('different')), + }); + + await expect(__testing.runVaultChangePassphrase(context({}, false), harness)).rejects.toMatchObject({ + code: 'VAULT_PASSPHRASE_MISMATCH', + }); + expect(harness.client.changePassphrase).not.toHaveBeenCalled(); + }); + + it('rejects a short replacement passphrase and clears it', async () => { + const current = Buffer.from('current1'); + const next = Buffer.from('123'); + const harness = deps({ + readPassphrase: vi.fn().mockResolvedValueOnce(current).mockResolvedValueOnce(next), + }); + + await expect(__testing.runVaultChangePassphrase(context({}, false), harness)).rejects.toMatchObject({ + code: 'VAULT_PASSPHRASE_TOO_SHORT', + }); + expect(current).toEqual(Buffer.alloc(8)); + expect(next).toEqual(Buffer.alloc(3)); + expect(harness.client.changePassphrase).not.toHaveBeenCalled(); + }); + + it('clears passphrases when changing the daemon credential fails', async () => { + const current = Buffer.from('current1'); + const next = Buffer.from('next123'); + const harness = deps({ + readPassphrase: vi + .fn() + .mockResolvedValueOnce(current) + .mockResolvedValueOnce(next) + .mockResolvedValueOnce(Buffer.from('next123')), + }); + harness.client.changePassphrase.mockRejectedValueOnce( + new SecureStorageError('secure_storage_unavailable', 'change failed'), + ); + + await expect(__testing.runVaultChangePassphrase(context({}, false), harness)).rejects.toMatchObject({ + code: 'secure_storage_unavailable', + }); + expect(current).toEqual(Buffer.alloc(8)); + expect(next).toEqual(Buffer.alloc(7)); + }); + + it('maps secure storage errors through the command context', async () => { + const harness = deps(); + harness.readVaultStatus.mockRejectedValue( + new SecureStorageError('secure_storage_secret_missing', 'The InFlow vault is locked.'), + ); + + await expect(__testing.runVaultStatus(context(), harness)).rejects.toMatchObject({ + code: 'secure_storage_secret_missing', + }); + }); + + it('propagates non-storage errors and maps non-corruption unlock errors', async () => { + const c = context(); + await expect(__testing.mapSecureStorageError(c, () => Promise.reject(new Error('unexpected')))).rejects.toThrow( + 'unexpected', + ); + await expect( + __testing.mapVaultUnlockError(c, () => + Promise.reject(new SecureStorageError('secure_storage_unavailable', 'unavailable')), + ), + ).rejects.toMatchObject({ code: 'secure_storage_unavailable' }); + await expect(__testing.mapVaultUnlockError(c, () => Promise.reject(new Error('unexpected')))).rejects.toThrow( + 'unexpected', + ); + }); + + it('classifies only daemon transport failures as unavailable', () => { + expect(__testing.isVaultDaemonUnavailable({ code: 'ECONNREFUSED' })).toBe(true); + expect(__testing.isVaultDaemonUnavailable({ code: 'EINVAL' })).toBe(true); + expect(__testing.isVaultDaemonUnavailable({ code: 'EACCES' })).toBe(false); + expect(__testing.isVaultDaemonUnavailable(new Error('other'))).toBe(false); + expect(__testing.isVaultDaemonUnavailable(new SecureStorageError('secure_storage_corrupt', 'corrupt'))).toBe(false); + }); + + it('registers the visible vault command surface', () => { + expect(createVaultCli()).toBeDefined(); + }); +}); diff --git a/packages/cli/test/unit/startup-vault.test.ts b/packages/cli/test/unit/startup-vault.test.ts new file mode 100644 index 0000000..2422017 --- /dev/null +++ b/packages/cli/test/unit/startup-vault.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { + commandPath, + shouldReconcileVaultDaemon, + shouldStartVaultDaemon, + shouldUnlockVault, +} from '../../src/startup-vault.js'; + +function argv(...args: string[]): string[] { + return ['node', 'inflow', ...args]; +} + +describe('vault startup decisions', () => { + it.each([ + { args: ['--base-url', 'https://api.test', 'aep', 'status', 'https://seller.test'], path: ['aep', 'status'] }, + { args: ['--format=json', 'mpp', 'pay', 'https://seller.test'], path: ['mpp', 'pay'] }, + { args: ['--schema', 'x402', 'fetch'], path: ['x402', 'fetch'] }, + { args: ['--skill', 'agentic-payments'], path: [] }, + { args: ['inspect', '--', '--not-a-flag'], path: ['inspect'] }, + ])('extracts command path from $args', ({ args, path }) => { + expect(commandPath(argv(...args))).toEqual(path); + }); + + it.each([ + ['auth login', ['auth', 'login'], true], + ['auth logout', ['auth', 'logout'], true], + ['auth status', ['auth', 'status'], false], + ['aep enroll', ['aep', 'enroll'], true], + ['aep fetch', ['aep', 'fetch'], true], + ['aep grant', ['aep', 'grant'], true], + ['aep inspect', ['aep', 'inspect'], false], + ['aep revoke', ['aep', 'revoke'], true], + ['aep status', ['aep', 'status'], true], + ['mpp pay', ['mpp', 'pay'], true], + ['mpp fetch', ['mpp', 'fetch'], true], + ['mpp status', ['mpp', 'status'], true], + ['mpp supported', ['mpp', 'supported'], true], + ['mpp inspect', ['mpp', 'inspect'], false], + ['x402 pay', ['x402', 'pay'], true], + ['x402 fetch', ['x402', 'fetch'], true], + ['x402 status', ['x402', 'status'], true], + ['x402 supported', ['x402', 'supported'], true], + ['x402 inspect', ['x402', 'inspect'], false], + ['balances list', ['balances', 'list'], false], + ['deposit-addresses list', ['deposit-addresses', 'list'], false], + ['user get', ['user', 'get'], false], + ['vault unlock', ['vault', 'unlock'], false], + ['top inspect', ['inspect'], false], + ] as const)('starts daemon for %s when required', (_label, args, expected) => { + expect(shouldStartVaultDaemon(argv(...args))).toBe(expected); + }); + + it.each([ + ['auth status', ['auth', 'status'], true], + ['balances list', ['balances', 'list'], true], + ['aep status', ['aep', 'status'], true], + ['mpp pay', ['mpp', 'pay'], true], + ['x402 fetch', ['x402', 'fetch'], true], + ['aep inspect', ['aep', 'inspect'], false], + ['vault unlock', ['vault', 'unlock'], false], + ['top inspect', ['inspect'], false], + ] as const)('reconciles a running daemon for %s when required', (_label, args, expected) => { + expect(shouldReconcileVaultDaemon(argv(...args))).toBe(expected); + }); + + it.each([ + ['auth login', ['auth', 'login'], true], + ['auth logout', ['auth', 'logout'], false], + ['auth status', ['auth', 'status'], false], + ['aep enroll', ['aep', 'enroll'], true], + ['aep fetch', ['aep', 'fetch'], true], + ['aep grant', ['aep', 'grant'], true], + ['aep inspect', ['aep', 'inspect'], false], + ['aep revoke', ['aep', 'revoke'], true], + ['aep status', ['aep', 'status'], true], + ['mpp pay', ['mpp', 'pay'], true], + ['mpp fetch', ['mpp', 'fetch'], true], + ['mpp status', ['mpp', 'status'], true], + ['mpp supported', ['mpp', 'supported'], true], + ['mpp inspect', ['mpp', 'inspect'], false], + ['x402 pay', ['x402', 'pay'], true], + ['x402 fetch', ['x402', 'fetch'], true], + ['x402 status', ['x402', 'status'], true], + ['x402 supported', ['x402', 'supported'], true], + ['x402 inspect', ['x402', 'inspect'], false], + ['balances list', ['balances', 'list'], true], + ['deposit-addresses list', ['deposit-addresses', 'list'], true], + ['user get', ['user', 'get'], true], + ['vault unlock', ['vault', 'unlock'], false], + ['top inspect', ['inspect'], false], + ] as const)('unlocks vault for human %s when required', (_label, args, expected) => { + expect(shouldUnlockVault(argv(...args))).toBe(expected); + }); + + it('does not start or unlock the vault when a direct API key or schema mode is used', () => { + expect(shouldStartVaultDaemon(argv('aep', 'status'), true)).toBe(false); + expect(shouldReconcileVaultDaemon(argv('aep', 'status'), true)).toBe(false); + expect(shouldUnlockVault(argv('aep', 'status'), { hasDirectApiKey: true })).toBe(false); + expect(shouldStartVaultDaemon(argv('aep', 'status', '--schema'))).toBe(false); + expect(shouldReconcileVaultDaemon(argv('aep', 'status', '--schema'))).toBe(false); + expect(shouldUnlockVault(argv('aep', 'status', '--schema'))).toBe(false); + }); + + it('does not prompt agents or MCP callers before command handling', () => { + expect(shouldUnlockVault(argv('aep', 'status'), { isAgent: true })).toBe(false); + expect(shouldUnlockVault(argv('--mcp'), { isAgent: true })).toBe(false); + expect(shouldUnlockVault(argv('mpp', 'pay', 'https://seller.test'), { isAgent: true })).toBe(false); + }); +}); diff --git a/packages/cli/test/unit/utils/update-probe.test.ts b/packages/cli/test/unit/utils/update-probe.test.ts index 61b94e2..1baae5c 100644 --- a/packages/cli/test/unit/utils/update-probe.test.ts +++ b/packages/cli/test/unit/utils/update-probe.test.ts @@ -6,11 +6,14 @@ import { makeFrozenUpdateProbe, } from '../../../src/utils/update-probe.js'; -function githubFetch(version: string): { calls: { headers: Headers; url: string }[]; fetch: typeof globalThis.fetch } { - const calls: { headers: Headers; url: string }[] = []; +function githubFetch(version: string): { + calls: { headers: Headers; signal: AbortSignal | null | undefined; url: string }[]; + fetch: typeof globalThis.fetch; +} { + const calls: { headers: Headers; signal: AbortSignal | null | undefined; url: string }[] = []; const fetch = ((input, init) => { const url = typeof input === 'string' ? input : (input as URL).toString(); - calls.push({ headers: new Headers(init?.headers), url }); + calls.push({ headers: new Headers(init?.headers), signal: init?.signal, url }); return Promise.resolve(Response.json({ tag_name: `v${version}` })); }) as typeof globalThis.fetch; return { calls, fetch }; @@ -50,6 +53,7 @@ describe('makeBackgroundUpdateProbe', () => { expect(calls[0]?.url).toBe('https://api.github.com/repos/inflowpayai/inflow-cli/releases/latest'); expect(calls[0]?.headers.get('Accept')).toBe('application/vnd.github+json'); expect(calls[0]?.headers.get('User-Agent')).toBe('inflow/1.0.0'); + expect(calls[0]?.signal).toBeInstanceOf(AbortSignal); }); it('returns undefined when the GitHub release version equals the current version', async () => { @@ -101,7 +105,13 @@ describe('makeFrozenUpdateProbe', () => { }); describe('formatUpdateNotice', () => { - it('renders one human-facing line', () => { - expect(formatUpdateNotice({ current: '0.5.0', latest: '0.5.1' })).toBe('A newer InFlow CLI is available: 0.5.1.\n'); + it('renders the available version and signed-binary upgrade guidance', () => { + expect(formatUpdateNotice({ current: '0.5.0', latest: '0.5.1' })).toBe( + [ + 'A newer InFlow CLI is available: 0.5.1.', + 'Upgrade with Homebrew or rerun the hosted installer: https://inflowcli.ai/', + '', + ].join('\n'), + ); }); }); diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 2d88e0c..b39f93b 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { createHash } from 'node:crypto'; +import { dirname, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'tsup'; @@ -12,6 +13,7 @@ interface CliManifest { } const manifest = JSON.parse(readFileSync(resolve(here, 'package.json'), 'utf-8')) as CliManifest; +const buildId = computeBuildId(); const skillsDir = resolve(repoRoot, 'skills'); @@ -51,6 +53,7 @@ export default defineConfig({ clean: true, define: { __BOOTSTRAP_BODY__: JSON.stringify(bootstrapBody), + __CLI_BUILD_ID__: JSON.stringify(buildId), __CLI_NAME__: JSON.stringify(manifest.name), __CLI_VERSION__: JSON.stringify(manifest.version), __SKILL_BODIES__: JSON.stringify(skillBodies), @@ -61,6 +64,7 @@ export default defineConfig({ 'react-devtools-core': reactDevtoolsAlias, }; }, + external: ['@node-rs/argon2'], entry: { cli: 'src/cli.tsx', 'npm-shim': 'src/npm-shim.ts' }, format: ['esm'], outDir: 'dist', @@ -69,3 +73,47 @@ export default defineConfig({ sourcemap: false, target: 'node24', }); + +function computeBuildId(): string { + const hash = createHash('sha256'); + for (const filePath of buildIdentityFiles()) { + hash.update(relative(repoRoot, filePath)); + hash.update('\0'); + hash.update(readFileSync(filePath)); + hash.update('\0'); + } + return hash.digest('hex'); +} + +function buildIdentityFiles(): string[] { + return [ + resolve(repoRoot, 'package.json'), + resolve(repoRoot, 'pnpm-lock.yaml'), + resolve(repoRoot, 'packages/core/package.json'), + resolve(repoRoot, 'packages/cli/package.json'), + resolve(repoRoot, 'packages/cli/tsup.config.ts'), + resolve(repoRoot, 'packages/cli/tsup.standalone.config.ts'), + resolve(repoRoot, 'patches/incur.patch'), + ...existingSourceFiles(resolve(repoRoot, 'packages/core/native')), + ...sourceFiles(resolve(repoRoot, 'packages/core/src')), + ...sourceFiles(resolve(repoRoot, 'packages/cli/src')), + ...sourceFiles(resolve(repoRoot, 'skills')), + ].sort(); +} + +function existingSourceFiles(root: string): string[] { + return existsSync(root) ? sourceFiles(root) : []; +} + +function sourceFiles(root: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const entryPath = resolve(root, entry.name); + if (entry.isDirectory()) { + out.push(...sourceFiles(entryPath)); + continue; + } + if (entry.isFile()) out.push(entryPath); + } + return out; +} diff --git a/packages/cli/tsup.standalone.config.ts b/packages/cli/tsup.standalone.config.ts index c3fcd69..0d6d7b1 100644 --- a/packages/cli/tsup.standalone.config.ts +++ b/packages/cli/tsup.standalone.config.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { createHash } from 'node:crypto'; +import { dirname, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'tsup'; @@ -12,6 +13,8 @@ interface CliManifest { } const manifest = JSON.parse(readFileSync(resolve(here, 'package.json'), 'utf-8')) as CliManifest; +const buildId = computeBuildId(); +const vaultPeerNativeSha256 = computeVaultPeerNativeSha256(); const skillsDir = resolve(repoRoot, 'skills'); @@ -43,9 +46,10 @@ const reactDevtoolsAlias = resolve(here, 'src/stubs/react-devtools-core.ts'); const BUNDLE_BANNER = [ '#!/usr/bin/env node', "import { createRequire as __createRequire, Module as __Module } from 'node:module';", - "import { dirname as __dirnamePath, resolve as __resolvePath } from 'node:path';", + "import { delimiter as __pathDelimiter, dirname as __dirnamePath, resolve as __resolvePath } from 'node:path';", 'const require = __createRequire(process.execPath);', - "process.env.NODE_PATH = [__resolvePath(__dirnamePath(process.execPath), '../Resources/app/node_modules'), process.env.NODE_PATH ?? ''].filter(Boolean).join(':');", + "Reflect.set(globalThis, '__inflowRequire', require);", + "process.env.NODE_PATH = [__resolvePath(__dirnamePath(process.execPath), 'runtime/node_modules'), __resolvePath(__dirnamePath(process.execPath), '../Resources/app/node_modules'), __resolvePath(__dirnamePath(process.execPath), '../lib/inflow/node_modules'), process.env.NODE_PATH ?? ''].filter(Boolean).join(__pathDelimiter);", '__Module._initPaths();', ].join('\n'); @@ -54,9 +58,11 @@ export default defineConfig({ clean: false, define: { __BOOTSTRAP_BODY__: JSON.stringify(bootstrapBody), + __CLI_BUILD_ID__: JSON.stringify(buildId), __CLI_NAME__: JSON.stringify(manifest.name), __CLI_VERSION__: JSON.stringify(manifest.version), __SKILL_BODIES__: JSON.stringify(skillBodies), + __VAULT_PEER_NATIVE_SHA256__: JSON.stringify(vaultPeerNativeSha256), }, esbuildOptions(options) { options.alias = { @@ -65,8 +71,8 @@ export default defineConfig({ }; }, entry: { 'cli.standalone': 'src/cli.tsx' }, - external: [], - noExternal: [/.*/], + external: ['@node-rs/argon2'], + noExternal: [/^(?!@node-rs\/argon2$).*/], format: ['esm'], outExtension() { return { js: '.mjs' }; @@ -77,3 +83,59 @@ export default defineConfig({ sourcemap: false, target: 'node24', }); + +function computeBuildId(): string { + const hash = createHash('sha256'); + for (const filePath of buildIdentityFiles()) { + hash.update(relative(repoRoot, filePath)); + hash.update('\0'); + hash.update(readFileSync(filePath)); + hash.update('\0'); + } + return hash.digest('hex'); +} + +function computeVaultPeerNativeSha256(): string { + if (process.platform !== 'darwin' && process.platform !== 'linux' && process.platform !== 'win32') { + throw new Error(`Native vault peer verification is unavailable on ${process.platform}.`); + } + const platformName = process.platform === 'win32' ? 'windows' : process.platform; + const nativePath = resolve(repoRoot, `packages/core/native/build/vault_peer_${platformName}.node`); + if (!existsSync(nativePath)) { + throw new Error(`Native vault peer verifier is missing at ${nativePath}.`); + } + return createHash('sha256').update(readFileSync(nativePath)).digest('hex'); +} + +function buildIdentityFiles(): string[] { + return [ + resolve(repoRoot, 'package.json'), + resolve(repoRoot, 'pnpm-lock.yaml'), + resolve(repoRoot, 'packages/core/package.json'), + resolve(repoRoot, 'packages/cli/package.json'), + resolve(repoRoot, 'packages/cli/tsup.config.ts'), + resolve(repoRoot, 'packages/cli/tsup.standalone.config.ts'), + resolve(repoRoot, 'patches/incur.patch'), + ...existingSourceFiles(resolve(repoRoot, 'packages/core/native')), + ...sourceFiles(resolve(repoRoot, 'packages/core/src')), + ...sourceFiles(resolve(repoRoot, 'packages/cli/src')), + ...sourceFiles(resolve(repoRoot, 'skills')), + ].sort(); +} + +function existingSourceFiles(root: string): string[] { + return existsSync(root) ? sourceFiles(root) : []; +} + +function sourceFiles(root: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const entryPath = resolve(root, entry.name); + if (entry.isDirectory()) { + out.push(...sourceFiles(entryPath)); + continue; + } + if (entry.isFile()) out.push(entryPath); + } + return out; +} diff --git a/packages/core/native/vault_crypto_native.c b/packages/core/native/vault_crypto_native.c new file mode 100644 index 0000000..251e8c3 --- /dev/null +++ b/packages/core/native/vault_crypto_native.c @@ -0,0 +1,533 @@ +#include "vault_crypto_native.h" +#include "vault_secure_memory.h" + +#include +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include +#include +#else +#include +#include +#include +#endif +#include +#include +#include + +#define RECORD_KEY_BYTES 32 +#define RECORD_NONCE_BYTES 12 +#define RECORD_TAG_BYTES 16 +#define VAULT_ARGON_MEMORY_KIB (64 * 1024) +#define VAULT_ARGON_TIME_COST 3 +#define VAULT_SALT_BYTES 16 + +static const uint8_t RECORD_KEY_LABEL[] = "inflow vault record encryption"; +static const uint8_t VAULT_WRAPPING_LABEL[] = "inflow vault material wrapping"; + +static napi_value make_error(napi_env env, const char *code, const char *message) { + napi_value error_message; + napi_value error; + napi_value code_value; + napi_create_string_utf8(env, message, NAPI_AUTO_LENGTH, &error_message); + napi_create_error(env, NULL, error_message, &error); + napi_create_string_utf8(env, code, NAPI_AUTO_LENGTH, &code_value); + napi_set_named_property(env, error, "code", code_value); + return error; +} + +static int byte_array(napi_env env, napi_value value, uint8_t **data, size_t *length) { + bool is_typed_array = false; + if (napi_is_typedarray(env, value, &is_typed_array) != napi_ok || !is_typed_array) { + return 0; + } + napi_typedarray_type type; + napi_value array_buffer; + size_t byte_offset; + void *bytes; + if (napi_get_typedarray_info(env, value, &type, length, &bytes, &array_buffer, &byte_offset) != napi_ok || + type != napi_uint8_array) { + return 0; + } + *data = bytes; + return 1; +} + +#if defined(_WIN32) +static int hmac_sha256( + const uint8_t *key, + size_t key_length, + const uint8_t *first, + size_t first_length, + const uint8_t *second, + size_t second_length, + uint8_t output[VAULT_KEY_BYTES]) { + if (key_length > UINT32_MAX || first_length > UINT32_MAX || second_length > UINT32_MAX) { + return 0; + } + BCRYPT_ALG_HANDLE algorithm = NULL; + BCRYPT_HASH_HANDLE hash = NULL; + DWORD object_length = 0; + DWORD result_length = 0; + uint8_t *object = NULL; + int success = + BCryptOpenAlgorithmProvider( + &algorithm, BCRYPT_SHA256_ALGORITHM, NULL, BCRYPT_ALG_HANDLE_HMAC_FLAG) == 0 && + BCryptGetProperty( + algorithm, + BCRYPT_OBJECT_LENGTH, + (PUCHAR)&object_length, + sizeof(object_length), + &result_length, + 0) == 0 && + object_length > 0 && + (object = malloc(object_length)) != NULL && + BCryptCreateHash( + algorithm, &hash, object, object_length, (PUCHAR)key, (ULONG)key_length, 0) == 0 && + BCryptHashData(hash, (PUCHAR)first, (ULONG)first_length, 0) == 0 && + BCryptHashData(hash, (PUCHAR)second, (ULONG)second_length, 0) == 0 && + BCryptFinishHash(hash, output, VAULT_KEY_BYTES, 0) == 0; + if (hash != NULL) BCryptDestroyHash(hash); + if (object != NULL) { + vault_secure_clear(object, object_length); + free(object); + } + if (algorithm != NULL) BCryptCloseAlgorithmProvider(algorithm, 0); + return success; +} + +static int derive_hkdf_sha256( + const uint8_t *input, + size_t input_length, + const uint8_t *label, + size_t label_length, + uint8_t output[VAULT_KEY_BYTES]) { + const uint8_t zero_salt[VAULT_KEY_BYTES] = {0}; + const uint8_t counter = 1; + uint8_t pseudorandom_key[VAULT_KEY_BYTES]; + const int success = + hmac_sha256( + zero_salt, + sizeof(zero_salt), + input, + input_length, + zero_salt, + 0, + pseudorandom_key) && + hmac_sha256( + pseudorandom_key, + sizeof(pseudorandom_key), + label, + label_length, + &counter, + sizeof(counter), + output); + vault_secure_clear(pseudorandom_key, sizeof(pseudorandom_key)); + return success; +} +#else +static int derive_hkdf_sha256( + const uint8_t *input, + size_t input_length, + const uint8_t *label, + size_t label_length, + uint8_t output[VAULT_KEY_BYTES]) { + int result = 0; + EVP_PKEY_CTX *context = EVP_PKEY_CTX_new_id(EVP_PKEY_HKDF, NULL); + if (context == NULL) { + return 0; + } + size_t output_length = VAULT_KEY_BYTES; + if (EVP_PKEY_derive_init(context) > 0 && + EVP_PKEY_CTX_set_hkdf_md(context, EVP_sha256()) > 0 && + EVP_PKEY_CTX_set1_hkdf_key(context, input, input_length) > 0 && + EVP_PKEY_CTX_add1_hkdf_info(context, label, label_length) > 0 && + EVP_PKEY_derive(context, output, &output_length) > 0 && output_length == VAULT_KEY_BYTES) { + result = 1; + } + EVP_PKEY_CTX_free(context); + return result; +} +#endif + +static int derive_record_key(const vault_protected_key *key, uint8_t output[RECORD_KEY_BYTES]) { + vault_protected_key *mutable_key = (vault_protected_key *)key; + if (!vault_protected_key_open(mutable_key)) { + return 0; + } + const int result = + derive_hkdf_sha256( + key->memory, + VAULT_KEY_BYTES, + RECORD_KEY_LABEL, + sizeof(RECORD_KEY_LABEL) - 1, + output); + return vault_protected_key_close(mutable_key) ? result : 0; +} + +static napi_value derive_vault_wrapping_key(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value argv[2]; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc != 2) { + napi_throw(env, make_error(env, "EINVAL", "expected unlock factor and 16-byte salt")); + return NULL; + } + uint8_t *unlock_factor; + size_t unlock_factor_length; + uint8_t *salt; + size_t salt_length; + if (!byte_array(env, argv[0], &unlock_factor, &unlock_factor_length) || + !byte_array(env, argv[1], &salt, &salt_length) || + unlock_factor_length < 6 || unlock_factor_length > UINT32_MAX || salt_length != VAULT_SALT_BYTES) { + napi_throw(env, make_error(env, "EINVAL", "unlock factor or salt is malformed")); + return NULL; + } + + uint8_t argon_output[VAULT_KEY_BYTES]; + uint8_t wrapping_key[VAULT_KEY_BYTES]; + size_t password_allocation_size = 0; + uint8_t *password = vault_secure_allocate(unlock_factor_length, &password_allocation_size); + if (password == NULL) { + napi_throw(env, make_error(env, "ENOMEM", "vault key derivation input allocation failed")); + return NULL; + } + memcpy(password, unlock_factor, unlock_factor_length); + argon2_context context = { + .out = argon_output, + .outlen = sizeof(argon_output), + .pwd = password, + .pwdlen = (uint32_t)unlock_factor_length, + .salt = salt, + .saltlen = (uint32_t)salt_length, + .secret = NULL, + .secretlen = 0, + .ad = NULL, + .adlen = 0, + .t_cost = VAULT_ARGON_TIME_COST, + .m_cost = VAULT_ARGON_MEMORY_KIB, + .lanes = 1, + .threads = 1, + .version = ARGON2_VERSION_13, + .allocate_cbk = NULL, + .free_cbk = NULL, + .flags = ARGON2_FLAG_CLEAR_PASSWORD | ARGON2_FLAG_CLEAR_SECRET, + }; + const int argon_result = argon2id_ctx(&context); + const int success = + argon_result == ARGON2_OK && + derive_hkdf_sha256( + argon_output, + sizeof(argon_output), + VAULT_WRAPPING_LABEL, + sizeof(VAULT_WRAPPING_LABEL) - 1, + wrapping_key); + vault_secure_release(password, password_allocation_size); + vault_secure_clear(argon_output, sizeof(argon_output)); + if (!success) { + vault_secure_clear(wrapping_key, sizeof(wrapping_key)); + napi_throw(env, make_error(env, "ECRYPTO", "vault key derivation failed")); + return NULL; + } + + napi_value result; + if (napi_create_buffer_copy(env, sizeof(wrapping_key), wrapping_key, NULL, &result) != napi_ok) { + vault_secure_clear(wrapping_key, sizeof(wrapping_key)); + napi_throw(env, make_error(env, "ENOMEM", "vault key derivation output failed")); + return NULL; + } + vault_secure_clear(wrapping_key, sizeof(wrapping_key)); + return result; +} + +#if defined(_WIN32) +static int aes_gcm( + int encrypt, + const uint8_t key[RECORD_KEY_BYTES], + const uint8_t *aad, + size_t aad_length, + const uint8_t *input, + size_t input_length, + uint8_t *output, + uint8_t nonce[RECORD_NONCE_BYTES], + uint8_t tag[RECORD_TAG_BYTES]) { + BCRYPT_ALG_HANDLE algorithm = NULL; + BCRYPT_KEY_HANDLE key_handle = NULL; + DWORD key_object_length = 0; + DWORD result_length = 0; + ULONG output_length = 0; + uint8_t *key_object = NULL; + int success = + BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_AES_ALGORITHM, NULL, 0) == 0 && + BCryptSetProperty( + algorithm, + BCRYPT_CHAINING_MODE, + (PUCHAR)BCRYPT_CHAIN_MODE_GCM, + sizeof(BCRYPT_CHAIN_MODE_GCM), + 0) == 0 && + BCryptGetProperty( + algorithm, + BCRYPT_OBJECT_LENGTH, + (PUCHAR)&key_object_length, + sizeof(key_object_length), + &result_length, + 0) == 0 && + key_object_length > 0 && + (key_object = malloc(key_object_length)) != NULL && + BCryptGenerateSymmetricKey( + algorithm, + &key_handle, + key_object, + key_object_length, + (PUCHAR)key, + RECORD_KEY_BYTES, + 0) == 0; + if (success && encrypt) { + success = BCryptGenRandom(NULL, nonce, RECORD_NONCE_BYTES, BCRYPT_USE_SYSTEM_PREFERRED_RNG) == 0; + } + BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO authentication; + BCRYPT_INIT_AUTH_MODE_INFO(authentication); + authentication.pbNonce = nonce; + authentication.cbNonce = RECORD_NONCE_BYTES; + authentication.pbAuthData = (PUCHAR)aad; + authentication.cbAuthData = (ULONG)aad_length; + authentication.pbTag = tag; + authentication.cbTag = RECORD_TAG_BYTES; + if (success) { + const NTSTATUS status = + encrypt + ? BCryptEncrypt( + key_handle, + (PUCHAR)input, + (ULONG)input_length, + &authentication, + NULL, + 0, + output, + (ULONG)input_length, + &output_length, + 0) + : BCryptDecrypt( + key_handle, + (PUCHAR)input, + (ULONG)input_length, + &authentication, + NULL, + 0, + output, + (ULONG)input_length, + &output_length, + 0); + success = status == 0 && output_length == input_length; + } + if (key_handle != NULL) BCryptDestroyKey(key_handle); + if (key_object != NULL) { + vault_secure_clear(key_object, key_object_length); + free(key_object); + } + if (algorithm != NULL) BCryptCloseAlgorithmProvider(algorithm, 0); + return success; +} +#endif + +static napi_value encrypt_record(napi_env env, napi_callback_info info) { + size_t argc = 3; + napi_value argv[3]; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc != 3) { + napi_throw(env, make_error(env, "EINVAL", "expected protected key, authenticated data, and plaintext")); + return NULL; + } + vault_protected_key *key = vault_protected_key_argument(env, argv[0]); + if (key == NULL) { + return NULL; + } + uint8_t *aad; + size_t aad_length; + uint8_t *plaintext; + size_t plaintext_length; + if (!byte_array(env, argv[1], &aad, &aad_length) || !byte_array(env, argv[2], &plaintext, &plaintext_length) || + plaintext_length > INT32_MAX || aad_length > INT32_MAX) { + napi_throw(env, make_error(env, "EINVAL", "expected bounded byte arrays")); + return NULL; + } + + uint8_t record_key[RECORD_KEY_BYTES]; + uint8_t nonce[RECORD_NONCE_BYTES]; + uint8_t tag[RECORD_TAG_BYTES]; + uint8_t *ciphertext = malloc(plaintext_length == 0 ? 1 : plaintext_length); +#if !defined(_WIN32) + EVP_CIPHER_CTX *context = NULL; + int aad_output_length = 0; + int output_length = 0; + int final_length = 0; +#endif +#if defined(_WIN32) + int success = + ciphertext != NULL && + derive_record_key(key, record_key) && + aes_gcm( + 1, + record_key, + aad, + aad_length, + plaintext, + plaintext_length, + ciphertext, + nonce, + tag); +#else + int success = + ciphertext != NULL && derive_record_key(key, record_key) && + RAND_bytes(nonce, sizeof(nonce)) == 1 && + (context = EVP_CIPHER_CTX_new()) != NULL && + EVP_EncryptInit_ex(context, EVP_aes_256_gcm(), NULL, NULL, NULL) == 1 && + EVP_CIPHER_CTX_ctrl(context, EVP_CTRL_GCM_SET_IVLEN, sizeof(nonce), NULL) == 1 && + EVP_EncryptInit_ex(context, NULL, NULL, record_key, nonce) == 1 && + EVP_EncryptUpdate(context, NULL, &aad_output_length, aad, (int)aad_length) == 1 && + EVP_EncryptUpdate(context, ciphertext, &output_length, plaintext, (int)plaintext_length) == 1 && + EVP_EncryptFinal_ex(context, ciphertext + output_length, &final_length) == 1 && + EVP_CIPHER_CTX_ctrl(context, EVP_CTRL_GCM_GET_TAG, sizeof(tag), tag) == 1; +#endif + + vault_secure_clear(record_key, sizeof(record_key)); +#if !defined(_WIN32) + EVP_CIPHER_CTX_free(context); +#endif + if (!success) { + if (ciphertext != NULL) { + vault_secure_clear(ciphertext, plaintext_length); + free(ciphertext); + } + napi_throw(env, make_error(env, "ECRYPTO", "record encryption failed")); + return NULL; + } + + napi_value result; + napi_value ciphertext_value; + napi_value nonce_value; + napi_value tag_value; + if (napi_create_object(env, &result) != napi_ok || +#if defined(_WIN32) + napi_create_buffer_copy(env, plaintext_length, ciphertext, NULL, &ciphertext_value) != napi_ok || +#else + napi_create_buffer_copy(env, (size_t)(output_length + final_length), ciphertext, NULL, &ciphertext_value) != + napi_ok || +#endif + napi_create_buffer_copy(env, sizeof(nonce), nonce, NULL, &nonce_value) != napi_ok || + napi_create_buffer_copy(env, sizeof(tag), tag, NULL, &tag_value) != napi_ok || + napi_set_named_property(env, result, "ciphertext", ciphertext_value) != napi_ok || + napi_set_named_property(env, result, "nonce", nonce_value) != napi_ok || + napi_set_named_property(env, result, "tag", tag_value) != napi_ok) { + vault_secure_clear(ciphertext, plaintext_length); + free(ciphertext); + napi_throw(env, make_error(env, "ENOMEM", "record encryption output failed")); + return NULL; + } + vault_secure_clear(ciphertext, plaintext_length); + free(ciphertext); + return result; +} + +static napi_value decrypt_record(napi_env env, napi_callback_info info) { + size_t argc = 5; + napi_value argv[5]; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc != 5) { + napi_throw(env, make_error(env, "EINVAL", "expected protected key, authenticated data, ciphertext, nonce, and tag")); + return NULL; + } + vault_protected_key *key = vault_protected_key_argument(env, argv[0]); + if (key == NULL) { + return NULL; + } + uint8_t *aad; + size_t aad_length; + uint8_t *ciphertext; + size_t ciphertext_length; + uint8_t *nonce; + size_t nonce_length; + uint8_t *tag; + size_t tag_length; + if (!byte_array(env, argv[1], &aad, &aad_length) || !byte_array(env, argv[2], &ciphertext, &ciphertext_length) || + !byte_array(env, argv[3], &nonce, &nonce_length) || !byte_array(env, argv[4], &tag, &tag_length) || + nonce_length != RECORD_NONCE_BYTES || tag_length != RECORD_TAG_BYTES || ciphertext_length > INT32_MAX || + aad_length > INT32_MAX) { + napi_throw(env, make_error(env, "EINVAL", "encrypted record fields are malformed")); + return NULL; + } + + uint8_t record_key[RECORD_KEY_BYTES]; + uint8_t *plaintext = malloc(ciphertext_length == 0 ? 1 : ciphertext_length); +#if !defined(_WIN32) + EVP_CIPHER_CTX *context = NULL; + int aad_output_length = 0; + int output_length = 0; + int final_length = 0; +#endif +#if defined(_WIN32) + int success = + plaintext != NULL && + derive_record_key(key, record_key) && + aes_gcm( + 0, + record_key, + aad, + aad_length, + ciphertext, + ciphertext_length, + plaintext, + nonce, + tag); +#else + int success = + plaintext != NULL && derive_record_key(key, record_key) && + (context = EVP_CIPHER_CTX_new()) != NULL && + EVP_DecryptInit_ex(context, EVP_aes_256_gcm(), NULL, NULL, NULL) == 1 && + EVP_CIPHER_CTX_ctrl(context, EVP_CTRL_GCM_SET_IVLEN, (int)nonce_length, NULL) == 1 && + EVP_DecryptInit_ex(context, NULL, NULL, record_key, nonce) == 1 && + EVP_DecryptUpdate(context, NULL, &aad_output_length, aad, (int)aad_length) == 1 && + EVP_DecryptUpdate(context, plaintext, &output_length, ciphertext, (int)ciphertext_length) == 1 && + EVP_CIPHER_CTX_ctrl(context, EVP_CTRL_GCM_SET_TAG, (int)tag_length, tag) == 1 && + EVP_DecryptFinal_ex(context, plaintext + output_length, &final_length) == 1; +#endif + + vault_secure_clear(record_key, sizeof(record_key)); +#if !defined(_WIN32) + EVP_CIPHER_CTX_free(context); +#endif + if (!success) { + if (plaintext != NULL) { + vault_secure_clear(plaintext, ciphertext_length); + free(plaintext); + } + napi_throw(env, make_error(env, "EAUTH", "record authentication failed")); + return NULL; + } + + napi_value result; + if (napi_create_buffer_copy( + env, +#if defined(_WIN32) + ciphertext_length, +#else + (size_t)(output_length + final_length), +#endif + plaintext, + NULL, + &result) != napi_ok) { + vault_secure_clear(plaintext, ciphertext_length); + free(plaintext); + napi_throw(env, make_error(env, "ENOMEM", "record decryption output failed")); + return NULL; + } + vault_secure_clear(plaintext, ciphertext_length); + free(plaintext); + return result; +} + +napi_status register_vault_crypto_native(napi_env env, napi_value exports) { + napi_property_descriptor properties[] = { + {"decryptRecord", NULL, decrypt_record, NULL, NULL, NULL, napi_default, NULL}, + {"deriveVaultWrappingKey", NULL, derive_vault_wrapping_key, NULL, NULL, NULL, napi_default, NULL}, + {"encryptRecord", NULL, encrypt_record, NULL, NULL, NULL, napi_default, NULL}, + }; + return napi_define_properties(env, exports, sizeof(properties) / sizeof(properties[0]), properties); +} diff --git a/packages/core/native/vault_crypto_native.h b/packages/core/native/vault_crypto_native.h new file mode 100644 index 0000000..d4f01fb --- /dev/null +++ b/packages/core/native/vault_crypto_native.h @@ -0,0 +1,8 @@ +#ifndef INFLOW_VAULT_CRYPTO_NATIVE_H +#define INFLOW_VAULT_CRYPTO_NATIVE_H + +#include + +napi_status register_vault_crypto_native(napi_env env, napi_value exports); + +#endif diff --git a/packages/core/native/vault_peer_darwin.c b/packages/core/native/vault_peer_darwin.c new file mode 100644 index 0000000..0467a74 --- /dev/null +++ b/packages/core/native/vault_peer_darwin.c @@ -0,0 +1,91 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "vault_crypto_native.h" +#include "vault_secure_memory.h" + +static napi_value make_error(napi_env env, const char *code, const char *message) { + napi_value error; + napi_value code_value; + napi_create_string_utf8(env, message, NAPI_AUTO_LENGTH, &error); + napi_create_error(env, NULL, error, &error); + napi_create_string_utf8(env, code, NAPI_AUTO_LENGTH, &code_value); + napi_set_named_property(env, error, "code", code_value); + return error; +} + +static napi_value peer_info(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + napi_get_cb_info(env, info, &argc, argv, NULL, NULL); + if (argc != 1) { + napi_throw(env, make_error(env, "EINVAL", "expected socket file descriptor")); + return NULL; + } + + int32_t fd = -1; + if (napi_get_value_int32(env, argv[0], &fd) != napi_ok || fd < 0) { + napi_throw(env, make_error(env, "EINVAL", "expected socket file descriptor")); + return NULL; + } + + pid_t pid = 0; + socklen_t pid_len = sizeof(pid); + if (getsockopt(fd, SOL_LOCAL, LOCAL_PEERPID, &pid, &pid_len) != 0 || pid <= 0) { + napi_throw(env, make_error(env, "EPEERPID", strerror(errno))); + return NULL; + } + + uid_t uid = 0; + gid_t gid = 0; + if (getpeereid(fd, &uid, &gid) != 0) { + napi_throw(env, make_error(env, "EPEERUID", strerror(errno))); + return NULL; + } + + char path[PROC_PIDPATHINFO_MAXSIZE]; + int path_len = proc_pidpath(pid, path, sizeof(path)); + if (path_len <= 0) { + napi_throw(env, make_error(env, "EPEERPATH", strerror(errno))); + return NULL; + } + + napi_value result; + napi_create_object(env, &result); + + napi_value pid_value; + napi_create_int32(env, pid, &pid_value); + napi_set_named_property(env, result, "pid", pid_value); + + napi_value uid_value; + napi_create_uint32(env, (uint32_t)uid, &uid_value); + napi_set_named_property(env, result, "uid", uid_value); + + napi_value path_value; + napi_create_string_utf8(env, path, (size_t)path_len, &path_value); + napi_set_named_property(env, result, "path", path_value); + + return result; +} + +static napi_value init(napi_env env, napi_value exports) { + napi_value peer_info_fn; + napi_create_function(env, "peerInfo", NAPI_AUTO_LENGTH, peer_info, NULL, &peer_info_fn); + napi_set_named_property(env, exports, "peerInfo", peer_info_fn); + if (register_vault_secure_memory(env, exports) != napi_ok) { + return NULL; + } + if (register_vault_crypto_native(env, exports) != napi_ok) { + return NULL; + } + return exports; +} + +NAPI_MODULE(NODE_GYP_MODULE_NAME, init) diff --git a/packages/core/native/vault_peer_linux.c b/packages/core/native/vault_peer_linux.c new file mode 100644 index 0000000..4bdb2ae --- /dev/null +++ b/packages/core/native/vault_peer_linux.c @@ -0,0 +1,160 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "vault_crypto_native.h" +#include "vault_secure_memory.h" + +static napi_value make_error(napi_env env, const char *code, const char *message) { + napi_value error; + napi_value code_value; + napi_create_string_utf8(env, message, NAPI_AUTO_LENGTH, &error); + napi_create_error(env, NULL, error, &error); + napi_create_string_utf8(env, code, NAPI_AUTO_LENGTH, &code_value); + napi_set_named_property(env, error, "code", code_value); + return error; +} + +static napi_value peer_info(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + napi_get_cb_info(env, info, &argc, argv, NULL, NULL); + if (argc != 1) { + napi_throw(env, make_error(env, "EINVAL", "expected socket file descriptor")); + return NULL; + } + + int32_t fd = -1; + if (napi_get_value_int32(env, argv[0], &fd) != napi_ok || fd < 0) { + napi_throw(env, make_error(env, "EINVAL", "expected socket file descriptor")); + return NULL; + } + + struct ucred credentials; + socklen_t credentials_length = sizeof(credentials); + if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &credentials, &credentials_length) != 0 || credentials.pid <= 0) { + napi_throw(env, make_error(env, "EPEERCRED", strerror(errno))); + return NULL; + } + + int peer_pidfd = -1; + socklen_t peer_pidfd_length = sizeof(peer_pidfd); + if (getsockopt(fd, SOL_SOCKET, SO_PEERPIDFD, &peer_pidfd, &peer_pidfd_length) != 0 || peer_pidfd < 0) { + napi_throw(env, make_error(env, "EPEERPIDFD", strerror(errno))); + return NULL; + } + + char proc_path[64]; + int proc_path_length = snprintf(proc_path, sizeof(proc_path), "/proc/%d/exe", credentials.pid); + if (proc_path_length <= 0 || (size_t)proc_path_length >= sizeof(proc_path)) { + close(peer_pidfd); + napi_throw(env, make_error(env, "EPEERPATH", "peer executable path is unavailable")); + return NULL; + } + + char path[PATH_MAX]; + ssize_t path_length = readlink(proc_path, path, sizeof(path) - 1); + if (path_length <= 0) { + int saved_errno = errno; + close(peer_pidfd); + errno = saved_errno; + napi_throw(env, make_error(env, "EPEERPATH", strerror(errno))); + return NULL; + } + path[path_length] = '\0'; + + struct pollfd peer_poll = { + .fd = peer_pidfd, + .events = POLLIN, + }; + int poll_result = poll(&peer_poll, 1, 0); + if (poll_result < 0 || + (poll_result > 0 && + (peer_poll.revents & (POLLIN | POLLHUP | POLLERR)) != 0)) { + int saved_errno = poll_result < 0 ? errno : ESRCH; + close(peer_pidfd); + errno = saved_errno; + napi_throw(env, make_error(env, "EPEEREXIT", strerror(errno))); + return NULL; + } + close(peer_pidfd); + + napi_value result; + napi_create_object(env, &result); + + napi_value pid_value; + napi_create_int32(env, credentials.pid, &pid_value); + napi_set_named_property(env, result, "pid", pid_value); + + napi_value uid_value; + napi_create_uint32(env, (uint32_t)credentials.uid, &uid_value); + napi_set_named_property(env, result, "uid", uid_value); + + napi_value path_value; + napi_create_string_utf8(env, path, (size_t)path_length, &path_value); + napi_set_named_property(env, result, "path", path_value); + + return result; +} + +static napi_value peer_credentials(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + napi_get_cb_info(env, info, &argc, argv, NULL, NULL); + if (argc != 1) { + napi_throw(env, make_error(env, "EINVAL", "expected socket file descriptor")); + return NULL; + } + + int32_t fd = -1; + if (napi_get_value_int32(env, argv[0], &fd) != napi_ok || fd < 0) { + napi_throw(env, make_error(env, "EINVAL", "expected socket file descriptor")); + return NULL; + } + + struct ucred credentials; + socklen_t credentials_length = sizeof(credentials); + if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &credentials, &credentials_length) != 0 || credentials.pid <= 0) { + napi_throw(env, make_error(env, "EPEERCRED", strerror(errno))); + return NULL; + } + + napi_value result; + napi_create_object(env, &result); + + napi_value pid_value; + napi_create_int32(env, credentials.pid, &pid_value); + napi_set_named_property(env, result, "pid", pid_value); + + napi_value uid_value; + napi_create_uint32(env, (uint32_t)credentials.uid, &uid_value); + napi_set_named_property(env, result, "uid", uid_value); + + return result; +} + +static napi_value init(napi_env env, napi_value exports) { + napi_value peer_info_function; + napi_create_function(env, "peerInfo", NAPI_AUTO_LENGTH, peer_info, NULL, &peer_info_function); + napi_set_named_property(env, exports, "peerInfo", peer_info_function); + napi_value peer_credentials_function; + napi_create_function(env, "peerCredentials", NAPI_AUTO_LENGTH, peer_credentials, NULL, &peer_credentials_function); + napi_set_named_property(env, exports, "peerCredentials", peer_credentials_function); + if (register_vault_secure_memory(env, exports) != napi_ok) { + return NULL; + } + if (register_vault_crypto_native(env, exports) != napi_ok) { + return NULL; + } + return exports; +} + +NAPI_MODULE(NODE_GYP_MODULE_NAME, init) diff --git a/packages/core/native/vault_peer_windows.c b/packages/core/native/vault_peer_windows.c new file mode 100644 index 0000000..8005bef --- /dev/null +++ b/packages/core/native/vault_peer_windows.c @@ -0,0 +1,1190 @@ +#define WIN32_LEAN_AND_MEAN + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "vault_crypto_native.h" +#include "vault_secure_memory.h" + +#define VAULT_IPC_MAX_FRAME_BYTES (4 + 1024 * 1024) + +typedef struct vault_pipe_connection { + HANDLE handle; +} vault_pipe_connection; + +static const uint8_t vault_pipe_handshake[] = {'I', 'N', 'F', 'L', 'O', 'W', 'V', '1'}; + +static HANDLE service_ready_event = NULL; +static HANDLE service_stopped_event = NULL; +static volatile LONG service_stop_requested = 0; +static volatile LONG service_lock_requested = 0; +static SERVICE_STATUS_HANDLE service_status_handle = NULL; +static SRWLOCK service_io_lock = SRWLOCK_INIT; +static HANDLE service_io_thread = NULL; + +static int begin_service_io(void) { + HANDLE thread = NULL; + if (!DuplicateHandle( + GetCurrentProcess(), + GetCurrentThread(), + GetCurrentProcess(), + &thread, + 0, + FALSE, + DUPLICATE_SAME_ACCESS)) { + return 0; + } + AcquireSRWLockExclusive(&service_io_lock); + if (service_io_thread != NULL) { + CloseHandle(service_io_thread); + } + service_io_thread = thread; + ReleaseSRWLockExclusive(&service_io_lock); + return 1; +} + +static void end_service_io(void) { + AcquireSRWLockExclusive(&service_io_lock); + if (service_io_thread != NULL) { + CloseHandle(service_io_thread); + service_io_thread = NULL; + } + ReleaseSRWLockExclusive(&service_io_lock); +} + +static void cancel_service_io(void) { + AcquireSRWLockShared(&service_io_lock); + if (service_io_thread != NULL) { + CancelSynchronousIo(service_io_thread); + } + ReleaseSRWLockShared(&service_io_lock); +} + +static napi_value make_error(napi_env env, const char *code, const char *message) { + napi_value error_message; + napi_value error; + napi_value code_value; + napi_create_string_utf8(env, message, NAPI_AUTO_LENGTH, &error_message); + napi_create_error(env, NULL, error_message, &error); + napi_create_string_utf8(env, code, NAPI_AUTO_LENGTH, &code_value); + napi_set_named_property(env, error, "code", code_value); + return error; +} + +static void report_service_status(DWORD state, DWORD controls, DWORD exit_code, DWORD wait_hint) { + if (service_status_handle == NULL) return; + SERVICE_STATUS status = { + .dwServiceType = SERVICE_WIN32_OWN_PROCESS, + .dwCurrentState = state, + .dwControlsAccepted = controls, + .dwWin32ExitCode = exit_code, + .dwWaitHint = wait_hint, + }; + SetServiceStatus(service_status_handle, &status); +} + +static DWORD WINAPI vault_service_control( + DWORD control, + DWORD event_type, + LPVOID event_data, + LPVOID context) { + (void)event_data; + (void)context; + if (control == SERVICE_CONTROL_STOP) { + InterlockedExchange(&service_stop_requested, 1); + report_service_status(SERVICE_STOP_PENDING, 0, NO_ERROR, 20000); + cancel_service_io(); + return NO_ERROR; + } + if (control == SERVICE_CONTROL_POWEREVENT && event_type == PBT_APMSUSPEND) { + InterlockedExchange(&service_lock_requested, 1); + return NO_ERROR; + } + if (control == SERVICE_CONTROL_INTERROGATE) return NO_ERROR; + return ERROR_CALL_NOT_IMPLEMENTED; +} + +static void WINAPI vault_service_main(DWORD argc, LPWSTR *argv) { + (void)argc; + (void)argv; + service_status_handle = + RegisterServiceCtrlHandlerExW(L"InFlowVault", vault_service_control, NULL); + if (service_status_handle == NULL) return; + report_service_status(SERVICE_START_PENDING, 0, NO_ERROR, 20000); + if (WaitForSingleObject(service_ready_event, 20000) != WAIT_OBJECT_0) { + InterlockedExchange(&service_stop_requested, 1); + cancel_service_io(); + report_service_status(SERVICE_STOPPED, 0, ERROR_SERVICE_REQUEST_TIMEOUT, 0); + return; + } + report_service_status( + SERVICE_RUNNING, + SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_POWEREVENT, + NO_ERROR, + 0); + WaitForSingleObject(service_stopped_event, INFINITE); + report_service_status(SERVICE_STOPPED, 0, NO_ERROR, 0); +} + +static int initialize_service_events(void) { + if (service_ready_event != NULL && service_stopped_event != NULL) return 1; + service_ready_event = CreateEventW(NULL, TRUE, FALSE, NULL); + service_stopped_event = CreateEventW(NULL, TRUE, FALSE, NULL); + return service_ready_event != NULL && service_stopped_event != NULL; +} + +static int peer_process_id(HANDLE pipe, ULONG *pid) { + if (GetNamedPipeClientProcessId(pipe, pid) && *pid > 0) { + return 1; + } + *pid = 0; + return GetNamedPipeServerProcessId(pipe, pid) && *pid > 0; +} + +static int process_path(HANDLE process, wchar_t **path, DWORD *length) { + DWORD capacity = 32768; + wchar_t *value = calloc(capacity, sizeof(*value)); + if (value == NULL) { + return 0; + } + if (!QueryFullProcessImageNameW(process, 0, value, &capacity) || capacity == 0) { + free(value); + return 0; + } + *path = value; + *length = capacity; + return 1; +} + +static int sid_string(PSID sid, wchar_t **result) { + if (!IsValidSid(sid)) { + return 0; + } + const SID_IDENTIFIER_AUTHORITY *authority = GetSidIdentifierAuthority(sid); + const UCHAR sub_authority_count = *GetSidSubAuthorityCount(sid); + wchar_t *value = calloc(256, sizeof(*value)); + if (value == NULL) { + return 0; + } + int written; + if (authority->Value[0] != 0 || authority->Value[1] != 0) { + written = swprintf_s( + value, + 256, + L"S-%u-0x%02x%02x%02x%02x%02x%02x", + SID_REVISION, + authority->Value[0], + authority->Value[1], + authority->Value[2], + authority->Value[3], + authority->Value[4], + authority->Value[5]); + } else { + const ULONG authority_value = + ((ULONG)authority->Value[2] << 24) | + ((ULONG)authority->Value[3] << 16) | + ((ULONG)authority->Value[4] << 8) | + (ULONG)authority->Value[5]; + written = swprintf_s(value, 256, L"S-%u-%lu", SID_REVISION, authority_value); + } + if (written <= 0) { + free(value); + return 0; + } + size_t offset = (size_t)written; + for (UCHAR index = 0; index < sub_authority_count; index++) { + written = swprintf_s(value + offset, 256 - offset, L"-%lu", *GetSidSubAuthority(sid, index)); + if (written <= 0) { + free(value); + return 0; + } + offset += (size_t)written; + } + *result = value; + return 1; +} + +static int token_sid(HANDLE token, wchar_t **sid) { + DWORD required = 0; + TOKEN_USER *user = NULL; + GetTokenInformation(token, TokenUser, NULL, 0, &required); + if (required == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return 0; + } + user = malloc(required); + if (user == NULL || !GetTokenInformation(token, TokenUser, user, required, &required) || + !sid_string(user->User.Sid, sid)) { + free(user); + return 0; + } + free(user); + return 1; +} + +static int process_sid(HANDLE process, wchar_t **sid) { + HANDLE token = NULL; + if (!OpenProcessToken(process, TOKEN_QUERY, &token)) { + return 0; + } + const int success = token_sid(token, sid); + CloseHandle(token); + return success; +} + +static int pipe_client_identity( + HANDLE pipe, + ULONG observed_pid, + wchar_t **path, + DWORD *path_length, + wchar_t **sid) { + HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, observed_pid); + if (process == NULL) { + return 0; + } + int success = process_path(process, path, path_length); + CloseHandle(process); + if (!success || !ImpersonateNamedPipeClient(pipe)) { + free(*path); + *path = NULL; + return 0; + } + HANDLE token = NULL; + ULONG confirmed_pid = 0; + success = + OpenThreadToken(GetCurrentThread(), TOKEN_QUERY, TRUE, &token) && + token_sid(token, sid) && + peer_process_id(pipe, &confirmed_pid) && + confirmed_pid == observed_pid; + if (token != NULL) { + CloseHandle(token); + } + if (!RevertToSelf()) { + success = 0; + } + if (!success) { + free(*path); + free(*sid); + *path = NULL; + *sid = NULL; + } + return success; +} + +static int allow_service_process_query(void) { + DWORD sid_length = 0; + DWORD domain_length = 0; + SID_NAME_USE sid_type; + LookupAccountNameW( + NULL, + L"NT SERVICE\\InFlowVault", + NULL, + &sid_length, + NULL, + &domain_length, + &sid_type); + if (sid_length == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return 0; + } + PSID service_sid = malloc(sid_length); + wchar_t *domain = calloc(domain_length + 1, sizeof(*domain)); + if (service_sid == NULL || domain == NULL || + !LookupAccountNameW( + NULL, + L"NT SERVICE\\InFlowVault", + service_sid, + &sid_length, + domain, + &domain_length, + &sid_type)) { + free(service_sid); + free(domain); + return 0; + } + PACL existing_dacl = NULL; + PSECURITY_DESCRIPTOR descriptor = NULL; + DWORD status = GetSecurityInfo( + GetCurrentProcess(), + SE_KERNEL_OBJECT, + DACL_SECURITY_INFORMATION, + NULL, + NULL, + &existing_dacl, + NULL, + &descriptor); + PACL updated_dacl = NULL; + if (status == ERROR_SUCCESS) { + EXPLICIT_ACCESSW access = { + .grfAccessPermissions = PROCESS_QUERY_LIMITED_INFORMATION, + .grfAccessMode = GRANT_ACCESS, + .grfInheritance = NO_INHERITANCE, + .Trustee = { + .pMultipleTrustee = NULL, + .MultipleTrusteeOperation = NO_MULTIPLE_TRUSTEE, + .TrusteeForm = TRUSTEE_IS_SID, + .TrusteeType = TRUSTEE_IS_USER, + .ptstrName = service_sid, + }, + }; + status = SetEntriesInAclW(1, &access, existing_dacl, &updated_dacl); + } + if (status == ERROR_SUCCESS) { + status = SetSecurityInfo( + GetCurrentProcess(), + SE_KERNEL_OBJECT, + DACL_SECURITY_INFORMATION, + NULL, + NULL, + updated_dacl, + NULL); + } + if (updated_dacl != NULL) { + LocalFree(updated_dacl); + } + if (descriptor != NULL) { + LocalFree(descriptor); + } + free(service_sid); + free(domain); + return status == ERROR_SUCCESS; +} + +static int pipe_peer_identity( + HANDLE pipe, + int peer_is_client, + ULONG *pid, + wchar_t **path, + DWORD *path_length, + wchar_t **sid) { + ULONG observed_pid = 0; + if (!peer_process_id(pipe, &observed_pid)) { + return 0; + } + if (peer_is_client) { + if (!pipe_client_identity(pipe, observed_pid, path, path_length, sid)) { + return 0; + } + *pid = observed_pid; + return 1; + } + HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, observed_pid); + if (process == NULL) { + return 0; + } + ULONG confirmed_pid = 0; + const int success = + peer_process_id(pipe, &confirmed_pid) && + confirmed_pid == observed_pid && + process_path(process, path, path_length) && + process_sid(process, sid); + CloseHandle(process); + if (!success) { + free(*path); + free(*sid); + *path = NULL; + *sid = NULL; + return 0; + } + *pid = observed_pid; + return 1; +} + +static napi_value peer_identity_value( + napi_env env, + ULONG pid, + const wchar_t *path, + DWORD path_length, + const wchar_t *sid) { + napi_value result; + napi_value path_value; + napi_value pid_value; + napi_value principal_value; + napi_value uid_value; + napi_create_object(env, &result); + napi_create_string_utf16(env, (const char16_t *)path, path_length, &path_value); + napi_create_uint32(env, pid, &pid_value); + napi_create_string_utf16(env, (const char16_t *)sid, NAPI_AUTO_LENGTH, &principal_value); + napi_create_uint32(env, 0, &uid_value); + napi_set_named_property(env, result, "path", path_value); + napi_set_named_property(env, result, "pid", pid_value); + napi_set_named_property(env, result, "principal", principal_value); + napi_set_named_property(env, result, "uid", uid_value); + return result; +} + +static int read_exact(HANDLE pipe, uint8_t *buffer, DWORD length) { + DWORD offset = 0; + while (offset < length) { + DWORD bytes_read = 0; + if (!ReadFile(pipe, buffer + offset, length - offset, &bytes_read, NULL) || bytes_read == 0) { + return 0; + } + offset += bytes_read; + } + return 1; +} + +static int write_exact(HANDLE pipe, const uint8_t *buffer, DWORD length) { + DWORD offset = 0; + while (offset < length) { + DWORD bytes_written = 0; + if (!WriteFile(pipe, buffer + offset, length - offset, &bytes_written, NULL) || bytes_written == 0) { + return 0; + } + offset += bytes_written; + } + return FlushFileBuffers(pipe); +} + +static int read_frame(HANDLE pipe, uint8_t **frame, DWORD *frame_length) { + uint8_t length_bytes[4]; + if (!read_exact(pipe, length_bytes, sizeof(length_bytes))) { + return 0; + } + const uint32_t body_length = + ((uint32_t)length_bytes[0] << 24) | + ((uint32_t)length_bytes[1] << 16) | + ((uint32_t)length_bytes[2] << 8) | + (uint32_t)length_bytes[3]; + if (body_length == 0 || body_length > VAULT_IPC_MAX_FRAME_BYTES - sizeof(length_bytes)) { + SetLastError(ERROR_INVALID_DATA); + return 0; + } + const DWORD total_length = (DWORD)sizeof(length_bytes) + body_length; + uint8_t *value = malloc(total_length); + if (value == NULL) { + SetLastError(ERROR_NOT_ENOUGH_MEMORY); + return 0; + } + memcpy(value, length_bytes, sizeof(length_bytes)); + if (!read_exact(pipe, value + sizeof(length_bytes), body_length)) { + vault_secure_clear(value, total_length); + free(value); + return 0; + } + *frame = value; + *frame_length = total_length; + return 1; +} + +static int string_argument(napi_env env, napi_value value, wchar_t **result) { + size_t length = 0; + if (napi_get_value_string_utf16(env, value, NULL, 0, &length) != napi_ok || length == 0 || + length > 32767) { + return 0; + } + wchar_t *text = calloc(length + 1, sizeof(*text)); + if (text == NULL || + napi_get_value_string_utf16(env, value, (char16_t *)text, length + 1, &length) != napi_ok) { + free(text); + return 0; + } + *result = text; + return 1; +} + +static int bytes_argument(napi_env env, napi_value value, uint8_t **data, size_t *length) { + bool is_buffer = false; + if (napi_is_buffer(env, value, &is_buffer) != napi_ok || !is_buffer || + napi_get_buffer_info(env, value, (void **)data, length) != napi_ok || + *length < 4 || *length > VAULT_IPC_MAX_FRAME_BYTES) { + return 0; + } + return 1; +} + +static void close_connection(vault_pipe_connection *connection) { + if (connection == NULL) { + return; + } + if (connection->handle != INVALID_HANDLE_VALUE) { + CloseHandle(connection->handle); + connection->handle = INVALID_HANDLE_VALUE; + } +} + +static void finalize_connection(napi_env env, void *data, void *hint) { + (void)env; + (void)hint; + vault_pipe_connection *connection = data; + close_connection(connection); + free(connection); +} + +static napi_value connection_result( + napi_env env, + HANDLE pipe, + ULONG pid, + const wchar_t *path, + DWORD path_length, + const wchar_t *sid) { + vault_pipe_connection *connection = calloc(1, sizeof(*connection)); + if (connection == NULL) { + return NULL; + } + connection->handle = pipe; + napi_value connection_value; + napi_value result; + napi_create_external(env, connection, finalize_connection, NULL, &connection_value); + napi_create_object(env, &result); + napi_set_named_property(env, result, "connection", connection_value); + napi_set_named_property(env, result, "peer", peer_identity_value(env, pid, path, path_length, sid)); + return result; +} + +static napi_value accept_pipe_connection(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + wchar_t *pipe_name = NULL; + PSECURITY_DESCRIPTOR descriptor = NULL; + HANDLE pipe = INVALID_HANDLE_VALUE; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc != 1 || + !string_argument(env, argv[0], &pipe_name)) { + napi_throw(env, make_error(env, "EINVAL", "expected named pipe path")); + return NULL; + } + const wchar_t *security = + L"D:P(D;;GA;;;NU)(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;AU)"; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + security, SDDL_REVISION_1, &descriptor, NULL)) { + free(pipe_name); + napi_throw(env, make_error(env, "EPIPESECURITY", "named pipe security initialization failed")); + return NULL; + } + SECURITY_ATTRIBUTES attributes = { + .nLength = sizeof(attributes), + .lpSecurityDescriptor = descriptor, + .bInheritHandle = FALSE, + }; + pipe = CreateNamedPipeW( + pipe_name, + PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + 1, + VAULT_IPC_MAX_FRAME_BYTES, + VAULT_IPC_MAX_FRAME_BYTES, + 0, + &attributes); + LocalFree(descriptor); + free(pipe_name); + if (pipe == INVALID_HANDLE_VALUE) { + napi_throw(env, make_error(env, "EPIPECREATE", "named pipe creation failed")); + return NULL; + } + if (!begin_service_io()) { + CloseHandle(pipe); + napi_throw(env, make_error(env, "EPIPECANCEL", "named pipe cancellation initialization failed")); + return NULL; + } + if (!ConnectNamedPipe(pipe, NULL) && GetLastError() != ERROR_PIPE_CONNECTED) { + end_service_io(); + CloseHandle(pipe); + napi_throw(env, make_error(env, "EPIPECONNECT", "named pipe connection failed")); + return NULL; + } + + ULONG pid = 0; + uint8_t handshake[sizeof(vault_pipe_handshake)]; + wchar_t *path = NULL; + wchar_t *sid = NULL; + DWORD path_length = 0; + if (!read_exact(pipe, handshake, sizeof(handshake)) || + memcmp(handshake, vault_pipe_handshake, sizeof(handshake)) != 0) { + end_service_io(); + CloseHandle(pipe); + napi_throw(env, make_error(env, "EPIPEHANDSHAKE", "named pipe authentication handshake failed")); + return NULL; + } + end_service_io(); + if (!pipe_peer_identity(pipe, 1, &pid, &path, &path_length, &sid)) { + free(path); + free(sid); + CloseHandle(pipe); + napi_throw(env, make_error(env, "EPIPEPEER", "named pipe client verification failed")); + return NULL; + } + + napi_value result = connection_result(env, pipe, pid, path, path_length, sid); + free(path); + free(sid); + if (result == NULL) { + CloseHandle(pipe); + napi_throw(env, make_error(env, "ENOMEM", "named pipe connection allocation failed")); + return NULL; + } + return result; +} + +static vault_pipe_connection *connection_argument(napi_env env, napi_value value) { + vault_pipe_connection *connection = NULL; + if (napi_get_value_external(env, value, (void **)&connection) != napi_ok || + connection == NULL || connection->handle == INVALID_HANDLE_VALUE) { + return NULL; + } + return connection; +} + +static napi_value close_pipe_connection(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc != 1) { + napi_throw(env, make_error(env, "EINVAL", "expected named pipe connection")); + return NULL; + } + vault_pipe_connection *connection = connection_argument(env, argv[0]); + if (connection == NULL) { + napi_throw(env, make_error(env, "EINVAL", "expected named pipe connection")); + return NULL; + } + close_connection(connection); + napi_value result; + napi_get_undefined(env, &result); + return result; +} + +static napi_value begin_pipe_session(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc != 1) { + napi_throw(env, make_error(env, "EINVAL", "expected named pipe connection")); + return NULL; + } + vault_pipe_connection *connection = connection_argument(env, argv[0]); + if (connection == NULL || + !write_exact(connection->handle, vault_pipe_handshake, sizeof(vault_pipe_handshake))) { + napi_throw(env, make_error(env, "EPIPEHANDSHAKE", "named pipe authentication handshake failed")); + return NULL; + } + napi_value result; + napi_get_undefined(env, &result); + return result; +} + +static napi_value read_pipe_request(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc != 1) { + napi_throw(env, make_error(env, "EINVAL", "expected named pipe connection")); + return NULL; + } + vault_pipe_connection *connection = connection_argument(env, argv[0]); + uint8_t *frame = NULL; + DWORD frame_length = 0; + if (connection == NULL) { + napi_throw(env, make_error(env, "EPIPEREAD", "named pipe request failed")); + return NULL; + } + if (!begin_service_io()) { + napi_throw(env, make_error(env, "EPIPECANCEL", "named pipe cancellation initialization failed")); + return NULL; + } + const int success = read_frame(connection->handle, &frame, &frame_length); + end_service_io(); + if (!success) { + napi_throw(env, make_error(env, "EPIPEREAD", "named pipe request failed")); + return NULL; + } + napi_value result; + napi_create_buffer_copy(env, frame_length, frame, NULL, &result); + vault_secure_clear(frame, frame_length); + free(frame); + return result; +} + +static napi_value write_pipe_response(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value argv[2]; + vault_pipe_connection *connection = NULL; + uint8_t *frame = NULL; + size_t frame_length = 0; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc != 2 || + (connection = connection_argument(env, argv[0])) == NULL || + !bytes_argument(env, argv[1], &frame, &frame_length)) { + napi_throw(env, make_error(env, "EINVAL", "expected named pipe connection and frame")); + return NULL; + } + if (!begin_service_io()) { + napi_throw(env, make_error(env, "EPIPECANCEL", "named pipe cancellation initialization failed")); + return NULL; + } + const int success = write_exact(connection->handle, frame, (DWORD)frame_length); + end_service_io(); + DisconnectNamedPipe(connection->handle); + close_connection(connection); + if (!success) { + napi_throw(env, make_error(env, "EPIPEWRITE", "named pipe response failed")); + return NULL; + } + napi_value result; + napi_get_undefined(env, &result); + return result; +} + +static napi_value connect_pipe(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + wchar_t *pipe_name = NULL; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc != 1 || + !string_argument(env, argv[0], &pipe_name)) { + free(pipe_name); + napi_throw(env, make_error(env, "EINVAL", "expected named pipe path")); + return NULL; + } + if (!allow_service_process_query()) { + free(pipe_name); + napi_throw(env, make_error(env, "EPIPEACL", "vault service process verification could not be authorized")); + return NULL; + } + SC_HANDLE manager = OpenSCManagerW(NULL, NULL, SC_MANAGER_CONNECT); + SC_HANDLE service = + manager == NULL ? NULL : OpenServiceW(manager, L"InFlowVault", SERVICE_START | SERVICE_QUERY_STATUS); + if (service == NULL || + (!StartServiceW(service, 0, NULL) && GetLastError() != ERROR_SERVICE_ALREADY_RUNNING)) { + if (service != NULL) CloseServiceHandle(service); + if (manager != NULL) CloseServiceHandle(manager); + free(pipe_name); + napi_throw(env, make_error(env, "ESERVICESTART", "vault service could not be started")); + return NULL; + } + CloseServiceHandle(service); + CloseServiceHandle(manager); + const ULONGLONG pipe_deadline = GetTickCount64() + 25000; + HANDLE pipe = INVALID_HANDLE_VALUE; + while (pipe == INVALID_HANDLE_VALUE) { + if (WaitNamedPipeW(pipe_name, 250)) { + pipe = CreateFileW( + pipe_name, + GENERIC_READ | GENERIC_WRITE, + 0, + NULL, + OPEN_EXISTING, + SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION, + NULL); + if (pipe != INVALID_HANDLE_VALUE) { + break; + } + } + const DWORD error = GetLastError(); + if ((error != ERROR_FILE_NOT_FOUND && error != ERROR_PIPE_BUSY && error != ERROR_SEM_TIMEOUT) || + GetTickCount64() >= pipe_deadline) { + free(pipe_name); + napi_throw(env, make_error(env, "EPIPEWAIT", "named pipe service is unavailable")); + return NULL; + } + Sleep(50); + } + free(pipe_name); + + ULONG pid = 0; + wchar_t *path = NULL; + wchar_t *sid = NULL; + DWORD path_length = 0; + if (!pipe_peer_identity(pipe, 0, &pid, &path, &path_length, &sid)) { + free(path); + free(sid); + CloseHandle(pipe); + napi_throw(env, make_error(env, "EPIPEPEER", "named pipe server verification failed")); + return NULL; + } + napi_value result = connection_result(env, pipe, pid, path, path_length, sid); + free(path); + free(sid); + if (result == NULL) { + CloseHandle(pipe); + napi_throw(env, make_error(env, "ENOMEM", "named pipe connection allocation failed")); + return NULL; + } + return result; +} + +static napi_value exchange_pipe_request(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value argv[2]; + vault_pipe_connection *connection = NULL; + uint8_t *request = NULL; + size_t request_length = 0; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc != 2 || + (connection = connection_argument(env, argv[0])) == NULL || + !bytes_argument(env, argv[1], &request, &request_length)) { + napi_throw(env, make_error(env, "EINVAL", "expected named pipe connection and frame")); + return NULL; + } + uint8_t *response = NULL; + DWORD response_length = 0; + if (!write_exact(connection->handle, request, (DWORD)request_length) || + !read_frame(connection->handle, &response, &response_length)) { + close_connection(connection); + napi_throw(env, make_error(env, "EPIPEEXCHANGE", "named pipe request failed")); + return NULL; + } + close_connection(connection); + napi_value result; + napi_create_buffer_copy(env, response_length, response, NULL, &result); + vault_secure_clear(response, response_length); + free(response); + return result; +} + +static napi_value verify_authenticode(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + wchar_t *path = NULL; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc != 1 || + !string_argument(env, argv[0], &path)) { + napi_throw(env, make_error(env, "EINVAL", "expected executable path")); + return NULL; + } + + WINTRUST_FILE_INFO file_info = { + .cbStruct = sizeof(file_info), + .pcwszFilePath = path, + }; + WINTRUST_DATA trust_data = { + .cbStruct = sizeof(trust_data), + .dwUIChoice = WTD_UI_NONE, + .fdwRevocationChecks = WTD_REVOKE_NONE, + .dwUnionChoice = WTD_CHOICE_FILE, + .pFile = &file_info, + .dwStateAction = WTD_STATEACTION_VERIFY, + .dwProvFlags = WTD_CACHE_ONLY_URL_RETRIEVAL, + }; + GUID action = WINTRUST_ACTION_GENERIC_VERIFY_V2; + const LONG trust_status = WinVerifyTrust(INVALID_HANDLE_VALUE, &action, &trust_data); + trust_data.dwStateAction = WTD_STATEACTION_CLOSE; + WinVerifyTrust(INVALID_HANDLE_VALUE, &action, &trust_data); + if (trust_status != ERROR_SUCCESS) { + free(path); + napi_throw(env, make_error(env, "EAUTHENTICODE", "Authenticode verification failed")); + return NULL; + } + + HCERTSTORE store = NULL; + HCRYPTMSG message = NULL; + DWORD encoding = 0; + DWORD content_type = 0; + DWORD format_type = 0; + if (!CryptQueryObject( + CERT_QUERY_OBJECT_FILE, + path, + CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED, + CERT_QUERY_FORMAT_FLAG_BINARY, + 0, + &encoding, + &content_type, + &format_type, + &store, + &message, + NULL)) { + free(path); + napi_throw(env, make_error(env, "EAUTHENTICODE", "Authenticode signer is unavailable")); + return NULL; + } + free(path); + + DWORD signer_size = 0; + CMSG_SIGNER_INFO *signer = NULL; + PCCERT_CONTEXT certificate = NULL; + wchar_t publisher[512]; + uint8_t thumbprint[32]; + DWORD thumbprint_length = sizeof(thumbprint); + int success = + CryptMsgGetParam(message, CMSG_SIGNER_INFO_PARAM, 0, NULL, &signer_size) && + signer_size > 0; + if (success) { + signer = malloc(signer_size); + success = signer != NULL && + CryptMsgGetParam(message, CMSG_SIGNER_INFO_PARAM, 0, signer, &signer_size); + } + if (success) { + CERT_INFO certificate_info = { + .Issuer = signer->Issuer, + .SerialNumber = signer->SerialNumber, + }; + certificate = CertFindCertificateInStore( + store, + encoding, + 0, + CERT_FIND_SUBJECT_CERT, + &certificate_info, + NULL); + success = certificate != NULL; + } + if (success) { + success = + CertGetNameStringW( + certificate, + CERT_NAME_SIMPLE_DISPLAY_TYPE, + 0, + NULL, + publisher, + sizeof(publisher) / sizeof(publisher[0])) > 1 && + CryptHashCertificate2( + BCRYPT_SHA256_ALGORITHM, + 0, + NULL, + certificate->pbCertEncoded, + certificate->cbCertEncoded, + thumbprint, + &thumbprint_length) && + thumbprint_length == sizeof(thumbprint); + } + if (!success) { + if (certificate != NULL) CertFreeCertificateContext(certificate); + free(signer); + CryptMsgClose(message); + CertCloseStore(store, 0); + napi_throw(env, make_error(env, "EAUTHENTICODE", "Authenticode signer is unavailable")); + return NULL; + } + + char thumbprint_hex[sizeof(thumbprint) * 2 + 1]; + static const char hex[] = "0123456789abcdef"; + for (size_t index = 0; index < sizeof(thumbprint); index++) { + thumbprint_hex[index * 2] = hex[thumbprint[index] >> 4]; + thumbprint_hex[index * 2 + 1] = hex[thumbprint[index] & 0x0f]; + } + thumbprint_hex[sizeof(thumbprint) * 2] = '\0'; + napi_value publisher_value; + napi_value result; + napi_value thumbprint_value; + napi_create_object(env, &result); + napi_create_string_utf16(env, (const char16_t *)publisher, NAPI_AUTO_LENGTH, &publisher_value); + napi_create_string_utf8(env, thumbprint_hex, NAPI_AUTO_LENGTH, &thumbprint_value); + napi_set_named_property(env, result, "publisher", publisher_value); + napi_set_named_property(env, result, "thumbprint", thumbprint_value); + vault_secure_clear(thumbprint, sizeof(thumbprint)); + CertFreeCertificateContext(certificate); + free(signer); + CryptMsgClose(message); + CertCloseStore(store, 0); + return result; +} + +static napi_value peer_info(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + int32_t file_descriptor = -1; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc != 1 || + napi_get_value_int32(env, argv[0], &file_descriptor) != napi_ok || file_descriptor < 0) { + napi_throw(env, make_error(env, "EINVAL", "expected named pipe file descriptor")); + return NULL; + } + + const uv_os_fd_t handle_value = uv_get_osfhandle(file_descriptor); + if (handle_value == INVALID_HANDLE_VALUE) { + napi_throw(env, make_error(env, "EINVAL", "named pipe handle is unavailable")); + return NULL; + } + ULONG pid = 0; + if (!peer_process_id(handle_value, &pid)) { + napi_throw(env, make_error(env, "EPEERPID", "named pipe peer process is unavailable")); + return NULL; + } + wchar_t *path = NULL; + wchar_t *sid = NULL; + DWORD path_length = 0; + if (!pipe_peer_identity(handle_value, 0, &pid, &path, &path_length, &sid)) { + free(path); + free(sid); + napi_throw(env, make_error(env, "EPEERIDENTITY", "named pipe peer identity is unavailable")); + return NULL; + } + napi_value result = peer_identity_value(env, pid, path, path_length, sid); + free(path); + free(sid); + return result; +} + +static napi_value run_service_dispatcher(napi_env env, napi_callback_info info) { + size_t argc = 0; + if (napi_get_cb_info(env, info, &argc, NULL, NULL, NULL) != napi_ok || argc != 0 || + !initialize_service_events()) { + napi_throw(env, make_error(env, "ESERVICE", "Windows service initialization failed")); + return NULL; + } + ResetEvent(service_ready_event); + ResetEvent(service_stopped_event); + InterlockedExchange(&service_stop_requested, 0); + InterlockedExchange(&service_lock_requested, 0); + SERVICE_TABLE_ENTRYW table[] = { + {L"InFlowVault", vault_service_main}, + {NULL, NULL}, + }; + if (!StartServiceCtrlDispatcherW(table)) { + napi_throw(env, make_error(env, "ESERVICE", "Windows service dispatcher failed")); + return NULL; + } + napi_value result; + napi_get_undefined(env, &result); + return result; +} + +static napi_value mark_service_ready(napi_env env, napi_callback_info info) { + size_t argc = 0; + if (napi_get_cb_info(env, info, &argc, NULL, NULL, NULL) != napi_ok || argc != 0 || + !initialize_service_events() || !SetEvent(service_ready_event)) { + napi_throw(env, make_error(env, "ESERVICE", "Windows service readiness failed")); + return NULL; + } + napi_value result; + napi_get_undefined(env, &result); + return result; +} + +static napi_value service_control_state(napi_env env, napi_callback_info info) { + size_t argc = 0; + if (napi_get_cb_info(env, info, &argc, NULL, NULL, NULL) != napi_ok || argc != 0) { + napi_throw(env, make_error(env, "EINVAL", "expected no arguments")); + return NULL; + } + napi_value lock_requested; + napi_value result; + napi_value stop_requested; + napi_create_object(env, &result); + napi_get_boolean(env, InterlockedExchange(&service_lock_requested, 0) != 0, &lock_requested); + napi_get_boolean(env, InterlockedCompareExchange(&service_stop_requested, 0, 0) != 0, &stop_requested); + napi_set_named_property(env, result, "lockRequested", lock_requested); + napi_set_named_property(env, result, "stopRequested", stop_requested); + return result; +} + +static napi_value complete_service_stop(napi_env env, napi_callback_info info) { + size_t argc = 0; + if (napi_get_cb_info(env, info, &argc, NULL, NULL, NULL) != napi_ok || argc != 0 || + !initialize_service_events() || !SetEvent(service_stopped_event)) { + napi_throw(env, make_error(env, "ESERVICE", "Windows service shutdown failed")); + return NULL; + } + napi_value result; + napi_get_undefined(env, &result); + return result; +} + +static napi_value init(napi_env env, napi_value exports) { + napi_value accept_pipe_connection_function; + napi_value begin_pipe_session_function; + napi_value close_pipe_connection_function; + napi_value connect_pipe_function; + napi_value complete_service_stop_function; + napi_value exchange_pipe_request_function; + napi_value mark_service_ready_function; + napi_value peer_info_function; + napi_value read_pipe_request_function; + napi_value run_service_dispatcher_function; + napi_value service_control_state_function; + napi_value verify_authenticode_function; + napi_value write_pipe_response_function; + napi_create_function( + env, + "acceptPipeConnection", + NAPI_AUTO_LENGTH, + accept_pipe_connection, + NULL, + &accept_pipe_connection_function); + napi_create_function( + env, + "beginPipeSession", + NAPI_AUTO_LENGTH, + begin_pipe_session, + NULL, + &begin_pipe_session_function); + napi_create_function( + env, + "closePipeConnection", + NAPI_AUTO_LENGTH, + close_pipe_connection, + NULL, + &close_pipe_connection_function); + napi_create_function(env, "connectPipe", NAPI_AUTO_LENGTH, connect_pipe, NULL, &connect_pipe_function); + napi_create_function( + env, + "completeServiceStop", + NAPI_AUTO_LENGTH, + complete_service_stop, + NULL, + &complete_service_stop_function); + napi_create_function( + env, + "exchangePipeRequest", + NAPI_AUTO_LENGTH, + exchange_pipe_request, + NULL, + &exchange_pipe_request_function); + napi_create_function(env, "peerInfo", NAPI_AUTO_LENGTH, peer_info, NULL, &peer_info_function); + napi_create_function( + env, + "readPipeRequest", + NAPI_AUTO_LENGTH, + read_pipe_request, + NULL, + &read_pipe_request_function); + napi_create_function( + env, + "runServiceDispatcher", + NAPI_AUTO_LENGTH, + run_service_dispatcher, + NULL, + &run_service_dispatcher_function); + napi_create_function( + env, + "markServiceReady", + NAPI_AUTO_LENGTH, + mark_service_ready, + NULL, + &mark_service_ready_function); + napi_create_function( + env, + "serviceControlState", + NAPI_AUTO_LENGTH, + service_control_state, + NULL, + &service_control_state_function); + napi_create_function( + env, + "writePipeResponse", + NAPI_AUTO_LENGTH, + write_pipe_response, + NULL, + &write_pipe_response_function); + napi_create_function( + env, + "verifyAuthenticode", + NAPI_AUTO_LENGTH, + verify_authenticode, + NULL, + &verify_authenticode_function); + napi_set_named_property(env, exports, "acceptPipeConnection", accept_pipe_connection_function); + napi_set_named_property(env, exports, "beginPipeSession", begin_pipe_session_function); + napi_set_named_property(env, exports, "closePipeConnection", close_pipe_connection_function); + napi_set_named_property(env, exports, "connectPipe", connect_pipe_function); + napi_set_named_property(env, exports, "completeServiceStop", complete_service_stop_function); + napi_set_named_property(env, exports, "exchangePipeRequest", exchange_pipe_request_function); + napi_set_named_property(env, exports, "peerInfo", peer_info_function); + napi_set_named_property(env, exports, "readPipeRequest", read_pipe_request_function); + napi_set_named_property(env, exports, "runServiceDispatcher", run_service_dispatcher_function); + napi_set_named_property(env, exports, "markServiceReady", mark_service_ready_function); + napi_set_named_property(env, exports, "serviceControlState", service_control_state_function); + napi_set_named_property(env, exports, "verifyAuthenticode", verify_authenticode_function); + napi_set_named_property(env, exports, "writePipeResponse", write_pipe_response_function); + if (register_vault_secure_memory(env, exports) != napi_ok) { + return NULL; + } + if (register_vault_crypto_native(env, exports) != napi_ok) { + return NULL; + } + return exports; +} + +NAPI_MODULE(NODE_GYP_MODULE_NAME, init) diff --git a/packages/core/native/vault_secure_memory.c b/packages/core/native/vault_secure_memory.c new file mode 100644 index 0000000..577e005 --- /dev/null +++ b/packages/core/native/vault_secure_memory.c @@ -0,0 +1,278 @@ +#if defined(__linux__) && !defined(_GNU_SOURCE) +#define _GNU_SOURCE +#endif + +#include "vault_secure_memory.h" + +#include +#include +#include +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include +#include +#else +#include +#if defined(__linux__) +#include +#endif +#include +#include +#endif + +void vault_secure_clear(void *value, size_t length) { + volatile uint8_t *bytes = value; + while (length > 0) { + *bytes = 0; + bytes++; + length--; + } +} + +void *vault_secure_allocate(size_t minimum_length, size_t *allocation_size) { +#if defined(_WIN32) + SYSTEM_INFO system_info; + GetSystemInfo(&system_info); + const size_t page_size = system_info.dwPageSize; + if (page_size == 0 || minimum_length == 0 || minimum_length > SIZE_MAX - (page_size - 1)) { + return NULL; + } + *allocation_size = ((minimum_length + page_size - 1) / page_size) * page_size; + void *memory = VirtualAlloc(NULL, *allocation_size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + if (memory == NULL) { + return NULL; + } + if (!VirtualLock(memory, *allocation_size)) { + VirtualFree(memory, 0, MEM_RELEASE); + return NULL; + } + return memory; +#else + long page_size = sysconf(_SC_PAGESIZE); + if (page_size <= 0 || minimum_length == 0 || minimum_length > SIZE_MAX - ((size_t)page_size - 1)) { + return NULL; + } + *allocation_size = ((minimum_length + (size_t)page_size - 1) / (size_t)page_size) * (size_t)page_size; + void *memory = mmap(NULL, *allocation_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (memory == MAP_FAILED) { + return NULL; + } + if (mlock(memory, *allocation_size) != 0) { + munmap(memory, *allocation_size); + return NULL; + } +#if defined(__linux__) + if (madvise(memory, *allocation_size, MADV_DONTDUMP) != 0) { + munlock(memory, *allocation_size); + munmap(memory, *allocation_size); + return NULL; + } +#endif + return memory; +#endif +} + +void vault_secure_release(void *value, size_t allocation_size) { + if (value == NULL || allocation_size == 0) { + return; + } + vault_secure_clear(value, allocation_size); +#if defined(_WIN32) + VirtualUnlock(value, allocation_size); + VirtualFree(value, 0, MEM_RELEASE); +#else + munlock(value, allocation_size); + munmap(value, allocation_size); +#endif +} + +int vault_protected_key_open(vault_protected_key *key) { + if (key == NULL || key->memory == NULL) { + return 0; + } +#if defined(_WIN32) + if (key->is_encrypted && !CryptUnprotectMemory(key->memory, VAULT_KEY_BYTES, CRYPTPROTECTMEMORY_SAME_PROCESS)) { + return 0; + } + key->is_encrypted = 0; +#endif + return 1; +} + +int vault_protected_key_close(vault_protected_key *key) { + if (key == NULL || key->memory == NULL) { + return 0; + } +#if defined(_WIN32) + if (!key->is_encrypted && !CryptProtectMemory(key->memory, VAULT_KEY_BYTES, CRYPTPROTECTMEMORY_SAME_PROCESS)) { + vault_secure_clear(key->memory, VAULT_KEY_BYTES); + return 0; + } + key->is_encrypted = 1; +#endif + return 1; +} + +static void destroy_key(vault_protected_key *key) { + if (key == NULL || key->memory == NULL) { + return; + } + vault_secure_release(key->memory, key->allocation_size); + key->memory = NULL; + key->allocation_size = 0; +} + +static void finalize_key(napi_env env, void *data, void *hint) { + (void)env; + (void)hint; + vault_protected_key *key = data; + destroy_key(key); + free(key); +} + +static napi_value make_error(napi_env env, const char *code, const char *message) { + napi_value error_message; + napi_value error; + napi_value code_value; + napi_create_string_utf8(env, message, NAPI_AUTO_LENGTH, &error_message); + napi_create_error(env, NULL, error_message, &error); + napi_create_string_utf8(env, code, NAPI_AUTO_LENGTH, &code_value); + napi_set_named_property(env, error, "code", code_value); + return error; +} + +vault_protected_key *vault_protected_key_argument(napi_env env, napi_value value) { + vault_protected_key *key = NULL; + if (napi_get_value_external(env, value, (void **)&key) != napi_ok || key == NULL || key->memory == NULL) { + napi_throw(env, make_error(env, "EINVAL", "protected key is unavailable")); + return NULL; + } + return key; +} + +static napi_value create_protected_key(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc != 1) { + napi_throw(env, make_error(env, "EINVAL", "expected 32-byte key")); + return NULL; + } + bool is_typed_array = false; + napi_is_typedarray(env, argv[0], &is_typed_array); + if (!is_typed_array) { + napi_throw(env, make_error(env, "EINVAL", "expected 32-byte key")); + return NULL; + } + napi_typedarray_type type; + size_t length; + void *data; + napi_value array_buffer; + size_t byte_offset; + if (napi_get_typedarray_info(env, argv[0], &type, &length, &data, &array_buffer, &byte_offset) != napi_ok || + type != napi_uint8_array || length != VAULT_KEY_BYTES) { + napi_throw(env, make_error(env, "EINVAL", "expected 32-byte key")); + return NULL; + } + + vault_protected_key *key = calloc(1, sizeof(*key)); + if (key == NULL) { + napi_throw(env, make_error(env, "ENOMEM", "secure memory allocation failed")); + return NULL; + } + key->memory = vault_secure_allocate(VAULT_KEY_BYTES, &key->allocation_size); + if (key->memory == NULL) { + free(key); + napi_throw(env, make_error(env, "ESECUREMEM", "secure memory allocation failed")); + return NULL; + } + memcpy(key->memory, data, VAULT_KEY_BYTES); + if (!vault_protected_key_close(key)) { + destroy_key(key); + free(key); + napi_throw(env, make_error(env, "ESECUREMEM", "secure memory protection failed")); + return NULL; + } + + napi_value external; + if (napi_create_external(env, key, finalize_key, NULL, &external) != napi_ok) { + destroy_key(key); + free(key); + napi_throw(env, make_error(env, "ESECUREMEM", "secure memory handle creation failed")); + return NULL; + } + return external; +} + +static napi_value destroy_protected_key(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc != 1) { + napi_throw(env, make_error(env, "EINVAL", "expected protected key")); + return NULL; + } + vault_protected_key *key = NULL; + if (napi_get_value_external(env, argv[0], (void **)&key) != napi_ok || key == NULL) { + napi_throw(env, make_error(env, "EINVAL", "expected protected key")); + return NULL; + } + destroy_key(key); + napi_value result; + napi_get_undefined(env, &result); + return result; +} + +static napi_value harden_process(napi_env env, napi_callback_info info) { + size_t argc = 0; + if (napi_get_cb_info(env, info, &argc, NULL, NULL, NULL) != napi_ok || argc != 0) { + napi_throw(env, make_error(env, "EINVAL", "expected no arguments")); + return NULL; + } +#if defined(_WIN32) + if (!HeapSetInformation(NULL, HeapEnableTerminationOnCorruption, NULL, 0)) { + napi_throw(env, make_error(env, "ESECUREPROC", "process exploit mitigation failed")); + return NULL; + } +#if !defined(_WIN64) + if (!SetProcessDEPPolicy(PROCESS_DEP_ENABLE | PROCESS_DEP_DISABLE_ATL_THUNK_EMULATION)) { + napi_throw(env, make_error(env, "ESECUREPROC", "process data execution prevention failed")); + return NULL; + } +#endif + PROCESS_MITIGATION_EXTENSION_POINT_DISABLE_POLICY extension_policy = {0}; + extension_policy.DisableExtensionPoints = 1; + PROCESS_MITIGATION_IMAGE_LOAD_POLICY image_policy = {0}; + image_policy.NoRemoteImages = 1; + image_policy.NoLowMandatoryLabelImages = 1; + image_policy.PreferSystem32Images = 1; + if (!SetProcessMitigationPolicy(ProcessExtensionPointDisablePolicy, &extension_policy, sizeof(extension_policy)) || + !SetProcessMitigationPolicy(ProcessImageLoadPolicy, &image_policy, sizeof(image_policy))) { + napi_throw(env, make_error(env, "ESECUREPROC", "process image policy failed")); + return NULL; + } + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX); +#else + const struct rlimit core_limit = {0, 0}; + if (setrlimit(RLIMIT_CORE, &core_limit) != 0) { + napi_throw(env, make_error(env, "ESECUREPROC", "process core dump restriction failed")); + return NULL; + } +#if defined(__linux__) + if (prctl(PR_SET_DUMPABLE, 0, 0, 0, 0) != 0) { + napi_throw(env, make_error(env, "ESECUREPROC", "process dump restriction failed")); + return NULL; + } +#endif +#endif + napi_value result; + napi_get_undefined(env, &result); + return result; +} + +napi_status register_vault_secure_memory(napi_env env, napi_value exports) { + napi_property_descriptor properties[] = { + {"createProtectedKey", NULL, create_protected_key, NULL, NULL, NULL, napi_default, NULL}, + {"destroyProtectedKey", NULL, destroy_protected_key, NULL, NULL, NULL, napi_default, NULL}, + {"hardenProcess", NULL, harden_process, NULL, NULL, NULL, napi_default, NULL}, + }; + return napi_define_properties(env, exports, sizeof(properties) / sizeof(properties[0]), properties); +} diff --git a/packages/core/native/vault_secure_memory.h b/packages/core/native/vault_secure_memory.h new file mode 100644 index 0000000..7dd503c --- /dev/null +++ b/packages/core/native/vault_secure_memory.h @@ -0,0 +1,25 @@ +#ifndef INFLOW_VAULT_SECURE_MEMORY_H +#define INFLOW_VAULT_SECURE_MEMORY_H + +#include +#include + +#define VAULT_KEY_BYTES 32 + +typedef struct { + uint8_t *memory; + size_t allocation_size; +#if defined(_WIN32) + int is_encrypted; +#endif +} vault_protected_key; + +napi_status register_vault_secure_memory(napi_env env, napi_value exports); +int vault_protected_key_close(vault_protected_key *key); +int vault_protected_key_open(vault_protected_key *key); +void *vault_secure_allocate(size_t minimum_length, size_t *allocation_size); +void vault_secure_clear(void *value, size_t length); +void vault_secure_release(void *value, size_t allocation_size); +vault_protected_key *vault_protected_key_argument(napi_env env, napi_value value); + +#endif diff --git a/packages/core/package.json b/packages/core/package.json index c43ebe8..60dd8b7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -43,11 +43,11 @@ "url": "https://github.com/inflowpayai/inflow-cli/issues" }, "dependencies": { - "@napi-rs/keyring": "1.3.0", + "@node-rs/argon2": "^2.0.2", "strip-ansi": "^7.2.0" }, "peerDependencies": { - "@aep-foundation/agent": "^0.2.0", + "@aep-foundation/agent": "^0.3.0", "@inflowpayai/mpp": "^0.7.0", "@inflowpayai/mpp-buyer": "^0.6.1", "@inflowpayai/x402": "^0.8.0", @@ -55,7 +55,7 @@ "@x402/core": "^2.12.0" }, "devDependencies": { - "@aep-foundation/agent": "^0.2.0", + "@aep-foundation/agent": "^0.3.0", "@inflowpayai/mpp": "^0.7.0", "@inflowpayai/mpp-buyer": "^0.6.1", "@inflowpayai/x402": "^0.8.0", diff --git a/packages/core/src/aep/storage.ts b/packages/core/src/aep/storage.ts index ba76887..126179a 100644 --- a/packages/core/src/aep/storage.ts +++ b/packages/core/src/aep/storage.ts @@ -34,10 +34,14 @@ export interface AepPersistedState { export interface AepStateStorage { clearAepState(): void; + deleteAepCredentials?(owner: AepOwner, serviceDid: string, selector: AepCredentialDeleteSelector): void; + findAepIdentity?(owner: AepOwner, serviceDid: string): AgentServiceIdentity | undefined; getAepState(): AepPersistedState | null; setAepState(state: AepPersistedState): void; } +export type AepCredentialDeleteSelector = { allGrantTypes: true } | { credentialId: string } | { grantType: string }; + export interface PublicDocumentStateStorage { getDiscoveryDocuments(): AepPublicDocumentCacheRecord[]; setDiscoveryDocuments(records: AepPublicDocumentCacheRecord[]): void; @@ -104,6 +108,10 @@ export class AepStorage { credentials(): AgentCredentialStore { return { deleteCredential: (serviceDid, credentialId) => { + if (this.storage.deleteAepCredentials !== undefined) { + this.storage.deleteAepCredentials(this.owner, serviceDid, { credentialId }); + return; + } const state = this.state(); this.purge(state); delete state.credentials[serviceDid]?.[credentialId]; @@ -147,6 +155,10 @@ export class AepStorage { identities(): AgentIdentityStore { return { findByServiceDid: (serviceDid) => { + if (this.storage.findAepIdentity !== undefined) { + const identity = this.storage.findAepIdentity(this.owner, serviceDid); + return identity === undefined ? undefined : structuredClone(identity); + } const identity = this.state().identities[serviceDid]; return identity === undefined ? undefined : structuredClone(identity); }, @@ -162,6 +174,25 @@ export class AepStorage { }; } + deleteCredentials(serviceDid: string, selector: AepCredentialDeleteSelector): void { + if (this.storage.deleteAepCredentials !== undefined) { + this.storage.deleteAepCredentials(this.owner, serviceDid, selector); + return; + } + const state = this.state(); + const records = state.credentials[serviceDid]; + if (records === undefined) return; + for (const [credentialId, record] of Object.entries(records)) { + if (matchesCredentialDeleteSelector(record, selector)) { + delete records[credentialId]; + } + } + if (Object.keys(records).length === 0) { + delete state.credentials[serviceDid]; + } + this.save(state); + } + inspectCache(): AgentInspectCache { return { delete: (serviceUrl) => { @@ -260,6 +291,17 @@ export class AepStorage { } } +function matchesCredentialDeleteSelector( + record: Pick, + selector: AepCredentialDeleteSelector, +): boolean { + return ( + 'allGrantTypes' in selector || + ('credentialId' in selector && record.credentialId === selector.credentialId) || + ('grantType' in selector && record.grantType === selector.grantType) + ); +} + function expired(record: AgentCredentialRecord, now: Date): boolean { if (record.expiresAt === undefined) { return false; diff --git a/packages/core/src/auth/poll.ts b/packages/core/src/auth/poll.ts index a776583..0637fc1 100644 --- a/packages/core/src/auth/poll.ts +++ b/packages/core/src/auth/poll.ts @@ -1,4 +1,5 @@ import type { IAuthResource } from '../resources/interfaces.js'; +import { SecureStorageError } from '../secure-storage/errors.js'; import type { AuthTokens } from '../types/index.js'; import { pollAsync, type PollExitReason } from '../utils/async-poll.js'; import { previewAccessToken } from '../utils/user-display.js'; @@ -89,14 +90,14 @@ async function advancePendingFlow( storage: AuthStorage, connection: ConnectionSettings | undefined, ): Promise { - const pending = storage.getPendingDeviceAuth(); + const pending = readStorageValue(() => storage.getPendingDeviceAuth(), null); if (!pending) return; const tokens: AuthTokens | null = await authResource.pollDeviceAuth(pending.device_code); if (!tokens) return; // Capture the prior refresh token BEFORE overwriting storage. The safe-rebind invariant: revoke fires only after new tokens are durable. - const priorRefreshToken = storage.getAuth()?.refresh_token; + const priorRefreshToken = readStorageValue(() => storage.getAuth(), null)?.refresh_token; storage.setAuth(tokens); // Single active auth method at a time. A successful device-flow login supersedes any prior API key on the same machine. storage.clearApiKey(); @@ -117,15 +118,15 @@ async function advancePendingFlow( * tokens are also present in storage. */ export function composeAuthSnapshot(storage: AuthStorage, options: ComposeAuthSnapshotOptions = {}): AuthSnapshotFrame { - const tokens = storage.getAuth(); + const tokens = readStorageValue(() => storage.getAuth(), null); // Runtime api key (from a flag/env) wins over the stored one. When composing a status frame mid-invocation this is what matches // InflowResources's precedence and avoids "auth status says X but actual calls used Y" confusion. const apiKey = options.effectiveApiKey !== undefined && options.effectiveApiKey.length > 0 ? options.effectiveApiKey - : (storage.getApiKey() ?? undefined); - const pending = storage.getPendingDeviceAuth(); - const connection = options.connection ?? storage.getConnection() ?? undefined; + : (readStorageValue(() => storage.getApiKey(), null) ?? undefined); + const pending = readStorageValue(() => storage.getPendingDeviceAuth(), null); + const connection = options.connection ?? readStorageValue(() => storage.getConnection(), null) ?? undefined; const credentialsPath = options.verbose ? storage.getPath() : undefined; // API key path takes precedence over device tokens — mirrors the resource layer (see InflowResources.authenticatedOptions). @@ -213,7 +214,7 @@ export async function* pollAuthStatus( if (composeOptions.connection !== undefined) { terminal.connection = composeOptions.connection; } else { - const fileConnection = storage.getConnection(); + const fileConnection = readStorageValue(() => storage.getConnection(), null); if (fileConnection !== null) terminal.connection = fileConnection; } if (composeOptions.update !== undefined) terminal.update = composeOptions.update; @@ -223,3 +224,21 @@ export async function* pollAuthStatus( yield outcome.value; } } + +function readStorageValue(read: () => T, fallback: T): T { + try { + return read(); + } catch (cause) { + if ( + cause instanceof SecureStorageError && + cause.secureStorageCode === 'secure_storage_secret_missing' && + cause.message === 'A referenced vault secret is missing.' + ) { + return fallback; + } + throw cause; + } +} + +/** @internal */ +export const __testing = { readStorageValue }; diff --git a/packages/core/src/flows/auth-logout.ts b/packages/core/src/flows/auth-logout.ts index e6be3bc..1d73c40 100644 --- a/packages/core/src/flows/auth-logout.ts +++ b/packages/core/src/flows/auth-logout.ts @@ -9,17 +9,6 @@ export interface AuthLogoutInput { authStorage: AuthStorage; } -/** - * Performs an InFlow logout: revoke the refresh token (best-effort) then clear every authenticated artifact from local - * storage. Mirrors the CLI's `inflow auth logout` semantics exactly. - * - * Order is load-bearing. The explicit `clearPendingDeviceAuth` call between `clearAuth` and `deleteConfig` prevents a - * concurrent `auth status --interval` from completing an in-flight device flow against the just-revoked session: after - * this call returns, any racing reader sees `pending=null` and stops pursuing the new `device_code`. - * - * `revokeToken` failures are swallowed — the local clear is the user-visible signal and the orphan refresh token - * expires server-side regardless. - */ export async function runAuthLogout(input: AuthLogoutInput): Promise { const auth = input.authStorage.getAuth(); if (auth?.refresh_token !== undefined) { diff --git a/packages/core/src/flows/user-get.ts b/packages/core/src/flows/user-get.ts index 690090d..8efccdd 100644 --- a/packages/core/src/flows/user-get.ts +++ b/packages/core/src/flows/user-get.ts @@ -1,11 +1,7 @@ import type { IUserResource } from '../resources/interfaces.js'; import type { User } from '../types/index.js'; -/** - * Public-facing user payload — the same shape the CLI's `user get` command emits in agent mode. The audit timestamps - * (`created` / `updated`) are deliberately stripped: they are server-side state and not part of the user-presentable - * profile. - */ +/** User profile projection without server audit timestamps. */ export type UserAgentPayload = Omit; export function projectUserPayload(user: User): UserAgentPayload { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3aa0c19..37df71c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -87,10 +87,95 @@ export { storage, } from './utils/storage.js'; export { SecureStorageError, type SecureStorageErrorCode } from './secure-storage/errors.js'; -export { SyncMemorySecretStore, type SecretReference, type SyncSecureSecretStore } from './secure-storage/keychain.js'; +export { + MemorySecretStore, + SecretReferenceManifest, + SyncMemorySecretStore, + SyncSecretReferenceManifestStore, + type SecretReference, + type SecureSecretStore, + type SyncSecretReferenceManifest, + type SyncSecureSecretStore, +} from './secure-storage/secret-store.js'; +export { + DEFAULT_VAULT_POLICY, + type Awaitable, + type DeleteExpiredVaultSecretsInput, + type DeleteVaultSecretInput, + type GetVaultSecretInput, + type PutVaultSecretInput, + type TouchVaultSecretInput, + type VaultBackend, + type VaultLockState, + type VaultPolicy, + type VaultSecretPayload, + type VaultStatus, +} from './secure-storage/vault-backend.js'; +export { + type LocalVaultDaemon, + type LocalVaultDaemonOptions, + type LinuxVaultBrokerOptions, + type LinuxVaultServiceOptions, + runLinuxTransferredVaultService, + runLinuxVaultBroker, + runLinuxVaultService, + runLocalVaultDaemon, + startLinuxVaultService, + startLocalVaultDaemon, + systemdSocketFileDescriptor, +} from './secure-storage/vault-daemon.js'; +export { + isWindowsVaultWorkerData, + runWindowsVaultService, + runWindowsVaultWorker, + type WindowsVaultWorkerData, + type WindowsVaultServiceOptions, +} from './secure-storage/vault-windows-service.js'; +export { + LocalVaultClient, + type LocalVaultClientOptions, + type LocalVaultDaemonInfo, +} from './secure-storage/vault-client.js'; +export { type VaultDaemonInfo } from './secure-storage/vault-daemon-handler.js'; +export { + linuxVaultServiceUserId, + removeVaultLocalState, + usesLinuxVaultService, + vaultFilePaths, + type VaultFilePaths, +} from './secure-storage/vault-files.js'; +export { + NoopSyncSecretReferenceManifest, + SyncVaultSecretStore, + type SyncVaultSecretStoreOptions, +} from './secure-storage/vault-sync-secret-store.js'; +export { + sendVaultIpcRequest, + startVaultSocketServer, + type StartMultiTenantVaultSocketServerOptions, + type StartSingleTenantVaultSocketServerOptions, + type StartVaultSocketServerOptions, + type VaultSocketServer, +} from './secure-storage/vault-socket.js'; +export { type VaultSocketPeer, type VaultSocketPeerVerifier } from './secure-storage/vault-peer-verifier.js'; +export { + type VaultIpcError, + type VaultIpcMessage, + type VaultIpcMethod, + type VaultIpcRequest, + type VaultIpcResponse, +} from './secure-storage/vault-ipc.js'; +export { + createVaultSecretReference, + parseVaultSecretReference, + type VaultRecordStatus, + type VaultSecretKind, + type VaultSecretReference, +} from './secure-storage/vault-types.js'; export { sanitizeDeep, sanitizeText } from './utils/sanitize-text.js'; export { sanitizeResource } from './utils/sanitize-proxy.js'; export { + type AepCredentialDeleteSelector, type AepPersistedInspectResult, type AepOwner, type AepPersistedState, diff --git a/packages/core/src/secure-storage/errors.ts b/packages/core/src/secure-storage/errors.ts index 1410864..7978e86 100644 --- a/packages/core/src/secure-storage/errors.ts +++ b/packages/core/src/secure-storage/errors.ts @@ -4,8 +4,13 @@ export type SecureStorageErrorCode = | 'secure_storage_corrupt' | 'secure_storage_invalid_path' | 'secure_storage_io_error' + | 'secure_storage_peer_verification_failed' + | 'secure_storage_secret_conflict' | 'secure_storage_secret_missing' - | 'secure_storage_unavailable'; + | 'secure_storage_unavailable' + | 'vault_daemon_busy' + | 'vault_locked' + | 'vault_not_initialized'; export class SecureStorageError extends InflowSdkError { readonly secureStorageCode: SecureStorageErrorCode; diff --git a/packages/core/src/secure-storage/lifecycle.ts b/packages/core/src/secure-storage/lifecycle.ts index 99c393b..00712db 100644 --- a/packages/core/src/secure-storage/lifecycle.ts +++ b/packages/core/src/secure-storage/lifecycle.ts @@ -1,5 +1,10 @@ -import type { KeychainReferenceManifest, SecretReference, SecureSecretStore } from './keychain.js'; -import type { SyncKeychainReferenceManifest, SyncSecureSecretStore } from './keychain.js'; +import type { + SecretReference, + SecretReferenceManifest, + SecureSecretStore, + SyncSecretReferenceManifest, + SyncSecureSecretStore, +} from './secret-store.js'; import type { SecureSqliteRepository } from './sqlite.js'; function errorFromCause(cause: unknown): Error { @@ -11,7 +16,7 @@ export class SecureSecretLifecycleCoordinator { constructor( private readonly repository: SecureSqliteRepository, private readonly store: SecureSecretStore, - private readonly manifest: KeychainReferenceManifest, + private readonly manifest: SecretReferenceManifest, ) {} async create( @@ -20,48 +25,47 @@ export class SecureSecretLifecycleCoordinator { principalId: number | null, payload: unknown = {}, ): Promise { - let failure: Error | undefined; - await this.repository.writeTransaction(async () => { + await this.repository.writeTransaction(() => { this.repository.beginSecretLifecycle(reference, principalId, payload); - try { - await this.store.create(reference, value); - await this.manifest.add(reference); - this.repository.markSecretActive(reference); - } catch (cause) { - failure = errorFromCause(cause); - } }); - if (failure !== undefined) throw failure; + try { + await this.store.create(reference, value); + await this.manifest.add(reference); + await this.repository.writeTransaction(() => { + this.repository.markSecretActive(reference); + }); + } catch (cause) { + throw errorFromCause(cause); + } } async delete(reference: SecretReference): Promise { - let failure: Error | undefined; - await this.repository.writeTransaction(async () => { + await this.repository.writeTransaction(() => { this.repository.markSecretDeleting(reference); - try { - await this.store.delete(reference); - await this.manifest.remove(reference); - this.repository.deleteSecretLifecycle(reference); - } catch (cause) { - failure = errorFromCause(cause); - } }); - if (failure !== undefined) throw failure; + try { + await this.store.delete(reference); + await this.manifest.remove(reference); + await this.repository.writeTransaction(() => { + this.repository.deleteSecretLifecycle(reference); + }); + } catch (cause) { + throw errorFromCause(cause); + } } async recoverInterruptedWork(): Promise { - await this.repository.writeTransaction(async () => { - for (const reference of this.repository.listSecretLifecycle('pending')) { - await this.deleteIfPresent(reference); - await this.manifest.remove(reference); - this.repository.deleteSecretLifecycle(reference); - } - for (const reference of this.repository.listSecretLifecycle('deleting')) { - await this.deleteIfPresent(reference); - await this.manifest.remove(reference); + const references = await this.repository.writeTransaction(() => [ + ...this.repository.listSecretLifecycle('pending'), + ...this.repository.listSecretLifecycle('deleting'), + ]); + for (const reference of references) { + await this.deleteIfPresent(reference); + await this.manifest.remove(reference); + await this.repository.writeTransaction(() => { this.repository.deleteSecretLifecycle(reference); - } - }); + }); + } } private async deleteIfPresent(reference: SecretReference): Promise { @@ -77,52 +81,51 @@ export class SyncSecureSecretLifecycleCoordinator { constructor( private readonly repository: SecureSqliteRepository, private readonly store: SyncSecureSecretStore, - private readonly manifest: SyncKeychainReferenceManifest, + private readonly manifest: SyncSecretReferenceManifest, ) {} create(reference: SecretReference, value: Uint8Array, principalId: number | null, payload: unknown = {}): void { - let failure: Error | undefined; this.repository.writeTransactionSync(() => { this.repository.beginSecretLifecycle(reference, principalId, payload); - try { - this.store.create(reference, value); - this.manifest.add(reference); - this.repository.markSecretActive(reference); - } catch (cause) { - failure = errorFromCause(cause); - } }); - if (failure !== undefined) throw failure; + try { + this.store.create(reference, value); + this.manifest.add(reference); + this.repository.writeTransactionSync(() => { + this.repository.markSecretActive(reference); + }); + } catch (cause) { + throw errorFromCause(cause); + } } delete(reference: SecretReference): void { - let failure: Error | undefined; this.repository.writeTransactionSync(() => { this.repository.markSecretDeleting(reference); - try { - this.store.delete(reference); - this.manifest.remove(reference); - this.repository.deleteSecretLifecycle(reference); - } catch (cause) { - failure = errorFromCause(cause); - } }); - if (failure !== undefined) throw failure; + try { + this.store.delete(reference); + this.manifest.remove(reference); + this.repository.writeTransactionSync(() => { + this.repository.deleteSecretLifecycle(reference); + }); + } catch (cause) { + throw errorFromCause(cause); + } } recoverInterruptedWork(): void { - this.repository.writeTransactionSync(() => { - for (const reference of this.repository.listSecretLifecycle('pending')) { - this.deleteIfPresent(reference); - this.manifest.remove(reference); + const references = this.repository.writeTransactionSync(() => [ + ...this.repository.listSecretLifecycle('pending'), + ...this.repository.listSecretLifecycle('deleting'), + ]); + for (const reference of references) { + this.deleteIfPresent(reference); + this.manifest.remove(reference); + this.repository.writeTransactionSync(() => { this.repository.deleteSecretLifecycle(reference); - } - for (const reference of this.repository.listSecretLifecycle('deleting')) { - this.deleteIfPresent(reference); - this.manifest.remove(reference); - this.repository.deleteSecretLifecycle(reference); - } - }); + }); + } } private deleteIfPresent(reference: SecretReference): void { diff --git a/packages/core/src/secure-storage/runtime-require.ts b/packages/core/src/secure-storage/runtime-require.ts new file mode 100644 index 0000000..66102b7 --- /dev/null +++ b/packages/core/src/secure-storage/runtime-require.ts @@ -0,0 +1,10 @@ +import { createRequire } from 'node:module'; + +export function runtimeRequire(): NodeRequire { + const embeddedRequire: unknown = Reflect.get(globalThis, '__inflowRequire'); + if (typeof embeddedRequire === 'function') { + // The standalone launcher installs a NodeRequire before evaluating the embedded bundle. + return embeddedRequire as NodeRequire; + } + return createRequire(import.meta.url); +} diff --git a/packages/core/src/secure-storage/keychain.ts b/packages/core/src/secure-storage/secret-store.ts similarity index 52% rename from packages/core/src/secure-storage/keychain.ts rename to packages/core/src/secure-storage/secret-store.ts index 950c619..deddef8 100644 --- a/packages/core/src/secure-storage/keychain.ts +++ b/packages/core/src/secure-storage/secret-store.ts @@ -1,34 +1,7 @@ import { Buffer } from 'node:buffer'; -import { createRequire } from 'node:module'; import { randomUUID } from 'node:crypto'; import { SecureStorageError } from './errors.js'; -const require = createRequire(import.meta.url); - -type KeyringPassword = string | Buffer | Uint8Array | null | undefined; - -interface KeyringEntry { - deletePassword(): Promise; - getPassword(): Promise; - setPassword(password: string | Buffer | Uint8Array): Promise; -} - -interface KeyringModule { - AsyncEntry: new (service: string, account: string) => KeyringEntry; - Entry: new (service: string, account: string) => SyncKeyringEntry; -} - -interface SyncKeyringEntry { - deleteCredential(): boolean; - getSecret(): number[] | Uint8Array | null; - setSecret(secret: Uint8Array): void; -} - -export interface KeychainSecretStoreOptions { - loadKeyring?: () => KeyringModule; - serviceName?: string; -} - export interface SecretReference { purpose: string; reference: string; @@ -46,137 +19,24 @@ export interface SyncSecureSecretStore { read(reference: SecretReference): Uint8Array; } -const DEFAULT_SERVICE_NAME = 'ai.inflowpay.cli'; - -function loadDefaultKeyring(): KeyringModule { - const loaded = require('@napi-rs/keyring') as unknown; - if (typeof loaded !== 'object' || loaded === null || !('AsyncEntry' in loaded)) { - throw new SecureStorageError('secure_storage_unavailable', 'The Keychain binding did not expose AsyncEntry.'); - } - return loaded as KeyringModule; -} - -function accountFor(reference: SecretReference): string { - if (reference.reference.length === 0 || reference.purpose.length === 0) { - throw new SecureStorageError( - 'secure_storage_invalid_path', - 'Secret references require a purpose and opaque reference.', - ); - } - return `${reference.purpose}:${reference.reference}`; -} - -function bytesFromPassword(value: KeyringPassword): Uint8Array { - if (value === null || value === undefined) { - throw new SecureStorageError('secure_storage_secret_missing', 'A referenced secret is missing from Keychain.'); - } - if (typeof value === 'string') { - return Buffer.from(value, 'base64'); - } - return Uint8Array.from(value); +export interface SyncSecretReferenceManifest { + add(reference: SecretReference): void; + read(): SecretReference[]; + remove(reference: SecretReference): void; } export function createOpaqueSecretReference(purpose: string): SecretReference { return { purpose, reference: randomUUID() }; } -export class KeychainSecretStore implements SecureSecretStore { - private readonly loadKeyring: () => KeyringModule; - private readonly serviceName: string; - - constructor(options: KeychainSecretStoreOptions = {}) { - this.loadKeyring = options.loadKeyring ?? loadDefaultKeyring; - this.serviceName = options.serviceName ?? DEFAULT_SERVICE_NAME; - } - - async create(reference: SecretReference, value: Uint8Array): Promise { - try { - await this.entry(reference).setPassword(Buffer.from(value).toString('base64')); - } catch (cause) { - throw new SecureStorageError('secure_storage_io_error', 'Failed to write a secret to Keychain.', { cause }); - } - } - - async read(reference: SecretReference): Promise { - try { - return bytesFromPassword(await this.entry(reference).getPassword()); - } catch (cause) { - if (cause instanceof SecureStorageError) throw cause; - throw new SecureStorageError('secure_storage_io_error', 'Failed to read a secret from Keychain.', { cause }); - } - } - - async delete(reference: SecretReference): Promise { - try { - const deleted = await this.entry(reference).deletePassword(); - if (!deleted) { - throw new SecureStorageError('secure_storage_secret_missing', 'A referenced secret could not be deleted.'); - } - } catch (cause) { - if (cause instanceof SecureStorageError) throw cause; - throw new SecureStorageError('secure_storage_io_error', 'Failed to delete a secret from Keychain.', { cause }); - } - } - - private entry(reference: SecretReference): KeyringEntry { - return new (this.loadKeyring().AsyncEntry)(this.serviceName, accountFor(reference)); - } -} - -export class SyncKeychainSecretStore implements SyncSecureSecretStore { - private readonly loadKeyring: () => KeyringModule; - private readonly serviceName: string; - - constructor(options: KeychainSecretStoreOptions = {}) { - this.loadKeyring = options.loadKeyring ?? loadDefaultKeyring; - this.serviceName = options.serviceName ?? DEFAULT_SERVICE_NAME; - } - - create(reference: SecretReference, value: Uint8Array): void { - try { - this.entry(reference).setSecret(value); - } catch (cause) { - throw new SecureStorageError('secure_storage_io_error', 'Failed to write a secret to Keychain.', { cause }); - } - } - - read(reference: SecretReference): Uint8Array { - try { - const value = this.entry(reference).getSecret(); - if (value === null) { - throw new SecureStorageError('secure_storage_secret_missing', 'A referenced secret is missing from Keychain.'); - } - return Uint8Array.from(value); - } catch (cause) { - if (cause instanceof SecureStorageError) throw cause; - throw new SecureStorageError('secure_storage_io_error', 'Failed to read a secret from Keychain.', { cause }); - } - } - - delete(reference: SecretReference): void { - try { - if (!this.entry(reference).deleteCredential()) { - throw new SecureStorageError('secure_storage_secret_missing', 'A referenced secret could not be deleted.'); - } - } catch (cause) { - if (cause instanceof SecureStorageError) throw cause; - throw new SecureStorageError('secure_storage_io_error', 'Failed to delete a secret from Keychain.', { cause }); - } - } - - private entry(reference: SecretReference): SyncKeyringEntry { - return new (this.loadKeyring().Entry)(this.serviceName, accountFor(reference)); - } -} - -export class KeychainReferenceManifest { +export class SecretReferenceManifest { private readonly reference: SecretReference; constructor( private readonly store: SecureSecretStore, purpose = 'manifest', ) { - this.reference = { purpose, reference: 'fixed-keychain-references' }; + this.reference = { purpose, reference: 'fixed-secret-references' }; } async add(reference: SecretReference): Promise { @@ -195,13 +55,13 @@ export class KeychainReferenceManifest { try { const parsed = JSON.parse(Buffer.from(await this.store.read(this.reference)).toString('utf8')) as unknown; if (!Array.isArray(parsed)) { - throw new SecureStorageError('secure_storage_corrupt', 'The Keychain reference manifest is malformed.'); + throw new SecureStorageError('secure_storage_corrupt', 'The secret reference manifest is malformed.'); } return parsed.map(parseManifestEntry); } catch (cause) { if (cause instanceof SecureStorageError && cause.secureStorageCode === 'secure_storage_secret_missing') return []; if (cause instanceof SecureStorageError) throw cause; - throw new SecureStorageError('secure_storage_corrupt', 'The Keychain reference manifest is malformed.', { + throw new SecureStorageError('secure_storage_corrupt', 'The secret reference manifest is malformed.', { cause, }); } @@ -220,14 +80,14 @@ export class KeychainReferenceManifest { } } -export class SyncKeychainReferenceManifest { +export class SyncSecretReferenceManifestStore implements SyncSecretReferenceManifest { private readonly reference: SecretReference; constructor( private readonly store: SyncSecureSecretStore, purpose = 'manifest', ) { - this.reference = { purpose, reference: 'fixed-keychain-references' }; + this.reference = { purpose, reference: 'fixed-secret-references' }; } add(reference: SecretReference): void { @@ -246,13 +106,13 @@ export class SyncKeychainReferenceManifest { try { const parsed = JSON.parse(Buffer.from(this.store.read(this.reference)).toString('utf8')) as unknown; if (!Array.isArray(parsed)) { - throw new SecureStorageError('secure_storage_corrupt', 'The Keychain reference manifest is malformed.'); + throw new SecureStorageError('secure_storage_corrupt', 'The secret reference manifest is malformed.'); } return parsed.map(parseManifestEntry); } catch (cause) { if (cause instanceof SecureStorageError && cause.secureStorageCode === 'secure_storage_secret_missing') return []; if (cause instanceof SecureStorageError) throw cause; - throw new SecureStorageError('secure_storage_corrupt', 'The Keychain reference manifest is malformed.', { + throw new SecureStorageError('secure_storage_corrupt', 'The secret reference manifest is malformed.', { cause, }); } @@ -271,27 +131,6 @@ export class SyncKeychainReferenceManifest { } } -function parseManifestEntry(value: unknown): SecretReference { - if (typeof value !== 'object' || value === null) { - throw new SecureStorageError( - 'secure_storage_corrupt', - 'The Keychain reference manifest contains a malformed entry.', - ); - } - const candidate = value as Partial; - if (typeof candidate.purpose !== 'string' || typeof candidate.reference !== 'string') { - throw new SecureStorageError( - 'secure_storage_corrupt', - 'The Keychain reference manifest contains a malformed entry.', - ); - } - return { purpose: candidate.purpose, reference: candidate.reference }; -} - -function sameReference(left: SecretReference, right: SecretReference): boolean { - return left.purpose === right.purpose && left.reference === right.reference; -} - export class MemorySecretStore implements SecureSecretStore { private readonly values = new Map(); @@ -304,7 +143,7 @@ export class MemorySecretStore implements SecureSecretStore { const value = this.values.get(accountFor(reference)); if (value === undefined) { return Promise.reject( - new SecureStorageError('secure_storage_secret_missing', 'A referenced secret is missing from Keychain.'), + new SecureStorageError('secure_storage_secret_missing', 'A referenced secret is missing from secret store.'), ); } return Promise.resolve(Uint8Array.from(value)); @@ -330,7 +169,10 @@ export class SyncMemorySecretStore implements SyncSecureSecretStore { read(reference: SecretReference): Uint8Array { const value = this.values.get(accountFor(reference)); if (value === undefined) { - throw new SecureStorageError('secure_storage_secret_missing', 'A referenced secret is missing from Keychain.'); + throw new SecureStorageError( + 'secure_storage_secret_missing', + 'A referenced secret is missing from secret store.', + ); } return Uint8Array.from(value); } @@ -341,3 +183,28 @@ export class SyncMemorySecretStore implements SyncSecureSecretStore { } } } + +function accountFor(reference: SecretReference): string { + if (reference.reference.length === 0 || reference.purpose.length === 0) { + throw new SecureStorageError( + 'secure_storage_invalid_path', + 'Secret references require a purpose and opaque reference.', + ); + } + return `${reference.purpose}:${reference.reference}`; +} + +function parseManifestEntry(value: unknown): SecretReference { + if (typeof value !== 'object' || value === null) { + throw new SecureStorageError('secure_storage_corrupt', 'The secret reference manifest contains a malformed entry.'); + } + const candidate = value as Partial; + if (typeof candidate.purpose !== 'string' || typeof candidate.reference !== 'string') { + throw new SecureStorageError('secure_storage_corrupt', 'The secret reference manifest contains a malformed entry.'); + } + return { purpose: candidate.purpose, reference: candidate.reference }; +} + +function sameReference(left: SecretReference, right: SecretReference): boolean { + return left.purpose === right.purpose && left.reference === right.reference; +} diff --git a/packages/core/src/secure-storage/sqlite.ts b/packages/core/src/secure-storage/sqlite.ts index 506ef0c..3557338 100644 --- a/packages/core/src/secure-storage/sqlite.ts +++ b/packages/core/src/secure-storage/sqlite.ts @@ -1,12 +1,22 @@ import { chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, realpathSync } from 'node:fs'; import { access, chmod, copyFile, mkdir, stat } from 'node:fs/promises'; -import { homedir, userInfo } from 'node:os'; +import { userInfo } from 'node:os'; import path from 'node:path'; -import { createRequire } from 'node:module'; +import process from 'node:process'; import { SecureStorageError } from './errors.js'; -import type { SecretReference } from './keychain.js'; - -const require = createRequire(import.meta.url); +import { runtimeRequire } from './runtime-require.js'; +import type { SecretReference } from './secret-store.js'; +import { defaultVaultRoot } from './vault-files.js'; +import { + vaultRecordStatusCode, + vaultRecordStatusName, + vaultSecretKindCode, + vaultSecretKindName, + type VaultRecordStatus, + type VaultSecretKind, +} from './vault-types.js'; + +const require = runtimeRequire(); type SQLiteValue = Uint8Array | bigint | number | string | null; type SQLiteRow = Record; @@ -57,13 +67,25 @@ export interface StoredPublicDocument { url: string; } +export interface StoredVaultRecord { + ciphertext: Uint8Array; + encryptionVersion: number; + expiresAt?: string; + kind: VaultSecretKind; + nonce: Uint8Array; + reference: string; + status: VaultRecordStatus; + tag: Uint8Array; + updatedAt: string; +} + const APPLICATION_DIRECTORY_MODE = 0o700; const DATABASE_FILE_MODE = 0o600; const SCHEMA_VERSION = 1; const STICKY_BIT_MODE = 0o1000; function defaultDatabasePath(): string { - return path.join(homedir(), 'Library', 'Application Support', 'InFlow', 'inflow.sqlite3'); + return path.join(defaultVaultRoot(), 'inflow.sqlite3'); } function loadDefaultSqlite(): SQLiteModule { @@ -114,6 +136,14 @@ function numberColumn(row: SQLiteRow, name: string): number { return value; } +function bytesColumn(row: SQLiteRow, name: string): Uint8Array { + const value = row[name]; + if (!(value instanceof Uint8Array)) { + throw new SecureStorageError('secure_storage_corrupt', `SQLite column ${name} is malformed.`); + } + return value; +} + function ensureDatabasePath(databasePath: string): void { const parent = path.dirname(databasePath); mkdirSync(parent, { mode: APPLICATION_DIRECTORY_MODE, recursive: true }); @@ -122,15 +152,19 @@ function ensureDatabasePath(databasePath: string): void { if (!parentStat.isDirectory()) { throw new SecureStorageError('secure_storage_invalid_path', 'The InFlow application data directory is invalid.'); } - const currentUid = userInfo().uid; - if (parentStat.uid !== currentUid && (parentStat.mode & STICKY_BIT_MODE) !== STICKY_BIT_MODE) { - throw new SecureStorageError( - 'secure_storage_invalid_path', - 'The InFlow application data directory is not owned by this user.', - ); - } - if (parentStat.uid === currentUid) { - chmodSync(realParent, APPLICATION_DIRECTORY_MODE); + if (process.platform === 'win32') { + assertCanonicalWindowsPath(parent, realParent); + } else { + const currentUid = userInfo().uid; + if (parentStat.uid !== currentUid && (parentStat.mode & STICKY_BIT_MODE) !== STICKY_BIT_MODE) { + throw new SecureStorageError( + 'secure_storage_invalid_path', + 'The InFlow application data directory is not owned by this user.', + ); + } + if (parentStat.uid === currentUid) { + chmodSync(realParent, APPLICATION_DIRECTORY_MODE); + } } if (!existsSync(databasePath)) { @@ -140,6 +174,11 @@ function ensureDatabasePath(databasePath: string): void { if (dbStat.isSymbolicLink() || !dbStat.isFile()) { throw new SecureStorageError('secure_storage_invalid_path', 'The InFlow SQLite database path is invalid.'); } + if (process.platform === 'win32') { + assertCanonicalWindowsPath(databasePath, realpathSync(databasePath)); + return; + } + const currentUid = userInfo().uid; if (dbStat.uid !== currentUid) { throw new SecureStorageError( 'secure_storage_invalid_path', @@ -149,6 +188,12 @@ function ensureDatabasePath(databasePath: string): void { chmodSync(databasePath, DATABASE_FILE_MODE); } +function assertCanonicalWindowsPath(requestedPath: string, realPath: string): void { + if (path.resolve(requestedPath).toLowerCase() !== path.resolve(realPath).toLowerCase()) { + throw new SecureStorageError('secure_storage_invalid_path', 'The InFlow SQLite path is invalid.'); + } +} + export class SecureSqliteRepository { private database: SQLiteDatabase | undefined; private inTransaction = false; @@ -269,11 +314,24 @@ export class SecureSqliteRepository { payload BLOB NOT NULL, PRIMARY KEY(secret_purpose, secret_reference) ); + CREATE TABLE IF NOT EXISTS vault_records ( + reference TEXT PRIMARY KEY, + kind INTEGER NOT NULL, + status INTEGER NOT NULL, + expires_at TEXT, + encryption_version INTEGER NOT NULL, + nonce BLOB NOT NULL, + tag BLOB NOT NULL, + ciphertext BLOB NOT NULL, + updated_at TEXT NOT NULL + ); CREATE INDEX IF NOT EXISTS auth_sessions_principal_idx ON auth_sessions(principal_id); CREATE INDEX IF NOT EXISTS aep_credentials_lookup_idx ON aep_credentials(principal_id, service_did, grant_type, expires_at); CREATE INDEX IF NOT EXISTS aep_identities_lookup_idx ON aep_identities(principal_id, service_did); CREATE INDEX IF NOT EXISTS public_documents_namespace_idx ON public_documents(namespace, cached_at); CREATE INDEX IF NOT EXISTS secret_lifecycle_state_idx ON secret_lifecycle(state); + CREATE INDEX IF NOT EXISTS vault_records_status_idx ON vault_records(status); + CREATE INDEX IF NOT EXISTS vault_records_expiry_idx ON vault_records(expires_at); `); this.setSchemaMetadata('schema_version', String(SCHEMA_VERSION)); const check = db.prepare('PRAGMA quick_check').get(); @@ -485,6 +543,79 @@ export class SecureSqliteRepository { })); } + putVaultRecord(record: StoredVaultRecord): void { + this.transaction(() => { + this.db() + .prepare( + `INSERT INTO vault_records ( + reference, kind, status, expires_at, encryption_version, nonce, tag, ciphertext, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + record.reference, + vaultSecretKindCode(record.kind), + vaultRecordStatusCode(record.status), + record.expiresAt ?? null, + record.encryptionVersion, + record.nonce, + record.tag, + record.ciphertext, + record.updatedAt, + ); + }); + } + + hasVaultRecord(reference: string): boolean { + return this.db().prepare('SELECT 1 FROM vault_records WHERE reference = ?').get(reference) !== undefined; + } + + getVaultRecordByReference(reference: string): StoredVaultRecord | undefined { + const row = this.db().prepare('SELECT * FROM vault_records WHERE reference = ?').get(reference); + return row === undefined ? undefined : this.vaultRecordFromRow(row); + } + + getVaultRecord(reference: string, expectedKind: VaultSecretKind): StoredVaultRecord | undefined { + const record = this.getVaultRecordByReference(reference); + if (record === undefined) return undefined; + if (record.kind !== expectedKind || record.status !== 'active') return undefined; + if (record.expiresAt !== undefined && Date.parse(record.expiresAt) <= Date.now()) return undefined; + return record; + } + + listVaultRecords(status: VaultRecordStatus): StoredVaultRecord[] { + return this.db() + .prepare('SELECT * FROM vault_records WHERE status = ? ORDER BY reference') + .all(vaultRecordStatusCode(status)) + .map((row) => this.vaultRecordFromRow(row)); + } + + markVaultRecordStatus(reference: string, status: VaultRecordStatus, updatedAt: string): void { + this.transaction(() => { + this.db() + .prepare('UPDATE vault_records SET status = ?, updated_at = ? WHERE reference = ?') + .run(vaultRecordStatusCode(status), updatedAt, reference); + }); + } + + deleteVaultRecord(reference: string): void { + this.transaction(() => { + this.db().prepare('DELETE FROM vault_records WHERE reference = ?').run(reference); + }); + } + + deleteExpiredVaultRecords(now: string): void { + this.transaction(() => { + this.db().prepare('DELETE FROM vault_records WHERE expires_at IS NOT NULL AND expires_at <= ?').run(now); + }); + } + + touchVaultRecord(reference: string, updatedAt: string): void { + this.transaction(() => { + this.db().prepare('UPDATE vault_records SET updated_at = ? WHERE reference = ?').run(updatedAt, reference); + }); + } + async verifyDatabaseFiles(): Promise { await access(this.databasePath, constants.R_OK | constants.W_OK); const dbStat = await stat(this.databasePath); @@ -551,4 +682,20 @@ export class SecureSqliteRepository { .run(state, reference.purpose, reference.reference); }); } + + private vaultRecordFromRow(row: SQLiteRow): StoredVaultRecord { + const record: StoredVaultRecord = { + ciphertext: bytesColumn(row, 'ciphertext'), + encryptionVersion: numberColumn(row, 'encryption_version'), + kind: vaultSecretKindName(numberColumn(row, 'kind')), + nonce: bytesColumn(row, 'nonce'), + reference: stringColumn(row, 'reference'), + status: vaultRecordStatusName(numberColumn(row, 'status')), + tag: bytesColumn(row, 'tag'), + updatedAt: stringColumn(row, 'updated_at'), + }; + const expiresAt = optionalStringColumn(row, 'expires_at'); + if (expiresAt !== undefined) record.expiresAt = expiresAt; + return record; + } } diff --git a/packages/core/src/secure-storage/vault-backend-lifetime.ts b/packages/core/src/secure-storage/vault-backend-lifetime.ts new file mode 100644 index 0000000..1f6cc22 --- /dev/null +++ b/packages/core/src/secure-storage/vault-backend-lifetime.ts @@ -0,0 +1,171 @@ +import type { + DeleteExpiredVaultSecretsInput, + DeleteVaultSecretInput, + GetVaultSecretInput, + PutVaultSecretInput, + TouchVaultSecretInput, + VaultBackend, + VaultPolicy, + VaultSecretPayload, + VaultStatus, +} from './vault-backend.js'; +import type { VaultSecretReference } from './vault-types.js'; + +const DEFAULT_SLEEP_CHECK_INTERVAL_MILLISECONDS = 30_000; +const DEFAULT_SLEEP_DRIFT_THRESHOLD_MILLISECONDS = 120_000; + +export interface VaultBackendLifetimeOptions { + sleepCheckIntervalMilliseconds?: number; + sleepDriftThresholdMilliseconds?: number; +} + +export class VaultBackendLifetime { + private expire: (() => Promise) | undefined; + private idleTimer: NodeJS.Timeout | undefined; + private sleepTimer: NodeJS.Timeout | undefined; + + constructor(private readonly options: VaultBackendLifetimeOptions = {}) {} + + clear(): void { + if (this.idleTimer !== undefined) { + clearTimeout(this.idleTimer); + this.idleTimer = undefined; + } + if (this.sleepTimer !== undefined) { + clearInterval(this.sleepTimer); + this.sleepTimer = undefined; + } + } + + expireWith(expire: () => Promise): void { + this.expire = expire; + } + + refresh(policy: VaultPolicy): void { + this.clear(); + if (policy.idleTimeoutSeconds !== null) { + this.idleTimer = setTimeout(() => { + void this.expire?.(); + }, policy.idleTimeoutSeconds * 1_000); + this.idleTimer.unref(); + } + if (policy.lockOnSleep) this.watchForSleep(); + } + + private watchForSleep(): void { + const interval = this.options.sleepCheckIntervalMilliseconds ?? DEFAULT_SLEEP_CHECK_INTERVAL_MILLISECONDS; + const threshold = this.options.sleepDriftThresholdMilliseconds ?? DEFAULT_SLEEP_DRIFT_THRESHOLD_MILLISECONDS; + let lastTick = Date.now(); + this.sleepTimer = setInterval(() => { + const now = Date.now(); + const drift = now - lastTick - interval; + lastTick = now; + if (drift >= threshold) void this.expire?.(); + }, interval); + this.sleepTimer.unref(); + } +} + +export class LifetimeVaultBackend implements VaultBackend { + constructor( + private readonly backend: VaultBackend, + private readonly lifetime: VaultBackendLifetime, + private readonly onReset?: () => Promise, + ) {} + + async changePassphrase(currentUnlockFactor: Uint8Array, nextUnlockFactor: Uint8Array): Promise { + await this.backend.changePassphrase(currentUnlockFactor, nextUnlockFactor); + await this.refresh(); + } + + async changeWrappingKey( + currentWrappingKey: Uint8Array, + nextWrappingKey: Uint8Array, + nextSalt: Uint8Array, + ): Promise { + await this.backend.changeWrappingKey(currentWrappingKey, nextWrappingKey, nextSalt); + await this.refresh(); + } + + async deleteExpired(input: DeleteExpiredVaultSecretsInput): Promise { + await this.backend.deleteExpired(input); + await this.refresh(); + } + + async deleteSecret(input: DeleteVaultSecretInput): Promise { + await this.backend.deleteSecret(input); + await this.refresh(); + } + + async exists(input: GetVaultSecretInput): Promise { + const result = await this.backend.exists(input); + await this.refresh(); + return result; + } + + async getPolicy(): Promise { + const policy = await this.backend.getPolicy(); + this.lifetime.refresh(policy); + return policy; + } + + async getSecret(input: GetVaultSecretInput): Promise { + const result = await this.backend.getSecret(input); + await this.refresh(); + return result; + } + + async lock(): Promise { + await this.backend.lock(); + this.lifetime.clear(); + } + + async putSecret(input: PutVaultSecretInput): Promise { + const result = await this.backend.putSecret(input); + await this.refresh(); + return result; + } + + async reset(): Promise { + this.lifetime.clear(); + await this.onReset?.(); + await this.backend.reset(); + } + + async setPolicy(policy: VaultPolicy): Promise { + const result = await this.backend.setPolicy(policy); + this.lifetime.refresh(result); + return result; + } + + async status(): Promise { + const result = await this.backend.status(); + if (result.lockState !== 'not_initialized') await this.refresh(); + return { ...result, daemonRunning: true }; + } + + async touch(input: TouchVaultSecretInput): Promise { + await this.backend.touch(input); + await this.refresh(); + } + + async unlock(unlockFactor: Uint8Array): Promise { + const result = await this.backend.unlock(unlockFactor); + await this.refresh(); + return { ...result, daemonRunning: true }; + } + + async unlockSalt(): Promise { + return this.backend.unlockSalt(); + } + + async unlockWithWrappingKey(wrappingKey: Uint8Array, salt: Uint8Array): Promise { + const result = await this.backend.unlockWithWrappingKey(wrappingKey, salt); + await this.refresh(); + return { ...result, daemonRunning: true }; + } + + private async refresh(): Promise { + this.lifetime.refresh(await this.backend.getPolicy()); + } +} diff --git a/packages/core/src/secure-storage/vault-backend.ts b/packages/core/src/secure-storage/vault-backend.ts new file mode 100644 index 0000000..9e5bb48 --- /dev/null +++ b/packages/core/src/secure-storage/vault-backend.ts @@ -0,0 +1,95 @@ +import type { VaultRecordStatus, VaultSecretKind, VaultSecretReference } from './vault-types.js'; + +export type VaultLockState = 'locked' | 'not_initialized' | 'unlocked'; +export type Awaitable = Promise | T; + +export const VAULT_BACKEND_METHODS = [ + 'changePassphrase', + 'changeWrappingKey', + 'deleteExpired', + 'deleteSecret', + 'exists', + 'getPolicy', + 'getSecret', + 'lock', + 'putSecret', + 'reset', + 'setPolicy', + 'status', + 'touch', + 'unlock', + 'unlockSalt', + 'unlockWithWrappingKey', +] as const; + +export const DEFAULT_VAULT_POLICY = { + idleTimeoutSeconds: 28_800, + lockOnSleep: true, +} as const satisfies VaultPolicy; + +export interface VaultStatus { + lockState: VaultLockState; + daemonRunning: boolean; +} + +export interface VaultPolicy { + idleTimeoutSeconds: number | null; + lockOnSleep: boolean; +} + +export interface VaultSecretPayload { + payload: Uint8Array; + reference: VaultSecretReference; +} + +export interface PutVaultSecretInput { + expectedKind: VaultSecretKind; + expiresAt?: string; + payload: Uint8Array; + reference?: VaultSecretReference; +} + +export interface GetVaultSecretInput { + expectedKind: VaultSecretKind; + reference: VaultSecretReference; +} + +export interface DeleteVaultSecretInput { + expectedKind: VaultSecretKind; + reference: VaultSecretReference; +} + +export interface TouchVaultSecretInput { + expectedKind: VaultSecretKind; + reference: VaultSecretReference; +} + +export interface DeleteExpiredVaultSecretsInput { + now: string; +} + +export interface VaultBackend { + changePassphrase(currentUnlockFactor: Uint8Array, nextUnlockFactor: Uint8Array): Awaitable; + changeWrappingKey(currentWrappingKey: Uint8Array, nextWrappingKey: Uint8Array, nextSalt: Uint8Array): Awaitable; + deleteExpired(input: DeleteExpiredVaultSecretsInput): Awaitable; + deleteSecret(input: DeleteVaultSecretInput): Awaitable; + exists(input: GetVaultSecretInput): Awaitable; + getPolicy(): Awaitable; + getSecret(input: GetVaultSecretInput): Awaitable; + lock(): Awaitable; + putSecret(input: PutVaultSecretInput): Awaitable; + reset(): Awaitable; + setPolicy(policy: VaultPolicy): Awaitable; + status(): Awaitable; + touch(input: TouchVaultSecretInput): Awaitable; + unlock(unlockFactor: Uint8Array): Awaitable; + unlockSalt(): Awaitable; + unlockWithWrappingKey(wrappingKey: Uint8Array, salt: Uint8Array): Awaitable; +} + +export interface StoredVaultSecretEnvelope { + expiresAt?: string; + kind: VaultSecretKind; + reference: VaultSecretReference; + status: VaultRecordStatus; +} diff --git a/packages/core/src/secure-storage/vault-broker-auth.ts b/packages/core/src/secure-storage/vault-broker-auth.ts new file mode 100644 index 0000000..5ad80d5 --- /dev/null +++ b/packages/core/src/secure-storage/vault-broker-auth.ts @@ -0,0 +1,275 @@ +import { Buffer } from 'node:buffer'; +import { + createPrivateKey, + createPublicKey, + generateKeyPairSync, + randomBytes, + sign, + verify, + type KeyObject, +} from 'node:crypto'; +import { chmodSync, lstatSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import type { Socket } from 'node:net'; +import path from 'node:path'; +import { SecureStorageError } from './errors.js'; +import { + createVaultPeerVerificationConfig, + socketFileDescriptor, + verifyVaultPeerVerificationConfig, + type VaultSocketPeer, + type VaultSocketPeerVerifier, +} from './vault-peer-verifier.js'; +import { runtimeRequire } from './runtime-require.js'; + +const CHALLENGE_MAGIC = Buffer.from('IFB1'); +const RESPONSE_MAGIC = Buffer.from('IFR1'); +const AUTHENTICATION_DOMAIN = Buffer.from('inflow-vault-broker-auth-v1\0'); +const CHALLENGE_BYTES = 36; +const RESPONSE_BYTES = 68; +const BROKER_KEY_DIRECTORY = '/var/lib/inflow-broker'; +const BROKER_PRIVATE_KEY = path.join(BROKER_KEY_DIRECTORY, 'private.der'); +export const LINUX_VAULT_BROKER_PUBLIC_KEY = path.join(BROKER_KEY_DIRECTORY, 'public.der'); + +interface NativePeerCredentialsModule { + peerCredentials(fd: number): Pick; +} + +export async function ensureLinuxVaultBrokerKey(): Promise { + await mkdir(BROKER_KEY_DIRECTORY, { mode: 0o755, recursive: true }); + try { + const privateKey = readBrokerPrivateKey(); + ensureBrokerPublicKey(privateKey); + return privateKey; + } catch (cause) { + if (!isMissingPath(cause)) throw cause; + } + const pair = generateKeyPairSync('ed25519'); + const privateBytes = pair.privateKey.export({ format: 'der', type: 'pkcs8' }); + const suffix = `${process.pid}.${Date.now()}`; + const privateTemporary = `${BROKER_PRIVATE_KEY}.${suffix}`; + try { + writeFileSync(privateTemporary, privateBytes, { flag: 'wx', mode: 0o600 }); + renameSync(privateTemporary, BROKER_PRIVATE_KEY); + chmodSync(BROKER_PRIVATE_KEY, 0o600); + } finally { + privateBytes.fill(0); + } + const privateKey = readBrokerPrivateKey(); + ensureBrokerPublicKey(privateKey); + return privateKey; +} + +export function createLinuxVaultBrokerPeerVerifier(expectedUserId: number): VaultSocketPeerVerifier { + const config = createVaultPeerVerificationConfig({ expectedUserId, requireSameUser: false }); + verifyVaultPeerVerificationConfig(config); + const native = runtimeRequire()(config.nativeModulePath) as NativePeerCredentialsModule; + return async (socket) => { + const peer = native.peerCredentials(socketFileDescriptor(socket)); + if (peer.uid !== expectedUserId) { + throw new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); + } + const publicKey = await readBrokerPublicKeyWhenReady(); + const nonce = randomBytes(32); + const challenge = Buffer.concat([CHALLENGE_MAGIC, nonce]); + try { + await writeBytes(socket, challenge); + const response = await readExact(socket, RESPONSE_BYTES); + try { + if ( + !response.subarray(0, RESPONSE_MAGIC.byteLength).equals(RESPONSE_MAGIC) || + !verify( + null, + signedChallenge(nonce, process.pid, currentUserId()), + publicKey, + response.subarray(RESPONSE_MAGIC.byteLength), + ) + ) { + throw new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); + } + } finally { + response.fill(0); + } + } finally { + challenge.fill(0); + nonce.fill(0); + } + return { path: config.expectedExecutablePath, pid: peer.pid, uid: peer.uid }; + }; +} + +async function readBrokerPublicKeyWhenReady(): Promise { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + try { + return readBrokerPublicKey(); + } catch (cause) { + if (!isMissingPath(cause)) throw cause; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new SecureStorageError('secure_storage_unavailable', 'The InFlow vault broker identity is unavailable.'); +} + +export async function authenticateLinuxVaultBrokerClient( + socket: Socket, + peer: VaultSocketPeer, + privateKey: KeyObject, +): Promise { + const challenge = await readExact(socket, CHALLENGE_BYTES); + try { + if (!challenge.subarray(0, CHALLENGE_MAGIC.byteLength).equals(CHALLENGE_MAGIC)) { + throw new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); + } + const signature = sign( + null, + signedChallenge(challenge.subarray(CHALLENGE_MAGIC.byteLength), peer.pid, peer.uid), + privateKey, + ); + const response = Buffer.concat([RESPONSE_MAGIC, signature]); + try { + socket.pause(); + await writeBytes(socket, response); + } finally { + response.fill(0); + signature.fill(0); + } + } finally { + challenge.fill(0); + } +} + +function readBrokerPrivateKey(): KeyObject { + validateBrokerKeyPath(BROKER_PRIVATE_KEY, 0o077); + return createPrivateKey({ + format: 'der', + key: readFileSync(BROKER_PRIVATE_KEY), + type: 'pkcs8', + }); +} + +function readBrokerPublicKey(): KeyObject { + validateBrokerKeyPath(LINUX_VAULT_BROKER_PUBLIC_KEY, 0o022); + return createPublicKey({ + format: 'der', + key: readFileSync(LINUX_VAULT_BROKER_PUBLIC_KEY), + type: 'spki', + }); +} + +function ensureBrokerPublicKey(privateKey: KeyObject): void { + try { + readBrokerPublicKey(); + chmodSync(LINUX_VAULT_BROKER_PUBLIC_KEY, 0o644); + const expected = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }); + try { + if (!expected.equals(readFileSync(LINUX_VAULT_BROKER_PUBLIC_KEY))) { + throw new SecureStorageError('secure_storage_unavailable', 'The InFlow vault broker identity is invalid.'); + } + } finally { + expected.fill(0); + } + return; + } catch (cause) { + if (!isMissingPath(cause)) throw cause; + } + const publicBytes = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }); + const temporary = `${LINUX_VAULT_BROKER_PUBLIC_KEY}.${process.pid}.${Date.now()}`; + try { + writeFileSync(temporary, publicBytes, { flag: 'wx', mode: 0o644 }); + renameSync(temporary, LINUX_VAULT_BROKER_PUBLIC_KEY); + chmodSync(LINUX_VAULT_BROKER_PUBLIC_KEY, 0o644); + } finally { + publicBytes.fill(0); + } +} + +function validateBrokerKeyPath(filePath: string, forbiddenMode: number): void { + const directory = lstatSync(BROKER_KEY_DIRECTORY); + const file = lstatSync(filePath); + if ( + directory.uid !== 0 || + !directory.isDirectory() || + directory.isSymbolicLink() || + (directory.mode & 0o022) !== 0 || + realpathSync(BROKER_KEY_DIRECTORY) !== BROKER_KEY_DIRECTORY || + file.uid !== 0 || + !file.isFile() || + file.isSymbolicLink() || + (file.mode & forbiddenMode) !== 0 || + realpathSync(filePath) !== filePath + ) { + throw new SecureStorageError('secure_storage_unavailable', 'The InFlow vault broker identity is invalid.'); + } +} + +function signedChallenge(nonce: Uint8Array, processId: number, userId: number): Buffer { + const identity = Buffer.alloc(8); + identity.writeUInt32BE(processId, 0); + identity.writeUInt32BE(userId, 4); + return Buffer.concat([AUTHENTICATION_DOMAIN, nonce, identity]); +} + +function currentUserId(): number { + const userId = process.getuid?.(); + if (userId === undefined) { + throw new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); + } + return userId; +} + +function writeBytes(socket: Socket, bytes: Buffer): Promise { + return new Promise((resolve, reject) => { + const onError = (cause: Error): void => { + reject(cause); + }; + socket.once('error', onError); + socket.write(bytes, (cause) => { + socket.off('error', onError); + if (cause === undefined || cause === null) resolve(); + else reject(cause instanceof Error ? cause : new Error('Vault broker authentication write failed.')); + }); + }); +} + +function readExact(socket: Socket, length: number): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + const cleanup = (): void => { + socket.off('data', onData); + socket.off('end', onEnd); + socket.off('error', onError); + }; + const onData = (chunk: Buffer): void => { + chunks.push(chunk); + total += chunk.byteLength; + if (total < length) return; + cleanup(); + const bytes = Buffer.concat(chunks); + for (const item of chunks) item.fill(0); + if (bytes.byteLength !== length) { + bytes.fill(0); + reject(new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.')); + return; + } + resolve(bytes); + }; + const onEnd = (): void => { + cleanup(); + reject(new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.')); + }; + const onError = (cause: Error): void => { + cleanup(); + reject(cause); + }; + socket.on('data', onData); + socket.once('end', onEnd); + socket.once('error', onError); + socket.resume(); + }); +} + +function isMissingPath(cause: unknown): boolean { + return typeof cause === 'object' && cause !== null && 'code' in cause && cause.code === 'ENOENT'; +} diff --git a/packages/core/src/secure-storage/vault-client.ts b/packages/core/src/secure-storage/vault-client.ts new file mode 100644 index 0000000..3dbd37f --- /dev/null +++ b/packages/core/src/secure-storage/vault-client.ts @@ -0,0 +1,255 @@ +import { Buffer } from 'node:buffer'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { SecureStorageError, type SecureStorageErrorCode } from './errors.js'; +import type { VaultPolicy, VaultStatus } from './vault-backend.js'; +import { createLinuxVaultBrokerPeerVerifier } from './vault-broker-auth.js'; +import { linuxVaultServiceUserId, usesLinuxVaultService, vaultFilePaths } from './vault-files.js'; +import { + createVaultSocketPeerVerifier, + shouldRequireVaultPeerVerification, + type VaultSocketPeerVerifier, +} from './vault-peer-verifier.js'; +import { sendVaultIpcRequest } from './vault-socket.js'; +import { assertUnlockFactor, equalBytes, VAULT_SALT_BYTES } from './vault-crypto.js'; +import { deriveVaultWrappingKey } from './vault-protected-key.js'; +import { sendWindowsVaultIpcRequest } from './vault-windows-transport.js'; + +export interface LocalVaultClientOptions { + rootDirectory?: string; +} + +export interface LocalVaultDaemonInfo { + buildId: string | null; + cliVersion: string | null; + executablePath: string; + pid: number; +} + +export class LocalVaultClient { + private readonly rootDirectory: string | undefined; + private readonly socketPath: string; + + constructor(options: LocalVaultClientOptions = {}) { + this.rootDirectory = options.rootDirectory; + this.socketPath = vaultFilePaths(options.rootDirectory).socket; + } + + async changePassphrase(currentUnlockFactor: Uint8Array, nextUnlockFactor: Uint8Array): Promise { + assertUnlockFactor(currentUnlockFactor); + assertUnlockFactor(nextUnlockFactor); + if (equalBytes(currentUnlockFactor, nextUnlockFactor)) { + throw new SecureStorageError( + 'secure_storage_invalid_path', + 'The new vault PIN or passphrase must differ from the current one.', + ); + } + const currentSalt = parseSalt(await this.request('vault.unlockSalt')); + const nextSalt = randomBytes(VAULT_SALT_BYTES); + const currentWrappingKey = deriveVaultWrappingKey(currentUnlockFactor, currentSalt); + const nextWrappingKey = deriveVaultWrappingKey(nextUnlockFactor, nextSalt); + try { + await this.request('vault.changePassphrase', { + currentWrappingKey, + nextSalt, + nextWrappingKey, + }); + await this.lock(); + let oldFactorRejected = false; + try { + await this.unlock(currentUnlockFactor); + } catch (cause) { + if (cause instanceof SecureStorageError && cause.secureStorageCode === 'secure_storage_corrupt') { + oldFactorRejected = true; + } else { + throw cause; + } + } + if (!oldFactorRejected) { + await this.lock(); + throw new SecureStorageError( + 'secure_storage_corrupt', + 'The previous vault PIN or passphrase remained valid after rotation.', + ); + } + if ((await this.status()).lockState !== 'locked') { + throw new SecureStorageError('secure_storage_corrupt', 'The vault did not remain locked during rotation.'); + } + if ((await this.unlock(nextUnlockFactor)).lockState !== 'unlocked') { + throw new SecureStorageError('secure_storage_corrupt', 'The new vault PIN or passphrase was not activated.'); + } + } finally { + currentSalt.fill(0); + nextSalt.fill(0); + currentWrappingKey.fill(0); + nextWrappingKey.fill(0); + } + } + + async getPolicy(): Promise { + return parsePolicy(await this.request('vault.getPolicy')); + } + + async info(): Promise { + return parseInfo(await this.request('daemon.info')); + } + + async lock(): Promise { + await this.request('vault.lock'); + } + + async reset(): Promise { + await this.request('vault.reset'); + } + + async shutdown(): Promise { + await this.request('daemon.shutdown'); + } + + async setPolicy(policy: VaultPolicy): Promise { + return parsePolicy(await this.request('vault.setPolicy', { policy })); + } + + async status(): Promise { + return parseStatus(await this.request('vault.status')); + } + + async unlock(unlockFactor: Uint8Array): Promise { + assertUnlockFactor(unlockFactor); + const salt = parseSalt(await this.request('vault.unlockSalt')); + const wrappingKey = deriveVaultWrappingKey(unlockFactor, salt); + try { + const reported = parseStatus( + await this.request('vault.unlock', { + salt, + wrappingKey, + }), + ); + if (reported.lockState !== 'unlocked' || (await this.status()).lockState !== 'unlocked') { + throw new SecureStorageError( + 'secure_storage_corrupt', + 'The vault did not remain unlocked after authentication.', + ); + } + return reported; + } finally { + salt.fill(0); + wrappingKey.fill(0); + } + } + + private async request( + method: Parameters[1]['method'], + params: Record = {}, + ): Promise> { + const request = { + id: `req_${randomUUID().replaceAll('-', '')}`, + method, + params, + version: 1 as const, + }; + const response = + process.platform === 'win32' && this.rootDirectory === undefined + ? sendWindowsVaultIpcRequest(this.socketPath, request) + : await sendVaultIpcRequest( + this.socketPath, + request, + createClientPeerVerifier(this.socketPath, this.rootDirectory), + ); + if (response.id !== request.id) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC response is malformed.'); + } + if (!response.ok) { + throw new SecureStorageError(codeFromResponse(response.error.code), response.error.message); + } + return response.result; + } +} + +function parseSalt(value: Record): Buffer { + const salt = value['salt']; + if (!(salt instanceof Uint8Array) || salt.byteLength !== VAULT_SALT_BYTES) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC unlock salt response is malformed.'); + } + const result = Buffer.from(salt); + salt.fill(0); + return result; +} + +function createClientPeerVerifier( + socketPath: string, + rootDirectory: string | undefined, +): VaultSocketPeerVerifier | undefined { + if (!shouldRequireVaultPeerVerification()) return undefined; + if (rootDirectory === undefined && usesLinuxVaultService()) { + return createLinuxVaultBrokerPeerVerifier(linuxVaultServiceUserId(socketPath)); + } + return createVaultSocketPeerVerifier(); +} + +function parseInfo(value: Record): LocalVaultDaemonInfo { + const cliVersion = value['cliVersion']; + const buildId = value['buildId']; + const executablePath = value['executablePath']; + const pid = value['pid']; + if ( + !(buildId === null || typeof buildId === 'string') || + !(cliVersion === null || typeof cliVersion === 'string') || + typeof executablePath !== 'string' || + !isNonNegativeInteger(pid) + ) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC daemon info response is malformed.'); + } + return { buildId, cliVersion, executablePath, pid }; +} + +function parseStatus(value: Record): VaultStatus { + const lockState = value['lockState']; + const daemonRunning = value['daemonRunning']; + if ( + !(lockState === 'locked' || lockState === 'not_initialized' || lockState === 'unlocked') || + typeof daemonRunning !== 'boolean' + ) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC status response is malformed.'); + } + return { daemonRunning, lockState }; +} + +function parsePolicy(value: Record): VaultPolicy { + const idleTimeoutSeconds = value['idleTimeoutSeconds']; + const lockOnSleep = value['lockOnSleep']; + if (!(idleTimeoutSeconds === null || isNonNegativeInteger(idleTimeoutSeconds)) || typeof lockOnSleep !== 'boolean') { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC policy response is malformed.'); + } + return { idleTimeoutSeconds, lockOnSleep }; +} + +function codeFromResponse(code: string): SecureStorageErrorCode { + switch (code) { + case 'secure_storage_corrupt': + case 'secure_storage_invalid_path': + case 'secure_storage_io_error': + case 'secure_storage_peer_verification_failed': + case 'secure_storage_secret_conflict': + case 'secure_storage_secret_missing': + case 'secure_storage_unavailable': + case 'vault_daemon_busy': + case 'vault_locked': + case 'vault_not_initialized': + return code; + default: + return 'secure_storage_io_error'; + } +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +/** @internal */ +export const __testing = { + codeFromResponse, + parseInfo, + parsePolicy, + parseSalt, + parseStatus, +}; diff --git a/packages/core/src/secure-storage/vault-crypto.ts b/packages/core/src/secure-storage/vault-crypto.ts new file mode 100644 index 0000000..db9b033 --- /dev/null +++ b/packages/core/src/secure-storage/vault-crypto.ts @@ -0,0 +1,207 @@ +import { Buffer } from 'node:buffer'; +import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'node:crypto'; +import { SecureStorageError } from './errors.js'; +import { deriveVaultWrappingKey } from './vault-protected-key.js'; +import { + vaultRecordStatusCode, + vaultSecretKindCode, + type VaultRecordStatus, + type VaultSecretKind, +} from './vault-types.js'; + +export interface VaultHeader { + material: Buffer; + nonce: Buffer; + salt: Buffer; + tag: Buffer; +} + +export interface EncryptedVaultRecordPayload { + ciphertext: Buffer; + nonce: Buffer; + tag: Buffer; +} + +export interface VaultRecordEncryptionContext { + encryptionVersion: number; + kind: VaultSecretKind; + reference: string; + status: VaultRecordStatus; +} + +export interface WrappedVaultMaterial { + header: Buffer; + masterKey: Buffer; +} + +export const VAULT_MASTER_KEY_BYTES = 32; +export const VAULT_SIDECAR_BYTES = 76; +export const VAULT_SALT_BYTES = 16; +export const VAULT_UNLOCK_FACTOR_MIN_BYTES = 6; + +const HEADER_NONCE_BYTES = 12; +const HEADER_TAG_BYTES = 16; +const HEADER_AAD = Buffer.from('inflow-vault-header-v1', 'utf8'); + +export function assertUnlockFactor(value: Uint8Array): void { + if (value.byteLength < VAULT_UNLOCK_FACTOR_MIN_BYTES) { + throw new SecureStorageError('secure_storage_invalid_path', 'PIN or passphrase must be at least 6 characters.'); + } +} + +export function createVaultMaterial(unlockFactor: Uint8Array): WrappedVaultMaterial { + assertUnlockFactor(unlockFactor); + const salt = randomBytes(VAULT_SALT_BYTES); + const wrappingKey = deriveWrappingKey(unlockFactor, salt); + try { + return createVaultMaterialWithWrappingKey(wrappingKey, salt); + } finally { + wrappingKey.fill(0); + } +} + +export function unwrapVaultMaterial(headerBytes: Uint8Array, unlockFactor: Uint8Array): Buffer { + assertUnlockFactor(unlockFactor); + const header = decodeVaultHeader(headerBytes); + const wrappingKey = deriveWrappingKey(unlockFactor, header.salt); + try { + return unwrapVaultMaterialWithWrappingKey(headerBytes, wrappingKey); + } finally { + wrappingKey.fill(0); + } +} + +export function changeVaultUnlockFactor( + headerBytes: Uint8Array, + currentUnlockFactor: Uint8Array, + nextUnlockFactor: Uint8Array, +): Buffer { + if (equalBytes(currentUnlockFactor, nextUnlockFactor)) { + throw new SecureStorageError( + 'secure_storage_invalid_path', + 'The new vault PIN or passphrase must differ from the current one.', + ); + } + const masterKey = unwrapVaultMaterial(headerBytes, currentUnlockFactor); + let wrappingKey: Buffer | undefined; + try { + assertUnlockFactor(nextUnlockFactor); + const salt = randomBytes(VAULT_SALT_BYTES); + wrappingKey = deriveWrappingKey(nextUnlockFactor, salt); + return encodeVaultHeader(encryptMaterial(masterKey, wrappingKey, salt)); + } finally { + masterKey.fill(0); + wrappingKey?.fill(0); + } +} + +export function createVaultMaterialWithWrappingKey(wrappingKey: Uint8Array, salt: Uint8Array): WrappedVaultMaterial { + assertWrappingMaterial(wrappingKey, salt); + const masterKey = randomBytes(VAULT_MASTER_KEY_BYTES); + try { + return { + header: encodeVaultHeader(encryptMaterial(masterKey, wrappingKey, salt)), + masterKey, + }; + } catch (cause) { + masterKey.fill(0); + throw cause; + } +} + +export function unwrapVaultMaterialWithWrappingKey(headerBytes: Uint8Array, wrappingKey: Uint8Array): Buffer { + if (wrappingKey.byteLength !== VAULT_MASTER_KEY_BYTES) { + throw new SecureStorageError('secure_storage_invalid_path', 'Vault wrapping key is malformed.'); + } + const header = decodeVaultHeader(headerBytes); + try { + const decipher = createDecipheriv('aes-256-gcm', wrappingKey, header.nonce); + decipher.setAAD(HEADER_AAD); + decipher.setAuthTag(header.tag); + return Buffer.concat([decipher.update(header.material), decipher.final()]); + } catch (cause) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault material could not be unwrapped.', { cause }); + } +} + +export function changeVaultWrappingKey( + headerBytes: Uint8Array, + currentWrappingKey: Uint8Array, + nextWrappingKey: Uint8Array, + nextSalt: Uint8Array, +): Buffer { + assertWrappingMaterial(nextWrappingKey, nextSalt); + const masterKey = unwrapVaultMaterialWithWrappingKey(headerBytes, currentWrappingKey); + try { + return encodeVaultHeader(encryptMaterial(masterKey, nextWrappingKey, nextSalt)); + } finally { + masterKey.fill(0); + } +} + +export function encodeVaultHeader(header: VaultHeader): Buffer { + if ( + header.salt.byteLength !== VAULT_SALT_BYTES || + header.nonce.byteLength !== HEADER_NONCE_BYTES || + header.tag.byteLength !== HEADER_TAG_BYTES || + header.material.byteLength !== VAULT_MASTER_KEY_BYTES + ) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault header fields have unexpected lengths.'); + } + return Buffer.concat([header.salt, header.nonce, header.tag, header.material]); +} + +export function decodeVaultHeader(headerBytes: Uint8Array): VaultHeader { + if (headerBytes.byteLength !== VAULT_SIDECAR_BYTES) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault header has an unexpected length.'); + } + const bytes = Buffer.from(headerBytes); + return { + salt: bytes.subarray(0, VAULT_SALT_BYTES), + nonce: bytes.subarray(VAULT_SALT_BYTES, VAULT_SALT_BYTES + HEADER_NONCE_BYTES), + tag: bytes.subarray( + VAULT_SALT_BYTES + HEADER_NONCE_BYTES, + VAULT_SALT_BYTES + HEADER_NONCE_BYTES + HEADER_TAG_BYTES, + ), + material: bytes.subarray(VAULT_SALT_BYTES + HEADER_NONCE_BYTES + HEADER_TAG_BYTES), + }; +} + +function deriveWrappingKey(unlockFactor: Uint8Array, salt: Uint8Array): Buffer { + return deriveVaultWrappingKey(unlockFactor, salt); +} + +function assertWrappingMaterial(wrappingKey: Uint8Array, salt: Uint8Array): void { + if (wrappingKey.byteLength !== VAULT_MASTER_KEY_BYTES || salt.byteLength !== VAULT_SALT_BYTES) { + throw new SecureStorageError('secure_storage_invalid_path', 'Vault wrapping material is malformed.'); + } +} + +function encryptMaterial(masterKey: Uint8Array, wrappingKey: Uint8Array, salt: Uint8Array): VaultHeader { + const nonce = randomBytes(HEADER_NONCE_BYTES); + const cipher = createCipheriv('aes-256-gcm', wrappingKey, nonce); + cipher.setAAD(HEADER_AAD); + const material = Buffer.concat([cipher.update(masterKey), cipher.final()]); + return { + material, + nonce, + salt: Buffer.from(salt), + tag: cipher.getAuthTag(), + }; +} + +export function vaultRecordAad(context: VaultRecordEncryptionContext): Buffer { + return Buffer.from( + JSON.stringify({ + encryption_version: context.encryptionVersion, + kind: vaultSecretKindCode(context.kind), + reference: context.reference, + status: vaultRecordStatusCode(context.status), + }), + 'utf8', + ); +} + +export function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + return left.byteLength === right.byteLength && timingSafeEqual(left, right); +} diff --git a/packages/core/src/secure-storage/vault-daemon-handler.ts b/packages/core/src/secure-storage/vault-daemon-handler.ts new file mode 100644 index 0000000..6211286 --- /dev/null +++ b/packages/core/src/secure-storage/vault-daemon-handler.ts @@ -0,0 +1,227 @@ +import { Buffer } from 'node:buffer'; +import { SecureStorageError } from './errors.js'; +import type { VaultBackend, VaultPolicy, VaultSecretPayload } from './vault-backend.js'; +import type { VaultIpcRequest, VaultIpcResponse } from './vault-ipc.js'; +import { isVaultSecretKind, parseVaultSecretReference, type VaultSecretKind } from './vault-types.js'; + +export async function handleVaultIpcRequest( + backend: VaultBackend, + request: VaultIpcRequest, + daemonInfo?: VaultDaemonInfo, + options: VaultDaemonHandlerOptions = {}, +): Promise { + try { + const result = await dispatchVaultIpcRequest(backend, request, daemonInfo, options); + return { id: request.id, ok: true, result, version: 1 }; + } catch (cause) { + return { + error: errorResponse(cause), + id: request.id, + ok: false, + version: 1, + }; + } +} + +export interface VaultDaemonInfo { + buildId: string | null; + cliVersion: string | null; + executablePath: string; + pid: number; +} + +export interface VaultDaemonHandlerOptions { + allowDaemonShutdown?: boolean; +} + +async function dispatchVaultIpcRequest( + backend: VaultBackend, + request: VaultIpcRequest, + daemonInfo: VaultDaemonInfo | undefined, + options: VaultDaemonHandlerOptions, +): Promise> { + switch (request.method) { + case 'daemon.info': + return daemonInfoResult(daemonInfo); + case 'daemon.shutdown': + if (options.allowDaemonShutdown === false) { + throw new SecureStorageError( + 'secure_storage_unavailable', + 'The vault service lifecycle is managed by the operating system.', + ); + } + await backend.lock(); + return {}; + case 'secret.delete': + await backend.deleteSecret(parseSecretReferenceParams(request.params)); + return {}; + case 'secret.deleteExpired': + await backend.deleteExpired({ now: parseStringParam(request.params, 'now') }); + return {}; + case 'secret.exists': + return { exists: await backend.exists(parseSecretReferenceParams(request.params)) }; + case 'secret.get': + return secretPayloadResult(await backend.getSecret(parseSecretReferenceParams(request.params))); + case 'secret.put': { + const input = parsePutSecretParams(request.params); + try { + return { reference: (await backend.putSecret(input)).reference }; + } finally { + input.payload.fill(0); + } + } + case 'secret.touch': + await backend.touch(parseSecretReferenceParams(request.params)); + return {}; + case 'vault.changePassphrase': { + const currentWrappingKey = parseBytesParam(request.params, 'currentWrappingKey'); + const nextWrappingKey = parseBytesParam(request.params, 'nextWrappingKey'); + const nextSalt = parseBytesParam(request.params, 'nextSalt'); + try { + await backend.changeWrappingKey(currentWrappingKey, nextWrappingKey, nextSalt); + return {}; + } finally { + currentWrappingKey.fill(0); + nextWrappingKey.fill(0); + nextSalt.fill(0); + } + } + case 'vault.getPolicy': + return policyResult(await backend.getPolicy()); + case 'vault.lock': + await backend.lock(); + return {}; + case 'vault.reset': + await backend.reset(); + return {}; + case 'vault.setPolicy': + return policyResult(await backend.setPolicy(parsePolicyParam(request.params))); + case 'vault.status': + return statusResult(await backend.status()); + case 'vault.unlock': { + const wrappingKey = parseBytesParam(request.params, 'wrappingKey'); + const salt = parseBytesParam(request.params, 'salt'); + try { + return statusResult(await backend.unlockWithWrappingKey(wrappingKey, salt)); + } finally { + wrappingKey.fill(0); + salt.fill(0); + } + } + case 'vault.unlockSalt': + return { salt: await backend.unlockSalt() }; + } +} + +function daemonInfoResult(info: VaultDaemonInfo | undefined): Record { + if (info === undefined) { + throw new SecureStorageError('secure_storage_unavailable', 'Vault daemon identity is unavailable.'); + } + return { + buildId: info.buildId, + cliVersion: info.cliVersion, + executablePath: info.executablePath, + pid: info.pid, + }; +} + +function parsePutSecretParams(params: Record) { + const input = { + expectedKind: parseKindParam(params), + payload: parseBytesParam(params, 'payload'), + }; + const reference = parseOptionalStringParam(params, 'reference'); + const withReference = reference === undefined ? input : { ...input, reference: parseVaultSecretReference(reference) }; + const expiresAt = parseOptionalStringParam(params, 'expiresAt'); + if (expiresAt !== undefined) return { ...withReference, expiresAt }; + return withReference; +} + +function parseSecretReferenceParams(params: Record) { + return { + expectedKind: parseKindParam(params), + reference: parseVaultSecretReference(parseStringParam(params, 'reference')), + }; +} + +function parsePolicyParam(params: Record): VaultPolicy { + const policy = params['policy']; + if (!isRecord(policy)) { + throw new SecureStorageError('secure_storage_invalid_path', 'Vault IPC request parameters are malformed.'); + } + const idleTimeoutSeconds = policy['idleTimeoutSeconds']; + const lockOnSleep = policy['lockOnSleep']; + if (!(idleTimeoutSeconds === null || isNonNegativeInteger(idleTimeoutSeconds)) || typeof lockOnSleep !== 'boolean') { + throw new SecureStorageError('secure_storage_invalid_path', 'Vault IPC request parameters are malformed.'); + } + return { idleTimeoutSeconds, lockOnSleep }; +} + +function parseKindParam(params: Record): VaultSecretKind { + const kind = params['expectedKind']; + if (!isVaultSecretKind(kind)) { + throw new SecureStorageError('secure_storage_invalid_path', 'Vault IPC request parameters are malformed.'); + } + return kind; +} + +function parseBytesParam(params: Record, name: string): Buffer { + const value = params[name]; + if (!(value instanceof Uint8Array) || value.byteLength === 0) { + throw new SecureStorageError('secure_storage_invalid_path', 'Vault IPC request parameters are malformed.'); + } + return Buffer.from(value); +} + +function parseStringParam(params: Record, name: string): string { + const value = params[name]; + if (typeof value !== 'string' || value.length === 0) { + throw new SecureStorageError('secure_storage_invalid_path', 'Vault IPC request parameters are malformed.'); + } + return value; +} + +function parseOptionalStringParam(params: Record, name: string): string | undefined { + const value = params[name]; + if (value === undefined) return undefined; + if (typeof value !== 'string' || value.length === 0) { + throw new SecureStorageError('secure_storage_invalid_path', 'Vault IPC request parameters are malformed.'); + } + return value; +} + +function secretPayloadResult(secret: VaultSecretPayload): Record { + return { + payload: secret.payload, + reference: secret.reference.reference, + }; +} + +function policyResult(policy: VaultPolicy): Record { + return { + idleTimeoutSeconds: policy.idleTimeoutSeconds, + lockOnSleep: policy.lockOnSleep, + }; +} + +function statusResult(status: { daemonRunning: boolean; lockState: string }): Record { + return { + daemonRunning: status.daemonRunning, + lockState: status.lockState, + }; +} + +function errorResponse(cause: unknown): { code: string; message: string } { + if (cause instanceof SecureStorageError) { + return { code: cause.secureStorageCode, message: cause.message }; + } + return { code: 'secure_storage_io_error', message: 'The InFlow vault operation failed.' }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} diff --git a/packages/core/src/secure-storage/vault-daemon.ts b/packages/core/src/secure-storage/vault-daemon.ts new file mode 100644 index 0000000..d3b2fa9 --- /dev/null +++ b/packages/core/src/secure-storage/vault-daemon.ts @@ -0,0 +1,497 @@ +import process from 'node:process'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { closeSync, lstatSync } from 'node:fs'; +import { createServer, Socket, type Server } from 'node:net'; +import type { VaultBackend } from './vault-backend.js'; +import { authenticateLinuxVaultBrokerClient, ensureLinuxVaultBrokerKey } from './vault-broker-auth.js'; +import { + LifetimeVaultBackend, + VaultBackendLifetime, + type VaultBackendLifetimeOptions, +} from './vault-backend-lifetime.js'; +import { SecureStorageError } from './errors.js'; +import { SecureSqliteRepository } from './sqlite.js'; +import { LocalVaultBackend } from './vault-local-backend.js'; +import { + createVaultSocketPeerVerifier, + shouldRequireVaultPeerVerification, + verifyTransferredVaultSocketPeer, + type VaultSocketPeer, + type VaultSocketPeerVerifier, +} from './vault-peer-verifier.js'; +import { vaultFilePaths } from './vault-files.js'; +import { hardenVaultDaemonProcess } from './vault-protected-key.js'; +import { createVaultSocketConnectionHandler, startVaultSocketServer, type VaultSocketServer } from './vault-socket.js'; +import { MultiTenantVaultBackendManager } from './vault-tenant-manager.js'; + +export interface LocalVaultDaemonOptions { + buildId?: string; + cliVersion?: string; + rootDirectory?: string; + sleepCheckIntervalMilliseconds?: number; + sleepDriftThresholdMilliseconds?: number; +} + +export interface LinuxVaultServiceOptions { + buildId?: string; + cliVersion?: string; + listenFd?: number; + peerVerifier?: VaultSocketPeerVerifier; + rootDirectory?: string; + sleepCheckIntervalMilliseconds?: number; + sleepDriftThresholdMilliseconds?: number; + socketPath?: string; +} + +export interface LinuxVaultBrokerOptions { + buildId?: string; + cliVersion?: string; + listenFd?: number; + serviceGroupId?: number; + serviceUserId?: number; + socketPath?: string; +} + +export interface LocalVaultDaemon { + close(): Promise; + closed: Promise; + socketPath: string; +} + +export interface LocalVaultDaemonRuntime { + exit(code: number): void; + once(signal: 'SIGINT' | 'SIGTERM', handler: () => Promise): void; +} + +interface LinuxTransferredVaultServiceRuntime { + exit(code: number): void; + on(event: 'message', handler: (message: unknown, handle: unknown) => void): void; + once(event: 'SIGINT' | 'SIGTERM' | 'disconnect', handler: () => void): void; + send(message: unknown): void; +} + +export async function startLocalVaultDaemon(options: LocalVaultDaemonOptions = {}): Promise { + const paths = vaultFilePaths(options.rootDirectory); + const repository = new SecureSqliteRepository({ databasePath: paths.database }); + const backend = new LocalVaultBackend({ paths, repository }); + const lifetime = new VaultBackendLifetime(lifetimeOptions(options)); + const shutdown = { close: undefined as (() => Promise) | undefined }; + let resolveClosed: () => void; + const closedPromise = new Promise((resolve) => { + resolveClosed = resolve; + }); + const server = await startVaultSocketServer( + socketServerOptions( + new LifetimeVaultBackend(backend, lifetime), + paths.socket, + options.cliVersion ?? null, + options.buildId ?? null, + () => { + void shutdown.close?.(); + }, + ), + ); + let isClosed = false; + const close = async (): Promise => { + if (isClosed) return; + isClosed = true; + lifetime.clear(); + try { + await closeLocalVaultDaemon(repository, server); + } finally { + resolveClosed(); + } + }; + shutdown.close = close; + lifetime.expireWith(close); + return { + close, + closed: closedPromise, + socketPath: paths.socket, + }; +} + +export async function startLinuxVaultService(options: LinuxVaultServiceOptions = {}): Promise { + const manager = new MultiTenantVaultBackendManager({ + rootDirectory: options.rootDirectory ?? '/var/lib/inflow/vaults', + ...lifetimeOptions(options), + }); + let resolveClosed: () => void; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + let server: VaultSocketServer; + try { + server = await startVaultSocketServer({ + backendForPeer: (peer) => manager.backendForPeer(peer), + daemonInfo: { + buildId: options.buildId ?? null, + cliVersion: options.cliVersion ?? null, + executablePath: process.execPath, + pid: process.pid, + }, + ...(options.listenFd === undefined ? {} : { listenFd: options.listenFd }), + peerVerifier: options.peerVerifier ?? createVaultSocketPeerVerifier({ requireSameUser: false }), + socketMode: 0o666, + socketPath: options.socketPath ?? '/run/inflow/vault.sock', + }); + } catch (cause) { + await manager.close(); + throw cause; + } + let isClosed = false; + const close = async (): Promise => { + if (isClosed) return; + isClosed = true; + try { + await server.close(); + } finally { + await manager.close(); + resolveClosed(); + } + }; + return { + close, + closed, + socketPath: server.socketPath, + }; +} + +function socketServerOptions( + backend: VaultBackend, + socketPath: string, + cliVersion: string | null, + buildId: string | null, + onShutdown: () => void, +) { + const options: { + backend: VaultBackend; + daemonInfo: { buildId: string | null; cliVersion: string | null; executablePath: string; pid: number }; + onShutdown: () => void; + peerVerifier?: VaultSocketPeerVerifier; + socketPath: string; + } = { + backend, + daemonInfo: { + buildId, + cliVersion, + executablePath: process.execPath, + pid: process.pid, + }, + onShutdown, + socketPath, + }; + if (shouldRequireVaultPeerVerification()) options.peerVerifier = createVaultSocketPeerVerifier(); + return options; +} + +export async function runLocalVaultDaemon(options: LocalVaultDaemonOptions = {}): Promise { + hardenVaultDaemonProcess(); + const daemon = await startLocalVaultDaemon(options); + attachLocalVaultDaemonSignalHandlers(daemon, process); + await daemon.closed; +} + +export async function runLinuxVaultService(options: LinuxVaultServiceOptions = {}): Promise { + if (process.platform !== 'linux') { + throw new Error('The Linux vault service is available only on Linux.'); + } + hardenVaultDaemonProcess(); + const activationFileDescriptor = options.listenFd ?? systemdSocketFileDescriptor(process.env, process.pid); + closeSync(activationFileDescriptor); + const { listenFd: _activationFileDescriptor, ...serviceOptions } = options; + const daemon = await startLinuxVaultService(serviceOptions); + attachLocalVaultDaemonSignalHandlers(daemon, process); + await daemon.closed; +} + +export async function runLinuxVaultBroker(options: LinuxVaultBrokerOptions = {}): Promise { + if (process.platform !== 'linux') { + throw new Error('The Linux vault broker is available only on Linux.'); + } + hardenVaultDaemonProcess(); + const activationFileDescriptor = options.listenFd ?? systemdSocketFileDescriptor(process.env, process.pid); + const serviceIdentity = + options.serviceUserId === undefined || options.serviceGroupId === undefined + ? linuxVaultServiceIdentity('/var/lib/inflow') + : { gid: options.serviceGroupId, uid: options.serviceUserId }; + const child = spawn(process.execPath, ['--daemon', 'vault-service'], { + env: process.env, + gid: serviceIdentity.gid, + stdio: ['ignore', 'ignore', 'inherit', 'ipc'], + uid: serviceIdentity.uid, + }); + await waitForVaultServiceReady(child); + const brokerPrivateKey = await ensureLinuxVaultBrokerKey(); + const verifyPeer = createVaultSocketPeerVerifier({ requireSameUser: false }); + const server = createServer({ pauseOnConnect: true }, (socket) => { + void Promise.resolve() + .then(() => verifyPeer(socket)) + .then(async (peer) => { + socket.resume(); + await authenticateLinuxVaultBrokerClient(socket, peer, brokerPrivateKey); + socket.pause(); + return peer; + }) + .then( + (peer) => transferSocketToVaultService(child, socket, peer), + () => socket.destroy(), + ); + }); + await listenBroker(server, activationFileDescriptor); + const close = async (): Promise => { + await closeBroker(server); + if (child.connected) child.disconnect(); + }; + process.once('SIGINT', () => { + void close(); + }); + process.once('SIGTERM', () => { + void close(); + }); + await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => { + void closeBroker(server).finally(() => { + if (code === 0 || signal === 'SIGTERM') resolve(); + else reject(new Error('The InFlow vault service exited unexpectedly.')); + }); + }); + }); +} + +export async function runLinuxTransferredVaultService(options: LinuxVaultServiceOptions = {}): Promise { + if (process.platform !== 'linux' || typeof process.send !== 'function') { + throw new Error('The Linux vault service requires its authenticated broker.'); + } + const send = process.send.bind(process); + await runLinuxTransferredVaultServiceWithRuntime(options, process.platform, { + exit(code) { + process.exit(code); + }, + on(event, handler) { + process.on(event, handler); + }, + once(event, handler) { + process.once(event, handler); + }, + send(message) { + send(message); + }, + }); +} + +/** @internal */ +export async function runLinuxTransferredVaultServiceWithRuntime( + options: LinuxVaultServiceOptions, + platform: NodeJS.Platform, + runtime: LinuxTransferredVaultServiceRuntime, +): Promise { + if (platform !== 'linux') { + throw new Error('The Linux vault service requires its authenticated broker.'); + } + hardenVaultDaemonProcess(); + const manager = new MultiTenantVaultBackendManager({ + rootDirectory: options.rootDirectory ?? '/var/lib/inflow/vaults', + ...lifetimeOptions(options), + }); + const handleConnection = createVaultSocketConnectionHandler({ + backendForPeer: (peer) => manager.backendForPeer(peer), + daemonInfo: { + buildId: options.buildId ?? null, + cliVersion: options.cliVersion ?? null, + executablePath: process.execPath, + pid: process.pid, + }, + peerVerifier: () => { + throw new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); + }, + socketPath: 'broker-transferred', + }); + runtime.on('message', (message, handle) => { + if (!isBrokerTransferMessage(message) || !isSocket(handle)) { + if (isSocket(handle)) handle.destroy(); + return; + } + handle.pause(); + try { + const peer = verifyTransferredVaultSocketPeer(handle, message.peer); + handleConnection(handle, peer); + handle.resume(); + } catch { + handle.destroy(); + } + }); + let closed = false; + let resolveClosed: () => void; + const closedPromise = new Promise((resolve) => { + resolveClosed = resolve; + }); + const close = async (): Promise => { + if (closed) return; + closed = true; + await manager.close(); + resolveClosed(); + runtime.exit(0); + }; + runtime.once('SIGINT', () => { + void close(); + }); + runtime.once('SIGTERM', () => { + void close(); + }); + runtime.once('disconnect', () => { + void close(); + }); + runtime.send({ type: 'vault-service-ready' }); + await closedPromise; +} + +function linuxVaultServiceIdentity(stateDirectory: string): { gid: number; uid: number } { + const stat = lstatSync(stateDirectory); + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid === 0 || stat.gid === 0 || (stat.mode & 0o077) !== 0) { + throw new SecureStorageError('secure_storage_unavailable', 'The InFlow vault service identity is invalid.'); + } + return { gid: stat.gid, uid: stat.uid }; +} + +function waitForVaultServiceReady(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + const onError = (cause: Error): void => { + child.off('message', onMessage); + child.off('exit', onExit); + reject(cause); + }; + const onExit = (): void => { + child.off('error', onError); + child.off('message', onMessage); + reject(new Error('The InFlow vault service did not start.')); + }; + const onMessage = (message: unknown): void => { + if (!isReadyMessage(message)) return; + child.off('error', onError); + child.off('exit', onExit); + resolve(); + }; + child.once('error', onError); + child.once('exit', onExit); + child.on('message', onMessage); + }); +} + +function transferSocketToVaultService(child: ChildProcess, socket: Socket, peer: VaultSocketPeer): void { + if (!child.connected) { + socket.destroy(); + return; + } + child.send({ peer, type: 'vault-client' }, socket, { keepOpen: true }, () => { + socket.destroy(); + }); +} + +function listenBroker(server: Server, fileDescriptor: number): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen({ fd: fileDescriptor }, () => { + server.off('error', reject); + resolve(); + }); + }); +} + +function closeBroker(server: Server): Promise { + return new Promise((resolve, reject) => { + if (!server.listening) { + resolve(); + return; + } + server.close((cause) => { + if (cause !== undefined) { + reject(cause); + return; + } + resolve(); + }); + }); +} + +function isSocket(handle: unknown): handle is Socket { + return handle instanceof Socket; +} + +function isReadyMessage(message: unknown): boolean { + return typeof message === 'object' && message !== null && 'type' in message && message.type === 'vault-service-ready'; +} + +function isBrokerTransferMessage(message: unknown): message is { peer: VaultSocketPeer; type: 'vault-client' } { + if (typeof message !== 'object' || message === null || !('type' in message) || message.type !== 'vault-client') { + return false; + } + if (!('peer' in message) || typeof message.peer !== 'object' || message.peer === null) return false; + const peer = message.peer as Record; + return ( + typeof peer['path'] === 'string' && + Number.isSafeInteger(peer['pid']) && + Number.isSafeInteger(peer['uid']) && + (peer['pid'] as number) > 0 && + (peer['uid'] as number) >= 0 + ); +} + +export function attachLocalVaultDaemonSignalHandlers(daemon: LocalVaultDaemon, runtime: LocalVaultDaemonRuntime): void { + const close = async (): Promise => { + await daemon.close(); + runtime.exit(0); + }; + runtime.once('SIGINT', close); + runtime.once('SIGTERM', close); +} + +async function closeLocalVaultDaemon(repository: SecureSqliteRepository, server: VaultSocketServer): Promise { + await server.close(); + repository.close(); +} + +function lifetimeOptions(options: LocalVaultDaemonOptions | LinuxVaultServiceOptions): VaultBackendLifetimeOptions { + const result: VaultBackendLifetimeOptions = {}; + if (options.sleepCheckIntervalMilliseconds !== undefined) { + result.sleepCheckIntervalMilliseconds = options.sleepCheckIntervalMilliseconds; + } + if (options.sleepDriftThresholdMilliseconds !== undefined) { + result.sleepDriftThresholdMilliseconds = options.sleepDriftThresholdMilliseconds; + } + return result; +} + +/** @internal */ +export const __testing = { + closeBroker, + isBrokerTransferMessage, + isReadyMessage, + linuxVaultServiceIdentity, + listenBroker, + lifetimeOptions, + parseNonNegativeInteger, + transferSocketToVaultService, + waitForVaultServiceReady, +}; + +export function systemdSocketFileDescriptor(environment: NodeJS.ProcessEnv, processId: number): number { + const listenProcessId = parseNonNegativeInteger(environment['LISTEN_PID']); + const descriptorCount = parseNonNegativeInteger(environment['LISTEN_FDS']); + const descriptorNames = environment['LISTEN_FDNAMES']; + if ( + listenProcessId !== processId || + descriptorCount !== 1 || + (descriptorNames !== undefined && descriptorNames !== 'inflow-vault') + ) { + throw new SecureStorageError('secure_storage_unavailable', 'Vault socket activation is unavailable.'); + } + return 3; +} + +function parseNonNegativeInteger(value: string | undefined): number | undefined { + if (value === undefined || !/^(0|[1-9][0-9]*)$/.test(value)) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : undefined; +} diff --git a/packages/core/src/secure-storage/vault-files.ts b/packages/core/src/secure-storage/vault-files.ts new file mode 100644 index 0000000..1583e4b --- /dev/null +++ b/packages/core/src/secure-storage/vault-files.ts @@ -0,0 +1,98 @@ +import { lstatSync, realpathSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { SecureStorageError } from './errors.js'; + +export interface VaultFilePaths { + database: string; + runDirectory: string; + sharedMemory: string; + sidecar: string; + socket: string; + writeAheadLog: string; +} + +export function defaultVaultRoot(): string { + if (process.platform === 'darwin') return path.join(homedir(), 'Library', 'Application Support', 'InFlow'); + if (process.platform === 'linux') { + const dataHome = process.env['XDG_DATA_HOME']; + return path.join( + dataHome === undefined || dataHome.length === 0 ? path.join(homedir(), '.local', 'share') : dataHome, + 'inflow', + ); + } + return path.join(homedir(), '.inflow'); +} + +export function vaultFilePaths(rootDirectory?: string): VaultFilePaths { + const root = rootDirectory ?? defaultVaultRoot(); + const database = path.join(root, 'inflow.sqlite3'); + const runDirectory = path.join(root, 'run'); + return { + database, + runDirectory, + sharedMemory: `${database}-shm`, + sidecar: path.join(root, 'inflow.vault'), + socket: + rootDirectory === undefined && usesLinuxVaultService() + ? '/run/inflow/vault.sock' + : rootDirectory === undefined && process.platform === 'win32' + ? '\\\\.\\pipe\\InFlowVault' + : path.join(runDirectory, 'vault.sock'), + writeAheadLog: `${database}-wal`, + }; +} + +export function usesLinuxVaultService(): boolean { + if (process.platform !== 'linux') return false; + try { + return realpathSync(process.execPath) === '/opt/inflow/bin/inflow'; + } catch { + return process.execPath === '/opt/inflow/bin/inflow'; + } +} + +export function linuxVaultServiceUserId(socketPath: string): number { + try { + const parent = path.dirname(socketPath); + const parentStat = lstatSync(parent); + const socketStat = lstatSync(socketPath); + if ( + realpathSync(parent) !== parent || + !parentStat.isDirectory() || + parentStat.uid !== socketStat.uid || + (parentStat.mode & 0o022) !== 0 || + !socketStat.isSocket() || + socketStat.isSymbolicLink() + ) { + throw new Error('unsafe vault service socket'); + } + return socketStat.uid; + } catch (cause) { + if (isMissingPath(cause)) { + throw new SecureStorageError('secure_storage_unavailable', 'The InFlow vault service is unavailable.', { cause }); + } + throw new SecureStorageError( + 'secure_storage_peer_verification_failed', + 'The InFlow vault service socket identity is invalid.', + { cause }, + ); + } +} + +function isMissingPath(cause: unknown): boolean { + return cause instanceof Error && 'code' in cause && (cause.code === 'ENOENT' || cause.code === 'ENOTDIR'); +} + +export async function removeVaultLocalState(paths: VaultFilePaths): Promise { + await removePath(paths.database); + await removePath(paths.writeAheadLog); + await removePath(paths.sharedMemory); + await removePath(paths.sidecar); + await removePath(paths.socket); +} + +async function removePath(filePath: string): Promise { + await rm(filePath, { force: true }); +} diff --git a/packages/core/src/secure-storage/vault-ipc.ts b/packages/core/src/secure-storage/vault-ipc.ts new file mode 100644 index 0000000..ef90ac9 --- /dev/null +++ b/packages/core/src/secure-storage/vault-ipc.ts @@ -0,0 +1,245 @@ +import { Buffer } from 'node:buffer'; +import { randomFillSync } from 'node:crypto'; +import { SecureStorageError } from './errors.js'; + +export interface VaultIpcError { + code: string; + message: string; +} + +export interface VaultIpcRequest { + id: string; + method: VaultIpcMethod; + params: Record; + version: 1; +} + +export type VaultIpcResponse = + | { + id: string; + ok: false; + error: VaultIpcError; + version: 1; + } + | { + id: string; + ok: true; + result: Record; + version: 1; + }; + +export type VaultIpcMessage = VaultIpcRequest | VaultIpcResponse; + +export type VaultIpcMethod = + | 'daemon.info' + | 'daemon.shutdown' + | 'secret.delete' + | 'secret.deleteExpired' + | 'secret.exists' + | 'secret.get' + | 'secret.put' + | 'secret.touch' + | 'vault.changePassphrase' + | 'vault.getPolicy' + | 'vault.lock' + | 'vault.reset' + | 'vault.setPolicy' + | 'vault.status' + | 'vault.unlock' + | 'vault.unlockSalt'; + +export const VAULT_IPC_MAX_MESSAGE_BYTES = 1024 * 1024; +export const VAULT_IPC_METHODS = [ + 'daemon.info', + 'daemon.shutdown', + 'secret.delete', + 'secret.deleteExpired', + 'secret.exists', + 'secret.get', + 'secret.put', + 'secret.touch', + 'vault.changePassphrase', + 'vault.getPolicy', + 'vault.lock', + 'vault.reset', + 'vault.setPolicy', + 'vault.status', + 'vault.unlock', + 'vault.unlockSalt', +] as const satisfies readonly VaultIpcMethod[]; + +const LENGTH_BYTES = 4; +const ATTACHMENT_HEADER_BYTES = 8; +const ATTACHMENT_MARKER = '$inflowVaultAttachment'; + +export function encodeVaultIpcMessage(message: VaultIpcMessage): Buffer { + const attachments: Uint8Array[] = []; + const json = Buffer.from(JSON.stringify(encodeAttachments(message, attachments)), 'utf8'); + const attachmentBytes = attachments.reduce( + (total, attachment) => total + LENGTH_BYTES + attachment.byteLength * 2, + 0, + ); + const bodyLength = ATTACHMENT_HEADER_BYTES + json.byteLength + attachmentBytes; + if (bodyLength > VAULT_IPC_MAX_MESSAGE_BYTES) { + throw new SecureStorageError('secure_storage_invalid_path', 'Vault IPC message is too large.'); + } + const frame = Buffer.alloc(LENGTH_BYTES + bodyLength); + frame.writeUInt32BE(bodyLength, 0); + frame.writeUInt32BE(json.byteLength, LENGTH_BYTES); + frame.writeUInt32BE(attachments.length, LENGTH_BYTES * 2); + json.copy(frame, LENGTH_BYTES + ATTACHMENT_HEADER_BYTES); + let offset = LENGTH_BYTES + ATTACHMENT_HEADER_BYTES + json.byteLength; + for (const attachment of attachments) { + frame.writeUInt32BE(attachment.byteLength * 2, offset); + offset += LENGTH_BYTES; + const mask = frame.subarray(offset, offset + attachment.byteLength); + randomFillSync(mask); + offset += attachment.byteLength; + for (let index = 0; index < attachment.byteLength; index += 1) { + frame[offset + index] = (attachment[index] ?? 0) ^ (mask[index] ?? 0); + } + offset += attachment.byteLength; + } + json.fill(0); + return frame; +} + +export function decodeVaultIpcFrame(frame: Uint8Array): VaultIpcMessage { + if (frame.byteLength < LENGTH_BYTES + ATTACHMENT_HEADER_BYTES) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC frame is truncated.'); + } + const bytes = Buffer.from(frame); + try { + const length = bytes.readUInt32BE(0); + if (length > VAULT_IPC_MAX_MESSAGE_BYTES) { + throw new SecureStorageError('secure_storage_invalid_path', 'Vault IPC message is too large.'); + } + if (bytes.byteLength !== LENGTH_BYTES + length) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC frame length is invalid.'); + } + const jsonLength = bytes.readUInt32BE(LENGTH_BYTES); + const attachmentCount = bytes.readUInt32BE(LENGTH_BYTES * 2); + const jsonStart = LENGTH_BYTES + ATTACHMENT_HEADER_BYTES; + const jsonEnd = jsonStart + jsonLength; + if (jsonEnd > bytes.byteLength) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC frame attachments are malformed.'); + } + const attachments: Buffer[] = []; + let offset = jsonEnd; + for (let index = 0; index < attachmentCount; index += 1) { + if (offset + LENGTH_BYTES > bytes.byteLength) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC frame attachments are malformed.'); + } + const attachmentLength = bytes.readUInt32BE(offset); + offset += LENGTH_BYTES; + if (offset + attachmentLength > bytes.byteLength) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC frame attachments are malformed.'); + } + if (attachmentLength % 2 !== 0) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC attachment masking is malformed.'); + } + const valueLength = attachmentLength / 2; + const attachment = Buffer.alloc(valueLength); + for (let index = 0; index < valueLength; index += 1) { + attachment[index] = (bytes[offset + index] ?? 0) ^ (bytes[offset + valueLength + index] ?? 0); + } + attachments.push(attachment); + offset += attachmentLength; + } + if (offset !== bytes.byteLength) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC frame attachments are malformed.'); + } + const parsed = JSON.parse(bytes.subarray(jsonStart, jsonEnd).toString('utf8')) as unknown; + return parseVaultIpcMessage(decodeAttachments(parsed, attachments)); + } finally { + bytes.fill(0); + } +} + +export function clearVaultIpcBytes(value: unknown): void { + if (value instanceof Uint8Array) { + value.fill(0); + return; + } + if (Array.isArray(value)) { + for (const item of value) clearVaultIpcBytes(item); + return; + } + if (!isRecord(value)) return; + for (const item of Object.values(value)) clearVaultIpcBytes(item); +} + +function encodeAttachments(value: unknown, attachments: Uint8Array[]): unknown { + if (value instanceof Uint8Array) { + const index = attachments.push(value) - 1; + return { [ATTACHMENT_MARKER]: index }; + } + if (Array.isArray(value)) return value.map((item) => encodeAttachments(item, attachments)); + if (!isRecord(value)) return value; + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, encodeAttachments(item, attachments)])); +} + +function decodeAttachments(value: unknown, attachments: Buffer[]): unknown { + if (isRecord(value) && Object.keys(value).length === 1 && ATTACHMENT_MARKER in value) { + const index = value[ATTACHMENT_MARKER]; + if (!Number.isSafeInteger(index) || (index as number) < 0 || (index as number) >= attachments.length) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC attachment reference is malformed.'); + } + return attachments[index as number]; + } + if (Array.isArray(value)) return value.map((item) => decodeAttachments(item, attachments)); + if (!isRecord(value)) return value; + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, decodeAttachments(item, attachments)])); +} + +function parseVaultIpcMessage(value: unknown): VaultIpcMessage { + if (!isRecord(value) || value['version'] !== 1 || typeof value['id'] !== 'string') { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC message is malformed.'); + } + if ('method' in value) return parseVaultIpcRequest(value); + return parseVaultIpcResponse(value); +} + +function parseVaultIpcRequest(value: Record): VaultIpcRequest { + if (!isVaultIpcMethod(value['method']) || !isRecord(value['params'])) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC request is malformed.'); + } + return { + id: value['id'] as string, + method: value['method'], + params: value['params'], + version: 1, + }; +} + +function parseVaultIpcResponse(value: Record): VaultIpcResponse { + if (value['ok'] === true && isRecord(value['result'])) { + return { + id: value['id'] as string, + ok: true, + result: value['result'], + version: 1, + }; + } + if (value['ok'] === false && isRecord(value['error'])) { + const code = value['error']['code']; + const message = value['error']['message']; + if (typeof code === 'string' && typeof message === 'string') { + return { + error: { code, message }, + id: value['id'] as string, + ok: false, + version: 1, + }; + } + } + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC response is malformed.'); +} + +function isVaultIpcMethod(value: unknown): value is VaultIpcMethod { + return typeof value === 'string' && VAULT_IPC_METHODS.includes(value as VaultIpcMethod); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/core/src/secure-storage/vault-local-backend.ts b/packages/core/src/secure-storage/vault-local-backend.ts new file mode 100644 index 0000000..a9ff2eb --- /dev/null +++ b/packages/core/src/secure-storage/vault-local-backend.ts @@ -0,0 +1,293 @@ +import { Buffer } from 'node:buffer'; +import { randomBytes } from 'node:crypto'; +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { SecureStorageError } from './errors.js'; +import type { SecureSqliteRepository, StoredVaultRecord } from './sqlite.js'; +import { + changeVaultUnlockFactor, + changeVaultWrappingKey, + createVaultMaterial, + createVaultMaterialWithWrappingKey, + decodeVaultHeader, + unwrapVaultMaterial, + unwrapVaultMaterialWithWrappingKey, + VAULT_SALT_BYTES, +} from './vault-crypto.js'; +import { removeVaultLocalState, vaultFilePaths, type VaultFilePaths } from './vault-files.js'; +import { ProtectedVaultKey } from './vault-protected-key.js'; +import { + DEFAULT_VAULT_POLICY, + type DeleteExpiredVaultSecretsInput, + type DeleteVaultSecretInput, + type GetVaultSecretInput, + type PutVaultSecretInput, + type TouchVaultSecretInput, + type VaultBackend, + type VaultPolicy, + type VaultSecretPayload, + type VaultStatus, +} from './vault-backend.js'; +import { createVaultSecretReference, parseVaultSecretReference, type VaultSecretKind } from './vault-types.js'; + +export interface LocalVaultBackendOptions { + paths?: VaultFilePaths; + repository: SecureSqliteRepository; + sidecarPath?: string; + now?: () => Date; +} + +const POLICY_SETTING_NAME = 'vault-policy'; +const RECORD_ENCRYPTION_VERSION = 1; +const SIDECAR_FILE_MODE = 0o600; + +export class LocalVaultBackend implements VaultBackend { + private masterKey: ProtectedVaultKey | undefined; + private readonly now: () => Date; + private readonly paths: VaultFilePaths; + private readonly repository: SecureSqliteRepository; + private readonly sidecarPath: string; + + constructor(options: LocalVaultBackendOptions) { + this.paths = options.paths ?? vaultFilePaths(); + this.repository = options.repository; + this.sidecarPath = options.sidecarPath ?? this.paths.sidecar; + this.now = options.now ?? (() => new Date()); + } + + async changePassphrase(currentUnlockFactor: Uint8Array, nextUnlockFactor: Uint8Array): Promise { + const header = await readFile(this.sidecarPath); + const nextHeader = changeVaultUnlockFactor(header, currentUnlockFactor, nextUnlockFactor); + await writeFile(this.sidecarPath, nextHeader, { flag: 'w', mode: SIDECAR_FILE_MODE }); + await chmod(this.sidecarPath, SIDECAR_FILE_MODE); + this.replaceMasterKey(unwrapVaultMaterial(nextHeader, nextUnlockFactor)); + } + + async changeWrappingKey( + currentWrappingKey: Uint8Array, + nextWrappingKey: Uint8Array, + nextSalt: Uint8Array, + ): Promise { + const header = await readFile(this.sidecarPath); + const nextHeader = changeVaultWrappingKey(header, currentWrappingKey, nextWrappingKey, nextSalt); + await writeFile(this.sidecarPath, nextHeader, { flag: 'w', mode: SIDECAR_FILE_MODE }); + await chmod(this.sidecarPath, SIDECAR_FILE_MODE); + this.replaceMasterKey(unwrapVaultMaterialWithWrappingKey(nextHeader, nextWrappingKey)); + } + + deleteExpired(input: DeleteExpiredVaultSecretsInput): void { + this.repository.deleteExpiredVaultRecords(input.now); + } + + deleteSecret(input: DeleteVaultSecretInput): void { + const reference = parseVaultSecretReference(input.reference.reference); + this.requireActiveRecord(reference.reference, input.expectedKind); + this.repository.markVaultRecordStatus(reference.reference, 'deleting', this.nowIso()); + this.repository.deleteVaultRecord(reference.reference); + } + + exists(input: GetVaultSecretInput): boolean { + const reference = parseVaultSecretReference(input.reference.reference); + return this.repository.getVaultRecord(reference.reference, input.expectedKind) !== undefined; + } + + getPolicy(): VaultPolicy { + const setting = this.repository.getSetting(POLICY_SETTING_NAME); + if (setting === undefined) return DEFAULT_VAULT_POLICY; + return parseVaultPolicy(setting.payload); + } + + getSecret(input: GetVaultSecretInput): VaultSecretPayload { + const masterKey = this.requireMasterKey(); + const reference = parseVaultSecretReference(input.reference.reference); + const record = this.requireActiveRecord(reference.reference, input.expectedKind); + return { + payload: masterKey.decrypt(recordContext(record), { + ciphertext: Buffer.from(record.ciphertext), + nonce: Buffer.from(record.nonce), + tag: Buffer.from(record.tag), + }), + reference, + }; + } + + lock(): void { + this.masterKey?.destroy(); + this.masterKey = undefined; + } + + putSecret(input: PutVaultSecretInput): { reference: string } { + const masterKey = this.requireMasterKey(); + const reference = + input.reference === undefined + ? createVaultSecretReference() + : parseVaultSecretReference(input.reference.reference); + if (this.repository.hasVaultRecord(reference.reference)) { + throw new SecureStorageError('secure_storage_secret_conflict', 'The vault secret reference already exists.'); + } + const status = 'active'; + const context = { + encryptionVersion: RECORD_ENCRYPTION_VERSION, + kind: input.expectedKind, + reference: reference.reference, + status, + } as const; + const encrypted = masterKey.encrypt(context, input.payload); + const record: StoredVaultRecord = { + ciphertext: encrypted.ciphertext, + encryptionVersion: RECORD_ENCRYPTION_VERSION, + kind: input.expectedKind, + nonce: encrypted.nonce, + reference: reference.reference, + status, + tag: encrypted.tag, + updatedAt: this.nowIso(), + }; + if (input.expiresAt !== undefined) record.expiresAt = input.expiresAt; + this.repository.putVaultRecord(record); + return reference; + } + + async reset(): Promise { + this.lock(); + await removeVaultLocalState(this.paths); + } + + setPolicy(policy: VaultPolicy): VaultPolicy { + const parsed = parseVaultPolicy(policy); + this.repository.upsertSetting(POLICY_SETTING_NAME, parsed); + return parsed; + } + + async status(): Promise { + return { + daemonRunning: false, + lockState: this.masterKey === undefined ? ((await this.hasSidecar()) ? 'locked' : 'not_initialized') : 'unlocked', + }; + } + + touch(input: TouchVaultSecretInput): void { + const reference = parseVaultSecretReference(input.reference.reference); + this.requireActiveRecord(reference.reference, input.expectedKind); + this.repository.touchVaultRecord(reference.reference, this.nowIso()); + } + + async unlock(unlockFactor: Uint8Array): Promise { + this.repository.initialize(); + let header: Buffer; + try { + header = await readFile(this.sidecarPath); + this.replaceMasterKey(unwrapVaultMaterial(header, unlockFactor)); + } catch (cause) { + if (!isMissingFileError(cause)) throw cause; + const material = createVaultMaterial(unlockFactor); + await mkdir(path.dirname(this.sidecarPath), { mode: 0o700, recursive: true }); + await writeFile(this.sidecarPath, material.header, { flag: 'wx', mode: SIDECAR_FILE_MODE }); + await chmod(this.sidecarPath, SIDECAR_FILE_MODE); + this.replaceMasterKey(material.masterKey); + } + return this.status(); + } + + async unlockSalt(): Promise { + try { + return Buffer.from(decodeVaultHeader(await readFile(this.sidecarPath)).salt); + } catch (cause) { + if (!isMissingFileError(cause)) throw cause; + return randomBytes(VAULT_SALT_BYTES); + } + } + + async unlockWithWrappingKey(wrappingKey: Uint8Array, salt: Uint8Array): Promise { + this.repository.initialize(); + let header: Buffer; + try { + header = await readFile(this.sidecarPath); + const storedSalt = decodeVaultHeader(header).salt; + if (!storedSalt.equals(salt)) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault unlock salt changed.'); + } + this.replaceMasterKey(unwrapVaultMaterialWithWrappingKey(header, wrappingKey)); + } catch (cause) { + if (!isMissingFileError(cause)) throw cause; + const material = createVaultMaterialWithWrappingKey(wrappingKey, salt); + await mkdir(path.dirname(this.sidecarPath), { mode: 0o700, recursive: true }); + await writeFile(this.sidecarPath, material.header, { flag: 'wx', mode: SIDECAR_FILE_MODE }); + await chmod(this.sidecarPath, SIDECAR_FILE_MODE); + this.replaceMasterKey(material.masterKey); + } + return this.status(); + } + + private async hasSidecar(): Promise { + try { + await readFile(this.sidecarPath); + return true; + } catch (cause) { + if (isMissingFileError(cause)) return false; + throw cause; + } + } + + private requireActiveRecord(reference: string, expectedKind: VaultSecretKind): StoredVaultRecord { + const record = this.repository.getVaultRecordByReference(reference); + if ( + record === undefined || + record.kind !== expectedKind || + record.status !== 'active' || + (record.expiresAt !== undefined && Date.parse(record.expiresAt) <= this.now().getTime()) + ) { + throw new SecureStorageError('secure_storage_secret_missing', 'A referenced vault secret is missing.'); + } + return record; + } + + private nowIso(): string { + return this.now().toISOString(); + } + + private requireMasterKey(): ProtectedVaultKey { + if (this.masterKey === undefined) { + throw new SecureStorageError('vault_locked', 'The InFlow vault is locked.'); + } + return this.masterKey; + } + + private replaceMasterKey(masterKey: Buffer): void { + const protectedKey = new ProtectedVaultKey(masterKey); + this.masterKey?.destroy(); + this.masterKey = protectedKey; + } +} + +function recordContext(record: StoredVaultRecord) { + return { + encryptionVersion: record.encryptionVersion, + kind: record.kind, + reference: record.reference, + status: record.status, + }; +} + +function parseVaultPolicy(value: unknown): VaultPolicy { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new SecureStorageError('secure_storage_corrupt', 'The vault policy is malformed.'); + } + const candidate = value as Record; + const idleTimeoutSeconds = candidate['idleTimeoutSeconds']; + const lockOnSleep = candidate['lockOnSleep']; + if (!(idleTimeoutSeconds === null || isNonNegativeInteger(idleTimeoutSeconds)) || typeof lockOnSleep !== 'boolean') { + throw new SecureStorageError('secure_storage_corrupt', 'The vault policy is malformed.'); + } + return { idleTimeoutSeconds, lockOnSleep }; +} + +function isMissingFileError(cause: unknown): boolean { + return ( + typeof cause === 'object' && cause !== null && 'code' in cause && (cause as { code?: unknown }).code === 'ENOENT' + ); +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} diff --git a/packages/core/src/secure-storage/vault-peer-verifier.ts b/packages/core/src/secure-storage/vault-peer-verifier.ts new file mode 100644 index 0000000..3735fd4 --- /dev/null +++ b/packages/core/src/secure-storage/vault-peer-verifier.ts @@ -0,0 +1,261 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { lstatSync, readFileSync, realpathSync } from 'node:fs'; +import { basename, dirname, resolve } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import type { Socket } from 'node:net'; +import { SecureStorageError } from './errors.js'; +import { runtimeRequire } from './runtime-require.js'; + +declare const __VAULT_PEER_NATIVE_SHA256__: string | undefined; + +export interface VaultSocketPeer { + path: string; + pid: number; + principal?: string; + uid: number; +} + +export type VaultSocketPeerVerifier = (socket: Socket) => Promise | VaultSocketPeer; + +interface NativeVaultPeerModule { + peerCredentials(fd: number): Pick; + peerInfo(fd: number): VaultSocketPeer; +} + +interface VaultPeerVerifierOptions { + expectedExecutablePath?: string; + expectedNativeModuleSha256?: string; + expectedTeamId?: string; + expectedUserId?: number; + nativeModulePath?: string; + requireSameUser?: boolean; + requireSignature?: boolean; +} + +export interface VaultPeerVerificationConfig { + expectedExecutablePath: string; + expectedNativeModuleSha256?: string; + expectedTeamId: string; + expectedUserId?: number; + nativeModulePath: string; + requireSameUser: boolean; + requireSignature: boolean; +} + +interface VaultPeerVerifierDependencies { + currentUserId(): number | undefined; + loadNativeModule(path: string): NativeVaultPeerModule; + realpath(path: string): string; + verifyNativeModule( + path: string, + options: { expectedSha256?: string; expectedTeamId: string; requireSignature: boolean }, + ): void; + verifySignature(path: string, teamId: string): void; +} + +const DEFAULT_TEAM_ID = 'B96U57DTR2'; + +export function shouldRequireVaultPeerVerification(): boolean { + if (process.platform === 'linux') return true; + return process.platform === 'darwin' && realExecutablePath().includes('/InFlow.app/Contents/MacOS/'); +} + +export function createVaultSocketPeerVerifier( + options: VaultPeerVerifierOptions = {}, + dependencies: VaultPeerVerifierDependencies = defaultPeerVerifierDependencies, +): VaultSocketPeerVerifier { + const config = createVaultPeerVerificationConfig(options, dependencies); + verifyVaultPeerVerificationConfig(config, dependencies); + const native = dependencies.loadNativeModule(config.nativeModulePath); + + return (socket) => { + const peer = native.peerInfo(socketFileDescriptor(socket)); + if (config.expectedUserId !== undefined) { + if (peer.uid !== config.expectedUserId) { + throw new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); + } + } else if (config.requireSameUser) { + const currentUserId = dependencies.currentUserId(); + if (currentUserId === undefined || peer.uid !== currentUserId) { + throw new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); + } + } + if (dependencies.realpath(peer.path) !== config.expectedExecutablePath) { + throw new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); + } + if (config.requireSignature) dependencies.verifySignature(peer.path, config.expectedTeamId); + return peer; + }; +} + +export function verifyTransferredVaultSocketPeer(socket: Socket, attestedPeer: VaultSocketPeer): VaultSocketPeer { + const config = createVaultPeerVerificationConfig(); + verifyVaultPeerVerificationConfig(config); + return verifyTransferredVaultSocketPeerWithDependencies( + socket, + attestedPeer, + config, + defaultPeerVerifierDependencies, + ); +} + +/** @internal */ +export function verifyTransferredVaultSocketPeerWithDependencies( + socket: Socket, + attestedPeer: VaultSocketPeer, + config: VaultPeerVerificationConfig, + dependencies: Pick, +): VaultSocketPeer { + if (dependencies.realpath(attestedPeer.path) !== config.expectedExecutablePath) { + throw new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); + } + const credentials = dependencies + .loadNativeModule(config.nativeModulePath) + .peerCredentials(socketFileDescriptor(socket)); + if (credentials.pid !== attestedPeer.pid || credentials.uid !== attestedPeer.uid) { + throw new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); + } + return attestedPeer; +} + +export function verifyVaultPeerVerificationConfig( + config: VaultPeerVerificationConfig, + dependencies: VaultPeerVerifierDependencies = defaultPeerVerifierDependencies, +): void { + dependencies.verifyNativeModule(config.nativeModulePath, { + ...(config.expectedNativeModuleSha256 === undefined ? {} : { expectedSha256: config.expectedNativeModuleSha256 }), + expectedTeamId: config.expectedTeamId, + requireSignature: config.requireSignature, + }); +} + +export function createVaultPeerVerificationConfig( + options: VaultPeerVerifierOptions = {}, + dependencies: VaultPeerVerifierDependencies = defaultPeerVerifierDependencies, +): VaultPeerVerificationConfig { + if (process.platform !== 'darwin' && process.platform !== 'linux') { + throw new SecureStorageError('secure_storage_unavailable', 'Vault peer verification is unavailable.'); + } + const nativeModulePath = options.nativeModulePath ?? defaultNativeModulePath(); + const expectedPath = dependencies.realpath(options.expectedExecutablePath ?? process.execPath); + const expectedTeamId = options.expectedTeamId ?? DEFAULT_TEAM_ID; + const requireSignature = + options.requireSignature ?? (process.platform === 'darwin' && expectedPath.includes('/InFlow.app/Contents/MacOS/')); + const expectedNativeModuleSha256 = options.expectedNativeModuleSha256 ?? embeddedNativeModuleSha256(); + if ( + options.expectedUserId !== undefined && + (!Number.isSafeInteger(options.expectedUserId) || options.expectedUserId < 0) + ) { + throw new SecureStorageError('secure_storage_unavailable', 'Vault peer verification configuration is invalid.'); + } + if ( + options.nativeModulePath === undefined && + expectedNativeModuleSha256 === undefined && + isPackagedVaultExecutable() + ) { + throw new SecureStorageError('secure_storage_unavailable', 'Vault native module integrity is unavailable.'); + } + return { + expectedExecutablePath: expectedPath, + ...(expectedNativeModuleSha256 === undefined ? {} : { expectedNativeModuleSha256 }), + expectedTeamId, + ...(options.expectedUserId === undefined ? {} : { expectedUserId: options.expectedUserId }), + nativeModulePath, + requireSameUser: options.requireSameUser ?? true, + requireSignature, + }; +} + +export function verifyVaultNativeModule( + path: string, + options: { + expectedSha256?: string; + expectedTeamId: string; + requireSignature: boolean; + }, +): void { + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || (process.platform !== 'win32' && (stat.mode & 0o022) !== 0)) { + throw new Error('unsafe native module'); + } + if (path !== resolve(path)) throw new Error('non-canonical native module'); + if (options.expectedSha256 !== undefined) { + const actual = createHash('sha256').update(readFileSync(path)).digest('hex'); + if (actual !== options.expectedSha256) throw new Error('native module digest mismatch'); + } + if (options.requireSignature) defaultPeerVerifierDependencies.verifySignature(path, options.expectedTeamId); + } catch { + throw new SecureStorageError('secure_storage_unavailable', 'Vault native module verification failed.'); + } +} + +export function socketFileDescriptor(socket: Socket): number { + const candidate = socket as Socket & { _handle?: { fd?: unknown } }; + const fd = candidate._handle?.fd; + if (typeof fd !== 'number' || !Number.isSafeInteger(fd) || fd < 0) { + throw new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); + } + return fd; +} + +function defaultNativeModulePath(): string { + const executablePath = realExecutablePath(); + if (executablePath.includes('/InFlow.app/Contents/MacOS/')) { + return resolve(dirname(executablePath), '../Resources/app/native/vault_peer_darwin.node'); + } + if (process.platform === 'linux' && executablePath.includes('/bin/inflow')) { + return resolve(dirname(executablePath), '../lib/inflow/native/vault_peer_linux.node'); + } + const platformName = process.platform === 'linux' ? 'linux' : 'darwin'; + const moduleDirectory = dirname(fileURLToPath(import.meta.url)); + const nativeRelativePath = + basename(moduleDirectory) === 'dist' + ? `../native/build/vault_peer_${platformName}.node` + : `../../native/build/vault_peer_${platformName}.node`; + return resolve(moduleDirectory, nativeRelativePath); +} + +function realExecutablePath(): string { + try { + return realpathSync(process.execPath); + } catch { + return process.execPath; + } +} + +function embeddedNativeModuleSha256(): string | undefined { + return typeof __VAULT_PEER_NATIVE_SHA256__ === 'string' ? __VAULT_PEER_NATIVE_SHA256__ : undefined; +} + +function isPackagedVaultExecutable(): boolean { + const executable = realExecutablePath(); + return executable.endsWith('/bin/inflow') || executable.includes('/InFlow.app/Contents/MacOS/'); +} + +const defaultPeerVerifierDependencies: VaultPeerVerifierDependencies = { + currentUserId() { + return typeof process.getuid === 'function' ? process.getuid() : undefined; + }, + loadNativeModule(path) { + return runtimeRequire()(path) as NativeVaultPeerModule; + }, + realpath(path) { + return realpathSync(path); + }, + verifyNativeModule(path, options) { + verifyVaultNativeModule(path, options); + }, + verifySignature(path, teamId) { + const requirement = `anchor apple generic and certificate leaf[subject.OU] = "${teamId}"`; + try { + execFileSync('/usr/bin/codesign', ['--verify', '--strict', `--test-requirement==${requirement}`, path], { + stdio: 'ignore', + }); + } catch { + throw new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); + } + }, +}; diff --git a/packages/core/src/secure-storage/vault-protected-key.ts b/packages/core/src/secure-storage/vault-protected-key.ts new file mode 100644 index 0000000..c3c59a4 --- /dev/null +++ b/packages/core/src/secure-storage/vault-protected-key.ts @@ -0,0 +1,136 @@ +import type { Buffer } from 'node:buffer'; +import { dirname, resolve } from 'node:path'; +import process from 'node:process'; +import { SecureStorageError } from './errors.js'; +import { + createVaultPeerVerificationConfig, + verifyVaultNativeModule, + verifyVaultPeerVerificationConfig, +} from './vault-peer-verifier.js'; +import { runtimeRequire } from './runtime-require.js'; +import { vaultRecordAad, type EncryptedVaultRecordPayload, type VaultRecordEncryptionContext } from './vault-crypto.js'; + +declare const __VAULT_PEER_NATIVE_SHA256__: string | undefined; + +interface VaultSecureMemoryModule { + createProtectedKey(bytes: Uint8Array): object; + decryptRecord( + handle: object, + authenticatedData: Uint8Array, + ciphertext: Uint8Array, + nonce: Uint8Array, + tag: Uint8Array, + ): Buffer; + deriveVaultWrappingKey?(unlockFactor: Uint8Array, salt: Uint8Array): Buffer; + destroyProtectedKey(handle: object): void; + encryptRecord(handle: object, authenticatedData: Uint8Array, plaintext: Uint8Array): EncryptedVaultRecordPayload; + hardenProcess(): void; +} + +export function deriveVaultWrappingKey(unlockFactor: Uint8Array, salt: Uint8Array): Buffer { + try { + const native = loadSecureMemoryModule(); + if (native.deriveVaultWrappingKey === undefined) { + throw new Error('Native vault key derivation is unavailable.'); + } + return native.deriveVaultWrappingKey(unlockFactor, salt); + } catch (cause) { + throw new SecureStorageError('secure_storage_unavailable', 'Vault key derivation is unavailable.', { cause }); + } +} + +export class ProtectedVaultKey { + private handle: object | undefined; + + constructor( + bytes: Uint8Array, + private readonly native: VaultSecureMemoryModule = loadSecureMemoryModule(), + ) { + try { + this.handle = native.createProtectedKey(bytes); + } catch (cause) { + throw new SecureStorageError('secure_storage_unavailable', 'Vault protected memory is unavailable.', { cause }); + } finally { + bytes.fill(0); + } + } + + decrypt(context: VaultRecordEncryptionContext, encrypted: EncryptedVaultRecordPayload): Buffer { + const handle = this.requireHandle(); + try { + return this.native.decryptRecord( + handle, + vaultRecordAad(context), + encrypted.ciphertext, + encrypted.nonce, + encrypted.tag, + ); + } catch (cause) { + if (nativeErrorCode(cause) === 'EAUTH') { + throw new SecureStorageError('secure_storage_corrupt', 'Vault record could not be decrypted.', { cause }); + } + throw new SecureStorageError('secure_storage_unavailable', 'Vault protected cryptography is unavailable.', { + cause, + }); + } + } + + destroy(): void { + if (this.handle === undefined) return; + try { + this.native.destroyProtectedKey(this.handle); + } finally { + this.handle = undefined; + } + } + + encrypt(context: VaultRecordEncryptionContext, payload: Uint8Array): EncryptedVaultRecordPayload { + const handle = this.requireHandle(); + try { + return this.native.encryptRecord(handle, vaultRecordAad(context), payload); + } catch (cause) { + throw new SecureStorageError('secure_storage_unavailable', 'Vault protected cryptography is unavailable.', { + cause, + }); + } + } + + private requireHandle(): object { + if (this.handle === undefined) { + throw new SecureStorageError('vault_locked', 'The InFlow vault is locked.'); + } + return this.handle; + } +} + +export function hardenVaultDaemonProcess(native: VaultSecureMemoryModule = loadSecureMemoryModule()): void { + try { + native.hardenProcess(); + } catch (cause) { + throw new SecureStorageError('secure_storage_unavailable', 'Vault process hardening is unavailable.', { cause }); + } +} + +function loadSecureMemoryModule(): VaultSecureMemoryModule { + if (process.platform === 'win32') { + if (typeof __VAULT_PEER_NATIVE_SHA256__ !== 'string') { + throw new SecureStorageError('secure_storage_unavailable', 'Vault native module integrity is unavailable.'); + } + const nativeModulePath = resolve(dirname(process.execPath), 'native', 'vault_peer_windows.node'); + verifyVaultNativeModule(nativeModulePath, { + expectedSha256: __VAULT_PEER_NATIVE_SHA256__, + expectedTeamId: '', + requireSignature: false, + }); + return runtimeRequire()(nativeModulePath) as VaultSecureMemoryModule; + } + const config = createVaultPeerVerificationConfig(); + verifyVaultPeerVerificationConfig(config); + // The integrity-verified native module supplies this internal CommonJS boundary. + return runtimeRequire()(config.nativeModulePath) as VaultSecureMemoryModule; +} + +function nativeErrorCode(cause: unknown): string | undefined { + if (typeof cause !== 'object' || cause === null || !('code' in cause)) return undefined; + return typeof cause.code === 'string' ? cause.code : undefined; +} diff --git a/packages/core/src/secure-storage/vault-socket.ts b/packages/core/src/secure-storage/vault-socket.ts new file mode 100644 index 0000000..ddfef6d --- /dev/null +++ b/packages/core/src/secure-storage/vault-socket.ts @@ -0,0 +1,348 @@ +import { Buffer } from 'node:buffer'; +import { createServer, createConnection, type Server, type Socket } from 'node:net'; +import { chmod, lstat, mkdir, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { SecureStorageError } from './errors.js'; +import type { VaultBackend } from './vault-backend.js'; +import { handleVaultIpcRequest, type VaultDaemonInfo } from './vault-daemon-handler.js'; +import type { VaultSocketPeer, VaultSocketPeerVerifier } from './vault-peer-verifier.js'; +import { + VAULT_IPC_MAX_MESSAGE_BYTES, + clearVaultIpcBytes, + decodeVaultIpcFrame, + encodeVaultIpcMessage, + type VaultIpcRequest, + type VaultIpcResponse, +} from './vault-ipc.js'; + +export interface VaultSocketServer { + close(): Promise; + socketPath: string; +} + +interface VaultSocketServerCommonOptions { + daemonInfo?: VaultDaemonInfo; + listenFd?: number; + socketMode?: number; + socketPath: string; +} + +export interface StartSingleTenantVaultSocketServerOptions extends VaultSocketServerCommonOptions { + backend: VaultBackend; + backendForPeer?: never; + onShutdown?: () => Promise | void; + peerVerifier?: VaultSocketPeerVerifier; +} + +export interface StartMultiTenantVaultSocketServerOptions extends VaultSocketServerCommonOptions { + backend?: never; + backendForPeer(peer: VaultSocketPeer): VaultBackend; + onShutdown?: never; + peerVerifier: VaultSocketPeerVerifier; +} + +export type StartVaultSocketServerOptions = + StartMultiTenantVaultSocketServerOptions | StartSingleTenantVaultSocketServerOptions; + +export function createVaultSocketConnectionHandler( + options: StartVaultSocketServerOptions, +): (socket: Socket, peer?: VaultSocketPeer) => void { + const requestQueue = new VaultBackendRequestQueue(); + return (socket, peer) => { + void handleSocket(socket, options, requestQueue, peer); + }; +} + +export async function startVaultSocketServer(options: StartVaultSocketServerOptions): Promise { + if (options.listenFd === undefined) await prepareSocketPath(options.socketPath); + const handleConnection = createVaultSocketConnectionHandler(options); + const server = createServer(handleConnection); + await listen(server, options); + if (options.listenFd === undefined) await chmod(options.socketPath, options.socketMode ?? 0o600); + let closed = false; + return { + close: async () => { + if (closed) return; + closed = true; + await closeServer(server, options.listenFd === undefined ? options.socketPath : undefined); + }, + socketPath: options.socketPath, + }; +} + +export async function sendVaultIpcRequest( + socketPath: string, + request: VaultIpcRequest, + peerVerifier?: VaultSocketPeerVerifier, +): Promise { + const socket = createConnection(socketPath); + await connectAndVerify(socket, peerVerifier); + const response = readOneFrame(socket); + const requestFrame = encodeVaultIpcMessage(request); + await writeFrame(socket, requestFrame); + return response.then((frame) => { + try { + const decoded = decodeVaultIpcFrame(frame); + if (!('ok' in decoded)) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC response is malformed.'); + } + socket.end(); + return decoded; + } finally { + frame.fill(0); + } + }); +} + +function connectAndVerify(socket: Socket, peerVerifier: VaultSocketPeerVerifier | undefined): Promise { + return new Promise((resolve, reject) => { + const onConnect = (): void => { + socket.off('error', onError); + Promise.resolve() + .then(() => peerVerifier?.(socket)) + .then( + () => resolve(), + (cause: unknown) => { + socket.destroy(); + reject( + cause instanceof Error + ? cause + : new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'), + ); + }, + ); + }; + const onError = (cause: Error): void => { + socket.off('connect', onConnect); + reject(cause); + }; + socket.once('connect', onConnect); + socket.once('error', onError); + }); +} + +async function handleSocket( + socket: Socket, + options: StartVaultSocketServerOptions, + requestQueue: VaultBackendRequestQueue, + verifiedPeer?: VaultSocketPeer, +): Promise { + try { + const peer = verifiedPeer ?? (await options.peerVerifier?.(socket)); + const multiTenant = options.backendForPeer !== undefined; + const backend = resolveBackend(options, peer); + const frame = await readOneFrame(socket); + const decoded = decodeVaultIpcFrame(frame); + if (!('method' in decoded)) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC request is malformed.'); + } + try { + const response = await requestQueue.run(backend, () => + handleVaultIpcRequest(backend, decoded, options.daemonInfo, { + allowDaemonShutdown: !multiTenant, + }), + ); + const responseFrame = encodeVaultIpcMessage(response); + clearVaultIpcBytes(response); + socket.end(responseFrame, () => { + responseFrame.fill(0); + if (!multiTenant && response.ok && (decoded.method === 'daemon.shutdown' || decoded.method === 'vault.reset')) { + void Promise.resolve(options.onShutdown?.()).catch(() => undefined); + } + }); + } finally { + clearVaultIpcBytes(decoded); + frame.fill(0); + } + } catch (cause) { + const response: VaultIpcResponse = { + error: { + code: cause instanceof SecureStorageError ? cause.secureStorageCode : 'secure_storage_io_error', + message: cause instanceof SecureStorageError ? cause.message : 'The InFlow vault operation failed.', + }, + id: 'unknown', + ok: false, + version: 1, + }; + const responseFrame = encodeVaultIpcMessage(response); + socket.end(responseFrame, () => { + responseFrame.fill(0); + socket.destroy(); + }); + } +} + +class VaultBackendRequestQueue { + private readonly tails = new WeakMap>(); + + async run(backend: VaultBackend, operation: () => Promise): Promise { + const previous = this.tails.get(backend) ?? Promise.resolve(); + let release = (): void => undefined; + const current = new Promise((resolve) => { + release = resolve; + }); + this.tails.set(backend, current); + await previous.catch(() => undefined); + try { + return await operation(); + } finally { + release(); + if (this.tails.get(backend) === current) this.tails.delete(backend); + } + } +} + +function resolveBackend(options: StartVaultSocketServerOptions, peer: VaultSocketPeer | undefined): VaultBackend { + if (options.backendForPeer === undefined) return options.backend; + if (peer === undefined) { + throw new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); + } + return options.backendForPeer(peer); +} + +function writeFrame(socket: Socket, frame: Buffer): Promise { + return new Promise((resolve, reject) => { + const onError = (cause: Error): void => { + frame.fill(0); + reject(cause); + }; + socket.once('error', onError); + socket.write(frame, () => { + socket.off('error', onError); + frame.fill(0); + resolve(); + }); + }); +} + +function readOneFrame(socket: Socket): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + let settled = false; + const settle = (result: Buffer | SecureStorageError | Error): void => { + if (settled) return; + settled = true; + socket.off('data', onData); + socket.off('end', onEnd); + for (const chunk of chunks) chunk.fill(0); + chunks.length = 0; + if (result instanceof Error) { + reject(result); + return; + } + resolve(result); + }; + const tryResolve = (): void => { + const buffer = Buffer.concat(chunks); + if (buffer.byteLength < 4) { + buffer.fill(0); + return; + } + const length = buffer.readUInt32BE(0); + if (length > VAULT_IPC_MAX_MESSAGE_BYTES) { + socket.destroy(); + buffer.fill(0); + settle(new SecureStorageError('secure_storage_invalid_path', 'Vault IPC message is too large.')); + return; + } + if (buffer.byteLength >= length + 4) { + settle(buffer.subarray(0, length + 4)); + return; + } + buffer.fill(0); + }; + const onData = (chunk: Buffer): void => { + chunks.push(chunk); + total += chunk.byteLength; + if (total > VAULT_IPC_MAX_MESSAGE_BYTES + 4) { + socket.destroy(); + settle(new SecureStorageError('secure_storage_invalid_path', 'Vault IPC message is too large.')); + return; + } + tryResolve(); + }; + const onError = (cause: Error): void => { + settle(cause); + }; + const onEnd = (): void => { + tryResolve(); + settle(new SecureStorageError('secure_storage_corrupt', 'Vault IPC frame is truncated.')); + }; + socket.on('data', onData); + socket.on('error', onError); + socket.on('end', onEnd); + }); +} + +async function prepareSocketPath(socketPath: string): Promise { + await mkdir(path.dirname(socketPath), { mode: 0o700, recursive: true }); + try { + const existing = await lstat(socketPath); + if (existing.isSymbolicLink() || existing.isDirectory()) { + throw new SecureStorageError('secure_storage_invalid_path', 'The vault socket path is unsafe.'); + } + if (existing.isSocket() && (await isReachableSocket(socketPath))) { + throw new SecureStorageError('secure_storage_unavailable', 'The InFlow vault daemon is already running.'); + } + await rm(socketPath, { force: true }); + } catch (cause) { + if (isMissingFileError(cause)) return; + throw cause; + } +} + +function isReachableSocket(socketPath: string): Promise { + return new Promise((resolve) => { + const socket = createConnection(socketPath); + let settled = false; + const settle = (reachable: boolean): void => { + if (settled) return; + settled = true; + socket.removeAllListeners(); + socket.destroy(); + resolve(reachable); + }; + socket.setTimeout(250, () => { + settle(false); + }); + socket.once('connect', () => { + settle(true); + }); + socket.once('error', () => { + settle(false); + }); + }); +} + +function listen(server: Server, options: StartVaultSocketServerOptions): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(options.listenFd === undefined ? options.socketPath : { fd: options.listenFd }, () => { + server.off('error', reject); + resolve(); + }); + }); +} + +function closeServer(server: Server, socketPath: string | undefined): Promise { + return new Promise((resolve, reject) => { + server.close((cause) => { + if (cause !== undefined) { + reject(cause); + return; + } + if (socketPath === undefined) { + resolve(); + return; + } + rm(socketPath, { force: true }).then(() => resolve(), reject); + }); + }); +} + +function isMissingFileError(cause: unknown): boolean { + return ( + typeof cause === 'object' && cause !== null && 'code' in cause && (cause as { code?: unknown }).code === 'ENOENT' + ); +} diff --git a/packages/core/src/secure-storage/vault-sync-secret-store.ts b/packages/core/src/secure-storage/vault-sync-secret-store.ts new file mode 100644 index 0000000..80294b2 --- /dev/null +++ b/packages/core/src/secure-storage/vault-sync-secret-store.ts @@ -0,0 +1,504 @@ +import { Buffer } from 'node:buffer'; +import { createHash, randomUUID } from 'node:crypto'; +import process from 'node:process'; +import { Worker } from 'node:worker_threads'; +import { SecureStorageError, type SecureStorageErrorCode } from './errors.js'; +import type { SecretReference, SyncSecureSecretStore } from './secret-store.js'; +import { LINUX_VAULT_BROKER_PUBLIC_KEY } from './vault-broker-auth.js'; +import { + decodeVaultIpcFrame, + VAULT_IPC_MAX_MESSAGE_BYTES, + type VaultIpcRequest, + type VaultIpcResponse, +} from './vault-ipc.js'; +import { linuxVaultServiceUserId, usesLinuxVaultService, vaultFilePaths } from './vault-files.js'; +import { + createVaultPeerVerificationConfig, + shouldRequireVaultPeerVerification, + verifyVaultPeerVerificationConfig, + type VaultPeerVerificationConfig, +} from './vault-peer-verifier.js'; +import type { VaultSecretKind } from './vault-types.js'; +import { sendWindowsVaultIpcRequest } from './vault-windows-transport.js'; + +export interface SyncVaultSecretStoreOptions { + rootDirectory?: string; + timeoutMs?: number; +} + +const SHARED_HEADER_INTS = 2; +const SHARED_HEADER_BYTES = SHARED_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT; +const DEFAULT_TIMEOUT_MS = 10_000; + +export class SyncVaultSecretStore implements SyncSecureSecretStore { + private readonly rootDirectory: string | undefined; + private readonly socketPath: string; + private readonly timeoutMs: number; + + constructor(options: SyncVaultSecretStoreOptions = {}) { + this.rootDirectory = options.rootDirectory; + this.socketPath = vaultFilePaths(options.rootDirectory).socket; + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + } + + create(reference: SecretReference, value: Uint8Array): void { + this.request('secret.put', { + expectedKind: kindForReference(reference), + payload: value, + reference: vaultReferenceFor(reference), + }); + } + + delete(reference: SecretReference): void { + this.request('secret.delete', { + expectedKind: kindForReference(reference), + reference: vaultReferenceFor(reference), + }); + } + + read(reference: SecretReference): Uint8Array { + const result = this.request('secret.get', { + expectedKind: kindForReference(reference), + reference: vaultReferenceFor(reference), + }); + const payload = result['payload']; + if (!(payload instanceof Uint8Array)) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC secret response is malformed.'); + } + return Buffer.from(payload); + } + + private request(method: VaultIpcRequest['method'], params: Record): Record { + const request: VaultIpcRequest = { + id: `req_${randomUUID().replaceAll('-', '')}`, + method, + params, + version: 1, + }; + if (process.platform === 'win32' && this.rootDirectory === undefined) { + return responseResult(sendWindowsVaultIpcRequest(this.socketPath, request)); + } + const shared = new SharedArrayBuffer(SHARED_HEADER_BYTES + VAULT_IPC_MAX_MESSAGE_BYTES + 4); + const state = new Int32Array(shared, 0, SHARED_HEADER_INTS); + const worker = new Worker(WORKER_SOURCE, { + eval: true, + workerData: { + request, + peerVerification: createPeerVerification(this.socketPath, this.rootDirectory), + shared, + socketPath: this.socketPath, + }, + }); + worker.unref(); + const waitResult = Atomics.wait(state, 0, 0, this.timeoutMs); + if (waitResult === 'timed-out') { + void worker.terminate(); + throw new SecureStorageError('secure_storage_unavailable', 'The InFlow vault daemon did not respond.'); + } + const status = Atomics.load(state, 0); + const length = Atomics.load(state, 1); + const bytes = new Uint8Array(shared, SHARED_HEADER_BYTES, length); + if (status === 2) { + throw errorFromWorker(bytes); + } + if (status !== 1) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC worker response is malformed.'); + } + try { + const response = decodeVaultIpcFrame(bytes); + if ('method' in response) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC response is malformed.'); + } + return responseResult(response); + } finally { + bytes.fill(0); + } + } +} + +function responseResult(response: VaultIpcResponse): Record { + if (!response.ok) { + throw new SecureStorageError(codeFromResponse(response.error.code), response.error.message); + } + return response.result; +} + +function createPeerVerification( + socketPath: string, + rootDirectory: string | undefined, +): (VaultPeerVerificationConfig & { linuxBrokerPublicKeyPath?: string }) | undefined { + if (!shouldRequireVaultPeerVerification()) return undefined; + const configuration = + rootDirectory === undefined && usesLinuxVaultService() + ? createVaultPeerVerificationConfig({ + expectedUserId: linuxVaultServiceUserId(socketPath), + requireSameUser: false, + }) + : createVaultPeerVerificationConfig(); + verifyVaultPeerVerificationConfig(configuration); + return rootDirectory === undefined && usesLinuxVaultService() + ? { ...configuration, linuxBrokerPublicKeyPath: LINUX_VAULT_BROKER_PUBLIC_KEY } + : configuration; +} + +export class NoopSyncSecretReferenceManifest { + add(_reference: SecretReference): void {} + + read(): SecretReference[] { + return []; + } + + remove(_reference: SecretReference): void {} +} + +function kindForReference(reference: SecretReference): VaultSecretKind { + switch (reference.purpose) { + case 'aep-credential': + return 'aep_credential'; + case 'api-key': + return 'inflow_api_key'; + case 'auth-access-token': + return 'auth_access_token'; + case 'auth-refresh-token': + return 'auth_refresh_token'; + case 'pending-device-code': + return 'pending_device_code'; + default: + throw new SecureStorageError('secure_storage_invalid_path', 'Secret reference purpose is not vault-backed.'); + } +} + +function vaultReferenceFor(reference: SecretReference): string { + const digest = createHash('sha256').update(reference.purpose).update('\0').update(reference.reference).digest('hex'); + return `vlt_${digest.slice(0, 32)}`; +} + +function errorFromWorker(bytes: Uint8Array): SecureStorageError { + try { + const parsed = JSON.parse(Buffer.from(bytes).toString('utf8')) as unknown; + if ( + typeof parsed === 'object' && + parsed !== null && + 'code' in parsed && + 'message' in parsed && + typeof parsed.code === 'string' && + typeof parsed.message === 'string' + ) { + return new SecureStorageError(codeFromResponse(parsed.code), parsed.message); + } + } catch { + return new SecureStorageError('secure_storage_io_error', 'The InFlow vault operation failed.'); + } + return new SecureStorageError('secure_storage_io_error', 'The InFlow vault operation failed.'); +} + +function codeFromResponse(code: string): SecureStorageErrorCode { + switch (code) { + case 'secure_storage_corrupt': + case 'secure_storage_invalid_path': + case 'secure_storage_io_error': + case 'secure_storage_peer_verification_failed': + case 'secure_storage_secret_conflict': + case 'secure_storage_secret_missing': + case 'secure_storage_unavailable': + case 'vault_daemon_busy': + case 'vault_locked': + case 'vault_not_initialized': + return code; + default: + return 'secure_storage_io_error'; + } +} + +/** @internal */ +export const __testing = { + codeFromResponse, + errorFromWorker, + kindForReference, + responseResult, + vaultReferenceFor, +}; + +const WORKER_SOURCE = ` +let catchState; +let catchOutput; +function finishFromCatch() { + if (catchState === undefined || catchOutput === undefined) return; + const bytes = new TextEncoder().encode(JSON.stringify({ + code: 'secure_storage_io_error', + message: 'The InFlow vault operation failed.' + })); + catchOutput.set(bytes, 0); + Atomics.store(catchState, 1, bytes.byteLength); + Atomics.store(catchState, 0, 2); + Atomics.notify(catchState, 0, 1); +} +(async () => { +const { Buffer } = await import('node:buffer'); +const { createHash, createPublicKey, randomBytes, verify } = await import('node:crypto'); +const { execFileSync } = await import('node:child_process'); +const { lstatSync, readFileSync, realpathSync } = await import('node:fs'); +const { createRequire } = await import('node:module'); +const { resolve } = await import('node:path'); +const net = await import('node:net'); +const { workerData } = await import('node:worker_threads'); + +const HEADER_BYTES = ${SHARED_HEADER_BYTES}; +const MAX_BYTES = ${VAULT_IPC_MAX_MESSAGE_BYTES + 4}; +const state = new Int32Array(workerData.shared, 0, ${SHARED_HEADER_INTS}); +const output = new Uint8Array(workerData.shared, HEADER_BYTES); +catchState = state; +catchOutput = output; + +function finish(status, bytes) { + const payload = Buffer.from(bytes); + if (payload.byteLength > output.byteLength) { + return finish(2, Buffer.from(JSON.stringify({ + code: 'secure_storage_invalid_path', + message: 'Vault IPC message is too large.' + }), 'utf8')); + } + output.set(payload, 0); + Atomics.store(state, 1, payload.byteLength); + Atomics.store(state, 0, status); + Atomics.notify(state, 0, 1); +} + +function fail(code, message) { + finish(2, Buffer.from(JSON.stringify({ code, message }), 'utf8')); +} + +async function verifyPeer(socket) { + const config = workerData.peerVerification; + if (config === undefined) return; + const stat = lstatSync(config.nativeModulePath); + if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o022) !== 0) throw new Error('unsafe native module'); + if (config.nativeModulePath !== resolve(config.nativeModulePath)) throw new Error('unsafe native module path'); + if (config.expectedNativeModuleSha256 !== undefined) { + const actual = createHash('sha256').update(readFileSync(config.nativeModulePath)).digest('hex'); + if (actual !== config.expectedNativeModuleSha256) throw new Error('native module digest mismatch'); + } + const requirement = \`anchor apple generic and certificate leaf[subject.OU] = "\${config.expectedTeamId}"\`; + if (config.requireSignature) { + execFileSync('/usr/bin/codesign', [ + '--verify', + '--strict', + \`--test-requirement==\${requirement}\`, + config.nativeModulePath + ], { stdio: 'ignore' }); + } + const native = createRequire(process.execPath)(config.nativeModulePath); + const fd = socket._handle?.fd; + if (!Number.isSafeInteger(fd) || fd < 0) throw new Error('peer descriptor unavailable'); + if (config.linuxBrokerPublicKeyPath !== undefined) { + const peer = native.peerCredentials(fd); + if (peer.uid !== config.expectedUserId) throw new Error('peer user mismatch'); + await waitForPath(config.linuxBrokerPublicKeyPath); + const keyDirectory = resolve(config.linuxBrokerPublicKeyPath, '..'); + const directoryStat = lstatSync(keyDirectory); + const keyStat = lstatSync(config.linuxBrokerPublicKeyPath); + if ( + directoryStat.uid !== 0 || + !directoryStat.isDirectory() || + directoryStat.isSymbolicLink() || + (directoryStat.mode & 0o022) !== 0 || + realpathSync(keyDirectory) !== keyDirectory || + keyStat.uid !== 0 || + !keyStat.isFile() || + keyStat.isSymbolicLink() || + (keyStat.mode & 0o022) !== 0 || + realpathSync(config.linuxBrokerPublicKeyPath) !== config.linuxBrokerPublicKeyPath + ) throw new Error('unsafe broker public key'); + const nonce = randomBytes(32); + const challenge = Buffer.concat([Buffer.from('IFB1'), nonce]); + await new Promise((resolveWrite, rejectWrite) => { + socket.write(challenge, (cause) => cause == null ? resolveWrite() : rejectWrite(cause)); + }); + challenge.fill(0); + const response = await readExact(socket, 68); + const identity = Buffer.alloc(8); + identity.writeUInt32BE(process.pid, 0); + identity.writeUInt32BE(process.getuid(), 4); + const signed = Buffer.concat([Buffer.from('inflow-vault-broker-auth-v1\\0'), nonce, identity]); + nonce.fill(0); + identity.fill(0); + const publicKey = createPublicKey({ + format: 'der', + key: readFileSync(config.linuxBrokerPublicKeyPath), + type: 'spki' + }); + const valid = + response.subarray(0, 4).equals(Buffer.from('IFR1')) && + verify(null, signed, publicKey, response.subarray(4)); + signed.fill(0); + response.fill(0); + if (!valid) throw new Error('broker authentication failed'); + return; + } + const peer = native.peerInfo(fd); + if (typeof process.getuid !== 'function' || peer.uid !== process.getuid()) throw new Error('peer user mismatch'); + if (realpathSync(peer.path) !== config.expectedExecutablePath) throw new Error('peer executable mismatch'); + if (config.requireSignature) { + execFileSync('/usr/bin/codesign', [ + '--verify', + '--strict', + \`--test-requirement==\${requirement}\`, + peer.path + ], { stdio: 'ignore' }); + } +} + +async function waitForPath(filePath) { + const deadline = Date.now() + 2000; + while (Date.now() < deadline) { + try { + lstatSync(filePath); + return; + } catch (cause) { + if (cause?.code !== 'ENOENT') throw cause; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 25)); + } + throw new Error('broker public key unavailable'); +} + +function readExact(socket, length) { + return new Promise((resolveRead, rejectRead) => { + const chunks = []; + let total = 0; + const cleanup = () => { + socket.off('data', onData); + socket.off('end', onEnd); + socket.off('error', onError); + }; + const onData = (chunk) => { + chunks.push(chunk); + total += chunk.byteLength; + if (total < length) return; + cleanup(); + const bytes = Buffer.concat(chunks); + for (const item of chunks) item.fill(0); + if (bytes.byteLength !== length) { + bytes.fill(0); + rejectRead(new Error('broker response length mismatch')); + } else { + resolveRead(bytes); + } + }; + const onEnd = () => { + cleanup(); + rejectRead(new Error('broker response truncated')); + }; + const onError = (cause) => { + cleanup(); + rejectRead(cause); + }; + socket.on('data', onData); + socket.once('end', onEnd); + socket.once('error', onError); + }); +} + +const attachments = []; +function encodeAttachments(value) { + if (value instanceof Uint8Array) { + const index = attachments.push(value) - 1; + return { $inflowVaultAttachment: index }; + } + if (Array.isArray(value)) return value.map(encodeAttachments); + if (typeof value !== 'object' || value === null) return value; + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, encodeAttachments(item)])); +} +const json = Buffer.from(JSON.stringify(encodeAttachments(workerData.request)), 'utf8'); +const attachmentBytes = attachments.reduce( + (total, attachment) => total + 4 + attachment.byteLength * 2, + 0, +); +const bodyLength = 8 + json.byteLength + attachmentBytes; +if (bodyLength > ${VAULT_IPC_MAX_MESSAGE_BYTES}) { + fail('secure_storage_invalid_path', 'Vault IPC message is too large.'); +} else { + const frame = Buffer.alloc(4 + bodyLength); + frame.writeUInt32BE(bodyLength, 0); + frame.writeUInt32BE(json.byteLength, 4); + frame.writeUInt32BE(attachments.length, 8); + json.copy(frame, 12); + let frameOffset = 12 + json.byteLength; + for (const attachment of attachments) { + frame.writeUInt32BE(attachment.byteLength * 2, frameOffset); + frameOffset += 4; + const mask = randomBytes(attachment.byteLength); + mask.copy(frame, frameOffset); + frameOffset += attachment.byteLength; + for (let index = 0; index < attachment.byteLength; index += 1) { + frame[frameOffset + index] = (attachment[index] ?? 0) ^ (mask[index] ?? 0); + } + frameOffset += attachment.byteLength; + mask.fill(0); + } + json.fill(0); + const socket = net.createConnection(workerData.socketPath); + socket.on('error', () => { + fail('secure_storage_unavailable', 'The InFlow vault daemon is unavailable.'); + }); + const chunks = []; + let total = 0; + let settled = false; + function settle(status, bytes) { + if (settled) return; + settled = true; + socket.destroy(); + finish(status, bytes); + bytes.fill(0); + for (const chunk of chunks) chunk.fill(0); + chunks.length = 0; + } + function tryResolve() { + const buffer = Buffer.concat(chunks); + if (buffer.byteLength < 4) return; + const length = buffer.readUInt32BE(0); + if (length > ${VAULT_IPC_MAX_MESSAGE_BYTES}) { + settle(2, Buffer.from(JSON.stringify({ + code: 'secure_storage_invalid_path', + message: 'Vault IPC message is too large.' + }), 'utf8')); + return; + } + if (buffer.byteLength >= length + 4) { + settle(1, buffer.subarray(0, length + 4)); + } + } + socket.on('connect', async () => { + try { + await verifyPeer(socket); + attachResponseListeners(); + socket.write(frame, () => { + frame.fill(0); + for (const attachment of attachments) attachment.fill(0); + }); + } catch { + fail('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); + socket.destroy(); + } + }); + function attachResponseListeners() { + socket.on('data', (chunk) => { + chunks.push(chunk); + total += chunk.byteLength; + if (total > MAX_BYTES) { + fail('secure_storage_invalid_path', 'Vault IPC message is too large.'); + socket.destroy(); + return; + } + tryResolve(); + }); + socket.on('end', () => { + tryResolve(); + fail('secure_storage_corrupt', 'Vault IPC frame is truncated.'); + }); + } +} +})().catch(() => { + finishFromCatch(); +}); +`; diff --git a/packages/core/src/secure-storage/vault-tenant-manager.ts b/packages/core/src/secure-storage/vault-tenant-manager.ts new file mode 100644 index 0000000..0ecc638 --- /dev/null +++ b/packages/core/src/secure-storage/vault-tenant-manager.ts @@ -0,0 +1,101 @@ +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import type { VaultBackend } from './vault-backend.js'; +import { + LifetimeVaultBackend, + VaultBackendLifetime, + type VaultBackendLifetimeOptions, +} from './vault-backend-lifetime.js'; +import { SecureStorageError } from './errors.js'; +import { SecureSqliteRepository } from './sqlite.js'; +import { vaultFilePaths } from './vault-files.js'; +import { LocalVaultBackend } from './vault-local-backend.js'; +import type { VaultSocketPeer } from './vault-peer-verifier.js'; + +interface VaultTenantContext { + backend: LocalVaultBackend; + facade: VaultBackend; + lifetime: VaultBackendLifetime; + repository: SecureSqliteRepository; +} + +export interface MultiTenantVaultBackendManagerOptions extends VaultBackendLifetimeOptions { + rootDirectory: string; +} + +export class MultiTenantVaultBackendManager { + private readonly contexts = new Map(); + private readonly lifetimeOptions: VaultBackendLifetimeOptions; + private readonly rootDirectory: string; + + constructor(options: MultiTenantVaultBackendManagerOptions) { + this.rootDirectory = path.resolve(options.rootDirectory); + this.lifetimeOptions = { + ...(options.sleepCheckIntervalMilliseconds === undefined + ? {} + : { sleepCheckIntervalMilliseconds: options.sleepCheckIntervalMilliseconds }), + ...(options.sleepDriftThresholdMilliseconds === undefined + ? {} + : { sleepDriftThresholdMilliseconds: options.sleepDriftThresholdMilliseconds }), + }; + } + + backendForPeer(peer: VaultSocketPeer): VaultBackend { + const tenantId = tenantIdForPeer(peer); + const existing = this.contexts.get(tenantId); + if (existing !== undefined) return existing.facade; + + const paths = vaultFilePaths(path.join(this.rootDirectory, tenantId)); + const repository = new SecureSqliteRepository({ databasePath: paths.database }); + const backend = new LocalVaultBackend({ paths, repository }); + const lifetime = new VaultBackendLifetime(this.lifetimeOptions); + const contextReference: { value?: VaultTenantContext } = {}; + const facade = new LifetimeVaultBackend(backend, lifetime, () => + contextReference.value === undefined ? Promise.resolve() : this.disposeTenant(tenantId, contextReference.value), + ); + const context = { backend, facade, lifetime, repository }; + contextReference.value = context; + this.contexts.set(tenantId, context); + lifetime.expireWith(() => this.disposeTenant(tenantId, context)); + return facade; + } + + async close(): Promise { + const contexts = [...this.contexts.entries()]; + this.contexts.clear(); + await Promise.all(contexts.map(([tenantId, context]) => this.disposeTenant(tenantId, context))); + } + + lockForSleep(): Promise { + for (const context of this.contexts.values()) { + if (context.backend.getPolicy().lockOnSleep) { + context.backend.lock(); + } + } + return Promise.resolve(); + } + + private disposeTenant(tenantId: string, context: VaultTenantContext): Promise { + if (this.contexts.get(tenantId) === context) this.contexts.delete(tenantId); + context.lifetime.clear(); + try { + context.backend.lock(); + } finally { + context.repository.close(); + } + return Promise.resolve(); + } +} + +function tenantIdForPeer(peer: VaultSocketPeer): string { + if (peer.principal !== undefined) { + if (!/^S-\d+(?:-\d+)+$/u.test(peer.principal)) { + throw new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); + } + return createHash('sha256').update(`windows-sid:${peer.principal}`).digest('hex'); + } + if (!Number.isSafeInteger(peer.uid) || peer.uid < 0) { + throw new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); + } + return createHash('sha256').update(`unix-uid:${peer.uid}`).digest('hex'); +} diff --git a/packages/core/src/secure-storage/vault-types.ts b/packages/core/src/secure-storage/vault-types.ts new file mode 100644 index 0000000..295d927 --- /dev/null +++ b/packages/core/src/secure-storage/vault-types.ts @@ -0,0 +1,78 @@ +import { randomUUID } from 'node:crypto'; +import { SecureStorageError } from './errors.js'; + +export type VaultSecretKind = + 'auth_access_token' | 'auth_refresh_token' | 'inflow_api_key' | 'pending_device_code' | 'aep_credential'; + +export type VaultRecordStatus = 'active' | 'pending' | 'deleting'; + +export interface VaultSecretReference { + reference: string; +} + +const VAULT_REFERENCE_PREFIX = 'vlt_'; + +const SECRET_KIND_CODES = { + auth_access_token: 1, + auth_refresh_token: 2, + inflow_api_key: 3, + pending_device_code: 4, + aep_credential: 5, +} as const satisfies Record; + +const SECRET_KIND_NAMES = new Map( + Object.entries(SECRET_KIND_CODES).map(([name, code]) => [code, name as VaultSecretKind]), +); + +const RECORD_STATUS_CODES = { + active: 1, + pending: 2, + deleting: 3, +} as const satisfies Record; + +const RECORD_STATUS_NAMES = new Map( + Object.entries(RECORD_STATUS_CODES).map(([name, code]) => [code, name as VaultRecordStatus]), +); + +export function createVaultSecretReference(): VaultSecretReference { + return { reference: `${VAULT_REFERENCE_PREFIX}${randomUUID().replaceAll('-', '')}` }; +} + +export function parseVaultSecretReference(reference: string): VaultSecretReference { + if (!reference.startsWith(VAULT_REFERENCE_PREFIX) || reference.length !== VAULT_REFERENCE_PREFIX.length + 32) { + throw new SecureStorageError('secure_storage_invalid_path', 'Vault secret reference is malformed.'); + } + const suffix = reference.slice(VAULT_REFERENCE_PREFIX.length); + if (!/^[0-9a-f]{32}$/.test(suffix)) { + throw new SecureStorageError('secure_storage_invalid_path', 'Vault secret reference is malformed.'); + } + return { reference }; +} + +export function vaultSecretKindCode(kind: VaultSecretKind): number { + return SECRET_KIND_CODES[kind]; +} + +export function vaultSecretKindName(code: number): VaultSecretKind { + const kind = SECRET_KIND_NAMES.get(code); + if (kind === undefined) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault secret kind is unknown.'); + } + return kind; +} + +export function isVaultSecretKind(value: unknown): value is VaultSecretKind { + return typeof value === 'string' && value in SECRET_KIND_CODES; +} + +export function vaultRecordStatusCode(status: VaultRecordStatus): number { + return RECORD_STATUS_CODES[status]; +} + +export function vaultRecordStatusName(code: number): VaultRecordStatus { + const status = RECORD_STATUS_NAMES.get(code); + if (status === undefined) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault record status is unknown.'); + } + return status; +} diff --git a/packages/core/src/secure-storage/vault-windows-service.ts b/packages/core/src/secure-storage/vault-windows-service.ts new file mode 100644 index 0000000..dddc20f --- /dev/null +++ b/packages/core/src/secure-storage/vault-windows-service.ts @@ -0,0 +1,210 @@ +import { Buffer } from 'node:buffer'; +import { once } from 'node:events'; +import path from 'node:path'; +import process from 'node:process'; +import { parentPort, Worker } from 'node:worker_threads'; +import { setTimeout as delay } from 'node:timers/promises'; +import { SecureStorageError } from './errors.js'; +import { handleVaultIpcRequest } from './vault-daemon-handler.js'; +import { clearVaultIpcBytes, decodeVaultIpcFrame, encodeVaultIpcMessage, type VaultIpcResponse } from './vault-ipc.js'; +import { hardenVaultDaemonProcess } from './vault-protected-key.js'; +import { MultiTenantVaultBackendManager } from './vault-tenant-manager.js'; +import { createPackagedWindowsVaultTransport } from './vault-windows-transport.js'; + +export interface WindowsVaultWorkerData { + buildId: string | null; + cliVersion: string | null; + role: 'pipe' | 'runtime'; +} + +export interface WindowsVaultServiceOptions { + buildId?: string; + cliVersion?: string; +} + +export function isWindowsVaultWorkerData(value: unknown): value is WindowsVaultWorkerData { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as Partial; + return ( + (candidate.role === 'pipe' || candidate.role === 'runtime') && + (candidate.buildId === null || typeof candidate.buildId === 'string') && + (candidate.cliVersion === null || typeof candidate.cliVersion === 'string') + ); +} + +export async function runWindowsVaultWorker(data: WindowsVaultWorkerData, entryUrl: URL): Promise { + if (data.role === 'pipe') { + await runPipeWorker(); + return; + } + await runRuntimeWorker(data, entryUrl); +} + +export async function runWindowsVaultService(entryUrl: URL, options: WindowsVaultServiceOptions = {}): Promise { + if (process.platform !== 'win32') throw new Error('The Windows vault service is available only on Windows.'); + const data: WindowsVaultWorkerData = { + buildId: options.buildId ?? null, + cliVersion: options.cliVersion ?? null, + role: 'runtime', + }; + const runtime = new Worker(entryUrl, { workerData: data }); + const transport = createPackagedWindowsVaultTransport(); + const runtimeExit = once(runtime, 'exit'); + try { + transport.runServiceDispatcher(); + await runtimeExit; + } finally { + await runtime.terminate(); + } +} + +async function runPipeWorker(): Promise { + if (parentPort === null) throw new Error('The Windows vault pipe worker requires a parent port.'); + const port = parentPort; + const transport = createPackagedWindowsVaultTransport(); + port.postMessage({ type: 'ready' }); + for (;;) { + try { + const connection = transport.accept('\\\\.\\pipe\\InFlowVault'); + const frame = transport.read(connection); + const transferredFrame = Uint8Array.from(frame); + frame.fill(0); + port.postMessage({ frame: transferredFrame, peer: connection.peer, type: 'request' }, [transferredFrame.buffer]); + const message = await new Promise((resolve) => { + port.once('message', resolve); + }); + if (!isResponseMessage(message)) { + transport.close(connection); + continue; + } + const response = Buffer.from(message.frame.buffer, message.frame.byteOffset, message.frame.byteLength); + try { + transport.write(connection, response); + } finally { + response.fill(0); + } + } catch { + if (transport.serviceControlState().stopRequested) return; + await delay(100); + } + } +} + +async function runRuntimeWorker(data: WindowsVaultWorkerData, entryUrl: URL): Promise { + if (parentPort === null) throw new Error('The Windows vault runtime worker requires a parent port.'); + const programData = process.env['ProgramData'] ?? 'C:\\ProgramData'; + const manager = new MultiTenantVaultBackendManager({ + rootDirectory: path.join(programData, 'InFlow', 'vaults'), + }); + const transport = createPackagedWindowsVaultTransport(); + const pipe = new Worker(entryUrl, { workerData: { ...data, role: 'pipe' } satisfies WindowsVaultWorkerData }); + let ready = false; + pipe.on('message', (message: unknown) => { + if (isReadyMessage(message)) { + if (!ready) { + ready = true; + transport.markServiceReady(); + } + return; + } + if (isRequestMessage(message)) void handlePipeRequest(pipe, manager, data, message); + }); + hardenVaultDaemonProcess(); + try { + for (;;) { + await delay(100); + const control = transport.serviceControlState(); + if (control.lockRequested) await manager.lockForSleep(); + if (control.stopRequested) break; + } + } finally { + await pipe.terminate(); + await manager.close(); + transport.completeServiceStop(); + } +} + +async function handlePipeRequest( + pipe: Worker, + manager: MultiTenantVaultBackendManager, + data: WindowsVaultWorkerData, + message: { frame: Uint8Array; peer: { path: string; pid: number; principal: string; uid: number } }, +): Promise { + const frame = Buffer.from(message.frame.buffer, message.frame.byteOffset, message.frame.byteLength); + let responseFrame: Buffer | undefined; + let response: VaultIpcResponse | undefined; + let requestId = 'unknown'; + try { + const request = decodeVaultIpcFrame(frame); + if (!('method' in request)) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC request is malformed.'); + } + requestId = request.id; + response = await handleVaultIpcRequest( + manager.backendForPeer(message.peer), + request, + { + buildId: data.buildId, + cliVersion: data.cliVersion, + executablePath: process.execPath, + pid: process.pid, + }, + { allowDaemonShutdown: false }, + ); + clearVaultIpcBytes(request); + responseFrame = encodeVaultIpcMessage(response); + } catch (cause) { + responseFrame = encodeVaultIpcMessage({ + error: { + code: cause instanceof SecureStorageError ? cause.secureStorageCode : 'secure_storage_io_error', + message: cause instanceof SecureStorageError ? cause.message : 'The InFlow vault operation failed.', + }, + id: requestId, + ok: false, + version: 1, + }); + } finally { + frame.fill(0); + clearVaultIpcBytes(response); + } + const transferredFrame = Uint8Array.from(responseFrame); + responseFrame.fill(0); + pipe.postMessage({ frame: transferredFrame, type: 'response' }, [transferredFrame.buffer]); +} + +function isReadyMessage(value: unknown): value is { type: 'ready' } { + return typeof value === 'object' && value !== null && 'type' in value && value.type === 'ready'; +} + +function isRequestMessage(value: unknown): value is { + frame: Uint8Array; + peer: { path: string; pid: number; principal: string; uid: number }; + type: 'request'; +} { + if (typeof value !== 'object' || value === null || !('type' in value) || value.type !== 'request') return false; + if (!('frame' in value) || !(value.frame instanceof Uint8Array) || !('peer' in value)) return false; + const peer = value.peer; + return ( + typeof peer === 'object' && + peer !== null && + 'path' in peer && + typeof peer.path === 'string' && + 'pid' in peer && + Number.isSafeInteger(peer.pid) && + 'principal' in peer && + typeof peer.principal === 'string' && + 'uid' in peer && + Number.isSafeInteger(peer.uid) + ); +} + +function isResponseMessage(value: unknown): value is { frame: Uint8Array; type: 'response' } { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + value.type === 'response' && + 'frame' in value && + value.frame instanceof Uint8Array + ); +} diff --git a/packages/core/src/secure-storage/vault-windows-transport.ts b/packages/core/src/secure-storage/vault-windows-transport.ts new file mode 100644 index 0000000..62322cc --- /dev/null +++ b/packages/core/src/secure-storage/vault-windows-transport.ts @@ -0,0 +1,193 @@ +import type { Buffer } from 'node:buffer'; +import { realpathSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import process from 'node:process'; +import { SecureStorageError } from './errors.js'; +import { runtimeRequire } from './runtime-require.js'; +import { + decodeVaultIpcFrame, + encodeVaultIpcMessage, + type VaultIpcRequest, + type VaultIpcResponse, +} from './vault-ipc.js'; +import { verifyVaultNativeModule, type VaultSocketPeer } from './vault-peer-verifier.js'; + +declare const __VAULT_PEER_NATIVE_SHA256__: string | undefined; + +const verifiedConnection = Symbol('verifiedWindowsVaultConnection'); + +interface WindowsVaultPipeConnection { + readonly connection: unknown; +} + +interface WindowsVaultNativeModule { + acceptPipeConnection(path: string): { connection: unknown; peer: VaultSocketPeer }; + beginPipeSession(connection: unknown): void; + closePipeConnection(connection: unknown): void; + connectPipe(path: string): { connection: unknown; peer: VaultSocketPeer }; + exchangePipeRequest(connection: unknown, frame: Buffer): Buffer; + readPipeRequest(connection: unknown): Buffer; + runServiceDispatcher(): void; + markServiceReady(): void; + serviceControlState(): { lockRequested: boolean; stopRequested: boolean }; + completeServiceStop(): void; + verifyAuthenticode(path: string): { publisher: string; thumbprint: string }; + writePipeResponse(connection: unknown, frame: Buffer): void; +} + +export interface WindowsVaultTransportOptions { + expectedExecutablePath: string; + expectedNativeModuleSha256: string; + expectedPublisher?: string; + nativeModulePath: string; +} + +export interface VerifiedWindowsVaultConnection extends WindowsVaultPipeConnection { + readonly peer: VaultSocketPeer & { principal: string }; + readonly [verifiedConnection]: true; +} + +export class WindowsVaultTransport { + private readonly expectedExecutablePath: string; + private readonly expectedPublisher: string; + private readonly native: WindowsVaultNativeModule; + + constructor(options: WindowsVaultTransportOptions) { + verifyVaultNativeModule(options.nativeModulePath, { + expectedSha256: options.expectedNativeModuleSha256, + expectedTeamId: '', + requireSignature: false, + }); + this.native = runtimeRequire()(options.nativeModulePath) as WindowsVaultNativeModule; + this.expectedExecutablePath = canonicalWindowsPath(options.expectedExecutablePath); + const selfSigner = this.native.verifyAuthenticode(this.expectedExecutablePath); + if (!/^[0-9a-f]{64}$/u.test(selfSigner.thumbprint)) throw peerVerificationError(); + this.expectedPublisher = options.expectedPublisher ?? selfSigner.publisher; + } + + accept(path: string): VerifiedWindowsVaultConnection { + return this.verify(this.native.acceptPipeConnection(path)); + } + + connect(path: string): VerifiedWindowsVaultConnection { + const connection = this.verify(this.native.connectPipe(path)); + try { + this.native.beginPipeSession(connection.connection); + return connection; + } catch (cause) { + this.native.closePipeConnection(connection.connection); + throw cause; + } + } + + close(connection: VerifiedWindowsVaultConnection): void { + this.native.closePipeConnection(connection.connection); + } + + exchange(connection: VerifiedWindowsVaultConnection, frame: Buffer): Buffer { + return this.native.exchangePipeRequest(connection.connection, frame); + } + + read(connection: VerifiedWindowsVaultConnection): Buffer { + return this.native.readPipeRequest(connection.connection); + } + + write(connection: VerifiedWindowsVaultConnection, frame: Buffer): void { + this.native.writePipeResponse(connection.connection, frame); + } + + completeServiceStop(): void { + this.native.completeServiceStop(); + } + + markServiceReady(): void { + this.native.markServiceReady(); + } + + runServiceDispatcher(): void { + this.native.runServiceDispatcher(); + } + + serviceControlState(): { lockRequested: boolean; stopRequested: boolean } { + return this.native.serviceControlState(); + } + + private verify(candidate: { connection: unknown; peer: VaultSocketPeer }): VerifiedWindowsVaultConnection { + try { + const { peer } = candidate; + if ( + peer.principal === undefined || + !/^S-\d+(?:-\d+)+$/u.test(peer.principal) || + canonicalWindowsPath(peer.path) !== this.expectedExecutablePath + ) { + throw peerVerificationError(); + } + const signer = this.native.verifyAuthenticode(peer.path); + if (signer.publisher !== this.expectedPublisher || !/^[0-9a-f]{64}$/u.test(signer.thumbprint)) { + throw peerVerificationError(); + } + return { + connection: candidate.connection, + peer: { ...peer, principal: peer.principal }, + [verifiedConnection]: true, + }; + } catch { + this.native.closePipeConnection(candidate.connection); + throw peerVerificationError(); + } + } +} + +export function createPackagedWindowsVaultTransport(): WindowsVaultTransport { + if (process.platform !== 'win32' || typeof __VAULT_PEER_NATIVE_SHA256__ !== 'string') { + throw new SecureStorageError('secure_storage_unavailable', 'Windows vault transport is unavailable.'); + } + return new WindowsVaultTransport({ + expectedExecutablePath: process.execPath, + expectedNativeModuleSha256: __VAULT_PEER_NATIVE_SHA256__, + nativeModulePath: resolve(dirname(process.execPath), 'native', 'vault_peer_windows.node'), + }); +} + +export function sendWindowsVaultIpcRequest(path: string, request: VaultIpcRequest): VaultIpcResponse { + return sendWindowsVaultIpcRequestWithTransport(createPackagedWindowsVaultTransport(), path, request); +} + +/** @internal */ +export function sendWindowsVaultIpcRequestWithTransport( + transport: { + close(connection: Connection): void; + connect(path: string): Connection; + exchange(connection: Connection, frame: Buffer): Buffer; + }, + path: string, + request: VaultIpcRequest, +): VaultIpcResponse { + const connection = transport.connect(path); + const requestFrame = encodeVaultIpcMessage(request); + let responseFrame: Buffer | undefined; + try { + responseFrame = transport.exchange(connection, requestFrame); + const response = decodeVaultIpcFrame(responseFrame); + if (!('ok' in response) || response.id !== request.id) { + throw new SecureStorageError('secure_storage_corrupt', 'Vault IPC response is malformed.'); + } + return response; + } finally { + requestFrame.fill(0); + responseFrame?.fill(0); + transport.close(connection); + } +} + +function canonicalWindowsPath(path: string): string { + try { + return realpathSync.native(path).toLowerCase(); + } catch { + throw peerVerificationError(); + } +} + +function peerVerificationError(): SecureStorageError { + return new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); +} diff --git a/packages/core/src/utils/storage.ts b/packages/core/src/utils/storage.ts index 7c4df2f..315473e 100644 --- a/packages/core/src/utils/storage.ts +++ b/packages/core/src/utils/storage.ts @@ -5,22 +5,22 @@ import path from 'node:path'; import { InflowConfigurationError } from '../errors.js'; import type { AuthTokens } from '../types/index.js'; import type { + AepCredentialDeleteSelector, AepOwner, AepPersistedInspectResult, AepPersistedState, AepStateStorage, PublicDocumentStateStorage, } from '../aep/storage.js'; -import type { AepPublicDocumentCacheRecord } from '@aep-foundation/agent'; +import type { AepPublicDocumentCacheRecord, AgentServiceIdentity } from '@aep-foundation/agent'; import { - SyncKeychainReferenceManifest, - SyncKeychainSecretStore, createOpaqueSecretReference, type SecretReference, type SyncSecureSecretStore, -} from '../secure-storage/keychain.js'; +} from '../secure-storage/secret-store.js'; import { SyncSecureSecretLifecycleCoordinator } from '../secure-storage/lifecycle.js'; import { SecureSqliteRepository, type StoredPublicDocument } from '../secure-storage/sqlite.js'; +import { NoopSyncSecretReferenceManifest, SyncVaultSecretStore } from '../secure-storage/vault-sync-secret-store.js'; export interface PendingDeviceAuth { device_code: string; @@ -163,11 +163,11 @@ export class Storage implements AuthStorage, AepStateStorage, PublicDocumentStat this.options = options; const databasePath = this.databasePath(); this.repository = new SecureSqliteRepository(databasePath === undefined ? {} : { databasePath }); - this.secretStore = options.secretStore ?? new SyncKeychainSecretStore(); + this.secretStore = options.secretStore ?? defaultSecretStore(); this.lifecycle = new SyncSecureSecretLifecycleCoordinator( this.repository, this.secretStore, - new SyncKeychainReferenceManifest(this.secretStore), + new NoopSyncSecretReferenceManifest(), ); } @@ -188,12 +188,12 @@ export class Storage implements AuthStorage, AepStateStorage, PublicDocumentStat setAuth(auth: AuthTokens): void { this.initialize(); const persisted = withComputedExpiry(auth); - this.repository.writeTransactionSync(() => { - this.clearAuth(); - const accessToken = createOpaqueSecretReference('auth-access-token'); - const refreshToken = createOpaqueSecretReference('auth-refresh-token'); - this.lifecycle.create(accessToken, utf8(persisted.access_token), null, { setting: AUTH_SETTING }); - this.lifecycle.create(refreshToken, utf8(persisted.refresh_token), null, { setting: AUTH_SETTING }); + this.clearAuth(); + const accessToken = createOpaqueSecretReference('auth-access-token'); + const refreshToken = createOpaqueSecretReference('auth-refresh-token'); + this.lifecycle.create(accessToken, utf8(persisted.access_token), null, { setting: AUTH_SETTING }); + this.lifecycle.create(refreshToken, utf8(persisted.refresh_token), null, { setting: AUTH_SETTING }); + try { this.repository.upsertSetting(AUTH_SETTING, { accessToken, refreshToken, @@ -202,7 +202,11 @@ export class Storage implements AuthStorage, AepStateStorage, PublicDocumentStat ...(persisted.scope === undefined ? {} : { scope: persisted.scope }), ...(persisted.expires_at === undefined ? {} : { expiresAt: persisted.expires_at }), } satisfies StoredAuth); - }); + } catch (cause) { + this.lifecycle.delete(accessToken); + this.lifecycle.delete(refreshToken); + throw cause; + } } clearAuth(): void { @@ -216,7 +220,11 @@ export class Storage implements AuthStorage, AepStateStorage, PublicDocumentStat } isAuthenticated(): boolean { - return this.getAuth() !== null || this.getApiKey() !== null; + this.initialize(); + return ( + isStoredAuth(this.repository.getSetting(AUTH_SETTING)?.payload) || + isStoredSecret(this.repository.getSetting(API_KEY_SETTING)?.payload) + ); } getApiKey(): string | null { @@ -231,12 +239,15 @@ export class Storage implements AuthStorage, AepStateStorage, PublicDocumentStat throw new InflowConfigurationError('Storage.setApiKey: api key must be a non-empty string.'); } this.initialize(); - this.repository.writeTransactionSync(() => { - this.clearApiKey(); - const secret = createOpaqueSecretReference('api-key'); - this.lifecycle.create(secret, utf8(apiKey), null, { setting: API_KEY_SETTING }); + this.clearApiKey(); + const secret = createOpaqueSecretReference('api-key'); + this.lifecycle.create(secret, utf8(apiKey), null, { setting: API_KEY_SETTING }); + try { this.repository.upsertSetting(API_KEY_SETTING, { secret } satisfies StoredSecret); - }); + } catch (cause) { + this.lifecycle.delete(secret); + throw cause; + } } clearApiKey(): void { @@ -283,10 +294,10 @@ export class Storage implements AuthStorage, AepStateStorage, PublicDocumentStat setPendingDeviceAuth(pending: PendingDeviceAuth): void { this.initialize(); - this.repository.writeTransactionSync(() => { - this.clearPendingDeviceAuth(); - const deviceCode = createOpaqueSecretReference('pending-device-code'); - this.lifecycle.create(deviceCode, utf8(pending.device_code), null, { setting: PENDING_AUTH_SETTING }); + this.clearPendingDeviceAuth(); + const deviceCode = createOpaqueSecretReference('pending-device-code'); + this.lifecycle.create(deviceCode, utf8(pending.device_code), null, { setting: PENDING_AUTH_SETTING }); + try { this.repository.upsertSetting(PENDING_AUTH_SETTING, { deviceCode, interval: pending.interval, @@ -294,7 +305,10 @@ export class Storage implements AuthStorage, AepStateStorage, PublicDocumentStat verificationUrl: pending.verification_url, phrase: pending.phrase, } satisfies StoredPendingDeviceAuth); - }); + } catch (cause) { + this.lifecycle.delete(deviceCode); + throw cause; + } } clearPendingDeviceAuth(): void { @@ -327,6 +341,40 @@ export class Storage implements AuthStorage, AepStateStorage, PublicDocumentStat this.repository.deleteSetting(AEP_STATE_SETTING); } + deleteAepCredentials(owner: AepOwner, serviceDid: string, selector: AepCredentialDeleteSelector): void { + this.initialize(); + const stored = this.repository.getSetting(AEP_STATE_SETTING)?.payload; + if (!isStoredAepState(stored)) return; + if (!sameAepOwner(stored.owner, owner)) { + this.clearAepState(); + return; + } + const records = stored.credentials[serviceDid]; + if (records === undefined) return; + for (const [credentialId, record] of Object.entries(records)) { + if (matchesStoredAepCredential(record, selector)) { + this.lifecycle.delete(record.credentialSecret); + delete records[credentialId]; + } + } + if (Object.keys(records).length === 0) { + delete stored.credentials[serviceDid]; + } + this.repository.upsertSetting(AEP_STATE_SETTING, stored); + } + + findAepIdentity(owner: AepOwner, serviceDid: string): AgentServiceIdentity | undefined { + this.initialize(); + const stored = this.repository.getSetting(AEP_STATE_SETTING)?.payload; + if (!isStoredAepState(stored)) return undefined; + if (!sameAepOwner(stored.owner, owner)) { + this.clearAepState(); + return undefined; + } + const identity = stored.identities[serviceDid]; + return identity === undefined ? undefined : structuredClone(identity); + } + getAepState(): AepPersistedState | null { this.initialize(); const stored = this.repository.getSetting(AEP_STATE_SETTING)?.payload; @@ -358,9 +406,10 @@ export class Storage implements AuthStorage, AepStateStorage, PublicDocumentStat setAepState(state: AepPersistedState): void { this.initialize(); - this.repository.writeTransactionSync(() => { - this.clearAepState(); - const credentials: StoredAepState['credentials'] = {}; + this.clearAepState(); + const created: SecretReference[] = []; + const credentials: StoredAepState['credentials'] = {}; + try { for (const [serviceDid, serviceRecords] of Object.entries(state.credentials)) { const byCredentialId: Record = {}; for (const [credentialId, record] of Object.entries(serviceRecords)) { @@ -369,6 +418,7 @@ export class Storage implements AuthStorage, AepStateStorage, PublicDocumentStat credentialId, serviceDid, }); + created.push(credentialSecret); byCredentialId[credentialId] = { credentialSecret, credentialId: record.credentialId, @@ -388,7 +438,12 @@ export class Storage implements AuthStorage, AepStateStorage, PublicDocumentStat version: 1, ...(state.inspect === undefined ? {} : { inspect: structuredClone(state.inspect) }), } satisfies StoredAepState); - }); + } catch (cause) { + for (const reference of created) { + this.lifecycle.delete(reference); + } + throw cause; + } } getDiscoveryDocuments(): AepPublicDocumentCacheRecord[] { @@ -592,6 +647,10 @@ function readUtf8(store: SyncSecureSecretStore, reference: SecretReference): str return Buffer.from(store.read(reference)).toString('utf8'); } +function defaultSecretStore(): SyncSecureSecretStore { + return new SyncVaultSecretStore(); +} + function publicDocumentToCacheRecord(record: StoredPublicDocument): AepPublicDocumentCacheRecord { return { cachedAt: record.cachedAt, @@ -702,4 +761,16 @@ function isStoredAepState(value: unknown): value is StoredAepState { return isRecord(value['identities']); } +function sameAepOwner(left: AepOwner, right: AepOwner): boolean { + return left.platformOrigin === right.platformOrigin && left.userId === right.userId; +} + +function matchesStoredAepCredential(record: StoredAepCredentialRecord, selector: AepCredentialDeleteSelector): boolean { + return ( + 'allGrantTypes' in selector || + ('credentialId' in selector && record.credentialId === selector.credentialId) || + ('grantType' in selector && record.grantType === selector.grantType) + ); +} + export const storage = new Storage(); diff --git a/packages/core/test/unit/aep/storage.test.ts b/packages/core/test/unit/aep/storage.test.ts index 23b9ddc..3c13886 100644 --- a/packages/core/test/unit/aep/storage.test.ts +++ b/packages/core/test/unit/aep/storage.test.ts @@ -3,8 +3,16 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { discoverPlatform, fetchAepPublicDocument, type CachedInspectServiceResult } from '@aep-foundation/agent'; -import { AepStorage, createAepPublicDocumentCache, MemoryStorage, runAuthLogout, Storage } from '../../../src/index.js'; -import { SyncMemorySecretStore } from '../../../src/secure-storage/keychain.js'; +import { + AepStorage, + createAepPublicDocumentCache, + MemoryStorage, + runAuthLogout, + Storage, + type AepPersistedState, + type AepStateStorage, +} from '../../../src/index.js'; +import { SyncMemorySecretStore } from '../../../src/secure-storage/secret-store.js'; const OWNER = { platformOrigin: 'https://api.example.test', userId: 'user-1' }; const IDENTITY = { @@ -130,8 +138,69 @@ describe('AepStorage', () => { expect(await second.identities().findByServiceDid(IDENTITY.serviceDid)).toBeUndefined(); expect(backing.getAepState()?.owner.userId).toBe('user-2'); }); + + it('delegates identity lookup and credential deletion to secure storage capabilities', async () => { + const deleteAepCredentials = vi.fn(); + const findAepIdentity = vi.fn(() => IDENTITY); + const storage: AepStateStorage = { + clearAepState: vi.fn(), + deleteAepCredentials, + findAepIdentity, + getAepState: vi.fn(() => null), + setAepState: vi.fn(), + }; + const aep = new AepStorage(storage, OWNER); + + expect(await aep.identities().findByServiceDid(IDENTITY.serviceDid)).toEqual(IDENTITY); + await aep.credentials().deleteCredential(IDENTITY.serviceDid, 'credential-1'); + aep.deleteCredentials(IDENTITY.serviceDid, { grantType: 'api-key' }); + + expect(findAepIdentity).toHaveBeenCalledWith(OWNER, IDENTITY.serviceDid); + expect(deleteAepCredentials).toHaveBeenNthCalledWith(1, OWNER, IDENTITY.serviceDid, { + credentialId: 'credential-1', + }); + expect(deleteAepCredentials).toHaveBeenNthCalledWith(2, OWNER, IDENTITY.serviceDid, { grantType: 'api-key' }); + }); + + it('deletes fallback credentials by identifier, grant type, and all grant types', () => { + const state: AepPersistedState = { + credentials: { + [IDENTITY.serviceDid]: { + first: credential('first', 'api-key'), + second: credential('second', 'oauth-bearer'), + third: credential('third', 'api-key'), + }, + }, + identities: {}, + owner: OWNER, + version: 1, + }; + const backing = new MemoryStorage(); + backing.setAepState(state); + const aep = new AepStorage(backing, OWNER); + + aep.deleteCredentials(IDENTITY.serviceDid, { credentialId: 'first' }); + expect(Object.keys(backing.getAepState()?.credentials[IDENTITY.serviceDid] ?? {})).toEqual(['second', 'third']); + + aep.deleteCredentials(IDENTITY.serviceDid, { grantType: 'api-key' }); + expect(Object.keys(backing.getAepState()?.credentials[IDENTITY.serviceDid] ?? {})).toEqual(['second']); + + aep.deleteCredentials(IDENTITY.serviceDid, { allGrantTypes: true }); + expect(backing.getAepState()?.credentials[IDENTITY.serviceDid]).toBeUndefined(); + aep.deleteCredentials('did:web:missing.example', { allGrantTypes: true }); + }); }); +function credential(credentialId: string, grantType: string) { + return { + credential: { credential_id: credentialId }, + credentialId, + grantType, + issuedAt: '2026-01-01T00:00:00.000Z', + serviceDid: IDENTITY.serviceDid, + }; +} + describe('AEP public document cache', () => { let tmpDir: string; diff --git a/packages/core/test/unit/auth/poll.test.ts b/packages/core/test/unit/auth/poll.test.ts index e32eee8..efdd344 100644 --- a/packages/core/test/unit/auth/poll.test.ts +++ b/packages/core/test/unit/auth/poll.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { pollAuthStatus } from '../../../src/auth/poll.js'; +import { __testing, pollAuthStatus } from '../../../src/auth/poll.js'; +import { SecureStorageError } from '../../../src/secure-storage/errors.js'; import type { IAuthResource } from '../../../src/resources/interfaces.js'; import type { AuthTokens } from '../../../src/types/index.js'; import { MemoryStorage } from '../../../src/utils/storage.js'; @@ -36,6 +37,24 @@ async function drain(gen: AsyncGenerator): Promise { } describe('pollAuthStatus', () => { + it('uses a fallback only for an exact missing-vault-secret error', () => { + expect( + __testing.readStorageValue(() => { + throw new SecureStorageError('secure_storage_secret_missing', 'A referenced vault secret is missing.'); + }, null), + ).toBeNull(); + expect(() => + __testing.readStorageValue(() => { + throw new SecureStorageError('secure_storage_secret_missing', 'A different secret is missing.'); + }, null), + ).toThrow('A different secret is missing.'); + expect(() => + __testing.readStorageValue(() => { + throw new Error('storage failed'); + }, null), + ).toThrow('storage failed'); + }); + it('yields authenticated:true once the in-flight device flow succeeds', async () => { const storage = new MemoryStorage(); storage.setPendingDeviceAuth({ diff --git a/packages/core/test/unit/secure-storage/keychain.test.ts b/packages/core/test/unit/secure-storage/keychain.test.ts deleted file mode 100644 index 598b53e..0000000 --- a/packages/core/test/unit/secure-storage/keychain.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { Buffer } from 'node:buffer'; -import { describe, expect, it } from 'vitest'; -import { - KeychainReferenceManifest, - KeychainSecretStore, - MemorySecretStore, - SyncKeychainReferenceManifest, - SyncKeychainSecretStore, - SyncMemorySecretStore, - createOpaqueSecretReference, - type SecretReference, -} from '../../../src/secure-storage/keychain.js'; -import { SecureStorageError } from '../../../src/secure-storage/errors.js'; - -class FakeAsyncEntry { - static readonly values = new Map(); - static readonly accounts: string[] = []; - - constructor( - private readonly service: string, - private readonly account: string, - ) { - FakeAsyncEntry.accounts.push(`${service}/${account}`); - } - - deletePassword(): Promise { - return Promise.resolve(FakeAsyncEntry.values.delete(`${this.service}/${this.account}`)); - } - - getPassword(): Promise { - return Promise.resolve(FakeAsyncEntry.values.get(`${this.service}/${this.account}`)); - } - - setPassword(password: string | Buffer | Uint8Array): Promise { - FakeAsyncEntry.values.set(`${this.service}/${this.account}`, Buffer.from(password).toString('utf8')); - return Promise.resolve(); - } -} - -class FakeEntry { - constructor( - private readonly service: string, - private readonly account: string, - ) {} - - deleteCredential(): boolean { - return FakeAsyncEntry.values.delete(`${this.service}/${this.account}`); - } - - getSecret(): Uint8Array | null { - const value = FakeAsyncEntry.values.get(`${this.service}/${this.account}`); - return value === undefined ? null : Buffer.from(value, 'utf8'); - } - - setSecret(secret: Uint8Array): void { - FakeAsyncEntry.values.set(`${this.service}/${this.account}`, Buffer.from(secret).toString('utf8')); - } -} - -class BinaryAsyncEntry { - deletePassword(): Promise { - return Promise.resolve(true); - } - - getPassword(): Promise { - return Promise.resolve(Uint8Array.from([1, 2, 3])); - } - - setPassword(): Promise { - return Promise.resolve(); - } -} - -class FailingAsyncEntry { - deletePassword(): Promise { - return Promise.reject(new Error('delete failed')); - } - - getPassword(): Promise { - return Promise.reject(new Error('read failed')); - } - - setPassword(): Promise { - return Promise.reject(new Error('write failed')); - } -} - -class FailingEntry { - deleteCredential(): boolean { - throw new Error('delete failed'); - } - - getSecret(): null { - throw new Error('read failed'); - } - - setSecret(): void { - throw new Error('write failed'); - } -} - -class FailingAsyncManifestDeleteStore extends MemorySecretStore { - override delete(_reference: SecretReference): Promise { - return Promise.reject(new Error('manifest delete failed')); - } -} - -class FailingSyncManifestDeleteStore extends SyncMemorySecretStore { - override delete(_reference: SecretReference): void { - throw new Error('manifest delete failed'); - } -} - -describe('KeychainSecretStore', () => { - it('reads and deletes exact opaque references without exposing a search surface', async () => { - FakeAsyncEntry.values.clear(); - FakeAsyncEntry.accounts.length = 0; - const store = new KeychainSecretStore({ - loadKeyring: () => ({ AsyncEntry: FakeAsyncEntry, Entry: FakeEntry }), - serviceName: 'ai.inflowpay.cli.test', - }); - const reference = { purpose: 'api-key', reference: 'opaque-reference' }; - - await store.create(reference, Buffer.from('secret-value', 'utf8')); - - expect(Buffer.from(await store.read(reference)).toString('utf8')).toBe('secret-value'); - expect(Object.keys(store)).not.toContain('find'); - expect(FakeAsyncEntry.accounts).toEqual([ - 'ai.inflowpay.cli.test/api-key:opaque-reference', - 'ai.inflowpay.cli.test/api-key:opaque-reference', - ]); - - await store.delete(reference); - await expect(store.read(reference)).rejects.toMatchObject({ secureStorageCode: 'secure_storage_secret_missing' }); - }); - - it('rejects empty references before touching the backend', async () => { - const store = new KeychainSecretStore({ loadKeyring: () => ({ AsyncEntry: FakeAsyncEntry, Entry: FakeEntry }) }); - - await expect(store.create({ purpose: 'api-key', reference: '' }, Buffer.from('x'))).rejects.toBeInstanceOf( - SecureStorageError, - ); - }); - - it('creates opaque references with the requested purpose', () => { - const reference = createOpaqueSecretReference('refresh-token'); - - expect(reference.purpose).toBe('refresh-token'); - expect(reference.reference).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); - }); - - it('accepts binary Keychain values without base64 decoding', async () => { - const store = new KeychainSecretStore({ - loadKeyring: () => ({ AsyncEntry: BinaryAsyncEntry, Entry: FakeEntry }), - }); - - await expect(store.read({ purpose: 'api-key', reference: 'binary' })).resolves.toEqual(Uint8Array.from([1, 2, 3])); - }); - - it('maps backend write, read, and delete failures to stable storage errors', async () => { - const store = new KeychainSecretStore({ - loadKeyring: () => ({ AsyncEntry: FailingAsyncEntry, Entry: FakeEntry }), - }); - const reference = { purpose: 'api-key', reference: 'failing' }; - - await expect(store.create(reference, Buffer.from('secret'))).rejects.toMatchObject({ - secureStorageCode: 'secure_storage_io_error', - }); - await expect(store.read(reference)).rejects.toMatchObject({ secureStorageCode: 'secure_storage_io_error' }); - await expect(store.delete(reference)).rejects.toMatchObject({ secureStorageCode: 'secure_storage_io_error' }); - }); - - it('reports deletion of a missing Keychain reference distinctly', async () => { - FakeAsyncEntry.values.clear(); - const store = new KeychainSecretStore({ loadKeyring: () => ({ AsyncEntry: FakeAsyncEntry, Entry: FakeEntry }) }); - - await expect(store.delete({ purpose: 'api-key', reference: 'missing' })).rejects.toMatchObject({ - secureStorageCode: 'secure_storage_secret_missing', - }); - }); -}); - -describe('SyncKeychainSecretStore', () => { - it('uses the exact-reference sync Entry API', () => { - FakeAsyncEntry.values.clear(); - const store = new SyncKeychainSecretStore({ - loadKeyring: () => ({ AsyncEntry: FakeAsyncEntry, Entry: FakeEntry }), - serviceName: 'ai.inflowpay.cli.test', - }); - const reference = { purpose: 'api-key', reference: 'sync-reference' }; - - store.create(reference, Buffer.from('sync-secret', 'utf8')); - - expect(Buffer.from(store.read(reference)).toString('utf8')).toBe('sync-secret'); - store.delete(reference); - expect(() => store.read(reference)).toThrow('A referenced secret is missing from Keychain.'); - }); - - it('maps synchronous backend failures and missing deletion to stable errors', () => { - const failing = new SyncKeychainSecretStore({ - loadKeyring: () => ({ AsyncEntry: FakeAsyncEntry, Entry: FailingEntry }), - }); - const reference = { purpose: 'api-key', reference: 'failing' }; - - expect(() => failing.create(reference, Buffer.from('secret'))).toThrow('Failed to write a secret to Keychain.'); - expect(() => failing.read(reference)).toThrow('Failed to read a secret from Keychain.'); - expect(() => failing.delete(reference)).toThrow('Failed to delete a secret from Keychain.'); - - FakeAsyncEntry.values.clear(); - const missing = new SyncKeychainSecretStore({ - loadKeyring: () => ({ AsyncEntry: FakeAsyncEntry, Entry: FakeEntry }), - }); - expect(() => missing.delete({ purpose: 'api-key', reference: 'missing' })).toThrow( - 'A referenced secret could not be deleted.', - ); - }); -}); - -describe('KeychainReferenceManifest', () => { - it('stores only opaque references in one fixed exact-reference item', async () => { - const store = new MemorySecretStore(); - const manifest = new KeychainReferenceManifest(store); - const first = { purpose: 'api-key', reference: 'one' }; - const second = { purpose: 'refresh-token', reference: 'two' }; - - await manifest.add(first); - await manifest.add(first); - await manifest.add(second); - expect(await manifest.read()).toEqual([first, second]); - - await manifest.remove(first); - expect(await manifest.read()).toEqual([second]); - }); - - it('rejects malformed JSON, non-array payloads, and malformed entries', async () => { - const store = new MemorySecretStore(); - const reference = { purpose: 'manifest', reference: 'fixed-keychain-references' }; - const manifest = new KeychainReferenceManifest(store); - - await store.create(reference, Buffer.from('{', 'utf8')); - await expect(manifest.read()).rejects.toMatchObject({ secureStorageCode: 'secure_storage_corrupt' }); - await store.delete(reference); - await store.create(reference, Buffer.from('{}', 'utf8')); - await expect(manifest.read()).rejects.toMatchObject({ secureStorageCode: 'secure_storage_corrupt' }); - await store.delete(reference); - await store.create(reference, Buffer.from('[null]', 'utf8')); - await expect(manifest.read()).rejects.toMatchObject({ secureStorageCode: 'secure_storage_corrupt' }); - await store.delete(reference); - await store.create(reference, Buffer.from('[{"purpose":1,"reference":"one"}]', 'utf8')); - await expect(manifest.read()).rejects.toMatchObject({ secureStorageCode: 'secure_storage_corrupt' }); - }); - - it('does not hide unexpected manifest deletion failures', async () => { - const manifest = new KeychainReferenceManifest(new FailingAsyncManifestDeleteStore()); - - await expect(manifest.add({ purpose: 'api-key', reference: 'one' })).rejects.toThrow('manifest delete failed'); - }); -}); - -describe('SyncKeychainReferenceManifest', () => { - it('stores only opaque references through the sync store', () => { - const store = new SyncMemorySecretStore(); - const manifest = new SyncKeychainReferenceManifest(store); - const reference = { purpose: 'api-key', reference: 'one' }; - - manifest.add(reference); - manifest.add(reference); - expect(manifest.read()).toEqual([reference]); - - manifest.remove(reference); - expect(manifest.read()).toEqual([]); - }); - - it('rejects corrupt synchronous manifests and propagates unexpected deletion failures', () => { - const store = new SyncMemorySecretStore(); - const reference = { purpose: 'manifest', reference: 'fixed-keychain-references' }; - const manifest = new SyncKeychainReferenceManifest(store); - - store.create(reference, Buffer.from('{', 'utf8')); - expect(() => manifest.read()).toThrow('The Keychain reference manifest is malformed.'); - store.delete(reference); - store.create(reference, Buffer.from('[{}]', 'utf8')); - expect(() => manifest.read()).toThrow('The Keychain reference manifest contains a malformed entry.'); - - const failing = new SyncKeychainReferenceManifest(new FailingSyncManifestDeleteStore()); - expect(() => failing.add({ purpose: 'api-key', reference: 'one' })).toThrow('manifest delete failed'); - }); - - it('reports missing values and deletions from both memory stores', async () => { - const reference = { purpose: 'api-key', reference: 'missing' }; - const asyncStore = new MemorySecretStore(); - const syncStore = new SyncMemorySecretStore(); - - await expect(asyncStore.read(reference)).rejects.toMatchObject({ - secureStorageCode: 'secure_storage_secret_missing', - }); - await expect(asyncStore.delete(reference)).rejects.toMatchObject({ - secureStorageCode: 'secure_storage_secret_missing', - }); - expect(() => syncStore.read(reference)).toThrow('A referenced secret is missing from Keychain.'); - expect(() => syncStore.delete(reference)).toThrow('A referenced secret could not be deleted.'); - }); -}); diff --git a/packages/core/test/unit/secure-storage/lifecycle.test.ts b/packages/core/test/unit/secure-storage/lifecycle.test.ts index caa7b3f..c091188 100644 --- a/packages/core/test/unit/secure-storage/lifecycle.test.ts +++ b/packages/core/test/unit/secure-storage/lifecycle.test.ts @@ -4,18 +4,18 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { - KeychainReferenceManifest, + SecretReferenceManifest, MemorySecretStore, - SyncKeychainReferenceManifest, + SyncSecretReferenceManifestStore, SyncMemorySecretStore, -} from '../../../src/secure-storage/keychain.js'; +} from '../../../src/secure-storage/secret-store.js'; import { SecureSecretLifecycleCoordinator, SyncSecureSecretLifecycleCoordinator, } from '../../../src/secure-storage/lifecycle.js'; import { SecureSqliteRepository } from '../../../src/secure-storage/sqlite.js'; -class FailingAddManifest extends KeychainReferenceManifest { +class FailingAddManifest extends SecretReferenceManifest { override add(): Promise { return Promise.reject(new Error('manifest add failed')); } @@ -26,25 +26,25 @@ function rejectWithUnknown(reason: unknown): Promise { return reject(reason); } -class NonErrorFailingAddManifest extends KeychainReferenceManifest { +class NonErrorFailingAddManifest extends SecretReferenceManifest { override add(): Promise { return rejectWithUnknown('manifest add failed'); } } -class FailingRemoveManifest extends KeychainReferenceManifest { +class FailingRemoveManifest extends SecretReferenceManifest { override remove(): Promise { return Promise.reject(new Error('manifest remove failed')); } } -class FailingSyncAddManifest extends SyncKeychainReferenceManifest { +class FailingSyncAddManifest extends SyncSecretReferenceManifestStore { override add(): void { throw new Error('manifest add failed'); } } -class FailingSyncRemoveManifest extends SyncKeychainReferenceManifest { +class FailingSyncRemoveManifest extends SyncSecretReferenceManifestStore { override remove(): void { throw new Error('manifest remove failed'); } @@ -54,7 +54,7 @@ describe('SecureSecretLifecycleCoordinator', () => { let tmpDir: string; let repository: SecureSqliteRepository; let store: MemorySecretStore; - let manifest: KeychainReferenceManifest; + let manifest: SecretReferenceManifest; let coordinator: SecureSecretLifecycleCoordinator; beforeEach(() => { @@ -62,7 +62,7 @@ describe('SecureSecretLifecycleCoordinator', () => { repository = new SecureSqliteRepository({ rootDir: tmpDir }); repository.initialize(); store = new MemorySecretStore(); - manifest = new KeychainReferenceManifest(store); + manifest = new SecretReferenceManifest(store); coordinator = new SecureSecretLifecycleCoordinator(repository, store, manifest); }); @@ -71,7 +71,7 @@ describe('SecureSecretLifecycleCoordinator', () => { rmSync(tmpDir, { force: true, recursive: true }); }); - it('creates a Keychain item, manifest reference, and active lifecycle row in one serialized operation', async () => { + it('creates a secret item, manifest reference, and active lifecycle row in one serialized operation', async () => { const principal = repository.upsertPrincipal('https://platform.example.test', 'user-1'); const reference = { purpose: 'api-key', reference: 'opaque-reference' }; @@ -82,7 +82,7 @@ describe('SecureSecretLifecycleCoordinator', () => { expect(repository.listSecretLifecycle('active')).toEqual([reference]); }); - it('deletes the Keychain item, manifest reference, and lifecycle row', async () => { + it('deletes the secret item, manifest reference, and lifecycle row', async () => { const reference = { purpose: 'api-key', reference: 'opaque-reference' }; await coordinator.create(reference, Buffer.from('secret'), null); @@ -126,7 +126,7 @@ describe('SecureSecretLifecycleCoordinator', () => { expect(repository.listSecretLifecycle('deleting')).toEqual([]); }); - it('keeps a failed create recoverable after the Keychain item is written', async () => { + it('keeps a failed create recoverable after the secret item is written', async () => { const reference = { purpose: 'api-key', reference: 'create-failure' }; const failing = new SecureSecretLifecycleCoordinator(repository, store, new FailingAddManifest(store)); @@ -150,7 +150,7 @@ describe('SecureSecretLifecycleCoordinator', () => { expect(repository.listSecretLifecycle('pending')).toEqual([reference]); }); - it('keeps a failed delete recoverable after the Keychain item is deleted', async () => { + it('keeps a failed delete recoverable after the secret item is deleted', async () => { const reference = { purpose: 'api-key', reference: 'delete-failure' }; await coordinator.create(reference, Buffer.from('secret'), null); const failing = new SecureSecretLifecycleCoordinator(repository, store, new FailingRemoveManifest(store)); @@ -167,7 +167,7 @@ describe('SecureSecretLifecycleCoordinator', () => { it('supports synchronous create, delete, and interrupted-work recovery', () => { const syncStore = new SyncMemorySecretStore(); - const syncManifest = new SyncKeychainReferenceManifest(syncStore); + const syncManifest = new SyncSecretReferenceManifestStore(syncStore); const syncCoordinator = new SyncSecureSecretLifecycleCoordinator(repository, syncStore, syncManifest); const active = { purpose: 'api-key', reference: 'sync-active' }; @@ -177,7 +177,7 @@ describe('SecureSecretLifecycleCoordinator', () => { expect(repository.listSecretLifecycle('active')).toEqual([active]); syncCoordinator.delete(active); - expect(() => syncStore.read(active)).toThrow('A referenced secret is missing from Keychain.'); + expect(() => syncStore.read(active)).toThrow('A referenced secret is missing from secret store.'); expect(repository.listSecretLifecycle('active')).toEqual([]); const pending = { purpose: 'api-key', reference: 'sync-pending' }; @@ -190,14 +190,14 @@ describe('SecureSecretLifecycleCoordinator', () => { syncCoordinator.recoverInterruptedWork(); - expect(() => syncStore.read(pending)).toThrow('A referenced secret is missing from Keychain.'); + expect(() => syncStore.read(pending)).toThrow('A referenced secret is missing from secret store.'); expect(repository.listSecretLifecycle('pending')).toEqual([]); expect(repository.listSecretLifecycle('deleting')).toEqual([]); }); - it('keeps synchronous create and delete failures recoverable after touching Keychain', () => { + it('keeps synchronous create and delete failures recoverable after touching secret store', () => { const syncStore = new SyncMemorySecretStore(); - const syncManifest = new SyncKeychainReferenceManifest(syncStore); + const syncManifest = new SyncSecretReferenceManifestStore(syncStore); const syncCoordinator = new SyncSecureSecretLifecycleCoordinator(repository, syncStore, syncManifest); const createFailure = { purpose: 'api-key', reference: 'sync-create-failure' }; const failingCreate = new SyncSecureSecretLifecycleCoordinator( @@ -210,7 +210,7 @@ describe('SecureSecretLifecycleCoordinator', () => { expect(repository.listSecretLifecycle('pending')).toEqual([createFailure]); expect(Buffer.from(syncStore.read(createFailure)).toString('utf8')).toBe('secret'); syncCoordinator.recoverInterruptedWork(); - expect(() => syncStore.read(createFailure)).toThrow('A referenced secret is missing from Keychain.'); + expect(() => syncStore.read(createFailure)).toThrow('A referenced secret is missing from secret store.'); const deleteFailure = { purpose: 'api-key', reference: 'sync-delete-failure' }; syncCoordinator.create(deleteFailure, Buffer.from('secret'), null); @@ -222,8 +222,37 @@ describe('SecureSecretLifecycleCoordinator', () => { expect(() => failingDelete.delete(deleteFailure)).toThrow('manifest remove failed'); expect(repository.listSecretLifecycle('deleting')).toEqual([deleteFailure]); - expect(() => syncStore.read(deleteFailure)).toThrow('A referenced secret is missing from Keychain.'); + expect(() => syncStore.read(deleteFailure)).toThrow('A referenced secret is missing from secret store.'); syncCoordinator.recoverInterruptedWork(); expect(repository.listSecretLifecycle('deleting')).toEqual([]); }); + + it('rejects malformed asynchronous and synchronous reference manifests', async () => { + const manifestReference = { purpose: 'manifest', reference: 'fixed-secret-references' }; + await store.create(manifestReference, Buffer.from('{"not":"an array"}')); + await expect(manifest.read()).rejects.toMatchObject({ secureStorageCode: 'secure_storage_corrupt' }); + + await store.delete(manifestReference); + await store.create(manifestReference, Buffer.from('[null]')); + await expect(manifest.read()).rejects.toMatchObject({ secureStorageCode: 'secure_storage_corrupt' }); + + const syncStore = new SyncMemorySecretStore(); + const syncManifest = new SyncSecretReferenceManifestStore(syncStore); + syncStore.create(manifestReference, Buffer.from('not json')); + expect(() => syncManifest.read()).toThrow('manifest is malformed'); + + syncStore.delete(manifestReference); + syncStore.create(manifestReference, Buffer.from('[{"purpose":1,"reference":"value"}]')); + expect(() => syncManifest.read()).toThrow('malformed entry'); + }); + + it('accepts a missing manifest and rejects empty secret references', async () => { + await expect(manifest.read()).resolves.toEqual([]); + expect(() => + new SyncMemorySecretStore().create({ purpose: '', reference: 'value' }, Buffer.from('secret')), + ).toThrow('Secret references require a purpose and opaque reference.'); + expect(() => store.create({ purpose: 'value', reference: '' }, Buffer.from('secret'))).toThrow( + 'Secret references require a purpose and opaque reference.', + ); + }); }); diff --git a/packages/core/test/unit/secure-storage/runtime-require.test.ts b/packages/core/test/unit/secure-storage/runtime-require.test.ts new file mode 100644 index 0000000..71c6fc9 --- /dev/null +++ b/packages/core/test/unit/secure-storage/runtime-require.test.ts @@ -0,0 +1,15 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { runtimeRequire } from '../../../src/secure-storage/runtime-require.js'; + +describe('runtimeRequire', () => { + afterEach(() => { + Reflect.deleteProperty(globalThis, '__inflowRequire'); + }); + + it('uses the require function installed by the standalone launcher', () => { + const embeddedRequire = vi.fn(); + Reflect.set(globalThis, '__inflowRequire', embeddedRequire); + + expect(runtimeRequire()).toBe(embeddedRequire); + }); +}); diff --git a/packages/core/test/unit/secure-storage/sqlite.test.ts b/packages/core/test/unit/secure-storage/sqlite.test.ts index acc61d2..138869d 100644 --- a/packages/core/test/unit/secure-storage/sqlite.test.ts +++ b/packages/core/test/unit/secure-storage/sqlite.test.ts @@ -72,6 +72,40 @@ describe('SecureSqliteRepository', () => { await expect(repository.ensureBackup()).resolves.toBeUndefined(); }); + it.runIf(process.platform === 'linux')('uses the Linux vault root for the default database', () => { + const originalDataHome = process.env['XDG_DATA_HOME']; + const dataHome = join(tmpDir, 'xdg-data'); + process.env['XDG_DATA_HOME'] = dataHome; + let databasePath: string | undefined; + const defaultRepository = new SecureSqliteRepository({ + loadSqlite: () => ({ + DatabaseSync: class { + constructor(filename: string) { + databasePath = filename; + } + + close(): void {} + + exec(_sql: string): void {} + + prepare(sql: string): EmptySqliteStatement { + return sql === 'PRAGMA quick_check' + ? new QuickCheckStatement({ quick_check: 'ok' }) + : new EmptySqliteStatement(); + } + }, + }), + }); + try { + defaultRepository.initialize(); + expect(databasePath).toBe(join(dataHome, 'inflow', 'inflow.sqlite3')); + } finally { + defaultRepository.close(); + if (originalDataHome === undefined) delete process.env['XDG_DATA_HOME']; + else process.env['XDG_DATA_HOME'] = originalDataHome; + } + }); + it('stores principal rows with stable owner lookup columns', () => { const created = repository.upsertPrincipal('https://platform.example.test', 'user-1', { displayName: 'User One' }); const loaded = repository.getPrincipal('https://platform.example.test', 'user-1'); @@ -165,6 +199,61 @@ describe('SecureSqliteRepository', () => { expect(repository.listSecretLifecycle('deleting')).toEqual([]); }); + it('stores encrypted vault rows with exact-reference kind and active-status retrieval', () => { + const active = { + ciphertext: Uint8Array.from([1, 2, 3]), + encryptionVersion: 1, + kind: 'aep_credential' as const, + nonce: Uint8Array.from([4, 5, 6]), + reference: 'vlt_11111111111111111111111111111111', + status: 'active' as const, + tag: Uint8Array.from([7, 8, 9]), + updatedAt: '2026-01-01T00:00:00.000Z', + }; + const pending = { + ...active, + reference: 'vlt_22222222222222222222222222222222', + status: 'pending' as const, + }; + repository.putVaultRecord(active); + repository.putVaultRecord(pending); + + expect(repository.getVaultRecord(active.reference, 'aep_credential')).toMatchObject({ + kind: 'aep_credential', + reference: active.reference, + status: 'active', + updatedAt: active.updatedAt, + }); + expect(repository.getVaultRecord(active.reference, 'inflow_api_key')).toBeUndefined(); + expect(repository.getVaultRecord(pending.reference, 'aep_credential')).toBeUndefined(); + expect(repository.listVaultRecords('pending')).toEqual([expect.objectContaining({ reference: pending.reference })]); + }); + + it('hides expired vault rows and hard-deletes completed records', () => { + const record = { + ciphertext: Uint8Array.from([1]), + encryptionVersion: 1, + expiresAt: '2000-01-01T00:00:00.000Z', + kind: 'auth_access_token' as const, + nonce: Uint8Array.from([2]), + reference: 'vlt_33333333333333333333333333333333', + status: 'active' as const, + tag: Uint8Array.from([3]), + updatedAt: '2026-01-01T00:00:00.000Z', + }; + repository.putVaultRecord(record); + + expect(repository.getVaultRecord(record.reference, 'auth_access_token')).toBeUndefined(); + + repository.markVaultRecordStatus(record.reference, 'deleting', '2026-01-02T00:00:00.000Z'); + expect(repository.listVaultRecords('deleting')).toEqual([ + expect.objectContaining({ reference: record.reference, status: 'deleting' }), + ]); + + repository.deleteVaultRecord(record.reference); + expect(repository.listVaultRecords('deleting')).toEqual([]); + }); + it('stores versioned non-secret settings payloads as JSON bytes', () => { repository.upsertSetting('connection', { apiBaseUrl: 'https://sandbox.inflowpay.ai' }); diff --git a/packages/core/test/unit/secure-storage/vault-backend-lifetime.test.ts b/packages/core/test/unit/secure-storage/vault-backend-lifetime.test.ts new file mode 100644 index 0000000..276d025 --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-backend-lifetime.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { VaultBackend, VaultPolicy } from '../../../src/secure-storage/vault-backend.js'; +import { LifetimeVaultBackend, VaultBackendLifetime } from '../../../src/secure-storage/vault-backend-lifetime.js'; +import { parseVaultSecretReference } from '../../../src/secure-storage/vault-types.js'; + +describe('LifetimeVaultBackend', () => { + it('forwards every backend operation and refreshes or clears the lifetime as appropriate', async () => { + const policy: VaultPolicy = { idleTimeoutSeconds: null, lockOnSleep: false }; + const reference = parseVaultSecretReference('vlt_0123456789abcdef0123456789abcdef'); + const payload = { payload: new Uint8Array([7]), reference }; + const status = { daemonRunning: false, lockState: 'unlocked' as const }; + const salt = new Uint8Array(16); + const backend: VaultBackend = { + changePassphrase: vi.fn(), + changeWrappingKey: vi.fn(), + deleteExpired: vi.fn(), + deleteSecret: vi.fn(), + exists: vi.fn(() => true), + getPolicy: vi.fn(() => policy), + getSecret: vi.fn(() => payload), + lock: vi.fn(), + putSecret: vi.fn(() => reference), + reset: vi.fn(), + setPolicy: vi.fn(() => policy), + status: vi.fn(() => status), + touch: vi.fn(), + unlock: vi.fn(() => status), + unlockSalt: vi.fn(() => salt), + unlockWithWrappingKey: vi.fn(() => status), + }; + const lifetime = new VaultBackendLifetime(); + const refresh = vi.spyOn(lifetime, 'refresh'); + const clear = vi.spyOn(lifetime, 'clear'); + const onReset = vi.fn(); + const wrapped = new LifetimeVaultBackend(backend, lifetime, onReset); + const secretInput = { expectedKind: 'inflow_api_key' as const, reference }; + + await wrapped.changePassphrase(new Uint8Array([1]), new Uint8Array([2])); + await wrapped.changeWrappingKey(new Uint8Array([1]), new Uint8Array([2]), salt); + await wrapped.deleteExpired({ now: '2026-07-24T00:00:00.000Z' }); + await wrapped.deleteSecret(secretInput); + await expect(wrapped.exists(secretInput)).resolves.toBe(true); + await expect(wrapped.getPolicy()).resolves.toBe(policy); + await expect(wrapped.getSecret(secretInput)).resolves.toBe(payload); + await expect(wrapped.putSecret({ expectedKind: 'inflow_api_key', payload: new Uint8Array([7]) })).resolves.toBe( + reference, + ); + await expect(wrapped.setPolicy(policy)).resolves.toBe(policy); + await expect(wrapped.status()).resolves.toEqual({ daemonRunning: true, lockState: 'unlocked' }); + await wrapped.touch(secretInput); + await expect(wrapped.unlock(new Uint8Array([1]))).resolves.toEqual({ + daemonRunning: true, + lockState: 'unlocked', + }); + await expect(wrapped.unlockSalt()).resolves.toBe(salt); + await expect(wrapped.unlockWithWrappingKey(new Uint8Array([1]), salt)).resolves.toEqual({ + daemonRunning: true, + lockState: 'unlocked', + }); + const clearsBeforeLock = clear.mock.calls.length; + await wrapped.lock(); + expect(clear).toHaveBeenCalledTimes(clearsBeforeLock + 1); + await wrapped.reset(); + + expect(refresh).toHaveBeenCalled(); + expect(clear).toHaveBeenCalledTimes(clearsBeforeLock + 2); + expect(onReset).toHaveBeenCalledOnce(); + expect(onReset.mock.invocationCallOrder[0]).toBeLessThan( + (backend.reset as ReturnType).mock.invocationCallOrder[0] as number, + ); + }); + + it('does not refresh lifetime state for an uninitialized vault', async () => { + const backend = uninitializedBackend(); + const lifetime = new VaultBackendLifetime(); + const refresh = vi.spyOn(lifetime, 'refresh'); + const wrapped = new LifetimeVaultBackend(backend, lifetime); + + await expect(wrapped.status()).resolves.toEqual({ + daemonRunning: true, + lockState: 'not_initialized', + }); + + expect(refresh).not.toHaveBeenCalled(); + }); +}); + +function uninitializedBackend(): VaultBackend { + const unavailable = (): never => { + throw new Error('unexpected backend operation'); + }; + return { + changePassphrase: unavailable, + changeWrappingKey: unavailable, + deleteExpired: unavailable, + deleteSecret: unavailable, + exists: unavailable, + getPolicy: unavailable, + getSecret: unavailable, + lock: unavailable, + putSecret: unavailable, + reset: unavailable, + setPolicy: unavailable, + status: () => ({ daemonRunning: false, lockState: 'not_initialized' }), + touch: unavailable, + unlock: unavailable, + unlockSalt: unavailable, + unlockWithWrappingKey: unavailable, + }; +} diff --git a/packages/core/test/unit/secure-storage/vault-backend.test.ts b/packages/core/test/unit/secure-storage/vault-backend.test.ts new file mode 100644 index 0000000..a0fb6b4 --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-backend.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_VAULT_POLICY, VAULT_BACKEND_METHODS } from '../../../src/secure-storage/vault-backend.js'; +import type { VaultBackend } from '../../../src/secure-storage/vault-backend.js'; + +describe('VaultBackend contract', () => { + it('keeps the interface generic and protocol-free', () => { + const methods = VAULT_BACKEND_METHODS satisfies readonly (keyof VaultBackend)[]; + + expect(methods).toContain('changePassphrase'); + expect(methods).toContain('reset'); + expect(methods).not.toContain('sign' as keyof VaultBackend); + expect(methods).not.toContain('fetch' as keyof VaultBackend); + expect(methods).not.toContain('aepGrant' as keyof VaultBackend); + expect(methods).not.toContain('mppPay' as keyof VaultBackend); + expect(methods).not.toContain('x402Pay' as keyof VaultBackend); + }); + + it('models the default policy fields without user or protocol ownership', () => { + expect(DEFAULT_VAULT_POLICY).toEqual({ + idleTimeoutSeconds: 28_800, + lockOnSleep: true, + }); + }); +}); diff --git a/packages/core/test/unit/secure-storage/vault-broker-auth.test.ts b/packages/core/test/unit/secure-storage/vault-broker-auth.test.ts new file mode 100644 index 0000000..676d5eb --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-broker-auth.test.ts @@ -0,0 +1,276 @@ +import { Buffer } from 'node:buffer'; +import { createPublicKey, generateKeyPairSync } from 'node:crypto'; +import type * as nodeFs from 'node:fs'; +import type * as nodeFsPromises from 'node:fs/promises'; +import { createConnection, createServer, type Server, type Socket } from 'node:net'; +import process from 'node:process'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SecureStorageError } from '../../../src/secure-storage/errors.js'; + +const state = vi.hoisted(() => ({ + nativePeer: { pid: 700, uid: 0 }, + privateExists: true, + privateKeyBytes: new Uint8Array(), + publicExists: true, + publicKeyBytes: new Uint8Array(), + temporaryFiles: new Map(), +})); + +vi.mock('../../../src/secure-storage/runtime-require.js', () => ({ + runtimeRequire: () => () => ({ + peerCredentials: () => state.nativePeer, + }), +})); + +vi.mock('../../../src/secure-storage/vault-peer-verifier.js', () => ({ + createVaultPeerVerificationConfig: ({ expectedUserId }: { expectedUserId: number }) => ({ + expectedExecutablePath: '/opt/inflow/bin/inflow', + expectedUserId, + nativeModulePath: '/opt/inflow/lib/inflow/native/vault_peer_linux.node', + }), + socketFileDescriptor: () => 42, + verifyVaultPeerVerificationConfig: () => undefined, +})); + +vi.mock('node:fs', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + chmodSync: (filePath: string, mode: number) => { + if (!filePath.startsWith('/var/lib/inflow-broker/')) original.chmodSync(filePath, mode); + }, + lstatSync: (filePath: string) => { + if (filePath === '/var/lib/inflow-broker') { + return { isDirectory: () => true, isSymbolicLink: () => false, mode: 0o40755, uid: 0 }; + } + if (filePath === '/var/lib/inflow-broker/private.der' || filePath === '/var/lib/inflow-broker/public.der') { + const exists = filePath.endsWith('private.der') ? state.privateExists : state.publicExists; + if (!exists) throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + return { + isFile: () => true, + isSymbolicLink: () => false, + mode: filePath.endsWith('private.der') ? 0o100600 : 0o100644, + uid: 0, + }; + } + return original.lstatSync(filePath); + }, + readFileSync: (filePath: string) => { + if (filePath === '/var/lib/inflow-broker/private.der') return state.privateKeyBytes; + if (filePath === '/var/lib/inflow-broker/public.der') return state.publicKeyBytes; + return original.readFileSync(filePath); + }, + renameSync: (source: string, destination: string) => { + const bytes = state.temporaryFiles.get(source); + if (bytes === undefined) return original.renameSync(source, destination); + if (destination.endsWith('private.der')) { + state.privateKeyBytes = Uint8Array.from(bytes); + state.privateExists = true; + } else { + state.publicKeyBytes = Uint8Array.from(bytes); + state.publicExists = true; + } + state.temporaryFiles.delete(source); + }, + realpathSync: (filePath: string) => filePath, + writeFileSync: (filePath: string, bytes: Uint8Array) => { + if (!filePath.startsWith('/var/lib/inflow-broker/')) return original.writeFileSync(filePath, bytes); + state.temporaryFiles.set(filePath, Uint8Array.from(bytes)); + }, + }; +}); + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + mkdir: () => Promise.resolve(undefined), +})); + +import { + authenticateLinuxVaultBrokerClient, + createLinuxVaultBrokerPeerVerifier, + ensureLinuxVaultBrokerKey, +} from '../../../src/secure-storage/vault-broker-auth.js'; + +describe('Linux vault broker authentication', () => { + let server: Server | undefined; + + beforeEach(() => { + state.nativePeer = { pid: 700, uid: 0 }; + state.privateExists = true; + state.publicExists = true; + state.temporaryFiles.clear(); + const pair = generateKeyPairSync('ed25519'); + state.privateKeyBytes = pair.privateKey.export({ format: 'der', type: 'pkcs8' }); + state.publicKeyBytes = createPublicKey(pair.privateKey).export({ format: 'der', type: 'spki' }); + }); + + afterEach(async () => { + await new Promise((resolve) => server?.close(() => resolve()) ?? resolve()); + server = undefined; + }); + + it('mutually authenticates the broker and binds its signature to the client identity', async () => { + const pair = generateKeyPairSync('ed25519'); + state.publicKeyBytes = createPublicKey(pair.privateKey).export({ format: 'der', type: 'spki' }); + const verifier = createLinuxVaultBrokerPeerVerifier(0); + const result = await withSocketPair(async (client, accepted) => { + const broker = authenticateLinuxVaultBrokerClient( + accepted, + { path: '/opt/inflow/bin/inflow', pid: process.pid, uid: currentUserId() }, + pair.privateKey, + ); + const peer = await verifier(client); + await broker; + return peer; + }); + + expect(result).toEqual({ path: '/opt/inflow/bin/inflow', pid: 700, uid: 0 }); + }); + + it('pauses client input before completing broker authentication', async () => { + const pair = generateKeyPairSync('ed25519'); + state.publicKeyBytes = createPublicKey(pair.privateKey).export({ format: 'der', type: 'spki' }); + const verifier = createLinuxVaultBrokerPeerVerifier(0); + + await withSocketPair(async (client, accepted) => { + const broker = authenticateLinuxVaultBrokerClient( + accepted, + { path: '/opt/inflow/bin/inflow', pid: process.pid, uid: currentUserId() }, + pair.privateKey, + ); + await verifier(client); + await broker; + + expect(accepted.isPaused()).toBe(true); + }); + }); + + it('loads a matching root-owned machine identity', async () => { + const privateKey = await ensureLinuxVaultBrokerKey(); + + expect(createPublicKey(privateKey).export({ format: 'der', type: 'spki' })).toEqual( + Buffer.from(state.publicKeyBytes), + ); + }); + + it('rejects a public machine identity that does not match its private key', async () => { + const different = generateKeyPairSync('ed25519'); + state.publicKeyBytes = createPublicKey(different.privateKey).export({ format: 'der', type: 'spki' }); + + await expect(ensureLinuxVaultBrokerKey()).rejects.toMatchObject({ + secureStorageCode: 'secure_storage_unavailable', + }); + }); + + it('creates a machine identity when no broker key exists', async () => { + state.privateExists = false; + state.publicExists = false; + + const privateKey = await ensureLinuxVaultBrokerKey(); + + expect(state.privateExists).toBe(true); + expect(state.publicExists).toBe(true); + expect(createPublicKey(privateKey).export({ format: 'der', type: 'spki' })).toEqual( + Buffer.from(state.publicKeyBytes), + ); + }); + + it('rejects a broker signature bound to a different client process', async () => { + const pair = generateKeyPairSync('ed25519'); + state.publicKeyBytes = createPublicKey(pair.privateKey).export({ format: 'der', type: 'spki' }); + const verifier = createLinuxVaultBrokerPeerVerifier(0); + + await expect( + withSocketPair(async (client, accepted) => { + const broker = authenticateLinuxVaultBrokerClient( + accepted, + { path: '/opt/inflow/bin/inflow', pid: process.pid + 1, uid: currentUserId() }, + pair.privateKey, + ); + const verification = verifier(client); + await broker; + return verification; + }), + ).rejects.toMatchObject({ secureStorageCode: 'secure_storage_peer_verification_failed' }); + }); + + it('rejects clients whose kernel user identity does not match the broker service', async () => { + const pair = generateKeyPairSync('ed25519'); + state.publicKeyBytes = createPublicKey(pair.privateKey).export({ format: 'der', type: 'spki' }); + state.nativePeer = { pid: 700, uid: 1 }; + + await withSocketPair(async (client, accepted) => { + accepted.destroy(); + await expect(createLinuxVaultBrokerPeerVerifier(0)(client)).rejects.toBeInstanceOf(SecureStorageError); + }); + }); + + it('rejects malformed and oversized broker challenges', async () => { + const pair = generateKeyPairSync('ed25519'); + for (const challenge of [Buffer.alloc(36), Buffer.alloc(37, 1)]) { + await withSocketPair(async (client, accepted) => { + const authentication = authenticateLinuxVaultBrokerClient( + accepted, + { path: '/opt/inflow/bin/inflow', pid: 1, uid: 1 }, + pair.privateKey, + ); + client.write(challenge); + await expect(authentication).rejects.toMatchObject({ + secureStorageCode: 'secure_storage_peer_verification_failed', + }); + }); + } + }); + + it('rejects a client that disconnects before sending a complete challenge', async () => { + const pair = generateKeyPairSync('ed25519'); + await withSocketPair(async (client, accepted) => { + const authentication = authenticateLinuxVaultBrokerClient( + accepted, + { path: '/opt/inflow/bin/inflow', pid: 1, uid: 1 }, + pair.privateKey, + ); + client.end(Buffer.alloc(10)); + await expect(authentication).rejects.toMatchObject({ + secureStorageCode: 'secure_storage_peer_verification_failed', + }); + }); + }); + + it('propagates a socket failure while reading a challenge', async () => { + const pair = generateKeyPairSync('ed25519'); + const failure = new Error('socket failed'); + await withSocketPair(async (_client, accepted) => { + const authentication = authenticateLinuxVaultBrokerClient( + accepted, + { path: '/opt/inflow/bin/inflow', pid: 1, uid: 1 }, + pair.privateKey, + ); + accepted.destroy(failure); + await expect(authentication).rejects.toBe(failure); + }); + }); + + async function withSocketPair(run: (client: Socket, accepted: Socket) => Promise): Promise { + const accepted = new Promise((resolve) => { + server = createServer(resolve); + }); + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)); + const address = server?.address(); + if (address === null || address === undefined || typeof address === 'string') throw new Error('missing address'); + const client = await new Promise((resolve) => { + const socket = createConnection(address.port, '127.0.0.1', () => resolve(socket)); + }); + const peer = await accepted; + try { + return await run(client, peer); + } finally { + client.destroy(); + peer.destroy(); + } + } +}); + +function currentUserId(): number { + return process.getuid?.() ?? 0; +} diff --git a/packages/core/test/unit/secure-storage/vault-client.test.ts b/packages/core/test/unit/secure-storage/vault-client.test.ts new file mode 100644 index 0000000..24c4e41 --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-client.test.ts @@ -0,0 +1,325 @@ +import { createServer, type Server } from 'node:net'; +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { __testing, LocalVaultClient } from '../../../src/secure-storage/vault-client.js'; +import { + decodeVaultIpcFrame, + encodeVaultIpcMessage, + type VaultIpcRequest, + type VaultIpcResponse, +} from '../../../src/secure-storage/vault-ipc.js'; + +describe('LocalVaultClient', () => { + let server: Server | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + await closeServer(server); + server = undefined; + if (tmpDir !== undefined) rmSync(tmpDir, { force: true, recursive: true }); + tmpDir = undefined; + }); + + it('maps vault status and policy responses from the local socket', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-client-')); + await listenWithResponder(tmpDir, (request) => { + if (request.method === 'vault.status') { + return { + id: request.id, + ok: true, + result: { daemonRunning: true, lockState: 'unlocked' }, + version: 1, + }; + } + if (request.method === 'daemon.info') { + return { + id: request.id, + ok: true, + result: { + buildId: 'build-1', + cliVersion: '0.9.0', + executablePath: '/Applications/InFlow.app/Contents/MacOS/inflow', + pid: 123, + }, + version: 1, + }; + } + return { + id: request.id, + ok: true, + result: { + idleTimeoutSeconds: 120, + lockOnSleep: false, + }, + version: 1, + }; + }); + const client = new LocalVaultClient({ rootDirectory: tmpDir }); + + await expect(client.status()).resolves.toEqual({ daemonRunning: true, lockState: 'unlocked' }); + await expect(client.info()).resolves.toEqual({ + buildId: 'build-1', + cliVersion: '0.9.0', + executablePath: '/Applications/InFlow.app/Contents/MacOS/inflow', + pid: 123, + }); + await expect(client.getPolicy()).resolves.toEqual({ + idleTimeoutSeconds: 120, + lockOnSleep: false, + }); + }); + + it('maps daemon errors into secure storage errors', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-client-')); + await listenWithResponder(tmpDir, (request) => ({ + error: { code: 'secure_storage_secret_missing', message: 'The InFlow vault is locked.' }, + id: request.id, + ok: false, + version: 1, + })); + const client = new LocalVaultClient({ rootDirectory: tmpDir }); + + await expect(client.unlock(Buffer.from('123456'))).rejects.toMatchObject({ + secureStorageCode: 'secure_storage_secret_missing', + }); + }); + + it('rejects an unlock response when an independent status request remains locked', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-client-')); + await listenWithResponder(tmpDir, (request) => ({ + id: request.id, + ok: true, + result: + request.method === 'vault.unlockSalt' + ? { salt: Buffer.alloc(16) } + : request.method === 'vault.unlock' + ? { daemonRunning: true, lockState: 'unlocked' } + : { daemonRunning: true, lockState: 'locked' }, + version: 1, + })); + const client = new LocalVaultClient({ rootDirectory: tmpDir }); + + await expect(client.unlock(Buffer.from('123456'))).rejects.toMatchObject({ + secureStorageCode: 'secure_storage_corrupt', + }); + }); + + it('rejects a response for a different request', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-client-')); + await listenWithResponder(tmpDir, () => ({ + id: 'req_stale', + ok: true, + result: { daemonRunning: true, lockState: 'locked' }, + version: 1, + })); + const client = new LocalVaultClient({ rootDirectory: tmpDir }); + + await expect(client.status()).rejects.toMatchObject({ + secureStorageCode: 'secure_storage_corrupt', + }); + }); + + it('sends lifecycle and policy update requests', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-client-')); + const methods: string[] = []; + let statusRequests = 0; + let unlockRequests = 0; + await listenWithResponder(tmpDir, (request) => { + methods.push(request.method); + if (request.method === 'vault.unlock') { + unlockRequests += 1; + if (unlockRequests === 1) { + return { + error: { code: 'secure_storage_corrupt', message: 'Vault material could not be unwrapped.' }, + id: request.id, + ok: false, + version: 1, + }; + } + return { + id: request.id, + ok: true, + result: { daemonRunning: true, lockState: 'unlocked' }, + version: 1, + }; + } + if (request.method === 'vault.status') statusRequests += 1; + return { + id: request.id, + ok: true, + result: + request.method === 'vault.setPolicy' + ? { + idleTimeoutSeconds: null, + lockOnSleep: false, + } + : request.method === 'vault.unlockSalt' + ? { salt: Buffer.alloc(16) } + : request.method === 'vault.status' + ? { daemonRunning: true, lockState: statusRequests === 1 ? 'locked' : 'unlocked' } + : {}, + version: 1, + }; + }); + const client = new LocalVaultClient({ rootDirectory: tmpDir }); + + await expect( + client.setPolicy({ + idleTimeoutSeconds: null, + lockOnSleep: false, + }), + ).resolves.toEqual({ + idleTimeoutSeconds: null, + lockOnSleep: false, + }); + await expect(client.changePassphrase(Buffer.from('current'), Buffer.from('next123'))).resolves.toBeUndefined(); + await expect(client.lock()).resolves.toBeUndefined(); + await expect(client.reset()).resolves.toBeUndefined(); + await expect(client.shutdown()).resolves.toBeUndefined(); + + expect(methods).toEqual([ + 'vault.setPolicy', + 'vault.unlockSalt', + 'vault.changePassphrase', + 'vault.lock', + 'vault.unlockSalt', + 'vault.unlock', + 'vault.status', + 'vault.unlockSalt', + 'vault.unlock', + 'vault.status', + 'vault.lock', + 'vault.reset', + 'daemon.shutdown', + ]); + }, 15_000); + + it('rejects malformed status and policy payloads', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-client-')); + await listenWithResponder(tmpDir, (request) => ({ + id: request.id, + ok: true, + result: + request.method === 'vault.status' ? { daemonRunning: true, lockState: 'bogus' } : { idleTimeoutSeconds: -1 }, + version: 1, + })); + const client = new LocalVaultClient({ rootDirectory: tmpDir }); + + await expect(client.status()).rejects.toMatchObject({ secureStorageCode: 'secure_storage_corrupt' }); + await expect(client.getPolicy()).rejects.toMatchObject({ secureStorageCode: 'secure_storage_corrupt' }); + }); + + it('rejects an unchanged passphrase before contacting the daemon', async () => { + const client = new LocalVaultClient({ rootDirectory: join(tmpdir(), 'missing-inflow-vault') }); + const factor = Buffer.from('current'); + + await expect(client.changePassphrase(factor, Buffer.from(factor))).rejects.toMatchObject({ + secureStorageCode: 'secure_storage_invalid_path', + }); + }); + + it('validates every daemon response field and preserves recognized error codes', () => { + expect(__testing.parseInfo({ buildId: null, cliVersion: null, executablePath: '/inflow', pid: 0 })).toEqual({ + buildId: null, + cliVersion: null, + executablePath: '/inflow', + pid: 0, + }); + for (const malformed of [ + { buildId: 1, cliVersion: null, executablePath: '/inflow', pid: 1 }, + { buildId: null, cliVersion: 1, executablePath: '/inflow', pid: 1 }, + { buildId: null, cliVersion: null, executablePath: 1, pid: 1 }, + { buildId: null, cliVersion: null, executablePath: '/inflow', pid: -1 }, + ]) { + expect(() => __testing.parseInfo(malformed)).toThrow('daemon info response is malformed'); + } + + for (const lockState of ['locked', 'not_initialized', 'unlocked'] as const) { + expect(__testing.parseStatus({ daemonRunning: false, lockState })).toEqual({ daemonRunning: false, lockState }); + } + expect(() => __testing.parseStatus({ daemonRunning: 'yes', lockState: 'locked' })).toThrow( + 'status response is malformed', + ); + expect(() => __testing.parseStatus({ daemonRunning: true, lockState: 'invalid' })).toThrow( + 'status response is malformed', + ); + + expect(__testing.parsePolicy({ idleTimeoutSeconds: 0, lockOnSleep: true })).toEqual({ + idleTimeoutSeconds: 0, + lockOnSleep: true, + }); + expect(__testing.parsePolicy({ idleTimeoutSeconds: null, lockOnSleep: false })).toEqual({ + idleTimeoutSeconds: null, + lockOnSleep: false, + }); + expect(() => __testing.parsePolicy({ idleTimeoutSeconds: 1.5, lockOnSleep: true })).toThrow( + 'policy response is malformed', + ); + expect(() => __testing.parsePolicy({ idleTimeoutSeconds: 1, lockOnSleep: 1 })).toThrow( + 'policy response is malformed', + ); + + const recognized = [ + 'secure_storage_corrupt', + 'secure_storage_invalid_path', + 'secure_storage_io_error', + 'secure_storage_peer_verification_failed', + 'secure_storage_secret_conflict', + 'secure_storage_secret_missing', + 'secure_storage_unavailable', + 'vault_daemon_busy', + 'vault_locked', + 'vault_not_initialized', + ] as const; + for (const code of recognized) expect(__testing.codeFromResponse(code)).toBe(code); + expect(__testing.codeFromResponse('unknown')).toBe('secure_storage_io_error'); + }); + + it('copies and clears valid salts and rejects malformed salts', () => { + const salt = Buffer.alloc(16, 7); + expect(__testing.parseSalt({ salt })).toEqual(Buffer.alloc(16, 7)); + expect(salt).toEqual(Buffer.alloc(16)); + expect(() => __testing.parseSalt({ salt: Buffer.alloc(15) })).toThrow('unlock salt response is malformed'); + expect(() => __testing.parseSalt({ salt: 'not-bytes' })).toThrow('unlock salt response is malformed'); + }); + + async function listenWithResponder(rootDirectory: string, respond: (request: VaultIpcRequest) => VaultIpcResponse) { + const socketPath = join(rootDirectory, 'run', 'vault.sock'); + mkdirSync(join(rootDirectory, 'run')); + server = createServer((socket) => { + const chunks: Buffer[] = []; + socket.on('data', (chunk: Buffer) => { + chunks.push(chunk); + const frame = Buffer.concat(chunks); + if (frame.byteLength < 4) return; + const length = frame.readUInt32BE(0); + if (frame.byteLength < length + 4) return; + const parsed = decodeVaultIpcFrame(frame.subarray(0, length + 4)); + if (!('method' in parsed)) throw new Error('expected request'); + socket.end(encodeVaultIpcMessage(respond(parsed))); + }); + }); + await new Promise((resolve, reject) => { + server?.once('error', reject); + server?.listen(socketPath, () => { + server?.off('error', reject); + resolve(); + }); + }); + } +}); + +function closeServer(server: Server | undefined): Promise { + if (server === undefined) return Promise.resolve(); + return new Promise((resolve, reject) => { + server.close((cause) => { + if (cause !== undefined) { + reject(cause); + return; + } + resolve(); + }); + }); +} diff --git a/packages/core/test/unit/secure-storage/vault-crypto.test.ts b/packages/core/test/unit/secure-storage/vault-crypto.test.ts new file mode 100644 index 0000000..236749b --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-crypto.test.ts @@ -0,0 +1,82 @@ +import { Buffer } from 'node:buffer'; +import { describe, expect, it } from 'vitest'; +import { + VAULT_MASTER_KEY_BYTES, + VAULT_SIDECAR_BYTES, + assertUnlockFactor, + changeVaultUnlockFactor, + createVaultMaterial, + decodeVaultHeader, + encodeVaultHeader, + equalBytes, + unwrapVaultMaterial, +} from '../../../src/secure-storage/vault-crypto.js'; + +const firstFactor = Buffer.from('123456', 'utf8'); +const secondFactor = Buffer.from('better-passphrase', 'utf8'); + +describe('vault crypto', () => { + it('wraps one random 32-byte vault master key in the fixed binary sidecar', () => { + const wrapped = createVaultMaterial(firstFactor); + + expect(wrapped.header.byteLength).toBe(VAULT_SIDECAR_BYTES); + expect(wrapped.masterKey.byteLength).toBe(VAULT_MASTER_KEY_BYTES); + expect(decodeVaultHeader(wrapped.header)).toMatchObject({ + material: expect.any(Buffer) as Buffer, + nonce: expect.any(Buffer) as Buffer, + salt: expect.any(Buffer) as Buffer, + tag: expect.any(Buffer) as Buffer, + }); + + const unwrapped = unwrapVaultMaterial(wrapped.header, firstFactor); + expect(equalBytes(unwrapped, wrapped.masterKey)).toBe(true); + }); + + it('rewraps material when changing the unlock factor without changing the master key', () => { + const wrapped = createVaultMaterial(firstFactor); + const nextHeader = changeVaultUnlockFactor(wrapped.header, firstFactor, secondFactor); + + expect(() => unwrapVaultMaterial(nextHeader, firstFactor)).toThrow('Vault material could not be unwrapped.'); + const unwrapped = unwrapVaultMaterial(nextHeader, secondFactor); + expect(equalBytes(unwrapped, wrapped.masterKey)).toBe(true); + }); + + it('rejects an unchanged unlock factor', () => { + const wrapped = createVaultMaterial(firstFactor); + + expect(() => changeVaultUnlockFactor(wrapped.header, firstFactor, Buffer.from(firstFactor))).toThrow( + 'The new vault PIN or passphrase must differ from the current one.', + ); + }); + + it('rejects short unlock factors', () => { + const short = Buffer.from('12345', 'utf8'); + + expect(() => assertUnlockFactor(short)).toThrow('PIN or passphrase must be at least 6 characters.'); + expect(() => createVaultMaterial(short)).toThrow('PIN or passphrase must be at least 6 characters.'); + }); + + it('fails closed for malformed and tampered sidecars', () => { + const wrapped = createVaultMaterial(firstFactor); + const truncated = wrapped.header.subarray(0, wrapped.header.byteLength - 1); + const tamperedTag = Buffer.from(wrapped.header); + const tamperedMaterial = Buffer.from(wrapped.header); + tamperedTag[30] = tamperedTag[30] === 0 ? 1 : 0; + tamperedMaterial[tamperedMaterial.byteLength - 1] = tamperedMaterial[tamperedMaterial.byteLength - 1] === 0 ? 1 : 0; + + expect(() => decodeVaultHeader(truncated)).toThrow('Vault header has an unexpected length.'); + expect(() => unwrapVaultMaterial(tamperedTag, firstFactor)).toThrow('Vault material could not be unwrapped.'); + expect(() => unwrapVaultMaterial(tamperedMaterial, firstFactor)).toThrow('Vault material could not be unwrapped.'); + }); + + it('rejects unexpected field lengths when encoding sidecars', () => { + const header = decodeVaultHeader(Buffer.alloc(VAULT_SIDECAR_BYTES)); + + expect(() => + encodeVaultHeader({ + ...header, + material: Buffer.alloc(VAULT_MASTER_KEY_BYTES - 1), + }), + ).toThrow('Vault header fields have unexpected lengths.'); + }); +}); diff --git a/packages/core/test/unit/secure-storage/vault-daemon-handler.test.ts b/packages/core/test/unit/secure-storage/vault-daemon-handler.test.ts new file mode 100644 index 0000000..f2021dd --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-daemon-handler.test.ts @@ -0,0 +1,281 @@ +import { Buffer } from 'node:buffer'; +import { describe, expect, it } from 'vitest'; +import { SecureStorageError } from '../../../src/secure-storage/errors.js'; +import type { + VaultBackend, + VaultPolicy, + VaultSecretPayload, + VaultStatus, +} from '../../../src/secure-storage/vault-backend.js'; +import { handleVaultIpcRequest } from '../../../src/secure-storage/vault-daemon-handler.js'; +import type { VaultIpcRequest } from '../../../src/secure-storage/vault-ipc.js'; +import type { VaultSecretReference } from '../../../src/secure-storage/vault-types.js'; + +class FakeVaultBackend implements VaultBackend { + policy: VaultPolicy = { + idleTimeoutSeconds: 28_800, + lockOnSleep: true, + }; + secret: VaultSecretPayload = { + payload: Buffer.from('secret'), + reference: { reference: 'vlt_11111111111111111111111111111111' }, + }; + unlocked = false; + + async changePassphrase(_currentUnlockFactor: Uint8Array, _nextUnlockFactor: Uint8Array): Promise {} + + changeWrappingKey(_currentWrappingKey: Uint8Array, _nextWrappingKey: Uint8Array, _nextSalt: Uint8Array): void {} + + deleteExpired(_input: { now: string }): void {} + + deleteSecret(_input: { expectedKind: 'inflow_api_key'; reference: VaultSecretReference }): void {} + + exists(_input: { expectedKind: 'inflow_api_key'; reference: VaultSecretReference }): boolean { + return true; + } + + getPolicy(): VaultPolicy { + return this.policy; + } + + getSecret(_input: { expectedKind: 'inflow_api_key'; reference: VaultSecretReference }): VaultSecretPayload { + return this.secret; + } + + lock(): void { + this.unlocked = false; + } + + putSecret(_input: { expectedKind: 'inflow_api_key'; payload: Uint8Array }): VaultSecretReference { + return this.secret.reference; + } + + reset(): void { + this.unlocked = false; + } + + setPolicy(policy: VaultPolicy): VaultPolicy { + this.policy = policy; + return policy; + } + + status(): VaultStatus { + return { daemonRunning: true, lockState: this.unlocked ? 'unlocked' : 'locked' }; + } + + touch(_input: { expectedKind: 'inflow_api_key'; reference: VaultSecretReference }): void {} + + unlock(_unlockFactor: Uint8Array): VaultStatus { + this.unlocked = true; + return this.status(); + } + + unlockSalt(): Uint8Array { + return Buffer.alloc(16); + } + + unlockWithWrappingKey(_wrappingKey: Uint8Array, _salt: Uint8Array): VaultStatus { + this.unlocked = true; + return this.status(); + } +} + +function request(method: VaultIpcRequest['method'], params: Record = {}): VaultIpcRequest { + return { id: 'req_1', method, params, version: 1 }; +} + +describe('handleVaultIpcRequest', () => { + it('dispatches generic vault and secret methods without protocol-specific behavior', async () => { + const backend = new FakeVaultBackend(); + const reference = 'vlt_11111111111111111111111111111111'; + + await expect( + handleVaultIpcRequest( + backend, + request('vault.unlock', { salt: Buffer.alloc(16), wrappingKey: Buffer.alloc(32) }), + ), + ).resolves.toMatchObject({ ok: true, result: { lockState: 'unlocked' } }); + await expect( + handleVaultIpcRequest(backend, request('daemon.info'), { + buildId: 'build-1', + cliVersion: '0.9.0', + executablePath: '/Applications/InFlow.app/Contents/MacOS/inflow', + pid: 123, + }), + ).resolves.toMatchObject({ + ok: true, + result: { + buildId: 'build-1', + cliVersion: '0.9.0', + executablePath: '/Applications/InFlow.app/Contents/MacOS/inflow', + pid: 123, + }, + }); + await expect(handleVaultIpcRequest(backend, request('vault.status'))).resolves.toMatchObject({ + ok: true, + result: { daemonRunning: true, lockState: 'unlocked' }, + }); + await expect( + handleVaultIpcRequest( + backend, + request('secret.put', { + expectedKind: 'inflow_api_key', + payload: Buffer.from('api-key'), + }), + ), + ).resolves.toMatchObject({ ok: true, result: { reference } }); + await expect( + handleVaultIpcRequest(backend, request('secret.get', { expectedKind: 'inflow_api_key', reference })), + ).resolves.toMatchObject({ + ok: true, + result: { payload: Buffer.from('secret'), reference }, + }); + await expect( + handleVaultIpcRequest(backend, request('secret.exists', { expectedKind: 'inflow_api_key', reference })), + ).resolves.toMatchObject({ ok: true, result: { exists: true } }); + await expect( + handleVaultIpcRequest(backend, request('secret.touch', { expectedKind: 'inflow_api_key', reference })), + ).resolves.toMatchObject({ ok: true, result: {} }); + await expect( + handleVaultIpcRequest(backend, request('secret.delete', { expectedKind: 'inflow_api_key', reference })), + ).resolves.toMatchObject({ ok: true, result: {} }); + await expect( + handleVaultIpcRequest(backend, request('secret.deleteExpired', { now: '2026-01-01T00:00:00.000Z' })), + ).resolves.toMatchObject({ ok: true, result: {} }); + }); + + it('dispatches policy, passphrase, reset, lock, and shutdown operations', async () => { + const backend = new FakeVaultBackend(); + const policy = { + idleTimeoutSeconds: null, + lockOnSleep: false, + }; + + await expect(handleVaultIpcRequest(backend, request('vault.setPolicy', { policy }))).resolves.toMatchObject({ + ok: true, + result: policy, + }); + await expect(handleVaultIpcRequest(backend, request('vault.getPolicy'))).resolves.toMatchObject({ + ok: true, + result: policy, + }); + await expect( + handleVaultIpcRequest( + backend, + request('vault.changePassphrase', { + currentWrappingKey: Buffer.alloc(32), + nextSalt: Buffer.alloc(16), + nextWrappingKey: Buffer.alloc(32), + }), + ), + ).resolves.toMatchObject({ ok: true, result: {} }); + await expect(handleVaultIpcRequest(backend, request('vault.reset'))).resolves.toMatchObject({ + ok: true, + result: {}, + }); + await expect(handleVaultIpcRequest(backend, request('vault.lock'))).resolves.toMatchObject({ + ok: true, + result: {}, + }); + await expect(handleVaultIpcRequest(backend, request('daemon.shutdown'))).resolves.toMatchObject({ + ok: true, + result: {}, + }); + }); + + it('rejects daemon info when identity metadata is unavailable', async () => { + const backend = new FakeVaultBackend(); + + await expect(handleVaultIpcRequest(backend, request('daemon.info'))).resolves.toMatchObject({ + error: { code: 'secure_storage_unavailable' }, + ok: false, + }); + }); + + it('rejects malformed parameters and redacts unexpected failures', async () => { + const backend = new FakeVaultBackend(); + backend.getSecret = () => { + throw new Error('secret-value leaked'); + }; + + await expect( + handleVaultIpcRequest(backend, request('secret.get', { expectedKind: 'inflow_api_key', reference: 'bad-ref' })), + ).resolves.toMatchObject({ + error: { + code: 'secure_storage_invalid_path', + message: 'Vault secret reference is malformed.', + }, + ok: false, + }); + await expect( + handleVaultIpcRequest( + backend, + request('secret.get', { + expectedKind: 'inflow_api_key', + reference: 'vlt_11111111111111111111111111111111', + }), + ), + ).resolves.toMatchObject({ + error: { + code: 'secure_storage_io_error', + message: 'The InFlow vault operation failed.', + }, + ok: false, + }); + }); + + it('rejects malformed policy, base64, kind, and optional fields', async () => { + const backend = new FakeVaultBackend(); + + for (const ipcRequest of [ + request('vault.setPolicy', { policy: null }), + request('vault.setPolicy', { + policy: { + idleTimeoutSeconds: -1, + lockOnSleep: true, + }, + }), + request('secret.put', { + expectedKind: 'inflow_api_key', + expiresAt: '', + payload: Buffer.from('api-key'), + }), + request('secret.put', { + expectedKind: 'unknown', + payload: Buffer.from('api-key'), + }), + request('vault.unlock', { salt: Buffer.alloc(16), wrappingKey: 'not-bytes' }), + ]) { + await expect(handleVaultIpcRequest(backend, ipcRequest)).resolves.toMatchObject({ + error: { + code: 'secure_storage_invalid_path', + message: 'Vault IPC request parameters are malformed.', + }, + ok: false, + }); + } + }); + + it('returns secure-storage errors without exposing payloads', async () => { + const backend = new FakeVaultBackend(); + backend.putSecret = () => { + throw new SecureStorageError('secure_storage_secret_missing', 'The InFlow vault is locked.'); + }; + + await expect( + handleVaultIpcRequest( + backend, + request('secret.put', { + expectedKind: 'inflow_api_key', + payload: Buffer.from('api-key'), + }), + ), + ).resolves.toMatchObject({ + error: { + code: 'secure_storage_secret_missing', + message: 'The InFlow vault is locked.', + }, + ok: false, + }); + }); +}); diff --git a/packages/core/test/unit/secure-storage/vault-daemon.test.ts b/packages/core/test/unit/secure-storage/vault-daemon.test.ts new file mode 100644 index 0000000..f50ccc7 --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-daemon.test.ts @@ -0,0 +1,490 @@ +import { Buffer } from 'node:buffer'; +import { spawn } from 'node:child_process'; +import { chmodSync, lstatSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { createServer, Socket } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SecureStorageError } from '../../../src/secure-storage/errors.js'; +import { sendVaultIpcRequest } from '../../../src/secure-storage/vault-socket.js'; +import { + __testing, + attachLocalVaultDaemonSignalHandlers, + runLinuxTransferredVaultService, + runLinuxTransferredVaultServiceWithRuntime, + runLinuxVaultBroker, + runLinuxVaultService, + startLinuxVaultService, + startLocalVaultDaemon, + systemdSocketFileDescriptor, + type LocalVaultDaemon, + type LocalVaultDaemonRuntime, +} from '../../../src/secure-storage/vault-daemon.js'; +import type { VaultIpcResponse } from '../../../src/secure-storage/vault-ipc.js'; + +describe('local vault daemon lifecycle', () => { + let daemon: LocalVaultDaemon | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + if (daemon !== undefined) await daemon.close(); + daemon = undefined; + if (tmpDir !== undefined) rmSync(tmpDir, { force: true, recursive: true }); + tmpDir = undefined; + }); + + it('serves generic vault IPC over the configured socket', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-daemon-')); + daemon = await startLocalVaultDaemon({ buildId: 'build-1', cliVersion: '0.9.0', rootDirectory: tmpDir }); + + await expect( + sendVaultIpcRequest(daemon.socketPath, { + id: 'req_info', + method: 'daemon.info', + params: {}, + version: 1, + }), + ).resolves.toMatchObject({ + ok: true, + result: { + buildId: 'build-1', + cliVersion: '0.9.0', + executablePath: process.execPath, + }, + }); + + await expect( + sendVaultIpcRequest(daemon.socketPath, { + id: 'req_1', + method: 'vault.unlock', + params: { salt: Buffer.alloc(16), wrappingKey: Buffer.alloc(32) }, + version: 1, + }), + ).resolves.toMatchObject({ ok: true, result: { lockState: 'unlocked' } }); + const putResponse = await sendVaultIpcRequest(daemon.socketPath, { + id: 'req_2', + method: 'secret.put', + params: { + expectedKind: 'inflow_api_key', + payload: Buffer.from('api-key'), + }, + version: 1, + }); + + expect(vaultReferenceFromPut(putResponse)).toMatch(/^vlt_[0-9a-f]{32}$/); + }); + + it('serves isolated tenants without allowing a client to stop the Linux service', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-service-')); + const socketPath = join(tmpDir, 'run', 'vault.sock'); + const peerUserIds = [1001, 1002, 1001, 1002, 1002]; + daemon = await startLinuxVaultService({ + peerVerifier() { + const uid = peerUserIds.shift(); + if (uid === undefined) throw new Error('unexpected connection'); + return { path: '/opt/inflow/bin/inflow', pid: uid, uid }; + }, + rootDirectory: join(tmpDir, 'vaults'), + socketPath, + }); + + await expect( + sendVaultIpcRequest(socketPath, { + id: 'unlock_a', + method: 'vault.unlock', + params: { salt: Buffer.alloc(16), wrappingKey: Buffer.alloc(32) }, + version: 1, + }), + ).resolves.toMatchObject({ ok: true, result: { lockState: 'unlocked' } }); + await expect( + sendVaultIpcRequest(socketPath, { + id: 'status_b', + method: 'vault.status', + params: {}, + version: 1, + }), + ).resolves.toMatchObject({ ok: true, result: { lockState: 'not_initialized' } }); + await expect( + sendVaultIpcRequest(socketPath, { + id: 'reset_a', + method: 'vault.reset', + params: {}, + version: 1, + }), + ).resolves.toMatchObject({ ok: true }); + await expect( + sendVaultIpcRequest(socketPath, { + id: 'shutdown_b', + method: 'daemon.shutdown', + params: {}, + version: 1, + }), + ).resolves.toMatchObject({ error: { code: 'secure_storage_unavailable' }, ok: false }); + await expect( + sendVaultIpcRequest(socketPath, { + id: 'status_b_after', + method: 'vault.status', + params: {}, + version: 1, + }), + ).resolves.toMatchObject({ ok: true, result: { lockState: 'not_initialized' } }); + }); + + it('accepts exactly one named systemd socket owned by the current service process', () => { + expect( + systemdSocketFileDescriptor({ LISTEN_FDNAMES: 'inflow-vault', LISTEN_FDS: '1', LISTEN_PID: '123' }, 123), + ).toBe(3); + expect(() => + systemdSocketFileDescriptor({ LISTEN_FDNAMES: 'other', LISTEN_FDS: '1', LISTEN_PID: '123' }, 123), + ).toThrow(SecureStorageError); + expect(() => + systemdSocketFileDescriptor({ LISTEN_FDNAMES: 'inflow-vault', LISTEN_FDS: '2', LISTEN_PID: '123' }, 123), + ).toThrow(SecureStorageError); + expect(() => systemdSocketFileDescriptor({}, 123)).toThrow(SecureStorageError); + expect(() => systemdSocketFileDescriptor({ LISTEN_FDS: '01', LISTEN_PID: '123' }, 123)).toThrow(SecureStorageError); + expect(() => systemdSocketFileDescriptor({ LISTEN_FDS: '1', LISTEN_PID: '9007199254740992' }, 123)).toThrow( + SecureStorageError, + ); + }); + + it('rejects unavailable Linux service entry points', async () => { + if (process.platform === 'linux') { + await expect(runLinuxVaultService()).rejects.toThrow(SecureStorageError); + await expect(runLinuxVaultBroker()).rejects.toThrow(SecureStorageError); + } else { + await expect(runLinuxVaultService()).rejects.toThrow('available only on Linux'); + await expect(runLinuxVaultBroker()).rejects.toThrow('available only on Linux'); + await expect(runLinuxTransferredVaultService()).rejects.toThrow('requires its authenticated broker'); + } + }); + + it('runs the transferred Linux service on its broker IPC channel', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-transferred-service-')); + const handlers = new Map void>(); + const exits: number[] = []; + const messages: unknown[] = []; + let messageHandler: ((message: unknown, handle: unknown) => void) | undefined; + const service = runLinuxTransferredVaultServiceWithRuntime({ rootDirectory: tmpDir }, 'linux', { + exit(code) { + exits.push(code); + }, + on(_event, handler) { + messageHandler = handler; + }, + once(event, handler) { + handlers.set(event, handler); + }, + send(message) { + messages.push(message); + handlers.get('disconnect')?.(); + }, + }); + + await service; + messageHandler?.({ type: 'invalid' }, undefined); + handlers.get('SIGINT')?.(); + handlers.get('SIGTERM')?.(); + + expect(messages).toEqual([{ type: 'vault-service-ready' }]); + expect(exits).toEqual([0]); + }); + + it('rejects a transferred service runtime on other platforms', async () => { + await expect( + runLinuxTransferredVaultServiceWithRuntime({}, 'darwin', { + exit() {}, + on() {}, + once() {}, + send() {}, + }), + ).rejects.toThrow('requires its authenticated broker'); + }); + + it('validates broker control messages and peer identities', () => { + expect(__testing.isReadyMessage({ type: 'vault-service-ready' })).toBe(true); + expect(__testing.isReadyMessage(null)).toBe(false); + expect(__testing.isReadyMessage({ type: 'other' })).toBe(false); + + expect( + __testing.isBrokerTransferMessage({ + peer: { path: '/opt/inflow/bin/inflow', pid: 123, uid: 1000 }, + type: 'vault-client', + }), + ).toBe(true); + expect(__testing.isBrokerTransferMessage(null)).toBe(false); + expect(__testing.isBrokerTransferMessage({ type: 'other' })).toBe(false); + expect(__testing.isBrokerTransferMessage({ type: 'vault-client' })).toBe(false); + expect(__testing.isBrokerTransferMessage({ peer: null, type: 'vault-client' })).toBe(false); + expect( + __testing.isBrokerTransferMessage({ + peer: { path: 1, pid: 0, uid: -1 }, + type: 'vault-client', + }), + ).toBe(false); + }); + + it('maps only configured daemon lifetime options', () => { + expect(__testing.lifetimeOptions({})).toEqual({}); + expect( + __testing.lifetimeOptions({ + sleepCheckIntervalMilliseconds: 10, + sleepDriftThresholdMilliseconds: 20, + }), + ).toEqual({ + sleepCheckIntervalMilliseconds: 10, + sleepDriftThresholdMilliseconds: 20, + }); + }); + + it('waits for a ready child and rejects a child that exits first', async () => { + const ready = spawn(process.execPath, ['-e', "process.send({ type: 'vault-service-ready' })"], { + stdio: ['ignore', 'ignore', 'inherit', 'ipc'], + }); + const readyExited = new Promise((resolve) => ready.once('exit', () => resolve())); + await expect(__testing.waitForVaultServiceReady(ready)).resolves.toBeUndefined(); + if (ready.connected) ready.disconnect(); + await readyExited; + + const stopped = spawn(process.execPath, ['-e', 'process.exit(1)'], { + stdio: ['ignore', 'ignore', 'inherit', 'ipc'], + }); + await expect(__testing.waitForVaultServiceReady(stopped)).rejects.toThrow( + 'The InFlow vault service did not start.', + ); + + const failed = spawn('/definitely/missing/inflow'); + await expect(__testing.waitForVaultServiceReady(failed)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('validates the dedicated Linux service identity directory', () => { + const identityDirectory = mkdtempSync(join(tmpdir(), 'inflow-vault-identity-')); + tmpDir = identityDirectory; + chmodSync(identityDirectory, 0o700); + const identityStat = lstatSync(identityDirectory); + if (identityStat.uid === 0 || identityStat.gid === 0) { + expect(() => __testing.linuxVaultServiceIdentity(identityDirectory)).toThrow('service identity is invalid'); + } else { + expect(__testing.linuxVaultServiceIdentity(identityDirectory)).toMatchObject({ + gid: identityStat.gid, + uid: identityStat.uid, + }); + } + + const file = join(identityDirectory, 'file'); + const link = join(identityDirectory, 'link'); + writeFileSync(file, ''); + symlinkSync(identityDirectory, link); + expect(() => __testing.linuxVaultServiceIdentity(file)).toThrow('service identity is invalid'); + expect(() => __testing.linuxVaultServiceIdentity(link)).toThrow('service identity is invalid'); + chmodSync(identityDirectory, 0o755); + expect(() => __testing.linuxVaultServiceIdentity(identityDirectory)).toThrow('service identity is invalid'); + }); + + it('rejects invalid broker descriptors and destroys sockets when the service IPC channel is closed', async () => { + const server = createServer(); + await expect(__testing.listenBroker(server, -1)).rejects.toBeDefined(); + + const child = spawn(process.execPath, ['-e', ''], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }); + await new Promise((resolve) => child.once('exit', () => resolve())); + const socket = new Socket(); + const destroy = vi.spyOn(socket, 'destroy'); + __testing.transferSocketToVaultService(child, socket, { + path: '/opt/inflow/bin/inflow', + pid: 123, + uid: 1000, + }); + expect(destroy).toHaveBeenCalledOnce(); + }); + + it('closes listening and already-closed broker servers', async () => { + const idle = createServer(); + await expect(__testing.closeBroker(idle)).resolves.toBeUndefined(); + + const listening = createServer(); + await new Promise((resolve, reject) => { + listening.once('error', reject); + listening.listen(0, '127.0.0.1', resolve); + }); + await expect(__testing.closeBroker(listening)).resolves.toBeUndefined(); + }); + + it('closes the daemon before exiting on termination signals', async () => { + const handlers = new Map<'SIGINT' | 'SIGTERM', () => Promise>(); + const exits: number[] = []; + let closed = 0; + const runtime: LocalVaultDaemonRuntime = { + exit(code) { + exits.push(code); + }, + once(signal, handler) { + handlers.set(signal, handler); + }, + }; + + attachLocalVaultDaemonSignalHandlers( + { + close() { + closed += 1; + return Promise.resolve(); + }, + closed: Promise.resolve(), + socketPath: '/tmp/inflow-test-vault.sock', + }, + runtime, + ); + + await handlers.get('SIGTERM')?.(); + + expect(closed).toBe(1); + expect(exits).toEqual([0]); + expect(handlers.has('SIGINT')).toBe(true); + }); + + it('stops accepting IPC after a reset request', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-daemon-')); + daemon = await startLocalVaultDaemon({ rootDirectory: tmpDir }); + + await expect( + sendVaultIpcRequest(daemon.socketPath, { + id: 'req_reset', + method: 'vault.reset', + params: {}, + version: 1, + }), + ).resolves.toMatchObject({ ok: true }); + await new Promise((resolve) => setTimeout(resolve, 25)); + await expectClosed(daemon.closed); + + await expect( + sendVaultIpcRequest(daemon.socketPath, { + id: 'req_after_reset', + method: 'vault.status', + params: {}, + version: 1, + }), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('stops accepting IPC after a shutdown request', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-daemon-')); + daemon = await startLocalVaultDaemon({ rootDirectory: tmpDir }); + + await expect( + sendVaultIpcRequest(daemon.socketPath, { + id: 'req_shutdown', + method: 'daemon.shutdown', + params: {}, + version: 1, + }), + ).resolves.toMatchObject({ ok: true }); + await new Promise((resolve) => setTimeout(resolve, 25)); + await expectClosed(daemon.closed); + + await expect( + sendVaultIpcRequest(daemon.socketPath, { + id: 'req_after_shutdown', + method: 'vault.status', + params: {}, + version: 1, + }), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('stops accepting IPC after the configured idle timeout', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-daemon-')); + daemon = await startLocalVaultDaemon({ rootDirectory: tmpDir }); + + await sendVaultIpcRequest(daemon.socketPath, { + id: 'req_unlock', + method: 'vault.unlock', + params: { salt: Buffer.alloc(16), wrappingKey: Buffer.alloc(32) }, + version: 1, + }); + await expect( + sendVaultIpcRequest(daemon.socketPath, { + id: 'req_policy', + method: 'vault.setPolicy', + params: { + policy: { + idleTimeoutSeconds: 1, + lockOnSleep: true, + }, + }, + version: 1, + }), + ).resolves.toMatchObject({ ok: true }); + await new Promise((resolve) => setTimeout(resolve, 1_100)); + await expectClosed(daemon.closed); + + await expect( + sendVaultIpcRequest(daemon.socketPath, { + id: 'req_after_idle', + method: 'vault.status', + params: {}, + version: 1, + }), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('stops accepting IPC after sleep-like timer drift when sleep locking is enabled', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-daemon-')); + daemon = await startLocalVaultDaemon({ + rootDirectory: tmpDir, + sleepCheckIntervalMilliseconds: 10, + sleepDriftThresholdMilliseconds: 25, + }); + + await sendVaultIpcRequest(daemon.socketPath, { + id: 'req_unlock', + method: 'vault.unlock', + params: { salt: Buffer.alloc(16), wrappingKey: Buffer.alloc(32) }, + version: 1, + }); + await sendVaultIpcRequest(daemon.socketPath, { + id: 'req_policy', + method: 'vault.setPolicy', + params: { + policy: { + idleTimeoutSeconds: null, + lockOnSleep: true, + }, + }, + version: 1, + }); + + blockEventLoop(80); + await new Promise((resolve) => setTimeout(resolve, 30)); + await expectClosed(daemon.closed); + + await expect( + sendVaultIpcRequest(daemon.socketPath, { + id: 'req_after_sleep', + method: 'vault.status', + params: {}, + version: 1, + }), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); +}); + +async function expectClosed(closed: Promise): Promise { + await expect(Promise.race([closed.then(() => 'closed'), delay(500).then(() => 'open')])).resolves.toBe('closed'); +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function vaultReferenceFromPut(response: VaultIpcResponse): string { + if (!response.ok) throw new Error('expected successful put response'); + const reference = response.result['reference']; + if (typeof reference !== 'string') throw new Error('expected string reference'); + return reference; +} + +function blockEventLoop(milliseconds: number): void { + const deadline = Date.now() + milliseconds; + while (Date.now() < deadline) { + Math.sqrt(deadline); + } +} diff --git a/packages/core/test/unit/secure-storage/vault-files.test.ts b/packages/core/test/unit/secure-storage/vault-files.test.ts new file mode 100644 index 0000000..9cec5ad --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-files.test.ts @@ -0,0 +1,126 @@ +import { lstatSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'; +import { lstat, readdir, rm } from 'node:fs/promises'; +import { createServer, type Server } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import process from 'node:process'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + defaultVaultRoot, + linuxVaultServiceUserId, + removeVaultLocalState, + usesLinuxVaultService, + vaultFilePaths, +} from '../../../src/secure-storage/vault-files.js'; + +describe('vault file cleanup', () => { + let tmpDir: string; + const originalPlatform = process.platform; + const originalExecPath = process.execPath; + const originalXdgDataHome = process.env['XDG_DATA_HOME']; + let server: Server | undefined; + + beforeEach(() => { + tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'inflow-vault-files-'))); + }); + + afterEach(async () => { + await new Promise((resolve) => server?.close(() => resolve()) ?? resolve()); + server = undefined; + Object.defineProperty(process, 'platform', { value: originalPlatform }); + Object.defineProperty(process, 'execPath', { value: originalExecPath }); + if (originalXdgDataHome === undefined) { + delete process.env['XDG_DATA_HOME']; + } else { + process.env['XDG_DATA_HOME'] = originalXdgDataHome; + } + await rm(tmpDir, { force: true, recursive: true }); + }); + + it('uses the Linux data directory for vault state', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + process.env['XDG_DATA_HOME'] = tmpDir; + + expect(defaultVaultRoot()).toBe(join(tmpDir, 'inflow')); + }); + + it('uses the platform fallback data directory outside macOS and Linux', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + + expect(defaultVaultRoot()).toMatch(/\.inflow$/); + expect(usesLinuxVaultService()).toBe(false); + }); + + it('computes the database, sidecar, write-ahead log, shared-memory, and socket paths under one root', () => { + const paths = vaultFilePaths(tmpDir); + + expect(paths).toEqual({ + database: join(tmpDir, 'inflow.sqlite3'), + runDirectory: join(tmpDir, 'run'), + sharedMemory: join(tmpDir, 'inflow.sqlite3-shm'), + sidecar: join(tmpDir, 'inflow.vault'), + socket: join(tmpDir, 'run', 'vault.sock'), + writeAheadLog: join(tmpDir, 'inflow.sqlite3-wal'), + }); + }); + + it('uses the systemd socket for the installed Linux executable', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + Object.defineProperty(process, 'execPath', { value: '/opt/inflow/bin/inflow' }); + + expect(usesLinuxVaultService()).toBe(true); + expect(vaultFilePaths().socket).toBe('/run/inflow/vault.sock'); + }); + + it('reads a safely contained vault service socket owner', async () => { + const socketPath = join(tmpDir, 'vault.sock'); + server = createServer(); + await new Promise((resolve) => server?.listen(socketPath, resolve)); + + expect(linuxVaultServiceUserId(socketPath)).toBe(lstatSync(socketPath).uid); + }); + + it('distinguishes a missing service socket from an invalid socket identity', () => { + const missing = join(tmpDir, 'missing.sock'); + const invalid = join(tmpDir, 'invalid.sock'); + writeFileSync(invalid, ''); + + expect(captureError(() => linuxVaultServiceUserId(missing))).toMatchObject({ + secureStorageCode: 'secure_storage_unavailable', + }); + expect(captureError(() => linuxVaultServiceUserId(invalid))).toMatchObject({ + secureStorageCode: 'secure_storage_peer_verification_failed', + }); + }); + + it('removes local vault state files while leaving unrelated files in place', async () => { + const paths = vaultFilePaths(tmpDir); + mkdirSync(paths.runDirectory); + for (const filePath of [paths.database, paths.writeAheadLog, paths.sharedMemory, paths.sidecar, paths.socket]) { + writeFileSync(filePath, 'x'); + } + writeFileSync(join(tmpDir, 'unrelated'), 'keep'); + + await removeVaultLocalState(paths); + + await expect(lstat(paths.database)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(lstat(paths.writeAheadLog)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(lstat(paths.sharedMemory)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(lstat(paths.sidecar)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(lstat(paths.socket)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(readdir(tmpDir)).resolves.toEqual(['run', 'unrelated']); + }); + + it('is idempotent when local vault state files are absent', async () => { + await expect(removeVaultLocalState(vaultFilePaths(tmpDir))).resolves.toBeUndefined(); + }); +}); + +function captureError(run: () => unknown): unknown { + try { + run(); + } catch (cause) { + return cause; + } + throw new Error('expected error'); +} diff --git a/packages/core/test/unit/secure-storage/vault-ipc.test.ts b/packages/core/test/unit/secure-storage/vault-ipc.test.ts new file mode 100644 index 0000000..a0b3969 --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-ipc.test.ts @@ -0,0 +1,152 @@ +import { Buffer } from 'node:buffer'; +import { describe, expect, it } from 'vitest'; +import { + VAULT_IPC_MAX_MESSAGE_BYTES, + VAULT_IPC_METHODS, + clearVaultIpcBytes, + decodeVaultIpcFrame, + encodeVaultIpcMessage, + type VaultIpcMethod, +} from '../../../src/secure-storage/vault-ipc.js'; + +describe('vault IPC framing', () => { + it('round-trips length-prefixed requests and responses', () => { + const request = { + id: 'req_1', + method: 'vault.status' as const, + params: {}, + version: 1 as const, + }; + const success = { + id: 'req_1', + ok: true as const, + result: { lockState: 'locked' }, + version: 1 as const, + }; + const failure = { + error: { code: 'VAULT_LOCKED', message: 'The InFlow vault is locked.' }, + id: 'req_1', + ok: false as const, + version: 1 as const, + }; + + expect(decodeVaultIpcFrame(encodeVaultIpcMessage(request))).toEqual(request); + expect(decodeVaultIpcFrame(encodeVaultIpcMessage(success))).toEqual(success); + expect(decodeVaultIpcFrame(encodeVaultIpcMessage(failure))).toEqual(failure); + }); + + it('keeps the method list generic and protocol-free', () => { + const methods = VAULT_IPC_METHODS satisfies readonly VaultIpcMethod[]; + + expect(methods).toContain('secret.get'); + expect(methods).not.toContain('aep.grant' as VaultIpcMethod); + expect(methods).not.toContain('mpp.pay' as VaultIpcMethod); + expect(methods).not.toContain('x402.pay' as VaultIpcMethod); + expect(methods).not.toContain('fetch' as VaultIpcMethod); + expect(methods).not.toContain('sign' as VaultIpcMethod); + }); + + it('keeps sensitive bytes out of immutable JSON values', () => { + const secret = Buffer.from('binary-only-secret'); + const frame = encodeVaultIpcMessage({ + id: 'req_secret', + method: 'secret.put', + params: { expectedKind: 'inflow_api_key', payload: secret }, + version: 1, + }); + const jsonLength = frame.readUInt32BE(4); + const json = frame.subarray(12, 12 + jsonLength).toString('utf8'); + + expect(json).not.toContain(secret.toString('utf8')); + expect(decodeVaultIpcFrame(frame)).toMatchObject({ + params: { payload: secret }, + }); + }); + + it('rejects malformed, trailing, oversized, and unknown-method frames', () => { + const request = { + id: 'req_1', + method: 'vault.status' as const, + params: {}, + version: 1 as const, + }; + const frame = encodeVaultIpcMessage(request); + const oversized = Buffer.alloc(12); + oversized.writeUInt32BE(VAULT_IPC_MAX_MESSAGE_BYTES + 1, 0); + + expect(() => decodeVaultIpcFrame(frame.subarray(0, 3))).toThrow('Vault IPC frame is truncated.'); + expect(() => decodeVaultIpcFrame(Buffer.concat([frame, Buffer.from([0])]))).toThrow( + 'Vault IPC frame length is invalid.', + ); + expect(() => decodeVaultIpcFrame(oversized)).toThrow('Vault IPC message is too large.'); + expect(() => + decodeVaultIpcFrame( + encodeVaultIpcMessage({ + ...request, + method: 'aep.grant' as VaultIpcMethod, + }), + ), + ).toThrow('Vault IPC request is malformed.'); + }); + + it('rejects unknown versions and malformed responses', () => { + const unknownVersionFrame = rawFrame({ id: 'req_1', params: {}, version: 2 }); + const malformedResponseFrame = rawFrame({ id: 'req_1', ok: false, version: 1 }); + + expect(() => decodeVaultIpcFrame(unknownVersionFrame)).toThrow('Vault IPC message is malformed.'); + expect(() => decodeVaultIpcFrame(malformedResponseFrame)).toThrow('Vault IPC response is malformed.'); + }); + + it('rejects malformed attachment framing and references', () => { + expect(() => + encodeVaultIpcMessage({ + id: 'large', + method: 'secret.put', + params: { expectedKind: 'inflow_api_key', payload: Buffer.alloc(VAULT_IPC_MAX_MESSAGE_BYTES) }, + version: 1, + }), + ).toThrow('Vault IPC message is too large.'); + + expect(() => decodeVaultIpcFrame(rawFrame({}, { jsonLength: 100 }))).toThrow( + 'Vault IPC frame attachments are malformed.', + ); + expect(() => decodeVaultIpcFrame(rawFrame({}, { attachmentCount: 1 }))).toThrow( + 'Vault IPC frame attachments are malformed.', + ); + expect(() => + decodeVaultIpcFrame(rawFrame({}, { attachment: Buffer.from([0, 0, 0, 4]), attachmentCount: 1 })), + ).toThrow('Vault IPC frame attachments are malformed.'); + expect(() => + decodeVaultIpcFrame(rawFrame({}, { attachment: Buffer.from([0, 0, 0, 1, 0]), attachmentCount: 1 })), + ).toThrow('Vault IPC attachment masking is malformed.'); + expect(() => decodeVaultIpcFrame(rawFrame({}, { attachment: Buffer.from([0]) }))).toThrow( + 'Vault IPC frame attachments are malformed.', + ); + expect(() => + decodeVaultIpcFrame(rawFrame({ id: 'req', params: { payload: { $inflowVaultAttachment: 0 } }, version: 1 })), + ).toThrow('Vault IPC attachment reference is malformed.'); + }); + + it('clears nested mutable IPC byte values', () => { + const first = Buffer.from('first'); + const second = Buffer.from('second'); + clearVaultIpcBytes({ nested: [first, { second }], scalar: 'unchanged' }); + expect(first).toEqual(Buffer.alloc(5)); + expect(second).toEqual(Buffer.alloc(6)); + }); +}); + +function rawFrame( + value: unknown, + options: { attachment?: Buffer; attachmentCount?: number; jsonLength?: number } = {}, +): Buffer { + const json = Buffer.from(JSON.stringify(value), 'utf8'); + const attachment = options.attachment ?? Buffer.alloc(0); + const frame = Buffer.alloc(12 + json.byteLength + attachment.byteLength); + frame.writeUInt32BE(8 + json.byteLength + attachment.byteLength, 0); + frame.writeUInt32BE(options.jsonLength ?? json.byteLength, 4); + frame.writeUInt32BE(options.attachmentCount ?? 0, 8); + json.copy(frame, 12); + attachment.copy(frame, 12 + json.byteLength); + return frame; +} diff --git a/packages/core/test/unit/secure-storage/vault-local-backend.test.ts b/packages/core/test/unit/secure-storage/vault-local-backend.test.ts new file mode 100644 index 0000000..a49482c --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-local-backend.test.ts @@ -0,0 +1,318 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { SecureSqliteRepository } from '../../../src/secure-storage/sqlite.js'; +import { LocalVaultBackend } from '../../../src/secure-storage/vault-local-backend.js'; +import { vaultFilePaths } from '../../../src/secure-storage/vault-files.js'; +import { parseVaultSecretReference } from '../../../src/secure-storage/vault-types.js'; + +describe('LocalVaultBackend', () => { + let tmpDir: string; + let repository: SecureSqliteRepository; + let backend: LocalVaultBackend; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-local-vault-')); + repository = new SecureSqliteRepository({ rootDir: tmpDir }); + backend = new LocalVaultBackend({ + now: () => new Date('2026-01-01T00:00:00.000Z'), + paths: vaultFilePaths(tmpDir), + repository, + sidecarPath: join(tmpDir, 'inflow.vault'), + }); + }); + + afterEach(() => { + repository.close(); + rmSync(tmpDir, { force: true, recursive: true }); + }); + + it('creates the vault on first unlock and round-trips encrypted secrets by exact kind', async () => { + await expect(backend.status()).resolves.toEqual({ daemonRunning: false, lockState: 'not_initialized' }); + await expect(backend.unlock(Buffer.from('123456'))).resolves.toEqual({ + daemonRunning: false, + lockState: 'unlocked', + }); + + const reference = backend.putSecret({ + expectedKind: 'inflow_api_key', + payload: Buffer.from('secret-api-key'), + }); + + expect(parseVaultSecretReference(reference.reference)).toEqual(reference); + expect(backend.exists({ expectedKind: 'inflow_api_key', reference })).toBe(true); + expect(backend.exists({ expectedKind: 'auth_access_token', reference })).toBe(false); + expect(backend.getSecret({ expectedKind: 'inflow_api_key', reference })).toMatchObject({ + payload: Buffer.from('secret-api-key'), + reference, + }); + expect(() => backend.getSecret({ expectedKind: 'auth_access_token', reference })).toThrow( + 'A referenced vault secret is missing.', + ); + try { + backend.getSecret({ expectedKind: 'auth_access_token', reference }); + } catch (cause) { + expect(cause).toMatchObject({ + secureStorageCode: 'secure_storage_secret_missing', + }); + } + }); + + it('stores a secret at an exact caller-provided vault reference', async () => { + await backend.unlock(Buffer.from('123456')); + const reference = parseVaultSecretReference('vlt_11111111111111111111111111111111'); + + expect( + backend.putSecret({ + expectedKind: 'inflow_api_key', + payload: Buffer.from('secret-api-key'), + reference, + }), + ).toEqual(reference); + expect(backend.getSecret({ expectedKind: 'inflow_api_key', reference })).toMatchObject({ + payload: Buffer.from('secret-api-key'), + }); + }); + + it('refuses to overwrite an existing caller-provided vault reference', async () => { + await backend.unlock(Buffer.from('123456')); + const reference = parseVaultSecretReference('vlt_11111111111111111111111111111111'); + backend.putSecret({ + expectedKind: 'inflow_api_key', + payload: Buffer.from('original'), + reference, + }); + + expect(() => + backend.putSecret({ + expectedKind: 'inflow_api_key', + payload: Buffer.from('replacement'), + reference, + }), + ).toThrow('The vault secret reference already exists.'); + expect(backend.getSecret({ expectedKind: 'inflow_api_key', reference }).payload).toEqual(Buffer.from('original')); + }); + + it('rejects locked reads with the stable storage code', async () => { + await backend.unlock(Buffer.from('123456')); + const reference = backend.putSecret({ + expectedKind: 'auth_refresh_token', + payload: Buffer.from('refresh-token'), + }); + backend.lock(); + + try { + backend.getSecret({ expectedKind: 'auth_refresh_token', reference }); + } catch (cause) { + expect(cause).toMatchObject({ + secureStorageCode: 'vault_locked', + }); + return; + } + throw new Error('expected locked vault read to fail'); + }); + + it('locks, unlocks with the existing sidecar, and rejects the old passphrase after rotation', async () => { + await backend.unlock(Buffer.from('123456')); + const reference = backend.putSecret({ + expectedKind: 'auth_refresh_token', + payload: Buffer.from('refresh-token'), + }); + backend.lock(); + expect(() => backend.getSecret({ expectedKind: 'auth_refresh_token', reference })).toThrow( + 'The InFlow vault is locked.', + ); + + await backend.unlock(Buffer.from('123456')); + expect(backend.getSecret({ expectedKind: 'auth_refresh_token', reference })).toMatchObject({ + payload: Buffer.from('refresh-token'), + }); + + const before = await readFile(join(tmpDir, 'inflow.vault')); + await backend.changePassphrase(Buffer.from('123456'), Buffer.from('654321')); + const after = await readFile(join(tmpDir, 'inflow.vault')); + + expect(after).not.toEqual(before); + backend.lock(); + await expect(backend.unlock(Buffer.from('123456'))).rejects.toMatchObject({ + secureStorageCode: 'secure_storage_corrupt', + }); + await expect(backend.unlock(Buffer.from('654321'))).resolves.toMatchObject({ lockState: 'unlocked' }); + }, 15_000); + + it('fails closed when encrypted record metadata is changed', async () => { + await backend.unlock(Buffer.from('123456')); + const reference = backend.putSecret({ + expectedKind: 'aep_credential', + payload: Buffer.from('credential'), + }); + + repository.markVaultRecordStatus(reference.reference, 'deleting', '2026-01-01T00:01:00.000Z'); + try { + backend.getSecret({ expectedKind: 'aep_credential', reference }); + } catch (cause) { + expect(cause).toMatchObject({ + secureStorageCode: 'secure_storage_secret_missing', + }); + return; + } + throw new Error('expected metadata tamper to fail'); + }); + + it('fails closed when ciphertext, tag, or encryption version is changed', async () => { + await backend.unlock(Buffer.from('123456')); + const reference = backend.putSecret({ + expectedKind: 'pending_device_code', + payload: Buffer.from('device-code'), + }); + const record = repository.getVaultRecord(reference.reference, 'pending_device_code'); + expect(record).toBeDefined(); + if (record === undefined) throw new Error('expected stored vault record'); + + repository.deleteVaultRecord(record.reference); + repository.putVaultRecord({ + ...record, + ciphertext: Buffer.from('changed'), + }); + expect(() => backend.getSecret({ expectedKind: 'pending_device_code', reference })).toThrow( + 'Vault record could not be decrypted.', + ); + + repository.deleteVaultRecord(record.reference); + repository.putVaultRecord({ + ...record, + tag: Buffer.from('changed-tag-0000'), + }); + expect(() => backend.getSecret({ expectedKind: 'pending_device_code', reference })).toThrow( + 'Vault record could not be decrypted.', + ); + + repository.deleteVaultRecord(record.reference); + repository.putVaultRecord({ + ...record, + encryptionVersion: 2, + }); + expect(() => backend.getSecret({ expectedKind: 'pending_device_code', reference })).toThrow( + 'Vault record could not be decrypted.', + ); + }); + + it('deletes, expires, touches, and stores policy without exposing payload listing', async () => { + await backend.unlock(Buffer.from('123456')); + const active = backend.putSecret({ + expectedKind: 'auth_access_token', + payload: Buffer.from('access-token'), + }); + const expired = backend.putSecret({ + expectedKind: 'auth_access_token', + expiresAt: '2000-01-01T00:00:00.000Z', + payload: Buffer.from('expired-token'), + }); + + expect(backend.exists({ expectedKind: 'auth_access_token', reference: expired })).toBe(false); + backend.touch({ expectedKind: 'auth_access_token', reference: active }); + backend.deleteExpired({ now: '2026-01-01T00:00:00.000Z' }); + expect(backend.exists({ expectedKind: 'auth_access_token', reference: expired })).toBe(false); + backend.deleteSecret({ expectedKind: 'auth_access_token', reference: active }); + expect(backend.exists({ expectedKind: 'auth_access_token', reference: active })).toBe(false); + + expect(backend.getPolicy()).toMatchObject({ idleTimeoutSeconds: 28_800 }); + expect( + backend.setPolicy({ + idleTimeoutSeconds: null, + lockOnSleep: false, + }), + ).toMatchObject({ idleTimeoutSeconds: null, lockOnSleep: false }); + }); + + it('resets the local vault database, sidecar, and runtime artifacts', async () => { + const paths = vaultFilePaths(tmpDir); + await backend.unlock(Buffer.from('123456')); + backend.putSecret({ + expectedKind: 'inflow_api_key', + payload: Buffer.from('api-key'), + }); + mkdirSync(paths.runDirectory); + writeFileSync(paths.writeAheadLog, ''); + writeFileSync(paths.sharedMemory, ''); + writeFileSync(paths.socket, ''); + + await backend.reset(); + + expect(existsSync(paths.database)).toBe(false); + expect(existsSync(paths.writeAheadLog)).toBe(false); + expect(existsSync(paths.sharedMemory)).toBe(false); + expect(existsSync(paths.sidecar)).toBe(false); + expect(existsSync(paths.socket)).toBe(false); + await expect(backend.status()).resolves.toEqual({ daemonRunning: false, lockState: 'not_initialized' }); + }); + + it('rejects malformed policy settings and sidecars', async () => { + repository.initialize(); + repository.upsertSetting('vault-policy', { idleTimeoutSeconds: -1 }); + expect(() => backend.getPolicy()).toThrow('The vault policy is malformed.'); + + await writeFile(join(tmpDir, 'inflow.vault'), Buffer.from('bad')); + await expect(backend.unlock(Buffer.from('123456'))).rejects.toMatchObject({ + secureStorageCode: 'secure_storage_corrupt', + }); + }); + + it('returns a stable unlock salt and rejects a changed salt for wrapping-key authentication', async () => { + const firstSalt = await backend.unlockSalt(); + expect(firstSalt).toHaveLength(16); + const wrappingKey = Buffer.alloc(32, 7); + await expect(backend.unlockWithWrappingKey(wrappingKey, firstSalt)).resolves.toMatchObject({ + lockState: 'unlocked', + }); + backend.lock(); + + expect(await backend.unlockSalt()).toEqual(firstSalt); + const changedSalt = Buffer.from(firstSalt); + changedSalt[0] = (changedSalt[0] ?? 0) ^ 1; + await expect(backend.unlockWithWrappingKey(wrappingKey, changedSalt)).rejects.toMatchObject({ + secureStorageCode: 'secure_storage_corrupt', + message: 'Vault unlock salt changed.', + }); + }); + + it('rotates wrapping keys without changing stored secret material', async () => { + const originalSalt = await backend.unlockSalt(); + const originalWrappingKey = Buffer.alloc(32, 7); + await backend.unlockWithWrappingKey(originalWrappingKey, originalSalt); + const reference = backend.putSecret({ + expectedKind: 'auth_refresh_token', + payload: Buffer.from('refresh-token'), + }); + const nextSalt = Buffer.alloc(originalSalt.byteLength, 9); + const nextWrappingKey = Buffer.alloc(32, 8); + + await backend.changeWrappingKey(originalWrappingKey, nextWrappingKey, nextSalt); + backend.lock(); + + await expect(backend.unlockWithWrappingKey(originalWrappingKey, originalSalt)).rejects.toMatchObject({ + secureStorageCode: 'secure_storage_corrupt', + }); + await expect(backend.unlockWithWrappingKey(nextWrappingKey, nextSalt)).resolves.toMatchObject({ + lockState: 'unlocked', + }); + expect(backend.getSecret({ expectedKind: 'auth_refresh_token', reference }).payload).toEqual( + Buffer.from('refresh-token'), + ); + }); + + it('rejects non-object, array, and incorrectly typed policy settings', () => { + repository.initialize(); + for (const policy of [ + null, + [], + { idleTimeoutSeconds: 1.5, lockOnSleep: true }, + { idleTimeoutSeconds: 1, lockOnSleep: 'yes' }, + ]) { + repository.upsertSetting('vault-policy', policy); + expect(() => backend.getPolicy()).toThrow('The vault policy is malformed.'); + } + }); +}); diff --git a/packages/core/test/unit/secure-storage/vault-peer-verifier.test.ts b/packages/core/test/unit/secure-storage/vault-peer-verifier.test.ts new file mode 100644 index 0000000..de646a2 --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-peer-verifier.test.ts @@ -0,0 +1,437 @@ +import { Socket } from 'node:net'; +import { createHash } from 'node:crypto'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import process from 'node:process'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SecureStorageError } from '../../../src/secure-storage/errors.js'; +import { + createVaultSocketPeerVerifier, + shouldRequireVaultPeerVerification, + socketFileDescriptor, + type VaultSocketPeer, + type VaultPeerVerificationConfig, + verifyVaultNativeModule, + verifyTransferredVaultSocketPeerWithDependencies, +} from '../../../src/secure-storage/vault-peer-verifier.js'; + +describe('vault peer verifier', () => { + const originalPlatform = process.platform; + const originalExecPath = process.execPath; + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + Object.defineProperty(process, 'execPath', { value: originalExecPath }); + }); + + it('requires peer verification for packaged macOS app execution', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + Object.defineProperty(process, 'execPath', { value: '/Applications/InFlow.app/Contents/MacOS/inflow' }); + + expect(shouldRequireVaultPeerVerification()).toBe(true); + }); + + it('requires peer verification when the executable path is a symlink into the macOS app', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'inflow-peer-symlink-')); + try { + const appExecutable = join(tmpDir, 'InFlow.app', 'Contents', 'MacOS', 'inflow'); + const symlink = join(tmpDir, 'bin', 'inflow'); + mkdirSync(join(tmpDir, 'InFlow.app', 'Contents', 'MacOS'), { recursive: true }); + mkdirSync(join(tmpDir, 'bin')); + writeFileSync(appExecutable, ''); + symlinkSync(appExecutable, symlink); + Object.defineProperty(process, 'platform', { value: 'darwin' }); + Object.defineProperty(process, 'execPath', { value: symlink }); + + expect(shouldRequireVaultPeerVerification()).toBe(true); + } finally { + rmSync(tmpDir, { force: true, recursive: true }); + } + }); + + it('requires peer verification on Linux', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + Object.defineProperty(process, 'execPath', { value: '/opt/inflow/bin/inflow' }); + + expect(shouldRequireVaultPeerVerification()).toBe(true); + }); + + it('accepts same-user same-executable peers and verifies release signatures', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + const verified: { path: string; teamId: string }[] = []; + const verifier = createVaultSocketPeerVerifier( + { + expectedExecutablePath: '/Applications/InFlow.app/Contents/MacOS/inflow', + nativeModulePath: '/native/vault_peer_darwin.node', + requireSignature: true, + }, + dependencies({ + currentUserId: 501, + peer: { path: '/Applications/InFlow.app/Contents/MacOS/inflow', pid: 123, uid: 501 }, + realpaths: new Map([['/Applications/InFlow.app/Contents/MacOS/inflow', '/signed/inflow']]), + verifySignature(path, teamId) { + verified.push({ path, teamId }); + }, + }), + ); + + expect(verifier(socketWithFd(42))).toEqual({ + path: '/Applications/InFlow.app/Contents/MacOS/inflow', + pid: 123, + uid: 501, + }); + expect(verified).toEqual([{ path: '/Applications/InFlow.app/Contents/MacOS/inflow', teamId: 'B96U57DTR2' }]); + }); + + it('accepts an explicit expected Team ID for tests and future packaging variants', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + const verified: { path: string; teamId: string }[] = []; + const verifier = createVaultSocketPeerVerifier( + { + expectedExecutablePath: '/Applications/InFlow.app/Contents/MacOS/inflow', + expectedTeamId: 'TEAM123456', + nativeModulePath: '/native/vault_peer_darwin.node', + requireSignature: true, + }, + dependencies({ + currentUserId: 501, + peer: { path: '/Applications/InFlow.app/Contents/MacOS/inflow', pid: 123, uid: 501 }, + realpaths: new Map([['/Applications/InFlow.app/Contents/MacOS/inflow', '/signed/inflow']]), + verifySignature(path, teamId) { + verified.push({ path, teamId }); + }, + }), + ); + + expect(verifier(socketWithFd(42))).toEqual({ + path: '/Applications/InFlow.app/Contents/MacOS/inflow', + pid: 123, + uid: 501, + }); + expect(verified).toEqual([{ path: '/Applications/InFlow.app/Contents/MacOS/inflow', teamId: 'TEAM123456' }]); + }); + + it('rejects wrong-user, wrong-executable, and signature-failed peers', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + + expect(() => peerVerifierFor({ path: '/usr/bin/inflow', pid: 123, uid: 502 })).toThrow(SecureStorageError); + expect(() => peerVerifierFor({ path: '/tmp/fake-inflow', pid: 123, uid: 501 })).toThrow(SecureStorageError); + expect(() => + peerVerifierFor( + { path: '/usr/bin/inflow', pid: 123, uid: 501 }, + { + verifySignature() { + throw new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); + }, + }, + ), + ).toThrow(SecureStorageError); + }); + + it('uses stable secure storage error codes for rejected peers', () => { + expect(peerVerifierError({ path: '/usr/bin/inflow', pid: 123, uid: 502 })).toMatchObject({ + secureStorageCode: 'secure_storage_peer_verification_failed', + }); + expect(peerVerifierError({ path: '/tmp/fake-inflow', pid: 123, uid: 501 })).toMatchObject({ + secureStorageCode: 'secure_storage_peer_verification_failed', + }); + expect( + peerVerifierError( + { path: '/usr/bin/inflow', pid: 123, uid: 501 }, + { + verifySignature() { + throw new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); + }, + }, + ), + ).toMatchObject({ secureStorageCode: 'secure_storage_peer_verification_failed' }); + }); + + it('accepts same-user same-executable Linux peers without a signing check', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + const verified = vi.fn(); + const verifier = createVaultSocketPeerVerifier( + { + expectedExecutablePath: '/opt/inflow/bin/inflow', + nativeModulePath: '/opt/inflow/lib/inflow/native/vault_peer_linux.node', + }, + dependencies({ + currentUserId: 1000, + peer: { path: '/opt/inflow/bin/inflow', pid: 123, uid: 1000 }, + realpaths: new Map([['/opt/inflow/bin/inflow', '/opt/inflow/bin/inflow']]), + verifySignature: verified, + }), + ); + + expect(verifier(socketWithFd(42))).toEqual({ + path: '/opt/inflow/bin/inflow', + pid: 123, + uid: 1000, + }); + expect(verified).not.toHaveBeenCalled(); + }); + + it('binds a service peer to an explicit operating-system user identity', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + const verifier = createVaultSocketPeerVerifier( + { + expectedExecutablePath: '/opt/inflow/bin/inflow', + expectedUserId: 991, + nativeModulePath: '/opt/inflow/lib/inflow/native/vault_peer_linux.node', + requireSameUser: false, + }, + dependencies({ + currentUserId: 1000, + peer: { path: '/opt/inflow/bin/inflow', pid: 123, uid: 991 }, + realpaths: new Map([['/opt/inflow/bin/inflow', '/opt/inflow/bin/inflow']]), + }), + ); + + expect(verifier(socketWithFd(42))).toMatchObject({ uid: 991 }); + expect(() => + createVaultSocketPeerVerifier( + { + expectedExecutablePath: '/opt/inflow/bin/inflow', + expectedUserId: 991, + nativeModulePath: '/opt/inflow/lib/inflow/native/vault_peer_linux.node', + requireSameUser: false, + }, + dependencies({ + currentUserId: 1000, + peer: { path: '/opt/inflow/bin/inflow', pid: 123, uid: 1000 }, + realpaths: new Map([['/opt/inflow/bin/inflow', '/opt/inflow/bin/inflow']]), + }), + )(socketWithFd(42)), + ).toThrow(SecureStorageError); + }); + + it('binds broker-transferred sockets to the attested executable, process, and user', () => { + const config: VaultPeerVerificationConfig = { + expectedExecutablePath: '/opt/inflow/bin/inflow', + expectedTeamId: '', + nativeModulePath: '/opt/inflow/lib/inflow/native/vault_peer_linux.node', + requireSameUser: false, + requireSignature: false, + }; + const attested = { path: '/opt/inflow/bin/inflow', pid: 123, uid: 1000 }; + const transferredDependencies = (credentials: { pid: number; uid: number }) => ({ + loadNativeModule: () => ({ + peerCredentials: () => credentials, + peerInfo: () => attested, + }), + realpath: (path: string) => path, + }); + + expect( + verifyTransferredVaultSocketPeerWithDependencies( + socketWithFd(42), + attested, + config, + transferredDependencies({ pid: 123, uid: 1000 }), + ), + ).toEqual(attested); + expect(() => + verifyTransferredVaultSocketPeerWithDependencies( + socketWithFd(42), + { ...attested, path: '/tmp/inflow' }, + config, + transferredDependencies({ pid: 123, uid: 1000 }), + ), + ).toThrow(SecureStorageError); + expect(() => + verifyTransferredVaultSocketPeerWithDependencies( + socketWithFd(42), + attested, + config, + transferredDependencies({ pid: 124, uid: 1000 }), + ), + ).toThrow(SecureStorageError); + expect(() => + verifyTransferredVaultSocketPeerWithDependencies( + socketWithFd(42), + attested, + config, + transferredDependencies({ pid: 123, uid: 1001 }), + ), + ).toThrow(SecureStorageError); + }); + + it('rejects invalid explicit user identifiers and unavailable current-user identities', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + for (const expectedUserId of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + expect(() => + createVaultSocketPeerVerifier( + { + expectedExecutablePath: '/opt/inflow/bin/inflow', + expectedUserId, + nativeModulePath: '/native/vault_peer_linux.node', + }, + dependencies({ + currentUserId: 1000, + peer: { path: '/opt/inflow/bin/inflow', pid: 123, uid: 1000 }, + realpaths: new Map([['/opt/inflow/bin/inflow', '/opt/inflow/bin/inflow']]), + }), + ), + ).toThrow('configuration is invalid'); + } + + const verifier = createVaultSocketPeerVerifier( + { + expectedExecutablePath: '/opt/inflow/bin/inflow', + nativeModulePath: '/native/vault_peer_linux.node', + }, + dependencies({ + currentUserId: undefined, + peer: { path: '/opt/inflow/bin/inflow', pid: 123, uid: 1000 }, + realpaths: new Map([['/opt/inflow/bin/inflow', '/opt/inflow/bin/inflow']]), + }), + ); + expect(() => verifier(socketWithFd(42))).toThrow(SecureStorageError); + }); + + it('fails closed when a packaged executable has no embedded native module digest', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + Object.defineProperty(process, 'execPath', { value: '/opt/inflow/bin/inflow' }); + + expect(() => + createVaultSocketPeerVerifier( + {}, + dependencies({ + currentUserId: 1000, + peer: { path: '/opt/inflow/bin/inflow', pid: 123, uid: 1000 }, + realpaths: new Map([['/opt/inflow/bin/inflow', '/opt/inflow/bin/inflow']]), + }), + ), + ).toThrow(SecureStorageError); + }); + + it('rejects unsupported platforms and sockets without exposed descriptors', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + + expect(() => createVaultSocketPeerVerifier()).toThrow(SecureStorageError); + expect(() => socketFileDescriptor(new Socket())).toThrow(SecureStorageError); + }); + + it('accepts a canonical non-writable native module with the expected digest', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'inflow-native-module-')); + try { + const nativeModule = join(tmpDir, 'vault_peer.node'); + const content = Buffer.from('native-module'); + writeFileSync(nativeModule, content, { mode: 0o755 }); + + expect(() => + verifyVaultNativeModule(nativeModule, { + expectedSha256: createHash('sha256').update(content).digest('hex'), + expectedTeamId: 'TEAM123456', + requireSignature: false, + }), + ).not.toThrow(); + } finally { + rmSync(tmpDir, { force: true, recursive: true }); + } + }); + + it('uses the native module digest instead of POSIX mode bits on Windows', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'inflow-native-module-')); + try { + const nativeModule = join(tmpDir, 'vault_peer.node'); + const content = Buffer.from('native-module'); + writeFileSync(nativeModule, content, { mode: 0o777 }); + Object.defineProperty(process, 'platform', { value: 'win32' }); + + expect(() => + verifyVaultNativeModule(nativeModule, { + expectedSha256: createHash('sha256').update(content).digest('hex'), + expectedTeamId: '', + requireSignature: false, + }), + ).not.toThrow(); + } finally { + rmSync(tmpDir, { force: true, recursive: true }); + } + }); + + it('rejects modified, writable, and symlinked native modules', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'inflow-native-module-')); + try { + const nativeModule = join(tmpDir, 'vault_peer.node'); + const linkedModule = join(tmpDir, 'linked.node'); + writeFileSync(nativeModule, 'native-module', { mode: 0o755 }); + symlinkSync(nativeModule, linkedModule); + const options = { + expectedSha256: createHash('sha256').update('different-module').digest('hex'), + expectedTeamId: 'TEAM123456', + requireSignature: false, + }; + + expect(() => verifyVaultNativeModule(nativeModule, options)).toThrow(SecureStorageError); + chmodSync(nativeModule, 0o777); + expect(() => + verifyVaultNativeModule(nativeModule, { + ...options, + expectedSha256: createHash('sha256').update('native-module').digest('hex'), + }), + ).toThrow(SecureStorageError); + chmodSync(nativeModule, 0o755); + expect(() => verifyVaultNativeModule(linkedModule, options)).toThrow(SecureStorageError); + } finally { + rmSync(tmpDir, { force: true, recursive: true }); + } + }); +}); + +function peerVerifierFor(peer: VaultSocketPeer, overrides: Partial = {}): void { + const verifier = createVaultSocketPeerVerifier( + { + expectedExecutablePath: '/usr/bin/inflow', + nativeModulePath: '/native/vault_peer_darwin.node', + requireSignature: true, + }, + dependencies({ + currentUserId: 501, + peer, + realpaths: new Map([ + ['/usr/bin/inflow', '/usr/bin/inflow'], + ['/tmp/fake-inflow', '/tmp/fake-inflow'], + ]), + ...overrides, + }), + ); + void verifier(socketWithFd(42)); +} + +function peerVerifierError(peer: VaultSocketPeer, overrides: Partial = {}): unknown { + try { + peerVerifierFor(peer, overrides); + return undefined; + } catch (cause) { + return cause; + } +} + +function socketWithFd(fd: number): Socket { + const socket = new Socket() as Socket & { _handle?: { fd?: number } }; + socket._handle = { fd }; + return socket; +} + +interface TestDependencyInput { + currentUserId: number | undefined; + peer: VaultSocketPeer; + realpaths: Map; + verifySignature?: (path: string, teamId: string) => void; +} + +function dependencies(input: TestDependencyInput) { + return { + currentUserId: vi.fn(() => input.currentUserId), + loadNativeModule: vi.fn(() => ({ + peerCredentials: vi.fn(() => ({ pid: input.peer.pid, uid: input.peer.uid })), + peerInfo: vi.fn(() => input.peer), + })), + realpath: vi.fn((path: string) => input.realpaths.get(path) ?? path), + verifyNativeModule: vi.fn(), + verifySignature: vi.fn(input.verifySignature ?? (() => undefined)), + }; +} diff --git a/packages/core/test/unit/secure-storage/vault-protected-key-windows.test.ts b/packages/core/test/unit/secure-storage/vault-protected-key-windows.test.ts new file mode 100644 index 0000000..faa59dc --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-protected-key-windows.test.ts @@ -0,0 +1,53 @@ +import { dirname, resolve } from 'node:path'; +import process from 'node:process'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +describe('Windows protected-memory module loading', () => { + const originalExecPath = process.execPath; + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'execPath', { value: originalExecPath }); + Object.defineProperty(process, 'platform', { value: originalPlatform }); + vi.unstubAllGlobals(); + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it('verifies and loads the installed native module before hardening', async () => { + const digest = 'a'.repeat(64); + const hardenProcess = vi.fn(); + const loadNative = vi.fn(() => ({ hardenProcess })); + const verifyVaultNativeModule = vi.fn(); + Object.defineProperty(process, 'execPath', { value: 'C:\\Program Files\\InFlow\\inflow.exe' }); + Object.defineProperty(process, 'platform', { value: 'win32' }); + vi.stubGlobal('__VAULT_PEER_NATIVE_SHA256__', digest); + vi.doMock('../../../src/secure-storage/runtime-require.js', () => ({ + runtimeRequire: () => loadNative, + })); + vi.doMock('../../../src/secure-storage/vault-peer-verifier.js', () => ({ + verifyVaultNativeModule, + })); + + const { hardenVaultDaemonProcess } = await import('../../../src/secure-storage/vault-protected-key.js'); + hardenVaultDaemonProcess(); + + const nativeModulePath = resolve(dirname(process.execPath), 'native', 'vault_peer_windows.node'); + expect(verifyVaultNativeModule).toHaveBeenCalledWith(nativeModulePath, { + expectedSha256: digest, + expectedTeamId: '', + requireSignature: false, + }); + expect(loadNative).toHaveBeenCalledWith(nativeModulePath); + expect(hardenProcess).toHaveBeenCalledOnce(); + }); + + it('fails closed without an embedded native-module digest', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + vi.stubGlobal('__VAULT_PEER_NATIVE_SHA256__', undefined); + + const { hardenVaultDaemonProcess } = await import('../../../src/secure-storage/vault-protected-key.js'); + + expect(() => hardenVaultDaemonProcess()).toThrow('Vault native module integrity is unavailable.'); + }); +}); diff --git a/packages/core/test/unit/secure-storage/vault-protected-key.test.ts b/packages/core/test/unit/secure-storage/vault-protected-key.test.ts new file mode 100644 index 0000000..09bf9bc --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-protected-key.test.ts @@ -0,0 +1,122 @@ +import { Buffer } from 'node:buffer'; +import { describe, expect, it, vi } from 'vitest'; +import { + ProtectedVaultKey, + deriveVaultWrappingKey, + hardenVaultDaemonProcess, +} from '../../../src/secure-storage/vault-protected-key.js'; + +describe('native vault key derivation', () => { + it('matches the existing Argon2id sidecar derivation and preserves caller-owned input', () => { + const unlockFactor = Buffer.from('compatibility-factor'); + const original = Buffer.from(unlockFactor); + const salt = Buffer.from('000102030405060708090a0b0c0d0e0f', 'hex'); + + const key = deriveVaultWrappingKey(unlockFactor, salt); + + expect(key.toString('hex')).toBe('b14428c2aa1bd0af16cf37b0bbf7a52ab15df66629fb29d433808b938a3bbd00'); + expect(unlockFactor).toEqual(original); + }); +}); + +describe('ProtectedVaultKey', () => { + it('moves the source bytes into native custody and clears the source', () => { + const source = Buffer.alloc(32, 0x5a); + const native = nativeModule(); + + new ProtectedVaultKey(source, native); + + expect(source.equals(Buffer.alloc(32))).toBe(true); + expect(native.createProtectedKey).toHaveBeenCalledOnce(); + }); + + it('clears the source when native allocation fails', () => { + const source = Buffer.alloc(32, 0x5a); + const native = { + ...nativeModule(), + createProtectedKey: vi.fn((): object => { + throw new Error('mlock failed'); + }), + }; + + expect(() => new ProtectedVaultKey(source, native)).toThrow('Vault protected memory is unavailable.'); + expect(source.equals(Buffer.alloc(32))).toBe(true); + }); + + it('destroys native custody once and rejects later operations', () => { + const native = nativeModule(); + const key = new ProtectedVaultKey(Buffer.alloc(32), native); + + key.destroy(); + key.destroy(); + + expect(native.destroyProtectedKey).toHaveBeenCalledOnce(); + expect(() => key.encrypt(recordContext, Buffer.from('secret'))).toThrow('The InFlow vault is locked.'); + }); + + it('delegates record cryptography to the protected native handle', () => { + const native = nativeModule(); + const key = new ProtectedVaultKey(Buffer.alloc(32), native); + const payload = Buffer.from('secret'); + + const encrypted = key.encrypt(recordContext, payload); + const decrypted = key.decrypt(recordContext, encrypted); + + expect(encrypted.ciphertext).toEqual(Buffer.from('encrypted')); + expect(decrypted).toEqual(payload); + expect(native.encryptRecord).toHaveBeenCalledOnce(); + expect(native.decryptRecord).toHaveBeenCalledOnce(); + }); + + it('maps native authentication rejection to corrupt storage', () => { + const native = { + ...nativeModule(), + decryptRecord: vi.fn((): Buffer => { + throw Object.assign(new Error('authentication failed'), { code: 'EAUTH' }); + }), + }; + const key = new ProtectedVaultKey(Buffer.alloc(32), native); + + expect(() => + key.decrypt(recordContext, { + ciphertext: Buffer.from('encrypted'), + nonce: Buffer.alloc(12), + tag: Buffer.alloc(16), + }), + ).toThrow('Vault record could not be decrypted.'); + }); + + it('fails closed when native process hardening fails', () => { + const native = { + ...nativeModule(), + hardenProcess: vi.fn((): void => { + throw new Error('setrlimit failed'); + }), + }; + + expect(() => hardenVaultDaemonProcess(native)).toThrow('Vault process hardening is unavailable.'); + }); +}); + +const recordContext = { + encryptionVersion: 1, + kind: 'inflow_api_key', + reference: 'vlt_00000000000040008000000000000000', + status: 'active', +} as const; + +function nativeModule() { + return { + createProtectedKey: vi.fn(() => ({})), + decryptRecord: vi.fn((_handle: object, _aad: Uint8Array, ciphertext: Uint8Array) => + Buffer.from(Buffer.from(ciphertext).equals(Buffer.from('encrypted')) ? 'secret' : ''), + ), + destroyProtectedKey: vi.fn(), + encryptRecord: vi.fn(() => ({ + ciphertext: Buffer.from('encrypted'), + nonce: Buffer.alloc(12), + tag: Buffer.alloc(16), + })), + hardenProcess: vi.fn(), + }; +} diff --git a/packages/core/test/unit/secure-storage/vault-socket.test.ts b/packages/core/test/unit/secure-storage/vault-socket.test.ts new file mode 100644 index 0000000..7c30941 --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-socket.test.ts @@ -0,0 +1,510 @@ +import { Buffer } from 'node:buffer'; +import { createConnection, createServer, type Socket } from 'node:net'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { + VaultBackend, + VaultPolicy, + VaultSecretPayload, + VaultStatus, +} from '../../../src/secure-storage/vault-backend.js'; +import { SecureStorageError } from '../../../src/secure-storage/errors.js'; +import { + sendVaultIpcRequest, + startVaultSocketServer, + type VaultSocketServer, +} from '../../../src/secure-storage/vault-socket.js'; +import { + decodeVaultIpcFrame, + encodeVaultIpcMessage, + VAULT_IPC_MAX_MESSAGE_BYTES, + type VaultIpcRequest, +} from '../../../src/secure-storage/vault-ipc.js'; +import type { VaultSecretReference } from '../../../src/secure-storage/vault-types.js'; + +class SocketVaultBackend implements VaultBackend { + private active = true; + private locked = false; + readonly secret: VaultSecretPayload; + + constructor(payload = 'socket-secret') { + this.secret = { + payload: Buffer.from(payload), + reference: { reference: 'vlt_22222222222222222222222222222222' }, + }; + } + + changePassphrase(_currentUnlockFactor: Uint8Array, _nextUnlockFactor: Uint8Array): void {} + + changeWrappingKey(_currentWrappingKey: Uint8Array, _nextWrappingKey: Uint8Array, _nextSalt: Uint8Array): void {} + + deleteExpired(_input: { now: string }): void {} + + deleteSecret(_input: { expectedKind: 'inflow_api_key'; reference: VaultSecretReference }): void {} + + exists(_input: { expectedKind: 'inflow_api_key'; reference: VaultSecretReference }): boolean { + return true; + } + + getPolicy(): VaultPolicy { + return { + idleTimeoutSeconds: 28_800, + lockOnSleep: true, + }; + } + + getSecret(_input: { expectedKind: 'inflow_api_key'; reference: VaultSecretReference }): VaultSecretPayload { + if (!this.active) { + throw new SecureStorageError('secure_storage_secret_missing', 'The vault secret was not found.'); + } + return { payload: Buffer.from(this.secret.payload), reference: this.secret.reference }; + } + + lock(): void { + this.locked = true; + } + + putSecret(_input: { expectedKind: 'inflow_api_key'; payload: Uint8Array }): VaultSecretReference { + return this.secret.reference; + } + + reset(): Promise { + this.active = false; + return Promise.resolve(); + } + + setPolicy(policy: VaultPolicy): VaultPolicy { + return policy; + } + + status(): VaultStatus { + return { daemonRunning: true, lockState: this.locked ? 'locked' : 'unlocked' }; + } + + touch(_input: { expectedKind: 'inflow_api_key'; reference: VaultSecretReference }): void {} + + unlock(_unlockFactor: Uint8Array): VaultStatus { + this.locked = false; + return this.status(); + } + + unlockSalt(): Uint8Array { + return Buffer.alloc(16); + } + + unlockWithWrappingKey(_wrappingKey: Uint8Array, _salt: Uint8Array): VaultStatus { + this.locked = false; + return this.status(); + } +} + +class BlockingSocketVaultBackend extends SocketVaultBackend { + private releaseReset: () => void = () => undefined; + private resolveResetStarted: () => void = () => undefined; + readonly resetStarted = new Promise((resolve) => { + this.resolveResetStarted = resolve; + }); + statusCalls = 0; + + finishReset(): void { + this.releaseReset(); + } + + override async reset(): Promise { + this.resolveResetStarted(); + await new Promise((resolve) => { + this.releaseReset = resolve; + }); + await super.reset(); + } + + override status(): VaultStatus { + this.statusCalls += 1; + return super.status(); + } +} + +function request(method: VaultIpcRequest['method'], params: Record = {}): VaultIpcRequest { + return { id: 'req_socket', method, params, version: 1 }; +} + +describe('vault socket transport', () => { + const servers: VaultSocketServer[] = []; + let tmpDir: string | undefined; + + afterEach(async () => { + for (const server of servers.splice(0)) await server.close(); + if (tmpDir !== undefined) rmSync(tmpDir, { force: true, recursive: true }); + tmpDir = undefined; + }); + + it('sends one bounded IPC request and receives one response', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-socket-')); + const socketPath = join(tmpDir, 'run', 'vault.sock'); + servers.push(await startVaultSocketServer({ backend: new SocketVaultBackend(), socketPath })); + + await expect(sendVaultIpcRequest(socketPath, request('vault.status'))).resolves.toMatchObject({ + id: 'req_socket', + ok: true, + result: { daemonRunning: true, lockState: 'unlocked' }, + }); + await expect( + sendVaultIpcRequest( + socketPath, + request('secret.get', { + expectedKind: 'inflow_api_key', + reference: 'vlt_22222222222222222222222222222222', + }), + ), + ).resolves.toMatchObject({ + ok: true, + result: { payload: Buffer.from('socket-secret') }, + }); + }); + + it('authenticates the daemon before transmitting request bytes', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-socket-')); + const socketPath = join(tmpDir, 'run', 'vault.sock'); + mkdirSync(join(tmpDir, 'run')); + let receivedBytes = false; + const fakeDaemon = createServer((socket) => { + socket.on('data', () => { + receivedBytes = true; + }); + }); + await new Promise((resolve, reject) => { + fakeDaemon.once('error', reject); + fakeDaemon.listen(socketPath, resolve); + }); + servers.push({ + close: () => + new Promise((resolve, reject) => { + fakeDaemon.close((cause) => { + if (cause !== undefined) { + reject(cause); + return; + } + resolve(); + }); + }), + socketPath, + }); + + await expect( + sendVaultIpcRequest(socketPath, request('vault.status'), () => { + throw new SecureStorageError('secure_storage_peer_verification_failed', 'Vault peer verification failed.'); + }), + ).rejects.toMatchObject({ secureStorageCode: 'secure_storage_peer_verification_failed' }); + expect(receivedBytes).toBe(false); + }); + + it('removes stale socket files before listening', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-socket-')); + const socketPath = join(tmpDir, 'run', 'vault.sock'); + mkdirSync(join(tmpDir, 'run')); + await writeFile(socketPath, 'stale'); + servers.push(await startVaultSocketServer({ backend: new SocketVaultBackend(), socketPath })); + + await expect(sendVaultIpcRequest(socketPath, request('vault.status'))).resolves.toMatchObject({ + ok: true, + result: { lockState: 'unlocked' }, + }); + }); + + it('refuses to unlink a reachable daemon socket', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-socket-')); + const socketPath = join(tmpDir, 'run', 'vault.sock'); + mkdirSync(join(tmpDir, 'run')); + const existing = createServer((socket) => { + socket.end(); + }); + await new Promise((resolve, reject) => { + existing.once('error', reject); + existing.listen(socketPath, () => { + existing.off('error', reject); + resolve(); + }); + }); + servers.push({ + close: () => + new Promise((resolve, reject) => { + existing.close((cause) => { + if (cause !== undefined) { + reject(cause); + return; + } + resolve(); + }); + }), + socketPath, + }); + + await expect(startVaultSocketServer({ backend: new SocketVaultBackend(), socketPath })).rejects.toMatchObject({ + secureStorageCode: 'secure_storage_unavailable', + }); + }); + + it('returns a redacted socket response for malformed request frames', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-socket-')); + const socketPath = join(tmpDir, 'run', 'vault.sock'); + servers.push(await startVaultSocketServer({ backend: new SocketVaultBackend(), socketPath })); + const socket = createConnection(socketPath); + const response = readRawFrame(socket); + socket.end( + encodeVaultIpcMessage({ + id: 'response_instead_of_request', + ok: true, + result: {}, + version: 1, + }), + ); + + await expect(response.then((frame) => decodeVaultIpcFrame(frame))).resolves.toMatchObject({ + error: { + code: 'secure_storage_corrupt', + message: 'Vault IPC request is malformed.', + }, + ok: false, + }); + }); + + it('closes an oversized frame after reading only its declared length', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-socket-')); + const socketPath = join(tmpDir, 'run', 'vault.sock'); + servers.push(await startVaultSocketServer({ backend: new SocketVaultBackend(), socketPath })); + const socket = createConnection(socketPath); + await new Promise((resolve, reject) => { + socket.once('connect', resolve); + socket.once('error', reject); + }); + const closed = new Promise((resolve, reject) => { + socket.once('close', resolve); + socket.once('error', reject); + }); + const header = Buffer.alloc(4); + header.writeUInt32BE(VAULT_IPC_MAX_MESSAGE_BYTES + 1); + socket.write(header); + + await expect(closed).resolves.toBe(false); + }); + + it('rejects a frame whose accumulated bytes exceed the transport limit', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-socket-')); + const socketPath = join(tmpDir, 'run', 'vault.sock'); + servers.push(await startVaultSocketServer({ backend: new SocketVaultBackend(), socketPath })); + const socket = createConnection(socketPath); + const closed = new Promise((resolve) => { + socket.once('close', resolve); + socket.once('error', () => undefined); + }); + await new Promise((resolve, reject) => { + socket.once('connect', resolve); + socket.once('error', reject); + }); + + socket.write(Buffer.alloc(VAULT_IPC_MAX_MESSAGE_BYTES + 5)); + + await expect(closed).resolves.toEqual(expect.any(Boolean)); + }); + + it('returns a redacted socket response when peer verification fails', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-socket-')); + const socketPath = join(tmpDir, 'run', 'vault.sock'); + servers.push( + await startVaultSocketServer({ + backend: new SocketVaultBackend(), + peerVerifier() { + throw new Error('wrong peer'); + }, + socketPath, + }), + ); + + await expect(sendVaultIpcRequest(socketPath, request('vault.status'))).resolves.toMatchObject({ + error: { code: 'secure_storage_io_error' }, + id: 'unknown', + ok: false, + }); + }); + + it('routes authenticated peers to isolated tenant backends', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-socket-')); + const socketPath = join(tmpDir, 'run', 'vault.sock'); + const backends = new Map([ + [1001, new SocketVaultBackend('tenant-a-secret')], + [1002, new SocketVaultBackend('tenant-b-secret')], + ]); + const peerUserIds = [1001, 1002, 1001, 1002, 1001, 1002]; + const backendForPeer = vi.fn((peer: { uid: number }) => { + const backend = backends.get(peer.uid); + if (backend === undefined) throw new Error('unknown peer'); + return backend; + }); + servers.push( + await startVaultSocketServer({ + backendForPeer, + peerVerifier() { + const uid = peerUserIds.shift(); + if (uid === undefined) throw new Error('unexpected connection'); + return { path: '/usr/bin/inflow', pid: uid, uid }; + }, + socketPath, + }), + ); + const secretRequest = request('secret.get', { + expectedKind: 'inflow_api_key', + reference: 'vlt_22222222222222222222222222222222', + }); + + await expect(sendVaultIpcRequest(socketPath, secretRequest)).resolves.toMatchObject({ + ok: true, + result: { payload: Buffer.from('tenant-a-secret') }, + }); + await expect(sendVaultIpcRequest(socketPath, secretRequest)).resolves.toMatchObject({ + ok: true, + result: { payload: Buffer.from('tenant-b-secret') }, + }); + await expect(sendVaultIpcRequest(socketPath, request('vault.reset'))).resolves.toMatchObject({ ok: true }); + await expect(sendVaultIpcRequest(socketPath, secretRequest)).resolves.toMatchObject({ + ok: true, + result: { payload: Buffer.from('tenant-b-secret') }, + }); + await expect(sendVaultIpcRequest(socketPath, request('daemon.shutdown'))).resolves.toMatchObject({ + error: { + code: 'secure_storage_unavailable', + message: 'The vault service lifecycle is managed by the operating system.', + }, + ok: false, + }); + await expect(sendVaultIpcRequest(socketPath, request('vault.status'))).resolves.toMatchObject({ + ok: true, + result: { lockState: 'unlocked' }, + }); + expect(backendForPeer.mock.calls.map(([peer]) => peer.uid)).toEqual([1001, 1002, 1001, 1002, 1001, 1002]); + }); + + it('serializes concurrent requests for one tenant backend', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-socket-')); + const socketPath = join(tmpDir, 'run', 'vault.sock'); + const backend = new BlockingSocketVaultBackend(); + servers.push(await startVaultSocketServer({ backend, socketPath })); + + const reset = sendVaultIpcRequest(socketPath, request('vault.reset')); + await backend.resetStarted; + const status = sendVaultIpcRequest(socketPath, request('vault.status')); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(backend.statusCalls).toBe(0); + + backend.finishReset(); + + await expect(reset).resolves.toMatchObject({ ok: true }); + await expect(status).resolves.toMatchObject({ ok: true }); + expect(backend.statusCalls).toBe(1); + }); + + it('rejects malformed socket responses', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-socket-')); + const socketPath = join(tmpDir, 'vault.sock'); + const rawSockets = new Set(); + const server = createServer((socket) => { + rawSockets.add(socket); + socket.on('close', () => { + rawSockets.delete(socket); + }); + socket.end(encodeVaultIpcMessage(request('vault.status'))); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, () => { + server.off('error', reject); + resolve(); + }); + }); + servers.push({ + close: () => + new Promise((resolve, reject) => { + for (const socket of rawSockets) socket.destroy(); + server.close((cause) => { + if (cause !== undefined) { + reject(cause); + return; + } + resolve(); + }); + }), + socketPath, + }); + + await expect(sendVaultIpcRequest(socketPath, request('vault.status'))).rejects.toMatchObject({ + secureStorageCode: 'secure_storage_corrupt', + }); + }); + + it('rejects truncated socket responses', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-socket-')); + const socketPath = join(tmpDir, 'vault.sock'); + const rawSockets = new Set(); + const server = createServer((socket) => { + rawSockets.add(socket); + socket.on('close', () => { + rawSockets.delete(socket); + }); + socket.end(Buffer.from([0, 0, 0, 8, 123])); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, () => { + server.off('error', reject); + resolve(); + }); + }); + servers.push({ + close: () => + new Promise((resolve, reject) => { + for (const socket of rawSockets) socket.destroy(); + server.close((cause) => { + if (cause !== undefined) { + reject(cause); + return; + } + resolve(); + }); + }), + socketPath, + }); + + await expect(sendVaultIpcRequest(socketPath, request('vault.status'))).rejects.toMatchObject({ + secureStorageCode: 'secure_storage_corrupt', + }); + }); + + it('rejects unsafe socket paths before listening', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-socket-')); + const socketPath = join(tmpDir, 'run', 'vault.sock'); + mkdirSync(join(tmpDir, 'run')); + symlinkSync('/tmp/unsafe-vault.sock', socketPath); + + await expect(startVaultSocketServer({ backend: new SocketVaultBackend(), socketPath })).rejects.toMatchObject({ + secureStorageCode: 'secure_storage_invalid_path', + }); + }); +}); + +function readRawFrame(socket: Socket): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + socket.on('data', (chunk: Buffer) => { + chunks.push(chunk); + const buffer = Buffer.concat(chunks); + if (buffer.byteLength < 4) return; + const length = buffer.readUInt32BE(0); + if (buffer.byteLength >= length + 4) { + resolve(buffer.subarray(0, length + 4)); + } + }); + socket.on('error', reject); + }); +} diff --git a/packages/core/test/unit/secure-storage/vault-sync-secret-store-windows.test.ts b/packages/core/test/unit/secure-storage/vault-sync-secret-store-windows.test.ts new file mode 100644 index 0000000..f085102 --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-sync-secret-store-windows.test.ts @@ -0,0 +1,48 @@ +import { Buffer } from 'node:buffer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + methods: [] as string[], + sendWindowsVaultIpcRequest: vi.fn(), +})); + +vi.mock('node:process', () => ({ default: { platform: 'win32' } })); +vi.mock('../../../src/secure-storage/vault-files.js', () => ({ + linuxVaultServiceUserId: vi.fn(), + usesLinuxVaultService: () => false, + vaultFilePaths: () => ({ socket: '\\\\.\\pipe\\InFlowVault' }), +})); +vi.mock('../../../src/secure-storage/vault-windows-transport.js', () => ({ + sendWindowsVaultIpcRequest: mocks.sendWindowsVaultIpcRequest, +})); + +import { SyncVaultSecretStore } from '../../../src/secure-storage/vault-sync-secret-store.js'; + +describe('SyncVaultSecretStore on Windows', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.methods.length = 0; + }); + + it('uses the authenticated native Windows transport for synchronous secret operations', () => { + mocks.sendWindowsVaultIpcRequest.mockImplementation((_path: string, request: { id: string; method: string }) => { + mocks.methods.push(request.method); + return { + id: request.id, + ok: true, + result: request.method === 'secret.get' ? { payload: Buffer.from('stored-secret') } : {}, + version: 1, + }; + }); + const store = new SyncVaultSecretStore(); + const reference = { purpose: 'api-key', reference: 'windows-api-key' } as const; + + store.create(reference, Buffer.from('stored-secret')); + expect(Buffer.from(store.read(reference)).toString('utf8')).toBe('stored-secret'); + store.delete(reference); + + expect(mocks.sendWindowsVaultIpcRequest).toHaveBeenCalledTimes(3); + expect(mocks.methods).toEqual(['secret.put', 'secret.get', 'secret.delete']); + expect(mocks.sendWindowsVaultIpcRequest.mock.calls[0]?.[0]).toBe('\\\\.\\pipe\\InFlowVault'); + }); +}); diff --git a/packages/core/test/unit/secure-storage/vault-sync-secret-store.test.ts b/packages/core/test/unit/secure-storage/vault-sync-secret-store.test.ts new file mode 100644 index 0000000..eb5ac3f --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-sync-secret-store.test.ts @@ -0,0 +1,296 @@ +import { spawn, type ChildProcessByStdio } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Readable } from 'node:stream'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + __testing, + NoopSyncSecretReferenceManifest, + SyncVaultSecretStore, +} from '../../../src/secure-storage/vault-sync-secret-store.js'; + +describe('SyncVaultSecretStore', () => { + let child: ChildProcessByStdio | undefined; + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-sync-vault-store-')); + }); + + afterEach(() => { + child?.kill(); + child = undefined; + rmSync(tmpDir, { force: true, recursive: true }); + }); + + it('uses exact references over a separate vault socket process', async () => { + child = await startVaultSocketFixture(join(tmpDir, 'run', 'vault.sock')); + const store = new SyncVaultSecretStore({ rootDirectory: tmpDir }); + const references = [ + { purpose: 'aep-credential', reference: 'stored-aep-credential' }, + { purpose: 'api-key', reference: 'stored-api-key' }, + { purpose: 'auth-access-token', reference: 'stored-access-token' }, + { purpose: 'auth-refresh-token', reference: 'stored-refresh-token' }, + { purpose: 'pending-device-code', reference: 'stored-device-code' }, + ]; + + for (const reference of references) { + store.create(reference, Buffer.from(`secret-${reference.purpose}`)); + expect(Buffer.from(store.read(reference)).toString('utf8')).toBe(`secret-${reference.purpose}`); + } + + const reference = references[1]; + if (reference === undefined) throw new Error('expected reference'); + store.delete(reference); + expect(() => store.read(reference)).toThrow('missing'); + }); + + it('works when loaded from the built ESM package', async () => { + child = await startVaultSocketFixture(join(tmpDir, 'run', 'vault.sock')); + + const result = await runNodeProcess(['--input-type=module', '-e', esmProbeSource(), tmpDir]); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toBe('secret-esm\n'); + }, 15_000); + + it('fails closed for unsupported purposes, malformed payloads, and unavailable daemons', async () => { + const store = new SyncVaultSecretStore({ rootDirectory: tmpDir, timeoutMs: 250 }); + expect(() => store.create({ purpose: 'manifest', reference: 'one' }, Buffer.from('x'))).toThrow( + 'Secret reference purpose is not vault-backed.', + ); + expect(() => store.read({ purpose: 'api-key', reference: 'missing-daemon' })).toThrow( + 'The InFlow vault daemon is unavailable.', + ); + + child = await startVaultSocketFixture(join(tmpDir, 'run', 'vault.sock')); + expect(() => store.read({ purpose: 'api-key', reference: 'malformed-payload' })).toThrow( + 'Vault IPC secret response is malformed.', + ); + }); + + it('uses a no-op reference manifest for vault-backed lifecycle rows', () => { + const manifest = new NoopSyncSecretReferenceManifest(); + const reference = { purpose: 'api-key', reference: 'one' }; + + manifest.add(reference); + manifest.remove(reference); + + expect(manifest.read()).toEqual([]); + }); + + it('validates worker errors and daemon response envelopes', () => { + expect( + __testing.errorFromWorker(Buffer.from(JSON.stringify({ code: 'vault_locked', message: 'locked' }))), + ).toMatchObject({ message: 'locked', secureStorageCode: 'vault_locked' }); + expect(__testing.errorFromWorker(Buffer.from('{'))).toMatchObject({ + secureStorageCode: 'secure_storage_io_error', + }); + expect(__testing.errorFromWorker(Buffer.from(JSON.stringify({ code: 1, message: 'bad' })))).toMatchObject({ + secureStorageCode: 'secure_storage_io_error', + }); + expect(__testing.responseResult({ id: 'one', ok: true, result: { value: 1 }, version: 1 })).toEqual({ value: 1 }); + expect(() => + __testing.responseResult({ + error: { code: 'unknown', message: 'failed' }, + id: 'one', + ok: false, + version: 1, + }), + ).toThrow('failed'); + }); + + it('maps every supported secret purpose and stable storage error code', () => { + const purposes = new Map([ + ['aep-credential', 'aep_credential'], + ['api-key', 'inflow_api_key'], + ['auth-access-token', 'auth_access_token'], + ['auth-refresh-token', 'auth_refresh_token'], + ['pending-device-code', 'pending_device_code'], + ]); + for (const [purpose, kind] of purposes) { + const reference = { purpose, reference: 'one' }; + expect(__testing.kindForReference(reference)).toBe(kind); + expect(__testing.vaultReferenceFor(reference)).toMatch(/^vlt_[0-9a-f]{32}$/u); + } + const codes = [ + 'secure_storage_corrupt', + 'secure_storage_invalid_path', + 'secure_storage_io_error', + 'secure_storage_peer_verification_failed', + 'secure_storage_secret_conflict', + 'secure_storage_secret_missing', + 'secure_storage_unavailable', + 'vault_daemon_busy', + 'vault_locked', + 'vault_not_initialized', + ] as const; + for (const code of codes) expect(__testing.codeFromResponse(code)).toBe(code); + expect(__testing.codeFromResponse('unknown')).toBe('secure_storage_io_error'); + }); +}); + +async function startVaultSocketFixture(socketPath: string): Promise> { + const child = spawn(process.execPath, ['-e', fixtureSource(), socketPath], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + }); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`fixture socket did not start: ${stderr}`)); + }, 5_000); + child.stdout.on('data', (chunk: Buffer) => { + if (chunk.toString('utf8').includes('ready')) { + clearTimeout(timeout); + resolve(); + } + }); + child.once('error', (cause) => { + clearTimeout(timeout); + reject(cause); + }); + child.once('exit', (code) => { + clearTimeout(timeout); + reject(new Error(`fixture socket exited ${code}: ${stderr}`)); + }); + }); + return child; +} + +async function runNodeProcess(args: string[]): Promise<{ status: number | null; stdout: string; stderr: string }> { + const probe = spawn(process.execPath, args, { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + probe.stdout.on('data', (chunk: Buffer) => { + stdout.push(chunk); + }); + probe.stderr.on('data', (chunk: Buffer) => { + stderr.push(chunk); + }); + const status = await new Promise((resolve, reject) => { + probe.once('error', reject); + probe.once('exit', resolve); + }); + return { + status, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + }; +} + +function esmProbeSource(): string { + return ` +import { Buffer } from 'node:buffer'; +import { SyncVaultSecretStore } from './dist/index.js'; +const rootDirectory = process.argv[1]; +const store = new SyncVaultSecretStore({ rootDirectory, timeoutMs: 5000 }); +const reference = { purpose: 'api-key', reference: 'esm-api-key' }; +store.create(reference, Buffer.from('secret-esm', 'utf8')); +process.stdout.write(Buffer.from(store.read(reference)).toString('utf8') + '\\n'); +store.delete(reference); +`; +} + +function fixtureSource(): string { + return ` +const { mkdirSync, rmSync } = require('node:fs'); +const { dirname } = require('node:path'); +const net = require('node:net'); +const socketPath = process.argv[1]; +const values = new Map(); +function transform(value, attachments, decode) { + if (decode && value && typeof value === 'object' && !Array.isArray(value) && + Object.keys(value).length === 1 && Number.isSafeInteger(value.$inflowVaultAttachment)) { + return attachments[value.$inflowVaultAttachment]; + } + if (!decode && value instanceof Uint8Array) { + const index = attachments.push(value) - 1; + return { $inflowVaultAttachment: index }; + } + if (Array.isArray(value)) return value.map((item) => transform(item, attachments, decode)); + if (typeof value !== 'object' || value === null) return value; + return Object.fromEntries(Object.entries(value).map(([key, item]) => [ + key, + transform(item, attachments, decode) + ])); +} +function decodeFrame(buffer) { + const jsonLength = buffer.readUInt32BE(4); + const attachmentCount = buffer.readUInt32BE(8); + const jsonEnd = 12 + jsonLength; + const attachments = []; + let offset = jsonEnd; + for (let index = 0; index < attachmentCount; index += 1) { + const length = buffer.readUInt32BE(offset); + offset += 4; + attachments.push(Buffer.from(buffer.subarray(offset, offset + length))); + offset += length; + } + return transform(JSON.parse(buffer.subarray(12, jsonEnd).toString('utf8')), attachments, true); +} +function encodeFrame(message) { + const attachments = []; + const json = Buffer.from(JSON.stringify(transform(message, attachments, false)), 'utf8'); + const bodyLength = 8 + json.byteLength + + attachments.reduce((total, attachment) => total + 4 + attachment.byteLength, 0); + const frame = Buffer.alloc(4 + bodyLength); + frame.writeUInt32BE(bodyLength, 0); + frame.writeUInt32BE(json.byteLength, 4); + frame.writeUInt32BE(attachments.length, 8); + json.copy(frame, 12); + let offset = 12 + json.byteLength; + for (const attachment of attachments) { + frame.writeUInt32BE(attachment.byteLength, offset); + offset += 4; + Buffer.from(attachment).copy(frame, offset); + offset += attachment.byteLength; + } + return frame; +} +mkdirSync(dirname(socketPath), { recursive: true, mode: 0o700 }); +rmSync(socketPath, { force: true }); +const server = net.createServer((socket) => { + const chunks = []; + socket.on('data', (chunk) => { + chunks.push(chunk); + const buffer = Buffer.concat(chunks); + if (buffer.byteLength < 4) return; + const length = buffer.readUInt32BE(0); + if (buffer.byteLength < length + 4) return; + const request = decodeFrame(buffer.subarray(0, length + 4)); + const params = request.params; + let response; + if (request.method === 'secret.put') { + values.set(params.reference, params.payload); + response = { id: request.id, ok: true, result: { reference: params.reference }, version: 1 }; + } else if (request.method === 'secret.get') { + if (params.reference === 'vlt_49f4c397821a81dab9433f0f7a56565e') { + response = { id: request.id, ok: true, result: { payload: 1, reference: params.reference }, version: 1 }; + } else { + const payload = values.get(params.reference); + response = payload === undefined + ? { id: request.id, ok: false, error: { code: 'secure_storage_secret_missing', message: 'missing' }, version: 1 } + : { id: request.id, ok: true, result: { payload, reference: params.reference }, version: 1 }; + } + } else if (request.method === 'secret.delete') { + values.delete(params.reference); + response = { id: request.id, ok: true, result: {}, version: 1 }; + } else { + response = { id: request.id, ok: false, error: { code: 'secure_storage_invalid_path', message: 'bad method' }, version: 1 }; + } + socket.end(encodeFrame(response)); + }); +}); +server.listen(socketPath, () => { + process.stdout.write('ready\\n'); +}); +`; +} diff --git a/packages/core/test/unit/secure-storage/vault-tenant-manager.test.ts b/packages/core/test/unit/secure-storage/vault-tenant-manager.test.ts new file mode 100644 index 0000000..2d704e6 --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-tenant-manager.test.ts @@ -0,0 +1,134 @@ +import { Buffer } from 'node:buffer'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { MultiTenantVaultBackendManager } from '../../../src/secure-storage/vault-tenant-manager.js'; +import type { VaultSocketPeer } from '../../../src/secure-storage/vault-peer-verifier.js'; +import { parseVaultSecretReference } from '../../../src/secure-storage/vault-types.js'; + +const REFERENCE = parseVaultSecretReference('vlt_22222222222222222222222222222222'); + +describe('multi-tenant vault backend manager', () => { + let manager: MultiTenantVaultBackendManager | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + await manager?.close(); + manager = undefined; + if (tmpDir !== undefined) rmSync(tmpDir, { force: true, recursive: true }); + tmpDir = undefined; + }); + + it('isolates vault state selected from verified operating-system user identities', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-tenants-')); + manager = new MultiTenantVaultBackendManager({ rootDirectory: tmpDir }); + const tenantA = manager.backendForPeer(peer(1001)); + const tenantB = manager.backendForPeer(peer(1002)); + + await tenantA.unlock(Buffer.from('tenant-a-factor')); + await tenantB.unlock(Buffer.from('tenant-b-factor')); + await tenantA.putSecret({ + expectedKind: 'inflow_api_key', + payload: Buffer.from('tenant-a-secret'), + reference: REFERENCE, + }); + await tenantB.putSecret({ + expectedKind: 'inflow_api_key', + payload: Buffer.from('tenant-b-secret'), + reference: REFERENCE, + }); + + await expect(tenantA.getSecret({ expectedKind: 'inflow_api_key', reference: REFERENCE })).resolves.toMatchObject({ + payload: Buffer.from('tenant-a-secret'), + }); + await expect(tenantB.getSecret({ expectedKind: 'inflow_api_key', reference: REFERENCE })).resolves.toMatchObject({ + payload: Buffer.from('tenant-b-secret'), + }); + + await tenantA.reset(); + + await expect(manager.backendForPeer(peer(1001)).status()).resolves.toMatchObject({ + lockState: 'not_initialized', + }); + await expect(manager.backendForPeer(peer(1001)).unlock(Buffer.from('tenant-a-replacement'))).resolves.toMatchObject( + { + lockState: 'unlocked', + }, + ); + await expect( + manager.backendForPeer(peer(1002)).getSecret({ expectedKind: 'inflow_api_key', reference: REFERENCE }), + ).resolves.toMatchObject({ payload: Buffer.from('tenant-b-secret') }); + }, 15_000); + + it('expires and locks only an idle tenant context', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-tenants-')); + manager = new MultiTenantVaultBackendManager({ rootDirectory: tmpDir }); + const tenantA = manager.backendForPeer(peer(1001)); + const tenantB = manager.backendForPeer(peer(1002)); + await tenantA.unlock(Buffer.from('tenant-a-factor')); + await tenantB.unlock(Buffer.from('tenant-b-factor')); + await tenantA.setPolicy({ idleTimeoutSeconds: 0, lockOnSleep: false }); + + await new Promise((resolve) => setTimeout(resolve, 25)); + + await expect(manager.backendForPeer(peer(1001)).status()).resolves.toMatchObject({ lockState: 'locked' }); + await expect(manager.backendForPeer(peer(1002)).status()).resolves.toMatchObject({ lockState: 'unlocked' }); + }); + + it('locks only tenants whose policy enables locking on sleep', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-tenants-')); + manager = new MultiTenantVaultBackendManager({ rootDirectory: tmpDir }); + const tenantA = manager.backendForPeer(peer(1001)); + const tenantB = manager.backendForPeer(peer(1002)); + await tenantA.unlock(Buffer.from('tenant-a-factor')); + await tenantB.unlock(Buffer.from('tenant-b-factor')); + await tenantA.setPolicy({ idleTimeoutSeconds: null, lockOnSleep: false }); + + await manager.lockForSleep(); + + await expect(tenantA.status()).resolves.toMatchObject({ lockState: 'unlocked' }); + await expect(tenantB.status()).resolves.toMatchObject({ lockState: 'locked' }); + }); + + it('rejects invalid peer user identities before selecting a filesystem path', () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-tenants-')); + manager = new MultiTenantVaultBackendManager({ rootDirectory: tmpDir }); + + expect(peerError(manager, peer(-1))).toMatchObject({ + secureStorageCode: 'secure_storage_peer_verification_failed', + }); + expect(peerError(manager, windowsPeer('not-a-sid'))).toMatchObject({ + secureStorageCode: 'secure_storage_peer_verification_failed', + }); + }); + + it('selects Windows tenants from complete token security identifiers', () => { + tmpDir = mkdtempSync(join(tmpdir(), 'inflow-vault-tenants-')); + manager = new MultiTenantVaultBackendManager({ rootDirectory: tmpDir }); + + const tenantA = manager.backendForPeer(windowsPeer('S-1-5-21-100-200-300-1001')); + const sameTenantA = manager.backendForPeer(windowsPeer('S-1-5-21-100-200-300-1001')); + const tenantB = manager.backendForPeer(windowsPeer('S-1-5-21-100-200-300-1002')); + + expect(sameTenantA).toBe(tenantA); + expect(tenantB).not.toBe(tenantA); + }); +}); + +function peer(uid: number): VaultSocketPeer { + return { path: '/usr/bin/inflow', pid: uid, uid }; +} + +function windowsPeer(principal: string): VaultSocketPeer { + return { path: 'C:\\Program Files\\InFlow\\inflow.exe', pid: 100, principal, uid: 0 }; +} + +function peerError(manager: MultiTenantVaultBackendManager, invalidPeer: VaultSocketPeer): unknown { + try { + manager.backendForPeer(invalidPeer); + return undefined; + } catch (cause) { + return cause; + } +} diff --git a/packages/core/test/unit/secure-storage/vault-types.test.ts b/packages/core/test/unit/secure-storage/vault-types.test.ts new file mode 100644 index 0000000..8496413 --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-types.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { + createVaultSecretReference, + parseVaultSecretReference, + vaultRecordStatusCode, + vaultRecordStatusName, + vaultSecretKindCode, + vaultSecretKindName, + type VaultRecordStatus, + type VaultSecretKind, +} from '../../../src/secure-storage/vault-types.js'; + +const secretKinds: readonly VaultSecretKind[] = [ + 'auth_access_token', + 'auth_refresh_token', + 'inflow_api_key', + 'pending_device_code', + 'aep_credential', +]; + +const statuses: readonly VaultRecordStatus[] = ['active', 'pending', 'deleting']; + +describe('vault type mappings', () => { + it('creates opaque generated references without protocol metadata', () => { + const first = createVaultSecretReference(); + const second = createVaultSecretReference(); + + expect(first.reference).toMatch(/^vlt_[0-9a-f]{32}$/); + expect(second.reference).toMatch(/^vlt_[0-9a-f]{32}$/); + expect(first.reference).not.toBe(second.reference); + expect(first.reference).not.toContain('aep'); + expect(first.reference).not.toContain('user'); + expect(parseVaultSecretReference(first.reference)).toEqual(first); + }); + + it('rejects malformed references', () => { + for (const reference of ['vlt_', 'vlt_not-hex', 'auth_access_token', 'vlt_0123', `vlt_${'g'.repeat(32)}`]) { + expect(() => parseVaultSecretReference(reference)).toThrow('Vault secret reference is malformed.'); + } + }); + + it('round-trips stable secret kind codes and rejects unknown values', () => { + for (const [index, kind] of secretKinds.entries()) { + const code = index + 1; + expect(vaultSecretKindCode(kind)).toBe(code); + expect(vaultSecretKindName(code)).toBe(kind); + } + + expect(() => vaultSecretKindName(0)).toThrow('Vault secret kind is unknown.'); + expect(() => vaultSecretKindName(6)).toThrow('Vault secret kind is unknown.'); + }); + + it('round-trips stable record status codes and rejects unknown values', () => { + for (const [index, status] of statuses.entries()) { + const code = index + 1; + expect(vaultRecordStatusCode(status)).toBe(code); + expect(vaultRecordStatusName(code)).toBe(status); + } + + expect(() => vaultRecordStatusName(0)).toThrow('Vault record status is unknown.'); + expect(() => vaultRecordStatusName(4)).toThrow('Vault record status is unknown.'); + }); +}); diff --git a/packages/core/test/unit/secure-storage/vault-windows-service-workers.test.ts b/packages/core/test/unit/secure-storage/vault-windows-service-workers.test.ts new file mode 100644 index 0000000..69c9313 --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-windows-service-workers.test.ts @@ -0,0 +1,287 @@ +import { Buffer } from 'node:buffer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { encodeVaultIpcMessage } from '../../../src/secure-storage/vault-ipc.js'; + +const mocks = vi.hoisted(() => { + class Emitter { + private readonly listeners = new Map void>>(); + + on(event: string, listener: (message: unknown) => void): this { + const listeners = this.listeners.get(event) ?? []; + listeners.push(listener); + this.listeners.set(event, listeners); + return this; + } + + once(event: string, listener: (message: unknown) => void): this { + const wrapper = (message: unknown): void => { + this.off(event, wrapper); + listener(message); + }; + return this.on(event, wrapper); + } + + off(event: string, listener: (message: unknown) => void): this { + const listeners = this.listeners.get(event) ?? []; + this.listeners.set( + event, + listeners.filter((candidate) => candidate !== listener), + ); + return this; + } + + emit(event: string, message: unknown): void { + for (const listener of [...(this.listeners.get(event) ?? [])]) listener(message); + } + } + + class Port extends Emitter { + responseFrame = new Uint8Array([1]); + + readonly postMessage = vi.fn((message: unknown, _transferList?: readonly ArrayBuffer[]) => { + if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'request') { + queueMicrotask(() => { + this.emit('message', { + frame: this.responseFrame, + type: 'response', + }); + }); + } + }); + } + class Worker extends Emitter { + readonly postMessage = vi.fn<(message: unknown) => void>(); + readonly terminate = vi.fn(() => Promise.resolve(0)); + + constructor() { + super(); + workers.push(this); + queueMicrotask(() => { + this.emit('message', { type: 'ready' }); + if (state.workerMessage !== undefined) this.emit('message', state.workerMessage); + }); + } + } + const state: { workerMessage: unknown } = { workerMessage: undefined }; + const backend = {}; + const manager = { + backendForPeer: vi.fn(() => backend), + close: vi.fn(() => Promise.resolve()), + lockForSleep: vi.fn(() => Promise.resolve()), + }; + const handleVaultIpcRequest = vi.fn((request: { id: string }) => + Promise.resolve({ + id: request.id, + ok: true as const, + result: { lock_state: 'locked' as const }, + version: 1 as const, + }), + ); + const hardenVaultDaemonProcess = vi.fn(); + const parentPort = new Port(); + const transport = { + accept: vi.fn(), + close: vi.fn(), + completeServiceStop: vi.fn(), + markServiceReady: vi.fn(), + read: vi.fn(), + serviceControlState: vi.fn(), + write: vi.fn(), + }; + const workers: Worker[] = []; + return { + backend, + handleVaultIpcRequest, + hardenVaultDaemonProcess, + manager, + parentPort, + state, + transport, + Worker, + workers, + }; +}); + +vi.mock('node:worker_threads', () => ({ + parentPort: mocks.parentPort, + Worker: mocks.Worker, +})); +vi.mock('../../../src/secure-storage/vault-tenant-manager.js', () => ({ + MultiTenantVaultBackendManager: class { + backendForPeer = mocks.manager.backendForPeer; + close = mocks.manager.close; + lockForSleep = mocks.manager.lockForSleep; + }, +})); +vi.mock('../../../src/secure-storage/vault-daemon-handler.js', () => ({ + handleVaultIpcRequest: mocks.handleVaultIpcRequest, +})); +vi.mock('../../../src/secure-storage/vault-protected-key.js', () => ({ + hardenVaultDaemonProcess: mocks.hardenVaultDaemonProcess, +})); +vi.mock('../../../src/secure-storage/vault-windows-transport.js', () => ({ + createPackagedWindowsVaultTransport: () => mocks.transport, +})); + +import { runWindowsVaultWorker } from '../../../src/secure-storage/vault-windows-service.js'; + +describe('Windows vault service workers', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.workers.length = 0; + mocks.state.workerMessage = undefined; + }); + + it('accepts one verified request, clears transferred bytes, writes the response, and exits on stop', async () => { + const connection = { + connection: {}, + peer: { path: 'C:\\Program Files\\InFlow\\inflow.exe', pid: 42, principal: 'S-1-5-21-1000', uid: 0 }, + }; + const request = encodeVaultIpcMessage({ + id: 'request', + method: 'vault.status', + params: {}, + version: 1, + }); + mocks.transport.accept.mockReturnValueOnce(connection).mockImplementationOnce(() => { + throw new Error('cancelled'); + }); + mocks.transport.read.mockReturnValue(request); + mocks.transport.serviceControlState.mockReturnValue({ lockRequested: false, stopRequested: true }); + + await runWindowsVaultWorker({ buildId: null, cliVersion: null, role: 'pipe' }, new URL('file:///inflow.js')); + + expect(mocks.parentPort.postMessage).toHaveBeenCalledWith({ type: 'ready' }); + const transferCall = mocks.parentPort.postMessage.mock.calls.find(([message]) => isRequest(message)); + const transferredMessage = transferCall?.[0]; + expect(isRequest(transferredMessage)).toBe(true); + if (!isRequest(transferredMessage)) throw new Error('expected transferred request'); + expect(transferredMessage.peer).toEqual(connection.peer); + expect(transferCall?.[1]).toEqual([transferredMessage.frame.buffer]); + expect(request.every((byte) => byte === 0)).toBe(true); + expect(mocks.transport.write).toHaveBeenCalledOnce(); + }); + + it('closes a connection for a malformed worker response before stopping', async () => { + const connection = { + connection: {}, + peer: { path: 'C:\\Program Files\\InFlow\\inflow.exe', pid: 42, principal: 'S-1-5-21-1000', uid: 0 }, + }; + const request = Buffer.from( + encodeVaultIpcMessage({ + id: 'request', + method: 'vault.status', + params: {}, + version: 1, + }), + ); + mocks.parentPort.postMessage + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => { + queueMicrotask(() => mocks.parentPort.emit('message', { type: 'invalid' })); + }); + mocks.transport.accept.mockReturnValueOnce(connection).mockImplementationOnce(() => { + throw new Error('cancelled'); + }); + mocks.transport.read.mockReturnValue(request); + mocks.transport.serviceControlState.mockReturnValue({ lockRequested: false, stopRequested: true }); + + await runWindowsVaultWorker({ buildId: null, cliVersion: null, role: 'pipe' }, new URL('file:///inflow.js')); + + expect(mocks.transport.close).toHaveBeenCalledWith(connection); + }); + + it('marks runtime readiness, applies lock control, and completes a clean stop', async () => { + const request = encodeVaultIpcMessage({ + id: 'runtime-request', + method: 'vault.status', + params: {}, + version: 1, + }); + const peer = { + path: 'C:\\Program Files\\InFlow\\inflow.exe', + pid: 42, + principal: 'S-1-5-21-1000', + uid: 0, + }; + mocks.state.workerMessage = { frame: request, peer, type: 'request' }; + mocks.transport.serviceControlState.mockReturnValueOnce({ lockRequested: true, stopRequested: true }); + + await runWindowsVaultWorker( + { buildId: 'build', cliVersion: '1.2.3', role: 'runtime' }, + new URL('file:///inflow.js'), + ); + + expect(mocks.transport.markServiceReady).toHaveBeenCalledOnce(); + expect(mocks.hardenVaultDaemonProcess).toHaveBeenCalledOnce(); + expect(mocks.manager.backendForPeer).toHaveBeenCalledWith(peer); + expect(mocks.handleVaultIpcRequest).toHaveBeenCalledWith( + mocks.backend, + expect.objectContaining({ id: 'runtime-request', method: 'vault.status' }), + expect.objectContaining({ buildId: 'build', cliVersion: '1.2.3' }), + { allowDaemonShutdown: false }, + ); + expect(mocks.workers[0]?.postMessage.mock.calls.some(([message]) => isResponse(message))).toBe(true); + expect(mocks.manager.lockForSleep).toHaveBeenCalledOnce(); + expect(mocks.workers[0]?.terminate).toHaveBeenCalledOnce(); + expect(mocks.manager.close).toHaveBeenCalledOnce(); + expect(mocks.transport.completeServiceStop).toHaveBeenCalledOnce(); + }); + + it('returns a stable corruption response when a pipe sends a response frame as a request', async () => { + mocks.state.workerMessage = { + frame: encodeVaultIpcMessage({ + id: 'malformed-request', + ok: true, + result: { lock_state: 'locked' }, + version: 1, + }), + peer: { + path: 'C:\\Program Files\\InFlow\\inflow.exe', + pid: 42, + principal: 'S-1-5-21-1000', + uid: 0, + }, + type: 'request', + }; + mocks.transport.serviceControlState.mockReturnValueOnce({ + lockRequested: false, + stopRequested: true, + }); + + await runWindowsVaultWorker({ buildId: null, cliVersion: null, role: 'runtime' }, new URL('file:///inflow.js')); + + expect(mocks.handleVaultIpcRequest).not.toHaveBeenCalled(); + expect(mocks.workers[0]?.postMessage.mock.calls.some(([message]) => isResponse(message))).toBe(true); + expect(mocks.transport.completeServiceStop).toHaveBeenCalledOnce(); + }); +}); + +function isResponse(value: unknown): boolean { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + value.type === 'response' && + 'frame' in value && + value.frame instanceof Uint8Array + ); +} + +function isRequest(value: unknown): value is { + frame: Uint8Array; + peer: { path: string; pid: number; principal: string; uid: number }; + type: 'request'; +} { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + value.type === 'request' && + 'frame' in value && + value.frame instanceof Uint8Array && + 'peer' in value && + typeof value.peer === 'object' && + value.peer !== null + ); +} diff --git a/packages/core/test/unit/secure-storage/vault-windows-service.test.ts b/packages/core/test/unit/secure-storage/vault-windows-service.test.ts new file mode 100644 index 0000000..2338911 --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-windows-service.test.ts @@ -0,0 +1,122 @@ +import process from 'node:process'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + class Worker { + readonly listeners = new Map void>>(); + readonly terminate = vi.fn(() => Promise.resolve(0)); + + constructor( + readonly entry: URL, + readonly options: { workerData: unknown }, + ) { + workers.push(this); + } + + emit(event: string, ...arguments_: unknown[]): void { + for (const listener of this.listeners.get(event) ?? []) listener(...arguments_); + this.listeners.delete(event); + } + + on(event: string, listener: (...arguments_: unknown[]) => void): this { + const listeners = this.listeners.get(event) ?? []; + listeners.push(listener); + this.listeners.set(event, listeners); + return this; + } + + once(event: string, listener: (...arguments_: unknown[]) => void): this { + return this.on(event, listener); + } + } + const workers: Worker[] = []; + const transport = { + completeServiceStop: vi.fn(), + markServiceReady: vi.fn(), + runServiceDispatcher: vi.fn(), + serviceControlState: vi.fn(), + }; + return { transport, Worker, workers }; +}); + +vi.mock('node:worker_threads', () => ({ + parentPort: null, + Worker: mocks.Worker, +})); +vi.mock('../../../src/secure-storage/vault-windows-transport.js', () => ({ + createPackagedWindowsVaultTransport: () => mocks.transport, +})); + +import { + isWindowsVaultWorkerData, + runWindowsVaultService, + runWindowsVaultWorker, +} from '../../../src/secure-storage/vault-windows-service.js'; + +describe('Windows vault service orchestration', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.workers.length = 0; + mocks.transport.serviceControlState.mockReturnValue({ lockRequested: false, stopRequested: false }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('recognizes only complete Windows worker data', () => { + expect(isWindowsVaultWorkerData({ buildId: null, cliVersion: null, role: 'pipe' })).toBe(true); + expect(isWindowsVaultWorkerData({ buildId: 'build', cliVersion: '1.2.3', role: 'runtime' })).toBe(true); + expect(isWindowsVaultWorkerData(null)).toBe(false); + expect(isWindowsVaultWorkerData({ buildId: 1, cliVersion: null, role: 'pipe' })).toBe(false); + expect(isWindowsVaultWorkerData({ buildId: null, cliVersion: 1, role: 'pipe' })).toBe(false); + expect(isWindowsVaultWorkerData({ buildId: null, cliVersion: null, role: 'other' })).toBe(false); + }); + + it('rejects service execution outside Windows', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + + await expect(runWindowsVaultService(new URL('file:///inflow.js'))).rejects.toThrow( + 'The Windows vault service is available only on Windows.', + ); + }); + + it('starts the runtime worker, runs the dispatcher, and terminates after runtime exit', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + mocks.transport.runServiceDispatcher.mockImplementation(() => { + queueMicrotask(() => mocks.workers[0]?.emit('exit', 0)); + }); + + await runWindowsVaultService(new URL('file:///inflow.js'), { buildId: 'build', cliVersion: '1.2.3' }); + + expect(mocks.workers).toHaveLength(1); + expect(mocks.workers[0]?.options.workerData).toEqual({ + buildId: 'build', + cliVersion: '1.2.3', + role: 'runtime', + }); + expect(mocks.transport.runServiceDispatcher).toHaveBeenCalledOnce(); + expect(mocks.workers[0]?.terminate).toHaveBeenCalledOnce(); + }); + + it('terminates the runtime worker when the dispatcher fails', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + mocks.transport.runServiceDispatcher.mockImplementation(() => { + throw new Error('dispatcher failed'); + }); + + await expect(runWindowsVaultService(new URL('file:///inflow.js'))).rejects.toThrow('dispatcher failed'); + expect(mocks.workers[0]?.terminate).toHaveBeenCalledOnce(); + }); + + it('fails closed when a pipe or runtime worker has no parent port', async () => { + await expect( + runWindowsVaultWorker({ buildId: null, cliVersion: null, role: 'pipe' }, new URL('file:///inflow.js')), + ).rejects.toThrow('The Windows vault pipe worker requires a parent port.'); + await expect( + runWindowsVaultWorker({ buildId: null, cliVersion: null, role: 'runtime' }, new URL('file:///inflow.js')), + ).rejects.toThrow('The Windows vault runtime worker requires a parent port.'); + }); +}); diff --git a/packages/core/test/unit/secure-storage/vault-windows-transport.test.ts b/packages/core/test/unit/secure-storage/vault-windows-transport.test.ts new file mode 100644 index 0000000..d16c0da --- /dev/null +++ b/packages/core/test/unit/secure-storage/vault-windows-transport.test.ts @@ -0,0 +1,224 @@ +import { Buffer } from 'node:buffer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SecureStorageError } from '../../../src/secure-storage/errors.js'; +import type * as VaultPeerVerifierModule from '../../../src/secure-storage/vault-peer-verifier.js'; + +const mocks = vi.hoisted(() => { + const realpath = vi.fn<(path: string) => string>(); + const native = { + acceptPipeConnection: vi.fn(), + beginPipeSession: vi.fn(), + closePipeConnection: vi.fn(), + completeServiceStop: vi.fn(), + connectPipe: vi.fn(), + exchangePipeRequest: vi.fn(), + markServiceReady: vi.fn(), + readPipeRequest: vi.fn(), + runServiceDispatcher: vi.fn(), + serviceControlState: vi.fn(), + verifyAuthenticode: vi.fn(), + writePipeResponse: vi.fn(), + }; + return { native, realpath, verifyVaultNativeModule: vi.fn() }; +}); + +vi.mock('node:fs', () => ({ + realpathSync: Object.assign(mocks.realpath, { native: mocks.realpath }), +})); +vi.mock('../../../src/secure-storage/runtime-require.js', () => ({ + runtimeRequire: () => () => mocks.native, +})); +vi.mock('../../../src/secure-storage/vault-peer-verifier.js', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, verifyVaultNativeModule: mocks.verifyVaultNativeModule }; +}); + +import { + sendWindowsVaultIpcRequestWithTransport, + WindowsVaultTransport, +} from '../../../src/secure-storage/vault-windows-transport.js'; +import { encodeVaultIpcMessage } from '../../../src/secure-storage/vault-ipc.js'; + +const executablePath = 'C:\\Program Files\\InFlow\\inflow.exe'; +const nativeModulePath = 'C:\\Program Files\\InFlow\\native\\vault_peer_windows.node'; +const peer = { path: executablePath, pid: 42, principal: 'S-1-5-21-1000', uid: 0 }; + +describe('Windows vault transport', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.realpath.mockImplementation((path) => path); + mocks.native.verifyAuthenticode.mockReturnValue({ + publisher: 'InFlow Development Signing', + thumbprint: 'a'.repeat(64), + }); + mocks.native.serviceControlState.mockReturnValue({ lockRequested: false, stopRequested: false }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('verifies the native module and authenticates the daemon before beginning the session', () => { + const connection = {}; + const order: string[] = []; + mocks.native.connectPipe.mockImplementation(() => { + order.push('connect'); + return { connection, peer }; + }); + mocks.native.verifyAuthenticode.mockImplementation(() => { + order.push('verify'); + return { publisher: 'InFlow Development Signing', thumbprint: 'a'.repeat(64) }; + }); + mocks.native.beginPipeSession.mockImplementation(() => { + order.push('handshake'); + }); + + const transport = createTransport(); + + expect(transport.connect('\\\\.\\pipe\\InFlowVault')).toMatchObject({ connection, peer }); + expect(order.slice(-3)).toEqual(['connect', 'verify', 'handshake']); + expect(mocks.verifyVaultNativeModule).toHaveBeenCalledWith(nativeModulePath, { + expectedSha256: 'b'.repeat(64), + expectedTeamId: '', + requireSignature: false, + }); + }); + + it('closes a verified connection when the authentication handshake fails', () => { + const connection = {}; + mocks.native.connectPipe.mockReturnValue({ connection, peer }); + mocks.native.beginPipeSession.mockImplementation(() => { + throw new Error('handshake failed'); + }); + const transport = createTransport(); + + expect(() => transport.connect('\\\\.\\pipe\\InFlowVault')).toThrow('handshake failed'); + expect(mocks.native.closePipeConnection).toHaveBeenCalledWith(connection); + }); + + it('rejects and closes peers with the wrong path, principal, publisher, or thumbprint', () => { + const candidates = [ + { ...peer, path: 'C:\\Temp\\inflow.exe' }, + { ...peer, principal: 'invalid' }, + { ...peer, path: executablePath }, + { ...peer, path: executablePath }, + ]; + const signerResults = [ + { publisher: 'Other Publisher', thumbprint: 'a'.repeat(64) }, + { publisher: 'InFlow Development Signing', thumbprint: 'invalid' }, + ]; + const transport = createTransport(); + + for (const candidate of candidates.slice(0, 2)) { + const connection = {}; + mocks.native.acceptPipeConnection.mockReturnValue({ connection, peer: candidate }); + expect(() => transport.accept('\\\\.\\pipe\\InFlowVault')).toThrow(SecureStorageError); + expect(mocks.native.closePipeConnection).toHaveBeenLastCalledWith(connection); + } + for (const signer of signerResults) { + const connection = {}; + mocks.native.acceptPipeConnection.mockReturnValue({ connection, peer }); + mocks.native.verifyAuthenticode.mockReturnValueOnce(signer); + expect(() => transport.accept('\\\\.\\pipe\\InFlowVault')).toThrow(SecureStorageError); + expect(mocks.native.closePipeConnection).toHaveBeenLastCalledWith(connection); + } + }); + + it('delegates verified connection operations to the native module', () => { + const connection = {}; + const frame = Buffer.from('request'); + const response = Buffer.from('response'); + mocks.native.acceptPipeConnection.mockReturnValue({ connection, peer }); + mocks.native.readPipeRequest.mockReturnValue(frame); + mocks.native.exchangePipeRequest.mockReturnValue(response); + const transport = createTransport(); + const verified = transport.accept('\\\\.\\pipe\\InFlowVault'); + + expect(transport.read(verified)).toBe(frame); + expect(transport.exchange(verified, frame)).toBe(response); + transport.write(verified, response); + transport.close(verified); + transport.markServiceReady(); + transport.runServiceDispatcher(); + expect(transport.serviceControlState()).toEqual({ lockRequested: false, stopRequested: false }); + transport.completeServiceStop(); + + expect(mocks.native.writePipeResponse).toHaveBeenCalledWith(connection, response); + expect(mocks.native.closePipeConnection).toHaveBeenCalledWith(connection); + expect(mocks.native.markServiceReady).toHaveBeenCalledOnce(); + expect(mocks.native.runServiceDispatcher).toHaveBeenCalledOnce(); + expect(mocks.native.completeServiceStop).toHaveBeenCalledOnce(); + }); + + it('fails closed for an invalid self signature and an unreadable canonical path', () => { + mocks.native.verifyAuthenticode.mockReturnValueOnce({ + publisher: 'InFlow Development Signing', + thumbprint: 'invalid', + }); + expect(() => createTransport()).toThrow(SecureStorageError); + + mocks.realpath.mockImplementationOnce(() => { + throw new Error('unreadable'); + }); + expect(() => createTransport()).toThrow(SecureStorageError); + }); + + it('exchanges one Windows IPC request and clears request and response frames', () => { + const connection = {}; + const responseFrame = encodeVaultIpcMessage({ + id: 'request-1', + ok: true, + result: { lockState: 'locked' }, + version: 1, + }); + const transport = { + close: vi.fn(), + connect: vi.fn(() => ({ connection, peer })), + exchange: vi.fn(() => responseFrame), + }; + + expect( + sendWindowsVaultIpcRequestWithTransport(transport, '\\\\.\\pipe\\InFlowVault', { + id: 'request-1', + method: 'vault.status', + params: {}, + version: 1, + }), + ).toMatchObject({ id: 'request-1', ok: true }); + expect(transport.connect).toHaveBeenCalledWith('\\\\.\\pipe\\InFlowVault'); + expect(transport.close).toHaveBeenCalledWith({ connection, peer }); + expect(responseFrame).toEqual(Buffer.alloc(responseFrame.byteLength)); + }); + + it('rejects malformed Windows IPC responses and still clears their bytes', () => { + for (const response of [ + { id: 'other', ok: true as const, result: {}, version: 1 as const }, + { id: 'request-1', method: 'vault.status' as const, params: {}, version: 1 as const }, + ]) { + const responseFrame = encodeVaultIpcMessage(response); + const transport = { + close: vi.fn(), + connect: vi.fn(() => ({ connection: {}, peer })), + exchange: vi.fn(() => responseFrame), + }; + expect(() => + sendWindowsVaultIpcRequestWithTransport(transport, '\\\\.\\pipe\\InFlowVault', { + id: 'request-1', + method: 'vault.status', + params: {}, + version: 1, + }), + ).toThrow('Vault IPC response is malformed'); + expect(transport.close).toHaveBeenCalledOnce(); + expect(responseFrame).toEqual(Buffer.alloc(responseFrame.byteLength)); + } + }); +}); + +function createTransport(): WindowsVaultTransport { + return new WindowsVaultTransport({ + expectedExecutablePath: executablePath, + expectedNativeModuleSha256: 'b'.repeat(64), + nativeModulePath, + }); +} diff --git a/packages/core/test/unit/session.test.ts b/packages/core/test/unit/session.test.ts index b200b12..aef22b0 100644 --- a/packages/core/test/unit/session.test.ts +++ b/packages/core/test/unit/session.test.ts @@ -47,6 +47,21 @@ describe('createAccessTokenProvider', () => { expect(refreshSpy).not.toHaveBeenCalled(); }); + it('observes credential changes during one provider lifetime', async () => { + const storage = new MemoryStorage({ + ...initialTokens, + expires_at: Date.now() + 5 * 60_000, + }); + const { resource } = makeAuthResource(() => Promise.resolve({ ...initialTokens, access_token: 'rotated' })); + const provide = createAccessTokenProvider(resource, storage); + + expect(await provide()).toBe('access-1'); + storage.setAuth({ ...initialTokens, access_token: 'access-2', expires_at: Date.now() + 5 * 60_000 }); + expect(await provide()).toBe('access-2'); + storage.clearAuth(); + await expect(provide()).rejects.toBeInstanceOf(InflowAuthenticationError); + }); + it('refreshes when expiry within the 60s buffer', async () => { const storage = new MemoryStorage({ ...initialTokens, diff --git a/packages/core/test/unit/utils/storage.test.ts b/packages/core/test/unit/utils/storage.test.ts index 154e5bb..a49359a 100644 --- a/packages/core/test/unit/utils/storage.test.ts +++ b/packages/core/test/unit/utils/storage.test.ts @@ -1,11 +1,12 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { AuthTokens } from '../../../src/types/index.js'; import { MemoryStorage, Storage } from '../../../src/utils/storage.js'; import { AepStorage } from '../../../src/aep/storage.js'; -import { SyncMemorySecretStore, type SecretReference } from '../../../src/secure-storage/keychain.js'; +import { SyncMemorySecretStore, type SecretReference } from '../../../src/secure-storage/secret-store.js'; +import { SecureSqliteRepository } from '../../../src/secure-storage/sqlite.js'; const sampleAuth: AuthTokens = { access_token: 'a', @@ -16,11 +17,17 @@ const sampleAuth: AuthTokens = { class CountingSecretStore extends SyncMemorySecretStore { readonly deleted: SecretReference[] = []; + readonly readReferences: SecretReference[] = []; override delete(reference: SecretReference): void { super.delete(reference); this.deleted.push(reference); } + + override read(reference: SecretReference): Uint8Array { + this.readReferences.push(reference); + return super.read(reference); + } } describe('Storage (file-backed)', () => { @@ -31,6 +38,7 @@ describe('Storage (file-backed)', () => { }); afterEach(() => { + vi.restoreAllMocks(); rmSync(tmpDir, { recursive: true, force: true }); }); @@ -65,6 +73,16 @@ describe('Storage (file-backed)', () => { expect(s.isAuthenticated()).toBe(false); }); + it('isAuthenticated checks stored auth metadata without reading secrets', () => { + const secretStore = new CountingSecretStore(); + const s = new Storage({ cwd: tmpDir, secretStore }); + s.setAuth(sampleAuth); + secretStore.readReferences.length = 0; + + expect(s.isAuthenticated()).toBe(true); + expect(secretStore.readReferences).toEqual([]); + }); + it('pendingDeviceAuth evicts at read time when expired', () => { const s = secureStorage(); s.setPendingDeviceAuth({ @@ -121,6 +139,40 @@ describe('Storage (file-backed)', () => { expect(s.getApiKey()).toBeNull(); }); + it.each([ + { + expectedDeletes: 2, + label: 'authentication', + write: (storage: Storage) => storage.setAuth(sampleAuth), + }, + { + expectedDeletes: 1, + label: 'API key', + write: (storage: Storage) => storage.setApiKey('inflow_live_rollback'), + }, + { + expectedDeletes: 1, + label: 'pending device authentication', + write: (storage: Storage) => + storage.setPendingDeviceAuth({ + device_code: 'device-code', + expires_at: Date.now() + 60_000, + interval: 5, + phrase: 'P-1', + verification_url: 'https://example.test/verify', + }), + }, + ])('deletes newly created $label secrets when metadata persistence fails', ({ expectedDeletes, write }) => { + const secretStore = new CountingSecretStore(); + const storage = new Storage({ cwd: tmpDir, secretStore }); + vi.spyOn(SecureSqliteRepository.prototype, 'upsertSetting').mockImplementation(() => { + throw new Error('metadata write failed'); + }); + + expect(() => write(storage)).toThrow('metadata write failed'); + expect(secretStore.deleted).toHaveLength(expectedDeletes); + }); + it('isAuthenticated is true with just an apiKey set (no device tokens)', () => { const s = secureStorage(); expect(s.isAuthenticated()).toBe(false); @@ -255,7 +307,7 @@ describe('Storage (file-backed)', () => { await expect(s.deleteConfig()).resolves.toBeUndefined(); }); - it('stores AEP credential payloads as Keychain secrets and reconstructs state', () => { + it('stores AEP credential payloads as vault secrets and reconstructs state', () => { const s = secureStorage(); s.setAepState({ credentials: { @@ -329,6 +381,115 @@ describe('Storage (file-backed)', () => { expect(readFileSync(s.getPath(), 'utf8')).not.toContain('expired-secret-value'); expect(secretStore.deleted.length).toBeGreaterThanOrEqual(2); }); + + it('finds AEP identities and deletes matching credentials without reading credential payloads', () => { + const secretStore = new CountingSecretStore(); + const s = new Storage({ cwd: tmpDir, secretStore }); + const owner = { platformOrigin: 'https://platform.example', userId: 'user-1' }; + s.setAepState({ + credentials: { + 'did:web:service.example': { + keep: { + credential: { credential_id: 'keep', value: 'keep-secret-value' }, + credentialId: 'keep', + expiresAt: '2999-01-01T00:00:00.000Z', + grantType: 'oauth-bearer', + issuedAt: '2026-01-01T00:00:00.000Z', + serviceDid: 'did:web:service.example', + }, + remove: { + credential: { credential_id: 'remove', value: 'remove-secret-value' }, + credentialId: 'remove', + expiresAt: '2999-01-01T00:00:00.000Z', + grantType: 'api-key', + issuedAt: '2026-01-01T00:00:00.000Z', + serviceDid: 'did:web:service.example', + }, + }, + }, + identities: { + 'did:web:service.example': { + agentDid: 'did:web:platform.example:agents:one', + identityKind: 'platform-hosted', + serviceDid: 'did:web:service.example', + signingAlgorithms: ['ES256'], + }, + }, + owner, + version: 1, + }); + secretStore.readReferences.length = 0; + + expect(s.findAepIdentity(owner, 'did:web:service.example')).toMatchObject({ + agentDid: 'did:web:platform.example:agents:one', + }); + s.deleteAepCredentials(owner, 'did:web:service.example', { grantType: 'api-key' }); + + expect(secretStore.readReferences.filter((reference) => reference.purpose === 'aep-credential')).toEqual([]); + expect(secretStore.deleted.filter((reference) => reference.purpose === 'aep-credential')).toHaveLength(1); + const state = s.getAepState(); + expect(state?.credentials['did:web:service.example']?.['remove']).toBeUndefined(); + expect(state?.credentials['did:web:service.example']?.['keep']).toMatchObject({ + credential: { credential_id: 'keep', value: 'keep-secret-value' }, + }); + + s.deleteAepCredentials(owner, 'did:web:service.example', { allGrantTypes: true }); + expect(s.getAepState()?.credentials['did:web:service.example']).toBeUndefined(); + }); + + it('deletes newly created AEP secrets when metadata persistence fails', () => { + const secretStore = new CountingSecretStore(); + const storage = new Storage({ cwd: tmpDir, secretStore }); + vi.spyOn(SecureSqliteRepository.prototype, 'upsertSetting').mockImplementation(() => { + throw new Error('metadata write failed'); + }); + + expect(() => + storage.setAepState({ + credentials: { + 'did:web:service.example': { + 'credential-1': { + credential: { credential_id: 'credential-1', value: 'secret-value' }, + credentialId: 'credential-1', + grantType: 'api-key', + issuedAt: '2026-01-01T00:00:00.000Z', + serviceDid: 'did:web:service.example', + }, + }, + }, + identities: {}, + owner: { platformOrigin: 'https://platform.example', userId: 'user-1' }, + version: 1, + }), + ).toThrow('metadata write failed'); + expect(secretStore.deleted).toHaveLength(1); + }); + + it('clears secure AEP state when direct operations use a different owner', () => { + const owner = { platformOrigin: 'https://platform.example', userId: 'user-1' }; + const otherOwner = { ...owner, userId: 'user-2' }; + const first = secureStorage(); + first.setAepState({ + credentials: {}, + identities: { + 'did:web:service.example': { + agentDid: 'did:web:platform.example:agents:one', + identityKind: 'platform-hosted', + serviceDid: 'did:web:service.example', + signingAlgorithms: ['ES256'], + }, + }, + owner, + version: 1, + }); + expect(first.findAepIdentity(otherOwner, 'did:web:service.example')).toBeUndefined(); + expect(first.getAepState()).toBeNull(); + + const second = secureStorage(); + second.setAepState({ credentials: {}, identities: {}, owner, version: 1 }); + second.deleteAepCredentials(otherOwner, 'did:web:service.example', { allGrantTypes: true }); + expect(second.getAepState()).toBeNull(); + }); }); describe('MemoryStorage', () => { diff --git a/packaging/linux/inflow-tmpfiles.conf b/packaging/linux/inflow-tmpfiles.conf new file mode 100644 index 0000000..0965551 --- /dev/null +++ b/packaging/linux/inflow-tmpfiles.conf @@ -0,0 +1,2 @@ +d /var/lib/inflow 0700 inflow inflow - +d /var/lib/inflow-broker 0755 root root - diff --git a/packaging/linux/inflow-vault.service b/packaging/linux/inflow-vault.service new file mode 100644 index 0000000..6b12cf6 --- /dev/null +++ b/packaging/linux/inflow-vault.service @@ -0,0 +1,33 @@ +[Unit] +Description=InFlow multi-tenant vault service +Requires=inflow-vault.socket +After=inflow-vault.socket + +[Service] +Type=simple +ExecStart=/opt/inflow/bin/inflow --daemon vault-broker +RuntimeDirectory=inflow +RuntimeDirectoryMode=0755 +NoNewPrivileges=yes +CapabilityBoundingSet=CAP_SYS_PTRACE CAP_SETUID CAP_SETGID +PrivateDevices=yes +PrivateTmp=yes +ProtectClock=yes +ProtectControlGroups=yes +ProtectHome=yes +ProtectHostname=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectSystem=strict +ReadWritePaths=/run/inflow /var/lib/inflow /var/lib/inflow-broker +RestrictAddressFamilies=AF_UNIX +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +SystemCallArchitectures=native +UMask=0077 + +[Install] +Also=inflow-vault.socket diff --git a/packaging/linux/inflow-vault.socket b/packaging/linux/inflow-vault.socket new file mode 100644 index 0000000..d7755f1 --- /dev/null +++ b/packaging/linux/inflow-vault.socket @@ -0,0 +1,14 @@ +[Unit] +Description=InFlow vault service socket + +[Socket] +ListenStream=/run/inflow/vault.sock +FileDescriptorName=inflow-vault +SocketUser=root +SocketGroup=root +SocketMode=0666 +DirectoryMode=0755 +RemoveOnStop=yes + +[Install] +WantedBy=sockets.target diff --git a/packaging/linux/inflow.conf b/packaging/linux/inflow.conf new file mode 100644 index 0000000..5cf2c05 --- /dev/null +++ b/packaging/linux/inflow.conf @@ -0,0 +1 @@ +u inflow - "InFlow vault service" /var/lib/inflow - diff --git a/packaging/linux/install.sh.template b/packaging/linux/install.sh.template new file mode 100644 index 0000000..bdb1b2c --- /dev/null +++ b/packaging/linux/install.sh.template @@ -0,0 +1,148 @@ +#!/bin/sh +set -eu + +version='__INFLOW_VERSION__' +repository='inflowpayai/inflow-cli' +release_base_url="${INFLOW_RELEASE_BASE_URL:-https://github.com/$repository/releases/download/v$version}" +signing_fingerprint='__INFLOW_LINUX_SIGNING_FINGERPRINT__' +temporary_directory='' + +say() { + printf '%s\n' "[inflow] $*" +} + +fail() { + printf '%s\n' "[inflow] $*" >&2 + exit 1 +} + +cleanup() { + if [ -n "$temporary_directory" ]; then + rm -rf "$temporary_directory" + fi +} + +privileged() { + if [ "$(id -u)" -eq 0 ]; then + "$@" + elif command -v sudo >/dev/null 2>&1; then + sudo "$@" + else + fail "Installation requires root privileges. Install sudo or run this installer as root." + fi +} + +checksum() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c "$1" + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 -c "$1" + else + fail "SHA-256 verification requires sha256sum or shasum." + fi +} + +fingerprint() { + gpg --batch --with-colons --show-keys "$1" | + awk -F: '$1 == "fpr" { print toupper($10); exit }' +} + +if [ "$(uname -s)" != 'Linux' ]; then + fail "This installer supports Linux only." +fi + +architecture="$(uname -m)" +case "$architecture" in + aarch64 | arm64) + debian_architecture='arm64' + rpm_architecture='aarch64' + ;; + x86_64 | amd64) + debian_architecture='amd64' + rpm_architecture='x86_64' + ;; + *) + fail "Unsupported Linux architecture: $architecture" + ;; +esac + +action="${1:-install}" +case "$action" in + --uninstall) + if command -v apt-get >/dev/null 2>&1; then + privileged env DEBIAN_FRONTEND=noninteractive apt-get remove --yes inflow + elif command -v dnf >/dev/null 2>&1; then + privileged dnf remove --assumeyes inflow + else + fail "Uninstall requires apt-get or dnf." + fi + say "InFlow was uninstalled. Encrypted vault data was preserved." + exit 0 + ;; + --purge) + if command -v apt-get >/dev/null 2>&1; then + privileged env DEBIAN_FRONTEND=noninteractive apt-get purge --yes inflow + elif command -v dnf >/dev/null 2>&1; then + privileged dnf remove --assumeyes inflow + privileged rm -rf /var/lib/inflow + else + fail "Purge requires apt-get or dnf." + fi + say "InFlow and its system vault data were removed." + exit 0 + ;; + install) + ;; + *) + fail "Usage: install.sh [--uninstall|--purge]" + ;; +esac + +command -v curl >/dev/null 2>&1 || fail "Installation requires curl." +command -v gpg >/dev/null 2>&1 || fail "OpenPGP verification requires gpg." +command -v gpgv >/dev/null 2>&1 || fail "OpenPGP verification requires gpgv." +temporary_directory="$(mktemp -d)" +trap cleanup EXIT HUP INT TERM + +if command -v apt-get >/dev/null 2>&1; then + asset="inflow_${version}_${debian_architecture}.deb" + installer='apt' +elif command -v dnf >/dev/null 2>&1; then + asset="inflow-${version}-1.${rpm_architecture}.rpm" + installer='dnf' +else + fail "Installation requires a supported Debian/Ubuntu or Fedora/RHEL system with systemd." +fi + +say "Downloading InFlow $version for $architecture." +curl --fail --location --silent --show-error --output "$temporary_directory/$asset" "$release_base_url/$asset" +curl --fail --location --silent --show-error --output "$temporary_directory/SHA256SUMS" "$release_base_url/SHA256SUMS" +curl --fail --location --silent --show-error --output "$temporary_directory/SHA256SUMS.asc" "$release_base_url/SHA256SUMS.asc" +curl --fail --location --silent --show-error --output "$temporary_directory/inflow-linux-signing-key.asc" \ + "$release_base_url/inflow-linux-signing-key.asc" + +actual_fingerprint="$(fingerprint "$temporary_directory/inflow-linux-signing-key.asc")" +[ "$actual_fingerprint" = "$signing_fingerprint" ] || + fail "The Linux release signing-key fingerprint is invalid." +gpg --batch --yes --dearmor --output "$temporary_directory/inflow-linux-signing-key.gpg" \ + "$temporary_directory/inflow-linux-signing-key.asc" +gpgv --keyring "$temporary_directory/inflow-linux-signing-key.gpg" \ + "$temporary_directory/SHA256SUMS.asc" "$temporary_directory/SHA256SUMS" || + fail "The Linux release signature is invalid." +awk -v asset="$asset" '$2 == asset { print; found = 1 } END { if (!found) exit 1 }' \ + "$temporary_directory/SHA256SUMS" > "$temporary_directory/$asset.sha256" || + fail "The Linux release manifest does not contain the selected package." +( + cd "$temporary_directory" + checksum "$asset.sha256" +) + +if [ "$installer" = 'apt' ]; then + privileged env DEBIAN_FRONTEND=noninteractive apt-get install --yes "$temporary_directory/$asset" +else + privileged dnf install --assumeyes "$temporary_directory/$asset" +fi + +command -v inflow >/dev/null 2>&1 || fail "InFlow was installed but is not available on PATH." +say "Installed $(inflow --version)." +say "Run 'inflow vault unlock' in a terminal before using credential-dependent commands." diff --git a/packaging/windows/inflow-node.def b/packaging/windows/inflow-node.def new file mode 100644 index 0000000..12df3ff --- /dev/null +++ b/packaging/windows/inflow-node.def @@ -0,0 +1,24 @@ +LIBRARY inflow.exe +EXPORTS + napi_create_buffer_copy + napi_create_error + napi_create_external + napi_create_function + napi_create_object + napi_create_string_utf16 + napi_create_string_utf8 + napi_create_uint32 + napi_define_properties + napi_get_boolean + napi_get_buffer_info + napi_get_cb_info + napi_get_typedarray_info + napi_get_undefined + napi_get_value_external + napi_get_value_int32 + napi_get_value_string_utf16 + napi_is_buffer + napi_is_typedarray + napi_set_named_property + napi_throw + uv_get_osfhandle diff --git a/packaging/windows/inflow.wxs b/packaging/windows/inflow.wxs new file mode 100644 index 0000000..c14a1be --- /dev/null +++ b/packaging/windows/inflow.wxs @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/install.ps1.template b/packaging/windows/install.ps1.template new file mode 100644 index 0000000..61292db --- /dev/null +++ b/packaging/windows/install.ps1.template @@ -0,0 +1,64 @@ +$ErrorActionPreference = 'Stop' + +$version = '__INFLOW_VERSION__' +$uninstall = $env:INFLOW_UNINSTALL -eq '1' +if ($uninstall) { + $product = Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' | + Where-Object { $_.DisplayName -eq 'InFlow' -and $_.Publisher -eq 'Jarwin, Inc.' } | + Select-Object -First 1 + if ($null -eq $product) { + Write-Output 'InFlow is not installed.' + exit 0 + } + $process = Start-Process -FilePath "$env:SystemRoot\System32\msiexec.exe" ` + -ArgumentList @('/x', $product.PSChildName, '/qn', '/norestart') -Verb RunAs -Wait -PassThru + if ($process.ExitCode -notin @(0, 3010)) { + throw "InFlow uninstall failed with Windows Installer exit code $($process.ExitCode)." + } + Write-Output 'InFlow was uninstalled. Encrypted vault data was preserved.' + exit 0 +} + +$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() +if ($architecture -notin @('x64', 'arm64')) { + throw "InFlow $version is not published for Windows architecture '$architecture'." +} + +$asset = "inflow-$version-windows-$architecture.msi" +$uri = "https://github.com/inflowpayai/inflow-cli/releases/download/v$version/$asset" +$expectedHash = if ($architecture -eq 'arm64') { + '__INFLOW_MSI_SHA256_ARM64__' +} else { + '__INFLOW_MSI_SHA256_X64__' +} +$expectedPublisher = '__INFLOW_WINDOWS_PUBLISHER__' +$installerPath = Join-Path ([System.IO.Path]::GetTempPath()) "$([Guid]::NewGuid()).msi" +$logPath = Join-Path ([System.IO.Path]::GetTempPath()) "inflow-install-$([Guid]::NewGuid()).log" + +try { + Invoke-WebRequest -UseBasicParsing -Uri $uri -OutFile $installerPath + $actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installerPath).Hash.ToLowerInvariant() + if ($actualHash -ne $expectedHash) { + throw 'The InFlow installer checksum is invalid.' + } + $signature = Get-AuthenticodeSignature -LiteralPath $installerPath + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { + throw "The InFlow installer signature is invalid: $($signature.StatusMessage)" + } + if ($signature.SignerCertificate.Subject -ne $expectedPublisher) { + throw "The InFlow installer publisher is invalid: $($signature.SignerCertificate.Subject)" + } + $arguments = @('/i', "`"$installerPath`"", '/qn', '/norestart', '/L*v', "`"$logPath`"") + $process = Start-Process -FilePath "$env:SystemRoot\System32\msiexec.exe" ` + -ArgumentList $arguments -Verb RunAs -Wait -PassThru + if ($process.ExitCode -notin @(0, 3010)) { + throw "The InFlow installer failed with Windows Installer exit code $($process.ExitCode). Log: $logPath" + } + if ($process.ExitCode -eq 3010) { + Write-Output 'InFlow was installed. Windows requested a restart.' + } else { + Write-Output 'InFlow was installed.' + } +} finally { + Remove-Item -LiteralPath $installerPath -Force -ErrorAction SilentlyContinue +} diff --git a/packaging/windows/winget/installer.yaml.template b/packaging/windows/winget/installer.yaml.template new file mode 100644 index 0000000..0ae70b8 --- /dev/null +++ b/packaging/windows/winget/installer.yaml.template @@ -0,0 +1,13 @@ +PackageIdentifier: InFlowPayAI.InFlow +PackageVersion: __INFLOW_VERSION__ +InstallerType: wix +Scope: machine +Installers: + - Architecture: x64 + InstallerUrl: https://github.com/inflowpayai/inflow-cli/releases/download/v__INFLOW_VERSION__/inflow-__INFLOW_VERSION__-windows-x64.msi + InstallerSha256: __INFLOW_MSI_SHA256_X64_UPPER__ + - Architecture: arm64 + InstallerUrl: https://github.com/inflowpayai/inflow-cli/releases/download/v__INFLOW_VERSION__/inflow-__INFLOW_VERSION__-windows-arm64.msi + InstallerSha256: __INFLOW_MSI_SHA256_ARM64_UPPER__ +ManifestType: installer +ManifestVersion: 1.12.0 diff --git a/packaging/windows/winget/locale.en-US.yaml.template b/packaging/windows/winget/locale.en-US.yaml.template new file mode 100644 index 0000000..bf1d5b5 --- /dev/null +++ b/packaging/windows/winget/locale.en-US.yaml.template @@ -0,0 +1,13 @@ +PackageIdentifier: InFlowPayAI.InFlow +PackageVersion: __INFLOW_VERSION__ +PackageLocale: en-US +Publisher: Jarwin, Inc. +PublisherUrl: https://inflowpay.ai +PublisherSupportUrl: https://github.com/inflowpayai/inflow-cli/issues +PackageName: InFlow +PackageUrl: https://github.com/inflowpayai/inflow-cli +License: MIT +LicenseUrl: https://github.com/inflowpayai/inflow-cli/blob/main/LICENSE +ShortDescription: Agent-native and human-accessible payments and credential management. +ManifestType: defaultLocale +ManifestVersion: 1.12.0 diff --git a/packaging/windows/winget/version.yaml.template b/packaging/windows/winget/version.yaml.template new file mode 100644 index 0000000..841ce1b --- /dev/null +++ b/packaging/windows/winget/version.yaml.template @@ -0,0 +1,5 @@ +PackageIdentifier: InFlowPayAI.InFlow +PackageVersion: __INFLOW_VERSION__ +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.12.0 diff --git a/patches/incur.patch b/patches/incur.patch index b7f1e99..d1e43fe 100644 --- a/patches/incur.patch +++ b/patches/incur.patch @@ -1,20 +1,19 @@ -diff --git a/dist/Cli.js b/dist/Cli.js -index 4d3ac1f385f1d00095fffae9d5f0017a1715b976..d72d02bfa53b102253c6d6d62232dbe9cacff88c 100644 --- a/dist/Cli.js +++ b/dist/Cli.js -@@ -1802,12 +1802,15 @@ function extractBuiltinFlags(argv, options = {}) { +@@ -1802,12 +1802,15 @@ format = 'json'; formatExplicit = true; } - else if (token === '--format' && argv[i + 1]) { - if (!validFormats.has(argv[i + 1])) +- throw new ParseError({ +- message: `Invalid format: "${argv[i + 1]}". Expected one of: ${[...validFormats].join(', ')}`, + else if (token === '--format') { + const value = argv[i + 1]; + if (value === undefined) + throw new ParseError({ message: 'Missing value for flag: --format' }); + if (!validFormats.has(value)) - throw new ParseError({ -- message: `Invalid format: "${argv[i + 1]}". Expected one of: ${[...validFormats].join(', ')}`, ++ throw new ParseError({ + message: `Invalid format: "${value}". Expected one of: ${[...validFormats].join(', ')}`, }); - format = argv[i + 1]; @@ -22,11 +21,9 @@ index 4d3ac1f385f1d00095fffae9d5f0017a1715b976..d72d02bfa53b102253c6d6d62232dbe9 formatExplicit = true; i++; } -diff --git a/dist/Formatter.js b/dist/Formatter.js -index 82bc6e25713eb8578dd566c5b7bfcc2b956f6092..2b2ad0e2669ba5f059905eb087deefbdb2e27b33 100644 --- a/dist/Formatter.js +++ b/dist/Formatter.js -@@ -54,15 +54,22 @@ function table(headers, rows) { +@@ -54,15 +54,22 @@ const body = rows.map((r) => `| ${headers.map((_, i) => pad(r[i] ?? '', i)).join(' | ')} |`); return `${headerRow}\n${sep}\n${body.join('\n')}`; } @@ -51,7 +48,6 @@ index 82bc6e25713eb8578dd566c5b7bfcc2b956f6092..2b2ad0e2669ba5f059905eb087deefbd } /** Formats a value as Markdown, recursing into nested objects. */ function formatMarkdown(value, path = []) { -diff --git a/dist/Help.d.ts b/dist/Help.d.ts --- a/dist/Help.d.ts +++ b/dist/Help.d.ts @@ -1,5 +1,10 @@ @@ -65,21 +61,20 @@ diff --git a/dist/Help.d.ts b/dist/Help.d.ts /** Formats help text for a router CLI or command group. */ export declare function formatRoot(name: string, options?: formatRoot.Options): string; export declare namespace formatRoot { -diff --git a/dist/Help.js b/dist/Help.js --- a/dist/Help.js +++ b/dist/Help.js -@@ -3,6 +3,11 @@ +@@ -2,6 +2,11 @@ + import { builtinCommands } from './internal/command.js'; import { toKebab } from './internal/helpers.js'; import { defaultEnvSource } from './Parser.js'; - /** Formats help text for a router CLI or command group. */ +const extraGlobalFlags = []; +/** Registers app-level global flags to include in generated help output. */ +export function registerGlobalFlags(flags) { + extraGlobalFlags.push(...flags); +} + /** Formats help text for a router CLI or command group. */ export function formatRoot(name, options = {}) { const { aliases, configFlag, description, globals, version, commands = [], root = false, } = options; - const lines = []; @@ -290,6 +295,7 @@ } } @@ -88,3 +83,47 @@ diff --git a/dist/Help.js b/dist/Help.js ...(configFlag ? [{ flag: `--${configFlag} `, desc: 'Load JSON option defaults from a file' }] : []), +--- a/dist/Mcp.js ++++ b/dist/Mcp.js +@@ -130,6 +130,7 @@ + const hasInput = Object.keys(mergedShape).length > 0; + server.registerTool(tool.name, { + ...(tool.description ? { description: tool.description } : undefined), ++ ...(tool.title ? { title: tool.title } : undefined), + ...(hasInput ? { inputSchema: z.object(mergedShape) } : undefined), + ...(tool.outputSchema + ? { outputSchema: options.fromJsonSchema(tool.outputSchema) } +@@ -159,6 +160,7 @@ + return toolResult({ + tools: page.map((tool) => ({ + name: tool.name, ++ ...(tool.title ? { title: tool.title } : undefined), + ...(tool.description ? { description: tool.description } : undefined), + ...(tool.annotations ? { annotations: tool.annotations } : undefined), + })), +@@ -177,6 +179,7 @@ + return toolError(`Unknown tool: ${params.name}`); + return toolResult({ + name: tool.name, ++ ...(tool.title ? { title: tool.title } : undefined), + ...(tool.description ? { description: tool.description } : undefined), + inputSchema: tool.inputSchema, + ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : undefined), +@@ -284,7 +287,8 @@ + export function collectTools(commands, prefix, parentMiddlewares = [], filter) { + const tools = filterTools(collectToolEntries(commands, prefix, parentMiddlewares), filter); + assertUniqueToolNames(tools); +- return tools.sort((a, b) => a.name.localeCompare(b.name)); ++ return tools.sort((a, b) => (a.title ?? a.name).localeCompare(b.title ?? b.name) || ++ a.name.localeCompare(b.name)); + } + function collectToolEntries(commands, prefix, parentMiddlewares = []) { + const result = []; +@@ -306,6 +310,7 @@ + const outputSchema = entry.output ? mcpOutputSchema(entry.output) : undefined; + result.push({ + name: mcp?.name ?? path.join('_'), ++ ...(mcp?.annotations?.title ? { title: mcp.annotations.title } : undefined), + description: mcp?.description ?? entry.description, + inputSchema: buildToolSchema(entry.args, entry.options), + ...(outputSchema ? { outputSchema } : undefined), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3020b42..1274790 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false patchedDependencies: - incur: e4b6d21fc6715f01b659381500443dd8a2e94c8d9de5a3606eeb92036946b745 + incur: 811c32d70903670aacd7e84800e2dac041ec445a5b47f9e4c34416077c2c793b importers: @@ -78,8 +78,8 @@ importers: packages/cli: dependencies: '@aep-foundation/agent': - specifier: ^0.2.0 - version: 0.2.0 + specifier: ^0.3.0 + version: 0.3.0 '@inflowpayai/mpp': specifier: ^0.7.0 version: 0.7.1(mppx@0.6.31(typescript@5.9.3)(viem@2.55.2(typescript@5.9.3)(zod@4.4.3))) @@ -92,12 +92,18 @@ importers: '@inflowpayai/x402-buyer': specifier: ^0.7.0 version: 0.7.1(@x402/core@2.19.0) + '@modelcontextprotocol/server': + specifier: 2.0.0-alpha.4 + version: 2.0.0-alpha.4 + '@node-rs/argon2': + specifier: ^2.0.2 + version: 2.0.2 '@x402/core': specifier: ^2.12.0 version: 2.19.0 incur: specifier: ^0.4.5 - version: 0.4.19(patch_hash=e4b6d21fc6715f01b659381500443dd8a2e94c8d9de5a3606eeb92036946b745) + version: 0.4.19(patch_hash=811c32d70903670aacd7e84800e2dac041ec445a5b47f9e4c34416077c2c793b) ink: specifier: ^5.2.1 version: 5.2.1(@types/react@18.3.31)(react@18.3.1) @@ -132,16 +138,16 @@ importers: packages/core: dependencies: - '@napi-rs/keyring': - specifier: 1.3.0 - version: 1.3.0 + '@node-rs/argon2': + specifier: ^2.0.2 + version: 2.0.2 strip-ansi: specifier: ^7.2.0 version: 7.2.0 devDependencies: '@aep-foundation/agent': - specifier: ^0.2.0 - version: 0.2.0 + specifier: ^0.3.0 + version: 0.3.0 '@inflowpayai/mpp': specifier: ^0.7.0 version: 0.7.1(mppx@0.6.31(typescript@5.9.3)(viem@2.55.2(typescript@5.9.3)(zod@4.4.3))) @@ -169,16 +175,16 @@ packages: '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} - '@aep-foundation/agent@0.2.0': - resolution: {integrity: sha512-8howkD9j+qStuEDw/cIr68M5+F3Ybu9pXtcbqGMI2AdGmelYSXxzOqE+K7/FhcDilQvVdvjm+KuHk/kfUvmtUA==} + '@aep-foundation/agent@0.3.0': + resolution: {integrity: sha512-jVq7tzcv0ITy6NgClHHE3Le2uKUrNAkzaFEhpGPwP0yb3kywZI9I4l21ss/n8vHBqHcSv23JH2WJKrWhGhy9Yg==} engines: {node: '>=22.0.0'} - '@aep-foundation/core@0.2.0': - resolution: {integrity: sha512-Zc6Tyzi/A3z2YOPoND89JVD0xZYi4XzxMEcotCmMWD8epsfOYZ3EmgCVtgadfGYcAkxkzE6MlKQN0yQtG+CfHA==} + '@aep-foundation/core@0.3.0': + resolution: {integrity: sha512-QiK/Uhu2VT3lf6D+3+fcBpd4Y10rdBfSPR28jWCsPExh835EkmLZMQn4gBxUwa6F+1MUtX6p523XeugJ3Hn50w==} engines: {node: '>=22.0.0'} - '@aep-foundation/platform@0.2.0': - resolution: {integrity: sha512-XYbz4J6M+/sbdjt53igKHhPZkn3dbn2g5J9Hd3Lo2lrRgv7fQ1LhUaEzTao+XS5i1siNt6/Y8JtXVB3lbzfyFg==} + '@aep-foundation/platform@0.2.1': + resolution: {integrity: sha512-HOZ5NfmCWhAyTFjRCpVoov2evmZoysetYshrPTKbqA9oPV9PbLlRLE82cPG49wTBTLzD/8KrpmufkajehiuQYg==} engines: {node: '>=22.0.0'} '@alcalzone/ansi-tokenize@0.1.3': @@ -277,6 +283,15 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@es-joy/jsdoccomment@0.50.2': resolution: {integrity: sha512-YAdE/IJSpwbOTiaURNCKECdAwqrJuFiZhylmesBcIRawtYKnBR2wxPhoIewMg+Yu+QuYvHfJNReWpoxGBKOChA==} engines: {node: '>=18'} @@ -895,99 +910,112 @@ packages: resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} engines: {node: '>=18'} - '@napi-rs/keyring-darwin-arm64@1.3.0': - resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.1': + resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@node-rs/argon2-android-arm-eabi@2.0.2': + resolution: {integrity: sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@node-rs/argon2-android-arm64@2.0.2': + resolution: {integrity: sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@node-rs/argon2-darwin-arm64@2.0.2': + resolution: {integrity: sha512-3TTNL/7wbcpNju5YcqUrCgXnXUSbD7ogeAKatzBVHsbpjZQbNb1NDxDjqqrWoTt6XL3z9mJUMGwbAk7zQltHtA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@napi-rs/keyring-darwin-x64@1.3.0': - resolution: {integrity: sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==} + '@node-rs/argon2-darwin-x64@2.0.2': + resolution: {integrity: sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@napi-rs/keyring-freebsd-x64@1.3.0': - resolution: {integrity: sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==} + '@node-rs/argon2-freebsd-x64@2.0.2': + resolution: {integrity: sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg==} engines: {node: '>= 10'} cpu: [x64] os: [freebsd] - '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': - resolution: {integrity: sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==} + '@node-rs/argon2-linux-arm-gnueabihf@2.0.2': + resolution: {integrity: sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@napi-rs/keyring-linux-arm64-gnu@1.3.0': - resolution: {integrity: sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==} + '@node-rs/argon2-linux-arm64-gnu@2.0.2': + resolution: {integrity: sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@napi-rs/keyring-linux-arm64-musl@1.3.0': - resolution: {integrity: sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==} + '@node-rs/argon2-linux-arm64-musl@2.0.2': + resolution: {integrity: sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': - resolution: {integrity: sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==} - engines: {node: '>= 10'} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@napi-rs/keyring-linux-x64-gnu@1.3.0': - resolution: {integrity: sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==} + '@node-rs/argon2-linux-x64-gnu@2.0.2': + resolution: {integrity: sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@napi-rs/keyring-linux-x64-musl@1.3.0': - resolution: {integrity: sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==} + '@node-rs/argon2-linux-x64-musl@2.0.2': + resolution: {integrity: sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@napi-rs/keyring-win32-arm64-msvc@1.3.0': - resolution: {integrity: sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==} + '@node-rs/argon2-wasm32-wasi@2.0.2': + resolution: {integrity: sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@node-rs/argon2-win32-arm64-msvc@2.0.2': + resolution: {integrity: sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@napi-rs/keyring-win32-ia32-msvc@1.3.0': - resolution: {integrity: sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==} + '@node-rs/argon2-win32-ia32-msvc@2.0.2': + resolution: {integrity: sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@napi-rs/keyring-win32-x64-msvc@1.3.0': - resolution: {integrity: sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==} + '@node-rs/argon2-win32-x64-msvc@2.0.2': + resolution: {integrity: sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@napi-rs/keyring@1.3.0': - resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==} + '@node-rs/argon2@2.0.2': + resolution: {integrity: sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==} engines: {node: '>= 10'} - '@noble/ciphers@1.3.0': - resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} - engines: {node: ^14.21.3 || >=16} - - '@noble/curves@1.9.1': - resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} - engines: {node: ^14.21.3 || >=16} - - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} - '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1215,6 +1243,9 @@ packages: cpu: [arm64] os: [win32] + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -2723,6 +2754,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.5.1: resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} engines: {node: '>=18'} @@ -2967,18 +3001,18 @@ snapshots: '@adraffy/ens-normalize@1.11.1': {} - '@aep-foundation/agent@0.2.0': + '@aep-foundation/agent@0.3.0': dependencies: - '@aep-foundation/core': 0.2.0 - '@aep-foundation/platform': 0.2.0 + '@aep-foundation/core': 0.3.0 + '@aep-foundation/platform': 0.2.1 - '@aep-foundation/core@0.2.0': + '@aep-foundation/core@0.3.0': dependencies: jose: 6.2.3 - '@aep-foundation/platform@0.2.0': + '@aep-foundation/platform@0.2.1': dependencies: - '@aep-foundation/core': 0.2.0 + '@aep-foundation/core': 0.3.0 '@alcalzone/ansi-tokenize@0.1.3': dependencies: @@ -3167,6 +3201,22 @@ snapshots: human-id: 4.2.0 prettier: 2.8.8 + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@es-joy/jsdoccomment@0.50.2': dependencies: '@types/estree': 1.0.9 @@ -3577,64 +3627,81 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 - '@napi-rs/keyring-darwin-arm64@1.3.0': + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/keyring-darwin-x64@1.3.0': + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@node-rs/argon2-android-arm-eabi@2.0.2': optional: true - '@napi-rs/keyring-freebsd-x64@1.3.0': + '@node-rs/argon2-android-arm64@2.0.2': optional: true - '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + '@node-rs/argon2-darwin-arm64@2.0.2': optional: true - '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + '@node-rs/argon2-darwin-x64@2.0.2': optional: true - '@napi-rs/keyring-linux-arm64-musl@1.3.0': + '@node-rs/argon2-freebsd-x64@2.0.2': optional: true - '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + '@node-rs/argon2-linux-arm-gnueabihf@2.0.2': optional: true - '@napi-rs/keyring-linux-x64-gnu@1.3.0': + '@node-rs/argon2-linux-arm64-gnu@2.0.2': optional: true - '@napi-rs/keyring-linux-x64-musl@1.3.0': + '@node-rs/argon2-linux-arm64-musl@2.0.2': optional: true - '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + '@node-rs/argon2-linux-x64-gnu@2.0.2': optional: true - '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + '@node-rs/argon2-linux-x64-musl@2.0.2': optional: true - '@napi-rs/keyring-win32-x64-msvc@1.3.0': + '@node-rs/argon2-wasm32-wasi@2.0.2': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 optional: true - '@napi-rs/keyring@1.3.0': - optionalDependencies: - '@napi-rs/keyring-darwin-arm64': 1.3.0 - '@napi-rs/keyring-darwin-x64': 1.3.0 - '@napi-rs/keyring-freebsd-x64': 1.3.0 - '@napi-rs/keyring-linux-arm-gnueabihf': 1.3.0 - '@napi-rs/keyring-linux-arm64-gnu': 1.3.0 - '@napi-rs/keyring-linux-arm64-musl': 1.3.0 - '@napi-rs/keyring-linux-riscv64-gnu': 1.3.0 - '@napi-rs/keyring-linux-x64-gnu': 1.3.0 - '@napi-rs/keyring-linux-x64-musl': 1.3.0 - '@napi-rs/keyring-win32-arm64-msvc': 1.3.0 - '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 - '@napi-rs/keyring-win32-x64-msvc': 1.3.0 + '@node-rs/argon2-win32-arm64-msvc@2.0.2': + optional: true - '@noble/ciphers@1.3.0': {} + '@node-rs/argon2-win32-ia32-msvc@2.0.2': + optional: true - '@noble/curves@1.9.1': - dependencies: - '@noble/hashes': 1.8.0 + '@node-rs/argon2-win32-x64-msvc@2.0.2': + optional: true - '@noble/hashes@1.8.0': {} + '@node-rs/argon2@2.0.2': + optionalDependencies: + '@node-rs/argon2-android-arm-eabi': 2.0.2 + '@node-rs/argon2-android-arm64': 2.0.2 + '@node-rs/argon2-darwin-arm64': 2.0.2 + '@node-rs/argon2-darwin-x64': 2.0.2 + '@node-rs/argon2-freebsd-x64': 2.0.2 + '@node-rs/argon2-linux-arm-gnueabihf': 2.0.2 + '@node-rs/argon2-linux-arm64-gnu': 2.0.2 + '@node-rs/argon2-linux-arm64-musl': 2.0.2 + '@node-rs/argon2-linux-x64-gnu': 2.0.2 + '@node-rs/argon2-linux-x64-musl': 2.0.2 + '@node-rs/argon2-wasm32-wasi': 2.0.2 + '@node-rs/argon2-win32-arm64-msvc': 2.0.2 + '@node-rs/argon2-win32-ia32-msvc': 2.0.2 + '@node-rs/argon2-win32-x64-msvc': 2.0.2 '@nodelib/fs.scandir@2.1.5': dependencies: @@ -3792,6 +3859,11 @@ snapshots: '@turbo/windows-arm64@2.10.5': optional: true + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -4554,7 +4626,7 @@ snapshots: imurmurhash@0.1.4: {} - incur@0.4.19(patch_hash=e4b6d21fc6715f01b659381500443dd8a2e94c8d9de5a3606eeb92036946b745): + incur@0.4.19(patch_hash=811c32d70903670aacd7e84800e2dac041ec445a5b47f9e4c34416077c2c793b): dependencies: '@cfworker/json-schema': 4.1.1 '@modelcontextprotocol/server': 2.0.0-alpha.4 @@ -4947,7 +5019,7 @@ snapshots: mppx@0.6.31(typescript@5.9.3)(viem@2.55.2(typescript@5.9.3)(zod@4.4.3)): dependencies: - incur: 0.4.19(patch_hash=e4b6d21fc6715f01b659381500443dd8a2e94c8d9de5a3606eeb92036946b745) + incur: 0.4.19(patch_hash=811c32d70903670aacd7e84800e2dac041ec445a5b47f9e4c34416077c2c793b) ox: 0.14.27(typescript@5.9.3)(zod@4.4.3) viem: 2.55.2(typescript@5.9.3)(zod@4.4.3) zod: 4.4.3 @@ -5041,7 +5113,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3)(zod@4.4.3) + abitype: 1.3.0(typescript@5.9.3)(zod@4.4.3) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.9.3 @@ -5406,6 +5478,9 @@ snapshots: ts-interface-checker@0.1.13: {} + tslib@2.8.1: + optional: true + tsup@8.5.1(postcss@8.5.20)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 14fe92e..d55d634 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,6 +17,9 @@ minimumReleaseAgeExclude: - '@inflowpayai/mpp-buyer' - '@inflowpayai/x402' - '@inflowpayai/x402-buyer' + - '@aep-foundation/agent@0.3.0' + - '@aep-foundation/core@0.3.0' + - '@aep-foundation/platform@0.2.1' auto-install-peers: true diff --git a/scripts/build-linux-apt-repository.mjs b/scripts/build-linux-apt-repository.mjs new file mode 100644 index 0000000..6d4a885 --- /dev/null +++ b/scripts/build-linux-apt-repository.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import { copyFileSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { gzipSync } from 'node:zlib'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageRoot = resolve(process.argv[2] ?? join(repoRoot, 'dist/linux/packages')); +const outputRoot = resolve(process.argv[3] ?? join(repoRoot, 'dist/linux/apt')); +const signingKey = requiredEnvironment('INFLOW_LINUX_SIGNING_KEY_ID'); +const architectures = ['amd64', 'arm64']; +const distributionRoot = join(outputRoot, 'dists/stable'); +const poolRoot = join(outputRoot, 'pool/main/i/inflow'); + +rmSync(outputRoot, { force: true, recursive: true }); +mkdirSync(poolRoot, { recursive: true }); + +const debianPackages = readdirSync(packageRoot) + .filter((name) => name.endsWith('.deb')) + .sort(); +if (debianPackages.length !== architectures.length) { + throw new Error( + `Expected ${architectures.length.toString()} Debian packages, found ${debianPackages.length.toString()}.`, + ); +} +for (const name of debianPackages) copyFileSync(join(packageRoot, name), join(poolRoot, name)); + +for (const architecture of architectures) { + const binaryRoot = join(distributionRoot, 'main', `binary-${architecture}`); + mkdirSync(binaryRoot, { recursive: true }); + const packages = run('dpkg-scanpackages', ['--arch', architecture, 'pool/main/i/inflow', '/dev/null'], outputRoot); + if (packages.length === 0) throw new Error(`APT metadata is empty for ${architecture}.`); + writeFileSync(join(binaryRoot, 'Packages'), packages); + writeFileSync(join(binaryRoot, 'Packages.gz'), gzipSync(packages, { level: 9 })); +} + +const release = run('apt-ftparchive', ['release', 'dists/stable'], outputRoot); +writeFileSync( + join(distributionRoot, 'Release'), + [ + 'Origin: InFlow', + 'Label: InFlow', + 'Suite: stable', + 'Codename: stable', + `Architectures: ${architectures.join(' ')}`, + 'Components: main', + 'Description: InFlow signed package repository', + release.toString().trimEnd(), + '', + ].join('\n'), +); + +run('gpg', [ + '--batch', + '--yes', + '--digest-algo', + 'SHA256', + '--local-user', + signingKey, + '--clearsign', + '--output', + join(distributionRoot, 'InRelease'), + join(distributionRoot, 'Release'), +]); +run('gpg', [ + '--batch', + '--yes', + '--digest-algo', + 'SHA256', + '--local-user', + signingKey, + '--detach-sign', + '--output', + join(distributionRoot, 'Release.gpg'), + join(distributionRoot, 'Release'), +]); +writeFileSync(join(outputRoot, 'inflow-archive-keyring.gpg'), run('gpg', ['--batch', '--export', signingKey])); + +process.stdout.write(`${outputRoot}\n`); + +function requiredEnvironment(name) { + const value = process.env[name]; + if (value === undefined || value.length === 0) throw new Error(`${name} is required.`); + return value; +} + +function run(command, args, cwd = repoRoot) { + return execFileSync(command, args, { + cwd, + encoding: command === 'gpg' && args.includes('--export') ? 'buffer' : null, + }); +} diff --git a/scripts/build-linux-package.mjs b/scripts/build-linux-package.mjs new file mode 100644 index 0000000..b2806e1 --- /dev/null +++ b/scripts/build-linux-package.mjs @@ -0,0 +1,525 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { + copyFileSync, + cpSync, + existsSync, + chmodSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const requireFromCli = createRequire(join(repoRoot, 'packages/cli/package.json')); +const requireFromCore = createRequire(join(repoRoot, 'packages/core/package.json')); +const version = packageVersion(); +const architecture = linuxArchitecture(); +const artifactRoot = resolve(process.env.INFLOW_LINUX_ARTIFACT_DIR ?? join(repoRoot, 'dist/linux')); +const packageName = `inflow-${version}-linux-${architecture}`; +const packageRoot = join(artifactRoot, packageName); +const executablePath = join(packageRoot, 'bin/inflow'); +const runtimePath = join(packageRoot, 'lib/inflow'); +const runtimeNodeModules = join(runtimePath, 'node_modules'); +const buildRoot = join(artifactRoot, 'build'); +const seaConfigPath = join(buildRoot, 'sea-config.json'); +const seaBlobPath = join(buildRoot, 'inflow.sea.blob'); +const seaMainPath = join(buildRoot, 'sea-main.cjs'); +const archivePath = join(artifactRoot, `${packageName}.tar.gz`); + +if (process.platform !== 'linux') { + throw new Error('Linux packaging must run on Linux.'); +} + +if (!isAtLeastNodeVersion(process.versions.node, 24, 15, 0)) { + throw new Error(`Linux packaging requires Node 24.15.0 or newer; current Node is ${process.versions.node}.`); +} +if (typeof process.getuid !== 'function' || process.getuid() !== 0) { + throw new Error('Linux packaging requires root so release ownership can be normalized.'); +} + +rmSync(artifactRoot, { force: true, recursive: true }); +mkdirSync(buildRoot, { recursive: true }); +mkdirSync(dirname(executablePath), { recursive: true }); +mkdirSync(runtimePath, { recursive: true }); + +run('pnpm', ['--filter', '@inflowpayai/inflow-core', 'build']); +run(process.execPath, ['scripts/build-vault-peer-native.mjs']); +run('pnpm', ['--filter', '@inflowpayai/inflow', 'build:standalone']); + +copyArgon2Runtime(); +copyMcpRuntime(); +copyVaultPeerRuntime(); +writeSeaMain(); +writeFileSync( + seaConfigPath, + `${JSON.stringify( + { + disableExperimentalSEAWarning: true, + main: seaMainPath, + output: seaBlobPath, + }, + null, + 2, + )}\n`, +); +run(process.execPath, ['--experimental-sea-config', seaConfigPath]); +copyFileSync(process.execPath, executablePath); +run(resolvePostject(), [ + executablePath, + 'NODE_SEA_BLOB', + seaBlobPath, + '--sentinel-fuse', + 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2', +]); +linkExecutable(); +normalizeRuntimePermissions(packageRoot); +chmodSync(executablePath, 0o755); +run('chown', ['-R', '0:0', packageRoot]); +archive(); +const nativePackages = buildNativePackages(); + +const checksum = sha256(archivePath); +writeFileSync(`${archivePath}.sha256`, `${checksum} ${packageName}.tar.gz\n`); +writeFileSync( + join(artifactRoot, 'manifest.json'), + `${JSON.stringify( + { + arch: architecture, + archive: archivePath, + executable: executablePath, + node: process.versions.node, + packages: nativePackages, + platform: process.platform, + sha256: checksum, + }, + null, + 2, + )}\n`, +); + +console.log(`Built ${packageRoot}`); +console.log(`Packaged ${archivePath}`); + +function copyArgon2Runtime() { + const argon2Root = dirname(requireFromCore.resolve('@node-rs/argon2/package.json')); + const requireFromArgon2 = createRequire(join(argon2Root, 'index.js')); + const nativePackageName = `@node-rs/argon2-linux-${architecture}-gnu`; + const nativeRoot = dirname(requireFromArgon2.resolve(`${nativePackageName}/package.json`)); + copyPackage(argon2Root, join(runtimeNodeModules, '@node-rs/argon2')); + copyPackage(nativeRoot, join(runtimeNodeModules, nativePackageName)); + copyFileSync( + join(nativeRoot, `argon2.linux-${architecture}-gnu.node`), + join(runtimeNodeModules, '@node-rs/argon2', `argon2.linux-${architecture}-gnu.node`), + ); +} + +function copyMcpRuntime() { + const serverRoot = packageDirectoryRoot('@modelcontextprotocol/server', [ + () => nestedPackageRoot('incur', '@modelcontextprotocol/server'), + ]); + const zodRoot = dependencyPackageRoot('zod', requireFromCli); + copyPackage(serverRoot, join(runtimeNodeModules, '@modelcontextprotocol/server')); + copyPackage(zodRoot, join(runtimeNodeModules, 'zod')); +} + +function copyVaultPeerRuntime() { + const nativeRuntimePath = join(runtimePath, 'native'); + mkdirSync(nativeRuntimePath, { recursive: true }); + copyFileSync( + join(repoRoot, 'packages/core/native/build/vault_peer_linux.node'), + join(nativeRuntimePath, 'vault_peer_linux.node'), + ); +} + +function linkExecutable() { + const linkedPath = join(artifactRoot, 'bin/inflow'); + mkdirSync(dirname(linkedPath), { recursive: true }); + symlinkSync(`../${packageName}/bin/inflow`, linkedPath); +} + +function archive() { + run('tar', ['-C', artifactRoot, '-czf', archivePath, packageName]); +} + +function buildNativePackages() { + const packages = {}; + if (commandExists('dpkg-deb')) packages.deb = packageMetadata(buildDebianPackage()); + if (commandExists('rpmbuild')) packages.rpm = packageMetadata(buildRpmPackage()); + return packages; +} + +function packageMetadata(path) { + const checksum = sha256(path); + writeFileSync(`${path}.sha256`, `${checksum} ${path.split('/').at(-1)}\n`); + return { path, sha256: checksum }; +} + +function buildDebianPackage() { + const debianRoot = join(buildRoot, 'debian'); + installLinuxTree(debianRoot); + const controlDirectory = join(debianRoot, 'DEBIAN'); + mkdirSync(controlDirectory, { recursive: true }); + writeFileSync( + join(controlDirectory, 'control'), + [ + 'Package: inflow', + `Version: ${version}`, + `Architecture: ${debianArchitecture()}`, + 'Maintainer: InFlow ', + 'Depends: systemd', + 'Section: utils', + 'Priority: optional', + 'Description: InFlow agent-native payment command line', + '', + ].join('\n'), + ); + writeMaintainerScript( + join(controlDirectory, 'postinst'), + `#!/bin/sh +set -e +if command -v systemd-sysusers >/dev/null 2>&1; then + systemd-sysusers /usr/lib/sysusers.d/inflow.conf +elif ! getent passwd inflow >/dev/null; then + useradd --system --user-group --home-dir /var/lib/inflow --shell /usr/sbin/nologin inflow +fi +inflow_uid=$(id -u inflow) +inflow_home=$(getent passwd inflow | cut -d: -f6) +inflow_shell=$(getent passwd inflow | cut -d: -f7) +if [ "$inflow_uid" -eq 0 ] || [ "$inflow_uid" -ge 1000 ] || + [ "$inflow_home" != /var/lib/inflow ] || + { [ "$inflow_shell" != /usr/sbin/nologin ] && [ "$inflow_shell" != /sbin/nologin ]; }; then + echo "The existing inflow account is not a compatible locked system identity." >&2 + exit 1 +fi +if command -v systemd-tmpfiles >/dev/null 2>&1; then + systemd-tmpfiles --create /usr/lib/tmpfiles.d/inflow.conf +fi +if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true + systemctl preset inflow-vault.socket || systemctl enable inflow-vault.socket || true + systemctl stop inflow-vault.service inflow-vault.socket || true + systemctl start inflow-vault.socket || true +fi +`, + ); + writeMaintainerScript( + join(controlDirectory, 'prerm'), + `#!/bin/sh +set -e +if [ "$1" = remove ] && command -v systemctl >/dev/null 2>&1; then + systemctl stop inflow-vault.service inflow-vault.socket || true + systemctl disable inflow-vault.socket || true +fi +`, + ); + writeMaintainerScript( + join(controlDirectory, 'postrm'), + `#!/bin/sh +set -e +if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true +fi +if [ "$1" = purge ]; then + rm -rf /var/lib/inflow /run/inflow +fi +`, + ); + const output = join(artifactRoot, `inflow_${version}_${debianArchitecture()}.deb`); + run('dpkg-deb', ['--root-owner-group', '--build', debianRoot, output]); + return output; +} + +function buildRpmPackage() { + const rpmRoot = join(buildRoot, 'rpm'); + const sourceRoot = join(rpmRoot, 'SOURCES', `inflow-${version}`); + const specsRoot = join(rpmRoot, 'SPECS'); + installLinuxTree(sourceRoot); + mkdirSync(specsRoot, { recursive: true }); + const sourceArchive = join(rpmRoot, 'SOURCES', `inflow-${version}.tar.gz`); + run('tar', ['-C', join(rpmRoot, 'SOURCES'), '-czf', sourceArchive, `inflow-${version}`]); + const specPath = join(specsRoot, 'inflow.spec'); + writeFileSync( + specPath, + `%global __strip /bin/true +Name: inflow +Version: ${version} +Release: 1%{?dist} +Summary: InFlow agent-native payment command line +License: Proprietary +Source0: %{name}-%{version}.tar.gz +BuildArch: ${rpmArchitecture()} +Requires: systemd + +%description +InFlow agent-native and human-accessible payment command line. + +%prep +%setup -q + +%build + +%install +mkdir -p %{buildroot} +cp -a . %{buildroot}/ + +%post +if command -v systemd-sysusers >/dev/null 2>&1; then + systemd-sysusers /usr/lib/sysusers.d/inflow.conf || : +fi +inflow_uid=$(id -u inflow) +inflow_home=$(getent passwd inflow | cut -d: -f6) +inflow_shell=$(getent passwd inflow | cut -d: -f7) +if [ "$inflow_uid" -eq 0 ] || [ "$inflow_uid" -ge 1000 ] || + [ "$inflow_home" != /var/lib/inflow ] || + { [ "$inflow_shell" != /usr/sbin/nologin ] && [ "$inflow_shell" != /sbin/nologin ]; }; then + echo "The existing inflow account is not a compatible locked system identity." >&2 + exit 1 +fi +if command -v systemd-tmpfiles >/dev/null 2>&1; then + systemd-tmpfiles --create /usr/lib/tmpfiles.d/inflow.conf || : +fi +if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || : + systemctl preset inflow-vault.socket || systemctl enable inflow-vault.socket || : +fi + +%posttrans +systemctl stop inflow-vault.service inflow-vault.socket >/dev/null 2>&1 || : +systemctl start inflow-vault.socket >/dev/null 2>&1 || : + +%preun +if [ "$1" -eq 0 ] && command -v systemctl >/dev/null 2>&1; then + systemctl stop inflow-vault.service inflow-vault.socket || : + systemctl disable inflow-vault.socket || : +fi + +%postun +if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || : +fi + +%files +/opt/inflow +/usr/bin/inflow +/usr/lib/systemd/system/inflow-vault.service +/usr/lib/systemd/system/inflow-vault.socket +/usr/lib/sysusers.d/inflow.conf +/usr/lib/tmpfiles.d/inflow.conf +`, + ); + run('rpmbuild', ['--define', `_topdir ${rpmRoot}`, '-bb', specPath]); + const rpmDirectory = join(rpmRoot, 'RPMS', rpmArchitecture()); + const rpmName = readdirSync(rpmDirectory).find((name) => name.endsWith('.rpm')); + if (rpmName === undefined) throw new Error('RPM output is missing.'); + const built = join(rpmDirectory, rpmName); + const output = join(artifactRoot, rpmName); + copyFileSync(built, output); + return output; +} + +function installLinuxTree(root) { + cpSync(join(packageRoot, 'bin'), join(root, 'opt/inflow/bin'), { recursive: true }); + cpSync(join(packageRoot, 'lib'), join(root, 'opt/inflow/lib'), { recursive: true }); + normalizeRuntimePermissions(join(root, 'opt/inflow')); + chmodSync(join(root, 'opt/inflow/bin/inflow'), 0o755); + mkdirSync(join(root, 'usr/bin'), { recursive: true }); + symlinkSync('/opt/inflow/bin/inflow', join(root, 'usr/bin/inflow')); + mkdirSync(join(root, 'usr/lib/systemd/system'), { recursive: true }); + copyFileSync( + join(repoRoot, 'packaging/linux/inflow-vault.service'), + join(root, 'usr/lib/systemd/system/inflow-vault.service'), + ); + chmodSync(join(root, 'usr/lib/systemd/system/inflow-vault.service'), 0o644); + copyFileSync( + join(repoRoot, 'packaging/linux/inflow-vault.socket'), + join(root, 'usr/lib/systemd/system/inflow-vault.socket'), + ); + chmodSync(join(root, 'usr/lib/systemd/system/inflow-vault.socket'), 0o644); + mkdirSync(join(root, 'usr/lib/sysusers.d'), { recursive: true }); + copyFileSync(join(repoRoot, 'packaging/linux/inflow.conf'), join(root, 'usr/lib/sysusers.d/inflow.conf')); + chmodSync(join(root, 'usr/lib/sysusers.d/inflow.conf'), 0o644); + mkdirSync(join(root, 'usr/lib/tmpfiles.d'), { recursive: true }); + copyFileSync(join(repoRoot, 'packaging/linux/inflow-tmpfiles.conf'), join(root, 'usr/lib/tmpfiles.d/inflow.conf')); + chmodSync(join(root, 'usr/lib/tmpfiles.d/inflow.conf'), 0o644); +} + +function normalizeRuntimePermissions(root) { + chmodSync(root, 0o755); + for (const entry of readdirSync(root, { withFileTypes: true })) { + const entryPath = join(root, entry.name); + if (entry.isDirectory()) { + normalizeRuntimePermissions(entryPath); + } else if (entry.isFile()) { + chmodSync(entryPath, 0o644); + } + } +} + +function writeMaintainerScript(path, content) { + writeFileSync(path, content); + chmodSync(path, 0o755); +} + +function commandExists(command) { + try { + execFileSync('/bin/sh', ['-c', `command -v ${command}`], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function debianArchitecture() { + return architecture === 'x64' ? 'amd64' : 'arm64'; +} + +function rpmArchitecture() { + return architecture === 'x64' ? 'x86_64' : 'aarch64'; +} + +function writeSeaMain() { + const cliSource = readFileSync(join(repoRoot, 'packages/cli/dist/cli.standalone.mjs')).toString('base64'); + const runtimeManifest = runtimeFiles().map((path) => [relative(runtimePath, path), sha256(path)]); + writeFileSync( + seaMainPath, + `const { createHash } = require('node:crypto'); +const { lstatSync, readdirSync, readFileSync, realpathSync } = require('node:fs'); +const { dirname, join, relative, resolve } = require('node:path'); + +const executablePath = realpathSync(process.execPath); +const runtimeRoot = resolve(dirname(executablePath), '../lib/inflow'); +const manifest = new Map(${JSON.stringify(runtimeManifest)}); + +function files(root) { + verifyOwnedPath(root, 'directory'); + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const path = join(root, entry.name); + if (entry.isDirectory()) return files(path); + return entry.isFile() ? [path] : []; + }); +} + +function verifyOwnedPath(path, type) { + const stat = lstatSync(path); + if (stat.uid !== 0 || (stat.mode & 0o022) !== 0 || (type === 'file' ? !stat.isFile() : !stat.isDirectory())) { + throw new Error('unsafe runtime ownership'); + } + if (realpathSync(path) !== path) throw new Error('unsafe runtime path'); +} + +try { + verifyOwnedPath(executablePath, 'file'); + verifyOwnedPath(runtimeRoot, 'directory'); + const actualFiles = files(runtimeRoot).map((path) => relative(runtimeRoot, path)).sort(); + const expectedFiles = [...manifest.keys()].sort(); + if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) throw new Error('runtime file set mismatch'); + for (const [name, expectedHash] of manifest) { + const path = join(runtimeRoot, name); + verifyOwnedPath(path, 'file'); + const actualHash = createHash('sha256').update(readFileSync(path)).digest('hex'); + if (actualHash !== expectedHash) throw new Error('runtime digest mismatch'); + } +} catch (cause) { + const reason = cause instanceof Error ? cause.message : 'unknown integrity failure'; + process.stderr.write(\`InFlow runtime integrity verification failed: \${reason}.\\n\`); + process.exit(1); +} + +const cli = ${JSON.stringify(`data:text/javascript;base64,${cliSource}`)}; +import(cli).catch((cause) => { + const message = cause instanceof Error ? cause.stack ?? cause.message : String(cause); + process.stderr.write(\`\${message}\\n\`); + process.exit(1); +}); +`, + ); +} + +function runtimeFiles(root = runtimePath) { + return readdirSync(root, { withFileTypes: true }) + .flatMap((entry) => { + const path = join(root, entry.name); + return entry.isDirectory() ? runtimeFiles(path) : entry.isFile() ? [path] : []; + }) + .sort(); +} + +function copyPackage(source, destination) { + mkdirSync(dirname(destination), { recursive: true }); + cpSync(source, destination, { + dereference: true, + filter: (sourcePath) => !sourcePath.includes(`${source}/node_modules/`), + recursive: true, + }); +} + +function packageDirectoryRoot(packageName, fallbacks = []) { + const candidate = join(repoRoot, 'packages/cli/node_modules', packageName); + if (existsSync(join(candidate, 'package.json'))) return candidate; + for (const fallback of fallbacks) { + try { + return fallback(); + } catch { + continue; + } + } + throw new Error(`Could not find package root for ${packageName}.`); +} + +function dependencyPackageRoot(packageName, resolver) { + return findPackageRoot(dirname(resolver.resolve(packageName))); +} + +function nestedPackageRoot(parentPackageName, packageName) { + const parentRoot = findPackageRoot(dirname(requireFromCli.resolve(parentPackageName))); + return findPackageRoot(join(parentRoot, 'node_modules', packageName)); +} + +function findPackageRoot(startPath) { + let current = startPath; + while (current !== dirname(current)) { + if (existsSync(join(current, 'package.json'))) return current; + current = dirname(current); + } + throw new Error(`Could not find package root from ${startPath}.`); +} + +function resolvePostject() { + const bin = join(repoRoot, 'node_modules/.bin/postject'); + if (!existsSync(bin)) throw new Error('postject is not installed. Run pnpm install.'); + return bin; +} + +function packageVersion() { + const manifest = JSON.parse(readFileSync(join(repoRoot, 'packages/cli/package.json'), 'utf8')); + if (typeof manifest.version !== 'string') throw new Error('CLI package version is missing.'); + return manifest.version; +} + +function linuxArchitecture() { + if (process.arch === 'x64' || process.arch === 'arm64') return process.arch; + throw new Error(`Unsupported Linux architecture: ${process.arch}.`); +} + +function sha256(path) { + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} + +function isAtLeastNodeVersion(value, major, minor, patch) { + const [actualMajor = 0, actualMinor = 0, actualPatch = 0] = value.split('.').map(Number); + if (actualMajor !== major) return actualMajor > major; + if (actualMinor !== minor) return actualMinor > minor; + return actualPatch >= patch; +} + +function run(command, args) { + execFileSync(command, args, { cwd: repoRoot, stdio: 'inherit' }); +} diff --git a/scripts/build-linux-rpm-repository.mjs b/scripts/build-linux-rpm-repository.mjs new file mode 100644 index 0000000..fb47b33 --- /dev/null +++ b/scripts/build-linux-rpm-repository.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import { copyFileSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageRoot = resolve(process.argv[2] ?? join(repoRoot, 'dist/linux/packages')); +const outputRoot = resolve(process.argv[3] ?? join(repoRoot, 'dist/linux/rpm')); +const signingKey = requiredEnvironment('INFLOW_LINUX_SIGNING_KEY_ID'); +const gpgHome = requiredEnvironment('GNUPGHOME'); +const packagesRoot = join(outputRoot, 'packages'); + +rmSync(outputRoot, { force: true, recursive: true }); +mkdirSync(packagesRoot, { recursive: true }); + +const rpmPackages = readdirSync(packageRoot) + .filter((name) => name.endsWith('.rpm')) + .sort(); +if (rpmPackages.length !== 2) { + throw new Error(`Expected 2 RPM packages, found ${rpmPackages.length.toString()}.`); +} + +for (const name of rpmPackages) { + const destination = join(packagesRoot, name); + copyFileSync(join(packageRoot, name), destination); + run('rpmsign', ['--addsign', '--define', `_gpg_name ${signingKey}`, '--define', `_gpg_path ${gpgHome}`, destination]); +} + +run('createrepo_c', ['--checksum', 'sha256', outputRoot]); +const metadata = join(outputRoot, 'repodata/repomd.xml'); +run('gpg', [ + '--armor', + '--batch', + '--yes', + '--digest-algo', + 'SHA256', + '--local-user', + signingKey, + '--detach-sign', + '--output', + `${metadata}.asc`, + metadata, +]); +writeFileSync(join(outputRoot, 'inflow-signing-key.gpg'), run('gpg', ['--batch', '--export', signingKey])); +writeFileSync(join(outputRoot, 'inflow-signing-key.asc'), run('gpg', ['--armor', '--batch', '--export', signingKey])); + +process.stdout.write(`${outputRoot}\n`); + +function requiredEnvironment(name) { + const value = process.env[name]; + if (value === undefined || value.length === 0) throw new Error(`${name} is required.`); + return value; +} + +function run(command, args) { + return execFileSync(command, args, { cwd: repoRoot }); +} diff --git a/scripts/build-macos-app.mjs b/scripts/build-macos-app.mjs index f96da1f..834180a 100644 --- a/scripts/build-macos-app.mjs +++ b/scripts/build-macos-app.mjs @@ -17,11 +17,14 @@ import { createRequire } from 'node:module'; import { execFileSync } from 'node:child_process'; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const requireFromCli = createRequire(join(repoRoot, 'packages/cli/package.json')); const requireFromCore = createRequire(join(repoRoot, 'packages/core/package.json')); const args = new Set(process.argv.slice(2)); const release = args.has('--release'); +const signedDebug = args.has('--signed-debug'); const skipNotarize = args.has('--skip-notarize') || process.env.INFLOW_SKIP_NOTARIZATION === '1'; -const identity = process.env.INFLOW_CODESIGN_IDENTITY ?? '-'; +const identity = resolveCodesignIdentity({ release, signedDebug }); +const timestampMode = process.env.INFLOW_CODESIGN_TIMESTAMP ?? 'default'; const notaryProfile = process.env.INFLOW_NOTARY_PROFILE ?? 'inflow-notary'; const packageName = 'InFlow'; const version = packageVersion(); @@ -38,6 +41,7 @@ const seaConfigPath = join(buildRoot, 'sea-config.json'); const seaBlobPath = join(buildRoot, 'inflow.sea.blob'); const seaMainPath = join(buildRoot, 'sea-main.cjs'); const standaloneBundlePath = join(resourcesPath, 'app/cli.standalone.mjs'); +const nativeRuntimePath = join(resourcesPath, 'app/native'); const zipPath = join(artifactRoot, `inflow-${version}-${process.platform}-${process.arch}.zip`); const nodeVersion = process.versions.node; @@ -49,20 +53,22 @@ if (release && !isAtLeastNodeVersion(nodeVersion, 24, 15, 0)) { throw new Error(`release packaging requires Node 24.15.0 or newer; current Node is ${nodeVersion}.`); } -if (release && identity === '-') { - throw new Error('release packaging requires INFLOW_CODESIGN_IDENTITY to name a Developer ID Application identity.'); -} - if (release && skipNotarize) { throw new Error('release packaging requires notarization.'); } +if (release && timestampMode === 'none') { + throw new Error('release packaging requires codesign timestamping.'); +} + rmSync(artifactRoot, { force: true, recursive: true }); mkdirSync(buildRoot, { recursive: true }); mkdirSync(join(appPath, 'Contents/MacOS'), { recursive: true }); mkdirSync(resourcesPath, { recursive: true }); run('pnpm', ['--filter', '@inflowpayai/inflow-core', 'build']); +run(process.execPath, ['scripts/build-vault-peer-native.mjs']); +signVaultPeerBuild(); run('pnpm', ['--filter', '@inflowpayai/inflow', 'build:standalone']); mkdirSync(dirname(standaloneBundlePath), { recursive: true }); @@ -96,17 +102,20 @@ run(resolvePostject(), [ writeInfoPlist(); writeEntitlements(); -copyKeyringRuntime(); +copyArgon2Runtime(); +copyMcpRuntime(); +copyVaultPeerRuntime(); +run('xattr', ['-cr', appPath]); signNestedCode(); signExecutable(); -verifySignature(); +verifySignature({ assessGatekeeper: false, gatekeeperOptional: true }); linkExecutable(); zipArtifact(); if (!skipNotarize) { notarize(); staple(); - verifySignature(); + verifySignature({ assessGatekeeper: true, gatekeeperOptional: false }); } writeFileSync( @@ -132,13 +141,58 @@ writeFileSync( console.log(`Built ${appPath}`); console.log(`Packaged ${zipPath}`); -function copyKeyringRuntime() { - const keyringRoot = dirname(requireFromCore.resolve('@napi-rs/keyring/package.json')); - const requireFromKeyring = createRequire(join(keyringRoot, 'index.js')); - const nativePackageName = `@napi-rs/keyring-darwin-${process.arch}`; - const nativeRoot = dirname(requireFromKeyring.resolve(`${nativePackageName}/package.json`)); - copyPackage(keyringRoot, join(resourceNodeModules, '@napi-rs/keyring')); +function copyArgon2Runtime() { + const argon2Root = dirname(requireFromCore.resolve('@node-rs/argon2/package.json')); + const requireFromArgon2 = createRequire(join(argon2Root, 'index.js')); + const nativePackageName = `@node-rs/argon2-darwin-${process.arch}`; + const nativeRoot = dirname(requireFromArgon2.resolve(`${nativePackageName}/package.json`)); + copyPackage(argon2Root, join(resourceNodeModules, '@node-rs/argon2')); copyPackage(nativeRoot, join(resourceNodeModules, nativePackageName)); + copyFileSync( + join(nativeRoot, `argon2.darwin-${process.arch}.node`), + join(resourceNodeModules, '@node-rs/argon2', `argon2.darwin-${process.arch}.node`), + ); +} + +function copyMcpRuntime() { + const serverRoot = packageDirectoryRoot('@modelcontextprotocol/server', [ + () => nestedPackageRoot('incur', '@modelcontextprotocol/server'), + ]); + const zodRoot = packageRoot('zod', requireFromCli); + copyPackage(serverRoot, join(resourceNodeModules, '@modelcontextprotocol/server')); + copyPackage(zodRoot, join(resourceNodeModules, 'zod')); +} + +function packageDirectoryRoot(packageName, fallbacks = []) { + const candidate = join(repoRoot, 'packages/cli/node_modules', packageName); + if (existsSync(join(candidate, 'package.json'))) return candidate; + for (const fallback of fallbacks) { + try { + return fallback(); + } catch { + continue; + } + } + throw new Error(`Could not find package root for ${packageName}.`); +} + +function copyVaultPeerRuntime() { + mkdirSync(nativeRuntimePath, { recursive: true }); + copyFileSync( + join(repoRoot, 'packages/core/native/build/vault_peer_darwin.node'), + join(nativeRuntimePath, 'vault_peer_darwin.node'), + ); +} + +function signVaultPeerBuild() { + run('codesign', [ + '--force', + '--sign', + identity, + ...timestampArgs(), + ...runtimeArgs(), + join(repoRoot, 'packages/core/native/build/vault_peer_darwin.node'), + ]); } function copyPackage(source, destination) { @@ -150,10 +204,40 @@ function copyPackage(source, destination) { }); } +function packageRoot(packageName, resolver, fallbacks = []) { + try { + return findPackageRoot(dirname(resolver.resolve(packageName))); + } catch (cause) { + for (const fallback of fallbacks) { + try { + return fallback(); + } catch { + continue; + } + } + throw cause; + } +} + +function nestedPackageRoot(parentPackageName, packageName) { + const parentRoot = findPackageRoot(dirname(requireFromCli.resolve(parentPackageName))); + return findPackageRoot(join(parentRoot, 'node_modules', packageName)); +} + +function findPackageRoot(startPath) { + let current = startPath; + while (current !== dirname(current)) { + if (existsSync(join(current, 'package.json'))) return current; + current = dirname(current); + } + throw new Error(`Could not find package root from ${startPath}.`); +} + function signNestedCode() { for (const file of listFiles(appPath)) { + if (file === join(nativeRuntimePath, 'vault_peer_darwin.node')) continue; if (file.endsWith('.node') || isMachO(file)) { - run('codesign', ['--force', '--sign', identity, ...timestampArgs(), '--options', 'runtime', file]); + run('codesign', ['--force', '--sign', identity, ...timestampArgs(), ...runtimeArgs(), file]); } } } @@ -164,8 +248,7 @@ function signExecutable() { '--sign', identity, ...timestampArgs(), - '--options', - 'runtime', + ...runtimeArgs(), '--entitlements', entitlementsPath, appPath, @@ -173,13 +256,20 @@ function signExecutable() { } function timestampArgs() { + if (timestampMode === 'none') return ['--timestamp=none']; return identity === '-' ? ['--timestamp=none'] : ['--timestamp']; } -function verifySignature() { +function runtimeArgs() { + return identity === '-' ? [] : ['--options', 'runtime']; +} + +function verifySignature(options = { assessGatekeeper: true, gatekeeperOptional: false }) { run('codesign', ['--verify', '--deep', '--strict', '--verbose=2', appPath]); - if (identity !== '-') { - run('spctl', ['--assess', '--type', 'execute', '--verbose=2', appPath], { optional: true }); + if (identity !== '-' && options.assessGatekeeper) { + run('spctl', ['--assess', '--type', 'execute', '--verbose=2', appPath], { + optional: options.gatekeeperOptional, + }); } } @@ -311,6 +401,36 @@ function isAtLeastNodeVersion(version, major, minor, patch) { return actualPatch >= patch; } +function resolveCodesignIdentity(options) { + const configured = process.env.INFLOW_CODESIGN_IDENTITY; + if (configured !== undefined) return configured; + if (!options.release && !options.signedDebug) return '-'; + const identities = discoverDeveloperIdApplicationIdentities(); + if (identities.length === 1) return identities[0]; + if (identities.length === 0) { + throw new Error('signed macOS packaging requires one visible Developer ID Application codesigning identity.'); + } + throw new Error( + [ + 'signed macOS packaging found multiple Developer ID Application identities.', + 'Set INFLOW_CODESIGN_IDENTITY to one of:', + ...identities.map((identityName) => `- ${identityName}`), + ].join('\n'), + ); +} + +function discoverDeveloperIdApplicationIdentities() { + const output = execFileSync('security', ['find-identity', '-v', '-p', 'codesigning'], { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return output + .split('\n') + .map((line) => line.match(/"([^"]+)"/)?.[1]) + .filter((identityName) => identityName?.startsWith('Developer ID Application:')); +} + function run(command, commandArgs, options = {}) { try { execFileSync(command, commandArgs, { cwd: repoRoot, stdio: 'inherit' }); diff --git a/scripts/build-vault-peer-native.mjs b/scripts/build-vault-peer-native.mjs new file mode 100644 index 0000000..d372e61 --- /dev/null +++ b/scripts/build-vault-peer-native.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +if (process.platform !== 'darwin' && process.platform !== 'linux' && process.platform !== 'win32') { + process.exit(0); +} + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const platformName = process.platform === 'darwin' ? 'darwin' : process.platform === 'linux' ? 'linux' : 'windows'; +const source = join(repoRoot, `packages/core/native/vault_peer_${platformName}.c`); +const secureMemorySource = join(repoRoot, 'packages/core/native/vault_secure_memory.c'); +const cryptoSource = join(repoRoot, 'packages/core/native/vault_crypto_native.c'); +const outputDirectory = join(repoRoot, 'packages/core/native/build'); +const output = join(outputDirectory, `vault_peer_${platformName}.node`); + +mkdirSync(outputDirectory, { recursive: true }); +if (process.platform === 'win32') { + buildWindowsNativeModule(); + process.exit(0); +} + +const nodeInclude = resolve(dirname(process.execPath), '../include/node'); +const platformArguments = + process.platform === 'darwin' ? ['-dynamiclib', '-undefined', 'dynamic_lookup'] : ['-shared', '-fPIC']; +const argon2Arguments = + process.platform === 'linux' && process.env.INFLOW_ARGON2_SOURCE_DIR !== undefined + ? linuxArgon2Arguments(requiredDirectory('INFLOW_ARGON2_SOURCE_DIR')) + : [ + ...execFileSync('pkg-config', ['--cflags', 'libargon2'], { + cwd: repoRoot, + encoding: 'utf8', + }) + .trim() + .split(/\s+/u) + .filter((argument) => argument.length > 0), + join( + execFileSync('pkg-config', ['--variable=libdir', 'libargon2'], { + cwd: repoRoot, + encoding: 'utf8', + }).trim(), + 'libargon2.a', + ), + ]; +execFileSync( + 'cc', + [ + '-std=c11', + '-Wall', + '-Wextra', + '-Werror', + ...platformArguments, + '-I', + nodeInclude, + source, + secureMemorySource, + cryptoSource, + ...argon2Arguments, + '-o', + output, + ], + { + cwd: repoRoot, + stdio: 'inherit', + }, +); + +function buildWindowsNativeModule() { + const nodeIncludeDirectory = requiredDirectory('INFLOW_NODE_INCLUDE_DIR'); + const nodeLibrary = requiredPath('INFLOW_NODE_LIBRARY'); + const argon2Root = requiredDirectory('INFLOW_ARGON2_SOURCE_DIR'); + const argon2Sources = [ + join(argon2Root, 'src/argon2.c'), + join(argon2Root, 'src/core.c'), + join(argon2Root, 'src/encoding.c'), + join(argon2Root, 'src/ref.c'), + join(argon2Root, 'src/blake2/blake2b.c'), + ]; + for (const argon2Source of argon2Sources) requiredPathValue(argon2Source); + execFileSync( + 'cl.exe', + [ + '/nologo', + '/LD', + '/std:c11', + '/W4', + '/WX', + '/guard:cf', + '/DARGON2_NO_THREADS', + '/D_CRT_SECURE_NO_WARNINGS', + `/I${nodeIncludeDirectory}`, + `/I${join(argon2Root, 'include')}`, + `/I${join(argon2Root, 'src')}`, + source, + secureMemorySource, + cryptoSource, + ...argon2Sources, + '/link', + '/guard:cf', + '/DYNAMICBASE', + '/NXCOMPAT', + `/out:${output}`, + nodeLibrary, + 'advapi32.lib', + 'bcrypt.lib', + 'crypt32.lib', + 'wintrust.lib', + 'wtsapi32.lib', + ], + { + cwd: repoRoot, + stdio: 'inherit', + }, + ); +} + +function buildLinuxArgon2Objects(argon2Root) { + const sources = [ + join(argon2Root, 'src/argon2.c'), + join(argon2Root, 'src/core.c'), + join(argon2Root, 'src/encoding.c'), + join(argon2Root, 'src/ref.c'), + join(argon2Root, 'src/blake2/blake2b.c'), + ]; + return sources.map((argon2Source, index) => { + const object = join(outputDirectory, `argon2-${index}.o`); + execFileSync( + 'cc', + [ + '-std=c11', + '-Wall', + '-Wextra', + '-fPIC', + '-DARGON2_NO_THREADS', + '-I', + join(argon2Root, 'include'), + '-I', + join(argon2Root, 'src'), + '-c', + argon2Source, + '-o', + object, + ], + { + cwd: repoRoot, + stdio: 'inherit', + }, + ); + return object; + }); +} + +function linuxArgon2Arguments(argon2Root) { + return ['-I', join(argon2Root, 'include'), '-I', join(argon2Root, 'src'), ...buildLinuxArgon2Objects(argon2Root)]; +} + +function requiredDirectory(name) { + return requiredPathValue(requiredEnvironment(name)); +} + +function requiredPath(name) { + return requiredPathValue(requiredEnvironment(name)); +} + +function requiredEnvironment(name) { + const value = process.env[name]; + if (value === undefined || value.length === 0) { + throw new Error(`${name} must identify the pinned Windows native-build dependency.`); + } + return resolve(value); +} + +function requiredPathValue(path) { + const resolvedPath = resolve(path); + if (!existsSync(resolvedPath)) { + throw new Error(`Windows native-build dependency is unavailable: ${path}`); + } + return resolvedPath; +} diff --git a/scripts/build-windows-package.mjs b/scripts/build-windows-package.mjs new file mode 100644 index 0000000..2a4fed2 --- /dev/null +++ b/scripts/build-windows-package.mjs @@ -0,0 +1,379 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const requireFromCli = createRequire(join(repoRoot, 'packages/cli/package.json')); +const requireFromCore = createRequire(join(repoRoot, 'packages/core/package.json')); +const args = new Set(process.argv.slice(2)); +const release = args.has('--release'); +const signedDebug = args.has('--signed-debug'); +const prepareExternalSigning = args.has('--prepare-external-signing'); +const buildMsiFromPreparedPayload = args.has('--build-msi-from-prepared-payload'); +const buildMsiFromSignedPayload = args.has('--build-msi-from-signed-payload'); +const writeUnsignedReleaseMetadata = args.has('--write-unsigned-release-metadata'); +const writeSignedReleaseMetadata = args.has('--write-signed-release-metadata'); +const externalSigningPhases = [ + prepareExternalSigning, + buildMsiFromPreparedPayload, + buildMsiFromSignedPayload, + writeUnsignedReleaseMetadata, + writeSignedReleaseMetadata, +].filter(Boolean); +const version = packageVersion(); +const architecture = windowsArchitecture( + buildMsiFromPreparedPayload || + buildMsiFromSignedPayload || + writeUnsignedReleaseMetadata || + writeSignedReleaseMetadata, +); +const artifactRoot = resolve(process.env.INFLOW_WINDOWS_ARTIFACT_DIR ?? join(repoRoot, 'dist/windows')); +const payloadRoot = join(artifactRoot, 'payload'); +const runtimePath = join(payloadRoot, 'runtime'); +const runtimeNodeModules = join(runtimePath, 'node_modules'); +const buildRoot = join(artifactRoot, 'build'); +const executablePath = join(payloadRoot, 'inflow.exe'); +const seaConfigPath = join(buildRoot, 'sea-config.json'); +const seaBlobPath = join(buildRoot, 'inflow.sea.blob'); +const seaMainPath = join(buildRoot, 'inflow-sea-main.cjs'); +const packagedNodeLibraryPath = join(buildRoot, 'inflow-node.lib'); +const msiPath = join(artifactRoot, `inflow-${version}-windows-${architecture}.msi`); + +if (process.platform !== 'win32') throw new Error('Windows packaging must run on Windows.'); +if (!isAtLeastNodeVersion(process.versions.node, 24, 15, 0)) { + throw new Error(`Windows packaging requires Node 24.15.0 or newer; current Node is ${process.versions.node}.`); +} +if (release && signedDebug) throw new Error('Choose either release signing or development signing.'); +if (externalSigningPhases.length > 1 || (externalSigningPhases.length === 1 && (release || signedDebug))) { + throw new Error('Choose exactly one external-signing phase, release signing, or development signing.'); +} + +if (buildMsiFromPreparedPayload || buildMsiFromSignedPayload) { + requirePreparedPayload(); + if (buildMsiFromSignedPayload) verifyAuthenticode(executablePath); + buildMsi(); + const payloadState = buildMsiFromSignedPayload ? 'signed' : 'prepared'; + process.stdout.write(`Packaged unsigned ${msiPath} from the ${payloadState} executable payload.\n`); +} else if (writeUnsignedReleaseMetadata || writeSignedReleaseMetadata) { + requirePreparedPayload(); + if (writeSignedReleaseMetadata) { + verifyAuthenticode(executablePath); + verifyAuthenticode(msiPath); + } else if (!existsSync(msiPath)) { + throw new Error(`Windows installer is unavailable: ${msiPath}`); + } + writeReleaseMetadata(writeSignedReleaseMetadata); + const signatureState = writeSignedReleaseMetadata ? 'signed' : 'unsigned'; + process.stdout.write(`Rendered ${signatureState} Windows release metadata for ${msiPath}.\n`); +} else { + buildExecutablePayload(); + if (prepareExternalSigning) { + process.stdout.write(`Prepared unsigned ${executablePath} for external signing.\n`); + } else { + sign(executablePath); + buildMsi(); + sign(msiPath); + writeReleaseMetadata(release || signedDebug); + process.stdout.write(`Packaged ${msiPath}\n`); + } +} + +function buildExecutablePayload() { + rmSync(artifactRoot, { force: true, recursive: true }); + mkdirSync(buildRoot, { recursive: true }); + mkdirSync(runtimePath, { recursive: true }); + + const tsupCli = join(repoRoot, 'node_modules/tsup/dist/cli-default.js'); + run(process.execPath, [tsupCli], { cwd: join(repoRoot, 'packages/core') }); + buildPackagedNodeImportLibrary(); + run(process.execPath, ['scripts/build-vault-peer-native.mjs'], { + env: { INFLOW_NODE_LIBRARY: packagedNodeLibraryPath }, + }); + verifyPackagedNativeImports(); + run(process.execPath, [tsupCli, '--config', 'tsup.standalone.config.ts'], { + cwd: join(repoRoot, 'packages/cli'), + }); + copyArgon2Runtime(); + copyMcpRuntime(); + copyVaultPeerRuntime(); + writeSeaMain(); + writeFileSync( + seaConfigPath, + `${JSON.stringify( + { + disableExperimentalSEAWarning: true, + main: seaMainPath, + output: seaBlobPath, + }, + null, + 2, + )}\n`, + ); + run(process.execPath, ['--experimental-sea-config', seaConfigPath]); + copyFileSync(process.execPath, executablePath); + removeExistingSignature(executablePath); + run(process.execPath, [ + resolvePostject(), + executablePath, + 'NODE_SEA_BLOB', + seaBlobPath, + '--sentinel-fuse', + 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2', + ]); +} + +function buildPackagedNodeImportLibrary() { + run('lib.exe', [ + '/nologo', + `/def:${join(repoRoot, 'packaging/windows/inflow-node.def')}`, + `/machine:${architecture}`, + `/out:${packagedNodeLibraryPath}`, + ]); +} + +function verifyPackagedNativeImports() { + const nativePath = join(repoRoot, 'packages/core/native/build/vault_peer_windows.node'); + const imports = execFileSync('dumpbin.exe', ['/imports', nativePath], { + cwd: repoRoot, + encoding: 'utf8', + }); + if (!imports.includes('inflow.exe') || imports.includes('node.exe')) { + throw new Error('Packaged Windows native module must import the InFlow executable host.'); + } +} + +function copyArgon2Runtime() { + const argon2Root = dirname(requireFromCore.resolve('@node-rs/argon2/package.json')); + const requireFromArgon2 = createRequire(join(argon2Root, 'index.js')); + const nativePackageName = `@node-rs/argon2-win32-${architecture}-msvc`; + const nativeRoot = dirname(requireFromArgon2.resolve(`${nativePackageName}/package.json`)); + copyPackage(argon2Root, join(runtimeNodeModules, '@node-rs/argon2')); + copyPackage(nativeRoot, join(runtimeNodeModules, nativePackageName)); + copyFileSync( + join(nativeRoot, `argon2.win32-${architecture}-msvc.node`), + join(runtimeNodeModules, '@node-rs/argon2', `argon2.win32-${architecture}-msvc.node`), + ); +} + +function copyMcpRuntime() { + const serverRoot = packageDirectoryRoot('@modelcontextprotocol/server', [ + () => nestedPackageRoot('incur', '@modelcontextprotocol/server'), + ]); + const zodRoot = dependencyPackageRoot('zod', requireFromCli); + copyPackage(serverRoot, join(runtimeNodeModules, '@modelcontextprotocol/server')); + copyPackage(zodRoot, join(runtimeNodeModules, 'zod')); +} + +function copyVaultPeerRuntime() { + const nativePath = join(payloadRoot, 'native'); + mkdirSync(nativePath, { recursive: true }); + copyFileSync( + join(repoRoot, 'packages/core/native/build/vault_peer_windows.node'), + join(nativePath, 'vault_peer_windows.node'), + ); +} + +function writeSeaMain() { + const cliSource = readFileSync(join(repoRoot, 'packages/cli/dist/cli.standalone.mjs')).toString('base64'); + const runtimeManifest = runtimeFiles().map((path) => [relative(payloadRoot, path), sha256(path)]); + writeFileSync( + seaMainPath, + `const { createHash } = require('node:crypto'); +const { lstatSync, readFileSync, realpathSync } = require('node:fs'); +const { dirname, join, resolve } = require('node:path'); + +const executablePath = realpathSync(process.execPath); +const payloadRoot = dirname(executablePath); +const manifest = new Map(${JSON.stringify(runtimeManifest)}); + +try { + for (const [name, expectedHash] of manifest) { + const path = join(payloadRoot, name); + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || realpathSync(path) !== path) { + throw new Error('unsafe runtime path'); + } + const actualHash = createHash('sha256').update(readFileSync(path)).digest('hex'); + if (actualHash !== expectedHash) throw new Error('runtime digest mismatch'); + } +} catch (cause) { + const reason = cause instanceof Error ? cause.message : 'unknown integrity failure'; + process.stderr.write(\`InFlow runtime integrity verification failed: \${reason}.\\n\`); + process.exit(1); +} + +const cli = ${JSON.stringify(`data:text/javascript;base64,${cliSource}`)}; +import(cli).catch((cause) => { + const message = cause instanceof Error ? cause.stack ?? cause.message : String(cause); + process.stderr.write(\`\${message}\\n\`); + process.exit(1); +}); +`, + ); +} + +function buildMsi() { + const wix = process.env.INFLOW_WIX_PATH ?? 'wix.exe'; + run(wix, [ + 'build', + '-arch', + architecture, + '-d', + `Version=${version}`, + '-d', + `Payload=${payloadRoot}`, + '-o', + msiPath, + join(repoRoot, 'packaging/windows/inflow.wxs'), + ]); +} + +function sign(path) { + if (!release && !signedDebug) return; + const signtool = requiredEnvironment('INFLOW_SIGNTOOL_PATH'); + const subject = requiredEnvironment('INFLOW_WINDOWS_SIGNING_SUBJECT'); + const signingArguments = ['sign', '/fd', 'SHA256', '/n', subject]; + if (signedDebug) signingArguments.push('/sm'); + if (release) { + signingArguments.push('/tr', requiredEnvironment('INFLOW_WINDOWS_TIMESTAMP_URL'), '/td', 'SHA256'); + } + signingArguments.push(path); + run(signtool, signingArguments); + run(signtool, ['verify', '/pa', '/v', path]); +} + +function verifyAuthenticode(path) { + if (!existsSync(path)) throw new Error(`Signed Windows artifact is unavailable: ${path}`); + const signtool = process.env.INFLOW_SIGNTOOL_PATH ?? 'signtool.exe'; + run(signtool, ['verify', '/pa', '/v', path]); +} + +function removeExistingSignature(path) { + const signtool = process.env.INFLOW_SIGNTOOL_PATH ?? 'signtool.exe'; + run(signtool, ['remove', '/s', path]); +} + +function writeReleaseMetadata(signed) { + const checksum = sha256(msiPath); + writeFileSync(`${msiPath}.sha256`, `${checksum} ${msiPath.split(/[\\/]/u).at(-1)}\n`); + writeFileSync( + join(artifactRoot, 'manifest.json'), + `${JSON.stringify( + { + arch: architecture, + msi: msiPath, + node: process.versions.node, + platform: process.platform, + sha256: checksum, + signed, + version, + }, + null, + 2, + )}\n`, + ); +} + +function runtimeFiles(root = runtimePath) { + return readdirSync(root, { withFileTypes: true }) + .flatMap((entry) => { + const path = join(root, entry.name); + return entry.isDirectory() ? runtimeFiles(path) : entry.isFile() ? [path] : []; + }) + .sort(); +} + +function copyPackage(source, destination) { + mkdirSync(dirname(destination), { recursive: true }); + cpSync(source, destination, { + dereference: true, + filter: (sourcePath) => !sourcePath.includes(`${source}${process.platform === 'win32' ? '\\' : '/'}node_modules`), + recursive: true, + }); +} + +function packageDirectoryRoot(packageName, fallbacks = []) { + const candidate = join(repoRoot, 'packages/cli/node_modules', packageName); + if (existsSync(join(candidate, 'package.json'))) return candidate; + for (const fallback of fallbacks) { + try { + return fallback(); + } catch { + continue; + } + } + throw new Error(`Could not find package root for ${packageName}.`); +} + +function dependencyPackageRoot(packageName, resolver) { + return findPackageRoot(dirname(resolver.resolve(packageName))); +} + +function nestedPackageRoot(parentPackageName, packageName) { + const parentRoot = findPackageRoot(dirname(requireFromCli.resolve(parentPackageName))); + return findPackageRoot(join(parentRoot, 'node_modules', packageName)); +} + +function findPackageRoot(startPath) { + let current = startPath; + while (current !== dirname(current)) { + if (existsSync(join(current, 'package.json'))) return current; + current = dirname(current); + } + throw new Error(`Could not find package root from ${startPath}.`); +} + +function resolvePostject() { + const bin = join(repoRoot, 'node_modules/postject/dist/cli.js'); + if (!existsSync(bin)) throw new Error('postject is not installed. Run pnpm install.'); + return bin; +} + +function requiredEnvironment(name) { + const value = process.env[name]; + if (value === undefined || value.length === 0) throw new Error(`${name} is required.`); + return value; +} + +function packageVersion() { + const manifest = JSON.parse(readFileSync(join(repoRoot, 'packages/cli/package.json'), 'utf8')); + if (typeof manifest.version !== 'string') throw new Error('CLI package version is missing.'); + return manifest.version; +} + +function windowsArchitecture(allowTargetOverride) { + const target = allowTargetOverride ? process.env.INFLOW_WINDOWS_TARGET_ARCH : undefined; + if (target === 'x64' || target === 'arm64') return target; + if (target !== undefined) throw new Error(`Unsupported Windows target architecture: ${target}.`); + if (process.arch === 'x64' || process.arch === 'arm64') return process.arch; + throw new Error(`Unsupported Windows architecture: ${process.arch}.`); +} + +function requirePreparedPayload() { + if (!existsSync(executablePath) || !existsSync(runtimePath)) { + throw new Error(`Prepared Windows payload is unavailable under ${payloadRoot}.`); + } +} + +function sha256(path) { + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} + +function isAtLeastNodeVersion(value, major, minor, patch) { + const [actualMajor = 0, actualMinor = 0, actualPatch = 0] = value.split('.').map(Number); + if (actualMajor !== major) return actualMajor > major; + if (actualMinor !== minor) return actualMinor > minor; + return actualPatch >= patch; +} + +function run(command, commandArguments, options = {}) { + execFileSync(command, commandArguments, { + cwd: options.cwd ?? repoRoot, + env: { ...process.env, ...options.env }, + stdio: 'inherit', + }); +} diff --git a/scripts/build-windows-transition-fixtures.ps1 b/scripts/build-windows-transition-fixtures.ps1 new file mode 100644 index 0000000..74b281e --- /dev/null +++ b/scripts/build-windows-transition-fixtures.ps1 @@ -0,0 +1,69 @@ +param( + [string]$Repository = 'C:\Users\Public\inflow-windows-msi', + [string]$FixtureRoot = 'C:\Users\Public\inflow-msi-transitions', + [string]$NewVersion = '0.9.1', + [string]$Report = 'C:\Users\Public\inflow-windows-transition-build.log' +) + +$ErrorActionPreference = 'Stop' +$manifestPath = Join-Path $Repository 'packages\cli\package.json' +$oldMsi = Join-Path $Repository 'dist\windows\inflow-0.9.0-windows-arm64.msi' +$newMsi = Join-Path $Repository "dist\windows\inflow-$NewVersion-windows-arm64.msi" +$originalManifest = $null + +try { + if (-not (Test-Path -LiteralPath $oldMsi -PathType Leaf)) { + throw "The preserved baseline MSI is missing: $oldMsi" + } + New-Item -ItemType Directory -Path $FixtureRoot -Force | Out-Null + Copy-Item -LiteralPath $oldMsi -Destination (Join-Path $FixtureRoot 'inflow-0.9.0-windows-arm64.msi') -Force + + $originalManifest = [System.IO.File]::ReadAllBytes($manifestPath) + $manifestText = [System.Text.Encoding]::UTF8.GetString($originalManifest) + $updatedManifest = [regex]::Replace( + $manifestText, + '"version"\s*:\s*"[^"]+"', + "`"version`": `"$NewVersion`"", + 1 + ) + if ($updatedManifest -ceq $manifestText) { + throw 'The CLI package version was not updated.' + } + [System.IO.File]::WriteAllText( + $manifestPath, + $updatedManifest, + [System.Text.UTF8Encoding]::new($false) + ) + + & 'C:\Users\Public\build-inflow-windows-permanent.cmd' + if ($LASTEXITCODE -ne 0) { + throw "The signed Windows build failed with exit code $LASTEXITCODE." + } + if (-not (Test-Path -LiteralPath $newMsi -PathType Leaf)) { + throw "The signed transition MSI is missing: $newMsi" + } + Copy-Item -LiteralPath $newMsi -Destination (Join-Path $FixtureRoot "inflow-$NewVersion-windows-arm64.msi") -Force + + foreach ($path in @( + (Join-Path $FixtureRoot 'inflow-0.9.0-windows-arm64.msi'), + (Join-Path $FixtureRoot "inflow-$NewVersion-windows-arm64.msi") + )) { + $signature = Get-AuthenticodeSignature -LiteralPath $path + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { + throw "The signature is invalid for $path`: $($signature.StatusMessage)" + } + } + + @( + 'PASS: preserved signed 0.9.0 MSI' + "PASS: built signed $NewVersion MSI" + 'PASS: both MSI signatures valid' + ) | Set-Content -LiteralPath $Report -Encoding ascii +} catch { + "FAIL: $($_.Exception.Message)" | Set-Content -LiteralPath $Report -Encoding ascii + throw +} finally { + if ($null -ne $originalManifest) { + [System.IO.File]::WriteAllBytes($manifestPath, $originalManifest) + } +} diff --git a/scripts/check-changed-coverage.mjs b/scripts/check-changed-coverage.mjs index a0358ce..e731ac2 100644 --- a/scripts/check-changed-coverage.mjs +++ b/scripts/check-changed-coverage.mjs @@ -7,6 +7,7 @@ const repoRoot = resolve(import.meta.dirname, '..'); const baseRef = process.env.PATCH_COVERAGE_BASE ?? githubBaseRef() ?? 'origin/main'; const target = Number(process.env.PATCH_COVERAGE_TARGET ?? '90'); const lcovFiles = ['packages/cli/coverage/lcov.info', 'packages/core/coverage/lcov.info']; +const excludedSources = new Set(['packages/cli/src/cli.tsx']); if (!Number.isFinite(target) || target < 0 || target > 100) { throw new Error(`PATCH_COVERAGE_TARGET must be a percentage from 0 through 100; got ${String(target)}.`); @@ -90,7 +91,8 @@ function changedSourceLines(ref) { let currentFile; for (const line of diff.split('\n')) { if (line.startsWith('+++ b/')) { - currentFile = line.slice('+++ b/'.length); + const file = line.slice('+++ b/'.length); + currentFile = excludedSources.has(file) ? undefined : file; continue; } if (currentFile === undefined || !line.startsWith('@@')) continue; diff --git a/scripts/diagnose-windows-lifecycle-hang.cmd b/scripts/diagnose-windows-lifecycle-hang.cmd new file mode 100644 index 0000000..819bb6f --- /dev/null +++ b/scripts/diagnose-windows-lifecycle-hang.cmd @@ -0,0 +1,10 @@ +@echo off +( + echo ==== processes ==== + tasklist /v /fi "IMAGENAME eq inflow.exe" + tasklist /v /fi "IMAGENAME eq powershell.exe" + echo ==== jobs ==== + netstat -ano -p tcp + echo ==== service ==== + sc.exe queryex InFlowVault +) > "C:\Users\Public\inflow-lifecycle-hang.log" 2>&1 diff --git a/scripts/diagnose-windows-pagefile.ps1 b/scripts/diagnose-windows-pagefile.ps1 new file mode 100644 index 0000000..d875c11 --- /dev/null +++ b/scripts/diagnose-windows-pagefile.ps1 @@ -0,0 +1,20 @@ +$memoryPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management' +$crashPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl' +$computer = Get-CimInstance Win32_ComputerSystem +$memory = Get-ItemProperty -LiteralPath $memoryPath +$crash = Get-ItemProperty -LiteralPath $crashPath +@{ + automaticManagedPagefile = [bool]$computer.AutomaticManagedPagefile + crashDumpEnabled = $crash.CrashDumpEnabled + existingPageFiles = @($memory.ExistingPageFiles) + pagingFiles = @($memory.PagingFiles) + settings = @( + Get-CimInstance Win32_PageFileSetting -ErrorAction SilentlyContinue | + Select-Object Name, InitialSize, MaximumSize + ) + usage = @( + Get-CimInstance Win32_PageFileUsage -ErrorAction SilentlyContinue | + Select-Object Name, AllocatedBaseSize, CurrentUsage, PeakUsage, TempPageFile + ) +} | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath 'C:\Users\Public\inflow-pagefile-diagnostic.json' -Encoding ascii diff --git a/scripts/diagnose-windows-vault.cmd b/scripts/diagnose-windows-vault.cmd new file mode 100644 index 0000000..5411fd1 --- /dev/null +++ b/scripts/diagnose-windows-vault.cmd @@ -0,0 +1,10 @@ +@echo off +sc.exe stop InFlowVault >nul 2>&1 +:wait_for_stop +sc.exe query InFlowVault | find "STATE" | find "STOPPED" >nul +if errorlevel 1 ( + timeout /t 1 /nobreak >nul + goto wait_for_stop +) +"C:\Program Files\InFlow\inflow.exe" vault status --format json > "C:\Users\Public\inflow-status-diagnostic.log" 2>&1 +echo EXIT=%ERRORLEVEL%>> "C:\Users\Public\inflow-status-diagnostic.log" diff --git a/scripts/disable-windows-pagefile-for-test.ps1 b/scripts/disable-windows-pagefile-for-test.ps1 new file mode 100644 index 0000000..276f4b2 --- /dev/null +++ b/scripts/disable-windows-pagefile-for-test.ps1 @@ -0,0 +1,46 @@ +$ErrorActionPreference = 'Stop' +$backup = 'C:\Users\Public\inflow-pagefile-backup.json' +$report = 'C:\Users\Public\inflow-pagefile-disable.log' + +try { + $computer = Get-CimInstance Win32_ComputerSystem + $settings = @(Get-CimInstance Win32_PageFileSetting -ErrorAction SilentlyContinue) + $pagingFiles = @( + (Get-ItemProperty ` + -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management' ` + -Name PagingFiles).PagingFiles + ) + if (Test-Path -LiteralPath $backup) { + $saved = Get-Content -LiteralPath $backup -Raw | ConvertFrom-Json + $saved | Add-Member -NotePropertyName pagingFiles -NotePropertyValue $pagingFiles -Force + $saved | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $backup -Encoding ascii + } else { + @{ + automaticManagedPagefile = [bool]$computer.AutomaticManagedPagefile + pagingFiles = $pagingFiles + settings = @( + $settings | ForEach-Object { + @{ + initialSize = [uint32]$_.InitialSize + maximumSize = [uint32]$_.MaximumSize + name = [string]$_.Name + } + } + ) + } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $backup -Encoding ascii + } + + Set-CimInstance -InputObject $computer -Property @{ AutomaticManagedPagefile = $false } | Out-Null + foreach ($setting in $settings) { + Remove-CimInstance -InputObject $setting + } + Set-ItemProperty ` + -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management' ` + -Name PagingFiles ` + -Value ([string[]]@()) + 'Pagefile configuration disabled. A reboot is required.' | + Set-Content -LiteralPath $report -Encoding ascii +} catch { + "FAIL: $($_.Exception.Message)" | Set-Content -LiteralPath $report -Encoding ascii + throw +} diff --git a/scripts/install-windows-diagnostic-build.cmd b/scripts/install-windows-diagnostic-build.cmd new file mode 100644 index 0000000..b292b5e --- /dev/null +++ b/scripts/install-windows-diagnostic-build.cmd @@ -0,0 +1,13 @@ +@echo off +setlocal +set "REPORT=C:\Users\Public\inflow-diagnostic-install-start.log" +powershell.exe -NoLogo -NoProfile -Command "$service = Get-Service -Name InFlowVault; if ($service.Status -ne 'Stopped') { Stop-Service -Name InFlowVault; $service.WaitForStatus([System.ServiceProcess.ServiceControllerStatus]::Stopped, [TimeSpan]::FromSeconds(30)) }" > "%REPORT%" 2>&1 +if errorlevel 1 exit /b 1 +copy /y "C:\Users\Public\inflow-windows-msi\dist\windows\payload\inflow.exe" "C:\Program Files\InFlow\inflow.exe" >> "%REPORT%" 2>&1 +if errorlevel 1 exit /b 1 +copy /y "C:\Users\Public\inflow-windows-msi\dist\windows\payload\native\vault_peer_windows.node" "C:\Program Files\InFlow\native\vault_peer_windows.node" >> "%REPORT%" 2>&1 +if errorlevel 1 exit /b 1 +certutil.exe -hashfile "C:\Program Files\InFlow\inflow.exe" SHA256 >> "%REPORT%" 2>&1 +certutil.exe -hashfile "C:\Program Files\InFlow\native\vault_peer_windows.node" SHA256 >> "%REPORT%" 2>&1 +sc.exe start InFlowVault >> "%REPORT%" 2>&1 +exit /b %ERRORLEVEL% diff --git a/scripts/list-windows-vault-state.cmd b/scripts/list-windows-vault-state.cmd new file mode 100644 index 0000000..ee40979 --- /dev/null +++ b/scripts/list-windows-vault-state.cmd @@ -0,0 +1,2 @@ +@echo off +dir /s /a "C:\ProgramData\InFlow\vaults" > "C:\Users\Public\inflow-vault-state-list.log" 2>&1 diff --git a/scripts/macos-task-memory-probe.c b/scripts/macos-task-memory-probe.c new file mode 100644 index 0000000..a899fc6 --- /dev/null +++ b/scripts/macos-task-memory-probe.c @@ -0,0 +1,55 @@ +#include +#include +#include +#include +#include +#include + +int main(int argc, char **argv) { + if (argc != 2) { + return 1; + } + + errno = 0; + char *end = NULL; + long parsed_pid = strtol(argv[1], &end, 10); + if (errno != 0 || end == argv[1] || *end != '\0' || parsed_pid <= 0 || parsed_pid > INT32_MAX) { + return 1; + } + + mach_port_t task = MACH_PORT_NULL; + kern_return_t task_result = task_for_pid(mach_task_self(), (pid_t)parsed_pid, &task); + if (task_result != KERN_SUCCESS) { + return 0; + } + + mach_vm_address_t address = 0; + while (address < MACH_VM_MAX_ADDRESS) { + mach_vm_size_t size = 0; + natural_t depth = 0; + vm_region_submap_info_data_64_t info; + mach_msg_type_number_t info_count = VM_REGION_SUBMAP_INFO_COUNT_64; + kern_return_t region_result = mach_vm_region_recurse( + task, &address, &size, &depth, (vm_region_recurse_info_t)&info, &info_count); + if (region_result != KERN_SUCCESS) { + break; + } + if ((info.protection & VM_PROT_READ) != 0 && size > 0) { + uint8_t byte = 0; + mach_vm_size_t read_size = 0; + kern_return_t read_result = + mach_vm_read_overwrite(task, address, sizeof(byte), (mach_vm_address_t)&byte, &read_size); + if (read_result == KERN_SUCCESS && read_size == sizeof(byte)) { + mach_port_deallocate(mach_task_self(), task); + return 2; + } + } + if (size == 0 || address > MACH_VM_MAX_ADDRESS - size) { + break; + } + address += size; + } + + mach_port_deallocate(mach_task_self(), task); + return 3; +} diff --git a/scripts/parse-windows-vault-lifecycle.cmd b/scripts/parse-windows-vault-lifecycle.cmd new file mode 100644 index 0000000..da7c20c --- /dev/null +++ b/scripts/parse-windows-vault-lifecycle.cmd @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoLogo -NoProfile -Command "$tokens = $null; $errors = $null; [void][System.Management.Automation.Language.Parser]::ParseFile('C:\Users\Public\smoke-windows-vault-lifecycle.ps1', [ref]$tokens, [ref]$errors); if ($errors.Count -ne 0) { $errors | Out-File -FilePath 'C:\Users\Public\inflow-lifecycle-parse.log' -Encoding ascii; exit 1 }; 'PowerShell 5.1 parse passed.' | Out-File -FilePath 'C:\Users\Public\inflow-lifecycle-parse.log' -Encoding ascii" diff --git a/scripts/prepare-argon2-source.mjs b/scripts/prepare-argon2-source.mjs new file mode 100644 index 0000000..5943255 --- /dev/null +++ b/scripts/prepare-argon2-source.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const archiveUrl = 'https://github.com/P-H-C/phc-winner-argon2/archive/refs/tags/20190702.tar.gz'; +const archiveSha256 = 'daf972a89577f8772602bf2eb38b6a3dd3d922bf5724d45e7f9589b5e830442c'; +const destination = resolve(process.argv[2] ?? 'dist/native-sources/argon2-20190702'); +const archive = `${destination}.tar.gz`; + +rmSync(destination, { force: true, recursive: true }); +mkdirSync(destination, { recursive: true }); + +const response = await fetch(archiveUrl, { redirect: 'follow' }); +if (!response.ok) throw new Error(`Argon2 source download failed with HTTP ${response.status}.`); +const bytes = Buffer.from(await response.arrayBuffer()); +const actualSha256 = createHash('sha256').update(bytes).digest('hex'); +if (actualSha256 !== archiveSha256) { + bytes.fill(0); + throw new Error(`Argon2 source checksum mismatch: ${actualSha256}.`); +} + +writeFileSync(archive, bytes, { mode: 0o600 }); +bytes.fill(0); +try { + execFileSync('tar', ['-xzf', archive, '--strip-components=1', '-C', destination], { stdio: 'inherit' }); +} finally { + rmSync(archive, { force: true }); +} + +process.stdout.write(`${destination}\n`); diff --git a/scripts/render-linux-installer.mjs b/scripts/render-linux-installer.mjs new file mode 100644 index 0000000..cd78f4d --- /dev/null +++ b/scripts/render-linux-installer.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const version = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')).version; +const output = resolve(process.argv[2] ?? join(repoRoot, 'dist/linux/install.sh')); +const template = readFileSync(join(repoRoot, 'packaging/linux/install.sh.template'), 'utf8'); +const signingFingerprint = requiredEnvironment('INFLOW_LINUX_SIGNING_FINGERPRINT').replaceAll(/\s/gu, '').toUpperCase(); +if (!/^[0-9A-F]{40,64}$/u.test(signingFingerprint)) { + throw new Error('INFLOW_LINUX_SIGNING_FINGERPRINT is invalid.'); +} + +mkdirSync(dirname(output), { recursive: true }); +writeFileSync( + output, + template + .replaceAll('__INFLOW_VERSION__', version) + .replaceAll('__INFLOW_LINUX_SIGNING_FINGERPRINT__', signingFingerprint), + { mode: 0o755 }, +); +process.stdout.write(`${output}\n`); + +function requiredEnvironment(name) { + const value = process.env[name]; + if (value === undefined || value.length === 0) throw new Error(`${name} is required.`); + return value; +} diff --git a/scripts/render-windows-release-metadata.mjs b/scripts/render-windows-release-metadata.mjs new file mode 100644 index 0000000..0fb71cf --- /dev/null +++ b/scripts/render-windows-release-metadata.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const artifactRoot = resolve(process.env.INFLOW_WINDOWS_ARTIFACT_DIR ?? join(repoRoot, 'dist/windows')); +const version = packageVersion(); +const publisher = requiredEnvironment('INFLOW_WINDOWS_SIGNING_SUBJECT'); +const architectures = ['x64', 'arm64']; +const checksums = new Map( + architectures.map((architecture) => { + const msi = join(artifactRoot, `inflow-${version}-windows-${architecture}.msi`); + if (!existsSync(msi)) throw new Error(`Windows installer is unavailable: ${msi}`); + const checksum = sha256(msi); + writeFileSync(`${msi}.sha256`, `${checksum} ${msi.split(/[\\/]/u).at(-1)}\n`); + return [architecture, checksum]; + }), +); + +renderTemplate('packaging/windows/install.ps1.template', 'install.ps1'); +const wingetRoot = join(artifactRoot, 'winget'); +mkdirSync(wingetRoot, { recursive: true }); +renderTemplate('packaging/windows/winget/installer.yaml.template', 'winget/InFlowPayAI.InFlow.installer.yaml'); +renderTemplate('packaging/windows/winget/locale.en-US.yaml.template', 'winget/InFlowPayAI.InFlow.locale.en-US.yaml'); +renderTemplate('packaging/windows/winget/version.yaml.template', 'winget/InFlowPayAI.InFlow.yaml'); + +process.stdout.write(`Rendered combined x64 and ARM64 Windows release metadata for version ${version}.\n`); + +function renderTemplate(source, destination) { + const content = readFileSync(join(repoRoot, source), 'utf8') + .replaceAll('__INFLOW_VERSION__', version) + .replaceAll('__INFLOW_MSI_SHA256_X64__', requiredChecksum('x64')) + .replaceAll('__INFLOW_MSI_SHA256_X64_UPPER__', requiredChecksum('x64').toUpperCase()) + .replaceAll('__INFLOW_MSI_SHA256_ARM64__', requiredChecksum('arm64')) + .replaceAll('__INFLOW_MSI_SHA256_ARM64_UPPER__', requiredChecksum('arm64').toUpperCase()) + .replaceAll('__INFLOW_WINDOWS_PUBLISHER__', publisher); + writeFileSync(join(artifactRoot, destination), content); +} + +function requiredChecksum(architecture) { + const checksum = checksums.get(architecture); + if (checksum === undefined) throw new Error(`Windows checksum is unavailable for ${architecture}.`); + return checksum; +} + +function packageVersion() { + const manifest = JSON.parse(readFileSync(join(repoRoot, 'packages/cli/package.json'), 'utf8')); + if (typeof manifest.version !== 'string') throw new Error('CLI package version is missing.'); + return manifest.version; +} + +function requiredEnvironment(name) { + const value = process.env[name]; + if (value === undefined || value.length === 0) throw new Error(`${name} is required.`); + return value; +} + +function sha256(path) { + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} diff --git a/scripts/restore-windows-pagefile-after-test.ps1 b/scripts/restore-windows-pagefile-after-test.ps1 new file mode 100644 index 0000000..d024cdb --- /dev/null +++ b/scripts/restore-windows-pagefile-after-test.ps1 @@ -0,0 +1,35 @@ +$ErrorActionPreference = 'Stop' +$backup = 'C:\Users\Public\inflow-pagefile-backup.json' +$report = 'C:\Users\Public\inflow-pagefile-restore.log' + +try { + $saved = Get-Content -LiteralPath $backup -Raw | ConvertFrom-Json + foreach ($setting in @(Get-CimInstance Win32_PageFileSetting -ErrorAction SilentlyContinue)) { + Remove-CimInstance -InputObject $setting + } + Set-ItemProperty ` + -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management' ` + -Name PagingFiles ` + -Value ([string[]]@($saved.pagingFiles)) + $computer = Get-WmiObject Win32_ComputerSystem -EnableAllPrivileges + $computer.AutomaticManagedPagefile = [bool]$saved.automaticManagedPagefile + $computer.Put() | Out-Null + if (-not [bool]$saved.automaticManagedPagefile) { + foreach ($setting in @($saved.settings)) { + New-CimInstance -ClassName Win32_PageFileSetting -Property @{ + InitialSize = [uint32]$setting.initialSize + MaximumSize = [uint32]$setting.maximumSize + Name = [string]$setting.name + } | Out-Null + } + } + $restored = Get-WmiObject Win32_ComputerSystem -EnableAllPrivileges + if ([bool]$restored.AutomaticManagedPagefile -ne [bool]$saved.automaticManagedPagefile) { + throw 'Windows did not accept the saved AutomaticManagedPagefile setting.' + } + 'Original pagefile configuration restored. A reboot is required.' | + Set-Content -LiteralPath $report -Encoding ascii +} catch { + "FAIL: $($_.Exception.Message)" | Set-Content -LiteralPath $report -Encoding ascii + throw +} diff --git a/scripts/run-windows-msi-transitions.cmd b/scripts/run-windows-msi-transitions.cmd new file mode 100644 index 0000000..8fe9d76 --- /dev/null +++ b/scripts/run-windows-msi-transitions.cmd @@ -0,0 +1,12 @@ +@echo off +net session >nul 2>&1 +if errorlevel 1 ( + powershell.exe -NoLogo -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs" + exit /b +) +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0smoke-windows-msi-transitions.ps1" +echo. +echo Result: +type "C:\Users\Public\inflow-windows-msi-transitions.log" +echo. +pause diff --git a/scripts/run-windows-vault-disk-scan.cmd b/scripts/run-windows-vault-disk-scan.cmd new file mode 100644 index 0000000..26641c9 --- /dev/null +++ b/scripts/run-windows-vault-disk-scan.cmd @@ -0,0 +1,7 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "C:\Users\Public\smoke-windows-vault-lifecycle.ps1" -SkipRelock +echo. +echo Result: +type "C:\Users\Public\inflow-windows-vault-lifecycle.log" +echo. +pause diff --git a/scripts/run-windows-vault-lifecycle.cmd b/scripts/run-windows-vault-lifecycle.cmd new file mode 100644 index 0000000..c23adf7 --- /dev/null +++ b/scripts/run-windows-vault-lifecycle.cmd @@ -0,0 +1,7 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "C:\Users\Public\smoke-windows-vault-lifecycle.ps1" +echo. +echo Result: +type "C:\Users\Public\inflow-windows-vault-lifecycle.log" +echo. +pause diff --git a/scripts/run-windows-vault-memory-scan.cmd b/scripts/run-windows-vault-memory-scan.cmd new file mode 100644 index 0000000..d034d45 --- /dev/null +++ b/scripts/run-windows-vault-memory-scan.cmd @@ -0,0 +1,12 @@ +@echo off +net.exe session >nul 2>&1 +if errorlevel 1 ( + powershell.exe -NoLogo -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs" + exit /b +) +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "C:\Users\Public\smoke-windows-vault-lifecycle.ps1" -SkipRelock +echo. +echo Result: +type "C:\Users\Public\inflow-windows-vault-lifecycle.log" +echo. +pause diff --git a/scripts/run-windows-vault-passphrase-reset.cmd b/scripts/run-windows-vault-passphrase-reset.cmd new file mode 100644 index 0000000..470d559 --- /dev/null +++ b/scripts/run-windows-vault-passphrase-reset.cmd @@ -0,0 +1,7 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0smoke-windows-vault-passphrase-reset.ps1" -SkipReset +echo. +echo Result: +type "C:\Users\Public\inflow-windows-vault-passphrase-reset.log" +echo. +pause diff --git a/scripts/run-windows-vault-reset.cmd b/scripts/run-windows-vault-reset.cmd new file mode 100644 index 0000000..8a5370d --- /dev/null +++ b/scripts/run-windows-vault-reset.cmd @@ -0,0 +1,7 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0smoke-windows-vault-passphrase-reset.ps1" +echo. +echo Result: +type "C:\Users\Public\inflow-windows-vault-passphrase-reset.log" +echo. +pause diff --git a/scripts/sign-linux-release.mjs b/scripts/sign-linux-release.mjs new file mode 100644 index 0000000..22d001e --- /dev/null +++ b/scripts/sign-linux-release.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const artifactRoot = resolve(process.argv[2] ?? join(repoRoot, 'dist/linux/release')); +const signingKey = requiredEnvironment('INFLOW_LINUX_SIGNING_KEY_ID'); +const expectedFingerprint = normalizedFingerprint(requiredEnvironment('INFLOW_LINUX_SIGNING_FINGERPRINT')); +const version = packageVersion(); +const expectedAssets = [ + `inflow-${version}-linux-arm64.tar.gz`, + `inflow-${version}-linux-x64.tar.gz`, + `inflow-${version}-1.aarch64.rpm`, + `inflow-${version}-1.x86_64.rpm`, + `inflow_${version}_amd64.deb`, + `inflow_${version}_arm64.deb`, +]; + +for (const name of expectedAssets) { + const asset = join(artifactRoot, name); + if (!existsSync(asset)) throw new Error(`Linux release asset is unavailable: ${asset}`); +} +const unexpectedPackages = readdirSync(artifactRoot) + .filter((name) => /\.(?:deb|rpm|tar\.gz)$/u.test(name) && !expectedAssets.includes(name)) + .sort(); +if (unexpectedPackages.length > 0) { + throw new Error(`Unexpected Linux release assets: ${unexpectedPackages.join(', ')}`); +} + +const actualFingerprint = normalizedFingerprint( + runText('gpg', ['--batch', '--with-colons', '--fingerprint', signingKey]) + .split('\n') + .find((line) => line.startsWith('fpr:')) + ?.split(':')[9] ?? '', +); +if (actualFingerprint !== expectedFingerprint) { + throw new Error( + `Linux signing-key fingerprint mismatch: expected ${expectedFingerprint}, received ${actualFingerprint}`, + ); +} + +for (const name of expectedAssets.filter((asset) => asset.endsWith('.rpm'))) { + run('rpmsign', [ + '--addsign', + '--define', + `_gpg_name ${signingKey}`, + '--define', + `_gpg_path ${requiredEnvironment('GNUPGHOME')}`, + join(artifactRoot, name), + ]); +} + +const checksums = expectedAssets + .map((name) => `${sha256(join(artifactRoot, name))} ${name}`) + .sort() + .join('\n'); +writeFileSync(join(artifactRoot, 'SHA256SUMS'), `${checksums}\n`); +for (const line of checksums.split('\n')) { + const [checksum, name] = line.split(' '); + if (checksum === undefined || name === undefined) throw new Error('Linux release checksum generation failed.'); + writeFileSync(join(artifactRoot, `${name}.sha256`), `${checksum} ${name}\n`); +} + +run('gpg', [ + '--armor', + '--batch', + '--yes', + '--digest-algo', + 'SHA256', + '--local-user', + signingKey, + '--detach-sign', + '--output', + join(artifactRoot, 'SHA256SUMS.asc'), + join(artifactRoot, 'SHA256SUMS'), +]); +writeFileSync( + join(artifactRoot, 'inflow-linux-signing-key.asc'), + runBuffer('gpg', ['--armor', '--batch', '--export', signingKey]), +); + +process.stdout.write(`Signed Linux release assets with ${actualFingerprint}.\n`); + +function packageVersion() { + const manifest = JSON.parse(readFileSync(join(repoRoot, 'packages/cli/package.json'), 'utf8')); + if (typeof manifest.version !== 'string') throw new Error('CLI package version is missing.'); + return manifest.version; +} + +function requiredEnvironment(name) { + const value = process.env[name]; + if (value === undefined || value.length === 0) throw new Error(`${name} is required.`); + return value; +} + +function normalizedFingerprint(value) { + const normalized = value.replaceAll(/\s/gu, '').toUpperCase(); + if (!/^[0-9A-F]{40,64}$/u.test(normalized)) throw new Error('Linux signing-key fingerprint is invalid.'); + return normalized; +} + +function sha256(path) { + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} + +function run(command, args) { + execFileSync(command, args, { cwd: repoRoot, stdio: 'inherit' }); +} + +function runBuffer(command, args) { + return execFileSync(command, args, { cwd: repoRoot }); +} + +function runText(command, args) { + return execFileSync(command, args, { cwd: repoRoot, encoding: 'utf8' }); +} diff --git a/scripts/smoke-linux-packaged-vault.mjs b/scripts/smoke-linux-packaged-vault.mjs new file mode 100644 index 0000000..3f1ed7c --- /dev/null +++ b/scripts/smoke-linux-packaged-vault.mjs @@ -0,0 +1,420 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import { + closeSync, + existsSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + readlinkSync, + readSync, + readdirSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import { createServer as createNetServer } from 'node:net'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const executable = resolve(process.env.INFLOW_PACKAGED_EXECUTABLE ?? join(repoRoot, 'dist/linux/bin/inflow')); +const testHome = mkdtempSync('/tmp/ifv-'); +const vaultRoot = join(testHome, '.local/share/inflow'); +const socketPath = join(vaultRoot, 'run/vault.sock'); +const passphrase = `package-smoke-${process.pid}`; +const apiKey = `inflow_package_smoke_${process.pid}`; +const environment = { + ...process.env, + HOME: testHome, + NO_UPDATE_NOTIFIER: '1', +}; + +if (process.platform !== 'linux') throw new Error('The packaged vault smoke requires Linux.'); + +let server; +let fakeServer; +try { + assertCliEmbedded(); + await expectTamperedNativeModuleRejected(); + await expectExitedPeerRejected(); + await expectFakeDaemonRejected(); + const endpoint = await startUserServer(); + + await runPty([executable, 'vault', 'unlock'], `${passphrase}\n`, 'Vault initialized and unlocked.'); + await expectJson([executable, 'vault', 'status', '--format', 'json'], { lock_state: 'unlocked' }); + await expectJson([executable, '--api-key', apiKey, '--base-url', endpoint, 'auth', 'login', '--format', 'json'], { + authenticated: true, + method: 'api_key', + }); + assertSecretsAbsentFromDaemonMemory([ + { label: 'unlock factor after store', value: passphrase }, + { label: 'stored credential after store', value: apiKey }, + ]); + await expectJson([executable, 'auth', 'status', '--format', 'json'], { + auth_method: 'api_key', + authenticated: true, + }); + assertSecretAbsentFromVaultFiles(apiKey); + assertSecretsAbsentFromDaemonMemory([ + { label: 'unlock factor after read', value: passphrase }, + { label: 'stored credential after read', value: apiKey }, + ]); + await expectCrossUserSocketRejected(); + await expectNodeClientRejected(); + + await expectJson([executable, 'vault', 'lock', '--format', 'json'], { locked: true }); + await expectFailure([executable, 'auth', 'status', '--format', 'json'], 'The InFlow vault is locked.'); + await runPty([executable, 'vault', 'unlock'], `${passphrase}\n`, 'Vault unlocked.'); + await expectJson([executable, 'auth', 'logout', '--format', 'json'], { authenticated: false }); + await waitFor(() => !pathExists(socketPath), 'The packaged vault daemon did not shut down after logout.'); + + process.stdout.write('Packaged Linux vault smoke passed.\n'); +} finally { + fakeServer?.close(); + server?.close(); + await runIgnoringFailure([executable, 'vault', 'reset', '--force', '--format', 'json']); + rmSync(testHome, { force: true, recursive: true }); +} + +async function expectFakeDaemonRejected() { + mkdirSync(dirname(socketPath), { mode: 0o700, recursive: true }); + rmSync(socketPath, { force: true }); + fakeServer = createNetServer(); + await new Promise((resolveListen, reject) => { + fakeServer.once('error', reject); + fakeServer.listen(socketPath, resolveListen); + }); + const command = [executable, 'vault', 'lock', '--format', 'json']; + const result = await run(command); + await new Promise((resolveClose) => fakeServer.close(resolveClose)); + fakeServer = undefined; + rmSync(socketPath, { force: true }); + if (result.code === 0 || !`${result.stdout}\n${result.stderr}`.includes('Vault peer verification failed.')) { + throw commandFailure(command, result); + } +} + +async function expectTamperedNativeModuleRejected() { + const nativeModule = vaultPeerNativeModule(); + const original = readFileSync(nativeModule); + const modified = Buffer.from(original); + modified[0] = (modified[0] ?? 0) ^ 0xff; + try { + writeFileSync(nativeModule, modified); + const result = await run([executable, '--daemon', 'vault']); + if (result.code === 0 || !result.stderr.includes('InFlow runtime integrity verification failed:')) { + throw commandFailure([executable, '--daemon', 'vault'], result); + } + } finally { + writeFileSync(nativeModule, original); + } +} + +async function expectExitedPeerRejected() { + const peerSocketPath = join(testHome, 'peer-exit.sock'); + const native = createRequire(import.meta.url)(vaultPeerNativeModule()); + const peerServer = createNetServer({ allowHalfOpen: true }); + let acceptedSocket; + const accepted = new Promise((resolveAccepted, reject) => { + peerServer.once('error', reject); + peerServer.once('connection', (socket) => { + acceptedSocket = socket; + resolveAccepted(); + }); + }); + await new Promise((resolveListen, reject) => { + peerServer.once('error', reject); + peerServer.listen(peerSocketPath, resolveListen); + }); + const child = spawn( + process.execPath, + [ + '-e', + "const net=require('node:net');net.createConnection(process.argv[1]);setInterval(()=>{},1000)", + peerSocketPath, + ], + { stdio: 'ignore' }, + ); + try { + await accepted; + const childClosed = new Promise((resolveClose, reject) => { + child.once('error', reject); + child.once('close', resolveClose); + }); + child.kill('SIGKILL'); + await childClosed; + const fd = acceptedSocket?._handle?.fd; + if (!Number.isSafeInteger(fd) || fd < 0) throw new Error('The accepted peer socket descriptor is unavailable.'); + let rejected = false; + try { + native.peerInfo(fd); + } catch { + rejected = true; + } + if (!rejected) throw new Error('The native verifier accepted an exited peer process.'); + } finally { + child.kill('SIGKILL'); + acceptedSocket?.destroy(); + await new Promise((resolveClose) => peerServer.close(resolveClose)); + rmSync(peerSocketPath, { force: true }); + } +} + +function assertCliEmbedded() { + const cliPath = join(dirname(realpathSync(executable)), '../lib/inflow/cli.standalone.mjs'); + if (existsSync(cliPath)) throw new Error('The packaged Linux CLI remains outside the executable.'); +} + +function vaultPeerNativeModule() { + return join(dirname(realpathSync(executable)), '../lib/inflow/native/vault_peer_linux.node'); +} + +function startUserServer() { + return new Promise((resolveEndpoint, reject) => { + server = createServer((request, response) => { + if (request.url !== '/v1/users/self' || request.headers['x-api-key'] !== apiKey) { + response.writeHead(401, { 'content-type': 'application/json' }); + response.end('{"code":"unauthorized"}'); + return; + } + response.writeHead(200, { 'content-type': 'application/json' }); + response.end('{"id":"packaged-smoke-user","email":"packaged-smoke@inflow.test"}'); + }); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (address === null || typeof address === 'string') { + reject(new Error('Could not determine the packaged vault smoke server address.')); + return; + } + resolveEndpoint(`http://127.0.0.1:${address.port}`); + }); + }); +} + +async function expectJson(command, expected) { + const result = await run(command); + if (result.code !== 0) throw commandFailure(command, result); + const value = JSON.parse(result.stdout); + const frames = Array.isArray(value) ? value : [value]; + const frame = frames.at(-1); + for (const [key, expectedValue] of Object.entries(expected)) { + if (frame?.[key] !== expectedValue) { + throw new Error(`Expected ${key}=${JSON.stringify(expectedValue)} from ${command.join(' ')}.`); + } + } +} + +async function expectFailure(command, message) { + const result = await run(command); + if (result.code === 0 || !`${result.stdout}\n${result.stderr}`.includes(message)) { + throw commandFailure(command, result); + } +} + +async function runPty(command, input, expectedOutput) { + const commandText = command.map(shellQuote).join(' '); + const result = await run( + ['/bin/sh', '-c', `printf '%s\\n' "$INFLOW_SMOKE_PASSPHRASE" | script -qec ${shellQuote(commandText)} /dev/null`], + { environment: { ...environment, INFLOW_SMOKE_PASSPHRASE: input.trimEnd() } }, + ); + if (result.code !== 0 || !`${result.stdout}\n${result.stderr}`.includes(expectedOutput)) { + throw commandFailure(command, result); + } +} + +async function expectNodeClientRejected() { + const source = [ + "import { LocalVaultClient } from './packages/core/dist/index.js';", + 'await new LocalVaultClient().status();', + ].join(''); + const result = await run([process.execPath, '--input-type=module', '-e', source]); + if (result.code === 0) throw new Error('The packaged vault accepted a Node client with a different executable.'); +} + +async function expectCrossUserSocketRejected() { + if (typeof process.getuid !== 'function' || process.getuid() !== 0) { + throw new Error('The cross-user vault smoke requires root.'); + } + const source = [ + "const net = require('node:net');", + 'const socket = net.createConnection(process.argv[1]);', + "socket.once('connect', () => process.exit(0));", + "socket.once('error', (cause) => {", + "process.stderr.write(`${cause.code ?? 'UNKNOWN'}\\n`);", + 'process.exit(2);', + '});', + ].join(''); + const result = await run([process.execPath, '-e', source, socketPath], { gid: 65534, uid: 65534 }); + if (result.code !== 2 || !result.stderr.includes('EACCES')) { + throw new Error(`A different user reached the packaged vault socket.\n${result.stderr}`); + } +} + +function assertSecretAbsentFromVaultFiles(secret) { + const secretBytes = Buffer.from(secret); + for (const file of files(vaultRoot)) { + if (readFileSync(file).includes(secretBytes)) { + throw new Error(`A plaintext credential was found in ${file}.`); + } + } +} + +function assertSecretsAbsentFromDaemonMemory(secrets) { + const pid = packagedDaemonPid(); + const memoryPath = `/proc/${pid}/mem`; + const descriptor = openSync(memoryPath, 'r'); + const sensitiveValues = secrets.map(({ label, value }) => ({ bytes: Buffer.from(value), label })); + const longestSecret = Math.max(...sensitiveValues.map(({ bytes }) => bytes.length)); + const chunk = Buffer.alloc(1024 * 1024 + longestSecret - 1); + try { + for (const [start, end, mapping] of readableMemoryRanges(pid)) { + let position = start; + let overlap = 0; + while (position < end) { + const requested = Math.min(1024 * 1024, end - position); + let bytesRead; + try { + bytesRead = readSync(descriptor, chunk, overlap, requested, position); + } catch (cause) { + if (cause?.code === 'EIO' || cause?.code === 'EFAULT') break; + throw cause; + } + if (bytesRead === 0) break; + const populated = overlap + bytesRead; + for (const { bytes, label } of sensitiveValues) { + const secretOffset = chunk.subarray(0, populated).indexOf(bytes); + if (secretOffset >= 0) { + const prefixOffset = secretOffset - 4; + const framed = prefixOffset >= 0 && chunk.readUInt32BE(prefixOffset) === bytes.byteLength; + const address = position - overlap + secretOffset; + throw new Error( + `The ${label} remained in daemon memory at process ${pid} address ${address.toString(16)} mapping ${mapping}; attachment frame: ${framed}.`, + ); + } + } + overlap = Math.min(longestSecret - 1, populated); + chunk.copyWithin(0, populated - overlap, populated); + chunk.fill(0, overlap, populated); + position += bytesRead; + } + chunk.fill(0); + } + } finally { + chunk.fill(0); + for (const { bytes } of sensitiveValues) bytes.fill(0); + closeSync(descriptor); + } +} + +function packagedDaemonPid() { + const expectedExecutable = realpathSync(executable); + const candidates = readdirSync('/proc') + .filter((name) => /^\d+$/.test(name)) + .map(Number) + .filter((pid) => { + try { + if (readlinkSync(`/proc/${pid}/exe`) !== expectedExecutable) return false; + const command = readFileSync(`/proc/${pid}/cmdline`); + return command.includes(Buffer.from('--daemon\0vault\0')); + } catch { + return false; + } + }); + if (candidates.length !== 1) { + throw new Error(`Expected one packaged vault daemon, found ${candidates.length}.`); + } + return candidates[0]; +} + +function readableMemoryRanges(pid) { + return readFileSync(`/proc/${pid}/maps`, 'utf8') + .split('\n') + .flatMap((line) => { + const match = /^([0-9a-f]+)-([0-9a-f]+)\s+(r...)\s/.exec(line); + if (match === null) return []; + const start = Number.parseInt(match[1], 16); + const end = Number.parseInt(match[2], 16); + return Number.isSafeInteger(start) && Number.isSafeInteger(end) && end > start ? [[start, end, line]] : []; + }); +} + +function files(root) { + if (!pathExists(root)) return []; + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const path = join(root, entry.name); + if (entry.isDirectory()) return files(path); + return entry.isFile() ? [path] : []; + }); +} + +function pathExists(path) { + try { + statSync(path); + return true; + } catch (cause) { + if (cause?.code === 'ENOENT') return false; + throw cause; + } +} + +async function waitFor(predicate, timeoutMessage) { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolveWait) => setTimeout(resolveWait, 25)); + } + throw new Error(timeoutMessage); +} + +function run(command, options = {}) { + return new Promise((resolveRun, reject) => { + const child = spawn(command[0], command.slice(1), { + cwd: repoRoot, + env: options.environment ?? environment, + ...(options.gid === undefined ? {} : { gid: options.gid }), + stdio: ['ignore', 'pipe', 'pipe'], + ...(options.uid === undefined ? {} : { uid: options.uid }), + }); + const stdout = []; + const stderr = []; + child.stdout.on('data', (chunk) => stdout.push(chunk)); + child.stderr.on('data', (chunk) => stderr.push(chunk)); + child.once('error', reject); + child.once('close', (code) => { + resolveRun({ + code: code ?? 1, + stderr: Buffer.concat(stderr).toString('utf8'), + stdout: Buffer.concat(stdout).toString('utf8'), + }); + }); + }); +} + +async function runIgnoringFailure(command) { + try { + await run(command); + } catch { + return; + } +} + +function shellQuote(value) { + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} + +function commandFailure(command, result) { + return new Error( + [ + `Command failed: ${command.join(' ')}`, + `exit: ${result.code}`, + `stdout: ${result.stdout.trim()}`, + `stderr: ${result.stderr.trim()}`, + ].join('\n'), + ); +} diff --git a/scripts/smoke-linux-system-vault.mjs b/scripts/smoke-linux-system-vault.mjs new file mode 100644 index 0000000..59d47b2 --- /dev/null +++ b/scripts/smoke-linux-system-vault.mjs @@ -0,0 +1,396 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import { + closeSync, + mkdtempSync, + openSync, + readFileSync, + readlinkSync, + readSync, + readdirSync, + realpathSync, + rmSync, + statSync, +} from 'node:fs'; +import { createServer } from 'node:http'; + +const executable = realpathSync(process.env.INFLOW_PACKAGED_EXECUTABLE ?? '/usr/bin/inflow'); +const upgradePackage = process.env.INFLOW_UPGRADE_PACKAGE; +const downgradePackage = process.env.INFLOW_DOWNGRADE_PACKAGE; +const unlockOnly = process.env.INFLOW_UNLOCK_ONLY === '1'; +const testHome = mkdtempSync('/tmp/inflow-system-vault-'); +const passphrase = `system-vault-factor-${process.pid}`; +const apiKey = `system_vault_api_key_${process.pid}`; +const environment = { ...process.env, HOME: testHome, NO_UPDATE_NOTIFIER: '1' }; +const secretFiles = ['/var/lib/inflow', '/var/lib/inflow-broker']; + +if (process.platform !== 'linux' || process.getuid?.() !== 0) { + throw new Error('The Linux system-vault smoke requires Linux root.'); +} +if ((upgradePackage === undefined) !== (downgradePackage === undefined)) { + throw new Error('Upgrade and downgrade package paths must be provided together.'); +} + +let server; +try { + await assertSystemService(); + const endpoint = await startUserServer(); + await runPty([executable, 'vault', 'unlock'], passphrase, 'Vault initialized and unlocked.'); + assertRuntimeSecurity(); + assertSecretsAbsentFromFiles(); + assertSecretsAbsentFromServiceMemory(); + if (unlockOnly) { + process.stdout.write('Packaged Linux system vault unlock security smoke passed.\n'); + } else { + await expectJson([executable, '--api-key', apiKey, '--base-url', endpoint, 'auth', 'login', '--format', 'json'], { + authenticated: true, + method: 'api_key', + }); + await expectJson([executable, 'auth', 'status', '--format', 'json'], { + auth_method: 'api_key', + authenticated: true, + }); + assertRuntimeSecurity(); + assertSecretsAbsentFromFiles(); + assertSecretsAbsentFromServiceMemory(); + + if (upgradePackage !== undefined && downgradePackage !== undefined) { + await verifyPackageReplacement(upgradePackage, 'upgrade'); + await verifyPackageReplacement(downgradePackage, 'downgrade'); + } + + process.stdout.write('Packaged Linux system vault security smoke passed.\n'); + } +} finally { + server?.close(); + rmSync(testHome, { force: true, recursive: true }); +} + +async function verifyPackageReplacement(packagePath, direction) { + const installedVersion = (await runChecked(['dpkg-query', '-W', '-f=${Version}', 'inflow'])).stdout.trim(); + const replacementVersion = (await runChecked(['dpkg-deb', '-f', packagePath, 'Version'])).stdout.trim(); + const comparison = direction === 'upgrade' ? 'gt' : 'lt'; + const comparisonResult = await run(['dpkg', '--compare-versions', replacementVersion, comparison, installedVersion]); + if (comparisonResult.code !== 0) { + throw new Error( + `Expected ${direction} package ${replacementVersion} to be ${comparison} installed ${installedVersion}.`, + ); + } + + const brokerKey = readFileSync('/var/lib/inflow-broker/public.der'); + const oldBrokerPid = serviceProcess('vault-broker'); + await runChecked(['dpkg', '-i', packagePath]); + await expectJson([executable, 'vault', 'status', '--format', 'json'], { lock_state: 'locked' }); + const newBrokerPid = serviceProcess('vault-broker'); + if (newBrokerPid === oldBrokerPid) throw new Error(`The vault broker did not restart during package ${direction}.`); + if (!brokerKey.equals(readFileSync('/var/lib/inflow-broker/public.der'))) { + throw new Error(`The broker machine identity changed during package ${direction}.`); + } + + await runPty([executable, 'vault', 'unlock'], passphrase, 'Vault unlocked.'); + await expectJson([executable, 'auth', 'status', '--format', 'json'], { + auth_method: 'api_key', + authenticated: true, + }); + assertRuntimeSecurity(); + assertSecretsAbsentFromFiles(); + assertSecretsAbsentFromServiceMemory(); +} + +async function assertSystemService() { + await runChecked(['systemctl', 'is-active', 'inflow-vault.socket']); + await expectJson([executable, 'vault', 'lock', '--format', 'json'], { locked: true }); + const socket = statSync('/run/inflow/vault.sock'); + if (!socket.isSocket() || socket.uid !== 0 || (socket.mode & 0o777) !== 0o666) { + throw new Error('The system vault socket identity or permissions are invalid.'); + } +} + +function assertRuntimeSecurity() { + const brokerPid = serviceProcess('vault-broker'); + const vaultPid = serviceProcess('vault-service'); + const brokerStatus = readFileSync(`/proc/${brokerPid}/status`, 'utf8'); + const vaultStatus = readFileSync(`/proc/${vaultPid}/status`, 'utf8'); + expectStatusValue(brokerStatus, 'CapEff', '00000000000800c0'); + expectStatusValue(brokerStatus, 'NoNewPrivs', '1'); + expectStatusValue(vaultStatus, 'CapEff', '0000000000000000'); + expectStatusValue(vaultStatus, 'CapPrm', '0000000000000000'); + expectStatusValue(vaultStatus, 'NoNewPrivs', '1'); + + for (const descriptor of readdirSync(`/proc/${brokerPid}/fd`)) { + let target; + try { + target = readlinkSync(`/proc/${brokerPid}/fd/${descriptor}`); + } catch { + continue; + } + if (target.startsWith('/var/lib/inflow/vaults/')) { + throw new Error(`The authentication broker opened tenant vault state: ${target}`); + } + } +} + +function expectStatusValue(status, name, expected) { + const match = new RegExp(`^${name}:\\s*(\\S+)`, 'm').exec(status); + if (match?.[1] !== expected) throw new Error(`Expected ${name}=${expected}, received ${match?.[1] ?? 'missing'}.`); +} + +function assertSecretsAbsentFromFiles() { + const secrets = sensitiveValues(); + try { + for (const root of secretFiles) { + for (const file of files(root)) { + const contents = readFileSync(file); + for (const { bytes, label } of secrets) { + if (contents.includes(bytes)) throw new Error(`The ${label} was found in plaintext in ${file}.`); + } + } + } + } finally { + for (const { bytes } of secrets) bytes.fill(0); + } +} + +function assertSecretsAbsentFromServiceMemory() { + if (readFileSync('/proc/swaps', 'utf8').trim().split('\n').length !== 1) { + throw new Error('The plaintext memory scan requires swap to be disabled.'); + } + const secrets = sensitiveValues(); + try { + for (const mode of ['vault-broker', 'vault-service']) { + scanProcessMemory(serviceProcess(mode), mode, secrets); + } + } finally { + for (const { bytes } of secrets) bytes.fill(0); + } +} + +function scanProcessMemory(pid, mode, secrets) { + const descriptor = openSync(`/proc/${pid}/mem`, 'r'); + const longestSecret = Math.max(...secrets.map(({ bytes }) => bytes.byteLength)); + const chunk = Buffer.alloc(1024 * 1024 + longestSecret - 1); + try { + for (const [start, end, mapping] of residentWritableMemoryRanges(pid)) { + let position = start; + let overlap = 0; + while (position < end) { + const requested = Math.min(1024 * 1024, end - position); + let bytesRead; + try { + bytesRead = readSync(descriptor, chunk, overlap, requested, position); + } catch (cause) { + if (cause?.code === 'EIO' || cause?.code === 'EFAULT') break; + throw cause; + } + if (bytesRead === 0) break; + const populated = overlap + bytesRead; + for (const { bytes, label } of secrets) { + const offset = chunk.subarray(0, populated).indexOf(bytes); + if (offset >= 0) { + throw new Error( + `The ${label} remained in ${mode} memory at ${(position - overlap + offset).toString(16)} (${mapping}).`, + ); + } + } + overlap = Math.min(longestSecret - 1, populated); + chunk.copyWithin(0, populated - overlap, populated); + chunk.fill(0, overlap, populated); + position += bytesRead; + } + chunk.fill(0); + } + } finally { + chunk.fill(0); + closeSync(descriptor); + } +} + +function residentWritableMemoryRanges(pid) { + const pageSizeMatch = /^KernelPageSize:\s+(\d+)\s+kB$/m.exec(readFileSync(`/proc/${pid}/smaps`, 'utf8')); + const pageSize = Number.parseInt(pageSizeMatch?.[1] ?? '', 10) * 1024; + if (!Number.isSafeInteger(pageSize) || pageSize <= 0) throw new Error('The Linux page size is unavailable.'); + const mappings = readFileSync(`/proc/${pid}/maps`, 'utf8') + .split('\n') + .flatMap((line) => { + const header = /^([0-9a-f]+)-([0-9a-f]+)\s+(rw..)\s/.exec(line); + if (header === null) return []; + const start = Number.parseInt(header[1], 16); + const end = Number.parseInt(header[2], 16); + return Number.isSafeInteger(start) && Number.isSafeInteger(end) && end > start ? [{ end, line, start }] : []; + }); + const ranges = []; + const pagemap = openSync(`/proc/${pid}/pagemap`, 'r'); + const presentBit = 1n << 63n; + try { + for (const mapping of mappings) { + const pageCount = Math.ceil((mapping.end - mapping.start) / pageSize); + const entries = Buffer.alloc(pageCount * 8); + const bytesRead = readSync(pagemap, entries, 0, entries.byteLength, (mapping.start / pageSize) * 8); + if (bytesRead !== entries.byteLength) throw new Error('The Linux pagemap is truncated.'); + let runStart; + for (let page = 0; page < pageCount; page += 1) { + const address = mapping.start + page * pageSize; + const present = (entries.readBigUInt64LE(page * 8) & presentBit) !== 0n; + if (present && runStart === undefined) runStart = address; + if (!present && runStart !== undefined) { + ranges.push([runStart, address, mapping.line]); + runStart = undefined; + } + } + if (runStart !== undefined) ranges.push([runStart, mapping.end, mapping.line]); + entries.fill(0); + } + } finally { + closeSync(pagemap); + } + return ranges; +} + +function serviceProcess(mode) { + const candidates = readdirSync('/proc') + .filter((name) => /^\d+$/.test(name)) + .map(Number) + .filter((pid) => { + try { + if (readlinkSync(`/proc/${pid}/exe`) !== executable) return false; + return readFileSync(`/proc/${pid}/cmdline`).includes(Buffer.from(`--daemon\0${mode}\0`)); + } catch { + return false; + } + }); + if (candidates.length !== 1) throw new Error(`Expected one ${mode} process, found ${candidates.length}.`); + return candidates[0]; +} + +function sensitiveValues() { + return [ + ...sensitiveRepresentations(passphrase, 'unlock factor'), + ...sensitiveRepresentations(apiKey, 'stored credential'), + ]; +} + +function sensitiveRepresentations(value, label) { + const bytes = Buffer.from(value, 'utf8'); + return [ + { bytes, label }, + { bytes: Buffer.from(bytes.toString('base64'), 'ascii'), label: `Base64-encoded ${label}` }, + { bytes: Buffer.from(bytes.toString('hex'), 'ascii'), label: `hexadecimal ${label}` }, + { bytes: Buffer.from(value, 'utf16le'), label: `UTF-16 ${label}` }, + ]; +} + +function files(root) { + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const entryPath = `${root}/${entry.name}`; + if (entry.isDirectory()) return files(entryPath); + return entry.isFile() ? [entryPath] : []; + }); +} + +function startUserServer() { + return new Promise((resolveEndpoint, reject) => { + server = createServer((request, response) => { + if (request.url !== '/v1/users/self' || request.headers['x-api-key'] !== apiKey) { + response.writeHead(401, { 'content-type': 'application/json' }); + response.end('{"code":"unauthorized"}'); + return; + } + response.writeHead(200, { 'content-type': 'application/json' }); + response.end('{"id":"system-vault-user","email":"system-vault@inflow.test"}'); + }); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (address === null || typeof address === 'string') { + reject(new Error('Could not determine the system-vault smoke server address.')); + return; + } + resolveEndpoint(`http://127.0.0.1:${address.port}`); + }); + }); +} + +async function expectJson(command, expected) { + const result = await runChecked(command); + const value = JSON.parse(result.stdout); + const frame = (Array.isArray(value) ? value : [value]).at(-1); + for (const [key, expectedValue] of Object.entries(expected)) { + if (frame?.[key] !== expectedValue) { + throw new Error(`Expected ${key}=${JSON.stringify(expectedValue)} from ${command.join(' ')}.`); + } + } +} + +async function runPty(command, input, expectedOutput) { + const commandText = command.map(shellQuote).join(' '); + const result = await new Promise((resolveRun, reject) => { + const child = spawn('script', ['-qec', commandText, '/dev/null'], { + env: environment, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const stdout = []; + const stderr = []; + let answered = false; + child.stdout.on('data', (chunk) => { + stdout.push(chunk); + if (!answered && Buffer.concat(stdout).includes(Buffer.from('passphrase: '))) { + answered = true; + child.stdin.end(`${input}\n`); + } + }); + child.stderr.on('data', (chunk) => stderr.push(chunk)); + child.once('error', reject); + child.once('close', (code) => { + resolveRun({ + code: code ?? 1, + stderr: Buffer.concat(stderr).toString('utf8'), + stdout: Buffer.concat(stdout).toString('utf8'), + }); + }); + }); + if (result.code !== 0 || !`${result.stdout}\n${result.stderr}`.includes(expectedOutput)) { + throw commandFailure(command, result); + } +} + +async function runChecked(command) { + const result = await run(command); + if (result.code !== 0) throw commandFailure(command, result); + return result; +} + +function run(command, childEnvironment = environment) { + return new Promise((resolveRun, reject) => { + const child = spawn(command[0], command.slice(1), { + env: childEnvironment, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout = []; + const stderr = []; + child.stdout.on('data', (chunk) => stdout.push(chunk)); + child.stderr.on('data', (chunk) => stderr.push(chunk)); + child.once('error', reject); + child.once('close', (code) => { + resolveRun({ + code: code ?? 1, + stderr: Buffer.concat(stderr).toString('utf8'), + stdout: Buffer.concat(stdout).toString('utf8'), + }); + }); + }); +} + +function shellQuote(value) { + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} + +function commandFailure(command, result) { + return new Error( + [ + `Command failed: ${command.join(' ')}`, + `exit: ${result.code}`, + `stdout: ${result.stdout.trim()}`, + `stderr: ${result.stderr.trim()}`, + ].join('\n'), + ); +} diff --git a/scripts/smoke-macos-packaged-vault.mjs b/scripts/smoke-macos-packaged-vault.mjs new file mode 100644 index 0000000..bd061c3 --- /dev/null +++ b/scripts/smoke-macos-packaged-vault.mjs @@ -0,0 +1,333 @@ +#!/usr/bin/env node +import { spawn, spawnSync } from 'node:child_process'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { createServer } from 'node:http'; +import { createServer as createNetServer } from 'node:net'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const executable = resolve(process.env.INFLOW_PACKAGED_EXECUTABLE ?? join(repoRoot, 'dist/macos/bin/inflow')); +const testHome = mkdtempSync('/private/tmp/ifv-'); +const vaultRoot = join(testHome, 'Library/Application Support/InFlow'); +const socketPath = join(vaultRoot, 'run/vault.sock'); +const passphrase = `package-smoke-${process.pid}`; +const apiKey = `inflow_package_smoke_${process.pid}`; +const environment = { + ...process.env, + HOME: testHome, + NO_UPDATE_NOTIFIER: '1', +}; + +if (process.platform !== 'darwin') throw new Error('The packaged vault smoke requires macOS.'); + +let server; +let fakeServer; +try { + await requireDeveloperIdSignature(); + await expectTamperedNativeModuleRejected(); + await expectFakeDaemonRejected(); + const endpoint = await startUserServer(); + + await runPty([executable, 'vault', 'unlock'], `${passphrase}\n`, 'Vault initialized and unlocked.'); + await expectJson([executable, 'vault', 'status', '--format', 'json'], { lock_state: 'unlocked' }); + + await expectJson([executable, '--api-key', apiKey, '--base-url', endpoint, 'auth', 'login', '--format', 'json'], { + authenticated: true, + method: 'api_key', + }); + await expectJson([executable, 'auth', 'status', '--format', 'json'], { + auth_method: 'api_key', + authenticated: true, + }); + assertSecretsAbsentFromVaultFiles(); + const daemonPid = await expectTaskMemoryReadRejected(); + await expectUnsignedClientRejected(); + + await expectJson([executable, 'vault', 'lock', '--format', 'json'], { locked: true }); + await expectFailure([executable, 'auth', 'status', '--format', 'json'], 'The InFlow vault is locked.'); + await runPty([executable, 'vault', 'unlock'], `${passphrase}\n`, 'Vault unlocked.'); + await expectJson([executable, 'auth', 'logout', '--format', 'json'], { authenticated: false }); + await waitFor(() => !pathExists(socketPath), 'The packaged vault daemon did not shut down after logout.'); + await waitFor(() => !processExists(daemonPid), 'The packaged vault daemon process did not exit after logout.'); + + process.stdout.write('Packaged macOS vault smoke passed.\n'); +} finally { + fakeServer?.close(); + server?.close(); + await runIgnoringFailure([executable, 'vault', 'reset', '--force', '--format', 'json']); + rmSync(testHome, { force: true, recursive: true }); +} + +async function expectFakeDaemonRejected() { + mkdirSync(dirname(socketPath), { mode: 0o700, recursive: true }); + rmSync(socketPath, { force: true }); + fakeServer = createNetServer(); + await new Promise((resolveListen, reject) => { + fakeServer.once('error', reject); + fakeServer.listen(socketPath, resolveListen); + }); + const command = [executable, 'vault', 'lock', '--format', 'json']; + const result = await run(command); + await new Promise((resolveClose) => fakeServer.close(resolveClose)); + fakeServer = undefined; + rmSync(socketPath, { force: true }); + if (result.code === 0 || !`${result.stdout}\n${result.stderr}`.includes('Vault peer verification failed.')) { + throw commandFailure(command, result); + } +} + +async function requireDeveloperIdSignature() { + const signature = await run(['/usr/bin/codesign', '-dvv', executable]); + const entitlements = await run(['/usr/bin/codesign', '-d', '--entitlements', '-', executable]); + if ( + !signature.stderr.includes('TeamIdentifier=B96U57DTR2') || + !signature.stderr.includes('flags=0x10000(runtime)') || + entitlements.stdout.includes('com.apple.security.get-task-allow') || + entitlements.stderr.includes('com.apple.security.get-task-allow') + ) { + throw new Error('The packaged vault smoke requires a hardened InFlow Developer-ID-signed executable.'); + } +} + +async function expectTamperedNativeModuleRejected() { + const nativeModule = join(dirname(realpathSync(executable)), '../Resources/app/native/vault_peer_darwin.node'); + const original = readFileSync(nativeModule); + const modified = Buffer.from(original); + modified[0] = (modified[0] ?? 0) ^ 0xff; + try { + writeFileSync(nativeModule, modified); + const result = await run([executable, '--daemon', 'vault']); + if (result.code === 0 || !result.stderr.includes('Vault native module verification failed.')) { + throw commandFailure([executable, '--daemon', 'vault'], result); + } + } finally { + writeFileSync(nativeModule, original); + } +} + +function startUserServer() { + return new Promise((resolveEndpoint, reject) => { + server = createServer((request, response) => { + if (request.url !== '/v1/users/self' || request.headers['x-api-key'] !== apiKey) { + response.writeHead(401, { 'content-type': 'application/json' }); + response.end('{"code":"unauthorized"}'); + return; + } + response.writeHead(200, { 'content-type': 'application/json' }); + response.end('{"id":"packaged-smoke-user","email":"packaged-smoke@inflow.test"}'); + }); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (address === null || typeof address === 'string') { + reject(new Error('Could not determine the packaged vault smoke server address.')); + return; + } + resolveEndpoint(`http://127.0.0.1:${address.port}`); + }); + }); +} + +async function expectJson(command, expected) { + const result = await run(command); + if (result.code !== 0) throw commandFailure(command, result); + const value = JSON.parse(result.stdout); + const frames = Array.isArray(value) ? value : [value]; + const frame = frames.at(-1); + for (const [key, expectedValue] of Object.entries(expected)) { + if (frame?.[key] !== expectedValue) { + throw new Error(`Expected ${key}=${JSON.stringify(expectedValue)} from ${command.join(' ')}.`); + } + } +} + +async function expectFailure(command, message) { + const result = await run(command); + if (result.code === 0 || !`${result.stdout}\n${result.stderr}`.includes(message)) { + throw commandFailure(command, result); + } +} + +async function runPty(command, input, expectedOutput) { + const result = await run([ + '/bin/sh', + '-c', + 'passphrase=$1; shift; printf "%s\\n" "$passphrase" | /usr/bin/script -q /dev/null "$@"', + 'inflow-packaged-vault-smoke', + input.trimEnd(), + ...command, + ]); + if (result.code !== 0 || !`${result.stdout}\n${result.stderr}`.includes(expectedOutput)) { + throw commandFailure(command, result); + } +} + +async function expectUnsignedClientRejected() { + const source = [ + "import { LocalVaultClient } from './packages/core/dist/index.js';", + 'await new LocalVaultClient().status();', + ].join(''); + const result = await run([process.execPath, '--input-type=module', '-e', source]); + if (result.code === 0) throw new Error('The packaged vault accepted an unsigned Node client.'); +} + +async function expectTaskMemoryReadRejected() { + const probe = join(testHome, 'macos-task-memory-probe'); + const compile = await run([ + '/usr/bin/clang', + '-Wall', + '-Wextra', + '-Werror', + '-o', + probe, + join(repoRoot, 'scripts/macos-task-memory-probe.c'), + ]); + if (compile.code !== 0) throw commandFailure(['/usr/bin/clang'], compile); + const daemonPid = await packagedDaemonPid(); + const result = await run([probe, `${daemonPid}`]); + if (result.code !== 0) { + throw new Error(`A same-user process obtained access to packaged daemon memory (probe exit ${result.code}).`); + } + return daemonPid; +} + +async function packagedDaemonPid() { + const result = await run(['/bin/ps', '-axo', 'pid=,command=']); + if (result.code !== 0) throw commandFailure(['/bin/ps'], result); + const daemonCommand = `${realpathSync(executable)} --daemon vault`; + const candidates = result.stdout + .split('\n') + .map((line) => /^\s*(\d+)\s+(.+)$/.exec(line)) + .filter((match) => match !== null && match[2] === daemonCommand) + .map((match) => Number(match[1])) + .filter((pid) => processHasOpenPath(pid, socketPath)); + if (candidates.length !== 1) { + throw new Error(`Expected one packaged vault daemon, found ${candidates.length}.`); + } + return candidates[0]; +} + +function processHasOpenPath(pid, filePath) { + const result = spawnSync('/usr/sbin/lsof', ['-a', '-p', `${pid}`, '-Fn', filePath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + return result.status === 0 && result.stdout.split('\n').includes(`n${filePath}`); +} + +function processExists(pid) { + try { + process.kill(pid, 0); + return true; + } catch (cause) { + if (cause?.code === 'ESRCH') return false; + throw cause; + } +} + +function assertSecretsAbsentFromVaultFiles() { + const secrets = [ + ...sensitiveRepresentations(passphrase, 'unlock factor'), + ...sensitiveRepresentations(apiKey, 'stored credential'), + ]; + try { + for (const file of files(vaultRoot)) { + const contents = readFileSync(file); + for (const { bytes, label } of secrets) { + if (contents.includes(bytes)) throw new Error(`The ${label} was found in ${file}.`); + } + } + } finally { + for (const { bytes } of secrets) bytes.fill(0); + } +} + +function sensitiveRepresentations(value, label) { + const bytes = Buffer.from(value, 'utf8'); + return [ + { bytes, label }, + { bytes: Buffer.from(bytes.toString('base64'), 'ascii'), label: `Base64-encoded ${label}` }, + { bytes: Buffer.from(bytes.toString('hex'), 'ascii'), label: `hexadecimal ${label}` }, + { bytes: Buffer.from(value, 'utf16le'), label: `UTF-16 ${label}` }, + ]; +} + +function files(root) { + if (!pathExists(root)) return []; + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const path = join(root, entry.name); + if (entry.isDirectory()) return files(path); + return entry.isFile() ? [path] : []; + }); +} + +function pathExists(path) { + try { + statSync(path); + return true; + } catch (cause) { + if (cause?.code === 'ENOENT') return false; + throw cause; + } +} + +async function waitFor(predicate, timeoutMessage) { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolveWait) => setTimeout(resolveWait, 25)); + } + throw new Error(timeoutMessage); +} + +function run(command, options = {}) { + return new Promise((resolveRun, reject) => { + const child = spawn(command[0], command.slice(1), { + cwd: repoRoot, + env: environment, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const stdout = []; + const stderr = []; + child.stdout.on('data', (chunk) => stdout.push(chunk)); + child.stderr.on('data', (chunk) => stderr.push(chunk)); + child.once('error', reject); + child.once('close', (code) => { + resolveRun({ + code: code ?? 1, + stderr: Buffer.concat(stderr).toString('utf8'), + stdout: Buffer.concat(stdout).toString('utf8'), + }); + }); + child.stdin.end(options.input); + }); +} + +async function runIgnoringFailure(command) { + try { + await run(command); + } catch { + return; + } +} + +function commandFailure(command, result) { + return new Error( + [ + `Command failed: ${command.join(' ')}`, + `exit: ${result.code}`, + `stdout: ${result.stdout.trim()}`, + `stderr: ${result.stderr.trim()}`, + ].join('\n'), + ); +} diff --git a/scripts/smoke-macos-vault-upgrade.mjs b/scripts/smoke-macos-vault-upgrade.mjs new file mode 100644 index 0000000..6c82653 --- /dev/null +++ b/scripts/smoke-macos-vault-upgrade.mjs @@ -0,0 +1,282 @@ +#!/usr/bin/env node +import { spawn, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createServer } from 'node:http'; +import { mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const previousApp = requiredPath('INFLOW_PREVIOUS_PACKAGED_APP'); +const currentApp = requiredPath('INFLOW_CURRENT_PACKAGED_APP'); +const testHome = mkdtempSync('/private/tmp/ifv-transition-'); +const installedApp = join(testHome, 'Applications/InFlow.app'); +const executable = join(installedApp, 'Contents/MacOS/inflow'); +const vaultRoot = join(testHome, 'Library/Application Support/InFlow'); +const socketPath = join(vaultRoot, 'run/vault.sock'); +const passphrase = `transition-passphrase-${process.pid}`; +const apiKey = `inflow_transition_key_${process.pid}`; +const environment = { + ...process.env, + HOME: testHome, + NO_UPDATE_NOTIFIER: '1', +}; + +let server; +try { + requireDistinctSignedApps(); + installApp(previousApp); + const endpoint = await startUserServer(); + + await runPty([executable, 'vault', 'unlock'], passphrase, 'Vault initialized and unlocked.'); + await expectJson( + [executable, '--api-key', apiKey, '--base-url', endpoint, 'auth', 'login', '--format', 'json'], + { authenticated: true, method: 'api_key' }, + ); + await expectAuthenticated(); + const previousPid = packagedDaemonPid(); + + installApp(currentApp); + await expectFailure([executable, 'auth', 'status', '--format', 'json'], 'The InFlow vault is locked.'); + await waitFor(() => !processExists(previousPid), 'The previous daemon remained alive after upgrade.'); + const currentPid = packagedDaemonPid(); + await runPty([executable, 'vault', 'unlock'], passphrase, 'Vault unlocked.'); + await expectAuthenticated(); + await expectLockUnlock(); + assertSecretsAbsentFromVaultFiles(); + + installApp(previousApp); + await expectJson([executable, 'vault', 'lock', '--format', 'json'], { locked: true }); + await waitFor(() => !processExists(currentPid), 'The current daemon remained alive after downgrade.'); + const downgradedPid = packagedDaemonPid(); + await runPty([executable, 'vault', 'unlock'], passphrase, 'Vault unlocked.'); + await expectAuthenticated(); + await expectLockUnlock(); + + installApp(currentApp); + await expectFailure([executable, 'auth', 'status', '--format', 'json'], 'The InFlow vault is locked.'); + await waitFor(() => !processExists(downgradedPid), 'The downgraded daemon remained alive after re-upgrade.'); + const finalPid = packagedDaemonPid(); + await runPty([executable, 'vault', 'unlock'], passphrase, 'Vault unlocked.'); + await expectAuthenticated(); + await expectJson([executable, 'auth', 'logout', '--format', 'json'], { authenticated: false }); + await waitFor(() => !processExists(finalPid), 'The final daemon remained alive after logout.'); + await waitFor(() => !pathExists(socketPath), 'The final daemon socket remained after logout.'); + + process.stdout.write('Packaged macOS vault upgrade and downgrade smoke passed.\n'); +} finally { + server?.close(); + await runIgnoringFailure([executable, 'vault', 'reset', '--force', '--format', 'json']); + rmSync(testHome, { force: true, recursive: true }); +} + +function requiredPath(name) { + const value = process.env[name]; + if (value === undefined) throw new Error(`${name} is required.`); + return realpathSync(resolve(value)); +} + +function requireDistinctSignedApps() { + const executables = [previousApp, currentApp].map((app) => join(app, 'Contents/MacOS/inflow')); + const digests = executables.map((path) => createHash('sha256').update(readFileSync(path)).digest('hex')); + if (digests[0] === digests[1]) throw new Error('Transition executables must have different build identities.'); + for (const path of executables) { + const result = spawnSync('/usr/bin/codesign', ['-dvv', path], { encoding: 'utf8' }); + if (result.status !== 0 || !result.stderr.includes('TeamIdentifier=B96U57DTR2')) { + throw new Error(`The transition executable is not signed by the expected team: ${path}`); + } + } +} + +function installApp(source) { + rmSync(installedApp, { force: true, recursive: true }); + const result = spawnSync('/usr/bin/ditto', [source, installedApp], { encoding: 'utf8' }); + if (result.status !== 0) throw new Error(`Could not install ${source}: ${result.stderr}`); +} + +function startUserServer() { + return new Promise((resolveEndpoint, reject) => { + server = createServer((request, response) => { + if (request.url !== '/v1/users/self' || request.headers['x-api-key'] !== apiKey) { + response.writeHead(401, { 'content-type': 'application/json' }); + response.end('{"code":"unauthorized"}'); + return; + } + response.writeHead(200, { 'content-type': 'application/json' }); + response.end('{"id":"transition-user","email":"transition@inflow.test"}'); + }); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (address === null || typeof address === 'string') { + reject(new Error('Could not determine the transition server address.')); + return; + } + resolveEndpoint(`http://127.0.0.1:${address.port}`); + }); + }); +} + +async function expectAuthenticated() { + await expectJson([executable, 'auth', 'status', '--format', 'json'], { + auth_method: 'api_key', + authenticated: true, + }); +} + +async function expectLockUnlock() { + await expectJson([executable, 'vault', 'lock', '--format', 'json'], { locked: true }); + await expectFailure([executable, 'auth', 'status', '--format', 'json'], 'The InFlow vault is locked.'); + await runPty([executable, 'vault', 'unlock'], passphrase, 'Vault unlocked.'); + await expectAuthenticated(); +} + +async function expectJson(command, expected) { + const result = await run(command); + if (result.code !== 0) throw commandFailure(command, result); + const value = JSON.parse(result.stdout); + const frame = (Array.isArray(value) ? value : [value]).at(-1); + for (const [key, expectedValue] of Object.entries(expected)) { + if (frame?.[key] !== expectedValue) { + throw new Error(`Expected ${key}=${JSON.stringify(expectedValue)} from ${command.join(' ')}.`); + } + } +} + +async function expectFailure(command, message) { + const result = await run(command); + if (result.code === 0 || !`${result.stdout}\n${result.stderr}`.includes(message)) { + throw commandFailure(command, result); + } +} + +async function runPty(command, input, expectedOutput) { + const result = await run([ + '/bin/sh', + '-c', + 'passphrase=$1; shift; printf "%s\\n" "$passphrase" | /usr/bin/script -q /dev/null "$@"', + 'inflow-macos-transition-smoke', + input, + ...command, + ]); + if (result.code !== 0 || !`${result.stdout}\n${result.stderr}`.includes(expectedOutput)) { + throw commandFailure(command, result); + } +} + +function packagedDaemonPid() { + const result = spawnSync('/bin/ps', ['-axo', 'pid=,command='], { encoding: 'utf8' }); + if (result.status !== 0) throw new Error(`Could not inspect daemon processes for ${executable}.`); + const daemonCommand = `${realpathSync(executable)} --daemon vault`; + const candidates = result.stdout + .split('\n') + .map((line) => /^\s*(\d+)\s+(.+)$/.exec(line)) + .filter((match) => match !== null && match[2] === daemonCommand) + .map((match) => Number(match[1])) + .filter((pid) => processHasOpenPath(pid, socketPath)); + if (candidates.length !== 1) throw new Error(`Expected one daemon for ${executable}, found ${candidates.length}.`); + return candidates[0]; +} + +function processHasOpenPath(pid, filePath) { + const result = spawnSync('/usr/sbin/lsof', ['-a', '-p', `${pid}`, '-Fn', filePath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + return result.status === 0 && result.stdout.split('\n').includes(`n${filePath}`); +} + +function processExists(pid) { + try { + process.kill(pid, 0); + return true; + } catch (cause) { + if (cause?.code === 'ESRCH') return false; + throw cause; + } +} + +function assertSecretsAbsentFromVaultFiles() { + const representations = [passphrase, apiKey].flatMap((value) => { + const bytes = Buffer.from(value, 'utf8'); + return [ + bytes, + Buffer.from(bytes.toString('base64'), 'ascii'), + Buffer.from(bytes.toString('hex'), 'ascii'), + Buffer.from(bytes.toString('hex').toUpperCase(), 'ascii'), + Buffer.from(value, 'utf16le'), + ]; + }); + try { + for (const file of files(vaultRoot)) { + const contents = readFileSync(file); + if (representations.some((representation) => contents.includes(representation))) { + throw new Error(`A transition secret was found in ${file}.`); + } + } + } finally { + for (const representation of representations) representation.fill(0); + } +} + +function files(root) { + if (!pathExists(root)) return []; + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const path = join(root, entry.name); + if (entry.isDirectory()) return files(path); + return entry.isFile() && statSync(path).isFile() ? [path] : []; + }); +} + +function pathExists(path) { + try { + statSync(path); + return true; + } catch (cause) { + if (cause?.code === 'ENOENT') return false; + throw cause; + } +} + +function run(command) { + return new Promise((resolveResult) => { + const child = spawn(command[0], command.slice(1), { + env: environment, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('close', (code) => { + resolveResult({ code: code ?? -1, stderr, stdout }); + }); + }); +} + +async function runIgnoringFailure(command) { + try { + await run(command); + } catch { + return; + } +} + +async function waitFor(predicate, message) { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolveWait) => setTimeout(resolveWait, 50)); + } + throw new Error(message); +} + +function commandFailure(command, result) { + return new Error( + [`Command failed: ${command.join(' ')}`, `exit: ${result.code}`, result.stdout, result.stderr] + .filter(Boolean) + .join('\n'), + ); +} diff --git a/scripts/smoke-windows-msi-transitions.ps1 b/scripts/smoke-windows-msi-transitions.ps1 new file mode 100644 index 0000000..1b5ea8b --- /dev/null +++ b/scripts/smoke-windows-msi-transitions.ps1 @@ -0,0 +1,137 @@ +param( + [string]$Executable = 'C:\Program Files\InFlow\inflow.exe', + [string]$FixtureRoot = 'C:\Users\Public\inflow-msi-transitions', + [string]$Report = 'C:\Users\Public\inflow-windows-msi-transitions.log' +) + +$ErrorActionPreference = 'Stop' +$results = [System.Collections.Generic.List[string]]::new() +$oldVersion = '0.9.0' +$newVersion = '0.9.1' +$oldMsi = Join-Path $FixtureRoot "inflow-$oldVersion-windows-arm64.msi" +$newMsi = Join-Path $FixtureRoot "inflow-$newVersion-windows-arm64.msi" + +function Assert-True([bool]$Condition, [string]$Message) { + if (-not $Condition) { + throw $Message + } +} + +function Add-Result([string]$Message) { + $results.Add("PASS: $Message") +} + +function Get-InFlowProduct { + $roots = @( + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' + ) + return $roots | + ForEach-Object { Get-ItemProperty $_ -ErrorAction SilentlyContinue } | + Where-Object { $_.DisplayName -eq 'InFlow' -and $_.Publisher -eq 'Jarwin, Inc.' } | + Select-Object -First 1 +} + +function Assert-InstalledVersion([string]$Expected) { + $product = Get-InFlowProduct + Assert-True ($null -ne $product) 'InFlow is not registered as an installed MSI product.' + Assert-True ($product.DisplayVersion -eq $Expected) ( + "Expected installed version $Expected, received $($product.DisplayVersion)." + ) +} + +function Invoke-Msi([string[]]$Arguments) { + $log = Join-Path $env:TEMP "inflow-msi-$([Guid]::NewGuid().ToString('N')).log" + $allArguments = $Arguments + @('/qn', '/norestart', '/L*v', "`"$log`"") + $process = Start-Process -FilePath "$env:SystemRoot\System32\msiexec.exe" ` + -ArgumentList $allArguments -Wait -PassThru + return @{ + ExitCode = $process.ExitCode + Log = $log + } +} + +function Invoke-InFlowJson([string[]]$Arguments) { + $output = & $Executable @Arguments 2>&1 + Assert-True ($LASTEXITCODE -eq 0) "InFlow failed: $($output -join [Environment]::NewLine)" + return ($output -join [Environment]::NewLine) | ConvertFrom-Json +} + +function Get-VaultState { + return (Invoke-InFlowJson @('vault', 'status', '--format', 'json')).lock_state +} + +function Lock-Vault { + if ((Get-VaultState) -eq 'unlocked') { + Invoke-InFlowJson @('vault', 'lock', '--format', 'json') | Out-Null + } + Assert-True ((Get-VaultState) -eq 'locked') 'The initialized vault could not be locked.' +} + +function Unlock-And-Verify([string]$Prompt) { + Write-Host '' + Write-Host $Prompt + & $Executable vault unlock + Assert-True ($LASTEXITCODE -eq 0) 'Vault unlock failed after an MSI transition.' + Assert-True ((Get-VaultState) -eq 'unlocked') 'The vault did not remain unlocked after an MSI transition.' +} + +try { + Assert-True (Test-Path -LiteralPath $oldMsi -PathType Leaf) "The old MSI is missing: $oldMsi" + Assert-True (Test-Path -LiteralPath $newMsi -PathType Leaf) "The new MSI is missing: $newMsi" + foreach ($path in @($oldMsi, $newMsi)) { + $signature = Get-AuthenticodeSignature -LiteralPath $path + Assert-True ( + $signature.Status -eq [System.Management.Automation.SignatureStatus]::Valid + ) "The MSI signature is invalid: $path" + } + Assert-InstalledVersion $oldVersion + Assert-True ((Get-VaultState) -ne 'not_initialized') 'The transition test requires an initialized vault.' + Lock-Vault + + $upgrade = Invoke-Msi @('/i', "`"$newMsi`"") + Assert-True ($upgrade.ExitCode -in @(0, 3010)) "MSI upgrade failed with $($upgrade.ExitCode). Log: $($upgrade.Log)" + Assert-InstalledVersion $newVersion + Assert-True ((Get-VaultState) -eq 'locked') 'The vault was not preserved as locked after upgrade.' + Unlock-And-Verify 'Enter the replacement vault passphrase to verify the 0.9.1 upgrade.' + Add-Result '0.9.0 to 0.9.1 upgrade and vault preservation' + + Lock-Vault + $rejectedDowngrade = Invoke-Msi @('/i', "`"$oldMsi`"") + Assert-True ($rejectedDowngrade.ExitCode -ne 0) 'The MSI unexpectedly allowed a direct downgrade.' + Assert-InstalledVersion $newVersion + Assert-True ((Get-VaultState) -eq 'locked') 'Rejected downgrade changed the vault lock state.' + Add-Result 'direct downgrade rejection without installed-state or vault-state loss' + + $newProduct = Get-InFlowProduct + $uninstall = Invoke-Msi @('/x', $newProduct.PSChildName) + Assert-True ($uninstall.ExitCode -in @(0, 3010)) ( + "MSI uninstall before rollback failed with $($uninstall.ExitCode). Log: $($uninstall.Log)" + ) + Assert-True ($null -eq (Get-InFlowProduct)) 'The newer MSI remained registered after uninstall.' + + $rollback = Invoke-Msi @('/i', "`"$oldMsi`"") + Assert-True ($rollback.ExitCode -in @(0, 3010)) ( + "MSI rollback install failed with $($rollback.ExitCode). Log: $($rollback.Log)" + ) + Assert-InstalledVersion $oldVersion + Assert-True ((Get-VaultState) -eq 'locked') 'The vault was not preserved as locked after rollback.' + Unlock-And-Verify 'Enter the same vault passphrase to verify rollback to 0.9.0.' + Add-Result 'supported uninstall/install rollback and vault preservation' + + Lock-Vault + $reupgrade = Invoke-Msi @('/i', "`"$newMsi`"") + Assert-True ($reupgrade.ExitCode -in @(0, 3010)) ( + "MSI re-upgrade failed with $($reupgrade.ExitCode). Log: $($reupgrade.Log)" + ) + Assert-InstalledVersion $newVersion + Assert-True ((Get-VaultState) -eq 'locked') 'The vault was not preserved as locked after re-upgrade.' + Unlock-And-Verify 'Enter the same vault passphrase once more to verify the final 0.9.1 installation.' + Add-Result 'final re-upgrade and vault preservation' + $results.Add('Windows MSI transition smoke passed.') +} catch { + $results.Add("FAIL: $($_.Exception.Message)") + throw +} finally { + $results | Set-Content -LiteralPath $Report -Encoding ascii +} diff --git a/scripts/smoke-windows-system-vault.ps1 b/scripts/smoke-windows-system-vault.ps1 new file mode 100644 index 0000000..05cfaf5 --- /dev/null +++ b/scripts/smoke-windows-system-vault.ps1 @@ -0,0 +1,238 @@ +param( + [string]$Executable = 'C:\Program Files\InFlow\inflow.exe', + [string]$NativeModule = 'C:\Program Files\InFlow\native\vault_peer_windows.node', + [string]$Report = 'C:\Users\Public\inflow-windows-security-smoke.log' +) + +$ErrorActionPreference = 'Stop' +$serviceName = 'InFlowVault' +$scratch = Join-Path $env:TEMP "inflow-windows-smoke-$PID" +$nativeBackup = Join-Path $scratch 'vault_peer_windows.node' +$copyRoot = Join-Path $scratch 'copy' +$results = [System.Collections.Generic.List[string]]::new() + +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +public static class InFlowMitigationProbe { + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(uint access, bool inheritHandle, int processId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetProcessMitigationPolicy( + IntPtr process, + int policy, + IntPtr buffer, + UIntPtr length + ); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool PeekNamedPipe( + IntPtr pipe, + IntPtr buffer, + uint bufferSize, + IntPtr bytesRead, + out uint bytesAvailable, + IntPtr bytesLeft + ); + + public static uint QueryFlags(int processId, int policy) { + IntPtr process = OpenProcess(0x0400, false, processId); + if (process == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); + int size = policy == 0 ? 8 : 4; + IntPtr buffer = Marshal.AllocHGlobal(size); + try { + for (int index = 0; index < size; index++) Marshal.WriteByte(buffer, index, 0); + if (!GetProcessMitigationPolicy(process, policy, buffer, (UIntPtr)size)) { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + return unchecked((uint)Marshal.ReadInt32(buffer)); + } finally { + Marshal.FreeHGlobal(buffer); + CloseHandle(process); + } + } + + public static int QueryPipeState(IntPtr pipe) { + uint bytesAvailable; + if (PeekNamedPipe(pipe, IntPtr.Zero, 0, IntPtr.Zero, out bytesAvailable, IntPtr.Zero)) { + return bytesAvailable == 0 ? 0 : 1; + } + int error = Marshal.GetLastWin32Error(); + if (error == 109 || error == 232) return 2; + throw new Win32Exception(error); + } +} +'@ + +function Assert-True([bool]$Condition, [string]$Message) { + if (-not $Condition) { + throw $Message + } +} + +function Add-Result([string]$Message) { + $results.Add("PASS: $Message") +} + +function Invoke-InFlow([string]$Path, [string[]]$Arguments) { + $start = [System.Diagnostics.ProcessStartInfo]::new() + $start.FileName = $Path + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.Arguments = $Arguments -join ' ' + $process = [System.Diagnostics.Process]::Start($start) + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + return @{ + ExitCode = $process.ExitCode + Stderr = $stderr + Stdout = $stdout + } +} + +function Start-VaultService { + Start-Service -Name $serviceName + (Get-Service -Name $serviceName).WaitForStatus( + [System.ServiceProcess.ServiceControllerStatus]::Running, + [TimeSpan]::FromSeconds(25) + ) +} + +function Stop-VaultService { + $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue + if ($null -ne $service -and $service.Status -ne 'Stopped') { + Stop-Service -Name $serviceName + $service.WaitForStatus( + [System.ServiceProcess.ServiceControllerStatus]::Stopped, + [TimeSpan]::FromSeconds(25) + ) + } +} + +function Assert-ProcessMitigations { + $processes = @(Get-Process -Name inflow -ErrorAction Stop) + Assert-True ($processes.Count -eq 1) 'The Windows vault service does not have exactly one process.' + foreach ($process in $processes) { + $dep = [InFlowMitigationProbe]::QueryFlags($process.Id, 0) + $extensionPoint = [InFlowMitigationProbe]::QueryFlags($process.Id, 6) + $imageLoad = [InFlowMitigationProbe]::QueryFlags($process.Id, 10) + Assert-True (($dep -band 1) -ne 0) "Data Execution Prevention is not enabled for PID $($process.Id)." + Assert-True (($extensionPoint -band 1) -ne 0) "Extension points are not disabled for PID $($process.Id)." + Assert-True (($imageLoad -band 1) -ne 0) "Remote image loading is not blocked for PID $($process.Id)." + Assert-True (($imageLoad -band 2) -ne 0) "Low-integrity image loading is not blocked for PID $($process.Id)." + Assert-True (($imageLoad -band 4) -ne 0) "System32 image preference is not enabled for PID $($process.Id)." + } + Add-Result 'service process mitigations' +} + +function Assert-ServiceIdentity { + $service = Get-CimInstance Win32_Service -Filter "Name='$serviceName'" + Assert-True ($service.StartName -eq 'NT SERVICE\InFlowVault') 'The service account is not NT SERVICE\InFlowVault.' + $sidType = (& sc.exe qsidtype $serviceName | Out-String) + Assert-True ($sidType -match 'SERVICE_SID_TYPE:\s+UNRESTRICTED') 'The service security identifier is not unrestricted.' + Add-Result 'dedicated virtual service account and service security identifier' +} + +function Assert-Signatures { + $signature = Get-AuthenticodeSignature -LiteralPath $Executable + Assert-True ($signature.Status -eq 'Valid') "Authenticode signature is invalid for $Executable." + Add-Result 'installed executable Authenticode signature' +} + +function Assert-ClientRejectsWrongDaemonIdentity { + New-Item -ItemType Directory -Path $copyRoot -Force | Out-Null + Copy-Item -LiteralPath (Split-Path $Executable -Parent) -Destination $copyRoot -Recurse + $copiedExecutable = Join-Path $copyRoot 'InFlow\inflow.exe' + $result = Invoke-InFlow $copiedExecutable @('vault', 'status', '--format', 'json') + Assert-True ($result.ExitCode -ne 0) 'A copied client accepted the installed daemon identity.' + Add-Result 'client rejects a daemon at a different executable identity' +} + +function Assert-DaemonRejectsForeignClient { + $pipe = [System.IO.Pipes.NamedPipeClientStream]::new( + '.', + 'InFlowVault', + [System.IO.Pipes.PipeDirection]::InOut, + [System.IO.Pipes.PipeOptions]::None, + [System.Security.Principal.TokenImpersonationLevel]::Identification + ) + try { + $pipe.Connect(5000) + $handshake = [System.Text.Encoding]::ASCII.GetBytes('INFLOWV1') + $pipe.Write($handshake, 0, $handshake.Length) + $pipe.Flush() + $state = 0 + for ($attempt = 0; $attempt -lt 50 -and $state -eq 0; $attempt += 1) { + Start-Sleep -Milliseconds 100 + $state = [InFlowMitigationProbe]::QueryPipeState( + $pipe.SafePipeHandle.DangerousGetHandle() + ) + } + Assert-True ($state -eq 2) 'The daemon did not close a foreign named-pipe client.' + } finally { + $pipe.Dispose() + } + Add-Result 'daemon rejects a foreign named-pipe client before vault protocol data' +} + +function Assert-NativeTamperFailsClosed { + Stop-VaultService + Copy-Item -LiteralPath $NativeModule -Destination $nativeBackup + try { + $stream = [System.IO.File]::Open($NativeModule, 'Open', 'ReadWrite', 'None') + try { + $stream.Seek(-1, [System.IO.SeekOrigin]::End) | Out-Null + $value = $stream.ReadByte() + $stream.Seek(-1, [System.IO.SeekOrigin]::Current) | Out-Null + $stream.WriteByte($value -bxor 1) + } finally { + $stream.Dispose() + } + try { + Start-VaultService + throw 'The service started with a tampered native module.' + } catch { + $state = (Get-Service -Name $serviceName).Status + Assert-True ($state -ne 'Running') 'The service remained running with a tampered native module.' + } + } finally { + Stop-VaultService + Copy-Item -LiteralPath $nativeBackup -Destination $NativeModule -Force + } + Add-Result 'native-module tampering fails closed' +} + +try { + New-Item -ItemType Directory -Path $scratch -Force | Out-Null + Assert-Signatures + Assert-ServiceIdentity + Start-VaultService + $status = Invoke-InFlow $Executable @('vault', 'status', '--format', 'json') + Assert-True ($status.ExitCode -eq 0) "Authenticated vault status failed: $($status.Stderr)" + Add-Result 'authenticated vault status' + Assert-ProcessMitigations + Assert-ClientRejectsWrongDaemonIdentity + Assert-DaemonRejectsForeignClient + Assert-NativeTamperFailsClosed + Start-VaultService + $restoredStatus = Invoke-InFlow $Executable @('vault', 'status', '--format', 'json') + Assert-True ($restoredStatus.ExitCode -eq 0) 'Vault status failed after restoring the native module.' + Add-Result 'restored installation remains operational' + $results.Add('Windows system vault security smoke passed.') +} catch { + $results.Add("FAIL: $($_.Exception.Message)") + throw +} finally { + Stop-VaultService + Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue + $results | Set-Content -LiteralPath $Report -Encoding ascii +} diff --git a/scripts/smoke-windows-vault-lifecycle.ps1 b/scripts/smoke-windows-vault-lifecycle.ps1 new file mode 100644 index 0000000..399e18f --- /dev/null +++ b/scripts/smoke-windows-vault-lifecycle.ps1 @@ -0,0 +1,404 @@ +param( + [string]$Executable = 'C:\Program Files\InFlow\inflow.exe', + [string]$VaultRoot = 'C:\ProgramData\InFlow\vaults', + [string]$Report = 'C:\Users\Public\inflow-windows-vault-lifecycle.log', + [switch]$SkipRelock, + [switch]$ProbeMemoryScanner +) + +$ErrorActionPreference = 'Stop' +$results = [System.Collections.Generic.List[string]]::new() +$apiKey = "inflow_windows_smoke_$([Guid]::NewGuid().ToString('N'))" +$server = $null + +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +public static class InFlowProcessMemoryScanner { + [StructLayout(LayoutKind.Sequential)] + private struct MemoryBasicInformation { + public IntPtr BaseAddress; + public IntPtr AllocationBase; + public uint AllocationProtect; + public UIntPtr RegionSize; + public uint State; + public uint Protect; + public uint Type; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(uint access, bool inheritHandle, int processId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern UIntPtr VirtualQueryEx( + IntPtr process, + IntPtr address, + out MemoryBasicInformation information, + UIntPtr length + ); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ReadProcessMemory( + IntPtr process, + IntPtr address, + byte[] buffer, + UIntPtr size, + out UIntPtr bytesRead + ); + + public static string FindAny(int processId, byte[][] patterns) { + const uint processQueryLimitedInformation = 0x1000; + const uint processVirtualMemoryRead = 0x0010; + const uint memoryCommit = 0x1000; + const uint pageGuard = 0x100; + const uint pageNoAccess = 0x01; + IntPtr process = OpenProcess( + processQueryLimitedInformation | processVirtualMemoryRead, + false, + processId + ); + if (process == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); + int longest = 1; + foreach (byte[] pattern in patterns) { + if (pattern != null && pattern.Length > longest) longest = pattern.Length; + } + byte[] buffer = new byte[1024 * 1024 + longest - 1]; + byte[] readBuffer = new byte[1024 * 1024]; + try { + ulong address = 0; + int informationSize = Marshal.SizeOf(typeof(MemoryBasicInformation)); + while (address < 0x00007fffffffffffUL) { + MemoryBasicInformation information; + UIntPtr queried = VirtualQueryEx( + process, + new IntPtr(unchecked((long)address)), + out information, + (UIntPtr)informationSize + ); + if (queried == UIntPtr.Zero) break; + ulong baseAddress = unchecked((ulong)information.BaseAddress.ToInt64()); + ulong regionSize = information.RegionSize.ToUInt64(); + if (regionSize == 0 || baseAddress + regionSize <= address) break; + if ( + information.State == memoryCommit && + (information.Protect & (pageGuard | pageNoAccess)) == 0 + ) { + int overlap = 0; + ulong offset = 0; + while (offset < regionSize) { + int requested = (int)Math.Min(1024UL * 1024UL, regionSize - offset); + UIntPtr bytesRead; + bool readable = ReadProcessMemory( + process, + new IntPtr(unchecked((long)(baseAddress + offset))), + readBuffer, + (UIntPtr)requested, + out bytesRead + ); + if (!readable || bytesRead == UIntPtr.Zero) break; + int received = checked((int)bytesRead.ToUInt64()); + Buffer.BlockCopy(readBuffer, 0, buffer, overlap, received); + Array.Clear(readBuffer, 0, received); + int populated = overlap + received; + foreach (byte[] pattern in patterns) { + int found = IndexOf(buffer, populated, pattern); + if (found >= 0) { + return "0x" + (baseAddress + offset - (ulong)overlap + (ulong)found).ToString("x"); + } + } + overlap = Math.Min(longest - 1, populated); + Buffer.BlockCopy(buffer, populated - overlap, buffer, 0, overlap); + Array.Clear(buffer, overlap, populated - overlap); + offset += bytesRead.ToUInt64(); + } + Array.Clear(buffer, 0, buffer.Length); + } + address = baseAddress + regionSize; + } + return null; + } finally { + Array.Clear(buffer, 0, buffer.Length); + Array.Clear(readBuffer, 0, readBuffer.Length); + CloseHandle(process); + } + } + + private static int IndexOf(byte[] buffer, int length, byte[] pattern) { + if (pattern == null || pattern.Length == 0 || length < pattern.Length) return -1; + for (int offset = 0; offset <= length - pattern.Length; offset++) { + int index = 0; + while (index < pattern.Length && buffer[offset + index] == pattern[index]) index++; + if (index == pattern.Length) return offset; + } + return -1; + } +} +'@ + +function Assert-True([bool]$Condition, [string]$Message) { + if (-not $Condition) { + throw $Message + } +} + +function Add-Result([string]$Message) { + $results.Add("PASS: $Message") +} + +function Invoke-InFlowJson([string[]]$Arguments) { + $output = & $Executable @Arguments 2>&1 + Assert-True ($LASTEXITCODE -eq 0) "InFlow failed: $($output -join [Environment]::NewLine)" + return ($output -join [Environment]::NewLine) | ConvertFrom-Json +} + +function Assert-VaultState([string]$Expected) { + $status = Invoke-InFlowJson @('vault', 'status', '--format', 'json') + Assert-True ($status.lock_state -eq $Expected) "Expected vault state $Expected, received $($status.lock_state)." +} + +function Find-ByteSequence([byte[]]$Contents, [byte[]]$Needle) { + if ($Needle.Length -eq 0 -or $Contents.Length -lt $Needle.Length) { + return $false + } + for ($offset = 0; $offset -le $Contents.Length - $Needle.Length; $offset += 1) { + $matches = $true + for ($index = 0; $index -lt $Needle.Length; $index += 1) { + if ($Contents[$offset + $index] -ne $Needle[$index]) { + $matches = $false + break + } + } + if ($matches) { + return $true + } + } + return $false +} + +function Assert-SecretAbsentFromVaultFiles([string]$Value, [string]$Label) { + $utf8 = [System.Text.Encoding]::UTF8.GetBytes($Value) + $hexadecimal = [BitConverter]::ToString($utf8).Replace('-', '') + $representations = [System.Collections.Generic.List[byte[]]]::new() + $representations.Add($utf8) + $representations.Add([System.Text.Encoding]::Unicode.GetBytes($Value)) + $representations.Add([System.Text.Encoding]::ASCII.GetBytes([Convert]::ToBase64String($utf8))) + $representations.Add([System.Text.Encoding]::ASCII.GetBytes($hexadecimal.ToLowerInvariant())) + $representations.Add([System.Text.Encoding]::ASCII.GetBytes($hexadecimal)) + try { + foreach ($file in Get-ChildItem -LiteralPath $VaultRoot -File -Recurse -Force) { + $contents = Read-LiveFile $file.FullName + try { + foreach ($representation in $representations) { + Assert-True (-not (Find-ByteSequence $contents $representation)) "The $Label was found in $($file.FullName)." + } + } finally { + [Array]::Clear($contents, 0, $contents.Length) + } + } + } finally { + foreach ($representation in $representations) { + [Array]::Clear($representation, 0, $representation.Length) + } + } +} + +function Assert-SecretAbsentFromServiceMemory([string]$Value, [string]$Label) { + $utf8 = [System.Text.Encoding]::UTF8.GetBytes($Value) + $hexadecimal = [BitConverter]::ToString($utf8).Replace('-', '') + $representations = [System.Collections.Generic.List[byte[]]]::new() + $representations.Add($utf8) + $representations.Add([System.Text.Encoding]::Unicode.GetBytes($Value)) + $representations.Add([System.Text.Encoding]::ASCII.GetBytes([Convert]::ToBase64String($utf8))) + $representations.Add([System.Text.Encoding]::ASCII.GetBytes($hexadecimal.ToLowerInvariant())) + $representations.Add([System.Text.Encoding]::ASCII.GetBytes($hexadecimal)) + try { + $service = Get-CimInstance Win32_Service -Filter "Name='InFlowVault'" + Assert-True ($service.ProcessId -gt 0) 'The Windows vault service process is unavailable.' + $found = [InFlowProcessMemoryScanner]::FindAny( + [int]$service.ProcessId, + $representations.ToArray() + ) + Assert-True ($null -eq $found) "The $Label remained in vault service memory at $found." + } finally { + foreach ($representation in $representations) { + [Array]::Clear($representation, 0, $representation.Length) + } + } +} + +function Read-LiveFile([string]$Path) { + $share = [System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete + $stream = [System.IO.File]::Open( + $Path, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + $share + ) + $memory = [System.IO.MemoryStream]::new() + try { + $stream.CopyTo($memory) + Write-Output -NoEnumerate $memory.ToArray() + } finally { + $memory.Dispose() + $stream.Dispose() + } +} + +function Start-ApiServer { + $listener = [System.Net.Sockets.TcpListener]::new( + [System.Net.IPAddress]::Loopback, + 0 + ) + $listener.Start() + $port = ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port + $listener.Stop() + $job = Start-Job -ArgumentList $port, $apiKey -ScriptBlock { + param($Port, $ApiKey) + $http = [System.Net.HttpListener]::new() + $http.Prefixes.Add("http://127.0.0.1:$Port/") + $http.Start() + Write-Output 'ready' + try { + for ($requestNumber = 0; $requestNumber -lt 4; $requestNumber += 1) { + $context = $http.GetContext() + $authorized = ( + $context.Request.Url.AbsolutePath -eq '/v1/users/self' -and + $context.Request.Headers['x-api-key'] -ceq $ApiKey + ) + if ($authorized) { + $body = [System.Text.Encoding]::UTF8.GetBytes( + '{"id":"windows-vault-user","email":"windows-vault@inflow.test"}' + ) + $context.Response.StatusCode = 200 + $context.Response.ContentType = 'application/json' + } else { + $body = [System.Text.Encoding]::UTF8.GetBytes('{"code":"unauthorized"}') + $context.Response.StatusCode = 401 + $context.Response.ContentType = 'application/json' + } + $context.Response.OutputStream.Write($body, 0, $body.Length) + $context.Response.Close() + } + } finally { + $http.Close() + } + } + $deadline = [DateTime]::UtcNow.AddSeconds(10) + do { + $jobOutput = @(Receive-Job -Job $job) + if ($jobOutput -contains 'ready') { + break + } + if ($job.State -eq 'Failed') { + $failure = Receive-Job -Job $job -Keep 2>&1 + throw "The local API server failed: $($failure -join [Environment]::NewLine)" + } + Start-Sleep -Milliseconds 50 + } while ([DateTime]::UtcNow -lt $deadline) + Assert-True ($jobOutput -contains 'ready') 'The local API server did not become ready.' + return @{ + Endpoint = "http://127.0.0.1:$port" + Job = $job + } +} + +try { + if ($ProbeMemoryScanner) { + $probe = [System.Text.Encoding]::UTF8.GetBytes("inflow-memory-probe-$([Guid]::NewGuid().ToString('N'))") + try { + $service = Get-CimInstance Win32_Service -Filter "Name='InFlowVault'" + Assert-True ($service.ProcessId -gt 0) 'The Windows vault service process is unavailable.' + $probePatterns = [System.Collections.Generic.List[byte[]]]::new() + $probePatterns.Add($probe) + $found = [InFlowProcessMemoryScanner]::FindAny([int]$service.ProcessId, $probePatterns.ToArray()) + Assert-True ($null -eq $found) "The scanner-only probe unexpectedly matched service memory at $found." + $results.Add('Windows process-memory scanner compatibility passed.') + return + } finally { + [Array]::Clear($probe, 0, $probe.Length) + } + } + + $initial = Invoke-InFlowJson @('vault', 'status', '--format', 'json') + if ($initial.lock_state -eq 'not_initialized') { + Write-Host 'Choose a temporary vault passphrase, then enter the same value at each InFlow prompt.' + & $Executable vault unlock + Assert-True ($LASTEXITCODE -eq 0) 'Vault initialization failed.' + Add-Result 'first-run vault initialization and unlock' + } elseif ($initial.lock_state -eq 'locked') { + Write-Host 'Enter the temporary vault passphrase chosen during the prior run.' + & $Executable vault unlock + Assert-True ($LASTEXITCODE -eq 0) 'Vault unlock failed.' + Add-Result 'existing vault unlock' + } else { + Assert-True ($initial.lock_state -eq 'unlocked') "Unexpected initial vault state $($initial.lock_state)." + Add-Result 'existing vault remained unlocked after the interrupted run' + } + Assert-VaultState 'unlocked' + + $server = Start-ApiServer + $login = Invoke-InFlowJson @( + '--api-key', $apiKey, + '--base-url', $server.Endpoint, + 'auth', 'login', + '--format', 'json' + ) + Assert-True ($login.authenticated -eq $true) 'API-key login was not authenticated.' + Assert-True ($login.method -eq 'api_key') 'API-key login reported the wrong authentication method.' + Add-Result 'credential storage through the packaged command path' + + $authStatus = Invoke-InFlowJson @('auth', 'status', '--format', 'json') + Assert-True ($authStatus.authenticated -eq $true) 'Stored credential validation failed.' + Assert-True ($authStatus.auth_method -eq 'api_key') 'Stored credential reported the wrong authentication method.' + Add-Result 'stored credential retrieval' + + Assert-SecretAbsentFromVaultFiles $apiKey 'stored credential' + Add-Result 'stored credential absent from persistent vault files in raw, UTF-16, Base64, and hexadecimal forms' + Assert-SecretAbsentFromServiceMemory $apiKey 'stored credential' + Add-Result 'stored credential absent from readable committed vault-service memory' + $pageFiles = @(Get-CimInstance Win32_PageFileUsage -ErrorAction SilentlyContinue) + $activePageFiles = @( + $pageFiles | Where-Object { + $null -ne $_ -and $null -ne $_.AllocatedBaseSize -and [uint32]$_.AllocatedBaseSize -gt 0 + } + ) + if ($activePageFiles.Count -eq 0) { + Add-Result 'pagefile disabled during process-memory scan' + } else { + $results.Add('LIMITATION: the Windows pagefile was enabled and was not scanned.') + } + if ($SkipRelock) { + $results.Add('Windows packaged vault credential-storage and disk-scan smoke passed.') + return + } + + $lock = Invoke-InFlowJson @('vault', 'lock', '--format', 'json') + Assert-True ($lock.locked -eq $true) 'Vault lock did not report success.' + Assert-VaultState 'locked' + Add-Result 'explicit vault lock' + + Write-Host 'Enter the same temporary vault passphrase once more.' + & $Executable vault unlock + Assert-True ($LASTEXITCODE -eq 0) 'Vault re-unlock failed.' + Assert-VaultState 'unlocked' + $persistedStatus = Invoke-InFlowJson @('auth', 'status', '--format', 'json') + Assert-True ($persistedStatus.authenticated -eq $true) 'Credential did not survive lock and unlock.' + Add-Result 'credential persistence across lock and unlock' + + $results.Add('Windows packaged vault lifecycle smoke passed.') +} catch { + $results.Add("FAIL: $($_.Exception.Message)") + throw +} finally { + if ($null -ne $server) { + Stop-Job -Job $server.Job -ErrorAction SilentlyContinue + Remove-Job -Job $server.Job -Force -ErrorAction SilentlyContinue + } + $apiKey = $null + $results | Set-Content -LiteralPath $Report -Encoding ascii +} diff --git a/scripts/smoke-windows-vault-passphrase-reset.ps1 b/scripts/smoke-windows-vault-passphrase-reset.ps1 new file mode 100644 index 0000000..2dbe660 --- /dev/null +++ b/scripts/smoke-windows-vault-passphrase-reset.ps1 @@ -0,0 +1,90 @@ +param( + [string]$Executable = 'C:\Program Files\InFlow\inflow.exe', + [string]$Report = 'C:\Users\Public\inflow-windows-vault-passphrase-reset.log', + [switch]$SkipReset +) + +$ErrorActionPreference = 'Stop' +$results = [System.Collections.Generic.List[string]]::new() + +function Assert-True([bool]$Condition, [string]$Message) { + if (-not $Condition) { + throw $Message + } +} + +function Add-Result([string]$Message) { + $results.Add("PASS: $Message") +} + +function Invoke-InFlowJson([string[]]$Arguments) { + $output = & $Executable @Arguments 2>&1 + Assert-True ($LASTEXITCODE -eq 0) "InFlow failed: $($output -join [Environment]::NewLine)" + return ($output -join [Environment]::NewLine) | ConvertFrom-Json +} + +function Get-VaultState { + return (Invoke-InFlowJson @('vault', 'status', '--format', 'json')).lock_state +} + +try { + Assert-True (Test-Path -LiteralPath $Executable -PathType Leaf) "InFlow is not installed at $Executable." + $initialState = Get-VaultState + Assert-True ($initialState -ne 'not_initialized') 'The passphrase-change test requires an initialized vault.' + + if ($initialState -eq 'locked') { + Write-Host 'Enter the CURRENT vault passphrase.' + & $Executable vault unlock + Assert-True ($LASTEXITCODE -eq 0) 'Unlock with the current passphrase failed.' + } + $stateAfterUnlock = Get-VaultState + Assert-True ($stateAfterUnlock -eq 'unlocked') "Expected unlocked after unlock, received $stateAfterUnlock." + + Write-Host '' + Write-Host 'Change the passphrase: enter the CURRENT passphrase once, then the NEW passphrase twice.' + & $Executable vault change-passphrase + Assert-True ($LASTEXITCODE -eq 0) 'Passphrase change failed.' + Add-Result 'passphrase change' + + Invoke-InFlowJson @('vault', 'lock', '--format', 'json') | Out-Null + Assert-True ((Get-VaultState) -eq 'locked') 'The vault did not lock after the passphrase change.' + + Write-Host '' + Write-Host 'NEGATIVE TEST: enter the OLD passphrase. InFlow must reject it.' + & $Executable vault unlock + Assert-True ($LASTEXITCODE -ne 0) 'The old passphrase was accepted after the passphrase change.' + Assert-True ((Get-VaultState) -eq 'locked') 'The vault did not remain locked after the old passphrase attempt.' + Add-Result 'old passphrase rejection' + + Write-Host '' + Write-Host 'POSITIVE TEST: enter the NEW passphrase. InFlow must accept it.' + & $Executable vault unlock + Assert-True ($LASTEXITCODE -eq 0) 'The new passphrase was rejected.' + Assert-True ((Get-VaultState) -eq 'unlocked') 'The vault is not unlocked with the new passphrase.' + Add-Result 'new passphrase unlock' + + if ($SkipReset) { + $results.Add('Windows packaged vault passphrase-change smoke passed.') + return + } + + Write-Host '' + Write-Host 'The reset test permanently removes this Windows user vault and its stored credentials.' + $confirmation = Read-Host 'Type RESET to continue' + Assert-True ($confirmation -ceq 'RESET') 'Reset test cancelled.' + Invoke-InFlowJson @('vault', 'reset', '--force', '--format', 'json') | Out-Null + Assert-True ((Get-VaultState) -eq 'not_initialized') 'The vault remained initialized after reset.' + Add-Result 'vault reset and tenant-state removal' + + Write-Host '' + Write-Host 'Create the replacement vault passphrase you want to keep after this test.' + & $Executable vault unlock + Assert-True ($LASTEXITCODE -eq 0) 'Vault reinitialization after reset failed.' + Assert-True ((Get-VaultState) -eq 'unlocked') 'The replacement vault is not unlocked.' + Add-Result 'clean reinitialization after reset' +} catch { + $results.Add("FAIL: $($_.Exception.Message)") + throw +} finally { + $results | Set-Content -LiteralPath $Report -Encoding ascii +} diff --git a/scripts/stress-windows-vault-status.cmd b/scripts/stress-windows-vault-status.cmd new file mode 100644 index 0000000..d896b9c --- /dev/null +++ b/scripts/stress-windows-vault-status.cmd @@ -0,0 +1,12 @@ +@echo off +setlocal +set "REPORT=C:\Users\Public\inflow-status-stress.log" +break > "%REPORT%" +for /l %%I in (1,1,25) do ( + "C:\Program Files\InFlow\inflow.exe" vault status --format json >> "%REPORT%" 2>&1 + if errorlevel 1 ( + echo FAILED_ITERATION=%%I>> "%REPORT%" + exit /b 1 + ) +) +echo PASS=25>> "%REPORT%" diff --git a/scripts/test-windows-powershell-scan-compat.ps1 b/scripts/test-windows-powershell-scan-compat.ps1 new file mode 100644 index 0000000..06acb2e --- /dev/null +++ b/scripts/test-windows-powershell-scan-compat.ps1 @@ -0,0 +1,52 @@ +$ErrorActionPreference = 'Stop' +$report = 'C:\Users\Public\inflow-powershell-scan-compat.log' + +function Read-CompatibleFile([string]$Path) { + $share = [System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete + $stream = [System.IO.File]::Open( + $Path, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + $share + ) + $memory = [System.IO.MemoryStream]::new() + try { + $stream.CopyTo($memory) + Write-Output -NoEnumerate $memory.ToArray() + } finally { + $memory.Dispose() + $stream.Dispose() + } +} + +try { + $utf8 = [System.Text.Encoding]::UTF8.GetBytes('compatibility-secret') + $hexadecimal = [BitConverter]::ToString($utf8).Replace('-', '') + $representations = [System.Collections.Generic.List[byte[]]]::new() + $representations.Add($utf8) + $representations.Add([System.Text.Encoding]::Unicode.GetBytes('compatibility-secret')) + $representations.Add([System.Text.Encoding]::ASCII.GetBytes([Convert]::ToBase64String($utf8))) + $representations.Add([System.Text.Encoding]::ASCII.GetBytes($hexadecimal.ToLowerInvariant())) + $representations.Add([System.Text.Encoding]::ASCII.GetBytes($hexadecimal)) + if ($representations.Count -ne 5) { + throw 'Typed representation construction failed.' + } + foreach ($representation in $representations) { + if ($null -eq $representation -or $representation.GetType() -ne [byte[]]) { + throw 'A representation is not a byte array.' + } + [Array]::Clear($representation, 0, $representation.Length) + } + + foreach ($file in Get-ChildItem -LiteralPath 'C:\ProgramData\InFlow\vaults' -File -Recurse -Force) { + [byte[]]$contents = Read-CompatibleFile $file.FullName + if ($null -eq $contents) { + throw "Reading $($file.FullName) returned null." + } + [Array]::Clear($contents, 0, $contents.Length) + } + 'Windows PowerShell 5.1 scan compatibility passed.' | Set-Content -LiteralPath $report -Encoding ascii +} catch { + "FAIL: $($_.Exception.Message)" | Set-Content -LiteralPath $report -Encoding ascii + throw +} diff --git a/scripts/test-windows-vault-backend.cmd b/scripts/test-windows-vault-backend.cmd new file mode 100644 index 0000000..af75f4d --- /dev/null +++ b/scripts/test-windows-vault-backend.cmd @@ -0,0 +1,6 @@ +@echo off +setlocal +set "PATH=C:\Program Files\nodejs;%PATH%" +cd /d C:\Users\Public\inflow-windows-msi +call corepack.cmd pnpm --filter @inflowpayai/inflow-core exec vitest run test/unit/secure-storage/vault-local-backend.test.ts --coverage=false > "C:\Users\Public\inflow-windows-backend-test.log" 2>&1 +echo EXIT=%ERRORLEVEL%>> "C:\Users\Public\inflow-windows-backend-test.log" diff --git a/scripts/verify-linux-automation-key.mjs b/scripts/verify-linux-automation-key.mjs new file mode 100644 index 0000000..7b2b572 --- /dev/null +++ b/scripts/verify-linux-automation-key.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; + +const expectedFingerprint = normalizedFingerprint(process.argv[2] ?? ''); +const records = execFileSync( + 'gpg', + ['--batch', '--with-colons', '--with-subkey-fingerprint', '--list-secret-keys', expectedFingerprint], + { encoding: 'utf8' }, +) + .trim() + .split('\n') + .map((line) => line.split(':')); +const primary = records.find((record) => record[0] === 'sec'); +const primaryFingerprint = records.find((record) => record[0] === 'fpr')?.[9] ?? ''; +const subkeys = records.filter((record) => record[0] === 'ssb'); + +if (normalizedFingerprint(primaryFingerprint) !== expectedFingerprint) { + throw new Error('The automation key primary fingerprint does not match the protected environment.'); +} +if (primary?.[14] !== '#') { + throw new Error('The automation key contains a usable primary secret key.'); +} +if ( + subkeys.length !== 1 || + subkeys[0]?.[14] !== '+' || + !(subkeys[0]?.[11] ?? '').toLowerCase().includes('s') +) { + throw new Error('The automation key must contain exactly one usable signing subkey.'); +} + +process.stdout.write(`Verified automation-only Linux signing key ${expectedFingerprint}.\n`); + +function normalizedFingerprint(value) { + const normalized = value.replaceAll(/\s/gu, '').toUpperCase(); + if (!/^[0-9A-F]{40,64}$/u.test(normalized)) throw new Error('Linux signing-key fingerprint is invalid.'); + return normalized; +} diff --git a/scripts/verify-windows-pagefile-disabled.ps1 b/scripts/verify-windows-pagefile-disabled.ps1 new file mode 100644 index 0000000..d12e7c7 --- /dev/null +++ b/scripts/verify-windows-pagefile-disabled.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference = 'Stop' +$report = 'C:\Users\Public\inflow-pagefile-status.log' + +try { + $automatic = [bool](Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile + $settings = @(Get-CimInstance Win32_PageFileSetting -ErrorAction SilentlyContinue) + $usage = @(Get-CimInstance Win32_PageFileUsage -ErrorAction SilentlyContinue) + if ($automatic -or $settings.Count -ne 0 -or $usage.Count -ne 0) { + throw "Pagefile remains configured or active: automatic=$automatic settings=$($settings.Count) usage=$($usage.Count)." + } + 'Windows pagefile is disabled and inactive.' | Set-Content -LiteralPath $report -Encoding ascii +} catch { + "FAIL: $($_.Exception.Message)" | Set-Content -LiteralPath $report -Encoding ascii + throw +} diff --git a/skills/agentic-enrollment/SKILL.md b/skills/agentic-enrollment/SKILL.md index a226386..006efa1 100644 --- a/skills/agentic-enrollment/SKILL.md +++ b/skills/agentic-enrollment/SKILL.md @@ -35,6 +35,10 @@ Use structured output when acting programmatically: inflow aep ... --format json ``` +Credential-bearing commands require the encrypted local vault. If the CLI reports that the vault is uninitialized or +locked, tell the user to run `inflow vault unlock` themselves in a terminal, then retry. Never ask for or accept the +vault PIN or passphrase through chat, an MCP tool, a command-line flag, or an environment variable. + Use `inflow aep --schema` for exact arguments and flags. Use `inflow --llms-full` for the complete command reference. diff --git a/skills/agentic-payments/SKILL.md b/skills/agentic-payments/SKILL.md index b3a7536..2118c41 100644 --- a/skills/agentic-payments/SKILL.md +++ b/skills/agentic-payments/SKILL.md @@ -40,20 +40,24 @@ InFlow runs as a **standalone CLI** or an **MCP server**. - `inflow --skill` - print this playbook (no frontmatter) to stdout. Use it to paste into the system-prompt field of an MCP host that doesn't natively load skills: `inflow --skill | pbcopy`. - Default output is `toon`. Override with `--format `; for programmatic parsing prefer `json` (single document) or `jsonl` (line-delimited). - Multi-step flows return `_next.command` - run it to continue. -- `--auth ` overrides the credentials file location. +- `--auth ` identifies a legacy plaintext credential file for deletion; it is not a credential backend. - `--api-key ` or `INFLOW_API_KEY=` is an alternative to device-flow auth. ## Authenticate Authentication is shared by both protocols - do it once, before either payment flow. **Don't start a payment until the user is authenticated.** +Credential-bearing commands require the encrypted local vault. If the CLI reports that the vault is uninitialized or +locked, tell the user to run `inflow vault unlock` themselves in a terminal, then retry. Never ask for or accept the +vault PIN or passphrase through chat, an MCP tool, a command-line flag, or an environment variable. + Check the current state first - the user may already be logged in: ```bash inflow auth status ``` -A successful `auth status` returns `authenticated: true` plus `auth_method` (`device_token` or `api_key`), a truncated `access_token` preview (never the full token), `credentials_path`, `connection`, and possibly an `update` field. For the user's identity (email, handle, account id), call `inflow user get` - `auth status` deliberately omits it. Run the command to see the full shape. +A successful `auth status` returns `authenticated: true` plus `auth_method` (`device_token` or `api_key`), a truncated `access_token` preview (never the full token), `credentials_path`, `connection`, and possibly an `update` field. Run the command to see the full shape. If the response includes an `update` field, a newer version of `inflow` is published. @@ -315,7 +319,7 @@ These apply to both protocols (in addition to each section's protocol-specific c | `INVALID_402` / `DECODE_FAILED` | Seller returned 402 but the protocol's header was missing (`INVALID_402`) or unparseable (`DECODE_FAILED`). Verify the URL is payable; pass the raw header to `inflow decode` for the detailed parse error. | - | | `POLLING_TIMEOUT` | `--interval` polling reached its max-attempts or timeout. Retryable - resume with `inflow fetch --interval 5 --max-attempts 180`. | "Still waiting on your approval - want me to keep polling, or cancel the request? (`inflow cancel ` cancels it.)" | | `PAYMENT_REPLAY_OUTCOME_UNKNOWN` | A credential-bearing seller request had an indeterminate transport failure. Do not automatically replay. | "The seller request may have received the payment credential, but the connection failed before we got a reliable response. I won't retry automatically because the credential may be consumed." | -| `api_error` | Non-2xx from the InFlow API on the plain data calls (`user`, `balances`, `deposit-addresses`); discriminate on `httpStatus`. `401` - saved auth rejected, re-run `inflow auth login`. `426` (`VERSION_UNSUPPORTED`) - upgrade and retry. `5xx` - server-side; wait and retry. (Note: `pay`/`status` rejections instead surface the server's own code, e.g. `INSUFFICIENT_FUNDS`, or the protocol's terminal code - not `api_error`.) | - | +| `api_error` | Non-2xx from the InFlow API on the plain data calls (`balances`, `deposit-addresses`); discriminate on `httpStatus`. `401` - saved auth rejected, re-run `inflow auth login`. `426` (`VERSION_UNSUPPORTED`) - upgrade and retry. `5xx` - server-side; wait and retry. (Note: `pay`/`status` rejections instead surface the server's own code, e.g. `INSUFFICIENT_FUNDS`, or the protocol's terminal code - not `api_error`.) | - | | `VERSION_UNSUPPORTED` / HTTP 426 | Installed `inflow` CLI is below the minimum supported version. Install the current release from https://inflowcli.ai/, then retry; don't retry on the old version. | - | | `transport_error` | Network failure - check connectivity; retry. | - | diff --git a/turbo.json b/turbo.json index d72ab3f..c74f36e 100644 --- a/turbo.json +++ b/turbo.json @@ -7,6 +7,24 @@ "inputs": ["src/**", "tsconfig.json", "tsup.config.ts", "package.json"], "outputs": ["dist/**"] }, + "@inflowpayai/inflow#build": { + "dependsOn": ["^build"], + "inputs": [ + "src/**", + "tsconfig.json", + "tsup.config.ts", + "tsup.standalone.config.ts", + "package.json", + "../../package.json", + "../../pnpm-lock.yaml", + "../../patches/incur.patch", + "../../skills/**", + "../core/package.json", + "../core/native/**", + "../core/src/**" + ], + "outputs": ["dist/**"] + }, "dev": { "cache": false, "persistent": true