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 +21 -0
- package/README.md +79 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/ownership.d.ts +147 -0
- package/dist/ownership.d.ts.map +1 -0
- package/dist/ownership.js +287 -0
- package/dist/ownership.js.map +1 -0
- package/dist/schema.d.ts +145 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +161 -0
- package/dist/schema.js.map +1 -0
- package/dist/signing.d.ts +41 -0
- package/dist/signing.d.ts.map +1 -0
- package/dist/signing.js +70 -0
- package/dist/signing.js.map +1 -0
- package/package.json +45 -4
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
|
-
#
|
|
1
|
+
# endpoint-attest
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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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"}
|
package/dist/schema.d.ts
ADDED
|
@@ -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"}
|
package/dist/signing.js
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|