diff options
24 files changed, 1591 insertions, 5 deletions
@@ -105,6 +105,20 @@ use `make install`. The latter target honors `PREFIX` and `DESTDIR`. Finally, to return your source tree to its pristine state, run `make clean`. +Bindings +-------- + +### Python + +The FFI crate contains Python bindings. To disable building, testing, +and installing the Python bindings, use `make PYTHON=disable`. + +To build the Python bindings, you will need cffi and pytest for Python3. + +Debian: + + $ apt install python3-cffi python3-pytest + Getting help ============ diff --git a/ffi/Makefile b/ffi/Makefile index 60447429..64f48235 100644 --- a/ffi/Makefile +++ b/ffi/Makefile @@ -23,15 +23,12 @@ all: build .PHONY: build build: - sed -e 's|VERSION|$(VERSION)|g' \ - -e 's|PREFIX|$(shell pwd)|g' \ - -e 's|libdir=.*|libdir='"$(CARGO_TARGET_DIR)"'/debug|g' \ - sequoia.pc.in \ - > $(CARGO_TARGET_DIR)/debug/sequoia.pc + $(MAKE) -Clang/python build # Testing and examples. .PHONY: test check test check: + $(MAKE) -Clang/python test .PHONY: examples examples: @@ -40,6 +37,7 @@ examples: # Installation. .PHONY: build-release build-release: + $(MAKE) -Clang/python build-release .PHONY: install install: @@ -62,9 +60,12 @@ install: $(DESTDIR)$(PREFIX)/lib/libsequoia_ffi.so $(INSTALL) $(CARGO_TARGET_DIR)/release/libsequoia_ffi.a \ $(DESTDIR)$(PREFIX)/lib/libsequoia_ffi.a + # Now the bindings. + $(MAKE) -Clang/python install # Housekeeping. .PHONY: clean clean: rm -f sequoia.pc $(MAKE) -Cexamples clean + $(MAKE) -Clang/python clean diff --git a/ffi/lang/python/.gitignore b/ffi/lang/python/.gitignore new file mode 100644 index 00000000..6d916e40 --- /dev/null +++ b/ffi/lang/python/.gitignore @@ -0,0 +1,8 @@ +/build +/dist +/sequoia.egg-info +/.eggs +__pycache__ +*.pyc +.stamp* +_sequoia.*.so diff --git a/ffi/lang/python/Makefile b/ffi/lang/python/Makefile new file mode 100644 index 00000000..960045ac --- /dev/null +++ b/ffi/lang/python/Makefile @@ -0,0 +1,81 @@ +# Makefile for Sequoia's Python bindings. + +# Configuration. +PREFIX ?= /usr/local +DESTDIR ?= +CFLAGS += -I../../include + +# Tools. +PYTHON ?= python3 +IPYTHON ?= ipython3 +PYTEST ?= pytest-3 +INSTALL ?= install + +ifneq "$(PYTHON)" "disable" +PY_VERSION = $(shell $(PYTHON) -c \ + 'import sys; print("{0.major}.{0.minor}".format(sys.version_info))') +endif + +# Make sure subprocesses pick these up. +export CFLAGS + +all: build + +.PHONY: build +build: .stamp-build +.stamp-build: sequoia/* ../../include/sequoia/* +ifneq "$(PYTHON)" "disable" + LDFLAGS=-L../../../target/debug $(PYTHON) setup.py build + touch $@ +endif + +# Testing and examples. +.PHONY: test check +test check: +ifneq "$(PYTHON)" "disable" + LDFLAGS=-L../../../target/debug LD_LIBRARY_PATH=../../../target/debug \ + $(PYTHON) setup.py test +endif + +.PHONY: shell +shell: build +ifneq "$(PYTHON)" "disable" + cp build/*/_sequoia.abi*.so . # XXX can we get setuptools to do that? + LDFLAGS=-L../../../target/debug LD_LIBRARY_PATH=../../../target/debug \ + $(IPYTHON) -i -c \ +'from sequoia.prelude import *; ctx = Context("org.sequoia-pgp.tests.interactive")' +endif + +# Installation. +.PHONY: build-release +build-release: .stamp-build-release +.stamp-build-release: +ifneq "$(PYTHON)" "disable" + rm -f .stamp-build + $(PYTHON) setup.py clean + LDFLAGS=-L../../../target/release \ + $(PYTHON) setup.py build + touch $@ +endif + +ifneq "$(DESTDIR)" "" + root_arg=--root=$(DESTDIR) +endif + +.PHONY: install +install: build-release +ifneq "$(PYTHON)" "disable" + $(INSTALL) -d $(DESTDIR)$(PREFIX)/lib/python$(PY_VERSION)/site-packages + + LDFLAGS=-L../../../target/release \ + $(PYTHON) setup.py install $(root_arg) --prefix=$(PREFIX) +endif + +# Housekeeping. +.PHONY: clean +clean: +ifneq "$(PYTHON)" "disable" + $(PYTHON) setup.py clean + rm -f _sequoia.*.so + rm -f .stamp-build .stamp-build-release +endif diff --git a/ffi/lang/python/README.md b/ffi/lang/python/README.md new file mode 100644 index 00000000..3be34691 --- /dev/null +++ b/ffi/lang/python/README.md @@ -0,0 +1,5 @@ +Python bindings for Sequoia +=========================== + +These bindings are work in progress. You are welcome to play with +them, but we will likely break the API in the future. diff --git a/ffi/lang/python/examples/decrypt.py b/ffi/lang/python/examples/decrypt.py new file mode 100644 index 00000000..c65f151d --- /dev/null +++ b/ffi/lang/python/examples/decrypt.py @@ -0,0 +1,43 @@ +import sys +import os +from getpass import getpass +from enum import Enum, auto +from sequoia.core import Context, NetworkPolicy +from sequoia.openpgp import Tag, PacketParser + +ctx = Context("org.sequoia-pgp.examples", + network_policy=NetworkPolicy.Offline, + ephemeral=True) + +class State(Enum): + Start = auto() + Decrypted = auto() + Deciphered = auto() + Done = auto() + +state = State.Start +algo, key = None, None +pp = PacketParser.open(ctx, sys.argv[1]) +while pp.has_packet: + packet = pp.packet + tag = packet.tag + + if state == State.Start: + if tag == Tag.SKESK: + passphrase = getpass("Enter passphrase to decrypt message: ") + algo, key = packet.match().decrypt(passphrase.encode()) + state = State.Decrypted + elif tag == Tag.PKESK: + sys.stderr.write("Decryption using PKESK not yet supported.\n") + elif state == State.Decrypted: + if tag == Tag.SEIP: + pp.decrypt(algo, key) + state = State.Deciphered + elif state == State.Deciphered: + if tag == Tag.Literal: + body = pp.buffer_unread_content() + os.write(sys.stdout.fileno(), body) + state = State.Done + + pp.recurse() +assert state == State.Done diff --git a/ffi/lang/python/sequoia/__init__.py b/ffi/lang/python/sequoia/__init__.py new file mode 100644 index 00000000..4f84346e --- /dev/null +++ b/ffi/lang/python/sequoia/__init__.py @@ -0,0 +1,8 @@ +from . import ( + prelude, + error, + openpgp, + core, + net, + store, +) diff --git a/ffi/lang/python/sequoia/core.py b/ffi/lang/python/sequoia/core.py new file mode 100644 index 00000000..9810c27b --- /dev/null +++ b/ffi/lang/python/sequoia/core.py @@ -0,0 +1,131 @@ +import io +from enum import Enum + +from _sequoia import ffi, lib +from .error import Error +from .glue import SQObject + +class NetworkPolicy(Enum): + Offline = lib.SQ_NETWORK_POLICY_OFFLINE + Anonymized = lib.SQ_NETWORK_POLICY_ANONYMIZED + Encrypted = lib.SQ_NETWORK_POLICY_ENCRYPTED + Insecure = lib.SQ_NETWORK_POLICY_INSECURE + +class IPCPolicy(Enum): + External = lib.SQ_IPC_POLICY_EXTERNAL + Internal = lib.SQ_IPC_POLICY_INTERNAL + Robust = lib.SQ_IPC_POLICY_ROBUST + +class Context(SQObject): + _del = lib.sq_context_free + def __init__(self, domain, + home=None, + network_policy=NetworkPolicy.Encrypted, + ipc_policy=IPCPolicy.Robust, + ephemeral=False): + cfg = lib.sq_context_configure(domain.encode()) + if home: + lib.sq_config_home(cfg, home.encode()) + lib.sq_config_network_policy(cfg, network_policy.value) + lib.sq_config_ipc_policy(cfg, ipc_policy.value) + if ephemeral: + lib.sq_config_ephemeral(cfg) + err = ffi.new("sq_error_t[1]") + o = lib.sq_config_build(cfg, err) + if o == ffi.NULL: + raise Error._from(err[0]) + super(Context, self).__init__(o) + +class AbstractReader(SQObject, io.RawIOBase): + _del = lib.sq_reader_free + + def readable(self): + return True + def writable(self): + return False + + def readinto(self, buf): + bytes_read = lib.sq_reader_read( + self.context().ref(), self.ref(), + ffi.cast("uint8_t *", ffi.from_buffer(buf)), len(buf)) + if bytes_read < 0: + raise Error._last(self.context()) + return bytes_read + + def close(self): + self._delete() + + # Implement the context manager protocol. + def __enter__(self): + return self + def __exit__(self, *args): + self.close() + return False + +class Reader(AbstractReader): + @classmethod + def open(cls, ctx, filename): + return Reader( + lib.sq_reader_from_file( + ctx.ref(), + filename.encode()), + context=ctx) + + @classmethod + def from_fd(cls, ctx, fd): + return Reader(lib.sq_reader_from_fd(fd), + context=ctx) + + @classmethod + def from_bytes(cls, ctx, buf): + return Reader( + lib.sq_reader_from_bytes( + ffi.cast("uint8_t *", ffi.from_buffer(buf)), len(buf)), + context=ctx) + +class AbstractWriter(SQObject, io.RawIOBase): + _del = lib.sq_writer_free + + def readable(self): + return False + def writable(self): + return True + + def write(self, buf): + bytes_written = lib.sq_writer_write( + self.context().ref(), self.ref(), + ffi.cast("const uint8_t *", ffi.from_buffer(buf)), len(buf)) + if bytes_written < 0: + raise Error._last(self.context()) + return bytes_written + + def close(self): + self._delete() + + # Implement the context manager protocol. + def __enter__(self): + return self + def __exit__(self, *args): + self.close() + return False + +class Writer(AbstractWriter): + @classmethod + def open(cls, ctx, filename): + return Writer( + lib.sq_writer_from_file( + ctx.ref(), + filename.encode()), + context=ctx) + + @classmethod + def from_fd(cls, ctx, fd): + return Writer(lib.sq_writer_from_fd(fd), + context=ctx) + + @classmethod + def from_bytes(cls, ctx, buf): + return Writer( + lib.sq_writer_from_bytes( + ffi.cast("uint8_t *", ffi.from_buffer(buf)), len(buf)), + context=ctx) diff --git a/ffi/lang/python/sequoia/error.py b/ffi/lang/python/sequoia/error.py new file mode 100644 index 00000000..29eb2545 --- /dev/null +++ b/ffi/lang/python/sequoia/error.py @@ -0,0 +1,69 @@ +from _sequoia import ffi, lib +from .glue import sq_str + +class Error(Exception): + @classmethod + def _from(cls, o): + if o == ffi.NULL: + return MalformedValue() + + status = lib.sq_error_status(o) + return _status_map[status](o) + + @classmethod + def _last(cls, ctx): + if not ctx: + return MalformedValue() + return Error._from(lib.sq_context_last_error(ctx.ref())) + +class MalformedValue(Error, ValueError): + def __init__(self, message="Malformed value"): + super(MalformedValue, self).__init__(message) + +class SQError(Error): + def __init__(self, o): + self.__o = ffi.gc(o, lib.sq_error_free) + super(SQError, self).__init__(sq_str(lib.sq_error_string(self.__o))) + +class Success(SQError): + pass + +class UnknownError(SQError): + pass + +class NetworkPolicyViolation(SQError): + pass + +class IoError(SQError): + pass + +class InvalidOperataion(SQError): + pass + +class MalformedPacket(SQError): + pass + +class UnsupportedHashAlgorithm(SQError): + pass + +class UnsupportedSymmetricAlgorithm(SQError): + pass + +class InvalidPassword(SQError): + pass + +class InvalidSessionKey(SQError): + pass + +_status_map = { + lib.SQ_STATUS_SUCCESS: Success, + lib.SQ_STATUS_UNKNOWN_ERROR: UnknownError, + lib.SQ_STATUS_NETWORK_POLICY_VIOLATION: NetworkPolicyViolation, + lib.SQ_STATUS_IO_ERROR: IoError, + lib.SQ_STATUS_INVALID_OPERATION: InvalidOperataion, + lib.SQ_STATUS_MALFORMED_PACKET: MalformedPacket, + lib.SQ_STATUS_UNSUPPORTED_HASH_ALGORITHM: UnsupportedHashAlgorithm, + lib.SQ_STATUS_UNSUPPORTED_SYMMETRIC_ALGORITHM: UnsupportedSymmetricAlgorithm, + lib.SQ_STATUS_INVALID_PASSWORD: InvalidPassword, + lib.SQ_STATUS_INVALID_SESSION_KEY: InvalidSessionKey, +} diff --git a/ffi/lang/python/sequoia/glue.py b/ffi/lang/python/sequoia/glue.py new file mode 100644 index 00000000..4b74d609 --- /dev/null +++ b/ffi/lang/python/sequoia/glue.py @@ -0,0 +1,102 @@ +from datetime import datetime, timezone + +from _sequoia import ffi, lib +from . import error + +class SQObject(object): + # These class attributes determine what features the wrapper class + # implements. They must be set to the relevant Sequoia functions. + # + # XXX: Once we can assume Python3.6 we can use '__init_subclass__' + # and reflection on the 'lib' object to set them automatically + # using the type name. + _del = None + _clone = None + _eq = None + _str = None + _hash = None + + def __init__(self, o, context=None, owner=None, references=None): + if o == ffi.NULL: + raise error.Error._last(context) + self.__o = None + self.ref_replace(o, owner=owner, references=references) + self.__ctx = context + if self.__class__._hash is None and not hasattr(self.__class__, '__hash__'): + # Unhashable types must have '__hash__' set to None. + # Until we can use '__init_subclass__', we need to patch + # the class here. Yuck. + self.__class__.__hash__ = None + + def ref(self): + return self.__o + + def ref_consume(self): + ref = self.ref() + self._delete(skip_free=True) + return ref + + def ref_replace(self, new, owner=None, references=None): + old = self.ref_consume() + if self._del and owner == None: + # There is a destructor and We own the referenced object + # new. + self.__o = ffi.gc(new, self._del) + else: + self.__o = new + self.__owner = owner + self.__references = references + return old + + def _delete(self, skip_free=False): + if not self.__o: + return + if self._del and skip_free: + ffi.gc(self.__o, None) + self.__o = None + self.__owner = None + self.__references = None + + def context(self): + return self.__ctx + + def __str__(self): + if self._str: + return _str(self._str(self.ref())) + else: + return repr(self) + + def __eq__(self, other): + if self._eq: + return (isinstance(other, self.__class__) + and bool(self._eq(self.ref(), other.ref()))) + else: + return NotImplemented + + def copy(self): + if self._clone: + return self.__class__(self._clone(self.ref())) + else: + raise NotImplementedError() + + def __hash__(self): + return self._hash(self.ref()) + +def sq_str(s): + t = ffi.string(s).decode() + lib.sq_string_free(s) + return t +_str = sq_str + +def sq_iterator(iterator, next_fn, map=lambda x: x): + while True: + entry = next_fn(iterator) + if entry == ffi.NULL: + break + yield map(entry) + +def sq_time(t): + if t == 0: + return None + else: + return datetime.fromtimestamp(t, timezone.utc) diff --git a/ffi/lang/python/sequoia/net.py b/ffi/lang/python/sequoia/net.py new file mode 100644 index 00000000..c5f4a7f4 --- /dev/null +++ b/ffi/lang/python/sequoia/net.py @@ -0,0 +1,37 @@ +from _sequoia import ffi, lib + +from .openpgp import TPK +from .error import Error +from .glue import SQObject + +class KeyServer(SQObject): + _del = lib.sq_keyserver_free + + @classmethod + def new(cls, ctx, uri, cert=None): + if not cert: + ks = lib.sq_keyserver_new(ctx.ref(), uri.encode()) + else: + ks = lib.sq_keyserver_with_cert( + ctx.ref(), uri.encode(), + ffi.cast("uint8_t *", ffi.from_buffer(cert)), + len(cert)) + return KeyServer(ks, context=ctx) + + @classmethod + def sks_pool(cls, ctx): + return KeyServer(lib.sq_keyserver_sks_pool(ctx.ref()), + context=ctx) + + def get(self, keyid): + return TPK(lib.sq_keyserver_get(self.context().ref(), + self.ref(), + keyid.ref()), + context=self.context()) + + def send(self, tpk): + r = lib.sq_keyserver_send(self.context().ref(), + self.ref(), + tpk.ref()) + if r: + raise Error._last(self.context()) diff --git a/ffi/lang/python/sequoia/openpgp.py b/ffi/lang/python/sequoia/openpgp.py new file mode 100644 index 00000000..aaea5262 --- /dev/null +++ b/ffi/lang/python/sequoia/openpgp.py @@ -0,0 +1,361 @@ +from enum import Enum + +from _sequoia import ffi, lib +from .error import Error +from .glue import _str, SQObject +from .core import AbstractReader, AbstractWriter + +class KeyID(SQObject): + _del = lib.sq_keyid_free + _clone = lib.sq_keyid_clone + _str = lib.sq_keyid_to_string + _eq = lib.sq_keyid_equal + _hash = lib.sq_keyid_hash + + @classmethod + def from_bytes(cls, fp): + if len(fp) != 8: + raise Error("KeyID must be of length 8") + return KeyID(lib.sq_keyid_from_bytes( + ffi.cast("uint8_t *", ffi.from_buffer(fp)))) + + @classmethod + def from_hex(cls, fp): + return KeyID(lib.sq_keyid_from_hex(fp.encode())) + + def hex(self): + return _str(lib.sq_keyid_to_hex(self.ref())) + +class Fingerprint(SQObject): + _del = lib.sq_fingerprint_free + _clone = lib.sq_fingerprint_clone + _str = lib.sq_fingerprint_to_string + _eq = lib.sq_fingerprint_equal + _hash = lib.sq_fingerprint_hash + + @classmethod + def from_bytes(cls, fp): + return Fingerprint(lib.sq_fingerprint_from_bytes( + ffi.cast("uint8_t *", ffi.from_buffer(fp)), len(fp))) + + @classmethod + def from_hex(cls, fp): + return Fingerprint(lib.sq_fingerprint_from_hex(fp.encode())) + + def hex(self): + return _str(lib.sq_fingerprint_to_hex(self.ref())) + + def keyid(self): + return KeyID(lib.sq_fingerprint_to_keyid(self.ref())) + +class PacketPile(SQObject): + _del = lib.sq_packet_pile_free + _clone = lib.sq_packet_pile_clone + + @classmethod + def from_reader(cls, ctx, reader): + return PacketPile(lib.sq_packet_pile_from_reader(ctx.ref(), reader.ref()), + context=ctx) + + @classmethod + def open(cls, ctx, filename): + return PacketPile(lib.sq_packet_pile_from_file(ctx.ref(), filename.encode()), + context=ctx) + + @classmethod + def from_bytes(cls, ctx, source): + return PacketPile(lib.sq_packet_pile_from_bytes(ctx.ref(), + ffi.from_buffer(source), + len(source)), + context=ctx) + + def serialize(self, writer): + status = lib.sq_packet_pile_serialize(self.context().ref(), + self.ref(), + writer.ref()) + if status: + raise Error._last(self.context()) + +class TPK(SQObject): + _del = lib.sq_tpk_free + _clone = lib.sq_tpk_clone + _eq = lib.sq_tpk_equal + + @classmethod + def from_reader(cls, ctx, reader): + return TPK(lib.sq_tpk_from_reader(ctx.ref(), reader.ref()), + context=ctx) + + @classmethod + def open(cls, ctx, filename): + return TPK(lib.sq_tpk_from_file(ctx.ref(), filename.encode()), + context=ctx) + + @classmethod + def from_packet_pile(cls, ctx, packet_pile): + return TPK(lib.sq_tpk_from_packet_pile(ctx.ref(), packet_pile.ref_consume()), + context=ctx) + + @classmethod + def from_bytes(cls, ctx, source): + return TPK(lib.sq_tpk_from_bytes(ctx.ref(), + ffi.from_buffer(source), + len(source)), + context=ctx) + + def serialize(self, writer): + status = lib.sq_tpk_serialize(self.context().ref(), + self.ref(), + writer.ref()) + if status: + raise Error._last(self.context()) + + def fingerprint(self): + return Fingerprint(lib.sq_tpk_fingerprint(self.ref()), + context=self.context()) + + def merge(self, other): + new = lib.sq_tpk_merge(self.context().ref(), + self.ref_consume(), |