detached c16867b

Merge pull request #122 from bnema/feat/udp-key-memory-only

feat(dgram): keep UDP bootstrap keys memory-only
commit
c16867b20bb842e7291fe473c37de9c60564df21
author
bnema · · 2026-07-23 21:09 UTC
committer
GitHub · · 2026-07-23 21:09 UTC
merge of
cb1999a + c4bb8e4 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
11 files · +287 -65
M docs/remote-resilience.md +2 -0
@@ -2,6 +2,8 @@
22
33 Remote attach starts with SSH bootstrap and then carries the vev session over authenticated UDP. The design is inspired by mosh's mobile-shell resilience, but vev uses its own protocol and is not compatible with mosh clients or servers.
44
5+The raw UDP AEAD key is never persisted by vev; the proxy registry stores only its SHA-256 ownership fingerprint. During bootstrap, the key briefly exists in memory in the proxy, the `_udp-bootstrap` relay, the client, and associated SSH/readiness I/O and string buffers. vev promptly clears reachable key buffers after handoff on a best-effort basis. Builds made with Go 1.26 and `GOEXPERIMENT=runtimesecret` additionally protect key-handling call trees with `runtime/secret` on linux/amd64 and linux/arm64; on unsupported platforms, `runtime/secret` calls the function directly. These protections do not cover globals, goroutines started by protected calls, copies retained by I/O implementations, or key material retained in cipher state.
6+
57 ## Operational model
68
79 - SSH bootstrap starts or finds the remote UDP endpoint.
@@ -2,6 +2,8 @@
2 2
3 Remote attach starts with SSH bootstrap and then carries the vev session over authenticated UDP. The design is inspired by mosh's mobile-shell resilience, but vev uses its own protocol and is not compatible with mosh clients or servers.3 Remote attach starts with SSH bootstrap and then carries the vev session over authenticated UDP. The design is inspired by mosh's mobile-shell resilience, but vev uses its own protocol and is not compatible with mosh clients or servers.
4 4
5+The raw UDP AEAD key is never persisted by vev; the proxy registry stores only its SHA-256 ownership fingerprint. During bootstrap, the key briefly exists in memory in the proxy, the `_udp-bootstrap` relay, the client, and associated SSH/readiness I/O and string buffers. vev promptly clears reachable key buffers after handoff on a best-effort basis. Builds made with Go 1.26 and `GOEXPERIMENT=runtimesecret` additionally protect key-handling call trees with `runtime/secret` on linux/amd64 and linux/arm64; on unsupported platforms, `runtime/secret` calls the function directly. These protections do not cover globals, goroutines started by protected calls, copies retained by I/O implementations, or key material retained in cipher state.
6+
5 ## Operational model7 ## Operational model
6 8
7 - SSH bootstrap starts or finds the remote UDP endpoint.9 - SSH bootstrap starts or finds the remote UDP endpoint.
M internal/adapters/dgram/proxy_registry.go +14 -8
@@ -1,10 +1,10 @@
11 package dgram
22
33 import (
4+ "crypto/sha256"
45 "encoding/base64"
56 "encoding/json"
67 "errors"
7- "fmt"
88 "os"
99 "path/filepath"
1010 "syscall"
@@ -14,14 +14,20 @@ import (
1414 )
1515
1616 type ProxyRecord struct {
17- Session string `json:"session"`
18- PID int `json:"pid"`
19- Port int `json:"port"`
20- Key string `json:"key"`
21- Created time.Time `json:"created"`
17+ Session string `json:"session"`
18+ PID int `json:"pid"`
19+ Port int `json:"port"`
20+ // KeyFingerprint is a non-secret ownership tag (SHA-256 of the AEAD key).
21+ // The raw key must never be persisted by vev.
22+ KeyFingerprint string `json:"key_fingerprint"`
23+ Created time.Time `json:"created"`
2224 }
2325
24-func (r ProxyRecord) ReadyLine() string { return fmt.Sprintf("VEV-UDP %d %s\n", r.Port, r.Key) }
26+// KeyFingerprint derives the non-secret registry ownership tag.
27+func KeyFingerprint(key []byte) string {
28+ sum := sha256.Sum256(key)
29+ return base64.StdEncoding.EncodeToString(sum[:])
30+}
2531
2632 type ProxyRegistry struct{ dir string }
2733
@@ -88,7 +94,7 @@ func (r *ProxyRegistry) RemoveOwned(rec ProxyRecord) error {
8894 if err != nil {
8995 return err
9096 }
91- if cur.PID != rec.PID || cur.Port != rec.Port || cur.Key != rec.Key {
97+ if cur.PID != rec.PID || cur.Port != rec.Port || cur.KeyFingerprint != rec.KeyFingerprint {
9298 return nil
9399 }
94100 return r.removeLocked(rec.Session)
@@ -1,10 +1,10 @@
1 package dgram1 package dgram
2 2
3 import (3 import (
4+ "crypto/sha256"
4 "encoding/base64"5 "encoding/base64"
5 "encoding/json"6 "encoding/json"
6 "errors"7 "errors"
7- "fmt"
8 "os"8 "os"
9 "path/filepath"9 "path/filepath"
10 "syscall"10 "syscall"
@@ -14,14 +14,20 @@ import (
14 )14 )
15 15
16 type ProxyRecord struct {16 type ProxyRecord struct {
17- Session string `json:"session"`17+ Session string `json:"session"`
18- PID int `json:"pid"`18+ PID int `json:"pid"`
19- Port int `json:"port"`19+ Port int `json:"port"`
20- Key string `json:"key"`20+ // KeyFingerprint is a non-secret ownership tag (SHA-256 of the AEAD key).
21- Created time.Time `json:"created"`21+ // The raw key must never be persisted by vev.
22+ KeyFingerprint string `json:"key_fingerprint"`
23+ Created time.Time `json:"created"`
22 }24 }
23 25
24-func (r ProxyRecord) ReadyLine() string { return fmt.Sprintf("VEV-UDP %d %s\n", r.Port, r.Key) }26+// KeyFingerprint derives the non-secret registry ownership tag.
27+func KeyFingerprint(key []byte) string {
28+ sum := sha256.Sum256(key)
29+ return base64.StdEncoding.EncodeToString(sum[:])
30+}
25 31
26 type ProxyRegistry struct{ dir string }32 type ProxyRegistry struct{ dir string }
27 33
@@ -88,7 +94,7 @@ func (r *ProxyRegistry) RemoveOwned(rec ProxyRecord) error {
88 if err != nil {94 if err != nil {
89 return err95 return err
90 }96 }
91- if cur.PID != rec.PID || cur.Port != rec.Port || cur.Key != rec.Key {97+ if cur.PID != rec.PID || cur.Port != rec.Port || cur.KeyFingerprint != rec.KeyFingerprint {
92 return nil98 return nil
93 }99 }
94 return r.removeLocked(rec.Session)100 return r.removeLocked(rec.Session)
M internal/adapters/dgram/proxy_registry_test.go +60 -11
@@ -1,6 +1,10 @@
11 package dgram
22
33 import (
4+ "bytes"
5+ "crypto/sha256"
6+ "encoding/base64"
7+ "encoding/json"
48 "os"
59 "path/filepath"
610 "testing"
@@ -11,10 +15,55 @@ func privateDir(t *testing.T) string {
1115 return filepath.Join(t.TempDir(), "vev")
1216 }
1317
18+func TestKeyFingerprint(t *testing.T) {
19+ key := bytes.Repeat([]byte{0xA5}, 32)
20+ sum := sha256.Sum256(key)
21+ want := base64.StdEncoding.EncodeToString(sum[:])
22+ if got := KeyFingerprint(key); got != want {
23+ t.Fatalf("KeyFingerprint()=%q, want %q", got, want)
24+ }
25+ other := bytes.Repeat([]byte{0xA6}, 32)
26+ if KeyFingerprint(key) == KeyFingerprint(other) {
27+ t.Fatal("distinct keys produced identical fingerprints")
28+ }
29+}
30+
31+func TestProxyRegistryFileHoldsNoKeyMaterial(t *testing.T) {
32+ r := NewProxyRegistry(privateDir(t))
33+ key := bytes.Repeat([]byte{0xA5}, 32)
34+ fingerprint := KeyFingerprint(key)
35+ rec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: fingerprint}
36+ if err := r.Publish(rec); err != nil {
37+ t.Fatal(err)
38+ }
39+
40+ data, err := os.ReadFile(r.path("work"))
41+ if err != nil {
42+ t.Fatal(err)
43+ }
44+ if bytes.Contains(data, key) || bytes.Contains(data, []byte(base64.StdEncoding.EncodeToString(key))) {
45+ t.Fatalf("registry file contains raw key material: %s", data)
46+ }
47+ var fields map[string]json.RawMessage
48+ if err := json.Unmarshal(data, &fields); err != nil {
49+ t.Fatal(err)
50+ }
51+ if _, ok := fields["key"]; ok {
52+ t.Fatalf("registry file contains raw key field: %s", data)
53+ }
54+ var got string
55+ if err := json.Unmarshal(fields["key_fingerprint"], &got); err != nil {
56+ t.Fatal(err)
57+ }
58+ if got != fingerprint {
59+ t.Fatalf("key_fingerprint=%q, want %q", got, fingerprint)
60+ }
61+}
62+
1463 func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
1564 t.Run("reuses live matching record", func(t *testing.T) {
1665 r := NewProxyRegistry(privateDir(t))
17- want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}
66+ want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "abc"}
1867 if err := r.Publish(want); err != nil {
1968 t.Fatal(err)
2069 }
@@ -22,16 +71,16 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
2271 if !ok {
2372 t.Fatal("Lookup ok=false, want true")
2473 }
25- if got.Port != want.Port || got.Key != want.Key || got.PID != want.PID {
74+ if got.Port != want.Port || got.KeyFingerprint != want.KeyFingerprint || got.PID != want.PID {
2675 t.Fatalf("Lookup()=%+v, want %+v", got, want)
2776 }
2877 })
2978 t.Run("removes stale record", func(t *testing.T) {
3079 r := NewProxyRegistry(privateDir(t))
31- if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}); err != nil {
80+ if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "abc"}); err != nil {
3281 t.Fatal(err)
3382 }
34- if err := os.WriteFile(r.path("work"), []byte(`{"session":"work","pid":-1,"port":61000,"key":"abc"}`), 0o600); err != nil {
83+ if err := os.WriteFile(r.path("work"), []byte(`{"session":"work","pid":-1,"port":61000,"key_fingerprint":"abc"}`), 0o600); err != nil {
3584 t.Fatal(err)
3685 }
3786 if _, ok := r.Lookup("work"); ok {
@@ -43,11 +92,11 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
4392 })
4493 t.Run("supersedes without pid-based kill and cleanup is owned", func(t *testing.T) {
4594 r := NewProxyRegistry(privateDir(t))
46- old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "old"}
95+ old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "old"}
4796 if err := r.Publish(old); err != nil {
4897 t.Fatal(err)
4998 }
50- newRec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61001, Key: "new"}
99+ newRec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61001, KeyFingerprint: "new"}
51100 if err := r.Publish(newRec); err != nil {
52101 t.Fatal(err)
53102 }
@@ -58,7 +107,7 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
58107 if !ok {
59108 t.Fatal("new live record removed by stale owner")
60109 }
61- if got.Port != newRec.Port || got.Key != newRec.Key {
110+ if got.Port != newRec.Port || got.KeyFingerprint != newRec.KeyFingerprint {
62111 t.Fatalf("Lookup()=%+v, want %+v", got, newRec)
63112 }
64113 if err := r.RemoveOwned(newRec); err != nil {
@@ -84,8 +133,8 @@ func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) {
84133 if r.path(tt.a) == r.path(tt.b) {
85134 t.Fatalf("path(%q) collides with path(%q): %s", tt.a, tt.b, r.path(tt.a))
86135 }
87- first := ProxyRecord{Session: tt.a, PID: os.Getpid(), Port: 61000, Key: "first"}
88- second := ProxyRecord{Session: tt.b, PID: os.Getpid(), Port: 61001, Key: "second"}
136+ first := ProxyRecord{Session: tt.a, PID: os.Getpid(), Port: 61000, KeyFingerprint: "first"}
137+ second := ProxyRecord{Session: tt.b, PID: os.Getpid(), Port: 61001, KeyFingerprint: "second"}
89138 if err := r.Publish(first); err != nil {
90139 t.Fatal(err)
91140 }
@@ -93,11 +142,11 @@ func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) {
93142 t.Fatal(err)
94143 }
95144 gotFirst, ok := r.Lookup(tt.a)
96- if !ok || gotFirst.Key != first.Key {
145+ if !ok || gotFirst.KeyFingerprint != first.KeyFingerprint {
97146 t.Fatalf("Lookup(%q)=%+v ok=%v, want first record", tt.a, gotFirst, ok)
98147 }
99148 gotSecond, ok := r.Lookup(tt.b)
100- if !ok || gotSecond.Key != second.Key {
149+ if !ok || gotSecond.KeyFingerprint != second.KeyFingerprint {
101150 t.Fatalf("Lookup(%q)=%+v ok=%v, want second record", tt.b, gotSecond, ok)
102151 }
103152 }
@@ -1,6 +1,10 @@
1 package dgram1 package dgram
2 2
3 import (3 import (
4+ "bytes"
5+ "crypto/sha256"
6+ "encoding/base64"
7+ "encoding/json"
4 "os"8 "os"
5 "path/filepath"9 "path/filepath"
6 "testing"10 "testing"
@@ -11,10 +15,55 @@ func privateDir(t *testing.T) string {
11 return filepath.Join(t.TempDir(), "vev")15 return filepath.Join(t.TempDir(), "vev")
12 }16 }
13 17
18+func TestKeyFingerprint(t *testing.T) {
19+ key := bytes.Repeat([]byte{0xA5}, 32)
20+ sum := sha256.Sum256(key)
21+ want := base64.StdEncoding.EncodeToString(sum[:])
22+ if got := KeyFingerprint(key); got != want {
23+ t.Fatalf("KeyFingerprint()=%q, want %q", got, want)
24+ }
25+ other := bytes.Repeat([]byte{0xA6}, 32)
26+ if KeyFingerprint(key) == KeyFingerprint(other) {
27+ t.Fatal("distinct keys produced identical fingerprints")
28+ }
29+}
30+
31+func TestProxyRegistryFileHoldsNoKeyMaterial(t *testing.T) {
32+ r := NewProxyRegistry(privateDir(t))
33+ key := bytes.Repeat([]byte{0xA5}, 32)
34+ fingerprint := KeyFingerprint(key)
35+ rec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: fingerprint}
36+ if err := r.Publish(rec); err != nil {
37+ t.Fatal(err)
38+ }
39+
40+ data, err := os.ReadFile(r.path("work"))
41+ if err != nil {
42+ t.Fatal(err)
43+ }
44+ if bytes.Contains(data, key) || bytes.Contains(data, []byte(base64.StdEncoding.EncodeToString(key))) {
45+ t.Fatalf("registry file contains raw key material: %s", data)
46+ }
47+ var fields map[string]json.RawMessage
48+ if err := json.Unmarshal(data, &fields); err != nil {
49+ t.Fatal(err)
50+ }
51+ if _, ok := fields["key"]; ok {
52+ t.Fatalf("registry file contains raw key field: %s", data)
53+ }
54+ var got string
55+ if err := json.Unmarshal(fields["key_fingerprint"], &got); err != nil {
56+ t.Fatal(err)
57+ }
58+ if got != fingerprint {
59+ t.Fatalf("key_fingerprint=%q, want %q", got, fingerprint)
60+ }
61+}
62+
14 func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {63 func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
15 t.Run("reuses live matching record", func(t *testing.T) {64 t.Run("reuses live matching record", func(t *testing.T) {
16 r := NewProxyRegistry(privateDir(t))65 r := NewProxyRegistry(privateDir(t))
17- want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}66+ want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "abc"}
18 if err := r.Publish(want); err != nil {67 if err := r.Publish(want); err != nil {
19 t.Fatal(err)68 t.Fatal(err)
20 }69 }
@@ -22,16 +71,16 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
22 if !ok {71 if !ok {
23 t.Fatal("Lookup ok=false, want true")72 t.Fatal("Lookup ok=false, want true")
24 }73 }
25- if got.Port != want.Port || got.Key != want.Key || got.PID != want.PID {74+ if got.Port != want.Port || got.KeyFingerprint != want.KeyFingerprint || got.PID != want.PID {
26 t.Fatalf("Lookup()=%+v, want %+v", got, want)75 t.Fatalf("Lookup()=%+v, want %+v", got, want)
27 }76 }
28 })77 })
29 t.Run("removes stale record", func(t *testing.T) {78 t.Run("removes stale record", func(t *testing.T) {
30 r := NewProxyRegistry(privateDir(t))79 r := NewProxyRegistry(privateDir(t))
31- if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}); err != nil {80+ if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "abc"}); err != nil {
32 t.Fatal(err)81 t.Fatal(err)
33 }82 }
34- if err := os.WriteFile(r.path("work"), []byte(`{"session":"work","pid":-1,"port":61000,"key":"abc"}`), 0o600); err != nil {83+ if err := os.WriteFile(r.path("work"), []byte(`{"session":"work","pid":-1,"port":61000,"key_fingerprint":"abc"}`), 0o600); err != nil {
35 t.Fatal(err)84 t.Fatal(err)
36 }85 }
37 if _, ok := r.Lookup("work"); ok {86 if _, ok := r.Lookup("work"); ok {
@@ -43,11 +92,11 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
43 })92 })
44 t.Run("supersedes without pid-based kill and cleanup is owned", func(t *testing.T) {93 t.Run("supersedes without pid-based kill and cleanup is owned", func(t *testing.T) {
45 r := NewProxyRegistry(privateDir(t))94 r := NewProxyRegistry(privateDir(t))
46- old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "old"}95+ old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "old"}
47 if err := r.Publish(old); err != nil {96 if err := r.Publish(old); err != nil {
48 t.Fatal(err)97 t.Fatal(err)
49 }98 }
50- newRec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61001, Key: "new"}99+ newRec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61001, KeyFingerprint: "new"}
51 if err := r.Publish(newRec); err != nil {100 if err := r.Publish(newRec); err != nil {
52 t.Fatal(err)101 t.Fatal(err)
53 }102 }
@@ -58,7 +107,7 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
58 if !ok {107 if !ok {
59 t.Fatal("new live record removed by stale owner")108 t.Fatal("new live record removed by stale owner")
60 }109 }
61- if got.Port != newRec.Port || got.Key != newRec.Key {110+ if got.Port != newRec.Port || got.KeyFingerprint != newRec.KeyFingerprint {
62 t.Fatalf("Lookup()=%+v, want %+v", got, newRec)111 t.Fatalf("Lookup()=%+v, want %+v", got, newRec)
63 }112 }
64 if err := r.RemoveOwned(newRec); err != nil {113 if err := r.RemoveOwned(newRec); err != nil {
@@ -84,8 +133,8 @@ func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) {
84 if r.path(tt.a) == r.path(tt.b) {133 if r.path(tt.a) == r.path(tt.b) {
85 t.Fatalf("path(%q) collides with path(%q): %s", tt.a, tt.b, r.path(tt.a))134 t.Fatalf("path(%q) collides with path(%q): %s", tt.a, tt.b, r.path(tt.a))
86 }135 }
87- first := ProxyRecord{Session: tt.a, PID: os.Getpid(), Port: 61000, Key: "first"}136+ first := ProxyRecord{Session: tt.a, PID: os.Getpid(), Port: 61000, KeyFingerprint: "first"}
88- second := ProxyRecord{Session: tt.b, PID: os.Getpid(), Port: 61001, Key: "second"}137+ second := ProxyRecord{Session: tt.b, PID: os.Getpid(), Port: 61001, KeyFingerprint: "second"}
89 if err := r.Publish(first); err != nil {138 if err := r.Publish(first); err != nil {
90 t.Fatal(err)139 t.Fatal(err)
91 }140 }
@@ -93,11 +142,11 @@ func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) {
93 t.Fatal(err)142 t.Fatal(err)
94 }143 }
95 gotFirst, ok := r.Lookup(tt.a)144 gotFirst, ok := r.Lookup(tt.a)
96- if !ok || gotFirst.Key != first.Key {145+ if !ok || gotFirst.KeyFingerprint != first.KeyFingerprint {
97 t.Fatalf("Lookup(%q)=%+v ok=%v, want first record", tt.a, gotFirst, ok)146 t.Fatalf("Lookup(%q)=%+v ok=%v, want first record", tt.a, gotFirst, ok)
98 }147 }
99 gotSecond, ok := r.Lookup(tt.b)148 gotSecond, ok := r.Lookup(tt.b)
100- if !ok || gotSecond.Key != second.Key {149+ if !ok || gotSecond.KeyFingerprint != second.KeyFingerprint {
101 t.Fatalf("Lookup(%q)=%+v ok=%v, want second record", tt.b, gotSecond, ok)150 t.Fatalf("Lookup(%q)=%+v ok=%v, want second record", tt.b, gotSecond, ok)
102 }151 }
103 }152 }
M internal/adapters/dgram/ssh_dialer.go +56 -24
@@ -158,6 +158,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
158158 cleanup = false
159159 return nil, udpUnavailable("read bootstrap readiness", err, &stderr)
160160 }
161+ defer pdgram.Erase(ready.key)
161162 if err := waitBootstrapContext(bootstrapCtx, proc); err != nil {
162163 waited = true
163164 cleanup = false
@@ -182,6 +183,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
182183 return listenUDPPacket(ctx)
183184 },
184185 }, WithRuntimeObserver(d.RuntimeObserver))
186+ pdgram.Erase(ready.key)
185187 if err != nil {
186188 _ = pc.Close()
187189 return nil, udpUnavailable("create UDP transport", err, &stderr)
@@ -196,15 +198,24 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
196198 return t, nil
197199 }
198200
199-func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) {
200- type result struct {
201- ready udpReady
202- err error
201+type udpReadyResult struct {
202+ ready udpReady
203+ err error
204+}
205+
206+func deliverUDPReady(ctx context.Context, ch chan<- udpReadyResult, res udpReadyResult) {
207+ select {
208+ case ch <- res:
209+ case <-ctx.Done():
210+ pdgram.Erase(res.ready.key)
203211 }
204- ch := make(chan result, 1)
212+}
213+
214+func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) {
215+ ch := make(chan udpReadyResult)
205216 go func() {
206217 ready, err := readUDPReady(r)
207- ch <- result{ready: ready, err: err}
218+ deliverUDPReady(ctx, ch, udpReadyResult{ready: ready, err: err})
208219 }()
209220 select {
210221 case res := <-ch:
@@ -247,26 +258,47 @@ type udpReady struct {
247258 }
248259
249260 func readUDPReady(r io.Reader) (udpReady, error) {
250- line, err := bufio.NewReader(r).ReadString('\n')
251- if err != nil && line == "" {
252- return udpReady{}, err
253- }
254- fields := strings.Fields(line)
255- if len(fields) != 3 || fields[0] != "VEV-UDP" {
256- return udpReady{}, fmt.Errorf("malformed readiness line %q", strings.TrimSpace(line))
257- }
258- port, err := strconv.Atoi(fields[1])
259- if err != nil || port < 1 || port > 65535 {
260- return udpReady{}, fmt.Errorf("invalid UDP port %q", fields[1])
261- }
262- key, err := base64.StdEncoding.DecodeString(fields[2])
263- if err != nil {
264- return udpReady{}, fmt.Errorf("invalid bootstrap key: %w", err)
261+ out := make([]byte, pdgram.KeySize)
262+ var (
263+ port int
264+ keyLen int
265+ parseErr error
266+ )
267+ pdgram.SecretDo(func() {
268+ line, err := bufio.NewReader(r).ReadString('\n')
269+ if err != nil && line == "" {
270+ parseErr = err
271+ return
272+ }
273+
274+ fields := strings.Fields(line)
275+ if len(fields) != 3 || fields[0] != "VEV-UDP" {
276+ parseErr = fmt.Errorf("malformed UDP readiness line")
277+ return
278+ }
279+ port, parseErr = strconv.Atoi(fields[1])
280+ if parseErr != nil || port < 1 || port > 65535 {
281+ parseErr = fmt.Errorf("invalid UDP port %q", fields[1])
282+ return
283+ }
284+ key, decodeErr := base64.StdEncoding.DecodeString(fields[2])
285+ defer pdgram.Erase(key)
286+ if decodeErr != nil {
287+ parseErr = fmt.Errorf("invalid bootstrap key: %w", decodeErr)
288+ return
289+ }
290+ keyLen = len(key)
291+ copy(out, key)
292+ })
293+ if parseErr != nil {
294+ pdgram.Erase(out)
295+ return udpReady{}, parseErr
265296 }
266- if len(key) != pdgram.KeySize {
267- return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", len(key))
297+ if keyLen != pdgram.KeySize {
298+ pdgram.Erase(out)
299+ return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", keyLen)
268300 }
269- return udpReady{port: port, key: key}, nil
301+ return udpReady{port: port, key: out}, nil
270302 }
271303
272304 func udpUnavailable(action string, err error, stderr fmt.Stringer) error {
@@ -158,6 +158,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
158 cleanup = false158 cleanup = false
159 return nil, udpUnavailable("read bootstrap readiness", err, &stderr)159 return nil, udpUnavailable("read bootstrap readiness", err, &stderr)
160 }160 }
161+ defer pdgram.Erase(ready.key)
161 if err := waitBootstrapContext(bootstrapCtx, proc); err != nil {162 if err := waitBootstrapContext(bootstrapCtx, proc); err != nil {
162 waited = true163 waited = true
163 cleanup = false164 cleanup = false
@@ -182,6 +183,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
182 return listenUDPPacket(ctx)183 return listenUDPPacket(ctx)
183 },184 },
184 }, WithRuntimeObserver(d.RuntimeObserver))185 }, WithRuntimeObserver(d.RuntimeObserver))
186+ pdgram.Erase(ready.key)
185 if err != nil {187 if err != nil {
186 _ = pc.Close()188 _ = pc.Close()
187 return nil, udpUnavailable("create UDP transport", err, &stderr)189 return nil, udpUnavailable("create UDP transport", err, &stderr)
@@ -196,15 +198,24 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
196 return t, nil198 return t, nil
197 }199 }
198 200
199-func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) {201+type udpReadyResult struct {
200- type result struct {202+ ready udpReady
201- ready udpReady203+ err error
202- err error204+}
205+
206+func deliverUDPReady(ctx context.Context, ch chan<- udpReadyResult, res udpReadyResult) {
207+ select {
208+ case ch <- res:
209+ case <-ctx.Done():
210+ pdgram.Erase(res.ready.key)
203 }211 }
204- ch := make(chan result, 1)212+}
213+
214+func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) {
215+ ch := make(chan udpReadyResult)
205 go func() {216 go func() {
206 ready, err := readUDPReady(r)217 ready, err := readUDPReady(r)
207- ch <- result{ready: ready, err: err}218+ deliverUDPReady(ctx, ch, udpReadyResult{ready: ready, err: err})
208 }()219 }()
209 select {220 select {
210 case res := <-ch:221 case res := <-ch:
@@ -247,26 +258,47 @@ type udpReady struct {
247 }258 }
248 259
249 func readUDPReady(r io.Reader) (udpReady, error) {260 func readUDPReady(r io.Reader) (udpReady, error) {
250- line, err := bufio.NewReader(r).ReadString('\n')261+ out := make([]byte, pdgram.KeySize)
251- if err != nil && line == "" {262+ var (
252- return udpReady{}, err263+ port int
253- }264+ keyLen int
254- fields := strings.Fields(line)265+ parseErr error
255- if len(fields) != 3 || fields[0] != "VEV-UDP" {266+ )
256- return udpReady{}, fmt.Errorf("malformed readiness line %q", strings.TrimSpace(line))267+ pdgram.SecretDo(func() {
257- }268+ line, err := bufio.NewReader(r).ReadString('\n')
258- port, err := strconv.Atoi(fields[1])269+ if err != nil && line == "" {
259- if err != nil || port < 1 || port > 65535 {270+ parseErr = err
260- return udpReady{}, fmt.Errorf("invalid UDP port %q", fields[1])271+ return
261- }272+ }
262- key, err := base64.StdEncoding.DecodeString(fields[2])273+
263- if err != nil {274+ fields := strings.Fields(line)
264- return udpReady{}, fmt.Errorf("invalid bootstrap key: %w", err)275+ if len(fields) != 3 || fields[0] != "VEV-UDP" {
276+ parseErr = fmt.Errorf("malformed UDP readiness line")
277+ return
278+ }
279+ port, parseErr = strconv.Atoi(fields[1])
280+ if parseErr != nil || port < 1 || port > 65535 {
281+ parseErr = fmt.Errorf("invalid UDP port %q", fields[1])
282+ return
283+ }
284+ key, decodeErr := base64.StdEncoding.DecodeString(fields[2])
285+ defer pdgram.Erase(key)
286+ if decodeErr != nil {
287+ parseErr = fmt.Errorf("invalid bootstrap key: %w", decodeErr)
288+ return
289+ }
290+ keyLen = len(key)
291+ copy(out, key)
292+ })
293+ if parseErr != nil {
294+ pdgram.Erase(out)
295+ return udpReady{}, parseErr
265 }296 }
266- if len(key) != pdgram.KeySize {297+ if keyLen != pdgram.KeySize {
267- return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", len(key))298+ pdgram.Erase(out)
299+ return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", keyLen)
268 }300 }
269- return udpReady{port: port, key: key}, nil301+ return udpReady{port: port, key: out}, nil
270 }302 }
271 303
272 func udpUnavailable(action string, err error, stderr fmt.Stringer) error {304 func udpUnavailable(action string, err error, stderr fmt.Stringer) error {
M internal/adapters/dgram/ssh_dialer_test.go +33 -5
@@ -60,7 +60,7 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
6060 wantStdoutClosed bool
6161 }{
6262 {name: "start failure", start: errors.New("ssh missing"), want: "remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},
63- {name: "malformed readiness", stdout: "hello\n", want: "malformed readiness line", killed: true, waited: true, wantStdoutClosed: true},
63+ {name: "malformed readiness", stdout: "hello\n", want: "malformed UDP readiness line", killed: true, waited: true, wantStdoutClosed: true},
6464 {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true},
6565 {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: true, wantStdoutClosed: true},
6666 }
@@ -174,24 +174,52 @@ type closeTrackPacketConn struct {
174174
175175 func (c closeTrackPacketConn) Close() error { *c.closed = true; return c.PacketConn.Close() }
176176
177+func TestDeliverUDPReadyErasesParsedKeyAfterCancellation(t *testing.T) {
178+ ctx, cancel := context.WithCancel(context.Background())
179+ cancel()
180+ key := make([]byte, pdgram.KeySize)
181+ for i := range key {
182+ key[i] = byte(i + 1)
183+ }
184+ ready, err := readUDPReady(strings.NewReader("VEV-UDP 1234 " + base64.StdEncoding.EncodeToString(key) + "\n"))
185+ if err != nil {
186+ t.Fatal(err)
187+ }
188+
189+ deliverUDPReady(ctx, make(chan udpReadyResult), udpReadyResult{ready: ready})
190+
191+ for i, b := range ready.key {
192+ if b != 0 {
193+ t.Fatalf("key[%d] = %d, want erased", i, b)
194+ }
195+ }
196+}
197+
177198 func TestReadUDPReady(t *testing.T) {
178199 key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
179200 tests := []struct {
180201 name, line string
181202 wantPort int
182203 wantErr string
204+ exactErr bool
183205 }{
184206 {name: "valid", line: "VEV-UDP 1234 " + key + "\n", wantPort: 1234},
185- {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed readiness"},
186- {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: "invalid UDP port"},
207+ {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed UDP readiness line", exactErr: true},
208+ {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: `invalid UDP port "99999"`, exactErr: true},
187209 {name: "bad key", line: "VEV-UDP 1234 !!!\n", wantErr: "invalid bootstrap key"},
188210 }
189211 for _, tt := range tests {
190212 t.Run(tt.name, func(t *testing.T) {
191213 got, err := readUDPReady(strings.NewReader(tt.line))
192214 if tt.wantErr != "" {
193- if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
194- t.Fatalf("err=%v, want %q", err, tt.wantErr)
215+ if err == nil {
216+ t.Fatalf("err=nil, want %q", tt.wantErr)
217+ }
218+ if tt.exactErr && err.Error() != tt.wantErr {
219+ t.Fatalf("err=%q, want exactly %q", err, tt.wantErr)
220+ }
221+ if !tt.exactErr && !strings.Contains(err.Error(), tt.wantErr) {
222+ t.Fatalf("err=%v, want containing %q", err, tt.wantErr)
195223 }
196224 return
197225 }
@@ -60,7 +60,7 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
60 wantStdoutClosed bool60 wantStdoutClosed bool
61 }{61 }{
62 {name: "start failure", start: errors.New("ssh missing"), want: "remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},62 {name: "start failure", start: errors.New("ssh missing"), want: "remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},
63- {name: "malformed readiness", stdout: "hello\n", want: "malformed readiness line", killed: true, waited: true, wantStdoutClosed: true},63+ {name: "malformed readiness", stdout: "hello\n", want: "malformed UDP readiness line", killed: true, waited: true, wantStdoutClosed: true},
64 {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true},64 {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true},
65 {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: true, wantStdoutClosed: true},65 {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: true, wantStdoutClosed: true},
66 }66 }
@@ -174,24 +174,52 @@ type closeTrackPacketConn struct {
174 174
175 func (c closeTrackPacketConn) Close() error { *c.closed = true; return c.PacketConn.Close() }175 func (c closeTrackPacketConn) Close() error { *c.closed = true; return c.PacketConn.Close() }
176 176
177+func TestDeliverUDPReadyErasesParsedKeyAfterCancellation(t *testing.T) {
178+ ctx, cancel := context.WithCancel(context.Background())
179+ cancel()
180+ key := make([]byte, pdgram.KeySize)
181+ for i := range key {
182+ key[i] = byte(i + 1)
183+ }
184+ ready, err := readUDPReady(strings.NewReader("VEV-UDP 1234 " + base64.StdEncoding.EncodeToString(key) + "\n"))
185+ if err != nil {
186+ t.Fatal(err)
187+ }
188+
189+ deliverUDPReady(ctx, make(chan udpReadyResult), udpReadyResult{ready: ready})
190+
191+ for i, b := range ready.key {
192+ if b != 0 {
193+ t.Fatalf("key[%d] = %d, want erased", i, b)
194+ }
195+ }
196+}
197+
177 func TestReadUDPReady(t *testing.T) {198 func TestReadUDPReady(t *testing.T) {
178 key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))199 key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
179 tests := []struct {200 tests := []struct {
180 name, line string201 name, line string
181 wantPort int202 wantPort int
182 wantErr string203 wantErr string
204+ exactErr bool
183 }{205 }{
184 {name: "valid", line: "VEV-UDP 1234 " + key + "\n", wantPort: 1234},206 {name: "valid", line: "VEV-UDP 1234 " + key + "\n", wantPort: 1234},
185- {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed readiness"},207+ {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed UDP readiness line", exactErr: true},
186- {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: "invalid UDP port"},208+ {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: `invalid UDP port "99999"`, exactErr: true},
187 {name: "bad key", line: "VEV-UDP 1234 !!!\n", wantErr: "invalid bootstrap key"},209 {name: "bad key", line: "VEV-UDP 1234 !!!\n", wantErr: "invalid bootstrap key"},
188 }210 }
189 for _, tt := range tests {211 for _, tt := range tests {
190 t.Run(tt.name, func(t *testing.T) {212 t.Run(tt.name, func(t *testing.T) {
191 got, err := readUDPReady(strings.NewReader(tt.line))213 got, err := readUDPReady(strings.NewReader(tt.line))
192 if tt.wantErr != "" {214 if tt.wantErr != "" {
193- if err == nil || !strings.Contains(err.Error(), tt.wantErr) {215+ if err == nil {
194- t.Fatalf("err=%v, want %q", err, tt.wantErr)216+ t.Fatalf("err=nil, want %q", tt.wantErr)
217+ }
218+ if tt.exactErr && err.Error() != tt.wantErr {
219+ t.Fatalf("err=%q, want exactly %q", err, tt.wantErr)
220+ }
221+ if !tt.exactErr && !strings.Contains(err.Error(), tt.wantErr) {
222+ t.Fatalf("err=%v, want containing %q", err, tt.wantErr)
195 }223 }
196 return224 return
197 }225 }
M internal/app/run.go +42 -17
@@ -48,6 +48,7 @@ import (
4848 "github.com/bnema/vev/internal/usecase/client"
4949 "github.com/bnema/vev/internal/usecase/confirm"
5050 "github.com/bnema/vev/internal/usecase/daemon"
51+ pdgram "github.com/bnema/vev/pkg/dgram"
5152 "github.com/bnema/vev/pkg/kv"
5253 )
5354
@@ -929,10 +930,6 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
929930 return err
930931 }
931932 defer func() { _ = conn.Close() }()
932- key := make([]byte, 32)
933- if _, err := rand.Read(key); err != nil {
934- return err
935- }
936933 daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
937934 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
938935 }, realSpawn, defaultBackoff)
@@ -942,26 +939,54 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
942939 defer func() { _ = daemonTr.Close() }()
943940 udpOptions := udpProxyClientTransportOptions
944941 udpOptions.Observe = dgram.DiagnosticLogObserver(log)
945- dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions, dgram.WithRuntimeObserver(observer))
946- if err != nil {
947- return err
948- }
949- defer func() { _ = dg.Close() }()
950-
951942 addr, ok := conn.LocalAddr().(*net.UDPAddr)
952943 if !ok {
953944 return errors.New("vev: udp proxy did not get a UDP address")
954945 }
955- keyText := base64.StdEncoding.EncodeToString(key)
956946 registry := dgram.NewProxyRegistry(filepath.Join(ipc.SocketDir(), "udp-proxies"))
957- record := dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, Key: keyText}
958- if err := registry.Publish(record); err != nil {
959- return err
947+
948+ var (
949+ dg *dgram.Transport
950+ record dgram.ProxyRecord
951+ published bool
952+ setupErr error
953+ )
954+ pdgram.SecretDo(func() {
955+ key := make([]byte, pdgram.KeySize)
956+ defer pdgram.Erase(key)
957+ if _, setupErr = rand.Read(key); setupErr != nil {
958+ return
959+ }
960+ dg, setupErr = dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions, dgram.WithRuntimeObserver(observer))
961+ if setupErr != nil {
962+ return
963+ }
964+ record = dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, KeyFingerprint: dgram.KeyFingerprint(key)}
965+ if setupErr = registry.Publish(record); setupErr != nil {
966+ return
967+ }
968+ published = true
969+
970+ readiness := append([]byte("VEV-UDP "), strconv.Itoa(addr.Port)...)
971+ readiness = append(readiness, ' ')
972+ readiness = base64.StdEncoding.AppendEncode(readiness, key)
973+ readiness = append(readiness, '\n')
974+ defer pdgram.Erase(readiness)
975+ _, setupErr = ready.Write(readiness)
976+ pdgram.Erase(readiness)
977+ pdgram.Erase(key)
978+ })
979+ if setupErr != nil {
980+ if dg != nil {
981+ _ = dg.Close()
982+ }
983+ if published {
984+ _ = registry.RemoveOwned(record)
985+ }
986+ return setupErr
960987 }
988+ defer func() { _ = dg.Close() }()
961989 defer func() { _ = registry.RemoveOwned(record) }()
962- if _, err := fmt.Fprintf(ready, "VEV-UDP %d %s\n", addr.Port, keyText); err != nil {
963- return err
964- }
965990
966991 return runUDPProxyRuntime(ctx, session, dg, daemonTr, os.Environ(), log)
967992 }
@@ -48,6 +48,7 @@ import (
48 "github.com/bnema/vev/internal/usecase/client"48 "github.com/bnema/vev/internal/usecase/client"
49 "github.com/bnema/vev/internal/usecase/confirm"49 "github.com/bnema/vev/internal/usecase/confirm"
50 "github.com/bnema/vev/internal/usecase/daemon"50 "github.com/bnema/vev/internal/usecase/daemon"
51+ pdgram "github.com/bnema/vev/pkg/dgram"
51 "github.com/bnema/vev/pkg/kv"52 "github.com/bnema/vev/pkg/kv"
52 )53 )
53 54
@@ -929,10 +930,6 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
929 return err930 return err
930 }931 }
931 defer func() { _ = conn.Close() }()932 defer func() { _ = conn.Close() }()
932- key := make([]byte, 32)
933- if _, err := rand.Read(key); err != nil {
934- return err
935- }
936 daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {933 daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
937 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))934 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
938 }, realSpawn, defaultBackoff)935 }, realSpawn, defaultBackoff)
@@ -942,26 +939,54 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
942 defer func() { _ = daemonTr.Close() }()939 defer func() { _ = daemonTr.Close() }()
943 udpOptions := udpProxyClientTransportOptions940 udpOptions := udpProxyClientTransportOptions
944 udpOptions.Observe = dgram.DiagnosticLogObserver(log)941 udpOptions.Observe = dgram.DiagnosticLogObserver(log)
945- dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions, dgram.WithRuntimeObserver(observer))
946- if err != nil {
947- return err
948- }
949- defer func() { _ = dg.Close() }()
950-
951 addr, ok := conn.LocalAddr().(*net.UDPAddr)942 addr, ok := conn.LocalAddr().(*net.UDPAddr)
952 if !ok {943 if !ok {
953 return errors.New("vev: udp proxy did not get a UDP address")944 return errors.New("vev: udp proxy did not get a UDP address")
954 }945 }
955- keyText := base64.StdEncoding.EncodeToString(key)
956 registry := dgram.NewProxyRegistry(filepath.Join(ipc.SocketDir(), "udp-proxies"))946 registry := dgram.NewProxyRegistry(filepath.Join(ipc.SocketDir(), "udp-proxies"))
957- record := dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, Key: keyText}947+
958- if err := registry.Publish(record); err != nil {948+ var (
959- return err949+ dg *dgram.Transport
950+ record dgram.ProxyRecord
951+ published bool
952+ setupErr error
953+ )
954+ pdgram.SecretDo(func() {
955+ key := make([]byte, pdgram.KeySize)
956+ defer pdgram.Erase(key)
957+ if _, setupErr = rand.Read(key); setupErr != nil {
958+ return
959+ }
960+ dg, setupErr = dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions, dgram.WithRuntimeObserver(observer))
961+ if setupErr != nil {
962+ return
963+ }
964+ record = dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, KeyFingerprint: dgram.KeyFingerprint(key)}
965+ if setupErr = registry.Publish(record); setupErr != nil {
966+ return
967+ }
968+ published = true
969+
970+ readiness := append([]byte("VEV-UDP "), strconv.Itoa(addr.Port)...)
971+ readiness = append(readiness, ' ')
972+ readiness = base64.StdEncoding.AppendEncode(readiness, key)
973+ readiness = append(readiness, '\n')
974+ defer pdgram.Erase(readiness)
975+ _, setupErr = ready.Write(readiness)
976+ pdgram.Erase(readiness)
977+ pdgram.Erase(key)
978+ })
979+ if setupErr != nil {
980+ if dg != nil {
981+ _ = dg.Close()
982+ }
983+ if published {
984+ _ = registry.RemoveOwned(record)
985+ }
986+ return setupErr
960 }987 }
988+ defer func() { _ = dg.Close() }()
961 defer func() { _ = registry.RemoveOwned(record) }()989 defer func() { _ = registry.RemoveOwned(record) }()
962- if _, err := fmt.Fprintf(ready, "VEV-UDP %d %s\n", addr.Port, keyText); err != nil {
963- return err
964- }
965 990
966 return runUDPProxyRuntime(ctx, session, dg, daemonTr, os.Environ(), log)991 return runUDPProxyRuntime(ctx, session, dg, daemonTr, os.Environ(), log)
967 }992 }
M pkg/dgram/dgram.go +6 -0
@@ -24,6 +24,12 @@ var (
2424 ErrFragment = errors.New("dgram: invalid fragment")
2525 )
2626
27+// Erase overwrites b with zeroes. This is best effort: copies made by the
28+// compiler, runtime, or called code may retain the original data.
29+func Erase(b []byte) {
30+ clear(b)
31+}
32+
2733 type Codec struct {
2834 gcm cipher.AEAD
2935 }
@@ -24,6 +24,12 @@ var (
24 ErrFragment = errors.New("dgram: invalid fragment")24 ErrFragment = errors.New("dgram: invalid fragment")
25 )25 )
26 26
27+// Erase overwrites b with zeroes. This is best effort: copies made by the
28+// compiler, runtime, or called code may retain the original data.
29+func Erase(b []byte) {
30+ clear(b)
31+}
32+
27 type Codec struct {33 type Codec struct {
28 gcm cipher.AEAD34 gcm cipher.AEAD
29 }35 }
A pkg/dgram/erase_test.go +27 -0
@@ -0,0 +1,27 @@
1+package dgram
2+
3+import (
4+ "bytes"
5+ "testing"
6+)
7+
8+func TestErase(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ buf []byte
12+ }{
13+ {name: "nil", buf: nil},
14+ {name: "empty", buf: []byte{}},
15+ {name: "key", buf: bytes.Repeat([]byte{0xa5}, KeySize)},
16+ }
17+
18+ for _, tt := range tests {
19+ t.Run(tt.name, func(t *testing.T) {
20+ Erase(tt.buf)
21+
22+ if !bytes.Equal(tt.buf, make([]byte, len(tt.buf))) {
23+ t.Fatalf("Erase() left data in buffer: %x", tt.buf)
24+ }
25+ })
26+ }
27+}
@@ -0,0 +1,27 @@
1+package dgram
2+
3+import (
4+ "bytes"
5+ "testing"
6+)
7+
8+func TestErase(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ buf []byte
12+ }{
13+ {name: "nil", buf: nil},
14+ {name: "empty", buf: []byte{}},
15+ {name: "key", buf: bytes.Repeat([]byte{0xa5}, KeySize)},
16+ }
17+
18+ for _, tt := range tests {
19+ t.Run(tt.name, func(t *testing.T) {
20+ Erase(tt.buf)
21+
22+ if !bytes.Equal(tt.buf, make([]byte, len(tt.buf))) {
23+ t.Fatalf("Erase() left data in buffer: %x", tt.buf)
24+ }
25+ })
26+ }
27+}
A pkg/dgram/secret_disabled.go +7 -0
@@ -0,0 +1,7 @@
1+//go:build !goexperiment.runtimesecret
2+
3+package dgram
4+
5+// SecretDo invokes f directly when runtime/secret is not in this build.
6+// Callers still explicitly clear every reachable key buffer with Erase.
7+func SecretDo(f func()) { f() }
@@ -0,0 +1,7 @@
1+//go:build !goexperiment.runtimesecret
2+
3+package dgram
4+
5+// SecretDo invokes f directly when runtime/secret is not in this build.
6+// Callers still explicitly clear every reachable key buffer with Erase.
7+func SecretDo(f func()) { f() }
A pkg/dgram/secret_enabled.go +12 -0
@@ -0,0 +1,12 @@
1+//go:build goexperiment.runtimesecret
2+
3+package dgram
4+
5+import "runtime/secret"
6+
7+// SecretDo invokes f through runtime/secret.Do. On supported Go 1.26
8+// platforms this protects temporary stack/register storage for f's call tree
9+// and marks its allocations for clearing after they become unreachable and
10+// the GC notices. On unsupported platforms runtime/secret.Do invokes f
11+// directly. It does not protect globals or goroutines started by f.
12+func SecretDo(f func()) { secret.Do(f) }
@@ -0,0 +1,12 @@
1+//go:build goexperiment.runtimesecret
2+
3+package dgram
4+
5+import "runtime/secret"
6+
7+// SecretDo invokes f through runtime/secret.Do. On supported Go 1.26
8+// platforms this protects temporary stack/register storage for f's call tree
9+// and marks its allocations for clearing after they become unreachable and
10+// the GC notices. On unsupported platforms runtime/secret.Do invokes f
11+// directly. It does not protect globals or goroutines started by f.
12+func SecretDo(f func()) { secret.Do(f) }
A pkg/dgram/secret_test.go +28 -0
@@ -0,0 +1,28 @@
1+package dgram
2+
3+import "testing"
4+
5+func TestSecretDoRunsSynchronously(t *testing.T) {
6+ called := false
7+
8+ SecretDo(func() {
9+ called = true
10+ })
11+
12+ if !called {
13+ t.Fatal("SecretDo returned before callback ran")
14+ }
15+}
16+
17+func TestSecretDoPropagatesPanic(t *testing.T) {
18+ want := &struct{ message string }{message: "secret callback panic"}
19+ defer func() {
20+ if got := recover(); got != want {
21+ t.Fatalf("recover() = %#v, want %#v", got, want)
22+ }
23+ }()
24+
25+ SecretDo(func() {
26+ panic(want)
27+ })
28+}
@@ -0,0 +1,28 @@
1+package dgram
2+
3+import "testing"
4+
5+func TestSecretDoRunsSynchronously(t *testing.T) {
6+ called := false
7+
8+ SecretDo(func() {
9+ called = true
10+ })
11+
12+ if !called {
13+ t.Fatal("SecretDo returned before callback ran")
14+ }
15+}
16+
17+func TestSecretDoPropagatesPanic(t *testing.T) {
18+ want := &struct{ message string }{message: "secret callback panic"}
19+ defer func() {
20+ if got := recover(); got != want {
21+ t.Fatalf("recover() = %#v, want %#v", got, want)
22+ }
23+ }()
24+
25+ SecretDo(func() {
26+ panic(want)
27+ })
28+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close