mirror of
https://source.quilibrium.com/quilibrium/ceremonyclient.git
synced 2024-12-30 18:35:18 +00:00
391 lines
17 KiB
Go
391 lines
17 KiB
Go
|
//
|
||
|
// Copyright Coinbase, Inc. All Rights Reserved.
|
||
|
//
|
||
|
// SPDX-License-Identifier: Apache-2.0
|
||
|
//
|
||
|
|
||
|
// Package simplest implements the "Verified Simplest OT", as defined in "protocol 7" of [DKLs18](https://eprint.iacr.org/2018/499.pdf).
|
||
|
// The original "Simplest OT" protocol is presented in [CC15](https://eprint.iacr.org/2015/267.pdf).
|
||
|
// In our implementation, we run OTs for multiple choice bits in parallel. Furthermore, as described in the DKLs paper,
|
||
|
// we implement this as Random OT protocol. We also add encryption and decryption steps as defined in the protocol, but
|
||
|
// emphasise that these steps are optional. Specifically, in the setting where this OT is used as the seed OT in an
|
||
|
// OT Extension protocol, the encryption and decryption steps are not needed.
|
||
|
//
|
||
|
// Limitation: currently we only support batch OTs that are multiples of 8.
|
||
|
//
|
||
|
// Ideal functionalities:
|
||
|
// - We have used ZKP Schnorr for the F^{R_{DL}}_{ZK}
|
||
|
// - We have used HMAC for realizing the Random Oracle Hash function, the key for HMAC is received as input to the protocol.
|
||
|
package simplest
|
||
|
|
||
|
import (
|
||
|
"crypto/rand"
|
||
|
"crypto/subtle"
|
||
|
"fmt"
|
||
|
|
||
|
"github.com/gtank/merlin"
|
||
|
"github.com/pkg/errors"
|
||
|
"golang.org/x/crypto/sha3"
|
||
|
|
||
|
"source.quilibrium.com/quilibrium/monorepo/nekryptology/pkg/core/curves"
|
||
|
"source.quilibrium.com/quilibrium/monorepo/nekryptology/pkg/zkp/schnorr"
|
||
|
)
|
||
|
|
||
|
const (
|
||
|
// keyCount is the number of encryption keys created. Since this is a 1-out-of-2 OT, the key count is set to 2.
|
||
|
keyCount = 2
|
||
|
|
||
|
// DigestSize is the length of hash. Similarly, when it comes to encrypting and decryption, it is the size of the
|
||
|
// plaintext and ciphertext.
|
||
|
DigestSize = 32
|
||
|
)
|
||
|
|
||
|
type (
|
||
|
// OneTimePadDecryptionKey is the type of Rho^w, Rho^0, and RHo^1 in the paper.
|
||
|
OneTimePadDecryptionKey = [DigestSize]byte
|
||
|
|
||
|
// OneTimePadEncryptionKeys is the type of Rho^0, and RHo^1 in the paper.
|
||
|
OneTimePadEncryptionKeys = [keyCount][DigestSize]byte
|
||
|
|
||
|
// OtChallenge is the type of xi in the paper.
|
||
|
OtChallenge = [DigestSize]byte
|
||
|
|
||
|
// OtChallengeResponse is the type of Rho' in the paper.
|
||
|
OtChallengeResponse = [DigestSize]byte
|
||
|
|
||
|
// ChallengeOpening is the type of hashed Rho^0 and Rho^1
|
||
|
ChallengeOpening = [keyCount][DigestSize]byte
|
||
|
|
||
|
// ReceiversMaskedChoices corresponds to the "A" value in the paper in compressed format.
|
||
|
ReceiversMaskedChoices = []byte
|
||
|
)
|
||
|
|
||
|
// SenderOutput are the outputs that the sender will obtain as a result of running the "random" OT protocol.
|
||
|
type SenderOutput struct {
|
||
|
// OneTimePadEncryptionKeys are Rho^0 and Rho^1, the output of the random OT.
|
||
|
// These can be used to encrypt and send two messages to the receiver.
|
||
|
// Therefore, for readability they are called OneTimePadEncryptionKeys in the code.
|
||
|
OneTimePadEncryptionKeys []OneTimePadEncryptionKeys
|
||
|
}
|
||
|
|
||
|
// ReceiverOutput are the outputs that the receiver will obtain as a result of running the "random" OT protocol.
|
||
|
type ReceiverOutput struct {
|
||
|
// PackedRandomChoiceBits is a packed version of the choice vector, the packing is done for performance reasons.
|
||
|
PackedRandomChoiceBits []byte
|
||
|
|
||
|
// RandomChoiceBits is the choice vector represented as unpacked int array. Initialed from PackedRandomChoiceBits.
|
||
|
RandomChoiceBits []int
|
||
|
|
||
|
// OneTimePadDecryptionKey is Rho^w, the output of the random OT. For the receiver, there is just 1 output per execution.
|
||
|
// This value will be used to decrypt one of the messages sent by the sender.
|
||
|
// Therefore, for readability this is called OneTimePadDecryptionKey in the code.
|
||
|
OneTimePadDecryptionKey []OneTimePadDecryptionKey
|
||
|
}
|
||
|
|
||
|
// Sender stores state for the "sender" role in OT. see Protocol 7 in Appendix A of DKLs18.
|
||
|
type Sender struct {
|
||
|
// Output is the output that is produced as a result of running random OT protocol.
|
||
|
Output *SenderOutput
|
||
|
|
||
|
curve *curves.Curve
|
||
|
|
||
|
// secretKey is the value `b` in the paper, which is the discrete log of B, which will be (re)used in _all_ executions of the OT.
|
||
|
secretKey curves.Scalar
|
||
|
|
||
|
// publicKey is the public key of the secretKey.
|
||
|
publicKey curves.Point
|
||
|
|
||
|
// batchSize is the number of parallel OTs.
|
||
|
batchSize int
|
||
|
|
||
|
transcript *merlin.Transcript
|
||
|
}
|
||
|
|
||
|
// Receiver stores state for the "receiver" role in OT. Protocol 7, Appendix A, of DKLs.
|
||
|
type Receiver struct {
|
||
|
// Output is the output that is produced as a result of running random OT protocol.
|
||
|
Output *ReceiverOutput
|
||
|
|
||
|
curve *curves.Curve
|
||
|
|
||
|
// senderPublicKey corresponds to "B" in the paper.
|
||
|
senderPublicKey curves.Point
|
||
|
|
||
|
// senderChallenge is "xi" in the protocol.
|
||
|
senderChallenge []OtChallenge
|
||
|
|
||
|
// batchSize is the number of parallel OTs.
|
||
|
batchSize int
|
||
|
|
||
|
transcript *merlin.Transcript
|
||
|
}
|
||
|
|
||
|
// NewSender creates a new "sender" object, ready to participate in a _random_ verified simplest OT in the role of the sender.
|
||
|
// no messages are specified by the sender, because random ones will be sent (hence the random OT).
|
||
|
// ultimately, the `Sender`'s `Output` field will be appropriately populated.
|
||
|
// you can use it directly, or alternatively bootstrap it into an _actual_ (non-random) OT using `Round7Encrypt` below
|
||
|
func NewSender(curve *curves.Curve, batchSize int, uniqueSessionId [DigestSize]byte) (*Sender, error) {
|
||
|
if batchSize&0x07 != 0 { // This is the same as `batchSize % 8 != 0`, but is constant time
|
||
|
return nil, errors.New("batch size should be a multiple of 8")
|
||
|
}
|
||
|
transcript := merlin.NewTranscript("Coinbase_DKLs_SeedOT")
|
||
|
transcript.AppendMessage([]byte("session_id"), uniqueSessionId[:])
|
||
|
return &Sender{
|
||
|
Output: &SenderOutput{},
|
||
|
curve: curve,
|
||
|
batchSize: batchSize,
|
||
|
transcript: transcript,
|
||
|
}, nil
|
||
|
}
|
||
|
|
||
|
// NewReceiver is a Random OT receiver. Therefore, the choice bits are created randomly.
|
||
|
// The choice bits are stored in a packed format (e.g., each choice is a single bit in a byte array).
|
||
|
func NewReceiver(curve *curves.Curve, batchSize int, uniqueSessionId [DigestSize]byte) (*Receiver, error) {
|
||
|
// This is the same as `batchSize % 8 != 0`, but is constant time
|
||
|
if batchSize&0x07 != 0 {
|
||
|
return nil, errors.New("batch size should be a multiple of 8")
|
||
|
}
|
||
|
|
||
|
transcript := merlin.NewTranscript("Coinbase_DKLs_SeedOT")
|
||
|
transcript.AppendMessage([]byte("session_id"), uniqueSessionId[:])
|
||
|
|
||
|
receiver := &Receiver{
|
||
|
Output: &ReceiverOutput{},
|
||
|
curve: curve,
|
||
|
batchSize: batchSize,
|
||
|
transcript: transcript,
|
||
|
}
|
||
|
batchSizeBytes := batchSize >> 3 // divide by 8
|
||
|
receiver.Output.PackedRandomChoiceBits = make([]byte, batchSizeBytes)
|
||
|
if _, err := rand.Read(receiver.Output.PackedRandomChoiceBits[:]); err != nil {
|
||
|
return nil, errors.Wrap(err, "choosing random choice bits")
|
||
|
}
|
||
|
// Unpack into Choice bits
|
||
|
receiver.initChoice()
|
||
|
return receiver, nil
|
||
|
}
|
||
|
|
||
|
// Round1ComputeAndZkpToPublicKey is the first phase of the protocol.
|
||
|
// computes and stores public key and returns the schnorr proof. serialized / packed.
|
||
|
// This implements step 1 of Protocol 7 of DKLs18, page 16.
|
||
|
func (sender *Sender) Round1ComputeAndZkpToPublicKey() (*schnorr.Proof, error) {
|
||
|
var err error
|
||
|
// Sample the secret key and compute the public key.
|
||
|
sender.secretKey = sender.curve.Scalar.Random(rand.Reader)
|
||
|
sender.publicKey = sender.curve.ScalarBaseMult(sender.secretKey)
|
||
|
|
||
|
// Generate the ZKP proof.
|
||
|
uniqueSessionId := [DigestSize]byte{}
|
||
|
copy(uniqueSessionId[:], sender.transcript.ExtractBytes([]byte("sender schnorr proof"), DigestSize))
|
||
|
prover := schnorr.NewProver(sender.curve, nil, sha3.New256(), uniqueSessionId[:])
|
||
|
proof, err := prover.Prove(sender.secretKey)
|
||
|
if err != nil {
|
||
|
return nil, errors.Wrap(err, "creating zkp proof for secret key in seed OT sender round 1")
|
||
|
}
|
||
|
return proof, nil
|
||
|
}
|
||
|
|
||
|
// Round2VerifySchnorrAndPadTransfer verifies the schnorr proof of the public key sent by the sender, i.e., step 2),
|
||
|
// and then does receiver's "Pad Transfer" phase in OT, i.e., step 3), of Protocol 7 (page 16) of the paper.
|
||
|
func (receiver *Receiver) Round2VerifySchnorrAndPadTransfer(proof *schnorr.Proof) ([]ReceiversMaskedChoices, error) {
|
||
|
receiver.senderPublicKey = proof.Statement
|
||
|
uniqueSessionId := [DigestSize]byte{}
|
||
|
copy(uniqueSessionId[:], receiver.transcript.ExtractBytes([]byte("sender schnorr proof"), DigestSize))
|
||
|
if err := schnorr.Verify(proof, receiver.curve, nil, sha3.New256(), uniqueSessionId[:]); err != nil {
|
||
|
return nil, errors.Wrap(err, "verifying schnorr proof in seed OT receiver round 2")
|
||
|
}
|
||
|
|
||
|
result := make([]ReceiversMaskedChoices, receiver.batchSize)
|
||
|
receiver.Output.OneTimePadDecryptionKey = make([]OneTimePadDecryptionKey, receiver.batchSize)
|
||
|
copy(uniqueSessionId[:], receiver.transcript.ExtractBytes([]byte("random oracle salts"), DigestSize))
|
||
|
for i := 0; i < receiver.batchSize; i++ {
|
||
|
a := receiver.curve.Scalar.Random(rand.Reader)
|
||
|
// Computing `A := a . G + w . B` in constant time, by first computing option0 = a.G and option1 = a.G+B and then
|
||
|
// constant time choosing one of them by first assuming that the output is option0, and overwrite it if the choice bit is 1.
|
||
|
|
||
|
option0 := receiver.curve.ScalarBaseMult(a)
|
||
|
option0Bytes := option0.ToAffineCompressed()
|
||
|
option1 := option0.Add(receiver.senderPublicKey)
|
||
|
option1Bytes := option1.ToAffineCompressed()
|
||
|
|
||
|
result[i] = option0Bytes
|
||
|
subtle.ConstantTimeCopy(receiver.Output.RandomChoiceBits[i], result[i], option1Bytes)
|
||
|
// compute the internal rho
|
||
|
rho := receiver.senderPublicKey.Mul(a)
|
||
|
hash := sha3.New256()
|
||
|
if _, err := hash.Write(uniqueSessionId[:]); err != nil {
|
||
|
return nil, errors.Wrap(err, "writing seed to hash in round 2 pad transfer")
|
||
|
}
|
||
|
if _, err := hash.Write([]byte{byte(i)}); err != nil {
|
||
|
return nil, errors.Wrap(err, "writing i to hash in round 2 pad transfer")
|
||
|
}
|
||
|
if _, err := hash.Write(rho.ToAffineCompressed()); err != nil {
|
||
|
return nil, errors.Wrap(err, "writing point to hash in round 2 pad transfer")
|
||
|
}
|
||
|
copy(receiver.Output.OneTimePadDecryptionKey[i][:], hash.Sum(nil))
|
||
|
}
|
||
|
return result, nil
|
||
|
}
|
||
|
|
||
|
// Round3PadTransfer is the sender's "Pad Transfer" phase in OT; see steps 4 and 5 of page 16 of the paper.
|
||
|
// Returns the challenges xi
|
||
|
func (sender *Sender) Round3PadTransfer(compressedReceiversMaskedChoice []ReceiversMaskedChoices) ([]OtChallenge, error) {
|
||
|
var err error
|
||
|
challenge := make([]OtChallenge, sender.batchSize)
|
||
|
sender.Output.OneTimePadEncryptionKeys = make([]OneTimePadEncryptionKeys, sender.batchSize)
|
||
|
negSenderPublicKey := sender.publicKey.Neg()
|
||
|
|
||
|
receiversMaskedChoice := make([]curves.Point, len(compressedReceiversMaskedChoice))
|
||
|
for i := 0; i < len(compressedReceiversMaskedChoice); i++ {
|
||
|
|
||
|
if receiversMaskedChoice[i], err = sender.curve.Point.FromAffineCompressed(compressedReceiversMaskedChoice[i]); err != nil {
|
||
|
return nil, errors.Wrap(err, "uncompress the point")
|
||
|
}
|
||
|
}
|
||
|
|
||
|
baseEncryptionKeyMaterial := make([]curves.Point, keyCount)
|
||
|
var hashedKey [keyCount][DigestSize]byte
|
||
|
uniqueSessionId := [DigestSize]byte{}
|
||
|
copy(uniqueSessionId[:], sender.transcript.ExtractBytes([]byte("random oracle salts"), DigestSize))
|
||
|
|
||
|
for i := 0; i < sender.batchSize; i++ {
|
||
|
// Sender creates two options that will eventually be used as her encryption keys.
|
||
|
// `baseEncryptionKeyMaterial[0]` and `baseEncryptionKeyMaterial[0]` correspond to rho_0 and rho_1 in the paper, respectively.
|
||
|
baseEncryptionKeyMaterial[0] = receiversMaskedChoice[i].Mul(sender.secretKey)
|
||
|
|
||
|
receiverChoiceMinusSenderPublicKey := receiversMaskedChoice[i].Add(negSenderPublicKey)
|
||
|
baseEncryptionKeyMaterial[1] = receiverChoiceMinusSenderPublicKey.Mul(sender.secretKey)
|
||
|
|
||
|
for k := 0; k < keyCount; k++ {
|
||
|
hash := sha3.New256()
|
||
|
if _, err = hash.Write(uniqueSessionId[:]); err != nil {
|
||
|
return nil, errors.Wrap(err, "writing seed to hash in round 3 pad transfer")
|
||
|
}
|
||
|
if _, err = hash.Write([]byte{byte(i)}); err != nil {
|
||
|
return nil, errors.Wrap(err, "writing i to hash in round 3 pad transfer")
|
||
|
}
|
||
|
if _, err = hash.Write(baseEncryptionKeyMaterial[k].ToAffineCompressed()); err != nil {
|
||
|
return nil, errors.Wrap(err, "writing point to hash in round 3 pad transfer")
|
||
|
}
|
||
|
copy(sender.Output.OneTimePadEncryptionKeys[i][k][:], hash.Sum(nil))
|
||
|
if err != nil {
|
||
|
return nil, errors.Wrap(err, "compute the encryption keys")
|
||
|
}
|
||
|
|
||
|
// Compute a challenge by XORing the hash of the hash of the key. Not a typo ;)
|
||
|
hashedKey[k] = sha3.Sum256(sender.Output.OneTimePadEncryptionKeys[i][k][:])
|
||
|
hashedKey[k] = sha3.Sum256(hashedKey[k][:])
|
||
|
}
|
||
|
|
||
|
challenge[i] = xorBytes(hashedKey[0], hashedKey[1])
|
||
|
}
|
||
|
return challenge, nil
|
||
|
}
|
||
|
|
||
|
// Round4RespondToChallenge corresponds to initial round of the receiver's "Verify" phase; see step 6 of page 16 of the paper.
|
||
|
// this is just the start of Verification. In this round, the receiver outputs "rho'", which the sender will check.
|
||
|
func (receiver *Receiver) Round4RespondToChallenge(challenge []OtChallenge) ([]OtChallengeResponse, error) {
|
||
|
// store to be used in future steps
|
||
|
receiver.senderChallenge = challenge
|
||
|
// challengeResponses is Rho' in the paper.
|
||
|
challengeResponses := make([]OtChallengeResponse, receiver.batchSize)
|
||
|
for i := 0; i < receiver.batchSize; i++ {
|
||
|
// Constant-time xor of the hashed key and the challenge, based on the choice bit.
|
||
|
hashedKey := sha3.Sum256(receiver.Output.OneTimePadDecryptionKey[i][:])
|
||
|
hashedKey = sha3.Sum256(hashedKey[:])
|
||
|
challengeResponses[i] = hashedKey
|
||
|
alternativeChallengeResponse := xorBytes(receiver.senderChallenge[i], hashedKey)
|
||
|
subtle.ConstantTimeCopy(receiver.Output.RandomChoiceBits[i], challengeResponses[i][:], alternativeChallengeResponse[:])
|
||
|
}
|
||
|
return challengeResponses, nil
|
||
|
}
|
||
|
|
||
|
// Round5Verify verifies the challenge response. If the verification passes, sender opens his challenges to the receiver.
|
||
|
// See step 7 of page 16 of the paper.
|
||
|
// Abort if Rho' != H(H(Rho^0)) in other words, if challengeResponse != H(H(encryption key 0)).
|
||
|
// opening is H(encryption key)
|
||
|
func (sender *Sender) Round5Verify(challengeResponses []OtChallengeResponse) ([]ChallengeOpening, error) {
|
||
|
opening := make([]ChallengeOpening, sender.batchSize)
|
||
|
for i := 0; i < sender.batchSize; i++ {
|
||
|
for k := 0; k < keyCount; k++ {
|
||
|
opening[i][k] = sha3.Sum256(sender.Output.OneTimePadEncryptionKeys[i][k][:])
|
||
|
}
|
||
|
|
||
|
// Verify
|
||
|
hashedKey0 := sha3.Sum256(opening[i][0][:])
|
||
|
if subtle.ConstantTimeCompare(hashedKey0[:], challengeResponses[i][:]) != 1 {
|
||
|
return nil, errors.New("receiver's challenge response didn't match H(H(rho^0))")
|
||
|
}
|
||
|
}
|
||
|
return opening, nil
|
||
|
}
|
||
|
|
||
|
// Round6Verify is the _last_ part of the "Verification" phase of OT; see p. 16 of https://eprint.iacr.org/2018/499.pdf.
|
||
|
// See step 8 of page 16 of the paper.
|
||
|
// Abort if H(Rho^w) != the one it calculated itself or
|
||
|
// if Xi != H(H(Rho^0)) XOR H(H(Rho^1))
|
||
|
// In other words,
|
||
|
// if opening_w != H(decryption key) or
|
||
|
// if challenge != H(opening 0) XOR H(opening 0)
|
||
|
func (receiver *Receiver) Round6Verify(challengeOpenings []ChallengeOpening) error {
|
||
|
for i := 0; i < receiver.batchSize; i++ {
|
||
|
hashedDecryptionKey := sha3.Sum256(receiver.Output.OneTimePadDecryptionKey[i][:])
|
||
|
w := receiver.Output.RandomChoiceBits[i]
|
||
|
if subtle.ConstantTimeCompare(hashedDecryptionKey[:], challengeOpenings[i][w][:]) != 1 {
|
||
|
return fmt.Errorf("sender's supposed H(rho^omega) doesn't match our own")
|
||
|
}
|
||
|
hashedKey0 := sha3.Sum256(challengeOpenings[i][0][:])
|
||
|
hashedKey1 := sha3.Sum256(challengeOpenings[i][1][:])
|
||
|
reconstructedChallenge := xorBytes(hashedKey0, hashedKey1)
|
||
|
if subtle.ConstantTimeCompare(reconstructedChallenge[:], receiver.senderChallenge[i][:]) != 1 {
|
||
|
return fmt.Errorf("sender's openings H(rho^0) and H(rho^1) didn't decommit to its prior message")
|
||
|
}
|
||
|
}
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
// Round7Encrypt wraps an `Encrypt` operation on the Sender's underlying output from the random OT; see `Encrypt` below.
|
||
|
// this is optional; it will be used only in circumstances when you want to run "actual" (i.e., non-random) OT
|
||
|
func (sender *Sender) Round7Encrypt(messages [][keyCount][DigestSize]byte) ([][keyCount][DigestSize]byte, error) {
|
||
|
return sender.Output.Encrypt(messages)
|
||
|
}
|
||
|
|
||
|
// Round8Decrypt wraps a `Decrypt` operation on the Receiver's underlying output from the random OT; see `Decrypt` below
|
||
|
// this is optional; it will be used only in circumstances when you want to run "actual" (i.e., non-random) OT
|
||
|
func (receiver *Receiver) Round8Decrypt(ciphertext [][keyCount][DigestSize]byte) ([][DigestSize]byte, error) {
|
||
|
return receiver.Output.Decrypt(ciphertext)
|
||
|
}
|
||
|
|
||
|
// Encrypt runs step 9) of the seed OT Protocol 7) of https://eprint.iacr.org/2018/499.pdf,
|
||
|
// in which the seed OT sender "encrypts" both messages under the "one-time keys" output by the random OT.
|
||
|
func (s *SenderOutput) Encrypt(plaintexts [][keyCount][DigestSize]byte) ([][keyCount][DigestSize]byte, error) {
|
||
|
batchSize := len(s.OneTimePadEncryptionKeys)
|
||
|
if len(plaintexts) != batchSize {
|
||
|
return nil, errors.New("message size should be same as batch size")
|
||
|
}
|
||
|
ciphertexts := make([][keyCount][DigestSize]byte, batchSize)
|
||
|
|
||
|
for i := 0; i < len(plaintexts); i++ {
|
||
|
for k := 0; k < keyCount; k++ {
|
||
|
ciphertexts[i][k] = xorBytes(s.OneTimePadEncryptionKeys[i][k], plaintexts[i][k])
|
||
|
}
|
||
|
}
|
||
|
return ciphertexts, nil
|
||
|
}
|
||
|
|
||
|
// Decrypt is step 10) of the seed OT Protocol 7) of https://eprint.iacr.org/2018/499.pdf,
|
||
|
// where the seed OT receiver "decrypts" the message it's receiving using the "key" it received in the random OT.
|
||
|
func (r *ReceiverOutput) Decrypt(ciphertexts [][keyCount][DigestSize]byte) ([][DigestSize]byte, error) {
|
||
|
batchSize := len(r.OneTimePadDecryptionKey)
|
||
|
if len(ciphertexts) != batchSize {
|
||
|
return nil, errors.New("number of ciphertexts should be same as batch size")
|
||
|
}
|
||
|
plaintexts := make([][DigestSize]byte, batchSize)
|
||
|
|
||
|
for i := 0; i < len(ciphertexts); i++ {
|
||
|
choice := r.RandomChoiceBits[i]
|
||
|
plaintexts[i] = xorBytes(r.OneTimePadDecryptionKey[i], ciphertexts[i][choice])
|
||
|
}
|
||
|
return plaintexts, nil
|
||
|
}
|