RSA密钥、加密和数字签名

RSA密钥

由上一篇文章我们可以知道,公钥是(e,n)、私钥是(d,n)。而在实际应用中,我们接触到到的不是e、d、n,而是特定格式的数据或者文件。

PKCS

PKCS 全称是 Public-Key Cryptography Standards(公钥加密标准),是由 RSA 实验室与其它安全系统开发商为促进公钥密码的发展而制订的一系列标准,PKCS 目前共发布过 15 个标准。其中比较常用的有:

标准名称格式简介
PKCS#1RSA密码编译标准/定义了RSA的数理基础、公/私钥格式,以及加/解密、签/验章的流程。
PKCS #7密码消息语法标准/参见RFC 2315。规范了以公开密钥基础设施(PKI)所产生之签名/密文之格式。其目的一样是为了拓展数字证书的应用。
PKCS#8私钥消息表示标准.p8Apache读取证书私钥的标准。
PKCS#10证书申请标准.p10 .csr参见RFC 2986。规范了向证书中心申请证书之CSR(certificate signing request)的格式。
PKCS#12个人消息交换标准.p12 .pfx定义了包含私钥与公钥证书(public key certificate)的文件格式。私钥采密码(password)保护。

其中.csr或.certSigningRequest是证书请求格式,拿着这个请求文件向CA获取签名过的证书。譬如我们在配置开发证书时候,先通过钥匙串生成.csr文件,然后上传,苹果根据.csr文件为我们生成开发证书。

pfx,p12文件是二进制格式,同时含私钥和证书,通常有保护密码。在钥匙串中所以可以展开的证书都可以导出p12。

X.509

X.509是常见通用的证书格式。所有的证书都符合为Public Key Infrastructure (PKI) 制定的 ITU-T X509 国际标准。

格式编码形式
.derASCII
.pemBase64
.cer二进制
.crt二进制
  • .cer/.crt是用于存放证书,它是2进制形式存放的,不含私钥。
  • pem文件一般是文本格式的,可以放证书或者私钥,或者两者都有
  • pem如果只含私钥的话,一般用.key扩展名,而且可以有密码保护

ASN.1格式

ASN.1格式在RSA密钥证书中,有举足轻重的地位。上面我们提到的所以证书格式p12、pfx、cer,都是ASN.1格式的。将pem中base64串编码,得到的公司钥实体数据也是ASN.1格式的。

在电信和计算机网络领域,ASN.1(Abstract Syntax Notation One) 是一套标准,是描述数据的表示、编码、传输、解码的灵活的记法。它提供了一套正式、无歧义和精确的规则以描述独立于特定计算机硬件的对象结构。

关于它的语法数据类型等详细介绍,请参看这篇文章

我们来看ASN.1的基本编码规则。ASN.1编码的数据大致分为三个部分,标签(tag)字段+长度(Length)字段+值(Value)字段

标签(tag)字段:关于标签类别和编码格式的信息。

长度(Length)字段:定义内容字段的长度(字节数)。

值(Value)字段:包含实际的数据 。

标签字段(标头)表示了不同的值的数据类型。常见的标头有

标签字段数据类型示例
0x01布尔值true表示为 0x01 01 FF;false表示为 0x01 01 00
0x02整型16位整形数的9表示为 0x02 02 0009
0x03位串(bit string)
0x04八位串(octor string)
0x05空值nil 表示为 0x05 00
0x13(19)可打印的ASCII编码字符串
0x16(22)ASCII编码字符串
0x31数组[3,5]表示为 0x31 06 0x02 01 03 0x02 01 05

RSA密钥的结构

下面我们来看看,公私钥匙到底长什么样子。n、e、d都是怎样存放的。

为了研究方便,我们先用openssl生成一个1024位的RSA私钥

openssl genrsa -out private-key-1024.pem 1024
复制代码

导出公钥

openssl rsa -in private-key-1024.pem -pubout -out public-key-1024.pem
复制代码

公钥数据的结构

pem格式包含的是base64编码的数据。我们取出其中字符串。然后取出首尾标识符及回车符,base64反编码得到ASN.1格式的二进制数据。

公钥的ASN.1结构为

RSAPublicKey :: = SEQUENCE{
	 modulus         INTEGER  n (模长,正整数)
	 publicExponent  INTEGER  e (公钥指数)
}
复制代码

我们取出公钥字符串,然后base64解码,得到34字节数据。他的大致结构如下

0x30 --标头,0x30表示序列类型
0x81 --内容较长,将用后面1(0x80 - 0b10000000)个字节标识长度
0x9f --包含159个字节长度的内容
   
   0x30  --标头,0x30表示序列类型
   0x0d  --数据长度,后面包含13个字节数据
   
	   0x06 --标头,6表示对象标识符
	   0x09 --9个字节
	   // oid值 1.2.840.113549.1.1.1 (rsaEncryption) 
	   0x2a 0x86 0x48 0x86 0xf7 0x0d 0x01 0x01 0x01 
	   
	   0x05 0x00 -- null
 
   0x03 --标头,03表示bitstring位串
   0x81 
   0x8d --141字节长度
   
   
       0x00  --bitstring开头
       0x30  --标头,0x30表示
       0x81  
       0x89  --137个字节长度
    
           0x02  --模长n的标头,2表示整数
           0x81  
           0x81  --129字节
           
           // 模长n的值,129字节存储,128个有效字节
           0x00 
           0xa0 0x29 0xbf 0xd0 0x38 0xfc 0xeb 0xbb 
           0xba 0xa9 0x09 0x90 0x7c 0x34 0xeb 0x9b 
           0xd8 0x61 0x73 0x11 0xd1 0x28 0x49 0x39 
           0xb8 0x43 0xe1 0xc2 0x1e 0xa2 0x87 0x20 
           0x19 0x5c 0xf1 0x50 0x88 0xb2 0x63 0xc0 
           0xd5 0x2b 0x68 0x88 0x52 0x75 0xcd 0xd8 
           0x26 0xba 0xb4 0x30 0x69 0xe0 0xa4 0xe9 
           0xe0 0x3d 0xcf 0xbf 0x67 0xa7 0x98 0xb1 
           0xbe 0x20 0x41 0x73 0x5b 0xe6 0xf0 0x7a 
           0x92 0x41 0x1b 0x62 0x57 0x47 0x60 0x25 
           0xbe 0x3b 0x75 0xed 0x46 0x0e 0x61 0x52 
           0x03 0xa5 0x00 0x59 0x1c 0x3c 0x94 0xd2 
           0x94 0x16 0xbc 0x08 0x6d 0x4f 0xba 0x86 
           0xc6 0xfc 0xd2 0x3c 0x79 0xc4 0x99 0x17 
           0xaf 0xf9 0x5c 0x99 0x50 0xe7 0x28 0x2a 
           0x42 0xd9 0xc7 0x2e 0xba 0x17 0x9c 0x23  
 
           0x02            -- e的标头
           0x03            -- e长度为3个字节
           0x01 0x00 0x01  -- 公钥指数e
复制代码

公钥的pem数据中,主要包含两部分内容,第一部分是OID值,第二部分是公钥数据实体。

OID值(Object Identifier 对象标识符)为1.2.840.113549.1.1.1,它表示PKCS1公钥加密标识符。

其中,各个数字按顺序表示为

  • 1 --ISO标准
  • 2 --member-body(成员主体)
  • 840 --US 美国
  • 113549 --RSADSI
  • 1 --PKCS
  • 1 --PKCS的第一个标准,即PKCS#1
  • 1 --rsaEncryption RSA加密

你可以在这里这里查看他的详细介绍

值得注意的是:

  • 当ASN.1的长度字段较大时,会以多个字节表示长度。在上述数据中,长度字段0x81&0b10000000=1,所以0x81不表示长度,而是其后0x81-0b10000000=1个字节表示长度
  • 当整数类型最高位为1时,会在最前面补0x00。上述数据中,1024位模长对应128字节的n,而前面的0x00不包含在n当中
  • e的值0x010001(65537),是一个固定值,无论用何种方式生成,模长是多少位的,e的值都是一样的。这是加密性能和安全的兼顾。同时由于e一样,所以模长的变化并不会增加加密的时间复杂度。
  • 实际有效的公钥数据,是最后bitstring部分的值

私钥长什么样

私钥的ASN.1结构为

RSAPrivateKey :: = SEQUENCE{
     version            Version,

     modulus            INTEGER,   ------ n
     publicExponent     INTEGER,   ------ e
     privateExponent    INTEGER,   ------ d
     prime1             INTEGER,   ------ p
     prime2             INTEGER,   ------ q
     exponent1          INTEGER,   ------ d mod (p -1)
     exponent2          INTEGER,   ------ d mod (q -1)
     coefficient        INTEGER,   ------- (inverse of q) mod p
     otherPrimeInfos    OtherPrimeInfos   ------ OPTIONAL(当version为0时,不存在;当 version为1时,必须有)
 }

 Version :: = INTEGER{ two-prime(0), multi(1)}
复制代码

值得注意的是私钥文件里边,不但是包含实际有效私钥(e,n),他还包含公钥指数,我们在密钥生成中用到的p、q,以及其他一些信息。这也是我们可以通过私钥导出公钥的原因。

我们将上面的到的私钥字符串base64反编码之后,的到的数据结构如下:

0x30 --标头,序列类型
0x82 --后面2个字节表示长度
0x02 0x5c --数据长度45

    0x02
    0x01
    0x00  --版本号version为0

    0x02
    0x81
    0x81 --129个字节
    // 模数n
    0x00 
    0xa0 0x29 0xbf 0xd0 0x38 0xfc 0xeb 0xbb 
    0xba 0xa9 0x09 0x90 0x7c 0x34 0xeb 0x9b 
    0xd8 0x61 0x73 0x11 0xd1 0x28 0x49 0x39 
    0xb8 0x43 0xe1 0xc2 0x1e 0xa2 0x87 0x20 
    0x19 0x5c 0xf1 0x50 0x88 0xb2 0x63 0xc0 
    0xd5 0x2b 0x68 0x88 0x52 0x75 0xcd 0xd8 
    0x26 0xba 0xb4 0x30 0x69 0xe0 0xa4 0xe9 
    0xe0 0x3d 0xcf 0xbf 0x67 0xa7 0x98 0xb1 
    0xbe 0x20 0x41 0x73 0x5b 0xe6 0xf0 0x7a 
    0x92 0x41 0x1b 0x62 0x57 0x47 0x60 0x25 
    0xbe 0x3b 0x75 0xed 0x46 0x0e 0x61 0x52 
    0x03 0xa5 0x00 0x59 0x1c 0x3c 0x94 0xd2 
    0x94 0x16 0xbc 0x08 0x6d 0x4f 0xba 0x86 
    0xc6 0xfc 0xd2 0x3c 0x79 0xc4 0x99 0x17 
    0xaf 0xf9 0x5c 0x99 0x50 0xe7 0x28 0x2a 
    0x42 0xd9 0xc7 0x2e 0xba 0x17 0x9c 0x23 
    
    
    0x02
    0x03
    0x01 0x00 0x01   --公钥指数e
 
    0x02
    0x81
    0x80 --128个字节
    // 私钥质数d
	0x79 0x69 0xcc 0xb7 0xbb 0x4b 0xb8 0x24 
	0x32 0xc7 0x4b 0xb1 0xd5 0x06 0x85 0x09 
	0x3a 0x49 0xfd 0x62 0x27 0x4d 0x43 0xdd 
	0x56 0x9b 0x56 0xfb 0xc2 0x1f 0x71 0x11 
	0xdb 0x48 0x42 0xc2 0xcb 0x2d 0x78 0x43 
	0x49 0x15 0xc4 0x03 0x7b 0x87 0x44 0x49 
	0x34 0x6a 0xda 0x87 0xcc 0xeb 0x77 0xf8 
	0xb7 0x7e 0x04 0x0b 0xd4 0x37 0x0f 0x9f 
	0x92 0xd6 0x31 0xd7 0x4f 0x90 0xa0 0x8e 
	0x07 0x1a 0xf7 0x0d 0x79 0x25 0xf6 0x1a 
	0x0a 0x83 0x6b 0x00 0x33 0xbd 0x32 0x2c 
	0xb3 0xdd 0x71 0x64 0xb5 0xf8 0xcc 0x9f 
	0x21 0xc3 0x81 0xad 0xab 0xb0 0x1f 0x92 
	0x0b 0xed 0x88 0x76 0x6c 0x95 0xc6 0xe2 
	0xe7 0x28 0x24 0xca 0xa0 0x85 0xc7 0x69 
	0xc2 0x56 0xa2 0x4d 0x70 0x4b 0x59 0xe9 
	
	
    0x02 
    0x41 --65字节
    // 质数p值,有效64字节
    0x00
    0xd5 0x5f 0x27 0xc6 0x84 0xf4 0x37 0xda 
    0xa8 0x10 0x28 0x0f 0x33 0x8f 0x05 0xe7 
    0xa8 0xd3 0x09 0x7f 0xca 0x71 0xfe 0x86 
    0xa0 0x95 0xb3 0x21 0x30 0xb8 0xb4 0xcf 
    0x27 0x89 0x21 0xea 0x6d 0xcd 0xaf 0x34 
    0x2f 0x6d 0x3b 0x64 0xd6 0x41 0x85 0x74 
    0x10 0xd1 0x63 0x29 0xaa 0xf2 0x79 0xc0 
    0x4b 0xed 0x2c 0xf9 0x7b 0x7c 0x43 0x0f

    0x02
    0x41
    // 质数q值,有效64字节
    0x00 
    0xc0 0x29 0x40 0x7a 0x96 0x32 0x89 0xf7 
    0x97 0xbd 0x76 0xa3 0x6c 0xea 0x1b 0x7d 
    0xa4 0x23 0xe3 0x3d 0x4e 0x08 0x1a 0x21 
    0x10 0x48 0x81 0xed 0x29 0x01 0xc5 0xae 
    0xba 0xb9 0x5f 0x98 0x55 0xf4 0x24 0x9c 
    0xb0 0x14 0x97 0xde 0x34 0x07 0x4d 0x5e 
    0x53 0x5b 0x6b 0xc2 0x4d 0xcd 0xaf 0x46 
    0xde 0x9d 0xb8 0x06 0xfd 0x41 0x05 0xad
 
    ......
    
复制代码

值得注意的是,p和q都是模长的一半,64字节,512位。私钥质数d,长度和模长一致,都是128字节,1024位。由于私钥指数d很大,所以解密时耗费的计算力是比较大的。

公私钥的导入

在加密或签名之前,我们需要将上面所说的密钥文件转化为我们的密钥对象。我们通常采用系统的Security框架进行加密,与之对应的。我们需要读取密钥文件并生成SecKey

pem文件的导入

pem是我们最为常见的存储RSA密钥的文件格式。

导入pem密钥时我们需要取出pem中的开始结束标识,再进行base64解密得到密钥data。

然后通过data生成SecKey

let keyClass = type == .public ? kSecAttrKeyClassPublic : kSecAttrKeyClassPrivate
let sizeInBits = data.count * 8
let keyDict: [CFString: Any] = [
    kSecAttrKeyType: kSecAttrKeyTypeRSA,
    kSecAttrKeyClass: keyClass,
    kSecAttrKeySizeInBits: NSNumber(value: sizeInBits),
    kSecReturnPersistentRef: true
]
    
var error: Unmanaged<CFError>?
guard let key = SecKeyCreateWithData(data as CFData, keyDict as CFDictionary, &error) else {
    print(error?.takeRetainedValue() ?? "unkown error")
    return nil
}
复制代码

公私钥唯一区别是,KeyClass,公钥时传kSecAttrKeyClassPublic,而私钥是kSecAttrKeyClassPrivate

p12/pfx文件导入

我们从文件读取p12文件,得到数据data,然后再用data创建SecKey

var item = CFArrayCreate(nil, nil, 0,nil)
let options = pwd != nil ? [kSecImportExportPassphrase:pwd] : [:]
let status = SecPKCS12Import(data as CFData,options as CFDictionary,&item)
if status != noErr {
    return nil
}
    
guard  let itemArr = item as? [Any],
    let dict = itemArr.first as? [String:Any],
    let secIdentity = dict[kSecImportItemIdentity as String]   else{
    return nil
}
    
let secIdentityRef = secIdentity as! SecIdentity
var keyRef : SecKey?
SecIdentityCopyPrivateKey(secIdentityRef,&keyRef)
复制代码

上述代码中的keyRef就是我们获取到的私钥对象。

因为私钥中包含了公钥的所以信息,我们也可以通过私钥keyRef导出公钥

let pubKey1 = SecKeyCopyPublicKey(keyRef)
复制代码

但这样做是毫无意义的,因为当我们拿到p12/pfx时,就意味着我们拿到的是私钥。对于客户端来说是要拿来最数据签名的。如果要做数据加密,我们拿到得将是指包含公钥的pem文件。

RSA加密与解密

Padding

在进行RSA加密之前,我们还需要理解一个重要的概念:padding

为了提高RSA加密的安全性,加密之前往往会在明文前面加上一段包含随机数的padding。加入padding之后的数据结构如下:

EM = 0x00 || 0x02 || PS || 0x00 || M.
复制代码
  • PS(padding string),随机数
  • M,明文

我们知道RSA是分块加密的,而如果有padding,每块还必须减去一部分长度

padding 方式模长(字节)每段明文最大长度(字节)每段密文长度
no paddingnnn
PKCS1nn-11n
OAEPnn-42n

加密的实现

先获取到模长,根据padding计算分块最大长度

// 模长
let blockSize = SecKeyGetBlockSize(key)

// 数据分块的最大长度
var maxChunkSize : Int
switch padding {
case .PKCS1:
    maxChunkSize = blockSize - 11
case .OAEP:
    maxChunkSize = blockSize - 42
case []: // no padding
    maxChunkSize = blockSize
default: // default PKCS1
    maxChunkSize = blockSize - 11
}
复制代码

对数据进行分块加密

var retData = Data()
var idx = 0

while idx < data.count {
    let endIdx = min(idx+maxChunkSize,data.count)
    var chunkData = [UInt8](data[idx..<endIdx])
    var outLen    = blockSize;
    let outBuf    = UnsafeMutablePointer<UInt8>.allocate(capacity:outLen)
    defer { outBuf.deallocate() }
    
    var status = noErr;
    status = SecKeyEncrypt(key,
                           padding,
                           &chunkData,
                           chunkData.count,
                           outBuf,
                           &outLen)
    guard  status == noErr else {
        print("SecKeyEncrypt fail. Error Code: \(status)")
        return nil;
    }
    
    retData.append(UnsafeBufferPointer(start:outBuf, count:outLen))
    idx += maxChunkSize
}
复制代码

其中,核心方法是SecKeyEncrypt。输入公钥key,padding类型,以及当前分块数据chunkData,输出outBuf

需要注意的是,需要同时满足以下三点,否则加密失败。

  • key必须是公钥;
  • chunkData长度必须与padding匹配不能过长;
  • outLen虽然是inout类型,但必须等于模长;

还有就是为了传输方便,一般会将data转化为base64字符串

let ret = retData.base64EncodedString()
复制代码

值得注意的是,由于padding的存在,我们对同一数据进行多次加密,每次加密得到的结果都是不一样的。但是这并不会影响解密的结果,因为padding后的数据结构是固定的,成功解密之后会自动去除无效的数据。

解密的实现

我们拿到的加密数据,一般是base64字符串。我们需要先将其转化为data再base64解码

 let data = Data(base64Encoded:string, options:.ignoreUnknownCharacters)
复制代码

跟加密类似的,解码我们用到SecKeyDecrypt方法,具体实现如下:

let blockSize = SecKeyGetBlockSize(key)
var retData = Data()
var idx = 0
while idx < data.count {
    let endIdx = min(idx+blockSize,data.count)
    var chunkData = [UInt8](data[idx..<endIdx])
    var outLen    = blockSize;
    let outBuf    = UnsafeMutablePointer<UInt8>.allocate(capacity:outLen)
    defer { outBuf.deallocate() }
    
    var status = noErr;
    status = SecKeyDecrypt(key,
                           padding,
                           &chunkData,
                           chunkData.count,
                           outBuf,
                           &outLen)
    guard  status == noErr else {
        print("SecKey decrypt fail. Error Code: \(status)")
        return nil;
    }
    
    let ret1 = UnsafeBufferPointer(start:outBuf, count:outLen)
    retData.append(ret1)
    idx += blockSize
}
复制代码

值得注意的是:

  • key必须是私钥
  • 解密也需要分块进行,每块长度都等于模长
  • outLen虽然是inout的值,解码完之后会变成实际得到的明文长度。但它的初始值不能小于明文长度,否则解密失败。我们取模长值是最稳妥的做法。

RSA签名和认证

一般在做数字签名是,往往不是直接用私钥对明文进行签名。而是将明文进行模中散列函数运算后,对消息摘要进行签名。

在验证的时候,如果用公钥能够对签名进行解密,说明发送者身份没有被仿冒。然后对明文进行散列函数运算得到的摘要与解密的摘要对比,如果一致证明消息和消息摘要在传送过程中都没有被串改。

不同的散列函数,对应不同的padding值

散列函数签名算法pading
MD5MD5WithRSAPKCS1MD5
SHA1SHA1WithRSAPKCS1SHA1
SHA224SHA224WithRSAPKCS1SHA224
SHA256SHA256WithRSAPKCS1SHA256
SHA384SHA384WithRSAPKCS1SHA384
SHA512SHA512WithRSAPKCS1SHA512

签名的实现

先对原始数据进行散列函数运算

var digestData : Data
switch pading {
case .PKCS1MD5:
    digestData = DigestUtil.md5(data:data)
case .PKCS1SHA1:
    digestData = DigestUtil.sha1(data:data)
case .PKCS1SHA1:
    digestData = DigestUtil.sha1(data:data)
case .PKCS1SHA224:
    digestData = DigestUtil.sha224(data:data)
case .PKCS1SHA256:
    digestData = DigestUtil.sha256(data:data)
case .PKCS1SHA384:
    digestData = DigestUtil.sha384(data:data)
case .PKCS1SHA512:
    digestData = DigestUtil.sha512(data:data)
default:
    digestData = data
}
复制代码

对消息摘要进行签名

let blockSize = SecKeyGetBlockSize(key)
var maxChunkSize : Int = blockSize - 11
var retData = Data()
var idx = 0
while idx < digestData.count {
    let endIdx = min(idx+maxChunkSize,digestData.count)
    var chunkData = [UInt8](digestData[idx..<endIdx])
    var outLen = SecKeyGetBlockSize(key);
    let outBuf = UnsafeMutablePointer<UInt8>.allocate(capacity:outLen)
    defer { outBuf.deallocate() }
    var status = noErr;
    status = SecKeyRawSign(key,
                           pading,
                           &chunkData,
                           chunkData.count,
                           outBuf,
                           &outLen)
    if status == noErr {
        let ret1 = UnsafeBufferPointer(start:outBuf, count:outLen)
        retData.append(ret1)
    }else {
        print("SecKey sign fail. Error Code: \(status)")
        return nil;
    }
    idx += maxChunkSize
}
复制代码

可以看到它和加密的实现是很像的,而且还采用了和PKCS1类似的padding

standard ASN.1 padding will be done, as well as PKCS1 padding

可以发现还有两种SecPadding我们没有提到,sigRawPKCS1MD2sigRaw是DSA算法的,使用的很少。PKCS1MD2安全性很低,基本已没人使用。

需要注意的是,由于散列运算之后的结果都是一致的,而即便是长度最大的SHA512也只有64个字节,远远小于RSA签名117字节的最大块长度。所以我们得到的结果,都是128字节。并且多次签名得到的结果都是一致的。

验证的实现

验证之前我们先对原始数据进行与签名相同散列运算,得到摘要digestData

var digestData : Data
switch pading {
case .PKCS1MD5:
    digestData = DigestUtil.md5(data:data)
case .PKCS1SHA1:
    digestData = DigestUtil.sha1(data:data)
case .PKCS1SHA1:
    digestData = DigestUtil.sha1(data:data)
case .PKCS1SHA224:
    digestData = DigestUtil.sha224(data:data)
case .PKCS1SHA256:
    digestData = DigestUtil.sha256(data:data)
case .PKCS1SHA384:
    digestData = DigestUtil.sha384(data:data)
case .PKCS1SHA512:
    digestData = DigestUtil.sha512(data:data)
default:
    digestData = data
}
复制代码

然后我们输入公钥、padding、明文摘要、签名,得到验证是否成功的结果。

var digestBuf = [UInt8](digestData)
let signBuf   = [UInt8](signData)
var status = noErr;
status = SecKeyRawVerify(key,
                         pading,
                         &digestBuf,
                         digestBuf.count,
                         signBuf,
                         signBuf.count)
                         
if status == errSecSuccess {
    return true
} else {
    return false
}
复制代码

可以看到,代码中不存在循环语句。因为签名数据、摘要数据都是固定长度,并且小于等于模长。所以没有分段验证的说法。

如果想看完整的实现,请看这里

参考资料

转载于:https://juejin.im/post/5d208bfa6fb9a07eae2a7edd

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值