Epic: stored signatures — save/reload cert files + persistence #29

Closed
opened 2026-10-14 16:06:16 +02:00 by EduarD3V · 21 comments
Owner

Goal

User stores signing identities once, reuses across sessions.

Two storage layers

  1. Signature files (the .p12/.pfx) — browser-side persistence so the user does not re-upload each visit:
    • Primary: IndexedDB (crypto-friendly, larger quota, structuredClone for ArrayBuffers) — store encrypted blobs only (PBKDF2→AES-GCM via existing web/src/lib crypto utils; passphrase = user-chosen store PIN, never persisted)
    • UX: settings dialog (shadcn) "Firmas guardadas" — add/list/remove, shows label + cert CN + expiry; explicit "forget" wipes blob
    • On load: CertPicker reads store first, falls back to file upload; unlocking requires PIN per session (PIN lives only in memory)
  2. Signed outputs — save produced .csig/.xsig/.pdf into the same store with metadata (filename, sha256, format, timestamp, cert CN) and a downloads list view (re-download or delete)

Constraints

  • NEVER persist PIN/passphrase, raw keys, or unencrypted .p12 (IndexedDB is origin-scoped but not encrypted at rest — encryption is our layer)
  • Key stays non-extractable CryptoKey after unlock (import from decrypted blob per session)
  • No server storage: this issue is client-side only (agents/server-side storage belongs to MCP epic #13 study)

Ownership boundaries

  • owns: src/lib/stores.ts (or new src/lib/certstore.ts), CertPicker additions, settings dialog component, related tests
  • do NOT touch: sign engine files (sign.ts, pkcs12.ts internals — consume via existing API), shadcn primitives (UI epic owns), mcp/

Acceptance

  • store PIN set on first save; blob round-trips encrypted (test: tamper ciphertext → load fails clean)
  • reload page → store lists entries, unlock → sign flow works with stored cert
  • signed outputs listed + re-downloadable; delete works
  • vitest: crypto round-trip, tamper rejection, quota-exceeded error path
## Goal User stores signing identities once, reuses across sessions. ## Two storage layers 1. **Signature files (the .p12/.pfx)** — browser-side persistence so the user does not re-upload each visit: - Primary: IndexedDB (crypto-friendly, larger quota, structuredClone for ArrayBuffers) — store encrypted blobs only (PBKDF2→AES-GCM via existing web/src/lib crypto utils; passphrase = user-chosen store PIN, never persisted) - UX: settings dialog (shadcn) "Firmas guardadas" — add/list/remove, shows label + cert CN + expiry; explicit "forget" wipes blob - On load: CertPicker reads store first, falls back to file upload; unlocking requires PIN per session (PIN lives only in memory) 2. **Signed outputs** — save produced .csig/.xsig/.pdf into the same store with metadata (filename, sha256, format, timestamp, cert CN) and a downloads list view (re-download or delete) ## Constraints - NEVER persist PIN/passphrase, raw keys, or unencrypted .p12 (IndexedDB is origin-scoped but not encrypted at rest — encryption is our layer) - Key stays non-extractable CryptoKey after unlock (import from decrypted blob per session) - No server storage: this issue is client-side only (agents/server-side storage belongs to MCP epic #13 study) ## Ownership boundaries - owns: `src/lib/stores.ts` (or new `src/lib/certstore.ts`), CertPicker additions, settings dialog component, related tests - do NOT touch: sign engine files (sign.ts, pkcs12.ts internals — consume via existing API), shadcn primitives (UI epic owns), mcp/ ## Acceptance - store PIN set on first save; blob round-trips encrypted (test: tamper ciphertext → load fails clean) - reload page → store lists entries, unlock → sign flow works with stored cert - signed outputs listed + re-downloadable; delete works - vitest: crypto round-trip, tamper rejection, quota-exceeded error path
Author
Owner

bullet: coder launched on branch issue/29-epic-stored-signatures-save-re (model nan/deepseek-v4-flash).

bullet: coder launched on branch `issue/29-epic-stored-signatures-save-re` (model `nan/deepseek-v4-flash`).
Author
Owner

Claimed — branch issue/29-epic-stored-signatures-save-re. Doing: client-side certstore (IndexedDB, PBKDF2→AES-GCM), stored identities in CertPicker, downloads dialog, vitest coverage.

Claimed — branch `issue/29-epic-stored-signatures-save-re`. Doing: client-side certstore (IndexedDB, PBKDF2→AES-GCM), stored identities in CertPicker, downloads dialog, vitest coverage.
Author
Owner

Plan — #29 Epic: stored signatures (save/reload cert files + persistence)

Restated goal

Add a client-side persistence layer to AutoFirma Web so a user uploads their PKCS#12
once and reuses it in later visits, plus a list of the signatures they produced.

Two layers:

  1. Identities — the .p12/.pfx bytes, stored in IndexedDB encrypted with
    PBKDF2(SHA-256)→AES-GCM under a user-chosen store PIN that is never persisted.
    Settings surface ("Firmas guardadas"): list (label + cert CN + expiry), unlock, forget.
    CertPicker reads the store first and falls back to file upload.
  2. Signed outputs — produced .csig/.xsig/.pdf bytes + metadata (filename, sha256,
    format, timestamp, cert CN) in the same IndexedDB database, with a downloads list
    that can re-download or delete.

"Done" = vitest proves crypto round-trip, tamper rejection and the quota error path; the
unlocked identity loads through the existing loadSignerSession API, so the sign flow
needs no engine change; gates green; branch clean.

Scope

New

  • web/src/lib/certstore.ts — sealed-blob crypto, Vault over an injectable backend,
    IndexedDB backend + in-memory backend.
  • web/src/lib/components/StoredIdentities.svelte — "Firmas guardadas" settings surface
    (list / unlock / forget / save-current-certificate).
  • web/src/lib/components/DownloadsDialog.svelte — native <dialog> listing signed
    outputs (re-download / delete).
  • web/src/lib/certstore.test.ts, web/src/lib/components/StoredIdentities.test.ts.

Changed (small, listed deliberately)

  • web/src/lib/components/CertPicker.svelte — mount StoredIdentities, hand the unlocked
    session to the existing onpick prop.
  • web/src/lib/components/BatchQueue.svelte — optional onsigned(documents) callback fired
    after a run, so produced bytes/metadata are saved without duplicating the queue's output
    naming. Two lines; not a shadcn primitive, not a sign engine.
  • web/src/lib/App.svelte — create the vault, pass it down, header button opening the
    downloads dialog.
  • web/src/lib/i18n.ts — ES/EN copy for the new surfaces.

NOT touched (fidelity contract)

  • web/src/lib/sign.ts, web/src/lib/pkcs12.ts, web/src/lib/keystore.ts, all signature
    engines (cms.ts, xades.ts, pades.ts, ooxml.ts), web/src/lib/verify/**,
    batch.ts, mcp/, decompiled/, any shadcn primitive (none exist yet in this repo).
  • No new npm dependency. No config knob.

Key decisions (assumptions, cannot ask)

  1. Stored blob = the encrypted .p12 bytes, exactly as the issue says ("encrypted blobs
    only"). Consequence: unlocking needs the store PIN and the file's own PKCS#12 PIN.
    Storing the file PIN (even encrypted) or the extracted PKCS#8 key would violate
    "NEVER persist PIN/passphrase, raw keys", so both are typed at unlock. Human decision
    point
    — if the double PIN is judged too clunky, the alternative is to persist the
    re-wrapped PKCS#8 (same protection class, one PIN); that changes the constraint reading.
  2. Outputs are stored unencrypted. A .csig/.xsig/.pdf holds only public data
    (signature + certificate); the constraint covers PINs, keys and .p12 containers. It also
    keeps re-download available without unlocking.
  3. Backend injection instead of fake-indexeddb. IndexedDB does not exist in the vitest
    jsdom environment and rule 9 forbids a new dependency, so Vault takes a 4-method backend
    (put/get/delete/list); the IndexedDB backend is the default in the browser, the in-memory
    one is used by tests. The quota path is exercised by a backend that throws
    DOMException('…', 'QuotaExceededError').
  4. A pincheck record (a sealed magic constant) distinguishes wrong PIN from tampered
    blob
    : PBKDF2/GCM alone cannot. additionalData = record id binds metadata to the
    ciphertext, so a tampered id/label is also rejected.
  5. No modal framework. Native <dialog> + .showModal() — platform feature, no JS state
    machine.

Ordered tasks (each with its verification command)

All commands from web/.

  1. certstore.ts: sealed-blob crypto (sealBlob/openBlob), StoreError + toStoreError,
    createVault(backend), IndexedDB backend, in-memory backend.
    Verify: pnpm vitest run src/lib/certstore.test.ts
  2. certstore.test.ts: round-trip, wrong PIN, tampered ciphertext, tampered id (AAD),
    quota-mapped error, identity CRUD, output CRUD + sha256 metadata.
    Time a PBKDF2 cost check: if 250k iterations makes the suite slow, lower to 100k in one place.
    Verify: pnpm vitest run src/lib/certstore.test.ts
  3. i18n.ts copy keys (ES + EN) for the new surfaces.
    Verify: pnpm exec svelte-check --tsconfig ./tsconfig.json (type-level only; run fully in
    task 8)
  4. StoredIdentities.svelte + mount in CertPicker.svelte; save-current-certificate form
    (label + store PIN), unlock (store PIN + file PIN) → onpick, forget.
    Verify: pnpm vitest run src/lib/components/StoredIdentities.test.ts
  5. DownloadsDialog.svelte (list / re-download / delete) + BatchQueue.svelte onsigned
    callback + App.svelte wiring.
    Verify: pnpm vitest run src/lib/components/StoredIdentities.test.ts then full
    pnpm test.
  6. Commit in logical chunks (certstore: … (Refs #29)), then run the full gate set.

Verification plan (gates)

  • test -d decompiled and find decompiled -name '*.java' | grep -q . (declared gate list).
  • cd web && pnpm test (vitest, whole suite — must stay green, including App.test.ts).
  • cd web && pnpm run check (svelte-check type gate).
  • cd web && pnpm build (the SPA must still build for the static adapter).
  • Manual smoke of pnpm dev: not possible head-less; instead the component tests mount the
    real Svelte components against the in-memory backend, and App.test.ts continues to mount
    the app. Report states this limitation.

Risk / fallbacks

  • IndexedDB unavailable in jsdom → injection (decision 3) already removes it; if a
    component test needs the browser backend, fall back to asserting the vault is constructed
    with the memory backend.
  • App.test.ts / svelte-check break on new props → props are optional
    (onsigned?, vault?); if a test still breaks, make the wiring lazy (vault created on
    first +page.svelte render) rather than changing the tests.
  • PBKDF2 250k too slow for the suite → 100k, noted in the report; still far above
    PKCS#12's own default.
  • Time cap → tasks 1–4 + tests are the acceptance core; the downloads dialog is task 5 and
    is cut only if a gate is still red, with the gap called out in the report.

Out of scope, explicitly

Server-side storage, MCP integration (#13), shadcn primitives, signature engines, key
export/backup UX, cross-tab sync, quota-management UI beyond the error message.

# Plan — #29 Epic: stored signatures (save/reload cert files + persistence) ## Restated goal Add a **client-side persistence layer** to AutoFirma Web so a user uploads their PKCS#12 once and reuses it in later visits, plus a list of the signatures they produced. Two layers: 1. **Identities** — the `.p12`/`.pfx` bytes, stored in IndexedDB **encrypted** with PBKDF2(SHA-256)→AES-GCM under a user-chosen **store PIN** that is never persisted. Settings surface ("Firmas guardadas"): list (label + cert CN + expiry), unlock, forget. CertPicker reads the store first and falls back to file upload. 2. **Signed outputs** — produced `.csig`/`.xsig`/`.pdf` bytes + metadata (filename, sha256, format, timestamp, cert CN) in the same IndexedDB database, with a downloads list that can re-download or delete. "Done" = vitest proves crypto round-trip, tamper rejection and the quota error path; the unlocked identity loads through the **existing** `loadSignerSession` API, so the sign flow needs no engine change; gates green; branch clean. ## Scope **New** - `web/src/lib/certstore.ts` — sealed-blob crypto, `Vault` over an injectable backend, IndexedDB backend + in-memory backend. - `web/src/lib/components/StoredIdentities.svelte` — "Firmas guardadas" settings surface (list / unlock / forget / save-current-certificate). - `web/src/lib/components/DownloadsDialog.svelte` — native `<dialog>` listing signed outputs (re-download / delete). - `web/src/lib/certstore.test.ts`, `web/src/lib/components/StoredIdentities.test.ts`. **Changed (small, listed deliberately)** - `web/src/lib/components/CertPicker.svelte` — mount `StoredIdentities`, hand the unlocked session to the existing `onpick` prop. - `web/src/lib/components/BatchQueue.svelte` — optional `onsigned(documents)` callback fired after a run, so produced bytes/metadata are saved without duplicating the queue's output naming. Two lines; not a shadcn primitive, not a sign engine. - `web/src/lib/App.svelte` — create the vault, pass it down, header button opening the downloads dialog. - `web/src/lib/i18n.ts` — ES/EN copy for the new surfaces. **NOT touched** (fidelity contract) - `web/src/lib/sign.ts`, `web/src/lib/pkcs12.ts`, `web/src/lib/keystore.ts`, all signature engines (`cms.ts`, `xades.ts`, `pades.ts`, `ooxml.ts`), `web/src/lib/verify/**`, `batch.ts`, `mcp/`, `decompiled/`, any shadcn primitive (none exist yet in this repo). - No new npm dependency. No config knob. ## Key decisions (assumptions, cannot ask) 1. **Stored blob = the encrypted `.p12` bytes**, exactly as the issue says ("encrypted blobs only"). Consequence: unlocking needs the **store PIN** *and* the file's own **PKCS#12 PIN**. Storing the file PIN (even encrypted) or the extracted PKCS#8 key would violate "NEVER persist PIN/passphrase, raw keys", so both are typed at unlock. **Human decision point** — if the double PIN is judged too clunky, the alternative is to persist the re-wrapped PKCS#8 (same protection class, one PIN); that changes the constraint reading. 2. **Outputs are stored unencrypted.** A `.csig`/`.xsig`/`.pdf` holds only public data (signature + certificate); the constraint covers PINs, keys and `.p12` containers. It also keeps re-download available without unlocking. 3. **Backend injection instead of `fake-indexeddb`.** IndexedDB does not exist in the vitest jsdom environment and rule 9 forbids a new dependency, so `Vault` takes a 4-method backend (`put/get/delete/list`); the IndexedDB backend is the default in the browser, the in-memory one is used by tests. The quota path is exercised by a backend that throws `DOMException('…', 'QuotaExceededError')`. 4. **A `pincheck` record** (a sealed magic constant) distinguishes *wrong PIN* from *tampered blob*: PBKDF2/GCM alone cannot. `additionalData = record id` binds metadata to the ciphertext, so a tampered id/label is also rejected. 5. **No modal framework.** Native `<dialog>` + `.showModal()` — platform feature, no JS state machine. ## Ordered tasks (each with its verification command) All commands from `web/`. 1. `certstore.ts`: sealed-blob crypto (`sealBlob`/`openBlob`), `StoreError` + `toStoreError`, `createVault(backend)`, IndexedDB backend, in-memory backend. *Verify:* `pnpm vitest run src/lib/certstore.test.ts` 2. `certstore.test.ts`: round-trip, wrong PIN, tampered ciphertext, tampered id (AAD), quota-mapped error, identity CRUD, output CRUD + sha256 metadata. Time a PBKDF2 cost check: if 250k iterations makes the suite slow, lower to 100k in one place. *Verify:* `pnpm vitest run src/lib/certstore.test.ts` 3. `i18n.ts` copy keys (ES + EN) for the new surfaces. *Verify:* `pnpm exec svelte-check --tsconfig ./tsconfig.json` (type-level only; run fully in task 8) 4. `StoredIdentities.svelte` + mount in `CertPicker.svelte`; save-current-certificate form (label + store PIN), unlock (store PIN + file PIN) → `onpick`, forget. *Verify:* `pnpm vitest run src/lib/components/StoredIdentities.test.ts` 5. `DownloadsDialog.svelte` (list / re-download / delete) + `BatchQueue.svelte` `onsigned` callback + `App.svelte` wiring. *Verify:* `pnpm vitest run src/lib/components/StoredIdentities.test.ts` then full `pnpm test`. 6. Commit in logical chunks (`certstore: … (Refs #29)`), then run the full gate set. ## Verification plan (gates) - `test -d decompiled` and `find decompiled -name '*.java' | grep -q .` (declared gate list). - `cd web && pnpm test` (vitest, whole suite — must stay green, including `App.test.ts`). - `cd web && pnpm run check` (svelte-check type gate). - `cd web && pnpm build` (the SPA must still build for the static adapter). - Manual smoke of `pnpm dev`: not possible head-less; instead the component tests mount the real Svelte components against the in-memory backend, and `App.test.ts` continues to mount the app. Report states this limitation. ## Risk / fallbacks - **IndexedDB unavailable in jsdom** → injection (decision 3) already removes it; if a component test needs the browser backend, fall back to asserting the vault is constructed with the memory backend. - **`App.test.ts` / `svelte-check` break on new props** → props are optional (`onsigned?`, `vault?`); if a test still breaks, make the wiring lazy (vault created on first `+page.svelte` render) rather than changing the tests. - **PBKDF2 250k too slow for the suite** → 100k, noted in the report; still far above PKCS#12's own default. - **Time cap** → tasks 1–4 + tests are the acceptance core; the downloads dialog is task 5 and is cut only if a gate is still red, with the gap called out in the report. ## Out of scope, explicitly Server-side storage, MCP integration (#13), shadcn primitives, signature engines, key export/backup UX, cross-tab sync, quota-management UI beyond the error message.
Author
Owner

bullet: coder launched on branch issue/29-epic-stored-signatures-save-re (model nan/deepseek-v4-flash).

bullet: coder launched on branch `issue/29-epic-stored-signatures-save-re` (model `nan/deepseek-v4-flash`).
Author
Owner

Claimed — branch issue/29-epic-stored-signatures-save-re. Doing: finish the paused store work from f184151+069aa47 — fix onsigned?.(await queue.run()) (JS skips argument evaluation on a nullish callee, so the batch signed nothing), shim jsdom <dialog>, re-verify crypto/store acceptance, browser smoke.

Claimed — branch `issue/29-epic-stored-signatures-save-re`. Doing: finish the paused store work from `f184151`+`069aa47` — fix `onsigned?.(await queue.run())` (JS skips argument evaluation on a nullish callee, so the batch signed nothing), shim jsdom `<dialog>`, re-verify crypto/store acceptance, browser smoke.
Author
Owner

Issue #29 — stored signatures: save/reload cert files + persistence

Restated goal

Client-side persistence for signing identities and produced signatures, so a user stores a
PKCS#12/PFX once and reuses it across visits, and re-downloads what they signed. Two layers over
one IndexedDB database: identities sealed with PBKDF2(SHA-256)→AES-GCM under a user-chosen
store PIN (never persisted), and outputs (the produced .csig/.xsig/.pdf) stored with
metadata (name, format, mime, sha256, cert CN, timestamp, size).

Done means: the "Firmas guardadas" panel lists/adds/removes identities and shows label + CN +
expiry; unlocking takes the store PIN and the container's own PKCS#12 PIN and hands a working
SignerSession to the picker (key non-extractable); the downloads dialog lists signed outputs,
re-downloads the exact bytes and deletes them; and vitest covers crypto round-trip, tamper
rejection, wrong PIN, quota-exceeded and reload survival — all green, plus the pre-existing suite.

State on arrival

The previous session committed most of this and then stopped for the operator:

  • f184151 — web/src/lib/certstore.ts + certstore.test.ts (vault, sealed blobs, IndexedDB and
    memory backends, typed errors). Tests green.
  • 069aa47 — wiring: CertPicker.svelte, StoredIdentities.svelte, DownloadsDialog.svelte,
    BatchQueue.svelte (onsigned hook), App.svelte, i18n.ts (ES/EN copy), component tests.

pnpm test on arrival: 3 failed / 11 files passed. Both failures are in that wiring commit and
are fixed here — no new feature surface is added.

Failure 1 — batch.test.ts "shows per-document status …"

BatchQueue.svelte had onsigned?.(await queue.run(signer)). In JS the argument list of an
optional call is only evaluated when the callee is non-nullish, so with onsigned undefined (the
test passes no such prop) queue.run() never ran at all — the queue stayed 0/3, no error.
A/B verified: reverting that one expression turns the test green. This is a real bug, not a test
artifact: any caller omitting onsigned silently signs nothing.

Failure 2 — DownloadsDialog.test.ts (2 cases) + 3 unhandled errors

TypeError: get(...)?.showModal is not a function. jsdom 30.1.2 ships HTMLDialogElement with
only the open reflection — show, showModal and close are absent (verified with a plain
node+jsdom probe). open() therefore threw before refresh() ran, so the list stayed empty.

Scope

Files I will change (2):

  • web/src/lib/components/BatchQueue.svelte — evaluate the run, then hand the final state to
    onsigned.
  • web/src/test-setup.ts — implement <dialog>'s show/showModal/close for jsdom.

Explicitly not touched: sign.ts, pkcs12.ts, keystore.ts, batch.ts, cms.ts,
xades.ts, pades.ts, certstore.ts (reviewed, correct as committed), StoredIdentities.svelte,
CertPicker.svelte, DownloadsDialog.svelte, App.svelte, i18n.ts, shadcn primitives, mcp/,
decompiled*/, original/.

Ordered tasks

  1. Fix the swallowed batch run. const done = await queue.run(signer); onsigned?.(done);
    Verify: cd web && pnpm vitest run src/lib/batch.test.ts → 5 passed.
  2. jsdom <dialog> shim in src/test-setup.ts (toggle the open attribute, dispatch
    close/cancel like the platform does, no-op when the API already exists).
    Verify: cd web && pnpm vitest run src/lib/components/DownloadsDialog.test.ts → 3 passed.
  3. Full suite + types + build. Verify: cd web && pnpm test && pnpm check && pnpm build.
  4. Real-browser smoke of the stored-identity and downloads flows (dev server, open tab,
    exercise the panel) — the components must be seen working outside jsdom.
  5. Gates from bullet.yaml, then commit and report.

Commits: certstore: … (Refs #29) per task boundary; no closing keywords anywhere.

Open questions (assumptions, no human to ask)

  • shadcn dialog — the issue asks for a settings dialog "(shadcn)", but this repo has no shadcn:
    package.json carries only fflate + pdf-lib, there is no components.json. The UI epic owns
    the primitives. Assumption: keep the existing plain <dialog>-based components and do not vendor
    shadcn here; note it for the UI epic.
  • "existing crypto utils" — the issue expects the seal to reuse them, but no PBKDF2→AES-GCM
    helper exists: pkcs12.ts implements the PKCS#12 KDF (RFC 7292 B.2), a different KDF, and
    keystore.ts only imports a non-extractable signing key. Assumption: certstore.ts's two small
    functions (sealBlob/openBlob) stay, covered by the crypto tests.
  • Store PIN vs container PIN — the issue says unlocking needs the PIN per session; the sealed
    container still needs its own PKCS#12 PIN. Assumption (as committed): ask for both, keep both in
    memory only.
  • Identity list refresh — the panel rereads the store on mount only; no cross-tab storage
    notification. Assumption: out of scope, one tab is the norm here.

Risk and fallback

  • Risk: splitting the onsigned call could change when App.keepSignatures runs. It does not —
    queue.run() resolves after the final state is published, and keepSignatures is called with the
    same resolved state, one microtask later. Fallback if App.test.ts regresses: pass the state
    through an explicit await and keep the call unconditional.
  • Risk: the jsdom dialog shim masking a real bug in a browser. Fallback: drop the shim and
    feature-detect in the component (panel?.showModal?.()); task 4's browser smoke decides.
  • The gates in bullet.yaml only assert the decompiled/ reference tree exists; the real
    verification is pnpm test + pnpm check + pnpm build + the browser smoke.
# Issue #29 — stored signatures: save/reload cert files + persistence ## Restated goal Client-side persistence for signing identities and produced signatures, so a user stores a PKCS#12/PFX once and reuses it across visits, and re-downloads what they signed. Two layers over one IndexedDB database: **identities** sealed with PBKDF2(SHA-256)→AES-GCM under a user-chosen store PIN (never persisted), and **outputs** (the produced `.csig`/`.xsig`/`.pdf`) stored with metadata (name, format, mime, sha256, cert CN, timestamp, size). **Done means:** the "Firmas guardadas" panel lists/adds/removes identities and shows label + CN + expiry; unlocking takes the store PIN and the container's own PKCS#12 PIN and hands a working `SignerSession` to the picker (key non-extractable); the downloads dialog lists signed outputs, re-downloads the exact bytes and deletes them; and vitest covers crypto round-trip, tamper rejection, wrong PIN, quota-exceeded and reload survival — all green, plus the pre-existing suite. ## State on arrival The previous session committed most of this and then stopped for the operator: - `f184151` — `web/src/lib/certstore.ts` + `certstore.test.ts` (vault, sealed blobs, IndexedDB and memory backends, typed errors). Tests green. - `069aa47` — wiring: `CertPicker.svelte`, `StoredIdentities.svelte`, `DownloadsDialog.svelte`, `BatchQueue.svelte` (`onsigned` hook), `App.svelte`, `i18n.ts` (ES/EN copy), component tests. `pnpm test` on arrival: **3 failed / 11 files passed**. Both failures are in that wiring commit and are fixed here — no new feature surface is added. ### Failure 1 — `batch.test.ts` "shows per-document status …" `BatchQueue.svelte` had `onsigned?.(await queue.run(signer))`. In JS the argument list of an optional call is only evaluated when the callee is non-nullish, so with `onsigned` undefined (the test passes no such prop) **`queue.run()` never ran at all** — the queue stayed `0/3`, no error. A/B verified: reverting that one expression turns the test green. This is a real bug, not a test artifact: any caller omitting `onsigned` silently signs nothing. ### Failure 2 — `DownloadsDialog.test.ts` (2 cases) + 3 unhandled errors `TypeError: get(...)?.showModal is not a function`. jsdom 30.1.2 ships `HTMLDialogElement` with only the `open` reflection — `show`, `showModal` and `close` are absent (verified with a plain node+jsdom probe). `open()` therefore threw before `refresh()` ran, so the list stayed empty. ## Scope Files I will change (2): - `web/src/lib/components/BatchQueue.svelte` — evaluate the run, then hand the final state to `onsigned`. - `web/src/test-setup.ts` — implement `<dialog>`'s `show`/`showModal`/`close` for jsdom. Explicitly **not** touched: `sign.ts`, `pkcs12.ts`, `keystore.ts`, `batch.ts`, `cms.ts`, `xades.ts`, `pades.ts`, `certstore.ts` (reviewed, correct as committed), `StoredIdentities.svelte`, `CertPicker.svelte`, `DownloadsDialog.svelte`, `App.svelte`, `i18n.ts`, shadcn primitives, `mcp/`, `decompiled*/`, `original/`. ## Ordered tasks 1. **Fix the swallowed batch run.** `const done = await queue.run(signer); onsigned?.(done);` Verify: `cd web && pnpm vitest run src/lib/batch.test.ts` → 5 passed. 2. **jsdom `<dialog>` shim** in `src/test-setup.ts` (toggle the `open` attribute, dispatch `close`/`cancel` like the platform does, no-op when the API already exists). Verify: `cd web && pnpm vitest run src/lib/components/DownloadsDialog.test.ts` → 3 passed. 3. **Full suite + types + build.** Verify: `cd web && pnpm test && pnpm check && pnpm build`. 4. **Real-browser smoke** of the stored-identity and downloads flows (dev server, open tab, exercise the panel) — the components must be seen working outside jsdom. 5. **Gates** from `bullet.yaml`, then commit and report. Commits: `certstore: … (Refs #29)` per task boundary; no closing keywords anywhere. ## Open questions (assumptions, no human to ask) - **shadcn dialog** — the issue asks for a settings dialog "(shadcn)", but this repo has no shadcn: `package.json` carries only `fflate` + `pdf-lib`, there is no `components.json`. The UI epic owns the primitives. Assumption: keep the existing plain `<dialog>`-based components and do not vendor shadcn here; note it for the UI epic. - **"existing crypto utils"** — the issue expects the seal to reuse them, but no PBKDF2→AES-GCM helper exists: `pkcs12.ts` implements the *PKCS#12* KDF (RFC 7292 B.2), a different KDF, and `keystore.ts` only imports a non-extractable signing key. Assumption: `certstore.ts`'s two small functions (`sealBlob`/`openBlob`) stay, covered by the crypto tests. - **Store PIN vs container PIN** — the issue says unlocking needs the PIN per session; the sealed container still needs its own PKCS#12 PIN. Assumption (as committed): ask for both, keep both in memory only. - **Identity list refresh** — the panel rereads the store on mount only; no cross-tab `storage` notification. Assumption: out of scope, one tab is the norm here. ## Risk and fallback - **Risk:** splitting the `onsigned` call could change when `App.keepSignatures` runs. It does not — `queue.run()` resolves after the final state is published, and `keepSignatures` is called with the same resolved state, one microtask later. Fallback if `App.test.ts` regresses: pass the state through an explicit `await` and keep the call unconditional. - **Risk:** the jsdom dialog shim masking a real bug in a browser. Fallback: drop the shim and feature-detect in the component (`panel?.showModal?.()`); task 4's browser smoke decides. - The gates in `bullet.yaml` only assert the `decompiled/` reference tree exists; the real verification is `pnpm test` + `pnpm check` + `pnpm build` + the browser smoke.
Author
Owner

DONE — branch issue/29-epic-stored-signatures-save-re, tree clean, no push.

Commits

  • 0a58574 store: fix the batch run swallowed by an optional call, shim jsdom dialog, realm-safe byte compare
  • 1a388c0 certpicker: render the certificate list from the app chain so a store-unlocked session fills the picker
  • 4676995 store tests: allow the PBKDF2 seal/unlock to finish under parallel files
    (all Refs #29)

Gates

  • test -d decompiled — 0
  • find decompiled -name "*.java" | grep -q . — 0
  • git diff --check — 0 (clean)
  • pnpm test — 13 files / 115 tests passed, 3 consecutive runs
  • pnpm check — 533 files, 0 errors, 0 warnings
  • pnpm build — static site built
  • browser smoke (headless Chromium + real IndexedDB) — save identity, reload, unlock with both PINs, picker fills, Firmados: 1/1; outputs listed/re-downloaded/deleted; tampered ciphertext and wrong store PIN refused with their own copy; stored bytes verified by openssl cms -verify (CMS Verification successful).

The paused WIP left 3 red tests; all three were real bugs (batch run skipped by f?.(await g()) argument evaluation, store-unlocked cert never reaching the picker list, jsdom has no <dialog> methods). Fixed the causes; no test weakened. Report: .bullet/meta/issue-29.report.md.

DONE — branch `issue/29-epic-stored-signatures-save-re`, tree clean, no push. Commits - `0a58574` store: fix the batch run swallowed by an optional call, shim jsdom dialog, realm-safe byte compare - `1a388c0` certpicker: render the certificate list from the app chain so a store-unlocked session fills the picker - `4676995` store tests: allow the PBKDF2 seal/unlock to finish under parallel files (all `Refs #29`) Gates - `test -d decompiled` — 0 - `find decompiled -name "*.java" | grep -q .` — 0 - `git diff --check` — 0 (clean) - `pnpm test` — 13 files / 115 tests passed, 3 consecutive runs - `pnpm check` — 533 files, 0 errors, 0 warnings - `pnpm build` — static site built - browser smoke (headless Chromium + real IndexedDB) — save identity, reload, unlock with both PINs, picker fills, `Firmados: 1/1`; outputs listed/re-downloaded/deleted; tampered ciphertext and wrong store PIN refused with their own copy; stored bytes verified by `openssl cms -verify` (CMS Verification successful). The paused WIP left 3 red tests; all three were real bugs (batch run skipped by `f?.(await g())` argument evaluation, store-unlocked cert never reaching the picker list, jsdom has no `<dialog>` methods). Fixed the causes; no test weakened. Report: `.bullet/meta/issue-29.report.md`.
Author
Owner

bullet: coder session died — relaunched from committed state (branch issue/29-epic-stored-signatures-save-re).

bullet: coder session died — relaunched from committed state (branch `issue/29-epic-stored-signatures-save-re`).
Author
Owner

bullet: implementation finished (DONE — branch issue/29-epic-stored-signatures-save-re, tree clean, no push.) — moving to review.

bullet: implementation finished (DONE — branch `issue/29-epic-stored-signatures-save-re`, tree clean, no push.) — moving to review.
Author
Owner

bullet: reviewer launched (model 9router/reviewer, independent of coder).

bullet: reviewer launched (model `9router/reviewer`, independent of coder).
Author
Owner

Claimed — branch issue/29-epic-stored-signatures-save-re. Doing: verify the pre-existing WIP store (certstore + StoredIdentities + DownloadsDialog) end to end in a real browser over IndexedDB, close any gap found, run gates.

Claimed — branch `issue/29-epic-stored-signatures-save-re`. Doing: verify the pre-existing WIP store (certstore + StoredIdentities + DownloadsDialog) end to end in a real browser over IndexedDB, close any gap found, run gates.
Author
Owner

Issue #29 — Epic: stored signatures (save/reload cert files + persistence)

Restated goal

User stores a signing identity once (the .p12/.pfx container, encrypted in the browser under a
store PIN that is never persisted) and reuses it in later visits without re-uploading; produced
signatures (.csig/.xsig/.pdf) are also kept with metadata (filename, SHA-256, format, timestamp,
cert CN) in a list the user can re-download from or delete.

"Done" = the two layers (encrypted identities + plain signed outputs) live over one IndexedDB
database, the settings panel ("Firmas guardadas") is usable end to end in a real browser including
after a page reload, the private key stays a non-extractable CryptoKey after unlock, the store
PIN / PKCS#12 PIN are never written anywhere, and vitest covers crypto round-trip, tamper rejection
and the quota-exceeded path.

Prior state found on this branch (not re-litigated)

The branch already carries a paused WIP (commits f184151, 0a58574, 1a388c0, 4676995):

  • web/src/lib/certstore.ts (423 ln) — Vault/StoreBackend, PBKDF2(250k, SHA-256)→AES-GCM seal/open
    with record-id AAD, sealed PIN-check magic to tell a wrong PIN from a tampered blob, IndexedDB
    backend + in-memory backend, typed StoreError.
  • web/src/lib/certstore.test.ts (197 ln) — round-trip, wrong PIN, tamper, id-rebinding, reload,
    forget, quota, output round-trip/delete.
  • web/src/lib/components/StoredIdentities.svelte (213 ln) — save/list/unlock/forget panel.
  • web/src/lib/components/DownloadsDialog.svelte (107 ln) — list/re-download/delete outputs.
  • web/src/lib/components/CertPicker.svelte — renders the panel; App.svelte — browserVault(),
    keepSignatures() feeding saveOutput, DownloadsDialog.

Baseline on this branch: pnpm test 115/115 pass, pnpm check 0 errors, pnpm build OK.

Scope

Change (only if a gap or a real bug is found):

  • web/src/lib/certstore.ts — the IndexedDB backend / browserVault (the one path jsdom cannot exercise).
  • web/src/lib/certstore.test.ts — new cases only if a genuine defect is fixed.
  • web/src/lib/components/StoredIdentities.svelte, DownloadsDialog.svelte — only for defects found
    in the browser smoke.
  • web/src/lib/i18n.ts — only if a copy key is missing/lying.

Will NOT touch: sign.ts, pkcs12.ts, keystore.ts, cms.ts, xades.ts, pades.ts, batch.ts
(consumed through their existing API), shadcn primitives (none exist here — no components.json, no
dependency; the UI epic owns that, so the panel stays hand-rolled like the rest of the app), mcp/,
docs/, test/ fixtures, anything under decompiled*/.

Ordered tasks

  1. Read the delivered code, no edits — confirm every acceptance item has an implementation.
    Verify: pnpm test (13 files / 115 tests pass) — done, green.
  2. Static gates — pnpm check (already 0 errors) and pnpm build (already green). Keep both green.
  3. Real-browser smoke, IndexedDB path (the acceptance test the unit suite cannot cover).
    Build a throwaway PKCS#12 with the shipped fixture helper, serve pnpm dev, then:
    upload → PIN 1234 → save under store PIN → reload the page → the entry is still listed →
    unlock → sign a document → downloads dialog lists it with SHA-256/CN → re-download → delete.
    Verify: browser tab against 127.0.0.1:5199; screenshots + array assertions from the page.
    A second tab over the same origin must NOT see a decrypted key; the PIN fields must stay empty.
  4. Fix what task 3 breaks, smallest correct diff; re-run 3.
  5. Commit each verified chunk store: <what> (Refs #29).
  6. Gates — test -d decompiled, find decompiled -name '*.java' | grep -q . plus
    pnpm test, pnpm check, pnpm build, git diff --check.
  7. Report to /home/eduardez/workspace/AutoFirme/.bullet/meta/issue-29.report.md, comment on #29.

Open questions (assumptions, proceeding)

  • shadcn dialog? The issue says "settings dialog (shadcn)". The repo has no shadcn: no
    components.json, no $lib/components/ui, no dependency, and the UI epic owns the primitives.
    Assumption: keep the existing native <dialog> panel; note it in the report for the UI epic to
    restyle. Adding a dependency now would break the no-new-deps law and the self-host-static rule.
  • Encrypt outputs? Assumption: no. A produced signature carries only public data; only the
    identity blob is sealed. The constraint list forbids persisting keys/PINs, not public signature bytes.
  • PKCS#12 PIN per session? Assumption: yes — the store PIN decrypts the container, the container's
    own PIN still opens it, so unlocking needs both. The issue does not ask to store the file PIN.
  • Forget without the PIN? Assumption: allowed (deleting a blob needs no secret), matches "explicit
    forget wipes blob".

Risk

  • IndexedDB in the managed Chromium is the untested seam. If indexedDbBackend() misbehaves
    (transaction lifetime, getAll typing, upgrade races), the boring fallback is a one-object-store
    layout: STORES stays, but records are keyed store:id in a single store, which makes the upgrade
    path trivial. Do not add fake-indexeddb — no new dependencies.
  • Dev-server port collision — the smoke uses an explicit port; if busy, pick another, never kill
    another process's server.
  • Flaky PBKDF2 timing (250k iterations) already bit this branch once (4676995): keep test timeouts
    as they are, never reduce iterations to make a test faster.
  • If a gate goes red for a reason outside scope, leave it red, say so, stop.
# Issue #29 — Epic: stored signatures (save/reload cert files + persistence) ## Restated goal User stores a signing identity once (the `.p12`/`.pfx` container, **encrypted** in the browser under a store PIN that is never persisted) and reuses it in later visits without re-uploading; produced signatures (`.csig`/`.xsig`/`.pdf`) are also kept with metadata (filename, SHA-256, format, timestamp, cert CN) in a list the user can re-download from or delete. "Done" = the two layers (encrypted identities + plain signed outputs) live over one IndexedDB database, the settings panel ("Firmas guardadas") is usable end to end in a real browser including **after a page reload**, the private key stays a non-extractable `CryptoKey` after unlock, the store PIN / PKCS#12 PIN are never written anywhere, and vitest covers crypto round-trip, tamper rejection and the quota-exceeded path. ## Prior state found on this branch (not re-litigated) The branch already carries a paused WIP (commits `f184151`, `0a58574`, `1a388c0`, `4676995`): - `web/src/lib/certstore.ts` (423 ln) — `Vault`/`StoreBackend`, PBKDF2(250k, SHA-256)→AES-GCM seal/open with record-id AAD, sealed PIN-check magic to tell a wrong PIN from a tampered blob, IndexedDB backend + in-memory backend, typed `StoreError`. - `web/src/lib/certstore.test.ts` (197 ln) — round-trip, wrong PIN, tamper, id-rebinding, reload, forget, quota, output round-trip/delete. - `web/src/lib/components/StoredIdentities.svelte` (213 ln) — save/list/unlock/forget panel. - `web/src/lib/components/DownloadsDialog.svelte` (107 ln) — list/re-download/delete outputs. - `web/src/lib/components/CertPicker.svelte` — renders the panel; `App.svelte` — `browserVault()`, `keepSignatures()` feeding `saveOutput`, `DownloadsDialog`. Baseline on this branch: `pnpm test` 115/115 pass, `pnpm check` 0 errors, `pnpm build` OK. ## Scope **Change (only if a gap or a real bug is found):** - `web/src/lib/certstore.ts` — the IndexedDB backend / `browserVault` (the one path jsdom cannot exercise). - `web/src/lib/certstore.test.ts` — new cases only if a genuine defect is fixed. - `web/src/lib/components/StoredIdentities.svelte`, `DownloadsDialog.svelte` — only for defects found in the browser smoke. - `web/src/lib/i18n.ts` — only if a copy key is missing/lying. **Will NOT touch:** `sign.ts`, `pkcs12.ts`, `keystore.ts`, `cms.ts`, `xades.ts`, `pades.ts`, `batch.ts` (consumed through their existing API), shadcn primitives (none exist here — no `components.json`, no dependency; the UI epic owns that, so the panel stays hand-rolled like the rest of the app), `mcp/`, `docs/`, `test/` fixtures, anything under `decompiled*/`. ## Ordered tasks 1. **Read the delivered code, no edits** — confirm every acceptance item has an implementation. Verify: `pnpm test` (13 files / 115 tests pass) — done, green. 2. **Static gates** — `pnpm check` (already 0 errors) and `pnpm build` (already green). Keep both green. 3. **Real-browser smoke, IndexedDB path** (the acceptance test the unit suite cannot cover). Build a throwaway PKCS#12 with the shipped fixture helper, serve `pnpm dev`, then: upload → PIN `1234` → save under store PIN → **reload the page** → the entry is still listed → unlock → sign a document → downloads dialog lists it with SHA-256/CN → re-download → delete. Verify: `browser` tab against `127.0.0.1:5199`; screenshots + array assertions from the page. A second tab over the same origin must NOT see a decrypted key; the PIN fields must stay empty. 4. **Fix what task 3 breaks**, smallest correct diff; re-run 3. 5. **Commit** each verified chunk `store: <what> (Refs #29)`. 6. **Gates** — `test -d decompiled`, `find decompiled -name '*.java' | grep -q .` plus `pnpm test`, `pnpm check`, `pnpm build`, `git diff --check`. 7. **Report** to `/home/eduardez/workspace/AutoFirme/.bullet/meta/issue-29.report.md`, comment on #29. ## Open questions (assumptions, proceeding) - **shadcn dialog?** The issue says "settings dialog (shadcn)". The repo has no shadcn: no `components.json`, no `$lib/components/ui`, no dependency, and the UI epic owns the primitives. *Assumption:* keep the existing native `<dialog>` panel; note it in the report for the UI epic to restyle. Adding a dependency now would break the no-new-deps law and the self-host-static rule. - **Encrypt outputs?** *Assumption:* no. A produced signature carries only public data; only the identity blob is sealed. The constraint list forbids persisting keys/PINs, not public signature bytes. - **PKCS#12 PIN per session?** *Assumption:* yes — the store PIN decrypts the container, the container's own PIN still opens it, so unlocking needs both. The issue does not ask to store the file PIN. - **Forget without the PIN?** *Assumption:* allowed (deleting a blob needs no secret), matches "explicit forget wipes blob". ## Risk - **IndexedDB in the managed Chromium is the untested seam.** If `indexedDbBackend()` misbehaves (transaction lifetime, `getAll` typing, upgrade races), the boring fallback is a one-object-store layout: `STORES` stays, but records are keyed `store:id` in a single store, which makes the upgrade path trivial. Do not add `fake-indexeddb` — no new dependencies. - **Dev-server port collision** — the smoke uses an explicit port; if busy, pick another, never kill another process's server. - **Flaky PBKDF2 timing** (250k iterations) already bit this branch once (`4676995`): keep test timeouts as they are, never reduce iterations to make a test faster. - If a gate goes red for a reason outside scope, leave it red, say so, stop.
Author
Owner

Issue #29 — stored signatures (save/reload cert files + persistence)

Outcome: DONE (verification-only session on top of the pre-existing WIP; no code change was needed)

Branch: issue/29-epic-stored-signatures-save-re
Worktree: /home/eduardez/workspace/AutoFirme/.bullet/worktrees/issue-29

Commits on the branch

Hash Subject
f184151 certstore: encrypted identity + signed-output store over IndexedDB (Refs #29)
069aa47 wip: paused by operator (issue #29) — partial work preserved
0a58574 store: fix the batch run swallowed by an optional call, shim jsdom dialog, realm-safe byte compare (Refs #29)
1a388c0 certpicker: render the certificate list from the app's chain so a store-unlocked session fills the picker (Refs #29)
4676995 store tests: allow the PBKDF2 seal/unlock to finish under parallel files (Refs #29)

This session added no commit: the acceptance criteria were already met by the WIP, and
git status --porcelain on the worktree is empty. No scaffolds, no throwaway scripts left in the
tree (the smoke fixtures lived in /tmp/smoke-p12).

Gates (all exit 0)

Gate Result
test -d decompiled && find decompiled -name '*.java' | grep -q . PASS (tag reference/decompile-1.9 trees present)
pnpm test PASS — 13 files, 115 tests
pnpm check (svelte-kit sync && svelte-check) PASS — 533 files, 0 errors, 0 warnings
pnpm build (adapter-static) PASS — wrote site to build
git diff --check clean

Files that carry the feature (all pre-existing on this branch, reviewed here)

  • web/src/lib/certstore.ts (423 ln) — Vault / 4-method StoreBackend; identity blobs sealed with
    PBKDF2-SHA-256 (250k iters) → AES-GCM, record id as AAD; sealed PIN-check magic so a wrong PIN
    (wrong-pin) is distinguishable from a modified blob (tampered); IndexedDB backend + in-memory
    backend + browserVault() fallback; typed StoreError mapped from DOMException/QuotaExceededError.
  • web/src/lib/certstore.test.ts (197 ln) — round-trip, wrong PIN, tamper, id-rebinding, reload via a
    second vault over the same backend, forget, missing, quota, output round-trip + delete.
  • web/src/lib/components/StoredIdentities.svelte — save / list / unlock / forget panel.
  • web/src/lib/components/DownloadsDialog.svelte — outputs list, re-download via object URL, delete.
  • web/src/lib/components/CertPicker.svelte — renders the panel; owns the container bytes used to seal.
  • web/src/lib/App.svelte — browserVault(), keepSignatures() → saveOutput per signed document,
    DownloadsDialog in the header.
  • web/src/lib/i18n.ts — ES/EN store* and downloads* copy.
  • Tests: web/src/lib/App.test.ts (store save → unlock → picker fills), StoredIdentities.test.ts,
    DownloadsDialog.test.ts.

Real-browser verification (the part jsdom cannot do — no IndexedDB there)

Ran the app against vite dev on 127.0.0.1:5199 in managed Chromium, with a throwaway PKCS#12
(CN Ñandú Prueba, PIN 1234) generated by openssl pkcs12 -export -keypbe AES-256-CBC:

  1. Picked a document → panel shows "No hay ninguna firma guardada".
  2. Uploaded the .p12, PIN 1234 → picker listed Ñandú Prueba (+ the CA, disabled, no key).
  3. Saved under label "Firma del despacho" and store PIN tienda-1234.
    IndexedDB autofirme-web.identities holds exactly {id,label,commonName,notAfter,createdAt,size,sealed};
    sealed.ciphertext first bytes [86,150,229,…] ≠ the container's PKCS#12 header — the plaintext is not
    recoverable from the store. No PIN, key or raw container anywhere in localStorage/sessionStorage/cookies.
  4. location.reload() → the same entry is listed (persistence across a visit).
  5. Unlock with a wrong store PIN → "PIN del almacén incorrecto."; with tienda-1234 + file PIN 1234 →
    session restored, picker shows the cert, all PIN inputs are empty afterwards.
  6. Signed 2 documents → 2 outputs stored with CN Ñandú Prueba and matching SHA-256; the downloads dialog
    listed both.
  7. Re-download of contrato-2.txt.csig: 1520 bytes, SHA-256 645e30c2db0f141e31b55124d79472c2bdbabed1ab95c57967e3504a8d92d6b9
    — identical to the stored metadata digest. Independent oracle: openssl cms -verify -inform DER -content contrato.txt -purpose any -binary → CMS Verification successful, extracted content byte-identical.
  8. Delete from the list → entry gone from the UI and from IndexedDB (2 → 1 records).

Note on method: uploadFile fired before Svelte hydration once and silently did nothing; repeated after
hydration it works. That is an automation race, not an app defect — the browser native path
(button → OS chooser → change) and the drop path both work.

Scope respected

Untouched: sign.ts, pkcs12.ts, keystore.ts, cms.ts, xades.ts, pades.ts, batch.ts,
web/test/, docs/, decompiled*/, mcp/. No new dependency (the store uses WebCrypto + IndexedDB and
a map-based backend for jsdom).

What a human must decide

  1. "settings dialog (shadcn)" was not built as shadcn. The repo has no shadcn (components.json absent,
    no dependency, no $lib/components/ui), and the UI epic owns the primitives. "Firmas guardadas" is a
    hand-rolled <dialog>-free inline panel inside the sign step's certificate section; the outputs list is
    a real <dialog> in the header ("Descargas"). Restyling/moving the identities panel behind a header
    dialog is a UI-epic call.
  2. Discoverability: the stored-identity list renders only after at least one document is selected
    (the certificate section is conditional on the file list). Matches the "pick documents → pick cert →
    sign" flow, but it is not reachable from an empty screen.
  3. Plaintext outputs: signed .csig/.xsig/.pdf bytes are stored unencrypted (public data only);
    only the identity container is sealed. Confirmed as the intended reading of the constraints.
  4. Unlock needs two PINs (store PIN + the container's own PKCS#12 PIN). The issue did not ask to persist
    the file PIN, and doing so would contradict the constraint. Confirm this is the wanted UX.
# Issue #29 — stored signatures (save/reload cert files + persistence) **Outcome: DONE** (verification-only session on top of the pre-existing WIP; no code change was needed) Branch: `issue/29-epic-stored-signatures-save-re` Worktree: `/home/eduardez/workspace/AutoFirme/.bullet/worktrees/issue-29` ## Commits on the branch | Hash | Subject | |---|---| | `f184151` | certstore: encrypted identity + signed-output store over IndexedDB (Refs #29) | | `069aa47` | wip: paused by operator (issue #29) — partial work preserved | | `0a58574` | store: fix the batch run swallowed by an optional call, shim jsdom dialog, realm-safe byte compare (Refs #29) | | `1a388c0` | certpicker: render the certificate list from the app's chain so a store-unlocked session fills the picker (Refs #29) | | `4676995` | store tests: allow the PBKDF2 seal/unlock to finish under parallel files (Refs #29) | This session added **no commit**: the acceptance criteria were already met by the WIP, and `git status --porcelain` on the worktree is empty. No scaffolds, no throwaway scripts left in the tree (the smoke fixtures lived in `/tmp/smoke-p12`). ## Gates (all exit 0) | Gate | Result | |---|---| | `test -d decompiled && find decompiled -name '*.java' \| grep -q .` | PASS (tag `reference/decompile-1.9` trees present) | | `pnpm test` | PASS — 13 files, 115 tests | | `pnpm check` (`svelte-kit sync && svelte-check`) | PASS — 533 files, 0 errors, 0 warnings | | `pnpm build` (adapter-static) | PASS — wrote site to `build` | | `git diff --check` | clean | ## Files that carry the feature (all pre-existing on this branch, reviewed here) - `web/src/lib/certstore.ts` (423 ln) — `Vault` / 4-method `StoreBackend`; identity blobs sealed with PBKDF2-SHA-256 (250k iters) → AES-GCM, record id as AAD; sealed PIN-check magic so a wrong PIN (`wrong-pin`) is distinguishable from a modified blob (`tampered`); IndexedDB backend + in-memory backend + `browserVault()` fallback; typed `StoreError` mapped from `DOMException`/`QuotaExceededError`. - `web/src/lib/certstore.test.ts` (197 ln) — round-trip, wrong PIN, tamper, id-rebinding, reload via a second vault over the same backend, forget, missing, quota, output round-trip + delete. - `web/src/lib/components/StoredIdentities.svelte` — save / list / unlock / forget panel. - `web/src/lib/components/DownloadsDialog.svelte` — outputs list, re-download via object URL, delete. - `web/src/lib/components/CertPicker.svelte` — renders the panel; owns the container bytes used to seal. - `web/src/lib/App.svelte` — `browserVault()`, `keepSignatures()` → `saveOutput` per signed document, `DownloadsDialog` in the header. - `web/src/lib/i18n.ts` — ES/EN `store*` and `downloads*` copy. - Tests: `web/src/lib/App.test.ts` (store save → unlock → picker fills), `StoredIdentities.test.ts`, `DownloadsDialog.test.ts`. ## Real-browser verification (the part jsdom cannot do — no IndexedDB there) Ran the app against `vite dev` on `127.0.0.1:5199` in managed Chromium, with a throwaway PKCS#12 (CN `Ñandú Prueba`, PIN `1234`) generated by `openssl pkcs12 -export -keypbe AES-256-CBC`: 1. Picked a document → panel shows "No hay ninguna firma guardada". 2. Uploaded the `.p12`, PIN `1234` → picker listed `Ñandú Prueba` (+ the CA, disabled, no key). 3. Saved under label "Firma del despacho" and store PIN `tienda-1234`. IndexedDB `autofirme-web.identities` holds exactly `{id,label,commonName,notAfter,createdAt,size,sealed}`; `sealed.ciphertext` first bytes `[86,150,229,…]` ≠ the container's PKCS#12 header — the plaintext is not recoverable from the store. No PIN, key or raw container anywhere in `localStorage`/`sessionStorage`/cookies. 4. `location.reload()` → the same entry is listed (persistence across a visit). 5. Unlock with a wrong store PIN → "PIN del almacén incorrecto."; with `tienda-1234` + file PIN `1234` → session restored, picker shows the cert, **all PIN inputs are empty afterwards**. 6. Signed 2 documents → 2 outputs stored with CN `Ñandú Prueba` and matching SHA-256; the downloads dialog listed both. 7. Re-download of `contrato-2.txt.csig`: 1520 bytes, SHA-256 `645e30c2db0f141e31b55124d79472c2bdbabed1ab95c57967e3504a8d92d6b9` — identical to the stored metadata digest. Independent oracle: `openssl cms -verify -inform DER -content contrato.txt -purpose any -binary` → **CMS Verification successful**, extracted content byte-identical. 8. Delete from the list → entry gone from the UI and from IndexedDB (2 → 1 records). Note on method: `uploadFile` fired before Svelte hydration once and silently did nothing; repeated after hydration it works. That is an automation race, not an app defect — the browser native path (button → OS chooser → `change`) and the drop path both work. ## Scope respected Untouched: `sign.ts`, `pkcs12.ts`, `keystore.ts`, `cms.ts`, `xades.ts`, `pades.ts`, `batch.ts`, `web/test/`, `docs/`, `decompiled*/`, `mcp/`. No new dependency (the store uses WebCrypto + IndexedDB and a map-based backend for jsdom). ## What a human must decide 1. **"settings dialog (shadcn)" was not built as shadcn.** The repo has no shadcn (`components.json` absent, no dependency, no `$lib/components/ui`), and the UI epic owns the primitives. "Firmas guardadas" is a hand-rolled `<dialog>`-free inline panel inside the sign step's certificate section; the outputs list is a real `<dialog>` in the header ("Descargas"). Restyling/moving the identities panel behind a header dialog is a UI-epic call. 2. **Discoverability**: the stored-identity list renders only after at least one document is selected (the certificate section is conditional on the file list). Matches the "pick documents → pick cert → sign" flow, but it is not reachable from an empty screen. 3. **Plaintext outputs**: signed `.csig`/`.xsig`/`.pdf` bytes are stored unencrypted (public data only); only the identity container is sealed. Confirmed as the intended reading of the constraints. 4. **Unlock needs two PINs** (store PIN + the container's own PKCS#12 PIN). The issue did not ask to persist the file PIN, and doing so would contradict the constraint. Confirm this is the wanted UX.
Author
Owner

REVIEW: CLEAN — commit 4676995: findings: none; UNFIXED: none

REVIEW: CLEAN — commit 4676995: findings: none; UNFIXED: none
Author
Owner

bullet: review CLEAN — ready to publish.

bullet: review CLEAN — ready to publish.
Author
Owner

bullet: PR #33 open → bullet-develop (auto-merge follows).

bullet: PR #33 open → `bullet-develop` (auto-merge follows).
Author
Owner

bullet: merge blocked (HTTP 405) — conciliation agent merges trunk work into bullet-develop; PR auto-merges next tick.

bullet: merge blocked (HTTP 405) — conciliation agent merges trunk work into `bullet-develop`; PR auto-merges next tick.
Author
Owner

bullet: merge conflicts with bullet-develop — conflict-solver agent owns the worktree until it pushes.

bullet: merge conflicts with `bullet-develop` — conflict-solver agent owns the worktree until it pushes.
Author
Owner

INTEGRATED: #29 merged into bullet-develop (conflicts resolved: path-only — #26 flattened web/ to repo root while #29 added web/src/lib/; took #29 content at src/lib/, develop side untouched since base; test 115/115, build ok, svelte-check 0 errors)

INTEGRATED: #29 merged into bullet-develop (conflicts resolved: path-only — #26 flattened web/ to repo root while #29 added web/src/lib/*; took #29 content at src/lib/*, develop side untouched since base; test 115/115, build ok, svelte-check 0 errors)
Author
Owner

bullet: PR #33 closed — work already merged into bullet-develop.

bullet: PR #33 closed — work already merged into `bullet-develop`.
Author
Owner

bullet: merged into bullet-develop — integration gates green.

bullet: merged into `bullet-develop` — integration gates green.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
EduarD3V/AutoFirme#29
No description provided.