Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions apps/api-server/internal/handlers/auc_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package handlers

import (
"net/http"

"github.com/gin-gonic/gin"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/services"
)

// AuCHandler handles Authentication Center (AuC) HTTP requests.
type AuCHandler struct {
subscriberService *services.SubscriberService
}

// NewAuCHandler creates a new AuCHandler.
func NewAuCHandler(subscriberService *services.SubscriberService) *AuCHandler {
return &AuCHandler{subscriberService: subscriberService}
}

// GenerateAuthVector generates a 3GPP EPS authentication vector for a subscriber.
//
// @Summary Generate authentication vector
// @Description Runs the Milenage algorithm and returns (RAND, XRES, CK, IK, AUTN).
//
// Called by the MME/AMF during an Attach or TAU procedure.
//
// @Tags auc
// @Produce json
// @Param imsi path string true "Subscriber IMSI (15 digits)"
// @Success 200 {object} services.AuthVector
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/auc/{imsi}/auth-vector [post]
func (h *AuCHandler) GenerateAuthVector(c *gin.Context) {
imsi := models.IMSI(c.Param("imsi"))
if imsi == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{
Error: "IMSI is required",
Code: ErrCodeMissingRequired,
Details: "imsi path parameter must not be empty",
})
return
}

av, err := h.subscriberService.GenerateAuthVector(c.Request.Context(), imsi)
if err != nil {
handleError(c, err, "Failed to generate authentication vector", ErrCodeInternalError)
return
}

c.JSON(http.StatusOK, av)
}

// RegisterAuCRoutes registers AuC routes on the given router group.
// Call this from your router setup alongside other handler registrations.
//
// v1 := r.Group("/api/v1")
// handlers.RegisterAuCRoutes(v1, handlers.NewAuCHandler(subscriberService))
func RegisterAuCRoutes(rg *gin.RouterGroup, h *AuCHandler) {
auc := rg.Group("/auc")
{
auc.POST("/:imsi/auth-vector", h.GenerateAuthVector)
}
}
1 change: 1 addition & 0 deletions apps/api-server/internal/models/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

type SubscriberAccount struct {
IMSI string `json:"imsi"`
MSISDN string `json:"msisdn"`
Balance float64 `json:"balance"`
DataLimit float64 `json:"data_limit"`
DataUsed float64 `json:"data_used"`
Expand Down
1 change: 1 addition & 0 deletions apps/api-server/internal/models/subscriber.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type Subscriber struct {

AuthKey string `json:"auth_key" gorm:"not null"`
OPc string `json:"opc" gorm:"not null"`
SQN int64 `json:"sqn" gorm:"default:0"`
ServingPLMN PLMN `json:"serving_plmn" gorm:"not null"`

EUICCID string `json:"euicc_id"`
Expand Down
2 changes: 1 addition & 1 deletion apps/api-server/internal/services/charging.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func (cs *ChargingService) GetSystemStats(ctx context.Context) (*models.SystemSt

// Count total accounts
var totalAccounts int64
if err := cs.db.DB.WithContext(ctx).Model(&models.SubscriberAccount{}).
if err := cs.db.DB.WithContext(ctx).Model(&models.Subscriber{}).
Count(&totalAccounts).Error; err != nil {
return nil, fmt.Errorf("failed to count total accounts: %w", err)
}
Expand Down
119 changes: 119 additions & 0 deletions apps/api-server/internal/services/subscriber_auc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package services

import (
"context"
"crypto/aes"
"crypto/rand"
"encoding/binary"
"encoding/hex"
"fmt"

"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
)

// defaultAMF is the Authentication Management Field (2 bytes).
// Bit 15 (separation bit) = 1 for 3G/4G per 3GPP TS 33.102 §6.3.3.
var defaultAMF = []byte{0x80, 0x00}

// AuthVector is a 3GPP EPS authentication vector (AV) returned to the MME/AMF.
type AuthVector struct {
RAND string `json:"rand"` // 16 bytes hex — random challenge sent to UE
XRES string `json:"xres"` // 8 bytes hex — expected response from UE (f2)
CK string `json:"ck"` // 16 bytes hex — cipher key (f3)
IK string `json:"ik"` // 16 bytes hex — integrity key (f4)
AUTN string `json:"autn"` // 16 bytes hex — (SQN XOR AK) || AMF || MAC-A
}

// GenerateAuthVector generates a fresh 3GPP EPS authentication vector for the
// subscriber identified by imsi. It atomically increments the subscriber's SQN
// in the database before computing the vector to prevent replay attacks.
func (s *SubscriberService) GenerateAuthVector(ctx context.Context, imsi models.IMSI) (*AuthVector, error) {
// 1. Load subscriber — need K (AuthKey) and OPc
sub, err := s.db.GetSubscriberByIMSI(ctx, imsi)
if err != nil {
return nil, fmt.Errorf("subscriber not found: %w", err)
}

// 2. Decode K and OPc from hex storage
k, err := hex.DecodeString(sub.AuthKey)
if err != nil || len(k) != 16 {
return nil, fmt.Errorf("invalid AuthKey for subscriber %s: must be 32 hex chars", imsi)
}
opc, err := hex.DecodeString(sub.OPc)
if err != nil || len(opc) != 16 {
return nil, fmt.Errorf("invalid OPc for subscriber %s: must be 32 hex chars", imsi)
}

// 3. Atomically increment SQN — prevents replay attacks
sqn, err := s.incrementSQN(ctx, sub)
if err != nil {
return nil, fmt.Errorf("failed to increment SQN: %w", err)
}

// 4. Generate RAND (16 cryptographically random bytes)
randBytes := make([]byte, 16)
if _, err := rand.Read(randBytes); err != nil {
return nil, fmt.Errorf("failed to generate RAND: %w", err)
}

// 5. Run Milenage and return the auth vector
return runMilenage(k, opc, randBytes, sqn, defaultAMF)
}

// runMilenage executes the full Milenage algorithm (3GPP TS 35.206) and
// returns a complete authentication vector.
func runMilenage(k, opc, randBytes []byte, sqn uint64, amf []byte) (*AuthVector, error) {
block, err := aes.NewCipher(k)
if err != nil {
return nil, fmt.Errorf("AES cipher init failed: %w", err)
}

// Encode SQN as 6 bytes (big-endian, 48-bit counter)
sqnBuf := make([]byte, 8)
binary.BigEndian.PutUint64(sqnBuf, sqn)
sqn6 := sqnBuf[2:] // lower 6 bytes

// Shared intermediate value: temp = AES-128(K, RAND XOR OPc)
temp := milenageTemp(block, randBytes, opc)

// f2 + f5: RES (expected response) and AK (anonymity key)
res, ak := milenageF2F5(block, temp, opc)

// f3: CK (cipher key)
ck := milenageF3(block, temp, opc)

// f4: IK (integrity key)
ik := milenageF4(block, temp, opc)

// f1: MAC-A (network authentication code) — uses raw SQN, not SQN XOR AK
macA := milenageF1(block, temp, opc, sqn6, amf)

// AUTN = (SQN XOR AK)[6] || AMF[2] || MAC-A[8] — total 16 bytes
sqnXorAK := xorBytes(sqn6, ak)
autn := make([]byte, 16)
copy(autn[0:6], sqnXorAK)
copy(autn[6:8], amf)
copy(autn[8:16], macA)

return &AuthVector{
RAND: hex.EncodeToString(randBytes),
XRES: hex.EncodeToString(res),
CK: hex.EncodeToString(ck),
IK: hex.EncodeToString(ik),
AUTN: hex.EncodeToString(autn),
}, nil
}

// incrementSQN atomically increments the subscriber's SQN in PostgreSQL and
// returns the new value. Using UPDATE … RETURNING ensures no two concurrent
// requests can receive the same SQN.
func (s *SubscriberService) incrementSQN(ctx context.Context, sub *models.Subscriber) (uint64, error) {
var newSQN int64
err := s.db.DB.WithContext(ctx).
Raw("UPDATE subscribers SET sqn = sqn + 1 WHERE id = ? RETURNING sqn", sub.ID).
Scan(&newSQN).Error
if err != nil {
return 0, fmt.Errorf("SQN increment failed: %w", err)
}
return uint64(newSQN), nil
}
151 changes: 104 additions & 47 deletions apps/api-server/internal/services/subscriber_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,80 +2,137 @@ package services

import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"fmt"
"os"
)

// generateAuthKeys generates authentication keys for the subscriber
// generateAuthKeys generates a fresh Ki (K) and derives OPc for a new subscriber.
// Returns (K_hex, OPc_hex, error).
func (s *SubscriberService) generateAuthKeys() (string, string, error) {
// Generate 128-bit random key (K)
key := make([]byte, 16)
if _, err := rand.Read(key); err != nil {
return "", "", err
k := make([]byte, 16)
if _, err := rand.Read(k); err != nil {
return "", "", fmt.Errorf("failed to generate K: %w", err)
}

// Get OP (Operator variant) from operator configuration
// This should be consistent across all subscribers for the same operator
op := s.getOperatorVariant()
if op == nil {
return "", "", fmt.Errorf("operator variant not configured")
}

// Generate OPc (derived from OP and K) using AES-128 encryption
// OPc = AES-128(K, OP) where OP is encrypted with K
opc, err := s.generateOPc(key, op)
opc, err := deriveOPc(k, op)
if err != nil {
return "", "", err
return "", "", fmt.Errorf("failed to derive OPc: %w", err)
}

return hex.EncodeToString(key), hex.EncodeToString(opc), nil
return hex.EncodeToString(k), hex.EncodeToString(opc), nil
}

// getOperatorVariant returns the operator variant (OP) from configuration
// getOperatorVariant returns the 16-byte OP from environment configuration.
// OP must be kept secret and identical across all subscribers for the same operator.
func (s *SubscriberService) getOperatorVariant() []byte {
// Get operator variant from environment variable or secure configuration
// This should be the same across all subscribers for the same operator
opStr := os.Getenv("OPERATOR_VARIANT")
if opStr == "" {
// Fallback to default for development
opStr = "TelecomOP1234567" // 16-byte operator variant
opStr = "TelecomOP1234567" // 16 bytes — override in production via env
}

// Ensure exactly 16 bytes for AES-128
op := make([]byte, 16)
copy(op, []byte(opStr))

// If shorter than 16 bytes, pad with zeros
if len(opStr) < 16 {
for i := len(opStr); i < 16; i++ {
op[i] = 0
}
}

return op
}

// generateOPc derives OPc from OP and K using AES-128 encryption
func (s *SubscriberService) generateOPc(k, op []byte) ([]byte, error) {
// Create AES-128 cipher block with key K
// deriveOPc computes OPc = AES-128(K, OP) XOR OP per 3GPP TS 35.206 §3.
// BUG FIX: the previous implementation was missing the final XOR OP step.
func deriveOPc(k, op []byte) ([]byte, error) {
if len(k) != 16 || len(op) != 16 {
return nil, fmt.Errorf("K and OP must each be exactly 16 bytes")
}
block, err := aes.NewCipher(k)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
return nil, fmt.Errorf("AES cipher init failed: %w", err)
}
opc := make([]byte, 16)
block.Encrypt(opc, op)
xorInPlace(opc, op) // OPc = AES-128(K, OP) XOR OP ← was missing
return opc, nil
}

// ─── Milenage f-functions (3GPP TS 35.206) ───────────────────────────────────

// milenageTemp computes the shared intermediate value used by all f-functions:
//
// temp = AES-128(K, RAND XOR OPc)
func milenageTemp(block cipher.Block, rand16, opc []byte) []byte {
out := make([]byte, 16)
block.Encrypt(out, xorBytes(rand16, opc))
return out
}

// milenageF1 computes MAC-A (8 bytes) — the network authentication code.
// 3GPP TS 35.206 §2.3: r1=64 bits (8 bytes), c1=0x00…00 (no-op XOR).
func milenageF1(block cipher.Block, temp, opc, sqn6, amf2 []byte) []byte {
// in1 = SQN[0..5] || AMF[0..1] || SQN[0..5] || AMF[0..1]
in1 := make([]byte, 16)
copy(in1[0:6], sqn6)
copy(in1[6:8], amf2)
copy(in1[8:14], sqn6)
copy(in1[14:16], amf2)

x := xorBytes(rotLeft(xorBytes(temp, opc), 8), in1) // rot r1=8 bytes, c1=0 (no-op)
out := make([]byte, 16)
block.Encrypt(out, x)
return xorBytes(out, opc)[:8] // MAC-A = out[0..7]
}

// milenageF2F5 computes RES (8 bytes) and AK (6 bytes) in one pass.
// 3GPP TS 35.206 §2.4 (f2) and §2.7 (f5): r2=0, c2=0x00…01.
func milenageF2F5(block cipher.Block, temp, opc []byte) (res, ak []byte) {
x := xorBytes(temp, opc) // rot r2=0 bits — no rotation
x[15] ^= 0x01 // XOR c2
out := make([]byte, 16)
block.Encrypt(out, x)
result := xorBytes(out, opc)
return append([]byte{}, result[8:16]...), append([]byte{}, result[0:6]...)
}

// milenageF3 computes CK (16 bytes) — the cipher key.
// 3GPP TS 35.206 §2.5: r3=32 bits (4 bytes), c3=0x00…02.
func milenageF3(block cipher.Block, temp, opc []byte) []byte {
x := rotLeft(xorBytes(temp, opc), 4)
x[15] ^= 0x02
out := make([]byte, 16)
block.Encrypt(out, x)
return xorBytes(out, opc)
}

// milenageF4 computes IK (16 bytes) — the integrity key.
// 3GPP TS 35.206 §2.6: r4=64 bits (8 bytes), c4=0x00…04.
func milenageF4(block cipher.Block, temp, opc []byte) []byte {
x := rotLeft(xorBytes(temp, opc), 8)
x[15] ^= 0x04
out := make([]byte, 16)
block.Encrypt(out, x)
return xorBytes(out, opc)
}

// OPc = AES-128(K, OP) - encrypt OP with key K
opc := make([]byte, aes.BlockSize) // AES block size is 16 bytes
// Create ECB mode cipher (as per 3GPP specification for OPc derivation)
// In 3GPP, OPc is derived using AES-128 ECB mode
if len(op) != aes.BlockSize {
return nil, fmt.Errorf("OP must be 16 bytes for AES-128")
// ─── Byte utilities ───────────────────────────────────────────────────────────

// rotLeft rotates a 16-byte slice left by byteCount positions.
func rotLeft(x []byte, byteCount int) []byte {
out := make([]byte, 16)
for i := 0; i < 16; i++ {
out[i] = x[(i+byteCount)%16]
}
return out
}

// Encrypt OP using ECB mode (single block)
block.Encrypt(opc, op)
// xorBytes returns a XOR b (slices must be the same length).
func xorBytes(a, b []byte) []byte {
out := make([]byte, len(a))
for i := range a {
out[i] = a[i] ^ b[i]
}
return out
}

return opc, nil
// xorInPlace XORs b into a in place.
func xorInPlace(a, b []byte) {
for i := range a {
a[i] ^= b[i]
}
}
Loading