provared 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {provared-0.2.0 → provared-0.3.0}/PKG-INFO +47 -4
- provared-0.2.0/src/provared.egg-info/PKG-INFO → provared-0.3.0/README.md +198 -176
- {provared-0.2.0 → provared-0.3.0}/pyproject.toml +4 -2
- provared-0.3.0/src/provared/dev.py +133 -0
- provared-0.2.0/README.md → provared-0.3.0/src/provared.egg-info/PKG-INFO +219 -157
- {provared-0.2.0 → provared-0.3.0}/src/provared.egg-info/SOURCES.txt +2 -0
- provared-0.3.0/tests/test_dev.py +97 -0
- {provared-0.2.0 → provared-0.3.0}/LICENSE +0 -0
- {provared-0.2.0 → provared-0.3.0}/LICENSE-CC-BY-4.0.txt +0 -0
- {provared-0.2.0 → provared-0.3.0}/setup.cfg +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/__init__.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/_js.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/actions.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/approval.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/blockstamp.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/book.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/check.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/compare.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/cover.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/der.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/encoding.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/fields.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/guard.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/headers.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/jws.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/keys.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/pass_.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/recorder.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/seal.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/service.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/signatures.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/slip.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/standing.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/stub.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/timestamp.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/tools.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/tree.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared/webauthn.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared.egg-info/dependency_links.txt +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared.egg-info/requires.txt +0 -0
- {provared-0.2.0 → provared-0.3.0}/src/provared.egg-info/top_level.txt +0 -0
- {provared-0.2.0 → provared-0.3.0}/tests/test_record_function.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/tests/test_recorder.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/tests/test_records.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/tests/test_review2.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/tests/test_tree.py +0 -0
- {provared-0.2.0 → provared-0.3.0}/tests/test_vectors.py +0 -0
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: provared
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Proof of what an AI agent was allowed to do, and what it then did. Evidence, not a verdict.
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Proof of what an AI agent was allowed to do, and what it then did. A passkey-signed permission with limits a machine can check, a signed receipt for each action, and a checker. Evidence, not a verdict.
|
|
5
5
|
Author: Pavel Izmaylov
|
|
6
6
|
License-Expression: Apache-2.0 AND CC-BY-4.0
|
|
7
|
+
Project-URL: Homepage, https://prova.red
|
|
7
8
|
Project-URL: Source, https://github.com/provared/provared
|
|
8
9
|
Project-URL: Documentation, https://github.com/provared/provared/tree/main/python
|
|
9
10
|
Project-URL: Issues, https://github.com/provared/provared/issues
|
|
11
|
+
Keywords: ai-agent,agent,audit-trail,permission,authorization,receipt,tamper-evident,passkey,webauthn,jws,ed25519,ml-dsa,post-quantum,evidence,langchain
|
|
10
12
|
Classifier: Development Status :: 3 - Alpha
|
|
11
13
|
Classifier: Programming Language :: Python :: 3
|
|
12
14
|
Classifier: Topic :: Security :: Cryptography
|
|
@@ -32,7 +34,7 @@ particular to Python.
|
|
|
32
34
|
|
|
33
35
|
## Status
|
|
34
36
|
|
|
35
|
-
**Version 0.
|
|
37
|
+
**Version 0.3.0, a draft**, the same version as the JavaScript library.
|
|
36
38
|
Published as it is, by one author in their own time; fixes as time
|
|
37
39
|
allows. It gives the same answer, case for case, as the shared test
|
|
38
40
|
files in [`test-vectors/`](https://github.com/provared/provared/tree/main/test-vectors) show: the same result, the
|
|
@@ -58,6 +60,44 @@ You need Python 3.11 or later. The one dependency is `cryptography`,
|
|
|
58
60
|
version 48 or later: Python has no signatures built in, and version 48
|
|
59
61
|
is the first whose ready-built packages hold ML-DSA (FIPS 204).
|
|
60
62
|
|
|
63
|
+
## A first record
|
|
64
|
+
|
|
65
|
+
The permission is signed with a **development stand-in for a passkey**
|
|
66
|
+
(`provared.dev`): a key made in memory, which no person confirmed. Every
|
|
67
|
+
permission it signs says so in its issuer's name, and a checker shows
|
|
68
|
+
that name. A real permission is signed by a person, in a browser, with a
|
|
69
|
+
real passkey (the browser part of the JavaScript library, and the
|
|
70
|
+
checking page).
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from provared import check_book
|
|
74
|
+
from provared.dev import development_recorder
|
|
75
|
+
|
|
76
|
+
SEND = 'provared.message.send'
|
|
77
|
+
|
|
78
|
+
# A permission: the agent may send messages, at most two.
|
|
79
|
+
made = development_recorder({'actions': [SEND], 'limits': [{'action': SEND, 'count': 2}]})
|
|
80
|
+
|
|
81
|
+
# Each action is asked for first, taken only if the permission allows it,
|
|
82
|
+
# and receipted after. The third message is outside it, so it is not sent.
|
|
83
|
+
for i in range(1, 4):
|
|
84
|
+
outcome = made.writer.act({'action': SEND}, lambda i=i: print(f'message {i} sent'))
|
|
85
|
+
if not outcome['done']:
|
|
86
|
+
print(f"message {i} not sent: {outcome['answer']['breaches'][0]['message']}")
|
|
87
|
+
|
|
88
|
+
book = made.writer.book() # the record, one entry to a line
|
|
89
|
+
summary = check_book(book, issuer_keys=made.issuer_keys)['summary']
|
|
90
|
+
print(summary['intact'], summary['withinSlips']) # True True
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The whole file is [`examples/first_record.py`](https://github.com/provared/provared/blob/main/python/examples/first_record.py).
|
|
94
|
+
`development_slip(fields)` gives the signed slip, its fingerprint, a
|
|
95
|
+
book holding it, the stand-in's thumbprint (`issuer_keys`) and the
|
|
96
|
+
agent's keys without opening the writer; `development_passkey()` gives
|
|
97
|
+
the stand-in alone, whose `sign(challenge)` signs a prepared slip,
|
|
98
|
+
approval or cancellation. The issuer's name always ends with
|
|
99
|
+
" (development)", whatever name is given.
|
|
100
|
+
|
|
61
101
|
## Use
|
|
62
102
|
|
|
63
103
|
```python
|
|
@@ -143,7 +183,10 @@ place into a `functools.partial` is no longer a parameter, so it is not
|
|
|
143
183
|
in the record, and nor is any change that a decorator inside makes. The
|
|
144
184
|
folder
|
|
145
185
|
[`examples/`](https://github.com/provared/provared/tree/main/python/examples)
|
|
146
|
-
shows this with three widely used agent frameworks
|
|
186
|
+
shows this with three widely used agent frameworks, and the folder
|
|
187
|
+
[`integrations/`](https://github.com/provared/provared/tree/main/python/integrations)
|
|
188
|
+
holds small packages for two of them, each of which puts every tool call
|
|
189
|
+
of an agent behind the writer in one line. `key_set_from_seeds` and `key_set_seeds` keep an
|
|
147
190
|
agent's keys between runs as two 32-byte seeds.
|
|
148
191
|
|
|
149
192
|
Names follow Python's way (`check_book`, `write_stub`). Options may be
|
|
@@ -1,176 +1,198 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
summary
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
and
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
python
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
(`
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
and
|
|
1
|
+
# Provared for Python
|
|
2
|
+
|
|
3
|
+
Proof of what an AI agent was allowed to do, and what it then did.
|
|
4
|
+
|
|
5
|
+
*Evidence, not a verdict.*
|
|
6
|
+
|
|
7
|
+
This is the Python version of Provared. The format, the reasons for it
|
|
8
|
+
and what a record does and does not prove are described in the
|
|
9
|
+
repository's own [README](https://github.com/provared/provared/blob/main/README.md), the
|
|
10
|
+
[format description](https://github.com/provared/provared/blob/main/spec/provared-format.md) and the
|
|
11
|
+
[list of threats](https://github.com/provared/provared/blob/main/docs/threat-model.md). This page says only what is
|
|
12
|
+
particular to Python.
|
|
13
|
+
|
|
14
|
+
## Status
|
|
15
|
+
|
|
16
|
+
**Version 0.3.0, a draft**, the same version as the JavaScript library.
|
|
17
|
+
Published as it is, by one author in their own time; fixes as time
|
|
18
|
+
allows. It gives the same answer, case for case, as the shared test
|
|
19
|
+
files in [`test-vectors/`](https://github.com/provared/provared/tree/main/test-vectors) show: the same result, the
|
|
20
|
+
same codes and the same words. Every shared file passes in both
|
|
21
|
+
languages.
|
|
22
|
+
|
|
23
|
+
One difference is stated, not hidden. The one package this version
|
|
24
|
+
needs, `cryptography`, does not yet have SLH-DSA (FIPS 205), the method
|
|
25
|
+
of a seal's third signature. So a seal's third signature is reported as
|
|
26
|
+
"not checked on this device", and never as a pass, as the checking page
|
|
27
|
+
in a browser reports it. Seals are checked in full by the JavaScript
|
|
28
|
+
command-line checker.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
pip install provared
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or, from a copy of the repository, `pip install ./python`.
|
|
37
|
+
|
|
38
|
+
You need Python 3.11 or later. The one dependency is `cryptography`,
|
|
39
|
+
version 48 or later: Python has no signatures built in, and version 48
|
|
40
|
+
is the first whose ready-built packages hold ML-DSA (FIPS 204).
|
|
41
|
+
|
|
42
|
+
## A first record
|
|
43
|
+
|
|
44
|
+
The permission is signed with a **development stand-in for a passkey**
|
|
45
|
+
(`provared.dev`): a key made in memory, which no person confirmed. Every
|
|
46
|
+
permission it signs says so in its issuer's name, and a checker shows
|
|
47
|
+
that name. A real permission is signed by a person, in a browser, with a
|
|
48
|
+
real passkey (the browser part of the JavaScript library, and the
|
|
49
|
+
checking page).
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from provared import check_book
|
|
53
|
+
from provared.dev import development_recorder
|
|
54
|
+
|
|
55
|
+
SEND = 'provared.message.send'
|
|
56
|
+
|
|
57
|
+
# A permission: the agent may send messages, at most two.
|
|
58
|
+
made = development_recorder({'actions': [SEND], 'limits': [{'action': SEND, 'count': 2}]})
|
|
59
|
+
|
|
60
|
+
# Each action is asked for first, taken only if the permission allows it,
|
|
61
|
+
# and receipted after. The third message is outside it, so it is not sent.
|
|
62
|
+
for i in range(1, 4):
|
|
63
|
+
outcome = made.writer.act({'action': SEND}, lambda i=i: print(f'message {i} sent'))
|
|
64
|
+
if not outcome['done']:
|
|
65
|
+
print(f"message {i} not sent: {outcome['answer']['breaches'][0]['message']}")
|
|
66
|
+
|
|
67
|
+
book = made.writer.book() # the record, one entry to a line
|
|
68
|
+
summary = check_book(book, issuer_keys=made.issuer_keys)['summary']
|
|
69
|
+
print(summary['intact'], summary['withinSlips']) # True True
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The whole file is [`examples/first_record.py`](https://github.com/provared/provared/blob/main/python/examples/first_record.py).
|
|
73
|
+
`development_slip(fields)` gives the signed slip, its fingerprint, a
|
|
74
|
+
book holding it, the stand-in's thumbprint (`issuer_keys`) and the
|
|
75
|
+
agent's keys without opening the writer; `development_passkey()` gives
|
|
76
|
+
the stand-in alone, whose `sign(challenge)` signs a prepared slip,
|
|
77
|
+
approval or cancellation. The issuer's name always ends with
|
|
78
|
+
" (development)", whatever name is given.
|
|
79
|
+
|
|
80
|
+
## Use
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from provared import check_book
|
|
84
|
+
|
|
85
|
+
with open('record.jsonl', encoding='utf-8') as f:
|
|
86
|
+
result = check_book(f.read(), issuer_keys=['<the thumbprint of the passkey you trust>'])
|
|
87
|
+
|
|
88
|
+
summary = result['summary']
|
|
89
|
+
print(summary['intact'], summary['withinSlips'], summary['firstBreach'])
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The answer is the same JSON as the JavaScript library gives (see "What
|
|
93
|
+
the checker returns" in the repository's README).
|
|
94
|
+
|
|
95
|
+
Before an agent acts:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from provared import check_before
|
|
99
|
+
|
|
100
|
+
answer = check_before(book_text, {
|
|
101
|
+
'slip': slip_fingerprint,
|
|
102
|
+
'action': 'supplies.order',
|
|
103
|
+
'amount': {'unit': 'GBP', 'value': 20},
|
|
104
|
+
'with': 'supplier',
|
|
105
|
+
}, issuer_keys=[passkey_thumbprint])
|
|
106
|
+
|
|
107
|
+
if not answer['allowed']:
|
|
108
|
+
print(answer['problems'], answer['breaches'])
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Beside an agent, the stub writer asks the check before acting, takes the
|
|
112
|
+
action only if the answer is yes, and writes the stub (see "Beside an
|
|
113
|
+
agent" in the repository's README):
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from provared import open_recorder
|
|
117
|
+
|
|
118
|
+
writer = open_recorder(book_text, slip_fingerprint, private_keys, issuer_keys=[passkey_thumbprint])
|
|
119
|
+
result = writer.act(
|
|
120
|
+
{'action': 'supplies.order', 'amount': {'unit': 'GBP', 'value': 20}, 'with': 'supplier'},
|
|
121
|
+
lambda: place_the_order(),
|
|
122
|
+
)
|
|
123
|
+
book_text, held = writer.snapshot() # keep both together, with the book
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Every call is an ordinary function. A writer's calls wait for one
|
|
127
|
+
another, also across threads. The other side is asked in a thread of
|
|
128
|
+
its own and given 30 seconds. An asynchronous agent calls the writer
|
|
129
|
+
through `asyncio.to_thread`. A call to the writer from inside an action
|
|
130
|
+
it is taking is refused at once, from the same thread or from a thread
|
|
131
|
+
that carries the action's context; from a new thread that the action
|
|
132
|
+
starts without it, the call waits until the action ends, so an action
|
|
133
|
+
must not wait for such a call. `record_tools` puts an agent's tools
|
|
134
|
+
behind the writer. `record_function` puts one ordinary function behind
|
|
135
|
+
it and keeps the function's name, parameters and description, so that an
|
|
136
|
+
agent framework describes it to its model as it would the function:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from provared import record_function
|
|
140
|
+
|
|
141
|
+
def place_order(item: str, value: int) -> str:
|
|
142
|
+
"""Order supplies from the supplier."""
|
|
143
|
+
...
|
|
144
|
+
|
|
145
|
+
place_order = record_function(writer, place_order, 'supplies.order', with_='supplier',
|
|
146
|
+
amount=lambda args: {'unit': 'GBP', 'value': args['value']})
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
A call that the slip does not allow raises `NotTaken`, and the function
|
|
150
|
+
is not run. The arguments must be plain data (text, numbers, lists,
|
|
151
|
+
dicts with text for names, True, False, None). A function with a
|
|
152
|
+
parameter whose type can never be plain data (a class of its own, such
|
|
153
|
+
as a model or a date, a tuple or a set) is refused when it is recorded,
|
|
154
|
+
and so is a generator function. A whole number that JavaScript cannot
|
|
155
|
+
hold exactly (beyond 2^53 - 1, unless a float holds it exactly) is
|
|
156
|
+
refused, since two such numbers could share one fingerprint. The
|
|
157
|
+
function is run with a copy of the arguments: a change it makes to a
|
|
158
|
+
list or a dict it was handed does not reach the caller, and a default
|
|
159
|
+
that is a list or a dict is a fresh copy at each call. The record holds the
|
|
160
|
+
arguments the recorded function is called with: an argument bound in
|
|
161
|
+
place into a `functools.partial` is no longer a parameter, so it is not
|
|
162
|
+
in the record, and nor is any change that a decorator inside makes. The
|
|
163
|
+
folder
|
|
164
|
+
[`examples/`](https://github.com/provared/provared/tree/main/python/examples)
|
|
165
|
+
shows this with three widely used agent frameworks, and the folder
|
|
166
|
+
[`integrations/`](https://github.com/provared/provared/tree/main/python/integrations)
|
|
167
|
+
holds small packages for two of them, each of which puts every tool call
|
|
168
|
+
of an agent behind the writer in one line. `key_set_from_seeds` and `key_set_seeds` keep an
|
|
169
|
+
agent's keys between runs as two 32-byte seeds.
|
|
170
|
+
|
|
171
|
+
Names follow Python's way (`check_book`, `write_stub`). Options may be
|
|
172
|
+
given as keyword arguments (`issuer_keys`, `expected_root`,
|
|
173
|
+
`stamp_services`, `seal_keys`, `vouchers`, `disclosures`,
|
|
174
|
+
`cancellations`, `without_methods`), or as one dict with the names the
|
|
175
|
+
JavaScript library uses (`issuerKeys` and so on). Records and answers
|
|
176
|
+
are dicts and lists, with the same member names as in the format.
|
|
177
|
+
|
|
178
|
+
`provared.check` holds the checker alone, for anyone who audits the
|
|
179
|
+
checking.
|
|
180
|
+
|
|
181
|
+
## Tests
|
|
182
|
+
|
|
183
|
+
From the `python` folder:
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
python -m venv .venv
|
|
187
|
+
.venv/bin/pip install "cryptography>=48" # on Windows: .venv\Scripts\pip
|
|
188
|
+
PYTHONPATH=src .venv/bin/python -m unittest discover -s tests
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The tests read the shared test files in `../test-vectors/`, which the
|
|
192
|
+
JavaScript library writes (`node tools/make-vectors.mjs`) and checks
|
|
193
|
+
(`node --test test/vectors.test.mjs`).
|
|
194
|
+
|
|
195
|
+
## Licences
|
|
196
|
+
|
|
197
|
+
As for the rest of the repository: the Apache License 2.0 for the code,
|
|
198
|
+
and Creative Commons Attribution 4.0 for the documents.
|
|
@@ -4,8 +4,9 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "provared"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "Proof of what an AI agent was allowed to do, and what it then did. Evidence, not a verdict."
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Proof of what an AI agent was allowed to do, and what it then did. A passkey-signed permission with limits a machine can check, a signed receipt for each action, and a checker. Evidence, not a verdict."
|
|
9
|
+
keywords = ["ai-agent", "agent", "audit-trail", "permission", "authorization", "receipt", "tamper-evident", "passkey", "webauthn", "jws", "ed25519", "ml-dsa", "post-quantum", "evidence", "langchain"]
|
|
9
10
|
readme = "README.md"
|
|
10
11
|
authors = [{ name = "Pavel Izmaylov" }]
|
|
11
12
|
license = "Apache-2.0 AND CC-BY-4.0"
|
|
@@ -21,6 +22,7 @@ classifiers = [
|
|
|
21
22
|
]
|
|
22
23
|
|
|
23
24
|
[project.urls]
|
|
25
|
+
Homepage = "https://prova.red"
|
|
24
26
|
Source = "https://github.com/provared/provared"
|
|
25
27
|
Documentation = "https://github.com/provared/provared/tree/main/python"
|
|
26
28
|
Issues = "https://github.com/provared/provared/issues"
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# A development stand-in for a passkey, and a stub writer opened under a
|
|
2
|
+
# slip it signed: for trying the library in an evening, and for tests.
|
|
3
|
+
#
|
|
4
|
+
# It is NOT a passkey. Its private key is an ordinary key made in this
|
|
5
|
+
# process's memory, and no person confirmed anything. It returns the same
|
|
6
|
+
# three values a passkey returns (the authenticator data, the client data,
|
|
7
|
+
# the signature), in the forms the W3C Web Authentication standard sets
|
|
8
|
+
# out, so that every record it signs passes the checker exactly as a real
|
|
9
|
+
# one would. To keep such a record from being mistaken for a person's, the
|
|
10
|
+
# name of the issuer in every slip it signs ends with " (development)", and
|
|
11
|
+
# the checker shows that name beside the slip.
|
|
12
|
+
#
|
|
13
|
+
# A real slip is signed by the person, in a browser, with a real passkey:
|
|
14
|
+
# see the browser part of the JavaScript library and the checking page.
|
|
15
|
+
# Nothing here is part of the checker, and nothing here reaches the network.
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
|
|
19
|
+
from cryptography.hazmat.primitives import hashes
|
|
20
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
21
|
+
|
|
22
|
+
from .book import write_book
|
|
23
|
+
from .encoding import fingerprint, format_time, from_base64url, now_ms, sha256, to_base64url, utf8
|
|
24
|
+
from .keys import thumbprint
|
|
25
|
+
from .recorder import open_recorder
|
|
26
|
+
from .signatures import generate_key_set
|
|
27
|
+
from .slip import assemble_slip, prepare_slip
|
|
28
|
+
|
|
29
|
+
__all__ = ['DEVELOPMENT_MARK', 'DevelopmentPasskey', 'DevelopmentSlip', 'development_passkey', 'development_slip', 'development_recorder']
|
|
30
|
+
|
|
31
|
+
DEVELOPMENT_MARK = '(development)'
|
|
32
|
+
"""The words every development slip carries in its issuer's name."""
|
|
33
|
+
|
|
34
|
+
_RP_ID = 'localhost'
|
|
35
|
+
_ORIGIN = 'http://localhost'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DevelopmentPasskey:
|
|
39
|
+
"""A development stand-in for a passkey (ES256, the method most passkeys
|
|
40
|
+
use). It signs on this computer, for the page address http://localhost,
|
|
41
|
+
which the format allows for development only. It is not a passkey."""
|
|
42
|
+
|
|
43
|
+
def __init__(self):
|
|
44
|
+
self._private = ec.generate_private_key(ec.SECP256R1())
|
|
45
|
+
numbers = self._private.public_key().public_numbers()
|
|
46
|
+
self.key = {'alg': 'ES256', 'crv': 'P-256', 'kty': 'EC',
|
|
47
|
+
'x': to_base64url(numbers.x.to_bytes(32, 'big')),
|
|
48
|
+
'y': to_base64url(numbers.y.to_bytes(32, 'big'))}
|
|
49
|
+
self.rp_id = _RP_ID
|
|
50
|
+
self.origin = _ORIGIN
|
|
51
|
+
|
|
52
|
+
def sign(self, challenge):
|
|
53
|
+
"""The three values a passkey returns, for a challenge from
|
|
54
|
+
prepare_slip, prepare_approval or prepare_cancellation: the
|
|
55
|
+
authenticator data, the client data and the signature."""
|
|
56
|
+
client_data_json = utf8(json.dumps({'type': 'webauthn.get', 'challenge': to_base64url(challenge),
|
|
57
|
+
'origin': _ORIGIN, 'crossOrigin': False}, separators=(',', ':')))
|
|
58
|
+
# Flags 0x05: "user present" and "user verified". Nobody was: this is a stand-in.
|
|
59
|
+
authenticator_data = sha256(utf8(_RP_ID)) + bytes([0x05, 0, 0, 0, 1])
|
|
60
|
+
signature = self._private.sign(authenticator_data + sha256(client_data_json), ec.ECDSA(hashes.SHA256()))
|
|
61
|
+
return authenticator_data, client_data_json, signature
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def development_passkey():
|
|
65
|
+
"""A development stand-in for a passkey."""
|
|
66
|
+
return DevelopmentPasskey()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _marked_name(name):
|
|
70
|
+
given = name.strip() if isinstance(name, str) and name.strip() else 'Development passkey'
|
|
71
|
+
return given if given.endswith(DEVELOPMENT_MARK) else f'{given} {DEVELOPMENT_MARK}'
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class DevelopmentSlip:
|
|
75
|
+
"""What development_slip hands back: the signed slip, its fingerprint, a
|
|
76
|
+
book holding the slip alone, the thumbprint of the stand-in passkey (to
|
|
77
|
+
hand to a checker as the passkey you trust), the agent's keys and the
|
|
78
|
+
stand-in passkey itself."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, slip, slip_fingerprint, book, issuer_keys, agent_keys, agent_private_keys, passkey):
|
|
81
|
+
self.slip = slip
|
|
82
|
+
self.slip_fingerprint = slip_fingerprint
|
|
83
|
+
self.book = book
|
|
84
|
+
self.issuer_keys = issuer_keys
|
|
85
|
+
self.agent_keys = agent_keys
|
|
86
|
+
self.agent_private_keys = agent_private_keys
|
|
87
|
+
self.passkey = passkey
|
|
88
|
+
self.writer = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def development_slip(fields=None, **named):
|
|
92
|
+
"""A slip signed with a development stand-in for a passkey, and new keys
|
|
93
|
+
for the agent. Give the slip's members as for prepare_slip, as one dict
|
|
94
|
+
or as keyword arguments; "actions" is required. "issuer" and the agent's
|
|
95
|
+
keys are filled in, and the issuer's name is made to end with
|
|
96
|
+
" (development)". By default the slip runs from a minute ago for one
|
|
97
|
+
day, holds no limits, names no other party, and its purpose says that
|
|
98
|
+
it is for development."""
|
|
99
|
+
given = {} if fields is None else fields
|
|
100
|
+
if not isinstance(given, dict):
|
|
101
|
+
raise TypeError('development_slip: give the members of the slip as a dict, with at least "actions".')
|
|
102
|
+
given = {**given, **named}
|
|
103
|
+
passkey = DevelopmentPasskey()
|
|
104
|
+
agent_keys, agent_private_keys = generate_key_set()
|
|
105
|
+
now = now_ms()
|
|
106
|
+
issuer = given.get('issuer') if isinstance(given.get('issuer'), dict) else {}
|
|
107
|
+
agent = given.get('agent') if isinstance(given.get('agent'), dict) else {}
|
|
108
|
+
content = {
|
|
109
|
+
'validFrom': format_time(now - 60_000),
|
|
110
|
+
'validUntil': format_time(now + 24 * 3_600_000),
|
|
111
|
+
'purpose': 'For development: a record made with a stand-in for a passkey, which no person signed.',
|
|
112
|
+
'limits': [],
|
|
113
|
+
'with': [],
|
|
114
|
+
**given,
|
|
115
|
+
'issuer': {'name': _marked_name(issuer.get('name')), 'key': passkey.key, 'rpId': passkey.rp_id, 'origin': passkey.origin},
|
|
116
|
+
'agent': {'name': agent['name'] if isinstance(agent.get('name'), str) else 'Development agent', **agent, 'keys': agent_keys},
|
|
117
|
+
}
|
|
118
|
+
prepared = prepare_slip(content)
|
|
119
|
+
slip = assemble_slip(prepared, *passkey.sign(prepared['challenge']))
|
|
120
|
+
slip_fingerprint = fingerprint(from_base64url(slip['payload']))
|
|
121
|
+
return DevelopmentSlip(slip, slip_fingerprint, write_book([{'slip': slip}]), [thumbprint(passkey.key)],
|
|
122
|
+
agent_keys, agent_private_keys, passkey)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def development_recorder(fields=None, more=None, **named):
|
|
126
|
+
"""The stub writer, opened under a development slip, in one call: the
|
|
127
|
+
shortest way to a first record. The same as development_slip followed by
|
|
128
|
+
open_recorder; "more" (a dict) is passed to open_recorder: "options",
|
|
129
|
+
"now", "countersign_within". What it hands back has the writer as
|
|
130
|
+
".writer", beside everything development_slip gives."""
|
|
131
|
+
made = development_slip(fields, **named)
|
|
132
|
+
made.writer = open_recorder(made.book, made.slip_fingerprint, made.agent_private_keys, made.issuer_keys, **(more or {}))
|
|
133
|
+
return made
|