Subnet Security

Securing a subnet starts with securing the Protocols, which is primarily where nodes communicate. It's important to implement security measures to avoid unwanted entries, record spam, sybil attacks, etc.

There are two main features to secure a subnet:


Authorizers are between peer-to-peer communication requests and responses. With Authorizers, custom logic can be deployed in protocols for all node communication. For example, an authorizer can include rate limiting in an inference subnet, require a proof-of-stake when joining a subnet, or require signing requests and responses for communication between nodes.

Note: The template comes with the above options, and developers can create their own custom Authorizers.

At a minimum, the DHT protocol (the main protocol for node communication and record storage) must implement the PoS authorizer to ensure only nodes with a proof of stake can join the subnet and access the records.

The subnet template comes with PoS (proof-of-stake) built in. Authorizers only need to be added to the protocols that subnet teams build if a subnet requires peer-to-peer communication for its application logic. If a subnet does not require peer-to-peer communication for its application logic, then Authorizers are not needed anywhere other than the built-in PoS authorizer.

As well, at a minimum, each custom protocol should implement a signature authorizer.

For example, in a subnet for inference that utilizes the signature authorizer, one peer will call another peer to perform an inference task, the requesting peer will sign the data, and the responding peer will validate the signed data and respond with a signed response (signed tensor output), and finally, the requesting peer will then validate the signed response.

Each DHT store request will first go through an Authorizer, if an Authorizer is implemented into the DHT Protocol (see example implementations below). For instance, records are stored through the DHT Protocol (see /dht/protocol.py). When you request another peer to store something, or another peer requests you to store something, both the request and response will go through the Authorizer first (see get_stub(p2p, peer) for a more detailed explanation), then the Record Validator is called before the record is stored.

Record validators validate the records in the decentralized database (see DHT Records) for POST and GET requests.

Record validators enable use cases where the subnet can have database conditions, just like a standard key-value database. Such as key, expiration, Pydantic schema validation conditions, owned and protected records, a commit-reveal schema, or any use case the subnet needs to validate records on both store requests and get requests.

For example, if utilizing a commit-reveal, you will likely implement the predicate validator or even create a custom record validator. A predicate validator allows Python callables to be accessed when storing or getting data from the DHT records, such as accessing blockchain RPC methods. This can be used to have a commit-reveal scheme in-subnet to ensure commits and reveals can only be performed between specific blocks in specific epochs. For example, you can require f"commit-{current_epoch}" can only be stored up to the 50% mark of the epoch (the current_epoch and the 50% mark can be accessed and calculated from values from the callable function).

At a minimum, a subnet should only allow keys that are required to be stored in the DHT records, and require a maximum expiration time in the storage for as long as it's required.

Having owned records can also be important, depending on the use case, to ensure that records cannot be updated by just anyone (see Signature Validators).


Working Together

Utilizing both an Authorizer and Record Validators together is a very powerful basis for security and expansiveness.

The DHT comes with its own built-in DHT protocol that utilizes the PoS Authorizer (the PoS Authorizer also utilizes the signature authorizer by default). Each protocol built for application logic, such as an inference protocol, should also implement its own authorizer (see Protocols). Whenever a peer wants to store data in the DHT, regardless of the application logic protocol, it first passes through the DHT's PoS Authorizer. Once the signatures and PoS are verified, the record or records are verified based on the Records Validators being used, and finally, after verification, the records are stored.

Example

In the following example, let's assume the Protocol is an inference protocol and each request for inference is verified to have been completed by the calling peer by storing a TRUE flag into the DHT records that can be later used for scoring.

This is a simple example and not something expected to be used in production.

The requesting peer will request another peer to respond with an inference output. The request will be signed by the requester and verified by the responder. Once the inference task is verified to be complete, the requesting peer will then store that inference task 1 is complete.

The requesting peer will then call the DHT to store data in the DHT. The request will be signed by the requester and verified by the storing peer that is chosen to store the record. Once the responder verifies the request is valid, it will then verify the record itself based on the record validators used in the DHT. Once complete, the storing peer will return True or False depending on whether the record was stored or not.

In practice, many peers store data. For brevity, the example will show only a single request to one of the peers to store the record. For more information on how peers are chosen to store data, see Traverse (crawl) DHT, and async store_many.


Implementing Authorizers

As previously mentioned, security comes down to securing protocols, where most of the communication takes place. To add an authorizer to the DHT Protocol, we do so from the DHT initialization since the DHT creates the DHT Protocol.

DHT

In this implementation, we start the DHT with a proof-of-stake authorizer that requires a proof of stake for all communications between all nodes.

Authorizers can include any logic and a mix of use cases.

# See server.py for full implementation
identity_path = "identity_path.id" # private key path
subnet_id = 1
pk = get_private_key(identity_path)
hypertensor = Hypertensor(rpc, PHRASE)

signature_authorizer = SignatureAuthorizer(pk)
pos = ProofOfStake(
    subnet_id,
    hypertensor,
    min_class=1,
)
pos_authorizer = ProofOfStakeAuthorizer(signature_authorizer, pos)

"""
Start DHT with PoS authorizer

- The authorizer is initialized into the DHTProtocol
"""
dht = DHT(
    initial_peers=initial_peers,
    start=True,
    num_workers=DEFAULT_NUM_WORKERS,
    use_relay=use_relay,
    use_auto_relay=use_auto_relay,
    client_mode=reachable_via_relay,
    record_validators=None,
    **dict(kwargs, authorizer=pos_authorizer) # implement PoS authorizer - only staked peers can join the subnet
)

Custom Protocol

Start the protocol with an RSA signature protocol. Requiring signing between peers is important so each peer can know who is calling them for each request. Knowing who's requesting and responding to communications allows for more robust logic, such as rate limiting, blacklisting, etc..

identity_path = "identity_path.id" # private key path
subnet_id = 1
hypertensor = Hypertensor(rpc, PHRASE)

pk = get_private_key(identity_path)
signature_authorizer = SignatureAuthorizer(pk)

mock_protocol = MockProtocol(
    dht=dht,
    subnet_id=subnet_id,
    hypertensor=hypertensor,
    authorizer=signature_authorizer, # implement signature authorizer - all communication between peers requires signing and validating
    start=True
)

Implementing Record Validators

Unlike Authorizers, the DHT Protocol can initialize with multiple record validators that validate in order of priority. See Record Validator for more information.

In the following, we are creating two validators, one that creates signed and owned records so we know who owns the record and only they can update it, and a Hypertensor Predicate Validator for a commit-reveal scheme that ensures no record is stored for too long, when specific keys can be stored (tied to an epoch), validates key names, and validates values that ensure is abides from a Pydantic schema.

identity_path = "identity_path.id" # private key path
pk = get_private_key(identity_path)

subnet_id = 1
hypertensor = Hypertensor(rpc, PHRASE)

# Create signed and owned records
signature_validator = SignatureValidator(pk)

# Create predicate validator for commit-reveal/key-value validations
consensus_predicate = HypertensorPredicateValidator.from_predicate_class(
    MockHypertensorCommitReveal, hypertensor=hypertensor, subnet_id=subnet_id
)

record_validators=[signature_validator, consensus_predicate]

dht = DHT(
    initial_peers=initial_peers,
    start=True,
    num_workers=DEFAULT_NUM_WORKERS,
    use_relay=use_relay,
    use_auto_relay=use_auto_relay,
    client_mode=reachable_via_relay,
    record_validators=record_validators,
    **kwargs,
)

Last updated