trooth 0.1.0__tar.gz → 0.2.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.
Files changed (27) hide show
  1. {trooth-0.1.0 → trooth-0.2.0}/LICENSE +57 -57
  2. {trooth-0.1.0 → trooth-0.2.0}/PKG-INFO +17 -6
  3. {trooth-0.1.0 → trooth-0.2.0}/README.md +12 -1
  4. {trooth-0.1.0 → trooth-0.2.0}/pyproject.toml +5 -5
  5. {trooth-0.1.0 → trooth-0.2.0}/trooth/__init__.py +6 -2
  6. {trooth-0.1.0 → trooth-0.2.0}/trooth.egg-info/PKG-INFO +17 -6
  7. {trooth-0.1.0 → trooth-0.2.0}/trooth.egg-info/SOURCES.txt +2 -0
  8. {trooth-0.1.0 → trooth-0.2.0}/trooth.egg-info/requires.txt +1 -1
  9. {trooth-0.1.0 → trooth-0.2.0}/trooth_semanto_verify/__init__.py +1 -1
  10. trooth-0.2.0/trooth_semanto_verify/answer.py +172 -0
  11. {trooth-0.1.0 → trooth-0.2.0}/trooth_semanto_verify/jws.py +2 -0
  12. trooth-0.2.0/trooth_semanto_verify/source.py +154 -0
  13. {trooth-0.1.0 → trooth-0.2.0}/trooth_semanto_verify/verify.py +13 -4
  14. {trooth-0.1.0 → trooth-0.2.0}/setup.cfg +0 -0
  15. {trooth-0.1.0 → trooth-0.2.0}/trooth/integrations/__init__.py +0 -0
  16. {trooth-0.1.0 → trooth-0.2.0}/trooth/integrations/langchain.py +0 -0
  17. {trooth-0.1.0 → trooth-0.2.0}/trooth/integrations/llama_index.py +0 -0
  18. {trooth-0.1.0 → trooth-0.2.0}/trooth/integrations/openai_agents.py +0 -0
  19. {trooth-0.1.0 → trooth-0.2.0}/trooth.egg-info/dependency_links.txt +0 -0
  20. {trooth-0.1.0 → trooth-0.2.0}/trooth.egg-info/entry_points.txt +0 -0
  21. {trooth-0.1.0 → trooth-0.2.0}/trooth.egg-info/top_level.txt +0 -0
  22. {trooth-0.1.0 → trooth-0.2.0}/trooth_semanto_verify/__main__.py +0 -0
  23. {trooth-0.1.0 → trooth-0.2.0}/trooth_semanto_verify/canonical.py +0 -0
  24. {trooth-0.1.0 → trooth-0.2.0}/trooth_semanto_verify/cli.py +0 -0
  25. {trooth-0.1.0 → trooth-0.2.0}/trooth_semanto_verify/client.py +0 -0
  26. {trooth-0.1.0 → trooth-0.2.0}/trooth_semanto_verify/httpsig.py +0 -0
  27. {trooth-0.1.0 → trooth-0.2.0}/trooth_semanto_verify/merkle.py +0 -0
@@ -1,58 +1,58 @@
1
- Apache License
2
-
3
- Version 2.0, January 2004
4
-
5
- http://www.apache.org/licenses/
6
-
7
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
8
-
9
- 1. Definitions.
10
-
11
- "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
12
-
13
- "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
14
-
15
- "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
16
-
17
- "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
18
-
19
- "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
20
-
21
- "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
22
-
23
- "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
24
-
25
- "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
26
-
27
- "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
28
-
29
- "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
30
-
31
- 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
32
-
33
- 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
34
-
35
- 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
36
-
37
- You must give any other recipients of the Work or Derivative Works a copy of this License; and
38
-
39
- You must cause any modified files to carry prominent notices stating that You changed the files; and
40
-
41
- You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
42
-
43
- If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
44
-
45
- 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
46
-
47
- 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
48
-
49
- 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
50
-
51
- 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
52
-
53
- 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
54
-
55
- END OF TERMS AND CONDITIONS
1
+ Apache License
2
+
3
+ Version 2.0, January 2004
4
+
5
+ http://www.apache.org/licenses/
6
+
7
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
8
+
9
+ 1. Definitions.
10
+
11
+ "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
16
+
17
+ "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
18
+
19
+ "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
20
+
21
+ "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
22
+
23
+ "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
24
+
25
+ "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
26
+
27
+ "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
28
+
29
+ "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
30
+
31
+ 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
32
+
33
+ 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
34
+
35
+ 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
36
+
37
+ You must give any other recipients of the Work or Derivative Works a copy of this License; and
38
+
39
+ You must cause any modified files to carry prominent notices stating that You changed the files; and
40
+
41
+ You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
42
+
43
+ If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
44
+
45
+ 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
46
+
47
+ 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
48
+
49
+ 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
50
+
51
+ 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
52
+
53
+ 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
54
+
55
+ END OF TERMS AND CONDITIONS
56
56
 
57
57
  Copyright (c) 2026 Trooth, LLC
58
58
 
@@ -66,5 +66,5 @@ distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, e
66
66
  implied. See the License for the specific language governing permissions and limitations under the
67
67
  License.
68
68
 
69
- This license covers the verifier/ folder only. Every other file in this repository is proprietary to
70
- Trooth, LLC (LicenseRef-Trooth-Proprietary) unless its own header says otherwise.
69
+ This license covers the verifier/ folder only. It grants nothing in any other part of Semanto, which
70
+ is proprietary to Trooth, LLC unless a file's own header says otherwise.
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trooth
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Read a company's Semanto signed copy and check it: signatures, keys, DNS binding, page proofs and the public log.
5
5
  Author: Trooth, LLC
6
6
  License-Expression: Apache-2.0
7
7
  Project-URL: Homepage, https://trooth.co/docs/semanto
8
8
  Project-URL: Documentation, https://trooth.co/docs/semanto
9
- Project-URL: Source, https://github.com/troothllc/trooth-semanto-worker/tree/main/verifier/python
10
- Project-URL: Issues, https://github.com/troothllc/trooth-semanto-worker/issues
11
- Project-URL: Changelog, https://github.com/troothllc/trooth-semanto-worker/releases
9
+ Project-URL: Source, https://github.com/troothllc/semanto/tree/main/verifier/python
10
+ Project-URL: Issues, https://github.com/troothllc/semanto/issues
11
+ Project-URL: Changelog, https://github.com/troothllc/semanto/releases
12
12
  Keywords: semanto,trooth,signatures,rfc9421,web-bot-auth,transparency-log,ai-agents
13
13
  Classifier: Development Status :: 4 - Beta
14
14
  Classifier: Intended Audience :: Developers
@@ -19,7 +19,7 @@ Classifier: Topic :: Security :: Cryptography
19
19
  Requires-Python: >=3.11
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
- Requires-Dist: cryptography>=41
22
+ Requires-Dist: cryptography>=50.0.2
23
23
  Provides-Extra: langchain
24
24
  Requires-Dist: langchain-core>=0.3; extra == "langchain"
25
25
  Provides-Extra: llama-index
@@ -75,7 +75,7 @@ Canonical JSON here supports integers exactly. A non-integer float raises `Canon
75
75
 
76
76
  ## Tests
77
77
 
78
- `python3 -m pytest` runs every shared vector in `verifier/test-vectors/v1` (positive and negative), the RFC 6962 known roots, the RFC 8785 cases, the state rules and the command line.
78
+ `python3 -m pytest` runs every shared vector in `verifier/test-vectors/v1` (positive and negative), the answer cases in `verifier/test-vectors/answer-v1`, the source's refresh and offline behavior, the RFC 6962 known roots, the RFC 8785 cases, the state rules and the command line.
79
79
 
80
80
  ## SemantoClient
81
81
 
@@ -86,3 +86,14 @@ print(r["ok"], r["dns"], r["reason"])
86
86
  ```
87
87
 
88
88
  It fetches with the standard library (pass `fetch=` to use your own), checks the response signature and the domain's `_trooth-keys` TXT record over DNS over HTTPS, and keeps key sets and DNS answers five minutes.
89
+
90
+ ## SemantoSource
91
+
92
+ ```python
93
+ from trooth import SemantoSource
94
+ acme = SemantoSource("acme.com", on_event=lambda e: print(e["event"], e["detail"]))
95
+ a = acme.answer(question="How much is the Starter plan?")
96
+ print(a["status"], a["text"])
97
+ ```
98
+
99
+ It loads `/.well-known/trooth.semanto` and the company's keys from the company's site, checks the file once for each new body, reads the site again at most every 15 minutes, and answers fact IDs and plain questions in process with the same matcher as Trooth's service (`trooth_semanto_verify.answer`, compared case by case with `verifier/test-vectors/answer-v1`). When Trooth's API cannot be reached it keeps answering from the last version that checked out and logs `trooth_api_unreachable`; a new version that does not check out is refused and the last good one stays in use. A fact marked changed since signing or expired returns its mark and no value.
@@ -45,7 +45,7 @@ Canonical JSON here supports integers exactly. A non-integer float raises `Canon
45
45
 
46
46
  ## Tests
47
47
 
48
- `python3 -m pytest` runs every shared vector in `verifier/test-vectors/v1` (positive and negative), the RFC 6962 known roots, the RFC 8785 cases, the state rules and the command line.
48
+ `python3 -m pytest` runs every shared vector in `verifier/test-vectors/v1` (positive and negative), the answer cases in `verifier/test-vectors/answer-v1`, the source's refresh and offline behavior, the RFC 6962 known roots, the RFC 8785 cases, the state rules and the command line.
49
49
 
50
50
  ## SemantoClient
51
51
 
@@ -56,3 +56,14 @@ print(r["ok"], r["dns"], r["reason"])
56
56
  ```
57
57
 
58
58
  It fetches with the standard library (pass `fetch=` to use your own), checks the response signature and the domain's `_trooth-keys` TXT record over DNS over HTTPS, and keeps key sets and DNS answers five minutes.
59
+
60
+ ## SemantoSource
61
+
62
+ ```python
63
+ from trooth import SemantoSource
64
+ acme = SemantoSource("acme.com", on_event=lambda e: print(e["event"], e["detail"]))
65
+ a = acme.answer(question="How much is the Starter plan?")
66
+ print(a["status"], a["text"])
67
+ ```
68
+
69
+ It loads `/.well-known/trooth.semanto` and the company's keys from the company's site, checks the file once for each new body, reads the site again at most every 15 minutes, and answers fact IDs and plain questions in process with the same matcher as Trooth's service (`trooth_semanto_verify.answer`, compared case by case with `verifier/test-vectors/answer-v1`). When Trooth's API cannot be reached it keeps answering from the last version that checked out and logs `trooth_api_unreachable`; a new version that does not check out is refused and the last good one stays in use. A fact marked changed since signing or expired returns its mark and no value.
@@ -12,7 +12,7 @@ build-backend = "setuptools.build_meta"
12
12
 
13
13
  [project]
14
14
  name = "trooth"
15
- version = "0.1.0"
15
+ version = "0.2.0"
16
16
  description = "Read a company's Semanto signed copy and check it: signatures, keys, DNS binding, page proofs and the public log."
17
17
  readme = "README.md"
18
18
  requires-python = ">=3.11"
@@ -28,7 +28,7 @@ classifiers = [
28
28
  "Programming Language :: Python :: 3 :: Only",
29
29
  "Topic :: Security :: Cryptography",
30
30
  ]
31
- dependencies = ["cryptography>=41"]
31
+ dependencies = ["cryptography>=50.0.2"]
32
32
 
33
33
  [project.optional-dependencies]
34
34
  langchain = ["langchain-core>=0.3"]
@@ -38,9 +38,9 @@ openai-agents = ["openai-agents>=0.2"]
38
38
  [project.urls]
39
39
  Homepage = "https://trooth.co/docs/semanto"
40
40
  Documentation = "https://trooth.co/docs/semanto"
41
- Source = "https://github.com/troothllc/trooth-semanto-worker/tree/main/verifier/python"
42
- Issues = "https://github.com/troothllc/trooth-semanto-worker/issues"
43
- Changelog = "https://github.com/troothllc/trooth-semanto-worker/releases"
41
+ Source = "https://github.com/troothllc/semanto/tree/main/verifier/python"
42
+ Issues = "https://github.com/troothllc/semanto/issues"
43
+ Changelog = "https://github.com/troothllc/semanto/releases"
44
44
 
45
45
  [project.scripts]
46
46
  semanto-verify = "trooth_semanto_verify.cli:main"
@@ -5,10 +5,14 @@
5
5
  from trooth import SemantoClient
6
6
  r = SemantoClient().read("https://acme.com/pricing")
7
7
 
8
+ from trooth import SemantoSource
9
+ a = SemantoSource("acme.com").answer(question="What is your phone number?")
10
+
8
11
  The checks themselves live in trooth_semanto_verify (the same functions and reason codes as the JavaScript and Go
9
12
  kits). Framework integrations are optional extras: trooth[langchain], trooth[llama-index], trooth[openai-agents].
10
13
  """
11
14
  from trooth_semanto_verify.client import SemantoClient
15
+ from trooth_semanto_verify.source import SemantoSource
12
16
 
13
- __version__ = "0.1.0"
14
- __all__ = ["SemantoClient", "__version__"]
17
+ __version__ = "0.2.0"
18
+ __all__ = ["SemantoClient", "SemantoSource", "__version__"]
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trooth
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Read a company's Semanto signed copy and check it: signatures, keys, DNS binding, page proofs and the public log.
5
5
  Author: Trooth, LLC
6
6
  License-Expression: Apache-2.0
7
7
  Project-URL: Homepage, https://trooth.co/docs/semanto
8
8
  Project-URL: Documentation, https://trooth.co/docs/semanto
9
- Project-URL: Source, https://github.com/troothllc/trooth-semanto-worker/tree/main/verifier/python
10
- Project-URL: Issues, https://github.com/troothllc/trooth-semanto-worker/issues
11
- Project-URL: Changelog, https://github.com/troothllc/trooth-semanto-worker/releases
9
+ Project-URL: Source, https://github.com/troothllc/semanto/tree/main/verifier/python
10
+ Project-URL: Issues, https://github.com/troothllc/semanto/issues
11
+ Project-URL: Changelog, https://github.com/troothllc/semanto/releases
12
12
  Keywords: semanto,trooth,signatures,rfc9421,web-bot-auth,transparency-log,ai-agents
13
13
  Classifier: Development Status :: 4 - Beta
14
14
  Classifier: Intended Audience :: Developers
@@ -19,7 +19,7 @@ Classifier: Topic :: Security :: Cryptography
19
19
  Requires-Python: >=3.11
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
- Requires-Dist: cryptography>=41
22
+ Requires-Dist: cryptography>=50.0.2
23
23
  Provides-Extra: langchain
24
24
  Requires-Dist: langchain-core>=0.3; extra == "langchain"
25
25
  Provides-Extra: llama-index
@@ -75,7 +75,7 @@ Canonical JSON here supports integers exactly. A non-integer float raises `Canon
75
75
 
76
76
  ## Tests
77
77
 
78
- `python3 -m pytest` runs every shared vector in `verifier/test-vectors/v1` (positive and negative), the RFC 6962 known roots, the RFC 8785 cases, the state rules and the command line.
78
+ `python3 -m pytest` runs every shared vector in `verifier/test-vectors/v1` (positive and negative), the answer cases in `verifier/test-vectors/answer-v1`, the source's refresh and offline behavior, the RFC 6962 known roots, the RFC 8785 cases, the state rules and the command line.
79
79
 
80
80
  ## SemantoClient
81
81
 
@@ -86,3 +86,14 @@ print(r["ok"], r["dns"], r["reason"])
86
86
  ```
87
87
 
88
88
  It fetches with the standard library (pass `fetch=` to use your own), checks the response signature and the domain's `_trooth-keys` TXT record over DNS over HTTPS, and keeps key sets and DNS answers five minutes.
89
+
90
+ ## SemantoSource
91
+
92
+ ```python
93
+ from trooth import SemantoSource
94
+ acme = SemantoSource("acme.com", on_event=lambda e: print(e["event"], e["detail"]))
95
+ a = acme.answer(question="How much is the Starter plan?")
96
+ print(a["status"], a["text"])
97
+ ```
98
+
99
+ It loads `/.well-known/trooth.semanto` and the company's keys from the company's site, checks the file once for each new body, reads the site again at most every 15 minutes, and answers fact IDs and plain questions in process with the same matcher as Trooth's service (`trooth_semanto_verify.answer`, compared case by case with `verifier/test-vectors/answer-v1`). When Trooth's API cannot be reached it keeps answering from the last version that checked out and logs `trooth_api_unreachable`; a new version that does not check out is refused and the last good one stays in use. A fact marked changed since signing or expired returns its mark and no value.
@@ -14,10 +14,12 @@ trooth/integrations/llama_index.py
14
14
  trooth/integrations/openai_agents.py
15
15
  trooth_semanto_verify/__init__.py
16
16
  trooth_semanto_verify/__main__.py
17
+ trooth_semanto_verify/answer.py
17
18
  trooth_semanto_verify/canonical.py
18
19
  trooth_semanto_verify/cli.py
19
20
  trooth_semanto_verify/client.py
20
21
  trooth_semanto_verify/httpsig.py
21
22
  trooth_semanto_verify/jws.py
22
23
  trooth_semanto_verify/merkle.py
24
+ trooth_semanto_verify/source.py
23
25
  trooth_semanto_verify/verify.py
@@ -1,4 +1,4 @@
1
- cryptography>=41
1
+ cryptography>=50.0.2
2
2
 
3
3
  [langchain]
4
4
  langchain-core>=0.3
@@ -91,4 +91,4 @@ __all__ = [
91
91
  "version_id",
92
92
  ]
93
93
 
94
- __version__ = "0.1.0"
94
+ __version__ = "0.2.0"
@@ -0,0 +1,172 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Trooth, LLC
3
+ """The answer matcher, run in process on a company's signed copy.
4
+
5
+ A fact ID or a plain question goes in; out comes the signed fact word for word with its typed value, conditions, date
6
+ and check link, or the candidates when more than one fact fits, or the not-published reply when none does. No model and
7
+ no generated wording: the same question against the same version gives the same answer as Trooth's service. MATCHER["id"]
8
+ names the matcher and equals the `matcher` field of the service's answers; test-vectors/answer-v1 holds the service's
9
+ answers to compare. The steps, in order: fact_id, question_map, key_terms, similarity.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ import unicodedata
16
+ from datetime import datetime, timezone
17
+ from typing import Any, Dict, Iterable, List, Optional
18
+
19
+ MATCHER: Dict[str, Any] = json.loads('{"id":"matcher-1-a75202e1","order":["fact_id","question_map","key_terms","similarity"],"SIMILARITY_THRESHOLD":0.34,"SIMILARITY_MARGIN":0.05,"KEY_TERMS":[["^contact\\\\.phone$|\\\\.telephone$",["phone","call","telephone","number"]],["^contact\\\\.(support_)?email$",["email","e-mail","mail"]],["^contact\\\\.address",["address","located","location","office","headquarters"]],["^hours\\\\.",["open","hours","close","opening","closing"]],["price|pricing|offer",["price","cost","how much","plan","pricing","fee"]],["^identity\\\\.founding_date$",["founded","started","established","since"]],["^identity\\\\.legal_name$",["legal name","legal entity","company name","registered"]],["refund|return",["refund","return","money back"]],["shipping|delivery",["shipping","delivery","ship"]]],"catalog":[["security.mfa",["multi-factor","multifactor","two-factor","2FA","MFA"]],["security.mfa_methods",["authenticator","TOTP","passkey","SMS","security key","WebAuthn","FIDO"]],["security.encryption_at_rest",["encrypted at rest","encryption at rest","at rest"]],["security.encryption_in_transit",["in transit","TLS"]],["security.soc2",["SOC 2","SOC2","SOC-2"]],["security.iso27001",["ISO 27001","ISO/IEC 27001","ISO27001"]],["security.data_residency",["data residency","stored in","hosted in","data center","region"]],["security.page_url",["security page","trust center","trust page"]],["privacy.subprocessors",["subprocessor","sub-processor"]],["privacy.data_deletion_days",["delet","purge","retention"]],["terms.uptime_commitment",["uptime","availability","SLA"]],["terms.sla_credits",["service credit","SLA credit","credits"]],["terms.minimum_term",["minimum term","contract term","commitment","annual contract","month-to-month"]],["terms.cancellation_notice",["cancel","notice"]],["support.hours",["support hours","support is available","support team","support"]],["support.channels",["email","chat","phone","ticket","portal"]],["commercial.pricing_model",["per seat","per user","usage-based","flat","tiered","pricing model","priced"]],["commercial.starting_price",["starts at","starting at","from","per month","per user","per seat"]],["commercial.free_trial",["free trial","trial"]],["commercial.returns_window_days",["return","refund"]],["commercial.authorized_sellers",["buy","sold","retailer","available at","stockist"]],["commercial.shipping_free_over",["free shipping","shipping"]],["commercial.ships_international",["international","worldwide","outside the"]],["products.{slug}.price",["price","cost","costs","priced","retails"]],["products.{slug}.sale_price",["sale","discount","off","deal"]],["products.{slug}.availability",["in stock","out of stock","available","sold out","backorder","pre-order","preorder"]],["products.{slug}.sizes",["size","sizes"]],["products.{slug}.variants",["color","colour","variant","option","finish"]],["products.{slug}.variant_differences",["difference","differs","compared","versus","vs"]],["products.{slug}.materials",["made of","made from","material","ingredient","contains"]],["products.{slug}.warranty",["warranty","guarantee"]],["products.{slug}.safety_warnings",["warning","caution","not suitable","choking","hazard"]],["products.{slug}.safety_standards",["standard","tested to","complies","compliant","EN ","ASTM","CPSC","UL "]],["products.{slug}.dimensions",["weigh","weight","dimension","measures","cm","inches","kg","lb"]],["local.hours",["open","hours","opening"]],["local.hours_sunday",["sunday"]],["local.holiday_hours",["holiday","christmas","thanksgiving","closed on"]],["local.address",["located","address","find us","at "]],["local.phone",["phone","call","telephone","reach"]],["local.email",["email","e-mail","@"]],["local.services",["service","offer","provide"]],["local.service_price",["cost","price","charge","fee"]],["local.parking",["parking","park","garage","lot"]],["local.accessibility",["wheelchair","accessible","step-free","ramp","elevator"]],["local.appointment_policy",["appointment","book","reservation"]],["local.walk_ins",["walk-in","walk in","walk-ins","without an appointment"]],["local.payment_methods",["accept","payment","cash","card","Apple Pay","Google Pay"]],["local.delivery",["deliver","delivery","pickup","pick-up","curbside"]],["local.languages",["language","speak","spoken","Spanish","French","Mandarin"]]]}')
20
+
21
+ MARK_WORDS = {
22
+ "changed_since_signing": "Changed since signing: the page this came from no longer says it the same way. Check the page before quoting a value.",
23
+ "expired": "Expired: the company said this held only until a date that has passed.",
24
+ }
25
+
26
+ _STOP = {"the", "a", "an", "is", "are", "do", "does", "what", "whats", "how", "when", "where", "who", "which", "of", "to", "for", "in", "on", "at", "your", "you", "their", "they", "its", "it", "and", "or", "can", "i", "we", "our", "my", "me", "this", "that", "page", "lists", "gives", "says"}
27
+ _KEY_TERMS = [(re.compile(src), terms) for src, terms in MATCHER["KEY_TERMS"]]
28
+ _ALNUM = re.compile(r"[^\W_]+")
29
+ _NOT_ALNUM = re.compile(r"[\W_]+")
30
+
31
+
32
+ def not_published(org: str, domain: str) -> str:
33
+ return f"{org}'s signed record does not answer this. Ask {org} at {domain}."
34
+
35
+
36
+ def _js_json(v: Any) -> str:
37
+ return json.dumps(v, ensure_ascii=False, separators=(",", ":"))
38
+
39
+
40
+ def matcher_id(m: Optional[Dict[str, Any]] = None) -> str:
41
+ """The matcher's identity, worked out from its data (FNV-1a over the UTF-16 code units of its JSON)."""
42
+ m = m or MATCHER
43
+ src = _js_json({"order": m["order"], "SIMILARITY_THRESHOLD": m["SIMILARITY_THRESHOLD"], "SIMILARITY_MARGIN": m["SIMILARITY_MARGIN"], "KEY_TERMS": m["KEY_TERMS"], "catalog": m["catalog"]})
44
+ h = 0x811C9DC5
45
+ units = src.encode("utf-16-le")
46
+ for i in range(0, len(units), 2):
47
+ h = ((h ^ (units[i] | units[i + 1] << 8)) * 0x01000193) & 0xFFFFFFFF
48
+ return f"matcher-1-{h:08x}"
49
+
50
+
51
+ def _lower(s: str) -> str:
52
+ return unicodedata.normalize("NFKC", s.lower())
53
+
54
+
55
+ def _words(s: str) -> set:
56
+ out = set()
57
+ for w in _ALNUM.findall(_lower(s)):
58
+ if len(w) > 1 and w not in _STOP:
59
+ out.add(re.sub(r"s$", "", re.sub(r"(ies)$", "y", w)))
60
+ return out
61
+
62
+
63
+ def _norm(s: str) -> str:
64
+ return _NOT_ALNUM.sub(" ", _lower(s)).strip()
65
+
66
+
67
+ def _parse_time(s: str) -> datetime:
68
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
69
+
70
+
71
+ def fact_marks(file: dict, changed: Iterable[str] = (), now: Optional[datetime] = None) -> Dict[str, str]:
72
+ """The marks in force: "expired" past a fact's valid_to, "changed_since_signing" when the version passed its
73
+ not_after or the monitor found the sentence changed (`changed`: fact IDs for this version)."""
74
+ now = now or datetime.now(timezone.utc)
75
+ today = now.astimezone(timezone.utc).strftime("%Y-%m-%d")
76
+ na = file["header"].get("not_after")
77
+ lapsed = na is not None and _parse_time(na) < now
78
+ ch = set(changed)
79
+ out: Dict[str, str] = {}
80
+ for f in file["facts"]:
81
+ if f.get("valid_to") and f["valid_to"] < today:
82
+ out[f["id"]] = "expired"
83
+ elif lapsed or f["id"] in ch:
84
+ out[f["id"]] = "changed_since_signing"
85
+ return out
86
+
87
+
88
+ def _nz(*vals: Any) -> Any:
89
+ for v in vals:
90
+ if v is not None:
91
+ return v
92
+ return None
93
+
94
+
95
+ def _as_answered(f: dict, mark: Optional[str]) -> dict:
96
+ return {
97
+ "fact_id": f["id"], "key": f["key"], "statement": None if mark else f["statement"], "typed": None if mark else f.get("typed"),
98
+ "conditions": [] if mark else (f.get("conditions") or []), "location": f.get("location"), "valid_from": f.get("valid_from"),
99
+ "valid_to": f.get("valid_to"), "provenance": f["provenance"], "source_url": f["evidence"]["url"], "mark": mark,
100
+ }
101
+
102
+
103
+ def _distinct(facts: List[dict]) -> List[dict]:
104
+ seen: Dict[str, dict] = {}
105
+ for f in facts:
106
+ typed = f.get("typed") or {}
107
+ k = f"{_js_json(_nz(typed.get('value'), f.get('value'), f['statement']))}|{f.get('location') or ''}|{';'.join(f.get('conditions') or [])}"
108
+ seen.setdefault(k, f)
109
+ return list(seen.values())
110
+
111
+
112
+ def _match(file: dict, fact_id: Optional[str], question: Optional[str]):
113
+ facts = file["facts"]
114
+ if fact_id:
115
+ f = next((x for x in facts if x["id"] == fact_id), None)
116
+ return ("fact_id" if f else None), ([f] if f else [])
117
+ q = question or ""
118
+ nq = _norm(q)
119
+ by_id = {f["id"]: f for f in facts}
120
+ for e in file.get("question_map") or []:
121
+ if _norm(e["question"]) == nq and e.get("fact_ids"):
122
+ return "question_map", [by_id[i] for i in e["fact_ids"] if i in by_id]
123
+ lq = f" {nq} "
124
+ for rx, terms in _KEY_TERMS:
125
+ if not any(f" {t} " in lq or f" {t}s " in lq for t in terms):
126
+ continue
127
+ hits = [f for f in facts if rx.search(f["key"])]
128
+ if hits:
129
+ return "key_terms", hits
130
+ for key, terms in MATCHER["catalog"]:
131
+ if not any(f" {_norm(t)} " in lq for t in terms):
132
+ continue
133
+ hits = [f for f in facts if f["key"] == key]
134
+ if hits:
135
+ return "key_terms", hits
136
+ qw = _words(q)
137
+ if not qw:
138
+ return None, []
139
+ ranked = []
140
+ for f in facts:
141
+ fw = _words(f["statement"])
142
+ s = len(qw & fw) / len(qw | fw)
143
+ if s >= MATCHER["SIMILARITY_THRESHOLD"]:
144
+ ranked.append((s, f))
145
+ if not ranked:
146
+ return None, []
147
+ ranked.sort(key=lambda x: -x[0])
148
+ top = ranked[0][0]
149
+ return "similarity", [f for s, f in ranked if top - s <= MATCHER["SIMILARITY_MARGIN"]]
150
+
151
+
152
+ def answer(file: dict, company: dict, fact_id: Optional[str] = None, question: Optional[str] = None, marks: Optional[Dict[str, str]] = None, check_base: str = "https://api.trooth.co/public/semanto", signed_at: Optional[str] = None) -> dict:
153
+ """Answer a fact ID or a plain question from a checked signed copy, as Trooth's service does.
154
+
155
+ company: {"trooth_id", "org", "domain"}; marks: from fact_marks()."""
156
+ marks = marks or {}
157
+ h = file["header"]
158
+ check = f"{check_base.rstrip('/')}/{company['trooth_id']}/versions/{h['version']}"
159
+ base = {"object": "semanto.answer", "matcher": MATCHER["id"], "company": {"trooth_id": company["trooth_id"], "name": company["org"], "domain": company["domain"]},
160
+ "version": h["version"], "version_hash": file["root_hash"], "signed_at": _nz(signed_at, (file.get("witness") or {}).get("at")), "check": check}
161
+ by, facts = _match(file, fact_id, question)
162
+ found = _distinct(facts)
163
+ if not found:
164
+ return {**base, "status": "not_published", "matched_by": None, "facts": [], "text": not_published(company["org"], company["domain"])}
165
+ if len(found) > 1:
166
+ return {**base, "status": "candidates", "matched_by": by, "facts": [_as_answered(f, marks.get(f["id"])) for f in found[:10]],
167
+ "text": f"{len(found)} signed facts of {company['org']} could answer this; none is picked. Ask about one of them by its fact ID."}
168
+ f = found[0]
169
+ mark = marks.get(f["id"])
170
+ lines = [MARK_WORDS[mark]] if mark else [f["statement"], *([f"Conditions: {'; '.join(f['conditions'])}."] if f.get("conditions") else [])]
171
+ lines.append(f"Check: {check}")
172
+ return {**base, "status": "answer", "matched_by": by, "facts": [_as_answered(f, mark)], "text": "\n".join(lines[:3])}
@@ -34,6 +34,8 @@ JWS_TYPES = {
34
34
  "revocation": "https://trooth.co/types/revocation-list",
35
35
  "contentSignals": "https://trooth.co/types/content-signals",
36
36
  "sellerList": "https://trooth.co/types/seller-list",
37
+ "captureRoot": "https://trooth.co/types/capture-root",
38
+ "answerReceipt": "https://trooth.co/types/answer-receipt",
37
39
  }
38
40
 
39
41
 
@@ -0,0 +1,154 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Trooth, LLC
3
+ """SemantoSource: a company's signed copy, loaded from the company's own site and answered from in process.
4
+
5
+ from trooth import SemantoSource
6
+ acme = SemantoSource("acme.com")
7
+ a = acme.answer(question="What is your phone number?")
8
+ if a["status"] == "answer":
9
+ show(a["text"])
10
+
11
+ It reads https://<domain>/.well-known/trooth.semanto and the company's keys from the same site, checks the file
12
+ (signature, witness, roots, window) once for each new body it serves, and answers value questions with the same matcher
13
+ as Trooth's service (answer.py). It reads the site again at most every refresh_seconds (900 at most, so a new version is
14
+ used within 15 minutes), and the marks (changed since signing, expired) with each refresh.
15
+
16
+ Trooth's API is used only for Trooth's witness keys and the monitor's marks. When it cannot be reached the source keeps
17
+ answering from the last version that checked out, keeps the last keys and marks, applies the date marks itself, and logs
18
+ a "trooth_api_unreachable" event. A new version is adopted only when it checks out; until then the last good one stays
19
+ in use and the failure is logged. The standard library does the HTTP; pass `fetch` to use your own.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import threading
25
+ import time
26
+ import urllib.parse
27
+ from datetime import datetime, timezone
28
+ from typing import Any, Callable, Dict, List, Optional
29
+
30
+ from .answer import answer as _answer
31
+ from .answer import fact_marks
32
+ from .client import Fetch, urllib_fetch
33
+ from .verify import verify_semanto
34
+
35
+ TROOTH_API = "https://api.trooth.co"
36
+ MAX_REFRESH_SECONDS = 900
37
+
38
+
39
+ class SemantoSource:
40
+ def __init__(self, domain: str, fetch: Optional[Fetch] = None, api_base: str = TROOTH_API, refresh_seconds: float = MAX_REFRESH_SECONDS,
41
+ now: Optional[Callable[[], float]] = None, on_event: Optional[Callable[[dict], None]] = None):
42
+ d = domain.lower()
43
+ for p in ("https://", "http://"):
44
+ if d.startswith(p):
45
+ d = d[len(p):]
46
+ self.domain = d.split("/")[0]
47
+ self.fetch: Fetch = fetch or urllib_fetch
48
+ self.api_base = api_base.rstrip("/")
49
+ self.refresh_seconds = min(max(refresh_seconds, 1), MAX_REFRESH_SECONDS)
50
+ self.now = now or time.time
51
+ self.on_event = on_event
52
+ self.events: List[dict] = []
53
+ self.file: Optional[dict] = None
54
+ self.company: Optional[dict] = None
55
+ self.trooth_keys: Optional[dict] = None
56
+ self.changed: List[str] = []
57
+ self.checked_at: Optional[float] = None
58
+ self.checks = 0
59
+ self._body: Optional[str] = None
60
+ self._lock = threading.Lock()
61
+
62
+ def _log(self, event: str, detail: str) -> None:
63
+ e = {"at": datetime.fromtimestamp(self.now(), timezone.utc).isoformat().replace("+00:00", "Z"), "event": event, "detail": detail}
64
+ self.events.append(e)
65
+ del self.events[:-200]
66
+ if self.on_event:
67
+ try:
68
+ self.on_event(e)
69
+ except Exception: # noqa: BLE001 (the caller's logger must not stop an answer)
70
+ pass
71
+
72
+ def _json(self, url: str) -> Any:
73
+ status, _, body = self.fetch(url, {"Accept": "application/json"})
74
+ if status != 200:
75
+ raise RuntimeError(f"answered {status}")
76
+ return json.loads(body)
77
+
78
+ def _trooth_key_set(self) -> Optional[dict]:
79
+ try:
80
+ self.trooth_keys = self._json(f"{self.api_base}/public/semanto/.well-known/trooth-keys.json")
81
+ except Exception as e: # noqa: BLE001
82
+ self._log("trooth_api_unreachable", f"Trooth's keys could not be read ({e}); {'the last ones are used' if self.trooth_keys else 'none are known yet'}.")
83
+ return self.trooth_keys
84
+
85
+ def _in_use(self) -> str:
86
+ return f"version {self.file['header']['version']} stays in use" if self.file else "there is no version yet"
87
+
88
+ def refresh(self) -> None:
89
+ """Read the site again, adopt a new version that checks out, and read the marks. Never raises."""
90
+ with self._lock:
91
+ started = self.now()
92
+ try:
93
+ self._load()
94
+ finally:
95
+ self.checked_at = started
96
+
97
+ def _load(self) -> None:
98
+ try:
99
+ file = self._json(f"https://{self.domain}/.well-known/trooth.semanto")
100
+ except Exception as e: # noqa: BLE001
101
+ self._log("site_unreachable", f"https://{self.domain}/.well-known/trooth.semanto could not be read ({e}); {self._in_use()}.")
102
+ return
103
+ body = json.dumps(file, sort_keys=True, separators=(",", ":"))
104
+ if body != self._body:
105
+ try:
106
+ company = self._json(f"https://{self.domain}/.well-known/trooth-keys.json")
107
+ except Exception as e: # noqa: BLE001
108
+ self._log("site_unreachable", f"The company's keys could not be read ({e}); the new version is not adopted.")
109
+ return
110
+ trooth = self._trooth_key_set()
111
+ res = verify_semanto(file, None, {"company": company, "trooth": trooth or {"keys": []}}, now=datetime.fromtimestamp(self.now(), timezone.utc))
112
+ self.checks += 1
113
+ h = file.get("header") or {}
114
+ domains = [str(d).lower() for d in h.get("domains") or []]
115
+ if res.get("ok") and self.domain not in domains:
116
+ self._log("version_refused", f"Version {h.get('version')} is for {', '.join(domains)}, not {self.domain}.")
117
+ elif not res.get("ok"):
118
+ self._log("version_refused", f"Version {h.get('version')} did not check out ({res.get('reason')}); {self._in_use()}.")
119
+ else:
120
+ before = self.file["header"]["version"] if self.file else None
121
+ self.file, self._body = file, body
122
+ self.company = {"trooth_id": h["trooth_id"], "org": h["org"], "domain": self.domain}
123
+ self.changed = []
124
+ self._log("version_adopted", f"Version {h['version']} checked out and is in use." if before is None else f"Version {h['version']} checked out and replaces version {before}.")
125
+ if self.file:
126
+ self._read_marks()
127
+
128
+ def _read_marks(self) -> None:
129
+ tid = urllib.parse.quote(self.file["header"]["trooth_id"], safe="")
130
+ try:
131
+ m = self._json(f"{self.api_base}/public/semanto/{tid}/facts/marks")
132
+ if int(m.get("version", -1)) == int(self.file["header"]["version"]):
133
+ self.changed = [x["fact_id"] for x in m.get("marks") or [] if x.get("mark") == "changed_since_signing"]
134
+ except Exception as e: # noqa: BLE001
135
+ self._log("trooth_api_unreachable", f"The marks could not be read ({e}); the last ones and the date marks are used.")
136
+
137
+ def ensure_fresh(self) -> None:
138
+ """Read the site again when the last read is older than the refresh interval."""
139
+ if self.file is not None and self.checked_at is not None and self.now() - self.checked_at < self.refresh_seconds:
140
+ return
141
+ self.refresh()
142
+
143
+ def marks(self) -> Dict[str, str]:
144
+ """The marks in force now: the monitor's last marks for this version and the date marks."""
145
+ return fact_marks(self.file, self.changed, datetime.fromtimestamp(self.now(), timezone.utc)) if self.file else {}
146
+
147
+ def answer(self, fact_id: Optional[str] = None, question: Optional[str] = None) -> dict:
148
+ """Answer a fact ID or a plain question from the last version that checked out.
149
+ status "not_read" means no version has checked out yet; `events` says why."""
150
+ self.ensure_fresh()
151
+ if not self.file:
152
+ return {"object": "semanto.answer", "status": "not_read", "matched_by": None, "company": {"trooth_id": None, "name": None, "domain": self.domain},
153
+ "version": None, "facts": [], "text": f"No signed record of {self.domain} could be read and checked yet."}
154
+ return _answer(self.file, self.company, fact_id=fact_id, question=question, marks=self.marks(), check_base=f"{self.api_base}/public/semanto")
@@ -3,7 +3,7 @@
3
3
  #
4
4
  # The top-level Semanto checks: a whole file offline, a per-page proof, a
5
5
  # signed response, tree heads, log inclusion and consistency, checkpoints,
6
- # offers, revocation lists, and the state of a version at an instant. Every
6
+ # offers, revocation lists, and the state of a version at a given time. Every
7
7
  # function returns plain dicts: {"ok", "reason", "checks": [{"name", "ok",
8
8
  # "detail"}]} so a CLI prints exactly what was checked, in plain words. Nothing
9
9
  # here produces a number or a verdict about a company.
@@ -212,7 +212,7 @@ def _as_bytes(b: Union[bytes, bytearray, str]) -> bytes:
212
212
 
213
213
 
214
214
  def verify_semanto(file: dict, page_bytes_by_url: PageBytes = None, keys: Any = None, now: Now = None, revocations: Optional[dict] = None, latest: Optional[int] = None) -> dict:
215
- """Verify a whole Semanto file offline. See docs/spec/semanto-file.md.
215
+ """Verify a whole Semanto file offline. The format is described at https://trooth.co/docs/semanto.
216
216
 
217
217
  keys: {"company": <JWK Set>, "trooth": <JWK Set>}. The company signature resolves only against the company's set,
218
218
  the witness statement only against Trooth's. A plain JWK Set is the company's, and then no witness checks out.
@@ -400,7 +400,15 @@ def verify_response(status: int, headers: Dict[str, Any], body: Union[bytes, str
400
400
  dg = verify_content_digest(header_get(headers, "content-digest"), b)
401
401
  c.add("content_digest", dg, "Content-Digest matches the body." if dg else "Content-Digest does not match the body, or is missing.", "content_digest_mismatch")
402
402
  v = verify_message({"kind": "response", "status": status, "headers": headers}, role_resolver(keys, role), required=required or SEMANTO_RESPONSE_COMPONENTS, tag=tag, now=now_sec, max_age_seconds=max_age_seconds)
403
- c.add("signature", v["ok"], f"The response is signed by the {who} key {v['keyid']} with tag {v['params'].get('tag')}, covering {', '.join(v['components'])}." if v["ok"] else f"The response signature did not check out against the {who} keys: {v['reason']}.", None if v["ok"] else v["reason"])
403
+ # a cached copy past its signature's lifetime keeps reason "expired" and says in state whether the signature
404
+ # checked out while it was valid.
405
+ stale = False
406
+ if not v["ok"] and v.get("reason") == "expired":
407
+ exps = [e["params"].get("expires") for e in parse_signature_input(header_get(headers, "signature-input") or "").values() if isinstance(e["params"].get("expires"), int)]
408
+ if exps:
409
+ again = verify_message({"kind": "response", "status": status, "headers": headers}, role_resolver(keys, role), required=required or SEMANTO_RESPONSE_COMPONENTS, tag=tag, now=max(exps) - 1)
410
+ stale = bool(again["ok"])
411
+ c.add("signature", v["ok"], (f"The response is signed by the {who} key {v['keyid']} with tag {v['params'].get('tag')}, covering {', '.join(v['components'])}." + "") if v["ok"] else ("The response signature checked out while it was valid and its lifetime has ended: a cached copy, valid with an expired header signature." if stale else None) or f"The response signature did not check out against the {who} keys: {v['reason']}.", None if v["ok"] else v["reason"])
404
412
  if v["ok"]:
405
413
  for n in v.get("notes", []):
406
414
  c.note(f"Response signature: {n}.")
@@ -413,7 +421,8 @@ def verify_response(status: int, headers: Dict[str, Any], body: Union[bytes, str
413
421
  page_hash = header_get(headers, "trooth-page-hash")
414
422
  d = digest(b)
415
423
  c.add("page_hash", page_hash == d, f"Trooth-Page-Hash is the digest of the body: {d}" if page_hash == d else (f"Trooth-Page-Hash {page_hash} is not the digest of the body ({d})." if page_hash else "The response carries no Trooth-Page-Hash."), "page_hash_mismatch")
416
- return c.result(keyid=v.get("keyid") if v["ok"] else None, params=v.get("params") if v["ok"] else None, signed_by=role, version=header_get(headers, "trooth-semanto-version"), page_hash=page_hash, signed_page_hash=header_get(headers, "trooth-signed-page-hash"))
424
+ extra = {"state": "valid_header_signature_expired"} if stale else {}
425
+ return c.result(keyid=v.get("keyid") if v["ok"] else None, params=v.get("params") if v["ok"] else None, signed_by=role, version=header_get(headers, "trooth-semanto-version"), page_hash=page_hash, signed_page_hash=header_get(headers, "trooth-signed-page-hash"), **extra)
417
426
 
418
427
 
419
428
  def verify_web_bot_auth_request(method: str, url: str, headers: Dict[str, Any], keys: Any = None, now: Now = None, max_age_seconds: Optional[int] = None) -> dict:
File without changes