projection: begin the exact-path public epoch
The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally. Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
This commit is contained in:
commit
67e26be2d9
838 changed files with 157302 additions and 0 deletions
96
server/internal/security/totp.go
Normal file
96
server/internal/security/totp.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// Package security provides TOTP (RFC 6238) validation for fleet-join 2FA
|
||||
// (SEC-025). The seed is stored AES-256-GCM encrypted on the registration
|
||||
// token; the host generates the seed and the code. The server only validates.
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// TotpPeriod is the time step in seconds (RFC 6238 default).
|
||||
TotpPeriod = 30
|
||||
// totpDigits is the number of digits in the TOTP code.
|
||||
totpDigits = 6
|
||||
// totpSkew is the number of time steps to accept in each direction.
|
||||
// ±1 step = ±30 seconds, matching standard authenticator apps.
|
||||
totpSkew = 1
|
||||
)
|
||||
|
||||
// GenerateTOTPSeed creates a new random TOTP seed (base32-encoded, 20 bytes).
|
||||
// Called by the standalone host; the server never generates seeds.
|
||||
func GenerateTOTPSeed() (string, error) {
|
||||
buf := make([]byte, 20) // 160 bits, standard for HMAC-SHA1
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("totp: seed generation failed: %w", err)
|
||||
}
|
||||
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// ValidTOTPSeed reports whether seed is valid base32 (the encoding TOTP uses).
|
||||
// Used at token-creation time to reject garbage seeds before they're encrypted
|
||||
// and stored — a seed that can't be decoded can never produce a valid code.
|
||||
func ValidTOTPSeed(seed string) bool {
|
||||
_, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(seed))
|
||||
return err == nil && len(seed) > 0
|
||||
}
|
||||
|
||||
// ValidateTOTPCode checks whether the given 6-digit code is valid for the
|
||||
// seed at the current time, with ±1 step tolerance.
|
||||
func ValidateTOTPCode(seed string, code string) bool {
|
||||
return ValidateTOTPCodeAt(seed, code, time.Now())
|
||||
}
|
||||
|
||||
// ValidateTOTPCodeAt checks the code at a specific time (for testing).
|
||||
func ValidateTOTPCodeAt(seed string, code string, t time.Time) bool {
|
||||
if len(code) != totpDigits {
|
||||
return false
|
||||
}
|
||||
|
||||
seedBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(seed))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
counter := uint64(t.Unix()) / TotpPeriod
|
||||
|
||||
// Check current time step and ±skew.
|
||||
for i := -totpSkew; i <= totpSkew; i++ {
|
||||
c := counter + uint64(i)
|
||||
if i < 0 && c > counter { // underflow guard
|
||||
continue
|
||||
}
|
||||
if totpAt(seedBytes, c) == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// totpAt generates the TOTP code for a given counter value.
|
||||
func totpAt(key []byte, counter uint64) string {
|
||||
buf := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(buf, counter)
|
||||
|
||||
mac := hmac.New(sha1.New, key)
|
||||
mac.Write(buf)
|
||||
sum := mac.Sum(nil)
|
||||
|
||||
// Dynamic truncation (RFC 4226 §5.4).
|
||||
offset := sum[len(sum)-1] & 0x0f
|
||||
code := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff
|
||||
|
||||
// Format as zero-padded digit string.
|
||||
divisor := uint32(1)
|
||||
for i := 0; i < totpDigits; i++ {
|
||||
divisor *= 10
|
||||
}
|
||||
return fmt.Sprintf("%0*d", totpDigits, code%divisor)
|
||||
}
|
||||
78
server/internal/security/totp_test.go
Normal file
78
server/internal/security/totp_test.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerateTOTPSeed(t *testing.T) {
|
||||
seed, err := GenerateTOTPSeed()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateTOTPSeed failed: %v", err)
|
||||
}
|
||||
if len(seed) == 0 {
|
||||
t.Fatal("seed is empty")
|
||||
}
|
||||
// Two seeds should differ.
|
||||
seed2, err := GenerateTOTPSeed()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateTOTPSeed (2) failed: %v", err)
|
||||
}
|
||||
if seed == seed2 {
|
||||
t.Fatal("two generated seeds are identical")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOTPValidation(t *testing.T) {
|
||||
seed := "JBSWY3DPEHPK3PXP"
|
||||
|
||||
// Generate a valid code for a fixed time and verify it passes.
|
||||
fixed := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||
code := CodeAt(seed, fixed)
|
||||
if len(code) != 6 {
|
||||
t.Fatalf("expected 6-digit code, got %q", code)
|
||||
}
|
||||
|
||||
if !ValidateTOTPCodeAt(seed, code, fixed) {
|
||||
t.Fatalf("code %q rejected at exact time", code)
|
||||
}
|
||||
|
||||
// ±30 seconds should also pass.
|
||||
if !ValidateTOTPCodeAt(seed, code, fixed.Add(29*time.Second)) {
|
||||
t.Fatal("code rejected at +29s")
|
||||
}
|
||||
if !ValidateTOTPCodeAt(seed, code, fixed.Add(-29*time.Second)) {
|
||||
t.Fatal("code rejected at -29s")
|
||||
}
|
||||
|
||||
// ±61 seconds should fail (outside ±1 step window).
|
||||
if ValidateTOTPCodeAt(seed, code, fixed.Add(61*time.Second)) {
|
||||
t.Fatal("code accepted at +61s — should be outside tolerance")
|
||||
}
|
||||
if ValidateTOTPCodeAt(seed, code, fixed.Add(-61*time.Second)) {
|
||||
t.Fatal("code accepted at -61s — should be outside tolerance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOTPInvalidCode(t *testing.T) {
|
||||
seed := "JBSWY3DPEHPK3PXP"
|
||||
fixed := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
if ValidateTOTPCodeAt(seed, "000000", fixed) {
|
||||
t.Fatal("all-zero code accepted")
|
||||
}
|
||||
if ValidateTOTPCodeAt(seed, "12345", fixed) {
|
||||
t.Fatal("5-digit code accepted")
|
||||
}
|
||||
if ValidateTOTPCodeAt(seed, "1234567", fixed) {
|
||||
t.Fatal("7-digit code accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// CodeAt generates the TOTP code at a specific time (exposed for testing).
|
||||
func CodeAt(seed string, t time.Time) string {
|
||||
seedBytes, _ := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(seed)
|
||||
counter := uint64(t.Unix()) / TotpPeriod
|
||||
return totpAt(seedBytes, counter)
|
||||
}
|
||||
Loading…
Reference in a new issue