Skip to content

Commit d6bdbc5

Browse files
committed
Ensure it's ECNet2 time
1 parent 5ce7e7c commit d6bdbc5

18 files changed

+1280
-1
lines changed

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"Lua.runtime.version": "Lua 5.3",
3+
}

README.md

Lines changed: 192 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,192 @@
1-
# ecnet
1+
# ECNet2
2+
ECNet2 is an encrypted networking library for CC:Tweaked. You can find usage
3+
examples in the examples directory.
4+
5+
## Dependencies
6+
- [CCryptolib](https://github.com/migeyel/ccryptolib) >=1.1.0 (you still need to
7+
initialize the random generator yourself)
8+
- [RedRun](https://gist.github.com/MCJack123/473475f07b980d57dd2bd818026c97e8)
9+
10+
## Goals
11+
- Let the user manage the lifetime of connections.
12+
- Minimize the work done by the responder before they accept a handshake.
13+
- Let the user pick a "protocol" (channel, namespace, ...) to talk through.
14+
- Strong assurances for traffic authenticity and confidentiality.
15+
- High efficiency in both computation and bandwidth usage.
16+
- Best-effort hiding of relevant parties' identities.
17+
- Best-effort handling of network abuse.
18+
19+
## Non-goals
20+
- Authenticated multicast or broadcast of messages.
21+
22+
# API Reference
23+
24+
### `ecnet2.open(modem: string)`
25+
Opens a modem for communications.
26+
27+
### `ecnet2.close([modem: string])`
28+
Closes a modem for communications.
29+
30+
### `ecnet2.isOpen([modem: string])`
31+
Returns whether a modem is open for communications.
32+
33+
### `ecnet2.address(): string`
34+
Returns the address for connecting to this device.
35+
36+
### `ecnet2.Protocol(interface: IProtocol): Protocol`
37+
Creates a protocol from a given interface.
38+
39+
## Type `IProtocol`
40+
A table containing a description for a protocol.
41+
42+
### `IProtocol.name: string`
43+
The protocol's name.
44+
45+
### `Iprotocol.serialize(object: any): string`
46+
A serializer for protocol objects.
47+
48+
### `IProtocol.deserialize(str: string): any`
49+
A deserializer for protocol objects.
50+
51+
## Type `Protocol`
52+
A namespace for interpreting messages received over connections.
53+
54+
### `Procotol:connect(address: string, modem: string): Connection`
55+
Creates a new connection using this protocol and a modem.
56+
57+
### `Protocol:listen(): Listener`
58+
Creates a listener for this protocol on all open modems.
59+
60+
## Type `Listener`
61+
A listener for incoming connection requests.
62+
63+
### `Listener.id: string`
64+
The listener's ID, used in resolving `ecnet2_request` events.
65+
66+
### `Listener:accept(reply: any[, request: Request]): Connection`
67+
Accepts a request and builds a connection. Waits for the next request if none
68+
are provided.
69+
70+
Throws `"invalid listener for this request"` if the supplied request isn't meant
71+
for this listener.
72+
73+
Returns a dummy connection if the request is malformed, or if the request has
74+
already been accepted.
75+
76+
## Type `Connection`
77+
An encrypted tunnel operating over a network.
78+
79+
### `Connection.id: string`
80+
The connection's ID, used in `ecnet2_message` events.
81+
82+
### `Connection:send(message: any)`
83+
Sends a message.
84+
85+
Throws `"can't send on an incomplete connection"` until at least one
86+
message has been received.
87+
88+
### `Connection:receive([timeout: number]): string, any`
89+
Yields until a message is received. Returns the sender and contents, or nil on
90+
timeout.
91+
92+
## Events
93+
### `"ecnet2_request", listenerId: string, request: Request, side: string`
94+
A connection request.
95+
- `listenerId` - The `id` field of the listener that received this request.
96+
- `request` - The request to pass on to the `accept` method.
97+
- `side` - Which modem the request was received through.
98+
99+
### `"ecnet2_message", connectionId: string, sender: string, message: any`
100+
A message in a connection.
101+
- `connectionId` - The `id` field of the connection that received this message.
102+
- `sender` - The sender's address.
103+
- `message` - The deserialized message data.
104+
105+
# Technical Details
106+
107+
## Descriptors
108+
Every ECNet2 packet has a 32 byte prefix known as the *descriptor*. Descriptors
109+
allow the receiver to know whether it is supposed to process a packet.
110+
Furthermore, secret descriptors allow for some resistance against decryption
111+
failure denial-of-service attacks on networks with no wormholes.
112+
113+
The listener descriptor is defined as `BLAKE3(BLAKE3(pk .. BLAKE3(protocol)))`,
114+
where `pk` is the listener's public key and `protocol` is the protocol name.
115+
116+
The connection descriptors are derived from the current decryption key, which is
117+
ratcheted every time a new message is received.
118+
119+
## Handshake
120+
We use the noise XK handshake, its pattern is:
121+
```
122+
XK:
123+
<- s
124+
...
125+
-> e, es
126+
<- e, ee
127+
-> s, se
128+
```
129+
130+
The contents of each handshake payload are:
131+
### `-> e, es`
132+
This payload currently contains only padding.
133+
134+
### `<- e, ee`
135+
This payload contains a user-defined reply and padding. Neither the user nor
136+
ECNet know who the initiator is. As a result, naive assumptions match exactly
137+
what the payload security properties (2, 1) are.
138+
139+
### `-> s, se`
140+
This payload contains a user-defined message and padding. The naive assumptions
141+
match exactly what the payload security properties (2, 5) are.
142+
143+
### Why _K?
144+
We need the initiator to have a secret descriptor at the first response,
145+
otherwise an attacker could trigger decryption failures arbitrarily, throwing
146+
the entire connection away. We could try restarting the connection again, but
147+
that's difficult to model in the interface.
148+
149+
### Why not IK?
150+
1. The initiator's identity claim is vulnerable to replay attack, so we can't
151+
assume anything until their first transport message, making IK pointless.
152+
2. IK has an `ss` token, which is harder to protect against timing attacks on
153+
the result of the DH operation, while `es` and `se` are a bit safer.
154+
3. IK hides identity more poorly than XK.
155+
156+
### Why not NK?
157+
Authenticating the initiatior makes the API simpler from the user's point of
158+
view, since they don't have to handle whether the message has a sender or not.
159+
160+
## Size Limits
161+
162+
### `accept()` Reply Argument
163+
The message size limit is 2¹⁵ - 1 = 32767 bytes. The other half of the payload
164+
is reserved for ECNet metadata.
165+
166+
### Initiator's First Message
167+
The message size limit is 2¹⁵ - 1 = 32767 bytes. The other half of the payload
168+
is reserved for ECNet metadata.
169+
170+
### Other Messages
171+
50 bytes of overhead:
172+
- 32 bytes for the descriptor
173+
- 1 byte for packet type information
174+
- At least 1 byte for padding
175+
- 16 bytes for the message's tag
176+
177+
Since noise allows packets of at most 2¹⁶ - 1 bytes in length, the message size
178+
limit is 2¹⁶ - 1 - 50 = 65485 bytes.
179+
180+
## Handshake Model
181+
The XK handshake is modeled into `Connection` and `Listener` objects. The second
182+
payload is modeled as a `reply` parameter to `accept`, while the third payload
183+
is modeled as a regular message:
184+
```
185+
Handshake payloads:
186+
connect() -> e, es, "" -> os.pullEvent("ecnet2_request")
187+
receive() <- e, ee, reply <- accept(reply)
188+
send(msg) -> s, se, msg -> receive()
189+
190+
Transport:
191+
receive() <- msg <- send(msg)
192+
send(msg) -> msg -> receive()

ecnet2/CipherState.lua

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
local class = require "ecnet2.class"
2+
local aead = require "ccryptolib.aead"
3+
local chacha = require "ccryptolib.chacha20"
4+
5+
--- A symmetric encryption cipher state, containing a key and a numeric nonce.
6+
--- @class ecnet2.CipherState
7+
--- @field private k string? The current key.
8+
--- @field private n number The current nonce.
9+
local CipherState = class "ecnet2.CipherState"
10+
11+
--- @param key string? A 32-byte key to initialize the state with.
12+
function CipherState:initialise(key)
13+
self.k = key
14+
self.n = 0
15+
end
16+
17+
--- Whether the state has a key or not.
18+
--- @return boolean
19+
function CipherState:hasKey()
20+
return self.k ~= nil
21+
end
22+
23+
--- Sets the nonce to the given value.
24+
--- @param nonce number
25+
function CipherState:setNonce(nonce)
26+
self.n = nonce
27+
end
28+
29+
--- Rekeys the cipher.
30+
function CipherState:rekey()
31+
self.k = chacha.crypt(self.k, ("<I12"):pack(2 ^ 64 - 1), ("\0"):rep(32), 8)
32+
end
33+
34+
--- Computes the cipher descriptor.
35+
--- @return string
36+
function CipherState:descriptor()
37+
local c = chacha.crypt(self.k, ("<I12"):pack(2 ^ 64 - 1), ("\0"):rep(64), 8)
38+
return c:sub(33)
39+
end
40+
41+
--- Encrypts a message. Returns the plaintext itself when no key is set.
42+
--- @param ad string Associated data to authenticate.
43+
--- @param plaintext string The plaintext to encrypt
44+
--- @return string ciphertext The encrypted text.
45+
function CipherState:encryptWithAd(ad, plaintext)
46+
if self:hasKey() then
47+
local nonce = ("<I12"):pack(self.n)
48+
local ctx, tag = aead.encrypt(self.k, nonce, plaintext, ad, 8)
49+
self.n = self.n + 1
50+
return ctx .. tag
51+
else
52+
return plaintext
53+
end
54+
end
55+
56+
--- Decrypts a message.
57+
--- @param ad string Associated data to authenticate.
58+
--- @param ciphertext string The ciphertext to decrypt.
59+
--- @return string? plaintext The decrypted plaintext, or nil on failure.
60+
function CipherState:decryptWithAd(ad, ciphertext)
61+
if self:hasKey() then
62+
if #ciphertext < 16 then return end
63+
local ctx, tag = ciphertext:sub(1, -17), ciphertext:sub(-16)
64+
local nonce = ("<I12"):pack(self.n)
65+
-- On decryption failure, increment nonce and return nil.
66+
local plaintext = aead.decrypt(self.k, nonce, tag, ctx, ad, 8)
67+
self.n = self.n + 1
68+
return plaintext
69+
else
70+
return ciphertext
71+
end
72+
end
73+
74+
return CipherState

ecnet2/Connection.lua

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
local class = require "ecnet2.class"
2+
local ecnetd = require "ecnet2.ecnetd"
3+
local addressEncoder = require "ecnet2.addressEncoder"
4+
local modems = require "ecnet2.modems"
5+
local uid = require "ecnet2.uid"
6+
7+
--- An encrypted tunnel operating over a modem.
8+
--- @class ecnet2.Connection
9+
--- @field _state ecnet2.HandshakeState The current handshake state.
10+
--- @field _protocol ecnet2.Protocol The connection's protocol.
11+
--- @field _side string The modem name this connection is routing through.
12+
--- @field _handler function The packet handler function.
13+
--- @field id string The connection's ID, used in `ecnet2_message` events.
14+
local Connection = class "ecnet2.Connection"
15+
16+
--- @param state ecnet2.HandshakeState
17+
--- @param protocol ecnet2.Protocol
18+
--- @param side string
19+
function Connection:initialise(state, protocol, side)
20+
self.id = uid()
21+
self._protocol = protocol
22+
self._side = side
23+
self._handler = function(m, _) return self:_handle(m, _) end
24+
self._state = state
25+
if state.d then ecnetd.handlers[state.d] = self._handler end
26+
end
27+
28+
--- @param newState ecnet2.HandshakeState
29+
function Connection:_setState(newState)
30+
if self._state.d then ecnetd.handlers[self._state.d] = nil end
31+
if newState.d then ecnetd.handlers[newState.d] = self._handler end
32+
self._state = newState
33+
end
34+
35+
--- Handles an incoming packet, modifying the state.
36+
--- @param packet string
37+
--- @param _ string
38+
function Connection:_handle(packet, _)
39+
local newState, msg = self._state.resolve(packet)
40+
self:_setState(newState)
41+
if not msg then return end
42+
local deserialize = self._protocol._interface.deserialize
43+
local ok, message = pcall(deserialize, msg)
44+
if ok then
45+
os.queueEvent("ecnet2_message", self.id, self._state.pk, message)
46+
end
47+
end
48+
49+
--- Sends a message.
50+
---
51+
--- Throws `"can't send on an incomplete connection"` until at least one
52+
--- message has been received.
53+
---
54+
--- @param message any The message object.
55+
function Connection:send(message)
56+
local str = self._protocol._interface.serialize(message)
57+
assert(type(str) == "string", "serializer returned non-string")
58+
assert(self._state.maxlen, "can't send on an incomplete connection")
59+
assert(#str <= self._state.maxlen, "serialized message is too large")
60+
local newState, data = self._state.send(str)
61+
self:_setState(newState)
62+
if data then modems.transmit(self._side, data) end
63+
end
64+
65+
--- Yields until a message is received. Returns the sender and contents, or nil
66+
--- on timeout.
67+
--- @param timeout number?
68+
--- @return string? sender
69+
--- @return any message
70+
function Connection:receive(timeout)
71+
local timer = -1
72+
if timeout then timer = os.startTimer(timeout) end
73+
while true do
74+
local event, p1, p2, p3 = os.pullEvent()
75+
if event == "timer" and p1 == timer then
76+
return
77+
elseif event == "ecnet2_message" and p1 == self.id then
78+
os.cancelTimer(timer)
79+
return addressEncoder.encode(p2), p3
80+
end
81+
end
82+
end
83+
84+
return Connection

0 commit comments

Comments
 (0)