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
+640 -199

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
Pas de nouveaux outils à apprendre pour l'IA : elle garde son shell, son écriture et son édition de fichiers. Un sélecteur **« L'IA agit sur : Ce serveur / <ton poste> »** décide simplement *où* ces outils s'exécutent. Tu bascules d'un clic. Et si le poste choisi est hors ligne, l'action échoue proprement plutôt que de retomber par erreur sur le serveur.
## Pensé pour ne pas se retourner contre toi
Le poste se connecte en sortant, sa clé d'appareil est stockée hachée côté serveur, et c'est **toi** qui décides, machine par machine, ce que l'IA a le droit de faire et dans quel dossier. Lecture et écriture sont confinées à ce dossier ; les capacités effectives sont l'intersection de ce que tu autorises et de ce que le poste accepte localement.
Le petit client s'appelle **ajean-remote** (quelques mégaoctets, aucune interface). Il s'installe en service — invisible, au démarrage, reconnexion automatique — et se télécharge depuis ajean.link, section « Piloter un autre PC ».
+35 -14
View File
@@ -77,24 +77,34 @@ Options :
// doConnect appaire au besoin, sauvegarde, puis installe (service) ou lance
// (avant-plan) selon install.
func doConnect(args []string, install bool) error {
url, code, allow, root, yes := parseFlags(args)
f := parseFlags(args)
cfg, lerr := nodeclient.LoadConfig()
needEnroll := lerr != nil || cfg.Key == ""
if url != "" {
cfg.ServerURL = url
needEnroll := lerr != nil || cfg.Priv == ""
if f.url != "" {
cfg.ServerURL = f.url
}
if f.key != "" {
cfg.AgentPub = f.key
}
if f.machine != "" {
cfg.MachineID = f.machine
}
if needEnroll {
if cfg.ServerURL == "" {
return fmt.Errorf("url du serveur requise (ex: http://192.168.1.127:8090)")
}
if code == "" {
if f.code == "" {
return fmt.Errorf("code d'appairage requis : --code CODE")
}
if err := nodeclient.Enroll(&cfg, code); err != nil {
if cfg.AgentPub == "" {
return fmt.Errorf("clé de l'agent requise : --key <clé> (fournie par la commande d'appairage)")
}
if err := nodeclient.Enroll(&cfg, f.code); err != nil {
return err
}
fmt.Printf("[ok] appairé (id %s)\n", cfg.ID)
}
allow, root, yes := f.allow, f.root, f.yes
if len(allow) > 0 {
cfg.Caps = nodeclient.SanitizeCaps(allow)
}
@@ -150,7 +160,14 @@ func doStatus() error {
return nil
}
func parseFlags(args []string) (url, code string, allow []string, root string, yes bool) {
type flags struct {
url, code, root, key, machine string
allow []string
yes bool
}
func parseFlags(args []string) flags {
var f flags
for i := 0; i < len(args); i++ {
a := args[i]
next := func() string {
@@ -162,20 +179,24 @@ func parseFlags(args []string) (url, code string, allow []string, root string, y
}
switch {
case a == "--code":
code = next()
f.code = next()
case a == "--key":
f.key = next()
case a == "--machine":
f.machine = next()
case a == "--allow":
for _, p := range strings.Split(next(), ",") {
if p = strings.TrimSpace(p); p != "" {
allow = append(allow, p)
f.allow = append(f.allow, p)
}
}
case a == "--root":
root = next()
f.root = next()
case a == "--yes" || a == "-y":
yes = true
case !strings.HasPrefix(a, "-") && url == "":
url = a
f.yes = true
case !strings.HasPrefix(a, "-") && f.url == "":
f.url = a
}
}
return
return f
}
Binary file not shown.
Binary file not shown.
+6 -6
View File
@@ -2,14 +2,14 @@
"FixedFileInfo": {
"FileVersion": {
"Major": 0,
"Minor": 8,
"Patch": 9,
"Minor": 9,
"Patch": 0,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 8,
"Patch": 9,
"Minor": 9,
"Patch": 0,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
"OriginalFilename": "ajean.exe",
"ProductName": "AJEAN",
"ProductVersion": "0.8.9",
"ProductVersion": "0.9.0",
"Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)"
},
"VarFileInfo": {
@@ -34,4 +34,4 @@
"CharsetID": "04B0"
}
}
}
}
+52 -24
View File
@@ -9,6 +9,7 @@
package ajean
import (
"encoding/base64"
"encoding/json"
"net/http"
"strings"
@@ -42,25 +43,48 @@ func handleNodePair(w http.ResponseWriter, r *http.Request) {
return
}
sendJSON(w, 200, map[string]any{
"ok": true,
"code": p.Code,
"caps": p.Caps,
"root": p.Root,
"expires": p.Expires,
"ttl_min": int(nodePairCodeTTL.Minutes()),
"ok": true,
"code": p.Code,
"caps": p.Caps,
"root": p.Root,
"expires": p.Expires,
"ttl_min": int(nodePairCodeTTL.Minutes()),
"machine": machineID(), // pour l'accès via ajean.link (/node/<machine>/)
"agent_pub": e2ePubHex(), // clé publique de l'agent (le poste scelle vers elle)
"fingerprint": e2eFingerprint(), // empreinte, ancre de confiance
})
}
// handleNodeEnroll (PUBLIC) échange un code d'appairage contre une clé
// d'appareil. Le code est à usage unique : consommé dès qu'il sert.
// handleNodeEnroll (PUBLIC) enrôle un poste. Le corps est un SCEAU anonyme vers
// la clé publique de l'agent contenant {pub, code, name, os} : le relais ne peut
// ni l'ouvrir, ni voir le code, ni la clé publique du poste. Si le code matche,
// la clé publique est enregistrée comme identité autorisée du poste.
func handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
var req struct {
Sealed string `json:"sealed"` // base64(ephPub||nonce||ct) vers la clé agent
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Sealed == "" {
sendJSON(w, 400, map[string]any{"error": "requête invalide"})
return
}
blob, err := base64.StdEncoding.DecodeString(req.Sealed)
if err != nil {
sendJSON(w, 400, map[string]any{"error": "format du sceau"})
return
}
plain, err := e2eOpenSeal(blob)
if err != nil {
sendJSON(w, 400, map[string]any{"error": "sceau invalide"})
return
}
var pm struct {
Pub string `json:"pub"`
Code string `json:"code"`
Name string `json:"name"`
OS string `json:"os"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"error": "requête invalide"})
if err := json.Unmarshal(plain, &pm); err != nil {
sendJSON(w, 400, map[string]any{"error": "contenu du sceau"})
return
}
pending, ok := loadPairPending()
@@ -68,23 +92,26 @@ func handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 403, map[string]any{"error": "aucun code d'appairage actif — générez-en un dans l'interface"})
return
}
if !strings.EqualFold(strings.TrimSpace(req.Code), pending.Code) {
if !strings.EqualFold(strings.TrimSpace(pm.Code), pending.Code) {
sendJSON(w, 403, map[string]any{"error": "code incorrect"})
return
}
// Consomme le code immédiatement (usage unique), même si la suite échoue.
clearPairPending()
clearPairPending() // usage unique
key := nodeRandHex(24)
name := strings.TrimSpace(req.Name)
pub := strings.ToLower(strings.TrimSpace(pm.Pub))
if len(pub) != 64 {
sendJSON(w, 400, map[string]any{"error": "clé publique du poste invalide"})
return
}
name := strings.TrimSpace(pm.Name)
if name == "" {
name = "poste"
}
node := pairedNode{
ID: nodeRandHex(8),
Name: name,
OS: strings.TrimSpace(req.OS),
KeyHash: nodeHashKey(key),
OS: strings.TrimSpace(pm.OS),
PubHex: pub,
Caps: pending.Caps,
Root: pending.Root,
CreatedAt: time.Now().Unix(),
@@ -94,14 +121,15 @@ func handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
// La clé n'est renvoyée QU'ICI, une seule fois : le poste la stocke en local.
// Réponse en clair : elle ne contient AUCUN secret (le secret, la clé privée,
// n'a jamais quitté le poste). machine_id sert au poste pour l'URL relais.
sendJSON(w, 200, map[string]any{
"ok": true,
"id": node.ID,
"key": key,
"caps": node.Caps,
"root": node.Root,
"name": node.Name,
"ok": true,
"id": node.ID,
"machine_id": machineID(),
"caps": node.Caps,
"root": node.Root,
"name": node.Name,
})
}
+106 -55
View File
@@ -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
+59 -58
View File
@@ -2,6 +2,7 @@ package ajean
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -11,15 +12,22 @@ import (
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"github.com/nathaninline/ajean/internal/nodewire"
)
// TestNodeEndToEnd exerce tout le chemin serveur : enrôlement via code, connexion
// WebSocket, négociation des capacités (intersection propriétaire ∩ poste), et
// routage d'un appel d'outil jusqu'au poste et retour.
func TestNodeEndToEnd(t *testing.T) {
// TestNodeE2E exerce tout le chemin : enrôlement SCELLÉ (le sceau du client est
// ouvert par e2eOpenSeal de l'agent → interop crypto validée), puis canal
// CHIFFRÉ poste↔agent (hello + appel d'outil + résultat), et refus d'une
// capacité non autorisée.
func TestNodeE2E(t *testing.T) {
testHome(t)
// Le propriétaire n'autorise que read + shell (pas write).
agentPub := e2ePubHex()
if agentPub == "" {
t.Fatal("clé publique agent indisponible")
}
// Le propriétaire autorise read + shell (pas write).
if err := savePairPending(nodePairPending{
Code: "ABCD1234",
Caps: []string{nodeCapRead, nodeCapShell},
@@ -34,104 +42,97 @@ func TestNodeEndToEnd(t *testing.T) {
srv := httptest.NewServer(mux)
defer srv.Close()
// 1) Enrôlement : le code est échangé contre une clé d'appareil.
body, _ := json.Marshal(map[string]string{"code": "abcd1234", "name": "testpc", "os": "linux/amd64"})
// 1) Le poste génère sa paire et SCELLE {pub, code, name, os} vers l'agent.
priv, pub, err := nodewire.GenKeyPair()
if err != nil {
t.Fatal(err)
}
inner, _ := json.Marshal(map[string]string{"pub": pub, "code": "abcd1234", "name": "testpc", "os": "linux/amd64"})
blob, err := nodewire.SealTo(agentPub, inner)
if err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]string{"sealed": base64.StdEncoding.EncodeToString(blob)})
resp, err := http.Post(srv.URL+"/api/node/enroll", "application/json", strings.NewReader(string(body)))
if err != nil {
t.Fatal(err)
}
var enr struct {
OK bool `json:"ok"`
Key string `json:"key"`
Caps []string `json:"caps"`
OK bool `json:"ok"`
}
_ = json.NewDecoder(resp.Body).Decode(&enr)
resp.Body.Close()
if !enr.OK || enr.Key == "" {
if !enr.OK {
t.Fatalf("enrôlement échoué: %+v", enr)
}
// Le code est à usage unique : un second enrôlement doit échouer.
resp2, _ := http.Post(srv.URL+"/api/node/enroll", "application/json", strings.NewReader(string(body)))
if resp2.StatusCode == 200 {
t.Fatal("le code d'appairage aurait dû être consommé (usage unique)")
if findNodeByPub(pub) == nil {
t.Fatal("la clé publique du poste n'a pas été enregistrée")
}
resp2.Body.Close()
// 2) Le poste se connecte et sert les appels.
// 2) Connexion WS chiffrée.
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/node/ws"
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
c, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{
HTTPHeader: http.Header{"Authorization": {"Bearer " + enr.Key}},
})
c, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer c.CloseNow()
// Le poste déclare pouvoir read/shell/write ; l'effectif sera ∩ = read/shell.
if err := wsjson.Write(ctx, c, nodeMsg{Type: "hello", Name: "testpc", OS: "linux/amd64",
Caps: []string{nodeCapRead, nodeCapShell, nodeCapWrite}}); err != nil {
t.Fatalf("hello: %v", err)
// hello_pub en clair, puis canal chiffré côté client.
if err := wsjson.Write(ctx, c, map[string]string{"type": "hello_pub", "pub": pub}); err != nil {
t.Fatal(err)
}
// Boucle client : répond à chaque call par "RESULT:<cap>".
key, _ := nodewire.ChannelKey(priv, agentPub)
ch, _ := nodewire.NewChan(key, true)
sendEnc := func(m nodewire.Msg) { _ = wsjson.Write(ctx, c, ch.Seal(mustJSON(m))) }
sendEnc(nodewire.Msg{Type: "hello", Name: "testpc", OS: "linux/amd64", Caps: []string{nodeCapRead, nodeCapShell, nodeCapWrite}})
// Boucle client : répond à chaque call chiffré par "RESULT:<cap>".
go func() {
for {
var m nodeMsg
if err := wsjson.Read(context.Background(), c, &m); err != nil {
var fr nodewire.Frame
if err := wsjson.Read(context.Background(), c, &fr); err != nil {
return
}
if m.Type == "call" {
_ = wsjson.Write(context.Background(), c, nodeMsg{Type: "result", ID: m.ID, Result: "RESULT:" + m.Cap})
plain, err := ch.Open(fr)
if err != nil {
return
}
var m nodewire.Msg
if json.Unmarshal(plain, &m) == nil && m.Type == "call" {
sendEnc(nodewire.Msg{Type: "result", ID: m.ID, Result: "RESULT:" + m.Cap})
}
}
}()
// Attend l'enregistrement du poste.
// Attend l'enregistrement.
slug := nodeSlug("testpc")
var nc *nodeConn
for i := 0; i < 100; i++ {
for i := 0; i < 200; i++ {
if nc = nodeGet(slug); nc != nil {
break
}
time.Sleep(10 * time.Millisecond)
}
if nc == nil {
t.Fatal("le poste ne s'est pas enregistré")
t.Fatal("le poste ne s'est pas enregistré (canal chiffré ?)")
}
// 3) Capacités effectives = intersection : read + shell, PAS write.
if len(nc.caps) != 2 || nc.caps[0] != nodeCapShell || nc.caps[1] != nodeCapRead {
t.Fatalf("capacités effectives inattendues: %v", nc.caps)
}
// 4) Un appel autorisé traverse jusqu'au poste et revient.
// 3) Appel autorisé routé jusqu'au poste et retour, à travers le chiffrement.
if got := nodeCall(slug, nodeCapRead, map[string]any{"path": "x"}); got != "RESULT:read" {
t.Fatalf("appel read: attendu RESULT:read, obtenu %q", got)
}
// 5) Un appel NON autorisé (write) est refusé côté serveur, sans atteindre le poste.
if got := nodeCall(slug, nodeCapWrite, map[string]any{"path": "x", "content": "y"}); !strings.Contains(got, "non autorisée") {
// 4) Appel non autorisé (write) refusé côté serveur.
if got := nodeCall(slug, nodeCapWrite, map[string]any{"path": "x"}); !strings.Contains(got, "non autorisée") {
t.Fatalf("appel write aurait dû être refusé, obtenu %q", got)
}
}
// 6) Sélection de la cible : l'agent agit sur ce poste. La méta reflète un
// poste connecté, et un appel shell (autorisé) est bien routé jusqu'au poste.
if err := setAgentTargetSlug(slug); err != nil {
t.Fatal(err)
}
meta, ok := nodeTargetMetaGet()
if !ok || !meta.connected || meta.slug != slug {
t.Fatalf("cible inattendue: ok=%v %+v", ok, meta)
}
if got := nodeCall(slug, nodeCapShell, map[string]any{"command": "echo hi"}); got != "RESULT:shell" {
t.Fatalf("routage shell: attendu RESULT:shell, obtenu %q", got)
}
// 7) nodeEditRemote lit puis réécrit via le poste (le client fictif renvoie
// "RESULT:read" à la lecture → "old" introuvable, donc erreur explicite, pas
// un plantage : on vérifie juste que le chemin read→write est emprunté).
if got := nodeEditRemote(slug, "f.txt", "absent", "x"); !strings.Contains(got, "introuvable") {
t.Fatalf("nodeEditRemote: attendu 'introuvable', obtenu %q", got)
}
setAgentTargetSlug("") // ne pas fuiter la cible sur d'autres tests
func mustJSON(v any) []byte {
b, _ := json.Marshal(v)
return b
}
+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
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
+1 -1
View File
@@ -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() {
+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="node-code" id="node-pair-code">——</div>
<div class="muted" style="font-size:12px;line-height:1.5">
Sur le PC à piloter, installe AJEAN puis lance :
Sur le PC à piloter, installe <b>ajean-remote</b> (ajean.link/download) puis lance — <b>accès à distance</b>, chiffré de bout en bout :
<pre class="node-cmd" id="node-pair-cmd"></pre>
<span style="opacity:.8">Ou en <b>réseau local</b> (connexion directe) :</span>
<pre class="node-cmd" id="node-pair-cmd-lan"></pre>
Le code expire dans <span id="node-pair-ttl">10</span> min et ne sert qu'une fois.
</div>
</div>
@@ -5504,8 +5506,17 @@ async function genNodeCode(){
document.getElementById('node-pair-ttl').textContent = r.ttl_min || 10;
const allow = caps.join(',');
const rootArg = root ? (' --root "'+root+'"') : '';
document.getElementById('node-pair-cmd').textContent =
'ajean-remote install '+location.origin+' --code '+r.code+' --allow '+allow+rootArg;
// Commande d'accès À DISTANCE (partout) via ajean.link : chiffrée de bout en
// bout, le relais reste aveugle. --key = clé publique de l'agent (le poste
// scelle vers elle), --machine = quelle machine joindre.
const remote = 'ajean-remote install https://ajean.link --machine '+r.machine+
' --key '+r.agent_pub+' --code '+r.code+' --allow '+allow+rootArg;
// Variante RÉSEAU LOCAL (connexion directe, sans passer par le relais).
const lan = 'ajean-remote install '+location.origin+
' --key '+r.agent_pub+' --code '+r.code+' --allow '+allow+rootArg;
document.getElementById('node-pair-cmd').textContent = remote;
const lanEl = document.getElementById('node-pair-cmd-lan');
if(lanEl) lanEl.textContent = lan;
document.getElementById('node-pair-out').style.display = '';
loadNode();
}
+3 -1
View File
@@ -567,8 +567,10 @@
<div class="subhead" style="margin-top:6px">Code d'appairage</div>
<div class="node-code" id="node-pair-code">——</div>
<div class="muted" style="font-size:12px;line-height:1.5">
Sur le PC à piloter, installe AJEAN puis lance :
Sur le PC à piloter, installe <b>ajean-remote</b> (ajean.link/download) puis lance — <b>accès à distance</b>, chiffré de bout en bout :
<pre class="node-cmd" id="node-pair-cmd"></pre>
<span style="opacity:.8">Ou en <b>réseau local</b> (connexion directe) :</span>
<pre class="node-cmd" id="node-pair-cmd-lan"></pre>
Le code expire dans <span id="node-pair-ttl">10</span> min et ne sert qu'une fois.
</div>
</div>
+11 -2
View File
@@ -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();
}
+74 -21
View File
@@ -13,6 +13,7 @@ package nodeclient
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -38,16 +39,35 @@ const (
)
// Config persiste l'appairage et les préférences locales du poste.
//
// L'identité du poste = sa paire de clés X25519 (Priv/Pub). Le canal vers l'agent
// est chiffré de bout en bout (relais aveugle). AgentPub est la clé publique de
// l'agent (fournie à l'install, hors-bande, via la commande d'appairage) : le
// poste scelle vers elle et en dérive la clé de canal. MachineID, s'il est
// renseigné, fait passer par le relais ajean.link (/node/<machine>/).
type Config struct {
ServerURL string `json:"server_url"`
ID string `json:"id"`
Key string `json:"key"`
Priv string `json:"priv"` // clé privée X25519 du poste (hex) — LE secret
Pub string `json:"pub"` // clé publique X25519 du poste (hex)
AgentPub string `json:"agent_pub"`
MachineID string `json:"machine_id,omitempty"` // non vide = passer par le relais
Name string `json:"name"`
Caps []string `json:"caps"`
Root string `json:"root"`
AutoYes bool `json:"auto_yes"`
}
// baseURL est la racine à laquelle joindre l'agent : directe (LAN) ou via le
// relais ajean.link quand un MachineID est configuré.
func (c Config) baseURL() string {
u := strings.TrimRight(c.ServerURL, "/")
if c.MachineID != "" {
u += "/node/" + c.MachineID
}
return u
}
// dataDir est le dossier de données du poste. Sur Windows : C:\ProgramData\
// ajean-remote — MACHINE-WIDE, pour que le service (qui tourne en LocalSystem) lise
// la MÊME config que celle écrite par `install` (le profil utilisateur de
@@ -102,13 +122,27 @@ func SaveConfig(c Config) error {
return os.WriteFile(p, b, 0o600) // 0600 : la clé d'appareil est un secret
}
// Enroll échange un code d'appairage contre une clé d'appareil et complète cfg.
// Enroll appaire le poste : génère sa paire de clés, SCELLE {pub, code, name, os}
// vers la clé publique de l'agent (le relais ne voit rien), et l'envoie. La clé
// privée ne quitte JAMAIS le poste.
func Enroll(cfg *Config, code string) error {
if cfg.AgentPub == "" {
return errors.New("clé publique de l'agent requise (--key, fournie par la commande d'appairage)")
}
priv, pub, err := nodewire.GenKeyPair()
if err != nil {
return err
}
name, _ := os.Hostname()
body, _ := json.Marshal(map[string]string{
"code": code, "name": name, "os": runtime.GOOS + "/" + runtime.GOARCH,
inner, _ := json.Marshal(map[string]string{
"pub": pub, "code": code, "name": name, "os": runtime.GOOS + "/" + runtime.GOARCH,
})
endpoint := strings.TrimRight(cfg.ServerURL, "/") + "/api/node/enroll"
blob, err := nodewire.SealTo(cfg.AgentPub, inner)
if err != nil {
return fmt.Errorf("scellement: %w", err)
}
body, _ := json.Marshal(map[string]string{"sealed": base64.StdEncoding.EncodeToString(blob)})
endpoint := cfg.baseURL() + "/api/node/enroll"
req, _ := http.NewRequest("POST", endpoint, strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
@@ -119,20 +153,19 @@ func Enroll(cfg *Config, code string) error {
var out struct {
OK bool `json:"ok"`
ID string `json:"id"`
Key string `json:"key"`
Caps []string `json:"caps"`
Root string `json:"root"`
Name string `json:"name"`
Err string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&out)
if !out.OK || out.Key == "" {
if !out.OK {
if out.Err == "" {
out.Err = "réponse inattendue du serveur (" + resp.Status + ")"
}
return errors.New(out.Err)
}
cfg.ID, cfg.Key, cfg.Caps = out.ID, out.Key, out.Caps
cfg.Priv, cfg.Pub, cfg.ID, cfg.Caps = priv, pub, out.ID, out.Caps
if out.Root != "" {
cfg.Root = out.Root
}
@@ -165,8 +198,8 @@ func Run(ctx context.Context, cfg Config, quiet bool) {
}
}
func wsURL(server string) string {
s := strings.TrimRight(server, "/")
func wsURL(cfg Config) string {
s := cfg.baseURL()
switch {
case strings.HasPrefix(s, "https://"):
s = "wss://" + strings.TrimPrefix(s, "https://")
@@ -181,35 +214,55 @@ func wsURL(server string) string {
func session(ctx context.Context, cfg Config, quiet bool) error {
dialCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
c, _, err := websocket.Dial(dialCtx, wsURL(cfg.ServerURL), &websocket.DialOptions{
HTTPHeader: http.Header{"Authorization": {"Bearer " + cfg.Key}},
})
c, _, err := websocket.Dial(dialCtx, wsURL(cfg), nil)
if err != nil {
return fmt.Errorf("connexion: %w", err)
}
c.SetReadLimit(-1)
defer c.CloseNow()
hello := nodewire.Msg{Type: "hello", Name: cfg.Name, OS: runtime.GOOS + "/" + runtime.GOARCH, Caps: cfg.Caps}
if err := wsjson.Write(dialCtx, c, hello); err != nil {
// Poignée de main : 1er frame EN CLAIR = notre clé publique (elle est publique).
if err := wsjson.Write(dialCtx, c, map[string]string{"type": "hello_pub", "pub": cfg.Pub}); err != nil {
return fmt.Errorf("hello_pub: %w", err)
}
// Canal chiffré : K = ECDH(notre priv, clé publique agent). Le relais ne l'a pas.
key, err := nodewire.ChannelKey(cfg.Priv, cfg.AgentPub)
if err != nil {
return fmt.Errorf("canal: %w", err)
}
ch, err := nodewire.NewChan(key, true) // poste = côté d'envoi 1
if err != nil {
return err
}
sendEnc := func(m nodewire.Msg) error {
raw, _ := json.Marshal(m)
return wsjson.Write(ctx, c, ch.Seal(raw))
}
// 1er frame CHIFFRÉ : le hello (nom/os/capacités). Prouve qu'on a la bonne clé.
if err := sendEnc(nodewire.Msg{Type: "hello", Name: cfg.Name, OS: runtime.GOOS + "/" + runtime.GOARCH, Caps: cfg.Caps}); err != nil {
return fmt.Errorf("hello: %w", err)
}
if !quiet {
fmt.Printf("[node] connecté ✓ (en attente des demandes de l'IA)\n")
fmt.Printf("[node] connecté ✓ (canal chiffré, en attente des demandes de l'IA)\n")
}
for {
var fr nodewire.Frame
if err := wsjson.Read(ctx, c, &fr); err != nil {
return err
}
plain, err := ch.Open(fr)
if err != nil {
return fmt.Errorf("déchiffrement: %w", err)
}
var m nodewire.Msg
if err := wsjson.Read(ctx, c, &m); err != nil {
if err := json.Unmarshal(plain, &m); err != nil {
return err
}
if m.Type != "call" {
continue
}
result := execute(ctx, cfg, m, quiet)
wc, wcancel := context.WithTimeout(ctx, 15*time.Second)
err := wsjson.Write(wc, c, nodewire.Msg{Type: "result", ID: m.ID, Result: result})
wcancel()
if err != nil {
if err := sendEnc(nodewire.Msg{Type: "result", ID: m.ID, Result: result}); err != nil {
return err
}
}
+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))
}
}