@muhkoo/connect 0.10.10-alpha.0 → 0.10.12-alpha.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/browser/index.js +395 -11
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +308 -3
- package/dist/server/index.js +395 -11
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/dist/server/index.js
CHANGED
|
@@ -2445,8 +2445,12 @@ class AuthClient {
|
|
|
2445
2445
|
* Read a factor record for `username` (returns an indistinguishable decoy if
|
|
2446
2446
|
* absent). Gated types (email) require the recover `verifyToken`.
|
|
2447
2447
|
*/
|
|
2448
|
-
async vaultRead(username, factorType, verifyToken
|
|
2449
|
-
|
|
2448
|
+
async vaultRead(username, factorType, verifyToken,
|
|
2449
|
+
/** Passkeys are per-origin: naming the caller's host returns THAT origin's
|
|
2450
|
+
* passkey instead of whichever was enrolled first (which WebAuthn would
|
|
2451
|
+
* refuse). Ignored for other factor types. */
|
|
2452
|
+
rpId) {
|
|
2453
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault`, this.json("POST", { username, factorType, verifyToken, rpId }));
|
|
2450
2454
|
return await this.parse("vaultRead", res);
|
|
2451
2455
|
}
|
|
2452
2456
|
/**
|
|
@@ -2926,6 +2930,29 @@ function fromBase64(b64) {
|
|
|
2926
2930
|
* seed) while moving credential entry onto the Muhkoo origin. The envelope is
|
|
2927
2931
|
* versioned so a future per-app-key handoff (v2) can swap in without a protocol
|
|
2928
2932
|
* change.
|
|
2933
|
+
*
|
|
2934
|
+
* ## v2 — device pairing (TV / no-redirect handoff)
|
|
2935
|
+
*
|
|
2936
|
+
* v1's K_t rides in the redirect URL *fragment*, which works only because the
|
|
2937
|
+
* receiving app is a browser page that can read `location.hash`. A TV app has
|
|
2938
|
+
* no redirect to receive: it polls. So v2 keeps the *entire* v1 shape — the
|
|
2939
|
+
* server relays a sealed envelope it cannot read — and replaces only the key
|
|
2940
|
+
* agreement:
|
|
2941
|
+
*
|
|
2942
|
+
* - the TV generates an ephemeral P-256 ECDH keypair in memory and publishes
|
|
2943
|
+
* the public half ({@link generateDevicePairingKeypair});
|
|
2944
|
+
* - the hosted page (the component that already holds the unlocked seed and
|
|
2945
|
+
* already owns sealing) seals *to that public key* with a fresh ephemeral
|
|
2946
|
+
* keypair of its own ({@link sealSeedToDevice}) — ECDH → HKDF-SHA256 →
|
|
2947
|
+
* AES-256-GCM;
|
|
2948
|
+
* - the TV recovers the seed with its private key
|
|
2949
|
+
* ({@link unsealSeedFromDevice}).
|
|
2950
|
+
*
|
|
2951
|
+
* The server stores and relays the v2 envelope byte-for-byte, exactly as it
|
|
2952
|
+
* does for v1, and can read neither. The one thing ECDH cannot supply is proof
|
|
2953
|
+
* that the public key the hosted page sealed to is *the TV's* rather than an
|
|
2954
|
+
* attacker's — that is what {@link pairingVerificationCode} is for, and it is
|
|
2955
|
+
* the only MITM defence in the flow (see the entropy note on that function).
|
|
2929
2956
|
*/
|
|
2930
2957
|
const TE = new TextEncoder();
|
|
2931
2958
|
const TD = new TextDecoder();
|
|
@@ -2951,6 +2978,236 @@ async function unsealSeed(sealedKeys, fragmentKey) {
|
|
|
2951
2978
|
const seed = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(envelope.iv) }, key, fromBase64(envelope.ct));
|
|
2952
2979
|
return new Uint8Array(seed);
|
|
2953
2980
|
}
|
|
2981
|
+
// ---- v2: ECDH device pairing ----------------------------------------------
|
|
2982
|
+
//
|
|
2983
|
+
// Deliberately a SEPARATE unseal entry point rather than one `unsealSeed` that
|
|
2984
|
+
// dispatches on `envelope.v`. The two versions do not just differ in envelope
|
|
2985
|
+
// shape — their *second argument* is a different kind of thing entirely (v1: a
|
|
2986
|
+
// base64url string plucked out of a URL fragment; v2: a non-extractable
|
|
2987
|
+
// `CryptoKey`). Overloading one function on a `string | CryptoKey` union would
|
|
2988
|
+
// buy nothing at the call sites (each caller statically knows which flow it is
|
|
2989
|
+
// in) while adding a narrowing branch to the v1 path, which is live in
|
|
2990
|
+
// production hosted auth. Each function still validates `v` and rejects the
|
|
2991
|
+
// other version's envelope, so a mis-routed envelope fails loudly rather than
|
|
2992
|
+
// being interpreted under the wrong scheme.
|
|
2993
|
+
/** P-256 curve parameters for the pairing ECDH. */
|
|
2994
|
+
const PAIRING_CURVE = { name: "ECDH", namedCurve: "P-256" };
|
|
2995
|
+
/**
|
|
2996
|
+
* HKDF `info` for the v2 seal. Versioned and domain-separated: v1 derives no
|
|
2997
|
+
* key at all (K_t is raw random), so no v1 key can ever collide with this, and
|
|
2998
|
+
* a future v3 must pick a new label rather than reuse this one.
|
|
2999
|
+
*/
|
|
3000
|
+
const HKDF_INFO_V2 = "muhkoo/hosted-handoff/v2/device-seal";
|
|
3001
|
+
/** Domain separator for {@link pairingVerificationCode} — distinct from the seal KDF. */
|
|
3002
|
+
const PAIRING_CODE_DOMAIN = "muhkoo/hosted-handoff/v2/pairing-code";
|
|
3003
|
+
/**
|
|
3004
|
+
* Alphabet for the pairing verification code: 30 symbols, every confusable pair
|
|
3005
|
+
* removed in BOTH directions — no `0`/`O`, no `1`/`I`/`L`, and no `U` (which is
|
|
3006
|
+
* read as `V` on the low-resolution, far-viewed type a TV renders). Because the
|
|
3007
|
+
* user compares this string across two screens by eye, unambiguity beats the
|
|
3008
|
+
* convenience of a power-of-two alphabet; the derivation below rejection-samples
|
|
3009
|
+
* to avoid the modulo bias that costs us.
|
|
3010
|
+
*/
|
|
3011
|
+
const PAIRING_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTVWXYZ23456789";
|
|
3012
|
+
/** Length of the pairing verification code, in characters. 30^8 ≈ 39.3 bits. */
|
|
3013
|
+
const PAIRING_CODE_LENGTH = 8;
|
|
3014
|
+
/**
|
|
3015
|
+
* PBKDF2 iterations for the verification code. This is NOT protecting a
|
|
3016
|
+
* password — it is a deliberate work factor on an attacker precomputing codes.
|
|
3017
|
+
* See {@link pairingVerificationCode}.
|
|
3018
|
+
*/
|
|
3019
|
+
const PAIRING_CODE_ITERATIONS = 200_000;
|
|
3020
|
+
/**
|
|
3021
|
+
* (Device side) Generate the ephemeral P-256 ECDH keypair for one pairing
|
|
3022
|
+
* attempt. Call this once per attempt and throw it away when the attempt ends —
|
|
3023
|
+
* reusing a keypair across attempts would let an old, observed ciphertext be
|
|
3024
|
+
* decrypted by whoever later obtains the key.
|
|
3025
|
+
*/
|
|
3026
|
+
async function generateDevicePairingKeypair() {
|
|
3027
|
+
const pair = (await crypto.subtle.generateKey(PAIRING_CURVE, false, ["deriveBits"]));
|
|
3028
|
+
const raw = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
|
|
3029
|
+
return { publicKeyB64: toBase64(raw), privateKey: pair.privateKey };
|
|
3030
|
+
}
|
|
3031
|
+
/**
|
|
3032
|
+
* (Hosted-page side) Seal the master seed *to a device's* pairing public key.
|
|
3033
|
+
*
|
|
3034
|
+
* A fresh ephemeral keypair is generated per call and its public half is
|
|
3035
|
+
* carried in the envelope (`epk`), so sealing the same seed to the same device
|
|
3036
|
+
* twice yields unrelated ciphertext — and the sealer retains nothing that could
|
|
3037
|
+
* later reopen the envelope.
|
|
3038
|
+
*
|
|
3039
|
+
* Returns the v2 `sealedKeys` string, which slots into exactly the same server
|
|
3040
|
+
* field as v1: relayed verbatim, never read.
|
|
3041
|
+
*/
|
|
3042
|
+
async function sealSeedToDevice(seed, devicePublicKeyB64) {
|
|
3043
|
+
const devicePubRaw = decodePairingPublicKey(devicePublicKeyB64);
|
|
3044
|
+
const devicePub = await importPairingPublicKey(devicePubRaw);
|
|
3045
|
+
const ephemeral = (await crypto.subtle.generateKey(PAIRING_CURVE, false, ["deriveBits"]));
|
|
3046
|
+
const epkRaw = new Uint8Array(await crypto.subtle.exportKey("raw", ephemeral.publicKey));
|
|
3047
|
+
const key = await deriveDeviceSealKey(ephemeral.privateKey, devicePub, epkRaw, devicePubRaw, ["encrypt"]);
|
|
3048
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
3049
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, seed));
|
|
3050
|
+
const envelope = { v: 2, epk: toBase64(epkRaw), iv: toBase64(iv), ct: toBase64(ct) };
|
|
3051
|
+
return toBase64(TE.encode(JSON.stringify(envelope)));
|
|
3052
|
+
}
|
|
3053
|
+
/**
|
|
3054
|
+
* (Device side) Recover the master seed from a v2 envelope using the ephemeral
|
|
3055
|
+
* private key from {@link generateDevicePairingKeypair}.
|
|
3056
|
+
*
|
|
3057
|
+
* Throws on any tamper: a substituted `epk` yields a different shared secret
|
|
3058
|
+
* (or fails point validation outright), and any edit to `iv`/`ct` fails the
|
|
3059
|
+
* AES-GCM tag. There is no path that returns partially-authenticated bytes.
|
|
3060
|
+
*
|
|
3061
|
+
* `devicePublicKeyB64` is the caller's OWN public half (from
|
|
3062
|
+
* {@link generateDevicePairingKeypair}). It is required, not optional, because
|
|
3063
|
+
* it is mixed into the KDF — see {@link deriveDeviceSealKey} for why binding the
|
|
3064
|
+
* recipient matters. A device always holds both halves, so this costs nothing.
|
|
3065
|
+
*/
|
|
3066
|
+
async function unsealSeedFromDevice(sealedKeys, privateKey, devicePublicKeyB64) {
|
|
3067
|
+
const envelope = JSON.parse(TD.decode(fromBase64Loose(sealedKeys)));
|
|
3068
|
+
if (envelope.v !== 2)
|
|
3069
|
+
throw new Error(`Unsupported device-handoff envelope version ${envelope.v}`);
|
|
3070
|
+
const devicePubRaw = decodePairingPublicKey(devicePublicKeyB64);
|
|
3071
|
+
const epkRaw = decodePairingPublicKey(envelope.epk);
|
|
3072
|
+
const epk = await importPairingPublicKey(epkRaw);
|
|
3073
|
+
const key = await deriveDeviceSealKey(privateKey, epk, epkRaw, devicePubRaw, ["decrypt"]);
|
|
3074
|
+
const seed = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64Loose(envelope.iv) }, key, fromBase64Loose(envelope.ct));
|
|
3075
|
+
return new Uint8Array(seed);
|
|
3076
|
+
}
|
|
3077
|
+
/**
|
|
3078
|
+
* Short code derived deterministically from the device's pairing public key,
|
|
3079
|
+
* displayed on BOTH the TV and the approving browser so the human can confirm
|
|
3080
|
+
* they match before approving.
|
|
3081
|
+
*
|
|
3082
|
+
* ## What this is worth (be honest about it)
|
|
3083
|
+
*
|
|
3084
|
+
* ~39.3 bits (30^8), rendered in an unambiguous 30-symbol alphabet and grouped
|
|
3085
|
+
* as `XXXX-XXXX` for comparison. Alongside the recipient-key binding in
|
|
3086
|
+
* {@link deriveDeviceSealKey}, this is what stops a substituted device key: the
|
|
3087
|
+
* KDF rejects a *negated* key, and this code is what a human uses to reject a
|
|
3088
|
+
* wholly *attacker-generated* one, which the crypto cannot detect on its own —
|
|
3089
|
+
* ECDH proves nothing about WHOSE public key it is.
|
|
3090
|
+
*
|
|
3091
|
+
* Two attacks, and what each actually costs:
|
|
3092
|
+
*
|
|
3093
|
+
* 1. **Online grind.** Generate keypairs until one's code equals the honest
|
|
3094
|
+
* TV's. ~2^39 trials. Naively that reads as "keygen + hash", but a real
|
|
3095
|
+
* attacker never generates keys: walking `P_{i+1} = P_i + G` while tracking
|
|
3096
|
+
* `d_i = d_0 + i` makes each trial one point-add plus one derivation. With
|
|
3097
|
+
* a plain hash that is well under a core-day — which is why this uses
|
|
3098
|
+
* PBKDF2-SHA256 at {@link PAIRING_CODE_ITERATIONS} iterations instead. The
|
|
3099
|
+
* work factor applies per trial, pushing the same grind to the order of
|
|
3100
|
+
* 10^5 core-days. The target is also only learned when pairing starts, so
|
|
3101
|
+
* the grind is online against the pairing TTL (keep it minutes).
|
|
3102
|
+
* 2. **Precomputed table.** The reason this is not a plain hash. A table over
|
|
3103
|
+
* candidate keys is computed once and reused forever; with SHA-256 that is
|
|
3104
|
+
* cheap enough to be the preferred attack. The iteration count multiplies
|
|
3105
|
+
* the table's build cost by the same 2 * 10^5.
|
|
3106
|
+
*
|
|
3107
|
+
* Neither is a proof of security, and an adversary with ASIC-scale hardware
|
|
3108
|
+
* changes these numbers. If the margin is ever judged insufficient, raise
|
|
3109
|
+
* {@link PAIRING_CODE_LENGTH} — 10 characters buys ~49 bits for two more
|
|
3110
|
+
* characters of human comparison, and is display-only: it does not touch the
|
|
3111
|
+
* envelope or the wire format.
|
|
3112
|
+
*
|
|
3113
|
+
* The cost is paid once per pairing, on two idle devices, so ~200k iterations
|
|
3114
|
+
* is imperceptible here in a way it would not be on a hot path.
|
|
3115
|
+
*/
|
|
3116
|
+
async function pairingVerificationCode(devicePublicKeyB64) {
|
|
3117
|
+
const pubRaw = decodePairingPublicKey(devicePublicKeyB64);
|
|
3118
|
+
const material = await crypto.subtle.importKey("raw", pubRaw, { name: "PBKDF2" }, false, [
|
|
3119
|
+
"deriveBits",
|
|
3120
|
+
]);
|
|
3121
|
+
// Enough bytes that rejection sampling (below) effectively never runs dry:
|
|
3122
|
+
// each byte is rejected with p = 16/256, so 32 bytes yields 8 symbols with
|
|
3123
|
+
// overwhelming probability.
|
|
3124
|
+
const bits = new Uint8Array(await crypto.subtle.deriveBits({
|
|
3125
|
+
name: "PBKDF2",
|
|
3126
|
+
hash: "SHA-256",
|
|
3127
|
+
salt: TE.encode(PAIRING_CODE_DOMAIN),
|
|
3128
|
+
iterations: PAIRING_CODE_ITERATIONS,
|
|
3129
|
+
}, material, 256));
|
|
3130
|
+
// Rejection-sample so all 30 symbols are equally likely. 256 is not a
|
|
3131
|
+
// multiple of 30, so a plain modulo would over-weight the first 16 symbols.
|
|
3132
|
+
const n = PAIRING_CODE_ALPHABET.length;
|
|
3133
|
+
const limit = Math.floor(256 / n) * n; // 240
|
|
3134
|
+
let out = "";
|
|
3135
|
+
for (let i = 0; i < bits.length && out.length < PAIRING_CODE_LENGTH; i++) {
|
|
3136
|
+
if (bits[i] >= limit)
|
|
3137
|
+
continue;
|
|
3138
|
+
out += PAIRING_CODE_ALPHABET[bits[i] % n];
|
|
3139
|
+
}
|
|
3140
|
+
if (out.length < PAIRING_CODE_LENGTH) {
|
|
3141
|
+
// Astronomically unlikely (p < 2^-40); fail loudly rather than return a
|
|
3142
|
+
// short code that two screens would still "agree" on.
|
|
3143
|
+
throw new Error("Pairing code derivation ran out of entropy.");
|
|
3144
|
+
}
|
|
3145
|
+
// Grouped for human comparison — the format the spec and both UIs display.
|
|
3146
|
+
return out.slice(0, 4) + "-" + out.slice(4);
|
|
3147
|
+
}
|
|
3148
|
+
// ---- v2 internals ----------------------------------------------------------
|
|
3149
|
+
/**
|
|
3150
|
+
* ECDH → HKDF-SHA256 → AES-256-GCM key.
|
|
3151
|
+
*
|
|
3152
|
+
* The raw ECDH output is never used as a key directly; it is extracted+expanded
|
|
3153
|
+
* with BOTH public keys mixed in:
|
|
3154
|
+
*
|
|
3155
|
+
* - the sender's ephemeral key as HKDF **salt**, and
|
|
3156
|
+
* - the recipient's device key appended to the HKDF **info**.
|
|
3157
|
+
*
|
|
3158
|
+
* Binding the recipient is not decoration. P-256 ECDH returns only the shared
|
|
3159
|
+
* point's x-coordinate, and negating a public key — `(x, y)` → `(x, p−y)`, which
|
|
3160
|
+
* is still on the curve and which ANYONE can compute from the published key —
|
|
3161
|
+
* yields the SAME x. So without the recipient in the KDF, a seal addressed to
|
|
3162
|
+
* the negated key still decrypts under the honest private key, even though that
|
|
3163
|
+
* negated key displays a DIFFERENT {@link pairingVerificationCode}. That would
|
|
3164
|
+
* leave the human code as the only thing rejecting a substituted recipient.
|
|
3165
|
+
* Mixing `devicePubRaw` in makes the substitution fail in the crypto layer,
|
|
3166
|
+
* where it belongs.
|
|
3167
|
+
*
|
|
3168
|
+
* (The salt is load-bearing for the same reason — it is what makes a swapped
|
|
3169
|
+
* `epk` derive a different key. Do not "simplify" either input away.)
|
|
3170
|
+
*/
|
|
3171
|
+
async function deriveDeviceSealKey(priv, pub, epkRaw, devicePubRaw, usages) {
|
|
3172
|
+
const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: pub }, priv, 256));
|
|
3173
|
+
const prk = await crypto.subtle.importKey("raw", shared, { name: "HKDF" }, false, ["deriveBits"]);
|
|
3174
|
+
const label = TE.encode(HKDF_INFO_V2);
|
|
3175
|
+
const info = new Uint8Array(label.length + devicePubRaw.length);
|
|
3176
|
+
info.set(label, 0);
|
|
3177
|
+
info.set(devicePubRaw, label.length);
|
|
3178
|
+
const okm = await crypto.subtle.deriveBits({
|
|
3179
|
+
name: "HKDF",
|
|
3180
|
+
hash: "SHA-256",
|
|
3181
|
+
salt: epkRaw,
|
|
3182
|
+
info: info,
|
|
3183
|
+
}, prk, 256);
|
|
3184
|
+
return crypto.subtle.importKey("raw", okm, { name: "AES-GCM" }, false, usages);
|
|
3185
|
+
}
|
|
3186
|
+
/** Decode + shape-check a raw SEC1 uncompressed P-256 public key. */
|
|
3187
|
+
function decodePairingPublicKey(b64) {
|
|
3188
|
+
if (typeof b64 !== "string" || b64.length === 0) {
|
|
3189
|
+
throw new Error("Pairing public key must be a non-empty base64 string.");
|
|
3190
|
+
}
|
|
3191
|
+
const raw = fromBase64Loose(b64);
|
|
3192
|
+
if (raw.length !== 65 || raw[0] !== 0x04) {
|
|
3193
|
+
throw new Error("Pairing public key must be a 65-byte uncompressed P-256 point.");
|
|
3194
|
+
}
|
|
3195
|
+
return raw;
|
|
3196
|
+
}
|
|
3197
|
+
/** Import a raw P-256 point for ECDH. Rejects points that are not on the curve. */
|
|
3198
|
+
function importPairingPublicKey(raw) {
|
|
3199
|
+
return crypto.subtle.importKey("raw", raw, PAIRING_CURVE, false, []);
|
|
3200
|
+
}
|
|
3201
|
+
/**
|
|
3202
|
+
* Base64 decode that also accepts the URL-safe alphabet and missing padding —
|
|
3203
|
+
* pairing public keys may travel through query strings, QR payloads, or JSON
|
|
3204
|
+
* depending on the client, and rejecting a key over `-` vs `+` would be a
|
|
3205
|
+
* miserable failure mode to debug.
|
|
3206
|
+
*/
|
|
3207
|
+
function fromBase64Loose(s) {
|
|
3208
|
+
const norm = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
3209
|
+
return fromBase64(norm.padEnd(Math.ceil(norm.length / 4) * 4, "="));
|
|
3210
|
+
}
|
|
2954
3211
|
/** Generate a PKCE verifier + S256 challenge for the authorize→token exchange. */
|
|
2955
3212
|
async function generatePkce() {
|
|
2956
3213
|
const codeVerifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
|
|
@@ -2976,7 +3233,7 @@ function base64UrlDecode(s) {
|
|
|
2976
3233
|
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
2977
3234
|
* `package.json` `version`.
|
|
2978
3235
|
*/
|
|
2979
|
-
const VERSION = "0.10.
|
|
3236
|
+
const VERSION = "0.10.12-alpha.0";
|
|
2980
3237
|
|
|
2981
3238
|
/**
|
|
2982
3239
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -3037,6 +3294,8 @@ class HttpClient {
|
|
|
3037
3294
|
/** Origin of {@link baseUrl} — credentials are only attached same-origin. */
|
|
3038
3295
|
baseOrigin;
|
|
3039
3296
|
apiKey;
|
|
3297
|
+
/** Scoped machine access token — takes precedence over {@link apiKey} on `X-Muhkoo-Key`. */
|
|
3298
|
+
accessToken;
|
|
3040
3299
|
getSessionToken;
|
|
3041
3300
|
onUnauthorized;
|
|
3042
3301
|
fetchFn;
|
|
@@ -3051,6 +3310,7 @@ class HttpClient {
|
|
|
3051
3310
|
this.baseOrigin = "";
|
|
3052
3311
|
}
|
|
3053
3312
|
this.apiKey = opts.apiKey ?? null;
|
|
3313
|
+
this.accessToken = opts.accessToken ?? null;
|
|
3054
3314
|
this.getSessionToken = opts.getSessionToken ?? (() => null);
|
|
3055
3315
|
this.onUnauthorized = opts.onUnauthorized ?? null;
|
|
3056
3316
|
const f = opts.fetch ?? globalThis.fetch;
|
|
@@ -3071,8 +3331,11 @@ class HttpClient {
|
|
|
3071
3331
|
// server-reflected path that escapes `baseUrl`) must NOT receive the app
|
|
3072
3332
|
// key or session token, or it becomes a token-exfiltration vector.
|
|
3073
3333
|
if (this.isSameOrigin(input)) {
|
|
3074
|
-
|
|
3075
|
-
|
|
3334
|
+
// Access token and app key resolve through the same accelerator path
|
|
3335
|
+
// and ride the same header; the access token takes precedence.
|
|
3336
|
+
const key = this.accessToken ?? this.apiKey;
|
|
3337
|
+
if (key)
|
|
3338
|
+
headers.set("X-Muhkoo-Key", key);
|
|
3076
3339
|
const token = this.getSessionToken();
|
|
3077
3340
|
if (token)
|
|
3078
3341
|
headers.set("X-Muhkoo-Session", token);
|
|
@@ -3486,6 +3749,35 @@ async function passkeyPrfCapable() {
|
|
|
3486
3749
|
function defaultRpId() {
|
|
3487
3750
|
return typeof location !== "undefined" ? location.hostname : "";
|
|
3488
3751
|
}
|
|
3752
|
+
/**
|
|
3753
|
+
* Can a passkey enrolled under `rpId` be used from the CURRENT origin?
|
|
3754
|
+
*
|
|
3755
|
+
* WebAuthn only accepts an RP id that is the origin's own host or a registrable
|
|
3756
|
+
* *parent* of it — so a passkey enrolled on `app.example.com` is unusable on
|
|
3757
|
+
* `app.example.net`, and one enrolled on a full host is unusable from a sibling
|
|
3758
|
+
* host. An app served on BOTH a custom domain and the platform host therefore
|
|
3759
|
+
* needs one passkey per origin.
|
|
3760
|
+
*
|
|
3761
|
+
* Call this BEFORE `navigator.credentials.get()`: passing a foreign rpId throws
|
|
3762
|
+
* a raw "The requested RPID did not match the origin or related origins", which
|
|
3763
|
+
* is a dead end for the user. Checking first lets the caller fall back to normal
|
|
3764
|
+
* sign-in (and offer to enroll a passkey for this origin) instead.
|
|
3765
|
+
*/
|
|
3766
|
+
function rpIdUsableForOrigin(rpId, host) {
|
|
3767
|
+
const h = host ?? (typeof location !== "undefined" ? location.hostname : "");
|
|
3768
|
+
if (!rpId || !h)
|
|
3769
|
+
return false;
|
|
3770
|
+
return h === rpId || h.endsWith(`.${rpId}`);
|
|
3771
|
+
}
|
|
3772
|
+
/** Thrown when the account's passkey belongs to a different origin. Callers
|
|
3773
|
+
* should treat this as "no passkey here" and fall back to another factor. */
|
|
3774
|
+
class PasskeyOriginError extends Error {
|
|
3775
|
+
code = "passkey_wrong_origin";
|
|
3776
|
+
constructor(message = "No passkey is enrolled for this site. Sign in another way, then add one for this device.") {
|
|
3777
|
+
super(message);
|
|
3778
|
+
this.name = "PasskeyOriginError";
|
|
3779
|
+
}
|
|
3780
|
+
}
|
|
3489
3781
|
/**
|
|
3490
3782
|
* Create a passkey with PRF and return its credential id, a fresh PRF salt, and
|
|
3491
3783
|
* the PRF output for that salt. Throws if the authenticator can't do PRF.
|
|
@@ -4179,12 +4471,22 @@ class ZkAuth {
|
|
|
4179
4471
|
* its PRF, unwraps the master seed → identity → session.
|
|
4180
4472
|
*/
|
|
4181
4473
|
async loginWithPasskey(username, opts = {}) {
|
|
4182
|
-
|
|
4474
|
+
// Ask for THIS origin's passkey — they're enrolled per-origin, so the
|
|
4475
|
+
// account's first passkey may belong to a different host entirely.
|
|
4476
|
+
const host = defaultRpId();
|
|
4477
|
+
const { factor } = await this.deps.auth.vaultRead(username, "passkey", undefined, host);
|
|
4183
4478
|
const params = factor?.params;
|
|
4184
4479
|
if (!factor?.wrap || !factor?.iv || !params?.credentialId || !params?.prfSalt) {
|
|
4185
4480
|
throw new Error("No passkey is set up for this account.");
|
|
4186
4481
|
}
|
|
4187
|
-
const
|
|
4482
|
+
const rpId = params.rpId ?? host;
|
|
4483
|
+
// Refuse a foreign rpId up front: WebAuthn would throw a raw "requested
|
|
4484
|
+
// RPID did not match the origin", which reads as a hard failure to the
|
|
4485
|
+
// user. A typed error lets the app fall back to another factor and offer
|
|
4486
|
+
// to enroll a passkey for this origin.
|
|
4487
|
+
if (!rpIdUsableForOrigin(rpId, host))
|
|
4488
|
+
throw new PasskeyOriginError();
|
|
4489
|
+
const prfOutput = await evaluatePasskeyPrf(rpId, fromBase64(params.credentialId), fromBase64(params.prfSalt));
|
|
4188
4490
|
const key = await wrapKeyFromBytes(prfOutput, "muhkoo-passkey-wrap");
|
|
4189
4491
|
const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
|
|
4190
4492
|
const identity = await deriveIdentityFromSeed(seed);
|
|
@@ -4204,6 +4506,27 @@ class ZkAuth {
|
|
|
4204
4506
|
const { factors } = await this.deps.auth.vaultFactors(token);
|
|
4205
4507
|
return factors;
|
|
4206
4508
|
}
|
|
4509
|
+
/**
|
|
4510
|
+
* Is a passkey enrolled that can unlock from THIS origin?
|
|
4511
|
+
*
|
|
4512
|
+
* Passkeys are origin-bound, so an app served on both a custom domain and the
|
|
4513
|
+
* platform host needs one per host. Use this to decide whether to offer
|
|
4514
|
+
* "Unlock with passkey" or go straight to sign-in — prompting with another
|
|
4515
|
+
* origin's passkey fails with a raw WebAuthn error. Requires a session
|
|
4516
|
+
* (metadata read); returns false when signed out or unsupported.
|
|
4517
|
+
*/
|
|
4518
|
+
async hasPasskeyForThisOrigin() {
|
|
4519
|
+
if (!passkeySupported())
|
|
4520
|
+
return false;
|
|
4521
|
+
try {
|
|
4522
|
+
const host = defaultRpId();
|
|
4523
|
+
const factors = await this.listFactors();
|
|
4524
|
+
return factors.some((f) => f.type === "passkey" && rpIdUsableForOrigin(f.rpId, host));
|
|
4525
|
+
}
|
|
4526
|
+
catch {
|
|
4527
|
+
return false;
|
|
4528
|
+
}
|
|
4529
|
+
}
|
|
4207
4530
|
/** Remove a login method by id (can't remove your only one). Session-authed. */
|
|
4208
4531
|
async removeFactor(id) {
|
|
4209
4532
|
const token = this.deps.session.token;
|
|
@@ -9840,6 +10163,59 @@ class FunctionsNamespace {
|
|
|
9840
10163
|
}
|
|
9841
10164
|
}
|
|
9842
10165
|
|
|
10166
|
+
/**
|
|
10167
|
+
* `client.accessTokens` — manage an app's **access tokens**: scoped, expiring,
|
|
10168
|
+
* non-ZK machine-to-machine credentials (`mk_<env>_at_…`).
|
|
10169
|
+
*
|
|
10170
|
+
* const { plaintext } = await client.accessTokens.create(appId, {
|
|
10171
|
+
* scopes: ["db:read", "db:write"], env: "live", expiresInDays: 30,
|
|
10172
|
+
* label: "ci-runner",
|
|
10173
|
+
* });
|
|
10174
|
+
* // hand `plaintext` to the machine caller as its ClientOptions.accessToken
|
|
10175
|
+
*
|
|
10176
|
+
* These are MANAGEMENT calls — creating/listing/revoking tokens is authorized by
|
|
10177
|
+
* the developer session (same as the `agents`/`functions` namespaces). The token
|
|
10178
|
+
* itself resolves through the SAME accelerator path as an app key (the
|
|
10179
|
+
* `X-Muhkoo-Key` header), but is scoped to a fixed vocabulary of permissions and
|
|
10180
|
+
* expires. The plaintext is returned ONCE, on create — store it then; it can't
|
|
10181
|
+
* be read back.
|
|
10182
|
+
*/
|
|
10183
|
+
/** All valid {@link Scope}s, in canonical order. */
|
|
10184
|
+
const ACCESS_TOKEN_SCOPES = [
|
|
10185
|
+
"db:read",
|
|
10186
|
+
"db:write",
|
|
10187
|
+
"kv:read",
|
|
10188
|
+
"kv:write",
|
|
10189
|
+
"storage:read",
|
|
10190
|
+
"storage:write",
|
|
10191
|
+
"messages:read",
|
|
10192
|
+
"messages:write",
|
|
10193
|
+
"functions:invoke",
|
|
10194
|
+
"ai:infer",
|
|
10195
|
+
];
|
|
10196
|
+
class AccessTokensNamespace {
|
|
10197
|
+
deps;
|
|
10198
|
+
constructor(deps) {
|
|
10199
|
+
this.deps = deps;
|
|
10200
|
+
}
|
|
10201
|
+
/** List the app's access tokens (metadata only — never the plaintext). */
|
|
10202
|
+
async list(appId) {
|
|
10203
|
+
const res = await this.deps.http.get(this.path(appId, ""));
|
|
10204
|
+
return res.tokens ?? [];
|
|
10205
|
+
}
|
|
10206
|
+
/** Mint an access token (owner/editor only). The plaintext is returned once. */
|
|
10207
|
+
async create(appId, input) {
|
|
10208
|
+
return this.deps.http.post(this.path(appId, ""), input);
|
|
10209
|
+
}
|
|
10210
|
+
/** Revoke an access token by its `keyId`. */
|
|
10211
|
+
async revoke(appId, keyId) {
|
|
10212
|
+
return this.deps.http.del(this.path(appId, `/${encodeURIComponent(keyId)}`));
|
|
10213
|
+
}
|
|
10214
|
+
path(appId, suffix) {
|
|
10215
|
+
return `/api/apps/${encodeURIComponent(appId)}/access-tokens${suffix}`;
|
|
10216
|
+
}
|
|
10217
|
+
}
|
|
10218
|
+
|
|
9843
10219
|
/**
|
|
9844
10220
|
* `ChatKeyVault` — SDK-owned lifecycle for a member's long-lived ratchet/space
|
|
9845
10221
|
* keypair (the connect `KeyStore` entry keyed by the member id / username).
|
|
@@ -11087,6 +11463,8 @@ class Client {
|
|
|
11087
11463
|
agents;
|
|
11088
11464
|
/** Serverless functions — `client.functions.deploy(appId, …)`, etc. */
|
|
11089
11465
|
functions;
|
|
11466
|
+
/** Access tokens — `client.accessTokens.create(appId, …)`, etc. */
|
|
11467
|
+
accessTokens;
|
|
11090
11468
|
/** Offline cache + sync — `client.offline.status`, `client.offline.snapshot`, etc. */
|
|
11091
11469
|
offline;
|
|
11092
11470
|
session;
|
|
@@ -11140,6 +11518,7 @@ class Client {
|
|
|
11140
11518
|
this.http = new HttpClient({
|
|
11141
11519
|
baseUrl: this.baseUrl,
|
|
11142
11520
|
apiKey: options.apiKey,
|
|
11521
|
+
accessToken: options.accessToken,
|
|
11143
11522
|
getSessionToken: () => this.session.token,
|
|
11144
11523
|
fetch: reportingFetch,
|
|
11145
11524
|
// Self-heal stale sessions: on a 401, try a silent re-auth (only
|
|
@@ -11216,6 +11595,7 @@ class Client {
|
|
|
11216
11595
|
});
|
|
11217
11596
|
this.agents = new AgentsNamespace({ http: this.http });
|
|
11218
11597
|
this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
|
|
11598
|
+
this.accessTokens = new AccessTokensNamespace({ http: this.http });
|
|
11219
11599
|
// Replay deferred shard uploads on reconnect. The bytes live in the
|
|
11220
11600
|
// Cache API keyed by hash; re-PUT them through a (defer-free) shard
|
|
11221
11601
|
// client so a failure surfaces as transient and the entry stays queued.
|
|
@@ -11475,6 +11855,7 @@ function MuhkooDB(meta) {
|
|
|
11475
11855
|
table: meta.table ?? String(propertyKey),
|
|
11476
11856
|
access: meta.access ?? "read",
|
|
11477
11857
|
description: meta.description,
|
|
11858
|
+
backend: meta.backend ?? false,
|
|
11478
11859
|
});
|
|
11479
11860
|
});
|
|
11480
11861
|
}
|
|
@@ -11514,9 +11895,12 @@ function ejectAgentPrompt(appClass) {
|
|
|
11514
11895
|
for (const s of d.spaces)
|
|
11515
11896
|
lines.push(`- ${s.name}: ${s.description}`);
|
|
11516
11897
|
}
|
|
11517
|
-
|
|
11898
|
+
// Backend-only tables are deliberately withheld — the agent should never be
|
|
11899
|
+
// told about server-managed / internal state it can't (and shouldn't) touch.
|
|
11900
|
+
const promptTables = d.tables.filter((t) => !t.backend);
|
|
11901
|
+
if (promptTables.length) {
|
|
11518
11902
|
lines.push("", "App database tables:");
|
|
11519
|
-
for (const t of
|
|
11903
|
+
for (const t of promptTables) {
|
|
11520
11904
|
const verbs = t.access === "write"
|
|
11521
11905
|
? "read & write — db_query/db_get/db_insert/db_update/db_delete"
|
|
11522
11906
|
: "read-only — db_query/db_get";
|
|
@@ -11534,7 +11918,7 @@ function ejectAgentPrompt(appClass) {
|
|
|
11534
11918
|
// How to respond — the load-bearing behavioral contract. Without this, models
|
|
11535
11919
|
// (especially gpt-oss) tend to run tool calls and then end their turn with no
|
|
11536
11920
|
// user-facing message, so the user sees the work happen but gets no reply.
|
|
11537
|
-
const hasTools =
|
|
11921
|
+
const hasTools = promptTables.length > 0 || d.functions.length > 0;
|
|
11538
11922
|
lines.push("", "How to respond:");
|
|
11539
11923
|
if (hasTools) {
|
|
11540
11924
|
lines.push("- When you need information or need to make a change, call your tools immediately — don't ask the user to do it, and don't describe or name the tools to them.", "- After your tool calls return, you MUST send one final reply to the channel, in plain language, that answers the request using the results.");
|
|
@@ -12104,5 +12488,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
|
|
|
12104
12488
|
default: loadRsWasm
|
|
12105
12489
|
});
|
|
12106
12490
|
|
|
12107
|
-
export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, DbNamespace, DbTable, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VERSION, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compareHlc, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64$1 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, log, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, sealSeed, serialize, shardHash, signMessage, stableStringify, toBase64$1 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
12491
|
+
export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AccessTokensNamespace, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, DbNamespace, DbTable, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VERSION, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compareHlc, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64$1 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateDevicePairingKeypair, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, log, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, pairingVerificationCode, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, serialize, shardHash, signMessage, stableStringify, toBase64$1 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
12108
12492
|
//# sourceMappingURL=index.js.map
|