@@ -3,10 +3,10 @@ package license
33import (
44 "bytes"
55 "context"
6+ "crypto/ed25519"
67 "crypto/rand"
78 "crypto/sha256"
89 "encoding/base64"
9- "encoding/hex"
1010 "encoding/json"
1111 "errors"
1212 "fmt"
@@ -51,7 +51,7 @@ type Client struct {
5151 publisherAt time.Time
5252
5353 deviceOnce sync.Once
54- deviceID string
54+ deviceKey ed25519. PrivateKey
5555}
5656
5757var _ Service = (* Client )(nil )
@@ -142,8 +142,8 @@ func (client *Client) Activate(ctx context.Context, code string) (StatusInfo, er
142142 Token string `json:"token"`
143143 }
144144 err = client .postJSON (ctx , "/api/v1/activate" , map [string ]string {
145- "activation_code" : code ,
146- "device_hash " : client .deviceHash (),
145+ "activation_code" : code ,
146+ "device_public_key " : client .devicePublicKeyBase64 (),
147147 }, "" , & activated )
148148 if err != nil {
149149 return client .Status (), err
@@ -207,12 +207,25 @@ func (client *Client) ResolvePackKey(ctx context.Context, keyID string) ([]byte,
207207 if keyID == "" {
208208 keyID = "default"
209209 }
210+ // Prove device possession: fetch a server challenge and sign it with the
211+ // device private key (which never left this machine and is not in the token).
212+ var challenged struct {
213+ Challenge string `json:"challenge"`
214+ }
215+ if err := client .postJSON (ctx , "/api/v1/pack-keys/challenge" , nil , stored .Token , & challenged ); err != nil {
216+ if errors .Is (err , ErrActivationRejected ) {
217+ return nil , fmt .Errorf ("%w: challenge rejected" , ErrPackKeyNotFound )
218+ }
219+ return nil , err
220+ }
221+ signature := ed25519 .Sign (client .devicePrivateKey (), []byte (challenged .Challenge ))
210222 var resolved struct {
211223 Key string `json:"key"`
212224 }
213225 if err := client .postJSON (ctx , "/api/v1/pack-keys/resolve" , map [string ]string {
214- "key_id" : keyID ,
215- "device_hash" : client .deviceHash (),
226+ "key_id" : keyID ,
227+ "challenge" : challenged .Challenge ,
228+ "device_signature" : base64 .StdEncoding .EncodeToString (signature ),
216229 }, stored .Token , & resolved ); err != nil {
217230 if errors .Is (err , ErrActivationRejected ) {
218231 return nil , fmt .Errorf ("%w: %q" , ErrPackKeyNotFound , keyID )
@@ -377,55 +390,61 @@ func clonePublisherKeys(keys map[string][]byte) map[string][]byte {
377390 return cloned
378391}
379392
380- // deviceHash returns a stable fingerprint of this installation. It is derived
381- // from a random device id persisted next to the license file, so a license
382- // token copied to another machine (which has its own device id) fails the
383- // device-binding check. Falls back to host attributes if the id cannot be
384- // persisted .
385- func (client * Client ) deviceHash () string {
393+ // deviceKeyOnce loads (or creates) this installation's Ed25519 device keypair.
394+ // The private key is persisted next to the license file and never leaves the
395+ // device; only its public key is bound into the license token. Pack-key
396+ // resolution requires signing a server challenge with the private key, so a
397+ // license token copied to another machine cannot obtain keys .
398+ func (client * Client ) devicePrivateKey () ed25519. PrivateKey {
386399 client .deviceOnce .Do (func () {
387- client .deviceID = client .loadOrCreateDeviceID ()
400+ client .deviceKey = client .loadOrCreateDeviceKey ()
388401 })
389- sum := sha256 .Sum256 ([]byte (client .deviceID ))
390- return hex .EncodeToString (sum [:16 ])
402+ return client .deviceKey
403+ }
404+
405+ // devicePublicKeyBase64 returns the base64-std device public key sent at
406+ // activation and matched against the token binding.
407+ func (client * Client ) devicePublicKeyBase64 () string {
408+ pub := client .devicePrivateKey ().Public ().(ed25519.PublicKey )
409+ return base64 .StdEncoding .EncodeToString (pub )
391410}
392411
393- func (client * Client ) loadOrCreateDeviceID () string {
394- path := filepath .Join (filepath .Dir (client .storePath ), "device-id " )
412+ func (client * Client ) loadOrCreateDeviceKey () ed25519. PrivateKey {
413+ path := filepath .Join (filepath .Dir (client .storePath ), "device-key " )
395414 if raw , err := os .ReadFile (path ); err == nil {
396- if id := strings .TrimSpace (string (raw )); id != "" {
397- return id
415+ if seed , decodeErr := base64 . StdEncoding . DecodeString ( strings .TrimSpace (string (raw ))); decodeErr == nil && len ( seed ) == ed25519 . SeedSize {
416+ return ed25519 . NewKeyFromSeed ( seed )
398417 }
399418 }
400- buf := make ([]byte , 16 )
401- if _ , err := rand .Read (buf ); err != nil {
402- return machineFallbackID ()
419+ seed := make ([]byte , ed25519 . SeedSize )
420+ if _ , err := rand .Read (seed ); err != nil {
421+ return machineFallbackKey ()
403422 }
404- id := hex .EncodeToString (buf )
405- // If the id cannot be persisted, fall back to a stable machine-derived id
406- // rather than returning this ephemeral one — otherwise every launch would
407- // regenerate a different fingerprint and silently invalidate an already
408- // activated license, forcing re-activation.
423+ // If the key cannot be persisted, derive a stable machine-bound key instead
424+ // of returning this ephemeral one — otherwise every launch would regenerate
425+ // a different key and silently invalidate an already activated license.
409426 if err := os .MkdirAll (filepath .Dir (path ), 0o700 ); err != nil {
410- return machineFallbackID ()
427+ return machineFallbackKey ()
411428 }
412- if err := os .WriteFile (path , []byte (id + "\n " ), 0o600 ); err != nil {
413- return machineFallbackID ()
429+ if err := os .WriteFile (path , []byte (base64 . StdEncoding . EncodeToString ( seed ) + "\n " ), 0o600 ); err != nil {
430+ return machineFallbackKey ()
414431 }
415- return id
432+ return ed25519 . NewKeyFromSeed ( seed )
416433}
417434
418- func machineFallbackID () string {
435+ func machineFallbackKey () ed25519. PrivateKey {
419436 hostname , _ := os .Hostname ()
420437 home , _ := os .UserHomeDir ()
421- return "host:" + hostname + "|" + home
438+ seed := sha256 .Sum256 ([]byte ("mediago-device|" + hostname + "|" + home ))
439+ return ed25519 .NewKeyFromSeed (seed [:])
422440}
423441
424442// tokenMatchesDevice reports whether a token is usable on this device. A token
425- // with an empty device hash is unbound (dev/legacy) and passes unconditionally.
443+ // with no bound device public key is unbound (dev/legacy) and passes. This is a
444+ // fast local check; the authoritative proof is the server challenge signature.
426445func (client * Client ) tokenMatchesDevice (payload TokenPayload ) bool {
427- if strings .TrimSpace (payload .DeviceHash ) == "" {
446+ if strings .TrimSpace (payload .DevicePublicKey ) == "" {
428447 return true
429448 }
430- return payload .DeviceHash == client .deviceHash ()
449+ return payload .DevicePublicKey == client .devicePublicKeyBase64 ()
431450}
0 commit comments