Major cross-canister query call delay

So I’m using a cross-canister call to get some randomness to generate uuids in my main canister. Here’s the Rust code that I’m using to get the randomness:

let call_result: Result<(Vec<u8>,), _> = ic_cdk::api::call::call(ic_cdk::export::Principal::management_canister(), "raw_rand", ()).await;

I am retreiving the randomness from update calls, which already take 5 seconds locally. Each time I do the call above, about 5 seconds is added to the call latency. So I am at a minimum waiting about 10 seconds for all of my update calls that use randomness. Adding more cross-canister query calls will continue to add 5 seconds a piece.

Is this a fundamental constraint? It’s strange to me that a cross-canister query call has to go through consensus as well.

2 Likes

The origin of randomness in a subnet is BLS signature. To create such a signature nodes should produce a valid block. To produce a valid block they should reach consensus on it.
So it just can’t work the other way.

What about simple cross-canister calls (without randomness) - I would also like to know the answer.

I believe the local network is somehow throttled to imitate worst case scenarios with some hardcoded thread::sleep magic - otherwise the consensus should be instant. DSCVR works very smoothly, so I don’t think these huge delays are an issue in production.

1 Like

Thoughts: Is it safe to simply hash the current timestamp in canister code to get a randomness? So to avoid going through consensus

I feel things are pretty slow when testing in local network. If this is the case, I surely hope there’s a flag to control the throttle timeout.

Dfx also has a faster emulator that may be good for your testing

dfx start --emulator

It doesn’t do cycle accounting yet and may have other limitations I’m unaware of.

You may need to stop the replica and ‘rm -r -f .dfx’ before doing this.

3 Likes

Not if you want cryptographic randomness, but for simple testing of everything else that might be OK.

1 Like

Hash of the timestamp is bad, because everybody knows preimages.

Randomness in a blockchain network is hard to reach. I’m glad that in IC we just have a single function for it.

I read in the IC interface spec that canister timestamp is actually local to individual nodes and there’s no guarantee of synchronization. Plus it’s nanosecond, I don’t think anyone can really predict e.g. the last digit.

Based on following assumptions:

  1. it’s impossible to predict timestamp down to full precisions
  2. canister private state is indeed opaque

I come up with this implementation, hope you guys can enlighten me on where it falls short.

import Array "mo:base/Array";
import Time "mo:base/Time";
import Nat8 "mo:base/Nat8";
import Nat64 "mo:base/Nat64";
import Int "mo:base/Int";
import SHA "SHA256";

actor class Random() {
  private func toBytes(n: Nat) : [Nat8] {
    var a : Nat = n;
    var bytes : [var Nat8] = [var];
    while (a != 0) {
      let min = a % 256;
      bytes := Array.thaw(Array.append(Array.make(Nat8.fromNat(min)), Array.freeze(bytes)));
      a := a / 256;
    };
    return Array.freeze(bytes);
  };

  private func genSalt(): [Nat8] {
    let timestamp = Nat64.fromNat(Int.abs(Time.now()));
    var rem = timestamp % 10;
    // based on assumption that `rem` is impossible to predict
    // rotate the Nat64 to sort of "amplify" randomness
    // does this make sense?
    let seed = Nat64.toNat(timestamp <<> rem);

    var bytes = SHA.sha256(toBytes(seed));
    while (rem > 0) {
      bytes := SHA.sha256(bytes);
      rem -= 1;
    };
    return bytes;
  };

  private stable var salt = genSalt();

  public func getRandom(): async [Nat8] {
    let bytes = SHA.sha256(toBytes(Int.abs(Time.now())));
    // append salt and hash again
    let rand = SHA.sha256(Array.append(bytes, salt));
    // update salt
    salt := SHA.sha256(rand);
    return rand;
  };
};

Depending on particular application an attacker could just calculate all the possible solutions (there are not that many if you at least know the day when the random number was generated).

Moreover, a node processing your request chooses the timestamp it wants to provide to your canister as an input. So a malicious node doesn’t even have to bruteforce it.

As @claudio said - it is fine for testing or for some non-valuable calculations, but bad for an online casino, for example.

1 Like

On ethereum you have to boot a whole startup to enable onchain randomness (RanDAO, Chainlink VRF) - so this task is not that easy as it seems on the first glance.

I probably wouldn’t risk it either and would just use the random beacon.

But solely for learning purpose, I’m not fully satisfied with your explanation. Let me try push back:

I think this factor is guarded by IC itself. Otherwise a malicious node should be able to push through adverse transaction of any kind, not just my specific case.

If I understand it right, subnet randomly choose which node to process by its rank, so the malicious node would need to also pretend to be rank-0 node in order to publish the tampered result. This would get caught by consensus protocol.

That’s why I add the salt. Say the getRandom() has been pulled n times. The attacker would need to guess correctly: a) the salt at contract deployment, and b) each and every timestamp in the n iteractions.

1 Like

Yea, you’re right. But chances to become a legitimate rank-0 node are quite high (1/7 for… I don’t remember the name of the subnet type). So each seventh block could be produced by such a node without consensus even noticing it.

It’s not the consensus is bad - it’s awesome. The task of secure RNG is tricky and requires care.

If you want to use this function in production, you can. But you have to make sure that the price of the attack execution is higher than the profit the attacker could make with it.

1 Like

Wait wat… so the malicious node can just wait till it actually becomes the valid rank-0 node to attack?! This sounds really bad :scream_cat: Doesn’t this mean the consensus protocol is broken? And node state in subnet would go out of sync from this point on?

Must be something we’re missing here…Unless Time.now() actually goes through consensus too.
Hope you can explain @claudio

No. The consensus is not broken. Time.now() is allowed to be inaccurate by, I think, 1-2 secs. So any timestamp within this interval is valid for everyone else. This is the place where an attacker could use the value they want to break your RNG. But apart from this - there is nothing they can do with the state.

No, take a look at this part.

public func getRandom(): async [Nat8] {
    let bytes = SHA.sha256(toBytes(Int.abs(Time.now())));
    // append salt and hash again
    let rand = SHA.sha256(Array.append(bytes, salt));
    // update salt
    salt := SHA.sha256(rand);
    return rand;
};

The adverse (but valid) message here is the returned ResponseMessage, which goes through consensus protocol. It’s a response to end user but not feedback into the system as input message.

All other good nodes will just update independently their internal state (salt in this case) base on CallMessage { func = "getRandom", args = () }.

In fact, if Time.now() is different to each node, it doesn’t even need a malicious node in play. The normal scenario will also result in out-of-sync internal state among nodes. I don’t think this is allowed.

The only reasonable explanation to me is Time.now() is agreed upon among nodes in the same subnet. (But I don’t remember reading about this anywhere…)

I would suggest you to watch a consensus-related video on YT Inside the Internet Computer | Consensus Overview - YouTube

And read the consensus wp internet_computer_consensus.pdf - Google Drive

As well as to dig a little deeper into concept of private data on the IC.

I’ve already gone through those, and I believe I have a reasonably good understanding of the content. It is based on this knowledge about consensus protocol and message handling that I drew above conclusion.

But thank you for spending time discussing with me. Really appreciate it!

1 Like

Yes, if you use the raw_rand function, it’ll take another round of consensus to receive the randomness. This is because the randomness has to be unpredictable, even for replicas that participate in the subnet consensus.

Let’s imagine an alternative design where raw_rand returns immediately. Where would the randomness come from? Note that this call will run on all replicas, and will have to return the same byte sequence, otherwise we risk divergence. So it would have to come from something already both known and agreed upon by all replicas. A block (of messages) is executed only after the block is finalized. But once a block is finalized, all inputs are decided, and the output should be fully deterministic. This means the randomness would have to be part of the finalized input. If this is the case, there is a chance that a block maker will be able to craft a block to their own benefit, by selecting their own messages (crafted on the spot once they know the randomness bytes) over user messages. So this is insecure.

Depending on your application, you may or may not need secure randomness. If you do need it, the extra latency is the price you pay.

BTW, latency in dfx at the moment is actually worse than on the main network. This was artificially set, and I believe a next release will address this problem soon.

2 Likes

For UUID, I believe you only need to seed a random number generator (RNG) with some randomness once (e.g. when you first initialize your canister), and then just keep using that RNG. You can also save the RNG as part of your canister state to use in the future.

Unless you need UUIDs to be unpredictable, I think pseudo random is good enough.

1 Like

During the same call that runs on multiple replicas, does ic0.time() return the same value?