diff --git a/.gitignore b/.gitignore
index 476572e..7a24582 100644
--- a/.gitignore
+++ b/.gitignore
@@ -202,3 +202,8 @@ fabric.properties
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
+
+
+# Others
+*.db
+/test*.py
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..73f69e0
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
+# Editor-based HTTP Client requests
+/httpRequests/
diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
new file mode 100644
index 0000000..1960207
--- /dev/null
+++ b/.idea/dataSources.xml
@@ -0,0 +1,19 @@
+
+
+
+
+ sqlite.xerial
+ true
+ org.sqlite.JDBC
+ jdbc:sqlite:D:\source\PyCharm\v25\test.db
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.31.1/license.txt
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.31.1/sqlite-jdbc-3.31.1.jar
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..e91148c
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 0000000..105ce2d
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..5cf8ffc
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..e275cb5
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/v25.iml b/.idea/v25.iml
new file mode 100644
index 0000000..74d515a
--- /dev/null
+++ b/.idea/v25.iml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/v25/__init__.py b/v25/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/v25/messaging/__init__.py b/v25/messaging/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/v25/messaging/message.py b/v25/messaging/message.py
new file mode 100644
index 0000000..6c54723
--- /dev/null
+++ b/v25/messaging/message.py
@@ -0,0 +1,122 @@
+import json
+from time import time
+from typing import Optional, Tuple
+
+import nacl.encoding
+import nacl.public
+import nacl.secret
+import nacl.signing
+import nacl.utils
+
+from v25.messaging.encoding import Encoding
+from v25.messaging.subject import Subject, PrivateSubject
+
+KEY_SIZE = 80
+SFROM = 0
+STO = 1
+
+
+class Message:
+ def __init__(self, sfrom: Subject, sto: Subject, idnonce: bytes, timestamp: Optional[float],
+ editnonce: Optional[bytes], pcontent: Optional[bytes], econtent: Optional[bytes],
+ flags: str):
+ self.sfrom = sfrom
+ self.sto = sto
+ self.idnonce = idnonce
+ self.timestamp = timestamp
+ self.editnonce = editnonce
+ self.pcontent = pcontent
+ self.econtent = econtent
+ self.flags = flags
+
+ @classmethod
+ def send(cls, sfrom: PrivateSubject, sto: Subject, pcontent: bytes):
+ return cls(sfrom, sto, Encoding.nonce(), time(),
+ Encoding.nonce(), pcontent, None,
+ '').sealed()
+
+ @classmethod
+ def loads(cls, s: str):
+ d = json.loads(s)
+ return cls(
+ Subject.loads(d['sf']), Subject.loads(d['st']), Encoding.decode(d['in']), d['ts'],
+ Encoding.decode(d['en']), None, Encoding.decode(d['ec']),
+ d['fl']
+ )
+
+ @property
+ def pair(self):
+ return self.sfrom, self.sto
+
+ def seal(self) -> bytes:
+ assert isinstance(self.sfrom, PrivateSubject)
+ scontent: nacl.signing.SignedMessage = self.sfrom.skey.sign(self.pcontent)
+ self.encrypt(scontent)
+ return self.econtent
+
+ def encrypt(self, scontent: bytes):
+ key = nacl.utils.random(nacl.secret.SecretBox.KEY_SIZE)
+ self.econtent = (nacl.public.SealedBox(self.sfrom.pkey).encrypt(key) +
+ nacl.public.SealedBox(self.sto.pkey).encrypt(key) +
+ nacl.secret.SecretBox(key).encrypt(scontent))
+
+ def sealed(self) -> 'Message':
+ self.seal()
+ return self
+
+ def dumps(self) -> str:
+ return json.dumps({
+ 'sf': self.sfrom.dumps(),
+ 'st': self.sto.dumps(),
+ 'in': Encoding.encode(self.idnonce),
+ 'ts': self.timestamp,
+ 'en': Encoding.encode(self.editnonce),
+ 'ec': Encoding.encode(self.econtent),
+ 'fl': self.flags,
+ })
+
+ def unseal(self, s: int) -> bytes:
+ scontent = self.decrypt(s)
+ self.pcontent = self.sfrom.vkey.verify(scontent)
+ return self.pcontent
+
+ def decrypt(self, s: int) -> bytes:
+ subject = self.pair[s]
+ assert isinstance(subject, PrivateSubject)
+ key: bytes = nacl.public.SealedBox(subject.ekey).decrypt(
+ [self.econtent[:KEY_SIZE], self.econtent[KEY_SIZE:2 * KEY_SIZE]][s])
+ return nacl.secret.SecretBox(key).decrypt(self.econtent[2 * KEY_SIZE:])
+
+ def unsealed(self, s: int):
+ self.unseal(s)
+ return self
+
+ def edit(self, pcontent: bytes) -> 'Message':
+ return Message(self.sfrom, self.sto, self.idnonce, None,
+ Encoding.nonce(), pcontent, None,
+ self.flags.replace('', '')).sealed()
+
+ def edited(self, other: 'Message'):
+ return self.pair == other.pair and self.idnonce == other.idnonce
+
+ def editt(self, content: bytes) -> Tuple['Message', 'Message']:
+ return self, self.edit(content)
+
+ def delete(self):
+ return Message(self.sfrom, self.sto, self.idnonce, None,
+ None, None, None,
+ '')
+
+ def flags_(self, flags: str):
+ return Message(self.sfrom, self.sto, self.idnonce, None,
+ self.editnonce, None, None,
+ flags)
+
+ def alter(self, sfrom: Optional[Subject] = None, sto: Optional[Subject] = None):
+ if sfrom is not None:
+ assert self.sfrom == sfrom
+ self.sfrom = sfrom
+ if sto is not None:
+ assert self.sto == sto
+ self.sto = sto
+ return self
diff --git a/v25/messaging/subject.py b/v25/messaging/subject.py
new file mode 100644
index 0000000..5832b83
--- /dev/null
+++ b/v25/messaging/subject.py
@@ -0,0 +1,39 @@
+import nacl.public
+import nacl.pwhash
+import nacl.signing
+
+from v25.messaging.encoding import Encoding
+
+
+class Subject:
+ def __init__(self, vkey: bytes):
+ self.vkey = nacl.signing.VerifyKey(vkey)
+ self.pkey: nacl.public.PublicKey = self.vkey.to_curve25519_public_key()
+
+ def __eq__(self, other):
+ if not isinstance(other, Subject):
+ return False
+ return self.vkey == other.vkey
+
+ def __hash__(self):
+ return hash(self.vkey.encode())
+
+ def dumps(self) -> str:
+ return Encoding.encode(self.vkey)
+
+ @classmethod
+ def loads(cls, s: str):
+ return Subject(Encoding.decode(s))
+
+
+class PrivateSubject(Subject):
+ def __init__(self, pwd: str):
+ salt = nacl.pwhash.argon2id.SALTBYTES * b'\0'
+ self.seed = nacl.pwhash.argon2id.kdf(
+ nacl.public.PrivateKey.SEED_SIZE, pwd.encode(), salt,
+
+ nacl.pwhash.OPSLIMIT_INTERACTIVE,
+ nacl.pwhash.MEMLIMIT_INTERACTIVE)
+ self.skey = nacl.signing.SigningKey(self.seed)
+ self.ekey: nacl.public.PrivateKey = self.skey.to_curve25519_private_key()
+ super().__init__(bytes(self.skey.verify_key))
diff --git a/v25/storage/__init__.py b/v25/storage/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/v25/storage/dbstorage.py b/v25/storage/dbstorage.py
new file mode 100644
index 0000000..1fd9eab
--- /dev/null
+++ b/v25/storage/dbstorage.py
@@ -0,0 +1,135 @@
+from typing import Tuple, Optional, Iterable
+
+from nacl.bindings import crypto_sign_PUBLICKEYBYTES
+from sqlalchemy import create_engine, LargeBinary, Column, REAL, BLOB, String, or_, and_, BigInteger
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import sessionmaker, Query
+
+from v25.messaging.encoding import NONCE_SIZE, Encoding
+from v25.messaging.message import Message
+from v25.messaging.subject import Subject
+from v25.storage.storage import AbstractStorage
+
+Base = declarative_base()
+
+
+class MsgSgn(Base):
+ __tablename__ = 'msgsgns'
+
+ sf = Column(LargeBinary(crypto_sign_PUBLICKEYBYTES), primary_key=True)
+ st = Column(LargeBinary(crypto_sign_PUBLICKEYBYTES), primary_key=True)
+ idn = Column(LargeBinary(NONCE_SIZE), primary_key=True)
+
+
+class Msg(Base):
+ __tablename__ = 'msgs'
+
+ sf = Column(LargeBinary(crypto_sign_PUBLICKEYBYTES), primary_key=True)
+ st = Column(LargeBinary(crypto_sign_PUBLICKEYBYTES), primary_key=True)
+ idn = Column(LargeBinary(NONCE_SIZE), primary_key=True)
+ ts = Column(REAL, nullable=False, index=True)
+ en = Column(LargeBinary(NONCE_SIZE), nullable=False)
+ ec = Column(BLOB, nullable=False)
+ flags = Column(String, nullable=False)
+ oid = Column(BigInteger, autoincrement=True, index=True, unique=True, nullable=False)
+
+ def sgn(self):
+ return MsgSgn(sf=self.sf, st=self.st, idn=self.idn)
+
+ def __repr__(self):
+ return f"{type(self).__name__}(" \
+ f"sf={self.sf!r}, st={self.st!r}, idn={self.idn!r}, ts={self.ts!r}," \
+ f" en={self.en!r}, ec={self.ec!r}," \
+ f" flags={self.flags!r}" \
+ f")"
+
+ @classmethod
+ def from_message(cls, m: Message):
+ # noinspection PyArgumentList
+ return cls(sf=m.sfrom.vkey.encode(), st=m.sto.vkey.encode(), idn=m.idnonce, ts=m.timestamp,
+ en=m.editnonce, ec=m.econtent,
+ flags=m.flags)
+
+ def to_message(self):
+ return Message(Subject(self.sf), Subject(self.st), self.idn, self.ts,
+ self.en, None, self.ec, self.flags)
+
+
+class DBStorage(AbstractStorage):
+ def __init__(self, *args):
+ self.engine = create_engine(*args, echo=False)
+ Base.metadata.create_all(self.engine)
+ self.Session = sessionmaker(bind=self.engine)
+
+ def check(self, s: Subject) -> dict:
+ return {'allowed': None}
+
+ def push(self, m: Message) -> None:
+ session = self.Session()
+ msg = Msg.from_message(m)
+ session.add(msg)
+ session.add(msg.sgn())
+ session.commit()
+ session.close()
+
+ def edit(self, old: Message, new: Message):
+ assert old.edited(new)
+ session = self.Session()
+ msg = self.one_alike(session, old)
+ assert msg.en == old.editnonce
+ msgn = Msg.from_message(new)
+ msg.en = msgn.en
+ msg.ec = msgn.ec
+ msg.flags = msgn.flags
+ session.commit()
+ session.close()
+
+ @staticmethod
+ def one_alike(session, m: Message) -> Msg:
+ return session.query(Msg).filter_by(sf=m.sfrom.vkey.encode(), st=m.sto.vkey.encode(), idn=m.idnonce).one()
+
+ def delete(self, m: Message):
+ session = self.Session()
+ session.delete(self.one_alike(session, m))
+ session.commit()
+ session.close()
+
+ def pull(self, pair: Tuple[Subject, Subject], params: Optional[dict] = None) -> Iterable[Message]:
+ if params is None:
+ params = {}
+ session = self.Session()
+ query: Query = session.query(Msg).filter(or_(
+ and_(
+ Msg.sf == pair[0].vkey.encode(),
+ Msg.st == pair[1].vkey.encode(),
+ ),
+ and_(
+ Msg.sf == pair[1].vkey.encode(),
+ Msg.st == pair[0].vkey.encode(),
+ ),
+ ))
+ if 'ts' in params:
+ if '>' in params:
+ query = query.filter(Msg.ts > params['ts']['>'])
+ if '<' in params:
+ query = query.filter(Msg.ts < params['ts']['<'])
+ if params.get('before'):
+ query = query.filter(Msg.oid < self.one_alike(
+ session,
+ Message(pair[0], pair[1], Encoding.decode(params['before']), None,
+ None, None, None,
+ '')).oid)
+ for flag in params.get('flags', ()):
+ query = query.filter(Msg.flags.contains(flag))
+ query = query.order_by(Msg.oid.desc())
+ if 'limit' in params:
+ query = query.limit(params['limit'])
+ return map(Msg.to_message, query.from_self().order_by(Msg.oid))
+
+ def flags(self, m: Message, flags: str):
+ session = self.Session()
+ msg: Msg = self.one_alike(session, m)
+ assert msg.en == m.editnonce
+ msg.flags = flags
+ session.commit()
+ session.close()
diff --git a/v25/storage/secstorage.py b/v25/storage/secstorage.py
new file mode 100644
index 0000000..c3101c7
--- /dev/null
+++ b/v25/storage/secstorage.py
@@ -0,0 +1,35 @@
+from typing import Tuple, Optional, Iterable
+
+from v25.messaging.message import Message
+from v25.messaging.subject import Subject
+from v25.storage.storage import AbstractStorage
+
+
+class SecureStorage(AbstractStorage):
+ def __init__(self, storage: AbstractStorage, subject: Subject):
+ self.storage = storage
+ self.subject = subject
+
+ def check(self, s: Subject) -> dict:
+ assert self.subject == s
+ return self.storage.check(s)
+
+ def push(self, m: Message) -> None:
+ assert self.subject == m.sfrom
+ return self.storage.push(m)
+
+ def edit(self, old: Message, new: Message):
+ assert self.subject == old.sfrom
+ return self.storage.edit(old, new)
+
+ def delete(self, m: Message):
+ assert self.subject in m.pair
+ return self.storage.delete(m)
+
+ def pull(self, pair: Tuple[Subject, Subject], params: Optional[dict] = None) -> Iterable[Message]:
+ assert self.subject in pair
+ return self.storage.pull(pair, params)
+
+ def flags(self, m: Message, flags: str):
+ assert self.subject in m.pair
+ return self.storage.flags(m, flags)
diff --git a/v25/storage/storage.py b/v25/storage/storage.py
new file mode 100644
index 0000000..e27aaf6
--- /dev/null
+++ b/v25/storage/storage.py
@@ -0,0 +1,25 @@
+from abc import ABC
+from typing import Tuple, Optional, List, Iterable
+
+from v25.messaging.message import Message
+from v25.messaging.subject import Subject
+
+
+class AbstractStorage(ABC):
+ def check(self, s: Subject) -> dict:
+ raise NotImplementedError
+
+ def push(self, m: Message) -> None:
+ raise NotImplementedError
+
+ def edit(self, old: Message, new: Message):
+ raise NotImplementedError
+
+ def delete(self, m: Message):
+ raise NotImplementedError
+
+ def pull(self, pair: Tuple[Subject, Subject], params: Optional[dict] = None) -> Iterable[Message]:
+ raise NotImplementedError
+
+ def flags(self, m: Message, flags: str):
+ raise NotImplementedError
diff --git a/v25/web/__init__.py b/v25/web/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/v25/web/client/__init__.py b/v25/web/client/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/v25/web/client/remotestorage.py b/v25/web/client/remotestorage.py
new file mode 100644
index 0000000..04cdf50
--- /dev/null
+++ b/v25/web/client/remotestorage.py
@@ -0,0 +1,46 @@
+from json import dumps
+from typing import Tuple, Optional, Iterable
+
+import requests
+
+from v25.messaging.encoding import Encoding
+from v25.messaging.message import Message
+from v25.messaging.subject import Subject, PrivateSubject
+from v25.storage.storage import AbstractStorage
+
+
+class RemoteStorage(AbstractStorage):
+ def __init__(self, url: str, subject: PrivateSubject):
+ self.url = url
+ self.subject = subject
+
+ def api(self, s: str):
+ return f'{self.url}/{s}'
+
+ def req(self, api: str, o: object):
+ source = dumps(o)
+ req = requests.post(self.api(api), json={
+ 'source': source,
+ 'subject': self.subject.dumps(),
+ 'signature': Encoding.encode(self.subject.skey.sign(source.encode()).signature),
+ })
+ return req.json()
+
+ def check(self, s: Subject) -> dict:
+ return self.req('check', s.dumps())
+
+ def push(self, m: Message) -> None:
+ self.req('push', m.dumps())
+
+ def edit(self, old: Message, new: Message):
+ self.req('edit', {'old': old.dumps(), 'new': new.dumps()})
+
+ def delete(self, m: Message):
+ self.req('delete', m.dumps())
+
+ def pull(self, pair: Tuple[Subject, Subject], params: Optional[dict] = None) -> Iterable[Message]:
+ return map(Message.loads,
+ self.req('pull', {'params': params, 'pair': [subject.dumps() for subject in pair]}))
+
+ def flags(self, m: Message, flags: str):
+ self.req('flags', {'m': m.dumps(), 'flags': flags})
diff --git a/v25/web/server/__init__.py b/v25/web/server/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/v25/web/server/api.py b/v25/web/server/api.py
new file mode 100644
index 0000000..366f401
--- /dev/null
+++ b/v25/web/server/api.py
@@ -0,0 +1,66 @@
+from json import loads
+from typing import Tuple
+
+from flask import Flask, jsonify, request
+
+from v25.messaging.encoding import Encoding
+from v25.messaging.message import Message
+from v25.messaging.subject import Subject
+from v25.storage.secstorage import SecureStorage
+from v25.storage.storage import AbstractStorage
+
+
+class API(Flask):
+ def __init__(self, import_name, storage: AbstractStorage):
+ self.storage = storage
+ super().__init__(import_name)
+ self.routes()
+
+ def allowed(self, s: Subject):
+ return 'allowed' in self.storage.check(s)
+
+ def ss(self):
+ d = request.json
+ source: str = d['source']
+ subject = Subject.loads(d['subject'])
+ assert self.allowed(subject)
+ subject.vkey.verify(source.encode(), Encoding.decode(d['signature']))
+ return loads(source), SecureStorage(self.storage, subject)
+
+ def routes(self):
+ app = self
+
+ @app.route('/')
+ def root():
+ return "V25API"
+
+ @app.route('/check', methods=['POST'])
+ def check():
+ d, storage = self.ss()
+ return jsonify(storage.check(Subject.loads(d)))
+
+ @app.route('/push', methods=['POST'])
+ def push():
+ d, storage = self.ss()
+ return jsonify(storage.push(Message.loads(d)))
+
+ @app.route('/edit', methods=['POST'])
+ def edit():
+ d, storage = self.ss()
+ return jsonify(storage.edit(Message.loads(d['old']), Message.loads(d['new'])))
+
+ @app.route('/delete', methods=['POST'])
+ def delete():
+ d, storage = self.ss()
+ return jsonify(storage.delete(Message.loads(d)))
+
+ @app.route('/pull', methods=['POST'])
+ def pull():
+ d, storage = self.ss()
+ pair: Tuple[Subject, Subject] = Subject.loads(d['pair'][0]), Subject.loads(d['pair'][1])
+ return jsonify(list(map(Message.dumps, storage.pull(pair, d['params']))))
+
+ @app.route('/flags', methods=['POST'])
+ def flags():
+ d, storage = self.ss()
+ return jsonify(storage.flags(Message.loads(d['m']), d['flags']))