endpoint-attest 0.0.0-stage → 0.1.0

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Abdellah Ouadoudi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
1
+ # endpoint-attest
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
+ Time-bounded, owner-requested security attestations for a network endpoint (for example an MCP server). The operator proves they control the endpoint, an independent attester runs some checks, and the outcome is recorded as a signed, content-addressed, expiring attestation that anyone can verify.
4
+
5
+ This is deliberately narrow. It is not a security score, not a reputation system, and the format is built for checks the owner asked for: it has a required field that records how the owner proved control. Whether that proof was real is for the verifier to re-check (see "What this does not do"). Every attestation expires, after at most 366 days.
6
+
7
+ Status: new, first release 0.1.0. Built to be reused by anything that needs "does this endpoint's operator have a recent, verifiable, opt-in check on record", the same way [capacity-attest](../capacity-attest) answers "was this delivery actually made". It reuses capacity-attest's canonical JSON hashing and EIP-191 signing rather than reimplementing them, but it is a separate package: capacity-attest's claim schema is closed by design (see its `ASSET_TYPES` enum), and an endpoint attestation is a different kind of claim about a different kind of thing.
8
+
9
+ ## Why this is a separate package, not a new claim type in capacity-attest
10
+
11
+ capacity-attest answers "did the seller deliver what was paid for". An endpoint attestation answers "did this operator prove they control this endpoint, and did an independent party check it recently". Different subject, different trust model (a distinct attester is required, never the owner), different lifecycle (mandatory expiry). Bolting that onto capacity-attest's closed claim schema would blur both.
12
+
13
+ ## How it works
14
+
15
+ 1. **The owner proves control of the endpoint.** Two ACME-style methods, both stateless (no server-side session to manage): a DNS TXT record at `_endpoint-attest.<host>`, or a file at the RFC 8615 well-known path `/.well-known/endpoint-attest.txt`. The token to publish is an HMAC of the endpoint, the owner's address and a shared secret, so it is bound to that exact endpoint and that owner address. It cannot be reused on a different endpoint or claimed under a different owner address. What it proves is control of the host (a TXT record under the host, or a file at the root of the origin), not of one path on it, and the owner address is a label the requester names: nobody proves they hold that address's key. See "What this does not do".
16
+ 2. **A distinct attester runs checks and signs.** The attester can never be the owner (`verifyAttestation` rejects that as `attester_is_owner`). It records what it actually checked (`checksPerformed` must be non-empty, so "passed" can never be read as a blanket safety claim), an outcome, and a hash of its findings.
17
+ 3. **The attestation is content-addressed and signed.** `computeAttestationId` hashes the canonical JSON of the content (reusing capacity-attest's `canonicalize`), and `signAttestation` produces an EIP-191 signature over that id (reusing capacity-attest's signing primitives). Anyone can recompute the id and the signer independently, without trusting this package's code.
18
+ 4. **The attestation always expires.** `expiresAt` is required, must be after `checkedAt` and at most 366 days after it, and the ownership proof may be at most 30 days older than `checkedAt`. There is no way to construct a non-expiring attestation; `verifyAttestation` treats an expired one as failed by default (`ignoreExpiry` exists only for callers who explicitly want to inspect an expired record).
19
+
20
+ ## Usage
21
+
22
+ ```ts
23
+ import {
24
+ challengeToken, verifyOwnership,
25
+ signAttestation, verifyAttestation, computeAttestationId,
26
+ } from "endpoint-attest";
27
+ // A server that checks endpoints for strangers must pass net-guard's guarded helpers as `deps`: they refuse loopback, private and
28
+ // metadata addresses. (examples/demo.ts uses the plain global fetch on purpose, because its target is a local server.)
29
+ import { guardedFetch, resolveTxtGuarded } from "tokenizen-net-guard";
30
+
31
+ // 1. Attester derives the challenge token for this exact endpoint and owner.
32
+ const token = challengeToken(endpoint, ownerAddress, sharedSecret);
33
+
34
+ // 2. Owner publishes it (DNS TXT or the well-known file), attester verifies control.
35
+ const proof = await verifyOwnership("well-known-file", endpoint, ownerAddress, sharedSecret, {
36
+ fetchImpl: guardedFetch,
37
+ resolveTxtRecords: resolveTxtGuarded,
38
+ });
39
+
40
+ // 3. Attester runs its own checks, then signs the result.
41
+ const { attestationId, signature } = await signAttestation(attesterWallet, {
42
+ endpoint, ownerAddress, attesterAddress: attesterWallet.address,
43
+ ownershipProof: { method: proof.method, token, location: proof.location, verifiedAt: proof.verifiedAt },
44
+ checksPerformed: ["tls-config", "no-open-admin-route", "auth-required-on-write"],
45
+ outcome: "passed",
46
+ findingsHash: sha256HexOfYourFullFindingsReport,
47
+ checkedAt: new Date().toISOString(),
48
+ expiresAt: thirtyDaysFromNow.toISOString(),
49
+ });
50
+
51
+ // 4. Anyone, independently, verifies it.
52
+ const result = verifyAttestation({ ...content, attestationId, signature });
53
+ // result.ok === true, or result.ok === false with a reason: "attestation_id_mismatch", "signature_recovery_failed",
54
+ // "signature_does_not_match_attester", "attester_is_owner" or "expired".
55
+ ```
56
+
57
+ Full working example with a real local HTTP server proving the whole flow, including the mandatory expiry: [examples/demo.ts](examples/demo.ts) (`npx tsx examples/demo.ts`).
58
+
59
+ ## What this does not do
60
+
61
+ - It does not re-check the ownership proof when it verifies an attestation. `verifyAttestation` checks the content id, the signature, that the attester is not the owner, and expiry. Anyone with a key can sign a well-formed attestation that names any endpoint and any owner address, and it will verify. To trust that the owner proved control, re-run `verifyOwnership` with guarded `deps`, or rely on the attester you trust.
62
+ - It does not prove control of a path. Both methods prove control of the host (DNS) or of the root of the origin (the well-known file). On an origin shared by several parties, for example one that hosts every tenant under its own path, someone who can publish a file at the origin root, or who runs another tenant's path on that origin, can pass the check for an endpoint that is not theirs. Use it for endpoints that have their own host.
63
+ - It does not prove that the owner address belongs to the requester. The token is bound to the owner address the requester typed in, but the person who publishes it only shows they control the endpoint. An attestation can therefore name an address whose holder never asked for it. Read `ownerAddress` as "the address this attestation was requested for", not as an identity check.
64
+ - It does not run the checks for you. `checksPerformed` and `findingsHash` are supplied by whatever attester process calls this library; this package only handles ownership proof, signing and verification.
65
+ - It does not publish attestations anywhere. Unlike capacity-attest, there is no built-in EAS/on-chain publishing path here yet. Storage and distribution are left to the caller.
66
+ - It is not a guarantee. An attestation records what a specific attester checked at a specific time. It says nothing about what changed afterward, and `checksPerformed` should always be read literally, not as "this endpoint is safe".
67
+
68
+ ## Status
69
+
70
+ 78 tests passing (`npm test`), clean typecheck and build. No independent, external review of this code has been done; see [capacity-attest's own security notes](../capacity-attest/docs/SECURITY-REVIEW-2026-09-11.md) for how that project handles the same honesty requirement, which this package follows too.
71
+
72
+ ## Verifying your own implementation
73
+
74
+ The full derivation, plus machine readable vectors you can check yourself in any language, lives in
75
+ [docs/OWNERSHIP-CONFORMANCE.md](docs/OWNERSHIP-CONFORMANCE.md) and
76
+ [examples/ownership-challenge-testvector.json](examples/ownership-challenge-testvector.json).
77
+
78
+ The vectors are generated from this implementation and re-checked against it in CI, so they cannot
79
+ drift away from the code. The secret in them is public on purpose: it exists so anyone can reproduce
80
+ the tokens without asking us for anything.
@@ -0,0 +1,4 @@
1
+ export { OWNERSHIP_METHODS, OUTCOMES, ETH_ADDRESS_RE, AttestationContentSchema, EndpointAttestationSchema, OwnershipProofSchema, attestationPreimage, computeAttestationId, isExpired, type OwnershipMethod, type Outcome, type OwnershipProof, type AttestationContent, type EndpointAttestation, } from "./schema.js";
2
+ export { signAttestation, verifyAttestation, type SignedAttestationParts, type VerifyResult, type VerifyReason, type VerifyOptions, } from "./signing.js";
3
+ export { DNS_LABEL, WELL_KNOWN_PATH, ACTIVE_CONSENT_DNS_LABEL, ACTIVE_CONSENT_WELL_KNOWN_PATH, challengeToken, activeTestConsentToken, endpointHost, normalizeEndpoint, proofLocation, consentProofLocation, verifyOwnership, verifyActiveTestConsent, OwnershipError, type OwnershipCheckDeps, type OwnershipResult, } from "./ownership.js";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,iBAAiB,EACjB,QAAQ,EACR,cAAc,EACd,wBAAwB,EACxB,yBAAyB,EACzB,oBAAoB,EACpB,mBAAmB,EACnB,oBAAoB,EACpB,SAAS,EACT,KAAK,eAAe,EACpB,KAAK,OAAO,EACZ,KAAK,cAAc,EACnB,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,GACzB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,KAAK,sBAAsB,EAC3B,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,aAAa,GACnB,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,SAAS,EACT,eAAe,EACf,wBAAwB,EACxB,8BAA8B,EAC9B,cAAc,EACd,sBAAsB,EACtB,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,oBAAoB,EACpB,eAAe,EACf,uBAAuB,EACvB,cAAc,EACd,KAAK,kBAAkB,EACvB,KAAK,eAAe,GACrB,MAAM,gBAAgB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // index.ts — public surface of this package.
2
+ export { OWNERSHIP_METHODS, OUTCOMES, ETH_ADDRESS_RE, AttestationContentSchema, EndpointAttestationSchema, OwnershipProofSchema, attestationPreimage, computeAttestationId, isExpired, } from "./schema.js";
3
+ export { signAttestation, verifyAttestation, } from "./signing.js";
4
+ export { DNS_LABEL, WELL_KNOWN_PATH, ACTIVE_CONSENT_DNS_LABEL, ACTIVE_CONSENT_WELL_KNOWN_PATH, challengeToken, activeTestConsentToken, endpointHost, normalizeEndpoint, proofLocation, consentProofLocation, verifyOwnership, verifyActiveTestConsent, OwnershipError, } from "./ownership.js";
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,6CAA6C;AAE7C,OAAO,EACL,iBAAiB,EACjB,QAAQ,EACR,cAAc,EACd,wBAAwB,EACxB,yBAAyB,EACzB,oBAAoB,EACpB,mBAAmB,EACnB,oBAAoB,EACpB,SAAS,GAMV,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,eAAe,EACf,iBAAiB,GAKlB,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,SAAS,EACT,eAAe,EACf,wBAAwB,EACxB,8BAA8B,EAC9B,cAAc,EACd,sBAAsB,EACtB,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,oBAAoB,EACpB,eAAe,EACf,uBAAuB,EACvB,cAAc,GAGf,MAAM,gBAAgB,CAAC"}
@@ -0,0 +1,147 @@
1
+ import { type OwnershipMethod } from "./schema.js";
2
+ /** The DNS label the token is published under, prefixed to the endpoint's host. Mirrors ACME's _acme-challenge convention. */
3
+ export declare const DNS_LABEL = "_endpoint-attest";
4
+ /** The path the token is published at for the file method. Standard well-known location per RFC 8615. */
5
+ export declare const WELL_KNOWN_PATH = "/.well-known/endpoint-attest.txt";
6
+ /**
7
+ * A SECOND, distinct proof the owner publishes to opt IN to active testing.
8
+ * Separate from the ownership proof on purpose: proving control of an endpoint
9
+ * is not the same as consenting to have it actively connected-to and probed
10
+ * (an MCP client handshake and tools/list), so each is its own published
11
+ * token at its own location. Published at a distinct DNS label / well-known
12
+ * path so it never collides with the ownership proof (a file holds one value).
13
+ */
14
+ export declare const ACTIVE_CONSENT_DNS_LABEL = "_endpoint-attest-active-consent";
15
+ export declare const ACTIVE_CONSENT_WELL_KNOWN_PATH = "/.well-known/endpoint-attest-active-consent.txt";
16
+ export declare class OwnershipError extends Error {
17
+ constructor(message: string);
18
+ }
19
+ /**
20
+ * Derive the challenge token for one (endpoint, owner) pair.
21
+ *
22
+ * Deterministic, so the same pair always yields the same token and no state
23
+ * needs to be kept between issuing and verifying. Unguessable without
24
+ * `attesterSecret`, so an outsider cannot publish a valid token for someone
25
+ * else's endpoint and claim to be its owner.
26
+ */
27
+ export declare function challengeToken(endpoint: string, ownerAddress: string, attesterSecret: string): string;
28
+ /**
29
+ * Derive the ACTIVE-TEST consent token for one (endpoint, owner) pair — the
30
+ * token the owner publishes to authorize active testing (an MCP client
31
+ * handshake and tools/list), distinct from the ownership challenge token.
32
+ *
33
+ * Domain-separated from challengeToken: the HMAC message is prefixed with
34
+ * "active-test-consent\n", so a consent token can never equal an ownership
35
+ * token for the same pair, and neither can be replayed as the other. Because a
36
+ * hostname cannot contain a newline and an owner address cannot either, the two
37
+ * messages ("host\nowner" vs "active-test-consent\nhost\nowner") can never
38
+ * collide. Same unguessable-without-the-secret property as the ownership token.
39
+ */
40
+ export declare function activeTestConsentToken(endpoint: string, ownerAddress: string, attesterSecret: string): string;
41
+ /** The exact host an endpoint's proof must be published under. Rejects anything that is not a plain http(s) URL, so a file:// or credential-bearing URL can never reach a DNS query or a fetch. */
42
+ export declare function endpointHost(endpoint: string): string;
43
+ /**
44
+ * The canonical form a token binds to and an attestation records: scheme +
45
+ * host + port + path, with credentials, query and fragment stripped and a
46
+ * single trailing slash removed (but the root "/" kept). Computed from a
47
+ * parsed URL, so a raw string can never smuggle a newline into the HMAC
48
+ * message (new URL rejects/strips control characters).
49
+ *
50
+ * WHY THE FULL ENDPOINT, NOT JUST THE HOST: the challenge/consent tokens used
51
+ * to bind only to the hostname, so one published proof (a single host-wide TXT
52
+ * record, or a host-root well-known file) authorized an attestation — and the
53
+ * consent-gated active probe — for EVERY scheme/port/path on that host. An
54
+ * attacker who could read a victim's published proof could then point the
55
+ * attester at http://victim:6379/anything and both mint a signed attestation
56
+ * the owner never requested and use the attester as a non-blind port/path
57
+ * probe. Binding the token to the normalized endpoint makes the token for
58
+ * one endpoint useless for any other, so only the exact endpoints the owner
59
+ * actually published a token for can be attested. The publish LOCATION stays
60
+ * host/origin-based (a DNS name cannot carry a path); distinctness comes from
61
+ * the token value, and verifyTokenPublished scans every record/finds the exact
62
+ * match, so one host can carry many endpoints' tokens side by side.
63
+ */
64
+ export declare function normalizeEndpoint(endpoint: string): string;
65
+ /** Where a reader can re-check the proof themselves, per method. Recorded verbatim in the attestation's ownershipProof.location. */
66
+ export declare function proofLocation(method: OwnershipMethod, endpoint: string): string;
67
+ /** Where the owner publishes the active-test consent token, per method. Distinct from proofLocation so the two proofs never share a file or DNS name. */
68
+ export declare function consentProofLocation(method: OwnershipMethod, endpoint: string): string;
69
+ /**
70
+ * The network operations this verifier performs, supplied by the CALLER
71
+ * rather than defaulted here. endpoint-attest deliberately does not ship the
72
+ * process that runs the check (see README "What this does not do"), and that
73
+ * now extends to the network primitives: a public server must inject a guarded
74
+ * fetch/resolver (net-guard) so an anonymous visitor cannot turn ownership
75
+ * verification into an SSRF or DNS-rebinding probe. Both are REQUIRED, so a
76
+ * caller physically cannot reach the network unguarded by forgetting to pass
77
+ * one — the previous global-fetch/node:dns defaults were exactly how one road
78
+ * stayed unguarded. Tests inject stubs, so this stays unit-testable offline.
79
+ */
80
+ export interface OwnershipCheckDeps {
81
+ resolveTxtRecords: (name: string) => Promise<string[][]>;
82
+ fetchImpl: typeof fetch;
83
+ timeoutMs?: number;
84
+ maxFileBytes?: number;
85
+ }
86
+ /**
87
+ * WAAROM `token` HIERIN ZIT, en dat is geen uitbreiding maar een reparatie.
88
+ *
89
+ * Het gepubliceerde 402-contract van de betaalde route eist `token` als verplicht
90
+ * uitvoerveld, en dit type gaf het nooit terug. Gemeten op 2026-10-18: ons eigen
91
+ * leveringscontroleur leest precies dat veld, dus hij kon geen enkel echt antwoord
92
+ * van onze eigen dienst verwerken. Een belofte in een catalogus die de route niet
93
+ * nakomt is erger dan een ontbrekende belofte.
94
+ *
95
+ * WAAROM DIT VEILIG IS, EN DE EERSTE VERSIE VAN DEZE ONDERBOUWING WAS ONJUIST. Er stond
96
+ * dat het token "per constructie een publieke waarde" is omdat de eigenaar hem zelf moet
97
+ * publiceren. Dat klopt bij een GESLAAGDE controle en is bij een MISLUKTE per definitie
98
+ * onwaar: mislukt betekent juist dat hij hem NIET gepubliceerd heeft. Onze eigen tweede
99
+ * keuring wees dat aan, en de verkeerde formulering stond toen ook al in het publieke
100
+ * Bazaar-schema van de betaalde route.
101
+ *
102
+ * De echte reden dat dit niets weggeeft is gemeten en luidt anders. `/api/challenge`
103
+ * geeft precies hetzelfde token al terug voor een WILLEKEURIG paar van eindpunt en
104
+ * eigenaar, zonder enig bewijs, met een RUIMERE limiet (30 per 5 minuten tegen 10) en een
105
+ * LOSSERE gastheercontrole. Wie het token wil hebben, heeft deze route dus niet nodig en
106
+ * neemt de goedkopere. Drie scenario's zijn nagelopen (een overgenomen subdomein, een
107
+ * zwevend dns-record, een plek waar een ander een bestand kan zetten) en geen van de
108
+ * drie geeft een aanvaller iets extra: het token bindt aan het VOLLEDIGE eindpunt, dus
109
+ * schema plus host plus poort plus pad, en publiceren kan alleen wie die plek beheert.
110
+ *
111
+ * DE KOPPELING DIE HIERUIT VOLGT, en die is nergens anders vastgelegd dan hier en in de
112
+ * test ernaast: zet iemand `/api/challenge` later achter een inlog, dan wordt deze
113
+ * functie stil het goedkoopste orakel voor hetzelfde token. Dan hoort deze afweging
114
+ * opnieuw gemaakt te worden, en niet overgenomen.
115
+ *
116
+ * Het token gaat ook mee bij een MISLUKTE controle, want dan is hij het antwoord op "wat
117
+ * had er moeten staan", en dat is wat een eigenaar nodig heeft om het te repareren.
118
+ */
119
+ export type OwnershipResult = {
120
+ ok: true;
121
+ method: OwnershipMethod;
122
+ token: string;
123
+ location: string;
124
+ verifiedAt: string;
125
+ } | {
126
+ ok: false;
127
+ method: OwnershipMethod;
128
+ token: string;
129
+ location: string;
130
+ reason: string;
131
+ };
132
+ /**
133
+ * Verify control of `endpoint` by the holder of `ownerAddress`, using one
134
+ * named method. Read-only: this queries DNS or fetches one file, and never
135
+ * writes to, logs into, or probes anything on the operator's system.
136
+ */
137
+ export declare function verifyOwnership(method: OwnershipMethod, endpoint: string, ownerAddress: string, attesterSecret: string, deps: OwnershipCheckDeps): Promise<OwnershipResult>;
138
+ /**
139
+ * Verify the owner has published the ACTIVE-TEST consent token (opting in to
140
+ * being actively connected-to and probed), using the same channel they used
141
+ * for the ownership proof. Read-only, same posture as verifyOwnership. This is
142
+ * a SEPARATE gate: proving control of an endpoint does not by itself authorize
143
+ * active testing of it, so the attester runs the active path only when this
144
+ * returns ok.
145
+ */
146
+ export declare function verifyActiveTestConsent(method: OwnershipMethod, endpoint: string, ownerAddress: string, attesterSecret: string, deps: OwnershipCheckDeps): Promise<OwnershipResult>;
147
+ //# sourceMappingURL=ownership.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ownership.d.ts","sourceRoot":"","sources":["../src/ownership.ts"],"names":[],"mappings":"AAgCA,OAAO,EAAqB,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAEtE,8HAA8H;AAC9H,eAAO,MAAM,SAAS,qBAAqB,CAAC;AAC5C,yGAAyG;AACzG,eAAO,MAAM,eAAe,qCAAqC,CAAC;AAElE;;;;;;;GAOG;AACH,eAAO,MAAM,wBAAwB,oCAAoC,CAAC;AAC1E,eAAO,MAAM,8BAA8B,oDAAoD,CAAC;AAShG,qBAAa,cAAe,SAAQ,KAAK;gBAC3B,OAAO,EAAE,MAAM;CAI5B;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,MAAM,CASrG;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,MAAM,CAS7G;AAED,mMAAmM;AACnM,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAErD;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAO1D;AAsBD,oIAAoI;AACpI,wBAAgB,aAAa,CAAC,MAAM,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAK/E;AAED,yJAAyJ;AACzJ,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAKtF;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,kBAAkB;IACjC,iBAAiB,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IACzD,SAAS,EAAE,OAAO,KAAK,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,MAAM,MAAM,eAAe,GACvB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,eAAe,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAC1F;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,eAAe,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5F;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,eAAe,EACvB,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,EACpB,cAAc,EAAE,MAAM,EACtB,IAAI,EAAE,kBAAkB,GACvB,OAAO,CAAC,eAAe,CAAC,CAO1B;AAED;;;;;;;GAOG;AACH,wBAAsB,uBAAuB,CAC3C,MAAM,EAAE,eAAe,EACvB,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,EACpB,cAAc,EAAE,MAAM,EACtB,IAAI,EAAE,kBAAkB,GACvB,OAAO,CAAC,eAAe,CAAC,CAO1B"}
@@ -0,0 +1,287 @@
1
+ // ownership.ts — prove that whoever requested an attestation actually
2
+ // controls the endpoint it is about.
3
+ //
4
+ // This is the part that keeps the whole system voluntary. Without it, an
5
+ // "attestation" about an endpoint is an unsolicited report about a bystander
6
+ // who never asked and gets no say. With it, the only attestations that can
7
+ // exist are ones the operator asked for and proved they had the right to ask
8
+ // for.
9
+ //
10
+ // DELIBERATELY NOT INVENTED HERE: the two challenge methods mirror ACME
11
+ // (the protocol every certificate authority on the internet uses) rather
12
+ // than a third scheme of our own. dns-txt is ACME's DNS-01, well-known-file
13
+ // is ACME's HTTP-01 shape, and /.well-known/ is the standard location for
14
+ // exactly this (RFC 8615). A reviewer who knows ACME needs no new concepts
15
+ // to audit this.
16
+ //
17
+ // STATELESS ON PURPOSE: the challenge token is derived with HMAC from an
18
+ // attester-held secret plus the (endpoint, owner) pair, so nothing has to be
19
+ // stored between issuing a challenge and verifying it. No database, no
20
+ // expiring session, no cleanup job. The token is not a credential: publishing
21
+ // it grants nothing, it only demonstrates control at the moment it is read.
22
+ //
23
+ // HONEST LIMIT, stated here rather than discovered later: a DNS record or a
24
+ // well-known file that stays in place after a domain changes hands keeps
25
+ // proving the OLD owner's control. This is inherent to every control-proof
26
+ // scheme built on DNS or HTTP, including ACME's, and it is the reason
27
+ // certificates expire rather than the reason not to use them. Our mandatory
28
+ // attestation expiry (schema.ts) is the same answer: control is re-proven at
29
+ // every renewal, so a stale record can only carry a claim for the remaining
30
+ // life of one attestation, not indefinitely.
31
+ import { createHmac } from "node:crypto";
32
+ import { OWNERSHIP_METHODS } from "./schema.js";
33
+ /** The DNS label the token is published under, prefixed to the endpoint's host. Mirrors ACME's _acme-challenge convention. */
34
+ export const DNS_LABEL = "_endpoint-attest";
35
+ /** The path the token is published at for the file method. Standard well-known location per RFC 8615. */
36
+ export const WELL_KNOWN_PATH = "/.well-known/endpoint-attest.txt";
37
+ /**
38
+ * A SECOND, distinct proof the owner publishes to opt IN to active testing.
39
+ * Separate from the ownership proof on purpose: proving control of an endpoint
40
+ * is not the same as consenting to have it actively connected-to and probed
41
+ * (an MCP client handshake and tools/list), so each is its own published
42
+ * token at its own location. Published at a distinct DNS label / well-known
43
+ * path so it never collides with the ownership proof (a file holds one value).
44
+ */
45
+ export const ACTIVE_CONSENT_DNS_LABEL = "_endpoint-attest-active-consent";
46
+ export const ACTIVE_CONSENT_WELL_KNOWN_PATH = "/.well-known/endpoint-attest-active-consent.txt";
47
+ const DEFAULT_TIMEOUT_MS = 10_000;
48
+ // A well-known file only ever needs to hold one short token. Anything larger
49
+ // is either a misconfiguration or an attempt to make the verifier chew
50
+ // through a large body, so it is refused while streaming rather than
51
+ // buffered first (same posture as capacity-attest's http-source.ts).
52
+ const MAX_FILE_BYTES = 8 * 1024;
53
+ export class OwnershipError extends Error {
54
+ constructor(message) {
55
+ super(message);
56
+ this.name = "OwnershipError";
57
+ }
58
+ }
59
+ /**
60
+ * Derive the challenge token for one (endpoint, owner) pair.
61
+ *
62
+ * Deterministic, so the same pair always yields the same token and no state
63
+ * needs to be kept between issuing and verifying. Unguessable without
64
+ * `attesterSecret`, so an outsider cannot publish a valid token for someone
65
+ * else's endpoint and claim to be its owner.
66
+ */
67
+ export function challengeToken(endpoint, ownerAddress, attesterSecret) {
68
+ if (attesterSecret.length < 32) {
69
+ throw new OwnershipError("attesterSecret must be at least 32 characters: a short secret makes every token guessable");
70
+ }
71
+ const bound = normalizeEndpoint(endpoint);
72
+ const mac = createHmac("sha256", attesterSecret)
73
+ .update(`${bound}\n${ownerAddress.toLowerCase()}`, "utf-8")
74
+ .digest("hex");
75
+ return `tok_${mac}`;
76
+ }
77
+ /**
78
+ * Derive the ACTIVE-TEST consent token for one (endpoint, owner) pair — the
79
+ * token the owner publishes to authorize active testing (an MCP client
80
+ * handshake and tools/list), distinct from the ownership challenge token.
81
+ *
82
+ * Domain-separated from challengeToken: the HMAC message is prefixed with
83
+ * "active-test-consent\n", so a consent token can never equal an ownership
84
+ * token for the same pair, and neither can be replayed as the other. Because a
85
+ * hostname cannot contain a newline and an owner address cannot either, the two
86
+ * messages ("host\nowner" vs "active-test-consent\nhost\nowner") can never
87
+ * collide. Same unguessable-without-the-secret property as the ownership token.
88
+ */
89
+ export function activeTestConsentToken(endpoint, ownerAddress, attesterSecret) {
90
+ if (attesterSecret.length < 32) {
91
+ throw new OwnershipError("attesterSecret must be at least 32 characters: a short secret makes every token guessable");
92
+ }
93
+ const bound = normalizeEndpoint(endpoint);
94
+ const mac = createHmac("sha256", attesterSecret)
95
+ .update(`active-test-consent\n${bound}\n${ownerAddress.toLowerCase()}`, "utf-8")
96
+ .digest("hex");
97
+ return `consent_${mac}`;
98
+ }
99
+ /** The exact host an endpoint's proof must be published under. Rejects anything that is not a plain http(s) URL, so a file:// or credential-bearing URL can never reach a DNS query or a fetch. */
100
+ export function endpointHost(endpoint) {
101
+ return parseEndpoint(endpoint).hostname;
102
+ }
103
+ /**
104
+ * The canonical form a token binds to and an attestation records: scheme +
105
+ * host + port + path, with credentials, query and fragment stripped and a
106
+ * single trailing slash removed (but the root "/" kept). Computed from a
107
+ * parsed URL, so a raw string can never smuggle a newline into the HMAC
108
+ * message (new URL rejects/strips control characters).
109
+ *
110
+ * WHY THE FULL ENDPOINT, NOT JUST THE HOST: the challenge/consent tokens used
111
+ * to bind only to the hostname, so one published proof (a single host-wide TXT
112
+ * record, or a host-root well-known file) authorized an attestation — and the
113
+ * consent-gated active probe — for EVERY scheme/port/path on that host. An
114
+ * attacker who could read a victim's published proof could then point the
115
+ * attester at http://victim:6379/anything and both mint a signed attestation
116
+ * the owner never requested and use the attester as a non-blind port/path
117
+ * probe. Binding the token to the normalized endpoint makes the token for
118
+ * one endpoint useless for any other, so only the exact endpoints the owner
119
+ * actually published a token for can be attested. The publish LOCATION stays
120
+ * host/origin-based (a DNS name cannot carry a path); distinctness comes from
121
+ * the token value, and verifyTokenPublished scans every record/finds the exact
122
+ * match, so one host can carry many endpoints' tokens side by side.
123
+ */
124
+ export function normalizeEndpoint(endpoint) {
125
+ const url = parseEndpoint(endpoint);
126
+ let path = url.pathname;
127
+ if (path.length > 1 && path.endsWith("/"))
128
+ path = path.slice(0, -1);
129
+ // url.host includes the port (unlike url.hostname) — the port is part of the
130
+ // identity we bind to, so a different port is a different endpoint.
131
+ return `${url.protocol}//${url.host}${path}`;
132
+ }
133
+ /** Parse + validate an endpoint as a plain, credential-free http(s) URL. Shared by endpointHost and normalizeEndpoint so both reject the same inputs identically. */
134
+ function parseEndpoint(endpoint) {
135
+ let url;
136
+ try {
137
+ url = new URL(endpoint);
138
+ }
139
+ catch {
140
+ throw new OwnershipError(`endpoint is not a valid URL: ${JSON.stringify(endpoint)}`);
141
+ }
142
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
143
+ throw new OwnershipError(`endpoint must be http or https, got ${url.protocol}`);
144
+ }
145
+ if (url.username !== "" || url.password !== "") {
146
+ throw new OwnershipError("endpoint must not contain credentials");
147
+ }
148
+ if (url.hostname === "") {
149
+ throw new OwnershipError("endpoint has no host");
150
+ }
151
+ return url;
152
+ }
153
+ /** Where a reader can re-check the proof themselves, per method. Recorded verbatim in the attestation's ownershipProof.location. */
154
+ export function proofLocation(method, endpoint) {
155
+ const host = endpointHost(endpoint);
156
+ if (method === "dns-txt")
157
+ return `${DNS_LABEL}.${host}`;
158
+ const url = new URL(endpoint);
159
+ return `${url.protocol}//${url.host}${WELL_KNOWN_PATH}`;
160
+ }
161
+ /** Where the owner publishes the active-test consent token, per method. Distinct from proofLocation so the two proofs never share a file or DNS name. */
162
+ export function consentProofLocation(method, endpoint) {
163
+ const host = endpointHost(endpoint);
164
+ if (method === "dns-txt")
165
+ return `${ACTIVE_CONSENT_DNS_LABEL}.${host}`;
166
+ const url = new URL(endpoint);
167
+ return `${url.protocol}//${url.host}${ACTIVE_CONSENT_WELL_KNOWN_PATH}`;
168
+ }
169
+ /**
170
+ * Verify control of `endpoint` by the holder of `ownerAddress`, using one
171
+ * named method. Read-only: this queries DNS or fetches one file, and never
172
+ * writes to, logs into, or probes anything on the operator's system.
173
+ */
174
+ export async function verifyOwnership(method, endpoint, ownerAddress, attesterSecret, deps) {
175
+ if (!OWNERSHIP_METHODS.includes(method)) {
176
+ throw new OwnershipError(`unknown ownership method: ${JSON.stringify(method)}`);
177
+ }
178
+ const expected = challengeToken(endpoint, ownerAddress, attesterSecret);
179
+ const location = proofLocation(method, endpoint);
180
+ return verifyTokenPublished(method, expected, location, deps);
181
+ }
182
+ /**
183
+ * Verify the owner has published the ACTIVE-TEST consent token (opting in to
184
+ * being actively connected-to and probed), using the same channel they used
185
+ * for the ownership proof. Read-only, same posture as verifyOwnership. This is
186
+ * a SEPARATE gate: proving control of an endpoint does not by itself authorize
187
+ * active testing of it, so the attester runs the active path only when this
188
+ * returns ok.
189
+ */
190
+ export async function verifyActiveTestConsent(method, endpoint, ownerAddress, attesterSecret, deps) {
191
+ if (!OWNERSHIP_METHODS.includes(method)) {
192
+ throw new OwnershipError(`unknown ownership method: ${JSON.stringify(method)}`);
193
+ }
194
+ const expected = activeTestConsentToken(endpoint, ownerAddress, attesterSecret);
195
+ const location = consentProofLocation(method, endpoint);
196
+ return verifyTokenPublished(method, expected, location, deps);
197
+ }
198
+ /**
199
+ * The shared read-only check behind both verifyOwnership and
200
+ * verifyActiveTestConsent: confirm that `expected` is exactly what is published
201
+ * at `location` via `method`. Factored out so the two gates can never drift
202
+ * apart in how they fetch, bound, and exact-match a published token.
203
+ */
204
+ async function verifyTokenPublished(method, expected, location, deps) {
205
+ const now = () => new Date().toISOString();
206
+ if (method === "dns-txt") {
207
+ const resolver = deps.resolveTxtRecords;
208
+ let records;
209
+ try {
210
+ records = await resolver(location);
211
+ }
212
+ catch (e) {
213
+ return { ok: false, method, token: expected, location, reason: `dns lookup failed: ${e.message}` };
214
+ }
215
+ // A TXT record can be split into multiple strings by the resolver; the
216
+ // published value is their concatenation, which is how every DNS client
217
+ // reassembles long records.
218
+ const values = records.map((chunks) => chunks.join("").trim());
219
+ if (!values.includes(expected)) {
220
+ return { ok: false, method, token: expected, location, reason: "no TXT record at that name matched the expected token" };
221
+ }
222
+ return { ok: true, method, token: expected, location, verifiedAt: now() };
223
+ }
224
+ const fetchImpl = deps.fetchImpl;
225
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
226
+ const maxFileBytes = deps.maxFileBytes ?? MAX_FILE_BYTES;
227
+ let response;
228
+ try {
229
+ // redirect:"error" — a control proof must be served DIRECTLY at the exact
230
+ // well-known URL. Following redirects would let an open redirect (or a
231
+ // scheme downgrade) at that path turn some OTHER server's body into a
232
+ // "valid" proof, so a third party could forge ownership of a host that
233
+ // merely has an open redirect at /.well-known/endpoint-attest.txt.
234
+ response = await fetchImpl(location, { signal: AbortSignal.timeout(timeoutMs), redirect: "error" });
235
+ }
236
+ catch (e) {
237
+ return { ok: false, method, token: expected, location, reason: `fetch failed: ${e.message}` };
238
+ }
239
+ if (!response.ok) {
240
+ return { ok: false, method, token: expected, location, reason: `responded ${response.status} ${response.statusText}` };
241
+ }
242
+ let body;
243
+ try {
244
+ body = await readBounded(response, maxFileBytes);
245
+ }
246
+ catch (e) {
247
+ return { ok: false, method, token: expected, location, reason: e.message };
248
+ }
249
+ // Exact match on the trimmed content, not a substring search: a token
250
+ // buried in an otherwise unrelated page does not demonstrate that the
251
+ // operator deliberately published it.
252
+ if (body.trim() !== expected) {
253
+ return { ok: false, method, token: expected, location, reason: "file content did not exactly match the expected token" };
254
+ }
255
+ return { ok: true, method, token: expected, location, verifiedAt: now() };
256
+ }
257
+ /** Read a response body up to maxBytes, refusing the moment the limit is crossed instead of buffering the rest. */
258
+ async function readBounded(response, maxBytes) {
259
+ const stream = response.body;
260
+ if (!stream)
261
+ return "";
262
+ const reader = stream.getReader();
263
+ const chunks = [];
264
+ let total = 0;
265
+ try {
266
+ for (;;) {
267
+ const { done, value } = await reader.read();
268
+ if (done)
269
+ break;
270
+ if (value) {
271
+ total += value.byteLength;
272
+ if (total > maxBytes) {
273
+ await reader.cancel().catch(() => { });
274
+ throw new OwnershipError(`proof file exceeded ${maxBytes} bytes, refused before reading further`);
275
+ }
276
+ chunks.push(value);
277
+ }
278
+ }
279
+ }
280
+ catch (e) {
281
+ if (e instanceof OwnershipError)
282
+ throw e;
283
+ throw new OwnershipError(`reading proof file failed: ${e.message}`);
284
+ }
285
+ return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf-8");
286
+ }
287
+ //# sourceMappingURL=ownership.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ownership.js","sourceRoot":"","sources":["../src/ownership.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,145 @@
1
+ import { z } from "zod";
2
+ export declare const ETH_ADDRESS_RE: RegExp;
3
+ /** How the endpoint operator proved they control the endpoint. Mirrors ACME's two proven challenge types rather than inventing a third. */
4
+ export declare const OWNERSHIP_METHODS: readonly ["dns-txt", "well-known-file"];
5
+ export type OwnershipMethod = (typeof OWNERSHIP_METHODS)[number];
6
+ /**
7
+ * The outcome of the checks that were actually run. Deliberately NOT called
8
+ * "delivered" (that is capacity-attest's word for a different question) and
9
+ * deliberately not a score or a grade.
10
+ *
11
+ * - `passed`: every check in `checksPerformed` passed.
12
+ * - `failed`: at least one check in `checksPerformed` did not pass.
13
+ * - `inconclusive`: the checks could not be completed (endpoint unreachable,
14
+ * rate-limited, ambiguous response). Fail-closed: this is NOT `passed`.
15
+ */
16
+ export declare const OUTCOMES: readonly ["passed", "failed", "inconclusive"];
17
+ export type Outcome = (typeof OUTCOMES)[number];
18
+ export declare const OwnershipProofSchema: z.ZodObject<{
19
+ method: z.ZodEnum<{
20
+ "dns-txt": "dns-txt";
21
+ "well-known-file": "well-known-file";
22
+ }>;
23
+ token: z.ZodString;
24
+ location: z.ZodString;
25
+ verifiedAt: z.ZodString;
26
+ }, z.core.$strict>;
27
+ export type OwnershipProof = z.infer<typeof OwnershipProofSchema>;
28
+ export declare const AttestationContentObject: z.ZodObject<{
29
+ endpoint: z.ZodString;
30
+ ownerAddress: z.ZodString;
31
+ attesterAddress: z.ZodString;
32
+ ownershipProof: z.ZodObject<{
33
+ method: z.ZodEnum<{
34
+ "dns-txt": "dns-txt";
35
+ "well-known-file": "well-known-file";
36
+ }>;
37
+ token: z.ZodString;
38
+ location: z.ZodString;
39
+ verifiedAt: z.ZodString;
40
+ }, z.core.$strict>;
41
+ checksPerformed: z.ZodArray<z.ZodString>;
42
+ outcome: z.ZodEnum<{
43
+ passed: "passed";
44
+ failed: "failed";
45
+ inconclusive: "inconclusive";
46
+ }>;
47
+ findingsHash: z.ZodString;
48
+ checkedAt: z.ZodString;
49
+ expiresAt: z.ZodString;
50
+ }, z.core.$strip>;
51
+ /** The longest an attestation may live, checkedAt to expiresAt. Without an upper bound "always expires" would still allow the year 9999. */
52
+ export declare const MAX_ATTESTATION_LIFE_DAYS = 366;
53
+ /** How much older than checkedAt the ownership proof may be. Control is re-proven for every attestation, so a stale proof is not carried forward. */
54
+ export declare const MAX_OWNERSHIP_PROOF_AGE_DAYS = 30;
55
+ export declare const AttestationContentSchema: z.ZodObject<{
56
+ endpoint: z.ZodString;
57
+ ownerAddress: z.ZodString;
58
+ attesterAddress: z.ZodString;
59
+ ownershipProof: z.ZodObject<{
60
+ method: z.ZodEnum<{
61
+ "dns-txt": "dns-txt";
62
+ "well-known-file": "well-known-file";
63
+ }>;
64
+ token: z.ZodString;
65
+ location: z.ZodString;
66
+ verifiedAt: z.ZodString;
67
+ }, z.core.$strict>;
68
+ checksPerformed: z.ZodArray<z.ZodString>;
69
+ outcome: z.ZodEnum<{
70
+ passed: "passed";
71
+ failed: "failed";
72
+ inconclusive: "inconclusive";
73
+ }>;
74
+ findingsHash: z.ZodString;
75
+ checkedAt: z.ZodString;
76
+ expiresAt: z.ZodString;
77
+ }, z.core.$strip>;
78
+ export type AttestationContent = z.infer<typeof AttestationContentSchema>;
79
+ export declare const EndpointAttestationObject: z.ZodObject<{
80
+ endpoint: z.ZodString;
81
+ ownerAddress: z.ZodString;
82
+ attesterAddress: z.ZodString;
83
+ ownershipProof: z.ZodObject<{
84
+ method: z.ZodEnum<{
85
+ "dns-txt": "dns-txt";
86
+ "well-known-file": "well-known-file";
87
+ }>;
88
+ token: z.ZodString;
89
+ location: z.ZodString;
90
+ verifiedAt: z.ZodString;
91
+ }, z.core.$strict>;
92
+ checksPerformed: z.ZodArray<z.ZodString>;
93
+ outcome: z.ZodEnum<{
94
+ passed: "passed";
95
+ failed: "failed";
96
+ inconclusive: "inconclusive";
97
+ }>;
98
+ findingsHash: z.ZodString;
99
+ checkedAt: z.ZodString;
100
+ expiresAt: z.ZodString;
101
+ attestationId: z.ZodString;
102
+ signature: z.ZodString;
103
+ }, z.core.$strip>;
104
+ export declare const EndpointAttestationSchema: z.ZodObject<{
105
+ endpoint: z.ZodString;
106
+ ownerAddress: z.ZodString;
107
+ attesterAddress: z.ZodString;
108
+ ownershipProof: z.ZodObject<{
109
+ method: z.ZodEnum<{
110
+ "dns-txt": "dns-txt";
111
+ "well-known-file": "well-known-file";
112
+ }>;
113
+ token: z.ZodString;
114
+ location: z.ZodString;
115
+ verifiedAt: z.ZodString;
116
+ }, z.core.$strict>;
117
+ checksPerformed: z.ZodArray<z.ZodString>;
118
+ outcome: z.ZodEnum<{
119
+ passed: "passed";
120
+ failed: "failed";
121
+ inconclusive: "inconclusive";
122
+ }>;
123
+ findingsHash: z.ZodString;
124
+ checkedAt: z.ZodString;
125
+ expiresAt: z.ZodString;
126
+ attestationId: z.ZodString;
127
+ signature: z.ZodString;
128
+ }, z.core.$strip>;
129
+ export type EndpointAttestation = z.infer<typeof EndpointAttestationSchema>;
130
+ /**
131
+ * The EXACT bytes that get hashed, as a string.
132
+ *
133
+ * Exported for the same reason capacity-attest exports claimPreimage: the
134
+ * preimage is `canonicalize(AttestationContentSchema.parse(content))`, NOT
135
+ * `canonicalize(content)`. The parse step normalises (lower-cases the two
136
+ * address fields, strips unknown top-level keys). Port only canonicalize()
137
+ * into another language and you will silently produce wrong ids for any
138
+ * attestation submitted with mixed-case addresses.
139
+ */
140
+ export declare function attestationPreimage(content: AttestationContent): string;
141
+ /** Content-addressed id: sha256 over the canonical JSON of the content fields (everything except attestationId and signature). */
142
+ export declare function computeAttestationId(content: AttestationContent): string;
143
+ /** True when `at` (default: now) is at or after expiresAt. An expired attestation is not a weaker attestation, it is not an attestation. */
144
+ export declare function isExpired(content: Pick<AttestationContent, "expiresAt">, at?: Date): boolean;
145
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AA0CA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,eAAO,MAAM,cAAc,QAAwB,CAAC;AAGpD,2IAA2I;AAC3I,eAAO,MAAM,iBAAiB,yCAA0C,CAAC;AACzE,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,iBAAiB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEjE;;;;;;;;;GASG;AACH,eAAO,MAAM,QAAQ,+CAAgD,CAAC;AACtE,MAAM,MAAM,OAAO,GAAG,CAAC,OAAO,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC;AAMhD,eAAO,MAAM,oBAAoB;;;;;;;;kBAQ/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAElE,eAAO,MAAM,wBAAwB;;;;;;;;;;;;;;;;;;;;;;iBAgBnC,CAAC;AAEH,4IAA4I;AAC5I,eAAO,MAAM,yBAAyB,MAAM,CAAC;AAC7C,qJAAqJ;AACrJ,eAAO,MAAM,4BAA4B,KAAK,CAAC;AAG/C,eAAO,MAAM,wBAAwB;;;;;;;;;;;;;;;;;;;;;;iBA6BnC,CAAC;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAE1E,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;iBAGpC,CAAC;AACH,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;iBAIpC,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,yBAAyB,CAAC,CAAC;AAE5E;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,kBAAkB,GAAG,MAAM,CAEvE;AAED,kIAAkI;AAClI,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,kBAAkB,GAAG,MAAM,CAExE;AAWD,4IAA4I;AAC5I,wBAAgB,SAAS,CAAC,OAAO,EAAE,IAAI,CAAC,kBAAkB,EAAE,WAAW,CAAC,EAAE,EAAE,GAAE,IAAiB,GAAG,OAAO,CAExG"}
package/dist/schema.js ADDED
@@ -0,0 +1,161 @@
1
+ // schema.ts — the EndpointAttestation content type, its content-addressed id,
2
+ // and the honesty constraints that are enforced by the schema itself rather
3
+ // than left to a caller's good intentions.
4
+ //
5
+ // WHY THIS IS A SEPARATE PACKAGE AND NOT A NEW CLAIM TYPE INSIDE
6
+ // capacity-attest: capacity-attest's ClaimContent has a deliberately CLOSED
7
+ // assetType enum (gpu-hours, storage, api-credits, bandwidth) and its README
8
+ // states plainly that the enum is closed on purpose. An endpoint security
9
+ // attestation is none of those four. Widening that enum to fit this would
10
+ // break a documented design boundary of an already-published package, and
11
+ // reusing one of the four would put untrue data in the field. So this package
12
+ // follows the pattern a third party (EmbryoSpace) already proved works in
13
+ // this ecosystem: reuse the CONTENT-ADDRESSING primitives, bring your own
14
+ // content shape, leave capacity-attest untouched. See
15
+ // capacity-attest/docs/INTEGRATION-GUIDE.md.
16
+ //
17
+ // WHAT IS REUSED, DELIBERATELY: canonicalize() from capacity-attest. That
18
+ // function carries the hard-won parts (recursive key sorting, a depth cap
19
+ // that turns a pathological nesting crash into a catchable error, and
20
+ // Object.create(null) so a key literally named "__proto__" is hashed as a
21
+ // normal key instead of vanishing). Reimplementing that here would mean
22
+ // reimplementing its bugs. What is NOT reused: the claim-specific preimage
23
+ // and verify functions, which are typed to ClaimContent.
24
+ //
25
+ // THE THREE HONESTY CONSTRAINTS, enforced here in the schema:
26
+ // 1. `expiresAt` is REQUIRED and must be after `checkedAt`. An attestation
27
+ // with no hard end date still looks valid months later, which is selling
28
+ // confidence that was never checked. There is no "never expires" option.
29
+ // 2. `checksPerformed` must be non-empty. `outcome: "passed"` may therefore
30
+ // never be read as "this endpoint is secure", only as "these named
31
+ // checks passed at that moment". A blanket safety claim is not
32
+ // expressible in this format, by construction.
33
+ // 3. `ownershipProof` is REQUIRED and may be at most MAX_OWNERSHIP_PROOF_AGE_DAYS
34
+ // older than `checkedAt`. The format CARRIES the proof (method, token, location,
35
+ // time); it does not RE-CHECK it. verifyAttestation checks the id, the signature,
36
+ // attester != owner and expiry, nothing more. Whoever relies on an attestation
37
+ // re-runs verifyOwnership (with guarded deps) or trusts the attester's word that
38
+ // the owner proved control. Anyone with a key can sign a well-formed attestation
39
+ // naming any endpoint and any owner address; the signature says who signed, not
40
+ // that the owner asked.
41
+ import { createHash } from "node:crypto";
42
+ import { z } from "zod";
43
+ import { canonicalize } from "capacity-attest/dist/schema.js";
44
+ export const ETH_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
45
+ const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
46
+ /** How the endpoint operator proved they control the endpoint. Mirrors ACME's two proven challenge types rather than inventing a third. */
47
+ export const OWNERSHIP_METHODS = ["dns-txt", "well-known-file"];
48
+ /**
49
+ * The outcome of the checks that were actually run. Deliberately NOT called
50
+ * "delivered" (that is capacity-attest's word for a different question) and
51
+ * deliberately not a score or a grade.
52
+ *
53
+ * - `passed`: every check in `checksPerformed` passed.
54
+ * - `failed`: at least one check in `checksPerformed` did not pass.
55
+ * - `inconclusive`: the checks could not be completed (endpoint unreachable,
56
+ * rate-limited, ambiguous response). Fail-closed: this is NOT `passed`.
57
+ */
58
+ export const OUTCOMES = ["passed", "failed", "inconclusive"];
59
+ const IsoTimestamp = z
60
+ .string()
61
+ .refine((s) => !Number.isNaN(Date.parse(s)), { message: "must be a parseable ISO-8601 timestamp" });
62
+ export const OwnershipProofSchema = z.strictObject({
63
+ method: z.enum(OWNERSHIP_METHODS),
64
+ /** The exact challenge token the operator published. Not a secret: it only proves control at verification time. */
65
+ token: z.string().min(16).max(512),
66
+ /** Where the token was found: the DNS name queried, or the full URL fetched. Recorded so a reader can re-check it themselves. */
67
+ location: z.string().min(1).max(2048),
68
+ /** When control was verified. May be earlier than checkedAt (control is proven once, checks can run after). */
69
+ verifiedAt: IsoTimestamp,
70
+ });
71
+ export const AttestationContentObject = z.object({
72
+ /** The endpoint this attestation is about. Expected in normalised form (origin plus path, no credentials, no fragment); this package does not enforce that. */
73
+ endpoint: z.string().min(1).max(2048),
74
+ /** The operator who requested this attestation and proved control of `endpoint`. */
75
+ ownerAddress: z.string().regex(ETH_ADDRESS_RE, "ownerAddress must be a 0x-prefixed 20-byte address"),
76
+ /** Whoever ran the checks and signs this attestation. The party that carries the responsibility for what it asserts. */
77
+ attesterAddress: z.string().regex(ETH_ADDRESS_RE, "attesterAddress must be a 0x-prefixed 20-byte address"),
78
+ ownershipProof: OwnershipProofSchema,
79
+ /** The named checks that were actually run. Non-empty by design: see constraint 2 in this file's header. */
80
+ checksPerformed: z.array(z.string().min(1).max(200)).min(1).max(100),
81
+ outcome: z.enum(OUTCOMES),
82
+ /** sha256 of the full findings report. The report itself is never stored here, only its hash, same posture as capacity-attest's evidenceHash. */
83
+ findingsHash: z.string().regex(SHA256_HEX_RE, "findingsHash must be lower-case sha256 hex"),
84
+ checkedAt: IsoTimestamp,
85
+ /** REQUIRED. There is deliberately no way to express an attestation that never expires. */
86
+ expiresAt: IsoTimestamp,
87
+ });
88
+ /** The longest an attestation may live, checkedAt to expiresAt. Without an upper bound "always expires" would still allow the year 9999. */
89
+ export const MAX_ATTESTATION_LIFE_DAYS = 366;
90
+ /** How much older than checkedAt the ownership proof may be. Control is re-proven for every attestation, so a stale proof is not carried forward. */
91
+ export const MAX_OWNERSHIP_PROOF_AGE_DAYS = 30;
92
+ const DAY_MS = 24 * 60 * 60 * 1000;
93
+ export const AttestationContentSchema = AttestationContentObject.superRefine((content, ctx) => {
94
+ if (Date.parse(content.expiresAt) <= Date.parse(content.checkedAt)) {
95
+ ctx.addIssue({
96
+ code: "custom",
97
+ path: ["expiresAt"],
98
+ message: "expiresAt must be strictly after checkedAt: an attestation that expires at or before the moment it was made asserts nothing",
99
+ });
100
+ }
101
+ if (Date.parse(content.expiresAt) - Date.parse(content.checkedAt) > MAX_ATTESTATION_LIFE_DAYS * DAY_MS) {
102
+ ctx.addIssue({
103
+ code: "custom",
104
+ path: ["expiresAt"],
105
+ message: `expiresAt must be at most ${MAX_ATTESTATION_LIFE_DAYS} days after checkedAt: a far-future expiry is a non-expiring attestation by another name`,
106
+ });
107
+ }
108
+ if (Date.parse(content.checkedAt) - Date.parse(content.ownershipProof.verifiedAt) > MAX_OWNERSHIP_PROOF_AGE_DAYS * DAY_MS) {
109
+ ctx.addIssue({
110
+ code: "custom",
111
+ path: ["ownershipProof", "verifiedAt"],
112
+ message: `ownershipProof.verifiedAt must be at most ${MAX_OWNERSHIP_PROOF_AGE_DAYS} days before checkedAt: control is re-proven for each attestation`,
113
+ });
114
+ }
115
+ if (Date.parse(content.ownershipProof.verifiedAt) > Date.parse(content.checkedAt)) {
116
+ ctx.addIssue({
117
+ code: "custom",
118
+ path: ["ownershipProof", "verifiedAt"],
119
+ message: "ownershipProof.verifiedAt must not be after checkedAt: control has to be proven before or when the checks run, not afterwards",
120
+ });
121
+ }
122
+ });
123
+ export const EndpointAttestationObject = AttestationContentObject.extend({
124
+ attestationId: z.string().regex(/^0x[0-9a-f]{64}$/, "attestationId must be 0x + lower-case sha256 hex"),
125
+ signature: z.string().regex(/^0x[0-9a-fA-F]{130}$/, "signature must be a 0x-prefixed 65-byte hex signature"),
126
+ });
127
+ export const EndpointAttestationSchema = EndpointAttestationObject.superRefine((content, ctx) => {
128
+ if (Date.parse(content.expiresAt) <= Date.parse(content.checkedAt)) {
129
+ ctx.addIssue({ code: "custom", path: ["expiresAt"], message: "expiresAt must be strictly after checkedAt" });
130
+ }
131
+ });
132
+ /**
133
+ * The EXACT bytes that get hashed, as a string.
134
+ *
135
+ * Exported for the same reason capacity-attest exports claimPreimage: the
136
+ * preimage is `canonicalize(AttestationContentSchema.parse(content))`, NOT
137
+ * `canonicalize(content)`. The parse step normalises (lower-cases the two
138
+ * address fields, strips unknown top-level keys). Port only canonicalize()
139
+ * into another language and you will silently produce wrong ids for any
140
+ * attestation submitted with mixed-case addresses.
141
+ */
142
+ export function attestationPreimage(content) {
143
+ return canonicalize(normalise(AttestationContentSchema.parse(content)));
144
+ }
145
+ /** Content-addressed id: sha256 over the canonical JSON of the content fields (everything except attestationId and signature). */
146
+ export function computeAttestationId(content) {
147
+ return "0x" + createHash("sha256").update(attestationPreimage(content), "utf-8").digest("hex");
148
+ }
149
+ /** Lower-case the address fields so the same attestation submitted with mixed-case addresses hashes identically. */
150
+ function normalise(content) {
151
+ return {
152
+ ...content,
153
+ ownerAddress: content.ownerAddress.toLowerCase(),
154
+ attesterAddress: content.attesterAddress.toLowerCase(),
155
+ };
156
+ }
157
+ /** True when `at` (default: now) is at or after expiresAt. An expired attestation is not a weaker attestation, it is not an attestation. */
158
+ export function isExpired(content, at = new Date()) {
159
+ return at.getTime() >= Date.parse(content.expiresAt);
160
+ }
161
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,41 @@
1
+ import { ethers } from "ethers";
2
+ import { type AttestationContent, type EndpointAttestation } from "./schema.js";
3
+ export interface SignedAttestationParts {
4
+ attestationId: string;
5
+ signature: string;
6
+ }
7
+ /** Sign an attestation as the attester. Returns the content-addressed id alongside the signature so a caller can assemble a full EndpointAttestation. */
8
+ export declare function signAttestation(attester: ethers.Signer, content: AttestationContent): Promise<SignedAttestationParts>;
9
+ export type VerifyReason = "attestation_id_mismatch" | "signature_recovery_failed" | "signature_does_not_match_attester" | "attester_is_owner" | "expired";
10
+ export type VerifyResult = {
11
+ ok: true;
12
+ } | {
13
+ ok: false;
14
+ reason: VerifyReason;
15
+ detail?: string;
16
+ };
17
+ export interface VerifyOptions {
18
+ /** Treat this as "now" when checking expiry. Injectable so tests do not depend on the wall clock. */
19
+ now?: Date;
20
+ /**
21
+ * Skip the expiry check. Use ONLY when the caller genuinely needs to know
22
+ * "was this ever validly signed" separately from "is it valid today", for
23
+ * example when displaying an archived attestation as historical. Never use
24
+ * it to present an expired attestation as current: that is exactly the
25
+ * false confidence the mandatory expiry exists to prevent.
26
+ */
27
+ ignoreExpiry?: boolean;
28
+ }
29
+ /**
30
+ * Verify that an assembled attestation is internally consistent AND still valid:
31
+ * 1. `attestationId` really is the sha256 content-address of the content fields.
32
+ * 2. `signature` recovers to `attesterAddress`.
33
+ * 3. The attester is not the owner (a self-declaration is not an attestation).
34
+ * 4. It has not expired (unless the caller explicitly opts out, see VerifyOptions).
35
+ *
36
+ * Checks run in that order on purpose: an expired attestation whose signature
37
+ * is also forged reports the forgery, not the expiry, so a caller can never
38
+ * mistake a forged attestation for a merely stale one.
39
+ */
40
+ export declare function verifyAttestation(attestation: EndpointAttestation, opts?: VerifyOptions): VerifyResult;
41
+ //# sourceMappingURL=signing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signing.d.ts","sourceRoot":"","sources":["../src/signing.ts"],"names":[],"mappings":"AAkBA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,OAAO,EACL,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,EAGzB,MAAM,aAAa,CAAC;AAErB,MAAM,WAAW,sBAAsB;IACrC,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,yJAAyJ;AACzJ,wBAAsB,eAAe,CACnC,QAAQ,EAAE,MAAM,CAAC,MAAM,EACvB,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC,sBAAsB,CAAC,CAIjC;AAED,MAAM,MAAM,YAAY,GACpB,yBAAyB,GACzB,2BAA2B,GAC3B,mCAAmC,GACnC,mBAAmB,GACnB,SAAS,CAAC;AAEd,MAAM,MAAM,YAAY,GAAG;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,YAAY,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAE/F,MAAM,WAAW,aAAa;IAC5B,qGAAqG;IACrG,GAAG,CAAC,EAAE,IAAI,CAAC;IACX;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,mBAAmB,EAAE,IAAI,GAAE,aAAkB,GAAG,YAAY,CAkC1G"}
@@ -0,0 +1,70 @@
1
+ // signing.ts — sign an endpoint attestation as the attester, and verify it later.
2
+ //
3
+ // Same deliberate choice as capacity-attest: EIP-191 personal-sign over the
4
+ // content-address, not EIP-712 typed data, to keep the crypto surface small
5
+ // enough to audit end to end. `recoverClaimSigner` is imported from
6
+ // capacity-attest rather than reimplemented: it is a one-line
7
+ // ethers.verifyMessage wrapper, but importing it keeps both packages
8
+ // provably on the same recovery path instead of two copies that can drift.
9
+ //
10
+ // WHO SIGNS, AND WHY IT MATTERS: the ATTESTER signs, not the owner. The
11
+ // owner's role is to prove control of the endpoint (see ownership.ts); the
12
+ // attester's role is to state what the checks found and to carry the
13
+ // responsibility for that statement. An attestation signed by the owner
14
+ // about their own endpoint would be a self-declaration, which is worth
15
+ // nothing to the third party reading it. verifyAttestation therefore
16
+ // requires the signature to recover to `attesterAddress`, and deliberately
17
+ // rejects an attestation where attester and owner are the same address.
18
+ import { recoverClaimSigner } from "capacity-attest/dist/signing.js";
19
+ import { computeAttestationId, isExpired, } from "./schema.js";
20
+ /** Sign an attestation as the attester. Returns the content-addressed id alongside the signature so a caller can assemble a full EndpointAttestation. */
21
+ export async function signAttestation(attester, content) {
22
+ const attestationId = computeAttestationId(content);
23
+ const signature = await attester.signMessage(attestationId);
24
+ return { attestationId, signature };
25
+ }
26
+ /**
27
+ * Verify that an assembled attestation is internally consistent AND still valid:
28
+ * 1. `attestationId` really is the sha256 content-address of the content fields.
29
+ * 2. `signature` recovers to `attesterAddress`.
30
+ * 3. The attester is not the owner (a self-declaration is not an attestation).
31
+ * 4. It has not expired (unless the caller explicitly opts out, see VerifyOptions).
32
+ *
33
+ * Checks run in that order on purpose: an expired attestation whose signature
34
+ * is also forged reports the forgery, not the expiry, so a caller can never
35
+ * mistake a forged attestation for a merely stale one.
36
+ */
37
+ export function verifyAttestation(attestation, opts = {}) {
38
+ let expectedId;
39
+ try {
40
+ expectedId = computeAttestationId(attestation);
41
+ }
42
+ catch (e) {
43
+ // Content that cannot even be canonicalised (pathological nesting, or a
44
+ // shape that fails AttestationContentSchema) can never have a matching
45
+ // id, so report it as the mismatch it is rather than throwing at a
46
+ // caller who asked a yes/no question.
47
+ return { ok: false, reason: "attestation_id_mismatch", detail: e.message };
48
+ }
49
+ if (expectedId.toLowerCase() !== attestation.attestationId.toLowerCase()) {
50
+ return { ok: false, reason: "attestation_id_mismatch" };
51
+ }
52
+ let recovered;
53
+ try {
54
+ recovered = recoverClaimSigner(attestation.attestationId, attestation.signature);
55
+ }
56
+ catch (e) {
57
+ return { ok: false, reason: "signature_recovery_failed", detail: e.message };
58
+ }
59
+ if (recovered.toLowerCase() !== attestation.attesterAddress.toLowerCase()) {
60
+ return { ok: false, reason: "signature_does_not_match_attester" };
61
+ }
62
+ if (attestation.attesterAddress.toLowerCase() === attestation.ownerAddress.toLowerCase()) {
63
+ return { ok: false, reason: "attester_is_owner" };
64
+ }
65
+ if (!opts.ignoreExpiry && isExpired(attestation, opts.now ?? new Date())) {
66
+ return { ok: false, reason: "expired" };
67
+ }
68
+ return { ok: true };
69
+ }
70
+ //# sourceMappingURL=signing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signing.js","sourceRoot":"","sources":["../src/signing.ts"],"names":[],"mappings":"AAAA,kFAAkF;AAClF,EAAE;AACF,4EAA4E;AAC5E,4EAA4E;AAC5E,oEAAoE;AACpE,8DAA8D;AAC9D,qEAAqE;AACrE,2EAA2E;AAC3E,EAAE;AACF,wEAAwE;AACxE,2EAA2E;AAC3E,qEAAqE;AACrE,wEAAwE;AACxE,uEAAuE;AACvE,qEAAqE;AACrE,2EAA2E;AAC3E,wEAAwE;AAGxE,OAAO,EAAE,kBAAkB,EAAE,MAAM,iCAAiC,CAAC;AACrE,OAAO,EAGL,oBAAoB,EACpB,SAAS,GACV,MAAM,aAAa,CAAC;AAOrB,yJAAyJ;AACzJ,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,QAAuB,EACvB,OAA2B;IAE3B,MAAM,aAAa,GAAG,oBAAoB,CAAC,OAAO,CAAC,CAAC;IACpD,MAAM,SAAS,GAAG,MAAM,QAAQ,CAAC,WAAW,CAAC,aAAa,CAAC,CAAC;IAC5D,OAAO,EAAE,aAAa,EAAE,SAAS,EAAE,CAAC;AACtC,CAAC;AAwBD;;;;;;;;;;GAUG;AACH,MAAM,UAAU,iBAAiB,CAAC,WAAgC,EAAE,OAAsB,EAAE;IAC1F,IAAI,UAAkB,CAAC;IACvB,IAAI,CAAC;QACH,UAAU,GAAG,oBAAoB,CAAC,WAAW,CAAC,CAAC;IACjD,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,wEAAwE;QACxE,uEAAuE;QACvE,mEAAmE;QACnE,sCAAsC;QACtC,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,yBAAyB,EAAE,MAAM,EAAG,CAAW,CAAC,OAAO,EAAE,CAAC;IACxF,CAAC;IACD,IAAI,UAAU,CAAC,WAAW,EAAE,KAAK,WAAW,CAAC,aAAa,CAAC,WAAW,EAAE,EAAE,CAAC;QACzE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,yBAAyB,EAAE,CAAC;IAC1D,CAAC;IAED,IAAI,SAAiB,CAAC;IACtB,IAAI,CAAC;QACH,SAAS,GAAG,kBAAkB,CAAC,WAAW,CAAC,aAAa,EAAE,WAAW,CAAC,SAAS,CAAC,CAAC;IACnF,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,2BAA2B,EAAE,MAAM,EAAG,CAAW,CAAC,OAAO,EAAE,CAAC;IAC1F,CAAC;IACD,IAAI,SAAS,CAAC,WAAW,EAAE,KAAK,WAAW,CAAC,eAAe,CAAC,WAAW,EAAE,EAAE,CAAC;QAC1E,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,mCAAmC,EAAE,CAAC;IACpE,CAAC;IAED,IAAI,WAAW,CAAC,eAAe,CAAC,WAAW,EAAE,KAAK,WAAW,CAAC,YAAY,CAAC,WAAW,EAAE,EAAE,CAAC;QACzF,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,CAAC;IACpD,CAAC;IAED,IAAI,CAAC,IAAI,CAAC,YAAY,IAAI,SAAS,CAAC,WAAW,EAAE,IAAI,CAAC,GAAG,IAAI,IAAI,IAAI,EAAE,CAAC,EAAE,CAAC;QACzE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IAC1C,CAAC;IAED,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC;AACtB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "endpoint-attest",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Time-bounded, owner-requested security attestations for a network endpoint: the operator proves control of the endpoint, a check is run, and the outcome is recorded as a signed, content-addressed, expiring attestation. Reuses capacity-attest's canonical hashing and EIP-191 signing rather than reimplementing them. Never a security score, never a claim about a party that did not ask.",
5
+ "keywords": [
6
+ "attestation",
7
+ "mcp",
8
+ "endpoint",
9
+ "domain-control",
10
+ "x402",
11
+ "agent-commerce"
12
+ ],
13
+ "author": "holistis",
14
+ "license": "MIT",
15
+ "homepage": "https://tokenizen.nl",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/holistis/tokenizen.git",
19
+ "directory": "packages/endpoint-attest"
20
+ },
21
+ "type": "module",
22
+ "main": "./dist/index.js",
23
+ "files": [
24
+ "dist",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc -p tsconfig.json",
33
+ "typecheck": "tsc -p tsconfig.json --noEmit",
34
+ "test": "vitest run"
35
+ },
36
+ "dependencies": {
37
+ "capacity-attest": "^0.9.0",
38
+ "ethers": "^6.13.0",
39
+ "zod": "^4.5.2"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^22.20.5",
43
+ "tsx": "^4.23.15",
44
+ "typescript": "^5.7.3",
45
+ "vitest": "^4.1.11"
46
+ }
47
+ }