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 60bc8247c3
Signed by untrusted user who does not match committer: micke
GPG key ID: 0DA0A7A5708FE257
16 changed files with 287 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

47
app/accept_invite.py Normal file
View file

@ -0,0 +1,47 @@
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:
raise cherrypy.HTTPError(500, "Unable to reach OCM")
if r.status_code != 200:
raise cherrypy.HTTPError(500,
f"Unable to reach OCM: {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']):
raise cherrypy.HTTPError(500,
"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

34
app/main.py Normal file
View file

@ -0,0 +1,34 @@
import cherrypy
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.engine.start()
cherrypy.engine.block()
if __name__ == '__main__':
main()

10
app/root.py Normal file
View file

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

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

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>

1
templates/index.html Normal file
View file

@ -0,0 +1 @@
<h1>{{ salutation }} {{ target }}</h1>

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>