shakun-kernel 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shakun_kernel/__init__.py +26 -0
- shakun_kernel/daemon/__init__.py +3 -0
- shakun_kernel/daemon/__main__.py +42 -0
- shakun_kernel/daemon/caller_resolver.py +67 -0
- shakun_kernel/daemon/delegation_wire.py +61 -0
- shakun_kernel/daemon/dispatcher.py +213 -0
- shakun_kernel/daemon/nonce_store.py +133 -0
- shakun_kernel/daemon/server.py +136 -0
- shakun_kernel/daemon/signature_resolver.py +173 -0
- shakun_kernel/daemon/signed_invocation.py +66 -0
- shakun_kernel/factory.py +236 -0
- shakun_kernel/kernel/__init__.py +0 -0
- shakun_kernel/kernel/attestation.py +275 -0
- shakun_kernel/kernel/authorization.py +156 -0
- shakun_kernel/kernel/caller.py +42 -0
- shakun_kernel/kernel/clock.py +75 -0
- shakun_kernel/kernel/delegation.py +215 -0
- shakun_kernel/kernel/event_chain.py +47 -0
- shakun_kernel/kernel/identity.py +17 -0
- shakun_kernel/kernel/identity_keys.py +140 -0
- shakun_kernel/kernel/kernel.py +2570 -0
- shakun_kernel/kernel/revocation_store.py +96 -0
- shakun_kernel/kernel/rotation.py +132 -0
- shakun_kernel/kernel/schemas.py +263 -0
- shakun_kernel/kernel/validation.py +14 -0
- shakun_kernel/kernel/verification.py +89 -0
- shakun_kernel/storage/__init__.py +0 -0
- shakun_kernel/storage/sqlite_store.py +311 -0
- shakun_kernel/transport/__init__.py +0 -0
- shakun_kernel/transport/http.py +163 -0
- shakun_kernel-0.1.0.dist-info/METADATA +285 -0
- shakun_kernel-0.1.0.dist-info/RECORD +35 -0
- shakun_kernel-0.1.0.dist-info/WHEEL +5 -0
- shakun_kernel-0.1.0.dist-info/licenses/LICENSE +19 -0
- shakun_kernel-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shakun — a trust and identity kernel for AI agents.
|
|
3
|
+
|
|
4
|
+
This module is the public surface. Everything a developer needs to stand
|
|
5
|
+
up a kernel, obtain a caller, register identities, and delegate authority
|
|
6
|
+
between them is exported here. Reaching into kernel internals or test
|
|
7
|
+
helpers should never be necessary for ordinary use; if it is, that is a
|
|
8
|
+
gap in this surface, not the intended path.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from shakun_kernel.factory import create_shakun
|
|
12
|
+
from shakun_kernel.kernel.caller import CallerContext
|
|
13
|
+
from shakun_kernel.kernel.identity_keys import generate_identity_keypair
|
|
14
|
+
from shakun_kernel.kernel.delegation import create_delegation
|
|
15
|
+
from shakun_kernel.kernel.authorization import AuthorizationContext
|
|
16
|
+
from shakun_kernel.kernel.revocation_store import InMemoryRevocationStore, RevocationRecord
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"create_shakun",
|
|
20
|
+
"CallerContext",
|
|
21
|
+
"generate_identity_keypair",
|
|
22
|
+
"create_delegation",
|
|
23
|
+
"AuthorizationContext",
|
|
24
|
+
"InMemoryRevocationStore",
|
|
25
|
+
"RevocationRecord",
|
|
26
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from shakun_kernel.daemon.server import run_daemon
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
parser = argparse.ArgumentParser(
|
|
10
|
+
prog="python -m shakun_kernel.daemon",
|
|
11
|
+
description="Shakun development daemon (local-only, no authentication).",
|
|
12
|
+
)
|
|
13
|
+
parser.add_argument("--key-path", required=True,
|
|
14
|
+
help="path to the daemon's encrypted identity key")
|
|
15
|
+
parser.add_argument("--passphrase", required=True,
|
|
16
|
+
help="passphrase for the identity key")
|
|
17
|
+
parser.add_argument("--db", required=True, dest="sqlite_path",
|
|
18
|
+
help="path to the kernel's sqlite event store")
|
|
19
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
20
|
+
parser.add_argument("--port", type=int, default=8000)
|
|
21
|
+
parser.add_argument("--allow-insecure-bind", action="store_true",
|
|
22
|
+
help="permit binding an unauthenticated daemon to a "
|
|
23
|
+
"non-loopback interface")
|
|
24
|
+
parser.add_argument("--dev-no-auth", action="store_true",
|
|
25
|
+
help="run WITHOUT request authentication (local dev "
|
|
26
|
+
"only; forces loopback unless --allow-insecure-bind)")
|
|
27
|
+
args = parser.parse_args()
|
|
28
|
+
|
|
29
|
+
logging.basicConfig(level=logging.INFO)
|
|
30
|
+
|
|
31
|
+
run_daemon(
|
|
32
|
+
key_path=args.key_path,
|
|
33
|
+
passphrase=args.passphrase,
|
|
34
|
+
sqlite_path=args.sqlite_path,
|
|
35
|
+
host=args.host,
|
|
36
|
+
port=args.port,
|
|
37
|
+
authenticated=not args.dev_no_auth,
|
|
38
|
+
allow_insecure_bind=args.allow_insecure_bind,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
main()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from shakun_kernel.kernel.caller import CallerContext
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CallerResolver:
|
|
5
|
+
"""
|
|
6
|
+
Turns an incoming transport request into a CallerContext, or
|
|
7
|
+
refuses. This is the single place authentication happens — the
|
|
8
|
+
concrete component behind "the transport authenticates, the kernel
|
|
9
|
+
authorizes."
|
|
10
|
+
|
|
11
|
+
Transport → CallerResolver → CallerContext → Kernel
|
|
12
|
+
|
|
13
|
+
Every resolver must honour the CallerContext Trust Boundary: it may
|
|
14
|
+
only construct a CallerContext for an identity it has actually
|
|
15
|
+
verified. Constructing one from unverified request data defeats the
|
|
16
|
+
kernel's authorization model, and the kernel cannot detect that it
|
|
17
|
+
happened.
|
|
18
|
+
|
|
19
|
+
Subclasses implement resolve(). The `request` argument is the
|
|
20
|
+
transport's own native request object, passed through opaquely: the
|
|
21
|
+
resolver is free to interpret it however it needs (read headers, a
|
|
22
|
+
signature, the raw body), and is the ONLY component permitted to.
|
|
23
|
+
Below the transport boundary the request does not exist — the
|
|
24
|
+
dispatcher and kernel see only the CallerContext that comes out,
|
|
25
|
+
never the request that produced it. That is what lets the same
|
|
26
|
+
dispatcher and kernel sit behind HTTP, a signature-verifying
|
|
27
|
+
resolver, or a future network resolver without changing.
|
|
28
|
+
"""
|
|
29
|
+
def resolve(self, request) -> CallerContext:
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LocalCallerResolver(CallerResolver):
|
|
34
|
+
"""
|
|
35
|
+
Development only. Resolves EVERY request to one configured local
|
|
36
|
+
identity, performing no authentication whatsoever.
|
|
37
|
+
|
|
38
|
+
This exists to exercise the request pipeline before the real trust
|
|
39
|
+
boundary (SignatureCallerResolver, CRITICAL 1) is built. It
|
|
40
|
+
performs no authentication, so a deployment using it is responsible
|
|
41
|
+
for ensuring it is never exposed to untrusted clients. That is an
|
|
42
|
+
operational guarantee the deployment must make — this class cannot
|
|
43
|
+
enforce it, since a resolver has no visibility into how or where
|
|
44
|
+
the server binds. It is never a production component.
|
|
45
|
+
|
|
46
|
+
The configured identity is normally the kernel's own genesis
|
|
47
|
+
identity — the one identity guaranteed to exist and to hold
|
|
48
|
+
registration authority — so a local developer can drive every
|
|
49
|
+
syscall without first standing up a second identity.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, identity_id):
|
|
53
|
+
if not isinstance(identity_id, str) or not identity_id.strip():
|
|
54
|
+
raise ValueError("LocalCallerResolver requires a non-blank identity_id")
|
|
55
|
+
self._identity_id = identity_id
|
|
56
|
+
|
|
57
|
+
def resolve(self, request):
|
|
58
|
+
# No authentication. The request is ignored for identity — every
|
|
59
|
+
# caller is the one configured local identity. The operation is
|
|
60
|
+
# read from the adapter unauthenticated, which is the entire
|
|
61
|
+
# nature of this dev resolver: it asserts nothing it verified.
|
|
62
|
+
from shakun_kernel.daemon.signature_resolver import AuthenticatedInvocation
|
|
63
|
+
fields = request.auth_fields()
|
|
64
|
+
return AuthenticatedInvocation(
|
|
65
|
+
caller=CallerContext(identity_id=self._identity_id),
|
|
66
|
+
operation=fields.get("operation"),
|
|
67
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from shakun_kernel.kernel.schemas import DelegationRecord
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# The wire form of a DelegationRecord: a flat JSON object with exactly
|
|
5
|
+
# these keys. This mapping IS the protocol — a client in any language
|
|
6
|
+
# produces this shape — so it is declared explicitly here rather than
|
|
7
|
+
# derived from the dataclass by introspection, and every field is
|
|
8
|
+
# covered by a round-trip test. If DelegationRecord gains a field, this
|
|
9
|
+
# fails loudly (missing key) rather than silently dropping it.
|
|
10
|
+
_DELEGATION_FIELDS = (
|
|
11
|
+
"version",
|
|
12
|
+
"delegation_id",
|
|
13
|
+
"delegator_identity_id",
|
|
14
|
+
"delegate_identity_id",
|
|
15
|
+
"issuer_identity_id",
|
|
16
|
+
"scope_id",
|
|
17
|
+
"mission_id",
|
|
18
|
+
"issued_at",
|
|
19
|
+
"expires_at",
|
|
20
|
+
"revocation_reference",
|
|
21
|
+
"signature",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Membership set for validation — the tuple gives deterministic
|
|
25
|
+
# reconstruction order, this gives O(1) presence/absence checks without
|
|
26
|
+
# rebuilding a list on every call.
|
|
27
|
+
_DELEGATION_FIELD_SET = frozenset(_DELEGATION_FIELDS)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def delegation_to_dict(record: DelegationRecord) -> dict:
|
|
31
|
+
"""Serialize a DelegationRecord to its wire form. Explicit per-field
|
|
32
|
+
so the wire shape is a deliberate contract, not an accident of
|
|
33
|
+
dataclass layout."""
|
|
34
|
+
return {field: getattr(record, field) for field in _DELEGATION_FIELDS}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def delegation_from_dict(data: dict) -> DelegationRecord:
|
|
38
|
+
"""
|
|
39
|
+
Reconstruct a DelegationRecord from its wire form.
|
|
40
|
+
|
|
41
|
+
Every field must be present. A missing field is a malformed
|
|
42
|
+
delegation and raises — we never silently default one, because a
|
|
43
|
+
defaulted field would change the bytes verify_delegation checks and
|
|
44
|
+
produce a confusing 'invalid signature' far from the real cause
|
|
45
|
+
(a truncated delegation). optional-VALUED fields (mission_id,
|
|
46
|
+
revocation_reference) may be null, but the KEY must exist.
|
|
47
|
+
"""
|
|
48
|
+
if not isinstance(data, dict):
|
|
49
|
+
raise ValueError("delegation must be a JSON object")
|
|
50
|
+
keys = set(data)
|
|
51
|
+
missing = _DELEGATION_FIELD_SET - keys
|
|
52
|
+
if missing:
|
|
53
|
+
raise ValueError(f"delegation is missing required field(s): {sorted(missing)}")
|
|
54
|
+
extra = keys - _DELEGATION_FIELD_SET
|
|
55
|
+
if extra:
|
|
56
|
+
# Strict protocol boundary: unknown fields are rejected, not
|
|
57
|
+
# ignored. Catches client typos (e.g. delegate_identity vs
|
|
58
|
+
# delegate_identity_id) immediately, at the boundary, rather than
|
|
59
|
+
# as a downstream signature mismatch.
|
|
60
|
+
raise ValueError(f"delegation contains unknown field(s): {sorted(extra)}")
|
|
61
|
+
return DelegationRecord(**{field: data[field] for field in _DELEGATION_FIELDS})
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
from shakun_kernel.kernel.caller import CallerContext
|
|
2
|
+
|
|
3
|
+
# NOTE (future): as transport-level validation grows, these one-off
|
|
4
|
+
# dispatcher exceptions want to become a small hierarchy (a DispatcherError
|
|
5
|
+
# base with MalformedDelegation / DelegationUnavailable / ... subclasses),
|
|
6
|
+
# so dispatch() catches the base once and maps code/type off the exception
|
|
7
|
+
# instead of accumulating except-clauses. Deferred until a third such
|
|
8
|
+
# exception makes the shared shape clear — building it with two members
|
|
9
|
+
# would be structure ahead of the load that justifies it.
|
|
10
|
+
class _DelegationUnavailable(Exception):
|
|
11
|
+
"""A delegated request reached a daemon with no revocation store.
|
|
12
|
+
Fails closed — see _auth_context."""
|
|
13
|
+
|
|
14
|
+
class SyscallDispatcher:
|
|
15
|
+
"""
|
|
16
|
+
Turns a resolved caller and a parsed request body into exactly one
|
|
17
|
+
kernel syscall, and returns the kernel's structured response
|
|
18
|
+
unchanged.
|
|
19
|
+
|
|
20
|
+
Transport → CallerResolver → CallerContext ─┐
|
|
21
|
+
├→ Dispatcher → Kernel
|
|
22
|
+
parsed request body ─────────────┘
|
|
23
|
+
|
|
24
|
+
This is the one place that knows the mapping from a named operation
|
|
25
|
+
to a kernel syscall and the shape of each operation's arguments.
|
|
26
|
+
It performs NO authentication (that already happened in the
|
|
27
|
+
resolver) and NO authorization (that happens inside the kernel).
|
|
28
|
+
Its whole job is: pick the syscall, pull named fields out of the
|
|
29
|
+
body, thread the caller through, hand back what the kernel returns.
|
|
30
|
+
|
|
31
|
+
It never calls a non-syscall_ kernel method. The syscall_ layer is
|
|
32
|
+
the boundary — see the Trust Boundary Law — and the dispatcher
|
|
33
|
+
stays on the outside of it.
|
|
34
|
+
|
|
35
|
+
A request that names an unknown operation, or omits a field a
|
|
36
|
+
syscall requires, is a client error and comes back as a structured
|
|
37
|
+
error dict, never an exception through the transport.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, kernel, revocation_store=None):
|
|
41
|
+
self._kernel = kernel
|
|
42
|
+
# The daemon's durable revocation store, used to build an
|
|
43
|
+
# AuthorizationContext for delegated requests. Optional: a daemon
|
|
44
|
+
# with no delegated operations (or the dev/local path) can run
|
|
45
|
+
# without one, in which case a request carrying a delegation is
|
|
46
|
+
# refused rather than silently unverified against revocation.
|
|
47
|
+
self._revocation_store = revocation_store
|
|
48
|
+
self._operations = {
|
|
49
|
+
"create_scope": self._create_scope,
|
|
50
|
+
"create_mission": self._create_mission,
|
|
51
|
+
"start_mission": self._start_mission,
|
|
52
|
+
"record_observation": self._record_observation,
|
|
53
|
+
"close_mission": self._close_mission,
|
|
54
|
+
"get_mission_state": self._get_mission_state,
|
|
55
|
+
"record_retrieval_event": self._record_retrieval_event,
|
|
56
|
+
"commit_memory": self._commit_memory,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
def dispatch(self, operation, body, caller: CallerContext):
|
|
60
|
+
handler = self._operations.get(operation)
|
|
61
|
+
if handler is None:
|
|
62
|
+
return {
|
|
63
|
+
"status": "error",
|
|
64
|
+
"error": {
|
|
65
|
+
"type": "UnknownOperation",
|
|
66
|
+
"code": "dispatch.unknown_operation",
|
|
67
|
+
"reason": f"no such operation: {operation}",
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
try:
|
|
71
|
+
return handler(body, caller)
|
|
72
|
+
except _DelegationUnavailable as e:
|
|
73
|
+
return {
|
|
74
|
+
"status": "error",
|
|
75
|
+
"error": {
|
|
76
|
+
"type": "DelegationUnavailable",
|
|
77
|
+
"code": "dispatch.delegation_unavailable",
|
|
78
|
+
"reason": str(e),
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
except ValueError as e:
|
|
82
|
+
# Malformed delegation (bad wire shape, missing/unknown field).
|
|
83
|
+
# A client error, surfaced clearly rather than as a 500.
|
|
84
|
+
return {
|
|
85
|
+
"status": "error",
|
|
86
|
+
"error": {
|
|
87
|
+
"type": "MalformedDelegation",
|
|
88
|
+
"code": "dispatch.malformed_delegation",
|
|
89
|
+
"reason": str(e),
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
except KeyError as missing:
|
|
93
|
+
# ONLY transport-level errors are caught here. Kernel errors
|
|
94
|
+
# (authorization, invariants, domain rules) are returned by
|
|
95
|
+
# _run_syscall as structured dicts, not raised — they pass
|
|
96
|
+
# through untouched. Do not add a broad except: it would
|
|
97
|
+
# create a second error vocabulary competing with the
|
|
98
|
+
# kernel's own.
|
|
99
|
+
return {
|
|
100
|
+
"status": "error",
|
|
101
|
+
"error": {
|
|
102
|
+
"type": "MissingField",
|
|
103
|
+
"code": "dispatch.missing_field",
|
|
104
|
+
"reason": f"request is missing required field: {missing.args[0]}",
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
def _auth_context(self, body):
|
|
109
|
+
"""
|
|
110
|
+
Assembles an AuthorizationContext from a request body, if the body
|
|
111
|
+
carries a delegation. Returns None for a direct-authority request
|
|
112
|
+
(no delegation field) — the kernel treats auth_context=None as
|
|
113
|
+
"direct authority only", exactly as for an in-process direct call.
|
|
114
|
+
|
|
115
|
+
This is the single place body-delegation becomes an
|
|
116
|
+
AuthorizationContext. delegation_wire only reconstructs the
|
|
117
|
+
DelegationRecord; this method is the one that knows about the
|
|
118
|
+
revocation store. The kernel still consumes only AuthorizationContext.
|
|
119
|
+
"""
|
|
120
|
+
from shakun_kernel.daemon.delegation_wire import delegation_from_dict
|
|
121
|
+
from shakun_kernel.kernel.authorization import AuthorizationContext
|
|
122
|
+
|
|
123
|
+
raw = body.get("delegation")
|
|
124
|
+
if raw is None:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
if self._revocation_store is None:
|
|
128
|
+
# A delegation was presented but this daemon has no revocation
|
|
129
|
+
# store to check it against. Refuse rather than verify a
|
|
130
|
+
# delegation we couldn't confirm is unrevoked — failing closed
|
|
131
|
+
# is the only safe choice for an authority grant.
|
|
132
|
+
raise _DelegationUnavailable(
|
|
133
|
+
"this daemon is not configured to accept delegated requests"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
delegation = delegation_from_dict(raw)
|
|
137
|
+
return AuthorizationContext(
|
|
138
|
+
delegation_record=delegation,
|
|
139
|
+
revocation_store=self._revocation_store,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# ── operation handlers ──────────────────────────────────────────────
|
|
143
|
+
# Each pulls the fields its syscall needs out of `body` and threads
|
|
144
|
+
# the caller through. A missing required field raises KeyError, which
|
|
145
|
+
# dispatch() turns into a clean dispatch.missing_field error.
|
|
146
|
+
|
|
147
|
+
def _create_scope(self, body, caller):
|
|
148
|
+
return self._kernel.syscall_create_scope(
|
|
149
|
+
body["scope_id"],
|
|
150
|
+
caller=caller,
|
|
151
|
+
parent_scope_id=body.get("parent_scope_id"),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def _create_mission(self, body, caller):
|
|
155
|
+
return self._kernel.syscall_create_mission(
|
|
156
|
+
body["goal_spec"],
|
|
157
|
+
scope_id=body["scope_id"],
|
|
158
|
+
caller=caller,
|
|
159
|
+
auth_context=self._auth_context(body),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def _start_mission(self, body, caller):
|
|
163
|
+
return self._kernel.syscall_start_mission(
|
|
164
|
+
body["mission_id"],
|
|
165
|
+
caller=caller,
|
|
166
|
+
auth_context=self._auth_context(body),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def _record_observation(self, body, caller):
|
|
170
|
+
return self._kernel.syscall_record_observation(
|
|
171
|
+
body["observation"],
|
|
172
|
+
scope_id=body["scope_id"],
|
|
173
|
+
mission_id=body.get("mission_id"),
|
|
174
|
+
caller=caller,
|
|
175
|
+
auth_context=self._auth_context(body),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def _close_mission(self, body, caller):
|
|
179
|
+
return self._kernel.syscall_close_mission(
|
|
180
|
+
body["mission_id"],
|
|
181
|
+
evidence_event_ids=body.get("evidence_event_ids"),
|
|
182
|
+
caller=caller,
|
|
183
|
+
auth_context=self._auth_context(body),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def _get_mission_state(self, body, caller):
|
|
187
|
+
return self._kernel.syscall_get_mission_state(
|
|
188
|
+
body["mission_id"],
|
|
189
|
+
caller=caller,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def _record_retrieval_event(self, body, caller):
|
|
193
|
+
return self._kernel.syscall_record_retrieval_event(
|
|
194
|
+
source_id=body["source_id"],
|
|
195
|
+
source_type=body["source_type"],
|
|
196
|
+
query=body["query"],
|
|
197
|
+
scope_id=body["scope_id"],
|
|
198
|
+
mission_id=body.get("mission_id"),
|
|
199
|
+
caller=caller,
|
|
200
|
+
auth_context=self._auth_context(body),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _commit_memory(self, body, caller):
|
|
204
|
+
from shakun_kernel.kernel.kernel import MemoryType
|
|
205
|
+
return self._kernel.syscall_commit_memory(
|
|
206
|
+
memory_type=MemoryType(body["memory_type"]),
|
|
207
|
+
scope_id=body["scope_id"],
|
|
208
|
+
provenance=body["provenance"],
|
|
209
|
+
content=body["content"],
|
|
210
|
+
schema_version=body.get("schema_version", 1),
|
|
211
|
+
caller=caller,
|
|
212
|
+
auth_context=self._auth_context(body),
|
|
213
|
+
)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
|
|
4
|
+
from shakun_kernel.kernel.clock import parse_iso_timestamp
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class NonceStore:
|
|
8
|
+
"""
|
|
9
|
+
Replay-prevention state: has this (identity, nonce) been seen before?
|
|
10
|
+
|
|
11
|
+
Same abstraction family as EventStore, RevocationStore, and the
|
|
12
|
+
keystores — the resolver depends on this interface, and durability is
|
|
13
|
+
a property of the chosen backend, not of the authentication protocol.
|
|
14
|
+
|
|
15
|
+
Nonces are scoped per identity: two different identities may
|
|
16
|
+
legitimately use the same nonce string, and one identity's nonces are
|
|
17
|
+
never another's concern.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def seen(self, identity_id, nonce) -> bool:
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
|
|
23
|
+
def record(self, identity_id, nonce, timestamp) -> None:
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class InMemoryNonceStore(NonceStore):
|
|
28
|
+
"""
|
|
29
|
+
Development / single-process store. Holds seen (identity, nonce)
|
|
30
|
+
pairs in memory.
|
|
31
|
+
|
|
32
|
+
LIMITATION, by design: this forgets every nonce on restart. Within
|
|
33
|
+
the SignedInvocation timestamp-skew window, a captured request could
|
|
34
|
+
therefore be replayed once across a restart. That is a property of
|
|
35
|
+
THIS backend, not of the protocol — a durable NonceStore
|
|
36
|
+
(SQLite/Redis/etc.) closes it without any change to the resolver.
|
|
37
|
+
Same category of deferred-durability as revocation persistence.
|
|
38
|
+
|
|
39
|
+
`timestamp` is recorded so a future implementation can prune entries
|
|
40
|
+
older than the skew window; this in-memory version keeps them for the
|
|
41
|
+
process lifetime, which is correct (never under-rejects) if wasteful.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self):
|
|
45
|
+
self._seen = set()
|
|
46
|
+
|
|
47
|
+
def seen(self, identity_id, nonce) -> bool:
|
|
48
|
+
return (identity_id, nonce) in self._seen
|
|
49
|
+
|
|
50
|
+
def record(self, identity_id, nonce, timestamp) -> None:
|
|
51
|
+
self._seen.add((identity_id, nonce))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SQLiteNonceStore(NonceStore):
|
|
55
|
+
"""
|
|
56
|
+
A durable NonceStore backed by SQLite. Same contract as
|
|
57
|
+
InMemoryNonceStore — has this (identity, nonce) been seen — but
|
|
58
|
+
survives restart, closing the cross-restart replay window that the
|
|
59
|
+
in-memory store leaves open (audit follow-up to HIGH 7).
|
|
60
|
+
|
|
61
|
+
Self-pruning: a nonce only needs to be remembered as long as a
|
|
62
|
+
request bearing it could still be ACCEPTED, which is the resolver's
|
|
63
|
+
timestamp-skew window. Past that, the resolver rejects the request as
|
|
64
|
+
stale regardless of the nonce check, so keeping the nonce is
|
|
65
|
+
pointless. Each record() therefore deletes entries older than
|
|
66
|
+
retention_seconds. This keeps an otherwise unbounded table bounded,
|
|
67
|
+
with no background worker or startup scan — cleanup happens naturally
|
|
68
|
+
as the daemon serves requests.
|
|
69
|
+
|
|
70
|
+
CRITICAL COUPLING: retention_seconds MUST be >= the resolver's
|
|
71
|
+
max_skew_seconds. If retention were shorter, a nonce could be pruned
|
|
72
|
+
while a request bearing it is still within the acceptance window —
|
|
73
|
+
silently reopening the exact replay hole this store exists to close.
|
|
74
|
+
The daemon must derive both values from one shared constant; see
|
|
75
|
+
build_daemon. This store validates only that retention is positive;
|
|
76
|
+
it cannot see the resolver's skew, so the >= relationship is the
|
|
77
|
+
daemon's responsibility to guarantee.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, db_path, *, retention_seconds=60):
|
|
81
|
+
if not isinstance(retention_seconds, (int, float)) or retention_seconds <= 0:
|
|
82
|
+
raise ValueError("retention_seconds must be a positive number")
|
|
83
|
+
self._db_path = db_path
|
|
84
|
+
self._retention = timedelta(seconds=retention_seconds)
|
|
85
|
+
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
86
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
87
|
+
self._conn.execute("""
|
|
88
|
+
CREATE TABLE IF NOT EXISTS nonces (
|
|
89
|
+
identity_id TEXT NOT NULL,
|
|
90
|
+
nonce TEXT NOT NULL,
|
|
91
|
+
timestamp TEXT NOT NULL,
|
|
92
|
+
PRIMARY KEY (identity_id, nonce)
|
|
93
|
+
)
|
|
94
|
+
""")
|
|
95
|
+
# Index for the prune range query — without it, pruning becomes a
|
|
96
|
+
# full table scan that worsens exactly as the table grows.
|
|
97
|
+
self._conn.execute(
|
|
98
|
+
"CREATE INDEX IF NOT EXISTS idx_nonce_timestamp ON nonces(timestamp)"
|
|
99
|
+
)
|
|
100
|
+
self._conn.commit()
|
|
101
|
+
|
|
102
|
+
def seen(self, identity_id, nonce) -> bool:
|
|
103
|
+
row = self._conn.execute(
|
|
104
|
+
"SELECT 1 FROM nonces WHERE identity_id = ? AND nonce = ?",
|
|
105
|
+
(identity_id, nonce),
|
|
106
|
+
).fetchone()
|
|
107
|
+
return row is not None
|
|
108
|
+
|
|
109
|
+
def record(self, identity_id, nonce, timestamp) -> None:
|
|
110
|
+
# Prune first: drop everything older than the retention window,
|
|
111
|
+
# computed from the newly-recorded request's own timestamp so the
|
|
112
|
+
# store needs no external clock. The cutoff is produced as an ISO
|
|
113
|
+
# string the same way stored timestamps are, so the string
|
|
114
|
+
# comparison below matches chronological order.
|
|
115
|
+
cutoff = (parse_iso_timestamp(timestamp) - self._retention).isoformat()
|
|
116
|
+
self._conn.execute("DELETE FROM nonces WHERE timestamp < ?", (cutoff,))
|
|
117
|
+
|
|
118
|
+
# INSERT OR IGNORE: recording the same (identity, nonce) twice is a
|
|
119
|
+
# no-op. The resolver only ever records a nonce it has already
|
|
120
|
+
# confirmed unseen, so a collision here would mean a genuine replay
|
|
121
|
+
# that the resolver's seen() check should have caught — ignoring is
|
|
122
|
+
# safe and the seen() check is the real guard.
|
|
123
|
+
self._conn.execute(
|
|
124
|
+
"INSERT OR IGNORE INTO nonces (identity_id, nonce, timestamp) "
|
|
125
|
+
"VALUES (?, ?, ?)",
|
|
126
|
+
(identity_id, nonce, timestamp),
|
|
127
|
+
)
|
|
128
|
+
self._conn.commit()
|
|
129
|
+
|
|
130
|
+
def close(self):
|
|
131
|
+
"""Closes the connection explicitly — deterministic lifecycle for
|
|
132
|
+
the daemon at shutdown and for tests before reopen/delete."""
|
|
133
|
+
self._conn.close()
|