Skip to content

Commit 41ad051

Browse files
feat(relay): production v1.0 multi-device E2E encrypted clipboard relay
1 parent c546cbb commit 41ad051

18 files changed

Lines changed: 1751 additions & 300 deletions

File tree

README.md

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -149,22 +149,13 @@ pastelocal-remote --snippet api-key
149149

150150
---
151151

152-
## Experimental: Multi-Device Relay
152+
## Multi-Device Relay (v1.0)
153153

154-
PasteLocal has an **experimental** relay system that allows clipboard sharing between multiple devices without requiring direct SSH tunnels.
154+
PasteLocal has a **production-ready relay** (v1.0) that allows E2E-encrypted clipboard sharing between any number of devices without requiring direct SSH tunnels between every pair. See docs/RELAY.md for full setup, commands, and auto-sync configuration.
155155

156-
**Current Status:** Experimental / Preview
156+
**Current Status:** v1.0 — persistence, per-peer encryption, CLI verbs, watcher auto-push, and Grok skills are complete and durable.
157157

158-
**What works today:**
159-
- Device pairing with end-to-end encryption (X25519 + AES-GCM)
160-
- Receiving clipboard content from paired peers via `pastelocal-remote --relay`
161-
162-
**What is still in progress:**
163-
- Reliable sending from the local daemon
164-
- Background notifications
165-
- Persistence across relay restarts
166-
167-
**Recommendation:** Use the SSH-based workflow for daily work. The relay is intended for testing and specific multi-machine setups.
158+
**Recommendation:** Use the SSH-based workflow for daily work. The relay is the recommended path for multi-device, cloud, or no-direct-tunnel scenarios.
168159

169160
---
170161

cmd/pastelocal-remote/main.go

Lines changed: 104 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,12 @@ func main() {
4444
// Relay mode: fetch from relay instead of local daemon.
4545
relayURL := flag.String("relay", "", "relay URL to fetch from (e.g., http://localhost:7332)")
4646

47+
// For relay send: target peer (device ID or fingerprint from 'pastelocal relay devices')
48+
relayPeer := flag.String("peer", "", "target peer device ID for --relay --send")
49+
4750
flag.Parse()
4851

49-
os.Exit(run(*port, expandHome(*outDir), *timeout, expandHome(*tokenFile), *send, *sendFormat, *watch, *list, *index, *snippet, *relayURL))
52+
os.Exit(run(*port, expandHome(*outDir), *timeout, expandHome(*tokenFile), *send, *sendFormat, *watch, *list, *index, *snippet, *relayURL, *relayPeer))
5053
}
5154

5255
// expandHome replaces a leading ~ with the user's home directory.
@@ -70,7 +73,7 @@ func readToken(path string) (string, error) {
7073
return strings.TrimSpace(string(data)), nil
7174
}
7275

73-
func run(port int, outDir string, timeout time.Duration, tokenFile string, sendPath string, sendFormat string, doWatch bool, doList bool, index int, snippetName string, relayURL string) int {
76+
func run(port int, outDir string, timeout time.Duration, tokenFile string, sendPath string, sendFormat string, doWatch bool, doList bool, index int, snippetName string, relayURL string, relayPeer string) int {
7477
token, err := readToken(tokenFile)
7578
if err != nil {
7679
fmt.Fprintf(os.Stderr, "error reading token: %v\n", err)
@@ -85,12 +88,17 @@ func run(port int, outDir string, timeout time.Duration, tokenFile string, sendP
8588
return runWatch(baseURL, token)
8689
}
8790

88-
// Send mode: push a file to the local clipboard.
89-
if sendPath != "" {
91+
// Send mode: push a file to the local clipboard (SSH path).
92+
if sendPath != "" && relayURL == "" {
9093
return runSend(client, baseURL, token, sendPath, sendFormat)
9194
}
9295

93-
// Relay mode: fetch from relay instead of local daemon.
96+
// Relay send: push file to a peer via relay (new in v1).
97+
if sendPath != "" && relayURL != "" {
98+
return runRelaySend(relayURL, sendPath, sendFormat, relayPeer)
99+
}
100+
101+
// Relay mode: fetch/list inbox from relay.
94102
if relayURL != "" {
95103
return runRelayFetch(client, relayURL, outDir)
96104
}
@@ -836,3 +844,94 @@ func randomString(n int) string {
836844
}
837845
return string(b)
838846
}
847+
848+
// runRelaySend pushes a local file to a peer's relay inbox (E2EE).
849+
// Used when both --relay and --send are provided.
850+
func runRelaySend(relayURL, filePath, format, peerDeviceID string) int {
851+
if peerDeviceID == "" {
852+
fmt.Fprintf(os.Stderr, "error: --peer <device-id> is required for relay send (see 'pastelocal relay devices' or 'pastelocal relay inbox')\n")
853+
return 10
854+
}
855+
856+
keyPath := expandHome("~/.config/pastelocal/device-key")
857+
tokenPath := expandHome("~/.config/pastelocal/relay-token")
858+
859+
keyData, err := os.ReadFile(keyPath)
860+
if err != nil {
861+
fmt.Fprintf(os.Stderr, "device key not found. Run 'pastelocal relay init' first: %v\n", err)
862+
return 10
863+
}
864+
tokenData, err := os.ReadFile(tokenPath)
865+
if err != nil {
866+
fmt.Fprintf(os.Stderr, "relay token not found. Run 'pastelocal relay pair' first: %v\n", err)
867+
return 10
868+
}
869+
870+
kp, err := crypto.LoadKeyPairFromBase64(strings.TrimSpace(string(keyData)))
871+
if err != nil {
872+
fmt.Fprintf(os.Stderr, "failed to load device keypair: %v\n", err)
873+
return 10
874+
}
875+
deviceID := kp.DeviceID()
876+
token := strings.TrimSpace(string(tokenData))
877+
878+
rclient := relay.NewClient(relayURL, deviceID, kp, token)
879+
880+
// Read file
881+
data, err := os.ReadFile(filePath)
882+
if err != nil {
883+
fmt.Fprintf(os.Stderr, "failed to read file: %v\n", err)
884+
return 10
885+
}
886+
if format == "" {
887+
if len(data) > 4 && data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G' {
888+
format = "png"
889+
} else {
890+
format = "text"
891+
}
892+
}
893+
894+
// Resolve peer pubkey (prefer peers list, fall back to devices)
895+
var peerPubB64 string
896+
peers, _ := rclient.ListPeers()
897+
if peers != nil && peers.OK {
898+
for _, p := range peers.Peers {
899+
if p.DeviceID == peerDeviceID || p.Fingerprint == peerDeviceID || strings.HasPrefix(p.DeviceID, peerDeviceID) {
900+
peerPubB64 = p.PublicKey
901+
peerDeviceID = p.DeviceID // normalize
902+
break
903+
}
904+
}
905+
}
906+
if peerPubB64 == "" {
907+
devs, _ := rclient.ListDevices()
908+
if devs != nil && devs.OK {
909+
for _, d := range devs.Devices {
910+
if d.DeviceID == peerDeviceID || d.Fingerprint == peerDeviceID || strings.HasPrefix(d.DeviceID, peerDeviceID) {
911+
peerPubB64 = d.PublicKey
912+
peerDeviceID = d.DeviceID
913+
break
914+
}
915+
}
916+
}
917+
}
918+
if peerPubB64 == "" {
919+
fmt.Fprintf(os.Stderr, "could not find public key for peer %s (have you run 'pastelocal relay add-peer' on both sides?)\n", peerDeviceID)
920+
return 10
921+
}
922+
923+
peerPub, err := crypto.ParsePublicKey(peerPubB64)
924+
if err != nil {
925+
fmt.Fprintf(os.Stderr, "failed to parse peer public key: %v\n", err)
926+
return 10
927+
}
928+
929+
_, err = rclient.EncryptAndUploadTo(peerPub, peerDeviceID, format, data, 300)
930+
if err != nil {
931+
fmt.Fprintf(os.Stderr, "relay send failed: %v\n", err)
932+
return 10
933+
}
934+
935+
fmt.Printf("Sent %s (%s) to peer %s via relay.\n", filePath, format, peerDeviceID)
936+
return 0
937+
}

0 commit comments

Comments
 (0)