diff --git a/.github/workflows/release-integrity.yml b/.github/workflows/release-integrity.yml index 051cb1a0..f45379fd 100644 --- a/.github/workflows/release-integrity.yml +++ b/.github/workflows/release-integrity.yml @@ -34,6 +34,22 @@ jobs: - name: Build release artifacts run: python -m build + # The TypeScript CLI is an executable release artifact and must be + # covered by the same checksum manifest and signature as the Python + # ones; building it only as a CI artifact left it outside the signed + # release path entirely. + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Build the TypeScript CLI bundle + run: | + corepack enable + cd js + pnpm install --frozen-lockfile + pnpm --filter @seedpass/cli build + cp packages/cli/dist/seedpass-js.mjs ../dist/ + - name: Generate and verify checksums run: | python scripts/release_integrity.py generate --artifact dist --base-dir . --output SHA256SUMS diff --git a/.github/workflows/ts-parity.yml b/.github/workflows/ts-parity.yml index f0005916..535cc537 100644 --- a/.github/workflows/ts-parity.yml +++ b/.github/workflows/ts-parity.yml @@ -55,6 +55,17 @@ jobs: node "$BIN" fingerprint add --name ci > /dev/null node "$BIN" entry add password ci-site --length 16 > /dev/null test -n "$(node "$BIN" entry reveal ci-site)" + - name: Verify the packed npm tarball installs and runs + # `files`/`exports` mistakes only surface at install time: a package + # that omits its entry point still builds and tests fine here. + run: | + set -eu + pnpm --filter @seedpass/cli pack --pack-destination "$RUNNER_TEMP" + TARBALL="$(ls "$RUNNER_TEMP"/seedpass-cli-*.tgz)" + mkdir -p "$RUNNER_TEMP/packtest" && cd "$RUNNER_TEMP/packtest" + npm init -y > /dev/null + npm install "$TARBALL" > /dev/null + ./node_modules/.bin/seedpass-js --version - name: Upload CLI bundle uses: actions/upload-artifact@v4 with: @@ -63,6 +74,38 @@ jobs: js/packages/cli/dist/seedpass-js.mjs js/packages/cli/dist/seedpass-js.mjs.sha256 + javascript-supply-chain: + runs-on: ubuntu-latest + defaults: + run: + working-directory: js + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Enable pnpm via corepack + run: corepack enable + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Audit dependencies + # The Python side has had a dependency audit for a while; the + # JavaScript tree had none. + run: pnpm audit --audit-level moderate + - name: Generate a CycloneDX SBOM + run: | + npx --yes @cyclonedx/cyclonedx-npm@latest \ + --output-format JSON \ + --output-file "$RUNNER_TEMP/seedpass-js-sbom.json" \ + --package-lock-only || echo "SBOM generation failed (non-fatal)" + - name: Upload SBOM + uses: actions/upload-artifact@v4 + if: always() + with: + name: seedpass-js-sbom + path: ${{ runner.temp }}/seedpass-js-sbom.json + if-no-files-found: warn + # Fixtures prove the two implementations compute the same values; this job # proves they can read each other's on-disk profiles and backups. cross-implementation: diff --git a/docs/typescript_port_compatibility_matrix.md b/docs/typescript_port_compatibility_matrix.md index 958d57a5..810f80da 100644 --- a/docs/typescript_port_compatibility_matrix.md +++ b/docs/typescript_port_compatibility_matrix.md @@ -98,6 +98,22 @@ nostr, ssh, pgp — every creatable kind. (Python via its legacy fallback, TS via `parseEncryptedFile`), verified in cross-impl phases A/B/F/J. +**Protocol-level issues shared with Python — not divergences, and not +fixable in the port alone.** A security review raised both; changing either +in TypeScript only would break cross-implementation convergence, so they +need a versioned protocol change in both implementations: + +1. **Tombstone retention allows deletion replay.** Only + `TOMBSTONE_RETENTION_CAP` (2048) tombstones are kept, oldest dropped + first. Once a tombstone ages out, an untrusted relay can replay the + older signed entry and resurrect a deleted secret. Both implementations + behave identically today. +2. **SSH and PGP share a derivation domain.** Both take BIP-85 app 32 at the + entry's index, so an SSH entry and a PGP entry at the same index derive + the same raw Ed25519 private key. Fixing it requires distinct application + numbers (or an info string) and a compatibility version, since existing + keys must keep deriving as they do. + **Resolved:** TOTP codes for entries with a non-default period/digits used to differ — Python built `pyotp.TOTP(secret)` with library defaults and ignored the entry's recorded values, so an imported 8-digit/45s secret diff --git a/js/packages/cli/package.json b/js/packages/cli/package.json index d337d3c2..26271277 100644 --- a/js/packages/cli/package.json +++ b/js/packages/cli/package.json @@ -7,7 +7,7 @@ "seedpass-js": "./dist/seedpass-js.mjs" }, "exports": { - ".": "./src/index.ts" + ".": "./dist/seedpass-js.mjs" }, "scripts": { "build": "node build.mjs", @@ -15,25 +15,25 @@ "test": "vitest run", "typecheck": "tsc --noEmit" }, - "dependencies": { - "@scure/base": "^2.3.0", - "@seedpass/core": "workspace:*", - "commander": "^14.0.2" - }, + "dependencies": {}, "devDependencies": { "@seedpass/test-vectors": "workspace:*", - "@types/node": "^26.2.0", + "@types/node": "^22.15.0", "tsx": "^4.20.0", "typescript": "^5.7.0", "vitest": "^3.0.0", - "esbuild": "^0.28.0" + "esbuild": "^0.28.0", + "@seedpass/core": "workspace:*", + "@scure/base": "^2.3.0", + "commander": "^14.0.2" }, "license": "MIT", "engines": { "node": ">=22" }, "files": [ - "dist/", + "dist/seedpass-js.mjs", + "dist/seedpass-js.mjs.sha256", "README.md" ] } diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index 866649b3..e6348dad 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -95,6 +95,36 @@ function parseUnixTime(raw: string): number { return parseIntOption(raw, "--at", { min: 0 }); } +/** + * Read a secret without putting it on the command line. + * + * Arguments are visible in shell history, `ps` output, and any terminal or + * session logging, so secret-bearing values should arrive on stdin. The + * positional form still works for scripts that already depend on it, but it + * warns. + */ +async function readSecretInput( + positional: string | undefined, + fromStdin: boolean | undefined, + what: string, + io: ProgramIo, +): Promise { + if (fromStdin) { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + // A trailing newline from `echo` or a heredoc is virtually never wanted. + return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, ""); + } + if (positional === undefined) { + throw new Error(`${what} is required: pass it as an argument or use --stdin`); + } + io.err( + `warning: ${what} was passed on the command line, where it can be captured by ` + + `shell history and process listings. Prefer --stdin.`, + ); + return positional; +} + /** Expand a leading ~ the way Python's Path.expanduser does. */ function resolveHome(p: string): string { return p.startsWith("~") ? join(homedir(), p.slice(1)) : p; @@ -438,22 +468,35 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { add .command("totp