Skip to content

Commit 2da258e

Browse files
y2hlbgclaude
andcommitted
feat(license): challenge-response device binding via device keypair
Replaces the replayable device_hash check with a real proof of device possession. The client generates an Ed25519 device keypair, persists the private key next to license.json (never in the token), and binds only the public key into the token at activation. Pack-key resolution now fetches a server-signed one-time challenge and signs it with the device private key; the server verifies the signature against the bound public key. A copied license.json (token + public key, no private key) can no longer resolve keys — replaying the token's own values or a captured signature fails against a fresh nonce. Contract docs and token schema updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a6bc3e5 commit 2da258e

5 files changed

Lines changed: 128 additions & 54 deletions

File tree

contracts/license/v1/README.md

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,26 +39,31 @@
3939

4040
| 接口 | 说明 |
4141
|---|---|
42-
| `POST /api/v1/activate` | 激活码 → 签名 token(token.schema.json 结构;wire 格式 `base64url(payload).base64url(ed25519sig)`|
43-
| `POST /api/v1/pack-keys/resolve` | Bearer token → 按 `key_id` 下发 pack 对称密钥 |
42+
| `POST /api/v1/activate` | 激活码 + 设备公钥 → 签名 token(token.schema.json 结构;wire 格式 `base64url(payload).base64url(ed25519sig)`|
43+
| `POST /api/v1/pack-keys/challenge` | Bearer token → 一次性设备质询(server 签名,含 nonce) |
44+
| `POST /api/v1/pack-keys/resolve` | Bearer token + `challenge` + `device_signature` → 按 `key_id` 下发 pack 对称密钥 |
4445
| `GET /api/v1/publisher-keys` | 受信发布者公钥环 |
4546
| `GET /api/v1/license/public-key` | token 验签公钥(客户端激活时获取并与 token 一起保存本地) |
4647

4748
未配置 `MEDIAGO_LICENSE_SERVER_URL` 时,客户端回退到开发期环境变量授权(见下)。
4849

49-
## 设备绑定
50+
## 设备绑定(质询-应答)
5051

51-
激活时客户端上报设备指纹(来自 license 目录下持久化的随机 `device-id`),license server 把它写进签名 token 的 `device_hash`。换取 pack 密钥时客户端带上当前设备指纹,server 校验其与 token 中的 `device_hash` 一致;不一致返回 403(错误码 40314)。`/api/v1/activate` 要求 `device_hash` 非空,避免签发"无绑定"的万能 token
52+
采用**质询-应答**,绑定的是"持有设备私钥的证明",而非 token 里可读出的明文值:
5253

53-
**当前强度(务必如实理解,勿夸大)**:这道绑定**只挡住"拷贝 license.json + 运行原版客户端"**这种非技术性共享——原版客户端在新机器上算出的本地指纹不同,本地校验先拦,且上报给 server 的指纹也不匹配。
54+
1. **激活**:客户端本机生成 Ed25519 **设备密钥对**,只把**公钥**`activation_code` 上报;server 把公钥写进签名 token 的 `device_public_key`**私钥永远留在本机**`<license 目录>/device-key`,0600),不进 token、不随 `license.json` 传播。`/api/v1/activate` 要求 `device_public_key` 非空,避免签发"无绑定"的万能 token。
55+
2. **换密钥**:客户端先 `POST /pack-keys/challenge` 取一个 server 签名的一次性质询(含随机 nonce,2 分钟有效);用**设备私钥**对质询整串签名;`POST /pack-keys/resolve` 带上 `challenge` + `device_signature`。server 校验质询由本 server 签发且未过期、`license_id` 与 token 一致、签名由 token 绑定的公钥所验。任一不过返回 403(40114 质询缺失/无效,40314 设备不符)。
5456

55-
**挡不住**稍有技术的复制者:
56-
- `device_hash` 以明文写在 token 里(`base64url(payload)` 可直接解出),所以持有被拷 token 的人能读出 `device_hash`,再手工发一个 HTTP 请求把同一个值回填过去——**server 端校验会通过**。即 server 端校验**并非真正权威**,一条 curl 即可绕过,无需改二进制。
57-
- `device-id` 是明文文件、与 `license.json` 同目录,整目录拷贝会一并带走指纹。
57+
**为什么真正有效**:换密钥要验证的是"对一个**每次都新**的 nonce 的签名",而签名必须用**不在 token 里**的设备私钥。所以——
5858

59-
要做到真正防"传已激活凭证",需要**质询-应答**:激活时客户端生成设备密钥对、只上报公钥(server 把公钥哈希写进 token);换密钥时 server 下发 nonce,客户端用**设备私钥**签名,server 用绑定的公钥验签。私钥不进 token、不随 `license.json` 传播,一条 HTTP 请求无法伪造。此为待实现项。
59+
- 拷贝 `license.json`(只有 token、公钥)到别的机器:没有私钥 → 签不出质询 → **拿不到密钥**。一条 curl 回填 token 里的值也没用(那只是公钥,签名仍缺私钥)。
60+
- 抓到一次合法签名去重放:下一个 nonce 变了 → 旧签名对不上 → 失败。
6061

61-
- `device_hash` 为空的 token 视为**不绑定**(团队/浮动授权场景,仅限内部/管理签发)。
62+
已用真实服务 E2E 验证:合法设备 200;伪造签名 403;重放旧签名 403。
63+
64+
**残留(诚实说明)**:仍是软件方案。若把**整个 license 目录**(含 `device-key` 私钥文件)一并拷走,则私钥也被带走 → 可用。要连这个都堵死需把私钥放进 **OS 钥匙串 / 安全芯片**(Keychain/TPM),让私钥读不出——后续可选加固。
65+
66+
- `device_public_key` 为空的 token 视为**不绑定**(团队/浮动授权,仅限内部/管理签发,公开 `/activate` 不签发)。
6267
- 与"传激活码"不同:激活码可多设备使用、受 `max_activations` 次数限制;设备绑定针对的是"传已激活凭证"。
6368

6469
## 密钥与信任

contracts/license/v1/token.schema.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@
3030
},
3131
"device_hash": {
3232
"type": "string",
33-
"description": "Optional device binding fingerprint."
33+
"description": "Optional short fingerprint of the bound device public key (display/records only, non-authoritative)."
34+
},
35+
"device_public_key": {
36+
"type": "string",
37+
"description": "Base64 Ed25519 device public key. When present, pack-key resolution requires a challenge signed by the matching device private key. Empty means unbound (floating license)."
3438
},
3539
"license_api_version": {
3640
"type": "string",

services/server/internal/service/license/client.go

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ package license
33
import (
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

5757
var _ 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.
426445
func (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
}

services/server/internal/service/license/client_test.go

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -206,25 +206,48 @@ func newFakeLicenseServer(t *testing.T) *fakeLicenseServer {
206206
})
207207
mux.HandleFunc("POST /api/v1/activate", func(w http.ResponseWriter, r *http.Request) {
208208
var body struct {
209-
ActivationCode string `json:"activation_code"`
210-
DeviceHash string `json:"device_hash"`
209+
ActivationCode string `json:"activation_code"`
210+
DevicePublicKey string `json:"device_public_key"`
211211
}
212212
_ = json.NewDecoder(r.Body).Decode(&body)
213213
if body.ActivationCode != "MG-GOOD-CODE" {
214214
writeEnvelopeError(w, http.StatusForbidden, 40310, "activation code is invalid")
215215
return
216216
}
217-
// Bind the issued token to the activating device, like the real server.
217+
// Bind the issued token to the activating device's public key.
218218
writeEnvelope(w, http.StatusOK, map[string]any{
219-
"token": fake.issueToken(nil, body.DeviceHash, time.Now().Add(time.Hour)),
219+
"token": fake.issueToken(nil, body.DevicePublicKey, time.Now().Add(time.Hour)),
220220
"plan": "pro",
221221
})
222222
})
223-
mux.HandleFunc("POST /api/v1/pack-keys/resolve", func(w http.ResponseWriter, r *http.Request) {
223+
mux.HandleFunc("POST /api/v1/pack-keys/challenge", func(w http.ResponseWriter, r *http.Request) {
224224
if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
225225
writeEnvelopeError(w, http.StatusUnauthorized, 40110, "license token is required")
226226
return
227227
}
228+
nonce := make([]byte, 32)
229+
_, _ = rand.Read(nonce)
230+
writeEnvelope(w, http.StatusOK, map[string]any{"challenge": base64.StdEncoding.EncodeToString(nonce)})
231+
})
232+
mux.HandleFunc("POST /api/v1/pack-keys/resolve", func(w http.ResponseWriter, r *http.Request) {
233+
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
234+
if strings.TrimSpace(token) == "" {
235+
writeEnvelopeError(w, http.StatusUnauthorized, 40110, "license token is required")
236+
return
237+
}
238+
var body struct {
239+
KeyID string `json:"key_id"`
240+
Challenge string `json:"challenge"`
241+
DeviceSignature string `json:"device_signature"`
242+
}
243+
_ = json.NewDecoder(r.Body).Decode(&body)
244+
// Verify the device proved possession of the private key bound in the token.
245+
pub := fake.tokenDevicePublicKey(token)
246+
sig, _ := base64.StdEncoding.DecodeString(body.DeviceSignature)
247+
if len(pub) != ed25519.PublicKeySize || !ed25519.Verify(pub, []byte(body.Challenge), sig) {
248+
writeEnvelopeError(w, http.StatusForbidden, 40314, "license is bound to another device")
249+
return
250+
}
228251
writeEnvelope(w, http.StatusOK, map[string]any{
229252
"key_id": "default",
230253
"key": base64.StdEncoding.EncodeToString(fake.packKey),
@@ -249,7 +272,7 @@ func (fake *fakeLicenseServer) publicKeyBase64() string {
249272
return base64.StdEncoding.EncodeToString(fake.publicKey)
250273
}
251274

252-
func (fake *fakeLicenseServer) issueToken(t *testing.T, deviceHash string, expiresAt time.Time) string {
275+
func (fake *fakeLicenseServer) issueToken(t *testing.T, devicePublicKey string, expiresAt time.Time) string {
253276
if t != nil {
254277
t.Helper()
255278
}
@@ -258,13 +281,35 @@ func (fake *fakeLicenseServer) issueToken(t *testing.T, deviceHash string, expir
258281
Plan: "pro",
259282
Entitlements: []string{"pack.import.pro"},
260283
ExpiresAt: expiresAt.UTC(),
261-
DeviceHash: deviceHash,
284+
DevicePublicKey: devicePublicKey,
262285
LicenseAPIVersion: "v1",
263286
})
264287
signature := ed25519.Sign(fake.private, payload)
265288
return base64.RawURLEncoding.EncodeToString(payload) + "." + base64.RawURLEncoding.EncodeToString(signature)
266289
}
267290

291+
// tokenDevicePublicKey extracts the bound device public key from a token,
292+
// mirroring how the real server reads it to verify the challenge signature.
293+
func (fake *fakeLicenseServer) tokenDevicePublicKey(token string) []byte {
294+
parts := strings.SplitN(strings.TrimSpace(token), ".", 2)
295+
if len(parts) != 2 {
296+
return nil
297+
}
298+
raw, err := base64.RawURLEncoding.DecodeString(parts[0])
299+
if err != nil {
300+
return nil
301+
}
302+
var payload TokenPayload
303+
if err := json.Unmarshal(raw, &payload); err != nil {
304+
return nil
305+
}
306+
pub, err := base64.StdEncoding.DecodeString(payload.DevicePublicKey)
307+
if err != nil {
308+
return nil
309+
}
310+
return pub
311+
}
312+
268313
func writeEnvelope(w http.ResponseWriter, status int, data any) {
269314
w.Header().Set("Content-Type", "application/json")
270315
w.WriteHeader(status)

services/server/internal/service/license/token.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type TokenPayload struct {
2525
Entitlements []string `json:"entitlements"`
2626
ExpiresAt time.Time `json:"expires_at"`
2727
DeviceHash string `json:"device_hash,omitempty"`
28+
DevicePublicKey string `json:"device_public_key,omitempty"`
2829
LicenseAPIVersion string `json:"license_api_version"`
2930
}
3031

0 commit comments

Comments
 (0)