v0.9.0 : poste distant accessible via ajean.link, chiffré de bout en bout (relais aveugle)

This commit is contained in:
nathaninline committed 2026-08-11 15:29:36 +02:00
1 parent 6d81f5c3f5
commit 1454dff2a7
16 files changed
+639 -198

No files matched your search

+8 -14
View File
@@ -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 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 ».
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.
+35 -14
View File
@@ -77,24 +77,34 @@ Options :
// doConnect appaire au besoin, sauvegarde, puis installe (service) ou lance // doConnect appaire au besoin, sauvegarde, puis installe (service) ou lance
// (avant-plan) selon install. // (avant-plan) selon install.
func doConnect(args []string, install bool) error { func doConnect(args []string, install bool) error {
url, code, allow, root, yes := parseFlags(args) f := parseFlags(args)
cfg, lerr := nodeclient.LoadConfig() cfg, lerr := nodeclient.LoadConfig()
needEnroll := lerr != nil || cfg.Key == "" needEnroll := lerr != nil || cfg.Priv == ""
if url != "" { if f.url != "" {
cfg.ServerURL = url cfg.ServerURL = f.url
}
if f.key != "" {
cfg.AgentPub = f.key
}
if f.machine != "" {
cfg.MachineID = f.machine
} }
if needEnroll { if needEnroll {
if cfg.ServerURL == "" { if cfg.ServerURL == "" {
return fmt.Errorf("url du serveur requise (ex: http://192.168.1.127:8090)") 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") 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 return err
} }
fmt.Printf("[ok] appairé (id %s)\n", cfg.ID) fmt.Printf("[ok] appairé (id %s)\n", cfg.ID)
} }
allow, root, yes := f.allow, f.root, f.yes
if len(allow) > 0 { if len(allow) > 0 {
cfg.Caps = nodeclient.SanitizeCaps(allow) cfg.Caps = nodeclient.SanitizeCaps(allow)
} }
@@ -150,7 +160,14 @@ func doStatus() error {
return nil 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++ { for i := 0; i < len(args); i++ {
a := args[i] a := args[i]
next := func() string { next := func() string {
@@ -162,20 +179,24 @@ func parseFlags(args []string) (url, code string, allow []string, root string, y
} }
switch { switch {
case a == "--code": case a == "--code":
code = next() f.code = next()
case a == "--key":
f.key = next()
case a == "--machine":
f.machine = next()
case a == "--allow": case a == "--allow":
for _, p := range strings.Split(next(), ",") { for _, p := range strings.Split(next(), ",") {
if p = strings.TrimSpace(p); p != "" { if p = strings.TrimSpace(p); p != "" {
allow = append(allow, p) f.allow = append(f.allow, p)
} }
} }
case a == "--root": case a == "--root":
root = next() f.root = next()
case a == "--yes" || a == "-y": case a == "--yes" || a == "-y":
yes = true f.yes = true
case !strings.HasPrefix(a, "-") && url == "": case !strings.HasPrefix(a, "-") && f.url == "":
url = a f.url = a
} }
} }
return return f
} }
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -2,14 +2,14 @@
"FixedFileInfo": { "FixedFileInfo": {
"FileVersion": { "FileVersion": {
"Major": 0, "Major": 0,
"Minor": 8, "Minor": 9,
"Patch": 9, "Patch": 0,
"Build": 0 "Build": 0
}, },
"ProductVersion": { "ProductVersion": {
"Major": 0, "Major": 0,
"Minor": 8, "Minor": 9,
"Patch": 9, "Patch": 0,
"Build": 0 "Build": 0
}, },
"FileFlagsMask": "3f", "FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
"OriginalFilename": "ajean.exe", "OriginalFilename": "ajean.exe",
"ProductName": "AJEAN", "ProductName": "AJEAN",
"ProductVersion": "0.8.9", "ProductVersion": "0.9.0",
"Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)"
}, },
"VarFileInfo": { "VarFileInfo": {
+52 -24
View File
@@ -9,6 +9,7 @@
package ajean package ajean
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
@@ -42,25 +43,48 @@ func handleNodePair(w http.ResponseWriter, r *http.Request) {
return return
} }
sendJSON(w, 200, map[string]any{ sendJSON(w, 200, map[string]any{
"ok": true, "ok": true,
"code": p.Code, "code": p.Code,
"caps": p.Caps, "caps": p.Caps,
"root": p.Root, "root": p.Root,
"expires": p.Expires, "expires": p.Expires,
"ttl_min": int(nodePairCodeTTL.Minutes()), "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é // handleNodeEnroll (PUBLIC) enrôle un poste. Le corps est un SCEAU anonyme vers
// d'appareil. Le code est à usage unique : consommé dès qu'il sert. // 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) { func handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
var req struct { 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"` Code string `json:"code"`
Name string `json:"name"` Name string `json:"name"`
OS string `json:"os"` OS string `json:"os"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.Unmarshal(plain, &pm); err != nil {
sendJSON(w, 400, map[string]any{"error": "requête invalide"}) sendJSON(w, 400, map[string]any{"error": "contenu du sceau"})
return return
} }
pending, ok := loadPairPending() 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"}) sendJSON(w, 403, map[string]any{"error": "aucun code d'appairage actif — générez-en un dans l'interface"})
return 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"}) sendJSON(w, 403, map[string]any{"error": "code incorrect"})
return return
} }
// Consomme le code immédiatement (usage unique), même si la suite échoue. clearPairPending() // usage unique
clearPairPending()
key := nodeRandHex(24) pub := strings.ToLower(strings.TrimSpace(pm.Pub))
name := strings.TrimSpace(req.Name) if len(pub) != 64 {
sendJSON(w, 400, map[string]any{"error": "clé publique du poste invalide"})
return
}
name := strings.TrimSpace(pm.Name)
if name == "" { if name == "" {
name = "poste" name = "poste"
} }
node := pairedNode{ node := pairedNode{
ID: nodeRandHex(8), ID: nodeRandHex(8),
Name: name, Name: name,
OS: strings.TrimSpace(req.OS), OS: strings.TrimSpace(pm.OS),
KeyHash: nodeHashKey(key), PubHex: pub,
Caps: pending.Caps, Caps: pending.Caps,
Root: pending.Root, Root: pending.Root,
CreatedAt: time.Now().Unix(), 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()}) sendJSON(w, 500, map[string]any{"error": err.Error()})
return 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{ sendJSON(w, 200, map[string]any{
"ok": true, "ok": true,
"id": node.ID, "id": node.ID,
"key": key, "machine_id": machineID(),
"caps": node.Caps, "caps": node.Caps,
"root": node.Root, "root": node.Root,
"name": node.Name, "name": node.Name,
}) })
} }
+106 -55
View File
@@ -7,9 +7,8 @@ package ajean
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"sort" "sort"
@@ -19,6 +18,7 @@ import (
"github.com/coder/websocket" "github.com/coder/websocket"
"github.com/coder/websocket/wsjson" "github.com/coder/websocket/wsjson"
"github.com/nathaninline/ajean/internal/nodewire"
) )
const ( const (
@@ -28,13 +28,14 @@ const (
) )
// pairedNode est un poste appairé, tel que persisté dans bkState["nodes"]. // 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, // L'identité du poste = sa CLÉ PUBLIQUE X25519 (PubHex). Il n'y a plus de « clé
// conserve la clé en local (voir node_client.go). // 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 { type pairedNode struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
OS string `json:"os"` 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 Caps []string `json:"caps"` // capacités AUTORISÉES par le propriétaire
Root string `json:"root,omitempty"` Root string `json:"root,omitempty"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
@@ -49,25 +50,30 @@ func loadNodes() []pairedNode {
func saveNodes(l []pairedNode) error { return putJSON(bkState, "nodes", l) } func saveNodes(l []pairedNode) error { return putJSON(bkState, "nodes", l) }
// findNodeByKey retrouve un poste appairé à partir de la clé d'appareil présentée // findNodeByPub retrouve un poste appairé à partir de sa clé publique.
// (comparaison à temps constant sur le hash). Renvoie nil si aucune ne matche. func findNodeByPub(pub string) *pairedNode {
func findNodeByKey(key string) *pairedNode { pub = strings.ToLower(strings.TrimSpace(pub))
if key == "" { if pub == "" {
return nil return nil
} }
want := nodeHashKey(key)
nodes := loadNodes() nodes := loadNodes()
for i := range nodes { 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 &nodes[i]
} }
} }
return nil return nil
} }
func nodeHashKey(key string) string { // agentPrivHex renvoie la clé privée X25519 de l'agent en hex (pour dériver la
sum := sha256.Sum256([]byte(key)) // clé de canal poste↔agent via nodewire). Même clé que le canal navigateur, mais
return hex.EncodeToString(sum[:]) // 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). // nodeRandHex renvoie n octets aléatoires en hexadécimal (2n caractères).
@@ -112,18 +118,42 @@ type nodeConn struct {
conn *websocket.Conn conn *websocket.Conn
ctx context.Context ctx context.Context
ch *nodewire.Chan // canal chiffré poste↔agent (relais aveugle)
writeMu sync.Mutex writeMu sync.Mutex
mu sync.Mutex mu sync.Mutex
pending map[string]chan string 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 { func (nc *nodeConn) send(m nodeMsg) error {
nc.writeMu.Lock() nc.writeMu.Lock()
defer nc.writeMu.Unlock() defer nc.writeMu.Unlock()
raw, err := json.Marshal(m)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(nc.ctx, 15*time.Second) ctx, cancel := context.WithTimeout(nc.ctx, 15*time.Second)
defer cancel() 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 ( var (
@@ -169,16 +199,12 @@ func nodeConnected() []*nodeConn {
// ─── Endpoint WebSocket : le poste se connecte ici ───────────────────────── // ─── Endpoint WebSocket : le poste se connecte ici ─────────────────────────
// handleNodeWS accueille la connexion sortante d'un poste. Route PUBLIQUE (pas // handleNodeWS accueille la connexion sortante d'un poste. Route PUBLIQUE : elle
// derrière la clé de pilotage) : l'authentification se fait par la clé d'appareil // peut arriver en direct (LAN) OU tunnelée par le relais ajean.link. Dans les
// remise à l'appairage, présentée en Bearer. // 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) { 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{"*"}}) c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
if err != nil { if err != nil {
return return
@@ -186,43 +212,77 @@ func handleNodeWS(w http.ResponseWriter, r *http.Request) {
c.SetReadLimit(-1) c.SetReadLimit(-1)
ctx := r.Context() 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) helloCtx, cancel := context.WithTimeout(ctx, nodeHelloWait)
var hello nodeMsg var hp struct {
err = wsjson.Read(helloCtx, c, &hello) Type string `json:"type"`
Pub string `json:"pub"`
}
err = wsjson.Read(helloCtx, c, &hp)
cancel() cancel()
if err != nil || hello.Type != "hello" { if err != nil || hp.Type != "hello_pub" || hp.Pub == "" {
_ = c.Close(websocket.StatusProtocolError, "hello attendu") _ = 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 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) name := strings.TrimSpace(hello.Name)
if name == "" { if name == "" {
name = pn.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) nodeRegister(nc)
defer nodeUnregister(nc) defer nodeUnregister(nc)
touchNodeSeen(pn.ID) touchNodeSeen(pn.ID)
// Boucle de lecture : les seules entrées attendues sont des « result » qui // Boucle de lecture : des « result » chiffrés qui débloquent l'appel en attente.
// débloquent l'appel en attente correspondant. Le ping/keepalive est géré par
// coder/websocket.
for { for {
var m nodeMsg m, err := nc.readMsg(ctx)
if err := wsjson.Read(ctx, c, &m); err != nil { if err != nil {
_ = c.CloseNow() _ = c.CloseNow()
return 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) ───────────── // ─── 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 // nodeCall envoie une demande d'exécution au poste et attend son résultat. Toute
+59 -58
View File
@@ -2,6 +2,7 @@ package ajean
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -11,15 +12,22 @@ import (
"github.com/coder/websocket" "github.com/coder/websocket"
"github.com/coder/websocket/wsjson" "github.com/coder/websocket/wsjson"
"github.com/nathaninline/ajean/internal/nodewire"
) )
// TestNodeEndToEnd exerce tout le chemin serveur : enrôlement via code, connexion // TestNodeE2E exerce tout le chemin : enrôlement SCELLÉ (le sceau du client est
// WebSocket, négociation des capacités (intersection propriétaire ∩ poste), et // ouvert par e2eOpenSeal de l'agent → interop crypto validée), puis canal
// routage d'un appel d'outil jusqu'au poste et retour. // CHIFFRÉ poste↔agent (hello + appel d'outil + résultat), et refus d'une
func TestNodeEndToEnd(t *testing.T) { // capacité non autorisée.
func TestNodeE2E(t *testing.T) {
testHome(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{ if err := savePairPending(nodePairPending{
Code: "ABCD1234", Code: "ABCD1234",
Caps: []string{nodeCapRead, nodeCapShell}, Caps: []string{nodeCapRead, nodeCapShell},
@@ -34,104 +42,97 @@ func TestNodeEndToEnd(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
defer srv.Close() defer srv.Close()
// 1) Enrôlement : le code est échangé contre une clé d'appareil. // 1) Le poste génère sa paire et SCELLE {pub, code, name, os} vers l'agent.
body, _ := json.Marshal(map[string]string{"code": "abcd1234", "name": "testpc", "os": "linux/amd64"}) 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))) resp, err := http.Post(srv.URL+"/api/node/enroll", "application/json", strings.NewReader(string(body)))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var enr struct { var enr struct {
OK bool `json:"ok"` OK bool `json:"ok"`
Key string `json:"key"`
Caps []string `json:"caps"`
} }
_ = json.NewDecoder(resp.Body).Decode(&enr) _ = json.NewDecoder(resp.Body).Decode(&enr)
resp.Body.Close() resp.Body.Close()
if !enr.OK || enr.Key == "" { if !enr.OK {
t.Fatalf("enrôlement échoué: %+v", enr) t.Fatalf("enrôlement échoué: %+v", enr)
} }
// Le code est à usage unique : un second enrôlement doit échouer. if findNodeByPub(pub) == nil {
resp2, _ := http.Post(srv.URL+"/api/node/enroll", "application/json", strings.NewReader(string(body))) t.Fatal("la clé publique du poste n'a pas été enregistrée")
if resp2.StatusCode == 200 {
t.Fatal("le code d'appairage aurait dû être consommé (usage unique)")
} }
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" wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/node/ws"
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
c, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{ c, _, err := websocket.Dial(ctx, wsURL, nil)
HTTPHeader: http.Header{"Authorization": {"Bearer " + enr.Key}},
})
if err != nil { if err != nil {
t.Fatalf("dial: %v", err) t.Fatalf("dial: %v", err)
} }
defer c.CloseNow() 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", // hello_pub en clair, puis canal chiffré côté client.
Caps: []string{nodeCapRead, nodeCapShell, nodeCapWrite}}); err != nil { if err := wsjson.Write(ctx, c, map[string]string{"type": "hello_pub", "pub": pub}); err != nil {
t.Fatalf("hello: %v", err) 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() { go func() {
for { for {
var m nodeMsg var fr nodewire.Frame
if err := wsjson.Read(context.Background(), c, &m); err != nil { if err := wsjson.Read(context.Background(), c, &fr); err != nil {
return return
} }
if m.Type == "call" { plain, err := ch.Open(fr)
_ = wsjson.Write(context.Background(), c, nodeMsg{Type: "result", ID: m.ID, Result: "RESULT:" + m.Cap}) 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") slug := nodeSlug("testpc")
var nc *nodeConn var nc *nodeConn
for i := 0; i < 100; i++ { for i := 0; i < 200; i++ {
if nc = nodeGet(slug); nc != nil { if nc = nodeGet(slug); nc != nil {
break break
} }
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
} }
if nc == nil { 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 { if len(nc.caps) != 2 || nc.caps[0] != nodeCapShell || nc.caps[1] != nodeCapRead {
t.Fatalf("capacités effectives inattendues: %v", nc.caps) 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" { if got := nodeCall(slug, nodeCapRead, map[string]any{"path": "x"}); got != "RESULT:read" {
t.Fatalf("appel read: attendu RESULT:read, obtenu %q", got) t.Fatalf("appel read: attendu RESULT:read, obtenu %q", got)
} }
// 4) Appel non autorisé (write) refusé côté serveur.
// 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"}); !strings.Contains(got, "non autorisée") {
if got := nodeCall(slug, nodeCapWrite, map[string]any{"path": "x", "content": "y"}); !strings.Contains(got, "non autorisée") {
t.Fatalf("appel write aurait dû être refusé, obtenu %q", got) 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 func mustJSON(v any) []byte {
// poste connecté, et un appel shell (autorisé) est bien routé jusqu'au poste. b, _ := json.Marshal(v)
if err := setAgentTargetSlug(slug); err != nil { return b
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
} }
+7
View File
@@ -499,6 +499,13 @@ func newLinkHandler(mux *http.ServeMux) http.Handler {
web.ServeHTTP(w, r) // /api/e2e/chat est routé par le mux web.ServeHTTP(w, r) // /api/e2e/chat est routé par le mux
return 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/") { 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) http.Error(w, "via le relais : seuls les endpoints chiffrés /api/e2e/* sont autorisés (boîte noire)", http.StatusForbidden)
return return
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings" "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). // Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
func Main() { func Main() {
+14 -3
View File
@@ -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="subhead" style="margin-top:6px">Code d'appairage</div>
<div class="node-code" id="node-pair-code">——</div> <div class="node-code" id="node-pair-code">——</div>
<div class="muted" style="font-size:12px;line-height:1.5"> <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> <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. Le code expire dans <span id="node-pair-ttl">10</span> min et ne sert qu'une fois.
</div> </div>
</div> </div>
@@ -5504,8 +5506,17 @@ async function genNodeCode(){
document.getElementById('node-pair-ttl').textContent = r.ttl_min || 10; document.getElementById('node-pair-ttl').textContent = r.ttl_min || 10;
const allow = caps.join(','); const allow = caps.join(',');
const rootArg = root ? (' --root "'+root+'"') : ''; const rootArg = root ? (' --root "'+root+'"') : '';
document.getElementById('node-pair-cmd').textContent = // Commande d'accès À DISTANCE (partout) via ajean.link : chiffrée de bout en
'ajean-remote install '+location.origin+' --code '+r.code+' --allow '+allow+rootArg; // 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 = ''; document.getElementById('node-pair-out').style.display = '';
loadNode(); loadNode();
} }
+3 -1
View File
@@ -567,8 +567,10 @@
<div class="subhead" style="margin-top:6px">Code d'appairage</div> <div class="subhead" style="margin-top:6px">Code d'appairage</div>
<div class="node-code" id="node-pair-code">——</div> <div class="node-code" id="node-pair-code">——</div>
<div class="muted" style="font-size:12px;line-height:1.5"> <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> <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. Le code expire dans <span id="node-pair-ttl">10</span> min et ne sert qu'une fois.
</div> </div>
</div> </div>
+11 -2
View File
@@ -112,8 +112,17 @@ async function genNodeCode(){
document.getElementById('node-pair-ttl').textContent = r.ttl_min || 10; document.getElementById('node-pair-ttl').textContent = r.ttl_min || 10;
const allow = caps.join(','); const allow = caps.join(',');
const rootArg = root ? (' --root "'+root+'"') : ''; const rootArg = root ? (' --root "'+root+'"') : '';
document.getElementById('node-pair-cmd').textContent = // Commande d'accès À DISTANCE (partout) via ajean.link : chiffrée de bout en
'ajean-remote install '+location.origin+' --code '+r.code+' --allow '+allow+rootArg; // 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 = ''; document.getElementById('node-pair-out').style.display = '';
loadNode(); loadNode();
} }
+74 -21
View File
@@ -13,6 +13,7 @@ package nodeclient
import ( import (
"bufio" "bufio"
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -38,16 +39,35 @@ const (
) )
// Config persiste l'appairage et les préférences locales du poste. // 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 { type Config struct {
ServerURL string `json:"server_url"` ServerURL string `json:"server_url"`
ID string `json:"id"` 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"` Name string `json:"name"`
Caps []string `json:"caps"` Caps []string `json:"caps"`
Root string `json:"root"` Root string `json:"root"`
AutoYes bool `json:"auto_yes"` 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\ // 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 // 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 // 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 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 { 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() name, _ := os.Hostname()
body, _ := json.Marshal(map[string]string{ inner, _ := json.Marshal(map[string]string{
"code": code, "name": name, "os": runtime.GOOS + "/" + runtime.GOARCH, "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, _ := http.NewRequest("POST", endpoint, strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req) resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
@@ -119,20 +153,19 @@ func Enroll(cfg *Config, code string) error {
var out struct { var out struct {
OK bool `json:"ok"` OK bool `json:"ok"`
ID string `json:"id"` ID string `json:"id"`
Key string `json:"key"`
Caps []string `json:"caps"` Caps []string `json:"caps"`
Root string `json:"root"` Root string `json:"root"`
Name string `json:"name"` Name string `json:"name"`
Err string `json:"error"` Err string `json:"error"`
} }
_ = json.NewDecoder(resp.Body).Decode(&out) _ = json.NewDecoder(resp.Body).Decode(&out)
if !out.OK || out.Key == "" { if !out.OK {
if out.Err == "" { if out.Err == "" {
out.Err = "réponse inattendue du serveur (" + resp.Status + ")" out.Err = "réponse inattendue du serveur (" + resp.Status + ")"
} }
return errors.New(out.Err) 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 != "" { if out.Root != "" {
cfg.Root = out.Root cfg.Root = out.Root
} }
@@ -165,8 +198,8 @@ func Run(ctx context.Context, cfg Config, quiet bool) {
} }
} }
func wsURL(server string) string { func wsURL(cfg Config) string {
s := strings.TrimRight(server, "/") s := cfg.baseURL()
switch { switch {
case strings.HasPrefix(s, "https://"): case strings.HasPrefix(s, "https://"):
s = "wss://" + strings.TrimPrefix(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 { func session(ctx context.Context, cfg Config, quiet bool) error {
dialCtx, cancel := context.WithTimeout(ctx, 20*time.Second) dialCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel() defer cancel()
c, _, err := websocket.Dial(dialCtx, wsURL(cfg.ServerURL), &websocket.DialOptions{ c, _, err := websocket.Dial(dialCtx, wsURL(cfg), nil)
HTTPHeader: http.Header{"Authorization": {"Bearer " + cfg.Key}},
})
if err != nil { if err != nil {
return fmt.Errorf("connexion: %w", err) return fmt.Errorf("connexion: %w", err)
} }
c.SetReadLimit(-1) c.SetReadLimit(-1)
defer c.CloseNow() defer c.CloseNow()
hello := nodewire.Msg{Type: "hello", Name: cfg.Name, OS: runtime.GOOS + "/" + runtime.GOARCH, Caps: cfg.Caps} // Poignée de main : 1er frame EN CLAIR = notre clé publique (elle est publique).
if err := wsjson.Write(dialCtx, c, hello); err != nil { 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) return fmt.Errorf("hello: %w", err)
} }
if !quiet { 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 { 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 var m nodewire.Msg
if err := wsjson.Read(ctx, c, &m); err != nil { if err := json.Unmarshal(plain, &m); err != nil {
return err return err
} }
if m.Type != "call" { if m.Type != "call" {
continue continue
} }
result := execute(ctx, cfg, m, quiet) result := execute(ctx, cfg, m, quiet)
wc, wcancel := context.WithTimeout(ctx, 15*time.Second) if err := sendEnc(nodewire.Msg{Type: "result", ID: m.ID, Result: result}); err != nil {
err := wsjson.Write(wc, c, nodewire.Msg{Type: "result", ID: m.ID, Result: result})
wcancel()
if err != nil {
return err return err
} }
} }
+201
View File
@@ -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
}
+63
View File
@@ -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))
}
}