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..7d09bbd --- /dev/null +++ b/.github/workflows/linux-release.yml @@ -0,0 +1,299 @@ +name: linux-release + +on: + pull_request: + workflow_dispatch: + inputs: + publish: + description: Upload artifacts to the matching GitHub release + required: true + type: boolean + default: false + tag: + description: Existing GitHub release tag + required: false + type: string + +permissions: + contents: read + +jobs: + package: + 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: Render Linux installer + run: sudo env "PATH=$PATH" pnpm build:linux-installer + + - 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: Reject a tampered Linux package + run: | + mkdir "$RUNNER_TEMP/inflow-tampered-installer" + cp dist/linux/install.sh dist/linux/inflow_*.deb "$RUNNER_TEMP/inflow-tampered-installer/" + checksum="$RUNNER_TEMP/inflow-tampered-installer/$(basename dist/linux/inflow_*.deb).sha256" + printf '%064d %s\n' 0 "$(basename dist/linux/inflow_*.deb)" > "$checksum" + if INFLOW_RELEASE_BASE_URL="file://$RUNNER_TEMP/inflow-tampered-installer" \ + sh "$RUNNER_TEMP/inflow-tampered-installer/install.sh"; then + echo "::error::The Linux installer accepted a tampered package" + exit 1 + fi + if dpkg-query --show inflow >/dev/null 2>&1; then + echo "::error::The Linux installer installed a package after checksum rejection" + exit 1 + fi + + - name: Test Debian system vault + run: | + INFLOW_RELEASE_BASE_URL="file://$PWD/dist/linux" sh dist/linux/install.sh + 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 + dist/linux/*.rpm + + - 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 + dist/linux/install.sh + dist/linux/manifest.json + if-no-files-found: error + + - name: Upload release assets + if: inputs.publish + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${{ inputs.tag }}" \ + 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 \ + dist/linux/install.sh \ + dist/linux/manifest.json \ + --clobber + + 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 diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml new file mode 100644 index 0000000..50dbb6a --- /dev/null +++ b/.github/workflows/windows-release.yml @@ -0,0 +1,395 @@ +name: windows release + +on: + pull_request: + 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..248276f 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. diff --git a/_PLAN126-LOCAL-VAULT-DAEMON.md b/_PLAN126-LOCAL-VAULT-DAEMON.md new file mode 100644 index 0000000..2e1e5e5 --- /dev/null +++ b/_PLAN126-LOCAL-VAULT-DAEMON.md @@ -0,0 +1,547 @@ +# Task 126 - Local Vault Daemon Implementation Plan + +This plan implements the Task 126 queue item from `_QUEUE2-SIGNED-SECURE-CLI.md`. + +The target is a macOS-first proof of the cross-platform local vault model. The implementation must keep protocol +intelligence in CLI/Core and make the daemon a secure dumb vault. The daemon is not an AEP, MPP, x402, payment, HTTP, or +signing engine. + +## Accepted Improved proposal + +This proposal is the controlling security specification. + +### Accepted architecture clarification + +- One InFlow application executable and process identity contains CLI, MCP, and daemon behavior. +- Security-sensitive native code may be a packaged native library loaded into that process when it is fixed-path, + non-user-writable, platform-signed or integrity-bound, verified before loading, and never executed as a separate + helper identity. +- Standard Node native modules are permitted under this rule. +- Do not build a custom Node runtime, use Electron, or extract native code to a temporary path. + +### A. One executable architecture proof + +- Prove one InFlow executable and process identity can load the native security core as an integrity-bound packaged + component. +- CLI, MCP, daemon dispatch stay same executable. +- macOS signing/notarization, Linux ARM64/AMD64, Windows feasibility. +- No separate native executable identity, writable native library, or temporary extraction. + +### B. Cross-platform mutual peer verification + +- Internal abstraction like: + + ```ts + interface VerifiedLocalPeer { + executableIdentity: string; + processId: number; + userIdentity: string; + } + interface LocalPeerVerifier { + verifyClient(connection: LocalConnection): VerifiedLocalPeer; + verifyServer(connection: LocalConnection): VerifiedLocalPeer; + } + ``` + +- Both directions verify before protocol traffic. +- Platform facts + shared enforcement: exact app identity, executable, user/session relationship, build/install + identity, fail closed. + +### C. Rust protected-memory core + +- Move only master-key lifecycle, key derivation, record encryption/decryption, zeroization, memory locking/dump + exclusion, platform peer inspection. +- Preserve CRUD semantics and repository orchestration unless security invariant demands movement. + +### D. Unified executable hardening + +- Cross-platform matrix: + - Publisher identity: Developer ID / signed Linux digest-package trust / Authenticode. + - Client verifies daemon required. + - Daemon verifies client required. + - Canonical executable required. + - Protected key memory native. + - Dump/debug restrictions platform-specific. + - Strict state permissions. + - Fake client and fake server rejection. + +### E. Adversarial verification before features + +- Tests: fake client, fake daemon/socket, correct signer wrong path, wrong signer correct-looking path, replacement + after daemon start, symlink launcher, PID reuse, peer exit during verify, missing verification service, unreadable + executable, cross-user, malformed/oversized/truncated frames/reset, locked access, dump attempt, plaintext scan, + binary/native tampering, upgrade while daemon running, downgrade. +- No installer/APT/new vault ops/payment policies/external provider implementation until invariants pass. + +### Native Linux verification + +- The bounded packaged release smoke passes on native ARM64 and AMD64 GitHub runners. Both architectures verify native + module loading, daemon startup, vault initialization and unlock, credential round trip, Debian installation and + system-vault behavior, tamper rejection, artifact attestation, and artifact upload. + +### Remaining core security work + +- Implement an installer-managed, socket-activated Linux daemon under a dedicated `inflow` service account and disable + dumpability. Debian and RPM packages create the account and service; the executable remains world-executable. +- Implement the equivalent Windows service under a dedicated service identity. +- Wire the multi-tenant backend manager into the installer-managed services. Authenticated socket peers are routed to + server-selected tenant backends, client-requested global service shutdown is rejected, and tenant reset does not stop + the service. Each backend owns a separate database, `inflow.vault`, protected key, policy, lock state, and lifetime. + Requests for one tenant are serialized while different tenants remain independent. IPC never accepts a caller-selected + tenant identifier. +- Complete adversarial dump and plaintext scanning and upgrade/downgrade verification. +- Implement and verify equivalent protected memory, mutual peer authentication, package integrity, and process hardening + on Windows. + +### Approved platform service model + +- Linux uses one installer-managed, socket-activated, multi-tenant daemon under a dedicated `inflow` service account. +- Windows uses one installer-managed, on-demand, multi-tenant service under a dedicated Windows service identity. +- macOS retains the signed, hardened per-user daemon. A real same-user task-memory probe must remain in the packaged + smoke suite and must fail to obtain or read the daemon task. + +### Verified Linux implementation status + +The Linux service-account and multi-tenant bullets under Remaining core security work are implemented for ARM64: + +- systemd owns `/run/inflow/vault.sock`; Debian and RPM installation create the `inflow` account and state directories. +- The root authentication broker and unprivileged vault are two modes of `/opt/inflow/bin/inflow`, not separate + executable identities. +- The broker holds only `CAP_SYS_PTRACE`, `CAP_SETUID`, and `CAP_SETGID`. It authenticates the client executable and + kernel identity, answers a fixed Ed25519 machine-identity challenge, and transfers the accepted socket descriptor. It + does not parse vault frames or open tenant vault files. +- The vault runs as `inflow` with no permitted or effective capabilities. It verifies that transferred socket + credentials match the broker attestation before reading a vault frame. +- Root and an ordinary user completed packaged lock, unlock, API-key login, and status operations through the same + systemd socket. The ordinary user could not read the vault process memory, and the broker held no tenant-vault file + descriptors. +- The ARM64 Debian lifecycle passed initialization, upgrade, downgrade, credential persistence, broker-identity + continuity, capability checks, and persistent-file and resident-memory scans for raw UTF-8, Base64, hexadecimal, and + UTF-16 representations of the passphrase and stored API key. +- The emulated AMD64 build and native AMD64 release runner pass the root-owned standalone package smoke and the + installed Debian multi-tenant service smoke, including peer rejection, cross-user isolation, capability checks, and + persistent-file and resident-memory scans. +- The AMD64 RPM contains the same executable bytes as the tested Debian package, preserves the single-executable payload + without RPM stripping, uses portable systemd lifecycle scripts, starts successfully after extraction, and contains no + group- or world-writable runtime files. +- Debian and RPM installation fails closed instead of reusing a conflicting human `inflow` login account. A compatible + service identity is a non-root system user with `/var/lib/inflow` as its home and a non-login shell. +- IPC attachments use a fresh random mask before entering transport frames. The mask is not treated as transport + encryption; it prevents stale Node transport allocations from retaining raw, Base64, hexadecimal, or UTF-16 secret + representations after the tracked decoded buffer is wiped. +- Debian and RPM package transitions stop both service and activation socket before restarting socket activation. + Corrected package transitions shut down the broker and vault cleanly without privileged process signaling. +- The signed macOS package rejects a tampered native module, a fake daemon, an unsigned client, and same-user + task-memory access. Its isolated smoke can run alongside a normal user daemon, requires its own daemon process to exit + after logout, and scans persistent state for raw UTF-8, Base64, hexadecimal, and UTF-16 representations of the unlock + factor and stored API key. +- Signed macOS in-place upgrade, downgrade, and re-upgrade transitions preserve the encrypted vault and stored API key, + replace incompatible running daemons, require a fresh human unlock after daemon replacement, and leave no raw UTF-8, + UTF-16, Base64, lowercase hexadecimal, or uppercase hexadecimal secret representation in persistent vault files. + +### Verified Windows implementation status + +- The ARM64 native module uses Windows virtual memory locking, same-process memory protection, process mitigations, + Windows CNG for HKDF-SHA256 and AES-256-GCM, and the pinned Argon2 `20190702` reference implementation. +- The Windows production native-build path passes cross-platform key-derivation parity, authenticated-encryption + round-trip, and authentication-tag tamper rejection. +- Named-pipe peers are verified before vault protocol frames are transmitted. The client verifies the daemon before + sending a fixed non-secret authentication handshake; the daemon uses that handshake to establish the Windows + impersonation context and verifies the client before reading a vault frame. Verification binds the process identifier, + canonical executable path, full user security identifier, Authenticode publisher, and signing-certificate thumbprint. +- The client grants only `PROCESS_QUERY_LIMITED_INFORMATION` on its own process to the `NT SERVICE\InFlowVault` security + identifier. The unprivileged service receives no general process-inspection privilege. +- The signed ARM64 Windows Installer package installs an on-demand service under the passwordless + `NT SERVICE\InFlowVault` virtual account. Signed package installation, service readiness, authenticated vault status, + cancellable shutdown, executable/native-module integrity binding, and clean process exit pass in Windows 11 ARM64. +- Unit coverage exercises native-module integrity verification, mutual-authentication ordering, peer rejection, service + dispatch, pipe request handling, malformed request rejection, lock control, and clean shutdown. The full repository + test and coverage gate passes. +- The signed ARM64 package passes a reversible Windows security smoke that verifies the dedicated service identity, + active process mitigations, authenticated vault status, client-side daemon identity rejection, daemon-side foreign + client rejection before vault protocol data, native-module tamper rejection, restoration, and clean shutdown. +- The packaged client tolerates service startup and named-pipe instance replacement by retrying the complete + wait-and-open operation within a bounded deadline. Twenty-five consecutive packaged status requests pass in Windows 11 + ARM64. +- Windows vault database path validation uses canonical-path and file-type checks; the installer-managed service-only + access-control list protects the vault root. Unix user identifiers and permission bits are not treated as Windows + ownership evidence. A native Windows backend run reaches the integrity-gated key-derivation boundary after creating + its database. +- The signed ARM64 package passes first-run initialization, interactive unlock, API-key storage and retrieval through + the mutually authenticated native transport, explicit lock, re-unlock, and credential persistence in Windows 11 ARM64. + The stored credential is absent from persistent tenant vault files in raw UTF-8, UTF-16, Base64, lowercase + hexadecimal, and uppercase hexadecimal representations. +- With the pagefile disabled and verified inactive, the packaged credential-storage path leaves no matching raw UTF-8, + UTF-16, Base64, lowercase hexadecimal, or uppercase hexadecimal representation in readable committed memory owned by + the long-lived vault service. The virtual machine's original automatically managed pagefile configuration was restored + and verified active after reboot. +- The signed ARM64 package verifies unlock state through an independent status request before reporting success. Manual + Windows Terminal testing confirms correct lock and unlock behavior, rejection of incorrect PINs and passphrases, and + idempotent unlock without another credential prompt when the vault is already unlocked. +- The signed ARM64 MSI passes passphrase change, old-passphrase rejection, new-passphrase unlock, tenant reset, and + clean vault reinitialization. Signed `0.9.0` and `0.9.1` MSI fixtures pass upgrade with vault preservation, direct + downgrade rejection without state loss, supported uninstall/install rollback with vault preservation, and final + re-upgrade with vault preservation. + +Task 126 has no incomplete core-security verification. Linux and Windows production distribution work remains tracked +separately in `_QUEUE2-SIGNED-SECURE-CLI.md`. + +## Non-negotiable decisions + +- Do not use macOS Keychain for credential payload storage. +- Do not use SQLite SEE. +- Evaluate `better-sqlite3-multiple-ciphers` only as optional database-encryption defense-in-depth. +- Record-level vault encryption is mandatory. +- Use Argon2id first. `scrypt` is only an explicit fallback if Argon2id cannot pass package/signing/release constraints. +- Use AES-256-GCM for authenticated encryption. +- Use one SQLite database for searchable metadata and encrypted vault records. +- Use a minimal binary bootstrap sidecar, not JSON. +- The bootstrap sidecar stores only `salt`, `nonce`, `tag`, and `material`. +- `material` is AES-256-GCM encrypted random 32-byte vault master key material. +- Stable cryptographic choices are hardcoded in the signed binary. +- CLI/Core owns protocol behavior. The daemon owns secret custody only. +- The local vault is not a multi-owner database. Logout/reset remove local user state. +- `inflow auth logout` performs local first-install cleanup, equivalent to `inflow vault reset`, plus selected remote + cleanup when feasible. +- `inflow vault setup` does not exist. +- `inflow vault unlock` creates the vault on first use. +- Agent/MCP/non-TTY flows must not accept PIN/passphrase input. +- Daemon mode is hidden from help, docs, skills, generated language-model docs, MCP tools, shell completion, and + installer copy. + +## Source-backed current secret inventory + +The implementation must derive final `VaultSecretKind` values from source inspection before adding enum values. Do not +add generic protocol kinds without a storage caller. + +Current persisted secret references found in `packages/core/src/utils/storage.ts`: + +| Secret category | Source | +| ------------------------------- | ------------------------------------------------------- | +| Device-flow access token | `setAuth()` creates `auth-access-token`. | +| Device-flow refresh token | `setAuth()` creates `auth-refresh-token`. | +| Saved InFlow API key | `setApiKey()` creates `api-key`. | +| Pending device auth device code | `setPendingDeviceAuth()` creates `pending-device-code`. | +| AEP credential payload | `setAepState()` creates `aep-credential`. | + +Before finalizing `VaultSecretKind`, inspect: + +- AEP identity storage to determine whether any identity seed/private material is currently durable and secret-bearing. +- MPP/x402 flows to confirm payment credentials are not durable local secrets. + +Final rule: + +- Every `VaultSecretKind` must have at least one source-backed storage caller. +- Every source-backed durable secret storage caller must map to exactly one `VaultSecretKind`. +- Do not add `payment_credential` or `aep_identity_seed` unless source inspection proves durable secret persistence. + +## Batch 1 - Cryptographic vault core, no daemon + +Implement and test the crypto/storage primitives without IPC first. + +Tasks: + +- Evaluate `argon2` and `@node-rs/argon2` against: + - Node 24.15 + - package maintenance and dependency surface + - ESM compatibility + - native-module packaging + - macOS signing/notarization constraints + - future Windows/Linux artifact constraints +- Benchmark Argon2id: + - primary: 64 MiB memory, 3 iterations, parallelism 1, 32-byte output + - fallback only if necessary: 32 MiB memory, 3 iterations, parallelism 1, 32-byte output +- Implement binary sidecar parser/writer: + - fixed hardcoded format version + - fields: `salt`, `nonce`, `tag`, `material` + - reject missing, malformed, wrong-length, trailing, or truncated fields +- Implement master-key creation and unwrap: + - generate random 32-byte vault master key + - derive key-encryption key from PIN/passphrase with Argon2id + - wrap/unwrap `material` with AES-256-GCM + - hardcoded associated data such as `inflow-vault-header-v1` +- Derive separate keys from the unwrapped master key using domain separation: + - `inflow vault record encryption` + - `inflow sqlite database encryption` if database encryption is adopted +- Implement record-level AES-256-GCM encryption: + - per-record nonce + - tag + - ciphertext + - authenticated data binding for ref, kind code, record encryption version, and other retrieval-constraining metadata +- Implement versioned per-kind payload envelopes. +- Implement stable integer mappings: + - `VaultSecretKind` + - `VaultRecordStatus`: `1 = active`, `2 = pending`, `3 = deleting` +- Implement one SQLite database with: + - searchable protocol metadata tables + - exact-reference encrypted vault record table +- Implement reset/logout file cleanup: + - remove `inflow.sqlite3` + - remove `inflow.sqlite3-wal` + - remove `inflow.sqlite3-shm` + - remove the vault sidecar + - remove stale daemon socket/runtime artifacts + +Verification: + +- Argon2id package audit and benchmark result recorded. +- Sidecar malformed/tamper tests fail closed. +- Passphrase change rewraps only `material`. +- Old passphrase fails after change. +- Record tamper tests fail closed for modified ciphertext, nonce, tag, ref, kind, status, and encryption version. +- Payload-envelope tests cover round trip, malformed payloads, and unknown versions. +- Mapping tests reject unknown integer codes and reserve retired codes. +- Copied database cannot recover plaintext without the unlock factor. +- Reset/logout file cleanup removes database, write-ahead log, shared-memory, sidecar, and runtime socket artifacts. + +## Batch 2 - Generic `VaultBackend` + +Create the pluggable interface that CLI/Core protocol code will use. + +Interface scope: + +- `status` +- `unlock` +- `lock` +- `getPolicy` +- `setPolicy` +- `putSecret` +- `getSecret` +- `deleteSecret` +- `exists` +- `touch` +- `deleteExpired` + +Retrieval contract: + +```ts +getSecret({ + reference, + expectedKind, +}); +``` + +Rules: + +- `reference` is opaque, generated, and exact. +- Use a shape such as `vlt_` plus a ULID/UUID/random identifier. +- Do not encode user ID, Service DID, grant type, environment, credential type, or protocol fields in the portable ref. +- Backend implementations may map the opaque ref into provider-specific paths internally. +- Reject partial references. +- Reject search by metadata. +- Reject search by value. +- Reject list-payload/export/decrypt-arbitrary operations. +- Reject wrong kind, missing ref, pending ref, deleting ref, deleted ref, and expired ref. + +Verification: + +- AEP, MPP, x402, auth, API-key, and pending-auth storage depend on `VaultBackend`, not daemon internals. +- Every secret kind has a source-backed caller. +- Every caller has one kind. +- Tests prove no payload listing/export path exists. + +## Batch 3 - Local daemon IPC + +Implement the daemon as a secure dumb vault over local IPC. + +IPC: + +- Local operating-system IPC, not HTTP. +- macOS proof uses Unix domain socket under a user-owned private runtime directory. +- Use bounded length-prefixed JSON messages. +- Request shape: `{ version, id, method, params }`. +- Response shape: `{ version, id, ok, result | error }`. + +Allowed methods: + +- `vault.status` +- `vault.unlock` +- `vault.lock` +- `vault.changePassphrase` +- `vault.reset` +- `vault.getPolicy` +- `vault.setPolicy` +- `secret.put` +- `secret.get` +- `secret.delete` +- `secret.exists` +- `secret.touch` +- `secret.deleteExpired` +- internal `daemon.shutdown` + +Rejected method classes: + +- HTTP fetch +- AEP +- MPP +- x402 +- payment +- signing +- approval +- command execution +- raw-secret export/list/search + +Runtime behavior: + +- The daemon stores unwrapped key material only in memory while unlocked. +- Do not store key material as JavaScript strings. +- Base64 is not protection. +- Zero buffers on lock/exit where practical. +- Unlock retry throttling is daemon-memory-only. +- Restart clears retry throttle state. + +Verification: + +- IPC tests cover success, typed errors, malformed frames, max-size rejection, unknown version, unknown method, no batch + support, and secret redaction. +- Method-surface tests prove only approved methods are registered. +- Socket tests cover wrong permissions, wrong owner, symlink path, stale socket, and fake socket. +- Throttle tests prove delays during one daemon lifetime and reset after daemon restart. + +## Batch 4 - macOS peer verification + +Implement release-mode peer verification behind a platform adapter. + +Rules: + +- Release/package mode fails closed if peer verification cannot be completed. +- Development/test mode may bypass only through explicit development configuration. +- Same-user checks are defense-in-depth, not the complete boundary. +- No raw-secret/list/export API exists even for verified callers. + +macOS release verifier: + +- Require private runtime directory ownership and permissions. +- Require same effective user. +- Obtain peer process identifier through a native platform adapter. Node's public `net` socket API is not sufficient for + release-mode peer verification because it does not expose Unix-socket peer credentials or peer process identifiers. +- Resolve peer executable. +- Verify signed InFlow executable identity: + - expected Team ID + - expected designated requirement / signing identity +- Reject unsigned, wrong signer, wrong executable, missing peer process, failed resolution, unavailable native verifier, + and failed signature check. +- Do not use `lsof`, caller-provided process identifiers, caller-provided executable paths, or shared filesystem tokens + as the release-mode verifier. They may be test fixtures, but not the security boundary. + +Verification: + +- Packaged-build tests or smoke scripts cover accepted signed InFlow client. +- Negative tests cover unsigned client, wrong Team ID, wrong path, missing peer process, and process-exit race. +- Development bypass tests require explicit opt-in and are impossible in release mode. + +Current macOS evidence: + +- Ad-hoc packaged app loads native vault dependencies and fails closed when peer verification is required. +- Developer ID signed, non-notarized packaged app accepts the expected Team ID `B96U57DTR2`. +- Peer-verifier tests cover wrong Team ID through explicit verifier options, not environment overrides. +- Local Developer ID signing required interactive Keychain approval. +- Local notarization is not yet verified because the `inflow-notary` notarytool profile is missing. + +## Batch 5 - CLI/Core integration and command surface + +Commands: + +- `inflow vault status` +- `inflow vault unlock` +- `inflow vault lock` +- `inflow vault policy` +- `inflow vault set-policy` +- `inflow vault change-passphrase` +- `inflow vault reset` + +No command: + +- `inflow vault setup` + +First-run human prompt: + +```text +No InFlow vault exists yet. +Create a PIN or passphrase to protect local InFlow credentials. +``` + +Behavior: + +- Human TTY credential-using commands may initialize or unlock inline, then continue the original command. +- Agent/MCP/non-TTY commands must not accept PIN/passphrase. +- Agent/MCP/non-TTY uninitialized vault returns `VAULT_NOT_INITIALIZED` with human action to run `inflow vault unlock`. +- Agent/MCP/non-TTY locked vault returns `VAULT_LOCKED` with human action to run `inflow vault unlock`. +- The locked/uninitialized message must make clear that a human must run the command; the agent must not provide the + PIN/passphrase. + +Lock policy: + +- default idle timeout: 8 hours +- lock on sleep: true +- policy is deleted by logout/reset + +Logout/reset: + +- `inflow auth logout` performs local first-install cleanup and shuts down the vault daemon, plus selected remote + cleanup when feasible. +- `inflow vault reset` performs local first-install cleanup with no remote calls. +- Remote cleanup attempts only credentials that are long-lived or lack definitive expiration. +- Reset/logout first request daemon lock-and-shutdown. +- If daemon is absent, continue cleanup. +- If daemon is alive and refuses or times out, return `VAULT_DAEMON_BUSY` and do not delete database or sidecar files. + +Verification: + +- Command help/docs/MCP expose `inflow vault ...` commands. +- No public surface exposes daemon mode. +- Recursive scan proves `--daemon` is absent from help, README, skills, generated docs, MCP schemas, completions, and + installer copy. +- TTY first-run initialization tests pass. +- Agent/MCP `VAULT_NOT_INITIALIZED` tests pass. +- TTY locked inline unlock tests pass. +- Agent/MCP `VAULT_LOCKED` tests pass. +- No passphrase fields appear in MCP schemas, logs, or structured outputs. +- Logout/reset delete database, sidecar, write-ahead log, shared-memory, socket, public caches, vault policy, encrypted + records, daemon state, AEP state, pending auth, API key, and auth tokens. +- Next login starts with first-run vault creation and cannot inherit previous policy, records, caches, or unlock state. + +## Batch 6 - Optional encrypted SQLite evaluation + +Evaluate `better-sqlite3-multiple-ciphers`. + +Adopt only if all pass: + +- Node 24.15 compatibility. +- ESM compatibility. +- package maintenance and dependency audit. +- native-module signing/notarization. +- packaged macOS app smoke. +- future Windows/Linux artifact feasibility. +- deterministic release behavior. +- no weakening of record-level vault encryption. + +If adoption fails: + +- Do not block Task 126. +- Keep mandatory record-level vault encryption. +- Record encrypted SQLite as a follow-up hardening task. + +## Full verification gates + +Before handoff: + +- `pnpm typecheck` +- `pnpm lint` +- `pnpm test` +- `pnpm typedoc` +- `pnpm build` +- package/signing smoke where changed paths require it +- `git diff --check` + +Coverage: + +- Patch coverage must meet the repository Codecov gate before creating a pull request. +- Add tests for new error branches, helper branches, schema branches, and negative-security paths. + +Report: + +- List changed files. +- List exact commands run and outcomes. +- List any skipped checks explicitly. +- State whether encrypted SQLite was adopted or deferred and why. diff --git a/_QUEUE1.md b/_QUEUE1.md new file mode 100644 index 0000000..0a6a3be --- /dev/null +++ b/_QUEUE1.md @@ -0,0 +1,1093 @@ +# Queue 1 - AEP Agent CLI Integration Design + +This is the active working queue for a bounded delivery track. It is separate from any implementation plan and must not +be used as a historical changelog. + +## Goal + +Design the integration of the published `@aep-foundation/agent` package into `inflow-cli`, including the public CLI +surface, InFlow core boundary, identity-provider configuration, durable identity and credential storage, output +contracts, failure behavior, documentation, tests, and release work required before implementation begins. + +Queue type: Research + +## Definition Of Done + +The queue is complete when the current source-backed behavior is documented, the user has approved the CLI and +credential model, every command has a specified interactive and agent-mode contract, the implementation work is split +into bounded tasks, and the Queue Closure Rules are satisfied. + +## Completion State + +Current state: Design Complete; implementation and explicitly deferred enhancements remain + +Current cursor: 120 Next task: Produce the implementation handoff for the five AEP Service operations + +Decision Points: + +1. Decision: Stage commands by their actual dependency order after `aep inspect`. Context: The user approved every + identified Agent-side operation but requires staged delivery. Inspect is stateless and can ship without local AEP + persistence. Enroll is the first operation that needs a durable Service-scoped Agent identity and therefore + establishes the storage boundary used by Status, Grant, and Revoke. Examples: Stage 1 can deliver + `inflow aep inspect `. Stage 2 can establish storage and deliver `inflow aep enroll `. + Later stages can add one command or one tightly coupled pair at a time. Contract: Stage 1 is Inspect. Stage 2 + establishes identity storage and Enroll. Continue with Status, Grant, and Revoke. These are the five Service + operations defined by AEP. Authentication-header generation remains an internal software development kit capability, + not a CLI command. No Identity, request, headers, or resume command is part of this queue. Tier-aware Approval + presentation, Seller Service-DID administration, and enterprise secure storage remain separately tracked + implementation enhancements. + +2. Decision: Delegated signing for AEP Enroll reuses the refactored `REGISTER` approval classification. Context: + `Approval.requesterId` is nullable. The current seller dependency comes from the seller-authenticated + `/v1/requests/register` creation path and Register notification rendering, not from a non-null approval-table + constraint. The AEP path is also different from the earlier draft: approval is triggered by the Platform delegated + `sign` operation when `op` is `ENROLL`, because that is the point where the CLI asks InFlow to authorize and sign the + assertion used immediately before Service Enroll. The refactor can let `REGISTER` approvals carry a null requester + when the Service DID is not mapped to InFlow, while rendering the Service identity instead of assuming a Seller. + Examples: An unregistered Service produces `REGISTER`, `requesterId: null`, and a persisted Service DID used for + “Register with example.com”. A Service registered on InFlow can resolve that DID to its InFlow requester identifier, + preserving policy and audit linkage without making registration a prerequisite for AEP. Contract: Refactor Register + creation, policy matching, notifications, and presentation to support a Service-DID requester with an optional InFlow + requester identifier. The Service DID remains authoritative. Keep `LOGIN`/Grant as a separate later decision based on + the actual Grant signing and disclosure requirements. + +3. Decision: Optional `platform_context` carries Platform-local delegated-signing context without changing assertion + semantics. Context: The AEP Platform sign request currently has a closed schema containing only `jti`, `op`, + `service_did`, and optional `lifetime_seconds`. InFlow needs to return and receive its approval identifier during + asynchronous Sign. Other Platforms may need their own authorization, hardware custody, or compliance context. This + context may govern whether a Platform signs, but it must never be copied into or alter the defined client assertion + claims. Examples: `"platform_context": { "approval_id": "..." }` for InFlow; another Platform could use its own + fields. Contract: Add optional `platform_context` as a free-form object to the generic Platform sign request. Define + it as Platform-local authorization/custody input excluded from JWT claim construction and returned assertion + semantics. The software development kit transports it unchanged. Platform profiles, including InFlow, define its + members. + +4. Revised decision: Bound unfinished AEP signing context to 900 seconds. Context: Pending Approvals are purgeable after + 15 minutes, so requested personal-data context must not remain after the authorization can no longer complete. + Contract: Delete pending or orphaned AepSignContext no later than 900 seconds after creation. Delete context + synchronously on controlled cancellation or decline where possible, and delete purgeable contexts before the matching + bulk Approval deletion. Retain an approved context with its non-purgeable approved Approval for audit and completion + integrity. SigningAudit remains independently durable. + +5. Revised decision: Persist a narrow typed binding in `AepSignContext`. Context: Approval holds the approval envelope + and UserDetails holds InFlow's approved disclosure categories. AepSignContext must contain only what delegated sign + needs to prove that `platform_context.approval_id` authorizes this exact AEP request. It must also preserve + normalized AEP claim names so approved values can be emitted with the correct protocol keys. Examples: Bind Agent + identity identifier, Service DID, operation, normalized requested AEP claim names, request fingerprint, approvalId, + created/updated/expiry, and completed-signing state. Do not duplicate userId, requesterId, ApprovalType, display, + notification type, or UserDetails from Approval. Contract: Persist approvalId, agentIdentityId, Service DID, + operation, normalized requested AEP claim names, canonical request fingerprint, created/updated/expiry timestamps, + and completed-signing state. Use a validated converter-backed AEP claim-name collection rather than an arbitrary + request JSON blob. Recompute and compare the canonical fingerprint on every sign attempt. Do not duplicate userId, + requesterId, ApprovalType, display, notification type, or UserDetails from Approval. + +6. Decision: Create authorization inside Sign and pass InFlow-local input through platform_context. Context: + AepPlatformController implements the AEP-defined Platform surface. InFlow interprets platform_context during Sign + without changing client-assertion claims. Examples: The initial generic Sign request carries InFlow's normalized + requested claim names under platform_context. InFlow creates AepSignContext and final Approval, then returns either + completed Sign or pending Sign with approval_id and retry guidance. The completion request carries approval_id and + loads the immutable server-side binding. Contract: The initial Sign request carries normalized requested claims in + platform_context. InFlow creates AepSignContext plus final REGISTER Approval directly. A pending Sign outcome returns + platform_context containing the InFlow approval identifier; the CLI polls the existing Approval endpoint and sends + the returned context on completion. + +7. Decision: Generic Platform Sign supports HTTP 202 pending with returned platform_context and retry guidance. Context: + The current Platform Sign schema has no idempotency_key, the software development kit sends no Idempotency-Key + header, and the specification defines only the completed response. jti is a JWT replay identifier, not a specified + HTTP idempotency key. Combining preparation and Sign requires a generic pending response that can return + Platform-local continuation context. Examples: Initial Sign with idempotency key K1 returns HTTP 202 and + platform_context containing approval_id. Exact retries with K1 return the same pending result. After approval, + completion Sign includes returned platform_context and a new idempotency key K2; exact retries with K2 return the + same completed assertion. Contract: HTTP 202 carries status pending, platform_context, and retry_after_seconds; HTTP + 200 retains the existing completed response. Use a new idempotency key for the completion-stage request because its + body includes the server-returned continuation context; retries within either stage reuse that stage's key. + +8. Decision: Use Idempotency-Key headers holistically across retry-sensitive Platform operations. Context: AEP Service + lifecycle commands currently duplicate the key in the Idempotency-Key header and request body, requiring equality, + but the Platform Hosted Identity API is a separate surface. Platform Provision normatively requires body + idempotency_key. Duplicating the Sign key in both locations adds validation and mismatch failure modes without adding + semantics if no intermediary depends on the header. Source finding: The broader onboarding design places idempotency + in the binding: HTTP mandates Idempotency-Key, while MCP/JSON-RPC mandates params.idempotency_key. The current core + draft explicitly says the optional Enroll body copy exists for bindings or application frameworks that persist + idempotency metadata with the body. No source-backed rationale was found for Platform Provision making the body field + normative. Contract: Before the unposted Platform draft is published, remove body idempotency_key from Provision and + require Idempotency-Key for Provision, delegated Sign, and hosted Verification. Hosted Verification needs safe retry + because it consumes assertion replay state. List and lifecycle GET calls remain keyless. Lifecycle PATCH remains + naturally idempotent while it only sets a requested state. A future non-HTTP Platform binding defines its own + transport location. This does not change the separate core Service-command contract. + +9. Decision: Retain Platform idempotency results for at least one hour. Context: The core Service-command draft requires + at least one hour. Platform Provision creates durable identity, Sign may remain pending through human approval, and + hosted Verification consumes replay state. A retry after the cache expires can create a second identity, approval, + assertion, or changed verification result unless durable domain uniqueness independently prevents it. Examples: A + Sign initiation can remain pending longer than one hour if the user does not approve promptly, but the current InFlow + pending Approval lifecycle is only 15 minutes. Provision should remain protected by its durable caller/service + uniqueness even after the cached response expires. Contract: Require at least one hour consistently in the generic + Platform draft, matching AEP core, while requiring implementations to enforce durable operation invariants beyond + cache expiry. InFlow may retain records longer according to its session and audit policies without making 24 hours a + universal interoperability burden. + +10. Decision: Scope a shared generic Platform idempotency cache by authenticated principal plus Idempotency-Key. + Context: A raw key cannot be globally unique across tenants, identities, and endpoints. The store must distinguish + Provision, Sign initiation, Sign completion, and hosted Verification while detecting changed material input. It must + not hash Authorization headers or other rotating credentials into the operation fingerprint. Examples: Scope a + record by authenticated principal plus operation/endpoint plus Idempotency-Key. Fingerprint a canonical + representation of material path parameters and request body. For Sign, include agent identity path id, jti, + lifetime, op, service DID, and platform_context; for Verification, include the assertion, op, and Service DID. + Contract: Lookup by authenticated principal plus Idempotency-Key across Provision, Sign, and hosted Verification. + Each cache record stores the originating normalized operation, cryptographic hash of canonical material path/body + input, complete replayable HTTP result, and expiry. Reuse under another operation or changed fingerprint returns + idempotency_conflict. Exclude authentication credentials, transport-only headers, server-generated timestamps, and + the Idempotency-Key itself from the fingerprint. + +11. Decision: InFlow uses the stable authenticated userId as the shared cache principal. Context: A user may + authenticate through refreshed tokens, API keys, or different clients. Keying by the presented credential would + break retries after credential rotation; keying only by a global key would permit cross-tenant collisions. The + generic Platform draft cannot prescribe InFlow's user model but must require a stable authorization principal scope. + Examples: InFlow uses the authenticated userId, not the access-token identifier or OAuth client identifier. A + multi-tenant Platform may use tenantId plus owner/accountId when account identifiers are not globally unique. + Contract: InFlow keys the shared cache by authenticated userId plus Idempotency-Key. Access tokens, API keys, OAuth + clients, and token identifiers authenticate the user but do not change the namespace. The generic Platform draft + describes this as the stable authorized principal, including tenant scope where identifiers are not globally unique. + +12. Decision: Pending Sign carries retry timing only in the JSON body. Context: HTTP 202 already communicates pending + state, and HTTP defines Retry-After for retry timing. The JSON body must carry platform_context so the Agent can + return the Platform's continuation data. Duplicating retry timing in both a header and body creates the same + ambiguity rejected for idempotency. Examples: HTTP 202 with Retry-After: 5 and body + `{ "status": "pending", "platform_context": { ... } }`. Contract: HTTP 202 body contains status pending, required + platform_context, and retry_after_seconds. Do not send Retry-After for this contract. The software development kit + reads retry timing directly from the validated response body. + +13. Decision: retry_after_seconds is a required positive decimal string on the wire. Context: The Platform draft + represents bounded numeric protocol values such as lifetime_seconds and page counts as decimal strings. Making retry + guidance optional forces each caller to invent a polling default, while requiring it gives deterministic behavior. + Examples: `{ "status": "pending", "platform_context": {...}, "retry_after_seconds": "5" }`. Contract: Follow + existing AEP-owned numeric conventions. The software development kit validates the wire string and exposes a numeric + retryAfterSeconds value to callers. Clients do not invent a missing default. + +14. Decision: retry_after_seconds allows 1 through 300 seconds. Context: Zero would permit a tight polling loop. An + unbounded value could cause a generic Agent to wait indefinitely or overflow a runtime timer. The field is polling + cadence, not the approval expiry or total CLI timeout; callers retain their own overall deadline. Examples: `"5"` + requests a five-second cadence. A Platform needing long-running approval can continue returning pending while the + caller polls at that cadence; it does not need a one-hour retry interval. Contract: The schema accepts decimal + strings from "1" through "300". The software development kit rejects an out-of-range Platform response and never + clamps it. The range controls cadence only; callers enforce total timeout. + +15. Decision: Require an explicit status discriminant on both Sign response variants. Context: The current completed + Sign response has no status field; completion is inferred from HTTP 200 and the presence of client_assertion. The + new pending body uses status pending. A symmetric wire union is easier to parse, validate, log, and transport + through abstractions that do not retain HTTP status, but requiring status completed changes the currently published + software development kit contract before the Platform draft is posted. Examples: HTTP 200 body starts with + `"status": "completed"`; HTTP 202 starts with `"status": "pending"`. Contract: HTTP 200 requires status completed + and the existing assertion fields. HTTP 202 requires status pending, platform_context, and retry_after_seconds. The + specification, schemas, vectors, and software development kit release change together before the Platform draft is + posted. + +16. Decision: platform_context is optional and present only when it contains data; InFlow completed Sign uses it for + approved claims. Context: Pending platform_context is a continuation value that lets the Agent complete + Platform-local authorization. Once an assertion is issued, no continuation is required. Returning InFlow's approval + identifier after completion may aid diagnostics but also retains and propagates Platform-local correlation data + beyond its operational need. Examples: Completed can contain only status plus the standard assertion fields, or + optionally echo final platform_context. Contract: The generic Sign request, pending response, and completed response + may carry platform_context. Omit the field when the object would be empty. An InFlow pending response includes + approval_id. An InFlow completed ENROLL Sign response includes approved_claims constructed from AepSignContext's + exact names and the approved UserDetails. + +17. Decision: InFlow uses mutually exclusive initiation and completion platform_context shapes. Context: The first Sign + call supplies requested claim names and creates AepSignContext plus REGISTER Approval. The pending response returns + approval_id. After approval, the completion Sign call needs only that server-issued identifier because the session + already persists the normalized claim binding. Allowing all fields simultaneously creates ambiguous behavior and + lets a completion request attempt to replace approved claims. Examples: Initiation context + `{ "claims": { "required": ["email"], "preferred": [], "optional": [] } }`; completion context + `{ "approval_id": "..." }`. Contract: Presence of approval_id selects completion and forbids claims; absence selects + initiation and requires the structured claims object. The server loads the stored AepSignContext during completion + and never accepts client changes to approved disclosure. + +18. Decision: Send required, preferred, and optional Inspect claims as a structured tiered object to InFlow. Context: + The AEP implementer guide resolves claims.required in the typical Enroll sequence. The core privacy section requires + minimum disclosure. InFlow's current Approval shares the requested UserDetails as one approval; it does not provide + per-claim selection for preferred or optional claims. Examples: For required email, preferred name, and optional + physical address, the CLI can request only email; email plus name; or all three. Contract: inflow-cli copies the + normalized Inspect claims tiers into platform_context.claims without flattening. inflow-server validates and + persists that tiered structure in AepSignContext, then flattens the currently supported mapping into the existing + Approval UserDetails set. The existing approval is all-or-nothing for the flattened set. A later approval-system + refinement consumes the already-tiered API without changing its request shape. + +19. Decision: Reject Inspect documents whose claim tiers overlap. Context: The structured request preserves required, + preferred, and optional arrays. Neither flattening nor choosing a tier silently should repair an ambiguous Service + advertisement because tier controls future approval and privacy behavior. The best boundary may be the AEP Inspect + validator rather than InFlow-specific server code. Examples: `email` appears in both required and preferred. + Contract: Specify required, preferred, and optional as pairwise-disjoint arrays. Enforce this in aep-node Inspect + validation before the CLI calls the Platform. Do not normalize duplicates or delegate repair to inflow-server. + +20. Decision: Keep AEP claim-name aliases and bidirectional category mapping in UserDetail. Context: The normative core + draft explicitly does not define a complete claim catalog; its concrete example is contact.email. The broader + onboarding design contains person._, contact._, company.\*, and other namespaces, while InFlow UserDetail exposes + birthdate, deposit addresses, email, mobile, name, national ID, physical address, and username. Inventing local + aliases would create non-portable Services; moving every InFlow field into core would over-expand the narrow base + protocol. Examples: contact.email maps cleanly to EMAIL; contact.mobile maps to MOBILE; person.first_name and + person.last_name share InFlow's NAME approval category but must emit only the individually requested AEP keys. + Contract: Add the supported claim-name mapping to UserDetail.java. UserDetail resolves exact AEP claim names to the + internal approval category and exposes the claim names associated with each category. Do not create a separate + mapping service or require a Foundation claim-catalog specification for this integration. + +21. Decision: Emit only exact claim names originally requested and approved through their mapped UserDetail. Context: + NAME can authorize person.first_name and person.last_name, and physical-address approval may authorize several + address-shaped aliases. Emitting every alias associated with an approved category would disclose fields the Service + did not request. Selecting one canonical alias would fail to honor a different exact requested name. Examples: Only + person.first_name is requested; Approval authorizes NAME. The Enroll claims object must contain only + person.first_name, not person.last_name. Contract: Preserve exact tiered request names in AepSignContext. A broad + approval such as NAME authorizes its mapped requested members, but claim construction iterates the original names + and emits only those exact keys. UserDetail owns the name/category vocabulary; AepSignContext owns which names were + requested. + +22. Decision: inflow-server owns claim mapping and exact approved-claim construction. Context: ApprovalController + currently returns generic ApprovedDetails fields such as firstName and lastName. If the CLI converts those fields + into AEP names, the UserDetail claim mapping is duplicated in TypeScript and can drift. The server has both + AepSignContext's exact request names and UserDetail's mapping/extraction behavior. Examples: An AEP-backed approved + REGISTER response can include `approved_claims: { "person.first_name": "Ada" }` while retaining existing + approvedDetails for non-AEP consumers. Contract: The server receives the structured tiered claim request, resolves + names through UserDetail, creates the flattened Approval UserDetails, preserves exact names in AepSignContext, and + later constructs exact approved_claims. inflow-cli does not contain a duplicate mapping table. + +23. Decision: The initial delegated Sign call is the server-side claim-mapping endpoint. Context: The combined design + already sends platform_context.claims to AepPlatformController.sign. That method can validate aliases, persist + AepSignContext, flatten UserDetails, and create REGISTER Approval before returning pending. A separate mapping + endpoint would restore the preparation round trip and need its own authentication, idempotency, persistence, and + response contract. Examples: One-call flow is `sign(claims) -> pending approval`; two-call flow is + `prepare/resolve(claims) -> token`, then `sign(token) -> pending approval`. Contract: Initial sign validates + platform_context.claims, resolves exact aliases through UserDetail, persists AepSignContext, flattens mapped + categories into Approval UserDetails, creates REGISTER Approval, and returns completed or pending. No separate + preparation or mapping endpoint is introduced. + +24. Revised decision: Reject unsupported required claims and silently drop unsupported non-required claims. Context: + Unsupported required claims make the Service contract unsatisfiable. Preferred and optional claims are not + prerequisites, and the server—not the CLI—now owns mapping. The pending response can return structured omission + diagnostics inside InFlow's platform_context alongside approval_id. Examples: required contact.email maps; optional + urn:example:risk-score does not. Pending context can contain approval_id plus + `unsupported_claims: { "preferred": [], "optional": ["urn:example:risk-score"] }`. Contract: The API request uses + tiered List values. Reject before creating AepSignContext or Approval when a required name is unsupported. + Silently drop unsupported preferred/optional names. After validation, map recognized names to AepClaim and persist + only bounded AepClaims tiers. No unsupported diagnostics are persisted or returned. + +25. Decision: Unsupported required claims return requirements_unmet. Context: AEP core already defines + requirements_unmet as HTTP 422 for required claims that are missing or invalid. Platform endpoints are instructed to + reuse core Problem Details codes where semantics match. invalid_request would classify a valid but unsatisfiable + claim request as malformed; a new unsupported_claim code would duplicate existing semantics. Examples: HTTP 422 + application/problem+json with code requirements_unmet and a tiered unsupported_claims extension containing the exact + required names. Contract: Return HTTP 422 application/problem+json with code requirements_unmet and exact names + under unsupported_claims.required. Do not expose user claim values. Exact idempotent retries return the cached same + error. + +26. Corrected decision: The final completed Sign response returns exact approved AEP claims in platform_context. + Context: The CLI already polls authenticated GET /v1/approvals/{approvalId}. ApprovalResponse currently returns + approvedDetails for approved identity-type approvals. AepSignContext and UserDetail give the server enough + information to construct only the exact requested AEP keys. A new endpoint would duplicate approval status and user + scoping; completed Sign should remain assertion-focused. Contract: The CLI polls the existing Approval endpoint only + to observe pending/approved/declined state. After approval it calls Sign completion with approval_id. InFlow + validates the Approval and AepSignContext, issues the assertion, and returns platform_context.approved_claims + containing only exact requested and approved keys. Do not add approved_claims to ApprovalResponse and do not create + an AEP-specific polling endpoint. + +27. Decision: Completed Sign returns approved claims and omits unsupported non-required diagnostics. the approval + identifier. Context: The initial pending response does not need to report unsupported non-required claims; final + Sign loads the immutable typed context by approval identifier. approval_id and completion idempotency state. + Returning the diagnostics again with approved_claims makes the final result self-contained; omitting them minimizes + response data but requires local continuation storage. Examples: completed platform_context can contain + approved_claims plus tiered unsupported_claims. Current contract: Completed platform_context contains + approved_claims only. Unsupported preferred/optional names are silently dropped during initial server mapping and + are not persisted or returned. + +28. Revised decision: AepSignContext owns approvalId; ApprovalFlags.BIT_AEP indicates the associated context exists. + Context: Combined Sign creates AepSignContext and final Approval together. Completion starts from approval_id, then + must load the exact AEP binding. Existing Approval owns nullable foreign keys to PolicySession and + TransactionSession. AEP needs one session per Approval and must prevent accidental reuse across approvals. Contract: + AepSignContext has a unique, indexed approvalId, following PolicySession's lookup pattern. Approval adds no nullable + AEP-context foreign-key column. ApprovalFlags gains BIT_AEP. Completion first loads Approval scoped to userId, + requires the AEP flag, then loads AepSignContext by approvalId. The flag is a discriminator, not proof: a flagged + Approval with no session is an internal integrity failure and must never sign. Session and flagged Approval are + created atomically in one writer transaction. + +29. Decision: Accept claim tiers as List on the wire, then persist recognized claims as AepClaim/AepClaims. + Context: Raw claim strings should be parsed once at the server boundary and represented by typed values internally. + This follows the existing enum plus EnumListModel collection pattern used by Currency/Currencies and + UserDetail/UserDetails. Contract: The request model uses List for required, preferred, and optional so + unsupported required names can be detected. Add AepClaim with exact wire-name parsing and AepClaims with persistence + conversion. After required validation and non-required filtering, AepSignContext stores AepClaims required, + preferred, and optional. UserDetail maps to and from AepClaim, not strings. Every Approval created by AEP Sign sets + ApprovalFlags.BIT_AEP. + +30. Decision: The CLI polls the existing generic Approval GET endpoint for status only. Context: Approval polling and + BIT_AEP are unrelated API concerns. The endpoint gains no AEP-specific fields or response behavior. The CLI reads + status and ignores unrelated generic ApprovalResponse fields. APPROVED triggers the final Sign call; completed Sign + returns the assertion and exact approved claims. Contract: BIT_AEP remains an internal Approval discriminator + indicating that AepSignContext exists. It is used by server-side AEP completion/integrity logic, not exposed to or + interpreted by the polling client. + +31. Decision: Do not persist unsupported claim names. Contract: Unsupported required names fail before persistence. + Unsupported preferred/optional names are silently dropped. AepSignContext contains only typed AepClaims collections + recognized by the Platform. + +32. Decision: Support the complete initial AepClaim-to-UserDetail mapping. Contract: Add + PERSON_BIRTHDATE/person.birthdate to BIRTHDATE; FINANCIAL_DEPOSIT_ADDRESSES/financial.deposit_addresses to + DEPOSIT_ADDRESSES; CONTACT_EMAIL/contact.email to EMAIL; CONTACT_MOBILE/contact.mobile to MOBILE; + PERSON_FIRST_NAME/person.first_name and PERSON_LAST_NAME/person.last_name to NAME; + PERSON_NATIONAL_ID/person.national_id to NATIONAL_ID; CONTACT_ADDRESS_PRIMARY/contact.address.primary to + PHYSICAL_ADDRESS; and PERSON_USERNAME/person.username to USERNAME. Exact requested claims control final emission. + +33. Decision: Approval.status owns lifecycle; AepSignContext stores nullable signedAt only. Context: Approval already + owns pending, approved, declined, cancelled, and terminal lifecycle. Duplicating those states in AepSignContext + creates synchronization risk. The signing context still needs to record whether its one approved completion has + issued an assertion so another completion idempotency key cannot sign twice. Examples: Keep Approval.status + authoritative and store nullable signedAt on AepSignContext. A null signedAt means an approved session has not + issued; non-null means terminal signing completion. Contract: Do not duplicate pending/approved/declined/cancelled + status. signedAt records assertion issuance as a separate fact. Persist signedAt with signing audit and completed + idempotency result as one logical transaction. + +34. Decision: Name the signing record AepSignContext. Context: The record no longer represents a separately prepared + session. It stores the typed claim tiers and binding context connecting initial Sign, Approval, and final Sign. + Approval owns pending/approved/declined/cancelled state. Contract: Use AepSignContext consistently for entity, + service, repository, manager, migration, and internal types. The name describes the binding context between initial + Sign, Approval, and final Sign without implying an independent approval lifecycle. + +35. Decision: Use set-based AepSignContext deletion before Approval batch deletion. Context: DeleteApprovalManager + performs set-based deletes by Approval status and age, so it cannot inspect BIT_AEP row-by-row. Duplicating Approval + status into the child record solely for cleanup risks drift. Loading and deleting individual Approval entities would + regress batch performance. Contract: In the same writer transaction, bulk-delete contexts whose approvalId is + selected by the same Approval status/age predicate, then run the existing Approval bulk delete. Synchronous deletion + deletes context by approvalId first; a missing child is a no-op. Do not load rows individually or duplicate Approval + status. + +36. Decision: Initial Sign and final Sign are separate completed operations with separate idempotency keys. Contract: + Once initial Sign returns pending, that request is complete and its key is no longer part of the polling or + final-Sign discussion. After polling reaches APPROVED, final Sign uses a new unique Idempotency-Key. Network retries + of final Sign reuse that same key. Successful final Sign sets signedAt and consumes the AepSignContext; no + additional repeated-issuance workflow is designed. + +37. Decision: Final Sign uses state-specific Approval outcomes. Context: The normal CLI calls final Sign only after + polling sees APPROVED, but status can race or callers can invoke the API directly. The server must fail closed + without treating approval_id as authorization by itself. Examples: PENDING can remain a 202 pending Sign result; + DECLINED/CANCELLED are explicit authorization denial; missing, wrong-user, non-AEP, or missing-context records + remain indistinguishable. Contract: PENDING returns 202 pending with the same approval_id context. APPROVED with + valid AEP context returns 200 completed. DECLINED/CANCELLED returns 403 authorization_denied. Missing, wrong-user, + or non-AEP returns 404 not_recognized. BIT_AEP with missing context fails closed as an internal integrity error. + +38. Decision: Resolve AEP REGISTER requesterId from a unique Seller.serviceDid column. Context: No current Seller or + User model stores a Service DID, so inflow-server has no authoritative DID-to-user mapping. Inferring ownership from + the did:web hostname and Seller website/domain would be unverified and unsafe. AepSignContext already preserves the + authoritative Service DID for presentation and audit. Contract: Add nullable unique serviceDid with length 256 to + Seller and exact lookup methods in Seller repositories and service. Initial Sign looks up the validated Service DID; + a match supplies Seller.sellerId as requesterId, while no match leaves requesterId null and does not block AEP. Do + not infer from website/hostname. Population and ownership verification are explicitly outside this delivery. + +39. Decision: Resolve only APPROVED Sellers by Service DID. Context: SellerStatus has ONBOARDING, REVIEW, and APPROVED. + Associating an unapproved Seller with an external Service Approval may present an identity that InFlow has not + accepted yet and may affect requester-scoped Register policy. Examples: A serviceDid match exists on a REVIEW + Seller. Contract: Seller lookup requires exact serviceDid and SellerStatus.APPROVED. ONBOARDING, REVIEW, or no match + leaves requesterId null. The authoritative Service DID still drives presentation and AEP continues. + +40. Decision: Show mapped Seller.name or fall back to the validated did:web hostname. Context: Existing Register + notifications dereference Seller.name. AEP permits requesterId null, so presentation must work without Seller. When + a verified APPROVED Seller mapping exists, its configured name is more human-friendly than a DID, but the Service + DID remains the authoritative protocol identity. Examples: Mapped `did:web:api.example.com` shows “Register with + Example, Inc.”; unmapped shows “Register with api.example.com”. The full DID remains available in detail/audit + views. Contract: Use Seller.name when an approved exact serviceDid mapping exists; otherwise derive the human label + from the validated did:web host. Preserve the full Service DID as secondary detail/audit data. Never dereference + Seller unconditionally. + +41. Revised decision: A new `inflow aep enroll` never resumes a prior enrollment. Context: MPP and x402 use interval + default 0, maxAttempts default 0, and timeout default 900. Zero returns pending immediately with a follow-up + command; a positive interval polls inline. AEP also receives server retry_after_seconds, but no separate AEP + approval-status command or durable resumable Enroll journal is needed. Contract: Every Enroll invocation starts a + new logical operation with new idempotency keys and may create a new Approval. It never searches for or consumes a + prior pending journal entry merely because Service DID and claims match. Interrupted, timed-out, or otherwise + incomplete attempts are abandoned, local operation state is removed, and later Enroll starts over. + +42. Decision: AEP Enroll has no resume command or continuation surface. Contract: Do not add aep resume, enroll + --resume, nested resume, or Approval-status continuation. A later Enroll is always new. Controlled interruption or + timeout best-effort cancels an existing Approval, then removes local operation state. Server cleanup removes + cancelled or crash-abandoned Approval/AepSignContext records. + +43. Decision: `inflow aep enroll` polls inline until completion or abandonment. Context: Final Sign and Service Enroll + run in the CLI. If the command returns while Approval is pending and resume is forbidden, no supported actor remains + to finish the operation. Therefore MPP/x402's interval=0 nonblocking pattern does not fit this command. Contract: + Both agent and TTY modes remain in the command until completed, declined/cancelled, timed out, or interrupted. Use + retry_after_seconds unless --interval overrides it. Keep timeout and max-attempts as interruption bounds. Do not + expose interval=0 nonblocking behavior. Any non-completed attempt is abandoned and removed as defined above. + +44. Decision: Return external Service pending as a valid Enroll outcome. Context: Service enrollment pending occurs + after InFlow approval and final Sign have completed. It is AEP lifecycle state, not an interrupted Approval + workflow. Contract: Return pending immediately and preserve the Agent identity. Retain the complete validated + Service Enroll response only for the current command result; do not persist it. A later AEP Status command fetches + active or rejected state from the Service. Do not remove identity or treat pending as CLI failure. + +45. Revised decision: Preserve the full Service lifecycle response in Enroll agent JSON while keeping Enroll human + rendering action-oriented and concise. Context: This is command presentation only, not storage. The Service owns + fetchable enrollment lifecycle state. Locally, the CLI persists only the Service-DID-to-Agent-identity mapping + needed for later authenticated operations. Enroll response details, approved claims, and Service status remain + ephemeral. EnrollResponse and StatusResponse already share status, owner_action_required, and requirements_pending; + Status can additionally contain since. Rationale: The Service's AEP Enroll response should mirror or remain closely + related to the AEP Status response. Agent-mode CLI JSON preserves that complete validated response because agents + need the protocol result for subsequent orchestration. Human rendering is a separate presentation contract: Enroll + is an action and should report its outcome concisely; a human who wants the verbose lifecycle view can run Status. + Contract: Persist only the canonical Service-DID-to-Agent-identity mapping. Do not persist Enroll response details, + approved claims, or Service lifecycle status. Enroll reports an action-oriented enrollment status; Status fetches + and presents the fuller lifecycle response from the Service. Do not conflate the Service response, agent JSON + projection, and human printable rendering. + +46. Decision: Enroll agent JSON returns the complete validated AEP Enroll response. Contract: Do not reduce the agent + result to status alone and do not invent an InFlow next_action vocabulary. Preserve the full protocol response, + including lifecycle fields shared with Status and valid extensions. This remains ephemeral command output; only the + Service-DID-to-Agent-identity mapping is persisted locally. + +47. Decision: Platform delegated Sign requires InFlow approval for ENROLL and GRANT only. Contract: ENROLL and GRANT + Sign requests enter the applicable approval flow before assertion issuance. STATUS and REVOKE Sign requests do not + create or poll an Approval; after authentication, authorization, request validation, and idempotency checks, the + Platform signs them directly. Keep the generic AEP Sign wire contract independent from InFlow's operation-specific + approval policy. + +48. Decision: Confirm the remaining Enroll edge contracts. Contract: A valid rejected Service response is a protocol + outcome. A later Enroll reuses the existing Service Agent identity but never resumes an interrupted operation. + Enroll agent JSON adds no InFlow envelope or schema_version. Persistence failure after remote Enroll success is + reported as a distinct partial-success failure. A rejected repeat Enroll does not delete a pre-existing + Service-to-Agent identity mapping because identity ownership is distinct from cached Service enrollment status. + +49. Decision: Status is a read-only Service lifecycle command over an existing local Agent identity. Context: AEP Status + is authenticated with an assertion whose operation is STATUS. The Service is authoritative for enrollment lifecycle + state, and the CLI intentionally persists no lifecycle snapshot. Creating an identity while asking for Status would + make a read command mutate Platform and local state. Contract: `inflow aep status ` uses the same + forgiving Service-origin normalization as Inspect, resolves the canonical Service DID, requires the existing local + Service-DID-to-Agent-identity mapping, obtains a directly signed STATUS assertion from the Platform without + Approval, calls the software development kit's Status operation, and returns the complete validated Status response + in agent JSON. Human output presents the detailed lifecycle view, including status, since, owner action, pending + requirements, and meaningful recognized extensions. Missing local identity fails before Platform Sign or Service + Status and does not provision automatically. Status performs no local persistence and preserves valid unknown + response extensions in agent JSON. + +50. Revised decision: Preserve distinct verification and requirement dimensions across Enroll and Status. Context: The + broader onboarding design distinguishes `verification_pending` from `requirements_pending`. `verification_pending` + names claims whose asynchronous verification remains incomplete after Enroll and can require Owner action. + `requirements_pending` names claim categories the Agent still needs to provide because current Service requirements + are unsatisfied, including requirements introduced after enrollment. Contract: `verification_pending` and + `requirements_pending` are distinct concepts, not aliases. Both EnrollResponse and StatusResponse may carry both + optional fields, subject to the response-presence and omission rules decided in the specification design session. + `owner_action_required` independently indicates an out-of-band Owner step. Correct specifications, schemas, + examples, AEP Node types/parsers, conformance fixtures, Service models, and tests together. + +55a. Decision: Allow both lifecycle dimensions on both Enroll and Status responses. Contract: EnrollResponse and +StatusResponse each permit optional `verification_pending` and `requirements_pending`. Enroll can report submitted +claims awaiting verification and additional requirements known at enrollment time. Status is the canonical polling +surface and reports how both dimensions evolve. Status may also contain `since`; the response types remain closely +related but distinct. + +55b. Decision: Omit empty lifecycle arrays. Contract: Include `verification_pending` and `requirements_pending` only +when the respective collection is non-empty. Absence means the corresponding pending set is empty. Parsers may normalize +absence to an empty internal collection; serializers omit empty collections. Consumers accept absent fields and unknown +valid extensions remain preserved. + +55c. Decision: Treat owner action as an independent lifecycle signal. Contract: `owner_action_required` states whether +the Owner must currently perform an out-of-band action. It may accompany verification_pending, requirements_pending, +both, or neither. It does not move or classify entries between the arrays. A Service may set it without naming the +underlying item when disclosure would be inappropriate. + +55d. Decision: Canonically omit owner_action_required when false. Contract: Serialize `owner_action_required: "true"` +only while Owner action is currently required. Omission means false. Consumers accept explicit `"false"` for +compatibility, but canonical serializers omit it. Parsers may normalize absence to false internally. + +55e. Decision: Keep lifecycle fields distinct from blocking Problem Details codes. Contract: Enroll may successfully +return pending lifecycle state, and Status remains callable to observe it. `verification_pending` Problem Details means +another authenticated operation cannot proceed because necessary verification is incomplete. `requirements_unmet` means +required information is missing or invalid. Pending state does not make every operation fail. Revoke remains permitted +for credential invalidation; Grant may be blocked when the condition prevents safe issuance. + +55f. Decision: Blocking Problem Details may carry actionable lifecycle extensions. Contract: After the Agent identity is +authenticated and recognized, `verification_pending` errors may include a non-empty verification_pending array and +`requirements_unmet` errors may include a non-empty requirements_pending array. Either may include owner_action_required +only when true. Do not include empty arrays or claim values. Recognition failures remain not_recognized without these +extensions. + +56. Decision: Define Sign policy for every current AEP operation and fail closed for unknown operations. Contract: + INSPECT is unauthenticated and never uses Platform Sign. ENROLL requires REGISTER Approval. GRANT requires an + approval flow whose user-facing classification is designed with Grant. REVOKE and STATUS are signed directly after + authentication, authorization, validation, and idempotency processing where applicable. A Platform Sign request + carrying an operation outside the current authenticated AEP operation set is rejected until an explicit policy is + registered; it is never signed by default. + +57. Revised decision: Bound unfinished AEP signing context to the 900-second Approval lifetime. Context: The existing + pending Approval purge threshold is 15 minutes. A 24-hour stale-session rule would retain an abandoned + AepSignContext long after its Approval can be completed and is unnecessary for this combined Sign flow. Contract: + Pending or orphaned AepSignContext records are cleanup-eligible no later than 900 seconds after creation. Controlled + cancellation and decline delete the context synchronously where possible. Approved contexts remain with their + non-purgeable approved Approval for audit and completion integrity. Platform idempotency retention remains at least + one hour because it is a protocol replay guarantee, not an Approval or signing-context lifetime. Assertion lifetime + and retry_after_seconds remain capped at 300 seconds; Inspect timeout remains capped at 300 seconds. + +58. Decision: Platform delegated `sign(GRANT)` reuses a refactored `LOGIN` Approval classification. Contract: Initial + GRANT Sign creates LOGIN Approval and sets ApprovalFlags.BIT_AEP. AepSignContext binds the exact Service DID, grant + type, requested scopes, extension parameters, and optional mapped requesterId. The existing LOGIN policy, + notification, and presentation paths must support the AEP Service context without assuming an ordinary InFlow login + request. + +59. Decision: Every explicit Grant command starts a fresh credential issuance operation. Contract: A usable stored + credential does not short-circuit `inflow aep grant`. Each invocation uses new operation idempotency keys, obtains a + new LOGIN Approval and signed GRANT assertion, and calls the Service Grant endpoint. Existing credentials remain + stored unless the Service returns the same credential identifier or later lifecycle cleanup or Revoke removes them. + +60. Decision: Persist complete validated Service-issued credentials with expiry-aware selection. Context: Service Grant + returns the actual bearer token, API key, Basic credential, or extension credential. That response is credential + material the Agent needs for later authentication, not merely display metadata. Contract: Validate the complete + Grant response and store it locally by canonical Service DID and credentialId with grant type, issuedAt, expiresAt + when supplied, scopes, and the complete credential payload. Multiple credential records may coexist. Saving the same + Service DID and credentialId replaces that record atomically. Credential selection must never return a credential + whose expiresAt is at or before the selection time; expired records may be removed lazily or by cleanup. Remote + issuance followed by local persistence failure is a distinct partial-success failure and must not falsely report a + usable local credential. + +61. Decision: Grant is an action; Status presents Service lifecycle and local grant availability. Context: This controls + CLI presentation only. It does not change the Service Grant response, validation, complete local credential storage, + expiry enforcement, or later internal credential use. Contract: `inflow aep grant` obtains approval, requests and + validates the complete Service credential, stores it, and returns a concise non-secret action result. It does not + print credential secrets or a verbose inventory. Human Grant confirms issuance and storage. Agent Grant returns + stable non-secret issuance metadata sufficient to identify the stored record. + +62. Revised decision: Status combines authoritative Service lifecycle with explicitly local credential availability. + Context: The AEP Service Status response is authoritative for enrollment state, Owner action, and pending + requirements, but it does not report whether this CLI possesses an unexpired locally stored credential. Contract: + Human Status presents the detailed Service lifecycle plus a local usable-grant summary without secrets. Agent Status + uses an envelope with the complete validated Service Status response under `service` and locally derived credential + summaries under `local.grants`. Each local summary contains credentialId, grantType, scopes, expiresAt when present, + and usability, but never credential payload. Expired credentials are never reported as usable. The envelope keeps + Service-authored state distinct from local Agent state and supersedes the earlier raw-Status-only JSON assumption. + +63. Decision: Revoke returns a concise non-secret action result. Contract: After the Service accepts Revoke and local + credential reconciliation succeeds, agent JSON confirms success and echoes the selected credentialId, grantType, or + allGrantTypes selector without credential material. Human output gives a short confirmation. Revoke does not return + the remaining credential inventory; Status presents remaining locally usable grants. The empty Service Revoke + response remains a protocol transport detail rather than the CLI action result. + +64. Decision: Bare Revoke invalidates all Service credentials; selectors are optional secondary behavior. Contract: + `inflow aep revoke ` sends the protocol `all_grant_types: "true"` selector and, after Service + success, deletes every locally stored credential for the canonical Service DID. Advanced callers may instead supply + exactly one `--credential-id` or `--grant-type`; the two flags are mutually exclusive. An explicit selector narrows + both the Service Revoke request and successful local reconciliation. Revoke never automatically chooses a preferred + credential. + +65. Decision: Do not expose the AEP Node generic Revoke `parameters` escape hatch in the initial CLI. Context: AEP Node + currently accepts `parameters?: Record` and spreads it into Grant and Revoke request bodies, but + the AEP specifications contain no registered Revoke extension or concrete source-backed example that defines such + fields. The core Revoke contract defines only credential_id, grant_type, and all_grant_types. Contract: The initial + CLI exposes only the core typed selectors. A future registered extension adds its own validated schema and flags; + the CLI does not accept arbitrary unbounded request members. + +66. Decision: Revoke action JSON follows existing CLI snake_case output conventions. Contract: CLI flags use kebab case + (`--credential-id`, `--grant-type`). Agent JSON uses snake_case, matching existing `transaction_id`, `approval_id`, + `instrument_id`, `output_saved_to`, and `credential_saved_to` result fields. Bare Revoke returns + `{ "revoked": true, "all_grant_types": true }`; narrowed forms return `credential_id` or `grant_type`. This is an + InFlow action result rather than the Service's empty protocol response. + +67. Clarification: Revoke selection has distinct CLI, core, wire, and output representations. Contract: The CLI accepts + `--grant-type oauth-bearer` and `--credential-id `. Its command schema maps those display-shaped flags to + core `grantType` and `credentialId`. AEP Node maps them to wire fields `grant_type` and `credential_id`. The + registered grant-type value itself remains the advertised protocol string `oauth-bearer`; field name casing rules do + not rewrite registered enum values to `oauth_bearer`. InFlow action JSON independently follows the repository's + snake_case field convention. + +68. Clarification: Generic `parameters` does not carry the core credential identifier selector. Context: AEP Node's + typed Revoke selector already maps `credentialId` directly to wire `credential_id`. The generic parameters object is + spread before the typed selector for additional fields that a future concrete credential specification might define; + no current AEP credential specification supplies such an example. Contract: Keep credentialId in the typed selector + path. Do not expose generic parameters in the initial CLI. + +69. Decision: Freeze the Enroll, Status, Grant, and Revoke command schemas. Contract: Every command accepts one + `service-reference` positional argument using the approved normalization. Inspect is logged-out; the other commands + require InFlow authentication. Enroll supports optional `--interval` overriding Platform retry_after_seconds, + `--max-attempts` default 0, and `--timeout` default and maximum 900; it always polls inline and rejects interval 0. + Status takes no polling flags. Grant accepts optional `--grant-type` and repeatable `--scope`; omitted grant type + selects the first advertised grant type, and scopes map to requested_scopes with stable exact de-duplication. Revoke + defaults to all grant types and supports mutually exclusive `--credential-id` or `--grant-type` narrowing. + +70. Decision: Freeze agent JSON contracts for all five commands. Contract: Inspect retains its approved versioned + envelope. Enroll returns the complete validated Service response. Status returns `{ service, local: { grants } }`, + preserving the complete Service Status response and listing local grant summaries sorted by grant_type then + credential_id. Each summary contains credential_id, grant_type, scopes, optional expires_at, and usable. + Credential-store access purges expired entries before Status builds the summary, so successfully returned Status + results do not contain expired grants. Grant returns granted true plus credential_id, grant_type, scopes, and + optional expires_at. Revoke returns revoked true plus exactly one of all_grant_types, credential_id, or grant_type. + Grant and Revoke do not repeat Service DID or expose credential payloads. + +71. Decision: Freeze human presentation for Enroll, Status, Grant, and Revoke. Contract: Enroll shows Service label and + concise active, pending-verification, or rejected outcome; pending indicates Owner action when applicable and points + to Status for details. Status shows Service identity, lifecycle, since, Owner action, pending + verification/requirements, and a non-secret local grant table. Grant confirms issuance and storage with grant type + and optional expiry. Revoke confirms the successful scope. Action commands never print credential secrets or + automatically run Status. + +72. Decision: Freeze stable CLI error contracts. Contract: Use AEP_SERVICE_URL_INVALID for invalid Service input; the + existing session-guard error for missing InFlow authentication; AEP_IDENTITY_NOT_FOUND; approved typed + Inspect/transport mappings; AEP_IDENTITY_PROVISION_FAILED; AEP_APPROVAL_DENIED; AEP_APPROVAL_TIMEOUT; + AEP_SIGN_FAILED; AEP_REQUIREMENTS_UNMET; AEP_GRANT_TYPE_UNSUPPORTED; AEP_CREDENTIAL_INVALID; sanitized AEP Problem + Details preserving the protocol code; AEP_LOCAL_PERSISTENCE_FAILED with partial_success true; + AEP_LOCAL_RECONCILIATION_FAILED with partial_success true; and AEP_INTERNAL_ERROR. Invalid input and unsupported + explicit grant type exit 2. Operational failures exit 1. Retryability follows the approved source category; valid + lifecycle outcomes are successful protocol results. + +73. Decision: Proactively remove expired credentials on every credential-store interaction. Context: Expiration must + affect both selection and lifecycle maintenance. Leaving cleanup to a future command or separate manager permits + indefinite stale secret retention in the temporary store. Contract: Every credential-store read or write begins by + purging records whose expiresAt is at or before the supplied current time. Selection independently excludes expired + records even if physical cleanup fails. A cleanup failure is a credential-store failure and is reported through the + applicable command error or local-reconciliation path; it is never silently treated as successful cleanup. Built-in + grant types require expires_at. Extension credentials may omit it only when their registered specification permits + non-expiring credentials. + +74. Decision: Freeze interruption and partial-success behavior. Contract: Controlled Enroll or Grant interruption + best-effort cancels Approval, removes incomplete local state, and returns the existing interruption error; hard + crashes rely on 900-second server cleanup. Remote Enroll or Grant success followed by storage failure returns + AEP_LOCAL_PERSISTENCE_FAILED with partial_success true and non-secret identifiers. Remote Revoke success followed by + local deletion failure returns AEP_LOCAL_RECONCILIATION_FAILED with partial_success true and does not automatically + repeat the remote request. + +## Active Focus Window + +| Order | Status | Focus | Next action | +| ----- | ----------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| 40 | Done | Enroll command contract | Implement the approved Enroll inputs, identity provisioning, Service enrollment, outputs, and failure behavior. | +| 42 | Done | Server signing approval | Implement approved operation-aware Sign, AEP Approval context, requester resolution, polling support, and disclosure return. | +| 44 | Done | CLI approval continuation | Implement inline delegated-sign polling, decline, timeout, interruption, and approved-claim handling without resume. | +| 46 | Not Started | Tier-aware AEP approval | Refine approval presentation and decisions for required, preferred, and optional claims. | +| 48 | Not Started | Seller DID population | Design verified ownership and population lifecycle for Seller.serviceDid. | +| 60 | Done | Status command | Implement direct-sign Status with full agent JSON and detailed human lifecycle presentation. | +| 70 | Done | Grant command | Implement LOGIN approval-backed Grant, complete expiry-aware credential storage, and concise non-secret action output. | +| 80 | Done | Revoke command | Implement direct-sign Service revocation, optional narrowing selectors, and local reconciliation. | +| 82 | Done | Exact CLI contracts | Implement approved schemas, JSON fields, human projections, errors, expiry cleanup, and partial-success behavior. | +| 110 | Done | Enterprise secure store | Delivered by Queue 2 Task 126 encrypted local vault daemon. | + +## No Buried Work Rule + +Before every status update, pause point, handoff, or final control-return message, audit the response for future-tense +work, prevention work, risks, blockers, follow-ups, or "should do next" statements. + +If the response mentions work that is not already represented in the queue, add it to the Active Focus Window and +Ordered Task List, add it to Missed Or Hidden Work Found for user vetting, add it to Decision Points, or move it to an +adjacent planning artifact before handing off. + +## Decision Log + +| Date | Decision | Rationale | Source | +| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-07-09 | Use the published AEP Node packages rather than copying protocol behavior into `inflow-cli`. | The package owns AEP Agent network behavior and the user requested integration of `aep-node`. | User instruction; `../AEP/aep-node/INTEGRATION.md:11` | +| 2026-07-09 | Expose AEP as the top-level `inflow aep` command group. | AEP is a distinct protocol and the user selected Option A. | User decision in the active thread. | +| 2026-07-09 | Include every identified Agent-side operation through staged delivery. | Inspect can ship without persistence; Enroll establishes the storage dependency; later commands follow in separate stages. | User decision in the active thread. | +| 2026-07-09 | Treat InFlow authentication and AEP authentication as separate systems. | InFlow authentication authorizes the Agent-side CLI to the InFlow Platform; Platform-signed AEP client assertions authenticate the Agent to Services. | User decision; `inflow-server/src/main/java/ai/inflowpay/api/v1/interceptor/ApiAuthenticationInterceptor.java:154`; `inflow-server/src/main/java/ai/inflowpay/aep/platform/service/AepPlatformService.java:239` | +| 2026-07-09 | Use a dedicated storage interface with an initial single local JSON implementation. | The interface permits staged delivery and a final migration task to an enterprise secure credential store. | User decision in the active thread. | +| 2026-07-09 | Clear the logged-in user's local AEP records on InFlow account logout. | AEP state belongs to the authenticated InFlow user and may be regenerated; a later user must not access it. | User decision in the active thread. | +| 2026-07-09 | Return Inspect as an envelope containing the complete validated document and resolved transport metadata. | This preserves extension fields while centralizing URL resolution and cache metadata; interactive mode renders a concise projection of the same result. | User selected Inspect output Option C in the active thread. | +| 2026-07-09 | Normalize AEP Service references to an origin, infer HTTPS for host/path input, and permit plaintext HTTP only when explicitly supplied for exact loopback hosts. | AEP Inspect resolves the origin-level well-known path. The positional input accepts `did:web`, host, host/path, and absolute URL forms; path components do not affect the Service origin. | User refined the Service-reference contract in the active thread; AEP core specification section “Discovery and Inspect.” | +| 2026-07-09 | Use the approved versioned Inspect JSON envelope without field changes. | The envelope separates the complete Service-authored document, core-resolved URLs, and observed HTTP response metadata while preserving unknown extension fields. | User selected Inspect envelope Option A in the active thread. | +| 2026-07-09 | Keep Stage 1 Inspect stateless and fetch on every invocation. | Inspect returns observed cache metadata but creates no local cache, adds no refresh flag, and does not pull storage ownership or migration into Stage 1. Shared persistent HTTP-aware caching is staged after the storage interface exists. | User selected Inspect cache Option A in the active thread. | +| 2026-07-09 | Use six stable Inspect error codes with explicit retry and exit semantics. | Caller input, network, HTTP, malformed JSON, invalid AEP document, and unexpected internal failures require distinct automation behavior. Invalid input exits `2`; operational failures exit `1`; native errors and response bodies are not exposed. | User selected Inspect error Option A in the active thread. | +| 2026-07-09 | Follow at most five same-origin Inspect redirects and reject every cross-origin redirect or scheme downgrade. | Automatic redirects must not silently transfer Inspect origin trust. The output reports the final accepted Inspect URL, and rejected redirects use a dedicated stable error code. | User selected Inspect redirect Option B in the active thread. | +| 2026-07-09 | Use a configurable total Inspect deadline with a 30-second default and 300-second maximum. | Inspect must not hang indefinitely. The deadline covers redirects and body reading; timeout is retryable, unlimited mode is not supported, and later AEP commands reuse the same core deadline policy. | User selected Inspect timeout Option B in the active thread. | +| 2026-07-09 | Push reusable protocol behavior into `aep-node` and normative requirements into `aep-specs` where meaningful. | `inflow-cli` owns product policy, command contracts, rendering, and orchestration; it must not duplicate protocol transport or validation behavior owned by the software development kit. | User instruction in the active thread. | +| 2026-07-09 | Require the `application/aep+json` media-type essence on successful Inspect responses. | Content negotiation is part of the protocol contract. Valid parameters are accepted; missing, malformed, or different media types are rejected before body parsing with a typed software development kit failure and stable CLI error. | User selected Inspect media-type Option A in the active thread. | +| 2026-07-09 | Bound Inspect responses to a secure one-mebibyte decoded-body default in `aep-node`. | The limit is enforced while streaming and remains configurable for embedded software development kit consumers; `inflow-cli` uses the secure default without exposing an override. | User selected Inspect response-size Option C in the active thread. | +| 2026-07-09 | Render interactive Inspect as a polished human capability summary rather than a transport report. | Human output includes the copyable Service URL but omits Bindings, Inspect URL, HTTP metadata, and resolved command URLs. Agent-mode JSON retains the complete document and technical metadata. | User approved a modified interactive Option A and clarified Service URL display in the active thread. | +| 2026-07-09 | Allow AEP Inspect without an InFlow login. | Existing `inflow mpp inspect` is an unauthenticated read-only probe, and AEP Inspect is also protocol-level unauthenticated discovery. AEP Inspect receives no auth storage or InFlow client dependency and never invokes the session guard. | User instructed AEP Inspect to follow verified MPP Inspect behavior. | +| 2026-07-09 | Accept full resource locators as AEP Service input and discard path, query, and fragment components. | An agent may reuse any protected or ordinary resource URL, including MPP and x402 URLs, for AEP discovery. Normalization extracts only the Service origin before network access, persistence, output, or error reporting. | User selected Service-reference Option A and supplied the protocol-neutral resource-locator use case in the active thread. | +| 2026-07-09 | Do not encode the current absence of an Inspect signature as a durable design or new normative premise. | Signed Inspect is a planned enhancement. Stage 1 reports current validation accurately while preserving an extension path for signature material, verification state, and trust policy. | User instruction in the active thread. | +| 2026-07-09 | Reject Service URLs containing embedded username or password information. | User information can visually disguise the actual hostname and is unrelated to unauthenticated Inspect. Rejection prevents origin confusion, and errors never echo the credential-bearing input. | User selected embedded-credential Option B in the active thread. | +| 2026-07-09 | Establish the permanent `inflow.aep` core handle in Stage 1 with `inspect()`. | This matches the repository's one-handle-per-command-group architecture and allows later stages to extend one stable integration shape without temporary functions or volatile placeholder stores. | User selected core architecture Option A in the active thread. | +| 2026-07-09 | Follow the existing MPP and x402 dependency pattern for `@aep-foundation/agent`. | Private core declares the Agent package as a peer and development dependency; the published CLI declares it as a runtime dependency and consumes a released npm version. | User instruction in the active thread. | +| 2026-07-09 | Make Stage 1 `aep-node` changes additive. | Add a reusable Service-reference resolver and narrow Inspect signal, body-limit, final-URL, redirect, media-type, and typed-failure behavior without renaming the existing `serviceUrl` lifecycle interfaces. | User selected software development kit interface Option A in the active thread. | +| 2026-07-09 | Add focused normative Inspect transport requirements to `aep-specs`. | Specify media-type enforcement, same-origin redirect trust, and implementation-defined response-size and completion-time limits without describing signature absence as permanent or constraining the planned signed-Inspect enhancement. | User selected specification Option A in the active thread. | +| 2026-07-09 | Deliver Stage 1 in specification, software development kit, then CLI authority order, with local linked validation before publication. | `aep-specs` defines the contract, `aep-node` implements it, and `inflow-cli` consumes it. A managed local pnpm link validates the integration before the software development kit pull request and npm release; committed CLI dependencies still use the released registry version. | User selected delivery Option A and required use of the local link workflow in the active thread. | +| 2026-07-09 | Expand the existing local link and unlink scripts to manage AEP packages; do not create fragmented AEP-specific scripts. | One workflow owns the marked pnpm override block and validates both local software development kit repositories. The AEP link set includes the Agent package's local Core and Platform dependency closure. | User instruction in the active thread. | +| 2026-07-09 | Expose one discriminated `AepInspectError` and a separate `AepServiceReferenceError` from `aep-node`. | Every known Inspect transport or validation failure maps without prose parsing; Service-reference failures remain separate; exhaustive core switches surface newly added software development kit reasons at compile time. | User selected software development kit error Option A in the active thread. | +| 2026-07-10 | Use one composed AEP storage boundary with typed sub-stores. | Identity and credential ports share one lifecycle boundary while remaining independently replaceable. Inspect is stateless, and incomplete operations are not persisted. The initial JSON implementation may use one physical repository; an enterprise implementation can move secret credentials without changing flows. | User selected the composed boundary and later rejected resumable operation storage in the active thread. | +| 2026-07-10 | Bind the active local AEP store to canonical InFlow Platform origin plus authenticated `userId`. | Ownership is verified before any record access. Mismatch fails closed and may replace disposable prior state with a new empty store, preventing production, sandbox, custom deployment, or cross-user leakage. | User selected storage-ownership Option B in the active thread. | +| 2026-07-10 | Logout attempts AEP state deletion, always clears InFlow authentication, and reports partial cleanup failure. | A broken AEP store cannot prevent logout, but residual local Service credentials are not silently ignored. Owner validation continues to prevent later-user access. | User selected logout Option B in the active thread. | +| 2026-07-10 | Preserve AEP state for exact-owner re-authentication and delete it when authentication changes owner. | Owner is canonical Platform origin plus `userId`. New authentication remains durable if cleanup fails, but AEP stays unavailable and the command reports a nonzero partial-transition failure until residual state is removed. | User selected authentication-transition Option A in the active thread. | +| 2026-07-10 | Store the initial AEP state as a versioned subtree in the existing auth JSON file. | One mode-`0o600` file owns authentication and initial AEP state. The composed AEP interface hides the physical layout so later enterprise credential storage can replace it without changing flows. | User selected file-placement Option B in the active thread. | +| 2026-07-10 | Version only the persisted `aep` subtree with an integer schema version. | AEP migrations remain independent from auth semantics. Missing or null AEP state means uninitialized; known older versions migrate sequentially; unsupported newer versions fail closed without rewrite. | User selected schema-version Option A in the active thread. | +| 2026-07-10 | Store Agent identities in an object map keyed by canonical Service DID. | The stored value is the runtime-validated `AgentServiceIdentity` persistence record; contained Service DID must match the key, writes replace atomically, metadata preserves unknown fields, and Platform-hosted records contain no private signing key. | User selected identity-schema Option A in the active thread. | +| 2026-07-10 | Accept last-writer-wins concurrency for the temporary shared JSON config file. | The initial file is a legacy bridge that will be replaced. It provides atomic individual file writes through `conf` but makes no cross-process transaction guarantee. Enterprise concurrency is deferred until the future secure store is selected. | User instruction in the active thread. | +| 2026-07-10 | Source Enroll claims through approval-mediated Platform delegated signing. | The CLI passes the Service-requested claim context into the AEP delegated signer. Initial `sign(ENROLL)` creates the InFlow Approval, the CLI waits through the existing approval lifecycle, and final Sign returns the assertion plus only the approved disclosure needed for Service Enroll. Claims are neither entered into the CLI nor sourced silently. | User corrected the approval trigger point in the active thread. | +| 2026-07-10 | Reuse a refactored `REGISTER` approval for delegated `sign(ENROLL)`. | Registration is the normalized user action. Register creation, policy, notifications, and presentation will accept a Service DID with an optional mapped InFlow requester identifier; the Service DID remains authoritative and unregistered Services remain supported. | User selected Option A in the active thread. | +| 2026-07-10 | Add optional generic `platform_context` to Platform delegated sign requests. | Platforms may use local authorization, custody, tenant, or compliance inputs without changing client-assertion claims. The software development kit transports the object unchanged; InFlow defines `approval_id` within its Platform profile. | User selected Option A in the active thread. | +| 2026-07-10 | Persist narrow typed AEP request-binding data in AepSignContext. | The record stores approval, identity, Service, operation, structured claim names, fingerprint, lifecycle timestamps, and signing completion state. Approval envelope data remains in Approval; raw request JSON is not retained. | User refined the context name and typed claim structure in the active thread. | +| 2026-07-10 | Create InFlow authorization inside delegated Sign. | The initial request carries InFlow authorization input through platform_context; InFlow creates AepSignContext and final Approval directly. A pending response returns approval_id for polling and final Sign. | User consolidated the API flow in the active thread. | +| 2026-07-10 | Define a generic asynchronous Platform Sign response. | HTTP 202 returns pending status, opaque Platform continuation context, and retry guidance; HTTP 200 remains the completed assertion response. InFlow returns approval_id inside platform_context for CLI polling and completion. | User selected asynchronous Sign Option A in the active thread. | +| 2026-07-10 | Standardize Platform HTTP idempotency in Idempotency-Key headers. | Provision, delegated Sign, and hosted Verification require the header. Verification is included because it consumes replay state. Read-only calls remain keyless, and lifecycle PATCH remains naturally idempotent while it only sets status. The unposted Platform draft can be corrected before publication. | User approved the holistic Platform idempotency direction in the active thread. | +| 2026-07-10 | Require at least one hour of Platform idempotency-result retention. | This matches AEP core. Implementations may retain longer, and durable domain uniqueness continues protecting state after cached responses expire. | User selected retention Option A in the active thread. | +| 2026-07-10 | Use one shared generic idempotency cache across Platform operations. | Records are keyed by authenticated principal plus Idempotency-Key. The stored operation and canonical request fingerprint detect reuse across endpoints or changed input and return idempotency_conflict. | User selected scope Option C for a shared generic cache in the active thread. | +| 2026-07-10 | Use authenticated userId as InFlow's Platform idempotency principal. | Credential rotation or authentication mechanism changes do not change the user's idempotency namespace. | User clarified the principal in the active thread. | +| 2026-07-10 | Carry pending Sign retry guidance only in JSON. | HTTP 202 body contains pending status, platform_context, and retry_after_seconds. The contract does not duplicate timing in Retry-After. | User selected pending-response Option B in the active thread. | +| 2026-07-10 | Encode retry_after_seconds as a required positive decimal string. | This follows AEP-owned numeric wire conventions. The software development kit exposes a validated numeric value and callers do not invent a missing default. | User confirmed the source-backed convention in the active thread. | +| 2026-07-10 | Bound retry_after_seconds to 1 through 300. | The bound prevents tight loops and pathological timers while remaining independent of approval lifetime and caller timeout. Out-of-range responses are invalid and are not clamped. | User selected range Option A in the active thread. | +| 2026-07-10 | Require status on both Platform Sign response variants. | HTTP 200 uses completed and HTTP 202 uses pending, producing a clean discriminated union before the Platform draft is published. | User selected response-discriminant Option A in the active thread. | +| 2026-07-10 | Make platform_context optional on Sign requests and responses. | The field is omitted when empty and included only when a Platform has local data to transport. InFlow pending includes approval_id; completed ENROLL includes approved_claims. | User clarified the platform_context presence contract and corrected completed disclosure flow in the active thread. | +| 2026-07-10 | Use mutually exclusive InFlow initiation and completion context shapes. | Initiation requires structured claims and forbids approval_id. Completion requires approval_id and forbids claims, loading the immutable binding from AepSignContext. | User selected context-shape Option A and refined the structured request in the active thread. | +| 2026-07-10 | Preserve Inspect claim tiers through the InFlow Sign API. | inflow-cli sends required, preferred, and optional arrays under platform_context.claims. inflow-server persists the structure and flattens it only when creating today's Approval UserDetails. | User corrected the API design boundary in the active thread. | +| 2026-07-10 | Require Inspect claim tiers to be pairwise disjoint. | Overlap is an invalid Inspect document enforced by aep-node; neither the CLI nor inflow-server repairs tier ambiguity. | User selected overlap Option A in the active thread. | +| 2026-07-10 | Put AEP claim-name mappings in UserDetail.java. | UserDetail resolves supported claim aliases to approval categories and exposes the reverse category vocabulary; no separate mapping service or Foundation catalog is required for this integration. | User selected mapping Option C and specified the owning file in the active thread. | +| 2026-07-10 | Emit only exact requested claim keys after category approval. | A claim such as person.first_name can resolve to NAME, but approval does not authorize emitting unrequested NAME aliases such as person.last_name. | User selected reverse-mapping Option A and clarified final Service disclosure in the active thread. | +| 2026-07-10 | Keep AEP claim mapping and approved-claim construction in inflow-server. | The server resolves structured claim names through UserDetail, flattens Approval UserDetails, preserves exact request names, and emits exact approved_claims; the CLI has no duplicate mapping table. | User clarified server-side mapping ownership in the active thread. | +| 2026-07-10 | Use initial delegated Sign as the InFlow claim-mapping endpoint. | Sign receives structured platform_context.claims at the authorization boundary and creates AepSignContext plus REGISTER Approval. | User selected mapping-endpoint Option A in the active thread. | +| 2026-07-10 | Fail only unsupported required claims during InFlow mapping. | Unsupported required names prevent session and Approval creation. Recognized non-required claims proceed; unsupported preferred/optional names are silently dropped before typed persistence. | User selected Option A and later revised non-required diagnostics in the active thread. | +| 2026-07-10 | Return requirements_unmet for unsupported required claims. | HTTP 422 carries exact unsupported required names without claim values, reusing the existing core AEP error semantics. | User selected error Option A in the active thread. | +| 2026-07-10 | Return exact approved claims from completed delegated Sign. | Approval polling observes decision state only. Sign completion validates approval, issues the assertion, and returns platform_context.approved_claims for direct Service Enroll. | User corrected the flow in the active thread. | +| 2026-07-10 | Link AepSignContext to Approval by approvalId and mark ApprovalFlags.BIT_AEP. | This avoids another nullable Approval column. Completion authorizes Approval first, checks the flag, then loads the unique context; missing context fails closed. | User proposed and approved the relationship design in the active thread. | +| 2026-07-10 | Represent supported AEP claims with AepClaim and AepClaims. | AepSignContext stores typed required, preferred, and optional collections; UserDetail maps to AepClaim rather than raw strings; every AEP Sign Approval sets BIT_AEP. | User restructured the claim persistence model in the active thread. | +| 2026-07-10 | Poll existing Approval GET for status only. | The endpoint receives no AEP-specific response behavior or fields. APPROVED tells the CLI to call final Sign. BIT_AEP is internal persistence/integrity state, not part of polling. | User corrected the polling boundary in the active thread. | +| 2026-07-10 | Use string claim lists at the API boundary and typed AepClaims in persistence. | String lists allow detection of unsupported required names. Recognized names map to AepClaim; unsupported preferred/optional names are silently dropped and never stored. | User revised the claim-boundary design in the active thread. | +| 2026-07-10 | Support the complete initial AepClaim mapping. | Nine exact claim names map into the eight existing UserDetail approval categories; NAME maps first and last name separately, and final Sign emits only requested keys. | User selected mapping Option A in the active thread. | +| 2026-07-10 | Rename the durable signing record to AepSignContext. | It stores binding context across initial Sign, Approval, and final Sign while Approval remains the lifecycle authority. | User selected naming Option A in the active thread. | +| 2026-07-10 | Bulk-delete AepSignContext before Approval batch deletion. | The same Approval status/age predicate drives a set-based child delete in one writer transaction, avoiding row loading and duplicated child status. | User selected cleanup Option A in the active thread. | +| 2026-07-10 | Map final Sign outcomes from Approval state. | Pending remains 202; approved completes; declined/cancelled returns authorization_denied; missing, wrong-user, or non-AEP remains not_recognized; inconsistent flagged state fails closed. | User selected state-mapping Option A in the active thread. | +| 2026-07-10 | Resolve AEP requesterId through unique Seller.serviceDid. | Exact validated DID lookup supplies Seller.sellerId when present; no match leaves requesterId null. Hostname inference is forbidden, and population is outside the current delivery. | User proposed the Seller mapping design in the active thread. | +| 2026-07-10 | Resolve only APPROVED Sellers by serviceDid. | ONBOARDING, REVIEW, and unmatched Sellers do not supply requesterId; the authoritative Service DID remains available and AEP continues. | User selected Seller-status Option A in the active thread. | +| 2026-07-10 | Present mapped Seller.name or the validated Service DID host. | The full Service DID remains authoritative secondary detail; Register notification and dashboard rendering never dereference Seller unconditionally. | User selected requester-label Option A in the active thread. | +| 2026-07-10 | Never implicitly resume a prior operation from `aep enroll`. | Every Enroll invocation is a new logical operation. Pending work continues only through an explicit action, never by matching Service DID or claim fingerprint. | User corrected the CLI continuation boundary in the active thread. | +| 2026-07-10 | Provide no AEP Enroll resume surface. | Incomplete attempts are abandoned and local state is removed. Controlled interruption best-effort cancels the Approval; server cleanup handles cancelled or crash-abandoned records. A later Enroll always starts new. | User explicitly rejected resume and required incomplete Enroll removal in the active thread. | +| 2026-07-10 | Return Service enrollment pending as a valid command outcome. | InFlow approval is complete; Service pending is durable AEP lifecycle state. Preserve identity and let the later Status command observe progress. | User selected Service-pending Option A in the active thread. | +| 2026-07-10 | Separate Enroll transport/JSON fidelity from human presentation. | The Service Enroll response mirrors or closely relates to Status, and agent JSON preserves the complete validated response. Human Enroll output remains concise because Status owns the verbose human lifecycle presentation. Only the Service-DID-to-Agent-identity mapping is persisted locally. | User corrected the three distinct contracts in the active thread. | +| 2026-07-10 | Require approval for ENROLL and GRANT Sign operations, but directly sign STATUS and REVOKE. | Enrollment and credential grants require user authorization; lifecycle observation and revocation do not create an Approval. The generic AEP Sign contract remains Platform-neutral. | User supplied the operation-specific Sign policy in the active thread. | +| 2026-07-10 | Confirm repeat-Enroll identity, raw JSON, rejected outcome, and partial-success behavior. | Identity persistence is separate from operation resumption and Service lifecycle state; agent JSON preserves the protocol response without an InFlow envelope. | User approved assumptions 11, 14, 21, 24, and 25 in the active thread. | + +## Source Authority Order + +When sources disagree, resolve them in this order unless the user says otherwise: + +1. Current repository source. +2. User decisions in the active thread. +3. AEP Node source and AEP specifications. +4. Generated artifacts. +5. Agent inference. + +## Provenance Boundary + +| Surface or capability | Provenance | Queue treatment | +| ------------------------------------ | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| `inflow aep inspect` | AEP Service operation plus explicit user decisions | Approved public command. | +| `inflow aep enroll` | AEP Service operation plus explicit user decisions | Approved public command. | +| `inflow aep status` | AEP Service operation plus explicit user decisions | Approved public command. | +| `inflow aep grant` | AEP Service operation plus explicit user decisions | Approved public command. | +| `inflow aep revoke` | AEP Service operation plus explicit user decisions | Approved public command. | +| Platform identity provisioning | AEP Platform specification and existing InFlow Server endpoint | Supporting internal flow, not a Service CLI command. | +| Platform delegated Sign | AEP Platform specification, existing server endpoint, and approved asynchronous extension | Supporting internal flow, not a Service CLI command. | +| Agent identity storage | Required by the Agent software development kit and approved persistence model | Internal storage capability, not an Identity command. | +| Credential authentication headers | Existing AEP Node session method | Internal consumption capability, not a `headers` command. | +| Protected-resource request execution | Existing MPP/x402 product behavior, not an AEP Service operation | Outside this queue; no `aep request` command is designed or approved. | +| Resume | Explicitly rejected by the user | No command, flag, journal, or cross-invocation continuation. | + +No public CLI surface may be added to this queue solely because the software development kit exposes a method with a +similar capability. A new public command requires an AEP Service operation or an explicit user instruction. + +## Assumptions + +| Assumption | Why acceptable | Revisit trigger | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| The first integration is Agent-side, not Service-side or Platform-server-side. | The user asked to bring `aep-node` into a buyer-oriented CLI and the Agent package supplies the matching client workflow. | The user requests Service hosting, Platform administration, or conformance tooling. | +| `_QUEUE1.md` is the task filename for this repository. | No queue file exists here, and the supplied source uses `_QUEUE.md`. | The user specifies another filename or queue number. | + +## Engineering Posture + +All design and implementation work in this queue must be: + +- DRY: protocol behavior, schemas, storage logic, rendering projections, and error mapping have one authoritative + implementation at the correct package boundary. Reuse must preserve clear ownership rather than create generic + abstractions without demonstrated consumers. +- Performant: avoid repeated discovery, identity provisioning, signing setup, configuration reads, and unnecessary + network calls. Performance decisions must preserve correctness, cache invalidation, credential isolation, and + observable failure behavior. +- Long term: public commands, output contracts, storage schemas, and core interfaces must support compatible evolution, + migration, and additional AEP identity modes without requiring a rewrite of the first integration. +- Enterprise aligned: treat tenancy, principal isolation, authorization audience, credential custody, auditability, + deterministic automation, lifecycle management, policy boundaries, migration, and operational diagnostics as design + requirements rather than later additions. + +These qualities do not justify speculative frameworks. Every abstraction and persistence boundary must be grounded in +the current repositories, the AEP contract, or an approved near-term extension point. + +## Current Source-Backed State + +Source evidence: + +| Claim | Source | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| The published Agent package is `@aep-foundation/agent` version `0.1.1` and supports Node.js 22 or newer. | `/Users/nxkavian/Drive/Source/AEP/aep-node/packages/agent/package.json:2`, `/Users/nxkavian/Drive/Source/AEP/aep-node/packages/agent/package.json:3`, `/Users/nxkavian/Drive/Source/AEP/aep-node/packages/agent/package.json:37` | +| The Agent package owns Inspect and AEP command networking; the application owns durable storage, authorization, and identity custody. | `/Users/nxkavian/Drive/Source/AEP/aep-node/INTEGRATION.md:8`, `/Users/nxkavian/Drive/Source/AEP/aep-node/INTEGRATION.md:11`, `/Users/nxkavian/Drive/Source/AEP/aep-node/INTEGRATION.md:42` | +| One `AepAgent` creates Service sessions exposing authentication headers, Enroll, Grant, Identity, Inspect, Revoke, and Status. | `/Users/nxkavian/Drive/Source/AEP/aep-node/packages/agent/src/index.ts:324`, `/Users/nxkavian/Drive/Source/AEP/aep-node/packages/agent/src/index.ts:378` | +| Agent identity, credential, idempotency-key, and Inspect-cache ports are injectable; defaults are in-memory. | `/Users/nxkavian/Drive/Source/AEP/aep-node/packages/agent/src/index.ts:61`, `/Users/nxkavian/Drive/Source/AEP/aep-node/packages/agent/src/index.ts:382` | +| Grant persists the returned credential through the configured credential store; Revoke deletes matching locally stored credentials after a successful command. | `/Users/nxkavian/Drive/Source/AEP/aep-node/packages/agent/src/index.ts:558`, `/Users/nxkavian/Drive/Source/AEP/aep-node/packages/agent/src/index.ts:579`, `/Users/nxkavian/Drive/Source/AEP/aep-node/packages/agent/src/index.ts:591` | +| The platform-hosted identity provider requires a Platform URL and optionally accepts an Authorization value. | `/Users/nxkavian/Drive/Source/AEP/aep-node/packages/agent/src/index.ts:292`, `/Users/nxkavian/Drive/Source/AEP/aep-node/packages/agent/src/index.ts:777` | +| `inflow-cli` registers top-level command groups from one `Inflow` instance and distinguishes agent mode using explicit format, Model Context Protocol mode, or a non-interactive standard output stream. | `packages/cli/src/cli.tsx:142`, `packages/cli/src/cli.tsx:169`, `packages/cli/src/cli.tsx:215` | +| CLI command flags and Model Context Protocol input share colocated schemas. | `AGENTS.md` under “Schemas drive flags AND MCP tool input”; `packages/cli/src/commands/mpp/schema.ts:1` | +| Existing persisted InFlow auth uses a mode-`0o600` `conf` file and stores OAuth tokens, an API key, pending device auth, and connection settings. | `packages/core/src/utils/storage.ts:26`, `packages/core/src/utils/storage.ts:59`, `packages/core/src/utils/storage.ts:85` | +| Existing logout clears every artifact in that auth store. | `packages/core/src/utils/storage.ts:47`, `packages/core/src/utils/storage.ts:165` | +| InFlow Server is the AEP Platform and publishes unauthenticated discovery at `/.well-known/aep-platform`. | `/Users/nxkavian/Drive/Source/InFlow/inflow-server/src/main/java/ai/inflowpay/www/v1/controller/user/WellKnownController.java:70` | +| Platform operations live under `/v1/aep`; list, provision, sign, identity status, lifecycle update, and hosted verification require an authenticated InFlow user. | `/Users/nxkavian/Drive/Source/InFlow/inflow-server/src/main/java/ai/inflowpay/api/v1/controller/AepPlatformController.java:45`, `/Users/nxkavian/Drive/Source/InFlow/inflow-server/src/main/java/ai/inflowpay/api/v1/controller/AepPlatformController.java:58` | +| The Platform authentication interceptor derives the InFlow user from either a Platform API key or an OAuth token. | `/Users/nxkavian/Drive/Source/InFlow/inflow-server/src/main/java/ai/inflowpay/api/v1/interceptor/ApiAuthenticationInterceptor.java:154` | +| Platform identity list, status, signing, and lifecycle operations are scoped to the authenticated InFlow user identifier. | `/Users/nxkavian/Drive/Source/InFlow/inflow-server/src/main/java/ai/inflowpay/api/v1/controller/AepPlatformController.java:69`, `/Users/nxkavian/Drive/Source/InFlow/inflow-server/src/main/java/ai/inflowpay/api/v1/controller/AepPlatformController.java:85`, `/Users/nxkavian/Drive/Source/InFlow/inflow-server/src/main/java/ai/inflowpay/api/v1/controller/AepPlatformController.java:92`, `/Users/nxkavian/Drive/Source/InFlow/inflow-server/src/main/java/ai/inflowpay/api/v1/controller/AepPlatformController.java:101` | +| Provisioning stores each identity against the authenticated InFlow user and the requested Service DID. | `/Users/nxkavian/Drive/Source/InFlow/inflow-server/src/main/java/ai/inflowpay/aep/platform/service/AepPlatformService.java:99`, `/Users/nxkavian/Drive/Source/InFlow/inflow-server/src/main/java/ai/inflowpay/aep/platform/service/AepPlatformService.java:170` | +| Delegated signing creates an AEP client assertion whose issuer and subject are the Agent DID and whose audience is the Service DID. | `/Users/nxkavian/Drive/Source/InFlow/inflow-server/src/main/java/ai/inflowpay/aep/platform/service/AepPlatformService.java:239` | + +## Scope Boundary + +Queue 1 includes: + +- Agent-side AEP CLI and core-client design. +- Hosted identity-provider configuration and authorization design. +- Durable local storage and principal-scoping design. +- Interactive and agent-mode command behavior and output contracts. +- Dependency, documentation, testing, Changeset, and release work breakdown. +- Required `inflow-server` AEP claim-disclosure and approval integration design. + +Queue 1 does not own: + +- AEP Service implementation or server adapters. +- AEP Platform server implementation. +- Sovereign local key custody unless the user explicitly selects it for the first release. +- Implementation before the design decisions are approved. +- Implementing multiple command stages in one undifferentiated batch. + +## Priority Definitions + +- P0: Blocks the queue goal. +- P1: Required for a correct, durable result. +- P2: Valuable follow-up that must be completed or explicitly re-homed before this queue closes. +- P3: Long-tail work that can be deferred with little consequence. + +## Lift Scale + +- 1: Trivial. +- 2-3: Small and clear. +- 4-5: Moderate and bounded. +- 6-8: Large or ambiguous; split before starting. +- 9+: Too large for one task; split before starting. + +## Research Notes + +| Topic | Finding | Source | +| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Agent package boundary | Applications supply storage and identity custody, but the Agent package owns AEP HTTP transport and validation. | `/Users/nxkavian/Drive/Source/AEP/aep-node/packages/agent/README.md:16` | +| Principal isolation | Production integrations create one Agent per principal or scope every store lookup by principal. | `/Users/nxkavian/Drive/Source/AEP/aep-node/INTEGRATION.md:27` | +| Credential preference | Session authentication headers prefer a usable issued credential and fall back to a client assertion unless disabled. | `/Users/nxkavian/Drive/Source/AEP/aep-node/packages/agent/src/index.ts:520` | +| CLI architecture | Existing protocol command groups are thin CLI render shells over core flows and handles. | `AGENTS.md` under “What this repo is” | +| Engineering posture | Work must be DRY, performant, long term, and enterprise aligned. | User instruction in the active thread, 2026-07-09. | +| Inspect integrity | The current Inspect schema has no signature field and the current software development kit performs structural validation without document-signature verification. Signed Inspect is a planned enhancement, so Stage 1 must not encode signature absence as a permanent contract. | Current sources: `/Users/nxkavian/Drive/Source/AEP/aep-specs/ietf/schemas/inspect-document.schema.json:1`, `/Users/nxkavian/Drive/Source/AEP/aep-node/packages/core/src/inspect.ts:13`; future direction: user instruction in the active thread. | +| Inspect Service reference | The positional input accepts `did:web` identifiers, hostnames, full protocol-neutral resource locators, and absolute URLs. Hostname forms infer HTTPS. All forms normalize to an origin; path, query, and fragment are discarded before network access, persistence, output, or error reporting. Explicit remote HTTP is rejected, while explicit loopback HTTP is accepted. | User instruction and Service-reference Option A in the active thread; `/Users/nxkavian/Drive/Source/AEP/aep-node/packages/agent/src/index.ts:1227`; `/Users/nxkavian/Drive/Source/AEP/aep-specs/ietf/specs/core/draft-kavian-agent-enrollment-protocol-01.md:130` | +| Inspect embedded credentials | Service inputs containing URL username or password information are rejected as `AEP_SERVICE_REFERENCE_INVALID`; the input is never echoed. | User selected Option B in the active thread, 2026-07-09. | +| AEP core handle | `Inflow` exposes a permanent `aep: IAep` handle beginning with authentication-independent `inspect()`. The handle uses low-level `aep-node` Inspect behavior in Stage 1 and is extended by later storage and lifecycle stages. | User selected Option A in the active thread, 2026-07-09. | +| AEP dependency ownership | `packages/core` declares `@aep-foundation/agent` as a peer and development dependency; `packages/cli` declares it as a runtime dependency. Integration uses a released npm version and follows the MPP and x402 pattern. | User instruction in the active thread; `packages/core/package.json:45`; `packages/cli/package.json:42`. | +| Stage 1 software development kit interface | `aep-node` adds `resolveAepServiceReference()`, optional Inspect `AbortSignal` and response-limit inputs, final accepted and normalized URLs, secure redirect and media behavior, bounded reading, and typed failures. Existing `serviceUrl` lifecycle properties remain unchanged. | User selected Option A in the active thread, 2026-07-09. | +| Stage 1 specification scope | `aep-specs` normatively requires the AEP media-type essence, prohibits automatic cross-origin Inspect redirects and HTTPS downgrade, and requires implementation-defined size and time limits. The patch preserves the planned signed-Inspect extension path. | User selected Option A in the active thread, 2026-07-09. | +| Stage 1 delivery sequence | Authority order is `aep-specs`, `aep-node`, then `inflow-cli`. Before `aep-node` publication, the built local Agent package is linked into `inflow-cli` through managed pnpm overrides for real cross-repository validation, then unlinked and replaced by the released npm version. | User selected Option A with a local-link checkpoint; existing mechanism: `scripts/link-local-inflow-node.mjs:1`. | +| Local link workflow | The existing `link-local-inflow-node.mjs` and companion unlink script expand in place to manage `inflow-node` and `aep-node` through one marked pnpm override block. AEP linking covers `@aep-foundation/agent`, `@aep-foundation/core`, and `@aep-foundation/platform`, validates their built outputs, and uses `AEP_NODE_PATH` with the checked-out repository as its default target. No parallel AEP-specific scripts are introduced. | User instruction in the active thread; Agent dependency closure: `/Users/nxkavian/Drive/Source/AEP/aep-node/packages/agent/package.json:41`. | +| Software development kit Inspect errors | `inspectService()` maps known failures into one `AepInspectError` with a stable `reason` discriminator and reason-specific status or validation issues. Service normalization throws `AepServiceReferenceError`. Standard `cause` may retain internals, but CLI output never exposes it. | User selected Option A in the active thread, 2026-07-09. | +| Stage 1 verification | Verification is layered across specification artifacts, software development kit unit tests, real loopback HTTP transport tests, core tests, CLI rendering and built integration tests, local linked-package validation, and final published-registry validation. | User selected verification Option A in the active thread, 2026-07-09. | +| AEP storage interface | `AepStorage` composes typed identity and credential stores plus lifecycle clearing. Physical storage remains an implementation detail, allowing the initial JSON repository and later enterprise secret-store composition to share one flow contract. Inspect remains stateless in Stage 1, and incomplete Enroll operations are not persisted. | User selected the composed boundary, then explicitly rejected resumable operation storage in the active thread, 2026-07-10. | +| AEP storage ownership | The initial store has one active owner identified by canonical Platform origin and `/v1/users/self` `userId`. No state is exposed before exact owner validation; malformed ownership fails closed; mismatch can replace old disposable state with an empty current-owner document. | User selected Option B in the active thread, 2026-07-10; `packages/core/src/types/index.ts:19`; `packages/core/src/resources/user.ts:16`. | +| AEP logout cleanup | Logout attempts remote token revocation and local AEP deletion, clears local InFlow auth in all cases, reports `aep_state_cleared`, and exits nonzero with `AEP_STATE_CLEAR_FAILED` when residual AEP state remains. | User selected Option B in the active thread, 2026-07-10. | +| AEP authentication transition | Successful same-owner login preserves AEP state. A different Platform-origin or `userId` triggers deletion after new credentials become durable. Cleanup failure leaves login successful, blocks AEP access, and reports `AEP_STATE_OWNER_TRANSITION_FAILED` with a nonzero exit. | User selected Option A in the active thread, 2026-07-10. | +| Initial AEP file placement | The existing mode-`0o600` auth JSON gains a versioned `aep` subtree. Auth and AEP share one physical file and corruption domain but retain separate logical interfaces and clear operations. | User selected Option B in the active thread, 2026-07-10; `packages/core/src/utils/storage.ts:26`. | +| AEP schema version | Only `aep.schemaVersion` versions local AEP state. It is an integer; missing or null AEP state is uninitialized; older supported versions migrate explicitly; unsupported newer versions fail closed. | User selected Option A in the active thread, 2026-07-10. | +| AEP identity collection | `aep.identities` is an object map keyed by canonical Service DID. Values are cloned, runtime-validated `AgentServiceIdentity` records whose contained Service DID matches the key; unknown metadata is preserved for evolution. | User selected Option A in the active thread, 2026-07-10. | +| Initial JSON concurrency | The current `conf`-backed file retains atomic individual writes and last-writer-wins cross-process behavior. No new lock, transaction framework, or concurrency dependency is added for the temporary implementation. | User instruction in the active thread, 2026-07-10; `conf` behavior: local package documentation and implementation. | +| Enroll claim sourcing | Claims are sourced through user-approved InFlow disclosure initiated inside Platform delegated `sign(ENROLL)`. The software development kit must pass enrollment disclosure context through the signer boundary; the CLI waits for approval and then uses the signed assertion and approved claims for Service Enroll. | User correction in the active thread, 2026-07-10; current signer boundary: `/Users/nxkavian/Drive/Source/AEP/aep-node/packages/agent/src/index.ts:50`, `/Users/nxkavian/Drive/Source/AEP/aep-node/packages/agent/src/index.ts:739`; current Platform sign: `/Users/nxkavian/Drive/Source/InFlow/inflow-server/src/main/java/ai/inflowpay/aep/platform/service/AepPlatformService.java:111`. | +| Approval classification constraints | `Approval.requesterId` and its derived `requester`/`seller` associations are nullable. Current `REGISTER` coupling is in the seller-authenticated request creation path and Register notification renderer. Reusing `REGISTER` for AEP is viable if those paths are refactored to present a Service DID and optional resolved InFlow requester rather than require a Seller. | `/Users/nxkavian/Drive/Source/InFlow/inflow-server/src/main/java/ai/inflowpay/datastore/model/Approval.java:88`; `/Users/nxkavian/Drive/Source/InFlow/inflow-server/src/main/java/ai/inflowpay/api/v1/controller/RequestController.java:168`; `/Users/nxkavian/Drive/Source/InFlow/inflow-server/src/main/java/ai/inflowpay/notification/email/RegisterApprovalEmail.java:16`. | +| Enroll approval classification | Initial Platform delegated `sign(ENROLL)` creates `ApprovalType.REGISTER`. The Approval persists the authoritative Service DID through `AepSignContext` and may carry a mapped InFlow `requesterId`; Register policy and presentation paths support both mapped and unmapped Services. | User selected Option A in the active thread, 2026-07-10. | +| Enroll signing continuation | Initial Sign atomically creates `AepSignContext` and the final `REGISTER` Approval, then returns pending `platform_context.approval_id`. The CLI polls generic Approval status inline and sends a separate final Sign request with that approval identifier. There is no cross-invocation resume. | User consolidated authorization into Sign and rejected resume in the active thread, 2026-07-10. | +| Approval persistence normalization | `AepSignContext` is a narrow child record keyed uniquely by `approvalId`; `ApprovalFlags.BIT_AEP` marks AEP-created Approvals. Approval remains the lifecycle authority. PolicySession and TransactionSession are not modified. | User architecture decisions in the active thread, 2026-07-10. | +| Inspect JSON contract | Agent-mode output contains `schema_version`, the complete validated `document`, normalized and advertised-command-only URLs under `resolved`, and optional `cache_control` and `etag` fields under an always-present `response` object. | User selected Option A in the active thread, 2026-07-09. | +| Inspect cache behavior | Stage 1 performs a network fetch on every invocation, returns `Cache-Control` and `ETag` when supplied, creates no local cache, and exposes no refresh option. | User selected Option A in the active thread, 2026-07-09. | +| Inspect errors | Public codes are `AEP_SERVICE_URL_INVALID`, `AEP_INSPECT_NETWORK_ERROR`, `AEP_INSPECT_HTTP_ERROR`, `AEP_INSPECT_JSON_INVALID`, `AEP_INSPECT_DOCUMENT_INVALID`, `AEP_INSPECT_REDIRECT_REJECTED`, and `AEP_INSPECT_INTERNAL_ERROR`, with the approved retryability, sanitization, and exit-code rules. | User selected error Option A and redirect Option B in the active thread, 2026-07-09. | +| Inspect redirects | Redirects are processed manually, limited to five hops, and accepted only when scheme, hostname, and effective port match the current URL. Every target passes Service URL validation; downgrade and cross-origin targets are rejected. | User selected Option B in the active thread, 2026-07-09. | +| Inspect timeout | `--timeout` is a positive finite total-operation deadline in seconds, defaults to `30`, is capped at `300`, and maps expiration to retryable `AEP_INSPECT_TIMEOUT`; the software development kit accepts an `AbortSignal`. | User selected Option B in the active thread, 2026-07-09. | +| Cross-repository ownership | Reusable AEP protocol transport and validation belong in `aep-node`; normative interoperable behavior belongs in `aep-specs`; CLI product policy and presentation belong in `inflow-cli`. | User instruction in the active thread, 2026-07-09. | +| Inspect media type | Successful Inspect responses require the `application/aep+json` media-type essence. Media-type comparison is case-insensitive, valid parameters do not prevent acceptance, and missing, malformed, or different media types fail as `AEP_INSPECT_MEDIA_TYPE_INVALID`. | User selected Option A in the active thread, 2026-07-09. | +| Inspect response size | `aep-node` enforces a one-mebibyte decoded-body default while streaming, permits an application-level override, and exposes a typed oversized-response failure. `inflow-cli` uses the default with no public override and maps failure to `AEP_INSPECT_RESPONSE_TOO_LARGE`. | User selected Option C in the active thread, 2026-07-09. | +| Inspect human output | Interactive output is a polished Service capability summary derived from the core envelope. It includes the copyable Service URL but shows neither Bindings, Inspect URL, HTTP response metadata, nor resolved command URLs; those remain available in agent-mode JSON. | User approved modified Option A and clarified URL display in the active thread, 2026-07-09. | +| Inspect authentication | AEP Inspect works logged out and does not receive auth storage, an InFlow Platform client, or an Authorization header. This matches `inflow mpp inspect`, whose CLI run path bypasses the session guard. | `packages/cli/src/commands/mpp/index.tsx:599`; user instruction in the active thread. | + +## Completion Evidence + +| Evidence | Required? | Result | Notes | +| ----------------------------------------------------------------------------------------------------------------------------------------------- | --------- | --------- | ------------------------------------------------------------------------------------------------------------------------- | +| User approval of the `aep` command group and staged inclusion of every Agent-side operation. | Yes | Confirmed | Public CLI scope. | +| InFlow Server Platform discovery, endpoint paths, authentication boundary, and user scoping verified against implementation. | Yes | Confirmed | Cross-repository boundary. | +| User approval of a dedicated storage interface, initial local JSON implementation, logout destruction, and final secure-store replacement task. | Yes | Confirmed | Storage architecture direction; individual storage behaviors remain separate design topics. | +| Inspect command behavior covering inputs, transport, both output modes, exact JSON fields, exact errors, ownership, and verification. | Yes | Confirmed | Stage 1 design approved through individual user decisions. | +| Separate Enroll storage and command behavior specifications. | Yes | Confirmed | Stage 2 contract, including repeat Enroll, output separation, and partial-success behavior. | +| Exact field-level CLI contracts for Enroll, Status, Grant, and Revoke. | Yes | Confirmed | User approved schemas, JSON fields, human projections, stable errors, and proactive expired-credential cleanup. | +| Implementation task decomposition reviewed across the repositories. | Yes | Confirmed | Covers the five Service operations, supporting Platform/server work, storage, tests, documentation, and release ordering. | + +## Ordered Task List + +Current cursor: 120 Next task: Produce the implementation handoff for the five AEP Service operations + +Status values: Not Started, In Progress, Blocked, Waiting On Decision, Done, Cut. Evidence types: Source, Test, Review, +Artifact, User Decision, External. + +| Order | Status | Priority | Lift | Task | Owning specs | Depends on | Output | Verification | User input needed | +| ----- | ----------- | -------- | ---: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | ------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ----------------------------- | +| 10 | Done | P0 | 4 | Specify the stateless Inspect command and its core boundary. | Stage 1 | None | Approved Inspect behavior contract. | Source, Review, and User Decision. | Yes, received. | +| 12 | Done | P0 | 4 | Specify required `aep-node` Inspect transport enhancements, including abort support, controlled redirects, final URL reporting, and typed failure boundaries. | Stage 1 | Task 10 | Upstream software development kit design and implementation task. | Source and User Decision; implementation verification remains in its delivery queue. | Yes, received. | +| 14 | Done | P1 | 3 | Review Stage 1 transport decisions for interoperable normative requirements and propose narrowly justified `aep-specs` changes. | Stage 1 | Task 10 | Approved focused specification scope. | Spec Review and User Decision. | Yes, received. | +| 20 | Done | P0 | 4 | Design the narrow local AEP storage interface and ownership boundary. | Stage 2 | Task 10 | Approved composed storage and owner boundary. | Source, Review, and User Decision. | Yes, received. | +| 30 | Done | P0 | 4 | Design the initial single local JSON implementation, authenticated-user ownership, logout destruction, permissions, and concurrency behavior. | Stage 2 | Task 20 | Approved bounded legacy JSON contract. | Review and User Decision. | Yes, received. | +| 40 | Done | P0 | 5 | Specify Enroll provisioning, persistence, Service command, retry, and partial-failure behavior. | Stage 2 | Tasks 20-30 | Approved Enroll behavior contract. | Source, Review, and User Decision. | Yes, received. | +| 42 | Done | P0 | 5 | Design InFlow Server approval-mediated delegated `sign(ENROLL)`, supported-claim mapping, Service-DID requester resolution, policy behavior, and signed/approved response contract. | Stage 2 | Task 40 | Approved server signing-disclosure contract. | Source, Review, and User Decision in `inflow-server`. | Yes, received. | +| 44 | Done | P0 | 4 | Design CLI delegated-sign polling, decline, timeout, interruption, and approved-claim handling without resume. | Stage 2 | Task 42 | Approved asynchronous Enroll pipeline. | Source, Review, and User Decision. | Yes, received. | +| 46 | Not Started | P2 | 4 | Refine the InFlow approval system to present and decide required, preferred, and optional AEP claim tiers distinctly. | Later enhancement | Task 42 | Tier-aware approval and disclosure contract. | Source, Review, Test, and User Decision in `inflow-server`. | Yes. | +| 48 | Not Started | P2 | 4 | Design verified ownership, population, update, and removal lifecycle for Seller.serviceDid. | Later enhancement | Task 42 | Seller Service-DID administration contract. | Source, Review, Test, and User Decision in `inflow-server`. | Yes. | +| 60 | Done | P1 | 3 | Design Status as a separate stage. | Later stage | Task 40 | Approved Status command contract. | Source, Review, and prior User Decisions. | No additional input required. | +| 70 | Done | P1 | 4 | Design Grant and credential persistence as a separate stage. | Later stage | Tasks 40, 60 | Approved Grant contract. | Source, Review, and User Decision. | Yes, received. | +| 80 | Done | P1 | 4 | Design Revoke and local credential reconciliation as a separate stage. | Later stage | Task 70 | Approved Revoke contract. | Source, Review, and User Decision. | Yes, received. | +| 82 | Done | P0 | 3 | Freeze exact CLI schemas, JSON fields, human projections, and stable error codes for Enroll, Status, Grant, and Revoke without changing their approved behavior. | CLI contract | Tasks 40-80 | Field-level implementation contract. | Source, Review, Artifact, and User Decision. | Yes, received. | +| 110 | Done | P1 | 4 | Replace the initial JSON implementation with an enterprise secure credential store behind the approved interface. | Final storage stage | Tasks 20-80 | Delivered by Queue 2 Task 126 encrypted local vault daemon. | Source, Review, Test, and signed debug binary evidence in Queue 2. | No. | +| 120 | Done | P1 | 3 | Map dependencies, documentation, tests, Changesets, and verification for every designed stage. | All designed stages | Tasks 10-80 | Implementation-ready batch map. | Artifact review. | No. | + +## Missed Or Hidden Work Found + +- Item: The approved Status, Grant, Revoke, and portions of Enroll behavior are not yet frozen to the field-level CLI + contract required by this repository: exact schema flags/defaults, agent JSON keys, human projections, and stable + error code/message/exit behavior. +- Moved to: Ordered Task 82. +- Reason: Architecture and behavior are approved, but implementation and contract tests require exact shapes. This task + must derive names and patterns from existing MPP/x402 commands and the AEP wire types without inventing new commands. +- Date: 2026-07-10 + +- Item: The software development kit needs abort support, controlled redirect behavior, final accepted URL reporting, + and typed error boundaries to keep protocol transport out of `inflow-cli`. +- Moved to: Ordered Task 12. +- Reason: This is required by approved Stage 1 behavior and belongs at the reusable software development kit boundary. +- Date: 2026-07-09 + +- Item: Approved transport rules must be reviewed for normative protocol requirements rather than being encoded only as + one CLI's product policy. +- Moved to: Ordered Task 14. +- Reason: Interoperable security requirements belong in `aep-specs` when justified. +- Date: 2026-07-09 + +- Item: Enroll claims require approval-mediated InFlow Server delegated `sign(ENROLL)`, a supported AEP-claim mapping, + Service-DID requester resolution, approval presentation, and an asynchronous signed/approved response contract. +- Moved to: Ordered Task 42. +- Reason: This replaces direct CLI claim entry and is required to complete Enroll with explicit user consent. +- Date: 2026-07-10 + +- Item: The CLI needs an approval-driven Enroll pipeline with inline polling in both agent and human modes. +- Moved to: Ordered Task 44. +- Reason: Claim disclosure is asynchronous and must follow the established approval interaction pattern. +- Date: 2026-07-10 + +- Item: The approval system should eventually present and decide required, preferred, and optional AEP claims as + distinct tiers rather than one all-or-nothing supported set. +- Moved to: Ordered Task 46. +- Reason: The current flow intentionally requests all advertised tiers, while tier-aware approval requires a separate + presentation, policy, persistence, and response contract. +- Date: 2026-07-10 + +- Item: Seller.serviceDid needs a verified ownership and population lifecycle. +- Moved to: Ordered Task 48. +- Reason: Lookup is required now, while population was explicitly deferred by the user and must not be inferred from + Seller website or hostname. +- Date: 2026-07-10 + +- Item: The core specification and broader onboarding design use `verification_pending` for pending Enroll verification, + while the current AEP Node `EnrollResponse`, parser, and schema use `requirements_pending`. +- Moved to: Stage 2 `aep-specs` and `aep-node` compatibility batch. +- Reason: `verification_pending` and `requirements_pending` have different meanings. The software development kit must + preserve Enroll verification separately from Status requirements before the CLI returns the full response. +- Date: 2026-07-10 + +- Item: Current AEP Node Platform Sign is synchronous, has no `platform_context`, pending response variant, or + Idempotency-Key header, and its delegated signer returns only a string assertion. +- Moved to: Stage 2 `aep-specs` and `aep-node` Platform Sign batch. +- Reason: Approval-backed ENROLL and GRANT require a generic asynchronous continuation contract without making the + software development kit InFlow-specific; STATUS and REVOKE still complete synchronously. +- Date: 2026-07-10 + +## Risk Register + +| Risk | Impact | Mitigation | Status | +| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | +| Reusing an InFlow credential with the wrong Platform audience. | Identity provisioning or signing fails, or credentials are sent to an unauthorized origin. | Verify endpoint ownership, audience, and accepted auth scheme before design approval. | Open | +| Using the Agent package's in-memory defaults. | Identities and credentials disappear between CLI invocations. | Supply durable store implementations in core. | Open | +| Local AEP state survives logout or crosses an authenticated-user boundary. | A later user could access another user's Agent identities or Service credentials. | Bind the local state to the logged-in user and destroy it during logout before another principal can use it. | Open | +| Printing issued credentials by default. | Secrets can enter transcripts, logs, and Model Context Protocol results. | Grant stores the complete credential but returns only non-secret action metadata; Status returns only non-secret local grant summaries. | Mitigated by design | +| Concurrent processes write the temporary shared JSON config. | Last-writer-wins can lose an auth or AEP update. | Treat the JSON backend as a bounded bridge, document the limitation, avoid claiming cross-process durability, and resolve concurrency in the final enterprise-store task. | Accepted for initial implementation | + +## Source Audit Findings + +1. Actual contradiction: The AEP core draft and broader onboarding design use `verification_pending` for incomplete + asynchronous Enroll verification, while the current AEP Node Enroll type, parser, and schema use + `requirements_pending`. Status uses `requirements_pending` for requirements the Agent still needs to satisfy. These + fields are not synonyms; Batch 5 corrects AEP Node without rewriting the specification meaning. +2. Lifetime correction: The earlier 24-hour stale AEP signing-context retention exceeded the 900-second pending Approval + lifetime and came from an inapplicable generic session analogy. Unfinished or orphaned AepSignContext cleanup is + bounded to 900 seconds. The one-hour Platform idempotency cache is intentionally separate because AEP requires at + least one hour of replay protection. + +## Implementation Batch Map + +| Batch | Repository | Depends on | Purpose | Required verification | +| ----------------------------------------- | ---------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| 1. Inspect specification | `aep-specs` | None | Normative Inspect media type, redirect trust, and bounded transport requirements. | IETF render, format, idnits, schemas, examples, and repository checks. | +| 2. Inspect software development kit | `aep-node` | Batch 1 | Additive Service-reference resolution, abort, redirects, response bounds, final URL, and typed errors. | `pnpm verify`, `pnpm check-publish`, package tests, and Changeset. | +| 3. Inspect CLI | `inflow-cli` | Batch 2 release or local link | Add `inflow.aep.inspect`, command schema, exact JSON, polished human rendering, tests, docs, and Changeset. | Typecheck, lint, test, TypeDoc, build, integration tests, and publish dry run. | +| 4. Platform Sign specification | `aep-specs` | Inspect contracts | Add optional unsigned `platform_context`, header-only Platform idempotency, completed/pending Sign union, retry seconds, hosted-verification idempotency, and explicit operation policy extension behavior. Preserve distinct Enroll verification and Status requirement fields. | Full IETF checks and updated examples/test vectors. | +| 5. Platform Sign software development kit | `aep-node` | Batch 4 | Extend Platform request/response types and delegated signer flow for pending continuation and approved claims without InFlow-specific fields. | `pnpm verify`, `pnpm check-publish`, focused Agent/Platform tests, and Changeset. | +| 6. InFlow Server AEP approval | `inflow-server` | Batches 4-5 contract | Implement shared idempotency cache, operation-aware Sign, `AepClaim`/`AepClaims`, `AepSignContext`, BIT_AEP, REGISTER refactor, Seller.serviceDid lookup, cleanup, and final approved claims. | Formatter, Checkstyle/static analysis, unit tests, integration tests, migrations, and JaCoCo review. | +| 7. CLI storage foundation | `inflow-cli` | Existing auth storage | Add composed typed `AepStorage`, owner isolation, temporary JSON subtree, logout/auth-transition cleanup, migrations, and tests. | Full CLI gate plus file-permission and ownership-transition tests. | +| 8. CLI Enroll | `inflow-cli` | Batches 5-7 and server availability | Add Enroll orchestration, inline Approval polling, exact full JSON response, concise human rendering, repeat-Enroll identity reuse, interruption cleanup, and partial-success errors. | Full CLI gate, local linked end-to-end validation, docs, and Changeset. | +| 9. Remaining Service operations | All applicable repositories | Batch 8 | Deliver Status, Grant, and Revoke in specification order with the approved direct-sign and approval policies. | Per-repository full gates and cross-repository integration tests. | +| 10. Enterprise storage | `inflow-cli` plus selected backend | Credential-producing stages | Replace temporary JSON behind `AepStorage` with transactional secure custody and migration. | Threat review, migration/recovery tests, concurrency tests, full CLI gate. | + +## Blocked Task Protocol + +Blocked tasks must include the blocking condition, attempted resolution, decision needed, and next unblock action. + +## Queue Closure Rules + +Do not close this queue until: + +- Every task is Done, Cut with rationale, or moved to another queue or planning document. +- Missed Or Hidden Work Found is empty, promoted, cut with rationale, or moved. +- Decision Points are resolved, cut with rationale, or moved. +- Completion Evidence is satisfied. +- Final Handoff is complete. + +Do not reorder, reprioritize, or cut tasks without recording the reason. Ask the user before changing P0 or P1 priority +unless it is a blocker carve-out. + +## Final Handoff + +- Summary: The AEP Agent CLI architecture and exact contracts are complete for Inspect, Enroll, Status, Grant, and + Revoke. `_PLAN1.md` indexes separate repository implementation plans for aep-specs, aep-node, inflow-server, and + inflow-cli so execution can use repository-scoped writable tasks. +- Completed tasks: 10, 12, 14, 20, 30, 40, 42, 44, 60, 70, 80, 82, and 120. +- Cut or deferred tasks and where they moved: Tasks 46 and 48 remain parked in this queue by user instruction. Task 110 + was delivered by Queue 2 Task 126. +- Decisions resolved: Public command surface, transport, storage, Platform Sign, Approval, claim disclosure, output, + error, expiry, idempotency, cleanup, and release-order contracts. +- Completion evidence: Decision log, source-backed state, completion-evidence table, implementation batch map, + `_PLAN1.md`, and the four repository-specific plans. +- Remaining risks: Temporary JSON last-writer-wins behavior, future Seller Service-DID population, tier-flattened + Approval presentation, and later enterprise credential custody. +- Files changed: `_QUEUE1.md`, `_PLAN1.md`, `_PLAN1-AEP-SPECS.md`, `_PLAN2-AEP-NODE.md`, `_PLAN3-INFLOW-SERVER.md`, and + `_PLAN4-INFLOW-CLI.md`. + +## Working Rules For This Queue + +- Check the source before relying on a boundary. +- Keep source-backed state separate from target design. +- Update task status as work progresses. +- Include the Active Focus Window and the single next action in every status update, pause point, handoff, or final + control-return message. +- Apply the No Buried Work Rule before every status update, pause point, handoff, or final control-return message. +- Record discovered work immediately under Missed Or Hidden Work Found and promote it only with user approval, explicit + scope confirmation, or a blocker carve-out. +- Break tasks with lift higher than 5 into smaller tasks. +- Do not use this file as a changelog. +- Report blockers with Question, Context, Examples, Options, Recommendation, and Consequence of deferring. diff --git a/_QUEUE2-SIGNED-SECURE-CLI.md b/_QUEUE2-SIGNED-SECURE-CLI.md new file mode 100644 index 0000000..111983d --- /dev/null +++ b/_QUEUE2-SIGNED-SECURE-CLI.md @@ -0,0 +1,563 @@ +# Queue 2 - Signed Secure CLI + +This is the active working queue for replacing the interpreted npm CLI distribution with a signed, secure InFlow +command-line application. It is separate from implementation plans and is not a historical changelog. + +## Goal + +Deliver a macOS-first, signed and notarized InFlow command-line application that exposes `inflow` on the executable +path, keeps credential operations inside the signed InFlow process, stores credentials as user-unlocked encrypted SQLite +vault records, stores durable application state in SQLite, removes the legacy plaintext configuration without migration, +and provides supported Homebrew and hosted-script installation paths. + +Queue type: Implementation + +## Definition Of Done + +The queue is complete when a user or host-side MCP client can install the signed artifact through Homebrew or the +InFlow-hosted installer, invoke `inflow` without an npm runtime installation, authenticate and use every existing CLI +and MCP flow through the signed local vault daemon without plaintext credential storage, upgrade without losing +encrypted vault access, uninstall cleanly, and verify the release using the Queue Closure Rules. + +## Completion State + +Current state: In Progress + +Current cursor: 150 Next task: run complete release and security verification. + +Decision Points: + +- Determine how release guidance handles legacy custom configuration files that the signed application cannot discover + automatically. +- Select the production APT and RPM repository host, offline recovery-key custody, automation signing subkey, rotation + period, and revocation publication path. + +## No Buried Work Rule + +Before every status update, pause point, handoff, or final control-return message, audit the response for future-tense +work, prevention work, risks, blockers, follow-ups, or "should do next" statements. + +If the response mentions work that is not already represented in the queue, add it to the Active Focus Window and +Ordered Task List, add it to Missed Or Hidden Work Found for user vetting, add it to Decision Points, or move it to an +adjacent planning artifact before handing off. + +## Decision Log + +| Date | Decision | Rationale | Source | +| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-07-18 | Target macOS for the first signed release; track Windows and Linux separately. | macOS is the immediate host platform and has a concrete Developer ID, notarization, and signed-artifact path. | User answer Q1=A in the active thread. | +| 2026-07-18 | Keep credential-capable operations inside the signed InFlow process and avoid a separate raw-secret helper. | Every component capable of using credential material must remain inside the signed InFlow process or its hidden daemon mode. | User approval in the active thread; native credential-store and daemon research recorded below. | +| 2026-07-18 | Replace production `config.json` storage with SQLite and use stable indexed columns plus versioned payload blobs to reduce schema churn. | The packaged application can own a transactional local database and avoid retaining the legacy configuration backend. | User instruction in the active thread. | +| 2026-07-18 | Make a clean break from legacy local state and automatically remove the old configuration instead of migrating it. | Existing credentials can be regenerated and deleting the plaintext store removes its residual exposure. | User instruction and answer Q3=auto in the active thread. | +| 2026-07-18 | Distribution must support `brew install` and an InFlow-hosted installation script usable by agents; installation must not require placing the product in `/Applications`. | InFlow is a command-line product and must be installable predictably by humans and agents. | User answer Q2 in the active thread. | +| 2026-07-18 | Deprecate the npm package as the executable CLI distribution. | The executable must run through the signed InFlow application identity rather than an arbitrary Node runtime. | User instruction in the active thread. | +| 2026-07-21 | Store credential payloads as encrypted SQLite vault records protected by a user PIN or passphrase, and route credential operations through a hidden signed local daemon. | Operating-system credential stores alone do not provide the needed same-user application boundary on every target platform; the vault model aligns with Bitwarden and 1Password-style local unlock semantics. | User decision after Windows/Linux credential-store review and Bitwarden/1Password comparison. | +| 2026-07-21 | Use a random vault data key wrapped by a PIN/passphrase-derived key; do not use the PIN/passphrase-derived key directly for every credential record. | Key wrapping supports future PIN/passphrase rotation, future hardware/user-presence wrappers, and record encryption without rewriting every payload for unlock-factor changes. | User answer 1=B in the active thread. | +| 2026-07-21 | Use authenticated encryption for vault records, with AES-256-GCM as the current baseline. | Vault payload tampering must be detected, not merely decrypted into unsafe plaintext. | User answer 2=A in the active thread. | +| 2026-07-21 | Treat Argon2id as the primary key-derivation target and verify the Node library choice before implementation. | OWASP recommends Argon2id first and scrypt only when Argon2id is unavailable; current Node Argon2 packages exist and must be evaluated for maintenance, packaging, signing, and release behavior. | User correction and Argon2id source review on 2026-07-21. | +| 2026-07-21 | Keep protocol intelligence in the CLI/Core and make the daemon a secure dumb vault. | The vault must be pluggable for future enterprise backends such as AWS Secrets Manager; moving AEP, MPP, x402, signing orchestration, or network calls into the daemon would turn it into a second CLI and block backend substitution. | User design decision in the active thread. | +| 2026-07-21 | Route CLI/Core credential access through one generic exact-reference `VaultBackend` interface. | Local daemon storage and future enterprise backends can share the same contract only if protocol code depends on exact secret references, kind/status checks, and generic vault operations instead of daemon internals. | User agreement in the active thread. | +| 2026-07-21 | Do not design the local vault as a multi-owner database. | The CLI has one active logged-in user and one active environment at a time; logout removes user-owned local data instead of preserving stale records for future users. | User correction in the active thread. | +| 2026-07-21 | Treat `inflow auth logout` as a local vault reset for credential/security state. | Vault policy, sidecar material, encrypted records, and user-specific metadata must not linger into the next login; `vault reset` is the local-only destructive cleanup, while logout also performs remote session cleanup when possible. | User decision in the active thread. | +| 2026-07-21 | Use daemon-memory-only unlock throttling rather than persisted throttle files. | Persisted throttle state can be deleted by a same-user attacker and would add local state; Argon2id cost is the offline guessing defense, while in-memory throttling is best-effort friction during a daemon lifetime. | User answer Option C in the active thread. | +| 2026-07-21 | Enforce only a six-character minimum for PIN/passphrase strength. | Weakness scoring and denylist maintenance are subjective and can sprawl; Argon2id cost and the user's chosen factor length carry the security weight. | User answer Option C in the active thread. | +| 2026-07-21 | Evaluate Argon2id with 64 MiB memory, 3 iterations, parallelism 1, and 32-byte output as the hardened baseline; use 32 MiB, 3 iterations, parallelism 1 only if performance requires fallback. | The baseline is stronger than OWASP's minimum Argon2id profiles and fits local vault unlock better than high-throughput server login; parallelism 1 is predictable across supported machines. | OWASP Password Storage Cheat Sheet, RFC 9106 discussion, libsodium guidance, and user agreement in the active thread. | +| 2026-07-21 | Do not use SQLite SEE; evaluate `better-sqlite3-multiple-ciphers` as the only encrypted-SQLite candidate. | SQLite SEE adds licensing/build complexity the user does not want; local database encryption is defense-in-depth and must not replace record-level vault encryption. | User decision in the active thread. | +| 2026-07-21 | If SQLite file encryption is adopted, store only the minimal encrypted bootstrap material outside the encrypted database in a binary sidecar, with the wrapped encrypted field named `material`. | The key needed to open an encrypted SQLite database cannot live only inside that same encrypted database; stable cryptographic choices are hardcoded in the signed binary, while descriptive metadata lives inside SQLite after unlock. | User correction and naming decision in the active thread. | +| 2026-07-21 | Record-level vault encryption is mandatory; encrypted SQLite is evaluated during Task 126 but only adopted if `better-sqlite3-multiple-ciphers` passes packaging, signing, and release verification. | The required security boundary is record-level encryption; encrypted SQLite is defense-in-depth and must not weaken or block the signed release pipeline. | User agreement in the active thread. | +| 2026-07-21 | Sidecar `material` wraps one random 32-byte vault master key, not a larger structure. | Envelope-encryption guidance supports locally generated data keys wrapped by a key-encryption key; the signed binary can derive separate database and record keys from the unwrapped master key using domain separation. | OWASP cryptographic storage guidance, AWS KMS envelope encryption guidance, Google Cloud KMS envelope encryption guidance, and user approval to follow the recommendation. | +| 2026-07-21 | Store searchable protocol metadata separately from exact-reference encrypted vault records in one SQLite database. | Protocol code needs indexed lookup fields for performance, while vault records must remain exact-reference only to avoid turning the vault into a searchable secret store. | User agreement in the active thread. | +| 2026-07-21 | Use opaque generated internal vault references and keep provider-specific path mapping behind the vault backend. | Enterprise secret managers often use meaningful paths for policy, but local protocol metadata already provides lookup context; opaque refs reduce metadata leakage in logs, errors, debug output, and portable contracts. | User agreement after AWS, HashiCorp Vault, Google Secret Manager, and OWASP source review. | +| 2026-07-21 | Use a fixed code-facing `VaultSecretKind` set and persist stable integer kind codes in SQLite. | String names keep code and JSON readable, while integer database encoding is compact and avoids typo drift; stable codes support long-term schema compatibility. | User agreement in the active thread. | +| 2026-07-21 | Derive the final `VaultSecretKind` mapping from source-backed persisted secrets, not generic protocol assumptions. | The current CLI stores server-issued auth tokens, saved API keys, pending device codes, and AEP credential payloads; payment credentials and AEP identity key material must not become durable vault kinds unless source inspection proves persistence. | Source inspection of `packages/core/src/utils/storage.ts` and user correction in the active thread. | +| 2026-07-21 | Use stable integer vault record status codes and hard-delete completed deletions instead of retaining tombstones by default. | The vault needs crash-recovery states but should minimize retained local metadata; audit tombstones can be added later as an enterprise policy feature. | User agreement in the active thread. | +| 2026-07-21 | Encrypt versioned per-kind payload envelopes as vault record plaintext. | Each secret kind needs its own canonical payload shape while the vault remains generic; versioned envelopes allow future payload changes to fail closed or migrate deliberately. | User agreement in the active thread. | +| 2026-07-22 | Defer another Apple notarized release build until Windows and Linux source-impacting work is farther along. | The macOS signed debug application is locally validated, and pushing another Apple release now risks churn if cross-platform vault or packaging work forces source changes. | User direction after macOS command testing passed. | +| 2026-07-18 | Support Claude Cowork through the signed host-side InFlow MCP application rather than a credential-bearing binary inside Cowork's Linux guest. | Anthropic keeps credentials in the host keychain and moved local MCP servers outside the virtual machine. | Anthropic Cowork architecture and containment documentation. | +| 2026-07-18 | Use `@napi-rs/keyring` behind an InFlow-private exact-reference adapter; do not expose its credential-search functions through InFlow. | It is the most-used maintained native Node credential package found, has current npm provenance, uses direct native stores without shell/file fallback, and supports opaque binary secrets. | Task 30 source, registry, adoption, and API audit. | +| 2026-07-18 | Build the signed application with Node 24.15 or newer and use built-in `node:sqlite`. | SQLite is available from Node 22.5, but becomes release-candidate quality in Node 24.15; Node 24.14 also enables defensive mode by default. | Node 22 and Node 24 SQLite documentation. | +| 2026-07-19 | Cut the CLI repository runtime and CI baseline to Node 24.15 or newer as an explicit contract change, not just a CI matrix edit. | The signed application release path requires Node 24.15 or newer, and the old npm-executable distribution is being deprecated. | Current package engines, CI workflow, release script, and user question on 2026-07-19. | +| 2026-07-19 | Add manual GitHub Release automation for notarized macOS artifacts before coupling it to npm publishing. | Apple notarization can hang independently from npm publishing, so signed binary release automation should be manually dispatchable and dry-run capable first. | Existing Changesets release workflow and Task 110 Homebrew hosting decision. | +| 2026-07-20 | Create the `inflow-server` hosted-installer PR only after the signed binary artifact shape is confirmed. | The hosted scripts must point at real release asset names and verification behavior; merging them before confirming binary output would publish broken install instructions. | User instruction after the merged two-architecture macOS dry run passed. | +| 2026-07-20 | Hosted installers verify per-asset GitHub Release `.sha256` files before extraction and support local uninstall. | Task 120 requires checksum, signature, upgrade, and uninstall behavior; re-running installs/upgrades, while uninstall needs an explicit safe local path. | `.github/workflows/macos-release.yml`; `inflow-server/src/main/resources/static/cli/install.sh`; `inflow-server/src/main/resources/static/cli/install.ps1`. | +| 2026-07-26 | Use Azure Artifact Signing Public Trust with GitHub OpenID Connect for Windows production signing. | The managed signing service keeps the hardware-protected private key out of GitHub. The executable must be signed before WiX embeds it, and the MSI must be signed before checksums and WinGet manifests are rendered. | User is completing Artifact Signing identity approval; Microsoft Artifact Signing documentation and official GitHub action. | + +## Source Authority Order + +When sources disagree, resolve them in this order unless the user says otherwise: + +1. Current repo source. +2. User decisions in the active thread. +3. Apple, Node, Homebrew, and MCP primary documentation. +4. Generated artifacts. +5. Agent inference. + +## Assumptions + +| Assumption | Why acceptable | Revisit trigger | +| ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| The signed container may be staged outside `/Applications` as long as its nested code remains intact and `inflow` resolves to its signed executable. | Homebrew supports linking a binary located inside an application bundle into its prefix. | Signing, notarization, vault daemon, or Homebrew tests show path-dependent behavior. | +| Public Inspect, Platform Discovery, and OpenAPI documents are non-secret state suitable for SQLite payload blobs. | They are fetched without secret-bearing response contracts and are already stored outside user-owned AEP credential state. | Source review finds credential-bearing fields in a cached public document. | +| The first release can require a fresh login, enrollment, and grant after deleting legacy state. | The user explicitly chose a clean break and existing AEP state is regenerable. | A source-backed non-regenerable local record is discovered. | + +## Current Source-Backed State + +Source evidence: + +| Claim | Source | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| The current storage schema places InFlow auth tokens, API keys, AEP state, connection data, and public-document caches in one `conf` store. | `packages/core/src/utils/storage.ts:25` | +| The current file backend requests mode `0o600` but stores values through JSON-compatible `conf` fields. | `packages/core/src/utils/storage.ts:64` and `packages/core/src/utils/storage.ts:94` | +| AEP persistence currently reads and replaces one owner-scoped state object containing identities, credentials, and Inspect cache records. | `packages/core/src/aep/storage.ts:21` and `packages/core/src/aep/storage.ts:98` | +| The published CLI is currently a Node executable whose package `bin` points at `dist/cli.js`. | `packages/cli/package.json:7` | +| The repository already builds a standalone JavaScript bundle but not a signed native distribution. | `packages/cli/package.json:20` and `packages/cli/tsup.standalone.config.ts:1` | +| Homebrew Cask supports a `binary` artifact and links it into the Homebrew prefix, including a binary contained in an application bundle. | https://docs.brew.sh/Cask-Cookbook | +| Apple supports Developer ID signing and notarization for command-line tools and externally built products. | https://developer.apple.com/documentation/xcode/creating-distribution-signed-code-for-the-mac/ | +| Apple Keychain access controls can track the creating signed application using its designated requirement. | https://developer.apple.com/library/archive/documentation/Security/Conceptual/CodeSigningGuide/AboutCS/AboutCS.html | +| MCPB can package local standard-input/output MCP servers, but the baseline InFlow integration is direct host-side `inflow --mcp` registration. | https://claude.com/docs/connectors/building/mcpb and Task 125 decision record. | +| `@napi-rs/keyring` received 1,898,663 npm downloads during 2026-07-11 through 2026-07-17, versus 343,881 for the maintained `@github/keytar` fork. | Official npm downloads API queried 2026-07-18. | +| `@napi-rs/keyring` 1.3.0 publishes npm-provenanced architecture packages and its tagged source selects the direct Apple native Keychain store. | npm registry metadata and `Brooooooklyn/keyring-node` tag `v1.3.0`. | +| Both leading candidates expose service-wide credential enumeration internally; InFlow requires only exact-reference create, read, and delete operations. | `@napi-rs/keyring` 1.3.0 `index.d.ts` and `@github/keytar` 7.10.6 `keytar.d.ts`. | +| `node:sqlite` was added in Node 22.5, became flag-free but experimental in Node 22.13, and became a release candidate in Node 24.15. | https://nodejs.org/download/release/latest-v22.x/docs/api/sqlite.html and https://nodejs.org/download/release/latest-v24.x/docs/api/sqlite.html | +| The login Keychain contains a valid `Developer ID Application: Jarwin, Inc. (B96U57DTR2)` identity, and the protected `inflow-notary` profile authenticates successfully. | Local Task 40 authority verification on 2026-07-18. | +| An ad-hoc proof application containing a Node single-executable application and nested `keyring.node` passes strict deep signature verification and runs through an external symlink. | Local Task 40 packaging proof on 2026-07-18. | +| A Developer ID-signed Node single-executable application using `@napi-rs/keyring` creates and reads a real Keychain item, and a separately built second version reads the first version's item under the stable designated requirement. | Local Task 40 Developer ID proof on 2026-07-18. | +| The first hardened-runtime proof launch omitted Node's just-in-time execution entitlements and V8 terminated with a fatal virtual-memory reservation error, producing a macOS crash report. | Local Task 40 proof on 2026-07-18. The temporary proof application and artifacts were removed. | +| CLI boot constructs one shared `AuthStorage` and passes it to Auth, User, Balances, Deposit Addresses, x402, MPP, AEP, top-level Inspect, and MCP command execution. | `packages/cli/src/cli.tsx` source review and Task 90 test coverage on 2026-07-18. | +| The live MCP tool list exposes no exact raw-secret input fields such as API keys, access tokens, refresh tokens, passwords, secrets, or authorization values. | `packages/cli/test/unit/cli.test.ts` Task 90 regression coverage. | +| Structured `auth status` reports API-key authentication without echoing the provided API key. | `packages/cli/test/unit/cli.test.ts` Task 90 regression coverage. | + +Persistence inventory: + +| Current record | Current contents | Security classification | Target location | +| -------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `auth` | OAuth access token, refresh token, token type, scope, and expiry metadata. | Access and refresh tokens are secrets; the remaining fields are metadata. | Encrypted vault records in SQLite protected by the user unlock factor. | +| `apiKey` | InFlow API key. | Secret. | Encrypted vault record in SQLite protected by the user unlock factor. | +| `pendingDeviceAuth` | Device code, human phrase, verification URL, polling interval, and expiry. | Device code is a short-lived authorization secret; the remaining fields are workflow metadata. | Encrypted vault record in SQLite protected by the user unlock factor. | +| `aep.credentials` | Service credential payload, credential identifier, Grant Type, Service DID and URL, issue time, and optional expiry. | Credential payload can contain API keys, Basic credentials, or bearer tokens; the remaining fields are metadata. | Encrypted vault records in SQLite protected by the user unlock factor. | +| `aep.identities` | Agent DID, Service DID, identity kind, signing algorithms, and metadata. | Non-secret for current Platform-hosted identities; sovereign identity metadata must not be assumed secret-free without validating its producer. | Versioned SQLite identity record; future sovereign private key material must use the encrypted vault. | +| `aep.inspect` | Inspect document and HTTP cache metadata. | Public cache data. | SQLite public-document/cache record. | +| `discoveryDocuments` | Inspect and Platform Discovery documents plus HTTP cache metadata. | Public cache data. | SQLite public-document/cache records. | +| `openApiDocuments` | OpenAPI documents plus HTTP cache metadata. | Public cache data. | SQLite public-document/cache records. | +| `connection` | Environment and API/authentication base URLs. | Non-secret configuration. | SQLite settings record. | + +Current boundary and deletion inventory: + +| Boundary | Source-backed behavior | Required cutover behavior | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Default legacy file | `conf` resolves project `inflow` to `~/Library/Preferences/inflow/config.json` on macOS and requests mode `0o600`. | Delete this exact file idempotently after the signed application initializes its new stores; do not migrate its contents. | +| Custom legacy file | `--auth` or `INFLOW_AUTH_FILE` constructs the same plaintext store at a caller-selected path. | Delete an explicitly supplied legacy path, reject its use as the new credential backend, and document that undiscoverable abandoned custom files require manual removal. | +| Logout | Best-effort revokes selected long-lived credentials when feasible; clears OAuth, API-key, pending-auth, connection, AEP state, public caches, and local vault state. | Perform local reset to first-install local state: delete vault sidecar material, vault policy, encrypted vault records, daemon unlock state, user-specific SQLite rows, and public caches. | +| Credential expiry | AEP reads proactively purge expired or malformed credential records from the current aggregate. | Delete both expired metadata rows and their encrypted vault payloads on every credential-store interaction. | +| User isolation | The active CLI session represents one logged-in user and one environment at a time. | Delete user-owned local data on logout; do not preserve stale user records as a multi-owner local database partition. | +| CLI process | `--api-key` and `INFLOW_API_KEY` override saved credentials; `--auth` selects a plaintext file. | Preserve explicit ephemeral credential inputs only if required, never persist them implicitly, remove the plaintext-backend option, and test process argument/environment non-disclosure. | +| MCP process | The same CLI process enters agent mode for `--mcp`; repository MCP manifests launch the signed `inflow --mcp` executable. | Keep host MCP registration pointed at the installed signed executable; expose no secret-management tools or raw secret fields. | +| npm distribution | The published package maps `inflow` to `dist/cli.js`; the standalone target remains a Node JavaScript bundle. | Replace install guidance and MCP manifests with the signed artifact, then prevent the npm executable from recreating plaintext state. | + +## SQLite Encrypted Vault Durability Contract + +The production database is `~/Library/Application Support/InFlow/inflow.sqlite3`. Its parent directory is mode `0o700` +and the database, write-ahead log, shared-memory file, and verified backup are mode `0o600`. The application rejects +symlinks and files not owned by the current user. Secret payloads are encrypted inside SQLite vault records; macOS +Keychain is not used for credential payload storage. + +Stable relational tables: + +| Table | Stable indexed fields | Versioned payload purpose | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | +| `schema_metadata` | schema version and application version | None. | +| `principals` | internal identifier; unique Platform origin and user identifier | Non-secret account metadata. | +| `connection_profiles` | internal identifier and environment | Extensible API and authentication endpoint settings. | +| `auth_sessions` | profile, principal, authentication method, expiry, encrypted access-token identifier, encrypted refresh-token identifier | Non-secret token metadata. | +| `pending_auth` | profile, expiry, encrypted device-code identifier | Verification URL, phrase, and polling metadata. | +| `aep_identities` | principal and Service DID; Agent DID and identity kind | Signing algorithms and non-secret identity metadata. | +| `aep_credentials` | principal, Service DID, credential identifier, Grant Type, issue time, expiry, encrypted vault identifier | Non-secret credential metadata. | +| `public_documents` | namespace and canonical URL; final URL, cache time, and validators | Inspect, Platform Discovery, or OpenAPI document. | +| `settings` | setting name | Versioned non-secret setting value. | +| `vault_lifecycle` | encrypted record identifier, purpose, principal, and `pending`, `active`, or `deleting` state | Recovery metadata only; never secret material. | + +Every extensible payload uses `payload_version INTEGER NOT NULL` and `payload BLOB NOT NULL`. Version 1 is canonical +UTF-8 JSON encoded as bytes. Readers reject unknown versions instead of guessing. Fields used by selection, ownership, +expiry, uniqueness, cleanup, or joins remain typed columns and are never hidden only inside a payload. + +Database behavior: + +- Open with foreign keys enabled, extension loading disabled, defensive mode enabled, and a bounded busy timeout. +- Configure write-ahead logging, `synchronous=FULL`, `trusted_schema=OFF`, and `secure_delete=ON`. +- Use prepared statements exclusively. Reject unknown named parameters and double-quoted string literals. +- Serialize mutations with `BEGIN IMMEDIATE`; readers use short read transactions. Retry only bounded busy failures with + jitter and never retry integrity, ownership, or corruption failures. +- Create a verified SQLite backup after successful schema initialization and after credential-structure migrations. + Backups contain encrypted vault payloads and metadata, never plaintext secret values. +- Run `quick_check` at normal startup and `integrity_check` before backup replacement or explicit diagnostics. + Corruption fails closed and preserves the damaged database for diagnosis; it is never silently replaced with an empty + database. + +Encrypted vault lifecycle: + +1. Allocate a cryptographically random encrypted vault record identifier and insert a `pending` lifecycle row inside a + write transaction. +2. Encrypt the secret payload with authenticated encryption using the unlocked vault key, attach the encrypted record to + its owning SQLite metadata row, and mark the lifecycle row `active` before committing. +3. On startup, delete unfinished `pending` encrypted vault records and their lifecycle rows. Missing or corrupted active + records are secure-store failures, not absent credentials. +4. For deletion, mark the lifecycle row `deleting`, delete the encrypted vault payload, then delete the owning row and + lifecycle row. Startup resumes any interrupted deletion. +5. Hold the SQLite writer transaction while updating encrypted vault metadata so concurrent CLI and MCP processes cannot + lose lifecycle entries. +6. If SQLite is unrecoverable, secret payloads are already encrypted and must not be recovered through plaintext + fallback or operating-system credential enumeration. + +Logout applies the deletion lifecycle to every user-owned secret and row in one serialized operation while retaining +public documents. Expiry cleanup uses the same lifecycle on every credential-store interaction. A different principal +cannot resolve or query another principal's encrypted vault record identifier. + +## Task 40 Notarization + +- Signing authority: the login Keychain contains the Developer ID application identity and the `inflow-notary` profile + authenticates successfully. A Developer ID installer identity is unnecessary unless distribution adopts a signed + installer package. +- Proven locally: two differently versioned Node single-executable application bundles and their nested native modules + pass strict Developer ID signature verification with hardened runtime and library validation enabled. Version 1 + created and read a real Keychain item; independently built version 2 read the same item using the stable designated + requirement. This remains packaging and signing evidence only; Task 126 supersedes Keychain payload storage. +- Apple accepted proof submission `66077cce-14f0-4c77-92ca-984613a98a0d`. +- Apple accepted release-pipeline submission `5261e525-ef6a-43e0-bd11-e9d2864ffe9b`. +- The real GitHub release workflow signed, notarized, stapled, and verified Apple Silicon and Intel artifacts. +- Version 2 deleted the temporary Keychain proof item and an exact read verified its absence. +- Safety requirement: Node's hardened-runtime signature includes the required just-in-time execution entitlements. The + production Developer ID build must not disable library validation; the main executable and native module must share + the same Team ID. + +## Scope Boundary + +Queue 2 includes: + +- macOS signed application packaging, encrypted SQLite vault storage, local vault daemon, clean legacy deletion, CLI and + MCP integration, Homebrew distribution, hosted installation, npm CLI deprecation, documentation, upgrade, uninstall, + signing, notarization, and security verification. +- Creation of explicit follow-up queues for Windows and Linux before this queue closes. + +Queue 2 does not own: + +- Windows Authenticode, Windows Credential Manager, or Windows installer implementation. +- Linux package signing, Secret Service, or headless Linux persistent-storage implementation. +- A remote InFlow MCP service for cloud-only or disconnected Cowork sessions. +- Changes to AEP protocol specifications or AEP Node unless implementation discovers a real cross-repository contract + gap. + +## Priority Definitions + +- P0: Blocks the queue goal. +- P1: Required for a correct, durable result. +- P2: Valuable follow-up that must be completed or explicitly re-homed before this queue closes. +- P3: Long-tail work that can be deferred with little consequence. + +## Lift Scale + +- 1: Trivial. +- 2-3: Small and clear. +- 4-5: Moderate and bounded. +- 6-8: Large or ambiguous; split before starting. +- 9+: Too large for one task; split before starting. + +## Research Notes + +| Topic | Finding | Source | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Node application convention | Signed Node applications commonly package JavaScript and an in-process native module as nested signed code; the native module is not a separately callable credential utility. | https://www.electronjs.org/docs/latest/tutorial/native-code-and-electron and https://www.electron.build/docs/contents/ | +| Keychain application identity | macOS can associate Keychain access with a signed application's designated requirement across updates, but InFlow's credential payload boundary is moving to the encrypted database vault instead of Keychain payload storage. | https://developer.apple.com/library/archive/technotes/tn2206/ and Task 126 decision record | +| Homebrew command distribution | A Cask `binary` artifact is linked into `$(brew --prefix)/bin` and may point inside a staged application bundle. | https://docs.brew.sh/Cask-Cookbook | +| Homebrew integrity | Cask downloads are checksum-controlled, but release verification must not assume Homebrew independently validates every upstream nested code signature. | https://docs.brew.sh/Supply-Chain-Security | +| Node 24 SEA format | Node 24 single-executable applications run the injected main script as CommonJS only; the signed app therefore embeds a CommonJS launcher and stores the ESM CLI bundle as a signed application resource. | https://nodejs.org/download/release/latest-v24.x/docs/api/single-executable-applications.html | +| Homebrew local cask audit | The generated `inflow` cask passes Homebrew's token-based strict audit when placed in a temporary local tap. | Local Task 110 audit on 2026-07-19. | +| Homebrew tap repository | The InFlow Homebrew tap is `inflowpayai/homebrew-tap`, installed as `brew tap inflowpayai/tap`. | User decision on 2026-07-19 and local repository `/Users/nxkavian/Drive/Source/InFlow/homebrew-tap`. | +| Homebrew release hosting | The Cask downloads versioned macOS zip assets from `inflowpayai/inflow-cli` GitHub Releases. | User decision on 2026-07-19 and local Task 110 renderer. | +| GitHub macOS runner architecture | The standard `macos-15` GitHub-hosted runner is arm64; Intel uses the separate `macos-15-intel` label. | https://docs.github.com/en/actions/reference/runners/github-hosted-runners | +| Claude sandbox boundary | Cowork keeps credentials in the host keychain and does not place them in the guest virtual machine. | https://support.claude.com/en/articles/14479288-claude-cowork-architecture-overview | +| Cowork local MCP placement | Anthropic moved local MCP servers out of the Linux guest and onto the host. | https://www.anthropic.com/engineering/how-we-contain-claude | +| Cowork guest compatibility | Anthropic does not publish a stable guest distribution, architecture, C-library, package-manager, persistence, or third-party-binary compatibility contract. | https://support.claude.com/en/articles/14479288-claude-cowork-architecture-overview | +| Credential-library adoption | `@napi-rs/keyring` has roughly 5.5 times the current weekly npm downloads of `@github/keytar`; the original `atom/node-keytar` repository has been archived since 2022. | Official npm downloads API and https://github.com/atom/node-keytar | +| Prior credential-library finding | `@napi-rs/keyring` was validated as a no-shell Keychain binding, but the approved vault model removes operating-system credential stores from credential payload storage. | `Brooooooklyn/keyring-node` tag `v1.3.0` and Task 126 decision record | +| Argon2id recommendation | OWASP recommends Argon2id first with minimum memory/time/parallelism parameters and recommends scrypt only if Argon2id is unavailable. | https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html | +| Node Argon2 package candidate | `argon2` is current, Node 22-or-newer compatible, uses the reference Argon2 implementation, defaults to Argon2id, and publishes prebuilt binaries for macOS, Windows, Linux, Alpine, and FreeBSD targets. | https://www.npmjs.com/package/argon2 | +| Node Rust Argon2 package candidate | `@node-rs/argon2` has zero package dependencies, no node-gyp/postinstall path, high npm usage, cross-platform prebuilt support, and Argon2id as its documented default. | https://www.npmjs.com/package/@node-rs/argon2 | +| Built-in SQLite baseline | Node 22 supports `node:sqlite` from 22.5 but labels it active development; Node 24.15 promotes it to release candidate, and 24.14 enables defensive mode by default. | Node 22.23.1 and Node 24.18.0 SQLite documentation. | +| Encrypted SQLite candidate | `better-sqlite3-multiple-ciphers` supports encrypted SQLite through PRAGMA key/rekey, supports Node 20 through 26, and has current releases; it must be evaluated against package signing, native-module policy, ESM compatibility, and release determinism before adoption. | https://www.npmjs.com/package/better-sqlite3-multiple-ciphers | +| Envelope encryption pattern | A randomly generated data encryption key can be stored encrypted by a key-encryption key; changing the user passphrase can rewrap the encrypted data key without re-encrypting all data. | https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html | +| Cloud envelope encryption | AWS and Google Cloud describe envelope encryption as encrypting data locally with a data key and storing the encrypted data key with or near the encrypted data. | https://docs.aws.amazon.com/kms/latest/developerguide/kms-cryptography.html and https://docs.cloud.google.com/kms/docs/envelope-encryption | +| Claude Code distribution shape | Claude Code supports macOS, Linux, Windows, and Windows Subsystem for Linux. Current install surfaces include hosted shell, hosted PowerShell, Homebrew, WinGet, `apt`, `dnf`, and `apk`. | https://code.claude.com/docs/en/installation | +| Codex distribution shape | Codex CLI documents hosted shell for macOS/Linux, hosted PowerShell for Windows, npm, Homebrew Cask, and GitHub Release binary download. Official support remains macOS and Linux, with Windows experimental or Windows Subsystem for Linux in older help text. | https://github.com/openai/codex/blob/main/README.md and https://help.openai.com/en/articles/11096431 | +| Hermes distribution shape | Hermes treats macOS Apple Silicon, Windows 10/11, Linux, and Windows Subsystem for Linux as first-tier. It uses hosted shell for Linux/macOS and hosted PowerShell for native Windows; Nix is best-effort and Homebrew is unsupported. | https://hermes-agent.nousresearch.com/docs/getting-started/platform-support | +| OpenClaw distribution shape | OpenClaw uses hosted shell for macOS/Linux/Windows Subsystem for Linux and hosted PowerShell for native Windows. Its installer documentation includes non-interactive automation flags and a local-prefix command-line installer. | https://docs.openclaw.ai/install/installer | +| WinGet baseline | WinGet is the Windows Package Manager client for install, upgrade, uninstall, and package discovery. It is available on Windows 11, modern Windows 10, and Windows Server 2025 through App Installer, but may require registration or installation on fresh/sandbox hosts. | https://learn.microsoft.com/en-us/windows/package-manager/winget/ | +| Windows PowerShell baseline | Windows PowerShell is preinstalled on modern Windows, and Windows PowerShell 5.1 remains the built-in Windows edition. Hosted PowerShell is therefore the lowest-friction direct bootstrap when WinGet is absent or not registered. | https://learn.microsoft.com/en-us/powershell/scripting/learn/ps101/01-getting-started and https://learn.microsoft.com/en-us/powershell/scripting/what-is-windows-powershell | +| PowerShell shortcut commands | PowerShell aliases such as `iwr` and `irm` are aliases, not distinct distribution systems. Documentation may show either aliases or full cmdlet names, but automation should use the explicit cmdlet form where clarity matters. | https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_aliases | +| APT repository trust | Third-party APT repositories should bind trust to the repository through `Signed-By` keyrings instead of broad trusted-keyring import. | https://manpages.debian.org/testing/apt/apt-secure.8.en.html and https://www.debian.org/doc/manuals/debian-handbook/sect.package-authentication.en.html | +| DNF repository trust | DNF supports package signature checks and repository metadata signature checks through `gpgcheck` and `repo_gpgcheck`; repository metadata keys are tracked separately by repository. | https://man7.org/linux/man-pages/man5/dnf.conf.5.html | +| WinGet submission path | Public WinGet packages are submitted as manifests to `microsoft/winget-pkgs`; pull requests are validated, may be manually reviewed, and the package becomes available through the default WinGet source after acceptance. | https://learn.microsoft.com/en-us/windows/package-manager/package/repository | +| WinGet manifest authoring | WinGet manifests are versioned YAML files. `wingetcreate` can create and update manifests, and `winget validate` validates local manifests before submission. | https://learn.microsoft.com/en-us/windows/package-manager/package/manifest | +| WinGet installer boundary | WinGet supports executable, ZIP, MSI, MSIX, portable, and other installer formats; the community repository README states script-based installers are not supported. | https://learn.microsoft.com/en-us/windows/package-manager/winget/ and https://github.com/microsoft/winget-pkgs | +| Debian maintainer scripts | Debian package maintainer scripts must be executable, exit non-zero on failure, and be idempotent so package-manager recovery can safely re-run them. | https://www.debian.org/doc/debian-policy/ch-maintainerscripts.html | +| Debian package removal | Debian packages can preserve configuration files on normal removal and delete them only on purge; InFlow package scripts must not delete user credentials or state on normal uninstall. | https://www.debian.org/doc/manuals/debian-faq/pkg-basics.en | +| Windows DPAPI boundary | DPAPI `CryptProtectData` is normally decryptable by the same user logon credential on the same computer; it is not an Authenticode-bound application access-control mechanism. | https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata | +| Windows Authenticode boundary | Authenticode verifies publisher identity and file integrity; it does not by itself restrict Windows Credential Manager or DPAPI reads to a specific signed executable. | https://learn.microsoft.com/en-us/windows-hardware/drivers/install/authenticode | +| Windows package identity option | MSIX identity packages can grant package identity to desktop applications with external location, but production registration requires a trusted signed package and matching application identity metadata. | https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/grant-identity-to-nonpackaged-apps | +| Windows Credential Locker option | Windows Credential Locker is exposed through WinRT `PasswordVault` APIs for Windows apps and desktop apps that call Windows Runtime APIs, with per-app resource semantics that need source-level validation before use for InFlow secrets. | https://learn.microsoft.com/en-us/windows/apps/develop/security/credential-locker | +| Bitwarden vault model | Bitwarden encrypts vault data locally before storage, derives encryption keys from the user's master password using PBKDF2 or Argon2id, supports unlock methods that decrypt the account key in memory, and deletes decrypted vault data and keys when locked. | https://bitwarden.com/help/bitwarden-security-white-paper/ and https://bitwarden.com/help/understand-log-in-vs-unlock/ | +| 1Password device unlock model | 1Password keeps its account-password/Secret-Key encryption model and uses device unlock as a convenience factor; unlock remains bound to device-unlock state and Windows requires manual unlock after restart. | https://support.1password.com/device-unlock-security/ | +| Claude Code MCP configuration | Claude Code supports local stdio MCP servers configured by `claude mcp add`; local scope is private to the current project, user scope is private across projects, and project scope writes `.mcp.json` for source control. | https://code.claude.com/docs/en/mcp | +| Claude Code managed MCP | Enterprise administrators can deploy `managed-mcp.json` system-wide on macOS, Linux/WSL, and Windows, or restrict allowed and denied MCP servers through managed settings. | https://code.claude.com/docs/en/mcp | +| Claude Desktop local MCP config | Claude Desktop local stdio servers are configured through `claude_desktop_config.json`; documented locations include macOS `~/Library/Application Support/Claude/claude_desktop_config.json` and Windows `%APPDATA%\Claude\claude_desktop_config.json`. | https://modelcontextprotocol.io/docs/develop/connect-local-servers | +| Cowork execution boundary | Remote Cowork sessions run in Anthropic-managed sandboxes; local Cowork uses a host-native agent loop plus isolated Linux virtual-machine code execution. Local plugin MCP servers run on the host side, and local MCP servers do not run in remote sessions. | https://support.claude.com/en/articles/14479288-claude-cowork-architecture-overview | +| Cowork admin controls | Managed-device controls can disable local MCP servers and desktop extensions independently; local MCP availability is therefore an enterprise policy dependency, not an InFlow installer guarantee. | https://support.claude.com/en/articles/14479288-claude-cowork-architecture-overview | + +## Task 123 Windows and Linux Distribution Decision Record + +| Decision area | Decision | Rationale | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Windows first surface | Keep hosted PowerShell as the direct bootstrap path and add WinGet as the mainline package-manager channel. | PowerShell is broadly available on Windows. WinGet is the user-facing Windows package manager, but it can be missing or unregistered on fresh Windows Sandbox, some server, and locked-down enterprise hosts. | +| Windows package format | Use a signed MSI as the required Windows package for hosted PowerShell and WinGet installation. Do not require MSIX. | The installer must register the service, dedicated identity, files, upgrade behavior, and uninstall behavior consistently. WinGet and the hosted PowerShell bootstrap install the same signed MSI. | +| Windows script wording | Document `Invoke-RestMethod ... \| Invoke-Expression` or `Invoke-WebRequest ... \| Invoke-Expression` examples for users; treat `irm`, `iwr`, and `iex` as aliases only. | The aliases are common and convenient, but explicit cmdlet names are clearer in enterprise and agent instructions. | +| Release architecture matrix | Build release artifacts for macOS Apple Silicon and Intel, Windows `x64` and `arm64`, and glibc Linux `x64` and `arm64`. | The install scripts, package-manager channels, and final release evidence need an explicit platform and architecture matrix so an architecture is not silently skipped. | +| Linux first surface | Keep hosted shell install as the first Linux surface and add one signed package repository next. | Hosted shell is the common agent-facing bootstrap for Claude Code, Codex, Hermes, and OpenClaw. A signed repository provides better update/uninstall behavior once the first Linux package-manager channel is chosen. | +| Linux package priority | Prefer APT first, then RPM/DNF, then Homebrew Linux or Nix only if target demand appears. | Ubuntu/Debian are the clearest common baseline across agent and server environments. Fedora/RHEL follows naturally. Homebrew Linux and Nix are useful niches but weaker as a first agent-first Linux baseline. | +| Linux repository security | APT must use repository-scoped `Signed-By`; DNF must enable package signature checks and should enable repository metadata signature checks. | This keeps repository trust scoped and avoids broad keyring trust. | +| Agent host support | Continue treating Linux and Windows as required distribution targets even though some hosted agent products use guest or sandbox boundaries. | Claude Code, Codex, Hermes, and OpenClaw all keep Linux or Windows install surfaces relevant. Claude Cowork does not publish a stable Linux guest binary contract, so host-side integration remains separate in Task 125. | +| Dropped channels | Snap and Flatpak remain cut. | They do not improve the agent-first install baseline and add packaging surface that is not currently justified. | + +## Task 136 Windows Mainline Distribution Baseline + +| Area | Decision | Implementation task | Verification | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| Release artifact | Produce signed Windows `x64` and `arm64` artifacts from the same release workflow family as macOS. | Add Windows build jobs that create versioned GitHub Release assets plus `.sha256` files. | Windows runner smoke: `inflow --version`, `inflow auth status --format json`, signature/authenticity check, checksum check. | +| Hosted PowerShell installer | Keep `/cli/install.ps1` as the direct Windows bootstrap and automation path. | Install to a user-writable versioned location, place `inflow` on the user executable path, verify checksum and signature, support upgrade and uninstall. | Real PowerShell install, upgrade, uninstall, checksum-failure, and signature-failure tests. | +| WinGet package-manager path | Add WinGet after the Windows artifact is stable; the WinGet package must point at a signed release artifact, not the hosted PowerShell script. | Create or automate `winget-pkgs` manifest generation for package identifier `InFlow.InFlowCLI` or the final chosen identifier. | `winget validate`; Windows Sandbox or equivalent manifest install test; submitted pull request status tracked until accepted or explicitly parked. | +| MSI/MSIX | Make the signed MSI the required baseline package; do not require MSIX. | Build the self-contained executable and native module into an MSI that registers the service and dedicated identity. Use that MSI from hosted PowerShell and WinGet. | Validate interactive and silent install, upgrade, uninstall, service registration, signature, checksum, and applicable policy behavior. | +| Documentation | Document hosted PowerShell first for agents and WinGet as the package-manager channel once accepted. | Use explicit PowerShell cmdlet examples in long-form docs and short aliases only where command length materially matters. | README and skill review for exact install command, upgrade command, uninstall command, and non-interactive behavior. | +| Dependency boundary | Do not require Node, npm, Git, or developer tools on Windows users' machines. | Ship a self-contained signed artifact. | Clean Windows host smoke where Node is absent. | +| Security boundary | Prove the signed local vault daemon model on Windows; do not add helper binaries, operating-system credential-store payload storage, or raw secret enumeration. | Validate encrypted SQLite vault records, user unlock, daemon peer checks, and fail-closed behavior before claiming Windows credential support complete. | Real Windows vault create/read/delete tests, fake-client rejection tests, and locked-vault command tests from the packaged `inflow` binary. | + +## Task 137 Linux Mainline Distribution Baseline + +| Area | Decision | Implementation task | Verification | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Release artifact | Produce Linux `x64` and `arm64` artifacts from GitHub Release automation. | Add Linux build jobs that create versioned tar/zip artifacts plus `.sha256` files for glibc-based Linux. | Linux runner smoke: `inflow --version`, `inflow auth status --format json`, checksum check, executable permission check. | +| Hosted shell installer | Keep `/cli/install.sh` as the direct Linux bootstrap and automation path. | Install to a user-writable versioned location by default, place `inflow` on the user executable path, verify checksum, support upgrade and uninstall. | Real shell install, upgrade, uninstall, checksum-failure, unsupported-platform, and no-root tests. | +| First package-manager path | Make APT the first Linux package-manager channel. | Build `.deb` packages for `amd64` and `arm64`, publish an APT repository, and add install instructions that use repository-scoped `Signed-By`. | `apt update`, install, upgrade, uninstall, purge, repository signature failure, package checksum, and clean-host smoke. | +| RPM/DNF | Treat RPM/DNF as the second Linux package-manager channel after APT evidence is complete. | Create a follow-up task for `.rpm` packages and signed DNF repository metadata. | `dnf install`, upgrade, uninstall, repository metadata signature, package signature, and clean-host smoke. | +| Homebrew Linux and Nix | Do not make Homebrew Linux or Nix baseline. | Reopen only if agent-host demand appears or users already prefer those channels. | No baseline test. | +| Snap/Flatpak | Remain cut. | No implementation work. | Queue decision only. | +| Headless credential support | Linux installation can succeed in headless environments, but credential-bearing commands must fail closed if the encrypted local vault daemon cannot run securely. | Validate encrypted SQLite vault records, user unlock, Unix-socket permissions, daemon peer checks, and no-plaintext fallback before claiming Linux credential support complete. | Real Linux vault create/read/delete tests on desktop and headless hosts, including locked vault, unavailable daemon, fake-client rejection, and absent graphical session scenarios. | +| Package uninstall behavior | Package uninstall removes the executable and package files, not user credentials or SQLite state; purge behavior requires explicit design. | Keep maintainer scripts minimal and idempotent. Do not prompt from package scripts. | Uninstall/purge tests confirm credentials are not silently exposed or partially deleted. | +| Dependency boundary | Do not require Node, npm, Git, or developer tools on Linux users' machines. | Ship a self-contained artifact. | Clean Linux host smoke where Node is absent. | + +## Task 125 Host-Side MCP Integration Baseline + +| Area | Decision | Implementation task | Verification | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| MCP command boundary | The MCP server command is the signed `inflow --mcp` executable already described by `.mcp.json`. | Keep `.mcp.json` pointed at `inflow` on `PATH`; do not introduce a separate helper, Node package, shell wrapper, or credential-enumerating utility. | `inflow --mcp` starts from the packaged binary and `tools/list` exposes the expected CLI-backed tools without raw secret fields. | +| Claude Code registration | Support local/user/project registration through `claude mcp add --transport stdio inflow -- inflow --mcp`. | Add documentation and optional installer guidance for user-scope registration; leave project-scope `.mcp.json` as a team-controlled choice. | `claude mcp get inflow`, `/mcp` status, and one real tool call through Claude Code. | +| Claude Desktop registration | Support direct `claude_desktop_config.json` registration that runs `inflow --mcp` from the signed binary on `PATH` or an absolute installed path. | Add an installer or command helper that can print or patch the JSON config idempotently after user confirmation. | Claude Desktop restart, MCP server visible, and one real tool call. | +| Cowork placement | Treat local Cowork as host-side MCP only. Do not copy `inflow`, SQLite vault state, or credentials into the Linux virtual machine guest. | Register the host-native `inflow --mcp` server where Claude Desktop/Code will launch local MCP servers; document that remote Cowork cannot run local MCP servers. | Local Cowork tool call reaches host-side MCP while guest shell cannot read InFlow credentials. Remote Cowork reports local MCP unavailable unless bridged by desktop. | +| MCPB/DXT packaging | Do not make MCPB or DXT a baseline requirement. | Reopen only if Claude Desktop/plugin marketplace distribution requires it or if enterprise policy disables local developer MCP but permits signed desktop extensions. | No baseline test. If reopened, add signed extension packaging, install, policy, and update tests. | +| Enterprise controls | Document that managed devices can disable local MCP or desktop extensions. | Surface a clear diagnostic when `inflow --mcp` is installed but the host refuses to load local MCP servers. | Managed-policy negative test or documented manual verification. | +| Secret boundary | Every MCP request uses the same signed-process secure store as the CLI. | Ensure MCP mode does not accept raw secrets as tool parameters and does not emit raw secrets in outputs or errors. | Recursive MCP schema scan, tool-call sanitization tests, and real secure-store read from packaged `inflow --mcp`. | + +## Task 126 Local Vault Daemon Baseline + +| Area | Decision | Implementation task | Verification | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Daemon visibility | Daemon mode is internal and hidden. | Do not show `--daemon` in help, README, skills, generated LLM docs, MCP tools, shell completion, or installer copy. | Recursive docs/help/schema scan proves `--daemon` is absent from public surfaces. | +| Unlock factor | Allow either PIN or passphrase with a minimum length of six characters; do not add weak-factor scoring or denylist checks. | Add first-run unlock and normal unlock flows with KDF parameters, salt, in-memory retry throttling, and minimum-length validation. | Offline attack tests, minimum-length tests, in-memory retry-throttle tests. | +| Key derivation | Use Argon2id if packaging, signing, and release verification pass; scrypt is only an explicit fallback if Argon2id is unavailable or cannot pass release constraints. | Evaluate `argon2` and `@node-rs/argon2` against Node 24.15, macOS signing, Windows/Linux artifact plans, native-module policy, maintenance, dependency surface, and deterministic release behavior before selecting one; benchmark 64 MiB, 3 iterations, parallelism 1, 32-byte output, with 32 MiB, 3 iterations, parallelism 1 as fallback if needed. | Package audit, local build, signed macOS package smoke, Node 24.15 test, dependency/supply-chain review, and unlock benchmark. | +| At-rest boundary | User-derived encryption is the cross-platform disk security boundary, including macOS; operating-system credential stores are not used for credential payload storage. | Encrypt secret payloads in SQLite with authenticated encryption using a vault key protected by a PIN/passphrase-derived key. | Same-user database copying or metadata correlation cannot recover plaintext without unlock factor. | +| Vault key hierarchy | A random vault data key encrypts credential records; the PIN/passphrase-derived key wraps the vault data key. | Store a versioned wrapped-key envelope with KDF parameters, salt, encryption algorithm, nonce, authentication tag, and rotation metadata. | Changing PIN/passphrase rewraps only the data key; copied database remains undecryptable without the unlock factor. | +| Record encryption | Vault records use authenticated encryption with AES-256-GCM as the baseline. | Store per-record nonce, authentication tag, key identifier, algorithm version, and encrypted payload blob. | Tamper tests fail closed for modified ciphertext, nonce, tag, associated data, and metadata binding. | +| Payload envelopes | Vault plaintext is a versioned per-kind payload envelope, not arbitrary untyped JSON. | Define canonical serializers/parsers per `VaultSecretKind`; include payload version inside encrypted plaintext; reject unknown versions; never log plaintext payloads. | Tests cover every kind's payload round trip, unknown version rejection, malformed payload rejection, and log/error redaction. | +| Associated data | Vault record metadata that constrains retrieval is bound as authenticated data. | Bind at least ref, kind code, status expectations where appropriate, and record encryption version into AES-256-GCM associated data so metadata swapping fails closed. | Tamper tests modify ref, kind, status, encryption version, nonce, tag, and ciphertext and verify decryption fails or retrieval is rejected. | +| Database encryption | SQLite file encryption is optional defense-in-depth; SQLite SEE is out of scope; `better-sqlite3-multiple-ciphers` is the only encrypted-SQLite candidate to evaluate. | Keep record-level vault encryption mandatory even if database encryption is enabled; evaluate encrypted SQLite against packaging, signing, native-module, ESM, Node 24.15, and release constraints; adopt it in Task 126 only if the evaluation passes. | Copied-database tests prove credential payloads remain protected with and without database encryption; dependency review decides adoption. | +| Vault bootstrap | Encrypted SQLite needs a minimal binary bootstrap sidecar because the database key cannot be stored only inside the encrypted database. | Store only `salt`, `nonce`, `tag`, and `material` in a fixed binary format. Hardcode format version, Argon2id, KDF parameters, AES-256-GCM wrapping, key labels, and validation rules in the signed binary. Store descriptive metadata in SQLite after unlock. | Unlock tests prove the sidecar plus PIN/passphrase opens encrypted SQLite; database-only copy cannot be opened; sidecar-only copy cannot recover plaintext. | +| Bootstrap material | `material` is the AES-256-GCM encrypted form of one random 32-byte vault master key. | Generate the vault master key with a cryptographically secure random source; after unlock, use HKDF with hardcoded labels to derive separate keys for record encryption and optional SQLite file encryption. | Tests prove `material` length and unwrap behavior; derived record and database keys are distinct; passphrase change rewraps only `material`. | +| Database key derivation | The PIN/passphrase unwraps key material from the sidecar; the unwrapped material derives the SQLite database key and record encryption keys with domain separation. | Use Argon2id over the PIN/passphrase and sidecar salt to derive a key-encryption key, unwrap `material`, then derive separate keys with labels such as `inflow sqlite database encryption` and `inflow vault record encryption`. | Key-derivation tests prove changing the passphrase rewraps `material`; database and record keys are distinct; old passphrase fails. | +| Bootstrap tamper binding | Bootstrap material must be authenticated during unwrap. | Use hardcoded associated data such as `inflow-vault-header-v1`; reject missing, trailing, malformed, or wrong-length sidecar fields. | Tamper tests fail closed for modified salt, nonce, tag, `material`, byte lengths, trailing bytes, and associated-data mismatch. | +| In-memory key handling | Do not store vault keys as JavaScript strings. Base64 is not considered protection. | Keep key material in byte buffers/native buffers where possible, minimize copies, and zero buffers on lock/exit as best effort. | Memory lifecycle tests for lock, timeout, process exit, and zeroization paths where inspectable. | +| Vault commands | Expose user-facing vault management under `inflow vault ...`; keep only daemon mode hidden; do not add a separate setup command. | Add `status`, `unlock`, `lock`, `policy`, `policy set`, `change-passphrase`, and `reset`. `unlock` creates the vault on first use. | Help/docs/MCP expose `inflow vault ...` commands but never expose `--daemon`; command tests prove no `setup` command exists. | +| First-run unlock | First-run initialization happens through `inflow vault unlock` with explicit creation copy. | If no vault exists, prompt: `No InFlow vault exists yet. Create a PIN or passphrase to protect local InFlow credentials.` then collect and confirm the factor. | Human-output and agent-output tests for first-run, existing locked, existing unlocked, and confirmation mismatch flows. | +| First-run command use | Human credential-using commands may initialize the vault inline; agent/MCP commands must not. | In TTY mode, a command such as `auth login` may show the first-run vault prompt and continue; in agent/MCP/non-TTY mode, return `VAULT_NOT_INITIALIZED` with a human action to run `inflow vault unlock`. | Tests cover TTY inline initialization, agent JSON `VAULT_NOT_INITIALIZED`, MCP locked/not-initialized output, and no passphrase fields in schemas. | +| Locked command use | Human credential-using commands may unlock inline; agent/MCP commands must return structured human-action output. | In TTY mode, prompt `InFlow vault is locked.` and collect the PIN/passphrase, then continue the original command; in agent/MCP/non-TTY mode, return `VAULT_LOCKED` explaining that a human must run `inflow vault unlock` and that the unlock factor must not be provided through agent or MCP input. | Tests cover TTY inline unlock, agent JSON `VAULT_LOCKED`, MCP locked output, and no passphrase fields in schemas or logs. | +| Unlock throttling | Unlock retry throttling is daemon-memory-only. | Track failed attempts and increasing delays only while the daemon is alive; restart clears the counter; do not add a throttle sidecar or throttle database row. | Tests prove retries are delayed during one daemon lifetime, reset after daemon restart, and all failures return non-disclosing unlock errors. | +| Passphrase change | Changing the PIN/passphrase is a rewrap operation, not a credential wipe. | `inflow vault change-passphrase` asks for current factor, new factor, and confirmation; it rewraps the existing vault data key. | Tests prove credential records remain readable after change and old factor fails. | +| Vault reset | Reset is the only destructive vault command and must be explicit; it returns local state to first-install shape. | `inflow vault reset` deletes local credential payloads, user-specific metadata, vault policy, vault sidecar material, daemon unlock state, and public caches; non-interactive mode requires `--force` and structured deletion output. | Reset tests prove credentials, grants, enrollments, pending auth, vault policy, sidecar material, daemon state, and public caches are deleted. | +| Logout cleanup | `inflow auth logout` performs the same local first-install cleanup as `inflow vault reset`, plus selected remote cleanup when feasible. | Delete vault sidecar material, vault policy, encrypted records, daemon unlock state, user-specific metadata, and public caches; attempt remote revocation only for credentials that are long-lived or lack definitive expiration. | Logout tests prove the next login starts with first-run vault creation and cannot inherit prior policy, records, caches, or unlock state. | +| Reset file cleanup | Reset/logout delete local state files rather than leaving an empty database. | Remove `inflow.sqlite3`, `inflow.sqlite3-wal`, `inflow.sqlite3-shm`, the vault sidecar, and the daemon socket; keep or recreate the application directory with strict permissions only as needed. | File-system tests prove reset/logout remove database, sidecar, write-ahead log, shared-memory, socket, and stale runtime artifacts. | +| Reset daemon ordering | Reset/logout stop the daemon before deleting local vault files and fail closed if the live daemon cannot be stopped safely. | Connect to the daemon if running, request lock-and-shutdown, wait a bounded time, then delete local state files; if the daemon is absent continue cleanup, but if it is alive and refuses or times out, return `VAULT_DAEMON_BUSY` without deleting database or sidecar files. | Tests cover running daemon shutdown, absent daemon cleanup, stale socket cleanup, busy daemon failure, and no partial deletion while daemon is live. | +| Daemon lifetime | Expose lock policy through vault subcommands and use a best-practice default rather than forcing indefinite unlock. | Implement a local policy record with idle-timeout, indefinite, and lock-on-sleep options. | Policy tests for default, indefinite, timeout, sleep-lock event, explicit lock, and daemon restart. | +| Default lock policy | Default to an eight-hour idle timeout with lock-on-sleep enabled. | Initialize new vaults with idle-timeout `8h`, lock-on-sleep `true`, lock-on-explicit-logout `true`, and lock-on-daemon-exit `true`; allow user changes through vault policy commands. | Default-policy tests and policy-override tests for human and agent modes. | +| Daemon API | The daemon is a secure dumb vault, not an InFlow protocol engine. | Expose vault state, unlock/lock, policy, exact-reference secret put/get/delete/exists/touch, expiry cleanup, reset, and passphrase-change operations; do not expose AEP, MPP, x402, payment, approval, signing orchestration, HTTP fetch, or command execution operations. | API contract tests prove the daemon has no protocol-specific command surface and no network-request surface. | +| Vault backend interface | CLI/Core credential access goes through one generic `VaultBackend` contract. | Define `status`, `unlock`, `lock`, `getPolicy`, `setPolicy`, `putSecret`, `getSecret`, `deleteSecret`, `exists`, and `touch`; keep local daemon, AWS Secrets Manager, HashiCorp Vault, and future enterprise stores as backend implementations. | Type and source review prove AEP, MPP, x402, auth, and API-key storage depend on `VaultBackend`, not a local-daemon-specific module. | +| Exact-reference retrieval | Secret retrieval requires exact reference plus expected kind and active status. | `getSecret` accepts `{ reference, expectedKind }`; reject partial references, search by metadata, search by value, list-payload operations, deleted records, pending records, and records with the wrong kind. | Negative tests for wrong kind, partial reference, missing reference, deleted reference, pending reference, expired reference, and list/export attempts. | +| IPC framing | Use bounded length-prefixed JSON messages over the local IPC socket for the macOS proof. | Define request `{ version, id, method, params }` and response `{ version, id, ok, result | error }`; reject unknown versions, unknown methods, batches, oversized messages, malformed JSON, and trailing bytes. | IPC tests cover success, typed errors, malformed frames, max-size rejection, unknown version/method, no batch support, and secret redaction. | +| IPC method surface | IPC methods are generic vault/secret methods only. | Allow `vault.status`, `vault.unlock`, `vault.lock`, `vault.changePassphrase`, `vault.reset`, `vault.getPolicy`, `vault.setPolicy`, `secret.put`, `secret.get`, `secret.delete`, `secret.exists`, `secret.touch`, `secret.deleteExpired`, and internal `daemon.shutdown`; no HTTP, AEP, MPP, x402, payment, sign, or execute methods. | Method-surface tests and source review prove only approved methods are registered. | +| Vault reference shape | Internal vault references are opaque generated identifiers, not protocol-derived paths. | Use a shape such as `vlt_` plus a ULID/UUID/random identifier; do not encode user ID, Service DID, grant type, environment, credential type, or protocol fields into the portable ref. Backend implementations may map the opaque ref into provider-specific paths internally. | Tests and log scans prove vault refs do not contain protocol metadata; backend mapping tests prove provider paths are hidden from CLI/Core protocol code. | +| Secret kind encoding | `VaultSecretKind` is a fixed code-facing enum/string-literal set and a stable integer in SQLite. | Define one mapping source; never reuse or renumber integer codes; keep retired values reserved; fail closed on unknown database values; render JSON/debug output with string names. | Mapping tests prove round trip, unknown-code rejection, retired-code reservation, and no ad hoc string writes to the database. | +| Secret kind inventory | Final `VaultSecretKind` values come from persisted secret source inspection before implementation. | Initial source-backed candidates are auth access token, auth refresh token, saved InFlow API key, pending device code, and AEP credential payload; inspect AEP identity storage and payment flows before adding any identity-seed or payment-credential kind. | Inventory tests and source citations prove every kind has a storage caller and every storage caller has exactly one kind. | +| Record status encoding | `VaultRecordStatus` is a fixed code-facing enum/string-literal set and a stable integer in SQLite. | Use `1 = active`, `2 = pending`, and `3 = deleting`; never reuse or renumber values; hard-delete after deletion completes rather than retaining tombstones by default. | Recovery tests prove startup cleans pending/deleting records; mapping tests reject unknown values and reserve retired values. | +| SQLite table split | Use one SQLite database with searchable protocol metadata tables and exact-reference encrypted vault payload rows. | Keep protocol lookup fields such as Service DID, grant type, expiration, status, and vault reference in metadata tables; keep encrypted payload, nonce, tag, kind, status, and lifecycle fields in vault records. | Query tests prove protocol tables perform required lookups; vault tests prove payload rows are retrieved only by exact reference and kind/status checks. | +| IPC transport | The CLI and daemon communicate through a local operating-system IPC channel, not HTTP. | Use a Unix domain socket for the macOS proof under a user-owned private runtime directory; reject symlinks, wrong ownership, and permissive modes. | Socket directory and socket-permission tests; fake socket path and symlink attack tests. | +| Peer verification | The daemon checks the connecting process where the operating system exposes enough peer information. | On macOS, verify same effective user, obtain the peer process identifier when available, resolve the executable, and verify the signed InFlow executable identity for packaged builds. | Packaged-build tests for accepted InFlow client, rejected unsigned client, rejected wrong Team ID, rejected wrong path, and safe process-exit handling. | +| Peer verification mode | Release/package mode fails closed when peer verification cannot be completed; development mode may bypass only through explicit development configuration. | Implement a `PeerVerifier` platform adapter; macOS release verifier requires same user, owned private socket path, peer process identifier where available, resolved executable, and expected InFlow signing identity; tests/dev may inject a fake verifier only outside release mode. | Release-mode tests reject unsigned, wrong signer, missing peer process, failed signature check, and fake clients; dev-mode tests require explicit opt-in. | +| Client authentication | Same-user and peer-process checks are defense-in-depth, not the complete security boundary. | Use socket permissions, peer identity checks, signed-binary checks where available, and request-level capability rules, but rely on no-raw-secret API design. | Fake-client tests on macOS first, then Windows named-pipe and Linux Unix-socket tests. | +| Secure vault authorization | The daemon may return secret material only by exact reference to a verified signed InFlow caller while the vault is unlocked. | Require exact secret reference, expected kind, and active record status; reject list-values, export, search-by-value, partial-match retrieval, decrypt-arbitrary, signing, payment, fetch, and execute-command requests. | Attack tests prove fake clients cannot enumerate secrets, extract by partial match, sign arbitrary payloads, make network calls through the vault, or bypass kind/status checks. | +| Protocol ownership | CLI/Core owns AEP, MPP, x402, approval polling, idempotency, HTTP, credential expiry decisions, output rendering, and signing logic after retrieving exact secret material from the vault. | Keep protocol-specific validation and network behavior out of the daemon; model the daemon behind a pluggable vault backend interface. | Source review proves protocol logic imports the vault interface rather than daemon internals, and daemon modules do not import AEP/MPP/x402/payment flow modules. | +| MCP unlock | Explore an MCP App unlock flow only if it can keep the PIN/passphrase out of the model transcript and host logs. | Design a UI-mediated unlock path that sends the factor only to the signed local process, or reject MCP App unlock if host isolation cannot prove that property. | Negative tests prove the unlock factor is not present in MCP messages, logs, tool inputs, or model-visible content. | +| Agent locked output | Non-interactive and MCP callers must not be told to enter or provide the unlock factor themselves. | Return structured `VAULT_LOCKED` output explaining that a human must manually run `inflow vault unlock`, unless a future secure MCP App unlock path is proven and enabled. | Agent JSON tests prove the message addresses the human operator and exposes no PIN/passphrase field or tool argument. | +| macOS-first proof | Refactor macOS to the cross-platform vault-daemon model before Windows/Linux proofing. | Implement the daemon, encrypted vault envelope, CLI/MCP client path, and vault commands on macOS using the existing signed artifact without Keychain payload storage. | End-to-end macOS tests: first-run unlock, unlock, command reuse, MCP locked response, lock, timeout, restart, same-user fake-client attempt. | + +## Active Focus Window + +| Order | Status | Focus | Next action | +| ----- | ------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| 124 | Blocked | Windows production distribution | Run the nonpublishing Windows workflow after commit/push, then run production signing after Azure identity approval. | + +## Completion Evidence + +| Evidence | Required? | Result | Notes | +| ----------------------------------------------------------------------------------------------------------- | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Full repository `pnpm typecheck`, `pnpm lint`, `pnpm test`, and `pnpm typedoc`. | Yes | Passed | Passed on 2026-07-26 with Node 24 after the update-notification, documentation, bundled-skill, build-cache, and Windows external-signing workflow changes. | +| Signed nested-code verification and Gatekeeper assessment. | Yes | Passed | GitHub release workflow verified strict nested signatures and Gatekeeper assessment for Apple Silicon and Intel artifacts on 2026-07-20. | +| Reproducible macOS application build script. | Yes | Passed | Real GitHub release workflow built, signed, notarized, stapled, verified, zipped, checksummed, and uploaded Apple Silicon and Intel artifacts. | +| Apple notarization and stapled-ticket validation. | Yes | Passed | Apple accepted local preserved submissions and the real GitHub release workflow passed stapled-ticket validation for both release artifacts. | +| Prior Keychain create/read/delete proof from the packaged executable. | Yes | Passed | Version 1 created/read; version 2 read/deleted; exact absence verified. This is packaging evidence, not the target credential-store model. | +| Upgrade test using two versions with the same designated requirement. | Yes | Passed | Independently signed version 2 read version 1's proof item. Task 126 replaces credential payload storage with encrypted SQLite vault records. | +| Homebrew install, upgrade, uninstall, and `PATH` tests on clean Apple Silicon and Intel environments. | Yes | Passed | Public tap install, `inflow --version`, `auth status --format json`, reinstall, and uninstall passed on Apple Silicon. Intel artifact was built, notarized, stapled, verified, checksummed, cask-audited, and fetched. | +| GitHub Release automation dry run. | Yes | Passed | Merged workflow dry run passed through GitHub Actions for Apple Silicon and Intel artifacts. | +| Hosted installer install, upgrade, uninstall, checksum, signature, and failure-path tests. | Yes | Partial | Hosted `install.sh` passed endpoint, checksum, signature, Gatekeeper, `inflow --version`, `auth status`, and uninstall with temporary paths. Hosted `install.ps1` now has a local Windows branch for signed `inflow--windows-.zip` artifacts; real Windows execution remains pending until those artifacts exist. | +| Windows Azure Artifact Signing release workflow. | Yes | Partial | The workflow builds x64 and ARM64 executable payloads natively, transfers them to the supported x64 signing runner, signs the executable before MSI construction, signs the MSI before release metadata, verifies publisher and timestamp state, and prevents unsigned publication. Local syntax, formatting, and repository gates pass; the nonpublishing GitHub Actions run and production signing run remain pending. | +| Windows WinGet manifest validation, install, upgrade, uninstall, and submission tracking. | Yes | Pending | Proves the Windows package-manager baseline in addition to hosted PowerShell. | +| Linux release artifacts, hosted shell installer, APT repository, package-manager, and headless vault tests. | Yes | Partial | Native ARM64 and AMD64 builds pass hosted-installer checksum rejection, non-root installation, system-vault tests, and attestations. Workflow run `30187714399` passed repository-scoped APT metadata, RPM package and repository metadata signatures, modified-package and modified-metadata rejection, DNF installation with `gpgcheck=1` and `repo_gpgcheck=1`, version execution, uninstall, and artifact upload. Production signing and publication remain pending. | +| Claude Desktop and local Cowork host-side MCP installation and real tool-call tests. | Yes | Passed | Local-only debug-signed `dist/macos/bin/inflow --mcp` JSON-RPC smoke passed for tool listing, read/write hints, vault status, and agent unlock behavior; user completed the controlled Claude Desktop/local Cowork host-side checklist. | +| CLI and MCP secret non-disclosure tests. | Yes | Passed | Task 90 covers structured API-key status output and recursive MCP input schema scanning for raw secret fields. | +| Legacy configuration deletion test with no migration. | Yes | Passed | Task 80 covers explicit legacy JSON paths, `cwd/config.json`, safe directory handling, and `deleteConfig`. | +| SQLite concurrency, expiry, corruption, and crash tests. | Yes | Passed | Task 122 added multi-handle SQLite coverage, typed corrupt-database handling, expired AEP credential cleanup, and recoverable storage lifecycle failure coverage. | +| Packaged macOS encrypted-vault security smoke. | Yes | Passed | Passed on 2026-07-25 against the hardened Developer ID-signed executable. The smoke verified native-module tamper rejection, fake-daemon rejection, unsigned-client rejection, same-user task-memory denial, encrypted-at-rest secret scanning, lock/unlock behavior, logout cleanup, and daemon shutdown. | +| npm CLI deprecation and documentation review. | Yes | Passed | Task 130 covers the npm shim behavior, structured output, tests, and installation guidance. | +| Signed-binary update notification behavior. | Yes | Passed | GitHub Release checks use a two-second deadline and a 24-hour in-process cache, polling loops do not fetch, `NO_UPDATE_NOTIFIER` disables checks, human output links Homebrew and hosted-installer guidance, and agent output preserves the `current_version` and `latest_version` contract. | +| Signed-application user and agent documentation. | Yes | Passed | Root and CLI documentation cover native installation, encrypted-vault authentication, MCP and local Cowork placement, upgrade, uninstall and purge behavior, troubleshooting, privacy, and security. Bundled skills direct locked users to terminal-only unlock without accepting a factor through an agent surface. | + +## Ordered Task List + +Current cursor: 150 Next task: run complete release and security verification. + +Status values: Not Started, In Progress, Blocked, Waiting On Decision, Done, Cut. Evidence types: Source, Test, Review, +Artifact, User Decision, External. + +| Order | Status | Priority | Lift | Task | Owning specs | Depends on | Output | Verification | User input needed | +| ----- | ------------------- | -------- | ---: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | +| 10 | Done | P0 | 2 | Freeze the original secret/database boundary. Task 126 supersedes the Keychain-reference model with encrypted SQLite vault records and a signed local daemon. | Queue Decision Log | None | Superseded storage-boundary decision. | User Decision and threat-model review. | No. | +| 20 | Done | P0 | 4 | Audit every current secret, metadata record, storage caller, logout/delete path, MCP surface, legacy file path, and distribution entry point. | Current source | Task 10 | Source-backed inventory with line citations. | Source and Review. | No. | +| 30 | Done | P0 | 4 | Evaluate maintained native credential-store bindings and reject enumeration exposure, shell/file fallbacks, runtime downloads, unsigned native artifacts, and unsupported Node targets. | Security boundary | Task 20 | Superseded dependency finding and bounded binding plan. | Source, External, and security review. | No. | +| 40 | Done | P0 | 5 | Build a packaging proof for signed nested Node code, stable designated requirement, notarization, non-Applications staging, and `PATH` linking. | Distribution design | Tasks 20-30 | Reproducible proof artifact and findings. | Artifact, prior Keychain proof, codesign, Gatekeeper, notarization. | No. | +| 50 | Done | P0 | 4 | Freeze the minimal SQLite schema, versioned payload envelope, indexed queries, transactions, locking, file permissions, corruption behavior, and concurrency guarantees. | Storage design | Tasks 10, 20 | Reviewed database contract. | Source and Review. | No. | +| 60 | Done | P0 | 5 | Implement the secure-store and database interfaces without raw-secret enumeration or plaintext fallback. | Tasks 30, 50 | Tasks 30, 50 | Core storage implementation and tests. | Test and security review. | No. | +| 70 | Done | P0 | 5 | Move InFlow auth, API keys, AEP identities/credentials, caches, connection settings, and pending state to the approved stores. | Task 20 inventory | Task 60 | Complete production persistence replacement. | Full source and integration tests. | No. | +| 80 | Done | P0 | 3 | Implement automatic deletion of the exact legacy configuration with no data migration and require fresh authentication. | Clean-break decision | Task 70 | Idempotent legacy cleanup. | Real-file tests and secret-remanence review. | No. | +| 90 | Done | P0 | 4 | Verify every CLI and MCP command uses the signed-process store and never exposes raw secret operations or values. | CLI/MCP contracts | Tasks 60-80 | Integrated secure CLI and MCP behavior. | CLI, MCP, sanitization, and real-store tests. | No. | +| 100 | Done | P0 | 5 | Create the reproducible macOS build, nested signing, Developer ID, hardened runtime, notarization, stapling, and artifact verification pipeline. | Packaging proof | Tasks 40, 90 | Release build system. | Artifact and External verification. | No. | +| 105 | Done | P0 | 2 | Cut the repository runtime contract from Node 22/24 support to Node 24.15-or-newer support across package engines, documentation, CI, type baseline, and generated install guidance. | Runtime contract | Task 100 | Single Node 24 baseline. | CI, package metadata review, generated docs review. | No. | +| 110 | Done | P0 | 4 | Create the Homebrew Cask/tap distribution so `brew install` places `inflow` on Homebrew's executable path without requiring `/Applications`. | Distribution decision | Task 100 | Versioned checksummed Homebrew installation. | Clean install, upgrade, uninstall, and signature tests. | No. | +| 115 | Done | P0 | 4 | Create manual GitHub Release automation for notarized macOS artifacts, with dry-run mode, Apple certificate import, notary profile setup, artifact upload, checksum capture, and Homebrew tap update. | Release automation | Tasks 100-110 | Repeatable signed binary release workflow. | Dry-run workflow, release workflow review, local script checks. | No. | +| 118 | Done | P0 | 2 | Create the `inflow-server` PR that publishes the hosted `/cli`, `/install.sh`, and `/install.ps1` installer files after the signed binary artifact names and verification behavior are confirmed. | Distribution decision | Task 115 | Hosted installer server PR. | Server PR review and hosted-script smoke checks. | No. | +| 120 | Done | P0 | 4 | Create an HTTPS-hosted, versioned installer on an InFlow domain with architecture detection, checksum and signature verification, safe versioned staging, `PATH` installation, upgrade, and uninstall behavior. | Distribution decision | Task 118 | Human/agent install path. | Clean-machine and tamper/failure tests. | No. | +| 122 | Done | P0 | 3 | Verify SQLite credential-store durability under concurrent CLI/MCP access, credential expiry cleanup, interrupted deletion recovery, corruption handling, and crash-safe storage lifecycle behavior. | Storage implementation | Tasks 60-90 | SQLite evidence report and regression coverage. | Concurrency tests, expiry tests, corruption tests, crash harness. | No. | +| 123 | Done | P0 | 3 | Create the source-backed cross-platform distribution decision record for Windows and Linux, including agent-host operating system support, Windows PowerShell versus WinGet roles, and Linux package-manager priority. | Distribution scope | Task 122 | Windows and Linux distribution sequence. | External source review and User Decision. | No. | +| 124 | Blocked | P0 | 2 | Prove the Windows signed local vault daemon, then run real hosted PowerShell and WinGet install, upgrade, uninstall, checksum, signature, manifest-validation, submission-tracking, and failure-path tests against signed Windows release artifacts. | Hosted installer | Tasks 126, 136 | Windows installer, WinGet, and credential-boundary evidence. | Real PowerShell tests, WinGet validation/install tests, smoke, upgrade, uninstall, failure tests, and same-user fake-client rejection tests. | Signed Windows release artifacts and Windows or PowerShell-capable host required. | +| 125 | Done | P0 | 4 | Package and register the signed host-side `inflow --mcp` integration for Claude Desktop and local Cowork without copying secrets or the credential-bearing application into the Linux guest. | Cowork host boundary | Tasks 100-122 | Installed host MCP integration. | Local-only debug-signed `dist/macos/bin/inflow --mcp` JSON-RPC smoke passed without daemon leakage; user-completed Claude Desktop/local Cowork checklist. | No. | +| 126 | Done | P0 | 5 | Implement the cross-platform local vault daemon model on macOS first, including PIN/passphrase unlock, encrypted SQLite vault records, visible `inflow vault ...` commands, hidden daemon mode, no-raw-secret daemon API, removal of Keychain payload storage, and CLI/MCP client integration. | Vault security | Tasks 60-122 | macOS vault-daemon security proof. | End-to-end macOS CLI tests, signed debug application smoke tests, daemon shutdown/reset/idle/sleep exit tests, socket-race tests, and repository regression tests. | No. | +| 130 | Done | P1 | 4 | Define and implement the old npm executable behavior after signed-binary cutover, including the exact human and agent-facing message, exit code, JSON response, and safe prevention of plaintext state recreation. | Release transition | Tasks 100-125 | npm command deprecation behavior. | npm install/run tests, JSON contract tests, documentation review. | No. | +| 135 | Done | P1 | 4 | Define and implement signed-binary update awareness for human and agent executions, including release-source checks, quiet/non-interactive behavior, structured update data, and Homebrew/install-script guidance. | Release transition | Tasks 110-120 | Human and agent update notification behavior. | CLI output tests, agent JSON tests, cache/rate-limit tests. | No. | +| 136 | Done | P1 | 3 | Define the Windows mainline distribution baseline for agent-first installation, including GitHub Release artifacts, hosted PowerShell install, WinGet packaging, and whether MSIX or MSI is necessary. | Windows distribution | Task 123 | Windows distribution decision and task plan. | Source-backed package-manager review and user decision. | No. | +| 137 | Done | P1 | 3 | Define the Linux mainline distribution baseline for agent-first installation, including GitHub Release artifacts, hosted shell install, and the first supported package-manager channel among APT, RPM, Homebrew Linux, and Nix. | Linux distribution | Task 123 | Linux distribution decision and task plan. | Source-backed package-manager review and user decision. | No. | +| 138 | Waiting On Decision | P0 | 5 | Build and verify Linux signed release artifacts, hosted shell installation, APT package repository, checksum/signature verification, upgrade, uninstall, unsupported-platform handling, no-root behavior, and headless secure-vault failure behavior. | Linux distribution | Tasks 126, 137 | Linux installer and credential-boundary evidence. | Linux runner smoke, hosted shell tests, APT install/upgrade/uninstall/purge tests, repository-signature failure tests, and headless vault failure tests. | Production repository host and signing-key custody decision required before publication. | +| 140 | Done | P1 | 4 | Update installation, authentication, MCP, Cowork, upgrade, uninstall, troubleshooting, privacy, and security documentation for the signed application. | Documentation | Tasks 100-138 | Complete user and agent documentation. | Documentation build and source review. | No. | +| 150 | Blocked | P1 | 5 | Run complete repository, encrypted vault, SQLite, packaged CLI/MCP, upgrade, installer, Homebrew, notarization, and security verification. | Completion Evidence | Tasks 60-140 | Release evidence report. | Every required evidence row. | Complete Tasks 124 and 138. | +| 160 | Done | P1 | 1 | Cut Snap and Flatpak from the active distribution plan. | Distribution decision | Task 122 | Cut-channel record. | User Decision and queue review. | No. | +| 170 | Cut | P2 | 1 | Package the signed CLI through Snap or Flatpak. | Linux app stores | None | None. | User Decision. | Revisit only if demand appears. | + +## Missed Or Hidden Work Found + +| Item | Why it was hidden | Proposed handling | Status | +| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- | +| Arbitrary legacy `--auth` / `INFLOW_AUTH_FILE` files cannot be discovered automatically. | The clean-break decision named the normal configuration file, while current source permits any caller-selected path. | Task 80 deletes the default path and any explicit legacy path supplied to the signed binary; Task 140 owns user-facing manual cleanup guidance for abandoned paths. | Handled by Tasks 80 and 140. | +| A missing or corrupt SQLite index can leave encrypted vault records unreachable from metadata. | Stable indexes and lifecycle rows are required to select, expire, and delete credentials without plaintext fallback. | Tasks 122 and 126 cover lifecycle cleanup, corruption failure behavior, verified backup behavior, and encrypted vault record cleanup. | Handled by Tasks 122 and 126. | +| Production APT and RPM repository hosting and signing-key custody are not selected. | Disposable continuous-integration signing proves repository mechanics but cannot establish the production trust root or stable repository URL. | Task 138 must select the production host, offline recovery-key custody, automation signing subkey, rotation period, and revocation publication path before publishing. | User decision required before production publication. | +| Turborepo did not include bundled skill files in the CLI build cache key. | The CLI build reads repository skill files outside the package directory, while the explicit build inputs covered only package-local files. | The CLI-specific build inputs include skills and every external file read by the build identity calculation; a forced build verified both skill bodies in `dist/cli.js`. | Handled by Task 140. | + +## Risk Register + +| Risk | Impact | Mitigation | Status | +| ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------- | +| A candidate native credential package exposes insecure fallbacks or untrusted prebuilt binaries. | Credential access leaves the signed boundary or inherits supply-chain risk. | Keep credential payload storage in the encrypted vault model and audit any native module before use. | Superseded | +| Homebrew or installer staging modifies signed nested code. | Signature, notarization, or daemon identity checks fail. | Prove staging and upgrades with real artifacts before selecting layout. | Open | +| The old npm CLI remains installed and recreates plaintext state. | Secrets return to `config.json` after the secure cutover. | Deprecate executable behavior, delete legacy state idempotently, and document uninstall/upgrade ordering. | Open | +| SQLite payload flexibility hides unindexed access patterns. | Performance degrades or later schema changes become unavoidable. | Inventory real queries first and promote only proven stable lookup fields to columns. | Open | +| Clean deletion removes data before users understand the cutover. | Users must unexpectedly reauthenticate and regenerate grants. | Announce the clean break in installer, release, and command output while keeping deletion automatic. | Open | +| Signing credentials or notarization authority are unavailable to automation. | Production artifact cannot satisfy the queue goal. | Establish release ownership and a protected signing pipeline during the packaging proof. | Open | +| A forgotten custom legacy authentication file remains outside the default location. | Plaintext credentials survive the automatic cutover cleanup. | Delete known paths, prevent the signed CLI from consuming them, and document manual search/removal. | Open | +| Apple notarization processing is externally delayed. | Stapled Gatekeeper and offline-ticket validation cannot finish until Apple returns a verdict. | Preserve submissions and use the GitHub release workflow evidence when Apple returns verdicts. | Closed | +| SQLite loss or corruption makes encrypted vault records unreachable from metadata. | Secrets remain encrypted but cannot be selected or deleted through normal lifecycle operations. | Use lifecycle records, verified backups, fail-closed handling, and startup cleanup for incomplete writes. | Mitigated | + +## Implementation Batch Map + +| Batch | Tasks | Purpose | +| ------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------- | +| Architecture proof | 10-50 | Freeze storage boundaries and prove the signed distribution before broad implementation. | +| Persistence replacement | 60-90 | Replace plaintext storage and integrate every CLI/MCP path. | +| Distribution | 100-138 | Build, sign, notarize, install, register host MCP, retire the npm executable, and prove Windows/Linux baselines. | +| Documentation and release | 140-160 | Document, verify, release, and re-home other platforms. | + +## Blocked Task Protocol + +Blocked tasks must include: + +- Blocking condition. +- Attempted resolution. +- Decision needed, if any. +- Next unblock action. + +## Queue Closure Rules + +Do not close this queue until: + +- Every task is Done, Cut with rationale, or moved to another queue or planning document. +- Missed Or Hidden Work Found is empty, promoted, cut with rationale, or moved. +- Decision Points are resolved, cut with rationale, or moved. +- Completion Evidence is satisfied. +- Final Handoff is complete. + +Do not reorder, reprioritize, or cut tasks without recording the reason. Ask the user before changing P0/P1 priority +unless it is a blocker carve-out. + +## Final Handoff + +- Summary: +- Completed tasks: +- Cut or deferred tasks and where they moved: +- Decisions resolved: +- Completion evidence: +- Remaining risks: +- Files changed: + +## Working Rules For This Queue + +- Check the source before relying on a boundary. +- Keep source-backed state separate from target design. +- Update task status as work progresses; do not wait until the end to mark everything Done. +- Include the Active Focus Window and the single next action in every status update, pause point, handoff, or final + control-return message. +- Apply the No Buried Work Rule before every status update, pause point, handoff, or final control-return message. +- Record discovered work immediately under Missed Or Hidden Work Found; promote it only after user approval, explicit + queue-scope confirmation, or a blocker carve-out. +- Try to break down tasks with a lift higher than 5 into smaller well-known tasks. +- Do not use this file as a changelog. +- When a blocker appears, report it in chat with Question, Context, Examples, Options, and Recommendation. 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/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 c31fea6..feb9c55 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,23 @@ "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-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 64a4358..10452af 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..38632d2 --- /dev/null +++ b/packaging/linux/install.sh.template @@ -0,0 +1,125 @@ +#!/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}" +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 +} + +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." +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/$asset.sha256" "$release_base_url/$asset.sha256" +( + 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..00edfb0 --- /dev/null +++ b/scripts/render-linux-installer.mjs @@ -0,0 +1,13 @@ +#!/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'); + +mkdirSync(dirname(output), { recursive: true }); +writeFileSync(output, template.replaceAll('__INFLOW_VERSION__', version), { mode: 0o755 }); +process.stdout.write(`${output}\n`); 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/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-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 4af888c..c70a832 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 2ec2757..3dd922e 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