From 9fd2e16b68e1a83e2605e8a9cbd9b6d04a4da2f6 Mon Sep 17 00:00:00 2001 From: eunhhu Date: Sun, 4 Oct 2026 12:46:34 +0100 Subject: Take an app's requests only with a token its tokens file grants (017, 3b) An app given a tokens file (tokens=FILE in --apps, --tokens FILE for the one app of a host) answers a request only when it bears a token (Authorization: Bearer) whose SHA-256 the file lists with a grant: read for its views, queries and documents, write for all, input=NAME for those inputs alone; 401 unauthorized (WWW-Authenticate: Bearer) without one, 403 forbidden past its grant. A delivery sealed by a key --sender names needs none. The digests are compared in constant time; a connection keeps its last token's digest, so the same token is hashed once. SHA-256 is the host's own (serve/sha256.c), no OpenSSL loaded. A reading puts a changed tokens file in place with the app running on; a file that is not one stops the app. pure-serve --make-token GRANT makes a token and its line. load takes PURE_LOAD_TOKEN. --- tests/check.py | 196 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) (limited to 'tests') diff --git a/tests/check.py b/tests/check.py index b746120..af93b82 100644 --- a/tests/check.py +++ b/tests/check.py @@ -4246,6 +4246,201 @@ def server_apps_lifecycle(aup, pure, work): 'refused_while_changing': refused[0], 'puts_at_changes': [put['src'] for put, _ in during]} +TOKENS_SOURCE = """state n = 0 +state got = 0 +input add = message { step: number } +input other = message { step: number } +input take = message { k: u32 } +on add { + n = n + add.step +} +on other { + n = n + 1000 +} +on take { + got = got + take.k +} +view total = n +view taken = got +""" +TOKENS_SENDER = """input put = message { k: u32 } +output out = message { k: u32 } +on put { + send out { k: put.k } +} +""" + + +def server_tokens(aup, pure, work): + """An app's routes take tokens (docs/design/017, 3b). With a tokens file + an app answers no request without a token (401, WWW-Authenticate: + Bearer), nor with one it does not list; read reads its views, queries + and documents, write does all, input=add posts to add alone (403 + past a grant), and a refused write changes nothing. Tokens of any + length check (SHA-256 against hashlib's), on one connection as well as + apart. An app with none is open. A new tokens file on SIGHUP takes + effect while the app runs on (no 503); one that is not a tokens file + stops the app (closed, not open). A delivery another node sealed with a + key this host names comes with no token; an unsealed one needs one. + The one app of a host takes --tokens.""" + import hashlib + import http.client + import select + import signal + import struct + import threading + import time + for name, text in (('tokens', TOKENS_SOURCE), ('tokens-sender', TOKENS_SENDER)): + (work / f'{name}.aup').write_text(text) + compile_module(aup, work / f'{name}.aup', work / f'{name}.pmod') + editor = work / 'tokens-editor.pmod' + compile_module(aup, EDITOR, editor) + serve = pure / 'build/pure-serve' + names = {'read': 'r' * 40, 'write': 'w' * 40, 'input=add': 'i' * 40, 'read2': 'q' * 40} + lengths = {n: bytes(65 + (k * 7) % 57 for k in range(n)).decode() for n in (1, 55, 56, 63, 64, 65, 119, 200)} + + def digest(token): + return hashlib.sha256(token.encode()).hexdigest() + + def tokens_file(grants): + return ''.join(f'{grant} {digest(token)} # {grant}\n' for grant, token in grants) + + base = [('read', names['read']), ('write', names['write']), ('input=add', names['input=add'])] + \ + [('read', token) for token in lengths.values()] + (work / 'tokens.list').write_text('# who may call t\n' + tokens_file(base)) + conf = work / 'tokens.conf' + conf.write_text(f"t {work / 'tokens.pmod'} {work / 'tokens-t'} tokens={work / 'tokens.list'}\n" + f"open {work / 'tokens.pmod'} {work / 'tokens-open'}\n" + f"docs {editor} {work / 'tokens-docs'} tokens={work / 'tokens.list'}\n") + host = HttpHost([serve, '--apps', conf]) + + def call(method, path, token=None, body=b'', headers=None, port=None): + connection = http.client.HTTPConnection('127.0.0.1', port or host.port, timeout=10) + try: + sent = dict(headers or {}) + if token is not None: + sent['Authorization'] = f'Bearer {token}' + if method != 'GET': + sent['Content-Length'] = str(len(body)) + connection.request(method, path, body if method != 'GET' else None, sent) + answer = connection.getresponse() + return answer.status, answer.getheader('X-Pure-Error'), answer.getheader('WWW-Authenticate'), answer.read() + finally: + connection.close() + + try: + none = call('GET', '/t/views/total') + assert none[:3] == (401, 'unauthorized', 'Bearer'), none + for method, path in (('POST', '/t/inputs/add?step=1'), ('GET', '/docs/objects/k'), ('PUT', '/t/queries/q'), + ('GET', '/t/queries/q')): + assert call(method, path)[:2] == (401, 'unauthorized'), (method, path) + assert call('GET', '/t/views/total', 'x' * 40)[:2] == (401, 'unauthorized') + r, w, i = names['read'], names['write'], names['input=add'] + assert call('GET', '/t/views/total', r)[0] == 200 + assert call('GET', '/t/queries/q', r)[:2] == (404, 'no_value') # read, and there is none + assert call('POST', '/t/inputs/add?step=1', r)[:2] == (403, 'forbidden') + assert call('PUT', '/docs/objects/k', r, b'x', {'If-None-Match': '*'})[:2] == (403, 'forbidden') + assert call('POST', '/t/inputs/add?step=1', w)[0] == 200 + edit = struct.pack('