Initial backup of root workspace files
Glue files not covered by subproject repos: top-level docs, logo, keysat-design-system, and crosscheck tests. Subproject folders are gitignored (each has its own Gitea remote).
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# Format cross-check
|
||||
|
||||
Verifies that the three implementations of the `LIC1-...-...` key format
|
||||
(licensing-service in Rust, licensing-client-rust SDK, licensing-client-ts SDK)
|
||||
all agree, byte for byte, on **both** the legacy v1 payload layout and the
|
||||
current v2 layout.
|
||||
|
||||
## Approach
|
||||
|
||||
1. `reference_signer.py` builds three license keys from scratch using the exact
|
||||
byte layouts documented in `licensing-service/src/crypto/mod.rs`. It uses
|
||||
Python's `cryptography` for Ed25519 signing — an independent implementation
|
||||
from the Rust one, so agreement here is strong evidence the format is
|
||||
correct, not just that the Rust side agrees with itself.
|
||||
|
||||
The three keys exercise every branch of the parser:
|
||||
|
||||
- **`v1`** — legacy fixed-74 payload, fingerprint-bound. New keys aren't
|
||||
issued in v1 anymore, but SDKs must still accept them indefinitely so
|
||||
old keys in the wild keep working.
|
||||
- **`v2`** — trial key, fingerprint-bound, with explicit expiry and two
|
||||
entitlements. Stresses the variable-length tail parser.
|
||||
- **`v2_perpetual_unbound`** — the "happy path" for a normal paid purchase:
|
||||
v2, no expiry, no fingerprint binding, no entitlements.
|
||||
|
||||
2. `run_ts.mjs` imports the built TypeScript SDK (`dist/index.js`) and runs
|
||||
each of the three fixtures through: field-by-field parse, signature
|
||||
verification, fingerprint binding (positive + negative), entitlement
|
||||
lookup, `isExpiredAt` boundaries, and tamper detection. Also spot-checks
|
||||
`hashFingerprint` against Python's `hashlib.sha256` and public-key loading.
|
||||
|
||||
## Rust SDK coverage
|
||||
|
||||
The Rust SDK uses the same crates as the service (`data_encoding::BASE32_NOPAD`,
|
||||
`ed25519_dalek`) with identical byte offsets (`licensing-client-rust/src/key.rs`
|
||||
mirrors `licensing-service/src/crypto/mod.rs`), so round-trip compatibility is
|
||||
guaranteed by construction. If you change the layout, update the service's
|
||||
`from_bytes`, the Rust SDK's `from_bytes`, and `reference_signer.py` together.
|
||||
|
||||
The Rust SDK's own unit tests (`cargo test -p licensing-client`) round-trip
|
||||
every flag + version combination.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# Build the TS SDK first.
|
||||
cd licensing-client-ts
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
cd ../tests/crosscheck
|
||||
python3 reference_signer.py > vector.json
|
||||
node --experimental-vm-modules run_ts.mjs
|
||||
```
|
||||
|
||||
## Current test vectors
|
||||
|
||||
Both vectors share a deterministic signing key seeded with `bytes(range(32))`,
|
||||
so the license strings in `vector.json` are stable across regenerations (a
|
||||
regression in encoding will produce a git diff).
|
||||
|
||||
| fixture | version | expires_at | entitlements | flags |
|
||||
|------------------------|---------|-------------|------------------|-----------------------------------------|
|
||||
| `v1` | 1 | (n/a) | (n/a) | `FINGERPRINT_BOUND` |
|
||||
| `v2` | 2 | 1900000000 | `["pro","multi-device"]` | `FINGERPRINT_BOUND \| TRIAL` |
|
||||
| `v2_perpetual_unbound` | 2 | 0 | `[]` | `0` |
|
||||
|
||||
Product UUID: `6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0`, fingerprint raw string
|
||||
`"test-machine-fingerprint"` (SHA-256
|
||||
`d34461ff63170de633b5aa2512ce2e15ac120b1c325acdada67c02e594ba3b3d`).
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Produce LIC1-...-... keys the service would accept — used to verify the
|
||||
SDK parsers round-trip correctly.
|
||||
|
||||
Emits two fixtures now: v1 (legacy fixed-74 payload) and v2 (variable-length
|
||||
payload with expires_at + entitlements). SDKs must accept both.
|
||||
|
||||
Byte layout (matches licensing-service/src/crypto/mod.rs exactly):
|
||||
|
||||
v1 (74 bytes, fixed):
|
||||
[0] version = 1
|
||||
[1] flags
|
||||
[2..18] product_id (UUID BE, 16 bytes)
|
||||
[18..34] license_id (UUID BE, 16 bytes)
|
||||
[34..42] issued_at (u64 BE, unix seconds)
|
||||
[42..74] fingerprint_hash (SHA-256, zero if unbound)
|
||||
|
||||
v2 (83 bytes + variable entitlements):
|
||||
[0] version = 2
|
||||
[1] flags
|
||||
[2..18] product_id
|
||||
[18..34] license_id
|
||||
[34..42] issued_at (u64 BE)
|
||||
[42..50] expires_at (u64 BE, 0 = perpetual)
|
||||
[50..82] fingerprint_hash (SHA-256, zero if unbound)
|
||||
[82] num_entitlements (u8)
|
||||
[83..] for each: [u8 len][len bytes of UTF-8 slug]
|
||||
|
||||
Signature: 64 bytes over the raw payload bytes.
|
||||
Encoding: LIC1-<base32(payload)>-<base32(signature)>
|
||||
RFC 4648 base32, uppercase, no padding.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding, PrivateFormat, PublicFormat, NoEncryption,
|
||||
)
|
||||
|
||||
KEY_VERSION_V1 = 1
|
||||
KEY_VERSION_V2 = 2
|
||||
|
||||
FLAG_FINGERPRINT_BOUND = 0b0000_0001
|
||||
FLAG_TRIAL = 0b0000_0010
|
||||
|
||||
|
||||
def b32nopad(b: bytes) -> str:
|
||||
return base64.b32encode(b).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def make_payload_v1(
|
||||
flags: int,
|
||||
product_id: bytes, license_id: bytes,
|
||||
issued_at: int, fp_hash: bytes,
|
||||
) -> bytes:
|
||||
assert len(product_id) == 16
|
||||
assert len(license_id) == 16
|
||||
assert len(fp_hash) == 32
|
||||
payload = (
|
||||
bytes([KEY_VERSION_V1, flags])
|
||||
+ product_id
|
||||
+ license_id
|
||||
+ issued_at.to_bytes(8, "big")
|
||||
+ fp_hash
|
||||
)
|
||||
assert len(payload) == 74, f"v1 payload is {len(payload)} bytes, expected 74"
|
||||
return payload
|
||||
|
||||
|
||||
def make_payload_v2(
|
||||
flags: int,
|
||||
product_id: bytes, license_id: bytes,
|
||||
issued_at: int, expires_at: int,
|
||||
fp_hash: bytes, entitlements: list[str],
|
||||
) -> bytes:
|
||||
assert len(product_id) == 16
|
||||
assert len(license_id) == 16
|
||||
assert len(fp_hash) == 32
|
||||
assert 0 <= len(entitlements) <= 255
|
||||
tail = bytearray()
|
||||
for slug in entitlements:
|
||||
encoded = slug.encode("utf-8")
|
||||
assert len(encoded) <= 255, f"entitlement '{slug}' too long"
|
||||
tail.append(len(encoded))
|
||||
tail.extend(encoded)
|
||||
head = (
|
||||
bytes([KEY_VERSION_V2, flags])
|
||||
+ product_id
|
||||
+ license_id
|
||||
+ issued_at.to_bytes(8, "big")
|
||||
+ expires_at.to_bytes(8, "big")
|
||||
+ fp_hash
|
||||
+ bytes([len(entitlements)])
|
||||
)
|
||||
assert len(head) == 83
|
||||
return head + bytes(tail)
|
||||
|
||||
|
||||
def sign_and_encode(sk: Ed25519PrivateKey, payload: bytes) -> str:
|
||||
sig = sk.sign(payload)
|
||||
assert len(sig) == 64
|
||||
return f"LIC1-{b32nopad(payload)}-{b32nopad(sig)}"
|
||||
|
||||
|
||||
def main():
|
||||
# Deterministic test vector — fixed seeds so the output is stable.
|
||||
sk = Ed25519PrivateKey.from_private_bytes(bytes(range(32)))
|
||||
pk = sk.public_key()
|
||||
pub_pem = pk.public_bytes(
|
||||
Encoding.PEM, PublicFormat.SubjectPublicKeyInfo
|
||||
).decode()
|
||||
|
||||
product_id = uuid.UUID("6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0").bytes
|
||||
license_id_v1 = uuid.UUID("11111111-2222-3333-4444-555555555555").bytes
|
||||
license_id_v2 = uuid.UUID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").bytes
|
||||
issued_at = 1_700_000_000
|
||||
expires_at_v2 = 1_900_000_000 # ~2030
|
||||
fingerprint_raw = "test-machine-fingerprint"
|
||||
fp_hash = hashlib.sha256(fingerprint_raw.encode()).digest()
|
||||
entitlements_v2 = ["pro", "multi-device"]
|
||||
|
||||
# v1: legacy fingerprint-bound key.
|
||||
v1_payload = make_payload_v1(
|
||||
FLAG_FINGERPRINT_BOUND,
|
||||
product_id, license_id_v1, issued_at, fp_hash,
|
||||
)
|
||||
v1_key = sign_and_encode(sk, v1_payload)
|
||||
|
||||
# v2: trial + fingerprint-bound, with entitlements and expiry.
|
||||
v2_flags = FLAG_FINGERPRINT_BOUND | FLAG_TRIAL
|
||||
v2_payload = make_payload_v2(
|
||||
v2_flags,
|
||||
product_id, license_id_v2, issued_at, expires_at_v2,
|
||||
fp_hash, entitlements_v2,
|
||||
)
|
||||
v2_key = sign_and_encode(sk, v2_payload)
|
||||
|
||||
# v2 perpetual, unbound, no entitlements — the "happy path" for a normal
|
||||
# permanent license purchase.
|
||||
v2_plain_payload = make_payload_v2(
|
||||
0,
|
||||
product_id, license_id_v2, issued_at, 0,
|
||||
bytes(32), [],
|
||||
)
|
||||
v2_plain_key = sign_and_encode(sk, v2_plain_payload)
|
||||
|
||||
out = {
|
||||
"publicKeyPem": pub_pem,
|
||||
"v1": {
|
||||
"licenseKey": v1_key,
|
||||
"expected": {
|
||||
"version": 1,
|
||||
"productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0",
|
||||
"licenseUuid": "11111111-2222-3333-4444-555555555555",
|
||||
"issuedAt": issued_at,
|
||||
"expiresAt": 0,
|
||||
"flags": FLAG_FINGERPRINT_BOUND,
|
||||
"isFingerprintBound": True,
|
||||
"isTrial": False,
|
||||
"entitlements": [],
|
||||
"fingerprintRaw": fingerprint_raw,
|
||||
"fingerprintHashHex": fp_hash.hex(),
|
||||
},
|
||||
},
|
||||
"v2": {
|
||||
"licenseKey": v2_key,
|
||||
"expected": {
|
||||
"version": 2,
|
||||
"productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0",
|
||||
"licenseUuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
"issuedAt": issued_at,
|
||||
"expiresAt": expires_at_v2,
|
||||
"flags": v2_flags,
|
||||
"isFingerprintBound": True,
|
||||
"isTrial": True,
|
||||
"entitlements": entitlements_v2,
|
||||
"fingerprintRaw": fingerprint_raw,
|
||||
"fingerprintHashHex": fp_hash.hex(),
|
||||
},
|
||||
},
|
||||
"v2_perpetual_unbound": {
|
||||
"licenseKey": v2_plain_key,
|
||||
"expected": {
|
||||
"version": 2,
|
||||
"productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0",
|
||||
"licenseUuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
"issuedAt": issued_at,
|
||||
"expiresAt": 0,
|
||||
"flags": 0,
|
||||
"isFingerprintBound": False,
|
||||
"isTrial": False,
|
||||
"entitlements": [],
|
||||
"fingerprintRaw": None,
|
||||
"fingerprintHashHex": "00" * 32,
|
||||
},
|
||||
},
|
||||
}
|
||||
json.dump(out, sys.stdout, indent=2)
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,138 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import {
|
||||
Verifier,
|
||||
PublicKey,
|
||||
hashFingerprint,
|
||||
parseLicenseKey,
|
||||
isExpiredAt,
|
||||
hasEntitlement,
|
||||
} from '/sessions/hopeful-determined-edison/ts-sdk-build/dist/index.js'
|
||||
|
||||
const vector = JSON.parse(readFileSync(new URL('./vector.json', import.meta.url), 'utf8'))
|
||||
|
||||
let failures = 0
|
||||
function check(name, cond, extra = '') {
|
||||
if (cond) {
|
||||
console.log(` OK ${name}`)
|
||||
} else {
|
||||
console.log(` FAIL ${name}${extra ? ': ' + extra : ''}`)
|
||||
failures++
|
||||
}
|
||||
}
|
||||
|
||||
const toHex = (b) =>
|
||||
Array.from(b).map((x) => x.toString(16).padStart(2, '0')).join('')
|
||||
|
||||
const verifier = new Verifier(PublicKey.fromPem(vector.publicKeyPem))
|
||||
|
||||
function runCase(label, caseData) {
|
||||
console.log(`\n== ${label}: parseLicenseKey + verify ==`)
|
||||
const expected = caseData.expected
|
||||
const parsed = parseLicenseKey(caseData.licenseKey)
|
||||
|
||||
check(`${label}.version`, parsed.payload.version === expected.version,
|
||||
`got ${parsed.payload.version}`)
|
||||
check(`${label}.productUuid`, parsed.payload.productUuid === expected.productUuid,
|
||||
`got ${parsed.payload.productUuid}`)
|
||||
check(`${label}.licenseUuid`, parsed.payload.licenseUuid === expected.licenseUuid,
|
||||
`got ${parsed.payload.licenseUuid}`)
|
||||
check(`${label}.issuedAt`, parsed.payload.issuedAt === expected.issuedAt)
|
||||
check(`${label}.expiresAt`, parsed.payload.expiresAt === expected.expiresAt,
|
||||
`got ${parsed.payload.expiresAt}`)
|
||||
check(`${label}.flags`, parsed.payload.flags === expected.flags,
|
||||
`got ${parsed.payload.flags}`)
|
||||
check(`${label}.isFingerprintBound`,
|
||||
parsed.payload.isFingerprintBound === expected.isFingerprintBound)
|
||||
check(`${label}.isTrial`, parsed.payload.isTrial === expected.isTrial)
|
||||
check(
|
||||
`${label}.entitlements`,
|
||||
JSON.stringify(parsed.payload.entitlements) === JSON.stringify(expected.entitlements),
|
||||
`got ${JSON.stringify(parsed.payload.entitlements)}`,
|
||||
)
|
||||
check(
|
||||
`${label}.fingerprintHash`,
|
||||
toHex(parsed.payload.fingerprintHash) === expected.fingerprintHashHex,
|
||||
)
|
||||
|
||||
// Signed bytes length depends on version + entitlements. We just assert
|
||||
// that it round-trips the signature check.
|
||||
check(`${label}.signature size`, parsed.signature.length === 64)
|
||||
|
||||
try {
|
||||
const v = verifier.verify(caseData.licenseKey)
|
||||
check(`${label}.verify()`, true)
|
||||
check(`${label}.verify productId`, v.productId === expected.productUuid)
|
||||
check(`${label}.verify licenseId`, v.licenseId === expected.licenseUuid)
|
||||
} catch (e) {
|
||||
check(`${label}.verify()`, false, String(e))
|
||||
}
|
||||
|
||||
if (expected.isFingerprintBound) {
|
||||
try {
|
||||
verifier.verifyWithFingerprint(caseData.licenseKey, expected.fingerprintRaw)
|
||||
check(`${label}.verifyWithFingerprint correct`, true)
|
||||
} catch (e) {
|
||||
check(`${label}.verifyWithFingerprint correct`, false, String(e))
|
||||
}
|
||||
let rejectedWrong = false
|
||||
try {
|
||||
verifier.verifyWithFingerprint(caseData.licenseKey, 'wrong-fingerprint')
|
||||
} catch (e) {
|
||||
rejectedWrong = String(e).toLowerCase().includes('fingerprint')
|
||||
}
|
||||
check(`${label}.verifyWithFingerprint wrong`, rejectedWrong)
|
||||
}
|
||||
|
||||
// Entitlement helper.
|
||||
for (const slug of expected.entitlements) {
|
||||
check(`${label}.hasEntitlement('${slug}')`, hasEntitlement(parsed.payload, slug))
|
||||
}
|
||||
check(`${label}.hasEntitlement('nonexistent')`,
|
||||
!hasEntitlement(parsed.payload, 'definitely-not-a-real-slug'))
|
||||
|
||||
// Expiry helper.
|
||||
if (expected.expiresAt > 0) {
|
||||
check(`${label}.isExpiredAt(before)`, !isExpiredAt(parsed.payload, expected.expiresAt - 1))
|
||||
check(`${label}.isExpiredAt(at)`, isExpiredAt(parsed.payload, expected.expiresAt))
|
||||
check(`${label}.isExpiredAt(after)`, isExpiredAt(parsed.payload, expected.expiresAt + 1))
|
||||
} else {
|
||||
check(`${label}.perpetual never expires`,
|
||||
!isExpiredAt(parsed.payload, 2_000_000_000))
|
||||
}
|
||||
|
||||
// Tamper check — flip the last base32 character of the signature.
|
||||
const orig = caseData.licenseKey
|
||||
const lastChar = orig.slice(-1)
|
||||
const flipped = lastChar === 'A' ? 'B' : 'A'
|
||||
const tampered = orig.slice(0, -1) + flipped
|
||||
let tamperRejected = false
|
||||
try {
|
||||
verifier.verify(tampered)
|
||||
} catch {
|
||||
tamperRejected = true
|
||||
}
|
||||
check(`${label}.tampered key rejected`, tamperRejected)
|
||||
}
|
||||
|
||||
runCase('v1', vector.v1)
|
||||
runCase('v2', vector.v2)
|
||||
runCase('v2_perpetual_unbound', vector.v2_perpetual_unbound)
|
||||
|
||||
console.log('\n== hashFingerprint === Python SHA-256 ==')
|
||||
check(
|
||||
'sha256 matches',
|
||||
toHex(hashFingerprint(vector.v1.expected.fingerprintRaw))
|
||||
=== vector.v1.expected.fingerprintHashHex,
|
||||
)
|
||||
|
||||
console.log('\n== pubkey loaded has correct length ==')
|
||||
check(
|
||||
'public key raw is 32 bytes',
|
||||
PublicKey.fromPem(vector.publicKeyPem).raw.length === 32,
|
||||
)
|
||||
|
||||
if (failures > 0) {
|
||||
console.log(`\nFAIL: ${failures} check(s) failed`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\nALL CHECKS PASSED')
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=\n-----END PUBLIC KEY-----\n",
|
||||
"v1": {
|
||||
"licenseKey": "LIC1-AEAW6RVE6YGS6SRIW2VD5D57N4UPAEIRCEISEIRTGNCEIVKVKVKVKVIAAAAAAZKT6EANGRDB75RRODPGGO22UJISZYXBLLASBMODEWWNVWTHYAXFSS5DWPI-FV56FI7ZTB5GIFQHIPQ35QVVE5AO5FQGVQS45UJ5F632MLXS7VHMHYVLZWGE64FJOEXD2PVIFNE5XGRMTNOUOVEKDTW736743W25MAY",
|
||||
"expected": {
|
||||
"version": 1,
|
||||
"productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0",
|
||||
"licenseUuid": "11111111-2222-3333-4444-555555555555",
|
||||
"issuedAt": 1700000000,
|
||||
"expiresAt": 0,
|
||||
"flags": 1,
|
||||
"isFingerprintBound": true,
|
||||
"isTrial": false,
|
||||
"entitlements": [],
|
||||
"fingerprintRaw": "test-machine-fingerprint",
|
||||
"fingerprintHashHex": "d34461ff63170de633b5aa2512ce2e15ac120b1c325acdada67c02e594ba3b3d"
|
||||
}
|
||||
},
|
||||
"v2": {
|
||||
"licenseKey": "LIC1-AIBW6RVE6YGS6SRIW2VD5D57N4UPBKVKVKVLXO6MZTO533XO53XO53QAAAAAAZKT6EAAAAAAABYT7MYA2NCGD73DC4G6MM5VVISRFTROCWWBECY4GJNM3LNGPQBOLFF2HM6QEA3QOJXQY3LVNR2GSLLEMV3GSY3F-QPSJIDYL6Y5TFCKXQ2SN43EDJIZIRJZCEROM2I4MJHODT6KO4KDPW6AJ3HMYJERYPD34CF2Z46PXPYFKSRZS7BDZKVKWE57UBJSTEBI",
|
||||
"expected": {
|
||||
"version": 2,
|
||||
"productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0",
|
||||
"licenseUuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
"issuedAt": 1700000000,
|
||||
"expiresAt": 1900000000,
|
||||
"flags": 3,
|
||||
"isFingerprintBound": true,
|
||||
"isTrial": true,
|
||||
"entitlements": [
|
||||
"pro",
|
||||
"multi-device"
|
||||
],
|
||||
"fingerprintRaw": "test-machine-fingerprint",
|
||||
"fingerprintHashHex": "d34461ff63170de633b5aa2512ce2e15ac120b1c325acdada67c02e594ba3b3d"
|
||||
}
|
||||
},
|
||||
"v2_perpetual_unbound": {
|
||||
"licenseKey": "LIC1-AIAG6RVE6YGS6SRIW2VD5D57N4UPBKVKVKVLXO6MZTO533XO53XO53QAAAAAAZKT6EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-E4IT25ES5NBBQAXVAMZPLBDB5P2ILZL4RGKYUWEWLME5ZVGM7HGBG5CP3XHWBQ5FCYPEC6YGKBHCTQ7M7RZP7OR7NAYAMNAJAWW4QDQ",
|
||||
"expected": {
|
||||
"version": 2,
|
||||
"productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0",
|
||||
"licenseUuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
"issuedAt": 1700000000,
|
||||
"expiresAt": 0,
|
||||
"flags": 0,
|
||||
"isFingerprintBound": false,
|
||||
"isTrial": false,
|
||||
"entitlements": [],
|
||||
"fingerprintRaw": null,
|
||||
"fingerprintHashHex": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user