openvibers/openvibe.link Commits
Link Send: seal in Go, sync the clipboard, and receive without the window (#160)
Changes
@@@ -116,9 +116,10 @@ tasks this machine ran today, and what happened, and lets you pause everything,share a port, or send a file, a note or copied text to another of your machines (sealed on this computer withopenvibe-sdk/locker; the other machine lists the sender and size and asks before saving). A transfer is uploaded inparts, so a dropped connection continues where it stopped; on the same network it is handed over directly, and--otherwise the relay holds only ciphertext. Clipboard sharing is a per-machine switch, off by default and text only. It is served only on--loopback, with a per-run token in the address fragment that every API call must send; see [docs/panel.md](docs/panel.md)--for the security rules.++otherwise the relay holds only ciphertext. Clipboard sharing is a per-machine switch, off by default and text only;++with auto-accept on, the daemon saves a received file to Downloads and puts received copied text on the clipboard++without needing the window open. It is served only on loopback, with a per-run token in the address fragment that++every API call must send; see [docs/panel.md](docs/panel.md) for the security rules.**Your machines:** [openvibe.link/machines](https://openvibe.link/machines) opens the signed-in machines hub: eachpaired machine, whether it is online, the roles it plays (Codes jobs, desks, your own models, a robot), and pairing a@@@ -39,8 +39,8 @@ A restart makes a new port and a new token: an old link stops working.| `POST /panel/api/shares` `{ "port": 3000 }` | Shares a port through Connect | Real for a share Connect already picked for this computer || `DELETE /panel/api/shares/:port` | Ends that share | Real || `POST /panel/api/first-run` `{ "uses": ["codes", "browser"] }` | The first-run answer: records it in `<state>/panel.json`, turns on a browser (`browser`) or the AI plugin (`models`), and stops asking. `codes` and `earn` are recorded only (Codes has no module switch yet, and Exchange confirms earning); `vibe` turns nothing on; `skip: true` only stops asking | Real; never turns on Terminal or Desktop |--| `POST /panel/api/clipboard` `{ "on": true }` | The per-machine clipboard-sharing switch | Off by default, text only, kept in `<state>/panel.json` |--| `POST /panel/api/send/auto-accept` `{ "on": true }` | Save items from the person's own machines without asking | Off by default, kept in `<state>/panel.json` |++| `POST /panel/api/clipboard` `{ "on": true }` | The per-machine clipboard-sharing switch | Off by default, text only, kept in `<state>/panel.json`; while on, the daemon reads the clipboard and seals changes for the person's other machines, even when the window is closed |++| `POST /panel/api/send/auto-accept` `{ "on": true }` | Save items from the person's own machines without asking | Off by default, kept in `<state>/panel.json`; the daemon saves files to Downloads and puts clipboard text on the clipboard while the window is closed || `GET /panel/api/send/targets` | Your other active machines that have a receive key | Real for Network-paired machines || `GET /panel/api/send/inbox` | Pending encrypted items | Real; the envelope remains ciphertext until you accept || `GET /panel/api/send/key` | This machine's receive key for opening an accepted envelope | Real; loopback panel token required |@@@ -50,9 +50,17 @@ A restart makes a new port and a new token: an old link stops working.| `POST /panel/api/files/:id/complete` | Removes an accepted item after the browser download starts | Real |`state.clipboard` and `state.auto_accept` are the two per-machine switches; both are `false` until the person turns--them on, and `panel.json` keeps them with the capability switches. A direct transfer on the same network is queued in--memory by this machine's listener and appears in `send/inbox` beside the relay's items; the listener accepts only an--HMAC from the owner's LAN secret, so another machine on the network cannot put anything there.++them on, and `panel.json` keeps them with the capability switches. The daemon keeps the transfer loop running without++the window: with auto-accept on it opens each item from this account's own machines, saves a file or note to++`~/Downloads` (never replacing an existing file) and reports the save in activity; with clipboard sharing on it does++the same for copied text, and seals each new clipboard change for the person's other machines. Clipboard text is++capped at 64 KB and is never sent in clear. An item granted by another account through machine access stays in++`send/inbox` for a person to answer, even when auto-accept is on.++++A direct transfer on the same network is queued in memory by this machine's listener and appears in `send/inbox`++beside the relay's items; the listener accepts only an HMAC from the owner's LAN secret, so another machine on the++network cannot put anything there. A granted transfer from another account uses the same listener with Network's++signed `send` grant, and always waits for the person.`state` omits what the Link cannot fill truthfully: `devices` (the paired account's machine list is not read here),`gpu` (no GPU detection), and `earnings_today`. `activity` is the Link's own record of what it saw (an approval@@@ -125,6 +125,19 @@ func (n *Agent) panelSendOptions(ctx context.Context) panelSend {}}}()++ // The window can be closed; receiving and clipboard sync still run here while the person has the switches on.++ // Auto-accept only keeps items from this account's own machines: a granted item from another account stays in++ // the window for an answer.++ service := send.NewService(send.ServiceOptions{++ Relay: client, Direct: lan, Key: key, Principal: n.opt.Creds.Principal, Log: n.log,++ Settings: func() send.Settings {++ n.mu.Lock()++ defer n.mu.Unlock()++ return send.Settings{AutoAccept: n.panelAutoAccept, Clipboard: n.panelClipboard}++ },++ OnActivity: n.addActivity,++ })++ go service.Run(ctx)return panelSend{key: func() any {return map[string]any{"private_key": key, "subject": map[string]string{"type": "link", "id": n.opt.Creds.Principal}}nnew file mode 100644@@@ -0,0 +1,291 @@++// Seal writes the people lock emitted by openvibe-sdk/locker. The format is the SDK's: every byte here matches++// what the SDK would write for the same key, salt, nonce and content (seal_test.go checks the shared vector).++package lockeropen++++import (++ "crypto/aes"++ "crypto/cipher"++ "crypto/ecdh"++ "crypto/elliptic"++ "crypto/hkdf"++ "crypto/rand"++ "crypto/sha256"++ "encoding/base64"++ "encoding/binary"++ "encoding/json"++ "errors"++ "fmt"++ "io"++ "math/big"++ "math/bits"++)++++const (++ minPaddedSize = 256++ finalFlag = byte(1)++ saltBytes = 16++ keyBytes = 32++ nonceBaseSize = 8++ nonceSize = 12++)++++// SealOptions fixes the values a test vector needs to be the same bytes every time. A caller sealing a real item++// leaves them empty and gets fresh random ones.++type SealOptions struct {++ ContentKey []byte++ NonceBase []byte++ // EphemeralPrivate is the envelope's one ephemeral P-256 private key as a 32-byte scalar.++ EphemeralPrivate []byte++ Salt []byte++ WrapNonce []byte++ // Random is the source for whatever was not given above. Nil means crypto/rand.++ Random io.Reader++}++++// Seal builds a people-locked envelope addressed to one recipient. It is the sender's half of Open: a machine++// that holds the private key for public opens it, and nobody else can.++func Seal(content []byte, recipient Subject, public JWK, opt SealOptions) ([]byte, error) {++ if public.Kty != "EC" || public.Crv != "P-256" || public.X == "" || public.Y == "" {++ return nil, errors.New("the receiving machine has no P-256 key")++ }++ if len(content) > maxBytes {++ return nil, errors.New("the item is too large to seal")++ }++ rnd := opt.Random++ if rnd == nil {++ rnd = rand.Reader++ }++ read := func(n int, given []byte) ([]byte, error) {++ if given != nil {++ if len(given) != n {++ return nil, errors.New("invalid seal value")++ }++ return given, nil++ }++ b := make([]byte, n)++ if _, err := io.ReadFull(rnd, b); err != nil {++ return nil, err++ }++ return b, nil++ }++ contentKey, err := read(keyBytes, opt.ContentKey)++ if err != nil {++ return nil, err++ }++ nonceBase, err := read(nonceBaseSize, opt.NonceBase)++ if err != nil {++ return nil, err++ }++ salt, err := read(saltBytes, opt.Salt)++ if err != nil {++ return nil, err++ }++ wrapNonce, err := read(nonceSize, opt.WrapNonce)++ if err != nil {++ return nil, err++ }++ ephemeral, err := ephemeralKey(opt.EphemeralPrivate, rnd)++ if err != nil {++ return nil, err++ }++ recipientKey, err := thumbprint(public)++ if err != nil {++ return nil, err++ }++ shared, err := ephemeral.ECDH(recipientPublic(public))++ if err != nil {++ return nil, errors.New("the receiving machine has no usable P-256 key")++ }++ defer wipe(shared)++ ephemeralJWK := ephemeralPublicJwk(ephemeral)++ ephemeralJSON, _ := json.Marshal(map[string]string{"crv": "P-256", "kty": "EC", "x": ephemeralJWK.X, "y": ephemeralJWK.Y})++ info := []byte(fmt.Sprintf("openvibe-locker people-slot v1|%s:%s|%s", recipient.Type, recipient.ID, recipientKey))++ wrapKey, err := hkdf.Key(sha256.New, shared, salt, string(info), keyBytes)++ if err != nil {++ return nil, err++ }++ defer wipe(wrapKey)++ aad := []byte(fmt.Sprintf("openvibe-locker people-slot v1|%s:%s|%s|%s|%s", recipient.Type, recipient.ID, recipientKey, ephemeralJSON, base64.StdEncoding.EncodeToString(salt)))++ block, err := aes.NewCipher(wrapKey)++ if err != nil {++ return nil, err++ }++ gcm, err := cipher.NewGCM(block)++ if err != nil {++ return nil, err++ }++ wrapped := gcm.Seal(nil, wrapNonce, contentKey, aad)++++ padded := paddedSize(len(content))++ header := map[string]any{++ "version": lockerVersion,++ "suite": suite,++ "chunk_size": chunkSize,++ "padded_size": padded,++ "nonce_base": base64.StdEncoding.EncodeToString(nonceBase),++ "lock": "people",++ "slots": map[string]any{"people": []any{map[string]any{++ "subject": map[string]string{"type": recipient.Type, "id": recipient.ID},++ "algorithm": "ECDH-P256/HKDF-SHA256/AES-256-GCM",++ "recipient_key": recipientKey,++ "ephemeral_public_key": map[string]string{"kty": "EC", "crv": "P-256", "x": ephemeralJWK.X, "y": ephemeralJWK.Y},++ "salt": base64.StdEncoding.EncodeToString(salt),++ "nonce": base64.StdEncoding.EncodeToString(wrapNonce),++ "wrapped_key": base64.StdEncoding.EncodeToString(wrapped),++ }}},++ }++ headerBytes, err := json.Marshal(header)++ if err != nil {++ return nil, err++ }++ core, err := json.Marshal(map[string]any{++ "version": lockerVersion,++ "suite": suite,++ "chunk_size": chunkSize,++ "padded_size": padded,++ "nonce_base": base64.StdEncoding.EncodeToString(nonceBase),++ "lock": "people",++ })++ if err != nil {++ return nil, err++ }++ plain := make([]byte, padded)++ binary.BigEndian.PutUint64(plain[:8], uint64(len(content)))++ copy(plain[8:], content)++ defer wipe(plain)++++ block, err = aes.NewCipher(contentKey)++ if err != nil {++ return nil, err++ }++ gcm, err = cipher.NewGCM(block)++ if err != nil {++ return nil, err++ }++ out := make([]byte, 0, 8+len(headerBytes)+len(plain)+gcm.Overhead())++ out = append(out, "OVLK"...)++ out = binary.BigEndian.AppendUint32(out, uint32(len(headerBytes)))++ out = append(out, headerBytes...)++ count := (padded + chunkSize - 1) / chunkSize++ for i := 0; i < count; i++ {++ start := i * chunkSize++ end := start + chunkSize++ if end > padded {++ end = padded++ }++ flags := byte(0)++ if i == count-1 {++ flags = finalFlag++ }++ nonce := make([]byte, nonceSize)++ copy(nonce, nonceBase)++ binary.BigEndian.PutUint32(nonce[8:], uint32(i))++ chunkAad := make([]byte, len(core)+5)++ copy(chunkAad, core)++ binary.BigEndian.PutUint32(chunkAad[len(core):], uint32(i))++ chunkAad[len(core)+4] = flags++ ciphertext := gcm.Seal(nil, nonce, plain[start:end], chunkAad)++ out = binary.BigEndian.AppendUint32(out, uint32(len(ciphertext)))++ out = append(out, flags)++ out = append(out, nonce...)++ out = append(out, ciphertext...)++ }++ return out, nil++}++++// Addressed reports whether an envelope names this machine's public key in a people slot, so Link can refuse an++// item encrypted for another machine before it asks the person anything.++func Addressed(envelope []byte, public JWK) bool {++ h, _, err := readHeader(envelope)++ if err != nil {++ return false++ }++ want, err := thumbprint(public)++ if err != nil {++ return false++ }++ for _, slot := range h.Slots.People {++ if slot.RecipientKey == want && slot.Algorithm == "ECDH-P256/HKDF-SHA256/AES-256-GCM" {++ return true++ }++ }++ return false++}++++func readHeader(envelope []byte) (header, int, error) {++ var h header++ if len(envelope) < 8 || len(envelope) > maxBytes || string(envelope[:4]) != "OVLK" {++ return h, 0, errors.New("invalid locker envelope")++ }++ headerLen := int(binary.BigEndian.Uint32(envelope[4:8]))++ if headerLen < 2 || headerLen > 65536 || 8+headerLen > len(envelope) {++ return h, 0, errors.New("invalid locker header")++ }++ if json.Unmarshal(envelope[8:8+headerLen], &h) != nil {++ return h, 0, errors.New("invalid locker header")++ }++ return h, headerLen, nil++}++++func ephemeralKey(scalar []byte, rnd io.Reader) (*ecdh.PrivateKey, error) {++ if scalar != nil {++ if len(scalar) != 32 {++ return nil, errors.New("invalid ephemeral key")++ }++ return ecdh.P256().NewPrivateKey(scalar)++ }++ return ecdh.P256().GenerateKey(rnd)++}++++func recipientPublic(public JWK) *ecdh.PublicKey {++ x, errX := base64.RawURLEncoding.DecodeString(public.X)++ y, errY := base64.RawURLEncoding.DecodeString(public.Y)++ if errX != nil || errY != nil || len(x) != 32 || len(y) != 32 {++ return nil++ }++ key, err := ecdh.P256().NewPublicKey(elliptic.Marshal(elliptic.P256(), new(big.Int).SetBytes(x), new(big.Int).SetBytes(y)))++ if err != nil {++ return nil++ }++ return key++}++++func ephemeralPublicJwk(key *ecdh.PrivateKey) JWK {++ pub := key.PublicKey().Bytes() // uncompressed point: 0x04 || X || Y++ return JWK{++ Kty: "EC", Crv: "P-256",++ X: base64.RawURLEncoding.EncodeToString(pub[1:33]),++ Y: base64.RawURLEncoding.EncodeToString(pub[33:]),++ }++}++++func thumbprint(public JWK) (string, error) {++ jwk, err := json.Marshal(map[string]string{"crv": "P-256", "kty": "EC", "x": public.X, "y": public.Y})++ if err != nil {++ return "", err++ }++ sum := sha256.Sum256(jwk)++ return base64.RawURLEncoding.EncodeToString(sum[:]), nil++}++++func paddedSize(size int) int {++ p := padme(8 + size)++ if p < minPaddedSize {++ return minPaddedSize++ }++ return p++}++++// padme is Padmé's grid (the SDK's padme.js): round up to the next size the band allows.++func padme(size int) int {++ if size <= 0 {++ return 0++ }++ if size == 1 {++ return 1++ }++ band := bits.Len(uint(size)) - 1++ steps := bits.Len(uint(band))++ step := 1 << (band - steps)++ return ((size + step - 1) / step) * step++}nnew file mode 100644@@@ -0,0 +1,105 @@++package lockeropen++++import (++ "crypto/ecdsa"++ "crypto/elliptic"++ "crypto/rand"++ "crypto/sha256"++ "encoding/base64"++ "encoding/hex"++ "math/big"++ "testing"++)++++// The shared SDK vector: fixed keys, fixed bytes, and the envelope hash the SDK's own test checks. If Go and the++// SDK ever write different bytes for the same input, this fails.++const (++ vectorContent = "The quick brown fox jumps over the lazy dog."++ vectorEnvelopeID = "a07abdad22e7400831299bb6c363c50241c816bb49c361e555f90bd148690b47"++)++++var vectorPublic = JWK{++ Kty: "EC", Crv: "P-256",++ X: "pNngpTSE3X3C_CGzfpAtenjliLPFZ9arBVNtI0ctPXw",++ Y: "fXZT2JcDDiXEb6xH3LUrqlPakM3GQwe3aqbV_1vhVLI",++ D: "cG9YgmeoPeaI8lXZRpijV5--2sC4rYWSqWbyGCYKPu0",++}++var vectorSubject = Subject{Type: "user", ID: "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"}++++// fixedRandom is the SDK's vectors.fixedRandom: the same xorshift32 stream, so the same salt and key bytes.++type fixedRandom struct{ state uint32 }++++func (f *fixedRandom) Read(p []byte) (int, error) {++ for i := range p {++ f.state ^= f.state << 13++ f.state ^= f.state >> 17++ f.state ^= f.state << 5++ p[i] = byte(f.state)++ }++ return len(p), nil++}++++func TestSealMatchesTheSDKVector(t *testing.T) {++ private, err := base64.RawURLEncoding.DecodeString(vectorPublic.D)++ if err != nil {++ t.Fatal(err)++ }++ envelope, err := Seal([]byte(vectorContent), vectorSubject, vectorPublic, SealOptions{++ EphemeralPrivate: private,++ Random: &fixedRandom{state: 0x1a2b3c4d},++ })++ if err != nil {++ t.Fatal(err)++ }++ sum := sha256.Sum256(envelope)++ if got := hex.EncodeToString(sum[:]); got != vectorEnvelopeID {++ t.Fatalf("sealed envelope %s, the SDK writes %s", got, vectorEnvelopeID)++ }++ opened, err := Open(envelope, vectorPublic, vectorSubject)++ if err != nil {++ t.Fatal(err)++ }++ if string(opened) != vectorContent {++ t.Fatalf("opened %q", opened)++ }++}++++func TestSealRoundTripForALinkMachine(t *testing.T) {++ key := testKey(t)++ recipient := Subject{Type: "link", ID: "lnk_01JAB2C3D4E5F6G7H8J9K0MNPQ"}++ content := []byte(`{"name":"clipboard.txt"}` + "\x00hello from the other machine")++ envelope, err := Seal(content, recipient, key, SealOptions{})++ if err != nil {++ t.Fatal(err)++ }++ if !Addressed(envelope, key) {++ t.Fatal("the envelope does not name its recipient")++ }++ if Addressed(envelope, testKey(t)) {++ t.Fatal("the envelope names a key it was not sealed for")++ }++ opened, err := Open(envelope, key, recipient)++ if err != nil {++ t.Fatal(err)++ }++ if string(opened) != string(content) {++ t.Fatalf("opened %q", opened)++ }++ if _, err := Open(envelope, key, Subject{Type: "link", ID: "lnk_01JAB2C3D4E5F6G7H8J9K0MNPR"}); err == nil {++ t.Fatal("another machine's subject opened the item")++ }++}++++func testKey(t *testing.T) JWK {++ t.Helper()++ private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)++ if err != nil {++ t.Fatal(err)++ }++ b64 := func(n *big.Int) string {++ raw := make([]byte, 32)++ n.FillBytes(raw)++ return base64.RawURLEncoding.EncodeToString(raw)++ }++ return JWK{Kty: "EC", Crv: "P-256", X: b64(private.X), Y: b64(private.Y), D: b64(private.D)}++}@@@ -29,8 +29,8 @@ fragment: `/panel/#t=<token>`. The page sends that token as `X-Link-Panel` on ev| `POST /panel/api/files/:id/decline` | — | Removes the incoming item. || `POST /panel/api/files/:id/complete` | — | Removes the accepted item after download starts. || `POST /panel/api/first-run` | `{ "uses": ["codes", "browser"], "skip": false }` | The first-run answer. Uses are `codes`, `browser`, `models`, `earn` and `vibe`. The daemon turns on what each says (`earn` stays off until it is confirmed on Exchange) and stops asking; `skip: true` only stops asking. |--| `POST /panel/api/clipboard` | `{ "on": true }` | The per-machine clipboard-sharing switch, off by default; text only, sealed like every other item. |--| `POST /panel/api/send/auto-accept` | `{ "on": true }` | Save incoming items from the person's own machines without asking, per machine. Off by default. |++| `POST /panel/api/clipboard` | `{ "on": true }` | The per-machine clipboard-sharing switch, off by default; text only. The daemon seals each new clipboard change for the person's other machines while the window is closed. |++| `POST /panel/api/send/auto-accept` | `{ "on": true }` | Save incoming items from the person's own machines without asking, per machine. Off by default; the daemon saves files to Downloads and clipboard text to the clipboard while the window is closed. |## `GET /panel/api/state`@@@ -181,7 +181,6 @@var drop = $('[data-lk-drop]'), say = $('[data-lk-drop-say]'), targetSelect = $('[data-lk-send-target]');var sendKey = null, lockerModule = null;var settings = { clipboard: false, auto_accept: false };-- var seenInbox = {};function panelApi(path, method, body) {return fetch('/panel/api/' + path, { method: method || 'GET', headers: Object.assign({ 'X-Link-Panel': token }, body ? { 'Content-Type': 'application/json' } : {}),body: body ? JSON.stringify(body) : undefined, credentials: 'same-origin' }).then(function (r) {@@@ -239,7 +238,7 @@var view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), n = new DataView(view.buffer, view.byteOffset, view.byteLength).getUint32(0);if (n < 2 || n > view.length - 4) throw new Error('The encrypted item is incomplete.');var info = JSON.parse(new TextDecoder().decode(view.subarray(4, 4 + n))), fileBytes = view.subarray(4 + n);-- if (info.kind === 'clipboard' && navigator.clipboard && navigator.clipboard.writeText) {++ if (info.kind === 'clipboard' && settings.clipboard && navigator.clipboard && navigator.clipboard.writeText) {var text = new TextDecoder().decode(fileBytes);return navigator.clipboard.writeText(text).then(function () {return panelApi('files/' + encodeURIComponent(item.id) + '/complete', 'POST', {}).then(function () {@@@ -257,11 +256,7 @@function loadInbox() {panelApi('send/inbox').then(function (body) {var list = $('[data-lk-send-inbox]');-- (body.items || []).forEach(function (item) {-- if (!settings.auto_accept || seenInbox[item.id]) return;-- seenInbox[item.id] = true;-- downloadAccepted(item).catch(function (e) { say(e.message || 'An item from one of your machines could not be opened.'); });-- });++ // Auto-accept runs in the daemon, even when this window is closed; doing it here too would save twice.list.replaceChildren.apply(list, (body.items || []).map(function (item) {var li = el('li', 'lk-send-item'), label = el('span', '', 'Encrypted item from ' + (item.sender_name || 'your machine') + ' · ' + size(item.bytes) + ' · ' + ago(item.created_at));var actions = el('div'), accept = el('button', '', 'Accept'), decline = el('button', '', 'Decline');nnew file mode 100644@@@ -0,0 +1,60 @@++package send++++import (++ "bytes"++ "context"++ "errors"++ "os/exec"++ "time"++)++++// Clipboard is this computer's text clipboard. Link only ever reads and writes text: an image or a file copied++// on one machine stays there.++type Clipboard interface {++ Read(ctx context.Context) (string, error)++ Write(ctx context.Context, text string) error++}++++// commandClipboard uses the clipboard tool the desktop already has, so Link adds no dependency and no C library.++type commandClipboard struct {++ read [][]string++ write [][]string++ tool string++}++++func (c *commandClipboard) Read(parent context.Context) (string, error) {++ ctx, cancel := context.WithTimeout(parent, 2*time.Second)++ defer cancel()++ for _, argv := range c.read {++ if _, err := exec.LookPath(argv[0]); err != nil {++ continue++ }++ out, err := exec.CommandContext(ctx, argv[0], argv[1:]...).Output()++ if err == nil {++ return string(out), nil++ }++ if ctx.Err() != nil {++ return "", ctx.Err()++ }++ }++ return "", errors.New(c.tool + " is not available on this computer")++}++++func (c *commandClipboard) Write(parent context.Context, text string) error {++ ctx, cancel := context.WithTimeout(parent, 5*time.Second)++ defer cancel()++ for _, argv := range c.write {++ if _, err := exec.LookPath(argv[0]); err != nil {++ continue++ }++ cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)++ cmd.Stdin = bytes.NewReader([]byte(text))++ if err := cmd.Run(); err == nil {++ return nil++ }++ if ctx.Err() != nil {++ return ctx.Err()++ }++ }++ return errors.New(c.tool + " is not available on this computer")++}nnew file mode 100644@@@ -0,0 +1,11 @@++//go:build darwin++++package send++++func SystemClipboard() Clipboard {++ return &commandClipboard{++ read: [][]string{{"pbpaste"}},++ write: [][]string{{"pbcopy"}},++ tool: "pbpaste and pbcopy",++ }++}nnew file mode 100644@@@ -0,0 +1,21 @@++//go:build linux++++package send++++// SystemClipboard prefers Wayland when the session is Wayland, and still falls back to X11 tools when it is not++// installed (a Wayland session can run X11 applications, but a missing wl-clipboard is common).++func SystemClipboard() Clipboard {++ return &commandClipboard{++ read: [][]string{++ {"wl-paste", "--no-newline"},++ {"xclip", "-selection", "clipboard", "-o"},++ {"xsel", "--clipboard", "--output"},++ },++ write: [][]string{++ {"wl-copy"},++ {"xclip", "-selection", "clipboard", "-i"},++ {"xsel", "--clipboard", "--input"},++ },++ tool: "wl-clipboard or xclip",++ }++}nnew file mode 100644@@@ -0,0 +1,7 @@++//go:build !linux && !darwin && !windows++++package send++++// SystemClipboard is absent on platforms without a clipboard Link can reach. Clipboard sync stays off there and++// the window says so instead of pretending.++func SystemClipboard() Clipboard { return nil }nnew file mode 100644@@@ -0,0 +1,49 @@++package send++++import (++ "context"++ "os"++ "path/filepath"++ "runtime"++ "testing"++)++++func TestClipboardCommandsReadAndWriteText(t *testing.T) {++ if runtime.GOOS == "windows" {++ t.Skip("the test uses a POSIX shell as the clipboard tool")++ }++ out := filepath.Join(t.TempDir(), "clipboard.txt")++ c := &commandClipboard{++ read: [][]string{{"sh", "-c", "cat " + out}},++ write: [][]string{{"sh", "-c", "cat > " + out}},++ tool: "the test clipboard",++ }++ if err := os.WriteFile(out, []byte("first"), 0o600); err != nil {++ t.Fatal(err)++ }++ got, err := c.Read(context.Background())++ if err != nil || got != "first" {++ t.Fatalf("read = %q, %v", got, err)++ }++ if err := c.Write(context.Background(), "second"); err != nil {++ t.Fatal(err)++ }++ b, err := os.ReadFile(out)++ if err != nil || string(b) != "second" {++ t.Fatalf("written = %q, %v", b, err)++ }++}++++func TestClipboardWithoutAToolSaysSo(t *testing.T) {++ c := &commandClipboard{++ read: [][]string{{"openvibe-link-no-such-clipboard-tool"}},++ write: [][]string{{"openvibe-link-no-such-clipboard-tool"}},++ tool: "a clipboard tool",++ }++ if _, err := c.Read(context.Background()); err == nil {++ t.Fatal("read without a tool succeeded")++ }++ if err := c.Write(context.Background(), "x"); err == nil {++ t.Fatal("write without a tool succeeded")++ }++}nnew file mode 100644@@@ -0,0 +1,11 @@++//go:build windows++++package send++++func SystemClipboard() Clipboard {++ return &commandClipboard{++ read: [][]string{{"powershell", "-NoProfile", "-NonInteractive", "-Command", "[Console]::Out.Write((Get-Clipboard -Raw))"}},++ write: [][]string{{"powershell", "-NoProfile", "-NonInteractive", "-Command", "[Console]::In.ReadToEnd() | Set-Clipboard"}},++ tool: "PowerShell's clipboard",++ }++}nnew file mode 100644@@@ -0,0 +1,144 @@++package send++++import (++ "encoding/binary"++ "encoding/json"++ "errors"++ "fmt"++ "os"++ "path/filepath"++ "strings"++ "unicode"++)++++// KindFile, KindNote and KindClipboard are the three things Link Send can carry. The kind is inside the sealed++// payload, never in the relay's metadata: the receiving machine learns it only after opening the item.++const (++ KindFile = "file"++ KindNote = "note"++ KindClipboard = "clipboard"++)++++// Content is what a sealed item carries: the name and type a person sees, then the bytes. It is sealed as one++// blob, so the relay never sees the name, the type or the kind either.++type Content struct {++ Name string `json:"name"`++ Mime string `json:"mime"`++ Kind string `json:"kind,omitempty"`++}++++type Packed struct {++ Content++ Data []byte++}++++func Pack(name, mime string, data []byte) []byte {++ return PackKind(name, mime, KindFile, data)++}++++func PackKind(name, mime, kind string, data []byte) []byte {++ if name == "" {++ name = "received-file"++ }++ meta, _ := json.Marshal(Content{Name: name, Mime: mime, Kind: kind})++ out := make([]byte, 4+len(meta)+len(data))++ binary.BigEndian.PutUint32(out[:4], uint32(len(meta)))++ copy(out[4:], meta)++ copy(out[4+len(meta):], data)++ return out++}++++func Unpack(b []byte) (Packed, error) {++ if len(b) < 4 {++ return Packed{}, errors.New("the received item is incomplete")++ }++ n := int(binary.BigEndian.Uint32(b[:4]))++ if n < 2 || n > len(b)-4 {++ return Packed{}, errors.New("the received item is incomplete")++ }++ var c Content++ if json.Unmarshal(b[4:4+n], &c) != nil {++ return Packed{}, errors.New("the received item does not say what it is")++ }++ if c.Kind == "" {++ c.Kind = KindFile++ }++ if c.Kind != KindFile && c.Kind != KindNote && c.Kind != KindClipboard {++ return Packed{}, errors.New("the received item is not something Link sends")++ }++ return Packed{Content: c, Data: b[4+n:]}, nil++}++++// SafeName keeps one path segment: no folder separators, no control characters, no leading dot, and short enough++// for every filesystem Link runs on.++func SafeName(name string) string {++ name = strings.TrimSpace(name)++ name = strings.Map(func(r rune) rune {++ if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' || unicode.IsControl(r) {++ return '_'++ }++ return r++ }, name)++ name = strings.TrimLeft(name, ".")++ if name == "" {++ return "received-file"++ }++ runes := []rune(name)++ if len(runes) > 180 {++ name = string(runes[:180])++ }++ return name++}++++// DownloadsDir is where received files go unless the daemon was given another folder.++func DownloadsDir() (string, error) {++ home, err := os.UserHomeDir()++ if err != nil || home == "" {++ return "", errNoDownloads++ }++ return filepath.Join(home, "Downloads"), nil++}++++// Save writes the item into dir without replacing an existing file: a second "report.pdf" becomes "report (2).pdf".++// It returns the path it wrote.++func Save(dir, name string, data []byte) (string, error) {++ if dir == "" {++ var err error++ if dir, err = DownloadsDir(); err != nil {++ return "", err++ }++ }++ if err := os.MkdirAll(dir, 0o700); err != nil {++ return "", fmt.Errorf("the Downloads folder could not be opened")++ }++ safe := SafeName(name)++ ext := filepath.Ext(safe)++ stem := strings.TrimSuffix(safe, ext)++ for i := 0; i < 1000; i++ {++ candidate := safe++ if i > 0 {++ candidate = fmt.Sprintf("%s (%d)%s", stem, i+1, ext)++ }++ path := filepath.Join(dir, candidate)++ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)++ if errors.Is(err, os.ErrExist) {++ continue++ }++ if err != nil {++ return "", fmt.Errorf("the file could not be saved in Downloads")++ }++ if _, err := f.Write(data); err != nil {++ _ = f.Close()++ _ = os.Remove(path)++ return "", fmt.Errorf("the file could not be saved in Downloads")++ }++ if err := f.Close(); err != nil {++ _ = os.Remove(path)++ return "", fmt.Errorf("the file could not be saved in Downloads")++ }++ return path, nil++ }++ return "", fmt.Errorf("there are too many files with that name in Downloads")++}++++var errNoDownloads = errors.New("this computer has no Downloads folder to save into")nnew file mode 100644@@@ -0,0 +1,99 @@++package send++++import (++ "os"++ "path/filepath"++ "runtime"++ "strings"++ "testing"++)++++func TestPackedItemKeepsItsNameKindAndBytes(t *testing.T) {++ envelope := PackKind("photo (1).png", "image/png", KindClipboard, []byte{0x89, 'P', 'N', 'G'})++ got, err := Unpack(envelope)++ if err != nil {++ t.Fatal(err)++ }++ if got.Name != "photo (1).png" || got.Mime != "image/png" || got.Kind != KindClipboard {++ t.Fatalf("content = %#v", got.Content)++ }++ if string(got.Data) != "\x89PNG" {++ t.Fatalf("data = %q", got.Data)++ }++ if plain, err := Unpack(Pack("note.txt", "text/plain", []byte("hello"))); err != nil || plain.Kind != KindFile {++ t.Fatalf("a v1 item without a kind = %#v, %v", plain, err)++ }++}++++func TestUnpackRefusesAnUnknownKindOrBrokenFrame(t *testing.T) {++ bad := Pack("note.txt", "text/plain", []byte("hello"))++ bad[0], bad[1], bad[2], bad[3] = 0, 0, 0, 200++ if _, err := Unpack(bad); err == nil {++ t.Fatal("accepted a frame longer than the item")++ }++ unknown := []byte(`{"name":"x","kind":"bogus"}`)++ frame := make([]byte, 4+len(unknown))++ frame[0], frame[1], frame[2], frame[3] = 0, 0, 0, byte(len(unknown))++ copy(frame[4:], unknown)++ if _, err := Unpack(frame); err == nil {++ t.Fatal("accepted an unknown kind")++ }++}++++func TestSafeNameIsOnePathSegment(t *testing.T) {++ cases := map[string]string{++ "../../etc/passwd": "_.._etc_passwd",++ `C:\Users\me\file.txt`: "C__Users_me_file.txt",++ ".hidden": "hidden",++ " ": "received-file",++ "a\x00b.txt": "a_b.txt",++ }++ for in, want := range cases {++ if got := SafeName(in); got != want {++ t.Fatalf("SafeName(%q) = %q, want %q", in, got, want)++ }++ }++ long := strings.Repeat("x", 300)++ if got := SafeName(long); len([]rune(got)) != 180 {++ t.Fatalf("a long name = %d runes, want 180", len([]rune(got)))++ }++}++++func TestSaveNeverReplacesAFile(t *testing.T) {++ dir := t.TempDir()++ first, err := Save(dir, "report.pdf", []byte("first"))++ if err != nil {++ t.Fatal(err)++ }++ second, err := Save(dir, "report.pdf", []byte("second"))++ if err != nil {++ t.Fatal(err)++ }++ if first == second || filepath.Base(second) != "report (2).pdf" {++ t.Fatalf("paths = %q, %q", first, second)++ }++ b, err := os.ReadFile(second)++ if err != nil || string(b) != "second" {++ t.Fatalf("second file = %q, %v", b, err)++ }++ if runtime.GOOS != "windows" {++ info, err := os.Stat(second)++ if err != nil || info.Mode().Perm() != 0o600 {++ t.Fatalf("saved mode = %v, %v", info.Mode().Perm(), err)++ }++ }++}++++func TestSaveRefusesAnUnwritableDownloadsFolder(t *testing.T) {++ if runtime.GOOS == "windows" {++ t.Skip("the test uses a file where a folder should be")++ }++ dir := t.TempDir()++ blocked := filepath.Join(dir, "not-a-folder")++ if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil {++ t.Fatal(err)++ }++ if _, err := Save(blocked, "file.txt", []byte("data")); err == nil {++ t.Fatal("saved into a path that is a file")++ }++}nnew file mode 100644@@@ -0,0 +1,383 @@++package send++++import (++ "context"++ "encoding/base64"++ "errors"++ "fmt"++ "io"++ "log/slog"++ "strings"++ "sync"++ "time"++++ "github.com/OpenVibers/OpenVibe.Link/internal/lockeropen"++)++++// maxClipboardText is what one clipboard item may carry. A copied paragraph is tiny; a whole document is not what++// clipboard sync is for.++const maxClipboardText = 64 << 10++++// maxClipboardEnvelope bounds the base64 Locker envelope a clipboard item can have, with room for the header and++// padding. While auto-accept is off, the service only opens items below it, so a large file is never marked accepted++// just to learn that it is not clipboard text.++const maxClipboardEnvelope = 160 << 10++++// Relay is the part of *Client the service uses, so the service can be tested without a network.++type Relay interface {++ Targets(ctx context.Context) ([]Machine, error)++ Inbox(ctx context.Context) ([]Item, error)++ Accept(ctx context.Context, id string) (string, error)++ Complete(ctx context.Context, id string) error++ Send(ctx context.Context, target, envelope string) error++}++++// Direct is this machine's queue of items another of the owner's machines handed over on the local network. The++// service only opens items whose sender is one of this account's machines; a granted transfer from another account++// stays in the window for the person to answer.++type Direct interface {++ Items() []Incoming++ Accept(id string) (string, error)++ Complete(id string) error++}++++type ServiceOptions struct {++ Relay Relay++ Direct Direct++ Key JWK++ Principal string++ Downloads string++ Clipboard Clipboard++ Settings func() Settings++ Log *slog.Logger++ OnActivity func(string)++ Now func() time.Time++}++++// Service is Link Send's background half: it receives items for this machine when the person turned auto-accept on,++// keeps the clipboard in step with their other machines when clipboard sync is on, and otherwise leaves everything++// in the window for a person to answer. It never sends in clear and never opens an item before deciding to keep it.++type Service struct {++ opt ServiceOptions++++ mu sync.Mutex++ handled map[string]bool++ inspected map[string]Settings++ failed map[string]bool++ clipPrimed bool++ clipLast string++ clipKnown string++}++++func NewService(opt ServiceOptions) *Service {++ if opt.Log == nil {++ opt.Log = slog.New(slog.NewTextHandler(io.Discard, nil))++ }++ if opt.Now == nil {++ opt.Now = time.Now++ }++ if opt.Clipboard == nil {++ opt.Clipboard = SystemClipboard()++ }++ return &Service{opt: opt, handled: map[string]bool{}, inspected: map[string]Settings{}, failed: map[string]bool{}}++}++++// Run polls the relay and the direct queue, and reads this machine's clipboard while the person has that switch on,++// until ctx ends. Registration and the direct listener belong to the window (internal/agent), not here.++func (s *Service) Run(ctx context.Context) {++ tick := time.NewTicker(2 * time.Second)++ defer tick.Stop()++ s.poll(ctx)++ s.syncClipboard(ctx)++ for {++ select {++ case <-ctx.Done():++ return++ case <-tick.C:++ s.poll(ctx)++ s.syncClipboard(ctx)++ }++ }++}++++func (s *Service) settings() Settings {++ if s.opt.Settings == nil {++ return DefaultSettings()++ }++ return s.opt.Settings()++}++++func (s *Service) activity(text string) {++ if s.opt.OnActivity != nil && strings.TrimSpace(text) != "" {++ s.opt.OnActivity(text)++ }++}++++type waiting struct {++ id string++ sender string++ bytes int64++ transport string++}++++func (s *Service) poll(ctx context.Context) {++ settings := s.settings()++ if !settings.AutoAccept && !settings.Clipboard {++ return++ }++ var items []waiting++ if s.opt.Relay != nil {++ relayItems, err := s.opt.Relay.Inbox(ctx)++ if err != nil {++ s.opt.Log.Debug("Link Send inbox could not be read", "err", err)++ }++ for _, item := range relayItems {++ items = append(items, waiting{id: item.ID, sender: item.SenderName, bytes: item.Bytes, transport: "relay"})++ }++ }++ var direct []Incoming++ if s.opt.Direct != nil {++ direct = s.opt.Direct.Items()++ }++ if len(direct) > 0 {++ own, err := s.ownMachines(ctx)++ if err != nil {++ s.opt.Log.Debug("Link Send could not check its own machines", "err", err)++ direct = nil++ }++ for _, item := range direct {++ if !own[item.Sender] {++ continue++ }++ items = append(items, waiting{id: item.ID, sender: item.SenderName, bytes: item.Bytes, transport: "direct"})++ }++ }++ for _, item := range items {++ if !s.shouldLook(settings, item.bytes) {++ continue++ }++ s.mu.Lock()++ done := s.handled[item.id] || s.failed[item.id]++ before, seen := s.inspected[item.id]++ s.mu.Unlock()++ if done || (seen && before == settings) {++ continue++ }++ s.handle(ctx, settings, item)++ }++}++++// shouldLook says whether an item could be one this machine handles with the switches as they are. When auto-accept++// is off only a clipboard-sized item is opened, because only its kind can make it something to keep now.++func (s *Service) shouldLook(settings Settings, bytes int64) bool {++ if settings.AutoAccept {++ return true++ }++ return settings.Clipboard && bytes > 0 && bytes <= maxClipboardEnvelope++}++++func (s *Service) ownMachines(ctx context.Context) (map[string]bool, error) {++ out := map[string]bool{}++ if s.opt.Relay == nil {++ return out, nil++ }++ machines, err := s.opt.Relay.Targets(ctx)++ if err != nil {++ return nil, err++ }++ for _, machine := range machines {++ out[machine.ID] = true++ }++ return out, nil++}++++func (s *Service) handle(ctx context.Context, settings Settings, item waiting) {++ envelope, err := s.envelope(ctx, item)++ if err != nil {++ s.opt.Log.Debug("a waiting item could not be fetched", "id", item.id, "err", err)++ return++ }++ raw, err := lockeropen.DecodeEnvelope(envelope)++ if err != nil {++ s.fail(item, "A transfer could not be opened; ask the other machine to send it again.")++ return++ }++ plain, err := lockeropen.Open(raw, lockeropen.JWK{Kty: s.opt.Key.Kty, Crv: s.opt.Key.Crv, X: s.opt.Key.X, Y: s.opt.Key.Y, D: s.opt.Key.D},++ lockeropen.Subject{Type: "link", ID: s.opt.Principal})++ if err != nil {++ s.fail(item, "A transfer was not encrypted for this machine, so Link could not open it.")++ return++ }++ packed, err := Unpack(plain)++ if err != nil {++ s.fail(item, "A transfer did not arrive in full; ask the other machine to send it again.")++ return++ }++ switch packed.Kind {++ case KindClipboard:++ if !settings.Clipboard {++ s.inspect(item, settings)++ return++ }++ if s.opt.Clipboard == nil {++ s.inspect(item, settings)++ return++ }++ if len(packed.Data) > maxClipboardText {++ s.fail(item, "Copied text from another machine was too large to put on this clipboard.")++ return++ }++ text := string(packed.Data)++ if err := s.opt.Clipboard.Write(ctx, text); err != nil {++ s.opt.Log.Warn("copied text could not be put on the clipboard", "err", err)++ s.inspect(item, settings)++ return++ }++ s.mu.Lock()++ s.clipKnown, s.clipLast = text, text++ s.mu.Unlock()++ s.complete(ctx, item)++ s.activity("Put copied text from " + orMachine(item.sender) + " on this computer's clipboard")++ default:++ if !settings.AutoAccept {++ s.inspect(item, settings)++ return++ }++ path, err := Save(s.opt.Downloads, packed.Name, packed.Data)++ if err != nil {++ s.fail(item, err.Error())++ return++ }++ s.complete(ctx, item)++ s.activity("Saved " + baseName(path) + " from " + orMachine(item.sender) + " to Downloads")++ }++}++++func (s *Service) envelope(ctx context.Context, item waiting) (string, error) {++ if item.transport == "direct" {++ if s.opt.Direct == nil {++ return "", errors.New("the direct queue is not available")++ }++ return s.opt.Direct.Accept(item.id)++ }++ if s.opt.Relay == nil {++ return "", errors.New("this machine is not paired for Link Send")++ }++ return s.opt.Relay.Accept(ctx, item.id)++}++++func (s *Service) complete(ctx context.Context, item waiting) {++ var err error++ if item.transport == "direct" {++ err = s.opt.Direct.Complete(item.id)++ } else {++ err = s.opt.Relay.Complete(ctx, item.id)++ }++ if err != nil {++ // The item is already saved here; a lost completion only leaves the sender waiting until the relay expires it.++ s.opt.Log.Warn("the relay did not learn an item was saved", "id", item.id, "err", err)++ }++ s.mu.Lock()++ s.handled[item.id] = true++ delete(s.inspected, item.id)++ s.mu.Unlock()++}++++// inspect remembers that this item was opened under these switches and left for the person. It is looked at again++// only after a switch changes, so a file that is waiting does not get accepted on every poll.++func (s *Service) inspect(item waiting, settings Settings) {++ s.mu.Lock()++ s.inspected[item.id] = settings++ s.mu.Unlock()++}++++func (s *Service) fail(item waiting, text string) {++ s.mu.Lock()++ s.failed[item.id] = true++ s.mu.Unlock()++ s.opt.Log.Warn("a Link Send item could not be received", "id", item.id)++ s.activity(text)++}++++// syncClipboard sends copied text to the person's other machines while the switch is on. Text only, and the first++// thing it sees after the switch is turned on is only remembered, never sent.++func (s *Service) syncClipboard(ctx context.Context) {++ settings := s.settings()++ if !settings.Clipboard || s.opt.Clipboard == nil || s.opt.Relay == nil {++ s.mu.Lock()++ s.clipPrimed = false++ s.mu.Unlock()++ return++ }++ text, err := s.opt.Clipboard.Read(ctx)++ if err != nil {++ return++ }++ s.mu.Lock()++ if !s.clipPrimed {++ s.clipPrimed, s.clipLast = true, text++ s.mu.Unlock()++ return++ }++ changed := text != s.clipLast++ echo := text == s.clipKnown++ if changed && !echo {++ s.clipKnown = ""++ }++ s.clipLast = text++ s.mu.Unlock()++ if !changed || echo || strings.TrimSpace(text) == "" {++ return++ }++ if len(text) > maxClipboardText {++ s.activity("A copied text was larger than 64 KB, so it was not synced")++ return++ }++ machines, err := s.opt.Relay.Targets(ctx)++ if err != nil {++ s.opt.Log.Debug("Link Send could not list this account's machines", "err", err)++ return++ }++ sent := 0++ for _, machine := range machines {++ if machine.PublicKey.Kty != "EC" || machine.PublicKey.Crv != "P-256" || machine.PublicKey.X == "" || machine.PublicKey.Y == "" {++ continue++ }++ sealed, err := lockeropen.Seal(PackKind("clipboard.txt", "text/plain;charset=utf-8", KindClipboard, []byte(text)),++ lockeropen.Subject{Type: "link", ID: machine.ID},++ lockeropen.JWK{Kty: machine.PublicKey.Kty, Crv: machine.PublicKey.Crv, X: machine.PublicKey.X, Y: machine.PublicKey.Y}, lockeropen.SealOptions{})++ if err != nil {++ s.opt.Log.Debug("a clipboard item could not be sealed", "machine", machine.ID, "err", err)++ continue++ }++ if err := s.opt.Relay.Send(ctx, machine.ID, base64.StdEncoding.EncodeToString(sealed)); err != nil {++ s.opt.Log.Debug("a clipboard item was not sent", "machine", machine.ID, "err", err)++ continue++ }++ sent++++ }++ if sent > 0 {++ s.activity("Synced this computer's clipboard to " + plural(sent, "other machine", "other machines"))++ }++}++++func orMachine(name string) string {++ if strings.TrimSpace(name) == "" {++ return "your other machine"++ }++ return name++}++++func baseName(path string) string {++ if i := strings.LastIndexAny(path, `/\`); i >= 0 {++ return path[i+1:]++ }++ return path++}++++func plural(n int, one, many string) string {++ if n == 1 {++ return fmt.Sprintf("%d %s", n, one)++ }++ return fmt.Sprintf("%d %s", n, many)++}nnew file mode 100644@@@ -0,0 +1,363 @@++package send++++import (++ "context"++ "encoding/base64"++ "errors"++ "os"++ "path/filepath"++ "strings"++ "testing"++++ "github.com/OpenVibers/OpenVibe.Link/internal/lockeropen"++)++++var (++ testMachine = "lnk_" + "01JAB2C3D4E5F6G7H8J9K0MNPQ"++ peerMachine = "lnk_" + "01JAB2C3D4E5F6G7H8J9K0MNPR"++ otherMachine = "lnk_" + "OTHER"++)++++type fakeRelay struct {++ targets []Machine++ items []Item++ envelopes map[string]string++ accepted []string++ completed []string++ sent []sentItem++ targetsErr error++}++++type sentItem struct{ target, envelope string }++++func (f *fakeRelay) Targets(context.Context) ([]Machine, error) {++ if f.targetsErr != nil {++ return nil, f.targetsErr++ }++ return f.targets, nil++}++func (f *fakeRelay) Inbox(context.Context) ([]Item, error) { return f.items, nil }++func (f *fakeRelay) Accept(_ context.Context, id string) (string, error) {++ f.accepted = append(f.accepted, id)++ envelope, ok := f.envelopes[id]++ if !ok {++ return "", errors.New("not waiting")++ }++ return envelope, nil++}++func (f *fakeRelay) Complete(_ context.Context, id string) error {++ f.completed = append(f.completed, id)++ return nil++}++func (f *fakeRelay) Send(_ context.Context, target, envelope string) error {++ f.sent = append(f.sent, sentItem{target: target, envelope: envelope})++ return nil++}++++type fakeDirect struct {++ items []Incoming++ accepted []string++ completed []string++}++++func (f *fakeDirect) Items() []Incoming { return f.items }++func (f *fakeDirect) Accept(id string) (string, error) {++ f.accepted = append(f.accepted, id)++ for _, item := range f.items {++ if item.ID == id {++ return item.Envelope, nil++ }++ }++ return "", errors.New("not waiting")++}++func (f *fakeDirect) Complete(id string) error {++ f.completed = append(f.completed, id)++ return nil++}++++type fakeClipboard struct {++ text string++ writes []string++}++++func (f *fakeClipboard) Read(context.Context) (string, error) { return f.text, nil }++func (f *fakeClipboard) Write(_ context.Context, text string) error {++ f.writes = append(f.writes, text)++ return nil++}++++func lockerJWK(key JWK) lockeropen.JWK {++ return lockeropen.JWK{Kty: key.Kty, Crv: key.Crv, X: key.X, Y: key.Y, D: key.D}++}++++func testSealed(t *testing.T, key JWK, machine, kind, name string, data []byte) string {++ t.Helper()++ envelope, err := lockeropen.Seal(PackKind(name, "text/plain;charset=utf-8", kind, data),++ lockeropen.Subject{Type: "link", ID: machine}, lockerJWK(key), lockeropen.SealOptions{})++ if err != nil {++ t.Fatal(err)++ }++ return base64.StdEncoding.EncodeToString(envelope)++}++++func serviceKey(t *testing.T) JWK {++ t.Helper()++ key, err := LoadOrCreate(t.TempDir())++ if err != nil {++ t.Fatal(err)++ }++ return key++}++++func serviceFor(t *testing.T, key JWK, relay Relay, direct Direct, settings Settings, clipboard Clipboard) (*Service, string) {++ t.Helper()++ downloads := t.TempDir()++ service := NewService(ServiceOptions{++ Relay: relay, Direct: direct, Key: key, Principal: testMachine,++ Downloads: downloads, Clipboard: clipboard,++ Settings: func() Settings { return settings },++ })++ return service, downloads++}++++func TestAutoAcceptSavesAFileWithoutTheWindow(t *testing.T) {++ key, err := LoadOrCreate(t.TempDir())++ if err != nil {++ t.Fatal(err)++ }++ envelope := testSealed(t, key, testMachine, KindFile, "report.txt", []byte("quarterly numbers"))++ relay := &fakeRelay{++ items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Laptop", Bytes: int64(len(envelope))}},++ envelopes: map[string]string{"snd_0123456789abcdef0123456789abcdef": envelope},++ }++ downloads := t.TempDir()++ service := NewService(ServiceOptions{++ Relay: relay, Key: key, Principal: testMachine, Downloads: downloads,++ Settings: func() Settings { return Settings{AutoAccept: true} },++ })++ service.poll(context.Background())++ b, err := os.ReadFile(filepath.Join(downloads, "report.txt"))++ if err != nil || string(b) != "quarterly numbers" {++ t.Fatalf("saved file = %q, %v", b, err)++ }++ if len(relay.completed) != 1 {++ t.Fatalf("completed = %v", relay.completed)++ }++ service.poll(context.Background())++ entries, _ := os.ReadDir(downloads)++ if len(entries) != 1 || len(relay.completed) != 1 {++ t.Fatalf("a second poll saved the item again: %d files, completed %v", len(entries), relay.completed)++ }++}++++func TestAutoAcceptOffLeavesAFileWaiting(t *testing.T) {++ key, err := LoadOrCreate(t.TempDir())++ if err != nil {++ t.Fatal(err)++ }++ envelope := testSealed(t, key, testMachine, KindFile, "report.txt", []byte("kept for later"))++ relay := &fakeRelay{++ items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Laptop", Bytes: int64(len(envelope))}},++ envelopes: map[string]string{"snd_0123456789abcdef0123456789abcdef": envelope},++ }++ settings := Settings{}++ service, downloads := serviceFor(t, serviceKey(t), relay, nil, settings, nil)++ service.opt.Settings = func() Settings { return settings }++ service.poll(context.Background())++ if len(relay.accepted) != 0 || len(relay.completed) != 0 {++ t.Fatalf("auto-accept off accepted %v, completed %v", relay.accepted, relay.completed)++ }++ if entries, _ := os.ReadDir(downloads); len(entries) != 0 {++ t.Fatalf("auto-accept off saved %d files", len(entries))++ }++}++++func TestClipboardOffDoesNotConsumeAClipboardItem(t *testing.T) {++ key, err := LoadOrCreate(t.TempDir())++ if err != nil {++ t.Fatal(err)++ }++ envelope := testSealed(t, key, testMachine, KindClipboard, "clipboard.txt", []byte("copied text"))++ relay := &fakeRelay{++ items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Laptop", Bytes: int64(len(envelope))}},++ envelopes: map[string]string{"snd_0123456789abcdef0123456789abcdef": envelope},++ }++ clip := &fakeClipboard{}++ service, _ := serviceFor(t, key, relay, nil, Settings{AutoAccept: true}, clip)++ service.poll(context.Background())++ if len(clip.writes) != 0 {++ t.Fatalf("clipboard switch off wrote %q", clip.writes)++ }++ if len(relay.completed) != 0 {++ t.Fatalf("clipboard switch off completed the item: %v", relay.completed)++ }++}++++func TestClipboardOnPutsReceivedTextOnTheClipboard(t *testing.T) {++ key, err := LoadOrCreate(t.TempDir())++ if err != nil {++ t.Fatal(err)++ }++ envelope := testSealed(t, key, testMachine, KindClipboard, "clipboard.txt", []byte("from the desktop"))++ relay := &fakeRelay{++ items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Desktop", Bytes: int64(len(envelope))}},++ envelopes: map[string]string{"snd_0123456789abcdef0123456789abcdef": envelope},++ }++ clip := &fakeClipboard{}++ service, _ := serviceFor(t, key, relay, nil, Settings{AutoAccept: true, Clipboard: true}, clip)++ service.poll(context.Background())++ if len(clip.writes) != 1 || clip.writes[0] != "from the desktop" {++ t.Fatalf("clipboard writes = %v", clip.writes)++ }++ if len(relay.completed) != 1 {++ t.Fatalf("completed = %v", relay.completed)++ }++}++++func TestClipboardSyncSealsCopiedTextForEachMachine(t *testing.T) {++ targetKey, err := LoadOrCreate(t.TempDir())++ if err != nil {++ t.Fatal(err)++ }++ relay := &fakeRelay{targets: []Machine{{++ ID: peerMachine, Name: "Desktop", PublicKey: Public(targetKey),++ }}}++ clip := &fakeClipboard{text: "before"}++ service, _ := serviceFor(t, serviceKey(t), relay, nil, Settings{Clipboard: true}, clip)++ service.syncClipboard(context.Background()) // prime: the first text is never sent++ clip.text = "copy this"++ service.syncClipboard(context.Background())++ if len(relay.sent) != 1 {++ t.Fatalf("sent = %v", relay.sent)++ }++ raw, err := base64.StdEncoding.DecodeString(relay.sent[0].envelope)++ if err != nil {++ t.Fatal(err)++ }++ plain, err := lockeropen.Open(raw, lockerJWK(targetKey), lockeropen.Subject{Type: "link", ID: peerMachine})++ if err != nil {++ t.Fatal(err)++ }++ packed, err := Unpack(plain)++ if err != nil || packed.Kind != KindClipboard || string(packed.Data) != "copy this" {++ t.Fatalf("packed = %#v, %v", packed, err)++ }++}++++func TestClipboardEchoFromAnotherMachineIsNotSentBack(t *testing.T) {++ relay := &fakeRelay{targets: []Machine{{++ ID: peerMachine, Name: "Desktop",++ PublicKey: JWK{Kty: "EC", Crv: "P-256", X: "x", Y: "y"},++ }}}++ clip := &fakeClipboard{text: "same"}++ service, _ := serviceFor(t, serviceKey(t), relay, nil, Settings{Clipboard: true}, clip)++ service.syncClipboard(context.Background())++ service.mu.Lock()++ service.clipKnown = "same"++ service.mu.Unlock()++ service.syncClipboard(context.Background())++ if len(relay.sent) != 0 {++ t.Fatalf("an echoed clipboard was sent back: %v", relay.sent)++ }++}++++func TestDirectItemFromAnotherAccountStaysForAPerson(t *testing.T) {++ key, err := LoadOrCreate(t.TempDir())++ if err != nil {++ t.Fatal(err)++ }++ envelope := testSealed(t, key, testMachine, KindFile, "granted.txt", []byte("from someone else"))++ direct := &fakeDirect{items: []Incoming{{ID: "lan_0123456789abcdef0123456789abcdef", Sender: otherMachine, SenderName: "Jo's laptop", Envelope: envelope}}}++ relay := &fakeRelay{} // the sender is not one of this account's own machines++ service, downloads := serviceFor(t, key, relay, direct, Settings{AutoAccept: true}, nil)++ service.poll(context.Background())++ if len(direct.accepted) != 0 || len(direct.completed) != 0 {++ t.Fatalf("a granted item was auto-accepted: %v", direct.accepted)++ }++ if entries, _ := os.ReadDir(downloads); len(entries) != 0 {++ t.Fatalf("a granted item was saved: %d files", len(entries))++ }++}++++func TestOwnDirectItemIsAutoAccepted(t *testing.T) {++ key, err := LoadOrCreate(t.TempDir())++ if err != nil {++ t.Fatal(err)++ }++ envelope := testSealed(t, key, testMachine, KindFile, "from-phone.txt", []byte("hello"))++ direct := &fakeDirect{items: []Incoming{{ID: "lan_0123456789abcdef0123456789abcdef", Sender: peerMachine, SenderName: "Phone", Envelope: envelope}}}++ relay := &fakeRelay{targets: []Machine{{ID: peerMachine, Name: "Phone"}}}++ service, downloads := serviceFor(t, key, relay, direct, Settings{AutoAccept: true}, nil)++ service.poll(context.Background())++ b, err := os.ReadFile(filepath.Join(downloads, "from-phone.txt"))++ if err != nil || string(b) != "hello" {++ t.Fatalf("saved = %q, %v", b, err)++ }++ if len(direct.completed) != 1 {++ t.Fatalf("completed = %v", direct.completed)++ }++}++++func TestServiceWithoutSwitchesDoesNothing(t *testing.T) {++ relay := &fakeRelay{items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", Bytes: 10}}}++ service, _ := serviceFor(t, serviceKey(t), relay, nil, Settings{}, nil)++ service.poll(context.Background())++ if len(relay.accepted) != 0 || len(relay.completed) != 0 {++ t.Fatalf("nothing should be touched with both switches off: %v %v", relay.accepted, relay.completed)++ }++}++++func TestClipboardSettingsStartOff(t *testing.T) {++ if DefaultSettings() != (Settings{}) {++ t.Fatalf("default settings = %#v, want both off", DefaultSettings())++ }++}++++func TestSealedItemKeepsItsKind(t *testing.T) {++ if got := strings.TrimSpace(KindClipboard); got != "clipboard" {++ t.Fatalf("kind = %q", got)++ }++}++++func TestClipboardOnlyOpensASmallItemOnceAndLeavesAFile(t *testing.T) {++ key, err := LoadOrCreate(t.TempDir())++ if err != nil {++ t.Fatal(err)++ }++ envelope := testSealed(t, key, testMachine, KindFile, "tiny.txt", []byte("small file"))++ relay := &fakeRelay{++ items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Laptop", Bytes: int64(len(envelope))}},++ envelopes: map[string]string{"snd_0123456789abcdef0123456789abcdef": envelope},++ }++ settings := Settings{Clipboard: true}++ service, downloads := serviceFor(t, key, relay, nil, settings, &fakeClipboard{})++ service.opt.Settings = func() Settings { return settings }++ service.poll(context.Background())++ service.poll(context.Background())++ if len(relay.accepted) != 1 {++ t.Fatalf("a small file was accepted %d times, want once", len(relay.accepted))++ }++ if len(relay.completed) != 0 {++ t.Fatalf("auto-accept off completed %v", relay.completed)++ }++ if entries, _ := os.ReadDir(downloads); len(entries) != 0 {++ t.Fatalf("auto-accept off saved %d files", len(entries))++ }++ settings.AutoAccept = true++ service.poll(context.Background())++ if b, err := os.ReadFile(filepath.Join(downloads, "tiny.txt")); err != nil || string(b) != "small file" {++ t.Fatalf("after auto-accept was turned on, saved = %q, %v", b, err)++ }++}++++func TestLargeItemIsNotOpenedWhileOnlyClipboardIsOn(t *testing.T) {++ relay := &fakeRelay{++ items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Laptop", Bytes: int64(maxClipboardEnvelope + 1)}},++ envelopes: map[string]string{"snd_0123456789abcdef0123456789abcdef": "not fetched"},++ }++ service, _ := serviceFor(t, serviceKey(t), relay, nil, Settings{Clipboard: true}, &fakeClipboard{})++ service.poll(context.Background())++ if len(relay.accepted) != 0 {++ t.Fatalf("a large item was accepted just to check its kind: %v", relay.accepted)++ }++}nnew file mode 100644@@@ -0,0 +1,13 @@++package send++++// Settings are the two per-machine choices Link Send needs while it runs. Both start off. The daemon keeps them in++// panel.json beside the capability switches (internal/agent), so this package only carries the snapshot and never++// writes a second settings file.++type Settings struct {++ // AutoAccept saves items from the person's own machines without asking first.++ AutoAccept bool++ // Clipboard syncs copied text with the person's other machines. Text only, and off until turned on.++ Clipboard bool++}++++func DefaultSettings() Settings { return Settings{} }
@@ -116,9 +116,10 @@ tasks this machine ran today, and what happened, and lets you pause everything,
@@ -116,9 +116,10 @@ tasks this machine ran today, and what happened, and lets you pause everything,
share a port, or send a file, a note or copied text to another of your machines (sealed on this computer with
share a port, or send a file, a note or copied text to another of your machines (sealed on this computer with
openvibe-sdk/locker; the other machine lists the sender and size and asks before saving). A transfer is uploaded in
openvibe-sdk/locker; the other machine lists the sender and size and asks before saving). A transfer is uploaded in
parts, so a dropped connection continues where it stopped; on the same network it is handed over directly, and
parts, so a dropped connection continues where it stopped; on the same network it is handed over directly, and
-otherwise the relay holds only ciphertext. Clipboard sharing is a per-machine switch, off by default and text only. It is served only on
-loopback, with a per-run token in the address fragment that every API call must send; see [docs/panel.md](docs/panel.md)
-for the security rules.
+otherwise the relay holds only ciphertext. Clipboard sharing is a per-machine switch, off by default and text only;
+with auto-accept on, the daemon saves a received file to Downloads and puts received copied text on the clipboard
+without needing the window open. It is served only on loopback, with a per-run token in the address fragment that
+every API call must send; see [docs/panel.md](docs/panel.md) for the security rules.
**Your machines:** [openvibe.link/machines](https://openvibe.link/machines) opens the signed-in machines hub: each
**Your machines:** [openvibe.link/machines](https://openvibe.link/machines) opens the signed-in machines hub: each
paired machine, whether it is online, the roles it plays (Codes jobs, desks, your own models, a robot), and pairing a
paired machine, whether it is online, the roles it plays (Codes jobs, desks, your own models, a robot), and pairing a
@@ -39,8 +39,8 @@ A restart makes a new port and a new token: an old link stops working.
@@ -39,8 +39,8 @@ A restart makes a new port and a new token: an old link stops working.
| `POST /panel/api/shares` `{ "port": 3000 }` | Shares a port through Connect | Real for a share Connect already picked for this computer |
| `POST /panel/api/shares` `{ "port": 3000 }` | Shares a port through Connect | Real for a share Connect already picked for this computer |
| `DELETE /panel/api/shares/:port` | Ends that share | Real |
| `DELETE /panel/api/shares/:port` | Ends that share | Real |
| `POST /panel/api/first-run` `{ "uses": ["codes", "browser"] }` | The first-run answer: records it in `<state>/panel.json`, turns on a browser (`browser`) or the AI plugin (`models`), and stops asking. `codes` and `earn` are recorded only (Codes has no module switch yet, and Exchange confirms earning); `vibe` turns nothing on; `skip: true` only stops asking | Real; never turns on Terminal or Desktop |
| `POST /panel/api/first-run` `{ "uses": ["codes", "browser"] }` | The first-run answer: records it in `<state>/panel.json`, turns on a browser (`browser`) or the AI plugin (`models`), and stops asking. `codes` and `earn` are recorded only (Codes has no module switch yet, and Exchange confirms earning); `vibe` turns nothing on; `skip: true` only stops asking | Real; never turns on Terminal or Desktop |
-| `POST /panel/api/clipboard` `{ "on": true }` | The per-machine clipboard-sharing switch | Off by default, text only, kept in `<state>/panel.json` |
-| `POST /panel/api/send/auto-accept` `{ "on": true }` | Save items from the person's own machines without asking | Off by default, kept in `<state>/panel.json` |
+| `POST /panel/api/clipboard` `{ "on": true }` | The per-machine clipboard-sharing switch | Off by default, text only, kept in `<state>/panel.json`; while on, the daemon reads the clipboard and seals changes for the person's other machines, even when the window is closed |
+| `POST /panel/api/send/auto-accept` `{ "on": true }` | Save items from the person's own machines without asking | Off by default, kept in `<state>/panel.json`; the daemon saves files to Downloads and puts clipboard text on the clipboard while the window is closed |
| `GET /panel/api/send/targets` | Your other active machines that have a receive key | Real for Network-paired machines |
| `GET /panel/api/send/targets` | Your other active machines that have a receive key | Real for Network-paired machines |
| `GET /panel/api/send/inbox` | Pending encrypted items | Real; the envelope remains ciphertext until you accept |
| `GET /panel/api/send/inbox` | Pending encrypted items | Real; the envelope remains ciphertext until you accept |
| `GET /panel/api/send/key` | This machine's receive key for opening an accepted envelope | Real; loopback panel token required |
| `GET /panel/api/send/key` | This machine's receive key for opening an accepted envelope | Real; loopback panel token required |
@@ -50,9 +50,17 @@ A restart makes a new port and a new token: an old link stops working.
@@ -50,9 +50,17 @@ A restart makes a new port and a new token: an old link stops working.
| `POST /panel/api/files/:id/complete` | Removes an accepted item after the browser download starts | Real |
| `POST /panel/api/files/:id/complete` | Removes an accepted item after the browser download starts | Real |
`state.clipboard` and `state.auto_accept` are the two per-machine switches; both are `false` until the person turns
`state.clipboard` and `state.auto_accept` are the two per-machine switches; both are `false` until the person turns
-them on, and `panel.json` keeps them with the capability switches. A direct transfer on the same network is queued in
-memory by this machine's listener and appears in `send/inbox` beside the relay's items; the listener accepts only an
-HMAC from the owner's LAN secret, so another machine on the network cannot put anything there.
+them on, and `panel.json` keeps them with the capability switches. The daemon keeps the transfer loop running without
+the window: with auto-accept on it opens each item from this account's own machines, saves a file or note to
+`~/Downloads` (never replacing an existing file) and reports the save in activity; with clipboard sharing on it does
+the same for copied text, and seals each new clipboard change for the person's other machines. Clipboard text is
+capped at 64 KB and is never sent in clear. An item granted by another account through machine access stays in
+`send/inbox` for a person to answer, even when auto-accept is on.
+
+A direct transfer on the same network is queued in memory by this machine's listener and appears in `send/inbox`
+beside the relay's items; the listener accepts only an HMAC from the owner's LAN secret, so another machine on the
+network cannot put anything there. A granted transfer from another account uses the same listener with Network's
+signed `send` grant, and always waits for the person.
`state` omits what the Link cannot fill truthfully: `devices` (the paired account's machine list is not read here),
`state` omits what the Link cannot fill truthfully: `devices` (the paired account's machine list is not read here),
`gpu` (no GPU detection), and `earnings_today`. `activity` is the Link's own record of what it saw (an approval
`gpu` (no GPU detection), and `earnings_today`. `activity` is the Link's own record of what it saw (an approval
@@ -125,6 +125,19 @@ func (n *Agent) panelSendOptions(ctx context.Context) panelSend {
@@ -125,6 +125,19 @@ func (n *Agent) panelSendOptions(ctx context.Context) panelSend {
}
}
}
}
}()
}()
+ // The window can be closed; receiving and clipboard sync still run here while the person has the switches on.
+ // Auto-accept only keeps items from this account's own machines: a granted item from another account stays in
+ // the window for an answer.
+ service := send.NewService(send.ServiceOptions{
+ Relay: client, Direct: lan, Key: key, Principal: n.opt.Creds.Principal, Log: n.log,
+ Settings: func() send.Settings {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ return send.Settings{AutoAccept: n.panelAutoAccept, Clipboard: n.panelClipboard}
+ },
+ OnActivity: n.addActivity,
+ })
+ go service.Run(ctx)
return panelSend{
return panelSend{
key: func() any {
key: func() any {
return map[string]any{"private_key": key, "subject": map[string]string{"type": "link", "id": n.opt.Creds.Principal}}
return map[string]any{"private_key": key, "subject": map[string]string{"type": "link", "id": n.opt.Creds.Principal}}
new file mode 100644
new file mode 100644
@@ -0,0 +1,291 @@
@@ -0,0 +1,291 @@
+// Seal writes the people lock emitted by openvibe-sdk/locker. The format is the SDK's: every byte here matches
+// what the SDK would write for the same key, salt, nonce and content (seal_test.go checks the shared vector).
+package lockeropen
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/ecdh"
+ "crypto/elliptic"
+ "crypto/hkdf"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "math/big"
+ "math/bits"
+)
+
+const (
+ minPaddedSize = 256
+ finalFlag = byte(1)
+ saltBytes = 16
+ keyBytes = 32
+ nonceBaseSize = 8
+ nonceSize = 12
+)
+
+// SealOptions fixes the values a test vector needs to be the same bytes every time. A caller sealing a real item
+// leaves them empty and gets fresh random ones.
+type SealOptions struct {
+ ContentKey []byte
+ NonceBase []byte
+ // EphemeralPrivate is the envelope's one ephemeral P-256 private key as a 32-byte scalar.
+ EphemeralPrivate []byte
+ Salt []byte
+ WrapNonce []byte
+ // Random is the source for whatever was not given above. Nil means crypto/rand.
+ Random io.Reader
+}
+
+// Seal builds a people-locked envelope addressed to one recipient. It is the sender's half of Open: a machine
+// that holds the private key for public opens it, and nobody else can.
+func Seal(content []byte, recipient Subject, public JWK, opt SealOptions) ([]byte, error) {
+ if public.Kty != "EC" || public.Crv != "P-256" || public.X == "" || public.Y == "" {
+ return nil, errors.New("the receiving machine has no P-256 key")
+ }
+ if len(content) > maxBytes {
+ return nil, errors.New("the item is too large to seal")
+ }
+ rnd := opt.Random
+ if rnd == nil {
+ rnd = rand.Reader
+ }
+ read := func(n int, given []byte) ([]byte, error) {
+ if given != nil {
+ if len(given) != n {
+ return nil, errors.New("invalid seal value")
+ }
+ return given, nil
+ }
+ b := make([]byte, n)
+ if _, err := io.ReadFull(rnd, b); err != nil {
+ return nil, err
+ }
+ return b, nil
+ }
+ contentKey, err := read(keyBytes, opt.ContentKey)
+ if err != nil {
+ return nil, err
+ }
+ nonceBase, err := read(nonceBaseSize, opt.NonceBase)
+ if err != nil {
+ return nil, err
+ }
+ salt, err := read(saltBytes, opt.Salt)
+ if err != nil {
+ return nil, err
+ }
+ wrapNonce, err := read(nonceSize, opt.WrapNonce)
+ if err != nil {
+ return nil, err
+ }
+ ephemeral, err := ephemeralKey(opt.EphemeralPrivate, rnd)
+ if err != nil {
+ return nil, err
+ }
+ recipientKey, err := thumbprint(public)
+ if err != nil {
+ return nil, err
+ }
+ shared, err := ephemeral.ECDH(recipientPublic(public))
+ if err != nil {
+ return nil, errors.New("the receiving machine has no usable P-256 key")
+ }
+ defer wipe(shared)
+ ephemeralJWK := ephemeralPublicJwk(ephemeral)
+ ephemeralJSON, _ := json.Marshal(map[string]string{"crv": "P-256", "kty": "EC", "x": ephemeralJWK.X, "y": ephemeralJWK.Y})
+ info := []byte(fmt.Sprintf("openvibe-locker people-slot v1|%s:%s|%s", recipient.Type, recipient.ID, recipientKey))
+ wrapKey, err := hkdf.Key(sha256.New, shared, salt, string(info), keyBytes)
+ if err != nil {
+ return nil, err
+ }
+ defer wipe(wrapKey)
+ aad := []byte(fmt.Sprintf("openvibe-locker people-slot v1|%s:%s|%s|%s|%s", recipient.Type, recipient.ID, recipientKey, ephemeralJSON, base64.StdEncoding.EncodeToString(salt)))
+ block, err := aes.NewCipher(wrapKey)
+ if err != nil {
+ return nil, err
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, err
+ }
+ wrapped := gcm.Seal(nil, wrapNonce, contentKey, aad)
+
+ padded := paddedSize(len(content))
+ header := map[string]any{
+ "version": lockerVersion,
+ "suite": suite,
+ "chunk_size": chunkSize,
+ "padded_size": padded,
+ "nonce_base": base64.StdEncoding.EncodeToString(nonceBase),
+ "lock": "people",
+ "slots": map[string]any{"people": []any{map[string]any{
+ "subject": map[string]string{"type": recipient.Type, "id": recipient.ID},
+ "algorithm": "ECDH-P256/HKDF-SHA256/AES-256-GCM",
+ "recipient_key": recipientKey,
+ "ephemeral_public_key": map[string]string{"kty": "EC", "crv": "P-256", "x": ephemeralJWK.X, "y": ephemeralJWK.Y},
+ "salt": base64.StdEncoding.EncodeToString(salt),
+ "nonce": base64.StdEncoding.EncodeToString(wrapNonce),
+ "wrapped_key": base64.StdEncoding.EncodeToString(wrapped),
+ }}},
+ }
+ headerBytes, err := json.Marshal(header)
+ if err != nil {
+ return nil, err
+ }
+ core, err := json.Marshal(map[string]any{
+ "version": lockerVersion,
+ "suite": suite,
+ "chunk_size": chunkSize,
+ "padded_size": padded,
+ "nonce_base": base64.StdEncoding.EncodeToString(nonceBase),
+ "lock": "people",
+ })
+ if err != nil {
+ return nil, err
+ }
+ plain := make([]byte, padded)
+ binary.BigEndian.PutUint64(plain[:8], uint64(len(content)))
+ copy(plain[8:], content)
+ defer wipe(plain)
+
+ block, err = aes.NewCipher(contentKey)
+ if err != nil {
+ return nil, err
+ }
+ gcm, err = cipher.NewGCM(block)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]byte, 0, 8+len(headerBytes)+len(plain)+gcm.Overhead())
+ out = append(out, "OVLK"...)
+ out = binary.BigEndian.AppendUint32(out, uint32(len(headerBytes)))
+ out = append(out, headerBytes...)
+ count := (padded + chunkSize - 1) / chunkSize
+ for i := 0; i < count; i++ {
+ start := i * chunkSize
+ end := start + chunkSize
+ if end > padded {
+ end = padded
+ }
+ flags := byte(0)
+ if i == count-1 {
+ flags = finalFlag
+ }
+ nonce := make([]byte, nonceSize)
+ copy(nonce, nonceBase)
+ binary.BigEndian.PutUint32(nonce[8:], uint32(i))
+ chunkAad := make([]byte, len(core)+5)
+ copy(chunkAad, core)
+ binary.BigEndian.PutUint32(chunkAad[len(core):], uint32(i))
+ chunkAad[len(core)+4] = flags
+ ciphertext := gcm.Seal(nil, nonce, plain[start:end], chunkAad)
+ out = binary.BigEndian.AppendUint32(out, uint32(len(ciphertext)))
+ out = append(out, flags)
+ out = append(out, nonce...)
+ out = append(out, ciphertext...)
+ }
+ return out, nil
+}
+
+// Addressed reports whether an envelope names this machine's public key in a people slot, so Link can refuse an
+// item encrypted for another machine before it asks the person anything.
+func Addressed(envelope []byte, public JWK) bool {
+ h, _, err := readHeader(envelope)
+ if err != nil {
+ return false
+ }
+ want, err := thumbprint(public)
+ if err != nil {
+ return false
+ }
+ for _, slot := range h.Slots.People {
+ if slot.RecipientKey == want && slot.Algorithm == "ECDH-P256/HKDF-SHA256/AES-256-GCM" {
+ return true
+ }
+ }
+ return false
+}
+
+func readHeader(envelope []byte) (header, int, error) {
+ var h header
+ if len(envelope) < 8 || len(envelope) > maxBytes || string(envelope[:4]) != "OVLK" {
+ return h, 0, errors.New("invalid locker envelope")
+ }
+ headerLen := int(binary.BigEndian.Uint32(envelope[4:8]))
+ if headerLen < 2 || headerLen > 65536 || 8+headerLen > len(envelope) {
+ return h, 0, errors.New("invalid locker header")
+ }
+ if json.Unmarshal(envelope[8:8+headerLen], &h) != nil {
+ return h, 0, errors.New("invalid locker header")
+ }
+ return h, headerLen, nil
+}
+
+func ephemeralKey(scalar []byte, rnd io.Reader) (*ecdh.PrivateKey, error) {
+ if scalar != nil {
+ if len(scalar) != 32 {
+ return nil, errors.New("invalid ephemeral key")
+ }
+ return ecdh.P256().NewPrivateKey(scalar)
+ }
+ return ecdh.P256().GenerateKey(rnd)
+}
+
+func recipientPublic(public JWK) *ecdh.PublicKey {
+ x, errX := base64.RawURLEncoding.DecodeString(public.X)
+ y, errY := base64.RawURLEncoding.DecodeString(public.Y)
+ if errX != nil || errY != nil || len(x) != 32 || len(y) != 32 {
+ return nil
+ }
+ key, err := ecdh.P256().NewPublicKey(elliptic.Marshal(elliptic.P256(), new(big.Int).SetBytes(x), new(big.Int).SetBytes(y)))
+ if err != nil {
+ return nil
+ }
+ return key
+}
+
+func ephemeralPublicJwk(key *ecdh.PrivateKey) JWK {
+ pub := key.PublicKey().Bytes() // uncompressed point: 0x04 || X || Y
+ return JWK{
+ Kty: "EC", Crv: "P-256",
+ X: base64.RawURLEncoding.EncodeToString(pub[1:33]),
+ Y: base64.RawURLEncoding.EncodeToString(pub[33:]),
+ }
+}
+
+func thumbprint(public JWK) (string, error) {
+ jwk, err := json.Marshal(map[string]string{"crv": "P-256", "kty": "EC", "x": public.X, "y": public.Y})
+ if err != nil {
+ return "", err
+ }
+ sum := sha256.Sum256(jwk)
+ return base64.RawURLEncoding.EncodeToString(sum[:]), nil
+}
+
+func paddedSize(size int) int {
+ p := padme(8 + size)
+ if p < minPaddedSize {
+ return minPaddedSize
+ }
+ return p
+}
+
+// padme is Padmé's grid (the SDK's padme.js): round up to the next size the band allows.
+func padme(size int) int {
+ if size <= 0 {
+ return 0
+ }
+ if size == 1 {
+ return 1
+ }
+ band := bits.Len(uint(size)) - 1
+ steps := bits.Len(uint(band))
+ step := 1 << (band - steps)
+ return ((size + step - 1) / step) * step
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+package lockeropen
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "math/big"
+ "testing"
+)
+
+// The shared SDK vector: fixed keys, fixed bytes, and the envelope hash the SDK's own test checks. If Go and the
+// SDK ever write different bytes for the same input, this fails.
+const (
+ vectorContent = "The quick brown fox jumps over the lazy dog."
+ vectorEnvelopeID = "a07abdad22e7400831299bb6c363c50241c816bb49c361e555f90bd148690b47"
+)
+
+var vectorPublic = JWK{
+ Kty: "EC", Crv: "P-256",
+ X: "pNngpTSE3X3C_CGzfpAtenjliLPFZ9arBVNtI0ctPXw",
+ Y: "fXZT2JcDDiXEb6xH3LUrqlPakM3GQwe3aqbV_1vhVLI",
+ D: "cG9YgmeoPeaI8lXZRpijV5--2sC4rYWSqWbyGCYKPu0",
+}
+var vectorSubject = Subject{Type: "user", ID: "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"}
+
+// fixedRandom is the SDK's vectors.fixedRandom: the same xorshift32 stream, so the same salt and key bytes.
+type fixedRandom struct{ state uint32 }
+
+func (f *fixedRandom) Read(p []byte) (int, error) {
+ for i := range p {
+ f.state ^= f.state << 13
+ f.state ^= f.state >> 17
+ f.state ^= f.state << 5
+ p[i] = byte(f.state)
+ }
+ return len(p), nil
+}
+
+func TestSealMatchesTheSDKVector(t *testing.T) {
+ private, err := base64.RawURLEncoding.DecodeString(vectorPublic.D)
+ if err != nil {
+ t.Fatal(err)
+ }
+ envelope, err := Seal([]byte(vectorContent), vectorSubject, vectorPublic, SealOptions{
+ EphemeralPrivate: private,
+ Random: &fixedRandom{state: 0x1a2b3c4d},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ sum := sha256.Sum256(envelope)
+ if got := hex.EncodeToString(sum[:]); got != vectorEnvelopeID {
+ t.Fatalf("sealed envelope %s, the SDK writes %s", got, vectorEnvelopeID)
+ }
+ opened, err := Open(envelope, vectorPublic, vectorSubject)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(opened) != vectorContent {
+ t.Fatalf("opened %q", opened)
+ }
+}
+
+func TestSealRoundTripForALinkMachine(t *testing.T) {
+ key := testKey(t)
+ recipient := Subject{Type: "link", ID: "lnk_01JAB2C3D4E5F6G7H8J9K0MNPQ"}
+ content := []byte(`{"name":"clipboard.txt"}` + "\x00hello from the other machine")
+ envelope, err := Seal(content, recipient, key, SealOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !Addressed(envelope, key) {
+ t.Fatal("the envelope does not name its recipient")
+ }
+ if Addressed(envelope, testKey(t)) {
+ t.Fatal("the envelope names a key it was not sealed for")
+ }
+ opened, err := Open(envelope, key, recipient)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(opened) != string(content) {
+ t.Fatalf("opened %q", opened)
+ }
+ if _, err := Open(envelope, key, Subject{Type: "link", ID: "lnk_01JAB2C3D4E5F6G7H8J9K0MNPR"}); err == nil {
+ t.Fatal("another machine's subject opened the item")
+ }
+}
+
+func testKey(t *testing.T) JWK {
+ t.Helper()
+ private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b64 := func(n *big.Int) string {
+ raw := make([]byte, 32)
+ n.FillBytes(raw)
+ return base64.RawURLEncoding.EncodeToString(raw)
+ }
+ return JWK{Kty: "EC", Crv: "P-256", X: b64(private.X), Y: b64(private.Y), D: b64(private.D)}
+}
@@ -29,8 +29,8 @@ fragment: `/panel/#t=<token>`. The page sends that token as `X-Link-Panel` on ev
@@ -29,8 +29,8 @@ fragment: `/panel/#t=<token>`. The page sends that token as `X-Link-Panel` on ev
| `POST /panel/api/files/:id/decline` | — | Removes the incoming item. |
| `POST /panel/api/files/:id/decline` | — | Removes the incoming item. |
| `POST /panel/api/files/:id/complete` | — | Removes the accepted item after download starts. |
| `POST /panel/api/files/:id/complete` | — | Removes the accepted item after download starts. |
| `POST /panel/api/first-run` | `{ "uses": ["codes", "browser"], "skip": false }` | The first-run answer. Uses are `codes`, `browser`, `models`, `earn` and `vibe`. The daemon turns on what each says (`earn` stays off until it is confirmed on Exchange) and stops asking; `skip: true` only stops asking. |
| `POST /panel/api/first-run` | `{ "uses": ["codes", "browser"], "skip": false }` | The first-run answer. Uses are `codes`, `browser`, `models`, `earn` and `vibe`. The daemon turns on what each says (`earn` stays off until it is confirmed on Exchange) and stops asking; `skip: true` only stops asking. |
-| `POST /panel/api/clipboard` | `{ "on": true }` | The per-machine clipboard-sharing switch, off by default; text only, sealed like every other item. |
-| `POST /panel/api/send/auto-accept` | `{ "on": true }` | Save incoming items from the person's own machines without asking, per machine. Off by default. |
+| `POST /panel/api/clipboard` | `{ "on": true }` | The per-machine clipboard-sharing switch, off by default; text only. The daemon seals each new clipboard change for the person's other machines while the window is closed. |
+| `POST /panel/api/send/auto-accept` | `{ "on": true }` | Save incoming items from the person's own machines without asking, per machine. Off by default; the daemon saves files to Downloads and clipboard text to the clipboard while the window is closed. |
## `GET /panel/api/state`
## `GET /panel/api/state`
@@ -181,7 +181,6 @@
@@ -181,7 +181,6 @@
var drop = $('[data-lk-drop]'), say = $('[data-lk-drop-say]'), targetSelect = $('[data-lk-send-target]');
var drop = $('[data-lk-drop]'), say = $('[data-lk-drop-say]'), targetSelect = $('[data-lk-send-target]');
var sendKey = null, lockerModule = null;
var sendKey = null, lockerModule = null;
var settings = { clipboard: false, auto_accept: false };
var settings = { clipboard: false, auto_accept: false };
- var seenInbox = {};
function panelApi(path, method, body) {
function panelApi(path, method, body) {
return fetch('/panel/api/' + path, { method: method || 'GET', headers: Object.assign({ 'X-Link-Panel': token }, body ? { 'Content-Type': 'application/json' } : {}),
return fetch('/panel/api/' + path, { method: method || 'GET', headers: Object.assign({ 'X-Link-Panel': token }, body ? { 'Content-Type': 'application/json' } : {}),
body: body ? JSON.stringify(body) : undefined, credentials: 'same-origin' }).then(function (r) {
body: body ? JSON.stringify(body) : undefined, credentials: 'same-origin' }).then(function (r) {
@@ -239,7 +238,7 @@
@@ -239,7 +238,7 @@
var view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), n = new DataView(view.buffer, view.byteOffset, view.byteLength).getUint32(0);
var view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), n = new DataView(view.buffer, view.byteOffset, view.byteLength).getUint32(0);
if (n < 2 || n > view.length - 4) throw new Error('The encrypted item is incomplete.');
if (n < 2 || n > view.length - 4) throw new Error('The encrypted item is incomplete.');
var info = JSON.parse(new TextDecoder().decode(view.subarray(4, 4 + n))), fileBytes = view.subarray(4 + n);
var info = JSON.parse(new TextDecoder().decode(view.subarray(4, 4 + n))), fileBytes = view.subarray(4 + n);
- if (info.kind === 'clipboard' && navigator.clipboard && navigator.clipboard.writeText) {
+ if (info.kind === 'clipboard' && settings.clipboard && navigator.clipboard && navigator.clipboard.writeText) {
var text = new TextDecoder().decode(fileBytes);
var text = new TextDecoder().decode(fileBytes);
return navigator.clipboard.writeText(text).then(function () {
return navigator.clipboard.writeText(text).then(function () {
return panelApi('files/' + encodeURIComponent(item.id) + '/complete', 'POST', {}).then(function () {
return panelApi('files/' + encodeURIComponent(item.id) + '/complete', 'POST', {}).then(function () {
@@ -257,11 +256,7 @@
@@ -257,11 +256,7 @@
function loadInbox() {
function loadInbox() {
panelApi('send/inbox').then(function (body) {
panelApi('send/inbox').then(function (body) {
var list = $('[data-lk-send-inbox]');
var list = $('[data-lk-send-inbox]');
- (body.items || []).forEach(function (item) {
- if (!settings.auto_accept || seenInbox[item.id]) return;
- seenInbox[item.id] = true;
- downloadAccepted(item).catch(function (e) { say(e.message || 'An item from one of your machines could not be opened.'); });
- });
+ // Auto-accept runs in the daemon, even when this window is closed; doing it here too would save twice.
list.replaceChildren.apply(list, (body.items || []).map(function (item) {
list.replaceChildren.apply(list, (body.items || []).map(function (item) {
var li = el('li', 'lk-send-item'), label = el('span', '', 'Encrypted item from ' + (item.sender_name || 'your machine') + ' · ' + size(item.bytes) + ' · ' + ago(item.created_at));
var li = el('li', 'lk-send-item'), label = el('span', '', 'Encrypted item from ' + (item.sender_name || 'your machine') + ' · ' + size(item.bytes) + ' · ' + ago(item.created_at));
var actions = el('div'), accept = el('button', '', 'Accept'), decline = el('button', '', 'Decline');
var actions = el('div'), accept = el('button', '', 'Accept'), decline = el('button', '', 'Decline');
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+package send
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "os/exec"
+ "time"
+)
+
+// Clipboard is this computer's text clipboard. Link only ever reads and writes text: an image or a file copied
+// on one machine stays there.
+type Clipboard interface {
+ Read(ctx context.Context) (string, error)
+ Write(ctx context.Context, text string) error
+}
+
+// commandClipboard uses the clipboard tool the desktop already has, so Link adds no dependency and no C library.
+type commandClipboard struct {
+ read [][]string
+ write [][]string
+ tool string
+}
+
+func (c *commandClipboard) Read(parent context.Context) (string, error) {
+ ctx, cancel := context.WithTimeout(parent, 2*time.Second)
+ defer cancel()
+ for _, argv := range c.read {
+ if _, err := exec.LookPath(argv[0]); err != nil {
+ continue
+ }
+ out, err := exec.CommandContext(ctx, argv[0], argv[1:]...).Output()
+ if err == nil {
+ return string(out), nil
+ }
+ if ctx.Err() != nil {
+ return "", ctx.Err()
+ }
+ }
+ return "", errors.New(c.tool + " is not available on this computer")
+}
+
+func (c *commandClipboard) Write(parent context.Context, text string) error {
+ ctx, cancel := context.WithTimeout(parent, 5*time.Second)
+ defer cancel()
+ for _, argv := range c.write {
+ if _, err := exec.LookPath(argv[0]); err != nil {
+ continue
+ }
+ cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
+ cmd.Stdin = bytes.NewReader([]byte(text))
+ if err := cmd.Run(); err == nil {
+ return nil
+ }
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ }
+ return errors.New(c.tool + " is not available on this computer")
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+//go:build darwin
+
+package send
+
+func SystemClipboard() Clipboard {
+ return &commandClipboard{
+ read: [][]string{{"pbpaste"}},
+ write: [][]string{{"pbcopy"}},
+ tool: "pbpaste and pbcopy",
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+//go:build linux
+
+package send
+
+// SystemClipboard prefers Wayland when the session is Wayland, and still falls back to X11 tools when it is not
+// installed (a Wayland session can run X11 applications, but a missing wl-clipboard is common).
+func SystemClipboard() Clipboard {
+ return &commandClipboard{
+ read: [][]string{
+ {"wl-paste", "--no-newline"},
+ {"xclip", "-selection", "clipboard", "-o"},
+ {"xsel", "--clipboard", "--output"},
+ },
+ write: [][]string{
+ {"wl-copy"},
+ {"xclip", "-selection", "clipboard", "-i"},
+ {"xsel", "--clipboard", "--input"},
+ },
+ tool: "wl-clipboard or xclip",
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+//go:build !linux && !darwin && !windows
+
+package send
+
+// SystemClipboard is absent on platforms without a clipboard Link can reach. Clipboard sync stays off there and
+// the window says so instead of pretending.
+func SystemClipboard() Clipboard { return nil }
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+package send
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+)
+
+func TestClipboardCommandsReadAndWriteText(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("the test uses a POSIX shell as the clipboard tool")
+ }
+ out := filepath.Join(t.TempDir(), "clipboard.txt")
+ c := &commandClipboard{
+ read: [][]string{{"sh", "-c", "cat " + out}},
+ write: [][]string{{"sh", "-c", "cat > " + out}},
+ tool: "the test clipboard",
+ }
+ if err := os.WriteFile(out, []byte("first"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ got, err := c.Read(context.Background())
+ if err != nil || got != "first" {
+ t.Fatalf("read = %q, %v", got, err)
+ }
+ if err := c.Write(context.Background(), "second"); err != nil {
+ t.Fatal(err)
+ }
+ b, err := os.ReadFile(out)
+ if err != nil || string(b) != "second" {
+ t.Fatalf("written = %q, %v", b, err)
+ }
+}
+
+func TestClipboardWithoutAToolSaysSo(t *testing.T) {
+ c := &commandClipboard{
+ read: [][]string{{"openvibe-link-no-such-clipboard-tool"}},
+ write: [][]string{{"openvibe-link-no-such-clipboard-tool"}},
+ tool: "a clipboard tool",
+ }
+ if _, err := c.Read(context.Background()); err == nil {
+ t.Fatal("read without a tool succeeded")
+ }
+ if err := c.Write(context.Background(), "x"); err == nil {
+ t.Fatal("write without a tool succeeded")
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+//go:build windows
+
+package send
+
+func SystemClipboard() Clipboard {
+ return &commandClipboard{
+ read: [][]string{{"powershell", "-NoProfile", "-NonInteractive", "-Command", "[Console]::Out.Write((Get-Clipboard -Raw))"}},
+ write: [][]string{{"powershell", "-NoProfile", "-NonInteractive", "-Command", "[Console]::In.ReadToEnd() | Set-Clipboard"}},
+ tool: "PowerShell's clipboard",
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,144 @@
@@ -0,0 +1,144 @@
+package send
+
+import (
+ "encoding/binary"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "unicode"
+)
+
+// KindFile, KindNote and KindClipboard are the three things Link Send can carry. The kind is inside the sealed
+// payload, never in the relay's metadata: the receiving machine learns it only after opening the item.
+const (
+ KindFile = "file"
+ KindNote = "note"
+ KindClipboard = "clipboard"
+)
+
+// Content is what a sealed item carries: the name and type a person sees, then the bytes. It is sealed as one
+// blob, so the relay never sees the name, the type or the kind either.
+type Content struct {
+ Name string `json:"name"`
+ Mime string `json:"mime"`
+ Kind string `json:"kind,omitempty"`
+}
+
+type Packed struct {
+ Content
+ Data []byte
+}
+
+func Pack(name, mime string, data []byte) []byte {
+ return PackKind(name, mime, KindFile, data)
+}
+
+func PackKind(name, mime, kind string, data []byte) []byte {
+ if name == "" {
+ name = "received-file"
+ }
+ meta, _ := json.Marshal(Content{Name: name, Mime: mime, Kind: kind})
+ out := make([]byte, 4+len(meta)+len(data))
+ binary.BigEndian.PutUint32(out[:4], uint32(len(meta)))
+ copy(out[4:], meta)
+ copy(out[4+len(meta):], data)
+ return out
+}
+
+func Unpack(b []byte) (Packed, error) {
+ if len(b) < 4 {
+ return Packed{}, errors.New("the received item is incomplete")
+ }
+ n := int(binary.BigEndian.Uint32(b[:4]))
+ if n < 2 || n > len(b)-4 {
+ return Packed{}, errors.New("the received item is incomplete")
+ }
+ var c Content
+ if json.Unmarshal(b[4:4+n], &c) != nil {
+ return Packed{}, errors.New("the received item does not say what it is")
+ }
+ if c.Kind == "" {
+ c.Kind = KindFile
+ }
+ if c.Kind != KindFile && c.Kind != KindNote && c.Kind != KindClipboard {
+ return Packed{}, errors.New("the received item is not something Link sends")
+ }
+ return Packed{Content: c, Data: b[4+n:]}, nil
+}
+
+// SafeName keeps one path segment: no folder separators, no control characters, no leading dot, and short enough
+// for every filesystem Link runs on.
+func SafeName(name string) string {
+ name = strings.TrimSpace(name)
+ name = strings.Map(func(r rune) rune {
+ if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' || unicode.IsControl(r) {
+ return '_'
+ }
+ return r
+ }, name)
+ name = strings.TrimLeft(name, ".")
+ if name == "" {
+ return "received-file"
+ }
+ runes := []rune(name)
+ if len(runes) > 180 {
+ name = string(runes[:180])
+ }
+ return name
+}
+
+// DownloadsDir is where received files go unless the daemon was given another folder.
+func DownloadsDir() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil || home == "" {
+ return "", errNoDownloads
+ }
+ return filepath.Join(home, "Downloads"), nil
+}
+
+// Save writes the item into dir without replacing an existing file: a second "report.pdf" becomes "report (2).pdf".
+// It returns the path it wrote.
+func Save(dir, name string, data []byte) (string, error) {
+ if dir == "" {
+ var err error
+ if dir, err = DownloadsDir(); err != nil {
+ return "", err
+ }
+ }
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return "", fmt.Errorf("the Downloads folder could not be opened")
+ }
+ safe := SafeName(name)
+ ext := filepath.Ext(safe)
+ stem := strings.TrimSuffix(safe, ext)
+ for i := 0; i < 1000; i++ {
+ candidate := safe
+ if i > 0 {
+ candidate = fmt.Sprintf("%s (%d)%s", stem, i+1, ext)
+ }
+ path := filepath.Join(dir, candidate)
+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if errors.Is(err, os.ErrExist) {
+ continue
+ }
+ if err != nil {
+ return "", fmt.Errorf("the file could not be saved in Downloads")
+ }
+ if _, err := f.Write(data); err != nil {
+ _ = f.Close()
+ _ = os.Remove(path)
+ return "", fmt.Errorf("the file could not be saved in Downloads")
+ }
+ if err := f.Close(); err != nil {
+ _ = os.Remove(path)
+ return "", fmt.Errorf("the file could not be saved in Downloads")
+ }
+ return path, nil
+ }
+ return "", fmt.Errorf("there are too many files with that name in Downloads")
+}
+
+var errNoDownloads = errors.New("this computer has no Downloads folder to save into")
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+package send
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+func TestPackedItemKeepsItsNameKindAndBytes(t *testing.T) {
+ envelope := PackKind("photo (1).png", "image/png", KindClipboard, []byte{0x89, 'P', 'N', 'G'})
+ got, err := Unpack(envelope)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Name != "photo (1).png" || got.Mime != "image/png" || got.Kind != KindClipboard {
+ t.Fatalf("content = %#v", got.Content)
+ }
+ if string(got.Data) != "\x89PNG" {
+ t.Fatalf("data = %q", got.Data)
+ }
+ if plain, err := Unpack(Pack("note.txt", "text/plain", []byte("hello"))); err != nil || plain.Kind != KindFile {
+ t.Fatalf("a v1 item without a kind = %#v, %v", plain, err)
+ }
+}
+
+func TestUnpackRefusesAnUnknownKindOrBrokenFrame(t *testing.T) {
+ bad := Pack("note.txt", "text/plain", []byte("hello"))
+ bad[0], bad[1], bad[2], bad[3] = 0, 0, 0, 200
+ if _, err := Unpack(bad); err == nil {
+ t.Fatal("accepted a frame longer than the item")
+ }
+ unknown := []byte(`{"name":"x","kind":"bogus"}`)
+ frame := make([]byte, 4+len(unknown))
+ frame[0], frame[1], frame[2], frame[3] = 0, 0, 0, byte(len(unknown))
+ copy(frame[4:], unknown)
+ if _, err := Unpack(frame); err == nil {
+ t.Fatal("accepted an unknown kind")
+ }
+}
+
+func TestSafeNameIsOnePathSegment(t *testing.T) {
+ cases := map[string]string{
+ "../../etc/passwd": "_.._etc_passwd",
+ `C:\Users\me\file.txt`: "C__Users_me_file.txt",
+ ".hidden": "hidden",
+ " ": "received-file",
+ "a\x00b.txt": "a_b.txt",
+ }
+ for in, want := range cases {
+ if got := SafeName(in); got != want {
+ t.Fatalf("SafeName(%q) = %q, want %q", in, got, want)
+ }
+ }
+ long := strings.Repeat("x", 300)
+ if got := SafeName(long); len([]rune(got)) != 180 {
+ t.Fatalf("a long name = %d runes, want 180", len([]rune(got)))
+ }
+}
+
+func TestSaveNeverReplacesAFile(t *testing.T) {
+ dir := t.TempDir()
+ first, err := Save(dir, "report.pdf", []byte("first"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := Save(dir, "report.pdf", []byte("second"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first == second || filepath.Base(second) != "report (2).pdf" {
+ t.Fatalf("paths = %q, %q", first, second)
+ }
+ b, err := os.ReadFile(second)
+ if err != nil || string(b) != "second" {
+ t.Fatalf("second file = %q, %v", b, err)
+ }
+ if runtime.GOOS != "windows" {
+ info, err := os.Stat(second)
+ if err != nil || info.Mode().Perm() != 0o600 {
+ t.Fatalf("saved mode = %v, %v", info.Mode().Perm(), err)
+ }
+ }
+}
+
+func TestSaveRefusesAnUnwritableDownloadsFolder(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("the test uses a file where a folder should be")
+ }
+ dir := t.TempDir()
+ blocked := filepath.Join(dir, "not-a-folder")
+ if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Save(blocked, "file.txt", []byte("data")); err == nil {
+ t.Fatal("saved into a path that is a file")
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,383 @@
@@ -0,0 +1,383 @@
+package send
+
+import (
+ "context"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Link/internal/lockeropen"
+)
+
+// maxClipboardText is what one clipboard item may carry. A copied paragraph is tiny; a whole document is not what
+// clipboard sync is for.
+const maxClipboardText = 64 << 10
+
+// maxClipboardEnvelope bounds the base64 Locker envelope a clipboard item can have, with room for the header and
+// padding. While auto-accept is off, the service only opens items below it, so a large file is never marked accepted
+// just to learn that it is not clipboard text.
+const maxClipboardEnvelope = 160 << 10
+
+// Relay is the part of *Client the service uses, so the service can be tested without a network.
+type Relay interface {
+ Targets(ctx context.Context) ([]Machine, error)
+ Inbox(ctx context.Context) ([]Item, error)
+ Accept(ctx context.Context, id string) (string, error)
+ Complete(ctx context.Context, id string) error
+ Send(ctx context.Context, target, envelope string) error
+}
+
+// Direct is this machine's queue of items another of the owner's machines handed over on the local network. The
+// service only opens items whose sender is one of this account's machines; a granted transfer from another account
+// stays in the window for the person to answer.
+type Direct interface {
+ Items() []Incoming
+ Accept(id string) (string, error)
+ Complete(id string) error
+}
+
+type ServiceOptions struct {
+ Relay Relay
+ Direct Direct
+ Key JWK
+ Principal string
+ Downloads string
+ Clipboard Clipboard
+ Settings func() Settings
+ Log *slog.Logger
+ OnActivity func(string)
+ Now func() time.Time
+}
+
+// Service is Link Send's background half: it receives items for this machine when the person turned auto-accept on,
+// keeps the clipboard in step with their other machines when clipboard sync is on, and otherwise leaves everything
+// in the window for a person to answer. It never sends in clear and never opens an item before deciding to keep it.
+type Service struct {
+ opt ServiceOptions
+
+ mu sync.Mutex
+ handled map[string]bool
+ inspected map[string]Settings
+ failed map[string]bool
+ clipPrimed bool
+ clipLast string
+ clipKnown string
+}
+
+func NewService(opt ServiceOptions) *Service {
+ if opt.Log == nil {
+ opt.Log = slog.New(slog.NewTextHandler(io.Discard, nil))
+ }
+ if opt.Now == nil {
+ opt.Now = time.Now
+ }
+ if opt.Clipboard == nil {
+ opt.Clipboard = SystemClipboard()
+ }
+ return &Service{opt: opt, handled: map[string]bool{}, inspected: map[string]Settings{}, failed: map[string]bool{}}
+}
+
+// Run polls the relay and the direct queue, and reads this machine's clipboard while the person has that switch on,
+// until ctx ends. Registration and the direct listener belong to the window (internal/agent), not here.
+func (s *Service) Run(ctx context.Context) {
+ tick := time.NewTicker(2 * time.Second)
+ defer tick.Stop()
+ s.poll(ctx)
+ s.syncClipboard(ctx)
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-tick.C:
+ s.poll(ctx)
+ s.syncClipboard(ctx)
+ }
+ }
+}
+
+func (s *Service) settings() Settings {
+ if s.opt.Settings == nil {
+ return DefaultSettings()
+ }
+ return s.opt.Settings()
+}
+
+func (s *Service) activity(text string) {
+ if s.opt.OnActivity != nil && strings.TrimSpace(text) != "" {
+ s.opt.OnActivity(text)
+ }
+}
+
+type waiting struct {
+ id string
+ sender string
+ bytes int64
+ transport string
+}
+
+func (s *Service) poll(ctx context.Context) {
+ settings := s.settings()
+ if !settings.AutoAccept && !settings.Clipboard {
+ return
+ }
+ var items []waiting
+ if s.opt.Relay != nil {
+ relayItems, err := s.opt.Relay.Inbox(ctx)
+ if err != nil {
+ s.opt.Log.Debug("Link Send inbox could not be read", "err", err)
+ }
+ for _, item := range relayItems {
+ items = append(items, waiting{id: item.ID, sender: item.SenderName, bytes: item.Bytes, transport: "relay"})
+ }
+ }
+ var direct []Incoming
+ if s.opt.Direct != nil {
+ direct = s.opt.Direct.Items()
+ }
+ if len(direct) > 0 {
+ own, err := s.ownMachines(ctx)
+ if err != nil {
+ s.opt.Log.Debug("Link Send could not check its own machines", "err", err)
+ direct = nil
+ }
+ for _, item := range direct {
+ if !own[item.Sender] {
+ continue
+ }
+ items = append(items, waiting{id: item.ID, sender: item.SenderName, bytes: item.Bytes, transport: "direct"})
+ }
+ }
+ for _, item := range items {
+ if !s.shouldLook(settings, item.bytes) {
+ continue
+ }
+ s.mu.Lock()
+ done := s.handled[item.id] || s.failed[item.id]
+ before, seen := s.inspected[item.id]
+ s.mu.Unlock()
+ if done || (seen && before == settings) {
+ continue
+ }
+ s.handle(ctx, settings, item)
+ }
+}
+
+// shouldLook says whether an item could be one this machine handles with the switches as they are. When auto-accept
+// is off only a clipboard-sized item is opened, because only its kind can make it something to keep now.
+func (s *Service) shouldLook(settings Settings, bytes int64) bool {
+ if settings.AutoAccept {
+ return true
+ }
+ return settings.Clipboard && bytes > 0 && bytes <= maxClipboardEnvelope
+}
+
+func (s *Service) ownMachines(ctx context.Context) (map[string]bool, error) {
+ out := map[string]bool{}
+ if s.opt.Relay == nil {
+ return out, nil
+ }
+ machines, err := s.opt.Relay.Targets(ctx)
+ if err != nil {
+ return nil, err
+ }
+ for _, machine := range machines {
+ out[machine.ID] = true
+ }
+ return out, nil
+}
+
+func (s *Service) handle(ctx context.Context, settings Settings, item waiting) {
+ envelope, err := s.envelope(ctx, item)
+ if err != nil {
+ s.opt.Log.Debug("a waiting item could not be fetched", "id", item.id, "err", err)
+ return
+ }
+ raw, err := lockeropen.DecodeEnvelope(envelope)
+ if err != nil {
+ s.fail(item, "A transfer could not be opened; ask the other machine to send it again.")
+ return
+ }
+ plain, err := lockeropen.Open(raw, lockeropen.JWK{Kty: s.opt.Key.Kty, Crv: s.opt.Key.Crv, X: s.opt.Key.X, Y: s.opt.Key.Y, D: s.opt.Key.D},
+ lockeropen.Subject{Type: "link", ID: s.opt.Principal})
+ if err != nil {
+ s.fail(item, "A transfer was not encrypted for this machine, so Link could not open it.")
+ return
+ }
+ packed, err := Unpack(plain)
+ if err != nil {
+ s.fail(item, "A transfer did not arrive in full; ask the other machine to send it again.")
+ return
+ }
+ switch packed.Kind {
+ case KindClipboard:
+ if !settings.Clipboard {
+ s.inspect(item, settings)
+ return
+ }
+ if s.opt.Clipboard == nil {
+ s.inspect(item, settings)
+ return
+ }
+ if len(packed.Data) > maxClipboardText {
+ s.fail(item, "Copied text from another machine was too large to put on this clipboard.")
+ return
+ }
+ text := string(packed.Data)
+ if err := s.opt.Clipboard.Write(ctx, text); err != nil {
+ s.opt.Log.Warn("copied text could not be put on the clipboard", "err", err)
+ s.inspect(item, settings)
+ return
+ }
+ s.mu.Lock()
+ s.clipKnown, s.clipLast = text, text
+ s.mu.Unlock()
+ s.complete(ctx, item)
+ s.activity("Put copied text from " + orMachine(item.sender) + " on this computer's clipboard")
+ default:
+ if !settings.AutoAccept {
+ s.inspect(item, settings)
+ return
+ }
+ path, err := Save(s.opt.Downloads, packed.Name, packed.Data)
+ if err != nil {
+ s.fail(item, err.Error())
+ return
+ }
+ s.complete(ctx, item)
+ s.activity("Saved " + baseName(path) + " from " + orMachine(item.sender) + " to Downloads")
+ }
+}
+
+func (s *Service) envelope(ctx context.Context, item waiting) (string, error) {
+ if item.transport == "direct" {
+ if s.opt.Direct == nil {
+ return "", errors.New("the direct queue is not available")
+ }
+ return s.opt.Direct.Accept(item.id)
+ }
+ if s.opt.Relay == nil {
+ return "", errors.New("this machine is not paired for Link Send")
+ }
+ return s.opt.Relay.Accept(ctx, item.id)
+}
+
+func (s *Service) complete(ctx context.Context, item waiting) {
+ var err error
+ if item.transport == "direct" {
+ err = s.opt.Direct.Complete(item.id)
+ } else {
+ err = s.opt.Relay.Complete(ctx, item.id)
+ }
+ if err != nil {
+ // The item is already saved here; a lost completion only leaves the sender waiting until the relay expires it.
+ s.opt.Log.Warn("the relay did not learn an item was saved", "id", item.id, "err", err)
+ }
+ s.mu.Lock()
+ s.handled[item.id] = true
+ delete(s.inspected, item.id)
+ s.mu.Unlock()
+}
+
+// inspect remembers that this item was opened under these switches and left for the person. It is looked at again
+// only after a switch changes, so a file that is waiting does not get accepted on every poll.
+func (s *Service) inspect(item waiting, settings Settings) {
+ s.mu.Lock()
+ s.inspected[item.id] = settings
+ s.mu.Unlock()
+}
+
+func (s *Service) fail(item waiting, text string) {
+ s.mu.Lock()
+ s.failed[item.id] = true
+ s.mu.Unlock()
+ s.opt.Log.Warn("a Link Send item could not be received", "id", item.id)
+ s.activity(text)
+}
+
+// syncClipboard sends copied text to the person's other machines while the switch is on. Text only, and the first
+// thing it sees after the switch is turned on is only remembered, never sent.
+func (s *Service) syncClipboard(ctx context.Context) {
+ settings := s.settings()
+ if !settings.Clipboard || s.opt.Clipboard == nil || s.opt.Relay == nil {
+ s.mu.Lock()
+ s.clipPrimed = false
+ s.mu.Unlock()
+ return
+ }
+ text, err := s.opt.Clipboard.Read(ctx)
+ if err != nil {
+ return
+ }
+ s.mu.Lock()
+ if !s.clipPrimed {
+ s.clipPrimed, s.clipLast = true, text
+ s.mu.Unlock()
+ return
+ }
+ changed := text != s.clipLast
+ echo := text == s.clipKnown
+ if changed && !echo {
+ s.clipKnown = ""
+ }
+ s.clipLast = text
+ s.mu.Unlock()
+ if !changed || echo || strings.TrimSpace(text) == "" {
+ return
+ }
+ if len(text) > maxClipboardText {
+ s.activity("A copied text was larger than 64 KB, so it was not synced")
+ return
+ }
+ machines, err := s.opt.Relay.Targets(ctx)
+ if err != nil {
+ s.opt.Log.Debug("Link Send could not list this account's machines", "err", err)
+ return
+ }
+ sent := 0
+ for _, machine := range machines {
+ if machine.PublicKey.Kty != "EC" || machine.PublicKey.Crv != "P-256" || machine.PublicKey.X == "" || machine.PublicKey.Y == "" {
+ continue
+ }
+ sealed, err := lockeropen.Seal(PackKind("clipboard.txt", "text/plain;charset=utf-8", KindClipboard, []byte(text)),
+ lockeropen.Subject{Type: "link", ID: machine.ID},
+ lockeropen.JWK{Kty: machine.PublicKey.Kty, Crv: machine.PublicKey.Crv, X: machine.PublicKey.X, Y: machine.PublicKey.Y}, lockeropen.SealOptions{})
+ if err != nil {
+ s.opt.Log.Debug("a clipboard item could not be sealed", "machine", machine.ID, "err", err)
+ continue
+ }
+ if err := s.opt.Relay.Send(ctx, machine.ID, base64.StdEncoding.EncodeToString(sealed)); err != nil {
+ s.opt.Log.Debug("a clipboard item was not sent", "machine", machine.ID, "err", err)
+ continue
+ }
+ sent++
+ }
+ if sent > 0 {
+ s.activity("Synced this computer's clipboard to " + plural(sent, "other machine", "other machines"))
+ }
+}
+
+func orMachine(name string) string {
+ if strings.TrimSpace(name) == "" {
+ return "your other machine"
+ }
+ return name
+}
+
+func baseName(path string) string {
+ if i := strings.LastIndexAny(path, `/\`); i >= 0 {
+ return path[i+1:]
+ }
+ return path
+}
+
+func plural(n int, one, many string) string {
+ if n == 1 {
+ return fmt.Sprintf("%d %s", n, one)
+ }
+ return fmt.Sprintf("%d %s", n, many)
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,363 @@
@@ -0,0 +1,363 @@
+package send
+
+import (
+ "context"
+ "encoding/base64"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/OpenVibers/OpenVibe.Link/internal/lockeropen"
+)
+
+var (
+ testMachine = "lnk_" + "01JAB2C3D4E5F6G7H8J9K0MNPQ"
+ peerMachine = "lnk_" + "01JAB2C3D4E5F6G7H8J9K0MNPR"
+ otherMachine = "lnk_" + "OTHER"
+)
+
+type fakeRelay struct {
+ targets []Machine
+ items []Item
+ envelopes map[string]string
+ accepted []string
+ completed []string
+ sent []sentItem
+ targetsErr error
+}
+
+type sentItem struct{ target, envelope string }
+
+func (f *fakeRelay) Targets(context.Context) ([]Machine, error) {
+ if f.targetsErr != nil {
+ return nil, f.targetsErr
+ }
+ return f.targets, nil
+}
+func (f *fakeRelay) Inbox(context.Context) ([]Item, error) { return f.items, nil }
+func (f *fakeRelay) Accept(_ context.Context, id string) (string, error) {
+ f.accepted = append(f.accepted, id)
+ envelope, ok := f.envelopes[id]
+ if !ok {
+ return "", errors.New("not waiting")
+ }
+ return envelope, nil
+}
+func (f *fakeRelay) Complete(_ context.Context, id string) error {
+ f.completed = append(f.completed, id)
+ return nil
+}
+func (f *fakeRelay) Send(_ context.Context, target, envelope string) error {
+ f.sent = append(f.sent, sentItem{target: target, envelope: envelope})
+ return nil
+}
+
+type fakeDirect struct {
+ items []Incoming
+ accepted []string
+ completed []string
+}
+
+func (f *fakeDirect) Items() []Incoming { return f.items }
+func (f *fakeDirect) Accept(id string) (string, error) {
+ f.accepted = append(f.accepted, id)
+ for _, item := range f.items {
+ if item.ID == id {
+ return item.Envelope, nil
+ }
+ }
+ return "", errors.New("not waiting")
+}
+func (f *fakeDirect) Complete(id string) error {
+ f.completed = append(f.completed, id)
+ return nil
+}
+
+type fakeClipboard struct {
+ text string
+ writes []string
+}
+
+func (f *fakeClipboard) Read(context.Context) (string, error) { return f.text, nil }
+func (f *fakeClipboard) Write(_ context.Context, text string) error {
+ f.writes = append(f.writes, text)
+ return nil
+}
+
+func lockerJWK(key JWK) lockeropen.JWK {
+ return lockeropen.JWK{Kty: key.Kty, Crv: key.Crv, X: key.X, Y: key.Y, D: key.D}
+}
+
+func testSealed(t *testing.T, key JWK, machine, kind, name string, data []byte) string {
+ t.Helper()
+ envelope, err := lockeropen.Seal(PackKind(name, "text/plain;charset=utf-8", kind, data),
+ lockeropen.Subject{Type: "link", ID: machine}, lockerJWK(key), lockeropen.SealOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return base64.StdEncoding.EncodeToString(envelope)
+}
+
+func serviceKey(t *testing.T) JWK {
+ t.Helper()
+ key, err := LoadOrCreate(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ return key
+}
+
+func serviceFor(t *testing.T, key JWK, relay Relay, direct Direct, settings Settings, clipboard Clipboard) (*Service, string) {
+ t.Helper()
+ downloads := t.TempDir()
+ service := NewService(ServiceOptions{
+ Relay: relay, Direct: direct, Key: key, Principal: testMachine,
+ Downloads: downloads, Clipboard: clipboard,
+ Settings: func() Settings { return settings },
+ })
+ return service, downloads
+}
+
+func TestAutoAcceptSavesAFileWithoutTheWindow(t *testing.T) {
+ key, err := LoadOrCreate(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ envelope := testSealed(t, key, testMachine, KindFile, "report.txt", []byte("quarterly numbers"))
+ relay := &fakeRelay{
+ items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Laptop", Bytes: int64(len(envelope))}},
+ envelopes: map[string]string{"snd_0123456789abcdef0123456789abcdef": envelope},
+ }
+ downloads := t.TempDir()
+ service := NewService(ServiceOptions{
+ Relay: relay, Key: key, Principal: testMachine, Downloads: downloads,
+ Settings: func() Settings { return Settings{AutoAccept: true} },
+ })
+ service.poll(context.Background())
+ b, err := os.ReadFile(filepath.Join(downloads, "report.txt"))
+ if err != nil || string(b) != "quarterly numbers" {
+ t.Fatalf("saved file = %q, %v", b, err)
+ }
+ if len(relay.completed) != 1 {
+ t.Fatalf("completed = %v", relay.completed)
+ }
+ service.poll(context.Background())
+ entries, _ := os.ReadDir(downloads)
+ if len(entries) != 1 || len(relay.completed) != 1 {
+ t.Fatalf("a second poll saved the item again: %d files, completed %v", len(entries), relay.completed)
+ }
+}
+
+func TestAutoAcceptOffLeavesAFileWaiting(t *testing.T) {
+ key, err := LoadOrCreate(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ envelope := testSealed(t, key, testMachine, KindFile, "report.txt", []byte("kept for later"))
+ relay := &fakeRelay{
+ items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Laptop", Bytes: int64(len(envelope))}},
+ envelopes: map[string]string{"snd_0123456789abcdef0123456789abcdef": envelope},
+ }
+ settings := Settings{}
+ service, downloads := serviceFor(t, serviceKey(t), relay, nil, settings, nil)
+ service.opt.Settings = func() Settings { return settings }
+ service.poll(context.Background())
+ if len(relay.accepted) != 0 || len(relay.completed) != 0 {
+ t.Fatalf("auto-accept off accepted %v, completed %v", relay.accepted, relay.completed)
+ }
+ if entries, _ := os.ReadDir(downloads); len(entries) != 0 {
+ t.Fatalf("auto-accept off saved %d files", len(entries))
+ }
+}
+
+func TestClipboardOffDoesNotConsumeAClipboardItem(t *testing.T) {
+ key, err := LoadOrCreate(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ envelope := testSealed(t, key, testMachine, KindClipboard, "clipboard.txt", []byte("copied text"))
+ relay := &fakeRelay{
+ items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Laptop", Bytes: int64(len(envelope))}},
+ envelopes: map[string]string{"snd_0123456789abcdef0123456789abcdef": envelope},
+ }
+ clip := &fakeClipboard{}
+ service, _ := serviceFor(t, key, relay, nil, Settings{AutoAccept: true}, clip)
+ service.poll(context.Background())
+ if len(clip.writes) != 0 {
+ t.Fatalf("clipboard switch off wrote %q", clip.writes)
+ }
+ if len(relay.completed) != 0 {
+ t.Fatalf("clipboard switch off completed the item: %v", relay.completed)
+ }
+}
+
+func TestClipboardOnPutsReceivedTextOnTheClipboard(t *testing.T) {
+ key, err := LoadOrCreate(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ envelope := testSealed(t, key, testMachine, KindClipboard, "clipboard.txt", []byte("from the desktop"))
+ relay := &fakeRelay{
+ items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Desktop", Bytes: int64(len(envelope))}},
+ envelopes: map[string]string{"snd_0123456789abcdef0123456789abcdef": envelope},
+ }
+ clip := &fakeClipboard{}
+ service, _ := serviceFor(t, key, relay, nil, Settings{AutoAccept: true, Clipboard: true}, clip)
+ service.poll(context.Background())
+ if len(clip.writes) != 1 || clip.writes[0] != "from the desktop" {
+ t.Fatalf("clipboard writes = %v", clip.writes)
+ }
+ if len(relay.completed) != 1 {
+ t.Fatalf("completed = %v", relay.completed)
+ }
+}
+
+func TestClipboardSyncSealsCopiedTextForEachMachine(t *testing.T) {
+ targetKey, err := LoadOrCreate(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ relay := &fakeRelay{targets: []Machine{{
+ ID: peerMachine, Name: "Desktop", PublicKey: Public(targetKey),
+ }}}
+ clip := &fakeClipboard{text: "before"}
+ service, _ := serviceFor(t, serviceKey(t), relay, nil, Settings{Clipboard: true}, clip)
+ service.syncClipboard(context.Background()) // prime: the first text is never sent
+ clip.text = "copy this"
+ service.syncClipboard(context.Background())
+ if len(relay.sent) != 1 {
+ t.Fatalf("sent = %v", relay.sent)
+ }
+ raw, err := base64.StdEncoding.DecodeString(relay.sent[0].envelope)
+ if err != nil {
+ t.Fatal(err)
+ }
+ plain, err := lockeropen.Open(raw, lockerJWK(targetKey), lockeropen.Subject{Type: "link", ID: peerMachine})
+ if err != nil {
+ t.Fatal(err)
+ }
+ packed, err := Unpack(plain)
+ if err != nil || packed.Kind != KindClipboard || string(packed.Data) != "copy this" {
+ t.Fatalf("packed = %#v, %v", packed, err)
+ }
+}
+
+func TestClipboardEchoFromAnotherMachineIsNotSentBack(t *testing.T) {
+ relay := &fakeRelay{targets: []Machine{{
+ ID: peerMachine, Name: "Desktop",
+ PublicKey: JWK{Kty: "EC", Crv: "P-256", X: "x", Y: "y"},
+ }}}
+ clip := &fakeClipboard{text: "same"}
+ service, _ := serviceFor(t, serviceKey(t), relay, nil, Settings{Clipboard: true}, clip)
+ service.syncClipboard(context.Background())
+ service.mu.Lock()
+ service.clipKnown = "same"
+ service.mu.Unlock()
+ service.syncClipboard(context.Background())
+ if len(relay.sent) != 0 {
+ t.Fatalf("an echoed clipboard was sent back: %v", relay.sent)
+ }
+}
+
+func TestDirectItemFromAnotherAccountStaysForAPerson(t *testing.T) {
+ key, err := LoadOrCreate(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ envelope := testSealed(t, key, testMachine, KindFile, "granted.txt", []byte("from someone else"))
+ direct := &fakeDirect{items: []Incoming{{ID: "lan_0123456789abcdef0123456789abcdef", Sender: otherMachine, SenderName: "Jo's laptop", Envelope: envelope}}}
+ relay := &fakeRelay{} // the sender is not one of this account's own machines
+ service, downloads := serviceFor(t, key, relay, direct, Settings{AutoAccept: true}, nil)
+ service.poll(context.Background())
+ if len(direct.accepted) != 0 || len(direct.completed) != 0 {
+ t.Fatalf("a granted item was auto-accepted: %v", direct.accepted)
+ }
+ if entries, _ := os.ReadDir(downloads); len(entries) != 0 {
+ t.Fatalf("a granted item was saved: %d files", len(entries))
+ }
+}
+
+func TestOwnDirectItemIsAutoAccepted(t *testing.T) {
+ key, err := LoadOrCreate(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ envelope := testSealed(t, key, testMachine, KindFile, "from-phone.txt", []byte("hello"))
+ direct := &fakeDirect{items: []Incoming{{ID: "lan_0123456789abcdef0123456789abcdef", Sender: peerMachine, SenderName: "Phone", Envelope: envelope}}}
+ relay := &fakeRelay{targets: []Machine{{ID: peerMachine, Name: "Phone"}}}
+ service, downloads := serviceFor(t, key, relay, direct, Settings{AutoAccept: true}, nil)
+ service.poll(context.Background())
+ b, err := os.ReadFile(filepath.Join(downloads, "from-phone.txt"))
+ if err != nil || string(b) != "hello" {
+ t.Fatalf("saved = %q, %v", b, err)
+ }
+ if len(direct.completed) != 1 {
+ t.Fatalf("completed = %v", direct.completed)
+ }
+}
+
+func TestServiceWithoutSwitchesDoesNothing(t *testing.T) {
+ relay := &fakeRelay{items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", Bytes: 10}}}
+ service, _ := serviceFor(t, serviceKey(t), relay, nil, Settings{}, nil)
+ service.poll(context.Background())
+ if len(relay.accepted) != 0 || len(relay.completed) != 0 {
+ t.Fatalf("nothing should be touched with both switches off: %v %v", relay.accepted, relay.completed)
+ }
+}
+
+func TestClipboardSettingsStartOff(t *testing.T) {
+ if DefaultSettings() != (Settings{}) {
+ t.Fatalf("default settings = %#v, want both off", DefaultSettings())
+ }
+}
+
+func TestSealedItemKeepsItsKind(t *testing.T) {
+ if got := strings.TrimSpace(KindClipboard); got != "clipboard" {
+ t.Fatalf("kind = %q", got)
+ }
+}
+
+func TestClipboardOnlyOpensASmallItemOnceAndLeavesAFile(t *testing.T) {
+ key, err := LoadOrCreate(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ envelope := testSealed(t, key, testMachine, KindFile, "tiny.txt", []byte("small file"))
+ relay := &fakeRelay{
+ items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Laptop", Bytes: int64(len(envelope))}},
+ envelopes: map[string]string{"snd_0123456789abcdef0123456789abcdef": envelope},
+ }
+ settings := Settings{Clipboard: true}
+ service, downloads := serviceFor(t, key, relay, nil, settings, &fakeClipboard{})
+ service.opt.Settings = func() Settings { return settings }
+ service.poll(context.Background())
+ service.poll(context.Background())
+ if len(relay.accepted) != 1 {
+ t.Fatalf("a small file was accepted %d times, want once", len(relay.accepted))
+ }
+ if len(relay.completed) != 0 {
+ t.Fatalf("auto-accept off completed %v", relay.completed)
+ }
+ if entries, _ := os.ReadDir(downloads); len(entries) != 0 {
+ t.Fatalf("auto-accept off saved %d files", len(entries))
+ }
+ settings.AutoAccept = true
+ service.poll(context.Background())
+ if b, err := os.ReadFile(filepath.Join(downloads, "tiny.txt")); err != nil || string(b) != "small file" {
+ t.Fatalf("after auto-accept was turned on, saved = %q, %v", b, err)
+ }
+}
+
+func TestLargeItemIsNotOpenedWhileOnlyClipboardIsOn(t *testing.T) {
+ relay := &fakeRelay{
+ items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Laptop", Bytes: int64(maxClipboardEnvelope + 1)}},
+ envelopes: map[string]string{"snd_0123456789abcdef0123456789abcdef": "not fetched"},
+ }
+ service, _ := serviceFor(t, serviceKey(t), relay, nil, Settings{Clipboard: true}, &fakeClipboard{})
+ service.poll(context.Background())
+ if len(relay.accepted) != 0 {
+ t.Fatalf("a large item was accepted just to check its kind: %v", relay.accepted)
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+package send
+
+// Settings are the two per-machine choices Link Send needs while it runs. Both start off. The daemon keeps them in
+// panel.json beside the capability switches (internal/agent), so this package only carries the snapshot and never
+// writes a second settings file.
+type Settings struct {
+ // AutoAccept saves items from the person's own machines without asking first.
+ AutoAccept bool
+ // Clipboard syncs copied text with the person's other machines. Text only, and off until turned on.
+ Clipboard bool
+}
+
+func DefaultSettings() Settings { return Settings{} }