cuboutils 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cuboutils-0.1.0/LICENSE_FORMAT.md +88 -0
- cuboutils-0.1.0/PKG-INFO +68 -0
- cuboutils-0.1.0/README.md +47 -0
- cuboutils-0.1.0/cuboutils.egg-info/PKG-INFO +68 -0
- cuboutils-0.1.0/cuboutils.egg-info/SOURCES.txt +21 -0
- cuboutils-0.1.0/cuboutils.egg-info/dependency_links.txt +1 -0
- cuboutils-0.1.0/cuboutils.egg-info/requires.txt +1 -0
- cuboutils-0.1.0/cuboutils.egg-info/top_level.txt +2 -0
- cuboutils-0.1.0/license/__init__.py +22 -0
- cuboutils-0.1.0/license/contract.py +129 -0
- cuboutils-0.1.0/license/issuer.py +20 -0
- cuboutils-0.1.0/license/machine.py +105 -0
- cuboutils-0.1.0/license/public_key.py +4 -0
- cuboutils-0.1.0/license/storage.py +74 -0
- cuboutils-0.1.0/license/verifier.py +92 -0
- cuboutils-0.1.0/setup.cfg +4 -0
- cuboutils-0.1.0/setup.py +21 -0
- cuboutils-0.1.0/tests/test_license_flow.py +91 -0
- cuboutils-0.1.0/tests/test_machine.py +24 -0
- cuboutils-0.1.0/tests/test_storage.py +23 -0
- cuboutils-0.1.0/tests/test_update_checker.py +56 -0
- cuboutils-0.1.0/update_checker/__init__.py +5 -0
- cuboutils-0.1.0/update_checker/checker.py +68 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Formato da licença
|
|
2
|
+
|
|
3
|
+
O emissor gera um blob JSON UTF-8 assinado com Ed25519. O instalador grava esse blob, sem extensão, em:
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
C:\ProgramData\CUBO\-
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
O caminho de `ProgramData` é obtido pela API `SHGetKnownFolderPath` do Windows, portanto não depende da variável de ambiente `%PROGRAMDATA%` nem de o Windows estar instalado na unidade `C:`.
|
|
10
|
+
|
|
11
|
+
## Dados assinados
|
|
12
|
+
|
|
13
|
+
O `payload` possui exatamente três campos:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"expires_at": "2027-09-04T23:59:59Z",
|
|
18
|
+
"machine_id": "HW1-ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRST",
|
|
19
|
+
"products": ["OutroPrograma", "TQSVigas"]
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### `machine_id`
|
|
24
|
+
|
|
25
|
+
- identifica o computador autorizado;
|
|
26
|
+
- começa com `HW1-`;
|
|
27
|
+
- após o prefixo, possui exatamente 52 caracteres Base32 maiúsculos, usando `A-Z` e `2-7`;
|
|
28
|
+
- é o SHA-256 dos identificadores normalizados do computador, codificado em Base32;
|
|
29
|
+
- não expõe diretamente UUID SMBIOS, serial da placa-mãe ou `MachineGuid`.
|
|
30
|
+
|
|
31
|
+
### `products`
|
|
32
|
+
|
|
33
|
+
- é uma lista com pelo menos um produto;
|
|
34
|
+
- é armazenada em ordem lexicográfica e sem duplicatas;
|
|
35
|
+
- cada identificador possui de 1 a 100 caracteres;
|
|
36
|
+
- o primeiro caractere deve ser alfanumérico;
|
|
37
|
+
- os demais podem ser alfanuméricos, ponto, sublinhado ou hífen;
|
|
38
|
+
- diferencia letras maiúsculas de minúsculas.
|
|
39
|
+
|
|
40
|
+
### `expires_at`
|
|
41
|
+
|
|
42
|
+
- indica o instante final da validade;
|
|
43
|
+
- usa UTC e não possui microssegundos;
|
|
44
|
+
- segue exatamente o formato `YYYY-MM-DDTHH:MM:SSZ`.
|
|
45
|
+
|
|
46
|
+
## Envelope armazenado
|
|
47
|
+
|
|
48
|
+
O arquivo `-` contém exatamente:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"payload": "BASE64URL_SEM_PADDING",
|
|
53
|
+
"signature": "ASSINATURA_ED25519_EM_BASE64URL"
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- `payload` é o JSON descrito acima, codificado em Base64URL sem `=` no final;
|
|
58
|
+
- `signature` é a assinatura Ed25519 dos bytes exatos do `payload`, também codificada em Base64URL sem `=`;
|
|
59
|
+
- a assinatura decodificada possui exatamente 64 bytes;
|
|
60
|
+
- o emissor produz JSON compacto, com chaves ordenadas e sem espaços desnecessários;
|
|
61
|
+
- o envelope produzido termina com uma quebra de linha.
|
|
62
|
+
|
|
63
|
+
O limite é de 4 KiB para o `payload` e 16 KiB para o blob completo.
|
|
64
|
+
|
|
65
|
+
## Armazenamento
|
|
66
|
+
|
|
67
|
+
`license.storage.save_license()` grava o blob em `C:\ProgramData\CUBO\-` e aplica os atributos `Hidden` e `System`. Esses atributos apenas reduzem a visibilidade no Explorador de Arquivos; não impedem leitura, cópia ou exclusão.
|
|
68
|
+
|
|
69
|
+
## Validação
|
|
70
|
+
|
|
71
|
+
`license` libera um produto somente quando:
|
|
72
|
+
|
|
73
|
+
1. o blob e o `payload` possuem a estrutura esperada;
|
|
74
|
+
2. a assinatura é válida para a chave pública incorporada;
|
|
75
|
+
3. o `machine_id` assinado coincide com o computador atual;
|
|
76
|
+
4. o produto solicitado aparece em `products`;
|
|
77
|
+
5. a hora atual não ultrapassou `expires_at`.
|
|
78
|
+
|
|
79
|
+
O arquivo não é criptografado. Qualquer pessoa pode decodificar e ler seu conteúdo, mas uma alteração no `machine_id`, em `products` ou em `expires_at` invalida a assinatura.
|
|
80
|
+
|
|
81
|
+
## Responsabilidades
|
|
82
|
+
|
|
83
|
+
- `license.issuer`: monta o `payload` e o assina com a chave privada recebida por argumento;
|
|
84
|
+
- `license.machine`: calcula o código da máquina;
|
|
85
|
+
- `license.storage`: lê e instala o blob;
|
|
86
|
+
- `license.verifier`: valida o blob com a chave pública incorporada.
|
|
87
|
+
|
|
88
|
+
Durante este teste de conceito, a chave privada está na raiz do repositório. Em produção, ela nunca deve fazer parte do repositório, do instalador ou dos programas consumidores.
|
cuboutils-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cuboutils
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Utilitários compartilhados pelos produtos CUBO
|
|
5
|
+
Author: Leonardo Pires Batista
|
|
6
|
+
Author-email: leonardopbatista98@gmail.com
|
|
7
|
+
Keywords: cubo licença atualização
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE_FORMAT.md
|
|
11
|
+
Requires-Dist: cryptography==50.0.1
|
|
12
|
+
Dynamic: author
|
|
13
|
+
Dynamic: author-email
|
|
14
|
+
Dynamic: description
|
|
15
|
+
Dynamic: description-content-type
|
|
16
|
+
Dynamic: keywords
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
Dynamic: requires-dist
|
|
19
|
+
Dynamic: requires-python
|
|
20
|
+
Dynamic: summary
|
|
21
|
+
|
|
22
|
+
# CuboUtils
|
|
23
|
+
|
|
24
|
+
Este repositório contém utilitários compartilhados pelos produtos CUBO:
|
|
25
|
+
|
|
26
|
+
- `license/`: emissão, instalação e validação de licenças;
|
|
27
|
+
- `update_checker/`: consulta da versão mais recente de um produto.
|
|
28
|
+
|
|
29
|
+
## Licenças
|
|
30
|
+
|
|
31
|
+
A diferença entre os programas está somente nas operações utilizadas:
|
|
32
|
+
|
|
33
|
+
- o consumidor chama `verify_installed_license()`;
|
|
34
|
+
- o instalador chama `current_machine_id()` e `save_license()`;
|
|
35
|
+
- o emissor chama `issue_license()` e fornece sua chave privada.
|
|
36
|
+
|
|
37
|
+
A chave pública fictícia está incorporada em `license/public_key.py`. Durante este teste de conceito, a chave privada correspondente está em `private-key.pem`, na raiz do repositório.
|
|
38
|
+
|
|
39
|
+
As duas chaves atuais são apenas para desenvolvimento. Antes de qualquer distribuição real, remova `private-key.pem` do repositório e substitua o par por chaves mantidas em um ambiente seguro.
|
|
40
|
+
|
|
41
|
+
A licença instalada usa o caminho fixo `C:\ProgramData\CUBO\-`. `save_license()` grava o arquivo com os atributos `Hidden` e `System`, enquanto os programas consumidores usam somente a interface de validação.
|
|
42
|
+
|
|
43
|
+
O formato compartilhado está descrito em `LICENSE_FORMAT.md`, e os exemplos de cada operação estão em `license/README.md`.
|
|
44
|
+
|
|
45
|
+
## Verificação de versão
|
|
46
|
+
|
|
47
|
+
O endpoint retorna um único objeto JSON que relaciona cada produto à sua versão mais recente:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"TQSVigas": "v1.0.0",
|
|
52
|
+
"TQSFormas": "v1.0.0"
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
O utilitário retorna `True` quando a versão informada é igual ou posterior à versão publicada:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from update_checker import is_latest_version
|
|
60
|
+
|
|
61
|
+
updated = is_latest_version(
|
|
62
|
+
endpoint="https://leonardopbatista.github.io/versions/versions.json",
|
|
63
|
+
product="TQSVigas",
|
|
64
|
+
version="2027.2.0",
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Versões aceitas são sequências numéricas separadas por pontos, com um `v` inicial opcional. A consulta possui timeout padrão de cinco segundos e lança `UpdateCheckError` quando a rede ou a resposta do servidor falha.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# CuboUtils
|
|
2
|
+
|
|
3
|
+
Este repositório contém utilitários compartilhados pelos produtos CUBO:
|
|
4
|
+
|
|
5
|
+
- `license/`: emissão, instalação e validação de licenças;
|
|
6
|
+
- `update_checker/`: consulta da versão mais recente de um produto.
|
|
7
|
+
|
|
8
|
+
## Licenças
|
|
9
|
+
|
|
10
|
+
A diferença entre os programas está somente nas operações utilizadas:
|
|
11
|
+
|
|
12
|
+
- o consumidor chama `verify_installed_license()`;
|
|
13
|
+
- o instalador chama `current_machine_id()` e `save_license()`;
|
|
14
|
+
- o emissor chama `issue_license()` e fornece sua chave privada.
|
|
15
|
+
|
|
16
|
+
A chave pública fictícia está incorporada em `license/public_key.py`. Durante este teste de conceito, a chave privada correspondente está em `private-key.pem`, na raiz do repositório.
|
|
17
|
+
|
|
18
|
+
As duas chaves atuais são apenas para desenvolvimento. Antes de qualquer distribuição real, remova `private-key.pem` do repositório e substitua o par por chaves mantidas em um ambiente seguro.
|
|
19
|
+
|
|
20
|
+
A licença instalada usa o caminho fixo `C:\ProgramData\CUBO\-`. `save_license()` grava o arquivo com os atributos `Hidden` e `System`, enquanto os programas consumidores usam somente a interface de validação.
|
|
21
|
+
|
|
22
|
+
O formato compartilhado está descrito em `LICENSE_FORMAT.md`, e os exemplos de cada operação estão em `license/README.md`.
|
|
23
|
+
|
|
24
|
+
## Verificação de versão
|
|
25
|
+
|
|
26
|
+
O endpoint retorna um único objeto JSON que relaciona cada produto à sua versão mais recente:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"TQSVigas": "v1.0.0",
|
|
31
|
+
"TQSFormas": "v1.0.0"
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
O utilitário retorna `True` quando a versão informada é igual ou posterior à versão publicada:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from update_checker import is_latest_version
|
|
39
|
+
|
|
40
|
+
updated = is_latest_version(
|
|
41
|
+
endpoint="https://leonardopbatista.github.io/versions/versions.json",
|
|
42
|
+
product="TQSVigas",
|
|
43
|
+
version="2027.2.0",
|
|
44
|
+
)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Versões aceitas são sequências numéricas separadas por pontos, com um `v` inicial opcional. A consulta possui timeout padrão de cinco segundos e lança `UpdateCheckError` quando a rede ou a resposta do servidor falha.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cuboutils
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Utilitários compartilhados pelos produtos CUBO
|
|
5
|
+
Author: Leonardo Pires Batista
|
|
6
|
+
Author-email: leonardopbatista98@gmail.com
|
|
7
|
+
Keywords: cubo licença atualização
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE_FORMAT.md
|
|
11
|
+
Requires-Dist: cryptography==50.0.1
|
|
12
|
+
Dynamic: author
|
|
13
|
+
Dynamic: author-email
|
|
14
|
+
Dynamic: description
|
|
15
|
+
Dynamic: description-content-type
|
|
16
|
+
Dynamic: keywords
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
Dynamic: requires-dist
|
|
19
|
+
Dynamic: requires-python
|
|
20
|
+
Dynamic: summary
|
|
21
|
+
|
|
22
|
+
# CuboUtils
|
|
23
|
+
|
|
24
|
+
Este repositório contém utilitários compartilhados pelos produtos CUBO:
|
|
25
|
+
|
|
26
|
+
- `license/`: emissão, instalação e validação de licenças;
|
|
27
|
+
- `update_checker/`: consulta da versão mais recente de um produto.
|
|
28
|
+
|
|
29
|
+
## Licenças
|
|
30
|
+
|
|
31
|
+
A diferença entre os programas está somente nas operações utilizadas:
|
|
32
|
+
|
|
33
|
+
- o consumidor chama `verify_installed_license()`;
|
|
34
|
+
- o instalador chama `current_machine_id()` e `save_license()`;
|
|
35
|
+
- o emissor chama `issue_license()` e fornece sua chave privada.
|
|
36
|
+
|
|
37
|
+
A chave pública fictícia está incorporada em `license/public_key.py`. Durante este teste de conceito, a chave privada correspondente está em `private-key.pem`, na raiz do repositório.
|
|
38
|
+
|
|
39
|
+
As duas chaves atuais são apenas para desenvolvimento. Antes de qualquer distribuição real, remova `private-key.pem` do repositório e substitua o par por chaves mantidas em um ambiente seguro.
|
|
40
|
+
|
|
41
|
+
A licença instalada usa o caminho fixo `C:\ProgramData\CUBO\-`. `save_license()` grava o arquivo com os atributos `Hidden` e `System`, enquanto os programas consumidores usam somente a interface de validação.
|
|
42
|
+
|
|
43
|
+
O formato compartilhado está descrito em `LICENSE_FORMAT.md`, e os exemplos de cada operação estão em `license/README.md`.
|
|
44
|
+
|
|
45
|
+
## Verificação de versão
|
|
46
|
+
|
|
47
|
+
O endpoint retorna um único objeto JSON que relaciona cada produto à sua versão mais recente:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"TQSVigas": "v1.0.0",
|
|
52
|
+
"TQSFormas": "v1.0.0"
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
O utilitário retorna `True` quando a versão informada é igual ou posterior à versão publicada:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from update_checker import is_latest_version
|
|
60
|
+
|
|
61
|
+
updated = is_latest_version(
|
|
62
|
+
endpoint="https://leonardopbatista.github.io/versions/versions.json",
|
|
63
|
+
product="TQSVigas",
|
|
64
|
+
version="2027.2.0",
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Versões aceitas são sequências numéricas separadas por pontos, com um `v` inicial opcional. A consulta possui timeout padrão de cinco segundos e lança `UpdateCheckError` quando a rede ou a resposta do servidor falha.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
LICENSE_FORMAT.md
|
|
2
|
+
README.md
|
|
3
|
+
setup.py
|
|
4
|
+
cuboutils.egg-info/PKG-INFO
|
|
5
|
+
cuboutils.egg-info/SOURCES.txt
|
|
6
|
+
cuboutils.egg-info/dependency_links.txt
|
|
7
|
+
cuboutils.egg-info/requires.txt
|
|
8
|
+
cuboutils.egg-info/top_level.txt
|
|
9
|
+
license/__init__.py
|
|
10
|
+
license/contract.py
|
|
11
|
+
license/issuer.py
|
|
12
|
+
license/machine.py
|
|
13
|
+
license/public_key.py
|
|
14
|
+
license/storage.py
|
|
15
|
+
license/verifier.py
|
|
16
|
+
tests/test_license_flow.py
|
|
17
|
+
tests/test_machine.py
|
|
18
|
+
tests/test_storage.py
|
|
19
|
+
tests/test_update_checker.py
|
|
20
|
+
update_checker/__init__.py
|
|
21
|
+
update_checker/checker.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cryptography==50.0.1
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Emissão, instalação e validação das licenças dos produtos CUBO."""
|
|
2
|
+
|
|
3
|
+
from license.contract import LicenseClaims, LicenseContractError
|
|
4
|
+
from license.issuer import issue_license
|
|
5
|
+
from license.machine import MachineFingerprintError, current_machine_id
|
|
6
|
+
from license.storage import LICENSE_PATH, load_license, save_license
|
|
7
|
+
from license.verifier import LicenseStatus, LicenseVerification, verify_installed_license, verify_license
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"LICENSE_PATH",
|
|
11
|
+
"LicenseClaims",
|
|
12
|
+
"LicenseContractError",
|
|
13
|
+
"LicenseStatus",
|
|
14
|
+
"LicenseVerification",
|
|
15
|
+
"MachineFingerprintError",
|
|
16
|
+
"current_machine_id",
|
|
17
|
+
"issue_license",
|
|
18
|
+
"load_license",
|
|
19
|
+
"save_license",
|
|
20
|
+
"verify_installed_license",
|
|
21
|
+
"verify_license",
|
|
22
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
MACHINE_ID_PATTERN = re.compile(r"HW1-[A-Z2-7]{52}")
|
|
12
|
+
PRODUCT_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,99}")
|
|
13
|
+
_PAYLOAD_FIELDS = frozenset({"machine_id", "products", "expires_at"})
|
|
14
|
+
_ENVELOPE_FIELDS = frozenset({"payload", "signature"})
|
|
15
|
+
_MAX_BLOB_SIZE = 16 * 1024
|
|
16
|
+
_MAX_PAYLOAD_SIZE = 4 * 1024
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LicenseContractError(ValueError):
|
|
20
|
+
"""Indica que uma licença ou seus dados não obedecem ao formato esperado."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class LicenseClaims:
|
|
25
|
+
machine_id: str
|
|
26
|
+
products: tuple[str, ...]
|
|
27
|
+
expires_at: datetime
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
if not isinstance(self.machine_id, str) or not MACHINE_ID_PATTERN.fullmatch(self.machine_id):
|
|
31
|
+
raise LicenseContractError("machine_id é inválido.")
|
|
32
|
+
if not isinstance(self.products, tuple) or not self.products:
|
|
33
|
+
raise LicenseContractError("products deve ser uma tupla com ao menos um produto.")
|
|
34
|
+
if any(not isinstance(product, str) or not PRODUCT_PATTERN.fullmatch(product) for product in self.products):
|
|
35
|
+
raise LicenseContractError("products contém um produto inválido.")
|
|
36
|
+
if not isinstance(self.expires_at, datetime) or self.expires_at.tzinfo is None or self.expires_at.utcoffset() is None:
|
|
37
|
+
raise LicenseContractError("expires_at deve possuir fuso horário.")
|
|
38
|
+
object.__setattr__(self, "products", tuple(sorted(set(self.products))))
|
|
39
|
+
object.__setattr__(self, "expires_at", self.expires_at.astimezone(timezone.utc).replace(microsecond=0))
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_payload_bytes(cls, payload_bytes: bytes) -> LicenseClaims:
|
|
43
|
+
if not payload_bytes or len(payload_bytes) > _MAX_PAYLOAD_SIZE:
|
|
44
|
+
raise LicenseContractError("O tamanho do payload é inválido.")
|
|
45
|
+
try:
|
|
46
|
+
payload = json.loads(payload_bytes.decode("utf-8"))
|
|
47
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
48
|
+
raise LicenseContractError("O payload não contém JSON UTF-8 válido.") from error
|
|
49
|
+
if not isinstance(payload, dict) or set(payload) != _PAYLOAD_FIELDS:
|
|
50
|
+
raise LicenseContractError("O payload não possui exatamente os campos esperados.")
|
|
51
|
+
|
|
52
|
+
machine_id = payload["machine_id"]
|
|
53
|
+
products = payload["products"]
|
|
54
|
+
expires_at = payload["expires_at"]
|
|
55
|
+
if not isinstance(machine_id, str) or not MACHINE_ID_PATTERN.fullmatch(machine_id):
|
|
56
|
+
raise LicenseContractError("machine_id é inválido.")
|
|
57
|
+
if not isinstance(products, list) or not products:
|
|
58
|
+
raise LicenseContractError("products deve ser uma lista com ao menos um produto.")
|
|
59
|
+
if any(not isinstance(product, str) or not PRODUCT_PATTERN.fullmatch(product) for product in products):
|
|
60
|
+
raise LicenseContractError("products contém um produto inválido.")
|
|
61
|
+
if products != sorted(set(products)):
|
|
62
|
+
raise LicenseContractError("products deve estar ordenado e sem duplicatas.")
|
|
63
|
+
if not isinstance(expires_at, str):
|
|
64
|
+
raise LicenseContractError("expires_at deve ser uma string UTC.")
|
|
65
|
+
try:
|
|
66
|
+
parsed_expiration = datetime.strptime(expires_at, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
67
|
+
except ValueError as error:
|
|
68
|
+
raise LicenseContractError("expires_at deve usar o formato YYYY-MM-DDTHH:MM:SSZ.") from error
|
|
69
|
+
return cls(machine_id, tuple(products), parsed_expiration)
|
|
70
|
+
|
|
71
|
+
def to_payload_bytes(self) -> bytes:
|
|
72
|
+
payload = {
|
|
73
|
+
"machine_id": self.machine_id,
|
|
74
|
+
"products": list(self.products),
|
|
75
|
+
"expires_at": self.expires_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
76
|
+
}
|
|
77
|
+
payload_bytes = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
78
|
+
if len(payload_bytes) > _MAX_PAYLOAD_SIZE:
|
|
79
|
+
raise LicenseContractError("O tamanho do payload é inválido.")
|
|
80
|
+
return payload_bytes
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class LicenseEnvelope:
|
|
85
|
+
payload: bytes
|
|
86
|
+
signature: bytes
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_blob(cls, blob: bytes) -> LicenseEnvelope:
|
|
90
|
+
if not blob or len(blob) > _MAX_BLOB_SIZE:
|
|
91
|
+
raise LicenseContractError("O tamanho da licença é inválido.")
|
|
92
|
+
try:
|
|
93
|
+
document = json.loads(blob.decode("utf-8"))
|
|
94
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
95
|
+
raise LicenseContractError("A licença não contém JSON UTF-8 válido.") from error
|
|
96
|
+
if not isinstance(document, dict) or set(document) != _ENVELOPE_FIELDS:
|
|
97
|
+
raise LicenseContractError("A licença não possui exatamente os campos esperados.")
|
|
98
|
+
if any(not isinstance(document[field], str) for field in _ENVELOPE_FIELDS):
|
|
99
|
+
raise LicenseContractError("Os campos da licença devem ser strings.")
|
|
100
|
+
payload = _base64url_decode(document["payload"], "payload")
|
|
101
|
+
signature = _base64url_decode(document["signature"], "signature")
|
|
102
|
+
if not payload or len(payload) > _MAX_PAYLOAD_SIZE:
|
|
103
|
+
raise LicenseContractError("O tamanho do payload é inválido.")
|
|
104
|
+
if len(signature) != 64:
|
|
105
|
+
raise LicenseContractError("A assinatura Ed25519 deve possuir 64 bytes.")
|
|
106
|
+
return cls(payload, signature)
|
|
107
|
+
|
|
108
|
+
def to_blob(self) -> bytes:
|
|
109
|
+
if not self.payload or len(self.payload) > _MAX_PAYLOAD_SIZE:
|
|
110
|
+
raise LicenseContractError("O tamanho do payload é inválido.")
|
|
111
|
+
if len(self.signature) != 64:
|
|
112
|
+
raise LicenseContractError("A assinatura Ed25519 deve possuir 64 bytes.")
|
|
113
|
+
document = {
|
|
114
|
+
"payload": base64.urlsafe_b64encode(self.payload).rstrip(b"=").decode("ascii"),
|
|
115
|
+
"signature": base64.urlsafe_b64encode(self.signature).rstrip(b"=").decode("ascii"),
|
|
116
|
+
}
|
|
117
|
+
blob = json.dumps(document, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
|
|
118
|
+
if len(blob) > _MAX_BLOB_SIZE:
|
|
119
|
+
raise LicenseContractError("O tamanho da licença é inválido.")
|
|
120
|
+
return blob
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _base64url_decode(value: str, field_name: str) -> bytes:
|
|
124
|
+
if not value or not re.fullmatch(r"[A-Za-z0-9_-]+", value):
|
|
125
|
+
raise LicenseContractError(f"{field_name} não contém base64url válido.")
|
|
126
|
+
try:
|
|
127
|
+
return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
|
|
128
|
+
except (ValueError, binascii.Error) as error:
|
|
129
|
+
raise LicenseContractError(f"{field_name} não contém base64url válido.") from error
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from cryptography.hazmat.primitives import serialization
|
|
4
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
5
|
+
|
|
6
|
+
from license.contract import LicenseClaims, LicenseContractError, LicenseEnvelope
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def issue_license(claims: LicenseClaims, private_key_pem: bytes, password: bytes | None = None) -> bytes:
|
|
10
|
+
"""Assina os dados com uma chave privada fornecida pelo ambiente emissor."""
|
|
11
|
+
if not isinstance(claims, LicenseClaims):
|
|
12
|
+
raise TypeError("claims deve ser uma instância de LicenseClaims.")
|
|
13
|
+
try:
|
|
14
|
+
private_key = serialization.load_pem_private_key(private_key_pem, password=password)
|
|
15
|
+
except (TypeError, ValueError) as error:
|
|
16
|
+
raise LicenseContractError("Não foi possível abrir a chave privada.") from error
|
|
17
|
+
if not isinstance(private_key, Ed25519PrivateKey):
|
|
18
|
+
raise LicenseContractError("A chave privada não é Ed25519.")
|
|
19
|
+
payload = claims.to_payload_bytes()
|
|
20
|
+
return LicenseEnvelope(payload, private_key.sign(payload)).to_blob()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import winreg
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_INVALID_VALUES = frozenset({
|
|
14
|
+
"",
|
|
15
|
+
"none",
|
|
16
|
+
"null",
|
|
17
|
+
"unknown",
|
|
18
|
+
"default string",
|
|
19
|
+
"system serial number",
|
|
20
|
+
"to be filled by o.e.m.",
|
|
21
|
+
"not applicable",
|
|
22
|
+
"not specified",
|
|
23
|
+
"ffffffff-ffff-ffff-ffff-ffffffffffff",
|
|
24
|
+
"00000000-0000-0000-0000-000000000000",
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MachineFingerprintError(RuntimeError):
|
|
29
|
+
"""Indica que o computador não forneceu identificadores utilizáveis."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def current_machine_id() -> str:
|
|
33
|
+
"""Calcula o identificador HW1 deste computador Windows."""
|
|
34
|
+
if sys.platform != "win32":
|
|
35
|
+
raise MachineFingerprintError("A coleta automática de identificadores está disponível apenas no Windows.")
|
|
36
|
+
return fingerprint_from_identifiers(collect_windows_identifiers())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def collect_windows_identifiers() -> dict[str, str]:
|
|
40
|
+
"""Coleta UUID SMBIOS, serial da placa-mãe e MachineGuid localmente."""
|
|
41
|
+
identifiers = _read_cim_identifiers()
|
|
42
|
+
machine_guid = _read_machine_guid()
|
|
43
|
+
if machine_guid:
|
|
44
|
+
identifiers["machine_guid"] = machine_guid
|
|
45
|
+
normalized = _normalize_identifiers(identifiers)
|
|
46
|
+
if not normalized:
|
|
47
|
+
raise MachineFingerprintError("O Windows não forneceu nenhum identificador de máquina utilizável.")
|
|
48
|
+
return normalized
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fingerprint_from_identifiers(identifiers: Mapping[str, str]) -> str:
|
|
52
|
+
"""Produz um hash determinístico sem expor os identificadores originais."""
|
|
53
|
+
normalized = _normalize_identifiers(identifiers)
|
|
54
|
+
if not normalized:
|
|
55
|
+
raise MachineFingerprintError("Nenhum identificador de máquina utilizável foi informado.")
|
|
56
|
+
serialized = json.dumps(normalized, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
57
|
+
return "HW1-" + base64.b32encode(hashlib.sha256(serialized).digest()).rstrip(b"=").decode("ascii")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _read_cim_identifiers() -> dict[str, str]:
|
|
61
|
+
script = "$ErrorActionPreference = 'Stop';" + \
|
|
62
|
+
"$board = Get-CimInstance -ClassName Win32_BaseBoard | Select-Object -First 1;" + \
|
|
63
|
+
"$system = Get-CimInstance -ClassName Win32_ComputerSystemProduct | Select-Object -First 1;" + \
|
|
64
|
+
"[PSCustomObject]@{baseboard_serial=$board.SerialNumber;system_uuid=$system.UUID} | ConvertTo-Json -Compress"
|
|
65
|
+
try:
|
|
66
|
+
process = subprocess.run(
|
|
67
|
+
["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
68
|
+
check=True,
|
|
69
|
+
capture_output=True,
|
|
70
|
+
text=True,
|
|
71
|
+
timeout=15,
|
|
72
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
73
|
+
)
|
|
74
|
+
content = json.loads(process.stdout)
|
|
75
|
+
except (OSError, subprocess.SubprocessError, json.JSONDecodeError):
|
|
76
|
+
return {}
|
|
77
|
+
if not isinstance(content, dict):
|
|
78
|
+
return {}
|
|
79
|
+
return {str(key): str(value) for key, value in content.items() if value is not None}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _read_machine_guid() -> str:
|
|
83
|
+
try:
|
|
84
|
+
with winreg.OpenKey(
|
|
85
|
+
winreg.HKEY_LOCAL_MACHINE,
|
|
86
|
+
"SOFTWARE\\Microsoft\\Cryptography",
|
|
87
|
+
0,
|
|
88
|
+
winreg.KEY_READ | winreg.KEY_WOW64_64KEY,
|
|
89
|
+
) as registry_key:
|
|
90
|
+
value, _value_type = winreg.QueryValueEx(registry_key, "MachineGuid")
|
|
91
|
+
except OSError:
|
|
92
|
+
return ""
|
|
93
|
+
return str(value)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _normalize_identifiers(identifiers: Mapping[str, str]) -> dict[str, str]:
|
|
97
|
+
normalized: dict[str, str] = {}
|
|
98
|
+
for raw_name, raw_value in identifiers.items():
|
|
99
|
+
name = re.sub(r"[^a-z0-9_]+", "_", str(raw_name).strip().casefold()).strip("_")
|
|
100
|
+
value = " ".join(str(raw_value).strip().casefold().split())
|
|
101
|
+
compact = re.sub(r"[^a-z0-9]", "", value)
|
|
102
|
+
repeated_placeholder = len(compact) >= 8 and len(set(compact)) == 1
|
|
103
|
+
if name and value and value not in _INVALID_VALUES and not repeated_placeholder:
|
|
104
|
+
normalized[name] = value
|
|
105
|
+
return dict(sorted(normalized.items()))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ctypes
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _GUID(ctypes.Structure):
|
|
9
|
+
_fields_ = [
|
|
10
|
+
("data1", ctypes.c_uint32),
|
|
11
|
+
("data2", ctypes.c_uint16),
|
|
12
|
+
("data3", ctypes.c_uint16),
|
|
13
|
+
("data4", ctypes.c_ubyte * 8),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_PROGRAM_DATA_FOLDER_ID = _GUID.from_buffer_copy(UUID("62ab5d82-fdc1-4dc3-a9dd-070d1d495d97").bytes_le)
|
|
18
|
+
_FILE_ATTRIBUTE_HIDDEN = 0x02
|
|
19
|
+
_FILE_ATTRIBUTE_SYSTEM = 0x04
|
|
20
|
+
_INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def program_data_path() -> Path:
|
|
24
|
+
"""Consulta o caminho real do ProgramData pela API de pastas conhecidas do Windows."""
|
|
25
|
+
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
|
|
26
|
+
ole32 = ctypes.WinDLL("ole32", use_last_error=True)
|
|
27
|
+
get_known_folder_path = shell32.SHGetKnownFolderPath
|
|
28
|
+
get_known_folder_path.argtypes = [ctypes.POINTER(_GUID), ctypes.c_uint32, ctypes.c_void_p, ctypes.POINTER(ctypes.c_wchar_p)]
|
|
29
|
+
get_known_folder_path.restype = ctypes.c_long
|
|
30
|
+
ole32.CoTaskMemFree.argtypes = [ctypes.c_void_p]
|
|
31
|
+
|
|
32
|
+
path_pointer = ctypes.c_wchar_p()
|
|
33
|
+
result = get_known_folder_path(ctypes.byref(_PROGRAM_DATA_FOLDER_ID), 0, None, ctypes.byref(path_pointer))
|
|
34
|
+
if result != 0:
|
|
35
|
+
raise OSError(f"O Windows não informou o caminho do ProgramData: HRESULT 0x{result & 0xFFFFFFFF:08X}.")
|
|
36
|
+
try:
|
|
37
|
+
return Path(path_pointer.value)
|
|
38
|
+
finally:
|
|
39
|
+
ole32.CoTaskMemFree(path_pointer)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
LICENSE_PATH = program_data_path() / "CUBO" / "-"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_license() -> bytes:
|
|
46
|
+
"""Lê a licença instalada no caminho padrão do CUBO."""
|
|
47
|
+
return LICENSE_PATH.read_bytes()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def save_license(blob: bytes) -> Path:
|
|
51
|
+
"""Grava o blob recebido e o oculta como arquivo de sistema no Windows."""
|
|
52
|
+
if not isinstance(blob, bytes) or not blob:
|
|
53
|
+
raise ValueError("A licença deve conter bytes.")
|
|
54
|
+
LICENSE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
LICENSE_PATH.write_bytes(blob)
|
|
56
|
+
_set_hidden_and_system(LICENSE_PATH)
|
|
57
|
+
return LICENSE_PATH
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _set_hidden_and_system(path: Path) -> None:
|
|
61
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
62
|
+
get_attributes = kernel32.GetFileAttributesW
|
|
63
|
+
get_attributes.argtypes = [ctypes.c_wchar_p]
|
|
64
|
+
get_attributes.restype = ctypes.c_uint32
|
|
65
|
+
set_attributes = kernel32.SetFileAttributesW
|
|
66
|
+
set_attributes.argtypes = [ctypes.c_wchar_p, ctypes.c_uint32]
|
|
67
|
+
set_attributes.restype = ctypes.c_int
|
|
68
|
+
|
|
69
|
+
absolute_path = str(path.resolve())
|
|
70
|
+
attributes = get_attributes(absolute_path)
|
|
71
|
+
if attributes == _INVALID_FILE_ATTRIBUTES:
|
|
72
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
73
|
+
if not set_attributes(absolute_path, attributes | _FILE_ATTRIBUTE_HIDDEN | _FILE_ATTRIBUTE_SYSTEM):
|
|
74
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
from cryptography.exceptions import InvalidSignature
|
|
9
|
+
from cryptography.hazmat.primitives import serialization
|
|
10
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
11
|
+
|
|
12
|
+
from license.contract import LicenseClaims, LicenseContractError, LicenseEnvelope
|
|
13
|
+
from license.machine import MachineFingerprintError, current_machine_id
|
|
14
|
+
from license.public_key import PUBLIC_KEY_PEM
|
|
15
|
+
from license.storage import load_license
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LicenseStatus(StrEnum):
|
|
19
|
+
valid = "valid"
|
|
20
|
+
license_not_found = "license_not_found"
|
|
21
|
+
license_read_error = "license_read_error"
|
|
22
|
+
machine_error = "machine_error"
|
|
23
|
+
malformed = "malformed"
|
|
24
|
+
invalid_public_key = "invalid_public_key"
|
|
25
|
+
invalid_signature = "invalid_signature"
|
|
26
|
+
wrong_machine = "wrong_machine"
|
|
27
|
+
wrong_product = "wrong_product"
|
|
28
|
+
expired = "expired"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class LicenseVerification:
|
|
33
|
+
valid: bool
|
|
34
|
+
status: LicenseStatus
|
|
35
|
+
message: str
|
|
36
|
+
claims: LicenseClaims | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def verify_installed_license(product: str, now: datetime | None = None) -> LicenseVerification:
|
|
40
|
+
"""Valida a licença instalada sem expor detalhes de armazenamento ou hardware ao programa."""
|
|
41
|
+
try:
|
|
42
|
+
blob = load_license()
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
return _failure(LicenseStatus.license_not_found, "A licença não está instalada.")
|
|
45
|
+
except OSError as error:
|
|
46
|
+
return _failure(LicenseStatus.license_read_error, f"Não foi possível ler a licença: {error}.")
|
|
47
|
+
try:
|
|
48
|
+
machine_id = current_machine_id()
|
|
49
|
+
except MachineFingerprintError as error:
|
|
50
|
+
return _failure(LicenseStatus.machine_error, str(error))
|
|
51
|
+
return verify_license(blob, machine_id, product, now)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def verify_license(
|
|
55
|
+
blob: bytes,
|
|
56
|
+
expected_machine_id: str,
|
|
57
|
+
product: str,
|
|
58
|
+
now: datetime | None = None,
|
|
59
|
+
) -> LicenseVerification:
|
|
60
|
+
"""Valida assinatura, computador, produto e vencimento."""
|
|
61
|
+
try:
|
|
62
|
+
envelope = LicenseEnvelope.from_blob(blob)
|
|
63
|
+
except LicenseContractError as error:
|
|
64
|
+
return _failure(LicenseStatus.malformed, str(error))
|
|
65
|
+
try:
|
|
66
|
+
public_key = serialization.load_pem_public_key(PUBLIC_KEY_PEM)
|
|
67
|
+
except (TypeError, ValueError) as error:
|
|
68
|
+
return _failure(LicenseStatus.invalid_public_key, f"Não foi possível abrir a chave pública: {error}.")
|
|
69
|
+
if not isinstance(public_key, Ed25519PublicKey):
|
|
70
|
+
return _failure(LicenseStatus.invalid_public_key, "A chave pública não é Ed25519.")
|
|
71
|
+
try:
|
|
72
|
+
public_key.verify(envelope.signature, envelope.payload)
|
|
73
|
+
except InvalidSignature:
|
|
74
|
+
return _failure(LicenseStatus.invalid_signature, "A assinatura da licença é inválida.")
|
|
75
|
+
try:
|
|
76
|
+
claims = LicenseClaims.from_payload_bytes(envelope.payload)
|
|
77
|
+
except LicenseContractError as error:
|
|
78
|
+
return _failure(LicenseStatus.malformed, str(error))
|
|
79
|
+
if not hmac.compare_digest(claims.machine_id, expected_machine_id):
|
|
80
|
+
return _failure(LicenseStatus.wrong_machine, "A licença pertence a outro computador.", claims)
|
|
81
|
+
if not any(hmac.compare_digest(licensed_product, product) for licensed_product in claims.products):
|
|
82
|
+
return _failure(LicenseStatus.wrong_product, f"A licença não libera o produto {product}.", claims)
|
|
83
|
+
current_time = datetime.now(timezone.utc) if now is None else now
|
|
84
|
+
if current_time.tzinfo is None or current_time.utcoffset() is None:
|
|
85
|
+
raise ValueError("now deve possuir fuso horário.")
|
|
86
|
+
if current_time.astimezone(timezone.utc) > claims.expires_at:
|
|
87
|
+
return _failure(LicenseStatus.expired, "A licença está vencida.", claims)
|
|
88
|
+
return LicenseVerification(True, LicenseStatus.valid, "Licença válida.", claims)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _failure(status: LicenseStatus, message: str, claims: LicenseClaims | None = None) -> LicenseVerification:
|
|
92
|
+
return LicenseVerification(False, status, message, claims)
|
cuboutils-0.1.0/setup.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from setuptools import find_packages, setup
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
readme = (Path(__file__).parent / "README.md").read_text(encoding="utf-8")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
setup(
|
|
10
|
+
name="cuboutils",
|
|
11
|
+
version="0.1.0",
|
|
12
|
+
author="Leonardo Pires Batista",
|
|
13
|
+
author_email="leonardopbatista98@gmail.com",
|
|
14
|
+
description="Utilitários compartilhados pelos produtos CUBO",
|
|
15
|
+
long_description=readme,
|
|
16
|
+
long_description_content_type="text/markdown",
|
|
17
|
+
keywords="cubo licença atualização",
|
|
18
|
+
packages=find_packages(exclude=("tests", "tests.*")),
|
|
19
|
+
python_requires=">=3.12",
|
|
20
|
+
install_requires=["cryptography==50.0.1"],
|
|
21
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest import TestCase, skipUnless
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
from cryptography.hazmat.primitives import serialization
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
10
|
+
|
|
11
|
+
from license import LicenseClaims, LicenseStatus, issue_license, verify_installed_license, verify_license
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
MACHINE_ID = "HW1-" + "A" * 52
|
|
15
|
+
OTHER_MACHINE_ID = "HW1-" + "B" * 52
|
|
16
|
+
PRIVATE_KEY_PATH = Path(__file__).resolve().parents[1] / "private-key.pem"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _instant(value: str) -> datetime:
|
|
20
|
+
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LicenseFlowTests(TestCase):
|
|
24
|
+
def setUp(self) -> None:
|
|
25
|
+
private_key = Ed25519PrivateKey.generate()
|
|
26
|
+
private_key_pem = private_key.private_bytes(
|
|
27
|
+
encoding=serialization.Encoding.PEM,
|
|
28
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
29
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
30
|
+
)
|
|
31
|
+
public_key_pem = private_key.public_key().public_bytes(
|
|
32
|
+
encoding=serialization.Encoding.PEM,
|
|
33
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
34
|
+
)
|
|
35
|
+
public_key_patch = patch("license.verifier.PUBLIC_KEY_PEM", public_key_pem)
|
|
36
|
+
public_key_patch.start()
|
|
37
|
+
self.addCleanup(public_key_patch.stop)
|
|
38
|
+
self.claims = LicenseClaims(MACHINE_ID, ("TQSVigas", "OutroProduto"), _instant("2027-09-04T23:59:59Z"))
|
|
39
|
+
self.blob = issue_license(self.claims, private_key_pem)
|
|
40
|
+
|
|
41
|
+
def test_issued_license_is_accepted_by_verifier(self) -> None:
|
|
42
|
+
first = verify_license(self.blob, MACHINE_ID, "TQSVigas", _instant("2026-09-20T12:00:00Z"))
|
|
43
|
+
second = verify_license(self.blob, MACHINE_ID, "OutroProduto", _instant("2026-09-20T12:00:00Z"))
|
|
44
|
+
|
|
45
|
+
self.assertTrue(first.valid)
|
|
46
|
+
self.assertTrue(second.valid)
|
|
47
|
+
self.assertEqual(first.claims.products, ("OutroProduto", "TQSVigas"))
|
|
48
|
+
|
|
49
|
+
def test_consumer_validates_the_installed_license_with_one_call(self) -> None:
|
|
50
|
+
with patch("license.verifier.load_license", return_value=self.blob), \
|
|
51
|
+
patch("license.verifier.current_machine_id", return_value=MACHINE_ID):
|
|
52
|
+
result = verify_installed_license("TQSVigas", _instant("2026-09-20T12:00:00Z"))
|
|
53
|
+
|
|
54
|
+
self.assertTrue(result.valid)
|
|
55
|
+
|
|
56
|
+
def test_reports_when_license_is_not_installed(self) -> None:
|
|
57
|
+
with patch("license.verifier.load_license", side_effect=FileNotFoundError):
|
|
58
|
+
result = verify_installed_license("TQSVigas")
|
|
59
|
+
|
|
60
|
+
self.assertEqual(result.status, LicenseStatus.license_not_found)
|
|
61
|
+
|
|
62
|
+
def test_rejects_tampered_license(self) -> None:
|
|
63
|
+
document = json.loads(self.blob)
|
|
64
|
+
payload = json.loads(base64.urlsafe_b64decode(document["payload"] + "=" * (-len(document["payload"]) % 4)))
|
|
65
|
+
payload["products"] = ["ProdutoAdulterado"]
|
|
66
|
+
changed = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
|
67
|
+
document["payload"] = base64.urlsafe_b64encode(changed).rstrip(b"=").decode()
|
|
68
|
+
|
|
69
|
+
result = verify_license(json.dumps(document).encode(), MACHINE_ID, "ProdutoAdulterado", _instant("2026-09-20T12:00:00Z"))
|
|
70
|
+
|
|
71
|
+
self.assertEqual(result.status, LicenseStatus.invalid_signature)
|
|
72
|
+
|
|
73
|
+
def test_rejects_wrong_machine_product_and_expiration(self) -> None:
|
|
74
|
+
wrong_machine = verify_license(self.blob, OTHER_MACHINE_ID, "TQSVigas", _instant("2026-09-20T12:00:00Z"))
|
|
75
|
+
wrong_product = verify_license(self.blob, MACHINE_ID, "ProdutoSemLicenca", _instant("2026-09-20T12:00:00Z"))
|
|
76
|
+
expired = verify_license(self.blob, MACHINE_ID, "TQSVigas", _instant("2027-09-05T12:00:00Z"))
|
|
77
|
+
|
|
78
|
+
self.assertEqual(wrong_machine.status, LicenseStatus.wrong_machine)
|
|
79
|
+
self.assertEqual(wrong_product.status, LicenseStatus.wrong_product)
|
|
80
|
+
self.assertEqual(expired.status, LicenseStatus.expired)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@skipUnless(PRIVATE_KEY_PATH.is_file(), "A chave privada local do protótipo não está disponível.")
|
|
84
|
+
class PrototypeKeyPairTests(TestCase):
|
|
85
|
+
def test_repository_private_key_matches_the_embedded_public_key(self) -> None:
|
|
86
|
+
claims = LicenseClaims(MACHINE_ID, ("TQSVigas",), _instant("2027-09-04T23:59:59Z"))
|
|
87
|
+
|
|
88
|
+
blob = issue_license(claims, PRIVATE_KEY_PATH.read_bytes())
|
|
89
|
+
result = verify_license(blob, MACHINE_ID, "TQSVigas", _instant("2026-09-20T12:00:00Z"))
|
|
90
|
+
|
|
91
|
+
self.assertTrue(result.valid)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from unittest import TestCase
|
|
2
|
+
|
|
3
|
+
from license import current_machine_id
|
|
4
|
+
from license.machine import MachineFingerprintError, fingerprint_from_identifiers
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MachineFingerprintTests(TestCase):
|
|
8
|
+
def test_is_deterministic_and_hides_source_values(self) -> None:
|
|
9
|
+
first = fingerprint_from_identifiers({"system_uuid": " ABC-123 ", "baseboard_serial": "Board-456"})
|
|
10
|
+
second = fingerprint_from_identifiers({"baseboard_serial": "board-456", "system_uuid": "abc-123"})
|
|
11
|
+
|
|
12
|
+
self.assertEqual(first, second)
|
|
13
|
+
self.assertRegex(first, r"^HW1-[A-Z2-7]{52}$")
|
|
14
|
+
self.assertNotIn("ABC", first)
|
|
15
|
+
self.assertNotIn("456", first)
|
|
16
|
+
|
|
17
|
+
def test_package_exports_the_machine_code(self) -> None:
|
|
18
|
+
from license.machine import current_machine_id as implementation
|
|
19
|
+
|
|
20
|
+
self.assertIs(current_machine_id, implementation)
|
|
21
|
+
|
|
22
|
+
def test_rejects_placeholder_identifiers(self) -> None:
|
|
23
|
+
with self.assertRaises(MachineFingerprintError):
|
|
24
|
+
fingerprint_from_identifiers({"system_uuid": "00000000-0000-0000-0000-000000000000", "serial": "FFFFFFFF"})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from unittest import TestCase
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
from license import save_license
|
|
6
|
+
from license.storage import LICENSE_PATH
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LicenseStorageTests(TestCase):
|
|
10
|
+
def test_uses_the_expected_path(self) -> None:
|
|
11
|
+
self.assertEqual(LICENSE_PATH.name, "-")
|
|
12
|
+
self.assertEqual(LICENSE_PATH.parent.name, "CUBO")
|
|
13
|
+
|
|
14
|
+
def test_saves_blob_and_applies_windows_attributes(self) -> None:
|
|
15
|
+
path = Path(r"C:\ProgramData\CUBO\-")
|
|
16
|
+
with patch("license.storage.LICENSE_PATH", path), patch.object(Path, "mkdir") as make_directory, \
|
|
17
|
+
patch.object(Path, "write_bytes") as write_bytes, patch("license.storage._set_hidden_and_system") as set_attributes:
|
|
18
|
+
saved_path = save_license(b"licenca")
|
|
19
|
+
|
|
20
|
+
self.assertEqual(saved_path, path)
|
|
21
|
+
make_directory.assert_called_once_with(parents=True, exist_ok=True)
|
|
22
|
+
write_bytes.assert_called_once_with(b"licenca")
|
|
23
|
+
set_attributes.assert_called_once_with(path)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from unittest import TestCase
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
from update_checker import UpdateCheckError, is_latest_version
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UpdateCheckerTests(TestCase):
|
|
9
|
+
def test_reports_the_latest_version(self) -> None:
|
|
10
|
+
response = self._response({"TQSVigas": "v2027.2.0", "TQSFormas": "v2027.1.0"})
|
|
11
|
+
|
|
12
|
+
with patch("update_checker.checker.urlopen", return_value=response) as open_endpoint:
|
|
13
|
+
result = is_latest_version("https://updates.example.com/versions.json", "TQSVigas", "2027.2")
|
|
14
|
+
|
|
15
|
+
self.assertTrue(result)
|
|
16
|
+
request = open_endpoint.call_args.args[0]
|
|
17
|
+
self.assertEqual(request.full_url, "https://updates.example.com/versions.json")
|
|
18
|
+
|
|
19
|
+
def test_reports_an_outdated_version(self) -> None:
|
|
20
|
+
response = self._response({"TQSVigas": "2027.2.0"})
|
|
21
|
+
|
|
22
|
+
with patch("update_checker.checker.urlopen", return_value=response):
|
|
23
|
+
result = is_latest_version("https://updates.example.com/versions.json", "TQSVigas", "2027.1.9")
|
|
24
|
+
|
|
25
|
+
self.assertFalse(result)
|
|
26
|
+
|
|
27
|
+
def test_accepts_a_version_newer_than_the_published_version(self) -> None:
|
|
28
|
+
response = self._response({"TQSVigas": "2027.2.0"})
|
|
29
|
+
|
|
30
|
+
with patch("update_checker.checker.urlopen", return_value=response):
|
|
31
|
+
result = is_latest_version("https://updates.example.com/versions.json", "TQSVigas", "2028.1.0")
|
|
32
|
+
|
|
33
|
+
self.assertTrue(result)
|
|
34
|
+
|
|
35
|
+
def test_rejects_an_insecure_endpoint(self) -> None:
|
|
36
|
+
with self.assertRaisesRegex(ValueError, "HTTPS"):
|
|
37
|
+
is_latest_version("http://updates.example.com/versions.json", "TQSVigas", "2027.2.0")
|
|
38
|
+
|
|
39
|
+
def test_reports_a_missing_product(self) -> None:
|
|
40
|
+
response = self._response({"OutroProduto": "2027.2.0"})
|
|
41
|
+
|
|
42
|
+
with patch("update_checker.checker.urlopen", return_value=response), self.assertRaises(UpdateCheckError):
|
|
43
|
+
is_latest_version("https://updates.example.com/versions.json", "TQSVigas", "2027.2.0")
|
|
44
|
+
|
|
45
|
+
def test_rejects_an_invalid_published_version(self) -> None:
|
|
46
|
+
response = self._response({"TQSVigas": "versão inválida"})
|
|
47
|
+
|
|
48
|
+
with patch("update_checker.checker.urlopen", return_value=response), self.assertRaises(UpdateCheckError):
|
|
49
|
+
is_latest_version("https://updates.example.com/versions.json", "TQSVigas", "2027.2.0")
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _response(versions: dict[str, str]) -> MagicMock:
|
|
53
|
+
response = MagicMock()
|
|
54
|
+
response.read.return_value = json.dumps(versions).encode("utf-8")
|
|
55
|
+
response.__enter__.return_value = response
|
|
56
|
+
return response
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from urllib.error import HTTPError, URLError
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
from urllib.request import Request, urlopen
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_MAX_RESPONSE_SIZE = 4 * 1024
|
|
11
|
+
_VERSION_PATTERN = re.compile(r"v?\d+(?:\.\d+)*")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UpdateCheckError(RuntimeError):
|
|
15
|
+
"""Indica que não foi possível verificar a versão mais recente."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_latest_version(endpoint: str, product: str, version: str, timeout: float = 5.0) -> bool:
|
|
19
|
+
"""Informa se a versão instalada é igual ou posterior à versão publicada."""
|
|
20
|
+
if not isinstance(product, str) or not product:
|
|
21
|
+
raise ValueError("product deve ser uma string não vazia.")
|
|
22
|
+
current_version = _parse_version(version)
|
|
23
|
+
_validate_endpoint(endpoint)
|
|
24
|
+
request = Request(endpoint, headers={"Accept": "application/json"})
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
with urlopen(request, timeout=timeout) as response:
|
|
28
|
+
content = response.read(_MAX_RESPONSE_SIZE + 1)
|
|
29
|
+
except (HTTPError, URLError, OSError) as error:
|
|
30
|
+
raise UpdateCheckError(f"Não foi possível consultar a versão de {product}.") from error
|
|
31
|
+
|
|
32
|
+
if len(content) > _MAX_RESPONSE_SIZE:
|
|
33
|
+
raise UpdateCheckError("A resposta do servidor excede o tamanho permitido.")
|
|
34
|
+
try:
|
|
35
|
+
document = json.loads(content.decode("utf-8"))
|
|
36
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
37
|
+
raise UpdateCheckError("O servidor não retornou um JSON UTF-8 válido.") from error
|
|
38
|
+
|
|
39
|
+
if not isinstance(document, dict):
|
|
40
|
+
raise UpdateCheckError("A resposta do servidor deve ser um objeto JSON.")
|
|
41
|
+
if product not in document:
|
|
42
|
+
raise UpdateCheckError(f"O produto {product} não foi encontrado na resposta do servidor.")
|
|
43
|
+
latest_version = document[product]
|
|
44
|
+
if not isinstance(latest_version, str):
|
|
45
|
+
raise UpdateCheckError(f"O servidor não retornou uma versão válida para {product}.")
|
|
46
|
+
try:
|
|
47
|
+
published_version = _parse_version(latest_version)
|
|
48
|
+
except ValueError as error:
|
|
49
|
+
raise UpdateCheckError("O servidor retornou uma versão inválida.") from error
|
|
50
|
+
|
|
51
|
+
return current_version >= published_version
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _validate_endpoint(endpoint: str) -> None:
|
|
55
|
+
if not isinstance(endpoint, str):
|
|
56
|
+
raise TypeError("endpoint deve ser uma string.")
|
|
57
|
+
parts = urlsplit(endpoint)
|
|
58
|
+
if parts.scheme.casefold() != "https" or not parts.netloc:
|
|
59
|
+
raise ValueError("endpoint deve ser uma URL HTTPS válida.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _parse_version(value: str) -> tuple[int, ...]:
|
|
63
|
+
if not isinstance(value, str) or not _VERSION_PATTERN.fullmatch(value):
|
|
64
|
+
raise ValueError("A versão deve conter números separados por pontos e pode começar com v.")
|
|
65
|
+
parts = [int(part) for part in value.removeprefix("v").split(".")]
|
|
66
|
+
while len(parts) > 1 and parts[-1] == 0:
|
|
67
|
+
parts.pop()
|
|
68
|
+
return tuple(parts)
|