security: keep secrets off argv, fix packaging, add JS supply-chain CI

Secret input:
- Values that are secrets can now be read from stdin: `entry add key-value
  --stdin`, `entry add totp --secret-stdin`, `entry add document --stdin`,
  and `entry modify --value-stdin`. Arguments are visible in shell history
  and process listings, so the positional forms now warn (without echoing
  the secret they are warning about) rather than silently accepting.

Packaging (the published package was broken):
- `exports` pointed at src/index.ts while `files` shipped only dist/, so an
  installed package had no entry point; exports now points at the bundle.
- A `workspace:*` runtime dependency would have failed any real install. The
  bundle inlines everything, so the package now declares no runtime
  dependencies at all. Verified by packing the tarball, installing it into a
  clean project and running the binary -- and CI now does the same on every
  change, because this class of mistake is invisible to build and test.
- The workspace globs cover apps/* so a future extension cannot sit outside
  CI.
- @types/node aligned with the advertised Node 22 runtime.

Release and supply chain:
- release-integrity now builds the CLI bundle into dist/ before checksums
  are generated and signed, so the executable TypeScript artifact is covered
  by the same manifest and cosign signature as the Python ones.
- A new javascript-supply-chain job runs `pnpm audit` and produces a
  CycloneDX SBOM; the JS tree previously had neither.

Also documents two findings that are protocol-level and shared with Python,
where changing the port alone would break convergence: tombstone retention
allowing deletion replay after the 2048-entry cap, and SSH/PGP sharing a
derivation domain (same index yields the same Ed25519 key). Both need a
versioned change in both implementations and are recorded in the
compatibility matrix rather than silently patched here.

193 core + 70 CLI tests green; pnpm audit clean.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-14 23:13:21 -04:00
1 parent 55879eaa1b
commit a1df3cfde0
8 files changed
+270 -29

No files matched your search

+16
View File
@@ -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
+43
View File
@@ -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:
@@ -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
+9 -9
View File
@@ -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"
]
}
+60 -12
View File
@@ -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<string> {
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 <label>")
.description("TOTP entry: deterministic by default, or --secret to import")
.option("--secret <b32>", "import an existing base32 secret")
.option("--secret <b32>", "import an existing base32 secret (prefer --secret-stdin)")
.option("--secret-stdin", "read the base32 secret from stdin")
.option("--period <s>", "period seconds", "30")
.option("--digits <n>", "code digits", "6"),
).action(
async (
label: string,
o: { secret?: string; period: string; digits: string; notes?: string; tags?: string[]; archived?: boolean },
o: {
secret?: string;
secretStdin?: boolean;
period: string;
digits: string;
notes?: string;
tags?: string[];
archived?: boolean;
},
) => {
const opts = {
...commonOpts(o),
period: parseIntOption(o.period, "--period", { min: 1 }),
digits: parseIntOption(o.digits, "--digits", { min: 6, max: 10 }),
};
const imported =
o.secret !== undefined || o.secretStdin
? await readSecretInput(o.secret, o.secretStdin, "TOTP secret", io)
: null;
await addEntry((vault) =>
o.secret
? addTotpImported(vault.index, label, o.secret, opts)
imported !== null
? addTotpImported(vault.index, label, imported, opts)
: addTotpDeterministic(vault.index, label, vault.mnemonic, opts),
);
},
@@ -461,16 +504,18 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
commonAddOptions(
add
.command("key-value <label> <key> <value>")
.description("store an arbitrary secret value"),
.command("key-value <label> <key> [value]")
.description("store an arbitrary secret value (prefer --stdin over an argument)")
.option("--stdin", "read the value from stdin instead of an argument"),
).action(
async (
label: string,
key: string,
value: string,
o: { notes?: string; tags?: string[]; archived?: boolean },
value: string | undefined,
o: { stdin?: boolean; notes?: string; tags?: string[]; archived?: boolean },
) => {
await addEntry((vault) => addKeyValueEntry(vault.index, label, key, value, commonOpts(o)));
const secret = await readSecretInput(value, o.stdin, "value", io);
await addEntry((vault) => addKeyValueEntry(vault.index, label, key, secret, commonOpts(o)));
},
);
@@ -649,7 +694,8 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
.option("--period <n>")
.option("--digits <n>")
.option("--key <text>")
.option("--value <text>", "new secret value (key_value/managed_account)")
.option("--value <text>", "new secret value (prefer --value-stdin)")
.option("--value-stdin", "read the new value from stdin")
.option("--content <text>", "new document content")
.option("--file-type <ext>")
.action(
@@ -658,7 +704,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
o: {
label?: string; username?: string; url?: string; notes?: string;
tags?: string[]; period?: string; digits?: string; key?: string;
value?: string; content?: string; fileType?: string;
value?: string; valueStdin?: boolean; content?: string; fileType?: string;
},
) => {
const changes: ModifyChanges = {
@@ -670,7 +716,9 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
...(o.period !== undefined && { period: parseIntOption(o.period, "--period", { min: 1 }) }),
...(o.digits !== undefined && { digits: parseIntOption(o.digits, "--digits", { min: 6, max: 10 }) }),
...(o.key !== undefined && { key: o.key }),
...(o.value !== undefined && { value: o.value }),
...((o.value !== undefined || o.valueStdin) && {
value: await readSecretInput(o.value, o.valueStdin, "value", io),
}),
...(o.content !== undefined && { content: o.content }),
...(o.fileType !== undefined && { file_type: o.fileType }),
};
+22
View File
@@ -103,6 +103,28 @@ describe("atomic, permission-correct writes", () => {
});
});
describe("secrets on the command line", () => {
it("warns when a secret arrives as an argument", async () => {
const out: string[] = [];
const err: string[] = [];
const io: ProgramIo = { out: (l) => out.push(l), err: (l) => err.push(l) };
const saved = process.env["SEEDPASS_MNEMONIC"];
process.env["SEEDPASS_MNEMONIC"] = MNEMONIC;
try {
await buildProgram(io).parseAsync([
"node", "seedpass-js", "--vault", vaultPath,
"entry", "add", "key-value", "argv-secret", "k", "exposed-on-argv",
]);
} finally {
if (saved === undefined) delete process.env["SEEDPASS_MNEMONIC"];
else process.env["SEEDPASS_MNEMONIC"] = saved;
}
expect(err.join("\n")).toContain("shell history");
// The warning must not repeat the secret it is warning about
expect(err.join("\n")).not.toContain("exposed-on-argv");
});
});
describe("profile path validation", () => {
it("refuses a fingerprint that is not 16 hex characters", async () => {
const app = new AppDir(dir);
+103 -8
View File
@@ -9,23 +9,22 @@ importers:
.: {}
packages/cli:
dependencies:
devDependencies:
'@scure/base':
specifier: ^2.3.0
version: 2.3.0
'@seedpass/core':
specifier: workspace:*
version: link:../core
commander:
specifier: ^14.0.2
version: 14.0.3
devDependencies:
'@seedpass/test-vectors':
specifier: workspace:*
version: link:../test-vectors
'@types/node':
specifier: ^26.2.0
version: 26.2.0
specifier: ^22.15.0
version: 22.20.1
commander:
specifier: ^14.0.2
version: 14.0.3
esbuild:
specifier: ^0.28.0
version: 0.28.2
@@ -37,7 +36,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.7(@types/node@26.2.0)([email protected](@noble/[email protected]))([email protected])
version: 3.2.7(@types/node@22.20.1)([email protected](@noble/[email protected]))([email protected])
packages/core:
dependencies:
@@ -474,6 +473,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/[email protected]':
resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
'@types/[email protected]':
resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
@@ -733,6 +735,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
[email protected]:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
@@ -1094,6 +1099,10 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
undici-types: 6.21.0
'@types/[email protected]':
dependencies:
undici-types: 8.3.0
@@ -1110,6 +1119,14 @@ snapshots:
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/[email protected]([email protected](@types/[email protected])([email protected]))':
dependencies:
'@vitest/spy': 3.2.7
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.6(@types/[email protected])([email protected])
'@vitest/[email protected]([email protected](@types/[email protected])([email protected]))':
dependencies:
'@vitest/spy': 3.2.7
@@ -1389,10 +1406,33 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected](@types/[email protected])([email protected]):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.3.6(@types/[email protected])([email protected])
transitivePeerDependencies:
- '@types/node'
- jiti
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
[email protected](@types/[email protected])([email protected]):
dependencies:
cac: 6.7.14
@@ -1414,6 +1454,19 @@ snapshots:
- tsx
- yaml
[email protected](@types/[email protected])([email protected]):
dependencies:
esbuild: 0.28.2
fdir: 6.5.0([email protected])
picomatch: 4.0.5
postcss: 8.5.26
rollup: 4.62.4
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 22.20.1
fsevents: 2.3.3
tsx: 4.23.12
[email protected](@types/[email protected])([email protected]):
dependencies:
esbuild: 0.28.2
@@ -1427,6 +1480,48 @@ snapshots:
fsevents: 2.3.3
tsx: 4.23.12
[email protected](@types/[email protected])([email protected](@noble/[email protected]))([email protected]):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.7
'@vitest/mocker': 3.2.7([email protected](@types/[email protected])([email protected]))
'@vitest/pretty-format': 3.2.7
'@vitest/runner': 3.2.7
'@vitest/snapshot': 3.2.7
'@vitest/spy': 3.2.7
'@vitest/utils': 3.2.7
chai: 5.3.3
debug: 4.4.3
expect-type: 1.4.0
magic-string: 0.30.21
pathe: 2.0.3
picomatch: 4.0.5
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.17
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.3.6(@types/[email protected])([email protected])
vite-node: 3.2.4(@types/[email protected])([email protected])
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.20.1
jsdom: 30.0.1(@noble/[email protected])
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
[email protected](@types/[email protected])([email protected](@noble/[email protected]))([email protected]):
dependencies:
'@types/chai': 5.2.3
+1
View File
@@ -1,4 +1,5 @@
packages:
- packages/*
- apps/*
allowBuilds:
esbuild: true