@living-agents/reference 0.0.0-stage → 0.4.10
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.
- package/LICENSE +202 -0
- package/README.md +20 -2
- package/package.json +26 -4
- package/src/algebra.js +252 -0
- package/src/crypto-util.js +148 -0
- package/src/index.js +8 -0
- package/src/jws.js +65 -0
- package/src/merkle-log.js +78 -0
- package/src/microcore.js +125 -0
- package/src/rekor.js +133 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/README.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @living-agents/reference
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Reference implementation of the **Living Agents Protocol (LAP)** — the existence layer for always-on AI agents. Implements LIP-1 (Agent Passport), LIP-3 (Scope Algebra v0.2), and LIP-4 (Micro-Core).
|
|
4
|
+
|
|
5
|
+
**Zero dependencies.** Node ≥ 20 (native Ed25519). Run the suite:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
node --test test/
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The tests verify the deterministic cross-language test vectors in `../output/lip/test-vectors/vectors.json` (generated by a Python script; canonical JSON must match byte-for-byte across languages) plus the adversarial scope-algebra cases from LIP-3 §8:
|
|
12
|
+
|
|
13
|
+
- passport JWS verification, tamper rejection, wrong-signer rejection, self-ownership rejection path
|
|
14
|
+
- did:key ↔ raw Ed25519 public key round-trip against published hex
|
|
15
|
+
- genesis hash chain reproduction
|
|
16
|
+
- Micro-Core: audience binding, request-signature body+passport binding, act/res/cap invocation checks, signed receipt verification
|
|
17
|
+
- algebra: sub-verb escalation reject (registry DAG), path prefix-escape reject, window-inversion reject, proportional subdivision accept, cross-currency reject, deny-bypass reject (effective sets), sibling budget-replication reject (conservation), depth/decay monotonicity, NFC/case normalization
|
|
18
|
+
|
|
19
|
+
Modules (`src/`): `crypto-util.js` (b64url, base58, did:key, JCS subset, normalization), `jws.js` (EdDSA compact JWS, passport verification), `algebra.js` (LIP-3 v0.2 subset checker — sound-but-incomplete by design, canonical-order deterministic), `microcore.js` (LIP-4 server invariant, idempotency cache, receipts).
|
|
20
|
+
|
|
21
|
+
Status: draft v0. Spec texts: `../output/LAP-founding-document.md` and `../output/lip/`. License: Apache-2.0.
|
package/package.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@living-agents/reference",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.10",
|
|
4
|
+
"description": "Living Agents Protocol (LAP) reference implementation: agent passport (LIP-1), scope algebra (LIP-3), Micro-Core request verification and receipts (LIP-4), RFC 6962 Merkle log, Sigstore Rekor binding. Zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"author": "Athar Nouman <atharnouman@gmail.com>",
|
|
8
|
+
"homepage": "https://atharnouman.github.io/living-agents-protocol/",
|
|
9
|
+
"repository": { "type": "git", "url": "git+https://github.com/atharnouman/living-agents-protocol.git", "directory": "lap-reference" },
|
|
10
|
+
"bugs": { "url": "https://github.com/atharnouman/living-agents-protocol/issues" },
|
|
11
|
+
"keywords": ["ai-agents", "agent-identity", "authorization", "mcp", "a2a", "did", "ed25519", "jws", "rfc9421", "merkle", "sigstore", "protocol"],
|
|
12
|
+
"engines": { "node": ">=20" },
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"main": "./src/index.js",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/index.js",
|
|
17
|
+
"./jws": "./src/jws.js",
|
|
18
|
+
"./algebra": "./src/algebra.js",
|
|
19
|
+
"./microcore": "./src/microcore.js",
|
|
20
|
+
"./crypto-util": "./src/crypto-util.js",
|
|
21
|
+
"./merkle-log": "./src/merkle-log.js",
|
|
22
|
+
"./rekor": "./src/rekor.js",
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"files": ["src/", "README.md", "LICENSE"],
|
|
26
|
+
"publishConfig": { "access": "public" },
|
|
27
|
+
"scripts": { "test": "node --test test/" }
|
|
28
|
+
}
|
package/src/algebra.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// LIP-3 v0.3 — LAP-Core Scope Algebra reference checker.
|
|
2
|
+
// Sound-but-incomplete by design (greedy first-match in canonical order; see LIP-3 §6).
|
|
3
|
+
import { jcs, normalizeUri, normalizeDid } from "./crypto-util.js";
|
|
4
|
+
|
|
5
|
+
const normId = (s) => (/^did:/i.test(s) ? normalizeDid(s) : normalizeUri(s));
|
|
6
|
+
|
|
7
|
+
// Seed Action Registry DAG (LIP-3 §2): only registry-declared edges subsume.
|
|
8
|
+
// String prefixing confers NOTHING: e.g. compute:exec:unconfined is deliberately absent.
|
|
9
|
+
export const ACTION_REGISTRY_V0 = {
|
|
10
|
+
"data:read": ["data:read:public"],
|
|
11
|
+
"data:write": [],
|
|
12
|
+
"finance:pay": ["finance:pay:escrow", "finance:pay:release"],
|
|
13
|
+
"compute:exec": ["compute:exec:sandboxed"],
|
|
14
|
+
"comm:send": [],
|
|
15
|
+
"identity:present": [],
|
|
16
|
+
"governance:revoke": [],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const WINDOW_SECONDS = { utc_hour: 3600, utc_day: 86400 };
|
|
20
|
+
const VALID_WINDOWS = new Set(["tx", "utc_hour", "utc_day", "epoch_total"]);
|
|
21
|
+
const MAX_DEPTH = 16;
|
|
22
|
+
|
|
23
|
+
// LIP-3 §2 (v0.4): closed resource-scheme set. did/urn are identifiers (used in `cp`), not
|
|
24
|
+
// resources; `git` is reserved for the experimental lap-git commit binding. Every other scheme
|
|
25
|
+
// (http, file, gopher, ...) is refused — the algebra never default-allows an unknown scheme.
|
|
26
|
+
const RES_SCHEMES = new Set(["mcp", "a2a", "ap2", "https", "git"]);
|
|
27
|
+
|
|
28
|
+
const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
|
|
29
|
+
const isStringList = (x) => Array.isArray(x) && x.every((s) => typeof s === "string");
|
|
30
|
+
const show = (x) => { try { return jcs(x); } catch { return JSON.stringify(x) ?? String(x); } };
|
|
31
|
+
|
|
32
|
+
export function isRegisteredVerb(act, registry = ACTION_REGISTRY_V0) {
|
|
33
|
+
if (act === "*") return true;
|
|
34
|
+
if (typeof act !== "string") return false;
|
|
35
|
+
if (Object.prototype.hasOwnProperty.call(registry, act)) return true;
|
|
36
|
+
return Object.values(registry).some((children) => children.includes(act));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// LIP-3 §2. Handles both a bare verb string and a verb array (envelope `act[]`),
|
|
40
|
+
// in exact parity with the Python port. Uses value-equality throughout — an array
|
|
41
|
+
// argument must never be compared by reference (round-3 audit F-extra).
|
|
42
|
+
export function dagSubsumes(parentAct, childAct, registry = ACTION_REGISTRY_V0) {
|
|
43
|
+
const pList = Array.isArray(parentAct) ? parentAct : [parentAct];
|
|
44
|
+
const cList = Array.isArray(childAct) ? childAct : [childAct];
|
|
45
|
+
if (![...pList, ...cList].every((a) => isRegisteredVerb(a, registry))) return false;
|
|
46
|
+
if (pList.includes("*")) return true;
|
|
47
|
+
const expand = (verb) => {
|
|
48
|
+
const out = new Set([verb]);
|
|
49
|
+
for (const ch of registry[verb] ?? []) for (const e of expand(ch)) out.add(e);
|
|
50
|
+
return out;
|
|
51
|
+
};
|
|
52
|
+
const allowed = new Set();
|
|
53
|
+
for (const p of pList) for (const e of expand(p)) allowed.add(e);
|
|
54
|
+
return cList.every((c) => allowed.has(c));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseRes(res) {
|
|
58
|
+
if (typeof res !== "string" || res.length === 0) throw new Error("LAP_ERR_RES: resource must be a non-empty string");
|
|
59
|
+
const norm = normalizeUri(res);
|
|
60
|
+
// Closed syntax (v0.3): the algebra defines scheme, authority and path — nothing else.
|
|
61
|
+
// A query, a fragment or an empty interior segment is an unknown construct; never
|
|
62
|
+
// default-allow an unknown construct (LIP-3 §2).
|
|
63
|
+
if (/[?#]/.test(norm)) throw new Error("LAP_ERR_RES: query and fragment are not allowed in resource patterns");
|
|
64
|
+
const m = norm.match(/^([a-z0-9+.-]+):\/\/([^/]+)(\/.*)?$/);
|
|
65
|
+
if (!m) throw new Error(`LAP_ERR_RES: invalid resource URI: ${res}`);
|
|
66
|
+
const [, scheme, host, path = ""] = m;
|
|
67
|
+
if (!RES_SCHEMES.has(scheme)) throw new Error(`LAP_ERR_RES: scheme not in the closed set: ${scheme}`); // F3
|
|
68
|
+
if (path.includes("//")) throw new Error("LAP_ERR_RES: empty path segment");
|
|
69
|
+
// F2/F10 (v0.4.10): a resource pattern is decoded, canonical text. Percent-encoding and
|
|
70
|
+
// backslashes are exactly the forms a downstream proxy resolves AFTER authorization
|
|
71
|
+
// (`/safe/..\\admin`, `/safe/%252e%252e/admin`), so a pattern MUST contain neither, nor any
|
|
72
|
+
// control character; with the dot-segment check below this closes the traversal class.
|
|
73
|
+
if (/[\\%]/.test(path)) throw new Error("LAP_ERR_RES: backslashes and percent-encoding are not allowed in resource patterns");
|
|
74
|
+
// eslint-disable-next-line no-control-regex
|
|
75
|
+
if (/[\u0000-\u001f\u007f]/.test(path)) throw new Error("LAP_ERR_RES: control characters are not allowed in resource patterns");
|
|
76
|
+
const segments = path.split("/").filter((s) => s.length > 0);
|
|
77
|
+
if (segments.includes(".") || segments.includes("..")) {
|
|
78
|
+
throw new Error("LAP_ERR_RES: dot-segments not allowed");
|
|
79
|
+
}
|
|
80
|
+
let wildcard = null;
|
|
81
|
+
if (segments.at(-1) === "**") { wildcard = "**"; segments.pop(); }
|
|
82
|
+
else if (segments.at(-1) === "*") { wildcard = "*"; segments.pop(); }
|
|
83
|
+
if (segments.includes("*") || segments.includes("**")) {
|
|
84
|
+
throw new Error("LAP_ERR_RES: wildcards are terminal-only");
|
|
85
|
+
}
|
|
86
|
+
return { scheme, host, segments, wildcard };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function pathSubsumes(parentRes, childRes) {
|
|
90
|
+
const p = parseRes(parentRes);
|
|
91
|
+
const c = parseRes(childRes);
|
|
92
|
+
if (p.scheme !== c.scheme || p.host !== c.host) return false;
|
|
93
|
+
const prefixMatches = p.segments.every((seg, i) => c.segments[i] === seg);
|
|
94
|
+
if (p.wildcard === "**") {
|
|
95
|
+
return c.segments.length >= p.segments.length && prefixMatches;
|
|
96
|
+
}
|
|
97
|
+
if (p.wildcard === "*") {
|
|
98
|
+
if (c.wildcard === "**") return false; // never widen
|
|
99
|
+
if (c.wildcard === "*") return c.segments.length === p.segments.length && prefixMatches; // the same set (v0.3: reflexive)
|
|
100
|
+
return c.segments.length === p.segments.length + 1 && prefixMatches;
|
|
101
|
+
}
|
|
102
|
+
return c.wildcard === null && c.segments.length === p.segments.length && prefixMatches;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const UNIVERSE = Symbol("universe");
|
|
106
|
+
|
|
107
|
+
export function cpIsValid(cp) {
|
|
108
|
+
if (cp === undefined || cp === null) return true;
|
|
109
|
+
if (!isPlainObject(cp)) return false;
|
|
110
|
+
return (cp.allow === undefined || isStringList(cp.allow)) && (cp.deny === undefined || isStringList(cp.deny));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function effectiveSet(cp = {}) {
|
|
114
|
+
const allow = (cp.allow ?? []).map(normId);
|
|
115
|
+
const deny = new Set((cp.deny ?? []).map(normId));
|
|
116
|
+
return { base: allow.length > 0 ? new Set(allow) : UNIVERSE, deny };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function counterpartySubsumes(parentCp, childCp) {
|
|
120
|
+
if (!cpIsValid(parentCp) || !cpIsValid(childCp)) return false;
|
|
121
|
+
const p = effectiveSet(parentCp ?? {});
|
|
122
|
+
const c = effectiveSet(childCp ?? {});
|
|
123
|
+
for (const d of p.deny) if (!c.deny.has(d)) return false;
|
|
124
|
+
if (p.base === UNIVERSE) return true;
|
|
125
|
+
if (c.base === UNIVERSE) return false;
|
|
126
|
+
for (const member of c.base) {
|
|
127
|
+
if (c.deny.has(member)) continue;
|
|
128
|
+
if (!p.base.has(member) || p.deny.has(member)) return false;
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// F8: caps must be non-negative safe integers with max_per_tx <= max_cumulative,
|
|
134
|
+
// a real unit, and a known window. Returns false (never throws) so direct callers
|
|
135
|
+
// of capSubsumes/scopeSubsumes fail closed; verifyEnvelopeAttenuation throws early.
|
|
136
|
+
export function capIsValid(cap) {
|
|
137
|
+
if (cap === undefined || cap === null) return true;
|
|
138
|
+
if (!isPlainObject(cap)) return false;
|
|
139
|
+
for (const f of ["max_per_tx", "max_cumulative"]) {
|
|
140
|
+
const v = cap[f];
|
|
141
|
+
if (!Number.isSafeInteger(v) || v < 0) return false;
|
|
142
|
+
}
|
|
143
|
+
if (cap.max_per_tx > cap.max_cumulative) return false;
|
|
144
|
+
if (typeof cap.unit !== "string" || cap.unit.length === 0) return false;
|
|
145
|
+
if (!VALID_WINDOWS.has(cap.window)) return false;
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// v0.3: window scaling is exact-integer arithmetic. Double arithmetic (value × 3600 / 86400)
|
|
150
|
+
// rounds above ~1e11 and diverged from the Python port by one unit in ~5% of large-value
|
|
151
|
+
// cases — found by the differential fuzzer. Inputs are safe integers (F8), so BigInt is exact.
|
|
152
|
+
const scaleFloor = (value, num, den) => (BigInt(value) * BigInt(num)) / BigInt(den);
|
|
153
|
+
|
|
154
|
+
// LIP-3 §4: two-dimensional cap rule. Fails closed on any invalid cap (F8).
|
|
155
|
+
export function capSubsumes(parentCap, childCap) {
|
|
156
|
+
if (parentCap === undefined || parentCap === null) return true;
|
|
157
|
+
if (childCap === undefined || childCap === null) return false;
|
|
158
|
+
if (!capIsValid(parentCap) || !capIsValid(childCap)) return false;
|
|
159
|
+
if (childCap.unit !== parentCap.unit) return false;
|
|
160
|
+
if (childCap.max_per_tx > parentCap.max_per_tx) return false;
|
|
161
|
+
const pw = parentCap.window;
|
|
162
|
+
const cw = childCap.window;
|
|
163
|
+
if (pw === "tx") {
|
|
164
|
+
// tightened (audit F7): a tx parent admits only a tx child — a differently-based
|
|
165
|
+
// child window would rate-expand the parent's per-transaction budget.
|
|
166
|
+
return cw === "tx" && childCap.max_cumulative <= parentCap.max_cumulative;
|
|
167
|
+
}
|
|
168
|
+
if (pw === "epoch_total") {
|
|
169
|
+
return cw === "epoch_total" && childCap.max_cumulative <= parentCap.max_cumulative;
|
|
170
|
+
}
|
|
171
|
+
if (cw === "tx" || cw === "epoch_total") return false;
|
|
172
|
+
const pSec = WINDOW_SECONDS[pw];
|
|
173
|
+
const cSec = WINDOW_SECONDS[cw];
|
|
174
|
+
if (!pSec || !cSec || cSec > pSec || pSec % cSec !== 0) return false;
|
|
175
|
+
return BigInt(childCap.max_cumulative) <= scaleFloor(parentCap.max_cumulative, cSec, pSec);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// LIP-3 §5 (v0.3): shape validation precedes every comparison. Returns false (never throws)
|
|
179
|
+
// so a direct caller fails closed; verifyEnvelopeAttenuation throws LAP_ERR_SCOPE_SCHEMA.
|
|
180
|
+
// Found by the fuzzer: malformed scopes used to surface TypeErrors from deep inside the
|
|
181
|
+
// comparison, and a missing `act` compared as "grants nothing ⊆ grants nothing" in one port.
|
|
182
|
+
export function scopeIsValid(scope) {
|
|
183
|
+
if (!isPlainObject(scope)) return false;
|
|
184
|
+
if (typeof scope.v !== "string") return false;
|
|
185
|
+
if (!(typeof scope.act === "string" || isStringList(scope.act))) return false;
|
|
186
|
+
if (typeof scope.res !== "string") return false;
|
|
187
|
+
if (!cpIsValid(scope.cp) || !capIsValid(scope.cap)) return false;
|
|
188
|
+
for (const f of ["depth", "decay_max_sec"]) {
|
|
189
|
+
const v = scope[f];
|
|
190
|
+
if (v !== undefined && !(Number.isSafeInteger(v) && v >= 0)) return false;
|
|
191
|
+
}
|
|
192
|
+
if (scope.depth !== undefined && scope.depth > MAX_DEPTH) return false;
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// F9: caps are a conjunct of the subset relation — a direct caller (a policy engine)
|
|
197
|
+
// must never get a cap-blind "true". scopeSubsumes now includes capSubsumes.
|
|
198
|
+
export function scopeSubsumes(parent, child, registry = ACTION_REGISTRY_V0) {
|
|
199
|
+
if (!scopeIsValid(parent) || !scopeIsValid(child)) return false;
|
|
200
|
+
return (
|
|
201
|
+
parent.v === "lap-scope-v0" &&
|
|
202
|
+
child.v === "lap-scope-v0" &&
|
|
203
|
+
dagSubsumes(parent.act, child.act, registry) &&
|
|
204
|
+
pathSubsumes(parent.res, child.res) &&
|
|
205
|
+
counterpartySubsumes(parent.cp, child.cp) &&
|
|
206
|
+
capSubsumes(parent.cap, child.cap) &&
|
|
207
|
+
(parent.depth === undefined || (child.depth !== undefined && child.depth <= parent.depth - 1)) &&
|
|
208
|
+
(parent.decay_max_sec === undefined || (child.decay_max_sec !== undefined && child.decay_max_sec <= parent.decay_max_sec))
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// F7: debit the child's allocation expressed in the PARENT window's units, so a
|
|
213
|
+
// smaller-window child cannot multiply the parent's budget across sub-buckets.
|
|
214
|
+
// A utc_hour child of a utc_day parent debits max_cumulative × 24, not × 1. (BigInt: exact.)
|
|
215
|
+
function windowDebit(parentCap, childCap) {
|
|
216
|
+
const pSec = WINDOW_SECONDS[parentCap.window];
|
|
217
|
+
const cSec = WINDOW_SECONDS[childCap.window];
|
|
218
|
+
if (pSec && cSec) return scaleFloor(childCap.max_cumulative, pSec, cSec);
|
|
219
|
+
return BigInt(childCap.max_cumulative); // same-basis (tx/epoch_total): no scaling
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// LIP-3 §6: envelope-level verification with budget conservation.
|
|
223
|
+
// Deterministic: children and parents evaluated in canonical (JCS) sorted order.
|
|
224
|
+
export function verifyEnvelopeAttenuation(parentScopes, childScopes, registry = ACTION_REGISTRY_V0) {
|
|
225
|
+
if (!Array.isArray(parentScopes) || !Array.isArray(childScopes)) {
|
|
226
|
+
throw new Error("LAP_ERR_SCOPE_SCHEMA: scope sets must be arrays");
|
|
227
|
+
}
|
|
228
|
+
for (const s of [...parentScopes, ...childScopes]) {
|
|
229
|
+
if (!capIsValid(isPlainObject(s) ? s.cap : undefined)) throw new Error(`LAP_ERR_CAP_SCHEMA: invalid cap in ${show(s)}`);
|
|
230
|
+
if (!scopeIsValid(s)) throw new Error(`LAP_ERR_SCOPE_SCHEMA: malformed scope ${show(s)}`);
|
|
231
|
+
}
|
|
232
|
+
const canonical = (arr) => [...arr].sort((a, b) => (jcs(a) < jcs(b) ? -1 : 1));
|
|
233
|
+
const parents = canonical(parentScopes);
|
|
234
|
+
const children = canonical(childScopes);
|
|
235
|
+
const remaining = parents.map((p) => (p.cap ? BigInt(p.cap.max_cumulative) : null));
|
|
236
|
+
for (const c of children) {
|
|
237
|
+
let matched = false;
|
|
238
|
+
for (let i = 0; i < parents.length; i++) {
|
|
239
|
+
const p = parents[i];
|
|
240
|
+
if (!scopeSubsumes(p, c, registry)) continue;
|
|
241
|
+
if (p.cap) {
|
|
242
|
+
const debit = windowDebit(p.cap, c.cap);
|
|
243
|
+
if (debit > remaining[i]) continue;
|
|
244
|
+
remaining[i] -= debit;
|
|
245
|
+
}
|
|
246
|
+
matched = true;
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
if (!matched) return { ok: false, rejected: c };
|
|
250
|
+
}
|
|
251
|
+
return { ok: true };
|
|
252
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
4
|
+
// DER SPKI header for a raw Ed25519 public key (RFC 8410)
|
|
5
|
+
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
6
|
+
|
|
7
|
+
const B64URL_ALPHABET = /^[A-Za-z0-9_-]*$/;
|
|
8
|
+
|
|
9
|
+
// STRICT base64url (RFC 4648 §5, unpadded; §3.5 canonical trailing bits). Lenient decoding lets
|
|
10
|
+
// one signature have many textual forms — 'A'→'B' as the final character of an Ed25519 signature
|
|
11
|
+
// changes padding bits only and still verifies — so anything keyed by the TEXT (passport hashes,
|
|
12
|
+
// dedup, denylists, transparency-log entries) could be evaded by re-encoding. Reject instead.
|
|
13
|
+
export function b64urlDecode(s) {
|
|
14
|
+
if (typeof s !== "string" || !B64URL_ALPHABET.test(s) || s.length % 4 === 1) {
|
|
15
|
+
throw new Error("LAP_ERR_ENCODING: invalid base64url");
|
|
16
|
+
}
|
|
17
|
+
const buf = Buffer.from(s, "base64url");
|
|
18
|
+
if (buf.toString("base64url") !== s) throw new Error("LAP_ERR_ENCODING: non-canonical base64url");
|
|
19
|
+
return buf;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function b64urlEncode(buf) {
|
|
23
|
+
return Buffer.from(buf).toString("base64url");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function sha256Hex(data) {
|
|
27
|
+
return createHash("sha256").update(data).digest("hex");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function sha256B64url(data) {
|
|
31
|
+
return b64urlEncode(createHash("sha256").update(data).digest());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function b58Encode(buf) {
|
|
35
|
+
let n = BigInt("0x" + (Buffer.from(buf).toString("hex") || "0"));
|
|
36
|
+
let s = "";
|
|
37
|
+
while (n > 0n) {
|
|
38
|
+
const r = n % 58n;
|
|
39
|
+
n /= 58n;
|
|
40
|
+
s = B58[Number(r)] + s;
|
|
41
|
+
}
|
|
42
|
+
let pad = 0;
|
|
43
|
+
for (const byte of buf) {
|
|
44
|
+
if (byte === 0) pad++;
|
|
45
|
+
else break;
|
|
46
|
+
}
|
|
47
|
+
return "1".repeat(pad) + s;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function b58Decode(s) {
|
|
51
|
+
if (!s || s.length === 0) return Buffer.alloc(0);
|
|
52
|
+
let n = 0n;
|
|
53
|
+
for (const ch of s) {
|
|
54
|
+
const v = B58.indexOf(ch);
|
|
55
|
+
if (v < 0) throw new Error(`invalid base58 character: ${ch}`);
|
|
56
|
+
n = n * 58n + BigInt(v);
|
|
57
|
+
}
|
|
58
|
+
let pad = 0;
|
|
59
|
+
for (const ch of s) {
|
|
60
|
+
if (ch === "1") pad++;
|
|
61
|
+
else break;
|
|
62
|
+
}
|
|
63
|
+
if (n === 0n) return Buffer.alloc(pad);
|
|
64
|
+
let hex = n.toString(16);
|
|
65
|
+
if (hex.length % 2) hex = "0" + hex;
|
|
66
|
+
const out = Buffer.from(hex, "hex");
|
|
67
|
+
return pad ? Buffer.concat([Buffer.alloc(pad), out]) : out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// did:key:z6Mk... -> 32-byte raw Ed25519 public key
|
|
71
|
+
export function didKeyToRawPublicKey(did) {
|
|
72
|
+
if (!did.startsWith("did:key:z")) throw new Error(`unsupported DID method: ${did}`);
|
|
73
|
+
const multibase = did.slice("did:key:z".length);
|
|
74
|
+
// F12: bound input BEFORE base58 decoding — an ed25519 did:key multibase is ~46-48
|
|
75
|
+
// chars; `iss`/`agentDid` are attacker-controlled and decoded pre-auth, so an
|
|
76
|
+
// unbounded string would drive O(n^2) BigInt work (CPU-exhaustion DoS).
|
|
77
|
+
if (multibase.length > 48) throw new Error("LAP_ERR_DID: multibase too long for ed25519 did:key");
|
|
78
|
+
const decoded = b58Decode(multibase);
|
|
79
|
+
if (decoded[0] !== 0xed || decoded[1] !== 0x01) throw new Error("not an ed25519-pub did:key");
|
|
80
|
+
const raw = decoded.subarray(2);
|
|
81
|
+
if (raw.length !== 32) throw new Error(`bad key length: ${raw.length}`);
|
|
82
|
+
return raw;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function publicKeyFromRaw(raw32) {
|
|
86
|
+
return createPublicKey({
|
|
87
|
+
key: Buffer.concat([ED25519_SPKI_PREFIX, raw32]),
|
|
88
|
+
format: "der",
|
|
89
|
+
type: "spki",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function publicKeyFromDid(did) {
|
|
94
|
+
return publicKeyFromRaw(didKeyToRawPublicKey(did));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 32-byte raw Ed25519 public key -> did:key:z6Mk...
|
|
98
|
+
export function didKeyFromRawPublicKey(raw32) {
|
|
99
|
+
return "did:key:z" + b58Encode(Buffer.concat([Buffer.from([0xed, 0x01]), raw32]));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function rawPublicKeyFromKeyObject(publicKey) {
|
|
103
|
+
const der = publicKey.export({ format: "der", type: "spki" });
|
|
104
|
+
return der.subarray(der.length - 32);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function verifyEd25519(publicKey, data, signature) {
|
|
108
|
+
return cryptoVerify(null, Buffer.from(data), publicKey, signature);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// DID-safe normalization: NFC + lowercase the "did:method:" prefix ONLY.
|
|
112
|
+
// The method-specific id is case-sensitive (base58 in did:key) and MUST NOT be
|
|
113
|
+
// case-folded — lowercasing it would corrupt the key. (Round-3 triage note: the
|
|
114
|
+
// reviewer's proposed fix of running DIDs through normalizeUri would do exactly that.)
|
|
115
|
+
export function normalizeDid(did) {
|
|
116
|
+
const s = did.normalize("NFC");
|
|
117
|
+
const m = s.match(/^did:([A-Za-z0-9]+):(.+)$/i); // prefix case-insensitive, id case preserved (parity with the Python port)
|
|
118
|
+
if (!m) return s;
|
|
119
|
+
return "did:" + m[1].toLowerCase() + ":" + m[2];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// LIP-1 §3 string normalization for resource URIs: NFC + lowercase scheme/host.
|
|
123
|
+
// Do NOT use on DIDs — use normalizeDid (host-lowercasing corrupts did:key ids).
|
|
124
|
+
export function normalizeUri(uri) {
|
|
125
|
+
const s = uri.normalize("NFC");
|
|
126
|
+
const m = s.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):(\/\/)?([^/]*)(.*)$/);
|
|
127
|
+
if (!m) return s;
|
|
128
|
+
const [, scheme, slashes = "", host, rest] = m;
|
|
129
|
+
return scheme.toLowerCase() + ":" + slashes + host.toLowerCase() + rest;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// JCS-compatible canonical JSON for the LAP data subset (ASCII strings, integers,
|
|
133
|
+
// objects, arrays, null, booleans). Matches Python json.dumps(sort_keys=True,
|
|
134
|
+
// separators=(",",":")) for this subset.
|
|
135
|
+
export function jcs(value) {
|
|
136
|
+
if (value === null || typeof value === "boolean") return JSON.stringify(value);
|
|
137
|
+
if (typeof value === "number") {
|
|
138
|
+
if (!Number.isInteger(value)) throw new Error("JCS subset: integers only");
|
|
139
|
+
return String(value);
|
|
140
|
+
}
|
|
141
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
142
|
+
if (Array.isArray(value)) return "[" + value.map(jcs).join(",") + "]";
|
|
143
|
+
if (typeof value === "object") {
|
|
144
|
+
const keys = Object.keys(value).sort();
|
|
145
|
+
return "{" + keys.map((k) => JSON.stringify(k) + ":" + jcs(value[k])).join(",") + "}";
|
|
146
|
+
}
|
|
147
|
+
throw new Error(`JCS subset: unsupported type ${typeof value}`);
|
|
148
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Living Agents Protocol — reference implementation, root entry point.
|
|
2
|
+
// Subpath imports remain available: "@living-agents/reference/microcore", ".../algebra", etc.
|
|
3
|
+
export * from "./crypto-util.js";
|
|
4
|
+
export * from "./jws.js";
|
|
5
|
+
export * from "./algebra.js";
|
|
6
|
+
export * from "./microcore.js";
|
|
7
|
+
export * from "./merkle-log.js";
|
|
8
|
+
export * from "./rekor.js";
|
package/src/jws.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { sign as cryptoSign } from "node:crypto";
|
|
2
|
+
import { b64urlDecode, b64urlEncode, jcs, normalizeDid, publicKeyFromDid, verifyEd25519 } from "./crypto-util.js";
|
|
3
|
+
|
|
4
|
+
// Produce a compact EdDSA JWS over the JCS canonical form of `payload`.
|
|
5
|
+
export function signJws(payload, privateKey, kid, typ = "lap-passport+jwt") {
|
|
6
|
+
const header = { alg: "EdDSA", kid, typ };
|
|
7
|
+
const input = b64urlEncode(Buffer.from(jcs(header))) + "." + b64urlEncode(Buffer.from(jcs(payload)));
|
|
8
|
+
const sig = cryptoSign(null, Buffer.from(input), privateKey);
|
|
9
|
+
return input + "." + b64urlEncode(sig);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const SKEW_SECONDS = 60; // LIP-1 §2 default clock-skew tolerance
|
|
13
|
+
|
|
14
|
+
function parseJwsJson(b64, what) {
|
|
15
|
+
// F7: a valid-base64url-but-invalid-JSON segment must be a typed protocol error, not a
|
|
16
|
+
// raw language SyntaxError leaking out of a verifier.
|
|
17
|
+
try { return JSON.parse(b64urlDecode(b64).toString()); }
|
|
18
|
+
catch (e) { throw new Error(`LAP_ERR_SIG: malformed JWS ${what} (${e.message})`); }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function decodeJws(token) {
|
|
22
|
+
if (typeof token !== "string") throw new Error("LAP_ERR_SIG: token must be a string"); // F8
|
|
23
|
+
const parts = token.split(".");
|
|
24
|
+
if (parts.length !== 3) throw new Error("LAP_ERR_SIG: not a compact JWS");
|
|
25
|
+
const [h, p, s] = parts;
|
|
26
|
+
return {
|
|
27
|
+
header: parseJwsJson(h, "header"),
|
|
28
|
+
payload: parseJwsJson(p, "payload"),
|
|
29
|
+
signature: b64urlDecode(s),
|
|
30
|
+
signingInput: Buffer.from(h + "." + p),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Verify a compact EdDSA JWS whose signer is identified by `signerDid` (did:key).
|
|
35
|
+
export function verifyJws(token, signerDid) {
|
|
36
|
+
const { header, payload, signature, signingInput } = decodeJws(token);
|
|
37
|
+
if (header.alg !== "EdDSA") throw new Error(`LAP_ERR_SIG: alg ${header.alg}`);
|
|
38
|
+
const key = publicKeyFromDid(signerDid);
|
|
39
|
+
if (!verifyEd25519(key, signingInput, signature)) throw new Error("LAP_ERR_SIG: signature invalid");
|
|
40
|
+
return { header, payload };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function checkTimeWindow(payload, now = Math.floor(Date.now() / 1000)) {
|
|
44
|
+
if (typeof payload.iat !== "number") throw new Error("LAP_ERR_EXPIRED: iat missing");
|
|
45
|
+
if (typeof payload.exp !== "number") throw new Error("LAP_ERR_EXPIRED: exp missing");
|
|
46
|
+
if (now < payload.iat - SKEW_SECONDS || now > payload.exp + SKEW_SECONDS) {
|
|
47
|
+
throw new Error("LAP_ERR_EXPIRED");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// LIP-1 §6 passport verification (the vector-testable subset: steps 1-5 of nine;
|
|
52
|
+
// proof-class credentials, revocation endpoints, and registration proofs need
|
|
53
|
+
// network context and live in the higher-level verifier).
|
|
54
|
+
export function verifyPassport(token, { now } = {}) {
|
|
55
|
+
const { header, payload } = decodeJws(token);
|
|
56
|
+
if (payload.v !== "lip1-v0") throw new Error("LAP_ERR_VERSION");
|
|
57
|
+
if (header.alg !== "EdDSA") throw new Error(`LAP_ERR_SIG: alg ${header.alg}`);
|
|
58
|
+
const principalDid = payload.principal?.id ? normalizeDid(payload.principal.id) : null;
|
|
59
|
+
const agentDid = payload.id ? normalizeDid(payload.id) : null;
|
|
60
|
+
if (!principalDid) throw new Error("LAP_ERR_SELF_OWNED: principal missing");
|
|
61
|
+
if (principalDid === agentDid) throw new Error("LAP_ERR_SELF_OWNED");
|
|
62
|
+
verifyJws(token, principalDid);
|
|
63
|
+
checkTimeWindow(payload, now);
|
|
64
|
+
return payload;
|
|
65
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Minimal append-only Merkle transparency log (RFC 6962 hashing convention) —
|
|
2
|
+
// the local building block of a LAP Agent Transparency Log (SCITT-profile, spec §14.5/§19).
|
|
3
|
+
// Plus salted blinded principal commitments (GDPR rule, spec §14.5): the salt stays
|
|
4
|
+
// off-log with the principal; erasure = salt destruction.
|
|
5
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
const h = (...bufs) => {
|
|
8
|
+
const c = createHash("sha256");
|
|
9
|
+
for (const b of bufs) c.update(b);
|
|
10
|
+
return c.digest();
|
|
11
|
+
};
|
|
12
|
+
const LEAF = Buffer.from([0x00]);
|
|
13
|
+
const NODE = Buffer.from([0x01]);
|
|
14
|
+
|
|
15
|
+
export function leafHash(dataBuf) {
|
|
16
|
+
return h(LEAF, dataBuf);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function saltedCommitment(principalDid, saltHex = randomBytes(16).toString("hex")) {
|
|
20
|
+
if (saltHex.length < 32) throw new Error("salt must be >=128 bits");
|
|
21
|
+
const commitment = h(Buffer.from(saltHex, "hex"), Buffer.from(principalDid)).toString("hex");
|
|
22
|
+
return { commitment, saltHex }; // saltHex is held OFF-log by the principal
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function verifyCommitment(commitmentHex, saltHex, principalDid) {
|
|
26
|
+
const a = Buffer.from(saltedCommitment(principalDid, saltHex).commitment, "hex");
|
|
27
|
+
const b = Buffer.from(commitmentHex, "hex");
|
|
28
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class MerkleLog {
|
|
32
|
+
constructor(name) {
|
|
33
|
+
this.name = name;
|
|
34
|
+
this.leaves = []; // leaf hashes (Buffers)
|
|
35
|
+
}
|
|
36
|
+
get size() { return this.leaves.length; }
|
|
37
|
+
|
|
38
|
+
append(dataBuf) {
|
|
39
|
+
this.leaves.push(leafHash(dataBuf));
|
|
40
|
+
return this.size - 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// RFC 6962 Merkle Tree Hash over leaves[lo, hi)
|
|
44
|
+
#mth(lo, hi) {
|
|
45
|
+
const n = hi - lo;
|
|
46
|
+
if (n === 0) return h();
|
|
47
|
+
if (n === 1) return this.leaves[lo];
|
|
48
|
+
let k = 1;
|
|
49
|
+
while (k * 2 < n) k *= 2;
|
|
50
|
+
return h(NODE, this.#mth(lo, lo + k), this.#mth(lo + k, hi));
|
|
51
|
+
}
|
|
52
|
+
root() { return this.#mth(0, this.size).toString("hex"); }
|
|
53
|
+
|
|
54
|
+
// RFC 6962 audit path for leaf at index m within leaves[lo, hi)
|
|
55
|
+
#path(m, lo, hi) {
|
|
56
|
+
const n = hi - lo;
|
|
57
|
+
if (n <= 1) return [];
|
|
58
|
+
let k = 1;
|
|
59
|
+
while (k * 2 < n) k *= 2;
|
|
60
|
+
if (m - lo < k) {
|
|
61
|
+
return [...this.#path(m, lo, lo + k), { side: "right", hash: this.#mth(lo + k, hi).toString("hex") }];
|
|
62
|
+
}
|
|
63
|
+
return [...this.#path(m, lo + k, hi), { side: "left", hash: this.#mth(lo, lo + k).toString("hex") }];
|
|
64
|
+
}
|
|
65
|
+
inclusionProof(index) {
|
|
66
|
+
if (index < 0 || index >= this.size) throw new Error("index out of range");
|
|
67
|
+
return { log: this.name, index, size: this.size, root: this.root(), path: this.#path(index, 0, this.size) };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function verifyInclusion(dataBuf, proof) {
|
|
72
|
+
let cur = leafHash(dataBuf);
|
|
73
|
+
for (const step of proof.path) {
|
|
74
|
+
const sib = Buffer.from(step.hash, "hex");
|
|
75
|
+
cur = step.side === "left" ? h(NODE, sib, cur) : h(NODE, cur, sib);
|
|
76
|
+
}
|
|
77
|
+
return cur.toString("hex") === proof.root;
|
|
78
|
+
}
|
package/src/microcore.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// LIP-4 — Micro-Core server-side verification invariant (§2), transport-agnostic core.
|
|
2
|
+
import { timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { publicKeyFromDid, verifyEd25519, b64urlDecode, sha256Hex, sha256B64url, normalizeUri, normalizeDid } from "./crypto-util.js";
|
|
4
|
+
import { decodeJws, verifyJws, checkTimeWindow } from "./jws.js";
|
|
5
|
+
import { pathSubsumes } from "./algebra.js";
|
|
6
|
+
|
|
7
|
+
const PROOF_CLASS_ORDER = ["self-asserted", "domain-validated", "org-validated", "gov-validated"];
|
|
8
|
+
|
|
9
|
+
// Step 1: verify the principal-signed passport JWT, audience, and time window.
|
|
10
|
+
// F4: `expectedAud` is MANDATORY — a missing audience is fail-open cross-server replay.
|
|
11
|
+
// F1: `allowedIssuers` / `minProofClass` let a server refuse self-asserted or unknown
|
|
12
|
+
// principals. A valid passport proves identity + envelope integrity; it is NOT by itself
|
|
13
|
+
// authority over a server-owned resource — the server MUST apply its own trust policy.
|
|
14
|
+
export function verifyMicroCorePassport(passportJwt, { expectedAud, allowedIssuers, minProofClass, now } = {}) {
|
|
15
|
+
if (!expectedAud) throw new Error("LAP_ERR_AUDIENCE: expectedAud (this server's identity) is required");
|
|
16
|
+
const iss = decodeJws(passportJwt).payload.iss; // F7: typed LAP_ERR_SIG on a malformed token, never a raw SyntaxError
|
|
17
|
+
if (!iss) throw new Error("LAP_ERR_PASSPORT: iss missing");
|
|
18
|
+
const { payload } = verifyJws(passportJwt, iss); // proves the presenter controls iss's key
|
|
19
|
+
if (!payload.aud) throw new Error("LAP_ERR_AUDIENCE: aud missing");
|
|
20
|
+
if (normalizeUri(payload.aud) !== normalizeUri(expectedAud)) throw new Error("LAP_ERR_AUDIENCE");
|
|
21
|
+
checkTimeWindow(payload, now);
|
|
22
|
+
if (!payload.lap?.envelope) throw new Error("LAP_ERR_PASSPORT_SIG: envelope missing");
|
|
23
|
+
if (allowedIssuers && !allowedIssuers.map(normalizeDid).includes(normalizeDid(iss))) {
|
|
24
|
+
throw new Error("LAP_ERR_ISSUER: issuer not in server allow-list");
|
|
25
|
+
}
|
|
26
|
+
if (minProofClass) {
|
|
27
|
+
const have = PROOF_CLASS_ORDER.indexOf(payload.lap.proof_class);
|
|
28
|
+
if (have < 0 || have < PROOF_CLASS_ORDER.indexOf(minProofClass)) {
|
|
29
|
+
throw new Error("LAP_ERR_PROOF_CLASS: below required floor");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return payload;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Step 2: verify the AGENT's request signature. F2: the verification key is derived
|
|
36
|
+
// from the verified passport's `sub` (holder-of-key) — never from a caller-supplied
|
|
37
|
+
// DID, or a captured passport plus any attacker keypair would pass. F3: the mandatory
|
|
38
|
+
// RFC 9421 covered components must be present, and are compared to the server-observed
|
|
39
|
+
// method/target when those are supplied.
|
|
40
|
+
export function verifyRequestSignature({ passportJwt, sub, signatureBase, signatureB64url, requestBody, expectedMethod, expectedTarget }) {
|
|
41
|
+
if (!sub) throw new Error("LAP_ERR_REQ_SIG: sub (verified passport subject) required");
|
|
42
|
+
const key = publicKeyFromDid(sub);
|
|
43
|
+
if (!verifyEd25519(key, Buffer.from(signatureBase), b64urlDecode(signatureB64url))) {
|
|
44
|
+
throw new Error("LAP_ERR_REQ_SIG");
|
|
45
|
+
}
|
|
46
|
+
const lines = signatureBase.split("\n");
|
|
47
|
+
const findLine = (prefix) => lines.find((l) => l.startsWith(prefix));
|
|
48
|
+
for (const comp of ['"@method":', '"@target-uri":', '"content-digest":', '"lap-passport-hash":', '"@signature-params":']) {
|
|
49
|
+
if (!findLine(comp)) throw new Error(`LAP_ERR_SIG_PARAMS: missing covered component ${comp}`);
|
|
50
|
+
}
|
|
51
|
+
const expectedDigest = `"content-digest": sha-256=:${sha256B64url(requestBody)}:`;
|
|
52
|
+
if (!lines.includes(expectedDigest)) throw new Error("LAP_ERR_DIGEST");
|
|
53
|
+
const expectedPassportLine = `"lap-passport-hash": sha256:${sha256Hex(passportJwt)}`;
|
|
54
|
+
if (!lines.includes(expectedPassportLine)) throw new Error("LAP_ERR_DIGEST: passport hash");
|
|
55
|
+
if (expectedMethod !== undefined && findLine('"@method":') !== `"@method": ${expectedMethod}`) {
|
|
56
|
+
throw new Error("LAP_ERR_METHOD: signed method does not match transport");
|
|
57
|
+
}
|
|
58
|
+
if (expectedTarget !== undefined && findLine('"@target-uri":') !== `"@target-uri": ${expectedTarget}`) {
|
|
59
|
+
throw new Error("LAP_ERR_TARGET: signed target does not match transport");
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Steps 3-4: the invoked operation must match the envelope (registered-verb equality,
|
|
65
|
+
// segment-wise resource match, per-transaction cap in the exact unit).
|
|
66
|
+
export function checkInvocation(envelope, { act, resource, amount, unit }) {
|
|
67
|
+
if (envelope.act.includes("*")) throw new Error("LAP_ERR_ACT: wildcard not allowed in Micro-Core envelopes");
|
|
68
|
+
if (!envelope.act.includes(act)) throw new Error("LAP_ERR_ACT");
|
|
69
|
+
if (!pathSubsumes(envelope.res, resource)) throw new Error("LAP_ERR_RES");
|
|
70
|
+
if (envelope.cap) {
|
|
71
|
+
// F5 parity: a capped tool with an indeterminable amount MUST fail closed.
|
|
72
|
+
if (amount === undefined || amount === null) throw new Error("LAP_ERR_CAP: capped operation requires an amount");
|
|
73
|
+
if (unit !== envelope.cap.unit) throw new Error("LAP_ERR_CAP: unit");
|
|
74
|
+
if (amount > envelope.cap.max_per_tx) throw new Error("LAP_ERR_CAP");
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Step 5 helper: idempotency cache keyed by (agent, key). NUL separator prevents
|
|
80
|
+
// key-splicing collisions. F6: `claim()` reserves atomically within one process,
|
|
81
|
+
// closing the check-then-await-then-store race. NOT distributed — production
|
|
82
|
+
// deployments MUST back this with a shared store (Redis SET NX / DB unique
|
|
83
|
+
// constraint) or a load balancer replays a signed key once per replica.
|
|
84
|
+
export function makeIdempotencyCache() {
|
|
85
|
+
const seen = new Map();
|
|
86
|
+
const k = (agentDid, idempotencyKey) => `${agentDid}\u0000${idempotencyKey}`;
|
|
87
|
+
return {
|
|
88
|
+
// F1 (v0.4.10): three-state atomic reservation. A binary null return could not tell a
|
|
89
|
+
// FRESH reservation from an IN-FLIGHT one, so two concurrent mutating requests both saw
|
|
90
|
+
// "free" and both executed. Callers proceed ONLY on {status:"reserved"}; {status:"in_flight"}
|
|
91
|
+
// is a duplicate to reject (e.g. HTTP 409); {status:"completed"} returns the cached receipt.
|
|
92
|
+
claim(agentDid, idempotencyKey) {
|
|
93
|
+
const key = k(agentDid, idempotencyKey);
|
|
94
|
+
if (!seen.has(key)) { seen.set(key, null); return { status: "reserved" }; }
|
|
95
|
+
const receipt = seen.get(key);
|
|
96
|
+
return receipt === null ? { status: "in_flight" } : { status: "completed", receipt };
|
|
97
|
+
},
|
|
98
|
+
complete(agentDid, idempotencyKey, receipt) {
|
|
99
|
+
seen.set(k(agentDid, idempotencyKey), receipt);
|
|
100
|
+
},
|
|
101
|
+
check(agentDid, idempotencyKey) {
|
|
102
|
+
return seen.get(k(agentDid, idempotencyKey));
|
|
103
|
+
},
|
|
104
|
+
store(agentDid, idempotencyKey, receipt) {
|
|
105
|
+
seen.set(k(agentDid, idempotencyKey), receipt);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// LIP-4 §3: verify a tripartite receipt line against the tool server's key.
|
|
111
|
+
export function verifyReceipt({ receiptBase, receiptSignatureB64url, serverDid, requestBody, responseBody }) {
|
|
112
|
+
const key = publicKeyFromDid(serverDid);
|
|
113
|
+
if (!verifyEd25519(key, Buffer.from(receiptBase), b64urlDecode(receiptSignatureB64url))) {
|
|
114
|
+
throw new Error("LAP_ERR_RECEIPT_SIG");
|
|
115
|
+
}
|
|
116
|
+
const [reqPart, respPart] = [receiptBase.split(":").slice(0, 2).join(":"), receiptBase.split(":").slice(2).join(":")];
|
|
117
|
+
const tse = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
118
|
+
if (requestBody !== undefined && !tse(reqPart, `sha256:${sha256Hex(requestBody)}`)) {
|
|
119
|
+
throw new Error("LAP_ERR_RECEIPT: request hash mismatch");
|
|
120
|
+
}
|
|
121
|
+
if (responseBody !== undefined && !tse(respPart, `sha256:${sha256Hex(responseBody)}`)) {
|
|
122
|
+
throw new Error("LAP_ERR_RECEIPT: response hash mismatch");
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
package/src/rekor.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Sigstore Rekor binding — registers a LAP Genesis Record in the PUBLIC Rekor
|
|
2
|
+
// transparency log and verifies the result with LAP's own RFC 6962 code.
|
|
3
|
+
//
|
|
4
|
+
// This turns "registration is self-attested" (the demo's honest caveat) into
|
|
5
|
+
// "registered in an append-only public log anyone can audit" (spec §14.5 / §19:
|
|
6
|
+
// the Agent Transparency Log pattern, here instantiated on an existing neutral log).
|
|
7
|
+
//
|
|
8
|
+
// What gets logged: the canonical genesis JSON, the principal's Ed25519 signature
|
|
9
|
+
// over it, and the principal's public key (PEM). Rekor verifies the signature before
|
|
10
|
+
// admitting the entry, then returns an inclusion proof against its Merkle root and a
|
|
11
|
+
// Signed Entry Timestamp (SET) under Rekor's own key. We verify all three locally.
|
|
12
|
+
import { createHash, createPublicKey, createVerify, sign, verify } from "node:crypto";
|
|
13
|
+
import { jcs, sha256Hex, didKeyFromRawPublicKey, rawPublicKeyFromKeyObject } from "./crypto-util.js";
|
|
14
|
+
import { leafHash } from "./merkle-log.js";
|
|
15
|
+
|
|
16
|
+
export const REKOR_PUBLIC = "https://rekor.sigstore.dev";
|
|
17
|
+
export const searchUrl = (logIndex) => `https://search.sigstore.dev/?logIndex=${logIndex}`;
|
|
18
|
+
|
|
19
|
+
// ---------- building the entry ----------
|
|
20
|
+
// `rekord` (x509 signature format) carries the full content, so Rekor can verify a
|
|
21
|
+
// pure-Ed25519 signature over it (hashedrekord cannot, since Ed25519 signs messages,
|
|
22
|
+
// not digests).
|
|
23
|
+
export function buildGenesisEntry(genesisRecord, principalPrivateKey) {
|
|
24
|
+
const content = Buffer.from(jcs(genesisRecord));
|
|
25
|
+
const signature = sign(null, content, principalPrivateKey);
|
|
26
|
+
const pem = createPublicKey(principalPrivateKey).export({ type: "spki", format: "pem" });
|
|
27
|
+
return {
|
|
28
|
+
apiVersion: "0.0.1",
|
|
29
|
+
kind: "rekord",
|
|
30
|
+
spec: {
|
|
31
|
+
data: { content: content.toString("base64") },
|
|
32
|
+
signature: {
|
|
33
|
+
format: "x509",
|
|
34
|
+
content: signature.toString("base64"),
|
|
35
|
+
publicKey: { content: Buffer.from(pem).toString("base64") },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------- talking to the log ----------
|
|
42
|
+
const normalize = (map) => { const [uuid, e] = Object.entries(map)[0]; return { uuid, ...e }; };
|
|
43
|
+
|
|
44
|
+
export async function submitEntry(entry, base = REKOR_PUBLIC) {
|
|
45
|
+
const r = await fetch(`${base}/api/v1/log/entries`, {
|
|
46
|
+
method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify(entry),
|
|
47
|
+
});
|
|
48
|
+
if (r.status === 409) { // identical entry already logged: idempotent — fetch it
|
|
49
|
+
const loc = r.headers.get("location") || "";
|
|
50
|
+
return fetchEntry(loc.split("/").pop(), base);
|
|
51
|
+
}
|
|
52
|
+
const text = await r.text();
|
|
53
|
+
if (r.status !== 201) throw new Error(`LAP_ERR_REKOR_SUBMIT: HTTP ${r.status}: ${text.slice(0, 300)}`);
|
|
54
|
+
return normalize(JSON.parse(text));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function fetchEntry(uuidOrIndex, base = REKOR_PUBLIC) {
|
|
58
|
+
const byIndex = /^\d+$/.test(String(uuidOrIndex));
|
|
59
|
+
const url = byIndex ? `${base}/api/v1/log/entries?logIndex=${uuidOrIndex}` : `${base}/api/v1/log/entries/${uuidOrIndex}`;
|
|
60
|
+
const r = await fetch(url, { headers: { accept: "application/json" } });
|
|
61
|
+
if (!r.ok) throw new Error(`LAP_ERR_REKOR_FETCH: HTTP ${r.status}`);
|
|
62
|
+
return normalize(await r.json());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function fetchRekorPublicKey(base = REKOR_PUBLIC) {
|
|
66
|
+
const r = await fetch(`${base}/api/v1/log/publicKey`);
|
|
67
|
+
if (!r.ok) throw new Error(`LAP_ERR_REKOR_KEY: HTTP ${r.status}`);
|
|
68
|
+
return await r.text(); // PEM (ECDSA P-256)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------- RFC 6962 verification, LAP-side ----------
|
|
72
|
+
const nodeHash = (l, r) => createHash("sha256").update(Buffer.from([0x01])).update(l).update(r).digest();
|
|
73
|
+
const bitLen = (x) => (x === 0n ? 0 : x.toString(2).length);
|
|
74
|
+
const popcount = (x) => x.toString(2).split("1").length - 1;
|
|
75
|
+
|
|
76
|
+
// Root reconstruction from a side-less audit path (RFC 6962 §2.1.1). This is the
|
|
77
|
+
// compact "inner + border" formulation used by Trillian and Rekor's own verifier;
|
|
78
|
+
// orientation at each level is derived from (index, size) rather than carried.
|
|
79
|
+
export function rootFromInclusionProof(leaf, index, size, hashes) {
|
|
80
|
+
if (index >= size) throw new Error("LAP_ERR_REKOR_PROOF: index >= treeSize");
|
|
81
|
+
const inner = bitLen(BigInt(index) ^ BigInt(size - 1));
|
|
82
|
+
const border = popcount(BigInt(index) >> BigInt(inner));
|
|
83
|
+
if (hashes.length !== inner + border) throw new Error(`LAP_ERR_REKOR_PROOF: expected ${inner + border} hashes, got ${hashes.length}`);
|
|
84
|
+
let seed = leaf;
|
|
85
|
+
for (let i = 0; i < inner; i++) {
|
|
86
|
+
const h = Buffer.from(hashes[i], "hex");
|
|
87
|
+
seed = ((BigInt(index) >> BigInt(i)) & 1n) === 0n ? nodeHash(seed, h) : nodeHash(h, seed);
|
|
88
|
+
}
|
|
89
|
+
for (let i = inner; i < hashes.length; i++) seed = nodeHash(Buffer.from(hashes[i], "hex"), seed);
|
|
90
|
+
return seed;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Verify a Rekor entry end to end:
|
|
94
|
+
// 1. inclusion — SHA256(0x00||body) chains to the log's root through the audit path;
|
|
95
|
+
// 2. substance — the logged key is the expected principal, the logged content is the
|
|
96
|
+
// expected genesis, and the logged signature verifies (Rekor is not trusted for this);
|
|
97
|
+
// 3. SET — Rekor's ECDSA-P256 signature over {body, integratedTime, logID, logIndex}.
|
|
98
|
+
export function verifyEntry(entry, { expectedPrincipalDid, expectedGenesis, rekorPublicKeyPem } = {}) {
|
|
99
|
+
const body = Buffer.from(entry.body, "base64");
|
|
100
|
+
const leaf = leafHash(body);
|
|
101
|
+
const p = entry.verification?.inclusionProof;
|
|
102
|
+
if (!p) throw new Error("LAP_ERR_REKOR_PROOF: entry carries no inclusion proof (not yet integrated?)");
|
|
103
|
+
const root = rootFromInclusionProof(leaf, p.logIndex, p.treeSize, p.hashes).toString("hex");
|
|
104
|
+
if (root !== p.rootHash) throw new Error("LAP_ERR_REKOR_INCLUSION: reconstructed root does not match the log root");
|
|
105
|
+
|
|
106
|
+
const spec = JSON.parse(body.toString()).spec;
|
|
107
|
+
const key = createPublicKey(Buffer.from(spec.signature.publicKey.content, "base64").toString());
|
|
108
|
+
const principalDid = didKeyFromRawPublicKey(rawPublicKeyFromKeyObject(key));
|
|
109
|
+
if (expectedPrincipalDid && principalDid !== expectedPrincipalDid) throw new Error("LAP_ERR_REKOR_KEY: logged key is not the expected principal");
|
|
110
|
+
// Rekor's canonical body keeps only the content's HASH (spec.data.hash) plus the
|
|
111
|
+
// signature and key — the content itself is not stored. Substance is therefore
|
|
112
|
+
// re-verified against the caller's expected genesis, never taken from the log.
|
|
113
|
+
const loggedHash = spec.data?.hash?.value ?? (spec.data?.content ? sha256Hex(Buffer.from(spec.data.content, "base64")) : null);
|
|
114
|
+
if (!loggedHash) throw new Error("LAP_ERR_REKOR_CONTENT: entry carries neither a data hash nor content");
|
|
115
|
+
let signatureVerified = null;
|
|
116
|
+
if (expectedGenesis) {
|
|
117
|
+
const content = Buffer.from(jcs(expectedGenesis));
|
|
118
|
+
if (sha256Hex(content) !== loggedHash) throw new Error("LAP_ERR_REKOR_CONTENT: logged hash is not the expected genesis record");
|
|
119
|
+
signatureVerified = verify(null, content, key, Buffer.from(spec.signature.content, "base64"));
|
|
120
|
+
if (!signatureVerified) throw new Error("LAP_ERR_REKOR_SIG: logged signature does not verify over the expected genesis");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let setVerified = null;
|
|
124
|
+
if (rekorPublicKeyPem) {
|
|
125
|
+
const payload = jcs({ body: entry.body, integratedTime: entry.integratedTime, logID: entry.logID, logIndex: entry.logIndex });
|
|
126
|
+
setVerified = createVerify("sha256").update(payload).verify(rekorPublicKeyPem, Buffer.from(entry.verification.signedEntryTimestamp, "base64"));
|
|
127
|
+
if (!setVerified) throw new Error("LAP_ERR_REKOR_SET: signed entry timestamp does not verify under Rekor's key");
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
ok: true, logIndex: entry.logIndex, integratedTime: entry.integratedTime, treeSize: p.treeSize,
|
|
131
|
+
leafHash: leaf.toString("hex"), root, principalDid, loggedHash, signatureVerified, setVerified, url: searchUrl(entry.logIndex),
|
|
132
|
+
};
|
|
133
|
+
}
|