@fungi.computer/seam 0.0.0-stage → 0.0.1
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 +52 -2
- package/dist/index.d.ts +45 -0
- package/dist/index.js +206 -0
- package/package.json +22 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jamstack Consulting LLC
|
|
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,53 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Fungi host seam
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Owns the Ed25519 request authentication protocol between the Hub and its on-box
|
|
4
|
+
hosts. Consumers import `@fungi.computer/seam`. The moved implementation and
|
|
5
|
+
replay/signature tests retain their original behavior. Keys remain with the
|
|
6
|
+
operator; no keys are stored in this package. The package ships the protocol and
|
|
7
|
+
replay guard; hosts own key provisioning.
|
|
8
|
+
|
|
9
|
+
Run `pnpm --filter @fungi.computer/seam test` for signature, audience, body and
|
|
10
|
+
replay laws. Computer and game-server host tests exercise its real consumers.
|
|
11
|
+
|
|
12
|
+
## Packed consumer readiness
|
|
13
|
+
|
|
14
|
+
Run `pnpm run build`, `pnpm run pack` and `pnpm run stranger` in this package.
|
|
15
|
+
The maintained stranger proof installs only package tarballs into a fresh npm
|
|
16
|
+
consumer, checks the exact shipped files, runs the public runtime fixture and
|
|
17
|
+
compiles every JavaScript entrypoint with strict NodeNext and no library-check
|
|
18
|
+
skip. This qualification does not publish npm releases or push a repository
|
|
19
|
+
split.
|
|
20
|
+
|
|
21
|
+
See [LICENSE](LICENSE) for the license and the package manifest for its public
|
|
22
|
+
repository destination.
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { SeamReplayGuard } from "@fungi.computer/seam";
|
|
28
|
+
|
|
29
|
+
const replay = new SeamReplayGuard();
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
console.log(replay.admit("00112233445566778899aabbccddeeff", now));
|
|
32
|
+
console.log(replay.admit("00112233445566778899aabbccddeeff", now));
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Core concepts
|
|
36
|
+
|
|
37
|
+
The sender signs method, audience, path, timestamp, nonce and payload digest.
|
|
38
|
+
The receiver checks the signature, freshness and replay guard against its pinned
|
|
39
|
+
keys.
|
|
40
|
+
|
|
41
|
+
## Examples and checks
|
|
42
|
+
|
|
43
|
+
The packed runtime fixture at `test/stranger/runtime.mjs` exercises the public
|
|
44
|
+
API in a fresh consumer. Run `pnpm run stranger` after building the package.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
Install the package tarball with npm. The manifest pins its runtime
|
|
49
|
+
dependencies.
|
|
50
|
+
|
|
51
|
+
## Contribute and license
|
|
52
|
+
|
|
53
|
+
See [LICENSE](LICENSE) for the MIT terms.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutual authentication for on-box host seams: every request in either
|
|
3
|
+
* direction is signed with Ed25519 by its sender and verified against a
|
|
4
|
+
* public key the receiver pins.
|
|
5
|
+
*
|
|
6
|
+
* - Down: the Hub signs with the platform seam key; the host pins its public key.
|
|
7
|
+
* - Up: the host signs reports with its host key; the Hub pins it in the host registry.
|
|
8
|
+
*
|
|
9
|
+
* The signature covers an audience (so a request meant for one side cannot
|
|
10
|
+
* be replayed at the other), the method, path and query, a timestamp, a
|
|
11
|
+
* nonce and the body digest. Streaming bodies (forwarded guest traffic,
|
|
12
|
+
* port previews, WebSocket upgrades) sign `UNSIGNED-PAYLOAD` and rely on the
|
|
13
|
+
* TLS transport (Cloudflare Tunnel) for body integrity. Receivers reject a
|
|
14
|
+
* timestamp outside the skew window and a nonce seen within it. Private keys
|
|
15
|
+
* stay with their owner; projections and registries carry public data only.
|
|
16
|
+
*
|
|
17
|
+
* This module is plain Web Crypto so the Worker and the Node.js host share it.
|
|
18
|
+
*/
|
|
19
|
+
export declare const seamHeader = "x-fungi-seam";
|
|
20
|
+
export declare const unsignedPayload = "UNSIGNED-PAYLOAD";
|
|
21
|
+
export type SeamSigner = Readonly<{
|
|
22
|
+
keyId: string;
|
|
23
|
+
key: CryptoKey;
|
|
24
|
+
}>;
|
|
25
|
+
export type SeamVerifiers = ReadonlyMap<string, CryptoKey>;
|
|
26
|
+
/** `keyId:base64(pkcs8)`, the form a Worker secret or key file holds. */
|
|
27
|
+
export declare function importSeamSigner(secret: string): Promise<SeamSigner>;
|
|
28
|
+
/** `{ keyId: base64(raw 32-byte public key) }`, the pinned peers. */
|
|
29
|
+
export declare function importSeamVerifiers(publicKeys: Readonly<Record<string, string>>): Promise<SeamVerifiers>;
|
|
30
|
+
/** The body digest to sign: SHA-256 hex of buffered bytes, or unsigned. */
|
|
31
|
+
export declare function seamBodyDigest(body: ArrayBuffer | Uint8Array<ArrayBuffer> | null): Promise<string>;
|
|
32
|
+
/** The `x-fungi-seam` header value for one request. */
|
|
33
|
+
export declare function signSeamRequest(signer: SeamSigner, audience: string, method: string, url: string, digest: string, now?: number): Promise<string>;
|
|
34
|
+
/** Nonces seen inside the skew window, bounded. */
|
|
35
|
+
export declare class SeamReplayGuard {
|
|
36
|
+
#private;
|
|
37
|
+
/** Record `nonce`; false if it was already seen and is still live. */
|
|
38
|
+
admit(nonce: string, now: number): boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Verify one request's seam header. `digest` is the receiver's own digest of
|
|
42
|
+
* the buffered body, or `null` when the route accepts an unsigned stream.
|
|
43
|
+
* Answers the sender's key id.
|
|
44
|
+
*/
|
|
45
|
+
export declare function verifySeamRequest(verifiers: SeamVerifiers, audience: string, method: string, url: string, header: string | null, digest: string | null, replay: SeamReplayGuard, now?: number): Promise<string>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutual authentication for on-box host seams: every request in either
|
|
3
|
+
* direction is signed with Ed25519 by its sender and verified against a
|
|
4
|
+
* public key the receiver pins.
|
|
5
|
+
*
|
|
6
|
+
* - Down: the Hub signs with the platform seam key; the host pins its public key.
|
|
7
|
+
* - Up: the host signs reports with its host key; the Hub pins it in the host registry.
|
|
8
|
+
*
|
|
9
|
+
* The signature covers an audience (so a request meant for one side cannot
|
|
10
|
+
* be replayed at the other), the method, path and query, a timestamp, a
|
|
11
|
+
* nonce and the body digest. Streaming bodies (forwarded guest traffic,
|
|
12
|
+
* port previews, WebSocket upgrades) sign `UNSIGNED-PAYLOAD` and rely on the
|
|
13
|
+
* TLS transport (Cloudflare Tunnel) for body integrity. Receivers reject a
|
|
14
|
+
* timestamp outside the skew window and a nonce seen within it. Private keys
|
|
15
|
+
* stay with their owner; projections and registries carry public data only.
|
|
16
|
+
*
|
|
17
|
+
* This module is plain Web Crypto so the Worker and the Node.js host share it.
|
|
18
|
+
*/
|
|
19
|
+
export const seamHeader = "x-fungi-seam";
|
|
20
|
+
export const unsignedPayload = "UNSIGNED-PAYLOAD";
|
|
21
|
+
const version = "fungi-seam-v1";
|
|
22
|
+
const maximumSkewMs = 60_000;
|
|
23
|
+
const maximumNonces = 10_000;
|
|
24
|
+
class SeamAuthError extends Error {
|
|
25
|
+
constructor(reason) {
|
|
26
|
+
super(`Seam request rejected: ${reason}`);
|
|
27
|
+
this.name = "SeamAuthError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const keyIdPattern = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
31
|
+
const noncePattern = /^[0-9a-f]{32}$/;
|
|
32
|
+
const digestPattern = /^([0-9a-f]{64}|UNSIGNED-PAYLOAD)$/;
|
|
33
|
+
const signaturePattern = /^[A-Za-z0-9_-]{86}$/;
|
|
34
|
+
function bytesFromBase64(value) {
|
|
35
|
+
const binary = atob(value);
|
|
36
|
+
const bytes = new Uint8Array(binary.length);
|
|
37
|
+
for (let index = 0; index < binary.length; index += 1)
|
|
38
|
+
bytes[index] = binary.charCodeAt(index);
|
|
39
|
+
return bytes;
|
|
40
|
+
}
|
|
41
|
+
function base64url(bytes) {
|
|
42
|
+
let binary = "";
|
|
43
|
+
for (const byte of new Uint8Array(bytes))
|
|
44
|
+
binary += String.fromCharCode(byte);
|
|
45
|
+
return btoa(binary)
|
|
46
|
+
.replaceAll("+", "-")
|
|
47
|
+
.replaceAll("/", "_")
|
|
48
|
+
.replace(/=+$/, "");
|
|
49
|
+
}
|
|
50
|
+
function bytesFromBase64url(value) {
|
|
51
|
+
const padded = value.replaceAll("-", "+").replaceAll("_", "/");
|
|
52
|
+
return bytesFromBase64(padded + "=".repeat((4 - (padded.length % 4)) % 4));
|
|
53
|
+
}
|
|
54
|
+
function hex(bytes) {
|
|
55
|
+
return Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
56
|
+
}
|
|
57
|
+
/** `keyId:base64(pkcs8)`, the form a Worker secret or key file holds. */
|
|
58
|
+
export async function importSeamSigner(secret) {
|
|
59
|
+
const at = secret.indexOf(":");
|
|
60
|
+
const keyId = secret.slice(0, at);
|
|
61
|
+
if (at < 1 || !keyIdPattern.test(keyId))
|
|
62
|
+
throw new SeamAuthError("the signing key has no valid key id");
|
|
63
|
+
const key = await crypto.subtle.importKey("pkcs8", bytesFromBase64(secret.slice(at + 1)), { name: "Ed25519" }, false, ["sign"]);
|
|
64
|
+
return { keyId, key };
|
|
65
|
+
}
|
|
66
|
+
/** `{ keyId: base64(raw 32-byte public key) }`, the pinned peers. */
|
|
67
|
+
export async function importSeamVerifiers(publicKeys) {
|
|
68
|
+
const verifiers = new Map();
|
|
69
|
+
for (const [keyId, raw] of Object.entries(publicKeys)) {
|
|
70
|
+
if (!keyIdPattern.test(keyId))
|
|
71
|
+
throw new SeamAuthError(`invalid pinned key id ${keyId}`);
|
|
72
|
+
verifiers.set(keyId, await crypto.subtle.importKey("raw", bytesFromBase64(raw), { name: "Ed25519" }, false, ["verify"]));
|
|
73
|
+
}
|
|
74
|
+
return verifiers;
|
|
75
|
+
}
|
|
76
|
+
/** The body digest to sign: SHA-256 hex of buffered bytes, or unsigned. */
|
|
77
|
+
export async function seamBodyDigest(body) {
|
|
78
|
+
if (body === null)
|
|
79
|
+
return unsignedPayload;
|
|
80
|
+
return hex(await crypto.subtle.digest("SHA-256", body));
|
|
81
|
+
}
|
|
82
|
+
function canonical(audience, keyId, method, pathAndQuery, timestamp, nonce, digest) {
|
|
83
|
+
// A copy, so the bytes sit in a plain ArrayBuffer Web Crypto accepts.
|
|
84
|
+
return new Uint8Array(new TextEncoder().encode([
|
|
85
|
+
version,
|
|
86
|
+
audience,
|
|
87
|
+
keyId,
|
|
88
|
+
method.toUpperCase(),
|
|
89
|
+
pathAndQuery,
|
|
90
|
+
String(timestamp),
|
|
91
|
+
nonce,
|
|
92
|
+
digest,
|
|
93
|
+
].join("\n")));
|
|
94
|
+
}
|
|
95
|
+
function pathAndQueryOf(url) {
|
|
96
|
+
const parsed = new URL(url);
|
|
97
|
+
return `${parsed.pathname}${parsed.search}`;
|
|
98
|
+
}
|
|
99
|
+
/** The `x-fungi-seam` header value for one request. */
|
|
100
|
+
export async function signSeamRequest(signer, audience, method, url, digest, now = Date.now()) {
|
|
101
|
+
const nonceBytes = new Uint8Array(16);
|
|
102
|
+
crypto.getRandomValues(nonceBytes);
|
|
103
|
+
const nonce = hex(nonceBytes.buffer);
|
|
104
|
+
const signature = await crypto.subtle.sign({ name: "Ed25519" }, signer.key, canonical(audience, signer.keyId, method, pathAndQueryOf(url), now, nonce, digest));
|
|
105
|
+
return `v1 key=${signer.keyId} ts=${String(now)} nonce=${nonce} body=${digest} sig=${base64url(signature)}`;
|
|
106
|
+
}
|
|
107
|
+
/** Nonces seen inside the skew window, bounded. */
|
|
108
|
+
export class SeamReplayGuard {
|
|
109
|
+
#seen = new Map();
|
|
110
|
+
/** Record `nonce`; false if it was already seen and is still live. */
|
|
111
|
+
admit(nonce, now) {
|
|
112
|
+
this.#prune(now);
|
|
113
|
+
if (this.#seen.has(nonce))
|
|
114
|
+
return false;
|
|
115
|
+
this.#makeRoom();
|
|
116
|
+
this.#seen.set(nonce, now + 2 * maximumSkewMs);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
#prune(now) {
|
|
120
|
+
for (const [seen, expires] of this.#seen) {
|
|
121
|
+
if (expires > now)
|
|
122
|
+
break;
|
|
123
|
+
this.#seen.delete(seen);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
#makeRoom() {
|
|
127
|
+
if (this.#seen.size < maximumNonces)
|
|
128
|
+
return;
|
|
129
|
+
const oldest = this.#seen.keys().next();
|
|
130
|
+
if (!oldest.done)
|
|
131
|
+
this.#seen.delete(oldest.value);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function seamFields(value) {
|
|
135
|
+
const fields = new Map();
|
|
136
|
+
const parts = (value ?? "").split(" ");
|
|
137
|
+
if (parts[0] !== "v1")
|
|
138
|
+
throw new SeamAuthError("missing or unknown version");
|
|
139
|
+
for (const part of parts.slice(1)) {
|
|
140
|
+
const [name, field] = seamField(part);
|
|
141
|
+
fields.set(name, field);
|
|
142
|
+
}
|
|
143
|
+
return fields;
|
|
144
|
+
}
|
|
145
|
+
function seamField(part) {
|
|
146
|
+
const at = part.indexOf("=");
|
|
147
|
+
if (at < 1)
|
|
148
|
+
throw new SeamAuthError("malformed header");
|
|
149
|
+
return [part.slice(0, at), part.slice(at + 1)];
|
|
150
|
+
}
|
|
151
|
+
function requiredField(fields, name) {
|
|
152
|
+
return fields.get(name) ?? "";
|
|
153
|
+
}
|
|
154
|
+
function parseSeamHeader(value) {
|
|
155
|
+
const fields = seamFields(value);
|
|
156
|
+
const parsed = {
|
|
157
|
+
keyId: requiredField(fields, "key"),
|
|
158
|
+
timestamp: Number(fields.get("ts")),
|
|
159
|
+
nonce: requiredField(fields, "nonce"),
|
|
160
|
+
digest: requiredField(fields, "body"),
|
|
161
|
+
signature: requiredField(fields, "sig"),
|
|
162
|
+
};
|
|
163
|
+
if (fields.size !== 5 || !validSeamHeader(parsed))
|
|
164
|
+
throw new SeamAuthError("malformed header");
|
|
165
|
+
return parsed;
|
|
166
|
+
}
|
|
167
|
+
function validSeamHeader(header) {
|
|
168
|
+
return [
|
|
169
|
+
keyIdPattern.test(header.keyId),
|
|
170
|
+
Number.isSafeInteger(header.timestamp),
|
|
171
|
+
noncePattern.test(header.nonce),
|
|
172
|
+
digestPattern.test(header.digest),
|
|
173
|
+
signaturePattern.test(header.signature),
|
|
174
|
+
].every(Boolean);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Verify one request's seam header. `digest` is the receiver's own digest of
|
|
178
|
+
* the buffered body, or `null` when the route accepts an unsigned stream.
|
|
179
|
+
* Answers the sender's key id.
|
|
180
|
+
*/
|
|
181
|
+
export async function verifySeamRequest(verifiers, audience, method, url, header, digest, replay, now = Date.now()) {
|
|
182
|
+
const parsed = parseSeamHeader(header);
|
|
183
|
+
const key = verifiers.get(parsed.keyId);
|
|
184
|
+
if (key === undefined)
|
|
185
|
+
throw new SeamAuthError("unknown key");
|
|
186
|
+
assertSeamFreshness(parsed, digest, now);
|
|
187
|
+
const valid = await crypto.subtle.verify({ name: "Ed25519" }, key, bytesFromBase64url(parsed.signature), canonical(audience, parsed.keyId, method, pathAndQueryOf(url), parsed.timestamp, parsed.nonce, parsed.digest));
|
|
188
|
+
if (!valid)
|
|
189
|
+
throw new SeamAuthError("bad signature");
|
|
190
|
+
if (!replay.admit(parsed.nonce, now))
|
|
191
|
+
throw new SeamAuthError("replayed nonce");
|
|
192
|
+
return parsed.keyId;
|
|
193
|
+
}
|
|
194
|
+
function assertSeamFreshness(parsed, digest, now) {
|
|
195
|
+
if (Math.abs(now - parsed.timestamp) > maximumSkewMs)
|
|
196
|
+
throw new SeamAuthError("timestamp outside the skew window");
|
|
197
|
+
assertSeamDigest(parsed.digest, digest);
|
|
198
|
+
}
|
|
199
|
+
function assertSeamDigest(signed, digest) {
|
|
200
|
+
const expected = digest ?? unsignedPayload;
|
|
201
|
+
if (signed === expected)
|
|
202
|
+
return;
|
|
203
|
+
throw new SeamAuthError(digest === null
|
|
204
|
+
? "route takes an unsigned stream only"
|
|
205
|
+
: "body digest mismatch");
|
|
206
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fungi.computer/seam",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"author": "Levi <levi@technomancy.dev>",
|
|
5
|
+
"private": false,
|
|
6
|
+
"description": "Ed25519 authentication between Fungi Hub and on-box hosts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/fungi-computer/seam.git"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
]
|
|
6
25
|
}
|