Skip to content

Commit 43c8dd4

Browse files
authored
Merge pull request #283 from voxpupuli/fix/init-bootstrap-reentrancy
fix(ca): stop Init's slow path re-entering the bootstrap lock
2 parents 3e30371 + d5d66be commit 43c8dd4

5 files changed

Lines changed: 407 additions & 68 deletions

File tree

docs/development/locking.md

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -378,9 +378,11 @@ written as two.
378378
inversion itself is safe only because `Init` runs to completion before
379379
the server starts serving, so nothing else can be holding a distributed lock
380380
while waiting on `c.mu`; do not copy this pattern into anything that runs
381-
while serving. Init also has a *separate*, unfixed hazard on the same lock —
382-
its slow path can re-enter `bootstrap` and deadlock startup
383-
([#201](https://github.com/voxpupuli/openvox-ca/issues/201)); see known gaps.
381+
while serving. Init also *had* a separate hazard on the same lock — its slow
382+
path re-entered `bootstrap` and deadlocked startup
383+
([#201](https://github.com/voxpupuli/openvox-ca/issues/201)). Fixed; the rule
384+
that produced it is not, because `WithLock` is still not reentrant at any
385+
tier. See known gaps.
384386
- `bootstrap``hmac-key` is the first nesting in which a name owned by
385387
`StorageService` sits inside one owned by the CA layer, and the only one taken
386388
entirely within `internal/storage`. It is not the only nesting of two
@@ -764,11 +766,30 @@ state when the document was last updated and is not guaranteed exhaustive.
764766
is reported as RFC 6960 `internalError` rather than `malformedRequest`
765767
the latter tells a verifier not to retry and logs an outage as a client
766768
error.
767-
- [#201](https://github.com/voxpupuli/openvox-ca/issues/201)`CA.Init`'s slow
769+
- ~~[#201](https://github.com/voxpupuli/openvox-ca/issues/201)`CA.Init`'s slow
768770
path can re-enter the `bootstrap` lock (via `finishLoadExisting`
769771
`seedSupportingState`) and deadlock startup, because `WithLock` is not
770772
reentrant and its process-local gate ignores the context. Reachable when a
771-
replica loads a CA bootstrapped elsewhere but then finds the CRL absent.
773+
replica loads a CA bootstrapped elsewhere but then finds the CRL absent.~~
774+
Fixed: the seeding is split into `seedSupportingStateLocked`, which does the
775+
work, and `seedSupportingState`, which acquires `bootstrap` around it.
776+
`finishLoadExisting` takes the variant to use from its caller, so the choice
777+
is made where the lock state is known — the fast path holds nothing and
778+
passes the acquiring one, the slow path is already inside the lock and passes
779+
the other. What is **not** fixed is the property underneath: `WithLock` is
780+
still not reentrant at any tier and its per-name gate is still a plain
781+
`sync.Mutex` that ignores the context, so a second acquisition on one
782+
goroutine still hangs rather than failing at the lock timeout. This closed one
783+
call site, not a mechanism.
784+
[initreentrancy_test.go](../../internal/ca/initreentrancy_test.go) pins both
785+
halves. The slow path is driven against a backend that refuses the first
786+
CA-certificate read, and the spec fails on a bounded wait for `Init` to
787+
return — neither of the two shapes the Tests section below describes, because
788+
a re-entrant acquisition never returns to be counted and waits on nobody else
789+
to be parked behind. The fast path is the before/after acquisition count that
790+
section does describe, and it is there because moving *both* call sites onto
791+
the `...Locked` variant would cure the hang and leave two replicas racing to
792+
seed — which the first spec alone cannot tell from a fix.
772793
- ~~[#202](https://github.com/voxpupuli/openvox-ca/issues/202)`hmac_key`
773794
initialisation (`EnsureHMACKey`, called by `InitHMAC` *before* the
774795
`bootstrap` lock) is an unlocked read-modify-write, so two replicas
@@ -781,9 +802,11 @@ state when the document was last updated and is not guaranteed exhaustive.
781802
`bootstrap` lock, deliberately: `InitHMAC` runs on every start, and the fast
782803
path immediately below it loads an already-bootstrapped CA *without* a
783804
distributed lock. Moving it inside `bootstrap` would make every replica's
784-
every start contend for that lock and would enlarge
785-
[#201](https://github.com/voxpupuli/openvox-ca/issues/201)'s re-entrancy
786-
hazard rather than avoid it. A separate reason, and a separate claim, is why
805+
every start contend for that lock, and would enlarge the `bootstrap` critical
806+
section that
807+
[#201](https://github.com/voxpupuli/openvox-ca/issues/201)'s re-entrancy was
808+
found inside. That gap is fixed above; the contention argument stands on its
809+
own. A separate reason, and a separate claim, is why
787810
the new name is not *called* `bootstrap`: `MigrateService` reaches
788811
`EnsureHMACKey` from inside that lock and `WithLock` is not reentrant, so a
789812
shared name would turn a migration that met a corrupt key into a hang —

internal/ca/init.go

Lines changed: 86 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,11 @@ func (c *CA) Init(ctx context.Context) error {
124124
// point rather than an oversight: InitHMAC runs on every start, and the
125125
// fast path a few lines down deliberately loads an already-bootstrapped CA
126126
// without taking a distributed lock. Moving it inside would make every
127-
// replica's every start contend for `bootstrap`, and would enlarge #201's
128-
// re-entrancy hazard rather than avoid it. Once the key exists EnsureHMACKey
129-
// takes no lock either, so a warm start still costs no lock at all.
127+
// replica's every start contend for `bootstrap`, and would enlarge the
128+
// bootstrap critical section that #201's re-entrancy was found inside. That
129+
// gap is fixed below; the contention argument stands on its own. Once the
130+
// key exists EnsureHMACKey takes no lock either, so a warm start still costs
131+
// no lock at all.
130132
hmacCtx, cancelHMAC := context.WithTimeout(ctx, LockTimeout)
131133
errHMAC := c.Storage.InitHMAC(hmacCtx)
132134
cancelHMAC()
@@ -147,7 +149,7 @@ func (c *CA) Init(ctx context.Context) error {
147149
// distributed lock. Once a CA exists, all replicas can read it.
148150
loadErr := c.loadCA(ctx)
149151
if loadErr == nil {
150-
return c.finishLoadExisting(ctx)
152+
return c.finishLoadExisting(ctx, c.seedSupportingState)
151153
}
152154

153155
// When using an external signer (key isolation mode), the frontend must
@@ -166,7 +168,9 @@ func (c *CA) Init(ctx context.Context) error {
166168
return c.Storage.WithLock(ctx, lockNameBootstrap, func() error {
167169
if err := c.loadCA(ctx); err == nil {
168170
slog.Info("Loaded CA bootstrapped by another replica", "cert", c.Storage.CACertPath())
169-
return c.finishLoadExisting(ctx)
171+
// ...Locked: we are inside the bootstrap critical section, and
172+
// WithLock is not reentrant. See finishLoadExisting's comment.
173+
return c.finishLoadExisting(ctx, c.seedSupportingStateLocked)
170174
}
171175
hasCert, errCert := c.Storage.HasCACert(ctx)
172176
if errCert != nil {
@@ -237,9 +241,24 @@ func (c *CA) Init(ctx context.Context) error {
237241
// cache). c.mu must be held by the caller. When the CRL is absent from the
238242
// backend but the cert+key loaded successfully — the common case when an
239243
// existing CA cert/key is mounted via an overlay against a fresh remote
240-
// backend — seed the CRL, inventory, and serial counter under the bootstrap
241-
// lock so startup can complete.
242-
func (c *CA) finishLoadExisting(ctx context.Context) error {
244+
// backend — the CRL, inventory and serial counter are seeded so startup can
245+
// complete.
246+
//
247+
// That seeding is the only part of this function that takes a distributed
248+
// lock, and only the caller knows whether the bootstrap lock is already held,
249+
// so the caller passes the variant to use. Init's fast path holds nothing and
250+
// passes seedSupportingState, which acquires bootstrap; Init's slow path is
251+
// already inside the bootstrap critical section and passes
252+
// seedSupportingStateLocked, which does the same work without acquiring it
253+
// again. finishLoadExisting itself acquires nothing either way.
254+
//
255+
// Making that the caller's choice is not a stylistic preference. WithLock is
256+
// not reentrant at any tier — every implementation takes a plain per-name
257+
// sync.Mutex before it touches the network or the filesystem, and a plain
258+
// sync.Mutex ignores the context — so a second acquisition on this goroutine
259+
// does not fail at LockTimeout, it hangs startup outright. The slow path used
260+
// to call the acquiring variant from inside the lock, which is #201.
261+
func (c *CA) finishLoadExisting(ctx context.Context, seed func(context.Context) error) error {
243262
slog.Info("Loaded existing CA", "cert", c.Storage.CACertPath())
244263
if err := c.buildSerialIndex(ctx); err != nil {
245264
slog.Warn("Failed to build OCSP serial index", "error", err)
@@ -257,7 +276,7 @@ func (c *CA) finishLoadExisting(ctx context.Context) error {
257276
if c.ExternalSigner != nil {
258277
return fmt.Errorf("failed to load CRL into memory: %w", err)
259278
}
260-
if err := c.seedSupportingState(ctx); err != nil {
279+
if err := seed(ctx); err != nil {
261280
return fmt.Errorf("seeding CA supporting state: %w", err)
262281
}
263282
if err := c.loadCRLCache(ctx); err != nil {
@@ -271,61 +290,70 @@ func (c *CA) finishLoadExisting(ctx context.Context) error {
271290

272291
// seedSupportingState writes the public key, CRL, inventory, and serial counter
273292
// that bootstrapCA would normally create, for the case where the cert+key
274-
// already exist (e.g. mounted via an overlay against an empty backend). Runs
275-
// under the bootstrap lock so concurrent replicas don't race to seed.
276-
//
277-
// The public key is also a backfill: a bootstrap that failed on that blob
278-
// leaves a cert and key that load cleanly forever after, so this is the only
279-
// path that would ever write it again.
293+
// already exist (e.g. mounted via an overlay against an empty backend). It
294+
// takes the bootstrap lock so concurrent replicas don't race to seed, so the
295+
// caller must NOT already hold that lock — one that does wants
296+
// seedSupportingStateLocked below, because WithLock is not reentrant.
280297
func (c *CA) seedSupportingState(ctx context.Context) error {
281298
ctx, cancel := context.WithTimeout(ctx, LockTimeout)
282299
defer cancel()
283300
return c.Storage.WithLock(ctx, lockNameBootstrap, func() error {
284-
// The public key first, and before the CRL short-circuit below, because
285-
// this is the one blob nothing else ever rewrites: bootstrapCA writes it
286-
// before the CRL, so a bootstrap that failed on it leaves a CA whose key
287-
// and certificate load cleanly forever after while ca_pub.pem stays
288-
// missing. Taken from the certificate rather than the key because it is
289-
// the public component every consumer already trusts, and because it is
290-
// available whether the key is local or at a provider. Idempotent, so a
291-
// replica that lost the race simply rewrites identical bytes.
292-
if err := savePubKeyPEM(ctx, c.Storage, c.CACert.PublicKey); err != nil {
293-
return fmt.Errorf("writing CA public key: %w", err)
294-
}
301+
return c.seedSupportingStateLocked(ctx)
302+
})
303+
}
295304

296-
// Another replica may have seeded between our initial check and the
297-
// lock acquisition.
298-
if _, err := c.Storage.GetCRL(ctx); err == nil {
299-
return nil
300-
} else if !errors.Is(err, fs.ErrNotExist) {
301-
return fmt.Errorf("re-checking CRL: %w", err)
302-
}
303-
crl, err := newEmptyCRL(c.CACert, c.CAKey, c.CRLValidityDuration())
304-
if err != nil {
305-
return fmt.Errorf("creating initial CRL: %w", err)
306-
}
307-
crlPEM := pem.EncodeToMemory(&pem.Block{Type: "X509 CRL", Bytes: crl.Raw})
308-
// Bootstrap-only write: runs before any CRL consumer exists, so it
309-
// deliberately skips the crlNotify signal (see signCRLLocked).
310-
if err := c.Storage.UpdateCRL(ctx, crlPEM); err != nil {
311-
return fmt.Errorf("writing initial CRL: %w", err)
312-
}
313-
if err := c.Storage.TouchInventory(ctx); err != nil {
314-
return fmt.Errorf("creating inventory: %w", err)
315-
}
316-
hasSerial, err := c.Storage.HasSerial(ctx)
317-
if err != nil {
318-
return fmt.Errorf("checking serial: %w", err)
319-
}
320-
if !hasSerial {
321-
if err := c.Storage.WriteSerial(ctx, "0001"); err != nil {
322-
return fmt.Errorf("writing serial: %w", err)
323-
}
324-
}
325-
slog.Info("Seeded CA supporting state for existing cert+key",
326-
"cert", c.Storage.CACertPath())
305+
// seedSupportingStateLocked does the seeding itself. The caller must hold the
306+
// bootstrap lock, and c.mu along with it — Init is the only caller of either
307+
// variant, and it holds c.mu across the whole of itself.
308+
//
309+
// The public key is also a backfill: a bootstrap that failed on that blob
310+
// leaves a cert and key that load cleanly forever after, so this is the only
311+
// path that would ever write it again.
312+
func (c *CA) seedSupportingStateLocked(ctx context.Context) error {
313+
// The public key first, and before the CRL short-circuit below, because
314+
// this is the one blob nothing else ever rewrites: bootstrapCA writes it
315+
// before the CRL, so a bootstrap that failed on it leaves a CA whose key
316+
// and certificate load cleanly forever after while ca_pub.pem stays
317+
// missing. Taken from the certificate rather than the key because it is
318+
// the public component every consumer already trusts, and because it is
319+
// available whether the key is local or at a provider. Idempotent, so a
320+
// replica that lost the race simply rewrites identical bytes.
321+
if err := savePubKeyPEM(ctx, c.Storage, c.CACert.PublicKey); err != nil {
322+
return fmt.Errorf("writing CA public key: %w", err)
323+
}
324+
325+
// Another replica may have seeded between our initial check and the
326+
// lock acquisition.
327+
if _, err := c.Storage.GetCRL(ctx); err == nil {
327328
return nil
328-
})
329+
} else if !errors.Is(err, fs.ErrNotExist) {
330+
return fmt.Errorf("re-checking CRL: %w", err)
331+
}
332+
crl, err := newEmptyCRL(c.CACert, c.CAKey, c.CRLValidityDuration())
333+
if err != nil {
334+
return fmt.Errorf("creating initial CRL: %w", err)
335+
}
336+
crlPEM := pem.EncodeToMemory(&pem.Block{Type: "X509 CRL", Bytes: crl.Raw})
337+
// Bootstrap-only write: runs before any CRL consumer exists, so it
338+
// deliberately skips the crlNotify signal (see signCRLLocked).
339+
if err := c.Storage.UpdateCRL(ctx, crlPEM); err != nil {
340+
return fmt.Errorf("writing initial CRL: %w", err)
341+
}
342+
if err := c.Storage.TouchInventory(ctx); err != nil {
343+
return fmt.Errorf("creating inventory: %w", err)
344+
}
345+
hasSerial, err := c.Storage.HasSerial(ctx)
346+
if err != nil {
347+
return fmt.Errorf("checking serial: %w", err)
348+
}
349+
if !hasSerial {
350+
if err := c.Storage.WriteSerial(ctx, "0001"); err != nil {
351+
return fmt.Errorf("writing serial: %w", err)
352+
}
353+
}
354+
slog.Info("Seeded CA supporting state for existing cert+key",
355+
"cert", c.Storage.CACertPath())
356+
return nil
329357
}
330358

331359
// loadCA reads and validates the CA key and certificate from disk.

0 commit comments

Comments
 (0)