openvibers/openvibe.link Commits

Add encrypted Link Send to the local window (#141)

OP OpenVibers committed 5 days ago · 5c4477d8da · 14 files, +679 −33

* Build Link Send in the local window

* Protect the local Link receive key

* Respect Windows receive key permissions

* Use Windows-safe receive key mode checks

* Link Send window: the inbox lists sender and size, Accept fetches the ciphertext

Network's inbox no longer carries the envelope (the window polls it every three
seconds). Accept asks for it once, then the window opens it and completes the item.
Each waiting item names the machine that sent it (or openvibe.link) and its size.

* Link v0.50.0: Send to my machine

Changes

ddiff --git a/README.md b/README.md
iindex 715c7bf..289d72c 100644
---- a/README.md
++++ b/README.md
@@@ -114,8 +114,10 @@ turns on a feature you turned off here.
**Link's own window:** the tray's **Open Link** (or `openvibe-link panel`) opens a small page on the machine itself
at `127.0.0.1` that shows what runs here, what this computer allows, your shares, the coding-agent CLIs and the Codes
tasks this machine ran today, and what happened, and lets you pause everything, stop one thing, turn a capability off,
--share a port, or send files (not in this version). 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.
++share a port, or send a file or a note 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). 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
paired machine, whether it is online, the roles it plays (Codes jobs, desks, your own models, a robot), and pairing a
ddiff --git a/STATUS.json b/STATUS.json
iindex c5d6fcf..553529c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,8 +5,8 @@
"wave": "T15",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"kind": "client",
-- "version": "0.49.0",
-- "release": "v0.49.0: Link can host a shared party desktop in a temporary neko Google Chrome container, with a bridge-bound public-web egress proxy and managed Chrome policy. The party owner can stop it from the Link window.",
++ "version": "0.50.0",
++ "release": "v0.50.0: Send to my machine. Link's window sends a file or a note, encrypted on this computer with openvibe-sdk/locker, to another of your paired machines (or from openvibe.link in a browser); the receiving window lists the sender and size and asks before saving.",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"database": "none",
"deployed": false,
ddiff --git a/docs/panel.md b/docs/panel.md
iindex cac87b5..ef5e286 100644
---- a/docs/panel.md
++++ b/docs/panel.md
@@@ -39,7 +39,13 @@ 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/files` | Sends files to your other devices | `501` with a reason: not in the daemon yet |
++| `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 |
++| `POST /panel/api/files` `{ "target_node": "nod_…", "envelope": "…" }` | Relays an already encrypted item to one of your other machines | Real for Network-paired machines; 20 MB file limit |
++| `POST /panel/api/files/:id/accept` | Gets the pending envelope after you choose Accept | Real; the window opens it locally and downloads it |
++| `POST /panel/api/files/:id/decline` | Removes a pending item | Real |
++| `POST /panel/api/files/:id/complete` | Removes an accepted item after the browser download starts | Real |
`state` omits what the Node cannot fill truthfully: `devices` (the paired account's machine list is not read here),
`gpu` (no GPU detection), and `earnings_today`. `activity` is the Node's own record of what it saw (an approval
ddiff --git a/internal/node/panel.go b/internal/node/panel.go
iindex 5709068..31896e3 100644
---- a/internal/node/panel.go
++++ b/internal/node/panel.go
@@@ -17,9 +17,11 @@ import (
"time"
"github.com/OpenVibers/OpenVibe.Node/internal/firstrun"
++ "github.com/OpenVibers/OpenVibe.Node/internal/link"
"github.com/OpenVibers/OpenVibe.Node/internal/panel"
"github.com/OpenVibers/OpenVibe.Node/internal/plugins"
"github.com/OpenVibers/OpenVibe.Node/internal/protocol"
++ "github.com/OpenVibers/OpenVibe.Node/internal/send"
)
// panelCapabilities are the switches the window shows, in its order. Every one is a switch the daemon already has:
@@@ -69,6 +71,75 @@ type panelFirstRun struct {
Uses []string `json:"uses,omitempty"`
}
++type panelSend struct {
++ key func() any
++ targets func() ([]panel.SendMachine, error)
++ send func(string, string) error
++ inbox func() ([]panel.IncomingSend, error)
++ accept func(string) (string, error)
++ decline func(string) error
++ complete func(string) error
++}
++
++func (n *Node) panelSendOptions(ctx context.Context) panelSend {
++ if n.opt.Creds == nil || !n.opt.Creds.NetworkPaired() {
++ return panelSend{}
++ }
++ key, err := send.LoadOrCreate(n.opt.Paths.ConfigDir)
++ if err != nil {
++ n.log.Warn("Link Send is unavailable: the receive key could not be opened", "err", err)
++ return panelSend{}
++ }
++ client := &send.Client{BaseURL: n.opt.Creds.Network,
++ Tokens: link.NewTokenSourceFor(n.opt.Creds, nil, "openvibe-node/"+n.opt.Version, "openvibe.network"),
++ Agent: "openvibe-node/" + n.opt.Version}
++ go func() {
++ t := time.NewTicker(time.Minute)
++ defer t.Stop()
++ for {
++ if err := client.Register(ctx, key); err == nil {
++ return
++ }
++ select {
++ case <-ctx.Done():
++ return
++ case <-t.C:
++ }
++ }
++ }()
++ return panelSend{
++ key: func() any {
++ return map[string]any{"private_key": key, "subject": map[string]string{"type": "node", "id": n.opt.Creds.Principal}}
++ },
++ targets: func() ([]panel.SendMachine, error) {
++ machines, err := client.Targets(ctx)
++ if err != nil {
++ return nil, err
++ }
++ out := make([]panel.SendMachine, 0, len(machines))
++ for _, m := range machines {
++ out = append(out, panel.SendMachine{ID: m.ID, Name: m.Name, Online: m.Online, PublicKey: m.PublicKey})
++ }
++ return out, nil
++ },
++ send: func(target, envelope string) error { return client.Send(ctx, target, envelope) },
++ inbox: func() ([]panel.IncomingSend, error) {
++ items, err := client.Inbox(ctx)
++ if err != nil {
++ return nil, err
++ }
++ out := make([]panel.IncomingSend, 0, len(items))
++ for _, item := range items {
++ out = append(out, panel.IncomingSend{ID: item.ID, Sender: item.Sender, SenderName: item.SenderName, Bytes: item.Bytes, CreatedAt: item.CreatedAt})
++ }
++ return out, nil
++ },
++ accept: func(id string) (string, error) { return client.Accept(ctx, id) },
++ decline: func(id string) error { return client.Decline(ctx, id) },
++ complete: func(id string) error { return client.Complete(ctx, id) },
++ }
++}
++
type panelSaved struct {
Off []string `json:"off"`
// FirstRun is absent in a file written before the question existed.
@@@ -123,6 +194,7 @@ func (n *Node) startPanel(ctx context.Context) {
n.log.Warn("Link's window off: no token", "err", err)
return
}
++ transfer := n.panelSendOptions(ctx)
srv, err := panel.Listen(panel.Options{
Token: token,
State: n.panelState,
@@@ -133,6 +205,13 @@ func (n *Node) startPanel(ctx context.Context) {
Unshare: n.shareRemove,
FirstRun: n.applyFirstRun,
CodesStop: n.panelStopCodesTask,
++ SendKey: transfer.key,
++ SendTargets: transfer.targets,
++ SendFile: transfer.send,
++ SendInbox: transfer.inbox,
++ SendAccept: transfer.accept,
++ SendDecline: transfer.decline,
++ SendComplete: transfer.complete,
Log: n.log,
})
if err != nil {
ddiff --git a/internal/panel/panel.go b/internal/panel/panel.go
iindex 77223fd..d7ee34c 100644
---- a/internal/panel/panel.go
++++ b/internal/panel/panel.go
@@@ -154,6 +154,22 @@ type FirstRunAnswer struct {
Skip bool `json:"skip"`
}
++// SendMachine and IncomingSend are the Link Send payloads shown in the window. An IncomingSend carries no ciphertext:
++// the window asks for it through accept when the person chooses to receive the item.
++type SendMachine struct {
++ ID string `json:"id"`
++ Name string `json:"name"`
++ Online bool `json:"online"`
++ PublicKey any `json:"public_key"`
++}
++type IncomingSend struct {
++ ID string `json:"id"`
++ Sender string `json:"sender_node,omitempty"`
++ SenderName string `json:"sender_name,omitempty"`
++ Bytes int64 `json:"bytes"`
++ CreatedAt time.Time `json:"created_at"`
++}
++
// Options are what the daemon gives the panel: its state, and the actions the window can ask for.
type Options struct {
// Token is the per-run secret the page sends as X-Link-Panel. Required.
@@@ -174,8 +190,15 @@ type Options struct {
FirstRun func(uses []string, skip bool) error
// CodesStop stops the local Codes task with this id the way Codes' own stop does. It returns an error when the
// task is not running on this computer.
-- CodesStop func(id string) error
-- Log *slog.Logger
++ CodesStop func(id string) error
++ SendKey func() any
++ SendTargets func() ([]SendMachine, error)
++ SendFile func(target, envelope string) error
++ SendInbox func() ([]IncomingSend, error)
++ SendAccept func(id string) (string, error)
++ SendDecline func(id string) error
++ SendComplete func(id string) error
++ Log *slog.Logger
}
// Server is one running panel server.
@@@ -277,7 +300,7 @@ func (s *Server) api() http.Handler {
writeJSON(w, http.StatusForbidden, errBody("forbidden"))
return
}
-- r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
++ r.Body = http.MaxBytesReader(w, r.Body, 40<<20)
path := strings.TrimPrefix(r.URL.Path, "/panel/api/")
switch {
case path == "state" && r.Method == http.MethodGet:
@@@ -286,6 +309,34 @@ func (s *Server) api() http.Handler {
st = s.opt.State()
}
writeJSON(w, http.StatusOK, st)
++ case path == "send/key" && r.Method == http.MethodGet:
++ if s.opt.SendKey == nil {
++ writeJSON(w, http.StatusServiceUnavailable, errBody("receiving is not available on this machine"))
++ return
++ }
++ writeJSON(w, http.StatusOK, map[string]any{"private_key": s.opt.SendKey()})
++ case path == "send/targets" && r.Method == http.MethodGet:
++ if s.opt.SendTargets == nil {
++ writeJSON(w, http.StatusServiceUnavailable, errBody("sending is not available on this machine"))
++ return
++ }
++ items, err := s.opt.SendTargets()
++ if err != nil {
++ writeJSON(w, http.StatusServiceUnavailable, errBody(err.Error()))
++ return
++ }
++ writeJSON(w, http.StatusOK, map[string]any{"machines": items})
++ case path == "send/inbox" && r.Method == http.MethodGet:
++ if s.opt.SendInbox == nil {
++ writeJSON(w, http.StatusServiceUnavailable, errBody("receiving is not available on this machine"))
++ return
++ }
++ items, err := s.opt.SendInbox()
++ if err != nil {
++ writeJSON(w, http.StatusServiceUnavailable, errBody(err.Error()))
++ return
++ }
++ writeJSON(w, http.StatusOK, map[string]any{"items": items})
case path == "pause" && r.Method == http.MethodPost:
var body struct {
Paused *bool `json:"paused"`
@@@ -412,8 +463,57 @@ func (s *Server) api() http.Handler {
}
writeJSON(w, http.StatusOK, map[string]bool{"first_run": false})
case path == "files" && r.Method == http.MethodPost:
-- // Sending files between devices is not in the daemon yet; the window already says it could not send them.
-- writeJSON(w, http.StatusNotImplemented, errBody("sending files between devices is not available in this version of Link"))
++ var body struct {
++ Target string `json:"target_node"`
++ Envelope string `json:"envelope"`
++ }
++ if !decode(w, r, &body) || body.Target == "" || body.Envelope == "" {
++ writeJSON(w, http.StatusBadRequest, errBody("choose a machine and provide an encrypted envelope"))
++ return
++ }
++ if s.opt.SendFile == nil {
++ writeJSON(w, http.StatusServiceUnavailable, errBody("sending is not available on this machine"))
++ return
++ }
++ if err := s.opt.SendFile(body.Target, body.Envelope); err != nil {
++ writeJSON(w, http.StatusConflict, errBody(err.Error()))
++ return
++ }
++ writeJSON(w, http.StatusCreated, map[string]bool{"sent": true})
++ case strings.HasPrefix(path, "files/") && strings.HasSuffix(path, "/accept") && r.Method == http.MethodPost:
++ id := strings.TrimSuffix(strings.TrimPrefix(path, "files/"), "/accept")
++ if s.opt.SendAccept == nil {
++ writeJSON(w, http.StatusServiceUnavailable, errBody("receiving is not available on this machine"))
++ return
++ }
++ envelope, err := s.opt.SendAccept(id)
++ if err != nil {
++ writeJSON(w, http.StatusConflict, errBody(err.Error()))
++ return
++ }
++ writeJSON(w, http.StatusOK, map[string]string{"envelope": envelope})
++ case strings.HasPrefix(path, "files/") && strings.HasSuffix(path, "/decline") && r.Method == http.MethodPost:
++ id := strings.TrimSuffix(strings.TrimPrefix(path, "files/"), "/decline")
++ if s.opt.SendDecline == nil {
++ writeJSON(w, http.StatusServiceUnavailable, errBody("receiving is not available on this machine"))
++ return
++ }
++ if err := s.opt.SendDecline(id); err != nil {
++ writeJSON(w, http.StatusConflict, errBody(err.Error()))
++ return
++ }
++ w.WriteHeader(http.StatusNoContent)
++ case strings.HasPrefix(path, "files/") && strings.HasSuffix(path, "/complete") && r.Method == http.MethodPost:
++ id := strings.TrimSuffix(strings.TrimPrefix(path, "files/"), "/complete")
++ if s.opt.SendComplete == nil {
++ writeJSON(w, http.StatusServiceUnavailable, errBody("receiving is not available on this machine"))
++ return
++ }
++ if err := s.opt.SendComplete(id); err != nil {
++ writeJSON(w, http.StatusConflict, errBody(err.Error()))
++ return
++ }
++ w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
@@@ -433,7 +533,7 @@ func (s *Server) authorized(r *http.Request) bool {
}
func decode(w http.ResponseWriter, r *http.Request, out any) bool {
-- dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
++ dec := json.NewDecoder(io.LimitReader(r.Body, 40<<20))
dec.DisallowUnknownFields()
return dec.Decode(out) == nil
}
ddiff --git a/internal/panel/panel_test.go b/internal/panel/panel_test.go
iindex c7763e3..4cdc18a 100644
---- a/internal/panel/panel_test.go
++++ b/internal/panel/panel_test.go
@@@ -333,17 +333,36 @@ func TestFirstRunWithoutAWayToRecordItSaysSo(t *testing.T) {
}
}
--func TestFilesAnswer501WithAReason(t *testing.T) {
-- srv := testServer(t, Options{})
-- w := do(t, srv, http.MethodPost, "/panel/api/files", testToken, "", "")
-- if w.Code != http.StatusNotImplemented {
-- t.Fatalf("files: %d", w.Code)
++func TestFilesSendEncryptedEnvelope(t *testing.T) {
++ var gotTarget, gotEnvelope string
++ srv := testServer(t, Options{SendFile: func(target, envelope string) error { gotTarget, gotEnvelope = target, envelope; return nil }})
++ w := do(t, srv, http.MethodPost, "/panel/api/files", testToken, "", `{"target_node":"nod_example","envelope":"Y2lwaGVydGV4dA=="}`)
++ if w.Code != http.StatusCreated || gotTarget != "nod_example" || gotEnvelope != "Y2lwaGVydGV4dA==" {
++ t.Fatalf("send: status %d target %q envelope %q body %s", w.Code, gotTarget, gotEnvelope, w.Body)
++ }
++}
++
++func TestIncomingItemsNeedAnExplicitAcceptOrDecline(t *testing.T) {
++ accepted, declined, completed := false, false, false
++ srv := testServer(t, Options{
++ SendInbox: func() ([]IncomingSend, error) {
++ return []IncomingSend{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Desk PC", Bytes: 2048}}, nil
++ },
++ SendAccept: func(id string) (string, error) { accepted = id != ""; return "ciphertext", nil },
++ SendDecline: func(id string) error { declined = id != ""; return nil },
++ SendComplete: func(id string) error { completed = id != ""; return nil },
++ })
++ if w := do(t, srv, http.MethodGet, "/panel/api/send/inbox", testToken, "", ""); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Desk PC") || strings.Contains(w.Body.String(), "envelope") {
++ t.Fatalf("inbox: %d %s", w.Code, w.Body)
++ }
++ if w := do(t, srv, http.MethodPost, "/panel/api/files/snd_0123456789abcdef0123456789abcdef/accept", testToken, "", "{}"); w.Code != http.StatusOK || !accepted || !strings.Contains(w.Body.String(), "ciphertext") {
++ t.Fatalf("accept: %d %s", w.Code, w.Body)
}
-- var body struct {
-- Error string `json:"error"`
++ if w := do(t, srv, http.MethodPost, "/panel/api/files/snd_0123456789abcdef0123456789abcdef/complete", testToken, "", "{}"); w.Code != http.StatusNoContent || !completed {
++ t.Fatalf("complete: %d %s", w.Code, w.Body)
}
-- if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || !strings.Contains(body.Error, "files") {
-- t.Fatalf("reason: %q %v", w.Body, err)
++ if w := do(t, srv, http.MethodPost, "/panel/api/files/snd_0123456789abcdef0123456789abcdef/decline", testToken, "", "{}"); w.Code != http.StatusNoContent || !declined {
++ t.Fatalf("decline: %d %s", w.Code, w.Body)
}
}
ddiff --git a/internal/panel/ui/README.md b/internal/panel/ui/README.md
iindex 22bc6da..0976342 100644
---- a/internal/panel/ui/README.md
++++ b/internal/panel/ui/README.md
@@@ -21,7 +21,13 @@ fragment: `/panel/#t=<token>`. The page sends that token as `X-Link-Panel` on ev
| `POST /panel/api/capabilities/:id` | `{ "on": false }` | Turns a capability on or off. Turning one on is the consent step. |
| `POST /panel/api/shares` | `{ "port": 3000 }` | Shares a local port through Connect, behind the person's sign-in. |
| `DELETE /panel/api/shares/:port` | — | Ends that share. |
--| `POST /panel/api/files` | multipart `file`… | Sends the files to the person's other devices. |
++| `GET /panel/api/send/targets` | — | The person's other paired machines with receive keys. |
++| `GET /panel/api/send/inbox` | — | Pending encrypted items, shown with Accept and Decline. |
++| `GET /panel/api/send/key` | — | This machine's Locker private key, returned only to the token-protected loopback window. |
++| `POST /panel/api/files` | `{ "target_node": "nod_…", "envelope": "base64" }` | Relays Locker ciphertext to one of the person's other machines. |
++| `POST /panel/api/files/:id/accept` | — | Returns ciphertext for local Locker opening after the person chooses Accept. |
++| `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. |
## `GET /panel/api/state`
ddiff --git a/internal/panel/ui/index.html b/internal/panel/ui/index.html
iindex 979385b..4b91dac 100644
---- a/internal/panel/ui/index.html
++++ b/internal/panel/ui/index.html
@@@ -60,9 +60,15 @@
<section class="lk-sec" data-lk-sec="drop" aria-labelledby="lk-drop-h">
<h2 id="lk-drop-h" class="lk-sr">Send files</h2>
-- <label class="lk-drop" data-lk-drop for="lk-files"><span class="lk-drop-ic" aria-hidden="true"></span><b>Send files to your other devices</b><small>Drop them here, or choose</small></label>
-- <input id="lk-files" class="lk-sr" type="file" multiple data-lk-files>
-- <p class="lk-fine" data-lk-drop-say role="status"></p>
++ <label class="lk-fine" for="lk-send-target">Send to</label>
++ <select id="lk-send-target" data-lk-send-target><option value="">Loading your machines…</option></select>
++ <label class="lk-drop" data-lk-drop for="lk-files"><span class="lk-drop-ic" aria-hidden="true"></span><b>Send a file</b><small>Drop it here, or choose</small></label>
++ <input id="lk-files" class="lk-sr" type="file" data-lk-files>
++ <label class="lk-fine" for="lk-send-note">Or send a text note</label>
++ <textarea id="lk-send-note" data-lk-send-note rows="3" maxlength="10000" placeholder="Write a short note"></textarea>
++ <button type="button" data-lk-send-note-button>Send note</button>
++ <p class="lk-fine" data-lk-drop-say role="status" aria-live="polite"></p>
++ <h3>Waiting for you</h3><ul class="lk-send-inbox" data-lk-send-inbox></ul>
</section>
</div>
ddiff --git a/internal/panel/ui/panel.css b/internal/panel/ui/panel.css
iindex 8ee9b1d..6f76bae 100644
---- a/internal/panel/ui/panel.css
++++ b/internal/panel/ui/panel.css
@@@ -113,6 +113,12 @@ a.lk-btn.is-primary { color: var(--on-accent); }
.lk-drop-ic { width: 28px; height: 28px; border-radius: 9px; background: color-mix(in srgb, var(--accent) 18%, transparent); position: relative; }
.lk-drop-ic::after { content: "↑"; position: absolute; inset: 0; display: grid; place-items: center; color: var(--accent-2); font-weight: 900; }
.lk-drop small { color: var(--muted); }
++.lk-sec[data-lk-sec="drop"] select,.lk-sec[data-lk-sec="drop"] textarea { width:100%;box-sizing:border-box;padding:9px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text);font:inherit }
++.lk-sec[data-lk-sec="drop"]>button,.lk-send-item button { padding:8px 11px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text);font:inherit;font-weight:650;cursor:pointer }
++.lk-send-inbox { display:grid;gap:8px;list-style:none;margin:8px 0 0;padding:0 }
++.lk-send-item { display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px;border:1px solid var(--line);border-radius:12px }
++.lk-send-item span { color:var(--muted);font-size:12px }
++.lk-send-item div { display:flex;gap:6px }
.lk-gpu-name { margin: 0 0 8px; font-weight: 700; }
.lk-models { display: grid; gap: 6px; }
ddiff --git a/internal/panel/ui/panel.js b/internal/panel/ui/panel.js
iindex c882afa..7b2d8cd 100644
---- a/internal/panel/ui/panel.js
++++ b/internal/panel/ui/panel.js
@@@ -173,18 +173,87 @@
if (!(port >= 1 && port <= 65535)) return;
var f = this; send('shares', 'POST', { port: port }).then(function () { f.reset(); });
});
-- var drop = $('[data-lk-drop]'), say = $('[data-lk-drop-say]');
-- function sendFiles(files) {
-- if (!files || !files.length) return;
-- var fd = new FormData(); for (var i = 0; i < files.length; i++) fd.append('file', files[i]);
-- say.textContent = 'Sending ' + files.length + ' file' + (files.length === 1 ? '' : 's') + '…';
-- fetch('/panel/api/files', { method: 'POST', body: fd, headers: { 'X-Link-Panel': token }, credentials: 'same-origin' })
-- .then(function (r) { say.textContent = r.ok ? 'Sent to your other devices.' : 'Link could not send them (' + r.status + ').'; }, function () { say.textContent = 'Link is not answering.'; });
++ var drop = $('[data-lk-drop]'), say = $('[data-lk-drop-say]'), targetSelect = $('[data-lk-send-target]');
++ var sendKey = null, lockerModule = null;
++ 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) {
++ if (!r.ok) return r.json().catch(function () { return {}; }).then(function (x) { throw new Error(x.error || 'Link said ' + r.status); });
++ return r.status === 204 ? null : r.json();
++ });
}
++ function base64(bytes) { var s = ''; for (var i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); return btoa(s); }
++ function unbase64(s) { var raw = atob(s), out = new Uint8Array(raw.length); for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out; }
++ function packed(name, mime, data) {
++ var meta = new TextEncoder().encode(JSON.stringify({ name: name, mime: mime || 'application/octet-stream' }));
++ var out = new Uint8Array(4 + meta.length + data.length); new DataView(out.buffer).setUint32(0, meta.length); out.set(meta, 4); out.set(data, 4 + meta.length); return out;
++ }
++ function moduleLocker() { return lockerModule || (lockerModule = import('https://openvibe.link/locker.mjs').then(function (m) { return m.locker; })); }
++ function loadTargets() {
++ panelApi('send/targets').then(function (body) {
++ var old = targetSelect.value; targetSelect.replaceChildren(new Option('Choose one of your machines', ''));
++ (body.machines || []).forEach(function (m) { var opt = new Option(m.name + (m.online ? '' : ' (offline)'), m.id); opt.dataset.publicKey = JSON.stringify(m.public_key); targetSelect.add(opt); });
++ if (old) targetSelect.value = old;
++ if (targetSelect.options.length === 1) say('Open Link on another paired machine to turn on receiving.');
++ }).catch(function (e) { if (targetSelect.options.length === 1) { targetSelect.replaceChildren(new Option('No receiving machine is ready', '')); say(e.message); } });
++ }
++ function sendPayload(name, mime, data) {
++ var opt = targetSelect.options[targetSelect.selectedIndex];
++ if (!opt || !opt.value) { say('Choose one of your machines first.'); return Promise.resolve(); }
++ if (data.length > 20 * 1024 * 1024) { say('Choose a file smaller than 20 MB for now.'); return Promise.resolve(); }
++ say('Encrypting on this computer…');
++ return moduleLocker().then(function (locker) {
++ var publicKey = JSON.parse(opt.dataset.publicKey || '{}');
++ var content = packed(name, mime, data);
++ return locker.seal(content, { lock: 'people', keys: [{ subject: { type: 'node', id: opt.value }, publicKey: publicKey }] }).then(function (sealed) {
++ say('Sending encrypted data…');
++ return panelApi('files', 'POST', { target_node: opt.value, envelope: base64(sealed.bytes) });
++ });
++ }).then(function () { say('Sent. The receiving machine will ask before saving it.'); loadTargets(); })
++ .catch(function (e) { say(e.message || 'Link could not send this item.'); });
++ }
++ function sendFile(file) { return file.arrayBuffer().then(function (b) { return sendPayload(file.name, file.type, new Uint8Array(b)); }); }
++ function sendNote() { var text = $('[data-lk-send-note]').value; if (!text.trim()) { say('Write a note first.'); return; } sendPayload('note.txt', 'text/plain;charset=utf-8', new TextEncoder().encode(text)).then(function () { $('[data-lk-send-note]').value = ''; }); }
++ function downloadAccepted(item) {
++ var path = 'files/' + encodeURIComponent(item.id);
++ return Promise.all([moduleLocker(), panelApi('send/key'), panelApi(path + '/accept', 'POST', {})]).then(function (parts) {
++ var locker = parts[0], own = parts[1], envelope = unbase64(parts[2].envelope || '');
++ if (!envelope.length) throw new Error('The encrypted item is no longer waiting.');
++ return locker.open(envelope, { privateKey: own.private_key.privateKey || own.private_key, subject: own.subject });
++ }).then(function (bytes) {
++ 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);
++ var safeName = String(info.name || 'received-file').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').slice(0, 180) || 'received-file';
++ var url = URL.createObjectURL(new Blob([fileBytes], { type: info.mime || 'application/octet-stream' }));
++ var a = document.createElement('a'); a.href = url; a.download = safeName; a.click(); setTimeout(function () { URL.revokeObjectURL(url); }, 30000);
++ return panelApi('files/' + encodeURIComponent(item.id) + '/complete', 'POST', {}).then(function () { say('Accepted. Your browser saved ' + safeName + ' to Downloads.'); });
++ });
++ }
++ function size(bytes) {
++ var n = Number(bytes) || 0;
++ return n < 1024 ? n + ' B' : n < 1048576 ? Math.round(n / 1024) + ' KB' : (n / 1048576).toFixed(1) + ' MB';
++ }
++ function loadInbox() {
++ panelApi('send/inbox').then(function (body) {
++ var list = $('[data-lk-send-inbox]');
++ 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');
++ accept.type = decline.type = 'button';
++ accept.addEventListener('click', function () { accept.disabled = true; say('Opening the encrypted item…'); downloadAccepted(item).catch(function (e) { accept.disabled = false; say(e.message || 'The item could not be opened. It is still waiting.'); }); });
++ decline.addEventListener('click', function () { decline.disabled = true; panelApi('files/' + encodeURIComponent(item.id) + '/decline', 'POST', {}).then(loadInbox).catch(function (e) { decline.disabled = false; say(e.message); }); });
++ actions.append(accept, decline); li.append(label, actions); return li;
++ }));
++ }).catch(function () {});
++ }
++ var dropTarget = $('[data-lk-drop]');
['dragenter', 'dragover'].forEach(function (t) { drop.addEventListener(t, function (e) { e.preventDefault(); drop.classList.add('is-over'); }); });
['dragleave', 'drop'].forEach(function (t) { drop.addEventListener(t, function () { drop.classList.remove('is-over'); }); });
-- drop.addEventListener('drop', function (e) { e.preventDefault(); sendFiles(e.dataTransfer.files); });
-- $('[data-lk-files]').addEventListener('change', function () { sendFiles(this.files); this.value = ''; });
++ dropTarget.addEventListener('drop', function (e) { e.preventDefault(); if (e.dataTransfer.files[0]) sendFile(e.dataTransfer.files[0]); });
++ $('[data-lk-files]').addEventListener('change', function () { if (this.files[0]) sendFile(this.files[0]); this.value = ''; });
++ $('[data-lk-send-note-button]').addEventListener('click', sendNote);
++ loadTargets(); loadInbox(); setInterval(function () { if (document.visibilityState === 'visible') { loadTargets(); loadInbox(); } }, 3000);
function tick() { if (document.visibilityState === 'visible') load(); }
load();
timer = setInterval(tick, 3000);
ddiff --git a/internal/send/client.go b/internal/send/client.go
nnew file mode 100644
iindex 0000000..a534a98
---- /dev/null
++++ b/internal/send/client.go
@@@ -0,0 +1,148 @@
++package send
++
++import (
++ "bytes"
++ "context"
++ "encoding/json"
++ "fmt"
++ "io"
++ "net/http"
++ "regexp"
++ "strings"
++ "time"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/credentials"
++)
++
++type Tokens interface {
++ Token(context.Context) (credentials.Secret, error)
++ Invalidate()
++}
++
++var itemID = regexp.MustCompile(`^snd_[a-f0-9]{32}$`)
++
++type Client struct {
++ BaseURL string
++ Tokens Tokens
++ HTTP *http.Client
++ Agent string
++}
++
++type Machine struct {
++ ID string `json:"id"`
++ Name string `json:"name"`
++ Online bool `json:"online"`
++ PublicKey JWK `json:"public_key"`
++}
++
++// Item is one waiting transfer as the inbox lists it: who sent it and how big it is. The ciphertext is not in the
++// list (it is polled every few seconds); Accept fetches it once.
++type Item struct {
++ ID string `json:"id"`
++ Sender string `json:"sender_node"`
++ SenderName string `json:"sender_name"`
++ Bytes int64 `json:"bytes"`
++ CreatedAt time.Time `json:"created_at"`
++ ExpiresAt time.Time `json:"expires_at"`
++}
++
++func (c *Client) Register(ctx context.Context, key JWK) error {
++ return c.do(ctx, http.MethodPut, "/api/v1/node/self/send/key", map[string]any{"public_key": Public(key)}, nil)
++}
++func (c *Client) Targets(ctx context.Context) ([]Machine, error) {
++ var out struct {
++ Machines []Machine `json:"machines"`
++ }
++ err := c.do(ctx, http.MethodGet, "/api/v1/node/self/send/targets", nil, &out)
++ return out.Machines, err
++}
++func (c *Client) Send(ctx context.Context, target, envelope string) error {
++ return c.do(ctx, http.MethodPost, "/api/v1/node/self/send", map[string]string{"target_node": target, "envelope": envelope}, nil)
++}
++func (c *Client) Inbox(ctx context.Context) ([]Item, error) {
++ var out struct {
++ Items []Item `json:"items"`
++ }
++ err := c.do(ctx, http.MethodGet, "/api/v1/node/self/send/inbox", nil, &out)
++ return out.Items, err
++}
++func (c *Client) Accept(ctx context.Context, id string) (string, error) {
++ if !itemID.MatchString(id) {
++ return "", fmt.Errorf("this transfer is no longer available")
++ }
++ var out struct {
++ Envelope string `json:"envelope"`
++ }
++ err := c.do(ctx, http.MethodPost, "/api/v1/node/self/send/"+id+"/accept", map[string]string{}, &out)
++ return out.Envelope, err
++}
++func (c *Client) Decline(ctx context.Context, id string) error {
++ if !itemID.MatchString(id) {
++ return fmt.Errorf("this transfer is no longer available")
++ }
++ return c.do(ctx, http.MethodPost, "/api/v1/node/self/send/"+id+"/decline", map[string]string{}, nil)
++}
++func (c *Client) Complete(ctx context.Context, id string) error {
++ if !itemID.MatchString(id) {
++ return fmt.Errorf("this transfer is no longer available")
++ }
++ return c.do(ctx, http.MethodPost, "/api/v1/node/self/send/"+id+"/complete", map[string]string{}, nil)
++}
++
++func (c *Client) do(ctx context.Context, method, path string, body any, out any) error {
++ if c == nil || c.Tokens == nil || c.BaseURL == "" {
++ return fmt.Errorf("this machine is not paired for Link Send")
++ }
++ tok, err := c.Tokens.Token(ctx)
++ if err != nil {
++ return err
++ }
++ var data io.Reader
++ if body != nil {
++ b, e := json.Marshal(body)
++ if e != nil {
++ return e
++ }
++ data = bytes.NewReader(b)
++ }
++ req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.BaseURL, "/")+path, data)
++ if err != nil {
++ return err
++ }
++ req.Header.Set("Authorization", "Bearer "+tok.Reveal())
++ req.Header.Set("Accept", "application/json")
++ if body != nil {
++ req.Header.Set("Content-Type", "application/json")
++ }
++ if c.Agent != "" {
++ req.Header.Set("User-Agent", c.Agent)
++ }
++ client := c.HTTP
++ if client == nil {
++ client = &http.Client{Timeout: 45 * time.Second}
++ }
++ resp, err := client.Do(req)
++ if err != nil {
++ return fmt.Errorf("OpenVibe could not be reached")
++ }
++ defer resp.Body.Close()
++ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
++ b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
++ var problem struct {
++ Detail string `json:"detail"`
++ }
++ _ = json.Unmarshal(b, &problem)
++ if problem.Detail == "" {
++ problem.Detail = "the transfer was refused"
++ }
++ return fmt.Errorf("%s", problem.Detail)
++ }
++ if out == nil {
++ _, err = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
++ return err
++ }
++ if err := json.NewDecoder(io.LimitReader(resp.Body, 40<<20)).Decode(out); err != nil {
++ return fmt.Errorf("OpenVibe returned an unreadable transfer")
++ }
++ return nil
++}
ddiff --git a/internal/send/client_test.go b/internal/send/client_test.go
nnew file mode 100644
iindex 0000000..f1bca63
---- /dev/null
++++ b/internal/send/client_test.go
@@@ -0,0 +1,54 @@
++package send
++
++import (
++ "context"
++ "encoding/json"
++ "net/http"
++ "net/http/httptest"
++ "testing"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/credentials"
++)
++
++type staticToken string
++
++func (s staticToken) Token(context.Context) (credentials.Secret, error) {
++ return credentials.NewSecret(string(s)), nil
++}
++func (staticToken) Invalidate() {}
++
++func TestClientUsesNodeTokenAndOnlyPublishesPublicReceiveKey(t *testing.T) {
++ var calls int
++ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
++ calls++
++ if r.Header.Get("Authorization") != "Bearer node-token" {
++ t.Errorf("authorization = %q", r.Header.Get("Authorization"))
++ }
++ if r.URL.Path != "/api/v1/node/self/send/key" || r.Method != http.MethodPut {
++ t.Errorf("request = %s %s", r.Method, r.URL.Path)
++ }
++ var body struct {
++ PublicKey JWK `json:"public_key"`
++ }
++ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
++ t.Fatal(err)
++ }
++ if body.PublicKey.D != "" || body.PublicKey.X == "" {
++ t.Errorf("registered key includes private data or is empty: %#v", body.PublicKey)
++ }
++ w.Header().Set("Content-Type", "application/json")
++ _, _ = w.Write([]byte(`{"node_id":"nod_test","key_thumbprint":"thumb"}`))
++ }))
++ defer server.Close()
++ key, err := LoadOrCreate(t.TempDir())
++ if err != nil {
++ t.Fatal(err)
++ }
++ c := &Client{BaseURL: server.URL, Tokens: staticToken("node-token"), HTTP: server.Client()}
++ if err := c.Register(context.Background(), key); err != nil {
++ t.Fatal(err)
++ }
++ if calls != 1 {
++ t.Fatalf("made %d calls", calls)
++ }
++}
ddiff --git a/internal/send/keys.go b/internal/send/keys.go
nnew file mode 100644
iindex 0000000..361a822
---- /dev/null
++++ b/internal/send/keys.go
@@@ -0,0 +1,91 @@
++package send
++
++import (
++ "crypto/ecdsa"
++ "crypto/elliptic"
++ "crypto/rand"
++ "encoding/base64"
++ "encoding/json"
++ "errors"
++ "math/big"
++ "os"
++ "path/filepath"
++ "runtime"
++)
++
++// JWK is the P-256 receive key shape used by openvibe-sdk/locker. The private fields stay in the Node's 0600 config
++// file and are returned only to its token-protected loopback panel so the user can open an accepted envelope.
++type JWK struct {
++ Kty string `json:"kty"`
++ Crv string `json:"crv"`
++ X string `json:"x"`
++ Y string `json:"y"`
++ D string `json:"d,omitempty"`
++}
++
++func LoadOrCreate(dir string) (JWK, error) {
++ path := filepath.Join(dir, "link-send-key.json")
++ if b, err := os.ReadFile(path); err == nil {
++ info, statErr := os.Lstat(path)
++ if statErr != nil || !info.Mode().IsRegular() || (runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0) {
++ return JWK{}, errors.New("the saved Link receive key must be a regular file accessible only to this account")
++ }
++ var key JWK
++ if json.Unmarshal(b, &key) != nil || !valid(key) {
++ return JWK{}, errors.New("the saved Link receive key is invalid")
++ }
++ return key, nil
++ } else if !errors.Is(err, os.ErrNotExist) {
++ return JWK{}, err
++ }
++ if err := os.MkdirAll(dir, 0o700); err != nil {
++ return JWK{}, err
++ }
++ private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
++ if err != nil {
++ return JWK{}, err
++ }
++ key := JWK{Kty: "EC", Crv: "P-256", X: b64(private.X), Y: b64(private.Y), D: b64(private.D)}
++ b, err := json.Marshal(key)
++ if err != nil {
++ return JWK{}, err
++ }
++ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
++ if err != nil {
++ if errors.Is(err, os.ErrExist) {
++ return LoadOrCreate(dir)
++ }
++ return JWK{}, err
++ }
++ if _, err = f.Write(append(b, '\n')); err != nil {
++ _ = f.Close()
++ return JWK{}, err
++ }
++ if err = f.Close(); err != nil {
++ return JWK{}, err
++ }
++ return key, nil
++}
++
++func Public(key JWK) JWK { key.D = ""; return key }
++
++func valid(key JWK) bool {
++ if key.Kty != "EC" || key.Crv != "P-256" || key.D == "" {
++ return false
++ }
++ x, errX := unb64(key.X)
++ y, errY := unb64(key.Y)
++ d, errD := unb64(key.D)
++ curve := elliptic.P256()
++ if errX != nil || errY != nil || errD != nil || len(x) != 32 || len(y) != 32 || len(d) != 32 {
++ return false
++ }
++ return curve.IsOnCurve(new(big.Int).SetBytes(x), new(big.Int).SetBytes(y))
++}
++
++func b64(n *big.Int) string {
++ raw := make([]byte, 32)
++ n.FillBytes(raw)
++ return base64.RawURLEncoding.EncodeToString(raw)
++}
++func unb64(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }
ddiff --git a/internal/send/keys_test.go b/internal/send/keys_test.go
nnew file mode 100644
iindex 0000000..f2be701
---- /dev/null
++++ b/internal/send/keys_test.go
@@@ -0,0 +1,60 @@
++package send
++
++import (
++ "os"
++ "path/filepath"
++ "runtime"
++ "testing"
++)
++
++func TestReceiveKeyPersistsPrivatelyAndPublishesOnlyPublicFields(t *testing.T) {
++ dir := t.TempDir()
++ key, err := LoadOrCreate(dir)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if key.Kty != "EC" || key.Crv != "P-256" || key.D == "" {
++ t.Fatalf("bad private key: %#v", key)
++ }
++ info, err := os.Stat(filepath.Join(dir, "link-send-key.json"))
++ if err != nil {
++ t.Fatal(err)
++ }
++ if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
++ t.Fatalf("key mode = %o, want 600", info.Mode().Perm())
++ }
++ public := Public(key)
++ if public.D != "" || public.X != key.X || public.Y != key.Y {
++ t.Fatalf("bad public projection: %#v", public)
++ }
++ loaded, err := LoadOrCreate(dir)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if loaded != key {
++ t.Fatal("receive key changed across restart")
++ }
++}
++
++func TestReceiveKeyRejectsMalformedSavedKey(t *testing.T) {
++ dir := t.TempDir()
++ if err := os.WriteFile(filepath.Join(dir, "link-send-key.json"), []byte(`{"kty":"RSA"}`), 0o600); err != nil {
++ t.Fatal(err)
++ }
++ if _, err := LoadOrCreate(dir); err == nil {
++ t.Fatal("accepted a malformed saved key")
++ }
++}
++
++func TestReceiveKeyRejectsBroadPermissions(t *testing.T) {
++ if runtime.GOOS == "windows" {
++ t.Skip("Windows file modes do not expose POSIX group and other permissions")
++ }
++ dir := t.TempDir()
++ if err := os.WriteFile(filepath.Join(dir, "link-send-key.json"), []byte(`{"kty":"EC","crv":"P-256","x":"x","y":"y","d":"d"}`), 0o644); err != nil {
++ t.Fatal(err)
++ }
++ if _, err := LoadOrCreate(dir); err == nil {
++ t.Fatal("accepted a receive key file readable by other accounts")
++ }
++}