ethereum/EIPs-712 Ethereum typed structured data hashing and signing

https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md

eiptitleauthordiscussions-tostatustypecategorycreatedrequires
712
Ethereum typed structured data hashing and signing
Remco Bloemen <remco@wicked.ventures>, Leonid Logvinov <logvinov.leon@gmail.com>, Jacob Evans <jacob@dekz.net>
Draft
Standards Track
Interface
2017-09-12
155, 191

 

一个对结构化数据的哈希和签名标准

翻译出处:https://segmentfault.com/a/1190000015647458

好好认真看看,为什么有这个,因为我们之前EIP-191都是对字符串等简单数据进行处理,而没有对复杂的结构化数据进行处理,如结构体等

 

简易大纲

对数据签名是一个已经被解决的问题如果我们只关注那些字节字符串。遗憾的是在这个真实的世界里,我们关心的是那些复杂的、有意义的信息。把结构化数据进行哈希处理不是件小事,错误的话会导致系统丧失安全性。

因此,谚语“不要推出你自己的加密算法”在这里就适用了。相反,我们需要使用一个经过同行评审的、经过充分测试的标准。这个EIP旨在成为这个标准。

摘要

这是一个对结构化数据哈希和签名的标准,而不仅仅是字节字符串。它包含:

  1. 正确编码功能的理想框架
  2. 结构化数据和solidity中的结构体类似并且兼容的详细说明
  3. 这些结构的实例的安全哈希算法
  4. 这些实例可以被安全地包含在一组可签名消息内
  5. 领域分离的可扩展机制
  6. 新的RPC调用:eth_signTypedData
  7. 应用于EVM的优化的哈希算法

 

动机

这个EIP旨在提高链下消息签名对链上的可用性。我们可以看到,因为节省gas以及减少链上交易的原因,采用链下消息签名的需求日益增长。现在已经被签名的消息,展示给用户的是一串难以理解的16进制的字符串,附带一些组成这个消息的项目的上下文。

这里我们大致描绘了编码结构化数据,并且在用户签名时把结构化数据展示给他们确认的场景。下面就是当用户签名时,应该展现给他们的符合EIP规范的消息 的例子:

 

签名以及哈希概要

签名方案由哈希算法和签名算法组成。以太坊选择的签名算法是secp256k1,哈希算法选择了keccak256,这是一个从字节串𝔹⁸ⁿ到256位字符串𝔹²⁵⁶的函数.

一个好的哈希算法应该满足安全属性,如确定性,第二个预映象阻抗和碰撞阻力。 当应用于字节串时, keccak256函数满足了上述条件。如果我们想将它应用于其他集合,首先我们需要把这个集合映射到字节串。编码函数的确定性和单射性相当重要。

  1.如果它不满足确定性的话,那么验证时刻的哈希可能会不同于签名时刻的哈希,这会导致签名不正确被拒绝。

  2.如果它不是单射的,那么在集合中就会有2个不同的元素哈希完得到相同的值,导致对一个完全不同的不相干的消息,签名也同样适用。

 

交易和字节串

在以太坊中,可以找到关于上述破损的解释例子。以太坊有两种消息,交易𝕋和字节串𝔹⁸ⁿ。这些分别用eth_sendTransactioneth_sign来签名。最初的编码函数encode : 𝕋∪𝔹⁸ⁿ→𝔹⁸ⁿ如下定义:

  • encode(t : T) = RLP_encode(t)
  • encode(b :  𝔹⁸ⁿ) = b

独立来看的话,它们都满足要求的属性,但是合在一起看就不满足了。如果我们采用b = RLP_encode(t)就会产生碰撞(即两种编码得到的结果就是一样的了)。在Geth PR 2940中,通过修改编码函数的第二条定义,这种情况得到了缓解(解决方法):

PR 2940

即在第二种编码方式前加上"\x19Ethereum Signed Message:\n" + len(message)

所以最后的结果是

This PR includes several things:

the behavior of eth_sign is changed. It now accepts an arbitrary message, prepends a known message, hashes the result using keccak256 it calculates the signature of the hash (breaks backwards compatability!).
add personal_sign, same semantics as eth_sign but also accepts a password. The private key used to sign the hash is temporary unlocked in the scope of the request.
add personal_recover, returns the address for the account that created the signature.

personal_sign(hash, address [, password])
web3.eth.personal.sign(dataToSign, address, password [, callback])
personal_recover(message, signature)
web3.eth.personal.ecRecover(dataThatWasSigned, signature [, callback])
The known message added to the input before hashing is "\x19Ethereum Signed Message:\n" + len(message).

举例:

内部编码

web3.eth.personal.sign("Hello world", "0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe", "test password!")
.then(console.log);
> "0x30755ed65396facf86c53e6217c52b4daebe72aa4941d89635409de4c9c7f9466d4e9aaec7977f05e923889b33c0d0dd27d7226b6e6f56ce737465c5cfd04be400"


web3.eth.personal.ecRecover("Hello world", "0x30755ed65396facf86c53e6217c52b4daebe72aa4941d89635409de4c9c7f9466d4e9aaec7977f05e923889b33c0d0dd27d7226b6e6f56ce737465c5cfd04be400").then(console.log);
> "0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe"

 

  • encode(b : 𝔹⁸ⁿ) = "\x19Ethereum Signed Message:\n" ‖ len(b) ‖ b 其中len(b)b中字节数的ASCII十进制编码。

这就解决了两个定义之间的冲突,因为RLP_encode(t : 𝕋)永远不会以\x19作为开头。但新的编码函数依然存在确定性和单射性风险,仔细思考这些对我们很有帮助。

原来,上面的定义并不具有确定性。对一个4个字节大小的字符串b来说,用len(b) = "4"或者len(b) = "004"都是有效的。我们可以进一步要求所有表示长度的十进制编码前面不能有0并且len("")="0"来解决这个问题。

上面的定义并不是明显无碰撞阻力的。一个以"\x19Ethereum Signed Message:\n42a…"开头的字节串到底表示一个42字节大小的字符串,还是一个以"2a"作为开头的字符串?这个问题在 Geth issue #14794中被提出来,也直接促使了trezor不使用这个标准。幸运的是这并没有导致真正的碰撞因为编码后的字节串总长度提供了足够的信息来消除这个歧义。

如果忽略了len(b),确定性和单射性就没有那么重要了。重点是,很难将任意集合映射到字节串,而不会在编码函数中引入安全问题。目前对eth_sign的设计仍然将字节串作为输入,并期望实现者提供一种编码。

 

任意消息

eth_sign方法会假设消息就是字节串形式的。在实践当中,我们不会哈希这些字节串,而是这些不同的dapp𝕄的所有不同语义的消息。遗憾的是,这个集合并不能正式确定。所以,我们用类型化的命名结构集𝕊来近似表示它。这个标准正式确定了𝕊集合并为它提供了确定性的、单射性的编码函数。

只是编码结构体还是不够的。比如两个不同的dapp使用同样的结构,那么用于其中一个dapp的签名消息同样对另一个也是有效的。这种签名是兼容的,这可能是有意而为的行为,在这种情况下,只要dapps预先把重放攻击(replay attack)考虑进来就没什么问题。如果不预先考虑这些问题,那么就会存在安全问题。

解决这个问题的办法啊就是引入一个域名分隔符,一个256位的数字。这个值和签名混合,并且每个域名的值都不一样。这就让针对不同域名的签名无法相互兼容。域名分隔符的设计中要包含Dapp的独特信息,比如dapp的名字,预期的验证者合约地址,预期的Dapp域名等。用户和用户代理可以使用此信息来减轻钓鱼攻击,如果一个恶意的Dapp试图诱骗用户为另一个Dapp的消息签名的话。

重放攻击注意点

这个标准只是关于对消息签名和验证签名。在很多实际应用中,已签名的消息被用来授权一个动作,例如token交换。使用者需要确保当应用程序看到两笔一模一样的已签名消息时依然可以做出正确的行为,这一点十分重要。举个例子,重复的消息需要被拒绝,或者授权的行为应当是幂等的(注:一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同)。至于这是如何实现的,要视特定应用而定,并且超出了本标准的范围。

详细说明

可签名的消息集合由交易和字节串𝕋 ∪ 𝔹⁸ⁿ扩展而来,还包含了结构化数据𝕊。可签名消息集合的最新表示就是`𝕋 ∪ 𝔹⁸ⁿ ∪ 𝕊。他们都被编码成适合哈希和签名的字节串,如下所示:

  • encode(transaction, T) = RLP_encode(transaction)
  • encode(message, 𝔹⁸ⁿ) = "\x19Ethereum Signed Message:\n" ‖ len(message) ‖ message,其中len(message)是message中字节数的非零填充的ascii十进制编码。
  • encode(domainSeparator : 𝔹²⁵⁶, message : 𝕊) = "\x19\x01" ‖ domainSeparator ‖ hashStruct(message),其中domainSeparatorhashStruct(message)如下定义。

这种编码满足确定性,因为单独的组件都满足确定性。同时编码也是单射的,因为在上面三种情况下,第一个字节永远不一样(RLP_encode(transaction))并不会以\x19作为开始。

这种编码同时也和EIP-191兼容。其中的vertion byte这里就是0x01version specific data这里就是32字节的域名分隔符domainSeparatordata to sign在这里就是hashStruct(message)

类型化的结构数据𝕊的定义

为了定义所有结构化数据的集合,我们从定义可接受的类型开始。就像ABIv2一样,这些都和solidity的类型紧密相关。用solidity符号来解释定义就是个例证。该标准特别针对EVM,但旨在脱离与更高级别的语言的关联。例如:

struct Mail {
    address from;
    address to;
    string contents;
}

定义:一个struct类型,具有有效的标识符作为名称并包含零个或多个成员变量。成员变量由一个成员类型和一个名称组成。

定义:一个成员类型可以是一个原子类型,动态类型或者引用类型。

定义:原子类型有:bytes1bytes32uint8uint256int8int256booladdress。这些在solidiy中都有相应的定义。注意没有别名uintint;注意合约地址始终是普通的address。该标准也不支持定点数,未来版本中可能会增加新的原子类型。

定义:动态类型有bytesstring。这些在声明时和原子类型一样,但是它们在编码中的处理是不同的。

定义:引用类型有arrays和structs。arrays可以是固定长度的,也可以是动态长度的,分别用Type[n]Type[]表示。structs是由其名称引用的其他结构体。该标准支持嵌套的struct。

定义:结构化的类型数据𝕊的集合包含所有struct类型的实例。

hashStruct的定义

hashStruct方法如下定义:

  • hashStruct(s : 𝕊) = keccak256(typeHash ‖ encodeData(s)) ,其中 typeHash = keccak256(encodeType(typeOf(s)))

注意typeHash对于给定结构类型来说是一个常量,并不需要运行时再计算。

encodeType的定义

一个结构的类型用name ‖ "(" ‖ member₁ ‖ "," ‖ member₂ ‖ "," ‖ … ‖ memberₙ ")"来编码,其中每个成员(member)都用type ‖ " " ‖ name来表示。举个例子,上面的Mail结构体,就用Mail(address from,address to,string contents) (这就是这个结构体的类型)来编码。

如果结构类型引用其他的结构体类型(并且这些结构类型又引用更多的结构类型),那么就会收集被引用的的结构类型集合,按名称排序并附加到编码中。一个编码的例子就是,Transaction(Person from,Person to,Asset tx)Asset(address token,uint256 amount)Person(address wallet,string name)

encodeData的定义

一个结构体实例的编码:enc(value₁) ‖ enc(value₂) ‖ … ‖ enc(valueₙ),也就是说,成员值的编码按照他们在类型中出现的顺序连接在一起,每个编码后的成员值长度是确定的32字节。

原子类型的值按照如下方法编码:

  • 布尔值falsevalue都分别编码成uint256类型的0或者1
  • 地址都编码成uint160类型
  • 整数(Integer)类型值都符号扩展成256位,并按大端顺序编码。
  • bytes1bytes31是从索引0开始到索引length - 1的数组,它们从自身结束到bytes32的位置都用0填充,并且按照从开始到结束的顺序编码。这对应了她们在ABI v1和v2中的编码。
  • 动态值bytesstring用他们内容的哈希值来编码。(哈希用keccak256方法)
  • 数组值的编码则是把其内容的encodedData连接起来,再对整体进行keccak256。(例如,对someType[5]进行编码,和对包含5个类型为someType的成员的结构体进行编码,是完全一样的)。
  • 结构体值被递归编码成hashStruct(value),对于循环数据不能采用这种定义。

domainSeparator的定义

domainSeparator = hashStruct(eip712Domain)

其中eip712Domain的类型是一个名为EIP712Domain的结构体,并带有一个或多个以下字段。协议设计者只需要包含对其签名域名有意义的字段,未使用的字段不在结构体类型中。

  • string name:用户可读的签名域名的名称。例如Dapp的名称或者协议。
  • string version:签名域名的目前主版本。不同版本的签名不兼容。
  • uint256 chainIdEIP-155中的链id。用户代理应当拒绝签名如果和目前的活跃链不匹配的话。
  • address verifyContract:验证签名的合约地址。用户代理可以做合约特定的网络钓鱼预防。
  • bytes32 salt:对协议消除歧义的加盐。这可以被用来做域名分隔符的最后的手段。

此标准的未来扩展可以添加具有新用户代理行为约束的新字段。用户代理可以自由使用提供的信息来通知/警告用户或者直接拒绝签名。

eth_signTypedData JSON RPC的详细说明

eth_signTypedData方法已经添加进了Ethereum JSON-RPC中。这个方法与`eth_sign相似。

eth_signTypedData

这个签名方法用sign(keccak256("\x19Ethereum Signed Message:\n" + len(message) + message))计算一个以太坊特定的签名。

通过给消息加上前缀,可以将计算出的签名识别为以太坊特定的签名。这可以防止恶意DApp签署任意数据(例如交易),并使用签名来冒充受害者的情况。

注意:用来签名的地址必须解锁。

参数

  1. Address - 20字节 - 对消息签名的账户地址
  2. TypedData - 需要被签名的类型化的结构数据。

类型化的数据是一个JSON对象,它包含类型信息,域名分割参数和消息对象。以下是一个TypedData参数的JSON-schema定义:

{
  type: 'object',
  properties: {
    types: {
      type: 'object',
      additionalProperties: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            name: {type: 'string'},
            type: {type: 'string'}
          },
          required: ['name', 'type']
        }
      }
    },
    primaryType: {type: 'string'},
    domain: {type: 'object'},
    message: {type: 'object'}
  }
}

 

返回值

DATA:签名。就像在eth_sign里一样,它是一个以0x开头的16进制的129字节数组。它以大端模式编码了rsv参数(黄皮书附录F)。字节0…64包含了参数r,字节64…128是参数s,最后一个字节是参数v。注意到参数v包含了链id,这在EIP-155有详细说明。

示例

请求

curl -X POST --data '{"jsonrpc":"2.0","method":"eth_signTypedData","params":["0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826", {"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Person":[{"name":"name","type":"string"},{"name":"wallet","type":"address"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person"},{"name":"contents","type":"string"}]},"primaryType":"Mail","domain":{"name":"Ether Mail","version":"1","chainId":1,"verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"},"message":{"from":{"name":"Cow","wallet":"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"},"to":{"name":"Bob","wallet":"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"},"contents":"Hello, Bob!"}}],"id":1}'

结果

{
  "id":1,
  "jsonrpc": "2.0",
  "result": "0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b915621c"
}

 

关于如何使用solidity中的ecrecover方法来验证用eth_signTypedData得出的签名的例子,可以在EIP712 Example.js中找到。这个合约就被部署在Ropsten和Rinkeby测试网络上。

例子说明

https://github.com/ethereum/EIPs/blob/master/assets/eip-712/Example.js

const ethUtil = require('ethereumjs-util');
const abi = require('ethereumjs-abi');
const chai = require('chai');

const typedData = {
    types: {//声明下面的数据的类型
        EIP712Domain: [
            { name: 'name', type: 'string' },
            { name: 'version', type: 'string' },
            { name: 'chainId', type: 'uint256' },
            { name: 'verifyingContract', type: 'address' },
        ],
        Person: [
            { name: 'name', type: 'string' },
            { name: 'wallet', type: 'address' }
        ],
        Mail: [
            { name: 'from', type: 'Person' },
            { name: 'to', type: 'Person' },
            { name: 'contents', type: 'string' }
        ],
    },
    primaryType: 'Mail',
    domain: {//即EIP712Domain
        name: 'Ether Mail',
        version: '1',
        chainId: 1,
        verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC',
    },
    message: {//即Mail
        from: {//即Person
            name: 'Cow',
            wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826',
        },
        to: {//即Person
            name: 'Bob',
            wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB',
        },
        contents: 'Hello, Bob!',
    },
};

const types = typedData.types;

// Recursively finds all the dependencies of a type
function dependencies(primaryType, found = []) {
    if (found.includes(primaryType)) {
        return found;
    }
    if (types[primaryType] === undefined) {
        return found;
    }
    found.push(primaryType);
    for (let field of types[primaryType]) {
        for (let dep of dependencies(field.type, found)) {
            if (!found.includes(dep)) {
                found.push(dep);
            }
        }
    }
    return found;
}

function encodeType(primaryType) {
    // Get dependencies primary first, then alphabetical
    let deps = dependencies(primaryType);
    deps = deps.filter(t => t != primaryType);
    deps = [primaryType].concat(deps.sort());

    // Format as a string with fields
    let result = '';
    for (let type of deps) {
        result += `${type}(${types[type].map(({ name, type }) => `${type} ${name}`).join(',')})`;
    }
    return result;
}

function typeHash(primaryType) {
    return ethUtil.sha3(encodeType(primaryType));
}

function encodeData(primaryType, data) {
    let encTypes = [];
    let encValues = [];

    // Add typehash
    encTypes.push('bytes32');
    encValues.push(typeHash(primaryType));

    // Add field contents
    for (let field of types[primaryType]) {
        let value = data[field.name];
        if (field.type == 'string' || field.type == 'bytes') {
            encTypes.push('bytes32');
            value = ethUtil.sha3(value);
            encValues.push(value);
        } else if (types[field.type] !== undefined) {
            encTypes.push('bytes32');
            value = ethUtil.sha3(encodeData(field.type, value));
            encValues.push(value);
        } else if (field.type.lastIndexOf(']') === field.type.length - 1) {
            throw 'TODO: Arrays currently unimplemented in encodeData';
        } else {
            encTypes.push(field.type);
            encValues.push(value);
        }
    }

    return abi.rawEncode(encTypes, encValues);
}

function structHash(primaryType, data) {
    return ethUtil.sha3(encodeData(primaryType, data));
}

function signHash() {
    return ethUtil.sha3(
        Buffer.concat([
            Buffer.from('1901', 'hex'),
            structHash('EIP712Domain', typedData.domain),
            structHash(typedData.primaryType, typedData.message),
        ]),
    );
}

const privateKey = ethUtil.sha3('cow');
const address = ethUtil.privateToAddress(privateKey);
const sig = ethUtil.ecsign(signHash(), privateKey);

const expect = chai.expect;
expect(encodeType('Mail')).to.equal('Mail(Person from,Person to,string contents)Person(string name,address wallet)');
expect(ethUtil.bufferToHex(typeHash('Mail'))).to.equal(
    '0xa0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2',
);
expect(ethUtil.bufferToHex(encodeData(typedData.primaryType, typedData.message))).to.equal(
    '0xa0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2fc71e5fa27ff56c350aa531bc129ebdf613b772b6604664f5d8dbe21b85eb0c8cd54f074a4af31b4411ff6a60c9719dbd559c221c8ac3492d9d872b041d703d1b5aadf3154a261abdd9086fc627b61efca26ae5702701d05cd2305f7c52a2fc8',
);
expect(ethUtil.bufferToHex(structHash(typedData.primaryType, typedData.message))).to.equal(
    '0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e',
);
expect(ethUtil.bufferToHex(structHash('EIP712Domain', typedData.domain))).to.equal(
    '0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f',
);
expect(ethUtil.bufferToHex(signHash())).to.equal('0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2');
expect(ethUtil.bufferToHex(address)).to.equal('0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826');
expect(sig.v).to.equal(28);
expect(ethUtil.bufferToHex(sig.r)).to.equal('0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d');
expect(ethUtil.bufferToHex(sig.s)).to.equal('0x07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b91562');

 

personal_signTypedData

同样还有一个对应的personal_signTypedData方法,这个方法接受账户的密码作为最后一个参数。

Web3 API的详细说明

Web3 version 1中新加了两个方法,和web3.eth.sign以及web3.eth.personal.sign类似。

web3.eth.signTypedData
web3.eth.signTypedData(typedData, address [, callback])

使用特定的账户对类型化的数据签名。这个账户需要解锁。

参数

  1. Object - 域名分割和需要签名的类型化数据。根据以上在eth_signTypedData JSON RPC调用中指定的JSON-schema进行结构化。
  2. String|Number - 用来签名数据的地址。或者是本地钱包的地址或索引:ref:web3.eth.accounts.wallet<eth_accounts_wallet>
  3. Function - (非必须)可选的回调函数,返回错误作为第一个参数,结果作为第二个参数。

注意:2.中的address参数同样可以是web3.eth.accounts.wallet <eth_accounts_wallet>中的地址或者索引。然后它会在本地用账户的私钥进行签名。

返回值

Promise返回String - 由eth_signTypedData返回的签名

示例

有关typedData的值,参考上面的eth_signTypedData JSON-API的示例

web3.eth.signTypedData(typedData, "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826")
.then(console.log);
> "0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b915621c"
web3.eth.personal.signTypedData
web3.eth.personal.signTypedData(typedData, address, password [, callback])

web3.eth.signTypedData一样,除了要多加一个password参数。(类比web3.eth.personal.sign

理念

对于新类型,encode方法将扩展为新的情况。编码的第一个字节用来区分这些情况。出于同样的原因,立即开始使用域名分隔符或者typeHash是不安全的。虽然很难,但可以构建出一个typeHash,这恰好也是一个合理的交易的RLP编码的前缀。

域名分割符可以防止和其他相同的结构碰撞。有可能两个Dapp具有同样的结构,比如Transfer(address from, address to, uint256 amount),但它们不应该兼容。通过引入域名分隔符,Dapp开发者可以保证不会发生签名冲突。

域名分隔符也允许相同的结构实例使用多个不同的签名用例在一个给定的Dapp中。在之前的例子中,或许fromto两个都需要提供,通过提供两个不同的域名分隔符,这些签名可以相互区分。

 

posted @ 2018-09-29 17:11  慢行厚积  阅读(900)  评论(0编辑  收藏  举报