@rine-network/core 0.5.1 → 0.6.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/index.js +1048 -4
- package/dist/src/api-types.d.ts +16 -0
- package/dist/src/funnel/acme.d.ts +43 -0
- package/dist/src/funnel/backoff.d.ts +22 -0
- package/dist/src/funnel/cert-cache.d.ts +52 -0
- package/dist/src/funnel/cert.d.ts +45 -0
- package/dist/src/funnel/csr.d.ts +19 -0
- package/dist/src/funnel/index.d.ts +6 -0
- package/dist/src/funnel/issue.d.ts +74 -0
- package/dist/src/funnel/mux-client.d.ts +40 -0
- package/dist/src/funnel/mux-codec.d.ts +37 -0
- package/dist/src/funnel/propagation.d.ts +11 -0
- package/dist/src/funnel/relay-adapter.d.ts +27 -0
- package/dist/src/funnel/secret-store.d.ts +16 -0
- package/dist/src/funnel/tls-listener.d.ts +32 -0
- package/dist/src/funnel/tunnel.d.ts +46 -0
- package/dist/src/group-invite-ops.d.ts +34 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/test/funnel/cert.test.d.ts +1 -0
- package/dist/test/funnel/no-body-log-structural.test.d.ts +1 -0
- package/dist/test/funnel/relay-adapter.test.d.ts +1 -0
- package/dist/test/funnel/tls-listener.test.d.ts +1 -0
- package/dist/test/funnel/tunnel.test.d.ts +1 -0
- package/dist/test/group-invite-ops.test.d.ts +1 -0
- package/dist/test/setup.d.ts +1 -0
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
4
|
import { Aes256Gcm, CipherSuite, HkdfSha256 } from "@hpke/core";
|
|
5
5
|
import { DhkemX25519HkdfSha256 } from "@hpke/dhkem-x25519";
|
|
6
6
|
import { ed25519, x25519 } from "@noble/curves/ed25519.js";
|
|
@@ -9,6 +9,11 @@ import { sha256 } from "@noble/hashes/sha2.js";
|
|
|
9
9
|
import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
|
|
10
10
|
import { hmac } from "@noble/hashes/hmac.js";
|
|
11
11
|
import { MlsError, acceptAll, clientStateDecoder, clientStateEncoder, createApplicationMessage, createCommit, createGroup, createGroupInfoWithExternalPubAndRatchetTree, decode, defaultCredentialTypes, encode, generateKeyPackageWithKey, getCiphersuiteImpl, joinGroup, joinGroupExternal, keyPackageDecoder, keyPackageEncoder, makeKeyPackageRef, mlsMessageDecoder, mlsMessageEncoder, privateKeyPackageDecoder, privateKeyPackageEncoder, processMessage, processPrivateMessage, protocolVersions, unsafeTestingAuthenticationService, wireformats, zeroOutUint8Array } from "ts-mls";
|
|
12
|
+
import { Client, crypto as crypto$1 } from "acme-client";
|
|
13
|
+
import { createHmac, generateKeyPairSync, randomBytes, timingSafeEqual } from "node:crypto";
|
|
14
|
+
import { Resolver, resolve4, resolveNs } from "node:dns/promises";
|
|
15
|
+
import { connect } from "node:net";
|
|
16
|
+
import { createServer } from "node:tls";
|
|
12
17
|
//#region src/errors.ts
|
|
13
18
|
var RineApiError = class extends Error {
|
|
14
19
|
constructor(status, detail, raw, code) {
|
|
@@ -1312,7 +1317,7 @@ function isPendingConfirmation(err) {
|
|
|
1312
1317
|
const CIPHER_SUITE = 1;
|
|
1313
1318
|
const PENDING_RETRY_LIMIT = 3;
|
|
1314
1319
|
const PENDING_RETRY_DELAY_MS = 400;
|
|
1315
|
-
function sleep(ms) {
|
|
1320
|
+
function sleep$1(ms) {
|
|
1316
1321
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1317
1322
|
}
|
|
1318
1323
|
/**
|
|
@@ -1363,7 +1368,7 @@ async function externalJoinMlsGroup(configDir, agentId, groupId, client, extraHe
|
|
|
1363
1368
|
break;
|
|
1364
1369
|
}
|
|
1365
1370
|
if (i >= PENDING_RETRY_LIMIT - 1) throw new Error(`Cannot self-join MLS group ${groupId}: GroupInfo is still pending peer confirmation after a brief retry. Try again shortly.`, { cause: err });
|
|
1366
|
-
await sleep(PENDING_RETRY_DELAY_MS);
|
|
1371
|
+
await sleep$1(PENDING_RETRY_DELAY_MS);
|
|
1367
1372
|
}
|
|
1368
1373
|
if (!isEpochConflict(firstErr)) throw firstErr;
|
|
1369
1374
|
try {
|
|
@@ -1857,6 +1862,1045 @@ async function fetchAndIngestPendingSKDistributions(client, configDir, agentId,
|
|
|
1857
1862
|
return ingested;
|
|
1858
1863
|
}
|
|
1859
1864
|
//#endregion
|
|
1865
|
+
//#region src/group-invite-ops.ts
|
|
1866
|
+
/**
|
|
1867
|
+
* Message type for the best-effort group-invite notification DM.
|
|
1868
|
+
*
|
|
1869
|
+
* Underscore-namespaced like `rine.v1.sender_key_distribution`. Sealed by the
|
|
1870
|
+
* inviter and delivered to the invitee's normal inbox; it is a convenience
|
|
1871
|
+
* nudge — `GET /groups/invites` is the authoritative source of truth.
|
|
1872
|
+
*/
|
|
1873
|
+
const GROUP_INVITE_TYPE = "rine.v1.group_invite";
|
|
1874
|
+
/**
|
|
1875
|
+
* Seal a `rine.v1.group_invite` notification to the invitee and POST it.
|
|
1876
|
+
*
|
|
1877
|
+
* Reuses the 1:1 DM crypto path (`fetchRecipientKeys` + `encryptMessage`), so
|
|
1878
|
+
* hybrid PQ is auto-negotiated when the invitee publishes an ML-KEM key. The
|
|
1879
|
+
* sealed payload carries `invited_by = inviterAgentId` (also cryptographically
|
|
1880
|
+
* bound via the sender signature). `X-Rine-Agent` selects the inviter as sender.
|
|
1881
|
+
*
|
|
1882
|
+
* Best-effort by contract: callers wrap this so a failure (e.g. groups_only
|
|
1883
|
+
* 403, missing keys) warns but never fails the invite.
|
|
1884
|
+
*/
|
|
1885
|
+
async function sendGroupInviteNotification(client, configDir, inviterAgentId, inviteeAgentId, invite) {
|
|
1886
|
+
const recipientKeys = await fetchRecipientKeys(client, inviteeAgentId);
|
|
1887
|
+
const payload = {
|
|
1888
|
+
group_id: invite.group_id,
|
|
1889
|
+
group_handle: invite.group_handle,
|
|
1890
|
+
invited_by: inviterAgentId,
|
|
1891
|
+
message: invite.message
|
|
1892
|
+
};
|
|
1893
|
+
const encrypted = await encryptMessage(configDir, inviterAgentId, recipientKeys.encryption, payload, recipientKeys.pqEncryption);
|
|
1894
|
+
await client.post("/messages", {
|
|
1895
|
+
to_agent_id: inviteeAgentId,
|
|
1896
|
+
type: GROUP_INVITE_TYPE,
|
|
1897
|
+
...encrypted
|
|
1898
|
+
}, { "X-Rine-Agent": inviterAgentId });
|
|
1899
|
+
}
|
|
1900
|
+
/**
|
|
1901
|
+
* List the calling agent's open group invites via the authoritative pull
|
|
1902
|
+
* endpoint `GET /groups/invites`. `extraHeaders` lets multi-agent orgs pass
|
|
1903
|
+
* `X-Rine-Agent` to disambiguate which agent's invites to list.
|
|
1904
|
+
*/
|
|
1905
|
+
async function listMyInvites(client, extraHeaders) {
|
|
1906
|
+
return (await client.get("/groups/invites", void 0, extraHeaders)).items;
|
|
1907
|
+
}
|
|
1908
|
+
//#endregion
|
|
1909
|
+
//#region src/funnel/backoff.ts
|
|
1910
|
+
const BACKOFF_BASE_MS = 3e4;
|
|
1911
|
+
const BACKOFF_FACTOR = 2;
|
|
1912
|
+
const BACKOFF_CAP_MS = 36e5;
|
|
1913
|
+
const RENEWAL_WINDOW_MS = 30 * 864e5;
|
|
1914
|
+
/**
|
|
1915
|
+
* Full-jitter exponential backoff: a uniform random delay in `[0, ceiling]`
|
|
1916
|
+
* where `ceiling = base * factor^(n-1)`, capped at 1h. Full jitter (not just a
|
|
1917
|
+
* jittered fixed delay) is what de-correlates a fleet of crash-looping relays.
|
|
1918
|
+
* `n` is the 1-based consecutive-failure count (n<=0 is treated as the first).
|
|
1919
|
+
*/
|
|
1920
|
+
function nextBackoffDelayMs(consecutiveFailures) {
|
|
1921
|
+
const ceiling = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * BACKOFF_FACTOR ** (Math.max(1, consecutiveFailures) - 1));
|
|
1922
|
+
return Math.floor(Math.random() * (ceiling + 1));
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1925
|
+
* A cert is due for renewal when `not_after - now <= 30 days` (boundary
|
|
1926
|
+
* inclusive). An already-expired cert is due (and forces a cold issuance).
|
|
1927
|
+
*/
|
|
1928
|
+
function isRenewalDue(meta, nowMs) {
|
|
1929
|
+
const notAfterMs = Date.parse(meta.not_after);
|
|
1930
|
+
if (Number.isNaN(notAfterMs)) return true;
|
|
1931
|
+
return notAfterMs - nowMs <= RENEWAL_WINDOW_MS;
|
|
1932
|
+
}
|
|
1933
|
+
/**
|
|
1934
|
+
* TERMINAL = an LE rate-limit ceiling that no amount of retrying can clear in
|
|
1935
|
+
* the near term (the duplicate-certificate limit: 5 identical FQDN sets / 7
|
|
1936
|
+
* days, and the per-account failed-validation limit). On a terminal error the
|
|
1937
|
+
* relay stops attempting and surfaces it rather than burning the LE budget.
|
|
1938
|
+
* Ordinary transient failures (timeouts, propagation, provider 502) are NOT
|
|
1939
|
+
* terminal — they retry on the backoff schedule.
|
|
1940
|
+
*/
|
|
1941
|
+
function isTerminalAcmeError(err) {
|
|
1942
|
+
if (typeof err !== "object" || err === null) return false;
|
|
1943
|
+
const tag = err.leRateLimit;
|
|
1944
|
+
if (tag === "duplicate-certificate" || tag === "failed-validation") return true;
|
|
1945
|
+
const msg = err instanceof Error ? err.message.toLowerCase() : "";
|
|
1946
|
+
return msg.includes("too many certificates") || msg.includes("too many failed authorizations");
|
|
1947
|
+
}
|
|
1948
|
+
//#endregion
|
|
1949
|
+
//#region src/funnel/cert-cache.ts
|
|
1950
|
+
const LE_PROD = "https://acme-v02.api.letsencrypt.org/directory";
|
|
1951
|
+
const LE_STAGING = "https://acme-staging-v02.api.letsencrypt.org/directory";
|
|
1952
|
+
/**
|
|
1953
|
+
* Resolve the ACME directory URL. Production by default; staging when either
|
|
1954
|
+
* `opts.staging` is set or `RINE_ACME_STAGING=1` is in the environment, so dev
|
|
1955
|
+
* never burns the production LE rate limits. Shares the single `isStaging`
|
|
1956
|
+
* decision with the cache-path split so a cert's directory_url and its cache
|
|
1957
|
+
* subtree are always derived from the same flag.
|
|
1958
|
+
*/
|
|
1959
|
+
function acmeDirectoryUrl(opts) {
|
|
1960
|
+
return isStaging(opts) ? LE_STAGING : LE_PROD;
|
|
1961
|
+
}
|
|
1962
|
+
const CERT_FILE = "cert.pem";
|
|
1963
|
+
const KEY_FILE = "key.pem";
|
|
1964
|
+
const META_FILE = "meta.json";
|
|
1965
|
+
function isStaging(opts) {
|
|
1966
|
+
return opts.staging === true || process.env.RINE_ACME_STAGING === "1";
|
|
1967
|
+
}
|
|
1968
|
+
/** `${configDir}/funnel` (prod) or `${configDir}/funnel/staging` (staging). */
|
|
1969
|
+
function funnelCacheDir(configDir, opts) {
|
|
1970
|
+
const root = join(configDir, "funnel");
|
|
1971
|
+
return isStaging(opts) ? join(root, "staging") : root;
|
|
1972
|
+
}
|
|
1973
|
+
/** The per-handle cache dir under the (staging-segregated) funnel root. */
|
|
1974
|
+
function handleCacheDir(configDir, handle, opts) {
|
|
1975
|
+
return join(funnelCacheDir(configDir, opts), handle);
|
|
1976
|
+
}
|
|
1977
|
+
/** One LE account key per relay, shared across handles, at the funnel root. */
|
|
1978
|
+
function accountKeyPath(configDir, opts) {
|
|
1979
|
+
return join(funnelCacheDir(configDir, opts), "acme-account.key");
|
|
1980
|
+
}
|
|
1981
|
+
function writeSecret(path, content) {
|
|
1982
|
+
const tmp = `${path}.tmp`;
|
|
1983
|
+
fs.writeFileSync(tmp, content, { mode: 384 });
|
|
1984
|
+
fs.renameSync(tmp, path);
|
|
1985
|
+
fs.chmodSync(path, 384);
|
|
1986
|
+
}
|
|
1987
|
+
function ensureHandleDir(configDir, handle, opts) {
|
|
1988
|
+
const dir = handleCacheDir(configDir, handle, opts);
|
|
1989
|
+
fs.mkdirSync(dir, {
|
|
1990
|
+
recursive: true,
|
|
1991
|
+
mode: 448
|
|
1992
|
+
});
|
|
1993
|
+
fs.chmodSync(dir, 448);
|
|
1994
|
+
return dir;
|
|
1995
|
+
}
|
|
1996
|
+
function saveMeta(configDir, handle, opts, meta) {
|
|
1997
|
+
writeSecret(join(ensureHandleDir(configDir, handle, opts), META_FILE), `${JSON.stringify(meta, null, 2)}\n`);
|
|
1998
|
+
}
|
|
1999
|
+
function loadMeta(configDir, handle, opts) {
|
|
2000
|
+
const path = join(handleCacheDir(configDir, handle, opts), META_FILE);
|
|
2001
|
+
try {
|
|
2002
|
+
return JSON.parse(fs.readFileSync(path, "utf-8"));
|
|
2003
|
+
} catch {
|
|
2004
|
+
return null;
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
function saveCert(configDir, handle, opts, cert) {
|
|
2008
|
+
const dir = ensureHandleDir(configDir, handle, opts);
|
|
2009
|
+
writeSecret(join(dir, KEY_FILE), cert.keyPem);
|
|
2010
|
+
writeSecret(join(dir, CERT_FILE), cert.certPem);
|
|
2011
|
+
saveMeta(configDir, handle, opts, cert.meta);
|
|
2012
|
+
}
|
|
2013
|
+
/**
|
|
2014
|
+
* Returns the cached cert ONLY if cert+key+meta are all present AND the meta's
|
|
2015
|
+
* directory_url matches the requested mode (staging cert never served in prod).
|
|
2016
|
+
* Any partial/corrupt cache reads back as `null` — never a half-cert.
|
|
2017
|
+
*/
|
|
2018
|
+
function loadCachedCert(configDir, handle, opts) {
|
|
2019
|
+
const dir = handleCacheDir(configDir, handle, opts);
|
|
2020
|
+
try {
|
|
2021
|
+
const certPem = fs.readFileSync(join(dir, CERT_FILE), "utf-8");
|
|
2022
|
+
const keyPem = fs.readFileSync(join(dir, KEY_FILE), "utf-8");
|
|
2023
|
+
const meta = JSON.parse(fs.readFileSync(join(dir, META_FILE), "utf-8"));
|
|
2024
|
+
if (!certPem || !keyPem || !meta.not_after) return null;
|
|
2025
|
+
if (meta.directory_url !== acmeDirectoryUrl(opts)) return null;
|
|
2026
|
+
return {
|
|
2027
|
+
certPem,
|
|
2028
|
+
keyPem,
|
|
2029
|
+
meta
|
|
2030
|
+
};
|
|
2031
|
+
} catch {
|
|
2032
|
+
return null;
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
/** Scoped wipe of one handle's cache dir (REQ-CERT-16). Other handles untouched. */
|
|
2036
|
+
function wipeHandleCache(configDir, handle, opts) {
|
|
2037
|
+
fs.rmSync(handleCacheDir(configDir, handle, opts), {
|
|
2038
|
+
recursive: true,
|
|
2039
|
+
force: true
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
2042
|
+
//#endregion
|
|
2043
|
+
//#region src/funnel/csr.ts
|
|
2044
|
+
const HOOK_ZONE = "hook.rine.network";
|
|
2045
|
+
/** The publicly-trusted hostname this relay terminates TLS for. */
|
|
2046
|
+
function hookFqdn(handle) {
|
|
2047
|
+
return `${handle}.${HOOK_ZONE}`;
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* Generate a fresh EC P-256 keypair (node:crypto) and a CSR for
|
|
2051
|
+
* `<handle>.hook.rine.network` whose CN and single SAN are that FQDN. A fresh,
|
|
2052
|
+
* unique key is minted on every call — no key reuse across issuances/renewals.
|
|
2053
|
+
*/
|
|
2054
|
+
async function generateCertKeypairAndCsr(handle) {
|
|
2055
|
+
const fqdn = hookFqdn(handle);
|
|
2056
|
+
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
2057
|
+
const keyPem = privateKey.export({
|
|
2058
|
+
type: "pkcs8",
|
|
2059
|
+
format: "pem"
|
|
2060
|
+
}).toString();
|
|
2061
|
+
const [, csrBuf] = await crypto$1.createCsr({
|
|
2062
|
+
commonName: fqdn,
|
|
2063
|
+
altNames: [fqdn]
|
|
2064
|
+
}, keyPem);
|
|
2065
|
+
return {
|
|
2066
|
+
keyPem,
|
|
2067
|
+
csrPem: csrBuf.toString(),
|
|
2068
|
+
commonName: fqdn,
|
|
2069
|
+
subjectAltNames: [fqdn],
|
|
2070
|
+
keyAlgorithm: "EC",
|
|
2071
|
+
keyCurve: "P-256"
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
//#endregion
|
|
2075
|
+
//#region src/funnel/issue.ts
|
|
2076
|
+
const DEFAULT_CERT_LIFETIME_MS = 90 * 864e5;
|
|
2077
|
+
function certNotAfter(certPem, nowMs) {
|
|
2078
|
+
try {
|
|
2079
|
+
return crypto$1.readCertificateInfo(certPem).notAfter.toISOString();
|
|
2080
|
+
} catch {
|
|
2081
|
+
return new Date(nowMs + DEFAULT_CERT_LIFETIME_MS).toISOString();
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
/**
|
|
2085
|
+
* Run one ACME DNS-01 issuance. Sequence (REQ-CERT-10):
|
|
2086
|
+
* newOrder -> dns:set -> waitForPropagation -> validate -> download -> dns:clear
|
|
2087
|
+
* The TXT is cleared in a `finally` so a FAILED order still cleans up its record
|
|
2088
|
+
* (no lingering challenge). Propagation is awaited BEFORE asking LE to validate
|
|
2089
|
+
* so a slow zone never burns a validation attempt.
|
|
2090
|
+
*/
|
|
2091
|
+
async function issueCert(args) {
|
|
2092
|
+
const { handle, delegationId, directoryUrl, deps } = args;
|
|
2093
|
+
const { keyPem, csrPem } = await generateCertKeypairAndCsr(handle);
|
|
2094
|
+
const order = await deps.acme.newOrder({
|
|
2095
|
+
handle,
|
|
2096
|
+
directoryUrl
|
|
2097
|
+
});
|
|
2098
|
+
if (!order.challengeTypesOffered.includes("dns-01")) throw new Error(`DNS-01 unavailable: order offered only [${order.challengeTypesOffered.join(", ")}]`);
|
|
2099
|
+
let txtSet = false;
|
|
2100
|
+
try {
|
|
2101
|
+
const setResult = await deps.dnsPost("set", order.dnsChallengeToken);
|
|
2102
|
+
txtSet = true;
|
|
2103
|
+
const fqdn = setResult.fqdn ?? `_acme-challenge.${delegationId}.hook.rine.network`;
|
|
2104
|
+
await deps.waitForPropagation({ fqdn });
|
|
2105
|
+
await deps.acme.validate({ handle });
|
|
2106
|
+
const { certPem } = await deps.acme.finalizeAndDownload({
|
|
2107
|
+
handle,
|
|
2108
|
+
csrPem
|
|
2109
|
+
});
|
|
2110
|
+
return {
|
|
2111
|
+
certPem,
|
|
2112
|
+
keyPem,
|
|
2113
|
+
meta: {
|
|
2114
|
+
directory_url: directoryUrl,
|
|
2115
|
+
not_after: certNotAfter(certPem, deps.now()),
|
|
2116
|
+
delegation_id: delegationId,
|
|
2117
|
+
last_attempt_ts: deps.now(),
|
|
2118
|
+
consecutive_failures: 0
|
|
2119
|
+
}
|
|
2120
|
+
};
|
|
2121
|
+
} finally {
|
|
2122
|
+
if (txtSet) await deps.dnsPost("clear");
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
//#endregion
|
|
2126
|
+
//#region src/funnel/cert.ts
|
|
2127
|
+
/**
|
|
2128
|
+
* Cold-start readiness gate (REQ-CERT-32) + cache reuse + persisted backoff
|
|
2129
|
+
* (REQ-CERT-13). A valid, fresh cached cert opens the gate immediately with NO
|
|
2130
|
+
* ACME round-trip. Otherwise issue, persist atomically, and reset the failure
|
|
2131
|
+
* counter on success. On failure the persisted `consecutive_failures` is bumped
|
|
2132
|
+
* (read from the prior meta so a crash-loop never resets it to zero) and the
|
|
2133
|
+
* error re-thrown — a failed cold start installs NOTHING (gate stays closed).
|
|
2134
|
+
*/
|
|
2135
|
+
async function ensureCert(args) {
|
|
2136
|
+
const { handle, agentId, delegationId, configDir, options, deps } = args;
|
|
2137
|
+
const directoryUrl = acmeDirectoryUrl(options);
|
|
2138
|
+
const cached = loadCachedCert(configDir, handle, options);
|
|
2139
|
+
if (cached && !isRenewalDue(cached.meta, deps.now())) return {
|
|
2140
|
+
ready: true,
|
|
2141
|
+
fromCache: true,
|
|
2142
|
+
...cached
|
|
2143
|
+
};
|
|
2144
|
+
const priorFailures = loadMeta(configDir, handle, options)?.consecutive_failures ?? 0;
|
|
2145
|
+
try {
|
|
2146
|
+
const issued = await issueCert({
|
|
2147
|
+
handle,
|
|
2148
|
+
agentId,
|
|
2149
|
+
delegationId,
|
|
2150
|
+
directoryUrl,
|
|
2151
|
+
deps
|
|
2152
|
+
});
|
|
2153
|
+
saveCert(configDir, handle, options, issued);
|
|
2154
|
+
return {
|
|
2155
|
+
ready: true,
|
|
2156
|
+
fromCache: false,
|
|
2157
|
+
...issued
|
|
2158
|
+
};
|
|
2159
|
+
} catch (err) {
|
|
2160
|
+
persistFailure(configDir, handle, options, {
|
|
2161
|
+
delegationId,
|
|
2162
|
+
directoryUrl,
|
|
2163
|
+
priorFailures,
|
|
2164
|
+
nowMs: deps.now()
|
|
2165
|
+
});
|
|
2166
|
+
throw isTerminalAcmeError(err) ? new Error(`terminal ACME error (LE rate limit): ${describe(err)}`, { cause: err }) : err;
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
/**
|
|
2170
|
+
* Graceful teardown (REQ-CERT-16): best-effort ACME revoke (relay-local — the
|
|
2171
|
+
* server holds no key and cannot revoke, REQ-CERT-15), then a SCOPED wipe of
|
|
2172
|
+
* just this handle's cache dir. A revoke failure (offline) is swallowed; the
|
|
2173
|
+
* local wipe is the meaningful local effect and always runs.
|
|
2174
|
+
*/
|
|
2175
|
+
async function revokeAndWipeCache(args) {
|
|
2176
|
+
const { handle, configDir, options, deps } = args;
|
|
2177
|
+
const cached = loadCachedCert(configDir, handle, options);
|
|
2178
|
+
if (cached) try {
|
|
2179
|
+
await deps.acme.revoke({
|
|
2180
|
+
certPem: cached.certPem,
|
|
2181
|
+
directoryUrl: cached.meta.directory_url
|
|
2182
|
+
});
|
|
2183
|
+
} catch {}
|
|
2184
|
+
wipeHandleCache(configDir, handle, options);
|
|
2185
|
+
}
|
|
2186
|
+
function persistFailure(configDir, handle, options, ctx) {
|
|
2187
|
+
const existing = loadMeta(configDir, handle, options);
|
|
2188
|
+
saveMeta(configDir, handle, options, {
|
|
2189
|
+
directory_url: existing?.directory_url ?? ctx.directoryUrl,
|
|
2190
|
+
not_after: existing?.not_after ?? new Date(ctx.nowMs).toISOString(),
|
|
2191
|
+
delegation_id: existing?.delegation_id ?? ctx.delegationId,
|
|
2192
|
+
last_attempt_ts: ctx.nowMs,
|
|
2193
|
+
consecutive_failures: ctx.priorFailures + 1
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
function describe(err) {
|
|
2197
|
+
return err instanceof Error ? err.message : String(err);
|
|
2198
|
+
}
|
|
2199
|
+
//#endregion
|
|
2200
|
+
//#region src/funnel/propagation.ts
|
|
2201
|
+
const PROPAGATION_TIMEOUT_MS = 12e4;
|
|
2202
|
+
const PROPAGATION_INTERVAL_MS = 5e3;
|
|
2203
|
+
function sleep(ms) {
|
|
2204
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2205
|
+
}
|
|
2206
|
+
/**
|
|
2207
|
+
* The authoritative NS hostnames for the zone that contains `fqdn`. The
|
|
2208
|
+
* challenge name (`_acme-challenge.<label>.hook.rine.network`) is BELOW the zone
|
|
2209
|
+
* apex, where neither SOA nor NS records live, so we walk UP one label at a time
|
|
2210
|
+
* asking for NS until a parent answers (the apex — e.g. `rine.network`). A plain
|
|
2211
|
+
* `resolveSoa(fqdn)` returns ENODATA at a sub-apex name, which is why the prior
|
|
2212
|
+
* SOA->NS approach silently fell through to the host resolver every time.
|
|
2213
|
+
*/
|
|
2214
|
+
async function nsHostsForZoneOf(fqdn) {
|
|
2215
|
+
let name = fqdn.replace(/\.$/, "");
|
|
2216
|
+
while (name.includes(".")) {
|
|
2217
|
+
try {
|
|
2218
|
+
const ns = await resolveNs(name);
|
|
2219
|
+
if (ns.length > 0) return ns;
|
|
2220
|
+
} catch {}
|
|
2221
|
+
name = name.slice(name.indexOf(".") + 1);
|
|
2222
|
+
}
|
|
2223
|
+
throw new Error(`no authoritative NS found for any parent of ${fqdn}`);
|
|
2224
|
+
}
|
|
2225
|
+
/**
|
|
2226
|
+
* Build a resolver pinned to the AUTHORITATIVE nameservers of `fqdn`'s zone
|
|
2227
|
+
* (NS -> A, then `Resolver.setServers`). Falls back to the system resolver if NS
|
|
2228
|
+
* discovery fails (best-effort — the bounded poll still backstops).
|
|
2229
|
+
*/
|
|
2230
|
+
async function authoritativeResolver(fqdn) {
|
|
2231
|
+
const resolver = new Resolver();
|
|
2232
|
+
try {
|
|
2233
|
+
const nsHosts = await nsHostsForZoneOf(fqdn);
|
|
2234
|
+
const addresses = (await Promise.all(nsHosts.map((ns) => resolve4(ns)))).flat().filter(Boolean);
|
|
2235
|
+
if (addresses.length > 0) resolver.setServers(addresses);
|
|
2236
|
+
} catch {}
|
|
2237
|
+
return resolver;
|
|
2238
|
+
}
|
|
2239
|
+
/**
|
|
2240
|
+
* Poll the authoritative DNS for the challenge TXT at the server-returned
|
|
2241
|
+
* `_acme-challenge.<cert-domain>.hook.rine.network` until it resolves (bounded,
|
|
2242
|
+
* ≤120s) before telling LE to validate, so a slow zone never burns a validation
|
|
2243
|
+
* attempt. The FQDN is the one the backend wrote the TXT under (the
|
|
2244
|
+
* dns-challenge `set` response's `fqdn`) — never derived from a relay-side guess.
|
|
2245
|
+
*/
|
|
2246
|
+
async function waitForTxtPropagation(args) {
|
|
2247
|
+
const { fqdn } = args;
|
|
2248
|
+
const resolver = await authoritativeResolver(fqdn);
|
|
2249
|
+
const deadline = Date.now() + PROPAGATION_TIMEOUT_MS;
|
|
2250
|
+
while (Date.now() < deadline) {
|
|
2251
|
+
try {
|
|
2252
|
+
if ((await resolver.resolveTxt(fqdn)).length > 0) return;
|
|
2253
|
+
} catch {}
|
|
2254
|
+
await sleep(PROPAGATION_INTERVAL_MS);
|
|
2255
|
+
}
|
|
2256
|
+
throw new Error(`DNS propagation timeout for ${fqdn}`);
|
|
2257
|
+
}
|
|
2258
|
+
//#endregion
|
|
2259
|
+
//#region src/funnel/acme.ts
|
|
2260
|
+
/**
|
|
2261
|
+
* Tag an LE rate-limit failure so backoff.ts classifies it as TERMINAL. Matches
|
|
2262
|
+
* the stable LE stem `too many certificates` so the modern parenthetical-count
|
|
2263
|
+
* wording (`too many certificates (5) already issued ...`) is still caught —
|
|
2264
|
+
* see backoff.ts isTerminalAcmeError, which uses the same stems.
|
|
2265
|
+
*/
|
|
2266
|
+
function tagLeRateLimit(err) {
|
|
2267
|
+
const msg = err.message.toLowerCase();
|
|
2268
|
+
if (msg.includes("too many certificates")) err.leRateLimit = "duplicate-certificate";
|
|
2269
|
+
else if (msg.includes("too many failed authorizations")) err.leRateLimit = "failed-validation";
|
|
2270
|
+
return err;
|
|
2271
|
+
}
|
|
2272
|
+
/**
|
|
2273
|
+
* POST to the backend dns-challenge endpoint, honoring a 429 + Retry-After
|
|
2274
|
+
* (the server-side rate-limit backstop, REQ-CERT-07). The body carries ONLY
|
|
2275
|
+
* {action, value?} — never the FQDN, never key material.
|
|
2276
|
+
*/
|
|
2277
|
+
function makeDnsPost(http, agentId, agentHeaders = {}) {
|
|
2278
|
+
const path = `/agents/${agentId}/funnel/dns-challenge`;
|
|
2279
|
+
return async (action, value) => {
|
|
2280
|
+
const body = action === "set" ? {
|
|
2281
|
+
action,
|
|
2282
|
+
value
|
|
2283
|
+
} : { action };
|
|
2284
|
+
try {
|
|
2285
|
+
return await http.post(path, body, agentHeaders);
|
|
2286
|
+
} catch (err) {
|
|
2287
|
+
if (err instanceof RineApiError && err.status === 429) {
|
|
2288
|
+
const retryAfter = retryAfterMs(err);
|
|
2289
|
+
if (retryAfter > 0) await sleep(retryAfter);
|
|
2290
|
+
return await http.post(path, body, agentHeaders);
|
|
2291
|
+
}
|
|
2292
|
+
throw err;
|
|
2293
|
+
}
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
function retryAfterMs(err) {
|
|
2297
|
+
const res = err.raw;
|
|
2298
|
+
if (res instanceof Response) {
|
|
2299
|
+
const header = res.headers.get("retry-after");
|
|
2300
|
+
const secs = header ? Number(header) : NaN;
|
|
2301
|
+
if (Number.isFinite(secs) && secs > 0) return secs * 1e3;
|
|
2302
|
+
}
|
|
2303
|
+
return 0;
|
|
2304
|
+
}
|
|
2305
|
+
/**
|
|
2306
|
+
* Build the production ACME deps. The acme-client `Client` is the order driver;
|
|
2307
|
+
* a per-issuance closure holds the order/challenge between newOrder→validate→
|
|
2308
|
+
* download so the orchestrator's step boundaries map onto the RFC 8555 flow.
|
|
2309
|
+
*/
|
|
2310
|
+
function makeAcmeClientDeps(client) {
|
|
2311
|
+
let pending;
|
|
2312
|
+
return { acme: {
|
|
2313
|
+
async newOrder({ handle }) {
|
|
2314
|
+
const fqdn = hookFqdn(handle);
|
|
2315
|
+
try {
|
|
2316
|
+
const order = await client.createOrder({ identifiers: [{
|
|
2317
|
+
type: "dns",
|
|
2318
|
+
value: fqdn
|
|
2319
|
+
}] });
|
|
2320
|
+
const authz = (await client.getAuthorizations(order))[0];
|
|
2321
|
+
if (!authz) throw new Error("no authorization on order");
|
|
2322
|
+
const dns = authz.challenges.find((c) => c.type === "dns-01");
|
|
2323
|
+
const offered = authz.challenges.map((c) => c.type);
|
|
2324
|
+
if (dns) {
|
|
2325
|
+
const keyAuth = await client.getChallengeKeyAuthorization(dns);
|
|
2326
|
+
pending = {
|
|
2327
|
+
order,
|
|
2328
|
+
authz,
|
|
2329
|
+
challenge: dns
|
|
2330
|
+
};
|
|
2331
|
+
return {
|
|
2332
|
+
dnsChallengeToken: keyAuth,
|
|
2333
|
+
challengeTypesOffered: offered
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
return {
|
|
2337
|
+
dnsChallengeToken: "",
|
|
2338
|
+
challengeTypesOffered: offered
|
|
2339
|
+
};
|
|
2340
|
+
} catch (err) {
|
|
2341
|
+
throw tagLeRateLimit(err);
|
|
2342
|
+
}
|
|
2343
|
+
},
|
|
2344
|
+
async validate() {
|
|
2345
|
+
if (!pending) throw new Error("validate called before newOrder");
|
|
2346
|
+
await client.completeChallenge(pending.challenge);
|
|
2347
|
+
await client.waitForValidStatus(pending.authz);
|
|
2348
|
+
},
|
|
2349
|
+
async finalizeAndDownload({ csrPem }) {
|
|
2350
|
+
if (!pending) throw new Error("finalize called before newOrder");
|
|
2351
|
+
const order = await client.finalizeOrder(pending.order, csrPem);
|
|
2352
|
+
return { certPem: await client.getCertificate(order) };
|
|
2353
|
+
},
|
|
2354
|
+
async revoke({ certPem }) {
|
|
2355
|
+
await client.revokeCertificate(certPem);
|
|
2356
|
+
}
|
|
2357
|
+
} };
|
|
2358
|
+
}
|
|
2359
|
+
/**
|
|
2360
|
+
* Load (or mint + persist 0600) the ONE shared ACME account key for this relay,
|
|
2361
|
+
* then build an `acme-client` Client against the selected LE directory. One LE
|
|
2362
|
+
* account is reused across all of the relay's hooks (REQ-CERT-12), so repeated
|
|
2363
|
+
* issuances don't churn accounts. The account key never leaves the box.
|
|
2364
|
+
*/
|
|
2365
|
+
async function buildAcmeClient(configDir, options) {
|
|
2366
|
+
const keyPath = accountKeyPath(configDir, options);
|
|
2367
|
+
let accountKey;
|
|
2368
|
+
try {
|
|
2369
|
+
accountKey = fs.readFileSync(keyPath, "utf-8");
|
|
2370
|
+
} catch {
|
|
2371
|
+
accountKey = (await crypto$1.createPrivateEcdsaKey("P-256")).toString();
|
|
2372
|
+
fs.mkdirSync(dirname(keyPath), {
|
|
2373
|
+
recursive: true,
|
|
2374
|
+
mode: 448
|
|
2375
|
+
});
|
|
2376
|
+
const tmp = `${keyPath}.tmp`;
|
|
2377
|
+
fs.writeFileSync(tmp, accountKey, { mode: 384 });
|
|
2378
|
+
fs.renameSync(tmp, keyPath);
|
|
2379
|
+
fs.chmodSync(keyPath, 384);
|
|
2380
|
+
}
|
|
2381
|
+
const client = new Client({
|
|
2382
|
+
directoryUrl: acmeDirectoryUrl(options),
|
|
2383
|
+
accountKey
|
|
2384
|
+
});
|
|
2385
|
+
await client.createAccount({ termsOfServiceAgreed: true });
|
|
2386
|
+
return client;
|
|
2387
|
+
}
|
|
2388
|
+
/**
|
|
2389
|
+
* Assemble the full production `IssueCertDeps` the relay (Track E) injects into
|
|
2390
|
+
* `ensureCert` / `revokeAndWipeCache`. Pure wiring: the ACME client drives the
|
|
2391
|
+
* order, the HttpClient brokers the TXT, node:dns confirms propagation.
|
|
2392
|
+
*/
|
|
2393
|
+
async function buildIssueCertDeps(args) {
|
|
2394
|
+
return {
|
|
2395
|
+
...makeAcmeClientDeps(await buildAcmeClient(args.configDir, args.options)),
|
|
2396
|
+
dnsPost: makeDnsPost(args.http, args.agentId, args.agentHeaders),
|
|
2397
|
+
waitForPropagation: waitForTxtPropagation,
|
|
2398
|
+
now: () => Date.now()
|
|
2399
|
+
};
|
|
2400
|
+
}
|
|
2401
|
+
//#endregion
|
|
2402
|
+
//#region src/funnel/mux-codec.ts
|
|
2403
|
+
/** Fixed mux header size: type(1) + conn_id(4) + len(4). */
|
|
2404
|
+
const HEADER_LEN = 9;
|
|
2405
|
+
/** conn_id 0 is reserved for the control plane (BIND and keepalive). */
|
|
2406
|
+
const CONTROL_CONN_ID = 0;
|
|
2407
|
+
/** Frame type discriminants. WINDOW (0x07) is intentionally absent. */
|
|
2408
|
+
const FrameType = {
|
|
2409
|
+
OPEN: 1,
|
|
2410
|
+
DATA: 2,
|
|
2411
|
+
CLOSE: 3,
|
|
2412
|
+
RESET: 4,
|
|
2413
|
+
BIND: 16,
|
|
2414
|
+
BIND_ACK: 17,
|
|
2415
|
+
BIND_NAK: 18
|
|
2416
|
+
};
|
|
2417
|
+
const KNOWN_TYPES = new Set(Object.values(FrameType));
|
|
2418
|
+
function isControl(t) {
|
|
2419
|
+
return t === FrameType.BIND || t === FrameType.BIND_ACK || t === FrameType.BIND_NAK;
|
|
2420
|
+
}
|
|
2421
|
+
/** A framing/protocol fault. The broker maps these to WS close codes (1002/1009). */
|
|
2422
|
+
var FrameError = class extends Error {
|
|
2423
|
+
constructor(message) {
|
|
2424
|
+
super(message);
|
|
2425
|
+
this.name = "FrameError";
|
|
2426
|
+
}
|
|
2427
|
+
};
|
|
2428
|
+
/**
|
|
2429
|
+
* Encode a frame into one WS-binary message body. Rejects an over-`maxFrameBytes`
|
|
2430
|
+
* payload and any type the codec does not know (so the relay can never put a
|
|
2431
|
+
* WINDOW/0x07 frame on the wire) before allocating.
|
|
2432
|
+
*/
|
|
2433
|
+
function encodeFrame(frame, maxFrameBytes) {
|
|
2434
|
+
if (!KNOWN_TYPES.has(frame.frameType)) throw new FrameError(`refusing to encode unknown frame type 0x${frame.frameType.toString(16)}`);
|
|
2435
|
+
const len = frame.payload.length;
|
|
2436
|
+
if (len > maxFrameBytes) throw new FrameError(`frame payload ${len} exceeds max_frame_bytes ${maxFrameBytes}`);
|
|
2437
|
+
const out = new Uint8Array(9 + len);
|
|
2438
|
+
const view = new DataView(out.buffer);
|
|
2439
|
+
out[0] = frame.frameType;
|
|
2440
|
+
view.setUint32(1, frame.connId, false);
|
|
2441
|
+
view.setUint32(5, len, false);
|
|
2442
|
+
out.set(frame.payload, 9);
|
|
2443
|
+
return out;
|
|
2444
|
+
}
|
|
2445
|
+
/**
|
|
2446
|
+
* Decode one WS-binary message body into a Frame, enforcing every REQ-TUN-05
|
|
2447
|
+
* rule: unknown/reserved type (incl. WINDOW 0x07), short header, declared-len
|
|
2448
|
+
* mismatch, conn_id=0 on a data frame, non-zero conn_id on a control frame, and
|
|
2449
|
+
* an over-`maxFrameBytes` payload.
|
|
2450
|
+
*/
|
|
2451
|
+
function decodeFrame(buf, maxFrameBytes) {
|
|
2452
|
+
if (buf.length < 9) throw new FrameError(`frame shorter than 9-byte header: ${buf.length} bytes`);
|
|
2453
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
2454
|
+
const typeByte = buf[0];
|
|
2455
|
+
if (!KNOWN_TYPES.has(typeByte)) throw new FrameError(`unknown frame type byte: 0x${typeByte.toString(16)}`);
|
|
2456
|
+
const frameType = typeByte;
|
|
2457
|
+
const connId = view.getUint32(1, false);
|
|
2458
|
+
const declared = view.getUint32(5, false);
|
|
2459
|
+
const body = buf.subarray(9);
|
|
2460
|
+
if (declared !== body.length) throw new FrameError(`declared len ${declared} != actual payload ${body.length}`);
|
|
2461
|
+
if (isControl(frameType)) {
|
|
2462
|
+
if (connId !== 0) throw new FrameError(`control frame must use conn_id=0, got ${connId}`);
|
|
2463
|
+
} else if (connId === 0) throw new FrameError("conn_id=0 reserved for control plane, not valid on a data-plane frame");
|
|
2464
|
+
if (body.length > maxFrameBytes) throw new FrameError(`frame payload ${body.length} exceeds max_frame_bytes ${maxFrameBytes}`);
|
|
2465
|
+
return {
|
|
2466
|
+
frameType,
|
|
2467
|
+
connId,
|
|
2468
|
+
payload: body.slice()
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
//#endregion
|
|
2472
|
+
//#region src/funnel/mux-client.ts
|
|
2473
|
+
/** Per-conn in-flight cap before the relay pauses reading from the broker. */
|
|
2474
|
+
const PER_CONN_INFLIGHT_CAP = 256 * 1024;
|
|
2475
|
+
const DEFAULT_MAX_FRAME$1 = 65536;
|
|
2476
|
+
/**
|
|
2477
|
+
* Build a mux client over `ws`. `start()` wires the WS binary + close callbacks;
|
|
2478
|
+
* `handleFrame` is exposed so a caller (and the unit tests) can drive a single
|
|
2479
|
+
* decoded frame directly.
|
|
2480
|
+
*/
|
|
2481
|
+
function createMuxClient(args) {
|
|
2482
|
+
const { ws, openLocalPipe } = args;
|
|
2483
|
+
const maxFrame = args.maxFrameBytes ?? DEFAULT_MAX_FRAME$1;
|
|
2484
|
+
const conns = /* @__PURE__ */ new Map();
|
|
2485
|
+
function sendData(connId, bytes) {
|
|
2486
|
+
ws.send(encodeFrame({
|
|
2487
|
+
frameType: FrameType.DATA,
|
|
2488
|
+
connId,
|
|
2489
|
+
payload: bytes
|
|
2490
|
+
}, maxFrame));
|
|
2491
|
+
}
|
|
2492
|
+
function teardown(connId) {
|
|
2493
|
+
const state = conns.get(connId);
|
|
2494
|
+
if (!state) return;
|
|
2495
|
+
conns.delete(connId);
|
|
2496
|
+
state.pipe.close();
|
|
2497
|
+
}
|
|
2498
|
+
function openConn(frame) {
|
|
2499
|
+
const pipe = openLocalPipe(frame);
|
|
2500
|
+
const state = {
|
|
2501
|
+
pipe,
|
|
2502
|
+
inflight: 0,
|
|
2503
|
+
paused: false
|
|
2504
|
+
};
|
|
2505
|
+
conns.set(frame.connId, state);
|
|
2506
|
+
pipe.onData((bytes) => {
|
|
2507
|
+
sendData(frame.connId, bytes);
|
|
2508
|
+
state.inflight = Math.max(0, state.inflight - bytes.length);
|
|
2509
|
+
if (state.paused && state.inflight < 262144) {
|
|
2510
|
+
state.paused = false;
|
|
2511
|
+
state.pipe.resume?.();
|
|
2512
|
+
}
|
|
2513
|
+
});
|
|
2514
|
+
}
|
|
2515
|
+
function onData(frame) {
|
|
2516
|
+
const state = conns.get(frame.connId);
|
|
2517
|
+
if (!state) return;
|
|
2518
|
+
state.pipe.write(frame.payload);
|
|
2519
|
+
state.inflight += frame.payload.length;
|
|
2520
|
+
if (state.inflight > 262144 && !state.paused) {
|
|
2521
|
+
state.paused = true;
|
|
2522
|
+
state.pipe.pause?.();
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
function handleFrame(frame) {
|
|
2526
|
+
switch (frame.frameType) {
|
|
2527
|
+
case FrameType.OPEN:
|
|
2528
|
+
openConn(frame);
|
|
2529
|
+
break;
|
|
2530
|
+
case FrameType.DATA:
|
|
2531
|
+
onData(frame);
|
|
2532
|
+
break;
|
|
2533
|
+
case FrameType.CLOSE:
|
|
2534
|
+
case FrameType.RESET:
|
|
2535
|
+
teardown(frame.connId);
|
|
2536
|
+
break;
|
|
2537
|
+
default: break;
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
function resetAllConns() {
|
|
2541
|
+
for (const connId of [...conns.keys()]) teardown(connId);
|
|
2542
|
+
}
|
|
2543
|
+
return {
|
|
2544
|
+
start() {
|
|
2545
|
+
ws.onBinary((data) => {
|
|
2546
|
+
const frame = decodeFrame(data, maxFrame);
|
|
2547
|
+
if (frame.connId === 0) return;
|
|
2548
|
+
handleFrame(frame);
|
|
2549
|
+
});
|
|
2550
|
+
ws.onClose?.(() => resetAllConns());
|
|
2551
|
+
},
|
|
2552
|
+
handleFrame
|
|
2553
|
+
};
|
|
2554
|
+
}
|
|
2555
|
+
/**
|
|
2556
|
+
* Reconnect backoff progression, identical to stream.ts: double, capped at 30s.
|
|
2557
|
+
* `1 → 2 → 4 → 8 → 16 → 30 → 30`.
|
|
2558
|
+
*/
|
|
2559
|
+
function nextReconnectBackoffSeconds(backoffSeconds) {
|
|
2560
|
+
return Math.min(backoffSeconds * 2, 30);
|
|
2561
|
+
}
|
|
2562
|
+
/** Full jitter over the current backoff window — identical to stream.ts. */
|
|
2563
|
+
function jitteredDelayMs(backoffSeconds) {
|
|
2564
|
+
return Math.round(backoffSeconds * (.5 + Math.random()) * 1e3);
|
|
2565
|
+
}
|
|
2566
|
+
//#endregion
|
|
2567
|
+
//#region src/funnel/tunnel.ts
|
|
2568
|
+
const SUBPROTOCOL = "rine.funnel.v1";
|
|
2569
|
+
const DEFAULT_MAX_FRAME = 65536;
|
|
2570
|
+
const CLEAN_CLOSE_CODES = new Set([
|
|
2571
|
+
0,
|
|
2572
|
+
1e3,
|
|
2573
|
+
1001,
|
|
2574
|
+
1005
|
|
2575
|
+
]);
|
|
2576
|
+
function classifyClose(ev) {
|
|
2577
|
+
const code = ev.code ?? 1005;
|
|
2578
|
+
return {
|
|
2579
|
+
code,
|
|
2580
|
+
clean: ev.wasClean === true || CLEAN_CLOSE_CODES.has(code)
|
|
2581
|
+
};
|
|
2582
|
+
}
|
|
2583
|
+
/** A bind rejection (BIND_NAK / ownership failure). `revoked` stops the relay. */
|
|
2584
|
+
var TunnelBindError = class extends Error {
|
|
2585
|
+
revoked = true;
|
|
2586
|
+
constructor(reason) {
|
|
2587
|
+
super(`funnel binding rejected: ${reason}`);
|
|
2588
|
+
this.name = "TunnelBindError";
|
|
2589
|
+
}
|
|
2590
|
+
};
|
|
2591
|
+
/**
|
|
2592
|
+
* Adapt a Node 24 global WebSocket to the minimal MuxWs surface. The close
|
|
2593
|
+
* callback receives the classified close (code + clean flag); the mux driver
|
|
2594
|
+
* (MuxWs.onClose) ignores the arg and just resets its conns.
|
|
2595
|
+
*/
|
|
2596
|
+
function adaptWs(ws) {
|
|
2597
|
+
ws.binaryType = "arraybuffer";
|
|
2598
|
+
return {
|
|
2599
|
+
send: (data) => ws.send(data),
|
|
2600
|
+
onBinary: (cb) => {
|
|
2601
|
+
ws.addEventListener("message", (ev) => {
|
|
2602
|
+
if (ev.data instanceof ArrayBuffer) cb(new Uint8Array(ev.data));
|
|
2603
|
+
});
|
|
2604
|
+
},
|
|
2605
|
+
onClose: (cb) => ws.addEventListener("close", (ev) => cb(classifyClose(ev))),
|
|
2606
|
+
close: () => ws.close()
|
|
2607
|
+
};
|
|
2608
|
+
}
|
|
2609
|
+
function sendBind(ws, agentId, hostname) {
|
|
2610
|
+
const payload = new TextEncoder().encode(JSON.stringify({
|
|
2611
|
+
v: 1,
|
|
2612
|
+
agent_id: agentId,
|
|
2613
|
+
hostname
|
|
2614
|
+
}));
|
|
2615
|
+
ws.send(encodeFrame({
|
|
2616
|
+
frameType: FrameType.BIND,
|
|
2617
|
+
connId: 0,
|
|
2618
|
+
payload
|
|
2619
|
+
}, DEFAULT_MAX_FRAME));
|
|
2620
|
+
}
|
|
2621
|
+
/**
|
|
2622
|
+
* Open the control WS (Bearer at the upgrade — Node 24's global WebSocket takes
|
|
2623
|
+
* a `{protocols, headers}` options object), perform the BIND handshake, and wire
|
|
2624
|
+
* the mux driver. The returned handle resolves once BIND_ACK arrives; a BIND_NAK
|
|
2625
|
+
* rejects with a TunnelBindError (the relay treats it as revoked and stops).
|
|
2626
|
+
*/
|
|
2627
|
+
function connectTunnel(args) {
|
|
2628
|
+
const { controlWsUrl, token, agentId, hostname, openLocalPipe } = args;
|
|
2629
|
+
return new Promise((resolve, reject) => {
|
|
2630
|
+
const ws = new WebSocket(controlWsUrl, {
|
|
2631
|
+
protocols: [SUBPROTOCOL],
|
|
2632
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
2633
|
+
});
|
|
2634
|
+
const adapted = adaptWs(ws);
|
|
2635
|
+
let bound = false;
|
|
2636
|
+
ws.addEventListener("open", () => sendBind(adapted, agentId, hostname));
|
|
2637
|
+
ws.addEventListener("error", () => {
|
|
2638
|
+
if (!bound) reject(/* @__PURE__ */ new Error("control WS connect failed"));
|
|
2639
|
+
});
|
|
2640
|
+
adapted.onBinary((data) => {
|
|
2641
|
+
const frame = decodeFrame(data, DEFAULT_MAX_FRAME);
|
|
2642
|
+
if (frame.connId !== 0) return;
|
|
2643
|
+
if (frame.frameType === FrameType.BIND_ACK) {
|
|
2644
|
+
bound = true;
|
|
2645
|
+
const caps = JSON.parse(new TextDecoder().decode(frame.payload)).caps;
|
|
2646
|
+
createMuxClient({
|
|
2647
|
+
ws: adapted,
|
|
2648
|
+
openLocalPipe,
|
|
2649
|
+
maxFrameBytes: caps.max_frame_bytes
|
|
2650
|
+
}).start();
|
|
2651
|
+
resolve({
|
|
2652
|
+
caps,
|
|
2653
|
+
close: () => ws.close(),
|
|
2654
|
+
onClose: (cb) => adapted.onClose(cb)
|
|
2655
|
+
});
|
|
2656
|
+
} else if (frame.frameType === FrameType.BIND_NAK) {
|
|
2657
|
+
const reason = JSON.parse(new TextDecoder().decode(frame.payload)).reason;
|
|
2658
|
+
ws.close();
|
|
2659
|
+
reject(new TunnelBindError(reason ?? "unknown"));
|
|
2660
|
+
}
|
|
2661
|
+
});
|
|
2662
|
+
});
|
|
2663
|
+
}
|
|
2664
|
+
//#endregion
|
|
2665
|
+
//#region src/funnel/tls-listener.ts
|
|
2666
|
+
/**
|
|
2667
|
+
* Stand up the loopback TLS listener. Each accepted socket is one webhook
|
|
2668
|
+
* delivery: parse the HTTP request head + body, hand the RAW body to `onRequest`,
|
|
2669
|
+
* then write a minimal 204/400 response and close (GitHub only needs a 2xx).
|
|
2670
|
+
*/
|
|
2671
|
+
async function startTlsListener(args) {
|
|
2672
|
+
const { certPem, keyPem, onRequest } = args;
|
|
2673
|
+
const server = createServer({
|
|
2674
|
+
cert: certPem,
|
|
2675
|
+
key: keyPem
|
|
2676
|
+
}, (socket) => handleConnection(socket, onRequest));
|
|
2677
|
+
const port = await new Promise((resolve, reject) => {
|
|
2678
|
+
server.once("error", reject);
|
|
2679
|
+
server.listen(args.port, "127.0.0.1", () => {
|
|
2680
|
+
const addr = server.address();
|
|
2681
|
+
resolve(typeof addr === "object" && addr ? addr.port : args.port);
|
|
2682
|
+
});
|
|
2683
|
+
});
|
|
2684
|
+
return {
|
|
2685
|
+
port,
|
|
2686
|
+
close: () => server.close(),
|
|
2687
|
+
openLocalPipe: () => openLoopbackPipe(port)
|
|
2688
|
+
};
|
|
2689
|
+
}
|
|
2690
|
+
/**
|
|
2691
|
+
* Buffer one HTTP request off the TLS socket and dispatch as soon as the full
|
|
2692
|
+
* body has arrived. A real HTTP/1.1 client (GitHub) sends the request then BLOCKS
|
|
2693
|
+
* on the response WITHOUT half-closing, so triggering on the socket `"end"` (EOF)
|
|
2694
|
+
* deadlocks until the client's own timeout. Instead we dispatch the moment we have
|
|
2695
|
+
* the head plus a `Content-Length`-worth of body; `"end"` stays a fallback for the
|
|
2696
|
+
* no-body / chunked / EOF-terminated cases. `handled` guards single dispatch.
|
|
2697
|
+
*/
|
|
2698
|
+
function handleConnection(socket, onRequest) {
|
|
2699
|
+
const chunks = [];
|
|
2700
|
+
let handled = false;
|
|
2701
|
+
const dispatch = async (eof) => {
|
|
2702
|
+
if (handled) return;
|
|
2703
|
+
const raw = Buffer.concat(chunks);
|
|
2704
|
+
const sep = raw.indexOf("\r\n\r\n");
|
|
2705
|
+
if (sep < 0) {
|
|
2706
|
+
if (eof) {
|
|
2707
|
+
handled = true;
|
|
2708
|
+
socket.end("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n");
|
|
2709
|
+
}
|
|
2710
|
+
return;
|
|
2711
|
+
}
|
|
2712
|
+
const headers = parseHeaders(raw.subarray(0, sep).toString("latin1"));
|
|
2713
|
+
const body = raw.subarray(sep + 4);
|
|
2714
|
+
const declared = Number(headers["content-length"]);
|
|
2715
|
+
if (Number.isInteger(declared) && declared >= 0) {
|
|
2716
|
+
if (body.length < declared && !eof) return;
|
|
2717
|
+
} else if (!eof) return;
|
|
2718
|
+
handled = true;
|
|
2719
|
+
const rawBody = Number.isInteger(declared) && declared >= 0 ? body.subarray(0, declared) : body;
|
|
2720
|
+
try {
|
|
2721
|
+
await onRequest({
|
|
2722
|
+
headers,
|
|
2723
|
+
rawBody
|
|
2724
|
+
});
|
|
2725
|
+
socket.end("HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n");
|
|
2726
|
+
} catch {
|
|
2727
|
+
socket.end("HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n");
|
|
2728
|
+
}
|
|
2729
|
+
};
|
|
2730
|
+
socket.on("data", (c) => {
|
|
2731
|
+
chunks.push(c);
|
|
2732
|
+
dispatch(false);
|
|
2733
|
+
});
|
|
2734
|
+
socket.on("error", () => socket.destroy());
|
|
2735
|
+
socket.on("end", () => void dispatch(true));
|
|
2736
|
+
}
|
|
2737
|
+
/** Parse a CRLF-delimited HTTP head into a lowercased header map (skip the request line). */
|
|
2738
|
+
function parseHeaders(head) {
|
|
2739
|
+
const out = {};
|
|
2740
|
+
const lines = head.split("\r\n");
|
|
2741
|
+
for (const line of lines.slice(1)) {
|
|
2742
|
+
const idx = line.indexOf(":");
|
|
2743
|
+
if (idx > 0) out[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
|
|
2744
|
+
}
|
|
2745
|
+
return out;
|
|
2746
|
+
}
|
|
2747
|
+
/** One mux conn → one loopback TCP socket bridging broker bytes ↔ TLS listener. */
|
|
2748
|
+
function openLoopbackPipe(port) {
|
|
2749
|
+
const sock = connect(port, "127.0.0.1");
|
|
2750
|
+
let dataCb;
|
|
2751
|
+
sock.on("data", (c) => dataCb?.(new Uint8Array(c)));
|
|
2752
|
+
sock.on("error", () => sock.destroy());
|
|
2753
|
+
return {
|
|
2754
|
+
write: (bytes) => sock.write(bytes),
|
|
2755
|
+
onData: (cb) => {
|
|
2756
|
+
dataCb = cb;
|
|
2757
|
+
},
|
|
2758
|
+
close: () => sock.destroy(),
|
|
2759
|
+
pause: () => sock.pause(),
|
|
2760
|
+
resume: () => sock.resume()
|
|
2761
|
+
};
|
|
2762
|
+
}
|
|
2763
|
+
//#endregion
|
|
2764
|
+
//#region src/funnel/relay-adapter.ts
|
|
2765
|
+
const WEBHOOK_MESSAGE_TYPE = "rine.v1.webhook";
|
|
2766
|
+
/** Parse `sha256=<hex>` (X-Hub-Signature-256) or a bare hex (X-Hook-Signature). */
|
|
2767
|
+
function parseSignature(headers) {
|
|
2768
|
+
const raw = headers["x-hub-signature-256"] ?? headers["x-hook-signature"];
|
|
2769
|
+
if (!raw) return void 0;
|
|
2770
|
+
const hex = raw.startsWith("sha256=") ? raw.slice(7) : raw;
|
|
2771
|
+
if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length % 2 !== 0) return void 0;
|
|
2772
|
+
return Uint8Array.from(Buffer.from(hex, "hex"));
|
|
2773
|
+
}
|
|
2774
|
+
/**
|
|
2775
|
+
* Constant-time HMAC-SHA-256 verification over the EXACT raw bytes (no JSON
|
|
2776
|
+
* re-serialization). A length-mismatched signature fails SAFELY: timingSafeEqual
|
|
2777
|
+
* throws on unequal buffers, so we length-guard first.
|
|
2778
|
+
*/
|
|
2779
|
+
function verifyHmac(secret, rawBody, headers) {
|
|
2780
|
+
const received = parseSignature(headers);
|
|
2781
|
+
if (!received) return false;
|
|
2782
|
+
const computed = createHmac("sha256", secret).update(rawBody).digest();
|
|
2783
|
+
if (received.length !== computed.length) return false;
|
|
2784
|
+
return timingSafeEqual(received, computed);
|
|
2785
|
+
}
|
|
2786
|
+
/**
|
|
2787
|
+
* Verify → encrypt → self-send one webhook. Returns the outcome; never throws out
|
|
2788
|
+
* (the caller's tunnel must survive a single bad request). A verify failure drops
|
|
2789
|
+
* the conn with no post and no encrypt; a post/key-fetch failure drops the conn
|
|
2790
|
+
* after verify with `relayed:false`.
|
|
2791
|
+
*/
|
|
2792
|
+
async function handleWebhookRequest(deps, req) {
|
|
2793
|
+
const { configDir, agentId, hookName, secret, client, emitLifecycle } = deps;
|
|
2794
|
+
if (!verifyHmac(secret, req.rawBody, req.headers)) {
|
|
2795
|
+
emitLifecycle?.({
|
|
2796
|
+
event: "lifecycle",
|
|
2797
|
+
data: {
|
|
2798
|
+
state: "verify_failed",
|
|
2799
|
+
hook_name: hookName
|
|
2800
|
+
}
|
|
2801
|
+
});
|
|
2802
|
+
return {
|
|
2803
|
+
verified: false,
|
|
2804
|
+
relayed: false
|
|
2805
|
+
};
|
|
2806
|
+
}
|
|
2807
|
+
try {
|
|
2808
|
+
const recipientKeys = await fetchRecipientKeys(client, agentId);
|
|
2809
|
+
const payload = decodePayload(req.rawBody);
|
|
2810
|
+
const encrypted = await encryptMessage(configDir, agentId, recipientKeys.encryption, payload, recipientKeys.pqEncryption);
|
|
2811
|
+
const body = {
|
|
2812
|
+
to_agent_id: agentId,
|
|
2813
|
+
type: WEBHOOK_MESSAGE_TYPE,
|
|
2814
|
+
metadata: { "rine.hook_name": hookName },
|
|
2815
|
+
encrypted_payload: encrypted.encrypted_payload,
|
|
2816
|
+
encryption_version: encrypted.encryption_version,
|
|
2817
|
+
sender_signing_kid: encrypted.sender_signing_kid
|
|
2818
|
+
};
|
|
2819
|
+
const result = await client.post("/messages", body);
|
|
2820
|
+
emitLifecycle?.({
|
|
2821
|
+
event: "lifecycle",
|
|
2822
|
+
data: {
|
|
2823
|
+
state: "webhook_relayed",
|
|
2824
|
+
hook_name: hookName,
|
|
2825
|
+
message_id: result?.id
|
|
2826
|
+
}
|
|
2827
|
+
});
|
|
2828
|
+
return {
|
|
2829
|
+
verified: true,
|
|
2830
|
+
relayed: true
|
|
2831
|
+
};
|
|
2832
|
+
} catch {
|
|
2833
|
+
emitLifecycle?.({
|
|
2834
|
+
event: "lifecycle",
|
|
2835
|
+
data: {
|
|
2836
|
+
state: "relay_error",
|
|
2837
|
+
hook_name: hookName
|
|
2838
|
+
}
|
|
2839
|
+
});
|
|
2840
|
+
return {
|
|
2841
|
+
verified: true,
|
|
2842
|
+
relayed: false
|
|
2843
|
+
};
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
/** Best-effort JSON decode; fall back to the raw text so any body still relays. */
|
|
2847
|
+
function decodePayload(rawBody) {
|
|
2848
|
+
const text = new TextDecoder("utf-8").decode(rawBody);
|
|
2849
|
+
try {
|
|
2850
|
+
return JSON.parse(text);
|
|
2851
|
+
} catch {
|
|
2852
|
+
return text;
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2855
|
+
//#endregion
|
|
2856
|
+
//#region src/funnel/secret-store.ts
|
|
2857
|
+
/** DNS-label-safe hook name (mirrors the backend HOOK_NAME_PATTERN). */
|
|
2858
|
+
const HOOK_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
|
|
2859
|
+
function isValidHookName(name) {
|
|
2860
|
+
return HOOK_NAME_RE.test(name);
|
|
2861
|
+
}
|
|
2862
|
+
/** Generate a 32-byte HMAC secret as 64 hex chars (client-side only). */
|
|
2863
|
+
function generateHookSecret() {
|
|
2864
|
+
return randomBytes(32).toString("hex");
|
|
2865
|
+
}
|
|
2866
|
+
/** Directory holding one agent's funnel secrets. */
|
|
2867
|
+
function funnelSecretDir(configDir, agentId) {
|
|
2868
|
+
return join(configDir, "funnel", agentId);
|
|
2869
|
+
}
|
|
2870
|
+
/** Absolute path of a hook's secret file. */
|
|
2871
|
+
function hookSecretPath(configDir, agentId, name) {
|
|
2872
|
+
return join(funnelSecretDir(configDir, agentId), `${name}.secret`);
|
|
2873
|
+
}
|
|
2874
|
+
/**
|
|
2875
|
+
* Persist a hook secret atomically with file mode 0600 in a 0700 dir. Returns the
|
|
2876
|
+
* path it wrote so callers can tell the operator where the secret lives.
|
|
2877
|
+
*/
|
|
2878
|
+
function saveHookSecret(configDir, agentId, name, secret) {
|
|
2879
|
+
const dir = funnelSecretDir(configDir, agentId);
|
|
2880
|
+
fs.mkdirSync(dir, {
|
|
2881
|
+
recursive: true,
|
|
2882
|
+
mode: 448
|
|
2883
|
+
});
|
|
2884
|
+
const path = hookSecretPath(configDir, agentId, name);
|
|
2885
|
+
const tmp = `${path}.tmp`;
|
|
2886
|
+
fs.writeFileSync(tmp, secret, { mode: 384 });
|
|
2887
|
+
fs.renameSync(tmp, path);
|
|
2888
|
+
fs.chmodSync(path, 384);
|
|
2889
|
+
return path;
|
|
2890
|
+
}
|
|
2891
|
+
/** Read a hook secret, or undefined if it is not stored locally. */
|
|
2892
|
+
function loadHookSecret(configDir, agentId, name) {
|
|
2893
|
+
try {
|
|
2894
|
+
return fs.readFileSync(hookSecretPath(configDir, agentId, name), "utf-8").trim();
|
|
2895
|
+
} catch {
|
|
2896
|
+
return;
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
/** Remove a hook secret (no error if it is already absent). */
|
|
2900
|
+
function deleteHookSecret(configDir, agentId, name) {
|
|
2901
|
+
fs.rmSync(hookSecretPath(configDir, agentId, name), { force: true });
|
|
2902
|
+
}
|
|
2903
|
+
//#endregion
|
|
1860
2904
|
//#region src/mls-ops.ts
|
|
1861
2905
|
/**
|
|
1862
2906
|
* Generate and upload MLS key packages for an agent, enabling other agents
|
|
@@ -2039,4 +3083,4 @@ async function performAgentCreation(client, configDir, profile, params) {
|
|
|
2039
3083
|
return agent;
|
|
2040
3084
|
}
|
|
2041
3085
|
//#endregion
|
|
2042
|
-
export { DEFAULT_API_URL, HttpClient, MLKEM_CT_SIZE, MLS_CIPHER_SUITE_ID, RineApiError, UUID_RE, VERSION_HYBRID, VERSION_MLS, addMemberToMlsGroup, addMlsGroupMember, advanceChain, agentIdFromKid, agentKeysExist, bytesToUuid, cacheMlsSelfRead, cacheToken, claimKeyPackages, createMlsGroup, decodeEnvelope, decryptGroupMessage, decryptMessage, decryptMlsAppMessage, deleteMlsState, deletePrivateKeyPackage, deriveMessageKey, distributeSenderKey, encodeEnvelope, encryptGroupMessage, encryptMessage, encryptMlsAppMessage, encryptMlsGroupMessage, encryptionPublicKeyToJWK, externalJoinMlsGroup, fetchAgents, fetchAndIngestPendingSKDistributions, fetchGroupInfo, fetchHandshakes, fetchOAuthToken, fetchRecipientKeys, fetchWelcomes, formatError, fromBase64Url, generateAgentKeys, generateEncryptionKeyPair, generateMlsKeyPackage, generatePqKeyPair, generateSenderKey, generateSigningKeyPair, getAgentPublicKeys, getCredentialEntry, getMlsCipherSuite, getMlsContext, getOrCreateSenderKey, getOrRefreshToken, ingestSenderKeyDistribution, initMlsGroup, isBareAgentName, jwkToPqPublicKey, jwkToPublicKey, listPrivateKeyPackages, loadAgentKeys, loadCredentials, loadMlsState, loadPrivateKeyPackage, loadSenderKeyStates, loadTokenCache, lookupMlsSelfRead, mlsAck, mlsCommit, fromBase64 as mlsFromBase64, mlsInit, mlsNack, toBase64 as mlsToBase64, needsRotation, normalizeHandle, open, openGroup, openHybrid, performAgentCreation, performRegistration, pqPublicKeyToJWK, processMlsCommit, processMlsWelcome, processMlsWelcomes, publishMlsKeyPackages, removeMemberFromMlsGroup, removeMlsGroupMember, resolveAgent, resolveApiUrl, resolveConfigDir, resolveHandleViaWebFinger, resolveToUuid, saveAgentKeys, saveCredentials, saveMlsState, savePqEncryptionKey, savePrivateKeyPackages, saveSenderKeyState, saveTokenCache, seal, sealGroup, sealHybrid, signPayload, signingPublicKeyToJWK, solveTimeLock, solveTimeLockWithProgress, submitProposal, syncMlsGroup, toBase64Url, uploadKeyPackages, uuidToBytes, validateEncryptionKey, validatePathId, validateSigningKey, validateSlug, verifySignature };
|
|
3086
|
+
export { CONTROL_CONN_ID, DEFAULT_API_URL, FrameError, FrameType, GROUP_INVITE_TYPE, HEADER_LEN, HttpClient, MLKEM_CT_SIZE, MLS_CIPHER_SUITE_ID, PER_CONN_INFLIGHT_CAP, RineApiError, TunnelBindError, UUID_RE, VERSION_HYBRID, VERSION_MLS, WEBHOOK_MESSAGE_TYPE, accountKeyPath, acmeDirectoryUrl, addMemberToMlsGroup, addMlsGroupMember, advanceChain, agentIdFromKid, agentKeysExist, buildAcmeClient, buildIssueCertDeps, bytesToUuid, cacheMlsSelfRead, cacheToken, claimKeyPackages, connectTunnel, createMlsGroup, createMuxClient, decodeEnvelope, decodeFrame, decryptGroupMessage, decryptMessage, decryptMlsAppMessage, deleteHookSecret, deleteMlsState, deletePrivateKeyPackage, deriveMessageKey, distributeSenderKey, encodeEnvelope, encodeFrame, encryptGroupMessage, encryptMessage, encryptMlsAppMessage, encryptMlsGroupMessage, encryptionPublicKeyToJWK, ensureCert, externalJoinMlsGroup, fetchAgents, fetchAndIngestPendingSKDistributions, fetchGroupInfo, fetchHandshakes, fetchOAuthToken, fetchRecipientKeys, fetchWelcomes, formatError, fromBase64Url, funnelCacheDir, funnelSecretDir, generateAgentKeys, generateCertKeypairAndCsr, generateEncryptionKeyPair, generateHookSecret, generateMlsKeyPackage, generatePqKeyPair, generateSenderKey, generateSigningKeyPair, getAgentPublicKeys, getCredentialEntry, getMlsCipherSuite, getMlsContext, getOrCreateSenderKey, getOrRefreshToken, handleCacheDir, handleConnection, handleWebhookRequest, hookFqdn, hookSecretPath, ingestSenderKeyDistribution, initMlsGroup, isBareAgentName, isRenewalDue, isTerminalAcmeError, isValidHookName, issueCert, jitteredDelayMs, jwkToPqPublicKey, jwkToPublicKey, listMyInvites, listPrivateKeyPackages, loadAgentKeys, loadCachedCert, loadCredentials, loadHookSecret, loadMeta, loadMlsState, loadPrivateKeyPackage, loadSenderKeyStates, loadTokenCache, lookupMlsSelfRead, makeAcmeClientDeps, makeDnsPost, mlsAck, mlsCommit, fromBase64 as mlsFromBase64, mlsInit, mlsNack, toBase64 as mlsToBase64, needsRotation, nextBackoffDelayMs, nextReconnectBackoffSeconds, normalizeHandle, open, openGroup, openHybrid, performAgentCreation, performRegistration, pqPublicKeyToJWK, processMlsCommit, processMlsWelcome, processMlsWelcomes, publishMlsKeyPackages, removeMemberFromMlsGroup, removeMlsGroupMember, resolveAgent, resolveApiUrl, resolveConfigDir, resolveHandleViaWebFinger, resolveToUuid, revokeAndWipeCache, saveAgentKeys, saveCert, saveCredentials, saveHookSecret, saveMeta, saveMlsState, savePqEncryptionKey, savePrivateKeyPackages, saveSenderKeyState, saveTokenCache, seal, sealGroup, sealHybrid, sendGroupInviteNotification, signPayload, signingPublicKeyToJWK, solveTimeLock, solveTimeLockWithProgress, startTlsListener, submitProposal, syncMlsGroup, tagLeRateLimit, toBase64Url, uploadKeyPackages, uuidToBytes, validateEncryptionKey, validatePathId, validateSigningKey, validateSlug, verifySignature, waitForTxtPropagation, wipeHandleCache };
|