Skip to main content

Protocol Reference

This page specifies protocol 2 at the level an implementer needs to write an independent client in any language: primitives, serialization, the ECIES envelope, the message, reply signing, limits and test vectors. The Neurai node is the reference implementation.

Primitives

PrimitiveDefinition
Curvesecp256k1. Public keys are compressed (33 bytes, 02 or 03 prefix)
SHA256(x)FIPS 180-4 SHA-256
SHA256d(x)SHA256(SHA256(x))
hash160(x)RIPEMD160(SHA256(x)). The P2PKH payload of an address is hash160(pubkey)
ECDH(d, Q)libsecp256k1 secp256k1_ecdh with the default hash: SHA256(prefix ‖ x) where (x, y) = d·Q and prefix is 0x02 for even y, 0x03 for odd. 32 bytes
KDF(secret, n)Counter-mode SHA-256: T_i = SHA256(secret ‖ BE32(i)), output the first n bytes of T_1 ‖ T_2 ‖ …. For n = 32 this is SHA256(secret ‖ 00000001)
AES-256-GCM32-byte key, 12-byte nonce, 16-byte tag, no additional data
Base64RFC 4648 with padding, for compact signatures
HexLowercase, for everything else

Serialization

All binary structures use Bitcoin's serialization:

  • Integers are little-endian (int64 is 8 bytes, uint8 is 1 byte).
  • CompactSize(n): 1 byte below 253; 0xFD + LE16 up to 0xFFFF; 0xFE + LE32 up to 0xFFFFFFFF; else 0xFF + LE64.
  • string and vector<uint8>: CompactSize(len) ‖ bytes.
  • CPubKey: CompactSize(len) ‖ bytes, so 0x21 + 33 bytes.
  • uint160: 20 raw bytes, the same bytes as the address payload.
  • map<K, V>: CompactSize(count) followed by K ‖ V pairs in ascending byte order of K.

Hash display order

A uint256 from SHA256d is displayed with its bytes reversed, as Bitcoin does for txids. hash fields and after_hash arguments are hex(reverse(digest)). Signatures are computed over the unreversed digest.

Message signing (recoverable)

Used for challenge requests, challenge responses and poolsig:

msghash(text) = SHA256d( ser_string("Neurai Signed Message:\n") ‖ ser_string(text) )

The signature is a 65-byte compact recoverable ECDSA signature header ‖ r ‖ s with header = 27 + recid + 4, base64-encoded. Verification recovers the public key and checks hash160(recovered) against the address. Signing is deterministic (RFC 6979). Any wallet's signmessage and verifymessage are interoperable.

Message signatures (DER)

Used for the message's signature field only: a DER-encoded ECDSA signature over the 32-byte message digest, verified with the sender's revealed public key. Producers should emit low-s values. Typically 70 to 72 bytes.

ECIES envelope

One construction, CECIESEncryptedMessage, serves three purposes: the message content, the submission envelope for the pool key, and every reply bound to an address. It encrypts a plaintext once for N recipients identified by address.

CECIESEncryptedMessage :=
ephemeralPubKey CPubKey 0x21 ‖ 33 bytes
encryptedPayload vector<uint8> nonce(12) ‖ ciphertext ‖ tag(16)
recipientKeys map<uint160, vector<uint8>> hash160 -> nonce(12) ‖ wrappedKey(32) ‖ tag(16)
CECIESEncryptedMessagehex of this serialization = encrypted_payload_hexephemeralPubKeyfresh per message · discard the private scalarcompressed secp256k1 public key0x21 ‖ 33 bytesencryptedPayloadone ciphertext for all recipients · 28 + len(plaintext) bytesnonce12 bytesAES-256-GCM ciphertextkey K = KDF(ephemeral scalar)GCM tag16 bytesrecipientKeysmap ordered by hash160 · 60 bytes per recipient · at most maxrecipientshash160(Alice)nonce12 bytesK wrapped with AES-256-GCMW = KDF(ECDH(ephemeral, recipient))GCM tag16 byteshash160(Bob)nonce12 bytesK wrapped with AES-256-GCMW = KDF(ECDH(ephemeral, recipient))GCM tag16 bytes

Each recipient entry is exactly 60 bytes. encryptedPayload is 28 + len(plaintext) bytes.

Encryption

e <- random 32-byte scalar (ephemeral private key), E = e·G
K = KDF(e, 32) # content key, derived from the ephemeral scalar
n <- random 12 bytes
(C, t) = AES-256-GCM-Encrypt(key=K, nonce=n, plaintext)
encryptedPayload = n ‖ C ‖ t

for each recipient public key P:
S = ECDH(e, P)
W = KDF(S, 32)
n_r <- random 12 bytes
(c, τ) = AES-256-GCM-Encrypt(key=W, nonce=n_r, plaintext=K)
recipientKeys[hash160(P)] = n_r ‖ c ‖ τ

The content key is derived from the ephemeral private scalar. Anyone holding e can decrypt, so e must be discarded after encryption. Plaintext must be non-empty.

Decryption

With private key d:

entry = recipientKeys[hash160(d·G)] # absent => not encrypted for this recipient
S = ECDH(d, ephemeralPubKey)
W = KDF(S, 32)
K = AES-256-GCM-Decrypt(key=W, nonce=entry[0:12], ct=entry[12:44], tag=entry[44:60])
plain = AES-256-GCM-Decrypt(key=K, nonce=payload[0:12], ct=payload[12:-16], tag=payload[-16:])

Both GCM tags must verify. A failure is an integrity error.

The message

CDepinMessage :=
token string e.g. "&NEWS/GENERAL"
senderAddress string base58 P2PKH address
timestamp int64 Unix seconds, sender's clock
messageType uint8 0x01 private, 0x02 group
encryptedPayload vector<uint8> serialized CECIESEncryptedMessage
signature vector<uint8> DER ECDSA signature

The plaintext inside encryptedPayload is the content as UTF-8 bytes with no framing. Applications that need structure define it inside the content. messageType must be 0x01 or 0x02; the node treats both identically and 0x02 is the conventional value.

Identifier and signature

digest = SHA256d( ser_string(token) ‖ ser_string(senderAddress) ‖ LE64(timestamp)
‖ uint8(messageType) ‖ ser_vector(encryptedPayload) )
hash = hex(reverse(digest)) # the "hash" field and paging cursor
signature = DER-ECDSA(senderKey, digest) # over the unreversed digest

The signed bytes are exactly the wire serialization of the first five fields. The pool is keyed by hash; a message with a known hash is rejected as a duplicate.

Acceptance rules

A message is stored only if all of the following hold, in this order:

  1. token is the pool root or inside its subtree.
  2. messageType is 0x01 or 0x02.
  3. timestamp is at most 60 seconds in the future.
  4. The message is not expired (default 168 hours).
  5. encryptedPayload is non-empty and at most maxmessagesize × maxrecipients bytes (defaults 1024 × 20).
  6. If the payload parses as an ECIES envelope, it has at most maxrecipients entries (default 20, hard cap 50).
  7. The pool size limit is not exceeded.
  8. signature verifies against the sender's revealed public key.
  9. senderAddress has inherited access to token.
  10. hash is not already in the pool.

The node does not check that the recipient list matches the holder set. The sender chooses its readers. Receivers see a message when they are the sender or appear in recipientKeys; decryption is the final boundary.

Expiry

A message is expired when now − timestamp exceeds the pool's expiry. Expired messages are removed periodically (default every 300 seconds) and never returned. Clients must not assume a message is retrievable later.

Reply authentication

Every reply is a plain body or a bound (encrypted) body, and both carry poolsig:

preimage = "DEPIN-RESP|" method "|" token "|" address "|" challenge "|" sha256hex(bodystr)
poolsig = signmessage(poolKey, preimage)
  • method is the RPC name. token, address and challenge are the request's values, each empty when the method has no such argument.
  • Plain replies that take no token (depingetmsginfo, depinpoolstats, depinmcpstatus, depinlistsections without arguments) bind the pool root as token. depingetancestorrecipients binds the token it was asked about.
  • bodystr is the ASCII hex string of body or encrypted exactly as received. sha256hex is the lowercase hex of a single SHA-256 of it.
  • Verification: the key recovered from poolsig over msghash(preimage) must equal the pinned depinpoolpkey. Equivalently, verifymessage depinpoolkeyaddress poolsig preimage.

Binding the request's token, address and challenge into the preimage makes a reply unusable for any other request.

The pool key is derived deterministically from the service wallet, so a correctly operated service keeps its key across restarts.

Authentication preimages

PurposePreimage
Request a challengeDEPIN-REQ|<type>|<token>|<address>|<milliseconds> with type = receive or admin
Use a receive challengeDEPIN-GET|<token>|<address>|<challenge>
Use an admin challengeDEPIN-CLEAR|<token>|<address>|<challenge>
Reply signatureDEPIN-RESP|<method>|<token>|<address>|<challenge>|<sha256hex(body)>

A challenge request is accepted only when the timestamp is within ±60 seconds of the node's clock, the signature verifies, the address is P2PKH with a revealed key, the address has access, the signature has never been presented before (remembered for 120 seconds) and the address is under its issuance quota. The node keeps at most 4 live challenges per address and 10,000 in total.

Constants and limits

ConstantValue
protocol2
Message magic"Neurai Signed Message:\n"
Request window±60 s
Challenge lifetime30 s issued, 300 s chained
Live challenges4 per address, 10,000 total
Issuance and submission quota20 per address and minute (node default, -depinratelimit)
Challenge nonce32 random bytes, 64 hex characters
RecipientsDefault 20, hard maximum 50
Content sizeDefault 1024 bytes (-depinmsgsize, max 10,240). Payload cap is size × recipients
ExpiryDefault 168 h (-depinmsgexpire, max 720 h)
Timestamp skew accepted on submit+60 s
Page sizelimit at most 1000

Abuse control

LayerRuleEffect
Node, per addressChallenges issued plus messages accepted at most depinratelimit per minute, counting only the address's own authenticated requestsJSON-RPC error -1
Node, per addressAt most 4 live challenges. Issuance requests are single-use within ±60 sOldest evicted, or -32600
Node, globalAt most 10,000 live challenges and 10,000 remembered requests-1
Proxy, per IPdepin* calls limited per minute (default 60). Excess blocks the IP temporarily (default 60 minutes)HTTP 429 with Retry-After
ProxyMethod not whitelistedHTTP 404

Security notes for implementers

  • Verify before you trust anything. poolsig against the pinned key, then the message signature against the sender's revealed key, then decrypt. Never display content that failed any step.
  • The proxy is an adversary in the model. Everything it can do is denial of service once the pool key is pinned. Do not fetch depinpoolpkey on every run and trust it.
  • Keys never leave the client. No RPC in protocol 2 takes a private key.
  • Discard the ephemeral scalar after encrypting. It decrypts the message.
  • The recipient set is a snapshot. Holders issued later do not gain access to older ciphertexts.
  • Metadata is public. Holdings, sections and revealed keys are on chain. The node sees sender, token, timestamp and recipient hashes.
  • Clocks matter. A device with a badly skewed clock cannot authenticate or publish.
  • Never reuse a signed request or a GCM nonce.

Test vectors

The node repository ships deterministic vectors produced by a regtest node, so a library can check its implementation without running a node:

  • contrib/depin/vectors.txt holds keys, preimages, signatures, a plain reply with its poolsig, a bound reply, a full message and eight negative cases that must be rejected.
  • contrib/depin/verify_vectors.py checks all of them with pure Python plus the cryptography package.
  • contrib/depin/regtest_walkthrough.sh runs the whole protocol end to end against a regtest node.
python3 contrib/depin/verify_vectors.py contrib/depin/vectors.txt

The regtest keys in the vectors must never be funded or reused.

The full normative specification lives in the node repository as doc/depin-messaging-protocol.md, with the RPC-by-RPC guide in doc/depinreceivemsg.md.