Skip to content

fix(gabikeys): wipe secret prime factors from private key (#69) - #70

Open
dobby-coder[bot] wants to merge 8 commits into
masterfrom
fix/issue-69-wipe-secret-primes
Open

fix(gabikeys): wipe secret prime factors from private key (#69)#70
dobby-coder[bot] wants to merge 8 commits into
masterfrom
fix/issue-69-wipe-secret-primes

Conversation

@dobby-coder

@dobby-coder dobby-coder Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Closes #69

Summary

Addresses the key-generation timing side-channel tracked in #69 (and the long-standing #8).

Go's math/big is documented as not constant-time. As #8 describes, a co-located attacker able to take precise cache/branch-timing measurements of the arithmetic performed on the issuer's secret primes during key generation has a realistic path to recovering secret material. That cannot be fully closed without replacing the bignum implementation, a large separate effort which #8 itself calls out ("implement and possibly upstream const-time exp/gcd arithmetic").

The one concrete, non-breaking mitigation named in #8 is its final recommendation: the secret prime factors "are not needed for anything else" beyond deriving N and the group order, so they should not be stored. This PR implements that.

What changed

PrivateKey.Destroy() in gabikeys/keys.go clears the secret material: P, Q, P', Q', the group Order (P'·Q'), and the ECDSA revocation private key (both the parsed key and the base64 ECDSAString it can be reconstructed from). For the big.Int values and the ECDSA scalar D it also overwrites the backing words in place. ECDSAString is a Go string and therefore immutable, so clearing it drops the reference and makes the key unreconstructable, but its base64 bytes are not overwritten and linger until the GC collects them — the doc comment names that caveat. Callers invoke it once the key is persisted or no longer needed, which shortens the window the highest-risk secret values linger in process memory. It is best-effort, since Go's GC may already have copied values, so it is defense in depth. It is idempotent and nil-safe. The public modulus N is retained; it is not secret.

A destroyed key now fails instead of being usable. Validate, WriteTo, WriteToFile and Print return an error once Destroy has run. encoding/xml omits nil pointer fields rather than erroring, so without this a destroyed key marshalled into a structurally valid IssuerPrivateKey document holding no key material at all, with a nil error. The natural place to call Destroy is right after writing the key to disk, so a later reordering of those two statements would have overwritten a production key with an empty one and exited 0. The WriteToFile check runs before the file is opened, because the forceOverwrite branch truncates on open.

NewPrivateKey now copies p and q. It previously stored the caller's pointers, so Destroy zeroed the caller's own primes in place.

Regression tests in gabikeys/destroy_test.go generate a key through the real GenerateKeyPair path (toy 256-bit params for speed) and assert that after Destroy the secret factors are nil and their backing word arrays are zeroed, the revocation key cannot be reconstructed, N survives, serialization fails, an existing key file on disk is left untouched, and the caller's own primes are intact.

The persisted XML key format is unchanged, so existing keys keep round-tripping and there is no interop break.

Scope of the mitigation

Wiping reaches furthest right after GenerateKeyPair, where the primes exist only as the big.Ints that Destroy overwrites. On the load-from-disk path (NewPrivateKeyFromFile) the primes also live in the heap as the decimal text of the XML file, which Destroy does not touch. So this is not a general "the primes are gone" guarantee, and the doc comment says so.

Audit of Exp/Mul/Gcd call sites on secret material

Per the request in #69 to audit remaining sites for unblinded secret material:

Location Operation Secret input Blinded? Assessment
gabikeys/keys.go keygen Mul(p,q)→N, Mul(p',q')→Order, Rsh(p,1), LegendreSymbol(s, P/Q) primes p, q, p', q' No The unblinded secret-material ops #8 flags. Run once, on freshly generated secrets: the co-located-attacker threat. Not fixable without constant-time bignum; this PR removes their retention after use.
clsignature.go:67, issuer.go:93, revocation/{proof,api}.go ModInverse(e, sk.Order) Order Yes e is a fresh random prime per signature, giving per-invocation noise. This is the "negligible chance" remote case in #8.
issuer.go:98,106 randomElementMultiplicativeGroup(Order), Mod(·, Order) Order Yes Fresh random eCommit per proof.
issuer.go:88 (Exp(Q, d, pk.N)) secret exponent d = e⁻¹ modulus N is public Yes d derives from the per-signature random e. clsignature.go even has a pre-existing TODO about this exact spot.
keyproof/*.go GCDs (squarefree, disjointprimeproduct, primepowerproduct, almostsafeprimeproduct, quasisafeprimeproduct) GCD(·, ·, ·, N) operates on public N n/a Not secret material, no concern.

The unblinded secret-material arithmetic is concentrated in the one-shot key-generation path; the runtime signing and revocation paths all carry a fresh per-invocation random element, which matches #8's analysis that remote exploitation is negligible. The residual co-located risk is the inherent math/big non-constant-time property, out of scope for a code-level change here.

Testing

  • go test ./..., all 10 packages pass.
  • New tests: TestPrivateKeyDestroy, TestPrivateKeyDestroyIdempotent, TestPrivateKeyDestroyDoesNotWipeCallerPrimes, TestPrivateKeyDestroyBlocksSerialization, TestPrivateKeyDestroyDoesNotTruncateExistingFile. The last three fail on the previous version of this branch and pass now.
  • gofmt, go vet ./..., go fix -diff ./..., ineffassign and misspell clean. staticcheck could not be run locally: go.mod requires go1.26 and the available staticcheck build is go1.24, which makes it analyse nothing. CI runs it properly.

Notes for reviewers

Destroy() is opt-in. Nothing in gabi or irmago calls it yet, so merging this changes no behaviour on its own. Where consumers should invoke it is a follow-up integration decision, and doing it in the wrong place would break them, because every field it clears is load-bearing somewhere: the irma server issuance path, key validation on every session, irma scheme issuer keyprove, revocation-keypair.

A further step would be to stop persisting pPrime/qPrime (both derivable from P and Q) to shrink the on-disk secret footprint, but that changes the XML output format and risks interop, so it is deliberately not done here.

The merge with master needed a hand resolution. Master's #56 permission tests and this branch's tests both added gabikeys/keys_test.go, in the external and the internal test package respectively. Go allows both packages in one directory but not in one file, so master's keys_test.go is kept verbatim and the Destroy tests moved to gabikeys/destroy_test.go.

Since this branch picked up new commits, @rubenhensen's earlier approval predates the two fixes above.

Related: #8, #67 (dependency updates, separate concern).

Go's math/big is not constant-time, so a co-located attacker can in
principle recover secret material by timing the arithmetic performed on
the issuer's secret primes during key generation (issue #8). The one
concrete, non-breaking mitigation from that issue is to not retain the
secret prime factors longer than necessary: they are only used to derive
N and the group order.

Add PrivateKey.Destroy, which overwrites and clears P, Q, P', Q', the
group order and the ECDSA revocation key. Callers can invoke it once the
key has been persisted or is no longer needed to shorten the time the
highest-risk secret values linger in memory. The public modulus N is
retained.

This does not make math/big constant-time (that requires replacing the
bignum implementation and is out of scope). The persisted XML format is
unchanged, so existing keys keep round-tripping.

Regression tests assert the secret factors are cleared and their backing
memory overwritten after a key is generated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dobby-coder
dobby-coder Bot requested a review from rubenhensen July 4, 2026 23:34

@dobby-coder dobby-coder Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict is request-changes — posted as COMMENT only because GitHub blocks self-request-changes on a bot's own PR. Treat as blocking.

Rules Dobby 2 gatekeeper review — 1 bug, 1 nit, 1 PR-hygiene issue.

Nice, focused mitigation for the storage half of #8/#69, and thanks for the call-site audit table — that's exactly the analysis the issue asked for. Two code issues and one PR-hygiene point block sign-off:

  • Bug — Destroy() leaves the ECDSA revocation private key fully recoverable. It nils ECDSA and zeroes ECDSA.D, but never clears ECDSAString, which holds the base64-encoded marshaled ECDSA private key (set from signed.MarshalPrivateKey at keys.go:277). After Destroy(), RevocationSupported() still returns true and parseRevocationKey() reconstructs the full private scalar D. The doc comment ("the revocation (ECDSA) private key … the key can no longer sign or be serialized") is therefore false. See inline. Fix: also set privk.ECDSAString = "".
  • Nit — ECDSA.D.SetInt64(0) does not scrub the backing words (unlike wipeInt), so the secret scalar bytes linger in memory. See inline.
  • PR hygiene — no closing keyword for #69. The body uses "Addresses … #69" and "Related: #8, #67", so merging will not auto-close the issue this PR resolves. Add a Closes #69 line (per the closing-keyword rule). Keep #8 as a soft reference since it's only partially addressed.

Sending back to PR Dobby to fix.

Comment thread gabikeys/keys.go
Comment thread gabikeys/keys.go Outdated
- Clear ECDSAString so the base64-marshaled ECDSA private key can no
  longer be reconstructed via parseRevocationKey after Destroy.
- Scrub the ECDSA scalar D's backing words (wipeStdInt) instead of only
  truncating its length with SetInt64(0).
- Extend TestPrivateKeyDestroy to assert the revocation key is
  unrecoverable after Destroy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@dobby-coder dobby-coder Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict is approve — posted as COMMENT only because GitHub blocks self-approval on a bot's own PR. Treat as a sign-off; no changes requested.

Rules Dobby 2 gatekeeper review — cycle 2: APPROVE.

All cycle-1 findings are resolved and verified on head commit 4e75aa9:

  • Revocation key fully wipedDestroy() now sets privk.ECDSAString = "" (so RevocationSupported() returns false and parseRevocationKey() cannot reconstruct D) and scrubs the scalar via wipeStdInt(privk.ECDSA.D), which zeroes the backing words (not just SetInt64(0)). TestPrivateKeyDestroy asserts the revocation key is unrecoverable after Destroy.
  • Closing keyword added — the body now leads with Closes #69, so merging auto-closes the issue.

Rule compliance pass (Haiku per-rule check) is clean: conventional-commit title ✓, closing keyword ✓, tests-on-fixes ✓ (TestPrivateKeyDestroy + TestPrivateKeyDestroyIdempotent via the real GenerateKeyPair path), promised-vs-delivered ✓, Go/lint ✓. Two automated flags were verified as false positives: the allZero([]mbig.Word) "type mismatch" is a non-issue because gabi/big.Int.Bits() returns math/big.Word (big/int.go:117), and the "missing audit" flag missed the Exp/Mul/Gcd audit table that is present in the PR body.

Scope note (non-blocking, no change required): this is an opt-in Destroy() the caller must invoke; the XML key format still persists P/Q/PPrime/QPrime on disk. The body reasonably justifies this (interop preservation; wiring Destroy into consumers like irmago is a documented follow-up). This satisfies the storage-mitigation ask of #8/#69; the constant-time bignum work remains tracked in #8.

Mechanics verified correct: big.Int.Go() is a pointer cast so Bits() zeroes live backing memory; N is retained (public); revocation key unrecoverable after Destroy. go test ./... green (10/10 pkgs), gofmt/vet clean.

@dobby-coder
dobby-coder Bot marked this pull request as ready for review July 5, 2026 03:49
rubenhensen
rubenhensen previously approved these changes Jul 9, 2026
@w-ensink

Copy link
Copy Markdown
Member

/dobby could this break any production use cases?

@dobby-coder

dobby-coder Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

On it — I'll dig into PR #70's changes and check for any production use cases that could be affected, then report back here.

@dobby-coder

dobby-coder Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Short answer: no — merging this PR as-is can't break anything in production. The diff is purely additive (one new Destroy() method plus helpers in gabikeys/keys.go), nothing in gabi or irmago calls it yet, the XML key format is untouched, and the full test suite plus all CI gates (test, lint, analyze, CodeQL) are green.

The catch is for later, once someone wires Destroy() in: every field it clears is load-bearing somewhere (issuance, revocation signing/witness updates, key loading/validation, irma scheme issuer keyprove/revocation-keypair), so calling it in the wrong place — especially the irma server issuance path — would break things. I also found it currently zeroes the caller's own prime pointers (aliasing risk) and can serialize a "hollow" key silently after being destroyed, rather than erroring. Details on all of that are in my comment on #70.

@w-ensink

Copy link
Copy Markdown
Member

/dobby fix the merge conflict and reevaluate

@dobby-coder

dobby-coder Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

On it — I'll resolve the merge conflict on this PR and reevaluate the changes afterward.

dobby-coder Bot and others added 2 commits July 30, 2026 12:26
master added gabikeys/keys_test.go (external test package gabikeys_test, the
issue #56 file-permission regression tests) where this branch had added a file
of the same name in the internal package gabikeys. Both are needed and Go allows
both packages in one directory, but not in one file: keep master's keys_test.go
verbatim and move this branch's Destroy tests to gabikeys/destroy_test.go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…thing

Two defects in Destroy, found while re-evaluating the change:

encoding/xml omits nil pointer fields rather than erroring, so a destroyed
key marshalled cleanly into a structurally valid IssuerPrivateKey document
containing no key material, with a nil error. Since the natural place to call
Destroy is right after writing the key to disk, a later reordering of those
two statements would have overwritten a production private key with an empty
one and exited 0. Track the wipe in a `destroyed` flag and have Validate,
WriteTo and WriteToFile (and so Print) return an error once it is set. The
WriteToFile check runs before the file is opened, because the forceOverwrite
branch truncates on open.

NewPrivateKey stored the caller's prime pointers, so Destroy zeroed the
caller's own p and q in place. Copy them in the constructor instead.

Also scope the doc comment: wiping helps most right after GenerateKeyPair,
and reaches less on the NewPrivateKeyFromFile path, where the primes also
live in the heap as the decimal text of the XML file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dobby-coder
dobby-coder Bot requested a review from w-ensink July 30, 2026 12:30
@dobby-coder

dobby-coder Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Merge conflict resolved and the change re-evaluated. Head is now 50524f6, mergeable: true, all four checks green (test, lint, analyze, CodeQL).

The conflict

Master's #56 fix and this branch both added gabikeys/keys_test.go: master's is the external test package gabikeys_test (the WriteToFile permission tests), this branch's is the internal package gabikeys (the Destroy tests need parseRevocationKey). Go allows both packages in one directory but not in one file, so master's keys_test.go is kept verbatim and the Destroy tests moved to gabikeys/destroy_test.go. No test was dropped. Master had also moved on again while I worked, so 06e8645 is merged in as well.

git diff origin/master --stat is non-empty, so the PR is not a no-op after the resolution.

Re-evaluation: two defects fixed

Re-reading the branch turned up the two problems I flagged when you asked about production impact, both in this PR's own new code, so I fixed them here.

  1. A destroyed key serialized to a hollow file. encoding/xml omits nil pointer fields rather than erroring, so WriteTo on a destroyed key returned 206 bytes of structurally valid IssuerPrivateKey XML with no key material and a nil error. Since the natural place to call Destroy() is right after writing the key to disk, a later reordering of those two statements would have overwritten a production key with an empty one and exited 0. Destroy now sets a destroyed flag and Validate, WriteTo, WriteToFile and Print return an error once it is set. The WriteToFile check runs before the file is opened, because the forceOverwrite branch truncates on open, so a late check would still have destroyed the file on disk.

  2. Destroy zeroed the caller's own primes. NewPrivateKey(p, q, ...) stored the caller's pointers, so wipeInt(privk.P) zeroed the caller's p in place. The constructor now copies p and q.

Three new tests cover this and all three fail on the previous head of this branch: TestPrivateKeyDestroyBlocksSerialization, TestPrivateKeyDestroyDoesNotTruncateExistingFile, TestPrivateKeyDestroyDoesNotWipeCallerPrimes.

I also scoped the doc comment. Wiping reaches furthest right after GenerateKeyPair; on the NewPrivateKeyFromFile path the primes also live in the heap as the decimal text of the XML file, which Destroy does not touch.

Unchanged

Still opt-in: nothing in gabi or irmago calls Destroy(), the XML key format is untouched, and merging changes no behaviour on its own. The integration question from the earlier thread stands: every field it clears is load-bearing in issuance, revocation and key validation, so wiring it into consumers is a separate decision.

@rubenhensen your approval predates these two commits, so it needs another look.

One note for whoever runs the lint gate locally: staticcheck ./... analyses nothing on a toolchain older than go.mod's go1.26 and only prints "module requires at least go1.26.0", which reads like a clean run. CI's setup-go gets it right.

…ontract

Two doc-only corrections from gatekeeper review, no behaviour change:

- ECDSAString is a Go string, so clearing it drops the reference but does
  not overwrite the base64 bytes the way wipeInt overwrites the big.Int
  backing words. Name that alongside the GC-copy and XML-decimal-text
  caveats already in the best-effort paragraph.
- Destroy mutates the key without synchronization while Validate/WriteTo/
  WriteToFile and the issuance and revocation paths read the same fields,
  so concurrent use is a data race, not just a nil-dereference. State the
  quiescence requirement consumers have to meet.

@dobby-coder dobby-coder Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict is approve — posted as COMMENT only because GitHub blocks self-approval on a bot's own PR. Treat as a sign-off; no changes requested.

Rules Dobby 2 gatekeeper review — cycle 3 (final): APPROVE.

Trigger was /dobby fix the merge conflict and reevaluate. The conflict is resolved and the resolution is provably lossless; the two remaining findings were doc-only and are fixed in this cycle.

Merge-conflict resolution — verified clean

The hand resolution described in the body (master's #56 permission tests kept in gabikeys/keys_test.go, the Destroy tests moved to gabikeys/destroy_test.go to avoid two packages in one file) holds up:

  • Nothing dropped from either side. Comparing the branch's contribution before the merge (git diff $(git merge-base origin/master 56a792f)..56a792f) against its contribution after (git diff origin/master...50524f6), the +/- lines are identical — so master + branch delta == merged tree exactly, in every file, flagged or not.
  • Master's keys_test.go survived byte-for-bytegit hash-object is 16132d1b on both origin/master and HEAD. The only file HEAD adds over master's file list is gabikeys/destroy_test.go.
  • Not a no-op. The two-dot git diff origin/master HEAD is non-empty and identical to the three-dot diff: keys.go +113/-2, destroy_test.go +198.
  • mergeable: MERGEABLE; all four checks green (CodeQL, analyze, lint, test).

Findings this cycle — 0 bugs, 2 nits, both fixed here

Neither warranted another coder loop, so I committed them directly as 85df3fb (doc-only, no behaviour change):

  1. ECDSAString wipe was overstated. privk.ECDSAString = "" clears the string header, but Go strings are immutable — the base64 bytes are not overwritten the way wipeInt/wipeStdInt zero the big.Int backing words. The code already does the most that is possible for a Go string; the problem was the description. The Destroy best-effort paragraph now names this caveat next to the GC-copy and XML-decimal-text ones, and the PR body's "overwrites the backing memory of ... the base64 ECDSAString" claim is corrected to distinguish cleared (unreconstructable) from overwritten.
  2. Destroy has no concurrency contract. It nils P/Q/PPrime/QPrime/Order and sets destroyed without synchronization, while those same fields are read by Validate/WriteTo/WriteToFile and by the issuance and revocation paths — confirmed live at issuer.go:93,98,106, clsignature.go:67 and revocation/api.go:196, with no mutex and no existing goroutine-safety contract anywhere in gabikeys. A consumer sharing one *PrivateKey across concurrent sessions gets a data race, not just a nil-deref. The doc comment now states the quiescence requirement. This matters for the deferred irmago integration, which the body already flags as a separate decision.

Rule compliance

Per-rule sweep (Haiku fan-out over Go-correctness, PR-hygiene and merge/claim-accuracy rule batches) came back clean apart from the ECDSAString item above, which all three batches independently converged on — corroborating the reviewer's finding rather than adding a new one. Verified: conventional-commit title ✓; Closes #69 confirmed via GraphQL closingIssuesReferences, not just body text ✓; reviewer assigned (@w-ensink) ✓; regression tests exercise the real GenerateKeyPair/NewPrivateKey/WriteToFile path rather than mocks ✓; PR ready, not a stale green draft ✓; no CHANGELOG in this repo, so no stale sibling entry could ride in on the merge ✓. Locally re-run on HEAD: go test ./... 10/10 packages, go vet, gofmt, go fix -diff all clean.

Not an embargoed fix — #8 and #69 are long-standing public issues and there is no GHSA reference, so nothing here needed holding back.

Scope note (unchanged, non-blocking)

Destroy() remains opt-in with no callers in gabi or irmago, and the XML format still persists P/Q/PPrime/QPrime on disk. Both are disclosed in the body with sound reasoning (interop preservation; wiring it into consumers is a follow-up integration decision). This delivers the storage mitigation #8 asked for; the constant-time bignum work stays tracked in #8.


This was cycle 3, the gatekeeper loop limit, so the automated review loop ends here — but it ends on a clean state rather than an exhausted one: there are no unresolved findings. A human approving review is still required to merge (reviewDecision: REVIEW_REQUIRED@rubenhensen's earlier approval was dismissed by the subsequent pushes, and Dobby never merges PRs). Over to @w-ensink.

@dobby-coder

dobby-coder Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Done — the merge conflict resolution checked out as lossless (byte-identical keys_test.go, no dropped hunks), and the cycle-3 reevaluation came back clean: 0 bugs, just 2 doc nits, which I fixed directly (85df3fb) rather than looping back to the coder — the ECDSAString wipe caveat and a missing concurrency contract on Destroy. I also corrected an overstated claim in the PR body about overwriting memory.

Everything else held up: title, Closes #69, tests (10/10 packages), go vet/gofmt/go fix all clean. I posted the APPROVE review on #70.

One thing worth a maintainer's judgment call: Destroy() has no callers yet, and the XML format still persists the raw key fields on disk — both are disclosed in the PR body. Since this was the cycle-3 limit and @rubenhensen's prior approval got dismissed by later pushes, it still needs a human review to merge — Dobby won't do that part.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

task: eliminate non-constant-time arithmetic on secret key factors

2 participants