package authenticator import ( "context" "crypto/hmac" "crypto/sha1" "fmt" "strings" "git.wxccs.org/iceking2nd/winauth-go/internal/global" ) // Battle.Net paper-restore protocol constants. The server responds with // a fixed 32-byte challenge to the initiate POST, and a fixed 20-byte // secret to the validate POST. Mismatching sizes are treated as fatal // since the binary protocol has no error envelope. const ( bnetRestoreChallengeSize = 32 bnetRestoreSecretSize = 20 bnetSerialDigits = 14 // CC-NNNN-NNNN-NNNN after stripping dashes bnetRestoreCodeLen = 10 ) // Restore recovers an existing Battle.Net authenticator's secret key // using the 10-character paper restore code the user wrote down when // they first enrolled. // // The wire protocol mirrors the original WinAuth implementation: // // 1. POST → /enrollment/initiatePaperRestore.htm // ← 32-byte challenge // 2. HMAC-SHA1(key = restoreCode-decoded-10-bytes, // data = serial-bytes || challenge) → 20-byte signature // 3. POST → /enrollment/validatePaperRestore.htm // ← 20-byte secret (the new SecretKey) // // SECURITY: restoreCode grants full account control if leaked. We do not // log it, never persist it, and wipe the derived 10-byte key buffer // before returning. The caller's restoreCode string is the caller's // responsibility to manage. func (b *BattleNetAuthenticator) Restore(ctx context.Context, serial, restoreCode string) error { const fn = "internal.authenticator.BattleNetAuthenticator.Restore" logger := global.Log.WithField("func", fn) cleanSerial := normalizeBnetSerial(serial) if len(cleanSerial) < bnetSerialDigits { return fmt.Errorf("battlenet: serial must contain %d digits after the region prefix", bnetSerialDigits) } region := cleanSerial[:2] if _, ok := battlenetURLs[region]; !ok { return fmt.Errorf("battlenet: unknown region %q in serial", region) } logger.WithField("region", region).Debug("starting paper restore") cleanCode := normalizeBnetRestoreCode(restoreCode) if len(cleanCode) != bnetRestoreCodeLen { return fmt.Errorf("battlenet: restore code must be %d characters", bnetRestoreCodeLen) } codeKey, err := decodeRestoreCode(cleanCode) if err != nil { return err } // Zero the derived key on return so it does not linger in stack/heap // after the HMAC call has consumed it. defer func() { for i := range codeKey { codeKey[i] = 0 } }() serialBytes := []byte(cleanSerial) challenge, err := bnetPostBinary(ctx, mobileURL(region)+bnetRestorePath, serialBytes) if err != nil { return fmt.Errorf("battlenet: initiate restore: %w", err) } if len(challenge) != bnetRestoreChallengeSize { return fmt.Errorf("battlenet: restore challenge size %d, want %d", len(challenge), bnetRestoreChallengeSize) } mac := hmac.New(sha1.New, codeKey) _, _ = mac.Write(serialBytes) _, _ = mac.Write(challenge) signature := mac.Sum(nil) // POST body is serial-ascii || HMAC signature. validateBody := make([]byte, 0, len(serialBytes)+len(signature)) validateBody = append(validateBody, serialBytes...) validateBody = append(validateBody, signature...) secret, err := bnetPostBinary(ctx, mobileURL(region)+bnetRestoreValidatePath, validateBody) if err != nil { return fmt.Errorf("battlenet: validate restore: %w", err) } if len(secret) != bnetRestoreSecretSize { return fmt.Errorf("battlenet: restore secret size %d, want %d", len(secret), bnetRestoreSecretSize) } b.SecretKey = secret b.Serial = cleanSerial b.RestoreCodeVerified = true logger.WithField("serial", b.Serial).Info("paper restore succeeded") return nil } // normalizeBnetSerial strips spaces and dashes, upper-cases, and returns // the canonical form ("CCNNNNNNNNNNNN", 14 ASCII bytes when valid). func normalizeBnetSerial(s string) string { s = strings.ToUpper(strings.TrimSpace(s)) s = strings.ReplaceAll(s, "-", "") s = strings.ReplaceAll(s, " ", "") return s } // normalizeBnetRestoreCode strips formatting whitespace / dashes and // upper-cases. The actual character-set validation happens in // decodeRestoreCode. func normalizeBnetRestoreCode(s string) string { s = strings.ToUpper(strings.TrimSpace(s)) s = strings.ReplaceAll(s, "-", "") s = strings.ReplaceAll(s, " ", "") return s } // decodeRestoreCode is the inverse of restoreByteToChar applied 10 // times in a row: each character maps back to one byte (low 5 bits // populated). The 10-byte buffer is what the protocol uses as the // HMAC-SHA1 key for the validate step. func decodeRestoreCode(code string) ([]byte, error) { if len(code) != bnetRestoreCodeLen { return nil, fmt.Errorf("battlenet: restore code must be %d characters", bnetRestoreCodeLen) } out := make([]byte, bnetRestoreCodeLen) for i := 0; i < bnetRestoreCodeLen; i++ { v, ok := restoreCharToByte(code[i]) if !ok { return nil, fmt.Errorf("battlenet: invalid character %q in restore code", code[i]) } out[i] = v } return out, nil } // restoreCharToByte is the inverse of restoreByteToChar. The encoding // uses a 5-bit value: 0–9 → '0'–'9', 10–25 → 'A'..'Z' but skipping // I, L, O, S. We undo the skips to recover the original 5-bit value. func restoreCharToByte(c byte) (byte, bool) { switch { case c >= '0' && c <= '9': return c - '0', true case c >= 'A' && c <= 'Z': // I, L, O, S are deliberately absent from the encoding alphabet // (visually similar to 1 / 1 / 0 / 5). Accepting them would map // to the wrong 5-bit value and silently corrupt the HMAC key. if c == 'I' || c == 'L' || c == 'O' || c == 'S' { return 0, false } v := int(c) if v >= 'T' { v-- } if v >= 'P' { v-- } if v >= 'M' { v-- } if v >= 'J' { v-- } v = v - 'A' + 10 if v < 10 || v > 31 { return 0, false } return byte(v), true } return 0, false }