├── .gitignore ├── README.md ├── TODO.md ├── key.go └── paycode.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # paycode 2 | 演示支付宝和微信支付的付款码生成原理 3 | 4 | 5 | ### HowToRun 6 | 7 | go run paycode.go `go run key.go mysecret` 8 | 9 | ### Design 10 | 11 | 利用two factor auth+uid生成与时间有关的加密18位数字组成的付款码。 12 | 13 | ### References 14 | 15 | https://tools.ietf.org/html/rfc6238 16 | https://en.wikipedia.org/wiki/Time-based_One-time_Password_Algorithm 17 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | - [X] 时钟不同步问题 4 | - 通过容错允许几分钟内的时钟不同步 5 | - 如果超过了容错范围,给手机客户端发push,让用户自己确认 6 | 7 | - [X] 暴露了uid 8 | 引入另外一套uid,仅仅给该业务使用 9 | 同时,把该uid与real uid进行绑定 10 | 11 | - [X] 防止码被重刷 12 | redis(uid): list(passwd) 13 | 14 | - [ ] 风控 15 | 如果密码多次不对,则suspicous user 16 | 17 | - [ ] key的保护和传递 18 | -------------------------------------------------------------------------------- /key.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/base32" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | func main() { 10 | secret := os.Args[1] 11 | key := base32.StdEncoding.EncodeToString([]byte(secret)) 12 | fmt.Println(key) 13 | } 14 | -------------------------------------------------------------------------------- /paycode.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha1" 6 | "encoding/base32" 7 | "fmt" 8 | "math" 9 | "os" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | const ( 15 | CLOCK_STEP = 30 // in seconds 16 | ONE_TIME_PASSWD_DIGITS = 4 17 | PAYCODE_FMT = "28%04d%08d%04d" 18 | UID = 10203405609 19 | ) 20 | 21 | func toBytes(value int64) []byte { 22 | var result []byte 23 | mask := int64(0xFF) 24 | shifts := [8]uint16{56, 48, 40, 32, 24, 16, 8, 0} 25 | for _, shift := range shifts { 26 | result = append(result, byte((value>>shift)&mask)) 27 | } 28 | return result 29 | } 30 | 31 | func toUint32(bytes []byte) uint32 { 32 | return (uint32(bytes[0]) << 24) + (uint32(bytes[1]) << 16) + 33 | (uint32(bytes[2]) << 8) + uint32(bytes[3]) 34 | } 35 | 36 | func oneTimePassword(key []byte, value []byte) uint32 { 37 | // sign the value using HMAC-SHA1 38 | hmacSha1 := hmac.New(sha1.New, key) 39 | hmacSha1.Write(value) 40 | hash := hmacSha1.Sum(nil) 41 | 42 | // We're going to use a subset of the generated hash. 43 | // Using the last nibble (half-byte) to choose the index to start from. 44 | // This number is always appropriate as it's maximum decimal 15, the hash will 45 | // have the maximum index 19 (20 bytes of SHA1) and we need 4 bytes. 46 | offset := hash[len(hash)-1] & 0x0F 47 | 48 | // get a 32-bit (4-byte) chunk from the hash starting at offset 49 | hashParts := hash[offset : offset+4] 50 | 51 | // ignore the most significant bit as per RFC 4226 52 | hashParts[0] = hashParts[0] & 0x7F 53 | 54 | number := toUint32(hashParts) 55 | 56 | // size to 6 digits 57 | // one million is the first number with 7 digits so the remainder 58 | // of the division will always return < 7 digits 59 | pwd := number % uint32(math.Pow10(ONE_TIME_PASSWD_DIGITS)) 60 | return pwd 61 | } 62 | 63 | // all []byte in this program are treated as Big Endian 64 | func generateOneTimePassword(input string) uint32 { 65 | // decode the key from the first argument 66 | inputNoSpaces := strings.Replace(input, " ", "", -1) 67 | inputNoSpacesUpper := strings.ToUpper(inputNoSpaces) 68 | key, err := base32.StdEncoding.DecodeString(inputNoSpacesUpper) 69 | if err != nil { 70 | fmt.Fprintln(os.Stderr, err.Error()) 71 | os.Exit(1) 72 | } 73 | 74 | // generate a one-time password using the time at 30-second intervals 75 | // server and client must use the same timezone 76 | epochSeconds := time.Now().Unix() 77 | pwd := oneTimePassword(key, toBytes(epochSeconds/CLOCK_STEP)) 78 | return pwd 79 | } 80 | 81 | // -- |--------|-----------|------- 82 | // 28 | x | y | z 83 | // -- |--------|-----------|------- 84 | // AI 4 digits 9 digits 3 digits => 18 digits 85 | func demoPaycode(key string) { 86 | const FACTOR = 5 87 | x := int(generateOneTimePassword(key)) 88 | y := UID/x + FACTOR*x 89 | z := UID % x 90 | paycode := fmt.Sprintf(PAYCODE_FMT, x, y, z) 91 | 92 | // paycode is generated, now given the paycode, decode to uid and validate x 93 | 94 | // get the uid from the paycode 95 | var origX, origY, origZ int 96 | fmt.Sscanf(paycode, PAYCODE_FMT, &origX, &origY, &origZ) 97 | origY -= origX * FACTOR 98 | origUid := origX*origY + origZ 99 | 100 | // validate the x factor 101 | var xValid bool 102 | if int(generateOneTimePassword(key)) == origX { 103 | // TODO 加入时间不一致的容错机制,容许n minutes的误差 104 | // 如果客户端时间和服务器时间相差非常大(e,g. 1h),支付宝的做法是把取到的uid发送 105 | // 到设备上,让用户自己进行支付确认 106 | xValid = true 107 | } 108 | 109 | if origUid != UID || !xValid { 110 | panic("invalid paycode:" + paycode) 111 | } 112 | 113 | fmt.Printf("paycode: %s, uid: %d\n", paycode, origUid) 114 | } 115 | 116 | func main() { 117 | if len(os.Args) < 2 { 118 | fmt.Fprintln(os.Stderr, "must specify key to use") 119 | os.Exit(1) 120 | } 121 | 122 | fmt.Printf("step: %ds\n", CLOCK_STEP) 123 | key := os.Args[1] 124 | for i := 0; i < 100; i++ { 125 | demoPaycode(key) 126 | time.Sleep(time.Second) 127 | } 128 | } 129 | --------------------------------------------------------------------------------