Webcrypto 和 Nodejs-Crypto 互操作

发布于 2024-10-25 17:58:33 字数 18655 浏览 4 评论 0

本文中的所有应用和示例,可参见项目代码文件:webcrypto.js

Webcrypto 已经是 Web 标准和 API,现代浏览器应有和支持的密码学工具箱。

在 WebCrypto 之前,我们在客户端通常需要引入一个外部的密码学工具库,如 CryptoJS 或者 SJCL 等,来实现密码学相关的数据处理。WebCrypto 给我们一个新的选项,在不引入第三方程序的情况下,实现和满足上述需求。

在一个具体的应用实现和我们的技术体系中,主要问题在于,需要建立一套体系和架构,使 Webcrypto 作为前端,可以和 Nodejs 作为后端(通过 crypto 实现) 进行交互,来实现业务和数据的安全需求。需要遵循以下原则:

  • 无第三方依赖和库,但可以在应用内做适当封装,简化开发和应用
  • 支持主流算法实现、选择和参数,并在浏览器和服务端可以相互验证
  • 支持能够在网络上使用 HTTP 传输的序列化和反序列化对象,建议使用 Base64URL 和 UTF-8 编码方式
  • 支持客户端/服务器端的交叉运算/逆运算和再运算

在浏览器中,webcrypto 通过 window.crypto 对象(crypto.subtle) 实现,当然,这些实现只能在 https 环境中有效。通常情况下,webcrypto 是 Promise 化的,当然也可以链接调用,实现相关处理的组合。详见项目示例。

可能涉及到的处理过程包括:

编码和转换

首先需要明确,所有编码转换的核心是最基础的数据结构,在客户端和服务器端分别为 Uint8Array 和 Buffer,两者基本上是对等的,都使用一个无符号的 int8 数组来表示信息。
然后以此为基础,进行各种形式的转换,来满足不同的应用需求和场景。

服务器端

在服务器端 Nodejs 提高了非常强大的编码转换,一般通过 Buffer.from 和 toString 方法就可以实现转换,如

Buffer.from(hexstring, "hex").toString("base64")

浏览器端

浏览器端没有类似于 Node 的 buffer 对象,处理和转换不够直观。可能要借助如 TextEncoder,atob,btoa,Uint8Array 等方式和对象进行组合处理。

// convert a Uint8array to hex string  
let hstring = new Uint8Array(ary).reduce((v,x)=> v + ("0"+x.toString(16)).slice(-2),"")

// hex string to Uint8Array 
let harray = new Uint8Array(hexstring.match(/.{1,2}/g).map(b => parseInt(b, 16)));

// array to base64 
let bstr = Array.from(new Uint8Array(ary)).map(byte => String.fromCharCode(byte)).join('');  

// Utf8 to array to base64
let ary = new TextEncoder().encode(signData)
let bstr = btoa(Array.from(ary).reduce((v,b)=> v+ String.fromCharCode(b),""))
console.log("bstr :",bstr)

// base64 to array and decode to utf8 string
let ary = Uint8Array.from(atob(b64str), c => c.charCodeAt(0))
let ostr = new TextDecoder().decode(ary)

// Base64URL from base64 string or reverse
let base64url = (text.indexOf('+')>-1 || text.indexOf('/')>-1 ) 
    ? text.replace(/\//g,'_').replace(/\+/g,'-').replace(/\=/g,'')
    : (text.indexOf('_') > -1 || text.indexOf('-') > -1) 
    ? text.replace(/_/g,'/').replace(/-/g,'+') 
    : text 

随机数发生器

随机生成指定位数的字节数组,可以转换为 Hex 作为随机字符串

let salt = crypto.randomBytes(8);
let iv =[...salt.map(v=>v ^ 0x36), ...salt].map(v=> v^ 0x5c);

也可以随机生成 hex 字符串,然后转换为数组,这是通用方法

let rstr = Math.random().toString(36).slice(2,10)

使用 WebCrypto 处理随机字符和数字

let salt = crypto.getRandomValues(new Uint8Array(8)) // random array
let iv   = [...salt.map(v=>v^0x36),...salt].map(v=>v^0x5c) // two salt as iv
let iter = 800 + 0|Math.random()*400 // random number

摘要算法和消息验证码

webcrypto 和 crypto 都原生支持标准的 sha256 和 hmac-sha256,摘要算法和消息验证码,可以直接使用。传输建议使用 base64,存储使用 hex。

const encUtf = new TextEncoder()

const aryB64 = (ary) => btoa(Array.from(new Uint8Array(ary)).reduce((v,b)=> v + String.fromCharCode(b),""))
const aryHex = (ary) => new Uint8Array(ary).reduce((v,b)=> v+ ("0"+b.toString(16)).slice(-2),"")

const hmac = async (text,passwd) =>{
    const algo = { name: "HMAC", hash: { name: "SHA-256" }}

    let salt = crypto.getRandomValues(new Uint8Array(8))
    let skey = encUtf.encode(passwd)
        
    let sign = await crypto.subtle
        .importKey("raw", skey, algo, false, ["sign"])
        .then( k1 => crypto.subtle.sign( "HMAC", k1, salt))
        .then( s1 => crypto.subtle.importKey("raw", s1, algo, false, ["sign"]))
        .then( k2 => crypto.subtle.sign( "HMAC", k2, encUtf.encode(text)))
        .then( s2 => aryB64(s2)+"."+aryB64(salt))
        .catch(err=>{
        console.log(err.message)
        return null
        })
    console.log("hmac:",sign)
}

const sha256 = async (text)=> {  
    let sresult = await crypto.subtle.digest("SHA-256", encUtf.encode(text))
        .then(aryHex)
        .catch(err=>{
        console.log(err.message)
        return null
        })
    
    console.log("sha:", sresult)
}

sha256("颜建华 123")

hmac("颜建华 123","password")

可以看到,核心分别是 digest 和 sign 方法,注意本例中,使用了共享密码加密的随机密钥作为 hmac 的密钥。

在 nodejs 端,这个处理就更加简单了:

let sha256  = crypto.createHash("sha256").update("hellodata").digest("hex");
let hmac256 = crypto.createHmac("sha256","password").update("hellodata").digest("hex");

HashCode

可以在摘要或消息验证码的基础上,进一步使用将其简化成为一个 HashCode 数值,作为一种简单的签名和验证信息。
其实现的基本思路和 Java HashCode 类似,参考代码如下

hashCode (data, option = { input : "string", output: "i36"}) {
    let ichar, 
    rcode = 0

    if (!data || data.length ==0 ) return 0

    switch(option.input) {
        case "array":
            for (let i = 0, l= data.length; i < l; i++) {
                rcode = rcode * 31 + data[i]
                rcode &= rcode // Convert to 32bit integer
            }
        break
        case "hex":
            for(let i=0,l=data.length; i<l; i+=2) {
                rcode = rcode * 31 + parseInt(data.slice(i,i+2))
                rcode &= rcode // Convert to 32bit integer
            }
        break

        default: // string or utf8
            for (let i = 0, l= data.length; i < l; i++) {
                rcode = rcode*31 + data.charCodeAt(i)
                rcode &= rcode // Convert to 32bit integer
            }
    }
  
    if (option.output != "int") { // uint, hex, i36 
        rcode &= 0x7FFFFFFF
    } 

    switch(option.output) {
        case "i36": return rcode.toString(36)
        case "hex": return rcode.toString(16)
        default   : return rcode
    }
}

此方案中,输出结果本质上为一个 32 位整数,默认的输入为 UTF8 字符串(和 UTF8 解码后再计算结果的结果不同),可选 Hex 字符串、Uint8Array;

默认输出为无符号 i36 格式,但可选整数、无符号整数和 16 进制整数。

密码衍生算法

基于数据加密安全特别是前向安全性的考虑,一般在密钥协商成功之后,不建议直接使用共享密码作为加密使用,而使用一些密钥衍生算法,生成随机动态密钥来进行信息的加密。生成过程中的辅助参数,如 salt、iv、iter 等,本身不需要再加密,也需要在传递信息时一并传递。这类算法通常被称为 KDF(Key Derivation Function,密钥派生函数),在加密过程中使用 KDF,可以通过大量消耗算力和资源,有效提高暴力破解的难度,并提高更好的前向安全性。

webcrypto 和 crypto 都支持 pbkdf2 密码衍生算法。建议作为标准使用。详见对称加密章节。

密钥对象

webcrypto 和 crypto 均提供了一系列方法和参数,来对密钥进行处理。主要是密钥(对) 生成、导入导出等方法。一般而言,在进行加密、解密、签名、验证、密钥协商等操作是,都需要进行密钥的生成或者导入。

在 webcrypto 中,常见的密钥操作包括

  • generateKey - 生成密钥或密钥对
  • importKey - 导入密钥
  • exportKey - 导出密钥, 用于存储或者交换,格式建议为 der,通常公钥使用 spki 类型,私钥使用 pkcs8 类型。不建议直接使用 raw 或者 jwk 格式(只限定在浏览器中操作)

webcrypto 中的相关操作,详见后续的签名和验证章节。

crypto 中的操作方式类似,示例代码如下:

// generate ecc key pair 
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'secp384r1'  });

// export public key 
let spkey = base64URL(publicKey.export({ type: "spki", format: "der"}).toString("base64"));
console.log(`spkey: ${spkey}`);

// export to private key 
let svkey = base64URL(privateKey.export({ type: "pkcs8", format: "der"}).toString("base64"));
console.log(`svkey: ${svkey}`);

// load public key from string 
let publicKey  = crypto.createPublicKey({
    key: base64Buf(cpKey),
    format: "der",
    type: "spki",
});

// load private key from string 
let privateKey = crypto.createPrivateKey({
    key: base64Buf(cvKey),
    format: "der",
    type: "pkcs8",
});

crypto 的密码衍生算法示例如下:

  // key generator,参数分别为 passwd,salt,iv, iter, hashmethod 
   key = crypto.pbkdf2Sync(Buffer.from(passwd), salt, iter, 32, ALGO_GCM.HASH);

密钥协商算法

密钥协商,使用两端支持的 ECDH 相关方法,基本参数为 secp384r1-sha256。

在 crypto 的代码示例如下:

const cpkey = "BP_CZN3_2RXzHLxx16rH0YPKNDigg_ojbJrPJC1ADOBp-66WbnPUy4IOmwb-QVvPhczmk8pdA4P64xRxmh3LcB_CYHQXulezVSbU7Qtd3BU9Ml6yip9tVHFiwNbPWL8pfQ";
const svkey = "hyJ45QUei2duPLmlq+KIgdEs+R6N/rkFoUXnXscSMYTSR8eajGgkLfFbD7Gjpegl";
const spkey = "BOprhzRyNBgh0Fno83GLC39E6Qj0V2PujPM9CONm0BrPkDf3SaY5aoNpZKDJwYjJzsmhjzUuZwjF82rdDySYdHhEhVGJwX5SE75m02xR2jlCugfvsMKspCQ94fTDO5uoAw==";

const alice = crypto.createECDH('secp384r1');
// alice.generateKeys(); // generate if needed
alice.setPrivateKey(Buffer.from(svkey,"base64"));

const alicePublicKey  = alice.getPublicKey('base64');  // 97bit
const alicePrivateKey = alice.getPrivateKey('base64');  // 48bit 

console.log(`publicKey: ${alicePublicKey}`);
console.log(`privateKey: ${alicePrivateKey}`);

const sharedSecret = alice.computeSecret(cpkey, 'base64', 'hex').slice(0,64);
console.log(`sharedSecret: ${sharedSecret}`);

crypto 可以生成密钥对,也可以从存储的密钥字符串中恢复密钥,只需要保存私钥,可以计算出公钥。需要注意,此曲线会协商出 384 位的密钥,如果需要生成 256 位的密钥,需要进行截断。

在 webcrypto 端的示例代码如下:

const cpkey2 = "BP_CZN3_2RXzHLxx16rH0YPKNDigg_ojbJrPJC1ADOBp-66WbnPUy4IOmwb-QVvPhczmk8pdA4P64xRxmh3LcB_CYHQXulezVSbU7Qtd3BU9Ml6yip9tVHFiwNbPWL8pfQ"
const cvkey2 = "MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDD5PS0O5M_dYVxoPmpuBQONVfY8u_tVZTF-huLl26ALVttwDvB5YTH62fRR6H_e3vKhZANiAAT_wmTd_9kV8xy8cdeqx9GDyjQ4oIP6I2yazyQtQAzgafuulm5z1MuCDpsG_kFbz4XM5pPKXQOD-uMUcZody3AfwmB0F7pXs1Um1O0LXdwVPTJesoqfbVRxYsDWz1i_KX0"

const spkey2 = "BOprhzRyNBgh0Fno83GLC39E6Qj0V2PujPM9CONm0BrPkDf3SaY5aoNpZKDJwYjJzsmhjzUuZwjF82rdDySYdHhEhVGJwX5SE75m02xR2jlCugfvsMKspCQ94fTDO5uoAw=="

// server public and client private key
let cc = await ecdh(spkey2,cvkey2)
console.log("ecdh :", cc )

// ecdh method if not set client key, will generate key pair
async function ecdh(spkey,ckey) {
    let sKey, cKeys,
    aspKey = b64Ary(spkey)

    return crypto.subtle
    .importKey("raw", aspKey , ALGO_ECDH, false, [] )
    .then(k1 => { // get server key object 
        sKey = k1
        if (ckey) { // import private key 
            return crypto.subtle.importKey("pkcs8", b64Ary(ckey),ALGO_ECDH, false, ["deriveKey", "deriveBits"] )
        } else { // genarate client keypair
            return crypto.subtle
                .generateKey(ALGO_ECDH, true, ["deriveKey", "deriveBits"]) //whether the key is extractable (i.e. can be used in exportKey)
                .then( key=> {
                    cKeys = key
                    return cKeys.privateKey
                })
        }
    })
    .then( vkey => crypto.subtle.deriveKey({ ...ALGO_ECDH, public: sKey, }, vkey, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]))
    .then (eKey => crypto.subtle.exportKey( "raw", eKey))
    .then ( shareKey=>{
        let rkey = {
            shareKey : ary2Hex(shareKey)
        }

        // only return result
        if (ckey) return rkey

        // export share key and key pair
        return crypto.subtle
            .exportKey( "raw", cKeys.publicKey)
            .then( pk=>{
                rkey.pkey = ary2B64(pk)
                return crypto.subtle.exportKey( "pkcs8", cKeys.privateKey)
            })
            .then( vk=>{
                rkey.vkey = ary2B64(vk) 
                return rkey
            })
    })
    .catch(err=>{
        console.log(err)
        return null
    })
}

两端使用同一种公钥的编码方式,即默认的 crypto.getPublicKey("base64") 导出的 base64 字符串,在 webcrypto 使用 raw 方式导入。
私钥方面,均保留各自的处理方式。webcrypto 使用 pkcs8, crypto 为 base64。

对称加密算法

对称加密算法,选择 aes-256-gcm,是基于双方支持和兼容的考虑。临时加密过程,共享密钥使用 pbkdf2 算法;会话加密使用 DH 生成的 key。
webcrypt 的实现如下:

// encrpt use aes Gcm use webcrypt
async function aesGcmEncrypt(plaintext, password) {
    // salt iv and iter generate
    let salt = crypto.getRandomValues(new Uint8Array(8))                             // get 96-bit random iv
    let iv   = Uint8Array.from([...salt,...salt]) // two salt as iv
    let iter = 800 + 0|Math.random()*400
    
    // password to key
    const cbuffer = await crypto.subtle.importKey(
        "raw",
        encUtf.encode(password),
        "PBKDF2",
        false,
        ["deriveBits", "deriveKey"]
    ).then( km => {
        return crypto.subtle.deriveKey(
            {   salt,
                name: "PBKDF2",
                hash: "SHA-256",
                iterations: iter
            },
            km,
            { name: "AES-GCM", length: 256},
            true,
            [ "encrypt", "decrypt" ]
        )
    }).then( key => {
        return crypto.subtle.encrypt(
            { name: 'AES-GCM', iv }, 
            key, 
            encUtf.encode(plaintext)
        ) // encrypt plaintext using key
    })

    // encrypt 
    return [ary2B64(cbuffer),ary2B64(salt),iter].join(".")                                                   // return iv+ciphertext
}

加密后输出的结果,除加密内容外,还可能包括 salt、iter 等其他参数。在 nodejs 端的实现如下:

const ALGO_GCM = {
    TEXT   : "aes-256-gcm",
    HASH   : "sha256",
    TGSIZE : 16
}

// user passwd encrypt 
exports.aes_gcm_encrypt = (ptext, passwd) => {
    if (typeof ptext == "object") ptext = JSON.stringify(ptext);

    let key, iv, r, tag,
    salt = crypto.randomBytes(8),
    iter = 800 + 0 | Math.random()*400;
    
    try {
        // key generator
        key = crypto.pbkdf2Sync(Buffer.from(passwd), salt, iter, 32, ALGO_GCM.HASH);
        
        iv = Buffer.concat([salt,salt]);
        const cipher = crypto.createCipheriv(ALGO_GCM.TEXT, key, iv);

        r  = Buffer.concat([cipher.update(ptext), cipher.final(), cipher.getAuthTag()]).toString("base64");
        
        r = [r,salt.toString("base64"),iter].join(".").replace(/\//g,'_').replace(/\+/g,'-').replace(/\=/g,'');;
    } catch (error) {
        console.log(error.message);
        r = null;
    }

    return r;
}

解密的操作类似,比较关键的是 key 的导入和处理,这里不再累述。

签名和验证

经过测试和评估,签名验证建议采用以下算法和主要参数

  • 基础签名验证算法为 ECDSA
  • 选择的曲线为 P-384(对应 secp384r1)
  • 摘要方式为 SHA256

在 webcrypto 中,实现的示例代码如下:

// algo param
const 
ECDH_CURVE = "P-384"  //can be "P-256", "P-384", or "P-521"
ALGO_HASH  = "SHA-256",
ALGO_DSA   = { name: "ECDSA", namedCurve: ECDH_CURVE },
ALGO_ECDH  = { name: "ECDH",  namedCurve: ECDH_CURVE },
ALGO_SIGN  = { name: "ECDSA", hash: { name: ALGO_HASH }},
ALGO_AES   = "AES-GCM"

async function sign(data, cvkey) {
    let cKeys,
    adata  = new TextEncoder().encode((typeof data === "object") ? JSON.stringify(data) : data)

    return crypto.subtle
    .generateKey(ALGO_DSA, true, ["sign"])
    .then( key=> {
        if (cvkey) {
            return crypto.subtle.importKey("pkcs8", b64Ary(cvkey), ALGO_DSA, false, ["sign"] )
        } else {
            cKeys = key
            return cKeys.privateKey
        }
    })
    .then(vkey => crypto.subtle.sign( ALGO_SIGN , vkey, adata ))
    .then(sign =>{
        sign = ary2B64(sign)
        if (cvkey) return { sign }  

        return crypto.subtle
            .exportKey( "spki", cKeys.publicKey)
            .then( pk=>{
                cpKey = ary2B64(pk)
                return crypto.subtle.exportKey( "pkcs8", cKeys.privateKey)
            })
            .then( vk=>{
                cvKey = ary2B64(vk)
                return  { sign, cpKey, cvKey }
            })
        
    })
    .catch(err => {
        console.log(err)
        return null
    })
}

注意信息!!

默认的情况下,WebCrypto 的签名和验证无法和 NodeCrypto 直接兼容,原因是默认情况下 NodeCrypto 使用 ASN1 编码来处理信息,而 WebCrypto 使用 IEEE1363 进行编码。
当然,可以使用如 ASN1 库来进行编码的转换,但最简单的处理的方式是在 Nodejs 签名是,直接使用 1363 编码,这样可以直接在客户端进行验证;同样,在客户端签名后不做处理,传输到服务器端验证时,使用 1363 编码方式引入处理。这一操作的核心是在签名和验证方法中设置 dsaEncoding 选项为 ieee-p1363。

在 nodejs 端,具体实现示例如下:

// Server side sign with private key object 
let sign1363 = crypto.sign("SHA256", Buffer.from(signData), {key: privateKey, dsaEncoding: "ieee-p1363"}).toString("base64");
console.log("sign1363:",sign1363, sign1363.length);

// Client side verify no special convert
return crypto.subtle.verify({ name: "ECDSA", hash: {name: "SHA-256"}, }, pkey, asign, adata )

// server side verify use 1363
let vresult = crypto.verify("SHA256",Buffer.from(data), { key:publicKey, dsaEncoding:"ieee-p1363" }, base64Buf(sign));
console.log("verify2:",vresult);

附加说明

ASN sign 转换

针对 webcrypto 和 nodecrypto 签名和验证信息不相容的问题,在网络上有人提出了将签名内容转换后,再验证的解决方案。

在 webcrypto 中的,示例代码如下,转换后提交给 nodejs crypto 验证通过:

// Auxs
const hlength = (hex)=> {
    return ('00' + (hex.length / 2).toString(16)).slice(-2).toString();
}
const asnSign = (sign)=> {
    let 
    signHex = Array.prototype.map.call(sign, x => ('0' + x.toString(16)).slice(-2)).join(''),
    r = signHex.substring(0,96),
    s = signHex.substring(96),
    rPre = true,
    sPre = true;

    while(r.indexOf('00') === 0) {
        r = r.substring(2)
        rPre = false
    }

    if (rPre && parseInt(r.substring(0, 2), 16) > 127) {
        r = '00' + r;
    }

    while(s.indexOf('00') === 0) {
        s = s.substring(2)
        sPre = false
    }

    if(sPre && parseInt(s.substring(0, 2), 16) > 127) {
        s = '00' + s
    }

    let payload = '02' + hlength(r) + r + '02' + hlength(s) + s

    return '30' + hlength(payload) + payload;
}

此方案应该应用到了 ASN1 的编码方式和原理,但遗憾的是,作者没有提供反向的转换代码,这一就不能实现在 node 中默认签名,在 web 中验证的方式。

选择 GCM 模式的原因和 authtag 的处理

之所以建议选择 AES-GCM 模式,因为它是一种 AEAD(Authenticated Encryption with Associated Data,带有相关数据的认证加密) 加密形式的实现,和一般的 AES 加密方式相比,它可以同时具备保密性,完整性和可认证性。AEAD 产生的原因很简单,单纯的对称加密算法,其解密步骤是无法确认密钥是否正确的。也就是说,加密后的数据可以用任何密钥执行解密运算,得到一组疑似原始数据,而不知道密钥是否是正确的,也不知道解密出来的原始数据是否正确。

虽然结合 Hash 和 AES 加密算法,也可以对加密过程和数据进行验证,但无论是 ETM(Encryption Then Mac,先加密后摘要)、E&M(Encryption and MAC,加密和摘要)、MTE(MAC Then Encryption 摘要后加密),都有一定的安全问题,所以需要从更底层设计如 GCM 这种加密结合验证的模式。

GCM ( Galois/Counter Mode) 是一种 AEAD 实现,它在 AES 计数器模式(Counter) 的基础上,增加了 GMAC 消息验证码,它利用伽罗华域(Galois Field,GF,有限域) 乘法运算来计算消息的 MAC 值。其他的 AEAD 实现还包括 AES-CCM 等,选择 GCM 的原因是在 WebCrypto API 中和 Nodejs 中都有原生支持。

在实现细节上,和普通的 AES 模式如 CBC 相比,这一类的模式会增加一个 authtag 数据,这一数据在 webcrypto 中,会直接合并到加密内容当中,所以在 crypto 端解密时,需要先分离此内容,并设置到解密实例中。如下述代码所示(此 tagsize 默认值为 16):

    content = Buffer.from(content,"base64");
    tag     = content.slice(-ALGO_GCM.TGSIZE); 
    content = content.slice(0,-ALGO_GCM.TGSIZE);

    ...
 
     // deciphter 
    const decipher = crypto.createDecipheriv(ALGO_GCM.TEXT, key, iv);
    decipher.setAuthTag(tag);

    // default utf8
    r = decipher.update(content) + decipher.final();

同样,在 nodejs 端加密的时候,需要将 tagdata 合并到加密结果中:

    const cipher = crypto.createCipheriv(ALGO_GCM.TEXT, key, iv);
    r  = Buffer.concat([cipher.update(ptext), cipher.final(), cipher.getAuthTag()]).toString("base64");

其他的具有验证功能的 AES 模式,都可以使用类似的处理方式。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

凑诗

暂无简介

0 文章
0 评论
22 人气
更多

推荐作者

新人笑

文章 0 评论 0

mb_vYjKhcd3

文章 0 评论 0

小高

文章 0 评论 0

来日方长

文章 0 评论 0

哄哄

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文