@messh/sdk 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/dist/keys.js ADDED
@@ -0,0 +1,198 @@
1
+ // Key sources. Keys are used locally; they never go to messh.
2
+ import { createHash, createPrivateKey, createPublicKey, sign as edSign } from "node:crypto";
3
+ import { readFile } from "node:fs/promises";
4
+ import { connect } from "node:net";
5
+ import { homedir } from "node:os";
6
+ import { MesshError } from "./errors.js";
7
+ import { sshString } from "./wba.js";
8
+ const PKCS8_ED25519_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
9
+ class LocalSigner {
10
+ #key;
11
+ #pub;
12
+ constructor(key) {
13
+ if (key.asymmetricKeyType !== "ed25519")
14
+ throw new MesshError("invalid_key", "need an Ed25519 key");
15
+ this.#key = key;
16
+ const der = createPublicKey(key).export({ format: "der", type: "spki" });
17
+ this.#pub = new Uint8Array(der.subarray(der.length - 32));
18
+ }
19
+ publicKey() { return this.#pub; }
20
+ sign(data) { return new Uint8Array(edSign(null, data, this.#key)); }
21
+ toJSON() { return "messh.Signer(ed25519)"; }
22
+ }
23
+ function fromSeed(seed) {
24
+ if (seed.length !== 32)
25
+ throw new MesshError("invalid_key", "seed must be 32 bytes");
26
+ return new LocalSigner(createPrivateKey({ key: Buffer.concat([PKCS8_ED25519_PREFIX, seed]), format: "der", type: "pkcs8" }));
27
+ }
28
+ /** Unpack the openssh-key-v1 container (PROTOCOL.key) to the Ed25519 seed. */
29
+ function fromOpenSSH(pem) {
30
+ const buf = Buffer.from(pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""), "base64");
31
+ const magic = "openssh-key-v1\0";
32
+ if (buf.subarray(0, magic.length).toString("latin1") !== magic)
33
+ throw new MesshError("invalid_key", "not an OpenSSH private key");
34
+ let off = magic.length;
35
+ const take = (b, o) => {
36
+ if (o + 4 > b.length)
37
+ throw new MesshError("invalid_key", "truncated OpenSSH key");
38
+ const n = b.readUInt32BE(o);
39
+ if (o + 4 + n > b.length)
40
+ throw new MesshError("invalid_key", "truncated OpenSSH key");
41
+ return [b.subarray(o + 4, o + 4 + n), o + 4 + n];
42
+ };
43
+ let cipher;
44
+ [cipher, off] = take(buf, off);
45
+ if (cipher.toString() !== "none")
46
+ throw new MesshError("invalid_key", "passphrase-protected key; load it into ssh-agent (ssh-add) and use sshAgent()");
47
+ [, off] = take(buf, off);
48
+ [, off] = take(buf, off);
49
+ if (buf.readUInt32BE(off) !== 1)
50
+ throw new MesshError("invalid_key", "expected one key");
51
+ off += 4;
52
+ [, off] = take(buf, off);
53
+ const [priv] = take(buf, off);
54
+ if (priv.readUInt32BE(0) !== priv.readUInt32BE(4))
55
+ throw new MesshError("invalid_key", "OpenSSH check bytes differ");
56
+ let p = 8, type, pub, sk;
57
+ [type, p] = take(priv, p);
58
+ if (type.toString() !== "ssh-ed25519")
59
+ throw new MesshError("invalid_key", `need an ssh-ed25519 key, got ${type}`);
60
+ [pub, p] = take(priv, p);
61
+ [sk, p] = take(priv, p);
62
+ if (sk.length !== 64 || !sk.subarray(32).equals(pub))
63
+ throw new MesshError("invalid_key", "malformed ssh-ed25519 private key");
64
+ return fromSeed(sk.subarray(0, 32));
65
+ }
66
+ function parse(data) {
67
+ const text = data.toString("utf8");
68
+ if (text.includes("OPENSSH PRIVATE KEY"))
69
+ return fromOpenSSH(text);
70
+ if (text.includes("ENCRYPTED PRIVATE KEY"))
71
+ throw new MesshError("invalid_key", "encrypted keys aren't supported; load the key into ssh-agent and use sshAgent()");
72
+ try {
73
+ return new LocalSigner(text.includes("-----BEGIN") ? createPrivateKey(text) : createPrivateKey({ key: data, format: "der", type: "pkcs8" }));
74
+ }
75
+ catch (e) {
76
+ if (e instanceof MesshError)
77
+ throw e;
78
+ throw new MesshError("invalid_key", e.message);
79
+ }
80
+ }
81
+ /** Unencrypted ssh-ed25519 OpenSSH key or PKCS#8 PEM, e.g. ~/.ssh/id_ed25519. */
82
+ export function keyFile(path) {
83
+ return async () => {
84
+ const p = path.startsWith("~/") ? homedir() + path.slice(1) : path;
85
+ let data;
86
+ try {
87
+ data = await readFile(p);
88
+ }
89
+ catch (e) {
90
+ const hint = e.code === "ENOENT" ? "; create one with ssh-keygen -t ed25519, then messh publish" : "";
91
+ throw new MesshError("invalid_key", `read ${path}: ${e.message}${hint}`);
92
+ }
93
+ return parse(data);
94
+ };
95
+ }
96
+ /**
97
+ * A key held by ssh-agent, such as a passphrase-protected key you added with
98
+ * ssh-add. The private key stays in the agent. With several Ed25519 keys
99
+ * loaded, pass the fingerprint (SHA256:…) of the one you published.
100
+ */
101
+ export function sshAgent(options = {}) {
102
+ return async () => {
103
+ const path = options.socket ?? process.env.SSH_AUTH_SOCK ?? (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : "");
104
+ if (!path)
105
+ throw new MesshError("invalid_key", 'ssh-agent isn\'t running (SSH_AUTH_SOCK is not set); start it: eval "$(ssh-agent)" && ssh-add');
106
+ const reply = await agentCall(path, Buffer.from([AGENTC_REQUEST_IDENTITIES]));
107
+ if (reply[0] !== AGENT_IDENTITIES_ANSWER)
108
+ throw new MesshError("invalid_key", "ssh-agent wouldn't list its keys");
109
+ const keys = [];
110
+ let off = 5;
111
+ for (let i = reply.readUInt32BE(1); i > 0; i--) {
112
+ const [blob, o1] = take(reply, off);
113
+ [, off] = take(reply, o1); // comment
114
+ const [type, o2] = take(blob, 0);
115
+ if (type.toString() !== "ssh-ed25519")
116
+ continue;
117
+ const [pub] = take(blob, o2);
118
+ keys.push({ blob, pub, fingerprint: "SHA256:" + createHash("sha256").update(blob).digest("base64").replace(/=+$/, "") });
119
+ }
120
+ const list = keys.map((k) => k.fingerprint).join(", ");
121
+ if (!keys.length)
122
+ throw new MesshError("invalid_key", "ssh-agent has no Ed25519 keys; add yours with ssh-add ~/.ssh/id_ed25519");
123
+ const pick = options.fingerprint ? keys.filter((k) => k.fingerprint === options.fingerprint) : keys;
124
+ if (!pick.length)
125
+ throw new MesshError("invalid_key", `ssh-agent has no key ${options.fingerprint}; it has ${list}`);
126
+ if (pick.length > 1)
127
+ throw new MesshError("invalid_key", `ssh-agent has ${keys.length} Ed25519 keys; choose one with sshAgent({ fingerprint }): ${list}`);
128
+ return new AgentSigner(path, pick[0].blob, new Uint8Array(pick[0].pub));
129
+ };
130
+ }
131
+ // ssh-agent protocol (draft-miller-ssh-agent): uint32 length, message type, payload.
132
+ const AGENTC_REQUEST_IDENTITIES = 11, AGENT_IDENTITIES_ANSWER = 12, AGENTC_SIGN_REQUEST = 13, AGENT_SIGN_RESPONSE = 14;
133
+ class AgentSigner {
134
+ path;
135
+ blob;
136
+ pub;
137
+ constructor(path, blob, pub) {
138
+ this.path = path;
139
+ this.blob = blob;
140
+ this.pub = pub;
141
+ }
142
+ publicKey() { return this.pub; }
143
+ async sign(data) {
144
+ const flags = Buffer.alloc(4);
145
+ const reply = await agentCall(this.path, Buffer.concat([Buffer.from([AGENTC_SIGN_REQUEST]), sshString(this.blob), sshString(data), flags]));
146
+ if (reply[0] !== AGENT_SIGN_RESPONSE)
147
+ throw new Error("ssh-agent refused to sign; is the key still loaded (ssh-add -l)?");
148
+ const [sigBlob] = take(reply, 1);
149
+ const [type, o] = take(sigBlob, 0);
150
+ const [sig] = take(sigBlob, o);
151
+ if (type.toString() !== "ssh-ed25519" || sig.length !== 64)
152
+ throw new Error("ssh-agent returned an unexpected signature");
153
+ return new Uint8Array(sig);
154
+ }
155
+ toJSON() { return "messh.Signer(ssh-agent)"; }
156
+ }
157
+ function take(b, o) {
158
+ if (o + 4 > b.length || o + 4 + b.readUInt32BE(o) > b.length)
159
+ throw new MesshError("invalid_key", "malformed ssh-agent reply");
160
+ const n = b.readUInt32BE(o);
161
+ return [b.subarray(o + 4, o + 4 + n), o + 4 + n];
162
+ }
163
+ function agentCall(path, msg) {
164
+ return new Promise((resolve, reject) => {
165
+ const sock = connect(path);
166
+ let buf = Buffer.alloc(0);
167
+ sock.setTimeout(30_000, () => sock.destroy(new Error("ssh-agent timed out")));
168
+ sock.on("connect", () => sock.write(sshString(msg)));
169
+ sock.on("data", (d) => {
170
+ buf = Buffer.concat([buf, d]);
171
+ if (buf.length >= 4 && buf.length >= 4 + buf.readUInt32BE(0)) {
172
+ sock.end();
173
+ resolve(buf.subarray(4, 4 + buf.readUInt32BE(0)));
174
+ }
175
+ });
176
+ sock.on("error", (e) => reject(new MesshError("invalid_key", `ssh-agent at ${path}: ${e.message}`)));
177
+ sock.on("close", () => reject(new MesshError("invalid_key", `ssh-agent at ${path} closed the connection`)));
178
+ });
179
+ }
180
+ /** Key in an environment variable: PEM text, or base64 of PEM or PKCS#8 DER. */
181
+ export function keyEnv(name) {
182
+ return async () => {
183
+ const v = (process.env[name] ?? "").trim();
184
+ if (!v)
185
+ throw new MesshError("invalid_key", `environment variable ${name} is empty`);
186
+ return parse(v.includes("-----BEGIN") ? Buffer.from(v) : Buffer.from(v, "base64"));
187
+ };
188
+ }
189
+ /** Raw 32-byte Ed25519 seed. */
190
+ export function keySeed(seed) {
191
+ return async () => fromSeed(seed);
192
+ }
193
+ export async function resolveKey(source) {
194
+ const signer = typeof source === "function" ? await source() : source;
195
+ if ((await signer.publicKey()).length !== 32)
196
+ throw new MesshError("invalid_key", "signer has no Ed25519 public key");
197
+ return signer;
198
+ }
@@ -0,0 +1,116 @@
1
+ import * as wba from "./wba.js";
2
+ export { VerifyError } from "./errors.js";
3
+ export declare const ACCEPT_SIGNATURE = "sig1=(\"@method\" \"@authority\" \"@path\" \"signature-agent\");keyid;created;expires;nonce;alg=\"ed25519\";tag=\"web-bot-auth\"";
4
+ /** Default limit on request bodies the middleware reads (bytes). */
5
+ export declare const MAX_BODY_BYTES: number;
6
+ /** A verified caller. */
7
+ export interface Agent {
8
+ /** messh username, e.g. "mukul". */
9
+ identity: string;
10
+ /** Key name on messh, e.g. "laptop". */
11
+ keyName: string;
12
+ /** RFC 7638 thumbprint. */
13
+ keyId: string;
14
+ /** SHA256:… of the SSH key. */
15
+ fingerprint: string;
16
+ /** Signature-Agent. */
17
+ agentUrl: string;
18
+ /** Signature creation time, seconds since epoch. */
19
+ created: number;
20
+ }
21
+ /** Replay store. add() returns false if key was seen within ttlSeconds. */
22
+ export interface NonceStore {
23
+ add(key: string, ttlSeconds: number): boolean | Promise<boolean>;
24
+ }
25
+ export declare class MemoryNonceStore implements NonceStore {
26
+ #private;
27
+ add(key: string, ttlSeconds: number): boolean;
28
+ }
29
+ export interface VerifierOptions {
30
+ /** Who may call: messh usernames, or "*". Required. */
31
+ trust: string[];
32
+ /** Accepted registry origins (default ["https://messh.io"]), or origin → fetch base URL. */
33
+ registries?: string[] | Record<string, string>;
34
+ /** Max |now − created| in seconds. Default 60. */
35
+ maxSkew?: number;
36
+ /** Key directory cache in seconds. Default 300. */
37
+ cacheTtl?: number;
38
+ /** Keep cached keys this long if messh is unreachable. Default 3600. */
39
+ staleIfError?: number;
40
+ /** Replay store. Default in memory. */
41
+ nonceStore?: NonceStore;
42
+ /** Directory fetch timeout. Default 5000 ms. */
43
+ timeoutMs?: number;
44
+ /** Largest request body messhAuth and verifyRequest read; bigger ones get 413 body_too_large. Default 1 MiB. */
45
+ maxBodyBytes?: number;
46
+ /** Seconds since epoch (tests). */
47
+ clock?: () => number;
48
+ /** Fixed directory or resolver (tests, air-gapped setups). */
49
+ directory?: wba.Directory | ((agentUrl: string, identity: string) => Promise<wba.Directory>);
50
+ }
51
+ export interface VerifyInput {
52
+ method: string;
53
+ /** Full request URL. */
54
+ url: string | URL;
55
+ headers: Headers | Record<string, string | string[] | undefined>;
56
+ body?: Uint8Array | string | null;
57
+ }
58
+ export declare class Verifier {
59
+ readonly trust: string[];
60
+ readonly maxBodyBytes: number;
61
+ private readonly registries;
62
+ private readonly maxSkew;
63
+ private readonly cacheTtl;
64
+ private readonly staleIfError;
65
+ private readonly nonces;
66
+ private readonly timeoutMs;
67
+ private readonly clock;
68
+ private readonly directory?;
69
+ private readonly cache;
70
+ private readonly inflight;
71
+ constructor(options: VerifierOptions);
72
+ /** Verify one request; throws VerifyError. */
73
+ verify(input: VerifyInput): Promise<Agent>;
74
+ /** Verify a fetch Request (Workers, Hono, Next.js route handlers, Deno, Bun). */
75
+ verifyRequest(request: Request): Promise<Agent>;
76
+ private identityOf;
77
+ /**
78
+ * The signer's key for kid. Directories are cached for cacheTtl; an unknown
79
+ * kid refetches at most every MIN_REFRESH s (so a key published a moment ago
80
+ * works), unknown identities are remembered the same way, one fetch per
81
+ * directory runs at a time, and while messh is unreachable cached keys keep
82
+ * working for staleIfError.
83
+ */
84
+ private lookup;
85
+ /** Fetch a directory: the directory, null for no such identity, undefined if messh couldn't answer. One request per URL at a time. */
86
+ private fetchOnce;
87
+ private store;
88
+ }
89
+ /** Create a verifier (reuse it; it caches keys). */
90
+ export declare function createVerifier(options: VerifierOptions): Verifier;
91
+ type Req = {
92
+ method: string;
93
+ originalUrl?: string;
94
+ url: string;
95
+ headers: Record<string, string | string[] | undefined>;
96
+ protocol?: string;
97
+ on: Function;
98
+ body?: unknown;
99
+ rawBody?: Buffer;
100
+ agent?: Agent;
101
+ _body?: boolean;
102
+ };
103
+ type Res = {
104
+ status(code: number): Res;
105
+ setHeader(k: string, v: string): void;
106
+ json(body: unknown): void;
107
+ };
108
+ /**
109
+ * Express / Connect middleware. Mount it before express.json(): it reads
110
+ * the raw body, verifies, then sets req.rawBody, req.agent and (for JSON)
111
+ * req.body. Set `trustProxy` if TLS ends at a proxy (uses X-Forwarded-Proto).
112
+ */
113
+ export declare function messhAuth(options: VerifierOptions & {
114
+ verifier?: Verifier;
115
+ trustProxy?: boolean;
116
+ }): (req: Req, res: Res, next: (err?: unknown) => void) => void;
package/dist/verify.js ADDED
@@ -0,0 +1,333 @@
1
+ // @messh/sdk/verify: verify Web Bot Auth signatures with keys from messh.
2
+ import { createPublicKey, verify as edVerify } from "node:crypto";
3
+ import * as wba from "./wba.js";
4
+ import { VerifyError } from "./errors.js";
5
+ import { VERSION } from "./index.js";
6
+ export { VerifyError } from "./errors.js";
7
+ export const ACCEPT_SIGNATURE = 'sig1=("@method" "@authority" "@path" "signature-agent");keyid;created;expires;nonce;alg="ed25519";tag="web-bot-auth"';
8
+ const IDENTITY = /^[a-z0-9](?:[a-z0-9-]{1,28}[a-z0-9])$/;
9
+ const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
10
+ // Directory cache policy, the same in every messh SDK.
11
+ /** An unknown keyid refetches its signer's directory at most this often (s), so new keys work within seconds. */
12
+ const MIN_REFRESH = 30;
13
+ /** While messh is unreachable, retry a directory at most this often (s). */
14
+ const RETRY_AFTER = 5;
15
+ /** Directories kept at most. */
16
+ const MAX_ENTRIES = 10_000;
17
+ /** Default limit on request bodies the middleware reads (bytes). */
18
+ export const MAX_BODY_BYTES = 1 << 20;
19
+ export class MemoryNonceStore {
20
+ #seen = new Map();
21
+ add(key, ttlSeconds) {
22
+ const now = Date.now();
23
+ for (const [k, exp] of this.#seen)
24
+ if (exp < now)
25
+ this.#seen.delete(k);
26
+ if (this.#seen.has(key))
27
+ return false;
28
+ this.#seen.set(key, now + ttlSeconds * 1000);
29
+ return true;
30
+ }
31
+ }
32
+ const fail = (code, msg, status = 401) => new VerifyError(code, msg, status);
33
+ export class Verifier {
34
+ trust;
35
+ maxBodyBytes;
36
+ registries;
37
+ maxSkew;
38
+ cacheTtl;
39
+ staleIfError;
40
+ nonces;
41
+ timeoutMs;
42
+ clock;
43
+ directory;
44
+ cache = new Map();
45
+ inflight = new Map();
46
+ constructor(options) {
47
+ if (!options?.trust?.length)
48
+ throw new TypeError('trust is required, e.g. { trust: ["mukul"] } or ["*"]');
49
+ this.trust = [...options.trust];
50
+ const regs = options.registries ?? ["https://messh.io"];
51
+ const pairs = Array.isArray(regs) ? regs.map((r) => [r, r]) : Object.entries(regs);
52
+ this.registries = new Map(pairs.map(([o, b]) => [o.replace(/\/+$/, ""), b.replace(/\/+$/, "")]));
53
+ this.maxSkew = options.maxSkew ?? 60;
54
+ this.cacheTtl = options.cacheTtl ?? 300;
55
+ this.staleIfError = options.staleIfError ?? 3600;
56
+ this.nonces = options.nonceStore ?? new MemoryNonceStore();
57
+ this.timeoutMs = options.timeoutMs ?? 5000;
58
+ this.maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
59
+ this.clock = options.clock ?? (() => Date.now() / 1000);
60
+ this.directory = options.directory;
61
+ }
62
+ /** Verify one request; throws VerifyError. */
63
+ async verify(input) {
64
+ const h = lower(input.headers);
65
+ const now = this.clock();
66
+ if (!h["signature-input"] || !h["signature"])
67
+ throw fail("missing_signature", "send Signature, Signature-Input and Signature-Agent (Web Bot Auth)");
68
+ let sp, sig;
69
+ try {
70
+ sp = wba.pickWebBotAuth(h["signature-input"]);
71
+ sig = wba.signatureBytes(h["signature"], sp.label);
72
+ }
73
+ catch (e) {
74
+ throw fail("bad_signature", e.message);
75
+ }
76
+ for (const c of ["@method", "@authority", "@path", "signature-agent"]) {
77
+ if (!sp.components.includes(c))
78
+ throw fail("bad_signature", `${c} must be covered`);
79
+ }
80
+ const u = new URL(input.url);
81
+ if (u.search && !sp.components.includes("@query"))
82
+ throw fail("bad_signature", "@query must be covered when the URL has a query");
83
+ if (!h["signature-agent"])
84
+ throw fail("bad_signature", "Signature-Agent header is missing");
85
+ let agentUrl;
86
+ try {
87
+ agentUrl = wba.parseAgent(h["signature-agent"], sp.label);
88
+ }
89
+ catch (e) {
90
+ throw fail("bad_signature", e.message);
91
+ }
92
+ if ((sp.params.alg ?? "ed25519") !== "ed25519")
93
+ throw fail("bad_signature", `alg ${sp.params.alg} is not supported`);
94
+ const body = typeof input.body === "string" ? Buffer.from(input.body) : (input.body ?? new Uint8Array());
95
+ if (sp.components.includes("content-digest")) {
96
+ if (h["content-digest"] !== wba.contentDigest(body))
97
+ throw fail("digest_mismatch", "the body doesn't match Content-Digest");
98
+ }
99
+ else if (body.length) {
100
+ throw fail("bad_signature", "a request body must be covered by content-digest");
101
+ }
102
+ const created = Number.parseInt(sp.params.created ?? "", 10);
103
+ if (!Number.isFinite(created))
104
+ throw fail("bad_signature", "created is required");
105
+ if (Math.abs(now - created) > this.maxSkew)
106
+ throw fail("clock_skew", `created is ${Math.round(now - created)}s from the server clock`);
107
+ if (sp.params.expires !== undefined) {
108
+ const expires = Number.parseInt(sp.params.expires, 10);
109
+ if (!Number.isFinite(expires) || now > expires || expires - created > wba.MAX_LIFETIME)
110
+ throw fail("clock_skew", "signature expired");
111
+ }
112
+ const nonce = sp.params.nonce;
113
+ if (!nonce)
114
+ throw fail("bad_signature", "nonce is required");
115
+ const identity = this.identityOf(agentUrl);
116
+ // Before any fetch: callers outside the trust list can't make this API fetch directories.
117
+ if (!this.trust.includes("*") && !this.trust.includes(identity))
118
+ throw fail("untrusted", `${identity} is not in this API's trust list`, 403);
119
+ const kid = sp.params.keyid ?? "";
120
+ const { key, pub } = await this.lookup(agentUrl, identity, kid);
121
+ const values = {
122
+ "@method": input.method.toUpperCase(),
123
+ "@authority": (h["host"] ?? u.host).toLowerCase(),
124
+ "@path": u.pathname || "/",
125
+ "@query": u.search || "?",
126
+ };
127
+ const comps = sp.components.map((c) => {
128
+ if (c in values)
129
+ return [c, values[c]];
130
+ if (c.startsWith("@"))
131
+ throw fail("bad_signature", `unsupported component ${c}`);
132
+ if (h[c] === undefined)
133
+ throw fail("bad_signature", `covered header ${c} is missing`);
134
+ return [c, h[c]];
135
+ });
136
+ const publicKey = createPublicKey({ key: Buffer.concat([SPKI_ED25519_PREFIX, pub]), format: "der", type: "spki" });
137
+ if (!edVerify(null, wba.base(comps, sp.raw), publicKey, sig))
138
+ throw fail("bad_signature", "the signature doesn't match the request");
139
+ if (!(await this.nonces.add(`${agentUrl} ${nonce}`, 2 * this.maxSkew)))
140
+ throw fail("nonce_replayed", "this signature was already accepted once");
141
+ return { identity, keyName: key.messh_name ?? "", keyId: kid, fingerprint: key.messh_fingerprint ?? "", agentUrl, created };
142
+ }
143
+ /** Verify a fetch Request (Workers, Hono, Next.js route handlers, Deno, Bun). */
144
+ async verifyRequest(request) {
145
+ const body = request.body ? await readLimited(request.clone(), this.maxBodyBytes) : undefined;
146
+ return this.verify({ method: request.method, url: request.url, headers: request.headers, body });
147
+ }
148
+ identityOf(agentUrl) {
149
+ let u;
150
+ try {
151
+ u = new URL(agentUrl);
152
+ }
153
+ catch {
154
+ throw fail("bad_signature", "Signature-Agent is not a URL");
155
+ }
156
+ if (!this.registries.has(u.origin))
157
+ throw fail("untrusted", `Signature-Agent ${agentUrl} is not a messh registry this API accepts`, 403);
158
+ const id = u.pathname.replace(/^\/+|\/+$/g, "");
159
+ if (!IDENTITY.test(id))
160
+ throw fail("unknown_key", "Signature-Agent must be <registry>/<user>");
161
+ return id;
162
+ }
163
+ /**
164
+ * The signer's key for kid. Directories are cached for cacheTtl; an unknown
165
+ * kid refetches at most every MIN_REFRESH s (so a key published a moment ago
166
+ * works), unknown identities are remembered the same way, one fetch per
167
+ * directory runs at a time, and while messh is unreachable cached keys keep
168
+ * working for staleIfError.
169
+ */
170
+ async lookup(agentUrl, identity, kid) {
171
+ const find = (dir) => {
172
+ for (const key of dir?.keys ?? []) {
173
+ if (key.kid !== kid)
174
+ continue;
175
+ const pub = wba.jwkPublicKey(key);
176
+ if (pub)
177
+ return { key, pub };
178
+ }
179
+ return undefined;
180
+ };
181
+ const found = (dir) => {
182
+ const hit = find(dir);
183
+ if (hit)
184
+ return hit;
185
+ throw dir === null
186
+ ? fail("unknown_key", `there's no messh identity at ${agentUrl}`)
187
+ : fail("unknown_key", `keyid ${kid} is not an active key of ${agentUrl}; publish it with messh publish`);
188
+ };
189
+ if (this.directory)
190
+ return found(typeof this.directory === "function" ? await this.directory(agentUrl, identity) : this.directory);
191
+ const now = this.clock();
192
+ let e = this.cache.get(agentUrl);
193
+ const age = e?.at === undefined ? Infinity : now - e.at;
194
+ if (age < this.cacheTtl) {
195
+ const hit = find(e.dir);
196
+ if (hit)
197
+ return hit;
198
+ if (age < MIN_REFRESH)
199
+ return found(e.dir);
200
+ }
201
+ if (e?.failedAt === undefined || now - e.failedAt >= RETRY_AFTER) {
202
+ const dir = await this.fetchOnce(agentUrl, identity);
203
+ if (dir !== undefined) {
204
+ this.store(agentUrl, { dir, at: now });
205
+ return found(dir);
206
+ }
207
+ e = { ...e, failedAt: now };
208
+ this.store(agentUrl, e);
209
+ }
210
+ // messh is unreachable: keep verifying with the cached directory for a while.
211
+ if (e?.dir !== undefined && now - e.at < this.staleIfError)
212
+ return found(e.dir);
213
+ throw fail("unknown_key", `key directory for ${agentUrl} is unavailable`);
214
+ }
215
+ /** Fetch a directory: the directory, null for no such identity, undefined if messh couldn't answer. One request per URL at a time. */
216
+ fetchOnce(agentUrl, identity) {
217
+ let p = this.inflight.get(agentUrl);
218
+ if (!p) {
219
+ const url = `${this.registries.get(new URL(agentUrl).origin)}/${identity}${wba.DIRECTORY_PATH}`;
220
+ p = (async () => {
221
+ try {
222
+ const res = await fetch(url, {
223
+ headers: { accept: "application/http-message-signatures-directory+json, application/json", "user-agent": `messh-js/${VERSION}` },
224
+ signal: AbortSignal.timeout(this.timeoutMs),
225
+ });
226
+ if (res.status === 404)
227
+ return null;
228
+ const dir = res.ok ? (await res.json()) : undefined;
229
+ return dir && Array.isArray(dir.keys) ? dir : undefined;
230
+ }
231
+ catch {
232
+ return undefined;
233
+ }
234
+ })().finally(() => this.inflight.delete(agentUrl));
235
+ this.inflight.set(agentUrl, p);
236
+ }
237
+ return p;
238
+ }
239
+ store(agentUrl, e) {
240
+ this.cache.delete(agentUrl); // re-insert: Map order is then least recently stored first
241
+ this.cache.set(agentUrl, e);
242
+ while (this.cache.size > MAX_ENTRIES)
243
+ this.cache.delete(this.cache.keys().next().value);
244
+ }
245
+ }
246
+ async function readLimited(request, max) {
247
+ const reader = request.body.getReader();
248
+ const parts = [];
249
+ let size = 0;
250
+ for (;;) {
251
+ const { done, value } = await reader.read();
252
+ if (done)
253
+ break;
254
+ size += value.length;
255
+ if (size > max) {
256
+ // Not awaited: on a cloned request the cancel settles only once the original's branch is done too.
257
+ reader.cancel().catch(() => { });
258
+ throw fail("body_too_large", `the request body is over this API's limit of ${max} bytes`, 413);
259
+ }
260
+ parts.push(value);
261
+ }
262
+ return Buffer.concat(parts);
263
+ }
264
+ function lower(headers) {
265
+ const out = {};
266
+ const add = (k, v) => {
267
+ k = k.toLowerCase();
268
+ v = v.trim();
269
+ out[k] = k in out ? `${out[k]}, ${v}` : v;
270
+ };
271
+ if (typeof headers.forEach === "function" && typeof headers.get === "function") {
272
+ headers.forEach((v, k) => add(k, v));
273
+ }
274
+ else {
275
+ for (const [k, v] of Object.entries(headers)) {
276
+ if (Array.isArray(v))
277
+ v.forEach((x) => add(k, x));
278
+ else if (v !== undefined)
279
+ add(k, v);
280
+ }
281
+ }
282
+ return out;
283
+ }
284
+ /** Create a verifier (reuse it; it caches keys). */
285
+ export function createVerifier(options) {
286
+ return new Verifier(options);
287
+ }
288
+ /**
289
+ * Express / Connect middleware. Mount it before express.json(): it reads
290
+ * the raw body, verifies, then sets req.rawBody, req.agent and (for JSON)
291
+ * req.body. Set `trustProxy` if TLS ends at a proxy (uses X-Forwarded-Proto).
292
+ */
293
+ export function messhAuth(options) {
294
+ const v = options.verifier ?? new Verifier(options);
295
+ return (req, res, next) => {
296
+ const chunks = [];
297
+ let size = 0;
298
+ req.on("data", (c) => {
299
+ size += c.length;
300
+ if (size <= v.maxBodyBytes)
301
+ chunks.push(c);
302
+ });
303
+ req.on("error", next);
304
+ req.on("end", async () => {
305
+ if (size > v.maxBodyBytes) {
306
+ res.status(413).json(fail("body_too_large", `the request body is over this API's limit of ${v.maxBodyBytes} bytes`, 413).toJSON());
307
+ return;
308
+ }
309
+ const body = Buffer.concat(chunks);
310
+ const proto = (options.trustProxy && String(req.headers["x-forwarded-proto"] ?? "").split(",")[0]) || req.protocol || "http";
311
+ const url = `${proto}://${req.headers.host}${req.originalUrl ?? req.url}`;
312
+ try {
313
+ req.agent = await v.verify({ method: req.method, url, headers: req.headers, body });
314
+ }
315
+ catch (e) {
316
+ const err = e instanceof VerifyError ? e : fail("bad_signature", String(e));
317
+ if (err.status === 401)
318
+ res.setHeader("Accept-Signature", ACCEPT_SIGNATURE);
319
+ res.status(err.status).json(err.toJSON());
320
+ return;
321
+ }
322
+ req.rawBody = body;
323
+ if (body.length && String(req.headers["content-type"] ?? "").includes("json")) {
324
+ try {
325
+ req.body = JSON.parse(body.toString("utf8"));
326
+ }
327
+ catch { /* leave unparsed */ }
328
+ }
329
+ req._body = true; // body-parser: already read
330
+ next();
331
+ });
332
+ };
333
+ }
package/dist/wba.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ export declare const TAG = "web-bot-auth";
2
+ export declare const LABEL = "sig1";
3
+ export declare const DIRECTORY_PATH = "/.well-known/http-message-signatures-directory";
4
+ export declare const MAX_LIFETIME = 300;
5
+ export interface JWK {
6
+ kty: string;
7
+ crv: string;
8
+ x: string;
9
+ kid?: string;
10
+ messh_name?: string;
11
+ messh_fingerprint?: string;
12
+ }
13
+ export interface Directory {
14
+ keys: JWK[];
15
+ }
16
+ export declare function thumbprint(publicKey: Uint8Array): string;
17
+ /** SSH wire-format string: uint32 length, then the bytes. */
18
+ export declare function sshString(b: Uint8Array): Buffer;
19
+ /** The key's SSH fingerprint, as ssh-keygen -l and messh print it: SHA256:… */
20
+ export declare function fingerprint(publicKey: Uint8Array): string;
21
+ export declare function contentDigest(body: Uint8Array): string;
22
+ export declare const quote: (s: string) => string;
23
+ export declare function params(names: string[], created: number, expires: number, keyid: string, nonce: string): string;
24
+ export declare function base(components: [string, string][], sigParams: string): Uint8Array;
25
+ export interface SigParams {
26
+ label: string;
27
+ components: string[];
28
+ params: Record<string, string>;
29
+ raw: string;
30
+ }
31
+ export declare function pickWebBotAuth(header: string): SigParams;
32
+ export declare function signatureBytes(header: string, label: string): Uint8Array;
33
+ export declare function parseAgent(header: string, label: string): string;
34
+ export declare function jwkPublicKey(jwk: JWK): Uint8Array | null;