Metadata-Version: 2.4
Name: common-encryption
Version: 1.0.0
Summary: Python port of the common-encryption Java module: transparent field-level encryption and decryption
Author-email: Francesco Baldi <francesco.baldi1987@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/bld-commons/dev-commons
Project-URL: Repository, https://github.com/bld-commons/dev-commons
Project-URL: Issues, https://github.com/bld-commons/dev-commons/issues
Keywords: cryptography,encryption,jks,pkcs12,rsa,x25519
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Security :: Cryptography
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: cryptography>=43
Provides-Extra: test
Requires-Dist: pytest>=8; extra == "test"
Dynamic: license-file

# common-encryption

Python port of the Java module [`common-encryption`](../common-encryption): field-level
encryption and decryption that is transparent to the caller, plus the underlying
utility classes.

Interoperability with the Java original is a hard requirement, not a best effort.
Payloads produced by either side decrypt on the other, byte for byte, and the test
suite locks this down against vectors generated by the Java code.

| Java | Python |
| --- | --- |
| `com.bld.crypto.pkcs12.CryptoPkcs12Utils` | `common_encryption.CryptoPkcs12Utils` |
| `com.bld.crypto.jks.CryptoJksUtils` | `common_encryption.CryptoJksUtils` |
| `@CryptoPkcs12` / `@CryptoJks` | `Annotated[T, CryptoPkcs12(...)]` |
| `@ConfigurationProperties` wiring | `CryptoConfig.from_properties()` / `from_env()` |

## Install

```bash
pip install common-encryption
```

Requires Python 3.10+ and `cryptography>=43` (the first release that can read an
X25519 key from a PKCS12 keystore).

## Transparent field encryption

Java marks a field with an annotation and lets Jackson encrypt it on the way out and
decrypt it on the way in. Python has no implicit hook, so the same contract is expressed
with `typing.Annotated` metadata plus `to_json` / `from_json`.

```python
from dataclasses import dataclass
from typing import Annotated

from common_encryption import CryptoPkcs12, CryptoConfig, from_json, to_json

CryptoConfig.from_properties({
    "com.bld": {"crypto": {"pkcs12": {
        "file": "/etc/keystore/app.p12",
        "password": "changeit",
        "alias": "mase-x25519",
        "instance-jks": "PKCS12",
    }}}
})

@dataclass
class IdUserFilter:
    id_user: Annotated[int | None, CryptoPkcs12("idUser", url=True)] = None
    roles: Annotated[list[int] | None, CryptoPkcs12("roles")] = None

body = to_json(IdUserFilter(1, [10, 20]))
# {"id_user":"TUNvd0JR...","roles":["MCowBQYD...","MCowBQYD..."]}

assert from_json(IdUserFilter, body) == IdUserFilter(1, [10, 20])
```

Field values are rendered the way the Java serializer renders them, and the same type
comes back:

- `str` and `int`/`float` are encrypted as text; `bool` as `"true"` / `"false"`.
- `list`, `tuple` and `set` are encrypted element by element.
- anything else is serialised to JSON first, so nested dataclasses work.
- `None` stays `None`; a blank word decrypts back to `None`.

| Annotation | Module | What the token is |
| --- | --- | --- |
| `CryptoPkcs12("idUser", url=False)` | PKCS12 | hybrid encrypted, bound to `idUser` |
| `CryptoJks("name")` | JKS | RSA encrypted, bound to `name` |
| `CryptoAes("k1")` | AES | AES encrypted, not bound |
| `CryptoHmac("idUser")` | HMAC | signed envelope, bound to `idUser` |
| `CryptoPubKey("mase")` | public key | RSA encrypted, not bound |
| `CryptoPubKeyEncrypt("mase")` / `CryptoPubKeyDecrypt("mase")` | public key | one direction only |

The `value` argument is a logical key identifier that gets sealed into the token and
checked on decryption, so a token cannot be moved from one field to another. AES and
public-key tokens carry no envelope, exactly as in Java.

### Configuration

`CryptoConfig.from_properties()` accepts every shape Spring produces, so the parsed
`application.yml` can be handed over verbatim:

```python
CryptoConfig.from_properties(yaml.safe_load(open("application.yml")))
CryptoConfig.from_properties({"com.bld.crypto.pkcs12.file": "app.p12", ...})
```

| Property | Environment variable |
| --- | --- |
| `com.bld.crypto.pkcs12.file` | `BLD_CRYPTO_PKCS12_FILE` |
| `com.bld.crypto.pkcs12.password` | `BLD_CRYPTO_PKCS12_PASSWORD` |
| `com.bld.crypto.pkcs12.alias` | `BLD_CRYPTO_PKCS12_ALIAS` |
| `com.bld.crypto.pkcs12.instance-jks` | `BLD_CRYPTO_PKCS12_INSTANCE_JKS` |
| `com.bld.crypto.jks.*` | `BLD_CRYPTO_JKS_*` |
| `com.bld.crypto.signature.*` | `BLD_CRYPTO_SIGNATURE_*` |
| `com.bld.crypto.hmac.secret` | `BLD_CRYPTO_HMAC_SECRET` |
| `com.bld.crypto.hmac.algorithm` | `BLD_CRYPTO_HMAC_ALGORITHM` |
| `com.bld.crypto.hmac.salt` | `BLD_CRYPTO_HMAC_SALT` |
| `com.bld.crypto.hmac.key-length` | `BLD_CRYPTO_HMAC_KEY_LENGTH` |

A section without `file` is skipped rather than raising, mirroring Java's
`@ConditionalOnProperty`. Property names are read relaxed, so `keyLength`,
`key-length` and `key_length` are the same field.

The AES keys and the public keys are named maps in Java, so they are configured through
properties or YAML rather than through a single environment variable:

```yaml
com:
  bld:
    crypto:
      aes:
        keys:
          k1:
            password: 0123456789abcdef   # raw key, 16/24/32 bytes
      public-key:
        keys:
          mase: classpath:cert.pem      # PEM certificate or Base64 SubjectPublicKeyInfo
```

Setting `password` together with `salt` and `keyLength` switches to PBKDF2: HMAC-SHA1 with
65536 iterations for AES, HMAC-SHA256 for HMAC, as in `AesConfiguration` and
`HmacConfiguration`.

## Utility classes

```python
from common_encryption import CryptoConfig, CryptoType

config = CryptoConfig.from_env()
pkcs12, jks = config.pkcs12, config.jks

pkcs12.encrypt_value("plain")                     # Base64(spki).Base64(nonce).Base64(ct+tag)
pkcs12.decrypt_uri(pkcs12.encrypt_uri("plain"))   # URL-safe variant
pkcs12.encrypt_key_value("email", "a@b.it")       # token bound to a field name
pkcs12.decrypt_object(pkcs12.encrypt_object({"id": 1}))

jks.encrypt_value("plain")                        # RSA, encrypt with the public key
jks.decrypt_value(token)                          # decrypt with the private key
jks.decrypt_value(jks.encrypt_value("x", CryptoType.private_key), CryptoType.public_key)
```

Both expose the same shape: `encrypt_value` / `decrypt_value`, `encrypt_uri` /
`decrypt_uri`, `encrypt_key_value` / `decrypt_key_value`, `encrypt_key_uri` /
`decrypt_key_uri`, and the `*_object` variants that round-trip JSON. `CryptoJksUtils`
takes an explicit `CryptoType` for each direction, since JCA decides the operation from
the key it is handed.

## AES, HMAC, public keys and signatures

```python
from common_encryption import (
    AesSecrets, CryptoAesUtils, CryptoHmacUtils, CryptoMapPublicKeyUtils,
    HmacSecret, SecretSpec, SignatureKeyUtils,
)

aes = CryptoAesUtils(AesSecrets({"k1": SecretSpec("0123456789abcdef")}))
aes.decrypt_value(aes.encrypt_value("plain", "k1"), "k1")

hmac = CryptoHmacUtils(HmacSecret(b"0123456789abcdef", "HmacSHA256"))
hmac.verify_value(hmac.sign_value("idUser", "plain"), "idUser")   # integrity, not secrecy

keys = CryptoMapPublicKeyUtils()          # sender side: public keys only
token = keys.encrypt_value("plain", "mase")
```

`CryptoPublicKeyUtils` holds both halves of one key pair and is the recipient side;
`CryptoMapPublicKeyUtils` holds named public keys and is the sender side, which is what
`com.bld.crypto.public-key.keys` builds.

`SignatureKeyUtils` signs and verifies with a keystore key:

```python
signature = SignatureKeyUtils(jks_key, "SHA256withRSA")
signature.verify(signature.sign("payload"), "payload")
```

## How each scheme works

**PKCS12** is hybrid, per operation:

1. generate a fresh ephemeral X25519 key pair;
2. ECDH against the recipient's static X25519 public key, giving a raw 32-byte secret;
3. HKDF-SHA256 with the 12-byte GCM nonce as salt and `b"CryptoPkcs12"` as info,
   producing a 256-bit AES key;
4. AES-256-GCM encrypts the payload with a 128-bit tag;
5. emit `Base64(spki).Base64(nonce).Base64(ciphertext + tag)`.

The keystore must hold an XDH/X25519 key pair, with or without a certificate:

```bash
openssl genpkey -algorithm X25519 -out private.key
openssl pkcs12 -export -inkey private.key -out app.p12 -passout pass:changeit
```

**JKS** is RSA with PKCS#1 v1.5 padding, matching `Cipher.getInstance("RSA")`.
`ENCRYPT_MODE` with an `RSAPrivateKey` runs the private operation over type 1 padding,
and `DECRYPT_MODE` with an `RSAPublicKey` reverses it, which is what the
`CryptoType.private_key` / `CryptoType.public_key` pair is for.

JKS files are parsed directly in Python, including the protected private key blob, so no
external `keytool` call is needed at runtime.

**AES** uses `Cipher.getInstance("AES")`, whose JCA default is AES/ECB with PKCS5 padding.
The key is the raw secret when it is already 16, 24 or 32 bytes, otherwise PBKDF2. ECB
only repeats the block, which is why the annotations are for values that already need
obscuring rather than for secrets you want hidden.

**HMAC** is integrity, not secrecy. The envelope
`{"key":"<field>","value":"<text>"}` is signed and the token is
`Base64(envelope).Base64(mac)`, so the value travels in the clear and a token cannot be
moved between fields. `url=True` switches both halves to the URL-safe alphabet.

**Public keys** are plain RSA with the same PKCS#1 v1.5 padding as JKS, with no envelope:
the key name comes from the annotation and is not sealed into the token.

**Signatures** use `Signature.getInstance(...)`, so `SHA256withRSA`, `SHA512withECDSA` and
the other JCA names all work.

## Tests

```bash
pip install -e ".[test]"
pytest
```

`tests/fixtures/` holds keystores produced by `keytool` and `openssl`, plus
`java_vectors.json`, a set of payloads encrypted by the Java implementation. To
regenerate the vectors after a protocol change:

```bash
java -Dvec.pkcs12File=tests/fixtures/x25519.p12 -Dvec.pkcs12Password=pkcs12pass \
     -Dvec.pkcs12Alias=mase-x25519 -Dvec.jksFile=tests/fixtures/mase.jks \
     -Dvec.jksPassword=jkspass -Dvec.jksAlias=mase-rsa -Dvec.jksType=JKS \
     -Dvec.aesKey=0123456789abcdef -Dvec.hmacKey=0123456789abcdef \
     tests/interop/JavaVectors.java gen tests/fixtures/java_vectors.json
```

`gen` writes payloads for Python to decrypt; `chk` takes a JSON object of tokens and
verifies them in Java, which covers the reverse direction.

## Known limitations

- `cryptography` returns a single key pair from a PKCS12 file and exposes no alias
  lookup. The alias is therefore validated against the store's `friendlyName`
  attributes when present, but a PKCS12 holding several keys is limited to its first
  entry. Use one keystore per key, or a JKS, when you need multiple aliases.
- `CryptoKeyUtils` stays abstract, as in Java: the concrete key selection lives in
  `CryptoJksUtils` and `CryptoPkcs12Utils`.
- There is no signature field annotation, in Python or in Java. The token replaces the
  value, so the text needed to verify it is gone by the time the field is read back;
  `SignatureKeyUtils.sign` / `.verify` stay explicit calls.
- A `@CryptoPubKey` field cannot be read back by the service that wrote it: the map
  utilities hold public keys only, and a token made with a public key needs the private
  one. Use `@CryptoPubKeyEncrypt` for outbound fields and `@CryptoPubKeyDecrypt` for
  tokens that arrive signed by the key owner.
