Observed since 20 Oct, 18:36 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
Python SDK for Activeledger, with post-quantum identity support
Python SDK for Activeledger, with post-quantum identity support.
Requires Activeledger 4.7.0+ for ml-dsa-65 and falcon-512. Python 3.9+.
Rewritten. The import root is now
activeledger, notactiveledgerPythonSDK, and the API changed throughout. The previous version dated from 2019, had no tests, and declared no dependencies while importing two.
pip install activeledger
Optional extras are installed alongside it:
pip install 'ecdsa>=0.19' # secp256k1 (the [ec] extra)
pip install 'liboqs-python>=0.16' # post-quantum (the [pq] extra)
The core has no dependencies. Post-quantum is opt-in because liboqs-python
builds liboqs from source on first import, needing git, CMake, a C compiler
and OpenSSL headers — mandatory for a client SDK would be close to unusable.
Verified: installs from PyPI into a clean venv and derives keys, with the BIP-39 wordlist included.
from activeledger import Activeledger, KeyPair, KeyType
ledger = Activeledger("http://localhost:5260")
key = KeyPair.generate(KeyType.ML_DSA_65)
identity = ledger.onboard(key)
print(identity.stream_id)
| Key type | Wire string | Public | Private | Signature | Encoding | Extra |
|---|---|---|---|---|---|---|
| ML-DSA-65 | ml-dsa-65 | 1952 | 4032 | 3309 | base64 | [pq] |
| Falcon-512 | falcon-512 | 897 | 1281 | 649-662, variable | base64 | [pq] |
| secp256k1 | secp256k1 | 33 or 65 | 32 | ~70-72, variable | 0x hex | [ec] |
Use secp256k1 unless the identity must outlive a cryptographically relevant quantum computer: roughly 22x smaller per transaction, and every byte is stored on the ledger permanently and replicated to every node. It also works with hardware wallets and HSMs, and is the only way to sign for an identity created before post-quantum support.
pip install activeledger[ec]
It installs python-ecdsa, which is pure Python with no build step — unlike
coincurve (libsecp256k1), which needs a C toolchain and rejects the
high-S signatures the ledger produces freely.
from activeledger import Secp256k1KeyPair
key = Secp256k1KeyPair.generate() # compressed
full = Secp256k1KeyPair.generate(compressed=False) # uncompressed
key.public_key # "0x02a1b2..." - give this to the ledger
key.private_key # store this
restored = Secp256k1KeyPair.from_keys(key.public_key, key.private_key)
verifier = Secp256k1KeyPair.from_public_key(key.public_key)
0x-prefixed hex, not base64. The prefix is required rather
than tolerated, because hex without it can decode as base64 into
plausible-looking bytes of the wrong length.Signing is RFC 6979 deterministic and low-S. python-ecdsa does not
normalise on its own, and it matters: without the normalisation this SDK
applies, 4 of the 12 published vectors come out high-S and differ from the
canonical form. Low-S is not for the ledger, which accepts either, but for
@noble/curves — the reference for the JavaScript side — and for libsecp256k1
and Rust's k256, all of which reject high-S by default.
Verification accepts high-S, because the ledger verifies through OpenSSL
and produces high-S freely. Rejecting those would fail on roughly half of all
valid signatures, and the half that succeeded would look like an intermittent
fault. python-ecdsa is permissive here — measured against the vectors, it
accepts all 7 high-S cases — which is why it is used.
Because signing is deterministic, this SDK's signatures are byte-identical to
@noble/curves for the same key and message, asserted against published
reference bytes on every test run.
KeyType.from_wire parses bitcoin and ethereum as secp256k1, because the
ledger routes them to identical verification. They are never emitted.
| Type | Wire string | Public | Private | Signature |
|---|---|---|---|---|
| ML-DSA-65 | ml-dsa-65 | 1952 B | 4032 B | 3309 B, fixed |
| Falcon-512 | falcon-512 | 897 B | 1281 B | 649-662 B, variable |
| secp256k1 | secp256k1 | 65 B | 32 B | ~70-72 B DER |
| RSA | rsa | - | - | - |
Post-quantum keys are base64 of raw algorithm bytes.
key = KeyPair.generate(KeyType.FALCON_512)
key.public_key_b64 # give this to the ledger
key.private_key_b64 # keep this
key.key_type # KeyType.FALCON_512
signature = key.sign(b"some bytes")
key.verify(b"some bytes", signature) # True
# Reload later
same = KeyPair.from_keys(KeyType.FALCON_512, pub_b64, prv_b64)
# Verify-only, no private key
checker = KeyPair.from_public(KeyType.FALCON_512, pub_b64)
Falcon signature length varies -- never assume it fixed. Signing is hedged for both schemes: two signatures over the same message differ, and both verify. That matches the reference implementation; FIPS 204 permits a deterministic variant which is deliberately not used.
verify() returns False for a malformed signature rather than raising -- a caller should not have to tell "invalid" from "wrong shape".
from activeledger import Secp256k1KeyPair, recovery
key = Secp256k1KeyPair.from_seed(seed) # 32 bytes
key = Secp256k1KeyPair.from_phrase(phrase) # BIP-39
key = Secp256k1KeyPair.from_phrase(phrase, "passphrase")
The same seed gives the same identity in every Activeledger SDK, which is what makes a seed the portable private-key format — it is how a private key moves between languages.
A seed of the wrong length is refused, not padded: a padded seed is a
different identity, not a malformed one. And for secp256k1 the seed is
the private scalar, so it has to be a valid one — a seed of zero, or one at or
above the curve order, is refused rather than reduced mod n, because
reducing produces a perfectly functional key belonging to a different identity
and nothing downstream ever reports a problem.
The phrase is validated, wordlist and checksum. A mistyped phrase that is not checked does not fail; it derives a valid key for an identity nobody owns, and the only symptom is the ledger not recognising it.
Secp256k1KeyPair.from_legacy_phrase() recovers a phrase made by the older
@activeledger/sdk-bip39 package — recovery only, never for new keys.
Every other Activeledger SDK can do this; this one cannot, and it is not an
oversight. liboqs — the post-quantum backend — exposes no derandomised
signature keygen: OQS_SIG_keypair takes no seed and there is no
OQS_SIG_keypair_derand. _keypair_derand exists for KEMs only, verified in
the 0.14.0 headers and against liboqs main, so there is nothing for the
Python binding to wrap.
KeyPair.from_seed() raises NotImplementedError with that explanation
rather than being absent, so code ported from another SDK finds out here
instead of from a signature the ledger rejects.
Post-quantum identities still work fully — generate(), sign, verify, and
export as key bytes. Only seed derivation is unavailable, and lifting it means
moving off liboqs.
The derivation every SDK shares, for reference:
| Type | Seed from the BIP-39 seed S |
|---|---|
secp256k1 | HMAC-SHA512("Bitcoin seed", S)[0..32] |
ml-dsa-65 | HKDF-SHA512(S, salt="", info="activeledger-seed-v1:ml-dsa-65", 32) |
falcon-512 | HKDF-SHA512(S, salt="", info="activeledger-seed-v1:falcon-512", 48) |
recovery.derive_seed() implements all three, so the post-quantum seeds can
be derived here and used in an SDK that can consume them.
from activeledger import TransactionBuilder
tx = (
TransactionBuilder()
.namespace("mynamespace")
.contract("mycontract")
.input(identity.stream_id, identity.signer, {"message": "hello"})
.output(target_stream, {"amount": 10})
.build()
)
response = ledger.submit(tx)
if not response.committed:
raise RuntimeError(response.errors)
print(response.new_streams) # stream ids created
print(response.responses) # returnToRemote values
.entry("update") sets $entry for contracts with multiple entry points.
There is no separate read API and no storage URL. A node's storage service listens only on the node's own host, so a client cannot reach it.
State is read through a transaction: name streams in $r, and the contract hands values back with returnToRemote.
tx = (
TransactionBuilder()
.namespace("mynamespace")
.contract("mycontract")
.input(identity.stream_id, identity.signer)
.readonly("target", some_stream_id) # becomes $r
.build()
)
for value in ledger.submit(tx).responses:
print(value)
EventStream (ledger.events) is deprecated and will be removed in the next major version.
Events are no longer served by ActiveCore, which is itself deprecated and
should not be used. A node serves contract events from its own storage
service at http://localhost:<storage port>/activeledgerevents/events, and
that service must never be reachable beyond the node's host - so a client
SDK has nothing it should connect to.
To react to events, run your own server-sent events listener on the node's
host and relay what your application needs through your own backend. Each
event is an SSE frame whose id is <milliseconds>-<counter>,<umid> and
whose data is {"name", "data", "phase", "contract"}.
Anything with key_type, public_key_b64 and sign(bytes) satisfies the Signer protocol, so the core needs no crypto dependency:
from activeledger import KeyType, TransactionBuilder
class HsmSigner:
key_type = KeyType.ML_DSA_65
@property
def public_key_b64(self):
return my_hsm.public_key()
def sign(self, message: bytes) -> bytes:
return my_hsm.sign(message)
tx = TransactionBuilder().namespace("n").contract("c").input(stream, HsmSigner()).build()
Always send the key type. The ledger defaults a missing type to "rsa" and then attempts RSA verification against a base64 post-quantum blob. This SDK always sends it; if you hand-build an envelope, do the same.
A rejected transaction is HTTP 200. Check response.committed, never the status code.
Errors are unhelpful by design. A wrong type string, a wrong-length key, or signed bytes differing by one escape all come back as 1220 "Signature Incorrect" -- never "unknown algorithm" or "bad key length". This SDK validates key lengths and type strings up front so these fail locally with a message naming the problem.
Signatures cover $tx only, not the envelope. tx.signed_bytes() shows exactly what was signed, which is the fastest way to diagnose a 1220.
Signatures cover the exact bytes of JSON.stringify($tx) encoded UTF-8 -- no hash prefix, no length prefix, no key sorting. Python's json.dumps is wrong here by default in three ways, all of which produce correct output on an ASCII-only integer payload:
| Default | Problem |
|---|---|
ensure_ascii=True | escapes non-ASCII to \uXXXX |
separators=(', ', ': ') | inserts spaces |
| whole floats | prints 1.0; JavaScript prints 1 |
activeledger.canonical_json handles all three, and is checked byte-for-byte against cross-language vectors generated by the JavaScript SDK.
from activeledger import canonical_json
canonical_json({"whole": 1.0, "note": "café"})
pip install -e '.[pq,dev]'
pytest
Integration tests need a live network. From an activeledger checkout:
npm run test:network:serve
then, with the URLs it prints:
AL_NODES=http://127.0.0.1:5510 AL_STORAGE=http://127.0.0.1:5509 pytest tests/integration
They skip when AL_NODES is unset.
Correctness is established against published cross-language vectors and a real 4-node network -- onboarding both post-quantum schemes, submitting transactions, and confirming a tampered payload is rejected -- not against a reading of the reference implementation.
MIT
Download counts and popularity will appear here as the collector receives them.
| Version | Published | Direct dependencies | Open version |
|---|---|---|---|
| 1.4.0 | 27 Sept 2026 | 3 |
This describes our collected data; it is not a security assessment.
Observed since 20 Oct, 18:36 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
package:0645a7f76bf52fe0092c3cf39178114fc87b263957217468d493c38fe7204ee0