diff --git a/internal/mcp/token.go b/internal/mcp/token.go new file mode 100644 index 0000000..93658bb --- /dev/null +++ b/internal/mcp/token.go @@ -0,0 +1,38 @@ +package mcp + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" +) + +// tokenRawLen 是 plaintext 随机字节长度。32 字节 = 256 bit 熵, +// base64.RawURL 编码后 43 字符(无 padding);加 "mcpt_" 前缀总 48 字符。 +// +// 与 user.NewSessionToken 的 24 字节比,MCP token 加大到 32 字节是因为: +// admin 签发的 token 长期存在(90 天默认),熵预算更宽。 +const tokenRawLen = 32 + +// tokenPrefix 是 plaintext 的肉眼可读前缀。鉴权时不参与 hash 计算 —— hash +// 是对完整字符串(含前缀)做 SHA-256;前缀仅是日志/调试时区分用。 +const tokenPrefix = "mcpt_" + +// NewToken 生成 (plaintext, hash) 对。plaintext 仅在签发时返回一次, +// 后续认证只用 hash 反查。 +func NewToken() (plaintext string, hash []byte, err error) { + raw := make([]byte, tokenRawLen) + if _, err := rand.Read(raw); err != nil { + return "", nil, fmt.Errorf("rand: %w", err) + } + enc := base64.RawURLEncoding.EncodeToString(raw) + plain := tokenPrefix + enc + return plain, HashToken(plain), nil +} + +// HashToken 对 token plaintext(含前缀)做 SHA-256,返回 32 字节固定长度。 +// 纯函数:相同输入恒返回相同输出,便于鉴权时按请求中的明文反查 DB。 +func HashToken(plaintext string) []byte { + sum := sha256.Sum256([]byte(plaintext)) + return sum[:] +} diff --git a/internal/mcp/token_test.go b/internal/mcp/token_test.go new file mode 100644 index 0000000..0850e71 --- /dev/null +++ b/internal/mcp/token_test.go @@ -0,0 +1,51 @@ +package mcp + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewToken_FormatAndUniqueness(t *testing.T) { + t.Parallel() + + tok1, hash1, err := NewToken() + require.NoError(t, err) + tok2, hash2, err := NewToken() + require.NoError(t, err) + + // 前缀正确 + assert.True(t, strings.HasPrefix(tok1, "mcpt_")) + assert.True(t, strings.HasPrefix(tok2, "mcpt_")) + + // 两次生成不重复(碰撞概率 ≈ 2^-128) + assert.NotEqual(t, tok1, tok2) + assert.NotEqual(t, hash1, hash2) + + // hash 是 32 字节 + assert.Len(t, hash1, 32) + assert.Len(t, hash2, 32) +} + +func TestHashToken_Deterministic(t *testing.T) { + t.Parallel() + + plain := "mcpt_someplaintext" + h1 := HashToken(plain) + h2 := HashToken(plain) + + assert.Equal(t, h1, h2) + assert.Len(t, h1, 32) +} + +func TestNewToken_HashMatchesPlaintext(t *testing.T) { + t.Parallel() + + tok, hash, err := NewToken() + require.NoError(t, err) + + expected := HashToken(tok) + assert.Equal(t, expected, hash) +}