@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 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
- # Temporary Holding Version
1
+ # @living-agents/reference
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }
@@ -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
+ }