mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 15:38:12 +02:00
v0.9.0 : poste distant accessible via ajean.link, chiffré de bout en bout (relais aveugle)
This commit is contained in:
1 parent
6d81f5c3f5
commit
1454dff2a7
16 files changed
+640
-199
No files matched your search
@@ -0,0 +1,201 @@
|
||||
package nodewire
|
||||
|
||||
// crypto.go — chiffrement de bout en bout du canal poste↔agent, pour que le
|
||||
// relais ajean.link ne voie que de l'opaque (« relais aveugle »), exactement
|
||||
// comme le canal navigateur↔agent.
|
||||
//
|
||||
// Deux briques, toutes en stdlib (crypto/ecdh X25519 + AES-GCM) :
|
||||
// - un SCEAU anonyme vers la clé publique de l'agent (enrôlement) : format
|
||||
// IDENTIQUE à e2eSeal/e2eOpenSeal de l'agent (ephPub32 || nonce12 || ct,
|
||||
// clé = SHA256(shared || ephPub || agentPub)) — donc l'agent ouvre le sceau
|
||||
// du poste avec son code existant, sans dupliquer la crypto ;
|
||||
// - un CANAL authentifié à clés statiques : K = SHA256(ECDH(postePriv,
|
||||
// agentPub) || "ajean-node-chan-v1"). Le poste et l'agent le calculent tous
|
||||
// les deux ; le relais, qui n'a aucune des deux clés privées, non. Chaque
|
||||
// message est scellé AES-GCM avec un nonce à compteur (anti-rejeu / anti-
|
||||
// réordonnancement à l'intérieur d'une connexion).
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GenKeyPair génère une paire X25519 et la renvoie en hex (priv, pub).
|
||||
func GenKeyPair() (privHex, pubHex string, err error) {
|
||||
k, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return hex.EncodeToString(k.Bytes()), hex.EncodeToString(k.PublicKey().Bytes()), nil
|
||||
}
|
||||
|
||||
// PubFromPriv recalcule la clé publique hex à partir de la privée hex.
|
||||
func PubFromPriv(privHex string) (string, error) {
|
||||
raw, err := hex.DecodeString(privHex)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
k, err := ecdh.X25519().NewPrivateKey(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(k.PublicKey().Bytes()), nil
|
||||
}
|
||||
|
||||
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
// SealTo scelle plaintext vers recipientPubHex (clé publique de l'agent). Le
|
||||
// format est celui qu'ouvre e2eOpenSeal de l'agent : ephPub32 || nonce12 || ct.
|
||||
func SealTo(recipientPubHex string, plaintext []byte) ([]byte, error) {
|
||||
recBytes, err := hex.DecodeString(recipientPubHex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recPub, err := ecdh.X25519().NewPublicKey(recBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
eph, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shared, err := eph.ECDH(recPub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := sealKey(shared, eph.PublicKey().Bytes(), recBytes)
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, 12)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ct := gcm.Seal(nil, nonce, plaintext, nil)
|
||||
out := make([]byte, 0, 32+12+len(ct))
|
||||
out = append(out, eph.PublicKey().Bytes()...)
|
||||
out = append(out, nonce...)
|
||||
out = append(out, ct...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// sealKey — identique à sealKey de l'agent (relay_e2e.go). Ne pas modifier sans
|
||||
// changer les deux : c'est ce qui garantit que l'agent ouvre le sceau du poste.
|
||||
func sealKey(shared, ephPub, recipientPub []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write(shared)
|
||||
h.Write(ephPub)
|
||||
h.Write(recipientPub)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// ChannelKey dérive la clé du canal poste↔agent à partir de la clé PRIVÉE (hex)
|
||||
// d'un bout et de la clé PUBLIQUE (hex) de l'autre. Symétrique : les deux bouts
|
||||
// obtiennent le même secret.
|
||||
func ChannelKey(privHex, peerPubHex string) ([]byte, error) {
|
||||
pr, err := hex.DecodeString(privHex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
priv, err := ecdh.X25519().NewPrivateKey(pr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pb, err := hex.DecodeString(peerPubHex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
peer, err := ecdh.X25519().NewPublicKey(pb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ss, err := priv.ECDH(peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h := sha256.New()
|
||||
h.Write(ss)
|
||||
h.Write([]byte("ajean-node-chan-v1"))
|
||||
return h.Sum(nil), nil
|
||||
}
|
||||
|
||||
// Frame est un message chiffré du canal : nonce (side||compteur) + ciphertext.
|
||||
type Frame struct {
|
||||
N []byte `json:"n"`
|
||||
C []byte `json:"c"`
|
||||
}
|
||||
|
||||
// Chan chiffre/déchiffre les messages d'un canal, avec des nonces à compteur
|
||||
// pour interdire rejeu et réordonnancement DANS la connexion. Chaque bout a un
|
||||
// « côté » (1 octet) distinct → les nonces des deux sens ne se croisent jamais.
|
||||
type Chan struct {
|
||||
gcm cipher.AEAD
|
||||
sendSide byte
|
||||
recvSide byte
|
||||
sendCtr uint64
|
||||
recvNext uint64
|
||||
}
|
||||
|
||||
// NewChan construit un canal à partir de la clé partagée. initiator=true pour le
|
||||
// POSTE (côté d'envoi 1), false pour l'AGENT (côté d'envoi 2). Les côtés doivent
|
||||
// être opposés des deux bouts, sinon les nonces entreraient en collision.
|
||||
func NewChan(key []byte, initiator bool) (*Chan, error) {
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &Chan{gcm: gcm}
|
||||
if initiator {
|
||||
c.sendSide, c.recvSide = 1, 2
|
||||
} else {
|
||||
c.sendSide, c.recvSide = 2, 1
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Chan) nonce(side byte, ctr uint64) []byte {
|
||||
n := make([]byte, 12)
|
||||
n[0] = side
|
||||
binary.BigEndian.PutUint64(n[1:9], ctr)
|
||||
return n
|
||||
}
|
||||
|
||||
// Seal chiffre un message sortant.
|
||||
func (c *Chan) Seal(plaintext []byte) Frame {
|
||||
n := c.nonce(c.sendSide, c.sendCtr)
|
||||
ct := c.gcm.Seal(nil, n, plaintext, nil)
|
||||
c.sendCtr++
|
||||
return Frame{N: n, C: ct}
|
||||
}
|
||||
|
||||
// Open déchiffre un message entrant et vérifie l'ordre (compteur strictement
|
||||
// croissant, bon côté) — un frame rejoué ou réordonné par le relais est rejeté.
|
||||
func (c *Chan) Open(f Frame) ([]byte, error) {
|
||||
if len(f.N) != 12 || f.N[0] != c.recvSide {
|
||||
return nil, errors.New("nonce invalide")
|
||||
}
|
||||
ctr := binary.BigEndian.Uint64(f.N[1:9])
|
||||
if ctr < c.recvNext {
|
||||
return nil, fmt.Errorf("rejeu détecté (compteur %d < %d)", ctr, c.recvNext)
|
||||
}
|
||||
plain, err := c.gcm.Open(nil, f.N, f.C, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("authentification échouée")
|
||||
}
|
||||
c.recvNext = ctr + 1
|
||||
return plain, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package nodewire
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// La clé de canal doit être IDENTIQUE des deux bouts (poste priv + agent pub =
|
||||
// agent priv + poste pub).
|
||||
func TestChannelKeySymmetric(t *testing.T) {
|
||||
aPriv, aPub, _ := GenKeyPair()
|
||||
bPriv, bPub, _ := GenKeyPair()
|
||||
ka, err := ChannelKey(aPriv, bPub)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kb, err := ChannelKey(bPriv, aPub)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(ka, kb) {
|
||||
t.Fatal("les deux bouts n'obtiennent pas la même clé de canal")
|
||||
}
|
||||
}
|
||||
|
||||
// Un aller-retour chiffré passe ; un frame rejoué est rejeté.
|
||||
func TestChanRoundTripAndReplay(t *testing.T) {
|
||||
aPriv, aPub, _ := GenKeyPair()
|
||||
bPriv, bPub, _ := GenKeyPair()
|
||||
ka, _ := ChannelKey(aPriv, bPub) // poste
|
||||
kb, _ := ChannelKey(bPriv, aPub) // agent
|
||||
|
||||
poste, _ := NewChan(ka, true)
|
||||
agent, _ := NewChan(kb, false)
|
||||
|
||||
f := poste.Seal([]byte("dir C:\\"))
|
||||
got, err := agent.Open(f)
|
||||
if err != nil || string(got) != "dir C:\\" {
|
||||
t.Fatalf("aller-retour: %q err=%v", got, err)
|
||||
}
|
||||
// Rejeu du même frame → rejeté (compteur non croissant).
|
||||
if _, err := agent.Open(f); err == nil {
|
||||
t.Fatal("un frame rejoué aurait dû être rejeté")
|
||||
}
|
||||
// Sens inverse.
|
||||
f2 := agent.Seal([]byte("exit: 0"))
|
||||
got2, err := poste.Open(f2)
|
||||
if err != nil || string(got2) != "exit: 0" {
|
||||
t.Fatalf("retour: %q err=%v", got2, err)
|
||||
}
|
||||
}
|
||||
|
||||
// SealTo doit produire le format ephPub32||nonce12||ct que l'agent sait ouvrir.
|
||||
func TestSealToFormat(t *testing.T) {
|
||||
_, pub, _ := GenKeyPair()
|
||||
blob, err := SealTo(pub, []byte("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(blob) < 32+12+16 {
|
||||
t.Fatalf("sceau trop court: %d octets", len(blob))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user