first commit

Signed-off-by: Micke Nordin <kano@sunet.se>
This commit is contained in:
Micke Nordin 2025-06-24 11:06:10 +02:00
commit 4dd92c0bdc
Signed by untrusted user who does not match committer: micke
GPG key ID: 0DA0A7A5708FE257
25 changed files with 321 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
ed25519_private_key.pem
ed25519_public_key.pem

0
README.md Normal file
View file

0
app/__init__.py Normal file
View file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

48
app/accept_invite.py Normal file
View file

@ -0,0 +1,48 @@
import cherrypy
import requests
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader('templates'))
def get_working_url(primary, fallback, timeout=1):
try:
response = requests.get(primary, timeout=timeout)
if response.status_code == 200:
return primary
except requests.RequestException:
pass
return fallback
class AcceptInvite(object):
@cherrypy.expose
def index(self, remote=None, token=None):
if not remote:
raise cherrypy.HTTPError(400, "Missing remote")
if not token:
raise cherrypy.HTTPError(400, "Missing token")
disco_url = get_working_url(f'{remote}/.well-known/ocm',
f'{remote}/ocm-provider')
try:
r = requests.get(disco_url)
except requests.exceptions.ConnectionError:
tmpl = env.get_template('error.html')
return tmpl.render(message='Unable to reach OCM provider')
if r.status_code != 200:
tmpl = env.get_template('error.html')
return tmpl.render(message=f'Unable to reach OCM provider: {r.status_code}')
provider_data = r.json()
if ('capabilities' not in provider_data) or (
'inviteAcceptDialog' not in provider_data) or (
'capabilities' in provider_data
and 'invite' not in provider_data['capabilities']):
tmpl = env.get_template('error.html')
return tmpl.render(message='Remote does not support OCM invites')
redirect_url = f'{provider_data['inviteAcceptDialog']}'
redirect_url += f'?token={token}&providerDomain={cherrypy.request.base}'
raise cherrypy.HTTPRedirect(redirect_url)

54
app/crypto.py Normal file
View file

@ -0,0 +1,54 @@
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives import serialization
PRIVATE_KEY_PATH = "ed25519_private_key.pem"
PUBLIC_KEY_PATH = "ed25519_public_key.pem"
def generate_and_save_keypair(private_key_path, public_key_path):
private_key = ed25519.Ed25519PrivateKey.generate()
public_key = private_key.public_key()
# Serialize the private key to PEM format without encryption
pem_private = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
# Serialize the public key to PEM format
pem_public = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
for key in [{
'path': private_key_path,
'pem': pem_private
}, {
'path': public_key_path,
'pem': pem_public
}]:
with open(key["path"], "wb") as f:
f.write(key["pem"])
def load_keypair(private_key_path, public_key_path):
# Load private key from PEM file
with open(private_key_path, "rb") as f:
private_key = serialization.load_pem_private_key(
f.read(),
password=None,
)
# Load public key from PEM file
with open(public_key_path, "rb") as f:
public_key = serialization.load_pem_public_key(f.read())
# Type check (optional but good for Ed25519-specific code)
if not isinstance(private_key, ed25519.Ed25519PrivateKey):
raise TypeError("The private key is not an Ed25519 key")
if not isinstance(public_key, ed25519.Ed25519PublicKey):
raise TypeError("The public key is not an Ed25519 key")
return private_key, public_key

10
app/datatx.py Normal file
View file

@ -0,0 +1,10 @@
import cherrypy
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader('templates'))
class DataTx(object):
@cherrypy.expose
def index(self):
tmpl = env.get_template('index.html')
return tmpl.render(salutation='Hello', target='World')

31
app/disco.py Normal file
View file

@ -0,0 +1,31 @@
import cherrypy
import yaml
import os
from jinja2 import Environment, FileSystemLoader
from cryptography.hazmat.primitives import serialization
from .crypto import generate_and_save_keypair, load_keypair, PRIVATE_KEY_PATH, PUBLIC_KEY_PATH
env = Environment(loader=FileSystemLoader('templates'))
class Disco(object):
@cherrypy.expose
@cherrypy.tools.json_out()
def index(self):
if not os.path.exists(PRIVATE_KEY_PATH):
generate_and_save_keypair(PRIVATE_KEY_PATH, PUBLIC_KEY_PATH)
_, public_key = load_keypair(PRIVATE_KEY_PATH, PUBLIC_KEY_PATH)
pem_bytes = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
pem_string = pem_bytes.decode('utf-8')
baseurl = cherrypy.request.base
tmpl = env.get_template('discovery.yaml')
yamldoc = yaml.safe_load(
tmpl.render(endpoint=baseurl + '/ocm',
public_key_id=baseurl + '/ocm#signature',
public_key_pem=pem_string))
return yamldoc

43
app/main.py Normal file
View file

@ -0,0 +1,43 @@
import cherrypy
import os
from .root import Root
from .disco import Disco
from .token import Token
from .wayf import Wayf
from .accept_invite import AcceptInvite
def main():
cherrypy.config.update("server.conf")
cherrypy.tree.mount(Disco(), '/.well-known/ocm')
cherrypy.tree.mount(Disco(), '/ocm-provider')
cherrypy.tree.mount(Root(), '/')
cherrypy.tree.mount(Token(), '/ocm/token')
cherrypy.tree.mount(Wayf(), '/wayf')
# cherrypy.tree.mount(AcceptInvite(), '/accept-invite')
cherrypy.tree.mount(AcceptInvite(),
'/accept-invite',
config={'/': {
'tools.trailing_slash.on': False
}})
cherrypy.tree.mount(None,
"/favicon.ico",
config={
"/": {
"tools.staticfile.on":
True,
"tools.staticfile.filename":
os.path.abspath("static/favicon.ico")
}
})
cherrypy.engine.start()
cherrypy.engine.block()
if __name__ == '__main__':
main()

11
app/root.py Normal file
View file

@ -0,0 +1,11 @@
import cherrypy
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader('templates'))
class Root(object):
@cherrypy.expose
def index(self):
user = 'Anonymous'
tmpl = env.get_template('index.html')
return tmpl.render(user=user)

36
app/token.py Normal file
View file

@ -0,0 +1,36 @@
import cherrypy
import json
class Token(object):
@cherrypy.expose
@cherrypy.tools.json_out()
def index(self):
raw_body = cherrypy.request.body.read()
data = json.loads(raw_body)
client_id = data['client_id']
code = data['code']
grant_type = data['grant_type']
if grant_type == 'ocm_authorization_code':
return self.get_token(client_id, code)
else:
return {'error': 'unsupported grant_type'}
def get_token(self, client_id, code) -> dict:
if self.validate_token(client_id, code):
token = {
"access_token": "asdfgh",
"token_type": "bearer",
"expires_in": 3600,
"refresh_token": "qwertyuiop"
}
return token
else:
error = {'error': 'invalid_grant'}
return error
def validate_token(self, client_id, token) -> bool:
return True

14
app/wayf.py Normal file
View file

@ -0,0 +1,14 @@
import cherrypy
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader('templates'))
class Wayf(object):
@cherrypy.expose
def index(self,token=None):
if token is None:
tmpl = env.get_template('error.html')
return tmpl.render(message="Sorry you need a token to access this page")
else:
tmpl = env.get_template('wayf.html')
return tmpl.render(token=token)

3
server.conf Normal file
View file

@ -0,0 +1,3 @@
[global]
server.socket_port: 9090

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

26
templates/discovery.yaml Normal file
View file

@ -0,0 +1,26 @@
enabled: true
apiVersion: "1.3.0"
endPoint: {{ endpoint }}
provider: mlog
resoureTypes:
- name: file
shareTypes:
- user
protocols:
datatx: /datatx
webdav: /webdav
capabilities:
- enforce-mfa
- invites
- protocol-object
- recieve-code
- webdav-uri
criteria:
- http-request-signatures
- recieve-code
- denylist
- allowlist
- invite
publicKey:
keyId: {{ public_key_id }}
publicKeyPem: '{{ public_key_pem }}'

2
templates/error.html Normal file
View file

@ -0,0 +1,2 @@
<h1>Oops!</h1>
<p>{{ message }}</p>

24
templates/index.html Normal file
View file

@ -0,0 +1,24 @@
<head>
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css" />
<title>Amity</title>
<style>
nav ul {
list-style-type: none;
margin: 0;
padding: 0;
}
nav ul li {
display: inline-block;
margin: 0 10px;
}
</style>
</head>
<body>
<nav>
<ul>
<li><a aria-current="files" href="/files">Files</a></li>
<li><a aria-current="invites" href="/friends">Friends</a></li>
</ul>
</nav>
<h1>Hello {{ user }}</h1>
</body>

17
templates/wayf.html Normal file
View file

@ -0,0 +1,17 @@
<h1>Where are you from?</h1>
<form action="/accept-invite" method="POST">
<div>
<label for="remote">Home server:</label>
<input
type="text"
id="remote"
name="remote"
placeholder="https://cernbox.cern.ch"
required
/>
<input type="hidden" name="token" value="{{ token }}" />
</div>
<div>
<button>Submit</button>
</div>
</form>