feat: add SecretBuffer and runtimesecret wrapper
- commit
- afc6dacbb01009776a3daca8089d122aec7c48ff
- author
- brice · · 2026-10-01 18:20 UTC
- parent
- e904307
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +166 -0
M go.mod +2 -0
| @@ -4,6 +4,8 @@ go 1.27 | |||
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/bnema/go-wayland-bindings v0.1.0 | |
| 7 | + | github.com/bnema/purego v0.13.0-bnema.1 // indirect | |
| 8 | + | github.com/bnema/purego-xkbcommon v0.1.0 | |
| 7 | 9 | github.com/bnema/wlturbo v0.6.0 | |
| 8 | 10 | github.com/stretchr/testify v1.12.1 | |
| 9 | 11 | golang.org/x/sys v0.48.0 | |
| @@ -4,6 +4,8 @@ go 1.27 | |||||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | github.com/bnema/go-wayland-bindings v0.1.0 | 6 | github.com/bnema/go-wayland-bindings v0.1.0 | ||
| 7 | + | github.com/bnema/purego v0.13.0-bnema.1 // indirect | |||
| 8 | + | github.com/bnema/purego-xkbcommon v0.1.0 | |||
| 7 | github.com/bnema/wlturbo v0.6.0 | 9 | github.com/bnema/wlturbo v0.6.0 | ||
| 8 | github.com/stretchr/testify v1.12.1 | 10 | github.com/stretchr/testify v1.12.1 | ||
| 9 | golang.org/x/sys v0.48.0 | 11 | golang.org/x/sys v0.48.0 | ||
A internal/secret/do_off.go +10 -0
| @@ -0,0 +1,10 @@ | |||
| 1 | + | //go:build !goexperiment.runtimesecret | |
| 2 | + | ||
| 3 | + | // Package secret runs key translation that touches secret text under | |
| 4 | + | // runtime/secret when the toolchain provides it (GOEXPERIMENT=runtimesecret), | |
| 5 | + | // and plainly otherwise. | |
| 6 | + | package secret | |
| 7 | + | ||
| 8 | + | // Do runs f. Without GOEXPERIMENT=runtimesecret there is nothing to erase | |
| 9 | + | // beyond what the caller wipes itself. | |
| 10 | + | func Do(f func()) { f() } | |
| @@ -0,0 +1,10 @@ | |||||
| 1 | + | //go:build !goexperiment.runtimesecret | |||
| 2 | + | ||||
| 3 | + | // Package secret runs key translation that touches secret text under | |||
| 4 | + | // runtime/secret when the toolchain provides it (GOEXPERIMENT=runtimesecret), | |||
| 5 | + | // and plainly otherwise. | |||
| 6 | + | package secret | |||
| 7 | + | ||||
| 8 | + | // Do runs f. Without GOEXPERIMENT=runtimesecret there is nothing to erase | |||
| 9 | + | // beyond what the caller wipes itself. | |||
| 10 | + | func Do(f func()) { f() } | |||
A internal/secret/do_on.go +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | //go:build goexperiment.runtimesecret | |
| 2 | + | ||
| 3 | + | // Package secret runs key translation that touches secret text under | |
| 4 | + | // runtime/secret when the toolchain provides it (GOEXPERIMENT=runtimesecret), | |
| 5 | + | // and plainly otherwise. | |
| 6 | + | package secret | |
| 7 | + | ||
| 8 | + | import "runtime/secret" | |
| 9 | + | ||
| 10 | + | // Do runs f with its registers, stack and heap temporaries erased afterwards. | |
| 11 | + | func Do(f func()) { secret.Do(f) } | |
| @@ -0,0 +1,11 @@ | |||||
| 1 | + | //go:build goexperiment.runtimesecret | |||
| 2 | + | ||||
| 3 | + | // Package secret runs key translation that touches secret text under | |||
| 4 | + | // runtime/secret when the toolchain provides it (GOEXPERIMENT=runtimesecret), | |||
| 5 | + | // and plainly otherwise. | |||
| 6 | + | package secret | |||
| 7 | + | ||||
| 8 | + | import "runtime/secret" | |||
| 9 | + | ||||
| 10 | + | // Do runs f with its registers, stack and heap temporaries erased afterwards. | |||
| 11 | + | func Do(f func()) { secret.Do(f) } | |||
A secret.go +92 -0
| @@ -0,0 +1,92 @@ | |||
| 1 | + | package neferclient | |
| 2 | + | ||
| 3 | + | import "unicode/utf8" | |
| 4 | + | ||
| 5 | + | // maxSecretCapacity bounds [NewSecretBuffer]. | |
| 6 | + | const maxSecretCapacity = 4096 | |
| 7 | + | ||
| 8 | + | // SecretBuffer collects text typed while it is installed with | |
| 9 | + | // [Seat.SetSecret], for example a password. Its storage is allocated once at | |
| 10 | + | // full size, so it never reallocates and leaves no stale copies behind; the | |
| 11 | + | // text is never turned into a string by the library. | |
| 12 | + | // | |
| 13 | + | // Only the owner goroutine (the one calling [Conn.Dispatch]) may use it. | |
| 14 | + | type SecretBuffer struct { | |
| 15 | + | buf []byte // capacity*utf8.UTFMax bytes, fixed | |
| 16 | + | n int // bytes in use | |
| 17 | + | points int // code points in use | |
| 18 | + | max int // capacity in code points | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // NewSecretBuffer returns a buffer holding up to capacity code points. | |
| 22 | + | // capacity is clamped to 1..4096. | |
| 23 | + | func NewSecretBuffer(capacity int) *SecretBuffer { | |
| 24 | + | capacity = min(max(capacity, 1), maxSecretCapacity) | |
| 25 | + | return &SecretBuffer{buf: make([]byte, capacity*utf8.UTFMax), max: capacity} | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Len returns the number of code points held. | |
| 29 | + | func (b *SecretBuffer) Len() int { | |
| 30 | + | if b == nil { | |
| 31 | + | return 0 | |
| 32 | + | } | |
| 33 | + | return b.points | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // Bytes returns the UTF-8 text held. The slice aliases the buffer: do not | |
| 37 | + | // retain it, convert it to a string or log it; it changes on the next edit. | |
| 38 | + | func (b *SecretBuffer) Bytes() []byte { | |
| 39 | + | if b == nil { | |
| 40 | + | return nil | |
| 41 | + | } | |
| 42 | + | return b.buf[:b.n:b.n] | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Wipe zeroes the whole storage and empties the buffer. | |
| 46 | + | func (b *SecretBuffer) Wipe() { | |
| 47 | + | if b == nil { | |
| 48 | + | return | |
| 49 | + | } | |
| 50 | + | clear(b.buf) | |
| 51 | + | b.n, b.points = 0, 0 | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // appendText adds text, which must be valid UTF-8. It reports false and | |
| 55 | + | // changes nothing when the text does not fit. | |
| 56 | + | func (b *SecretBuffer) appendText(text []byte) bool { | |
| 57 | + | cp := utf8.RuneCount(text) | |
| 58 | + | if cp == 0 || b.points+cp > b.max { | |
| 59 | + | return false | |
| 60 | + | } | |
| 61 | + | b.n += copy(b.buf[b.n:], text) | |
| 62 | + | b.points += cp | |
| 63 | + | return true | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // backspace removes the last code point and wipes its bytes. | |
| 67 | + | func (b *SecretBuffer) backspace() bool { | |
| 68 | + | if b.points == 0 { | |
| 69 | + | return false | |
| 70 | + | } | |
| 71 | + | _, size := utf8.DecodeLastRune(b.buf[:b.n]) | |
| 72 | + | clear(b.buf[b.n-size : b.n]) | |
| 73 | + | b.n -= size | |
| 74 | + | b.points-- | |
| 75 | + | return true | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // classifySecretText reports whether text is printable input for a secret: | |
| 79 | + | // non-empty, valid UTF-8 and free of C0 controls and DEL. | |
| 80 | + | func classifySecretText(text []byte) bool { | |
| 81 | + | if len(text) == 0 || !utf8.Valid(text) { | |
| 82 | + | return false | |
| 83 | + | } | |
| 84 | + | for rest := text; len(rest) > 0; { | |
| 85 | + | r, size := utf8.DecodeRune(rest) | |
| 86 | + | if r < 0x20 || r == 0x7f { | |
| 87 | + | return false | |
| 88 | + | } | |
| 89 | + | rest = rest[size:] | |
| 90 | + | } | |
| 91 | + | return true | |
| 92 | + | } | |
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | package neferclient | |||
| 2 | + | ||||
| 3 | + | import "unicode/utf8" | |||
| 4 | + | ||||
| 5 | + | // maxSecretCapacity bounds [NewSecretBuffer]. | |||
| 6 | + | const maxSecretCapacity = 4096 | |||
| 7 | + | ||||
| 8 | + | // SecretBuffer collects text typed while it is installed with | |||
| 9 | + | // [Seat.SetSecret], for example a password. Its storage is allocated once at | |||
| 10 | + | // full size, so it never reallocates and leaves no stale copies behind; the | |||
| 11 | + | // text is never turned into a string by the library. | |||
| 12 | + | // | |||
| 13 | + | // Only the owner goroutine (the one calling [Conn.Dispatch]) may use it. | |||
| 14 | + | type SecretBuffer struct { | |||
| 15 | + | buf []byte // capacity*utf8.UTFMax bytes, fixed | |||
| 16 | + | n int // bytes in use | |||
| 17 | + | points int // code points in use | |||
| 18 | + | max int // capacity in code points | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // NewSecretBuffer returns a buffer holding up to capacity code points. | |||
| 22 | + | // capacity is clamped to 1..4096. | |||
| 23 | + | func NewSecretBuffer(capacity int) *SecretBuffer { | |||
| 24 | + | capacity = min(max(capacity, 1), maxSecretCapacity) | |||
| 25 | + | return &SecretBuffer{buf: make([]byte, capacity*utf8.UTFMax), max: capacity} | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // Len returns the number of code points held. | |||
| 29 | + | func (b *SecretBuffer) Len() int { | |||
| 30 | + | if b == nil { | |||
| 31 | + | return 0 | |||
| 32 | + | } | |||
| 33 | + | return b.points | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // Bytes returns the UTF-8 text held. The slice aliases the buffer: do not | |||
| 37 | + | // retain it, convert it to a string or log it; it changes on the next edit. | |||
| 38 | + | func (b *SecretBuffer) Bytes() []byte { | |||
| 39 | + | if b == nil { | |||
| 40 | + | return nil | |||
| 41 | + | } | |||
| 42 | + | return b.buf[:b.n:b.n] | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // Wipe zeroes the whole storage and empties the buffer. | |||
| 46 | + | func (b *SecretBuffer) Wipe() { | |||
| 47 | + | if b == nil { | |||
| 48 | + | return | |||
| 49 | + | } | |||
| 50 | + | clear(b.buf) | |||
| 51 | + | b.n, b.points = 0, 0 | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // appendText adds text, which must be valid UTF-8. It reports false and | |||
| 55 | + | // changes nothing when the text does not fit. | |||
| 56 | + | func (b *SecretBuffer) appendText(text []byte) bool { | |||
| 57 | + | cp := utf8.RuneCount(text) | |||
| 58 | + | if cp == 0 || b.points+cp > b.max { | |||
| 59 | + | return false | |||
| 60 | + | } | |||
| 61 | + | b.n += copy(b.buf[b.n:], text) | |||
| 62 | + | b.points += cp | |||
| 63 | + | return true | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // backspace removes the last code point and wipes its bytes. | |||
| 67 | + | func (b *SecretBuffer) backspace() bool { | |||
| 68 | + | if b.points == 0 { | |||
| 69 | + | return false | |||
| 70 | + | } | |||
| 71 | + | _, size := utf8.DecodeLastRune(b.buf[:b.n]) | |||
| 72 | + | clear(b.buf[b.n-size : b.n]) | |||
| 73 | + | b.n -= size | |||
| 74 | + | b.points-- | |||
| 75 | + | return true | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // classifySecretText reports whether text is printable input for a secret: | |||
| 79 | + | // non-empty, valid UTF-8 and free of C0 controls and DEL. | |||
| 80 | + | func classifySecretText(text []byte) bool { | |||
| 81 | + | if len(text) == 0 || !utf8.Valid(text) { | |||
| 82 | + | return false | |||
| 83 | + | } | |||
| 84 | + | for rest := text; len(rest) > 0; { | |||
| 85 | + | r, size := utf8.DecodeRune(rest) | |||
| 86 | + | if r < 0x20 || r == 0x7f { | |||
| 87 | + | return false | |||
| 88 | + | } | |||
| 89 | + | rest = rest[size:] | |||
| 90 | + | } | |||
| 91 | + | return true | |||
| 92 | + | } | |||
A secret_test.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | package neferclient | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestSecretBufferEditing(t *testing.T) { | |
| 10 | + | b := NewSecretBuffer(4) | |
| 11 | + | storage := b.buf | |
| 12 | + | require.True(t, b.appendText([]byte("a"))) | |
| 13 | + | require.True(t, b.appendText([]byte("é€"))) | |
| 14 | + | require.Equal(t, 3, b.Len()) | |
| 15 | + | require.Equal(t, []byte("aé€"), b.Bytes()) | |
| 16 | + | ||
| 17 | + | require.True(t, b.backspace()) // one code point, not one byte | |
| 18 | + | require.Equal(t, 2, b.Len()) | |
| 19 | + | require.Equal(t, []byte("aé"), b.Bytes()) | |
| 20 | + | require.Equal(t, make([]byte, 3), storage[3:6], "removed bytes are wiped") | |
| 21 | + | ||
| 22 | + | require.False(t, b.appendText([]byte("xyz")), "does not fit: nothing changes") | |
| 23 | + | require.Equal(t, []byte("aé"), b.Bytes()) | |
| 24 | + | require.True(t, b.appendText([]byte("xy"))) | |
| 25 | + | require.False(t, b.appendText([]byte("z")), "full") | |
| 26 | + | ||
| 27 | + | b.Wipe() | |
| 28 | + | require.Zero(t, b.Len()) | |
| 29 | + | require.Empty(t, b.Bytes()) | |
| 30 | + | require.Equal(t, make([]byte, len(storage)), storage, "whole storage zeroed") | |
| 31 | + | require.False(t, b.backspace()) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestSecretBufferCapacityClampAndNil(t *testing.T) { | |
| 35 | + | require.Equal(t, 1, NewSecretBuffer(-5).max) | |
| 36 | + | require.Equal(t, 1, NewSecretBuffer(0).max) | |
| 37 | + | require.Equal(t, maxSecretCapacity, NewSecretBuffer(1<<20).max) | |
| 38 | + | var b *SecretBuffer | |
| 39 | + | require.Zero(t, b.Len()) | |
| 40 | + | require.Nil(t, b.Bytes()) | |
| 41 | + | b.Wipe() | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestClassifySecretText(t *testing.T) { | |
| 45 | + | for _, ok := range []string{"a", "é", "€", " ", "日本"} { | |
| 46 | + | require.True(t, classifySecretText([]byte(ok)), ok) | |
| 47 | + | } | |
| 48 | + | for _, bad := range [][]byte{nil, {}, {'\r'}, {0x1b}, {0x7f}, {'a', '\n'}, {0xff}, {0xc3}} { | |
| 49 | + | require.False(t, classifySecretText(bad), "%q", bad) | |
| 50 | + | } | |
| 51 | + | } | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | package neferclient | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestSecretBufferEditing(t *testing.T) { | |||
| 10 | + | b := NewSecretBuffer(4) | |||
| 11 | + | storage := b.buf | |||
| 12 | + | require.True(t, b.appendText([]byte("a"))) | |||
| 13 | + | require.True(t, b.appendText([]byte("é€"))) | |||
| 14 | + | require.Equal(t, 3, b.Len()) | |||
| 15 | + | require.Equal(t, []byte("aé€"), b.Bytes()) | |||
| 16 | + | ||||
| 17 | + | require.True(t, b.backspace()) // one code point, not one byte | |||
| 18 | + | require.Equal(t, 2, b.Len()) | |||
| 19 | + | require.Equal(t, []byte("aé"), b.Bytes()) | |||
| 20 | + | require.Equal(t, make([]byte, 3), storage[3:6], "removed bytes are wiped") | |||
| 21 | + | ||||
| 22 | + | require.False(t, b.appendText([]byte("xyz")), "does not fit: nothing changes") | |||
| 23 | + | require.Equal(t, []byte("aé"), b.Bytes()) | |||
| 24 | + | require.True(t, b.appendText([]byte("xy"))) | |||
| 25 | + | require.False(t, b.appendText([]byte("z")), "full") | |||
| 26 | + | ||||
| 27 | + | b.Wipe() | |||
| 28 | + | require.Zero(t, b.Len()) | |||
| 29 | + | require.Empty(t, b.Bytes()) | |||
| 30 | + | require.Equal(t, make([]byte, len(storage)), storage, "whole storage zeroed") | |||
| 31 | + | require.False(t, b.backspace()) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestSecretBufferCapacityClampAndNil(t *testing.T) { | |||
| 35 | + | require.Equal(t, 1, NewSecretBuffer(-5).max) | |||
| 36 | + | require.Equal(t, 1, NewSecretBuffer(0).max) | |||
| 37 | + | require.Equal(t, maxSecretCapacity, NewSecretBuffer(1<<20).max) | |||
| 38 | + | var b *SecretBuffer | |||
| 39 | + | require.Zero(t, b.Len()) | |||
| 40 | + | require.Nil(t, b.Bytes()) | |||
| 41 | + | b.Wipe() | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestClassifySecretText(t *testing.T) { | |||
| 45 | + | for _, ok := range []string{"a", "é", "€", " ", "日本"} { | |||
| 46 | + | require.True(t, classifySecretText([]byte(ok)), ok) | |||
| 47 | + | } | |||
| 48 | + | for _, bad := range [][]byte{nil, {}, {'\r'}, {0x1b}, {0x7f}, {'a', '\n'}, {0xff}, {0xc3}} { | |||
| 49 | + | require.False(t, classifySecretText(bad), "%q", bad) | |||
| 50 | + | } | |||
| 51 | + | } | |||