Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ enowX Cloud, opt-in, puts a team together around the same projects ([cloud.md](d

- **Teams with GitHub sign-in**, owners, admins, members and viewers. An invite is a link that works in the browser too: someone without the app signs in and joins there, then downloads enowX and finds the team.
- **A team's space:** its notes and canvases, its projects as Git links (cloned in one click, found when you have them already), its kit of skills, commands, subagents, pipelines, automations and templates, and each project's rules for the agents.
- **Shared hosts** over Tailscale: a member sees the sessions of a host shared with the team, watches one live, asks to take it over, or starts their own there with their own agent account, never the host owner's credentials.
- **Shared hosts** through the cloud's relay, end to end encrypted, no Tailscale needed: a member sees the sessions of a host shared with the team, watches one live, asks to take it over, or starts their own there with their own agent account, never the host owner's credentials.
- **Team chat** in the side panel, with `@` to mention members, notes, canvases, projects and files; messages edit and delete.
- **End-to-end encrypted:** what a team shares is sealed with the team's key on its members' devices, so the cloud holds ciphertext only; credentials never go to it. An audit log and a recovery key for the team's keys come with it.

Expand Down
26 changes: 26 additions & 0 deletions cloud/api/tickets.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ type HostTicketParams struct {
type HostTicket struct {
Ticket string `json:"ticket"`
Expires time.Time `json:"expires"`
// Relay is where the member connects to reach the host through the
// cloud's relay (wss://…/v1/relay/connect), "" for a cloud without one.
Relay string `json:"relay,omitempty"`
}

// RedeemParams redeems a ticket handed to the host.
Expand All @@ -40,3 +43,26 @@ type Visitor struct {
Role string `json:"role"`
Device string `json:"device"`
}

// The relay (docs/cloud.md, Hosts and sessions): a host sharing projects
// keeps a connection open to it (PathRelayHost, its own token), so it needs
// no address members can reach. A member connects with their ticket
// (PathRelayConnect, the ticket in HeaderTicket); the relay tells the host
// (a RelayCall), the host opens a connection for it (PathRelayAccept), and
// the relay passes bytes between the two, which are end to end encrypted
// (cloud/relay): it sees who connects to whom and how much, not what.
// All three are WebSocket connections.
const (
PathRelayHost = "/v1/relay/host" // the host's, its token: RelayCall messages
PathRelayAccept = "/v1/relay/accept/{id}" // the host's, its token: a call's bytes
PathRelayConnect = "/v1/relay/connect" // the member's, HeaderTicket: the host's bytes
HeaderTicket = "X-Enowx-Ticket"
)

// RelayCall tells the host a member waits on the relay: it opens
// PathRelayAccept with ID. From is the address the member came from, for
// the host's limits by address.
type RelayCall struct {
ID string `json:"id"`
From string `json:"from"`
}
276 changes: 276 additions & 0 deletions cloud/relay/e2e.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
package relay

import (
"crypto/cipher"
"crypto/ecdh"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"os"
"sync"
"time"

"golang.org/x/crypto/chacha20poly1305"
)

// End to end between a member and the host they visit, so the relay passes
// bytes it can't read. The host has a key of its own (X25519), whose public
// half it announces in its team's space, which the cloud keeps sealed and
// can't forge. The member's app opens a channel to that key alone:
//
// 1. the member sends a fresh key e;
// 2. the host answers with a fresh key e2 and a proof that it holds the
// announced key (a box only DH(e, host key) opens);
// 3. both derive a key each way from DH(e, host key) and DH(e, e2).
//
// The relay sees two fresh public keys and a proof; without the host's key
// it can neither read the records that follow nor stand in for the host,
// and the fresh keys mean a key stolen later can't read what was recorded.
// Who the member is, the host learns inside the channel, from their ticket.

const (
magic = "ENXR1"
recordMax = 16 << 10 // plaintext of a record
handshakeT = 15 * time.Second
)

// ErrHostKey is a host that couldn't prove it holds the key its team
// announced: not the host, or a relay standing in for it.
var ErrHostKey = errors.New("relay: the host didn't prove it holds the key its team announced")

// Key is a host's own key.
type Key struct{ priv *ecdh.PrivateKey }

// LoadKey reads the host's key from file, making one the first time.
func LoadKey(file string) (*Key, error) {
if data, err := os.ReadFile(file); err == nil {
priv, err := ecdh.X25519().NewPrivateKey(data)
if err != nil {
return nil, fmt.Errorf("relay: the host key in %s: %w", file, err)
}
return &Key{priv}, nil
}
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
if err := os.WriteFile(file, priv.Bytes(), 0o600); err != nil {
return nil, fmt.Errorf("relay: write the host key: %w", err)
}
return &Key{priv}, nil
}

// NewKey is a key kept in memory alone (tests).
func NewKey() *Key {
priv, _ := ecdh.X25519().GenerateKey(rand.Reader)
return &Key{priv}
}

// Public is the key's public half, as the host announces it.
func (k *Key) Public() string { return base64.StdEncoding.EncodeToString(k.priv.PublicKey().Bytes()) }

func transcript(parts ...[]byte) []byte {
h := sha256.New()
h.Write([]byte("enowx-relay-e2e-v1"))
for _, p := range parts {
h.Write(p)
}
return h.Sum(nil)
}

func derive(secret, salt []byte, info string, n int) ([]byte, error) {
return hkdf.Key(sha256.New, secret, salt, info, n)
}

// Client opens the channel to the host whose announced public key is
// hostKey, over nc (the member's end).
func Client(nc net.Conn, hostKey string) (net.Conn, error) {
raw, err := base64.StdEncoding.DecodeString(hostKey)
if err != nil {
return nil, fmt.Errorf("relay: the host's announced key: %w", err)
}
S, err := ecdh.X25519().NewPublicKey(raw)
if err != nil {
return nil, fmt.Errorf("relay: the host's announced key: %w", err)
}
e, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
_ = nc.SetDeadline(time.Now().Add(handshakeT))
defer nc.SetDeadline(time.Time{})
ePub := e.PublicKey().Bytes()
if _, err := nc.Write(append([]byte(magic), ePub...)); err != nil {
return nil, err
}
var answer [32 + 2 + chacha20poly1305.Overhead]byte
if _, err := io.ReadFull(nc, answer[:]); err != nil {
return nil, fmt.Errorf("relay: the host's answer: %w", err)
}
e2Pub := answer[:32]
dh1, err := e.ECDH(S)
if err != nil {
return nil, ErrHostKey
}
h := transcript(raw, ePub)
h2 := transcript(h, e2Pub)
k1, err := derive(dh1, h, "host proof", chacha20poly1305.KeySize)
if err != nil {
return nil, err
}
box, _ := chacha20poly1305.New(k1)
if ok, err := box.Open(nil, make([]byte, chacha20poly1305.NonceSize), answer[32:], h2); err != nil || string(ok) != "ok" {
return nil, ErrHostKey
}
E2, err := ecdh.X25519().NewPublicKey(e2Pub)
if err != nil {
return nil, ErrHostKey
}
dh2, err := e.ECDH(E2)
if err != nil {
return nil, ErrHostKey
}
return newSecure(nc, dh1, dh2, h2, true)
}

// Server answers a member's channel over nc (the host's end).
func (k *Key) Server(nc net.Conn) (net.Conn, error) {
_ = nc.SetDeadline(time.Now().Add(handshakeT))
defer nc.SetDeadline(time.Time{})
var hello [len(magic) + 32]byte
if _, err := io.ReadFull(nc, hello[:]); err != nil {
return nil, fmt.Errorf("relay: the member's hello: %w", err)
}
if string(hello[:len(magic)]) != magic {
return nil, errors.New("relay: not a member's channel")
}
ePub := hello[len(magic):]
E, err := ecdh.X25519().NewPublicKey(ePub)
if err != nil {
return nil, err
}
dh1, err := k.priv.ECDH(E)
if err != nil {
return nil, err
}
e2, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
dh2, err := e2.ECDH(E)
if err != nil {
return nil, err
}
e2Pub := e2.PublicKey().Bytes()
h := transcript(k.priv.PublicKey().Bytes(), ePub)
h2 := transcript(h, e2Pub)
k1, err := derive(dh1, h, "host proof", chacha20poly1305.KeySize)
if err != nil {
return nil, err
}
box, _ := chacha20poly1305.New(k1)
proof := box.Seal(nil, make([]byte, chacha20poly1305.NonceSize), []byte("ok"), h2)
if _, err := nc.Write(append(e2Pub, proof...)); err != nil {
return nil, err
}
return newSecure(nc, dh1, dh2, h2, false)
}

// secure is a channel's records: a length, then a box sealed with the
// direction's key and a counter, so a record dropped, repeated or moved by
// the relay fails to open and ends the channel.
type secure struct {
net.Conn
rmu sync.Mutex // the reading side: in, rbuf, rn
in cipher.AEAD
rn uint64
rbuf []byte
wmu sync.Mutex // the writing side: out, wn
out cipher.AEAD
wn uint64
readErr error
writeErr error
}

func newSecure(nc net.Conn, dh1, dh2, salt []byte, client bool) (net.Conn, error) {
keys, err := derive(append(append([]byte{}, dh1...), dh2...), salt, "enowx-relay keys", 2*chacha20poly1305.KeySize)
if err != nil {
return nil, err
}
toHost, _ := chacha20poly1305.New(keys[:32])
toMember, _ := chacha20poly1305.New(keys[32:])
s := &secure{Conn: nc, in: toMember, out: toHost}
if !client {
s.in, s.out = toHost, toMember
}
return s, nil
}

func nonce(n uint64) []byte {
var b [chacha20poly1305.NonceSize]byte
binary.LittleEndian.PutUint64(b[4:], n)
return b[:]
}

func (s *secure) Read(p []byte) (int, error) {
s.rmu.Lock()
defer s.rmu.Unlock()
for len(s.rbuf) == 0 {
if s.readErr != nil {
return 0, s.readErr
}
var hdr [2]byte
if _, err := io.ReadFull(s.Conn, hdr[:]); err != nil {
s.readErr = err
return 0, err
}
n := int(binary.BigEndian.Uint16(hdr[:]))
if n < chacha20poly1305.Overhead || n > recordMax+chacha20poly1305.Overhead {
s.readErr = errors.New("relay: a record of a wrong size")
return 0, s.readErr
}
rec := make([]byte, n)
if _, err := io.ReadFull(s.Conn, rec); err != nil {
s.readErr = err
return 0, err
}
plain, err := s.in.Open(rec[:0], nonce(s.rn), rec, nil)
if err != nil {
s.readErr = errors.New("relay: a record that doesn't open: changed on its way")
return 0, s.readErr
}
s.rn++
s.rbuf = plain
}
n := copy(p, s.rbuf)
s.rbuf = s.rbuf[n:]
return n, nil
}

func (s *secure) Write(p []byte) (int, error) {
s.wmu.Lock()
defer s.wmu.Unlock()
if s.writeErr != nil {
return 0, s.writeErr
}
done := 0
for done < len(p) {
chunk := p[done:min(len(p), done+recordMax)]
rec := make([]byte, 2, 2+len(chunk)+chacha20poly1305.Overhead)
rec = s.out.Seal(rec, nonce(s.wn), chunk, nil)
binary.BigEndian.PutUint16(rec, uint16(len(rec)-2))
if _, err := s.Conn.Write(rec); err != nil {
s.writeErr = err
return done, err
}
s.wn++
done += len(chunk)
}
return done, nil
}
75 changes: 75 additions & 0 deletions cloud/relay/e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package relay

import (
"errors"
"io"
"net"
"testing"
)

// A member's channel opens to the host holding the announced key, carries
// bytes both ways, and refuses one that doesn't hold it or a record changed
// on its way.
func TestChannel(t *testing.T) {
host := NewKey()
a, b := net.Pipe()
got := make(chan net.Conn, 1)
go func() {
sc, err := host.Server(b)
if err != nil {
t.Error(err)
}
got <- sc
}()
mc, err := Client(a, host.Public())
if err != nil {
t.Fatal(err)
}
hc := <-got
go mc.Write(make([]byte, 40000)) // past one record
buf := make([]byte, 40000)
if _, err := io.ReadFull(hc, buf); err != nil {
t.Fatal(err)
}
go hc.Write([]byte("back"))
small := make([]byte, 4)
if _, err := io.ReadFull(mc, small); err != nil || string(small) != "back" {
t.Fatalf("%q %v", small, err)
}

a, b = net.Pipe()
go NewKey().Server(b) // another host than the one announced
if _, err := Client(a, host.Public()); !errors.Is(err, ErrHostKey) {
t.Fatalf("a host without the announced key: %v", err)
}
}

func TestChangedRecordFails(t *testing.T) {
host := NewKey()
a, b := net.Pipe()
mid1, mid2 := net.Pipe() // what passes from member to host, through here
go func() {
sc, _ := host.Server(mid2)
buf := make([]byte, 8)
if _, err := sc.Read(buf); err == nil {
t.Error("a changed record opened")
}
}()
go func() { // the handshake both ways, then a record with a bit flipped
hello := make([]byte, 37)
io.ReadFull(b, hello)
mid1.Write(hello)
answer := make([]byte, 50)
io.ReadFull(mid1, answer)
b.Write(answer)
rec := make([]byte, 2+5+16)
io.ReadFull(b, rec)
rec[4] ^= 1
mid1.Write(rec)
}()
mc, err := Client(a, host.Public())
if err != nil {
t.Fatal(err)
}
mc.Write([]byte("hello"))
}
Loading
Loading