Dante项目开发中遇到底层的一些疑问和建议

目前,Dante隐私存储项目正在有条不紊的推进中。在项目开发至今,遇到了一些小小的疑问,在研发团队的努力下,研究出了相关的解决方法并提出了简单的建议。在此我们将这些疑问和相关的建议提出来,一是希望能够与社区的技术爱好者们进行深入的讨论,二是希望能够为今后其他生态项目的开发者们带来一定的帮助。

比较有意思的一个问题

首先,需要特别说明的是,这个问题是关于链下用户自定义并签名的一条数据,在链上进行签名验证的问题。(并非发起交易的交易签名)

问题描述:

通过 PlatonON JS SDK 提供的 web3.platon.accounts.sign 对数据进行签名,在 wasm 智能合约中调用 platon::platon_ecrecover,返回验证失败。

问题还原过程:

1.在 Node.js 环境中执行以下代码,通过 PlatON 提供的 client-sdk-js 计算出签名数据。

    const dataToSign = "Hello World";
    const privateKey = "0x4940cf212544505a0fad3e3932734220af101da915321489708f69bc908fda65"; // private key, Testnet only
    const signed = await web3.platon.accounts.sign(dataToSign, privateKey);
    console.log(signed);

返回值为:

{ message: 'Hello World',
  messageHash:
   '0xa1de988600a42c4b4ab089b619297c17d53cffae5d5120d82d8a92d0bb3b78f2',
  v: '0x1b',
  r:
   '0xf28b8fde3ea610e59590da237b9e99871390fd458baae8d30e2da070ac9850f7',
  s:
   '0x0cb89a8fb817d73a3709b375c3aee82a5b64fae5743cbd95feff1bb6965f040b',
  signature:
   '0xf28b8fde3ea610e59590da237b9e99871390fd458baae8d30e2da070ac9850f70cb89a8fb817d73a3709b375c3aee82a5b64fae5743cbd95feff1bb6965f040b1b' }

2.将以上的原始数据以及签名发送到 PlatON wasm 合约进行验签,合约代码如下:

string demo = "Hello World";
string message = "\u0019Ethereum Signed Message:\n" + std::to_string(demo.length()) + demo;
auto hashed_value = platon::platon_sha3(asBytes(message));
Address recovered_address;
auto result = platon::platon_ecrecover(hashed_value, fromHex("0xf28b8fde3ea610e59590da237b9e99871390fd458baae8d30e2da070ac9850f70cb89a8fb817d73a3709b375c3aee82a5b64fae5743cbd95feff1bb6965f040b1b"), recovered_address);
DEBUG(result);
DEBUG(recovered_address.toString());

输出结果:

-1 
lat1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq542u6a

通过日志可以看到验证失败。

原因分析:

通过 PlatON-Go 中的测试用例,我们尝试导入同一个私钥,对上述生成的 messageHash 再次进行验名,返回值为:

0xf28b8fde3ea610e59590da237b9e99871390fd458baae8d30e2da070ac9850f70cb89a8fb817d73a3709b375c3aee82a5b64fae5743cbd95feff1bb6965f040b00

将这个签名发到上述 wasm 合约进行验证,输出结果:

0 
lat1qavfd7zwaknrxyx0drcmv0vr5zehgthhaqq6ul

显示验证成功。

我们看一下两个签名的区别:

0xf28b8fde3ea610e59590da237b9e99871390fd458baae8d30e2da070ac9850f70cb89a8fb817d73a3709b375c3aee82a5b64fae5743cbd95feff1bb6965f040b00

0xf28b8fde3ea610e59590da237b9e99871390fd458baae8d30e2da070ac9850f70cb89a8fb817d73a3709b375c3aee82a5b64fae5743cbd95feff1bb6965f040b1b

唯一的区别在结尾最后两个字符,Node.js 签出的为 1b,Go 签出的为 00。

首先看一下 PlaonON-Go 中的签名是如何实现的:

// crypto/signature_cgo.go
func Sign(hash []byte, prv *ecdsa.PrivateKey) (sig []byte, err error) {
	if len(hash) != 32 {
		return nil, fmt.Errorf("hash is required to be exactly 32 bytes (%d)", len(hash))
	}
	seckey := math.PaddedBigBytes(prv.D, prv.Params().BitSize/8)
	defer zeroBytes(seckey)
	return secp256k1.Sign(hash, seckey)
}
// 关键是 secp256k1.Sign(hash, seckey)

继续跳转到 secp256k1.Sign

// crypto/secp256k1/secp256k1.go
func Sign(msg []byte, seckey []byte) ([]byte, error) {
  ....
  if C.secp256k1_ecdsa_sign_recoverable(context, &sigstruct, msgdata, seckeydata, noncefunc, nil) == 0 {
    return nil, ErrSignFailed
  }

  var (
    sig     = make([]byte, 65)
    sigdata = (*C.uchar)(unsafe.Pointer(&sig[0]))
    recid   C.int
  )
  C.secp256k1_ecdsa_recoverable_signature_serialize_compact(context, sigdata, &recid, &sigstruct)
  sig[64] = byte(recid) // add back recid to get 65 bytes sig
  return sig, nil
}

可以看到签名时候调用了C 库的 secp256k1_ecdsa_sign_recoverable 方法,这个 C 库最终调

secp256k1_ecdsa_sig_sign:

// crypto/secp256k1/libsecp256k1/src/ecdsa_impl.h
static int secp256k1_ecdsa_sig_sign(const secp256k1_ecmult_gen_context *ctx, secp256k1_scalar *sigr, secp256k1_scalar *sigs, const secp256k1_scalar *seckey, const secp256k1_scalar *message, const secp256k1_scalar *nonce, int *recid) {
   ...
    if (recid) {
        /* The overflow condition is cryptographically unreachable as hitting it requires finding the discrete log
         * of some P where P.x >= order, and only 1 in about 2^127 points meet this criteria.
         */
        *recid = (overflow ? 2 : 0) | (secp256k1_fe_is_odd(&r.y) ? 1 : 0);
    }
   ...
   if (secp256k1_scalar_is_high(sigs)) {
        secp256k1_scalar_negate(sigs, sigs);
        if (recid) {
            *recid ^= 1;
        }
    }
   ...
    return 1;
}

可以看到最终 recid (也就是下文所述的 v)的值为 0 或者 1,以上就是PlatON-Go 中的签名。

我们知道,区块链网络中 ECDSA signature = r + s + v,r 和 s 是 标准 ECDSA 的签名返回值,只有 v 很特别,v 的主要用处在于通过签名恢复公钥。而且各个区块链网络对这个 v 实现可能不太一样。那 PlatON 中对 v 是怎么实现的,我们来看看源码。

PlatON client-sdk-js 中 web3.platon.accounts.sign 源码如下:

var Account = require('eth-lib/lib/account');
Accounts.prototype.sign = function sign(data, privateKey) {
    var hash = this.hashMessage(data);
    var signature = Account.sign(hash, privateKey);
    var vrs = Account.decodeSignature(signature);
    return {
        message: data,
        messageHash: hash,
        v: vrs[0],
        r: vrs[1],
        s: vrs[2],
        signature: signature
    };
};

实际上 sign 调用的是 eth-lib/lib/account 的 sign 函数,继续往下找,node_modules/web3/packages/web3-eth-accounts/node_modules/eth-lib/lib/account.js 源码如下:

const encodeSignature = ([v, r, s]) => Bytes.flatten([r, s, v]);

const decodeSignature = hex => [Bytes.slice(64, Bytes.length(hex), hex), Bytes.slice(0, 32, hex), Bytes.slice(32, 64, hex)];

const makeSigner = addToV => (hash, privateKey) => {
  const signature = secp256k1.keyFromPrivate(new Buffer(privateKey.slice(2), "hex")).sign(new Buffer(hash.slice(2), "hex"), { canonical: true });
  return encodeSignature([Nat.fromString(Bytes.fromNumber(addToV + signature.recoveryParam)), Bytes.pad(32, Bytes.fromNat("0x" + signature.r.toString(16))), Bytes.pad(32, Bytes.fromNat("0x" + signature.s.toString(16)))]);
};

const sign = makeSigner(27); // v=27|28 instead of 0|1...

在该文件源代码中,v = 27 + signature.recoveryParam。

如果 recoveryParam = 0,则 v = 27 = 0x1b。

如果 recoveryParam = 1, 则 v = 28 = 0x1c。

由于PlatON-Go 签出的签名结尾为 00,有可能没有加 27,所以我们把 account.js 的源码改一下,把 27 去掉。

如果 recoveryParam = 0,则 v = 0x00。

如果 recoveryParam = 1,v = 0x01。

代码如下:

const makeSigner = () => (hash, privateKey) => {
  const signature = secp256k1.keyFromPrivate(new Buffer(privateKey.slice(2), "hex")).sign(new Buffer(hash.slice(2), "hex"), { canonical: true });
  return encodeSignature([signature.recoveryParam == 0 ? "0x00" : "0x01", Bytes.pad(32, Bytes.fromNat("0x" + signature.r.toString(16))), Bytes.pad(32, Bytes.fromNat("0x" + signature.s.toString(16)))]);
};

const sign = makeSigner(); // v=0|1 instead of 27|28...

尝试再次进行签名,输出结果:

{ message: 'Hello World',
  messageHash:
   '0xa1de988600a42c4b4ab089b619297c17d53cffae5d5120d82d8a92d0bb3b78f2',
  v: '0x00',
  r:
   '0xf28b8fde3ea610e59590da237b9e99871390fd458baae8d30e2da070ac9850f7',
  s:
   '0x0cb89a8fb817d73a3709b375c3aee82a5b64fae5743cbd95feff1bb6965f040b',
  signature:
   '0xf28b8fde3ea610e59590da237b9e99871390fd458baae8d30e2da070ac9850f70cb89a8fb817d73a3709b375c3aee82a5b64fae5743cbd95feff1bb6965f040b00' }

其中 signature 与 PlatON-Go 签出的签名一致,在合约上验证也通过。

问题建议:建议对client-sdk-js相关部分进行修改,以便和链上部分保持一致。

另外,关于 27、28,其实是有一定的历史原因的。在各个区块链网络中,recovery 函数 v 的实现不太一样,最早比特币网络中,v = 27 + recoveryParam,以太坊沿用了这一做法。

部分建议

官方 PRC-20 WASM 示例代码逻辑问题

描述:调用 transferFrom 以后需要更新 allowance

可能产生的影响:可能对开发者造成误解

Github PR地址:https://github.com/PlatONnetwork/docs/pull/75

官方 JS-SDK 示例代码逻辑问题

简介:将指定网络的的bech32 格式地址解析成有效 PlatON 地址参数位置错误

可能产生的影响:可能对开发者造成误解

github PR地址:https://github.com/PlatONnetwork/docs/pull/77

PlatON-CDT 合约 API 接口

简介:增加合约内部调用其他合约的重载函数

目的:使开发更加便捷

github PR地址:https://github.com/PlatONnetwork/PlatON-CDT/pull/191

总结

以上就是Dante项目在开发过程中,遇到的部分疑问和建议,经评估可能会对项目开发带来一定的影响,如有理解不对的地方希望社区技术爱好者们和官方技术大大们能够不吝赐教,共助PlatON生态的发展!

9 个赞

特别赞,多谢多谢,目前也在熟悉PlatON IDE,准备做些图形化开发,有问题到时候请教