Skip to content

Commit 731a82b

Browse files
pgodwinclaude
andcommitted
Correct core-ring reflect policy: bare reflect is fine, serialization isn't
Empirically verified crypto/rand (which transitively imports reflect) builds and links under real TinyGo (0.41.1). The old archtest rule banned "reflect" itself as a proxy for "TinyGo doesn't support this"; that proxy was wrong — TinyGo's reflect works, it's generic reflection-based *serialization* (struct-tag walking to encode/decode arbitrary types) that's unreliable there. Narrowed core/internal/archtest's forbidden list accordingly: dropped the blanket "reflect" ban, kept encoding/json, encoding/binary, and database/sql banned by name (with corrected rationale), and documented why fmt still isn't explicitly banned (crypto/rand itself transitively imports fmt, so banning it would break the very fix this commit makes). Two concrete consequences, found by grepping every "reflect" comment in core/adapter/client for places functionality was pushed out of core specifically because of the old (over-broad) rule: - core/csnet.RandomMAC is now the canonical implementation (moved from client/link, which keeps a thin wrapper for its existing callers/API). - core/auth gains NewCredential, generating its own random salt via crypto/rand instead of requiring every caller to generate one and pass it to DeriveCredential. adapter/auth/local's Store now calls it instead of hand-rolling salt generation; that package still lives in the adapter ring, but now correctly for its own reason (file I/O), not a stale crypto/rand rationale. Also, per direct instruction: core/port.ParseMAC now delegates to core/csnet.ParseMAC (net.ParseMAC) instead of its own hand-rolled parser, so it matches every other MAC parser in the codebase exactly. This is a deliberate behavior change: a single-nibble octet like "0:11:22:aa:bb:cc" is no longer accepted (net.ParseMAC has always rejected it). Surveyed the rest of the "reflection-free" comments across core/protocol/* et al. (hand-rolled big-endian codecs, hex formatting) — these remain correctly scoped: encoding/binary is still banned (Read/Write are the actual generic-serialization concern), so core/binaryprimitives and similar hand-rolled helpers are unaffected by this policy correction. Verified: go build/vet/test -tags all, TestCoreImportGraph (archtest), and a real TinyGo (0.41.1) build of cmd/cs-tinygo's linux/amd64 target. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 871c35f commit 731a82b

15 files changed

Lines changed: 166 additions & 115 deletions

File tree

adapter/auth/local/store.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
// colon-separated, salted PBKDF2-SHA256 hashes), separate from server.toml so
44
// secrets never ride the main config or its numbered backups.
55
//
6-
// It lives in the ADAPTER ring, not core, for one reason: generating a random
7-
// salt needs crypto/rand, which transitively imports reflect — banned in core by
8-
// the archtest gate (§1). The hashing/verification itself stays in core/auth
9-
// (reflection-free PBKDF2); this adapter supplies the randomness and the os file
10-
// I/O. It is the default store the way adapter/store/file and
11-
// adapter/metastore/sqlite are defaults — pure stdlib, no new dependency, no build
12-
// tag. A future PAM/Windows store is an additional adapter under adapter/auth/*.
6+
// It lives in the ADAPTER ring, not core, because it does file I/O (os,
7+
// path/filepath) — an adapter concern regardless of what core itself may
8+
// import. Credential generation/hashing/verification (including salt
9+
// generation via crypto/rand, which is fine in core — see
10+
// core/auth.NewCredential) stays in core/auth; this adapter supplies only the
11+
// on-disk format and file I/O. It is the default store the way
12+
// adapter/store/file and adapter/metastore/sqlite are defaults — pure stdlib,
13+
// no new dependency, no build tag. A future PAM/Windows store is an additional
14+
// adapter under adapter/auth/*.
1315
//
1416
// File format (one line per user; '#' comments and blank lines ignored):
1517
//
@@ -21,7 +23,6 @@
2123
package local
2224

2325
import (
24-
"crypto/rand"
2526
"errors"
2627
"os"
2728
"path/filepath"
@@ -148,11 +149,10 @@ func (s *Store) SetUser(username, password string) error {
148149
if password == "" {
149150
return auth.ErrEmptyPassword
150151
}
151-
salt := make([]byte, auth.SaltLen)
152-
if _, err := rand.Read(salt); err != nil {
152+
cred, err := auth.NewCredential(password)
153+
if err != nil {
153154
return err
154155
}
155-
cred := auth.DeriveCredential(password, salt)
156156

157157
s.mu.Lock()
158158
defer s.mu.Unlock()

client/link/random.go

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package link
22

3-
import "crypto/rand"
3+
import "github.com/ObsoleteMadness/ClassicStack/core/csnet"
44

55
// RandomMAC returns a synthetic locally-administered unicast Ethernet address for
66
// a virtual station: the first octet has the locally-administered bit set and the
@@ -10,13 +10,10 @@ import "crypto/rand"
1010
// borrow the host NIC's identity (which would collide, and on Windows cannot even
1111
// be resolved from an "\Device\NPF_{GUID}" name).
1212
//
13-
// This lives here rather than in core/csnet because it needs crypto/rand, which
14-
// transitively imports reflectforbidden in the core ring (core/internal/archtest,
15-
// §1). Every current caller (client/etherdfs, client/ncp, client/netbios,
16-
// client/smb, cmd/internal/csconnect) already imports this package.
13+
// Delegates to core/csnet.RandomMAC, the canonical implementation (crypto/rand is
14+
// allowed in coresee core/csnet/random.go's doc comment). Kept as a wrapper
15+
// here since client/etherdfs.RandomMAC, client/ncp.RandomMAC,
16+
// client/netbios.RandomMAC, and client/smb.RandomMAC all call it by this name.
1717
func RandomMAC() [6]byte {
18-
var mac [6]byte
19-
_, _ = rand.Read(mac[:])
20-
mac[0] = (mac[0] | 0x02) &^ 0x01 // locally-administered, unicast
21-
return mac
18+
return csnet.RandomMAC()
2219
}

client/link/random_test.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package link
22

33
import "testing"
44

5+
// Full RandomMAC coverage lives in core/csnet (csnet_test.go); this just proves
6+
// the wrapper actually delegates.
57
func TestRandomMAC(t *testing.T) {
68
mac := RandomMAC()
79
if mac[0]&0x02 == 0 {
@@ -10,7 +12,4 @@ func TestRandomMAC(t *testing.T) {
1012
if mac[0]&0x01 != 0 {
1113
t.Errorf("RandomMAC() first octet %02x: unicast bit (0x01) should be clear", mac[0])
1214
}
13-
if other := RandomMAC(); mac == other {
14-
t.Error("RandomMAC() returned the same address twice in a row (rand.Read broken?)")
15-
}
1615
}

cmd/cs-tinygo/main.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,10 @@ import (
5858

5959
// M8a: the authentication seam's CONTRACT + PBKDF2 credential codec must stay
6060
// TinyGo-clean so an embedded build can gate share access. Only core/auth is
61-
// blank-imported: it is reflection-free (hand-rolled hex, no crypto/rand). The
62-
// file-backed store lives in adapter/auth/local — it uses crypto/rand (which
63-
// pulls reflect) and os, so it is deliberately an adapter, not part of this gate.
61+
// blank-imported (crypto/rand is fine here — see core/csnet/random.go — but
62+
// hex coding stays hand-rolled regardless, matching core/binaryprimitives'
63+
// style). The file-backed store lives in adapter/auth/local — it needs os for
64+
// file I/O, so it is deliberately an adapter, not part of this gate.
6465
_ "github.com/ObsoleteMadness/ClassicStack/core/auth"
6566

6667
// M1: the pure-Go pcapfile capture writer is required to be TinyGo-safe (§6f)

core/auth/auth.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@
1010
// modern on our side of the bridge, faithful to the client's insecure dialect on
1111
// the wire.
1212
//
13-
// core discipline: this package imports only stdlib crypto (crypto/sha256,
14-
// crypto/rand, crypto/subtle, encoding/hex) — no net, no reflect, no
15-
// encoding/binary, no sqlite — so it compiles for embedded/TinyGo targets and
16-
// passes the archtest gate. A concrete file-backed store lives in the
17-
// core/auth/local subpackage (it needs os); a netless target that does not need
18-
// it simply does not import it.
13+
// core discipline: this package imports only stdlib crypto (crypto/hmac,
14+
// crypto/rand, crypto/sha256, crypto/subtle) plus errors — no net, no
15+
// encoding/binary, no encoding/json, no sqlite — so it compiles for
16+
// embedded/TinyGo targets and passes the archtest gate (hex coding is
17+
// hand-rolled in cred.go rather than encoding/hex, matching core/binaryprimitives'
18+
// style elsewhere in core). A concrete file-backed store lives in the
19+
// adapter/auth/local package (it needs os for file I/O — an adapter concern
20+
// regardless of what core itself may import); a netless target that does not
21+
// need it simply does not import it.
1922
package auth
2023

2124
import (

core/auth/cred.go

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package auth
22

33
import (
44
"crypto/hmac"
5+
"crypto/rand"
56
"crypto/sha256"
67
"crypto/subtle"
78
"errors"
@@ -12,14 +13,15 @@ import (
1213
// cost field. PBKDF2-HMAC-SHA256 is implemented here over crypto/hmac +
1314
// crypto/sha256 so the package needs no golang.org/x/crypto dependency.
1415
//
15-
// core discipline (§1 / archtest): this file imports only crypto/hmac,
16-
// crypto/sha256 and crypto/subtle — all reflection-free. It deliberately does NOT
17-
// import crypto/rand (which transitively pulls reflect) or encoding/hex (likewise):
18-
// SALT GENERATION is the caller's job (a store adapter, which may use crypto/rand
19-
// in the adapter ring), and hex coding is hand-rolled below. So the contract stays
20-
// TinyGo-clean while the randomness lives where reflect is allowed.
16+
// core discipline (§1 / archtest): crypto/hmac, crypto/sha256, crypto/subtle,
17+
// and crypto/rand are all fine in core — reflect itself builds and links under
18+
// TinyGo (see core/csnet/random.go); the archtest gate bans specific generic
19+
// reflection-based *serialization* packages (encoding/json, encoding/binary,
20+
// database/sql), not reflect itself. So salt generation (NewCredential) lives
21+
// here now rather than being the caller's job; hex coding stays hand-rolled
22+
// below regardless, matching core/binaryprimitives' style elsewhere in core.
2123
const (
22-
SaltLen = 16 // expected salt length in bytes (the adapter generates it)
24+
SaltLen = 16 // salt length in bytes NewCredential generates
2325
credIterations = 100000 // PBKDF2 iteration count
2426
credKeyLen = 32 // derived key length (SHA-256 output size)
2527
)
@@ -36,17 +38,29 @@ type Credential struct {
3638
Hash []byte
3739
}
3840

39-
// DeriveCredential derives a Credential for password under the supplied salt. The
40-
// caller (a store adapter) provides the salt — generated with crypto/rand for a
41-
// new user, or decoded from storage when re-deriving. Keeping rand out of here is
42-
// what lets core/auth stay reflection-free.
41+
// DeriveCredential derives a Credential for password under the supplied salt.
42+
// Used to re-derive an existing user's credential (e.g. to re-verify against a
43+
// stored salt) or by NewCredential for a fresh one; a caller decoding a stored
44+
// record uses this directly with the salt from storage.
4345
func DeriveCredential(password string, salt []byte) Credential {
4446
return Credential{
4547
Salt: salt,
4648
Hash: pbkdf2SHA256([]byte(password), salt, credIterations, credKeyLen),
4749
}
4850
}
4951

52+
// NewCredential generates a fresh random SaltLen-byte salt and derives a
53+
// Credential for password under it — the counterpart to DeriveCredential for
54+
// creating a new user (SetUser/change-password) rather than re-verifying one
55+
// already on disk.
56+
func NewCredential(password string) (Credential, error) {
57+
salt := make([]byte, SaltLen)
58+
if _, err := rand.Read(salt); err != nil {
59+
return Credential{}, err
60+
}
61+
return DeriveCredential(password, salt), nil
62+
}
63+
5064
// Verify reports whether password matches the credential, in constant time. A
5165
// zero-value (no Salt/Hash) credential never verifies.
5266
func (c Credential) Verify(password string) bool {

core/auth/cred_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,26 @@ func TestCredentialRoundTrip(t *testing.T) {
6161
}
6262
}
6363

64+
func TestNewCredential(t *testing.T) {
65+
c, err := NewCredential("hunter2")
66+
if err != nil {
67+
t.Fatalf("NewCredential: unexpected error: %v", err)
68+
}
69+
if len(c.Salt) != SaltLen || len(c.Hash) != credKeyLen {
70+
t.Fatalf("credential sizes salt=%d hash=%d", len(c.Salt), len(c.Hash))
71+
}
72+
if !c.Verify("hunter2") {
73+
t.Fatal("Verify rejected the correct password")
74+
}
75+
other, err := NewCredential("hunter2")
76+
if err != nil {
77+
t.Fatalf("NewCredential (second call): unexpected error: %v", err)
78+
}
79+
if string(c.Salt) == string(other.Salt) {
80+
t.Fatal("NewCredential produced the same salt twice in a row (rand.Read broken?)")
81+
}
82+
}
83+
6484
func TestCredentialSaltMakesHashesDiffer(t *testing.T) {
6585
saltA := []byte("aaaaaaaaaaaaaaaa")
6686
saltB := []byte("bbbbbbbbbbbbbbbb")

core/csnet/csnet_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,19 @@ func TestParseIPv4(t *testing.T) {
6666
}
6767
}
6868

69+
func TestRandomMAC(t *testing.T) {
70+
mac := csnet.RandomMAC()
71+
if mac[0]&0x02 == 0 {
72+
t.Errorf("RandomMAC() first octet %02x: locally-administered bit (0x02) not set", mac[0])
73+
}
74+
if mac[0]&0x01 != 0 {
75+
t.Errorf("RandomMAC() first octet %02x: unicast bit (0x01) should be clear", mac[0])
76+
}
77+
if other := csnet.RandomMAC(); mac == other {
78+
t.Error("RandomMAC() returned the same address twice in a row (rand.Read broken?)")
79+
}
80+
}
81+
6982
func TestParseIPv4_Rejects(t *testing.T) {
7083
cases := []string{"", "10.0.0", "10.0.0.256", "10.0.0.1.2", "not-an-ip", "::1"}
7184
for _, in := range cases {

core/csnet/doc.go

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
11
// Package csnet provides shared MAC and IPv4 address parsing/formatting for both
2-
// desktop and TinyGo/embedded builds: ParseMAC/FormatMAC and ParseIPv4/IPv4.
2+
// desktop and TinyGo/embedded builds: ParseMAC/FormatMAC, ParseIPv4/IPv4, and
3+
// RandomMAC.
34
//
4-
// Two implementations exist per concern, selected by the tinygo build tag: the
5-
// default (!tinygo) build wraps the standard library's net package; the tinygo
6-
// build hand-rolls the same operation, since TinyGo's net package does not
7-
// reliably provide ParseMAC/ParseIP on baremetal targets. This mirrors the split
8-
// core/buf and core/hostinfo already use for target-specific behavior (§1) —
9-
// callers use the same API regardless of which build produced it.
10-
//
11-
// RandomMAC deliberately does NOT live here: it needs crypto/rand, which
12-
// transitively imports reflect and is therefore forbidden in the core ring
13-
// (core/internal/archtest, §1). See client/link.RandomMAC instead — every
14-
// current caller (client/etherdfs, client/ncp, client/netbios, client/smb,
15-
// cmd/internal/csconnect) already imports client/link.
5+
// ParseMAC and ParseIPv4 have two implementations each, selected by the tinygo
6+
// build tag: the default (!tinygo) build wraps the standard library's net
7+
// package; the tinygo build hand-rolls the same operation, since TinyGo's net
8+
// package does not reliably provide ParseMAC/ParseIP on baremetal targets. This
9+
// mirrors the split core/buf and core/hostinfo already use for target-specific
10+
// behavior (§1) — callers use the same API regardless of which build produced
11+
// it. RandomMAC needs no such split: crypto/rand builds and links fine under
12+
// TinyGo (see random.go's doc comment for why it's allowed in core at all).
1613
package csnet

core/csnet/random.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package csnet
2+
3+
import "crypto/rand"
4+
5+
// RandomMAC returns a synthetic locally-administered unicast Ethernet address: the
6+
// first octet has the locally-administered bit set and the group/multicast bit
7+
// clear, the rest are random. Used to give a virtual station (a probe tool, a
8+
// client-side transport) its own identity distinct from the host NIC's real MAC,
9+
// so it doesn't collide with the host on the wire.
10+
//
11+
// crypto/rand transitively imports reflect, but reflect itself builds and links
12+
// fine under TinyGo (verified with the real toolchain) — the core ring only bans
13+
// the specific packages that do generic reflection-based *serialization*
14+
// (encoding/json, encoding/binary, database/sql; see core/internal/archtest),
15+
// which crypto/rand is not. So, unlike an earlier version of this package,
16+
// RandomMAC lives here rather than in client/link.
17+
func RandomMAC() [6]byte {
18+
var mac [6]byte
19+
_, _ = rand.Read(mac[:])
20+
mac[0] = (mac[0] | 0x02) &^ 0x01 // locally-administered, unicast
21+
return mac
22+
}

0 commit comments

Comments
 (0)