diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 829859c..b022c73 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 / »** 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 ». diff --git a/cmd/ajean-remote/main.go b/cmd/ajean-remote/main.go index d7f2078..62fa972 100644 --- a/cmd/ajean-remote/main.go +++ b/cmd/ajean-remote/main.go @@ -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 (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 } diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index b4288a0..f149d1b 100644 Binary files a/cmd/ajean/resource_windows_amd64.syso and b/cmd/ajean/resource_windows_amd64.syso differ diff --git a/cmd/ajean/resource_windows_arm64.syso b/cmd/ajean/resource_windows_arm64.syso index 18c0269..39a6089 100644 Binary files a/cmd/ajean/resource_windows_arm64.syso and b/cmd/ajean/resource_windows_arm64.syso differ diff --git a/cmd/ajean/versioninfo.json b/cmd/ajean/versioninfo.json index 8f31987..c0f4fc5 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -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" } } -} +} \ No newline at end of file diff --git a/internal/ajean/node_api.go b/internal/ajean/node_api.go index 47d26f9..82c929b 100644 --- a/internal/ajean/node_api.go +++ b/internal/ajean/node_api.go @@ -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//) + "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, }) } diff --git a/internal/ajean/node_server.go b/internal/ajean/node_server.go index 779f5a1..1d3d157 100644 --- a/internal/ajean/node_server.go +++ b/internal/ajean/node_server.go @@ -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 diff --git a/internal/ajean/node_ws_test.go b/internal/ajean/node_ws_test.go index bcd2c65..b9521f5 100644 --- a/internal/ajean/node_ws_test.go +++ b/internal/ajean/node_ws_test.go @@ -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:". + 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:". 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 } diff --git a/internal/ajean/relay_link.go b/internal/ajean/relay_link.go index 7a80447..76ab676 100644 --- a/internal/ajean/relay_link.go +++ b/internal/ajean/relay_link.go @@ -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 diff --git a/internal/ajean/run.go b/internal/ajean/run.go index c5b0a73..cd295cc 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -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() { diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index ad59cf7..737b5c1 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -1723,8 +1723,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
Code d'appairage
——
- Sur le PC à piloter, installe AJEAN puis lance : + Sur le PC à piloter, installe ajean-remote (ajean.link/download) puis lance — accès à distance, chiffré de bout en bout :

+          Ou en réseau local (connexion directe) :
+          

           Le code expire dans 10 min et ne sert qu'une fois.
         
@@ -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(); } diff --git a/internal/ajean/ui/src/index.tmpl.html b/internal/ajean/ui/src/index.tmpl.html index 626ef86..1ce1bb6 100644 --- a/internal/ajean/ui/src/index.tmpl.html +++ b/internal/ajean/ui/src/index.tmpl.html @@ -567,8 +567,10 @@
Code d'appairage
——
- Sur le PC à piloter, installe AJEAN puis lance : + Sur le PC à piloter, installe ajean-remote (ajean.link/download) puis lance — accès à distance, chiffré de bout en bout :

+          Ou en réseau local (connexion directe) :
+          

           Le code expire dans 10 min et ne sert qu'une fois.
         
diff --git a/internal/ajean/ui/src/js/15-node.js b/internal/ajean/ui/src/js/15-node.js index a651e90..40b4ecb 100644 --- a/internal/ajean/ui/src/js/15-node.js +++ b/internal/ajean/ui/src/js/15-node.js @@ -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(); } diff --git a/internal/nodeclient/client.go b/internal/nodeclient/client.go index ab56d1c..d2d9210 100644 --- a/internal/nodeclient/client.go +++ b/internal/nodeclient/client.go @@ -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//). 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 } } diff --git a/internal/nodewire/crypto.go b/internal/nodewire/crypto.go new file mode 100644 index 0000000..d565537 --- /dev/null +++ b/internal/nodewire/crypto.go @@ -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 +} diff --git a/internal/nodewire/crypto_test.go b/internal/nodewire/crypto_test.go new file mode 100644 index 0000000..44ea451 --- /dev/null +++ b/internal/nodewire/crypto_test.go @@ -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)) + } +}