-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsecurearena.go
More file actions
832 lines (768 loc) · 36.3 KB
/
Copy pathsecurearena.go
File metadata and controls
832 lines (768 loc) · 36.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
// securearena.go implements SecureArena, a fixed-size slot
// pool backed by a single mmap'd slab.
//
// # Motivation
//
// Each SecureBuffer occupies at least one full OS page (≥4 KiB on amd64) and
// registers individually with the emergency janitor. For O(10) long-lived
// secrets this is correct. Under server-grade concurrency (hundreds of
// short-lived per-session keys), per-buffer page overhead would exhaust
// RLIMIT_MEMLOCK and create O(N) janitor entries.
//
// SecureArena provisions one contiguous mmap'd slab subdivided into fixed-size
// slots. All slots share the same mlock, MADV_DONTDUMP, and janitor
// registration — N session keys incur O(1) overhead at the OS and GC layers.
//
// # Pointer-Free Slot Index
//
// slotMeta contains only scalar fields (no pointer fields, no slice
// headers, no interface values). The GC treats a []slotMeta as a
// "leaf" allocation — it scans the slice header but does NOT trace into the
// backing array. This eliminates per-slot GC scanning entirely.
//
// The index is also the ONLY per-slot heap allocation, at 16 bytes per slot.
// It used to be three — a 64-byte padded slotMeta, an 8-byte free-stack entry,
// and a 16-byte materialized canary zone, 88 bytes total, or nearly twice the
// locked bytes of a 32-byte slot. A type whose whole purpose is to make
// per-secret overhead small should not cost more in swappable GC-visible heap
// than the locked secret it holds. The free list moved into slotMeta.next, the
// canary zones became a descriptor (canaryLayout), and the cache-line padding
// went — see slotMeta for why it protected nothing.
//
// # When to Use SecureArena vs SecureBuffer
//
// - SecureBuffer: long-lived or high-value material (master keys, CA keys,
// signing keys, provider tokens). Isolated page, per-buffer mprotect.
// - SecureArena: many small, same-size, short-lived secrets (SSH session
// keys, ephemeral HMAC keys, per-request nonces). One slab, one mlock.
//
// # Concurrency Model
//
// - mu (bufferRWLock): rLock held during any WithBytes/WithBytesErr callback
// on any slot. Exclusive lock held only by Destroy. Ensures no callback
// races with munmap.
// - alloc (sync.Mutex): serializes slot lifecycle — the free list, the live
// counter, and every WRITE to a slot's generation. Taken by Acquire,
// Release and LiveCount only. The borrow path and IsLive do NOT take it:
// liveness is one atomic load of the parity-encoded generation, which is
// what lets borrows of distinct slots scale instead of serializing. The
// borrow path takes that load under mu.rLock, right before the slice is
// produced — never before the lock, where a wait for a queued writer
// would separate the check from the use.
// Never held across a callback.
//
// Each ArenaSlot should be owned by a single goroutine at a time. Concurrent
// access to the same slot is not prevented by internal locking — callers are
// responsible for external synchronization if needed.
//
// # Concurrent borrow cost, measured
//
// A borrow is now lock-free until the region lock: one atomic load of the
// slot's parity-encoded generation (the liveness check), then bufferRWLock's
// atomic-Add read path (see buflock.go). The first version of this path took
// TWO mutexes — the arena's alloc for the liveness check and the lock's own for
// its reader count — and measurement showed goroutines borrowing DISTINCT slots
// serializing on them: per-op cost grew ~6x from one core to sixteen, with
// aggregate throughput falling as cores were added. Both mutexes are gone from
// the path and the curve now steps once (shared-cache-line transfer on the
// reader count, roughly 4x the uncontended cost on the measured box) and stays
// flat from 2 through 16 cores. Numbers, machine, and history in TESTING.md;
// take your own before acting on them.
//
// What remains shared per borrow is one cache line's worth of atomics — the
// physics of any centralized reader count. If a workload needs true linear
// scaling, shard: separate arenas share no lock, no counter, and no metadata
// lines, and each shard keeps every guarantee. Acquire/Release (slot lifecycle)
// still serialize on the alloc mutex by design — churn is the pattern the
// "allocate once, borrow often" guidance already steers away from.
//
// # Neighbor-Slot Isolation
//
// Because multiple slots share a page, sub-page mprotect is not possible.
// ReadOnly / ReadWrite operate on the full slab — use judiciously. Slot
// indices are bounds-checked on every access to prevent cross-slot writes.
package secmem
import (
"errors"
"fmt"
"log/slog"
"math"
"runtime"
"sync"
"sync/atomic"
)
// slotMeta holds per-slot metadata. Exactly 16 bytes, with no wasted padding.
//
// Pointer-free: only scalars. The GC treats a []slotMeta backing array as a
// leaf — no per-slot GC scanning occurs regardless of how many slots the arena
// contains. This is the arena's O(1)-GC-overhead property and every field here
// is chosen to preserve it.
//
// # Why there is no cache-line padding
//
// This struct used to be padded to 64 bytes to give each slot its own cache
// line, against false sharing "between concurrent slot operations". What
// concurrency there is does not produce the write-write ping-pong that padding
// exists to stop: every WRITE to a slot's metadata happens under arena.alloc
// (Acquire and Release), serialized process-wide, so no two cores ever write
// adjacent entries at once. The borrow path READS generation atomically without
// the lock — that is what makes borrowing scale — and concurrent reads of a
// shared line cost nothing. The residual effect is that a lifecycle write to
// one slot invalidates the line for readers of its three neighbours (four
// 16-byte entries per 64-byte line): one extra cache miss per neighbour
// acquire/release, bounded by the alloc serialization. Padding would erase that
// at 4x the memory — 48 bytes per slot of swappable, GC-visible heap, more than
// a 32-byte secret itself — which is the wrong trade for a type whose purpose
// is small per-secret overhead. If a measured workload ever proves otherwise,
// padding comes back with the measurement attached. Note also that the
// intrusive free list below is the canonical shape for a lock-free Treiber
// stack, whose contention is on the head rather than on these entries.
type slotMeta struct {
// generation is the slot's state word and the ABA guard in one: it counts
// acquisitions AND encodes liveness in its low bit. Even = free, odd =
// live. Acquire increments it even→odd and hands the odd value to the
// ArenaSlot; Release increments it odd→even. A handle is therefore valid
// while the slot's generation still equals the handle's — released (now
// even) and recycled (a different odd) both mismatch — so the borrow
// path's whole liveness check is ONE atomic load and compare, with no
// lock of its own. The borrow path performs it under the arena's region
// lock, immediately before it hands out the slice, so a Release that
// completed before that instant is always observed; what it does not and
// cannot promise is anything about a Release that runs concurrently with
// the callback itself, which the single-owner rule forbids.
//
// atomic.Uint64 rather than a plain uint64 for two reasons. The borrow path
// and IsLive read it outside arena.alloc while Acquire/Release write it
// under alloc, which without atomics is a data race. And on 32-bit
// platforms (the executed GOARCH=386 leg) a 64-bit atomic requires 8-byte
// alignment that a plain uint64 field at the mercy of slice-element layout
// cannot guarantee — atomic.Uint64 carries the align64 marker that makes
// the compiler enforce it.
//
// 64 bits, not 32, because the guard has to outlive the counter. The free
// list is LIFO, so churn on one slot increments the SAME counter twice per
// acquire/release cycle; a 32-bit counter would wrap in minutes on a busy
// server, after which a stale handle matches again and can read, overwrite,
// or free a slot another owner is live in. 64 bits is not reachable.
generation atomic.Uint64
// next is the intrusive free-list link: the index of the next free slot, or
// -1 to terminate. Meaningful only while the slot is free (even
// generation); Acquire sets it to -1 on the way out so a live slot never
// carries a stale link. Only ever accessed under arena.alloc.
//
// int32 because holding the link inside slotMeta is what let the separate
// free []int stack be deleted, and its width is why NewArena rejects
// count > math.MaxInt32 explicitly rather than overflowing quietly.
// The struct is 12 bytes of fields in a 16-byte layout — the 4 trailing pad
// bytes are the floor set by generation's 8-byte alignment, still at the
// 16-byte pin and still under the 17 locked bytes of the smallest slot.
next int32
}
// SecureArena is a single mmap'd slab providing N fixed-size secret slots.
//
// Create with [NewArena]. Acquire slots with [SecureArena.Acquire]. Release
// individual slots with [ArenaSlot.Release]. Wipe and free the entire slab
// with [SecureArena.Destroy].
//
// Destroy is idempotent and goroutine-safe. After Destroy, all subsequent
// Acquire calls return [ErrArenaDestroyed].
type SecureArena struct {
// arenaRedactor is embedded (not a value receiver on SecureArena itself —
// alloc below is a value sync.Mutex that go vet's copylocks would flag on
// any value-receiver method declared directly on SecureArena) so its
// String/GoString/Format/LogValue methods promote into both SecureArena's
// and *SecureArena's method sets. See redact.go.
arenaRedactor
// mu: rLock is held by all WithBytes/WithBytesErr callbacks; exclusive lock
// is held only by Destroy. Uses bufferRWLock (not sync.RWMutex) so all
// blocking states are durably blocked under testing/synctest.
mu *bufferRWLock
// alloc serializes slot lifecycle: the free list, the live counter, and
// every write to a slot's generation. Never held across a callback or
// across mu.lock.
alloc sync.Mutex
// region is the guarded slab: inner (wipe/lock/protect target, canary
// strips included) bracketed by PROT_NONE guard pages inside outer (the
// unmap target). See secRegion for the field contract. Zeroed after
// Destroy.
region secRegion
// readOnly is true while ReadOnly() has set the slab to PROT_READ.
// ArenaSlot.Release checks it and returns ErrReadOnly rather than faulting
// on the slot wipe (a write to the PROT_READ slab). Destroy is unaffected —
// its wipe path forces the slab writable first. Protected by mu.
readOnly bool
// slots is the metadata index, and — through slotMeta.next — the free list
// itself. Pointer-free leaf: the GC scans the slice header but NOT the
// backing array. len(slots) == count. This is the ONLY heap allocation in
// the arena that scales with count; see NewArena on why that matters.
slots []slotMeta
// freeHead is the head of the intrusive LIFO free list threaded through
// slots[i].next, or -1 when the arena is full. It is what makes Acquire and
// Release O(1) rather than a scan over slots. Guarded by alloc.
//
// Invariant: a slot is reachable from freeHead at most once, and exactly
// when its generation is EVEN (see slotMeta.generation). Release enforces
// it by re-checking the generation under alloc before pushing, so a double
// Release of the same handle cannot hand the same slot to two live owners.
//
// That re-check is load-bearing in a way it was not when the free list was
// a slice: pushing an index twice onto a slice produced a duplicate entry,
// which is bad; pushing a node twice onto a linked list produces a CYCLE,
// after which Acquire hands out the same slot forever and never reports
// ErrArenaFull. Same guard, strictly higher stakes — do not weaken it.
//
// Seeded so slots[i].next == i+1 and the last is -1, which makes the first
// Acquires pop 0, 1, 2, … — the natural order a fresh arena used to hand
// out, preserved because it is the observable part. After any Release the
// order is most-recently-freed-first, which is deliberate: a just-released
// slot is the one most likely to still be cache-warm.
//
// int32 to match slotMeta.next, so the whole free list is one width and no
// narrowing conversion exists anywhere to get wrong. NewArena's count
// ceiling is what makes that width sufficient.
freeHead int32
// live is the number of acquired slots, cached so LiveCount is O(1) instead
// of walking the free list. Guarded by alloc, and updated in the same
// critical section as every freeHead change so the two cannot drift.
live int
// slotSize is the usable bytes per slot (caller-requested).
slotSize int
// stride is slotSize + canaryLen: each slot is followed by a canary strip
// so an overflow out of slot i corrupts the strip instead of silently
// running into slot i+1's secret. Slot i's data is
// inner[i*stride : i*stride+slotSize]; its strip fills the rest of the
// stride. Guard PAGES between slots are deliberately absent — a page per
// gap would defeat the slab's O(1)-OS-overhead purpose; the slab's two
// outer edges are guarded by the allocation itself.
stride int
// count is len(slots) — cached to avoid a len() on the hot path.
count int
// backing records which protections the slab allocation actually received.
// Immutable after construction; read by Capabilities without any lock.
backing allocInfo
// destroyed is set by Destroy BEFORE it takes the exclusive lock, so
// Acquire and the borrow path can fail fast with ErrArenaDestroyed instead
// of queueing behind a Destroy that is draining callbacks. Atomic because
// the borrow path reads it without any lock; the authoritative gate is
// still region.inner == nil under mu.
destroyed atomic.Bool
// wiped is set by WipeAllSecrets when the slab was wiped in place and
// deliberately left mapped. Shared with janitorRegion — the emergency path
// holds no *SecureArena, so this flag is how it reaches one. Acquire then
// refuses with ErrWiped: handing out a slot would put a fresh secret in a
// slab the emergency wipe already reported as handled. Existing slots stay
// readable (they hold zeros), preserving the no-fault guarantee.
wiped *atomic.Bool
// cleanup is the AddCleanup handle. Stopped by Destroy.
cleanup runtime.Cleanup
// janitorKey identifies this arena's raw slab in emergencyJanitor.
janitorKey uint64
}
// ArenaSlot is a handle to one fixed-size slot in a [SecureArena].
//
// Access secret data via [ArenaSlot.WithBytes] or [ArenaSlot.WithBytesErr].
// Return the slot to the pool with [ArenaSlot.Release].
//
// A slot should be owned by a single goroutine at a time; concurrent access
// to the same slot from multiple goroutines is not internally synchronized.
type ArenaSlot struct {
arena *SecureArena
// idx is int32 for the same reason slotMeta.next is: it is an index into
// slots, whose length NewArena caps at math.MaxInt32, and keeping one width
// across the whole free list means there is no narrowing conversion in the
// hot path to get wrong (or to have to explain to gosec).
idx int32
generation uint64 // matches slots[idx].generation at Acquire time — ABA guard
}
// NewArena creates a SecureArena with count fixed-size slots, each of
// slotSize bytes.
//
// The underlying slab is one contiguous guarded mmap region: PROT_NONE guard
// pages bracket the slab's two outer edges, and each slot is followed by a
// canaryLen-byte canary strip, verified on [ArenaSlot.Release] and on
// [SecureArena.Destroy]. There are deliberately NO guard pages between slots
// (a page per gap would defeat the slab's O(1)-OS-overhead purpose); the
// strips detect inter-slot overflows instead of trapping them.
// A single emergency janitor registration covers all slots.
//
// slotSize and count must both be > 0, and count must be <= math.MaxInt32.
//
// Common errors: EPERM / ENOMEM from mlock (RLIMIT_MEMLOCK exceeded). On
// platforms with no lockable off-heap memory the error is [ErrNoSecureMemory]
// unless [WithInsecureFallback] is passed.
//
// # Memory budget
//
// An arena costs (slotSize+16) bytes of LOCKED memory per slot, plus 16 bytes
// per slot of ordinary Go heap for the slot index — so roughly 1.3x the locked
// slab for a 32-byte slot, and proportionally less as slots get larger. Budget
// for both: the locked half draws on RLIMIT_MEMLOCK (see [EnsureMemlockLimit]),
// the heap half does not and is swappable and GC-visible like any other slice.
//
// The order of the two allocations is deliberate and load-bearing. The locked
// slab is requested FIRST and fails by returning an error; the heap index that
// follows can only fail by runtime.throw, which is not recoverable — no
// deferred function runs, [WipeAllSecrets] never gets called, the process is
// simply gone. Requesting the larger, recoverable allocation first means that
// on any budget too small for the arena you get an error from mlock rather than
// a dead process. The per-slot heap cost is deliberately kept below the
// per-slot locked cost (16 < slotSize+16 for every legal slotSize) so this
// ordering holds for every shape of arena, not just large-slot ones.
//
// The guard is not absolute, and it is worth knowing exactly how far it goes.
// A count can still be fatal if the slab fits and the index does not, which for
// usable memory M is the band
//
// M/(locked+heap) < count <= M/locked
//
// whose width does not depend on M at all: it is 1 + heap/locked. Because heap
// (16) is held below locked (slotSize+16, so at least 17), that ratio is always
// under 2 — the band of counts that can kill the process is now narrower than a
// factor of two, for every arena shape. It was up to 6.2x wide when the index
// cost 88 bytes per slot. That bound, not the byte count, is what
// TestArena_HeapMetadataStaysUnderLockedSlab is really pinning.
//
// Reaching the band at all takes an unbounded locked budget: on a default
// RLIMIT_MEMLOCK the slab is refused long before memory is at risk. If you raise
// the limit to unlimited — which many container configurations do by default —
// and size arenas from something you do not control, bound count yourself. See
// [EnsureMemlockLimit].
func NewArena(slotSize, count int, opts ...Option) (*SecureArena, error) {
if slotSize <= 0 {
return nil, fmt.Errorf("secmem.NewArena: slotSize must be > 0, got %d", slotSize)
}
if count <= 0 {
return nil, fmt.Errorf("secmem.NewArena: count must be > 0, got %d", count)
}
if count > math.MaxInt32 {
// slotMeta.next is an int32 index into slots (see the field comment), so
// a larger count would wrap the free-list links silently. Refuse it here
// rather than corrupt the list. The slab for such an arena is at least
// 36 GiB and would be refused below anyway; this exists so the failure
// is an error with a reason instead of a subtle miscount.
return nil, fmt.Errorf("secmem.NewArena: count must be <= %d, got %d", math.MaxInt32, count)
}
stride := slotSize + canaryLen
if slotSize > math.MaxInt/count-canaryLen {
return nil, fmt.Errorf("secmem.NewArena: (slotSize+canary)*count overflows int (slotSize=%d, count=%d)", slotSize, count)
}
if err := gateInsecure(platformHasSecureMemory, applyOptions(opts)); err != nil {
return nil, fmt.Errorf("secmem.NewArena: %w", err)
}
// ORDER IS LOAD-BEARING — do not hoist the cheap allocations above this one.
//
// The locked slab is the only allocation here that fails by RETURNING AN
// ERROR. Everything after it is Go heap, where an allocation the OS refuses
// is runtime.throw: no recover, no deferred wipe, no WipeAllSecrets, process
// gone. So the biggest request has to be the recoverable one, and it has to
// go first — on any budget that cannot hold this arena, mlock/VirtualLock
// refuses here and NewArena returns cleanly.
//
// See the "Memory budget" section of the doc comment for how much heap
// follows and why it is now smaller than the slab per slot.
totalBytes := stride * count
region, _, info, err := allocSecretMem(totalBytes)
if err != nil {
return nil, fmt.Errorf("secmem.NewArena: %w", err)
}
// Arm the canary strips (one after each slot) and the page-rounding tail.
// The SAME descriptor is handed to the janitor below, so the ranges armed
// here and the ranges verified before the wipe cannot drift apart.
canary := arenaCanary(stride, slotSize, count, len(region.inner))
if err := canary.arm(region.inner); err != nil {
_ = freeSecretMem(region) // nothing secret written yet
return nil, fmt.Errorf("secmem.NewArena: %w", err)
}
// The one heap allocation that scales with count. Seed the intrusive free
// list in ascending order so the first Acquires hand out 0, 1, 2, …
slots := make([]slotMeta, count)
for i := range slots {
slots[i].next = int32(i + 1)
}
slots[count-1].next = -1
a := &SecureArena{
mu: newBufferRWLock(),
wiped: new(atomic.Bool),
region: region,
slots: slots,
freeHead: 0,
slotSize: slotSize,
stride: stride,
count: count,
backing: info,
}
// Register the slab with emergency janitor using raw metadata only.
// Arenas have no Seal, hence no seal-cipher state. A refused registration
// (identity collision — see nextJanitorKey) leaves the slab unregistered
// and therefore unreachable by every wipe path; it is released here, and
// the caller gets the error instead of an arena nothing can clean up.
key, err := emergencyJanitor.register(region, canary, a.mu, nil, a.wiped)
if err != nil {
secureWipeSlice(region.inner)
_ = freeSecretMem(region)
return nil, fmt.Errorf("secmem.NewArena: %w", err)
}
a.janitorKey = key
// Safety-net cleanup: wipe and free the slab if Destroy was forgotten.
// Only the slab size is captured (not a reference to a) so that the
// cleanup closure cannot keep a alive and prevent it from becoming
// unreachable.
slabBytes := len(region.inner)
a.cleanup = runtime.AddCleanup(a, func(key uint64) {
slog.Warn("secmem: SecureArena finalized without explicit Destroy()",
slog.Int("slab_bytes", slabBytes),
slog.String("advice", "call Destroy() explicitly for deterministic wipe"),
)
if err := emergencyJanitor.release(key, false); err != nil {
slog.Error("secmem: SecureArena cleanup release failed",
slog.Any("error", err),
)
}
}, a.janitorKey)
return a, nil
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
// Destroy wipes the entire slab and releases the mmap'd region.
//
// Steps:
// 1. Mark arena destroyed (atomic; new Acquire and borrows fail fast).
// 2. Acquire exclusive mu lock (waits for all in-flight callbacks to return).
// 3. Wipe full raw region (REP STOSB + CLFLUSH on amd64).
// 4. Madvise DONTNEED_LOCKED.
// 5. Unmap (Linux munlocks first; Darwin deliberately does not — see
// mlock_darwin.go).
// 6. Nil raw — makes IsDestroyed() = true and Destroy idempotent.
//
// Destroy is idempotent and goroutine-safe. A second concurrent Destroy blocks
// on the exclusive lock rather than returning the moment it sees the destroyed
// flag: "Destroy returned" has to mean "the slab is wiped" for every caller,
// not only for the one that won the flag, or a concurrent caller could act on
// "the secret is gone" while the first call is still mid-wipe. This matches
// SecureBuffer.Destroy, which takes its exclusive lock before testing state.
func (a *SecureArena) Destroy() error {
if a == nil {
return nil
}
// Mark destroyed so new Acquire and WithBytes calls fail fast without
// blocking behind the exclusive lock this Destroy is about to take.
a.destroyed.Store(true)
// Acquire exclusive lock — waits for all in-flight WithBytes callbacks,
// and for any Destroy already running.
a.mu.lock()
defer a.mu.unlock()
if a.region.inner == nil {
return nil // already destroyed — idempotent
}
a.cleanup.Stop()
// Take exclusive ownership from janitor registry and wipe/free exactly once.
// If the cleanup or emergency-wipe path already released it, do not touch raw.
err := emergencyJanitor.release(a.janitorKey, true)
a.region = secRegion{}
runtime.KeepAlive(a)
if err != nil {
return fmt.Errorf("secmem.SecureArena.Destroy: %w", err)
}
return nil
}
// IsDestroyed reports whether the arena has been destroyed.
func (a *SecureArena) IsDestroyed() bool {
if a == nil {
return true
}
return a.destroyed.Load()
}
// ---------------------------------------------------------------------------
// Slot management
// ---------------------------------------------------------------------------
// Acquire returns the next free slot for exclusive use by the caller.
//
// Returns [ErrArenaFull] if all slots are occupied.
// Returns [ErrArenaDestroyed] if the arena has been destroyed.
func (a *SecureArena) Acquire() (*ArenaSlot, error) {
if a == nil {
return nil, ErrArenaDestroyed
}
a.alloc.Lock()
defer a.alloc.Unlock()
if a.destroyed.Load() {
return nil, ErrArenaDestroyed
}
if a.wiped.Load() {
return nil, ErrWiped
}
i := a.freeHead
if i < 0 {
return nil, ErrArenaFull
}
a.freeHead = a.slots[i].next
// Clear the link on the way out: a live slot must not carry a stale index.
// It costs one store and makes "reachable from freeHead" and "generation is
// even" checkable against each other rather than merely believed.
a.slots[i].next = -1
// even -> odd: the slot is live, and the odd value IS the handle's proof of
// ownership. See slotMeta.generation for the parity contract.
gen := a.slots[i].generation.Add(1)
a.live++
return &ArenaSlot{arena: a, idx: i, generation: gen}, nil
}
// LiveCount returns the number of currently acquired (live) slots.
func (a *SecureArena) LiveCount() int {
if a == nil {
return 0
}
a.alloc.Lock()
defer a.alloc.Unlock()
return a.live
}
// Cap returns the total slot capacity of the arena.
func (a *SecureArena) Cap() int {
if a == nil {
return 0
}
return a.count
}
// SlotSize returns the usable bytes per slot.
func (a *SecureArena) SlotSize() int {
if a == nil {
return 0
}
return a.slotSize
}
// ReadOnly sets the entire slab to read-only (PROT_READ).
// Affects ALL slots — sub-page mprotect is not possible.
//
// Call ReadWrite before releasing a slot: [ArenaSlot.Release] wipes the slot,
// which is a write, so it returns [ErrReadOnly] while the slab is read-only
// rather than faulting. [SecureArena.Destroy] needs no such call — its wipe
// path makes the slab writable first.
//
// The exclusive lock is held to drain all in-flight WithBytes callbacks
// before the mprotect, preventing a SIGSEGV from a concurrent write hitting
// a PROT_READ page.
func (a *SecureArena) ReadOnly() error {
if a == nil {
return errors.New("secmem.SecureArena.ReadOnly: nil receiver")
}
a.mu.lock()
defer a.mu.unlock()
if a.region.inner == nil {
return fmt.Errorf("secmem.SecureArena.ReadOnly: %w", ErrArenaDestroyed)
}
if err := mprotectSecretMem(a.region, 1 /*PROT_READ*/); err != nil {
return fmt.Errorf("secmem.SecureArena.ReadOnly: %w", err)
}
a.readOnly = true
return nil
}
// ReadWrite restores read-write access to the entire slab.
//
// The exclusive lock is held to drain all in-flight callbacks before the
// mprotect (arena SB-3 equivalent fix).
func (a *SecureArena) ReadWrite() error {
if a == nil {
return errors.New("secmem.SecureArena.ReadWrite: nil receiver")
}
a.mu.lock()
defer a.mu.unlock()
if a.region.inner == nil {
return fmt.Errorf("secmem.SecureArena.ReadWrite: %w", ErrArenaDestroyed)
}
if err := mprotectSecretMem(a.region, 3 /*PROT_READ|PROT_WRITE*/); err != nil {
return fmt.Errorf("secmem.SecureArena.ReadWrite: %w", err)
}
a.readOnly = false
return nil
}
// ---------------------------------------------------------------------------
// ArenaSlot — access API
// ---------------------------------------------------------------------------
// WithBytes calls fn with the slot's byte region.
//
// The slice is valid ONLY for the duration of fn. Never store or pass it to
// a goroutine. Returns [ErrSlotReleased] if the slot has been released.
// Returns [ErrArenaDestroyed] if the arena has been destroyed.
func (s *ArenaSlot) WithBytes(fn func([]byte)) error {
if fn == nil {
return errors.New("secmem.ArenaSlot.WithBytes: nil fn")
}
return s.WithBytesErr(func(b []byte) error {
fn(b)
return nil
})
}
// WithBytesErr is like [ArenaSlot.WithBytes] but fn may return an error.
func (s *ArenaSlot) WithBytesErr(fn func([]byte) error) error {
if fn == nil {
return errors.New("secmem.ArenaSlot.WithBytesErr: nil fn")
}
if s == nil {
return ErrSlotReleased
}
defer clearRegisters() // see SecureBuffer.WithBytes
// Fail fast on a destroyed arena rather than queue behind the Destroy
// that is draining callbacks. Advisory: the authoritative gate is the
// region-nil test under rLock below, and always was.
if s.arena.destroyed.Load() {
return ErrArenaDestroyed
}
// Hold arena RLock for the callback — blocks Destroy from unmapping.
s.arena.mu.rLock()
defer s.arena.mu.rUnlock()
if s.arena.region.inner == nil {
return ErrArenaDestroyed
}
// Liveness check — one atomic load, no further lock, and UNDER the region
// lock, immediately before the slice is produced. It used to run before
// the rLock, which made it check-then-use: a borrower that passed it and
// then waited for the lock (any queued writer — ReadOnly, ReadWrite, the
// emergency wipe — parks new readers) resumed holding a slice into a slot
// that had meanwhile been Released, wiped, re-Acquired and written by a
// new owner, and read or overwrote that owner's secret with no error.
// Checking here leaves nothing between the check and the use but the call
// itself: a Release that retired this handle before this instant is
// always observed. A Release that runs concurrently with the callback is
// the single-owner contract's to prevent (see "Concurrency Model"); the
// wipe it performs races the callback's reads regardless of any check.
//
// Still no mutex: this used to take arena.alloc, which made every borrow
// in the arena serialize on one lock even when the goroutines shared no
// slot; measured, that was the residual wall after the rw-lock fast path
// landed (see TESTING.md). The parity-encoded generation answers both
// "released?" (now even, mismatch) and "recycled?" (different odd,
// mismatch) in one load.
if s.arena.slots[s.idx].generation.Load() != s.generation {
return ErrSlotReleased
}
// Capacity-clamped to the slot's usable bytes: fn cannot re-slice its
// argument into the canary strip or the neighbouring slot.
start := int(s.idx) * s.arena.stride
end := start + s.arena.slotSize
return fn(s.arena.region.inner[start:end:end])
}
// Release wipes the slot's byte region and returns it to the arena pool.
//
// After Release, all subsequent WithBytes/WithBytesErr calls return
// [ErrSlotReleased]. Calling Release again is a no-op (idempotent).
//
// The wipe happens BEFORE the slot is marked free (SA-1 fix): this ensures
// the next Acquire cannot read stale secret data from this slot.
//
// Release also verifies the slot's trailing canary strip. If code overflowed
// this slot, Release returns [ErrCanaryViolation] — the wipe, the re-arming
// of the strip, and the return of the slot to the pool all complete
// regardless; the error is a bug report, not a refusal.
//
// After [WipeAllSecrets] the arena is dead but a slot acquired before the
// wipe is still held. Release is teardown, not reuse, so it is not refused
// with [ErrWiped]: it wipes the slot, returns it to the pool (which
// [SecureArena.Acquire] refuses to draw from anyway) and returns nil. The
// canary strip is not verified — the wipe zeroed it along with the secrets,
// so there is no pattern left to check.
//
// If the arena is read-only ([SecureArena.ReadOnly]), Release returns
// [ErrReadOnly] without wiping — the wipe is a write the PROT_READ slab would
// fault on. The slot stays in use; call [SecureArena.ReadWrite] first, or let
// [SecureArena.Destroy] wipe it (it makes the slab writable internally).
func (s *ArenaSlot) Release() error {
if s == nil {
return nil
}
// Early idempotent check — no-op if already free or stale handle. One
// atomic load: a released slot's generation is even (mismatch) and a
// recycled one's is a different odd (mismatch).
if s.arena.slots[s.idx].generation.Load() != s.generation {
return nil
}
// Verify + wipe FIRST — under rLock to prevent Destroy from unmapping
// mid-wipe. The slot's generation is still the handle's odd value, so it is
// not on the free list and no other goroutine can Acquire the same index
// until the commit below flips it even.
var violated bool
s.arena.mu.rLock()
if s.arena.region.inner != nil {
if s.arena.readOnly {
// The slab is PROT_READ; the canary re-arm and slot wipe below are
// writes that would fault the process. Refuse cleanly instead. The
// slot stays in use and un-wiped — still protected by the read-only
// page, and wiped when the caller ReadWrite()s and releases again,
// or on Destroy (which forces the slab writable). This honors the
// "ReadWrite before a slot write" contract on ReadOnly. Checked
// inside the live-region guard so a Release after Destroy (region
// already nil) stays an idempotent no-op, never ErrReadOnly.
s.arena.mu.rUnlock()
return fmt.Errorf("secmem.ArenaSlot.Release: %w", ErrReadOnly)
}
start := int(s.idx) * s.arena.stride
end := start + s.arena.slotSize
// After an emergency wipe the strip holds zeros, not the pattern:
// WipeAllSecrets zeroed the whole slab, strips included, and the
// janitor cleared its own layout for the same reason (retainWiped).
// Verifying here would report an overflow that never happened on
// every slot released after the wipe. The flag is set under the
// exclusive lock, after the wipe, and read here under rLock, so it
// cannot be observed mid-wipe. Only the check is skipped: the slot
// wipe below still runs, because a write through this pre-wipe
// handle is a live secret until something zeroes it.
if !s.arena.wiped.Load() {
strip := s.arena.region.inner[end : start+s.arena.stride]
if !canaryIntact(strip) {
violated = true
// Re-arm the strip so a later overflow of the recycled slot
// is still detectable. fillCanary cannot fail here: the
// pattern was already initialized when the arena armed it at
// construction.
_ = fillCanary(strip)
}
}
secureWipeSlice(s.arena.region.inner[start:end])
}
// Arena was destroyed concurrently — Destroy already wiped everything.
s.arena.mu.rUnlock()
// NOW mark free — slot is only available for re-Acquire after wipe completes.
//
// The generation is re-checked under alloc before the slot goes back on the
// free list. The early check above is not enough: two goroutines calling
// Release on the SAME handle can both pass it, and pushing twice would put
// one slot on the list twice — handing the same secret bytes to two live
// owners. On the intrusive list it is worse still: slots[i].next would point
// at i, and every future Acquire would hand out that one slot forever. The
// first committer's odd->even increment is what makes the second's re-check
// fail; this re-check is the only thing preventing the cycle.
s.arena.alloc.Lock()
if s.arena.slots[s.idx].generation.Load() == s.generation {
s.arena.slots[s.idx].generation.Add(1) // odd -> even: handle dead from here
s.arena.live--
s.arena.slots[s.idx].next = s.arena.freeHead
s.arena.freeHead = s.idx
}
s.arena.alloc.Unlock()
if violated {
return fmt.Errorf("secmem.ArenaSlot.Release: %w", ErrCanaryViolation)
}
return nil
}
// Index returns the slot's zero-based index within the arena.
func (s *ArenaSlot) Index() int {
if s == nil {
return -1
}
return int(s.idx)
}
// IsLive reports whether THIS HANDLE is still usable — its slot is acquired and
// has not been released and handed out again since.
//
// The generation carries the whole answer: an earlier version tested an in-use
// flag alone, which answers a different question — "is this index in use by
// anybody" — so a stale handle whose slot had since been re-acquired by another
// caller reported live, and was then refused by [ArenaSlot.WithBytes] with
// [ErrSlotReleased] on the very next line. The parity-encoded generation makes
// the correct answer one atomic load: released flips it even, recycling makes
// it a different odd, and either way it no longer equals the handle's.
func (s *ArenaSlot) IsLive() bool {
if s == nil {
return false
}
return s.arena.slots[s.idx].generation.Load() == s.generation
}