Use case: load signing cert from P12/PFX in browser #5
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
EduarD3V/AutoFirme#5
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?
Use case: user uploads .p12/.pfx + PIN, app imports key via WebCrypto as non-extractable, shows cert list (CN, issuer, valid dates) and lets user pick one. Mirrors AutoFirma keystore selection (decompiled/sources/es/gob/afirma/keystores/, filters/).
Constraints: key material never leaves browser memory; no server-side key ops.
Acceptance:
Depends on #4 (web/ scaffold).
bullet: coder launched on branch
issue/5-use-case-load-signing-cert-fro(modelnan/deepseek-v4-flash).Claimed — branch
issue/5-use-case-load-signing-cert-fro. Doing: hand-rolled PKCS#12 parser (DER + MAC + PBES2/PBKDF2+AES-CBC) with non-extractable WebCrypto key import, cert list UI, openssl-generated test fixtures.Plan — Issue #5: load signing cert from P12/PFX in browser
Restated goal
A user picks a
.p12/.pfxfile plus its PIN in the AutoFirma Web SPA. The app parses thePKCS#12 container entirely in the browser (no network, no server key ops), decrypts the
shrouded private key, imports it into WebCrypto as non-extractable (
extractable=false,usages
['sign']), lists the certificates in the file (subject CN, issuer, validity dates) andlets the user select one. The selected certificate +
CryptoKeyare exposed to the sign flow(
#6/#7/#11) through a typed seam inweb/src/lib/sign.ts. Error paths: wrong PIN, corruptfile, unsupported PBE algorithm, expired certificate (warn, still allow selection).
"Done" =
pnpm testgreen with a test that generates its own self-signed P12 in test setup(nothing binary committed), imports the key, and proves the imported key is the real one by
signing with WebCrypto and verifying that signature with
openssl dgst -verify. Plus the UIactually loading a P12 end to end in the SPA.
Reference only (read, not ported):
decompiled/sources/es/gob/afirma/keystores/— AutoFirmashows alias/CN + validity and asks for the PIN; same shape here.
Key technical decision (recorded, not negotiable mid-flight)
WebCrypto has no PKCS#12 support, and the browser has no other native P12 API, so the
container must be parsed by hand. We implement:
asn1.ts), PKCS#12 structure walk (RFC 7292), MAC check with the PKCS#12 KDF(HMAC-SHA1 / HMAC-SHA256) → this is what detects a wrong PIN.
crypto.subtle.deriveBits) +AES-128/192/256-CBC (
crypto.subtle.decrypt). This is what OpenSSL 3/4pkcs12 -exportwrites by default and what Java 9+
KeyStore.store(...,"PKCS12")writes.hand-rolling DES/RC2 from memory is a correctness hazard for crypto. It raises a typed,
actionable
UnsupportedPbeErrortelling the user to re-export the container as modernPKCS#12 (or use a hardware/OS keystore). Documented in
web/README.md.Scope
Files I will change:
web/src/lib/asn1.ts(new) — minimal DER reader + OID decode.web/src/lib/pkcs12.ts(new) — container parse, MAC verify, PBES2 key decryption.web/src/lib/x509.ts(new) — certificate summary (subject/issuer/validity/serial/SPKI) + thepublic-key material needed to match a cert to its private key.
web/src/lib/keystore.ts(new) —loadPkcs12(), typed errors,SignerSession.web/src/lib/sign.ts— extendSignerCert, add the key/cert pairing types,signWithKeyseam.web/src/lib/components/CertPicker.svelte— real control: file, PIN, load, error, cert select,expiry warning.
web/src/lib/App.svelte— hold the loaded session, enable signing when file+cert exist.web/src/lib/i18n.ts— ES/EN copy for the new control.web/src/lib/App.test.ts— end-to-end SPA flow against a generated P12.web/src/lib/pkcs12.test.ts(new) — container/error/openssl cross-check tests.web/src/test/fixtures.ts(new) — test-only P12 generation via theopensslCLI.web/vite.config.ts—test.setupFiles(jsdom'scryptohas nosubtle).web/src/test-setup.ts(new) — 3 lines installing Node'swebcrypto.web/README.md— status + the PBES1/PBES2 limitation.Not touching:
decompiled/**(read-only reference),original/,extracted/,docs/**(owned by #3),
.bullet/**(never committed), everything outsideweb/except nothing.Ordered tasks (each with its verifying command)
test -f /home/eduardez/workspace/AutoFirme/.bullet/meta/issue-5.plan.md.asn1.ts+x509.ts(parse DER, cert summary). Verify:cd web && pnpm testafter thefirst test lands (see 4) — interim check
npx tsc --noEmit -p tsconfig.jsonexit 0.pkcs12.ts+keystore.ts(MAC, PBES2 decrypt, import as non-extractable, cert↔keymatch). Verify:
cd web && pnpm test.web/src/test/fixtures.ts+web/src/lib/pkcs12.test.ts— openssl-generated fixtures(modern AES-256/PBKDF2-SHA256; 2-cert file; legacy PBES1 file for the error path), wrong PIN,
corrupt file, expiry via injected
now, and a real sign→openssl dgst -verifycross-check.Verify:
cd web && pnpm test.CertPicker.svelte,App.svelte,i18n.ts) +App.test.tsflow.Verify:
cd web && pnpm test && pnpm check.pnpm buildexit 0,git diff --checkexit 0, the two.bullet/bullet.yamlgates exit 0, then commit each step asweb: … (Refs #5).Open questions (assumption I proceed on)
Assumption: out of scope for a correct-diff budget today; ship PBES2 + a typed, actionable
error and document it. Follow-up issue material, not silently faked.
Assumption: compare key material — RSA modulus/exponent from PKCS#8 vs the certificate SPKI;
EC public point from PKCS#8
[1]vs SPKI. A cert whose key is not in the container is showndisabled rather than silently producing a broken signature.
os.tmpdir()per test run by theopensslCLI (dev tool, not a dependency, not a committed binary).crypto.subtle. Assumption:test.setupFilesinstalls Node'swebcrypto; no production code depends on it.Risk
deriveBitsPBKDF2 params) → fallback: run the cryptotests under
// @vitest-environment nodeand keep the jsdom test to the UI.-legacy, digest names) → fallback: keep the modern fixture(verified working) and drop the legacy fixture, keeping the error path unit-tested with a
hand-built PBES1 header.
the common types explicitly and fail with a clear error for anything else rather than guessing.
and the report says PARTIAL with the core landed.
DONE — branch
issue/5-use-case-load-signing-cert-fro, 3 commits, worktree clean.Commits:
db4a0ac(PKCS#12 core: DER + RFC 7292 walk, PBES2 AES-CBC decryption, non-extractable WebCrypto key import, cert↔key pairing),ac5afe2(picker UI + App wiring + ES/EN copy),9e1dbbb(ArrayBuffer-backed byte types for BufferSource).Gates:
test -d decompiled0 |find decompiled -name "*.java" | grep -q .0 |git diff --check0.Verification:
pnpm test14/14 (incl. signature produced by the imported key verified byopenssl dgst -sha256 -verify, and a tampered message rejected) |pnpm check0 errors |pnpm build0 | real Chromium smoke onpnpm preview: realkeystore.p12+ PIN -> cert list ["Ñandú Prueba"], selected, Sign enabled, no console errors.Two deviations, both documented in
web/README.mdand the report, each needing a follow-up issue: (1) legacy PBES1 containers (3DES/RC2) are rejected with an actionable message — WebCrypto has no DES/RC2 primitive, and aged FNMT exports are legacy; (2) the PKCS#12 MAC is not verified (RFC 7292 B.2 KDF not reproduced againstopenssl kdfafter 3 attempts) — a wrong PIN is still detected via the key-bag decryption failure. Also: the tests shell out to theopensslbinary; add it to CI if the image is bare.Refs #5
bullet: coder session died — relaunched from committed state (branch
issue/5-use-case-load-signing-cert-fro).Re-verified (relaunched coder session, no new commits, branch still at
9e1dbbb).From scratch in this session: gates
test -d decompiled0 |find decompiled -name "*.java" | grep -q .0 |git diff --check0.pnpm test14/14 |pnpm check0 errors |pnpm build0 |git ls-files -- original extracted work0 paths.Fresh real-Chromium smoke with a newly generated container (
openssl pkcs12 -export, leaf + CA, PBES2/PBKDF2/AES-256-CBC, MAC sha256, PIN 4321): wrong PIN ->PIN incorrecto.+ Firmar disabled; correct PIN -> 2 certs, signer auto-selected, CA disabled (no key), Firmar enabled, PIN cleared;tab.requests()27 requests / 0 external.No code changed, so the two documented deviations (legacy PBES1 rejected; PKCS#12 MAC not verified) still stand and remain follow-up material. Nothing pushed. Refs #5
bullet: coder session died — relaunched from committed state (branch
issue/5-use-case-load-signing-cert-fro).bullet: implementation finished (DONE — branch
issue/5-use-case-load-signing-cert-fro, 3 commits, worktree clean.) — moving to review.bullet: reviewer launched (model
9router/reviewer, independent of coder).Re-verified again (third session, branch still at
9e1dbbb, nothing new committed, tree clean). Gates 0/0/0,pnpm test14/14,pnpm check0 errors,pnpm buildexit 0.Fresh Chromium run against
pnpm previewwith two containers generated this session by openssl 4.0.3 (PBES2/PBKDF2/AES-256-CBC):9999→PIN incorrecto., empty cert list, Firmar disabled;2468→CN=Firma Humolisted with issuer andVálido hasta: Oct 14, 2027, auto-selected, Firmar enabled;notAfter 2021-01-01) →Este certificado está caducado; puedes firmar, pero puede ser rechazado.and Firmar still enabled (warn + allow, first time this path is proven in a real browser);localhost:4173, 0 external — key material never leaves the page.Code re-read, not trusted:
importKey(..., false, ["sign"]), cert↔key pairing by public-key material,canSign = file && cert?.keyexcludes expiry deliberately. Outcome stays DONE; the three documented deviations (legacy PBES1 rejected, MAC not verified, tests needopenssl) remain follow-up-issue material. Refs #5.REVIEW: FIXED 1 — commit
27d86a5: findings: CertPicker kept the previous file's certificate list (and enabled Firmar) after a different keystore file was picked, so the displayed signer did not match the selected file — fixed by clearing certs/error/selection on file change + regression test (fix commit27d86a5); UNFIXED: nonebullet: fixer launched (round 1/2).
FIXED: applied 1 —
d8d191a: the reviewed finding (CertPicker kept the previous file's certificate list, and enabled Firmar, after a different keystore file was picked) held only for a settled load; a load still in flight when the file changed repopulated the list from the superseded file. CertPicker now discards a superseded load generation (certs/error/loading), regression test App.test.ts:123 — red without the fix, green with it; 16/16 tests, pnpm check and build exit 0. Refs #5; UNFIXED: nonebullet: reviewer launched (model
9router/reviewer, independent of coder).REVIEW: FIXED 1 — findings: commit
d8d191a:clearFile()inweb/src/lib/App.sveltereset onlyfile, so removing the document and picking another re-enabled Firmar with the previously loaded signer while the picker showed "Sin certificado" — fixed inf873e60(clearscert, regression test inApp.test.ts); UNFIXED: nonebullet: fixer round done — fresh reviewer re-checking.
REVIEW: FIXED 2 — commit
f873e60: findings: PKCS#12 with two certificates sharing one serial crashed the cert picker (Svelte keyed-each throws each_key_duplicate) and made selection resolve to the first match — fixedad361cb; picker showed only the expiry date although the issue asks for valid dates (CN, issuer, valid dates) — fixeda19259f; UNFIXED: nonebullet: fixer round done — fresh reviewer re-checking.
REVIEW: FIXED 1 — commit
a19259f: findings: removing the document while a keystore load was still in flight left the late onpick(session.signer) repopulating App.cert after clearFile(), so the next document enabled signing with a certificate never loaded for it (reproduced red) — fixed3918525; UNFIXED: noneEvidence (this round, head
a19259f→ fix3918525):web/src/lib/App.test.ts"does not resurrect the signer when the document is removed while a load runs": red before the fix (expected false to be true— Firmar enabled), green after; fix isonDestroybumping the load generation inweb/src/lib/components/CertPicker.svelte.cd web && pnpm testexit 0 — 19 passed (10 pkcs12 + 9 App);pnpm check0 errors/373 files;pnpm buildwrotebuild.test -d decompiled/find decompiled -name '*.java' | grep -q ./git diff --check— 0 / 0 / 0.web/package.jsonuntouched),Refs #5only.Plan-scope and prior-round fixes (
27d86a5,d8d191a,f873e60,ad361cb,a19259f) re-verified green at this head. Full table:.bullet/meta/issue-5.review.md.bullet: fixer round done — fresh reviewer re-checking.
REVIEW: CLEAN — commit
3918525: findings noneAll issue #5 acceptance criteria met (parse P12 without node-forge, non-extractable CryptoKey exposed to sign flow, wrong-PIN + expired-cert error paths, generated-P12 unit tests); plan Scope = diff (14 files, all under web/, none out of scope). Re-ran this session: pnpm test 19/19, pnpm check 0 errors, pnpm build exit 0, bullet gates 0/0, git diff --check clean, no test skipped/loosened, no closing keywords, worktree clean. The head commit
3918525(in-flight load discarded on picker unmount) verified red-without-fix/green-with-fix. UNFIXED: none. Disclosed deviations (legacy PBES1 rejected, PKCS#12 MAC not verified, openssl as test tool) unchanged follow-up material.bullet: review CLEAN — ready to publish.
bullet: merge conflicts with
bullet-develop— conflict-solver agent owns the worktree until it pushes.bullet: PR #24 open →
bullet-develop(auto-merge follows).bullet: merge blocked (HTTP 405) — conciliation agent merges trunk work into
bullet-develop; PR auto-merges next tick.Claimed — branch
issue/5-use-case-load-signing-cert-fro. Doing: merge into bullet-develop, resolve App.svelte/App.test.ts conflicts.Claimed — branch
bullet-develop. Doing: trunk→bullet-develop reconciliation (trunk work already merged at329550a; clearing the dead conflict-solver's in-flight issue/5 merge from this worktree, then gates + report). Refs #5RECONCILED: trunk work merged into bullet-develop (conflicts resolved: none —
origin/main9c2af1ais already an ancestor oforigin/bullet-develop329550a,git merge origin/main= "Already up to date.", no commit; the only blockage in this worktree was a dead agent's uncommitted merge ofissue/5-..., saved as.bullet/meta/issue-5.inflight-issue-merge.patchthen aborted — PR #24 is red for issue-branch vs integration-branch, not trunk; gatestest -d decompiled0 /find decompiled -name "*.java" | grep -q .0,pnpm test92/92,pnpm check0 errors). Refs #5bullet: merge conflicts with
bullet-develop— conflict-solver agent owns the worktree until it pushes.bullet: merge blocked (HTTP 405) — conciliation agent merges trunk work into
bullet-develop; PR auto-merges next tick.Claimed — branch
bullet-develop. Doing: trunk→bullet-develop reconciliation (trunk advanced toc296b96= merge of PR #25; its tree is byte-identical tobullet-develop329550a, so trunk gained no content). Waiting on the live conflict-solver (-i-5, in-progress issue/5 merge in this worktree) before touching refs. Refs #5INTEGRATED: #5 merged into bullet-develop (conflicts resolved: App.svelte kept the verify-mode block and reinstated clearing the selection when the last document is removed; App.test.ts kept both sides tests plus the keystore import; CertPicker/keystore/i18n/fixtures auto-merged)
RECONCILED: trunk work merged into bullet-develop (conflicts resolved: none — the trunk only advanced with merge commits whose trees are byte-identical to bullet-develop (
c296b96= PR #25, thenff55d2f), sogit merge --no-ff origin/mainmerged clean twice and added zero content:a090a08(carries the conflict-solver merge1ae7480, Refs #5) andc9257b7(pushed;origin/bullet-develop=c9257b7,origin/main=ff55d2f, ahead by 1); issue branches untouched; gatestest -d decompiled0 /find decompiled -name "*.java" | grep -q .0 (11487 files) /git diff --check0; smokepnpm test96/96,pnpm check0 errors on the merged tree). Refs #5