Epic: stored signatures — save/reload cert files + persistence #29
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
EduarD3V/AutoFirme#29
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Goal
User stores signing identities once, reuses across sessions.
Two storage layers
Constraints
Ownership boundaries
src/lib/stores.ts(or newsrc/lib/certstore.ts), CertPicker additions, settings dialog component, related testsAcceptance
bullet: coder launched on branch
issue/29-epic-stored-signatures-save-re(modelnan/deepseek-v4-flash).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.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:
.p12/.pfxbytes, stored in IndexedDB encrypted withPBKDF2(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.
.csig/.xsig/.pdfbytes + 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
loadSignerSessionAPI, so the sign flowneeds no engine change; gates green; branch clean.
Scope
New
web/src/lib/certstore.ts— sealed-blob crypto,Vaultover 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 signedoutputs (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— mountStoredIdentities, hand the unlockedsession to the existing
onpickprop.web/src/lib/components/BatchQueue.svelte— optionalonsigned(documents)callback firedafter 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 thedownloads 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 signatureengines (
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).Key decisions (assumptions, cannot ask)
.p12bytes, exactly as the issue says ("encrypted blobsonly"). 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.
.csig/.xsig/.pdfholds only public data(signature + certificate); the constraint covers PINs, keys and
.p12containers. It alsokeeps re-download available without unlocking.
fake-indexeddb. IndexedDB does not exist in the vitestjsdom environment and rule 9 forbids a new dependency, so
Vaulttakes a 4-method backend(
put/get/delete/list); the IndexedDB backend is the default in the browser, the in-memoryone is used by tests. The quota path is exercised by a backend that throws
DOMException('…', 'QuotaExceededError').pincheckrecord (a sealed magic constant) distinguishes wrong PIN from tamperedblob: PBKDF2/GCM alone cannot.
additionalData = record idbinds metadata to theciphertext, so a tampered id/label is also rejected.
<dialog>+.showModal()— platform feature, no JS statemachine.
Ordered tasks (each with its verification command)
All commands from
web/.certstore.ts: sealed-blob crypto (sealBlob/openBlob),StoreError+toStoreError,createVault(backend), IndexedDB backend, in-memory backend.Verify:
pnpm vitest run src/lib/certstore.test.tscertstore.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.tsi18n.tscopy keys (ES + EN) for the new surfaces.Verify:
pnpm exec svelte-check --tsconfig ./tsconfig.json(type-level only; run fully intask 8)
StoredIdentities.svelte+ mount inCertPicker.svelte; save-current-certificate form(label + store PIN), unlock (store PIN + file PIN) →
onpick, forget.Verify:
pnpm vitest run src/lib/components/StoredIdentities.test.tsDownloadsDialog.svelte(list / re-download / delete) +BatchQueue.svelteonsignedcallback +
App.sveltewiring.Verify:
pnpm vitest run src/lib/components/StoredIdentities.test.tsthen fullpnpm test.certstore: … (Refs #29)), then run the full gate set.Verification plan (gates)
test -d decompiledandfind decompiled -name '*.java' | grep -q .(declared gate list).cd web && pnpm test(vitest, whole suite — must stay green, includingApp.test.ts).cd web && pnpm run check(svelte-check type gate).cd web && pnpm build(the SPA must still build for the static adapter).pnpm dev: not possible head-less; instead the component tests mount thereal Svelte components against the in-memory backend, and
App.test.tscontinues to mountthe app. Report states this limitation.
Risk / fallbacks
component test needs the browser backend, fall back to asserting the vault is constructed
with the memory backend.
App.test.ts/svelte-checkbreak on new props → props are optional(
onsigned?,vault?); if a test still breaks, make the wiring lazy (vault created onfirst
+page.svelterender) rather than changing the tests.PKCS#12's own default.
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.
bullet: coder launched on branch
issue/29-epic-stored-signatures-save-re(modelnan/deepseek-v4-flash).Claimed — branch
issue/29-epic-stored-signatures-save-re. Doing: finish the paused store work fromf184151+069aa47— fixonsigned?.(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.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 withmetadata (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
SignerSessionto 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 andmemory backends, typed errors). Tests green.
069aa47— wiring:CertPicker.svelte,StoredIdentities.svelte,DownloadsDialog.svelte,BatchQueue.svelte(onsignedhook),App.svelte,i18n.ts(ES/EN copy), component tests.pnpm teston arrival: 3 failed / 11 files passed. Both failures are in that wiring commit andare fixed here — no new feature surface is added.
Failure 1 —
batch.test.ts"shows per-document status …"BatchQueue.sveltehadonsigned?.(await queue.run(signer)). In JS the argument list of anoptional call is only evaluated when the callee is non-nullish, so with
onsignedundefined (thetest passes no such prop)
queue.run()never ran at all — the queue stayed0/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
onsignedsilently signs nothing.Failure 2 —
DownloadsDialog.test.ts(2 cases) + 3 unhandled errorsTypeError: get(...)?.showModal is not a function. jsdom 30.1.2 shipsHTMLDialogElementwithonly the
openreflection —show,showModalandcloseare absent (verified with a plainnode+jsdom probe).
open()therefore threw beforerefresh()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 toonsigned.web/src/test-setup.ts— implement<dialog>'sshow/showModal/closefor 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
const done = await queue.run(signer); onsigned?.(done);Verify:
cd web && pnpm vitest run src/lib/batch.test.ts→ 5 passed.<dialog>shim insrc/test-setup.ts(toggle theopenattribute, dispatchclose/cancellike the platform does, no-op when the API already exists).Verify:
cd web && pnpm vitest run src/lib/components/DownloadsDialog.test.ts→ 3 passed.cd web && pnpm test && pnpm check && pnpm build.exercise the panel) — the components must be seen working outside jsdom.
bullet.yaml, then commit and report.Commits:
certstore: … (Refs #29)per task boundary; no closing keywords anywhere.Open questions (assumptions, no human to ask)
package.jsoncarries onlyfflate+pdf-lib, there is nocomponents.json. The UI epic ownsthe primitives. Assumption: keep the existing plain
<dialog>-based components and do not vendorshadcn here; note it for the UI epic.
helper exists:
pkcs12.tsimplements the PKCS#12 KDF (RFC 7292 B.2), a different KDF, andkeystore.tsonly imports a non-extractable signing key. Assumption:certstore.ts's two smallfunctions (
sealBlob/openBlob) stay, covered by the crypto tests.container still needs its own PKCS#12 PIN. Assumption (as committed): ask for both, keep both in
memory only.
storagenotification. Assumption: out of scope, one tab is the norm here.
Risk and fallback
onsignedcall could change whenApp.keepSignaturesruns. It does not —queue.run()resolves after the final state is published, andkeepSignaturesis called with thesame resolved state, one microtask later. Fallback if
App.test.tsregresses: pass the statethrough an explicit
awaitand keep the call unconditional.feature-detect in the component (
panel?.showModal?.()); task 4's browser smoke decides.bullet.yamlonly assert thedecompiled/reference tree exists; the realverification is
pnpm test+pnpm check+pnpm build+ the browser smoke.DONE — branch
issue/29-epic-stored-signatures-save-re, tree clean, no push.Commits
0a58574store: fix the batch run swallowed by an optional call, shim jsdom dialog, realm-safe byte compare1a388c0certpicker: render the certificate list from the app chain so a store-unlocked session fills the picker4676995store tests: allow the PBKDF2 seal/unlock to finish under parallel files(all
Refs #29)Gates
test -d decompiled— 0find decompiled -name "*.java" | grep -q .— 0git diff --check— 0 (clean)pnpm test— 13 files / 115 tests passed, 3 consecutive runspnpm check— 533 files, 0 errors, 0 warningspnpm build— static site builtFirmados: 1/1; outputs listed/re-downloaded/deleted; tampered ciphertext and wrong store PIN refused with their own copy; stored bytes verified byopenssl 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.bullet: coder session died — relaunched from committed state (branch
issue/29-epic-stored-signatures-save-re).bullet: implementation finished (DONE — branch
issue/29-epic-stored-signatures-save-re, tree clean, no push.) — moving to review.bullet: reviewer launched (model
9router/reviewer, independent of coder).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.Issue #29 — Epic: stored signatures (save/reload cert files + persistence)
Restated goal
User stores a signing identity once (the
.p12/.pfxcontainer, encrypted in the browser under astore 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
CryptoKeyafter unlock, the storePIN / 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/openwith 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()feedingsaveOutput,DownloadsDialog.Baseline on this branch:
pnpm test115/115 pass,pnpm check0 errors,pnpm buildOK.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 foundin 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, nodependency; the UI epic owns that, so the panel stays hand-rolled like the rest of the app),
mcp/,docs/,test/fixtures, anything underdecompiled*/.Ordered tasks
Verify:
pnpm test(13 files / 115 tests pass) — done, green.pnpm check(already 0 errors) andpnpm build(already green). Keep both green.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:
browsertab against127.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.
store: <what> (Refs #29).test -d decompiled,find decompiled -name '*.java' | grep -q .pluspnpm test,pnpm check,pnpm build,git diff --check./home/eduardez/workspace/AutoFirme/.bullet/meta/issue-29.report.md, comment on #29.Open questions (assumptions, proceeding)
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 torestyle. Adding a dependency now would break the no-new-deps law and the self-host-static rule.
identity blob is sealed. The constraint list forbids persisting keys/PINs, not public signature bytes.
own PIN still opens it, so unlocking needs both. The issue does not ask to store the file PIN.
forget wipes blob".
Risk
indexedDbBackend()misbehaves(transaction lifetime,
getAlltyping, upgrade races), the boring fallback is a one-object-storelayout:
STORESstays, but records are keyedstore:idin a single store, which makes the upgradepath trivial. Do not add
fake-indexeddb— no new dependencies.another process's server.
4676995): keep test timeoutsas they are, never reduce iterations to make a test faster.
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-reWorktree:
/home/eduardez/workspace/AutoFirme/.bullet/worktrees/issue-29Commits on the branch
f184151069aa470a585741a388c04676995This session added no commit: the acceptance criteria were already met by the WIP, and
git status --porcelainon the worktree is empty. No scaffolds, no throwaway scripts left in thetree (the smoke fixtures lived in
/tmp/smoke-p12).Gates (all exit 0)
test -d decompiled && find decompiled -name '*.java' | grep -q .reference/decompile-1.9trees present)pnpm testpnpm check(svelte-kit sync && svelte-check)pnpm build(adapter-static)buildgit diff --checkFiles that carry the feature (all pre-existing on this branch, reviewed here)
web/src/lib/certstore.ts(423 ln) —Vault/ 4-methodStoreBackend; identity blobs sealed withPBKDF2-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-memorybackend +
browserVault()fallback; typedStoreErrormapped fromDOMException/QuotaExceededError.web/src/lib/certstore.test.ts(197 ln) — round-trip, wrong PIN, tamper, id-rebinding, reload via asecond 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()→saveOutputper signed document,DownloadsDialogin the header.web/src/lib/i18n.ts— ES/ENstore*anddownloads*copy.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 devon127.0.0.1:5199in managed Chromium, with a throwaway PKCS#12(CN
Ñandú Prueba, PIN1234) generated byopenssl pkcs12 -export -keypbe AES-256-CBC:.p12, PIN1234→ picker listedÑandú Prueba(+ the CA, disabled, no key).tienda-1234.IndexedDB
autofirme-web.identitiesholds exactly{id,label,commonName,notAfter,createdAt,size,sealed};sealed.ciphertextfirst bytes[86,150,229,…]≠ the container's PKCS#12 header — the plaintext is notrecoverable from the store. No PIN, key or raw container anywhere in
localStorage/sessionStorage/cookies.location.reload()→ the same entry is listed (persistence across a visit).tienda-1234+ file PIN1234→session restored, picker shows the cert, all PIN inputs are empty afterwards.
Ñandú Pruebaand matching SHA-256; the downloads dialoglisted both.
contrato-2.txt.csig: 1520 bytes, SHA-256645e30c2db0f141e31b55124d79472c2bdbabed1ab95c57967e3504a8d92d6b9— 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.Note on method:
uploadFilefired before Svelte hydration once and silently did nothing; repeated afterhydration 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 anda map-based backend for jsdom).
What a human must decide
components.jsonabsent,no dependency, no
$lib/components/ui), and the UI epic owns the primitives. "Firmas guardadas" is ahand-rolled
<dialog>-free inline panel inside the sign step's certificate section; the outputs list isa real
<dialog>in the header ("Descargas"). Restyling/moving the identities panel behind a headerdialog is a UI-epic call.
(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.
.csig/.xsig/.pdfbytes are stored unencrypted (public data only);only the identity container is sealed. Confirmed as the intended reading of the constraints.
the file PIN, and doing so would contradict the constraint. Confirm this is the wanted UX.
REVIEW: CLEAN — commit
4676995: findings: none; UNFIXED: nonebullet: review CLEAN — ready to publish.
bullet: PR #33 open →
bullet-develop(auto-merge follows).bullet: merge blocked (HTTP 405) — conciliation agent merges trunk work into
bullet-develop; PR auto-merges next tick.bullet: merge conflicts with
bullet-develop— conflict-solver agent owns the worktree until it pushes.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)
bullet: PR #33 closed — work already merged into
bullet-develop.bullet: merged into
bullet-develop— integration gates green.