mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
v0.9.0 : poste distant accessible via ajean.link, chiffré de bout en bout (relais aveugle)
This commit is contained in:
1 parent
6d81f5c3f5
commit
1454dff2a7
16 files changed
+640
-199
No files matched your search
+8
-14
@@ -1,21 +1,15 @@
|
||||
L'IA peut maintenant agir sur un autre PC que le serveur : un petit client à installer, et tu choisis la machine sur laquelle elle travaille.
|
||||
Piloter un autre PC fonctionne maintenant à distance, à travers ajean.link, sans que le relais ne voie jamais rien.
|
||||
|
||||
## Piloter un autre PC depuis l'IA
|
||||
## Le poste distant passe par ajean.link, chiffré de bout en bout
|
||||
|
||||
Parfois la puissance est sur le serveur, mais ce que tu veux faire est ailleurs — sur ton portable, un poste de travail, une autre machine. AJEAN sait maintenant s'y étendre.
|
||||
La 0.8.9 a introduit le poste distant, mais uniquement sur le réseau local. Il fonctionne désormais **depuis n'importe où**, via ajean.link — et avec la même exigence que le reste d'ajean.link : le **relais reste aveugle**.
|
||||
|
||||
Sur le PC à piloter, tu installes **ajean-remote** : un client minuscule et séparé (quelques mégaoctets, aucune interface ni moteur), qui ne fait qu'une chose — ouvrir une connexion **sortante** vers ton serveur et exécuter ce que l'IA lui demande. Aucun port à ouvrir, rien à configurer dans la box.
|
||||
Concrètement, l'identité d'un poste n'est plus une clé partagée mais une **paire de clés** qui lui est propre. À l'appairage, le poste scelle sa demande vers la clé publique de ton serveur : le relais ne voit ni le code, ni rien d'autre. Ensuite, tout ce qui circule entre le poste et ton serveur — les commandes de l'IA comme leurs résultats — est **chiffré de bout en bout** avec une clé que seuls ton serveur et ton poste peuvent calculer. Le relais ne transporte que de l'opaque.
|
||||
|
||||
Dans l'interface, une section **Postes distants** : tu génères un code d'appairage, tu choisis les capacités autorisées (shell, lecture, écriture, listing) et un dossier de travail. Sur l'autre PC :
|
||||
## Rien à changer pour toi
|
||||
|
||||
ajean-remote install https://ton-serveur --code TONCODE --allow shell,read,write,list
|
||||
Dans **Réglages → Postes distants**, générer un code affiche maintenant deux commandes : une pour l'**accès à distance** (via ajean.link) et une pour le **réseau local** (connexion directe). Tu choisis, tu copies, tu colles sur l'autre PC.
|
||||
|
||||
Le client se connecte, s'installe en service (invisible, au démarrage), et se reconnecte tout seul.
|
||||
## Un client, toujours aussi léger
|
||||
|
||||
## Tu choisis la machine cible
|
||||
|
||||
Pas de nouveaux outils à apprendre pour l'IA : elle garde son shell, son écriture et son édition de fichiers. Un sélecteur **« L'IA agit sur : Ce serveur / <ton poste> »** décide simplement *où* ces outils s'exécutent. Tu bascules d'un clic. Et si le poste choisi est hors ligne, l'action échoue proprement plutôt que de retomber par erreur sur le serveur.
|
||||
|
||||
## Pensé pour ne pas se retourner contre toi
|
||||
|
||||
Le poste se connecte en sortant, sa clé d'appareil est stockée hachée côté serveur, et c'est **toi** qui décides, machine par machine, ce que l'IA a le droit de faire et dans quel dossier. Lecture et écriture sont confinées à ce dossier ; les capacités effectives sont l'intersection de ce que tu autorises et de ce que le poste accepte localement.
|
||||
Le petit client s'appelle **ajean-remote** (quelques mégaoctets, aucune interface). Il s'installe en service — invisible, au démarrage, reconnexion automatique — et se télécharge depuis ajean.link, section « Piloter un autre PC ».
|
||||
+35
-14
@@ -77,24 +77,34 @@ Options :
|
||||
// doConnect appaire au besoin, sauvegarde, puis installe (service) ou lance
|
||||
// (avant-plan) selon install.
|
||||
func doConnect(args []string, install bool) error {
|
||||
url, code, allow, root, yes := parseFlags(args)
|
||||
f := parseFlags(args)
|
||||
cfg, lerr := nodeclient.LoadConfig()
|
||||
needEnroll := lerr != nil || cfg.Key == ""
|
||||
if url != "" {
|
||||
cfg.ServerURL = url
|
||||
needEnroll := lerr != nil || cfg.Priv == ""
|
||||
if f.url != "" {
|
||||
cfg.ServerURL = f.url
|
||||
}
|
||||
if f.key != "" {
|
||||
cfg.AgentPub = f.key
|
||||
}
|
||||
if f.machine != "" {
|
||||
cfg.MachineID = f.machine
|
||||
}
|
||||
if needEnroll {
|
||||
if cfg.ServerURL == "" {
|
||||
return fmt.Errorf("url du serveur requise (ex: http://192.168.1.127:8090)")
|
||||
}
|
||||
if code == "" {
|
||||
if f.code == "" {
|
||||
return fmt.Errorf("code d'appairage requis : --code CODE")
|
||||
}
|
||||
if err := nodeclient.Enroll(&cfg, code); err != nil {
|
||||
if cfg.AgentPub == "" {
|
||||
return fmt.Errorf("clé de l'agent requise : --key <clé> (fournie par la commande d'appairage)")
|
||||
}
|
||||
if err := nodeclient.Enroll(&cfg, f.code); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("[ok] appairé (id %s)\n", cfg.ID)
|
||||
}
|
||||
allow, root, yes := f.allow, f.root, f.yes
|
||||
if len(allow) > 0 {
|
||||
cfg.Caps = nodeclient.SanitizeCaps(allow)
|
||||
}
|
||||
@@ -150,7 +160,14 @@ func doStatus() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseFlags(args []string) (url, code string, allow []string, root string, yes bool) {
|
||||
type flags struct {
|
||||
url, code, root, key, machine string
|
||||
allow []string
|
||||
yes bool
|
||||
}
|
||||
|
||||
func parseFlags(args []string) flags {
|
||||
var f flags
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
next := func() string {
|
||||
@@ -162,20 +179,24 @@ func parseFlags(args []string) (url, code string, allow []string, root string, y
|
||||
}
|
||||
switch {
|
||||
case a == "--code":
|
||||
code = next()
|
||||
f.code = next()
|
||||
case a == "--key":
|
||||
f.key = next()
|
||||
case a == "--machine":
|
||||
f.machine = next()
|
||||
case a == "--allow":
|
||||
for _, p := range strings.Split(next(), ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
allow = append(allow, p)
|
||||
f.allow = append(f.allow, p)
|
||||
}
|
||||
}
|
||||
case a == "--root":
|
||||
root = next()
|
||||
f.root = next()
|
||||
case a == "--yes" || a == "-y":
|
||||
yes = true
|
||||
case !strings.HasPrefix(a, "-") && url == "":
|
||||
url = a
|
||||
f.yes = true
|
||||
case !strings.HasPrefix(a, "-") && f.url == "":
|
||||
f.url = a
|
||||
}
|
||||
}
|
||||
return
|
||||
return f
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -2,14 +2,14 @@
|
||||
"FixedFileInfo": {
|
||||
"FileVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 8,
|
||||
"Patch": 9,
|
||||
"Minor": 9,
|
||||
"Patch": 0,
|
||||
"Build": 0
|
||||
},
|
||||
"ProductVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 8,
|
||||
"Patch": 9,
|
||||
"Minor": 9,
|
||||
"Patch": 0,
|
||||
"Build": 0
|
||||
},
|
||||
"FileFlagsMask": "3f",
|
||||
@@ -25,7 +25,7 @@
|
||||
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
|
||||
"OriginalFilename": "ajean.exe",
|
||||
"ProductName": "AJEAN",
|
||||
"ProductVersion": "0.8.9",
|
||||
"ProductVersion": "0.9.0",
|
||||
"Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
@@ -34,4 +34,4 @@
|
||||
"CharsetID": "04B0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
-24
@@ -9,6 +9,7 @@
|
||||
package ajean
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -42,25 +43,48 @@ func handleNodePair(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"code": p.Code,
|
||||
"caps": p.Caps,
|
||||
"root": p.Root,
|
||||
"expires": p.Expires,
|
||||
"ttl_min": int(nodePairCodeTTL.Minutes()),
|
||||
"ok": true,
|
||||
"code": p.Code,
|
||||
"caps": p.Caps,
|
||||
"root": p.Root,
|
||||
"expires": p.Expires,
|
||||
"ttl_min": int(nodePairCodeTTL.Minutes()),
|
||||
"machine": machineID(), // pour l'accès via ajean.link (/node/<machine>/)
|
||||
"agent_pub": e2ePubHex(), // clé publique de l'agent (le poste scelle vers elle)
|
||||
"fingerprint": e2eFingerprint(), // empreinte, ancre de confiance
|
||||
})
|
||||
}
|
||||
|
||||
// handleNodeEnroll (PUBLIC) échange un code d'appairage contre une clé
|
||||
// d'appareil. Le code est à usage unique : consommé dès qu'il sert.
|
||||
// handleNodeEnroll (PUBLIC) enrôle un poste. Le corps est un SCEAU anonyme vers
|
||||
// la clé publique de l'agent contenant {pub, code, name, os} : le relais ne peut
|
||||
// ni l'ouvrir, ni voir le code, ni la clé publique du poste. Si le code matche,
|
||||
// la clé publique est enregistrée comme identité autorisée du poste.
|
||||
func handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Sealed string `json:"sealed"` // base64(ephPub||nonce||ct) vers la clé agent
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Sealed == "" {
|
||||
sendJSON(w, 400, map[string]any{"error": "requête invalide"})
|
||||
return
|
||||
}
|
||||
blob, err := base64.StdEncoding.DecodeString(req.Sealed)
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"error": "format du sceau"})
|
||||
return
|
||||
}
|
||||
plain, err := e2eOpenSeal(blob)
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"error": "sceau invalide"})
|
||||
return
|
||||
}
|
||||
var pm struct {
|
||||
Pub string `json:"pub"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
OS string `json:"os"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"error": "requête invalide"})
|
||||
if err := json.Unmarshal(plain, &pm); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"error": "contenu du sceau"})
|
||||
return
|
||||
}
|
||||
pending, ok := loadPairPending()
|
||||
@@ -68,23 +92,26 @@ func handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 403, map[string]any{"error": "aucun code d'appairage actif — générez-en un dans l'interface"})
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(req.Code), pending.Code) {
|
||||
if !strings.EqualFold(strings.TrimSpace(pm.Code), pending.Code) {
|
||||
sendJSON(w, 403, map[string]any{"error": "code incorrect"})
|
||||
return
|
||||
}
|
||||
// Consomme le code immédiatement (usage unique), même si la suite échoue.
|
||||
clearPairPending()
|
||||
clearPairPending() // usage unique
|
||||
|
||||
key := nodeRandHex(24)
|
||||
name := strings.TrimSpace(req.Name)
|
||||
pub := strings.ToLower(strings.TrimSpace(pm.Pub))
|
||||
if len(pub) != 64 {
|
||||
sendJSON(w, 400, map[string]any{"error": "clé publique du poste invalide"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(pm.Name)
|
||||
if name == "" {
|
||||
name = "poste"
|
||||
}
|
||||
node := pairedNode{
|
||||
ID: nodeRandHex(8),
|
||||
Name: name,
|
||||
OS: strings.TrimSpace(req.OS),
|
||||
KeyHash: nodeHashKey(key),
|
||||
OS: strings.TrimSpace(pm.OS),
|
||||
PubHex: pub,
|
||||
Caps: pending.Caps,
|
||||
Root: pending.Root,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
@@ -94,14 +121,15 @@ func handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// La clé n'est renvoyée QU'ICI, une seule fois : le poste la stocke en local.
|
||||
// Réponse en clair : elle ne contient AUCUN secret (le secret, la clé privée,
|
||||
// n'a jamais quitté le poste). machine_id sert au poste pour l'URL relais.
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"id": node.ID,
|
||||
"key": key,
|
||||
"caps": node.Caps,
|
||||
"root": node.Root,
|
||||
"name": node.Name,
|
||||
"ok": true,
|
||||
"id": node.ID,
|
||||
"machine_id": machineID(),
|
||||
"caps": node.Caps,
|
||||
"root": node.Root,
|
||||
"name": node.Name,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+106
-55
@@ -7,9 +7,8 @@ package ajean
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
@@ -19,6 +18,7 @@ import (
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
"github.com/nathaninline/ajean/internal/nodewire"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -28,13 +28,14 @@ const (
|
||||
)
|
||||
|
||||
// pairedNode est un poste appairé, tel que persisté dans bkState["nodes"].
|
||||
// On ne garde JAMAIS la clé en clair : seulement son SHA-256. Le poste, lui,
|
||||
// conserve la clé en local (voir node_client.go).
|
||||
// L'identité du poste = sa CLÉ PUBLIQUE X25519 (PubHex). Il n'y a plus de « clé
|
||||
// d'appareil » partagée : le poste prouve son identité en sachant chiffrer le
|
||||
// canal (ECDH avec sa clé privée), ce que le relais ne peut pas reproduire.
|
||||
type pairedNode struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OS string `json:"os"`
|
||||
KeyHash string `json:"key_hash"`
|
||||
PubHex string `json:"pub"` // clé publique X25519 du poste (son identité)
|
||||
Caps []string `json:"caps"` // capacités AUTORISÉES par le propriétaire
|
||||
Root string `json:"root,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
@@ -49,25 +50,30 @@ func loadNodes() []pairedNode {
|
||||
|
||||
func saveNodes(l []pairedNode) error { return putJSON(bkState, "nodes", l) }
|
||||
|
||||
// findNodeByKey retrouve un poste appairé à partir de la clé d'appareil présentée
|
||||
// (comparaison à temps constant sur le hash). Renvoie nil si aucune ne matche.
|
||||
func findNodeByKey(key string) *pairedNode {
|
||||
if key == "" {
|
||||
// findNodeByPub retrouve un poste appairé à partir de sa clé publique.
|
||||
func findNodeByPub(pub string) *pairedNode {
|
||||
pub = strings.ToLower(strings.TrimSpace(pub))
|
||||
if pub == "" {
|
||||
return nil
|
||||
}
|
||||
want := nodeHashKey(key)
|
||||
nodes := loadNodes()
|
||||
for i := range nodes {
|
||||
if subtle.ConstantTimeCompare([]byte(nodes[i].KeyHash), []byte(want)) == 1 {
|
||||
if strings.ToLower(nodes[i].PubHex) == pub {
|
||||
return &nodes[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nodeHashKey(key string) string {
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(sum[:])
|
||||
// agentPrivHex renvoie la clé privée X25519 de l'agent en hex (pour dériver la
|
||||
// clé de canal poste↔agent via nodewire). Même clé que le canal navigateur, mais
|
||||
// domaine séparé par la constante de dérivation.
|
||||
func agentPrivHex() (string, error) {
|
||||
k, err := e2ePrivateKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(k.Bytes()), nil
|
||||
}
|
||||
|
||||
// nodeRandHex renvoie n octets aléatoires en hexadécimal (2n caractères).
|
||||
@@ -112,18 +118,42 @@ type nodeConn struct {
|
||||
|
||||
conn *websocket.Conn
|
||||
ctx context.Context
|
||||
ch *nodewire.Chan // canal chiffré poste↔agent (relais aveugle)
|
||||
writeMu sync.Mutex
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[string]chan string
|
||||
}
|
||||
|
||||
// send chiffre le message et l'envoie en un frame opaque : le relais ne voit
|
||||
// que du ciphertext.
|
||||
func (nc *nodeConn) send(m nodeMsg) error {
|
||||
nc.writeMu.Lock()
|
||||
defer nc.writeMu.Unlock()
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(nc.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
return wsjson.Write(ctx, nc.conn, m)
|
||||
return wsjson.Write(ctx, nc.conn, nc.ch.Seal(raw))
|
||||
}
|
||||
|
||||
// readMsg lit un frame chiffré et le déchiffre en nodeMsg.
|
||||
func (nc *nodeConn) readMsg(ctx context.Context) (nodeMsg, error) {
|
||||
var fr nodewire.Frame
|
||||
if err := wsjson.Read(ctx, nc.conn, &fr); err != nil {
|
||||
return nodeMsg{}, err
|
||||
}
|
||||
plain, err := nc.ch.Open(fr)
|
||||
if err != nil {
|
||||
return nodeMsg{}, err
|
||||
}
|
||||
var m nodeMsg
|
||||
if err := json.Unmarshal(plain, &m); err != nil {
|
||||
return nodeMsg{}, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -169,16 +199,12 @@ func nodeConnected() []*nodeConn {
|
||||
|
||||
// ─── Endpoint WebSocket : le poste se connecte ici ─────────────────────────
|
||||
|
||||
// handleNodeWS accueille la connexion sortante d'un poste. Route PUBLIQUE (pas
|
||||
// derrière la clé de pilotage) : l'authentification se fait par la clé d'appareil
|
||||
// remise à l'appairage, présentée en Bearer.
|
||||
// handleNodeWS accueille la connexion sortante d'un poste. Route PUBLIQUE : elle
|
||||
// peut arriver en direct (LAN) OU tunnelée par le relais ajean.link. Dans les
|
||||
// deux cas le canal est chiffré de BOUT EN BOUT (poste↔agent) : le relais ne voit
|
||||
// que de l'opaque. L'authentification = la capacité du poste à chiffrer avec la
|
||||
// clé de canal, que seul le détenteur de la clé privée appairée peut calculer.
|
||||
func handleNodeWS(w http.ResponseWriter, r *http.Request) {
|
||||
key := bearerToken(r)
|
||||
pn := findNodeByKey(key)
|
||||
if pn == nil {
|
||||
http.Error(w, "poste non appairé", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
|
||||
if err != nil {
|
||||
return
|
||||
@@ -186,43 +212,77 @@ func handleNodeWS(w http.ResponseWriter, r *http.Request) {
|
||||
c.SetReadLimit(-1)
|
||||
ctx := r.Context()
|
||||
|
||||
// 1er message : le hello où le poste déclare son nom/os et ce qu'il sait faire.
|
||||
// Poignée de main : 1er frame EN CLAIR = la clé publique du poste (elle est
|
||||
// publique, aucun secret). Elle nous dit QUI se connecte et permet de dériver
|
||||
// la clé de canal. Le poste ne pourra chiffrer/déchiffrer que s'il détient la
|
||||
// clé privée correspondante — c'est ça, l'authentification.
|
||||
helloCtx, cancel := context.WithTimeout(ctx, nodeHelloWait)
|
||||
var hello nodeMsg
|
||||
err = wsjson.Read(helloCtx, c, &hello)
|
||||
var hp struct {
|
||||
Type string `json:"type"`
|
||||
Pub string `json:"pub"`
|
||||
}
|
||||
err = wsjson.Read(helloCtx, c, &hp)
|
||||
cancel()
|
||||
if err != nil || hello.Type != "hello" {
|
||||
_ = c.Close(websocket.StatusProtocolError, "hello attendu")
|
||||
if err != nil || hp.Type != "hello_pub" || hp.Pub == "" {
|
||||
_ = c.Close(websocket.StatusProtocolError, "hello_pub attendu")
|
||||
return
|
||||
}
|
||||
pn := findNodeByPub(hp.Pub)
|
||||
if pn == nil {
|
||||
_ = c.Close(websocket.StatusPolicyViolation, "poste non appairé")
|
||||
return
|
||||
}
|
||||
privHex, err := agentPrivHex()
|
||||
if err != nil {
|
||||
_ = c.Close(websocket.StatusInternalError, "clé agent indisponible")
|
||||
return
|
||||
}
|
||||
key, err := nodewire.ChannelKey(privHex, hp.Pub)
|
||||
if err != nil {
|
||||
_ = c.Close(websocket.StatusInternalError, "canal")
|
||||
return
|
||||
}
|
||||
ch, err := nodewire.NewChan(key, false) // agent = côté d'envoi 2
|
||||
if err != nil {
|
||||
_ = c.Close(websocket.StatusInternalError, "canal")
|
||||
return
|
||||
}
|
||||
|
||||
nc := &nodeConn{
|
||||
id: pn.ID,
|
||||
conn: c,
|
||||
ctx: ctx,
|
||||
ch: ch,
|
||||
pending: map[string]chan string{},
|
||||
root: pn.Root,
|
||||
}
|
||||
|
||||
// 1er frame CHIFFRÉ : le hello (nom/os/capacités déclarées). S'il ne déchiffre
|
||||
// pas, le poste n'a pas la bonne clé privée → connexion refusée.
|
||||
helloCtx2, cancel2 := context.WithTimeout(ctx, nodeHelloWait)
|
||||
hello, err := nc.readMsg(helloCtx2)
|
||||
cancel2()
|
||||
if err != nil || hello.Type != "hello" {
|
||||
_ = c.Close(websocket.StatusPolicyViolation, "hello chiffré attendu")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(hello.Name)
|
||||
if name == "" {
|
||||
name = pn.Name
|
||||
}
|
||||
caps := nodeCapIntersect(pn.Caps, hello.Caps)
|
||||
nc.name = name
|
||||
nc.slug = nodeSlug(name)
|
||||
nc.os = strings.TrimSpace(hello.OS)
|
||||
nc.caps = nodeCapIntersect(pn.Caps, hello.Caps)
|
||||
|
||||
nc := &nodeConn{
|
||||
id: pn.ID,
|
||||
slug: nodeSlug(name),
|
||||
name: name,
|
||||
os: strings.TrimSpace(hello.OS),
|
||||
caps: caps,
|
||||
root: pn.Root,
|
||||
conn: c,
|
||||
ctx: ctx,
|
||||
pending: map[string]chan string{},
|
||||
}
|
||||
nodeRegister(nc)
|
||||
defer nodeUnregister(nc)
|
||||
touchNodeSeen(pn.ID)
|
||||
|
||||
// Boucle de lecture : les seules entrées attendues sont des « result » qui
|
||||
// débloquent l'appel en attente correspondant. Le ping/keepalive est géré par
|
||||
// coder/websocket.
|
||||
// Boucle de lecture : des « result » chiffrés qui débloquent l'appel en attente.
|
||||
for {
|
||||
var m nodeMsg
|
||||
if err := wsjson.Read(ctx, c, &m); err != nil {
|
||||
m, err := nc.readMsg(ctx)
|
||||
if err != nil {
|
||||
_ = c.CloseNow()
|
||||
return
|
||||
}
|
||||
@@ -249,15 +309,6 @@ func touchNodeSeen(id string) {
|
||||
}
|
||||
}
|
||||
|
||||
// bearerToken extrait le jeton d'un en-tête « Authorization: Bearer … ».
|
||||
func bearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if s, ok := strings.CutPrefix(h, "Bearer "); ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ─── Appel d'un outil de poste (routage serveur→poste→serveur) ─────────────
|
||||
|
||||
// nodeCall envoie une demande d'exécution au poste et attend son résultat. Toute
|
||||
|
||||
@@ -2,6 +2,7 @@ package ajean
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -11,15 +12,22 @@ import (
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
"github.com/nathaninline/ajean/internal/nodewire"
|
||||
)
|
||||
|
||||
// TestNodeEndToEnd exerce tout le chemin serveur : enrôlement via code, connexion
|
||||
// WebSocket, négociation des capacités (intersection propriétaire ∩ poste), et
|
||||
// routage d'un appel d'outil jusqu'au poste et retour.
|
||||
func TestNodeEndToEnd(t *testing.T) {
|
||||
// TestNodeE2E exerce tout le chemin : enrôlement SCELLÉ (le sceau du client est
|
||||
// ouvert par e2eOpenSeal de l'agent → interop crypto validée), puis canal
|
||||
// CHIFFRÉ poste↔agent (hello + appel d'outil + résultat), et refus d'une
|
||||
// capacité non autorisée.
|
||||
func TestNodeE2E(t *testing.T) {
|
||||
testHome(t)
|
||||
|
||||
// Le propriétaire n'autorise que read + shell (pas write).
|
||||
agentPub := e2ePubHex()
|
||||
if agentPub == "" {
|
||||
t.Fatal("clé publique agent indisponible")
|
||||
}
|
||||
|
||||
// Le propriétaire autorise read + shell (pas write).
|
||||
if err := savePairPending(nodePairPending{
|
||||
Code: "ABCD1234",
|
||||
Caps: []string{nodeCapRead, nodeCapShell},
|
||||
@@ -34,104 +42,97 @@ func TestNodeEndToEnd(t *testing.T) {
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
// 1) Enrôlement : le code est échangé contre une clé d'appareil.
|
||||
body, _ := json.Marshal(map[string]string{"code": "abcd1234", "name": "testpc", "os": "linux/amd64"})
|
||||
// 1) Le poste génère sa paire et SCELLE {pub, code, name, os} vers l'agent.
|
||||
priv, pub, err := nodewire.GenKeyPair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inner, _ := json.Marshal(map[string]string{"pub": pub, "code": "abcd1234", "name": "testpc", "os": "linux/amd64"})
|
||||
blob, err := nodewire.SealTo(agentPub, inner)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"sealed": base64.StdEncoding.EncodeToString(blob)})
|
||||
resp, err := http.Post(srv.URL+"/api/node/enroll", "application/json", strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var enr struct {
|
||||
OK bool `json:"ok"`
|
||||
Key string `json:"key"`
|
||||
Caps []string `json:"caps"`
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&enr)
|
||||
resp.Body.Close()
|
||||
if !enr.OK || enr.Key == "" {
|
||||
if !enr.OK {
|
||||
t.Fatalf("enrôlement échoué: %+v", enr)
|
||||
}
|
||||
// Le code est à usage unique : un second enrôlement doit échouer.
|
||||
resp2, _ := http.Post(srv.URL+"/api/node/enroll", "application/json", strings.NewReader(string(body)))
|
||||
if resp2.StatusCode == 200 {
|
||||
t.Fatal("le code d'appairage aurait dû être consommé (usage unique)")
|
||||
if findNodeByPub(pub) == nil {
|
||||
t.Fatal("la clé publique du poste n'a pas été enregistrée")
|
||||
}
|
||||
resp2.Body.Close()
|
||||
|
||||
// 2) Le poste se connecte et sert les appels.
|
||||
// 2) Connexion WS chiffrée.
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/node/ws"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
c, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{
|
||||
HTTPHeader: http.Header{"Authorization": {"Bearer " + enr.Key}},
|
||||
})
|
||||
c, _, err := websocket.Dial(ctx, wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer c.CloseNow()
|
||||
// Le poste déclare pouvoir read/shell/write ; l'effectif sera ∩ = read/shell.
|
||||
if err := wsjson.Write(ctx, c, nodeMsg{Type: "hello", Name: "testpc", OS: "linux/amd64",
|
||||
Caps: []string{nodeCapRead, nodeCapShell, nodeCapWrite}}); err != nil {
|
||||
t.Fatalf("hello: %v", err)
|
||||
|
||||
// hello_pub en clair, puis canal chiffré côté client.
|
||||
if err := wsjson.Write(ctx, c, map[string]string{"type": "hello_pub", "pub": pub}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Boucle client : répond à chaque call par "RESULT:<cap>".
|
||||
key, _ := nodewire.ChannelKey(priv, agentPub)
|
||||
ch, _ := nodewire.NewChan(key, true)
|
||||
sendEnc := func(m nodewire.Msg) { _ = wsjson.Write(ctx, c, ch.Seal(mustJSON(m))) }
|
||||
sendEnc(nodewire.Msg{Type: "hello", Name: "testpc", OS: "linux/amd64", Caps: []string{nodeCapRead, nodeCapShell, nodeCapWrite}})
|
||||
|
||||
// Boucle client : répond à chaque call chiffré par "RESULT:<cap>".
|
||||
go func() {
|
||||
for {
|
||||
var m nodeMsg
|
||||
if err := wsjson.Read(context.Background(), c, &m); err != nil {
|
||||
var fr nodewire.Frame
|
||||
if err := wsjson.Read(context.Background(), c, &fr); err != nil {
|
||||
return
|
||||
}
|
||||
if m.Type == "call" {
|
||||
_ = wsjson.Write(context.Background(), c, nodeMsg{Type: "result", ID: m.ID, Result: "RESULT:" + m.Cap})
|
||||
plain, err := ch.Open(fr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var m nodewire.Msg
|
||||
if json.Unmarshal(plain, &m) == nil && m.Type == "call" {
|
||||
sendEnc(nodewire.Msg{Type: "result", ID: m.ID, Result: "RESULT:" + m.Cap})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Attend l'enregistrement du poste.
|
||||
// Attend l'enregistrement.
|
||||
slug := nodeSlug("testpc")
|
||||
var nc *nodeConn
|
||||
for i := 0; i < 100; i++ {
|
||||
for i := 0; i < 200; i++ {
|
||||
if nc = nodeGet(slug); nc != nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if nc == nil {
|
||||
t.Fatal("le poste ne s'est pas enregistré")
|
||||
t.Fatal("le poste ne s'est pas enregistré (canal chiffré ?)")
|
||||
}
|
||||
|
||||
// 3) Capacités effectives = intersection : read + shell, PAS write.
|
||||
if len(nc.caps) != 2 || nc.caps[0] != nodeCapShell || nc.caps[1] != nodeCapRead {
|
||||
t.Fatalf("capacités effectives inattendues: %v", nc.caps)
|
||||
}
|
||||
|
||||
// 4) Un appel autorisé traverse jusqu'au poste et revient.
|
||||
// 3) Appel autorisé routé jusqu'au poste et retour, à travers le chiffrement.
|
||||
if got := nodeCall(slug, nodeCapRead, map[string]any{"path": "x"}); got != "RESULT:read" {
|
||||
t.Fatalf("appel read: attendu RESULT:read, obtenu %q", got)
|
||||
}
|
||||
|
||||
// 5) Un appel NON autorisé (write) est refusé côté serveur, sans atteindre le poste.
|
||||
if got := nodeCall(slug, nodeCapWrite, map[string]any{"path": "x", "content": "y"}); !strings.Contains(got, "non autorisée") {
|
||||
// 4) Appel non autorisé (write) refusé côté serveur.
|
||||
if got := nodeCall(slug, nodeCapWrite, map[string]any{"path": "x"}); !strings.Contains(got, "non autorisée") {
|
||||
t.Fatalf("appel write aurait dû être refusé, obtenu %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 6) Sélection de la cible : l'agent agit sur ce poste. La méta reflète un
|
||||
// poste connecté, et un appel shell (autorisé) est bien routé jusqu'au poste.
|
||||
if err := setAgentTargetSlug(slug); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, ok := nodeTargetMetaGet()
|
||||
if !ok || !meta.connected || meta.slug != slug {
|
||||
t.Fatalf("cible inattendue: ok=%v %+v", ok, meta)
|
||||
}
|
||||
if got := nodeCall(slug, nodeCapShell, map[string]any{"command": "echo hi"}); got != "RESULT:shell" {
|
||||
t.Fatalf("routage shell: attendu RESULT:shell, obtenu %q", got)
|
||||
}
|
||||
|
||||
// 7) nodeEditRemote lit puis réécrit via le poste (le client fictif renvoie
|
||||
// "RESULT:read" à la lecture → "old" introuvable, donc erreur explicite, pas
|
||||
// un plantage : on vérifie juste que le chemin read→write est emprunté).
|
||||
if got := nodeEditRemote(slug, "f.txt", "absent", "x"); !strings.Contains(got, "introuvable") {
|
||||
t.Fatalf("nodeEditRemote: attendu 'introuvable', obtenu %q", got)
|
||||
}
|
||||
setAgentTargetSlug("") // ne pas fuiter la cible sur d'autres tests
|
||||
func mustJSON(v any) []byte {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
@@ -499,6 +499,13 @@ func newLinkHandler(mux *http.ServeMux) http.Handler {
|
||||
web.ServeHTTP(w, r) // /api/e2e/chat est routé par le mux
|
||||
return
|
||||
}
|
||||
// Poste distant : l'enrôlement (sceau anonyme) et le WebSocket (canal
|
||||
// chiffré poste↔agent) sont E2E — le relais ne voit que de l'opaque. On les
|
||||
// laisse donc passer, comme /api/e2e/*, sans casser la boîte noire.
|
||||
if p == "/api/node/enroll" || p == "/api/node/ws" {
|
||||
web.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(p, "/api/") {
|
||||
http.Error(w, "via le relais : seuls les endpoints chiffrés /api/e2e/* sont autorisés (boîte noire)", http.StatusForbidden)
|
||||
return
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Version = "0.8.9"
|
||||
const Version = "0.9.0"
|
||||
|
||||
// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
|
||||
func Main() {
|
||||
|
||||
@@ -1723,8 +1723,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
<div class="subhead" style="margin-top:6px">Code d'appairage</div>
|
||||
<div class="node-code" id="node-pair-code">——</div>
|
||||
<div class="muted" style="font-size:12px;line-height:1.5">
|
||||
Sur le PC à piloter, installe AJEAN puis lance :
|
||||
Sur le PC à piloter, installe <b>ajean-remote</b> (ajean.link/download) puis lance — <b>accès à distance</b>, chiffré de bout en bout :
|
||||
<pre class="node-cmd" id="node-pair-cmd"></pre>
|
||||
<span style="opacity:.8">Ou en <b>réseau local</b> (connexion directe) :</span>
|
||||
<pre class="node-cmd" id="node-pair-cmd-lan"></pre>
|
||||
Le code expire dans <span id="node-pair-ttl">10</span> min et ne sert qu'une fois.
|
||||
</div>
|
||||
</div>
|
||||
@@ -5504,8 +5506,17 @@ async function genNodeCode(){
|
||||
document.getElementById('node-pair-ttl').textContent = r.ttl_min || 10;
|
||||
const allow = caps.join(',');
|
||||
const rootArg = root ? (' --root "'+root+'"') : '';
|
||||
document.getElementById('node-pair-cmd').textContent =
|
||||
'ajean-remote install '+location.origin+' --code '+r.code+' --allow '+allow+rootArg;
|
||||
// Commande d'accès À DISTANCE (partout) via ajean.link : chiffrée de bout en
|
||||
// bout, le relais reste aveugle. --key = clé publique de l'agent (le poste
|
||||
// scelle vers elle), --machine = quelle machine joindre.
|
||||
const remote = 'ajean-remote install https://ajean.link --machine '+r.machine+
|
||||
' --key '+r.agent_pub+' --code '+r.code+' --allow '+allow+rootArg;
|
||||
// Variante RÉSEAU LOCAL (connexion directe, sans passer par le relais).
|
||||
const lan = 'ajean-remote install '+location.origin+
|
||||
' --key '+r.agent_pub+' --code '+r.code+' --allow '+allow+rootArg;
|
||||
document.getElementById('node-pair-cmd').textContent = remote;
|
||||
const lanEl = document.getElementById('node-pair-cmd-lan');
|
||||
if(lanEl) lanEl.textContent = lan;
|
||||
document.getElementById('node-pair-out').style.display = '';
|
||||
loadNode();
|
||||
}
|
||||
|
||||
@@ -567,8 +567,10 @@
|
||||
<div class="subhead" style="margin-top:6px">Code d'appairage</div>
|
||||
<div class="node-code" id="node-pair-code">——</div>
|
||||
<div class="muted" style="font-size:12px;line-height:1.5">
|
||||
Sur le PC à piloter, installe AJEAN puis lance :
|
||||
Sur le PC à piloter, installe <b>ajean-remote</b> (ajean.link/download) puis lance — <b>accès à distance</b>, chiffré de bout en bout :
|
||||
<pre class="node-cmd" id="node-pair-cmd"></pre>
|
||||
<span style="opacity:.8">Ou en <b>réseau local</b> (connexion directe) :</span>
|
||||
<pre class="node-cmd" id="node-pair-cmd-lan"></pre>
|
||||
Le code expire dans <span id="node-pair-ttl">10</span> min et ne sert qu'une fois.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,8 +112,17 @@ async function genNodeCode(){
|
||||
document.getElementById('node-pair-ttl').textContent = r.ttl_min || 10;
|
||||
const allow = caps.join(',');
|
||||
const rootArg = root ? (' --root "'+root+'"') : '';
|
||||
document.getElementById('node-pair-cmd').textContent =
|
||||
'ajean-remote install '+location.origin+' --code '+r.code+' --allow '+allow+rootArg;
|
||||
// Commande d'accès À DISTANCE (partout) via ajean.link : chiffrée de bout en
|
||||
// bout, le relais reste aveugle. --key = clé publique de l'agent (le poste
|
||||
// scelle vers elle), --machine = quelle machine joindre.
|
||||
const remote = 'ajean-remote install https://ajean.link --machine '+r.machine+
|
||||
' --key '+r.agent_pub+' --code '+r.code+' --allow '+allow+rootArg;
|
||||
// Variante RÉSEAU LOCAL (connexion directe, sans passer par le relais).
|
||||
const lan = 'ajean-remote install '+location.origin+
|
||||
' --key '+r.agent_pub+' --code '+r.code+' --allow '+allow+rootArg;
|
||||
document.getElementById('node-pair-cmd').textContent = remote;
|
||||
const lanEl = document.getElementById('node-pair-cmd-lan');
|
||||
if(lanEl) lanEl.textContent = lan;
|
||||
document.getElementById('node-pair-out').style.display = '';
|
||||
loadNode();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ package nodeclient
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -38,16 +39,35 @@ const (
|
||||
)
|
||||
|
||||
// Config persiste l'appairage et les préférences locales du poste.
|
||||
//
|
||||
// L'identité du poste = sa paire de clés X25519 (Priv/Pub). Le canal vers l'agent
|
||||
// est chiffré de bout en bout (relais aveugle). AgentPub est la clé publique de
|
||||
// l'agent (fournie à l'install, hors-bande, via la commande d'appairage) : le
|
||||
// poste scelle vers elle et en dérive la clé de canal. MachineID, s'il est
|
||||
// renseigné, fait passer par le relais ajean.link (/node/<machine>/).
|
||||
type Config struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Priv string `json:"priv"` // clé privée X25519 du poste (hex) — LE secret
|
||||
Pub string `json:"pub"` // clé publique X25519 du poste (hex)
|
||||
AgentPub string `json:"agent_pub"`
|
||||
MachineID string `json:"machine_id,omitempty"` // non vide = passer par le relais
|
||||
Name string `json:"name"`
|
||||
Caps []string `json:"caps"`
|
||||
Root string `json:"root"`
|
||||
AutoYes bool `json:"auto_yes"`
|
||||
}
|
||||
|
||||
// baseURL est la racine à laquelle joindre l'agent : directe (LAN) ou via le
|
||||
// relais ajean.link quand un MachineID est configuré.
|
||||
func (c Config) baseURL() string {
|
||||
u := strings.TrimRight(c.ServerURL, "/")
|
||||
if c.MachineID != "" {
|
||||
u += "/node/" + c.MachineID
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// dataDir est le dossier de données du poste. Sur Windows : C:\ProgramData\
|
||||
// ajean-remote — MACHINE-WIDE, pour que le service (qui tourne en LocalSystem) lise
|
||||
// la MÊME config que celle écrite par `install` (le profil utilisateur de
|
||||
@@ -102,13 +122,27 @@ func SaveConfig(c Config) error {
|
||||
return os.WriteFile(p, b, 0o600) // 0600 : la clé d'appareil est un secret
|
||||
}
|
||||
|
||||
// Enroll échange un code d'appairage contre une clé d'appareil et complète cfg.
|
||||
// Enroll appaire le poste : génère sa paire de clés, SCELLE {pub, code, name, os}
|
||||
// vers la clé publique de l'agent (le relais ne voit rien), et l'envoie. La clé
|
||||
// privée ne quitte JAMAIS le poste.
|
||||
func Enroll(cfg *Config, code string) error {
|
||||
if cfg.AgentPub == "" {
|
||||
return errors.New("clé publique de l'agent requise (--key, fournie par la commande d'appairage)")
|
||||
}
|
||||
priv, pub, err := nodewire.GenKeyPair()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := os.Hostname()
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"code": code, "name": name, "os": runtime.GOOS + "/" + runtime.GOARCH,
|
||||
inner, _ := json.Marshal(map[string]string{
|
||||
"pub": pub, "code": code, "name": name, "os": runtime.GOOS + "/" + runtime.GOARCH,
|
||||
})
|
||||
endpoint := strings.TrimRight(cfg.ServerURL, "/") + "/api/node/enroll"
|
||||
blob, err := nodewire.SealTo(cfg.AgentPub, inner)
|
||||
if err != nil {
|
||||
return fmt.Errorf("scellement: %w", err)
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"sealed": base64.StdEncoding.EncodeToString(blob)})
|
||||
endpoint := cfg.baseURL() + "/api/node/enroll"
|
||||
req, _ := http.NewRequest("POST", endpoint, strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
|
||||
@@ -119,20 +153,19 @@ func Enroll(cfg *Config, code string) error {
|
||||
var out struct {
|
||||
OK bool `json:"ok"`
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Caps []string `json:"caps"`
|
||||
Root string `json:"root"`
|
||||
Name string `json:"name"`
|
||||
Err string `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
if !out.OK || out.Key == "" {
|
||||
if !out.OK {
|
||||
if out.Err == "" {
|
||||
out.Err = "réponse inattendue du serveur (" + resp.Status + ")"
|
||||
}
|
||||
return errors.New(out.Err)
|
||||
}
|
||||
cfg.ID, cfg.Key, cfg.Caps = out.ID, out.Key, out.Caps
|
||||
cfg.Priv, cfg.Pub, cfg.ID, cfg.Caps = priv, pub, out.ID, out.Caps
|
||||
if out.Root != "" {
|
||||
cfg.Root = out.Root
|
||||
}
|
||||
@@ -165,8 +198,8 @@ func Run(ctx context.Context, cfg Config, quiet bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func wsURL(server string) string {
|
||||
s := strings.TrimRight(server, "/")
|
||||
func wsURL(cfg Config) string {
|
||||
s := cfg.baseURL()
|
||||
switch {
|
||||
case strings.HasPrefix(s, "https://"):
|
||||
s = "wss://" + strings.TrimPrefix(s, "https://")
|
||||
@@ -181,35 +214,55 @@ func wsURL(server string) string {
|
||||
func session(ctx context.Context, cfg Config, quiet bool) error {
|
||||
dialCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
c, _, err := websocket.Dial(dialCtx, wsURL(cfg.ServerURL), &websocket.DialOptions{
|
||||
HTTPHeader: http.Header{"Authorization": {"Bearer " + cfg.Key}},
|
||||
})
|
||||
c, _, err := websocket.Dial(dialCtx, wsURL(cfg), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connexion: %w", err)
|
||||
}
|
||||
c.SetReadLimit(-1)
|
||||
defer c.CloseNow()
|
||||
|
||||
hello := nodewire.Msg{Type: "hello", Name: cfg.Name, OS: runtime.GOOS + "/" + runtime.GOARCH, Caps: cfg.Caps}
|
||||
if err := wsjson.Write(dialCtx, c, hello); err != nil {
|
||||
// Poignée de main : 1er frame EN CLAIR = notre clé publique (elle est publique).
|
||||
if err := wsjson.Write(dialCtx, c, map[string]string{"type": "hello_pub", "pub": cfg.Pub}); err != nil {
|
||||
return fmt.Errorf("hello_pub: %w", err)
|
||||
}
|
||||
// Canal chiffré : K = ECDH(notre priv, clé publique agent). Le relais ne l'a pas.
|
||||
key, err := nodewire.ChannelKey(cfg.Priv, cfg.AgentPub)
|
||||
if err != nil {
|
||||
return fmt.Errorf("canal: %w", err)
|
||||
}
|
||||
ch, err := nodewire.NewChan(key, true) // poste = côté d'envoi 1
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sendEnc := func(m nodewire.Msg) error {
|
||||
raw, _ := json.Marshal(m)
|
||||
return wsjson.Write(ctx, c, ch.Seal(raw))
|
||||
}
|
||||
// 1er frame CHIFFRÉ : le hello (nom/os/capacités). Prouve qu'on a la bonne clé.
|
||||
if err := sendEnc(nodewire.Msg{Type: "hello", Name: cfg.Name, OS: runtime.GOOS + "/" + runtime.GOARCH, Caps: cfg.Caps}); err != nil {
|
||||
return fmt.Errorf("hello: %w", err)
|
||||
}
|
||||
if !quiet {
|
||||
fmt.Printf("[node] connecté ✓ (en attente des demandes de l'IA)\n")
|
||||
fmt.Printf("[node] connecté ✓ (canal chiffré, en attente des demandes de l'IA)\n")
|
||||
}
|
||||
for {
|
||||
var fr nodewire.Frame
|
||||
if err := wsjson.Read(ctx, c, &fr); err != nil {
|
||||
return err
|
||||
}
|
||||
plain, err := ch.Open(fr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("déchiffrement: %w", err)
|
||||
}
|
||||
var m nodewire.Msg
|
||||
if err := wsjson.Read(ctx, c, &m); err != nil {
|
||||
if err := json.Unmarshal(plain, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Type != "call" {
|
||||
continue
|
||||
}
|
||||
result := execute(ctx, cfg, m, quiet)
|
||||
wc, wcancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
err := wsjson.Write(wc, c, nodewire.Msg{Type: "result", ID: m.ID, Result: result})
|
||||
wcancel()
|
||||
if err != nil {
|
||||
if err := sendEnc(nodewire.Msg{Type: "result", ID: m.ID, Result: result}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package nodewire
|
||||
|
||||
// crypto.go — chiffrement de bout en bout du canal poste↔agent, pour que le
|
||||
// relais ajean.link ne voie que de l'opaque (« relais aveugle »), exactement
|
||||
// comme le canal navigateur↔agent.
|
||||
//
|
||||
// Deux briques, toutes en stdlib (crypto/ecdh X25519 + AES-GCM) :
|
||||
// - un SCEAU anonyme vers la clé publique de l'agent (enrôlement) : format
|
||||
// IDENTIQUE à e2eSeal/e2eOpenSeal de l'agent (ephPub32 || nonce12 || ct,
|
||||
// clé = SHA256(shared || ephPub || agentPub)) — donc l'agent ouvre le sceau
|
||||
// du poste avec son code existant, sans dupliquer la crypto ;
|
||||
// - un CANAL authentifié à clés statiques : K = SHA256(ECDH(postePriv,
|
||||
// agentPub) || "ajean-node-chan-v1"). Le poste et l'agent le calculent tous
|
||||
// les deux ; le relais, qui n'a aucune des deux clés privées, non. Chaque
|
||||
// message est scellé AES-GCM avec un nonce à compteur (anti-rejeu / anti-
|
||||
// réordonnancement à l'intérieur d'une connexion).
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GenKeyPair génère une paire X25519 et la renvoie en hex (priv, pub).
|
||||
func GenKeyPair() (privHex, pubHex string, err error) {
|
||||
k, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return hex.EncodeToString(k.Bytes()), hex.EncodeToString(k.PublicKey().Bytes()), nil
|
||||
}
|
||||
|
||||
// PubFromPriv recalcule la clé publique hex à partir de la privée hex.
|
||||
func PubFromPriv(privHex string) (string, error) {
|
||||
raw, err := hex.DecodeString(privHex)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
k, err := ecdh.X25519().NewPrivateKey(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(k.PublicKey().Bytes()), nil
|
||||
}
|
||||
|
||||
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
// SealTo scelle plaintext vers recipientPubHex (clé publique de l'agent). Le
|
||||
// format est celui qu'ouvre e2eOpenSeal de l'agent : ephPub32 || nonce12 || ct.
|
||||
func SealTo(recipientPubHex string, plaintext []byte) ([]byte, error) {
|
||||
recBytes, err := hex.DecodeString(recipientPubHex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recPub, err := ecdh.X25519().NewPublicKey(recBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
eph, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shared, err := eph.ECDH(recPub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := sealKey(shared, eph.PublicKey().Bytes(), recBytes)
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, 12)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ct := gcm.Seal(nil, nonce, plaintext, nil)
|
||||
out := make([]byte, 0, 32+12+len(ct))
|
||||
out = append(out, eph.PublicKey().Bytes()...)
|
||||
out = append(out, nonce...)
|
||||
out = append(out, ct...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// sealKey — identique à sealKey de l'agent (relay_e2e.go). Ne pas modifier sans
|
||||
// changer les deux : c'est ce qui garantit que l'agent ouvre le sceau du poste.
|
||||
func sealKey(shared, ephPub, recipientPub []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write(shared)
|
||||
h.Write(ephPub)
|
||||
h.Write(recipientPub)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// ChannelKey dérive la clé du canal poste↔agent à partir de la clé PRIVÉE (hex)
|
||||
// d'un bout et de la clé PUBLIQUE (hex) de l'autre. Symétrique : les deux bouts
|
||||
// obtiennent le même secret.
|
||||
func ChannelKey(privHex, peerPubHex string) ([]byte, error) {
|
||||
pr, err := hex.DecodeString(privHex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
priv, err := ecdh.X25519().NewPrivateKey(pr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pb, err := hex.DecodeString(peerPubHex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
peer, err := ecdh.X25519().NewPublicKey(pb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ss, err := priv.ECDH(peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h := sha256.New()
|
||||
h.Write(ss)
|
||||
h.Write([]byte("ajean-node-chan-v1"))
|
||||
return h.Sum(nil), nil
|
||||
}
|
||||
|
||||
// Frame est un message chiffré du canal : nonce (side||compteur) + ciphertext.
|
||||
type Frame struct {
|
||||
N []byte `json:"n"`
|
||||
C []byte `json:"c"`
|
||||
}
|
||||
|
||||
// Chan chiffre/déchiffre les messages d'un canal, avec des nonces à compteur
|
||||
// pour interdire rejeu et réordonnancement DANS la connexion. Chaque bout a un
|
||||
// « côté » (1 octet) distinct → les nonces des deux sens ne se croisent jamais.
|
||||
type Chan struct {
|
||||
gcm cipher.AEAD
|
||||
sendSide byte
|
||||
recvSide byte
|
||||
sendCtr uint64
|
||||
recvNext uint64
|
||||
}
|
||||
|
||||
// NewChan construit un canal à partir de la clé partagée. initiator=true pour le
|
||||
// POSTE (côté d'envoi 1), false pour l'AGENT (côté d'envoi 2). Les côtés doivent
|
||||
// être opposés des deux bouts, sinon les nonces entreraient en collision.
|
||||
func NewChan(key []byte, initiator bool) (*Chan, error) {
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &Chan{gcm: gcm}
|
||||
if initiator {
|
||||
c.sendSide, c.recvSide = 1, 2
|
||||
} else {
|
||||
c.sendSide, c.recvSide = 2, 1
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Chan) nonce(side byte, ctr uint64) []byte {
|
||||
n := make([]byte, 12)
|
||||
n[0] = side
|
||||
binary.BigEndian.PutUint64(n[1:9], ctr)
|
||||
return n
|
||||
}
|
||||
|
||||
// Seal chiffre un message sortant.
|
||||
func (c *Chan) Seal(plaintext []byte) Frame {
|
||||
n := c.nonce(c.sendSide, c.sendCtr)
|
||||
ct := c.gcm.Seal(nil, n, plaintext, nil)
|
||||
c.sendCtr++
|
||||
return Frame{N: n, C: ct}
|
||||
}
|
||||
|
||||
// Open déchiffre un message entrant et vérifie l'ordre (compteur strictement
|
||||
// croissant, bon côté) — un frame rejoué ou réordonné par le relais est rejeté.
|
||||
func (c *Chan) Open(f Frame) ([]byte, error) {
|
||||
if len(f.N) != 12 || f.N[0] != c.recvSide {
|
||||
return nil, errors.New("nonce invalide")
|
||||
}
|
||||
ctr := binary.BigEndian.Uint64(f.N[1:9])
|
||||
if ctr < c.recvNext {
|
||||
return nil, fmt.Errorf("rejeu détecté (compteur %d < %d)", ctr, c.recvNext)
|
||||
}
|
||||
plain, err := c.gcm.Open(nil, f.N, f.C, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("authentification échouée")
|
||||
}
|
||||
c.recvNext = ctr + 1
|
||||
return plain, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package nodewire
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// La clé de canal doit être IDENTIQUE des deux bouts (poste priv + agent pub =
|
||||
// agent priv + poste pub).
|
||||
func TestChannelKeySymmetric(t *testing.T) {
|
||||
aPriv, aPub, _ := GenKeyPair()
|
||||
bPriv, bPub, _ := GenKeyPair()
|
||||
ka, err := ChannelKey(aPriv, bPub)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kb, err := ChannelKey(bPriv, aPub)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(ka, kb) {
|
||||
t.Fatal("les deux bouts n'obtiennent pas la même clé de canal")
|
||||
}
|
||||
}
|
||||
|
||||
// Un aller-retour chiffré passe ; un frame rejoué est rejeté.
|
||||
func TestChanRoundTripAndReplay(t *testing.T) {
|
||||
aPriv, aPub, _ := GenKeyPair()
|
||||
bPriv, bPub, _ := GenKeyPair()
|
||||
ka, _ := ChannelKey(aPriv, bPub) // poste
|
||||
kb, _ := ChannelKey(bPriv, aPub) // agent
|
||||
|
||||
poste, _ := NewChan(ka, true)
|
||||
agent, _ := NewChan(kb, false)
|
||||
|
||||
f := poste.Seal([]byte("dir C:\\"))
|
||||
got, err := agent.Open(f)
|
||||
if err != nil || string(got) != "dir C:\\" {
|
||||
t.Fatalf("aller-retour: %q err=%v", got, err)
|
||||
}
|
||||
// Rejeu du même frame → rejeté (compteur non croissant).
|
||||
if _, err := agent.Open(f); err == nil {
|
||||
t.Fatal("un frame rejoué aurait dû être rejeté")
|
||||
}
|
||||
// Sens inverse.
|
||||
f2 := agent.Seal([]byte("exit: 0"))
|
||||
got2, err := poste.Open(f2)
|
||||
if err != nil || string(got2) != "exit: 0" {
|
||||
t.Fatalf("retour: %q err=%v", got2, err)
|
||||
}
|
||||
}
|
||||
|
||||
// SealTo doit produire le format ephPub32||nonce12||ct que l'agent sait ouvrir.
|
||||
func TestSealToFormat(t *testing.T) {
|
||||
_, pub, _ := GenKeyPair()
|
||||
blob, err := SealTo(pub, []byte("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(blob) < 32+12+16 {
|
||||
t.Fatalf("sceau trop court: %d octets", len(blob))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user