License framing — primitive défensive

Cadre cryptographiquement signé qui décide qui peut exécuter quel binaire, sur quelles machines, avec quels secrets, jusqu'à quand, sous quelle politique de révocation.

Hors-ligne par défaut, en-ligne en option. Aucun artefact réseau ou disque émis côté détection (le binaire n'envoie rien, sauf si l'opérateur active explicitement WithRevocation ou WithHeartbeat).

TL;DR — règles du jeu en une page

AspectValeur
Import pathgithub.com/oioio-space/maldev/license
RôleGate d'autorisation défensive à l'intérieur des binaires de recherche
SignatureEd25519, déterministe, 64 octets, pas d'algorithm-confusion
FormatPEM-armé MALDEV LICENSE enveloppant un JSON canonique base64
Bindingsmachine (liste), password (argon2id), custom (k/v + extensible)
En-ligneRevocationSource pluggable + heartbeat avec nonce echo (tous optionnels)
PinningSHA-256 du binaire sur disque + SHA-256 d'une identité embarquée (les deux optionnels)
Anti-tamper d'horlogePlancher signé trusted_floor + last-seen monotone, stocké dans un fichier HMAC
Erreur publiqueErrLicenseInvalid opaque ; la cause précise va dans slog, jamais dans err.Error()
Couches dépendancesLayer 1 — crypto/ed25519 stdlib + golang.org/x/crypto/{argon2,hkdf,chacha20poly1305,curve25519}
Tag Gov0.157.0+

Vocabulaire

TermeSignification
LicenseLe token signé qui autorise un binaire. Sérialisé en PEM MALDEV LICENSE.
KeyID (kid)Identifiant texte de la clé qui a signé. Permet la rotation : un binaire peut accepter plusieurs kid simultanément.
Issuer (iss)Texte libre désignant l'autorité émettrice (ex. "lab-eu"). Le binaire peut whitelist.
Subject (sub)À qui la licence est délivrée (email, hostname, nom d'agent…). Libre.
Audience (aud)Liste des binaires autorisés (ex. ["rshell", "memscan"]). Vide = wildcard avec warning.
BindingUne contrainte signée à l'intérieur de la licence. Trois types builtin (machine, password, custom:*) + extensible via RegisterVerifier.
EvidenceValeur fournie au moment de Verify qui doit matcher un binding (ex. WithMachineID(...) apporte l'évidence pour un binding machine).
Trustedstruct{Keys map[KeyID]ed25519.PublicKey} que le binaire connaît à la compilation. Contient une ou plusieurs clés acceptées.
RevocationSourceInterface Fetch(ctx) ([]byte, error) — d'où le binaire récupère la liste de révocation signée. Builtins : HTTP, File, Embed, Multi, + custom.
Identity32 octets aléatoires embarqués dans le binaire au build (via //go:embed). Survit au cmd/packer parce qu'il ne supprime pas les données embarquées.
TrustedFloorLe plus grand server_time jamais observé via revocation ou heartbeat. Stocké dans le state file. Un time.Now() inférieur déclenche causeClockRollback.
MaxClockSkewTolérance d'horloge (5 min par défaut) appliquée à NotBefore/NotAfter/TrustedFloor.
GracePeriodCombien de temps un binaire reste autorisé sans pouvoir joindre la revocation source ou le heartbeat. 0 = pas de tolérance.

Flow de vérification

sequenceDiagram
    autonumber
    participant Op as "Opérateur (issuer)"
    participant Bin as "Binaire (verifier)"
    participant Rev as "Revocation server (opt)"
    participant Hb as "Heartbeat server (opt)"
    participant NTP as "NTP (opt)"

    Op->>Op: GenerateKey + SavePrivateKey
    Op->>Bin: distribute issuer.pub + license.pem

    Note over Bin: license.Verify(data, trusted, opts...)

    Bin->>Bin: 1. parse PEM + size guard
    Bin->>Bin: 2. resolve KeyID → public key
    Bin->>Bin: 3. ed25519.Verify(domain_tag || body)
    Bin->>Bin: 4. read HMAC state file
    Bin->>Bin: 5. NotBefore / NotAfter (with skew)
    Bin->>Bin: 6. audience / issuer match
    Bin->>Bin: 7. bindings (machine, password, custom)
    Bin->>Bin: 8. pinning (BinarySHA256 / IdentitySHA256)
    Bin->>Rev: 9a. fetch signed revocation list (or cache)
    Rev-->>Bin: signed list + serverTime
    Bin->>Bin: 9b. check sequence ≥ last, IsRevoked(lic.ID)
    Bin->>Hb: 10a. POST heartbeat {license_id, nonce}
    Hb-->>Bin: signed reply
    Bin->>Bin: 10b. verify signature, nonce echo, ok==true
    Bin->>NTP: 11. SNTPv4 query (soft warning)
    Bin->>Bin: 12. atomic write state file
    Bin-->>Op: Verified{License, Payload, Warnings} | ErrLicenseInvalid

Quick start

pub, priv, _ := license.GenerateKey()
data, _ := license.New(priv, "alice@example.com", 24*time.Hour)
v, err := license.Verify(data, license.Trusted{Keys: license.SingleKey("default", pub)})

Voir le cookbook pour 8 recettes complètes copier-coller.


Référence des champs

type License

Le corps signé d'une licence. Tous les champs sont couverts par la signature.

ChampJSONTypeSensVide ⇒
VersionvintToujours 1 en v1.refus (causeBadFormat)
IDidstringUUIDv4 random au moment de l'émission. Identifie la licence dans les revocation lists.jamais vide
KeyIDkidstringIdentifiant texte de la clé qui a signé. Doit figurer dans Trusted.Keys.refus (causeUnknownKey)
IssuerissstringÉmetteur. Comparé à WithIssuer(...) si l'option est passée.l'option WithIssuer rejette toujours dans ce cas
SubjectsubstringBénéficiaire (email, agent, …). Libre. Logué et présent dans Verified.Subject.acceptable mais inutile
Audienceaud[]stringListe des binaires autorisés. Vide = wildcard (warning à Verify).warning
IssuedAtiattime.Time UTCHorodatage d'émission. Pas vérifié à Verify mais loguable.non bloquant
NotBeforenbftime.Time UTCAvant cette date, refus causeNotYetValid (avec MaxClockSkew).jamais "pas encore valide"
NotAfterexptime.Time UTCAprès cette date, refus causeExpired. Zéro = jamais expirer.jamais expirer
Bindingsbnd[]BindingContraintes à matcher avec des évidences à Verify.pas de contraintes spécifiques
Featuresfeat[]stringListe d'entitlements signée au niveau racine. Lue via Verified.HasFeature(name) sans désérialiser Payload.aucun entitlement
BinarySHA256binstring (hex)Hash SHA-256 du fichier os.Executable() autorisé.pas de pinning disque
IdentitySHA256id_shastring (hex)Hash SHA-256 de l'identité embarquée via //go:embed. Survit au packer.pas de pinning identité
Payloadpldjson.RawMessageDonnées libres signées en clair pour usage applicatif. Accessibles via Verified.Payload.aucune métadonnée applicative
SealedPayloadspld[]bytePayload chiffré par seal.Seal(recipientPub, ...). Signature publique mais contenu lisible seulement avec recipientPriv.pas de scellé

Note règle de pinning : si les deux BinarySHA256 et IdentitySHA256 sont définis ET que WithBinaryPinning() est passé, les deux doivent matcher (AND). Définir un seul des deux ne vérifie que celui-là. Aucun = warning sans refus.

type Binding

ChampJSONTypeSens
TypetstringUne parmi : "machine", "password", "totp", ou "custom:<name>"
Valuev[]stringPour machine/custom:* : liste de valeurs acceptées (OR-match). Pour totp : [secret_base32]. Vide pour password.
Hashh[]bytePour password : hash argon2id du mot de passe. Vide pour les autres types.
Salts[]bytePour password : sel de 16 octets random. Vide pour les autres types.
Paramsp*BindingParamsPour password : paramètres argon2id stampés à l'émission (time, memory, threads, keylen). Permet de re-tuner sans casser les licences existantes. Nil = défauts du package.

Les helpers BindMachineIDs(ids...), BindPassword(p), BindPasswordWithParams(p, params), BindTOTP(secret), BindCustom(name, vals...) construisent ces bindings correctement.

type IssueOptions

Tous les champs sauf PrivateKey et Subject sont optionnels.

ChampTypeSensDéfaut
PrivateKeyed25519.PrivateKeyClé qui signe. Requise.
KeyIDstringIdentifiant de la clé de signature."default"
IssuerstringÉmetteur (iss).vide
SubjectstringÀ qui (sub). Requise.
Audience[]stringBinaires autorisés.vide (wildcard)
NotBeforetime.TimeDate d'activation.maintenant
NotAftertime.TimeDate d'expiration.jamais (déconseillé)
Bindings[]BindingContraintes.aucune
BinarySHA256string (hex)Hash binaire requis.pas de pinning disque
IdentitySHA256string (hex)Hash identité requis.pas de pinning identité
Payloadjson.RawMessageDonnées applicatives signées en clair.aucune
SealedPayload[]byteDonnées scellées avec seal.Seal.aucune

type Verified (retour de Verify)

ChampTypeSens
LicenseembeddedLe corps vérifié, en lecture seule.
Payload[]byteLe Payload clair (=v.License.Payload).
KeyUsedstringLe KeyID qui a effectivement validé (utile en rotation).
Warnings[]stringAvertissements non bloquants (audience vide, NTP drift, pinning sans champs, etc.).

Options VerifyOption

Toutes les options sont des fonctions func(*verifyState) à passer en variadique à Verify. Plusieurs options peuvent se combiner librement.

OptionTypeEffet
WithContext(ctx)context.ContextPropage timeout/cancel aux appels réseau (revocation, heartbeat, NTP). Défaut : context.Background().
WithClock(c)ClockHorloge injectable. Pour tests ou usage avancé. Défaut : horloge système UTC.
WithLogger(l)*slog.LoggerLogueur pour les causes d'échec. Défaut : slog.Default().
WithMaxClockSkew(d)time.DurationTolérance appliquée à NotBefore/NotAfter/TrustedFloor. Défaut : 5 min.
WithAudience(aud...)...stringLe binaire déclare son nom. Doit appartenir à License.Audience.
WithIssuer(iss)stringÉmetteur attendu. Doit matcher License.Issuer.
WithMachineID(id)[]byteÉvidence pour un binding machine. Typiquement hostid.Local().
WithPassword(p)stringÉvidence pour un binding password.
WithCustom(name, value)string, stringÉvidence pour un binding custom:<name>.
WithBinaryPinning()Active le check de BinarySHA256 et/ou IdentitySHA256 si présents.
WithIdentityBytes(b)[]byteOverride les bytes d'identité (autrement lus via identity.Read()).
WithRevocation(src, refresh, cachePath)RevocationSource, Duration, stringActive la révocation. Fetch refresh max toutes les refresh, cache local signé.
WithGracePeriod(d)time.DurationTolérance offline (revocation + heartbeat).
WithHeartbeat(client, interval)heartbeat.Client, DurationActive le heartbeat ; skip si une réponse OK a été obtenue depuis moins de interval.
WithStateFile(path)stringChemin du state file HMAC pour anti-rollback d'horloge.
WithStateHostID(fn)func() ([]byte, error)Source du fingerprint machine pour dériver la clé HMAC du state file. Typiquement hostid.Local.
WithNTPCheck(server, maxDrift)string, DurationNTP cross-check soft (warning si drift > seuil).
WithNTPCheckStrict(server, maxDrift)string, DurationNTP strict : refus si drift > seuil.

Sous-packages

PackageQuand l'utiliser
licenseSurface API principale. Issue, Verify, GenerateKey, options.
license/canonicalJSON canonique pour signature reproductible. Utilisé en interne, exposé pour usage avancé.
license/hostidFingerprint machine cross-platform (Windows MachineGuid, Linux /etc/machine-id, Darwin IOPlatformUUID).
license/identityIdentité embarquée 32 octets. Inclure //go:embed identity.bin + identity.Set(bytes) au boot.
license/identity/cmd/gen-identityOutil go run qui génère identity.bin. Idempotent.
license/revokeTypes et primitives de revocation list ; sources HTTP/File/Embed/Multi pluggables ; cache local signé.
license/heartbeatClient HTTP pour ping serveur ; signature des réponses ; nonce echo.
license/sealSealed payload X25519 + HKDF-SHA256 + XChaCha20-Poly1305.
license/ntpSNTPv4 query minimaliste.
license/serverhttp.Handler builders pour servir révocation + heartbeat ; FileStore builtin ; interfaces RevocationStore/LicenseStore pour persistance custom.
license/internal/fileutilHelper AtomicWrite, interne uniquement.

Erreurs

ErrLicenseInvalid est la seule erreur publique. Discrimination via errors.Is(err, ErrLicenseInvalid). La cause précise (signature, expired, binding mismatch, etc.) part vers le logueur ; elle est volontairement absente de err.Error() pour ne pas guider un attaquant.

Causes internes loguées (non exportées) : bad-format, bad-signature, unknown-key, not-yet-valid, expired, clock-rollback, audience-mismatch, issuer-mismatch, binding-machine-mismatch, binding-password-mismatch, binding-custom-mismatch, binary-hash-mismatch, identity-mismatch, revoked, revocation-stale, heartbeat-failed, state-corrupted.


OPSEC & détection

AspectComportement
Bruit disqueVerify lit la licence et (si configuré) écrit le state file HMAC + le cache de révocation. Pas d'autres écritures.
Bruit réseauAucun par défaut. WithRevocation → 1 GET / refresh. WithHeartbeat → 1 POST / interval. WithNTPCheck → 1 query UDP.
Surface AV/EDRLe binaire de vérification embarque seulement la stdlib + x/crypto. Aucun artefact RWX, aucun syscall direct, aucun import suspect.
LogsTous les échecs partent dans slog.Default() (ou logueur custom via WithLogger). Le format est structuré, prêt pour pipeline d'audit.
Signature binaireLe package ne signe pas son propre binaire ; combiner avec cmd/packer + Authenticode pour résistance au reverse.

Limitations

Résiste à

  • Forgerie de signature (Ed25519).
  • Modification post-émission (toute la License est couverte par la signature).
  • Replay cross-audience (aud est signé).
  • Réutilisation cross-binaire (aud + binary/identity pinning).
  • Substitution de revocation list ancienne (sequence monotone + signed expiry + chain hash).
  • Brute-force de password binding (argon2id : t=3, m=64MiB, p=4).
  • Rollback de l'horloge sous le TrustedFloor.
  • Algorithm-confusion (un seul algo signé, domain-separated par message type).

Ne résiste PAS à

  • Un attaquant qui patche Verify dans le binaire pour return nil. Mitigation hors scope — combiner avec cmd/packer + intégrité OS (Authenticode / Sigstore).
  • Tamper d'horloge parfait sur une machine totalement offline qui n'a jamais contacté le serveur (= aucun TrustedFloor jamais établi).
  • Usage offline indéfini au-delà du GracePeriod après rotation de clé.
  • Modification simultanée du binaire et de l'identity embarquée.
  • Spoofing de hostid sur une machine que l'attaquant contrôle entièrement.
  • Partage de seat (deux machines avec le même hostid + binding password).

Voir threat-model.md pour le détail complet par classe de menace.


Voir aussi