License — Cookbook

Copy-paste programmes complets. Chaque recette compile telle quelle après go get github.com/oioio-space/maldev@latest.

Nouveau dans ce domaine ? Lis d'abord concepts.md — explique le vocabulaire et le cycle de vie sans jargon crypto. Pour les questions ponctuelles, va voir la FAQ.

Recettes "essentielles" :

Recettes "production" :

Recettes "scénarios métier" :


Recette 1

Génère une paire de clés, signe une licence, vérifie-la. Tout en RAM, zéro fichier.

package main

import (
	"fmt"
	"log"
	"time"

	"github.com/oioio-space/maldev/license"
)

func main() {
	pub, priv, err := license.GenerateKey()
	if err != nil {
		log.Fatal(err)
	}

	// Émission "one-liner" (KeyID = "default", aucune contrainte hors NotAfter).
	data, err := license.New(priv, "alice@example.com", 24*time.Hour)
	if err != nil {
		log.Fatal(err)
	}

	// Vérification.
	v, err := license.Verify(data, license.Trusted{Keys: license.SingleKey("default", pub)})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("OK — délivrée à %s, expire %s\n", v.Subject, v.NotAfter)
}

license.New(priv, subject, ttl) est le raccourci minimal. Pour ajouter Issuer, Audience, Bindings, Payload, etc., passe à license.Issue — Recette 2.


Recette 2

Émission CLI, vérification binaire, fichiers PEM sur disque.

a. Côté émetteur

package main

import (
	"log"
	"os"
	"time"

	"github.com/oioio-space/maldev/license"
)

func main() {
	dir, _ := os.UserHomeDir()
	dir += "/.maldev-issuer"
	_ = os.MkdirAll(dir, 0o700)

	// GenerateAndSave : génère + écrit issuer.key (0600) + issuer.pub (KID embarqué).
	_, priv, err := license.GenerateAndSave(dir, "k2026-05")
	if err != nil {
		log.Fatal(err)
	}

	data, err := license.Issue(license.IssueOptions{
		PrivateKey: priv,
		KeyID:      "k2026-05",
		Subject:    "alice@example.com",
		Issuer:     "lab-eu",
		Audience:   []string{"rshell"},
		NotAfter:   time.Now().Add(90 * 24 * time.Hour),
	})
	if err != nil {
		log.Fatal(err)
	}

	if err := license.SaveLicense("./alice.license", data); err != nil {
		log.Fatal(err)
	}
	log.Print("Licence écrite : ./alice.license")
	log.Print("Distribue ./alice.license et ~/.maldev-issuer/issuer.pub")
}

b. Côté binaire consommateur

package main

import (
	"log"

	"github.com/oioio-space/maldev/license"
)

func main() {
	pub, kid, err := license.LoadPublicKey("./issuer.pub")
	if err != nil {
		log.Fatal(err)
	}

	v, err := license.VerifyFile("./alice.license",
		license.Trusted{Keys: license.SingleKey(kid, pub)},
		license.WithAudience("rshell"),
		license.WithIssuer("lab-eu"),
	)
	if err != nil {
		log.Fatalf("ACCESS DENIED: %v", err)
	}
	log.Printf("Autorisé — Subject=%s, KeyUsed=%s", v.Subject, v.KeyUsed)
}

Recette 3

Limiter à un set de machines + exiger un mot de passe.

Émission

import "github.com/oioio-space/maldev/license/hostid"

// Obtient le fingerprint de la machine cible (depuis cette machine).
machineA, _ := hostid.Local()

pwBinding, err := license.BindPassword("hunter2")
if err != nil {
	log.Fatal(err)
}

data, err := license.Issue(license.IssueOptions{
	PrivateKey: priv,
	KeyID:      "k2026-05",
	Subject:    "alice@example.com",
	NotAfter:   time.Now().Add(30 * 24 * time.Hour),
	Bindings: []license.Binding{
		license.BindMachineIDs(string(machineA)),
		pwBinding,
	},
})

Vérification

me, _ := hostid.Local()
v, err := license.Verify(data, trusted,
	license.WithMachineID(me),
	license.WithPassword("hunter2"),
)

Si me ∉ liste OU password ≠ argon2id stocké → ErrLicenseInvalid (cause précise loguée mais absente du message — pour ne pas guider un attaquant).


Recette 4

Identité embarquée — résiste au packer.

a. Générer l'identité (une fois par série de builds)

go run github.com/oioio-space/maldev/license/identity/cmd/gen-identity \
    -out cmd/rshell/identity.bin

Idempotent. Commit identity.bin pour que toute l'équipe partage la même identité par binaire.

b. Embarquer dans le binaire

package main

import (
	_ "embed"
	"log"

	"github.com/oioio-space/maldev/license"
	"github.com/oioio-space/maldev/license/identity"
)

//go:embed identity.bin
var identityBytes []byte

func main() {
	identity.Set(identityBytes)

	pub, kid, _ := license.LoadPublicKey("issuer.pub")
	if _, err := license.VerifyFile("rshell.license",
		license.Trusted{Keys: license.SingleKey(kid, pub)},
		license.WithBinaryPinning(),
	); err != nil {
		log.Fatal(err)
	}
}

c. Émettre pour cette identité

identityBytes, _ := os.ReadFile("cmd/rshell/identity.bin")
data, _ := license.Issue(license.IssueOptions{
	PrivateKey:     priv,
	KeyID:          "k2026-05",
	Subject:        "alice",
	IdentitySHA256: license.HashIdentity(identityBytes),
	NotAfter:       time.Now().Add(180 * 24 * time.Hour),
})

La licence reste valide à travers cmd/packer pack, re-packing, strip, signature Authenticode — tant que identity.bin reste embarqué.


Recette 5

Serveur HTTP minimal — revocation list signée + heartbeat. ~30 lignes.

package main

import (
	"log"
	"net/http"
	"os"
	"time"

	"github.com/oioio-space/maldev/license"
	"github.com/oioio-space/maldev/license/server"
)

func main() {
	priv, err := license.LoadPrivateKey("/etc/maldev/issuer.key")
	if err != nil {
		log.Fatal(err)
	}

	mux := http.NewServeMux()
	mux.Handle("/revoked.pem", server.NewRevocationHandler(server.RevocationOptions{
		PrivateKey: priv,
		KeyID:      "k2026-05",
		Store:      server.FileStore("/var/lib/maldev/revoked.json"),
		ValidFor:   7 * 24 * time.Hour,
		AdminToken: os.Getenv("MALDEV_ADMIN_TOKEN"),
	}))
	mux.Handle("/heartbeat", server.NewHeartbeatHandler(server.HeartbeatOptions{
		PrivateKey: priv,
		KeyID:      "k2026-05",
		Store:      server.StaticLicenseStore{}, // remplace par ta propre LicenseStore (DB, fichier…)
		ValidFor:   time.Hour,
	}))

	log.Fatal(http.ListenAndServeTLS(":8443", "cert.pem", "key.pem", mux))
}

Révoquer / annuler une révocation (admin)

# Révoquer
curl -X POST https://lic.example.com/revoked.pem \
     -H "Authorization: Bearer $MALDEV_ADMIN_TOKEN" \
     -d '{"add":["<license-id>"]}'

# Annuler
curl -X POST https://lic.example.com/revoked.pem \
     -H "Authorization: Bearer $MALDEV_ADMIN_TOKEN" \
     -d '{"remove":["<license-id>"]}'

Recette 6

Verify "production" — pinning + révocation + heartbeat + state file + NTP soft + grace period offline.

package main

import (
	"context"
	"log"
	"os"
	"time"

	"github.com/oioio-space/maldev/license"
	"github.com/oioio-space/maldev/license/heartbeat"
	"github.com/oioio-space/maldev/license/hostid"
	"github.com/oioio-space/maldev/license/revoke"
)

func main() {
	pub, kid, _ := license.LoadPublicKey("issuer.pub")
	state := os.Getenv("HOME") + "/.maldev/license-state"

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	v, err := license.VerifyFile("rshell.license",
		license.Trusted{Keys: license.SingleKey(kid, pub)},

		// Scope.
		license.WithAudience("rshell"),
		license.WithIssuer("lab-eu"),
		license.WithMachineID(must(hostid.Local())),

		// Pinning binaire/identité.
		license.WithBinaryPinning(),

		// Révocation hybride : HTTP en priorité, fallback fichier, cache local.
		license.WithRevocation(
			revoke.MultiSource(
				revoke.HTTPSource("https://lic.example.com/revoked.pem", nil),
				revoke.FileSource("/etc/maldev/revoked.pem.cached"),
			),
			24*time.Hour,
			state+".revoke",
		),
		license.WithGracePeriod(7*24*time.Hour),

		// Heartbeat : ping rate-limité à 1/h.
		license.WithHeartbeat(
			heartbeat.HTTPClient("https://lic.example.com/heartbeat", nil),
			time.Hour,
		),

		// Anti-tamper d'horloge.
		license.WithStateFile(state),
		license.WithStateHostID(hostid.Local),
		license.WithMaxClockSkew(5*time.Minute),

		// NTP en soft warning (ne refuse pas).
		license.WithNTPCheck("pool.ntp.org:123", 10*time.Minute),

		// Timeout global réseau.
		license.WithContext(ctx),
	)
	if err != nil {
		log.Fatalf("license check failed: %v", err)
	}
	for _, w := range v.Warnings {
		log.Printf("[warn] %s", w)
	}
	log.Printf("autorisé — %s (key %s)", v.Subject, v.KeyUsed)
}

func must[T any](v T, err error) T {
	if err != nil {
		log.Fatal(err)
	}
	return v
}

Recette 7

Rotation de clé sans casser les licences existantes.

oldPub, _, _ := license.LoadPublicKey("/etc/maldev/issuer-2026-05.pub")
newPub, _, _ := license.LoadPublicKey("/etc/maldev/issuer-2026-11.pub")

trusted := license.Trusted{
	Keys: map[string]ed25519.PublicKey{
		"k2026-05": oldPub, // gardée jusqu'à expiry des dernières licences
		"k2026-11": newPub, // active maintenant
	},
}
_, err := license.VerifyFile("./client.license", trusted, /* options */)

Workflow recommandé :

  1. Génère et déploie la nouvelle clé : license.GenerateAndSave("/etc/maldev/issuer-2026-11/", "k2026-11")
  2. Push la nouvelle issuer-2026-11.pub à tous les binaires via release.
  3. Émets les nouvelles licences avec KeyID: "k2026-11".
  4. Attends que toutes les licences k2026-05 aient expiré (max(NotAfter)).
  5. Retire k2026-05 de Trusted.Keys dans les binaires à la release suivante.
  6. Détruis issuer-2026-05.key (HSM/disque).

Recette 7-bis — Payload applicatif typé

Embarquer un struct Go arbitraire dans la licence et le récupérer typé après vérification.

Le champ License.Payload est du JSON arbitraire signé en clair. Tu fournis n'importe quel objet sérialisable, et tu le récupères au choix avec (*Verified).Decode(&target) (style stdlib) ou PayloadAs[T](v) (générique).

Émission

type Config struct {
	Endpoint string   `json:"endpoint"`
	Tier     int      `json:"tier"`
	Tags     []string `json:"tags,omitempty"`
}

cfg := Config{Endpoint: "https://c2.example.com", Tier: 3, Tags: []string{"redteam"}}
raw, err := license.MarshalPayload(cfg)
if err != nil {
	log.Fatal(err)
}

data, _ := license.Issue(license.IssueOptions{
	PrivateKey: priv, KeyID: "k1", Subject: "alice",
	NotAfter: time.Now().Add(24 * time.Hour),
	Payload:  raw,
})

Vérification — style stdlib

v, err := license.Verify(data, trusted)
if err != nil {
	log.Fatal(err)
}
var cfg Config
if err := v.Decode(&cfg); err != nil {
	if errors.Is(err, license.ErrNoPayload) {
		log.Print("licence sans payload — j'utilise la config par défaut")
	} else {
		log.Fatalf("payload corrompu: %v", err)
	}
}
fmt.Println(cfg.Endpoint)

Vérification — style générique

v, _ := license.Verify(data, trusted)
cfg, err := license.PayloadAs[Config](v)
if err != nil {
	log.Fatal(err)
}
fmt.Println(cfg.Endpoint) // *Config typé, prêt à l'emploi

Le payload peut être n'importe quel type marshalable JSON : struct, map[string]any, []string, primitives. Le contenu est signé par l'émetteur — toute altération entre émission et vérification fait échouer Verify avec ErrLicenseInvalid.

Différence avec SealedPayload : Payload est signé en clair (lisible par n'importe qui qui détient la licence). SealedPayload est chiffré pour un destinataire spécifique (Recette 8) — utile pour des secrets sensibles.


Recette 8

Payload chiffré pour un destinataire — sealed payload.

import "github.com/oioio-space/maldev/license/seal"

// 1. Le destinataire publie sa clé publique X25519.
recipientPub, recipientPriv, _ := seal.GenerateRecipient()

// 2. L'émetteur scelle un payload pour ce destinataire.
secretConfig := []byte(`{"endpoint":"https://c2.example.com","token":"xxx"}`)
sealed, _ := seal.Seal(recipientPub, secretConfig)

// 3. La licence transporte le scellé. Signée publiquement par l'issuer,
//    mais le contenu n'est lisible que par recipientPriv.
data, _ := license.Issue(license.IssueOptions{
	PrivateKey:    priv,
	KeyID:         "k1",
	Subject:       "alice",
	NotAfter:      time.Now().Add(24 * time.Hour),
	SealedPayload: sealed,
})

// 4. Côté binaire : déchiffre après Verify.
v, err := license.Verify(data, trusted)
if err != nil { /* ... */ }
config, err := seal.Open(recipientPriv, v.SealedPayload)
if err != nil { /* clé X25519 incorrecte */ }
fmt.Println("config:", string(config))

Sous le capot : X25519 ECDH → HKDF-SHA256 → XChaCha20-Poly1305 AEAD avec l'ephemeral public key comme AAD.


Recette 9

Période d'essai gratuite de 14 jours, une par utilisateur.

L'idée : émettre une licence à durée fixe avec un payload qui marque le début de l'essai. Le binaire lit le payload et adapte ses fonctionnalités (ou refuse certaines actions) en fonction.

type Trial struct {
	StartedAt time.Time `json:"started_at"`
	Plan      string    `json:"plan"`
}

raw, _ := license.MarshalPayload(Trial{StartedAt: time.Now(), Plan: "trial-14d"})
data, _ := license.Issue(license.IssueOptions{
	PrivateKey: priv, KeyID: "k1",
	Subject:    "tester@example.com",
	NotAfter:   time.Now().Add(14 * 24 * time.Hour),
	Payload:    raw,
})
license.SaveLicense(fmt.Sprintf("./trials/%s.license", "tester@example.com"), data)

Côté binaire :

v, err := license.Verify(data, trusted)
if err != nil {
	if isExpiredCause(err) { // log analysis
		fmt.Println("Votre période d'essai est terminée.")
	}
	os.Exit(1)
}
trial, _ := license.PayloadAs[Trial](v)
fmt.Printf("Essai démarré le %s — plan %s\n", trial.StartedAt.Format("2006-01-02"), trial.Plan)

Recette 10

Niveaux de licence (basic / pro / enterprise) avec Features first-class.

Le champ License.Features est signé au niveau racine — lisible par Verified.HasFeature(name) sans désérialisation. Pour les paramètres qui ne sont pas des booléens (quotas, identifiants), Payload reste disponible.

data, _ := license.Issue(license.IssueOptions{
	PrivateKey: priv, KeyID: "k1", Subject: "client",
	NotAfter: time.Now().Add(365 * 24 * time.Hour),
	Features: []string{"export", "api", "advanced-recon"},
})

Côté binaire :

v, err := license.Verify(data, trusted)
if err != nil {
	log.Fatal(err)
}
if !v.HasFeature("export") {
	return errors.New("export non disponible dans votre licence — passez à un plan supérieur")
}

Pour combiner avec des quotas :

type Quota struct {
	MaxParallel int `json:"max_parallel"`
}
raw, _ := license.MarshalPayload(Quota{MaxParallel: 8})
data, _ := license.Issue(license.IssueOptions{
	PrivateKey: priv, KeyID: "k1", Subject: "client",
	NotAfter: time.Now().Add(365 * 24 * time.Hour),
	Features: []string{"export", "api"},   // booléens : HasFeature
	Payload:  raw,                          // chiffres et autres : PayloadAs[T]
})

Features et Payload sont tous deux signés : impossible pour l'utilisateur de modifier son plan sans casser la signature.


Recette 11

Distribuer 50 licences en batch à partir d'un CSV.

package main

import (
	"encoding/csv"
	"log"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/oioio-space/maldev/license"
)

func main() {
	priv, _ := license.LoadPrivateKey("/etc/maldev/issuer.key")

	f, _ := os.Open("subscribers.csv")
	defer f.Close()
	rows, _ := csv.NewReader(f).ReadAll()

	_ = os.MkdirAll("./out", 0o755)

	for _, row := range rows[1:] { // skip header
		email, plan := row[0], row[1]
		ttl := 365 * 24 * time.Hour
		if plan == "trial" {
			ttl = 14 * 24 * time.Hour
		}

		data, err := license.Issue(license.IssueOptions{
			PrivateKey: priv, KeyID: "k1",
			Subject:  email,
			NotAfter: time.Now().Add(ttl),
			Audience: []string{"rshell"},
		})
		if err != nil {
			log.Printf("[skip] %s: %v", email, err)
			continue
		}

		// Slug-safe filename à partir de l'email.
		slug := strings.ReplaceAll(email, "@", "_at_")
		out := filepath.Join("out", slug+".license")
		if err := license.SaveLicense(out, data); err != nil {
			log.Printf("[skip] %s: %v", email, err)
			continue
		}
		log.Printf("[ok] %s → %s", email, out)
	}
}

Format attendu de subscribers.csv :

email,plan
alice@example.com,pro
bob@example.com,trial

Recette 12

Inspecter une licence sans la valider — pour le diagnostic.

data, _ := os.ReadFile("./alice.license")
lic, err := license.Inspect(data)
if err != nil {
	log.Fatal(err)
}
fmt.Printf("Subject:  %s\n", lic.Subject)
fmt.Printf("Issuer:   %s\n", lic.Issuer)
fmt.Printf("KeyID:    %s\n", lic.KeyID)
fmt.Printf("NotAfter: %s\n", lic.NotAfter.Format(time.RFC3339))
fmt.Printf("Audience: %v\n", lic.Audience)
fmt.Printf("Bindings: %d\n", len(lic.Bindings))
for _, b := range lic.Bindings {
	fmt.Printf("  - type=%s, values=%v\n", b.Type, b.Value)
}

Inspect lit le contenu sans vérifier la signature. À utiliser uniquement pour le diagnostic — jamais pour autoriser une action.


Recette 13

Tests unitaires : générer des licences à la volée dans un testing.T.

func TestMyTool_RefusesExpiredLicense(t *testing.T) {
	pub, priv, _ := license.GenerateKey()
	expired, _ := license.Issue(license.IssueOptions{
		PrivateKey: priv, KeyID: "k1", Subject: "test",
		NotAfter: time.Now().Add(-time.Hour), // expirée
	})

	myTool := NewTool(WithTrusted(license.Trusted{Keys: license.SingleKey("k1", pub)}))
	if err := myTool.Run(expired); err == nil {
		t.Fatal("expected refusal on expired license")
	}
}

func TestMyTool_AcceptsValidLicense(t *testing.T) {
	pub, priv, _ := license.GenerateKey()
	valid, _ := license.New(priv, "test", time.Hour)

	myTool := NewTool(WithTrusted(license.Trusted{Keys: license.SingleKey("k1", pub)}))
	if err := myTool.Run(valid); err != nil {
		t.Fatalf("expected accept, got %v", err)
	}
}

Pour injecter une horloge déterministe :

clk := &license.FakeClock{T: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
license.Verify(data, trusted, license.WithClock(clk))

Recette 14

Plusieurs binaires partageant la même paire de clés, scopés par Audience.

Émission

priv, _ := license.LoadPrivateKey("./issuer.key")

// Une seule licence pour Alice, vaut pour rshell + memscan :
licMulti, _ := license.Issue(license.IssueOptions{
	PrivateKey: priv, KeyID: "k1",
	Subject:    "alice",
	Audience:   []string{"rshell", "memscan-server"},
	NotAfter:   time.Now().Add(90 * 24 * time.Hour),
})

// Une licence séparée pour Bob, uniquement memscan :
licMemOnly, _ := license.Issue(license.IssueOptions{
	PrivateKey: priv, KeyID: "k1",
	Subject:    "bob",
	Audience:   []string{"memscan-server"},
	NotAfter:   time.Now().Add(90 * 24 * time.Hour),
})

Côté chaque binaire

// cmd/rshell/main.go
license.Verify(data, trusted, license.WithAudience("rshell"))

// cmd/memscan/main.go
license.Verify(data, trusted, license.WithAudience("memscan-server"))

La licence de Bob (Audience=memscan-server) est refusée par rshell avec causeAudienceMismatch. La licence d'Alice fonctionne pour les deux.


Recette 15

Logger les causes d'échec en production pour le support.

import (
	"log/slog"
	"os"
)

func main() {
	// Logueur JSON structuré → pipe vers Loki/ELK/etc.
	logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
		Level: slog.LevelInfo,
	}))

	pub, kid, _ := license.LoadPublicKey("issuer.pub")
	v, err := license.VerifyFile("./user.license",
		license.Trusted{Keys: license.SingleKey(kid, pub)},
		license.WithLogger(logger),
	)
	if err != nil {
		// Le logueur a déjà émis un WARN avec la cause précise.
		// Le message rendu à l'utilisateur reste opaque.
		fmt.Fprintln(os.Stderr, "Cette licence n'est pas valide. Contactez le support avec votre ID.")
		os.Exit(1)
	}

	logger.Info("license accepted",
		"subject", v.Subject,
		"key_used", v.KeyUsed,
		"warnings", v.Warnings,
	)
}

Exemple de sortie sur un échec :

{"time":"2026-05-20T10:00:00Z","level":"WARN","msg":"license verify failed","cause":"binding-machine-mismatch"}

À pipe vers ton stack d'observabilité. Alertes utiles : clock-rollback ou binding-password-mismatch répétés sur un même subject (indice de brute-force ou tampering).


Recette 16

Embarquer la clé publique dans le binaire — pas de fichier externe à packager.

Le pattern : tu commit issuer.pub à côté du main.go du binaire consommateur. La directive //go:embed injecte son contenu dans une variable []byte au moment du go build. Tu parses ensuite ces bytes avec ParsePublicKey([]byte) — qui est la variante bytes-in de LoadPublicKey(path).

package main

import (
	_ "embed"
	"log"

	"github.com/oioio-space/maldev/license"
)

//go:embed issuer.pub
var issuerPub []byte

func main() {
	pub, kid, err := license.ParsePublicKey(issuerPub)
	if err != nil {
		log.Fatalf("issuer.pub corrompue ou absente : %v", err)
	}

	if _, err := license.VerifyFile("./user.license",
		license.Trusted{Keys: license.SingleKey(kid, pub)}); err != nil {
		log.Fatal("ACCESS DENIED")
	}
}

Variante : embarquer plusieurs clés (pour la rotation, ou pour accepter des licences de plusieurs émetteurs).

//go:embed issuer-2026-05.pub issuer-2026-11.pub
var pubFiles embed.FS

func loadTrusted() license.Trusted {
	keys := map[string]ed25519.PublicKey{}
	entries, _ := pubFiles.ReadDir(".")
	for _, e := range entries {
		data, _ := pubFiles.ReadFile(e.Name())
		pub, kid, err := license.ParsePublicKey(data)
		if err != nil {
			log.Printf("[skip] %s: %v", e.Name(), err)
			continue
		}
		keys[kid] = pub
	}
	return license.Trusted{Keys: keys}
}

Rappel : la clé publique se distribue sans risque (commit, embed, README). La clé privée ne quitte jamais le poste de l'émetteur. Voir concepts.md.


Recette 17

Générer un MALDEV_ADMIN_TOKEN.

Ce token est un secret partagé entre toi et ton serveur de révocation (Recette 5). Il authentifie les requêtes POST /revoked.pem qui ajoutent ou retirent des IDs. Sans token côté serveur, le endpoint POST est désactivé (read-only).

Caractéristiques attendues

  • Entropie ≥ 128 bits (~22 caractères base64 url-safe, ou 32 hex)
  • Stocké côté serveur en clair via variable d'environnement, ou dans un secrets manager (Vault, AWS Secrets Manager, sealed-secrets…)
  • Jamais committé, jamais loggué
  • Rotation périodique recommandée (annuelle, ou immédiate si compromission soupçonnée)

Génération en une commande

# Linux / macOS / Git Bash :
openssl rand -base64 32

# PowerShell :
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Max 256 } | ForEach-Object { [byte]$_ }))

# Avec Python :
python3 -c "import secrets; print(secrets.token_urlsafe(32))"

# Avec Go (le plus simple si tu veux scripter) :
go run -exec=- - <<'EOF'
package main
import ("crypto/rand"; "encoding/base64"; "fmt")
func main() { b := make([]byte, 32); rand.Read(b); fmt.Println(base64.URLEncoding.EncodeToString(b)) }
EOF

Résultat : une chaîne du genre cVnGEjRSdM2GR1Mxz5lFt-fOpkqsr8YgVRJP7H2ID5g.

Mise en place côté serveur

# Stockage local (acceptable en dev) :
export MALDEV_ADMIN_TOKEN="cVnGEjRSdM2GR1Mxz5lFt-fOpkqsr8YgVRJP7H2ID5g"
./mon-serveur-revocation

# systemd unit :
[Service]
Environment="MALDEV_ADMIN_TOKEN=…"
ExecStart=/usr/local/bin/mon-serveur-revocation

# Docker / Kubernetes : via secret monté en env var, pas dans l'image.

Utilisation pour révoquer / annuler une révocation

TOKEN="cVnGEjRSdM2GR1Mxz5lFt-fOpkqsr8YgVRJP7H2ID5g"

# Révoquer une licence :
curl -X POST https://lic.example.com/revoked.pem \
     -H "Authorization: Bearer $TOKEN" \
     -d '{"add":["73f56081-5cce-4073-9632-..."]}'

# Annuler :
curl -X POST https://lic.example.com/revoked.pem \
     -H "Authorization: Bearer $TOKEN" \
     -d '{"remove":["73f56081-5cce-4073-9632-..."]}'

Sécurité

Le check côté handler utilise subtle.ConstantTimeCompare indirectement (via strings.HasPrefix + comparaison) — un attaquant qui brute-force ne peut pas exploiter de timing. Mais c'est juste un Bearer token : ne le laisse pas fuiter dans des logs HTTP, des screenshots, ou un repo public.


Recette 18

TOTP comme second facteur — provisioning QR code + vérification.

Le binding BindTOTP ajoute une exigence "code à 6 chiffres" au démarrage du binaire. L'utilisateur scanne un QR code une fois (avec Google Authenticator, Authy, Yubico Authenticator, 1Password, etc.), puis tape le code courant à chaque démarrage.

a. Provisionnement (côté émetteur)

package main

import (
	"fmt"
	"log"
	"os"
	"time"

	"github.com/oioio-space/maldev/license"
	"github.com/oioio-space/maldev/license/totp"
)

func main() {
	priv, _ := license.LoadPrivateKey("./issuer.key")

	// 1. Génère un secret 20 octets (format authenticator standard).
	secret, err := totp.NewSecret()
	if err != nil {
		log.Fatal(err)
	}

	// 2. Émet la licence avec le binding TOTP.
	data, err := license.Issue(license.IssueOptions{
		PrivateKey: priv, KeyID: "k1",
		Subject:    "alice@example.com",
		NotAfter:   time.Now().Add(90 * 24 * time.Hour),
		Bindings:   []license.Binding{license.BindTOTP(secret)},
	})
	if err != nil {
		log.Fatal(err)
	}
	license.SaveLicense("./alice.license", data)

	// 3. Affiche le QR code en ASCII dans le terminal pour scan immédiat.
	ascii, _ := totp.QRImageASCII(secret, "alice@example.com", "rshell")
	fmt.Println("Scanne ce QR avec ton authenticator (Google Authenticator, Authy, etc.):")
	fmt.Println()
	fmt.Println(ascii)

	// 4. Ou sauvegarde en PNG pour envoi sécurisé une seule fois.
	_ = totp.WriteQRImagePNG("./alice-totp.png", secret, "alice@example.com", "rshell", 256)
	fmt.Println("PNG aussi écrit dans ./alice-totp.png (à transmettre par canal sécurisé, à détruire après scan).")

	// 5. (Optionnel) imprime le secret en clair pour saisie manuelle si le scan échoue.
	fmt.Printf("\nSecret (à saisir manuellement si le scan ne marche pas) : %s\n", secret)

	_ = os.Stdin // hint au compilateur que os est utilisé
}

Sortie attendue (le QR fait ~50×50 caractères "█" et espaces, lisible sur un terminal large) :

Scanne ce QR avec ton authenticator (Google Authenticator, Authy, etc.):

████████████████████  ██  ██████      ██████████████
██          ██  ████  ████    ██  ████          ██████
██  ██████  ████  ██  ██  ██    ████  ██████  ██
...

b. Vérification (côté binaire)

package main

import (
	"bufio"
	"fmt"
	"log"
	"os"
	"strings"

	"github.com/oioio-space/maldev/license"
)

func main() {
	pub, kid, _ := license.LoadPublicKey("issuer.pub")

	// Demande le code à l'utilisateur.
	fmt.Print("Code TOTP (6 chiffres) : ")
	scanner := bufio.NewScanner(os.Stdin)
	scanner.Scan()
	code := strings.TrimSpace(scanner.Text())

	_, err := license.VerifyFile("./alice.license",
		license.Trusted{Keys: license.SingleKey(kid, pub)},
		license.WithTOTPCode(code),
	)
	if err != nil {
		log.Fatal("ACCESS DENIED — code incorrect ou licence invalide")
	}
	fmt.Println("Autorisé.")
}

Garanties et limites — sois transparent

GarantieDétail
✅ Le code change toutes les 30 secondesUn screenshot ou un copier-coller a une durée de vie courte.
✅ Tolérance ±1 fenêtre (30s)Absorbe une légère désynchronisation d'horloge.
✅ Comparaison en temps constantPas de timing leak.
⚠️ Le secret est dans la licenceQuelqu'un qui détient le fichier .license peut extraire le secret et reproduire les codes. C'est un speed bump, pas une vraie 2FA.
⚠️ Pas de protection contre le partage volontaireSi l'utilisateur scanne et donne son authenticator à un tiers, le tiers peut générer des codes.

Quand utiliser : ajouter une friction supplémentaire en plus de BindPassword. Un attaquant qui obtient seulement le fichier .license doit encore extraire le secret et le saisir dans son propre authenticator avant de produire un code — l'utilisateur légitime ne fait que taper 6 chiffres.

Quand ne pas utiliser : si la menace inclut un attaquant qui a déjà la licence (vol de fichier), TOTP seul ne change rien. Combine alors avec :

pwBinding, _ := license.BindPassword("strong-passphrase-known-only-by-user")
totpBinding := license.BindTOTP(secret) // secret stocké dans la licence MAIS le code change

Bindings: []license.Binding{pwBinding, totpBinding}

L'attaquant doit avoir : la licence, le mot de passe (non stocké côté disque), ET un authenticator configuré avec le secret.


API helpers (cheatsheet)

FonctionQuand l'utiliser
license.GenerateKey()Crée une paire Ed25519 en RAM.
license.GenerateAndSave(dir, kid)Génère + écrit issuer.key (0600) et issuer.pub (0644).
license.SavePrivateKey(path, priv)PEM MALDEV PRIVATE KEY, atomique, 0600.
license.LoadPrivateKey(path)Lit + parse.
license.SavePublicKey(path, pub, kid)PEM MALDEV PUBLIC KEY avec header KID:.
license.LoadPublicKey(path)Renvoie (pub, kid).
license.SingleKey(kid, pub)Sucre pour map[string]ed25519.PublicKey{kid: pub}.
license.New(priv, sub, ttl)Émission one-liner.
license.Issue(IssueOptions{...})Émission complète.
license.SaveLicense(path, data)Écrit le PEM MALDEV LICENSE.
license.LoadLicense(path)Lit les bytes (à passer à Verify).
license.Verify(data, trusted, ...)Vérification bytes-in.
license.VerifyFile(path, trusted, ...)Vérification fichier-in.
license.Inspect(data)Parse sans signature — diagnostic uniquement.
license.HashFile(path)sha256 hex d'un fichier (→ BinarySHA256).
license.HashIdentity(b)sha256 hex d'octets (→ IdentitySHA256).
license.MarshalPayload(v)Encode n'importe quel objet Go en JSON pour IssueOptions.Payload.
(*Verified).Decode(target)Désérialise le payload dans *target (style json.Unmarshal).
license.PayloadAs[T](v)Désérialise le payload en *T typé (générique).
license.ErrNoPayloadSentinel retourné si la licence n'a pas de payload.
license.BindMachineIDs(...)Binding liste OU.
license.BindPassword(p)Binding password (argon2id, params défauts).
license.BindPasswordWithParams(p, params)Binding password avec paramètres argon2id explicites.
license.DefaultArgon2idParams()Copie des params argon2id par défaut, prête à override.
(*Verified).HasFeature(name)Vérifie un entitlement de License.Features.
hostid.Composite()Fingerprint multi-sources (Local() + CPU brand + MAC primaire).
license.BindTOTP(secret)Binding RFC 6238 TOTP (6 chiffres, ±30 s).
license.BindCustom(name, v...)Binding custom k/v.
license.RegisterVerifier(name, fn)Hook pour types de binding custom.
license.WithTOTPCode(code)Évidence pour BindTOTP.
totp.NewSecret()Nouveau secret 20 octets base32.
totp.Code(secret, t)Calcule un code à une date donnée.
totp.Verify(secret, code, skew)Vérifie un code avec tolérance.
totp.URI(secret, account, issuer)Construit l'URI otpauth://.
totp.QRImagePNG(secret, ...)Image PNG du QR.
totp.QRImageASCII(secret, ...)Représentation ASCII (terminal).
totp.WriteQRImagePNG(path, ...)Écrit le QR PNG sur disque.
license.ParsePublicKey([]byte)Variante "bytes-in" pour //go:embed.
license.ParsePrivateKey([]byte)Variante "bytes-in".

Référence des champs

Documentation détaillée champ par champ dans license-framing.md.