Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/agent-core-browser-safe-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@tangle-network/agent-core": patch
---

fix(auth): make the token module browser-import-safe

The auth token module referenced `Buffer` at module-eval time (top-level
`JWT_HEADER` / `JWT_HEADER_EDDSA` = `base64UrlEncode(...)`) and statically
imported `node:crypto`. Because the package **root** re-exports this module,
any browser bundle that transitively imports `@tangle-network/agent-core`
(e.g. via `@tangle-network/sdk-telemetry`) boot-crashed with
`ReferenceError: Buffer is not defined`.

- base64url encode/decode is now isomorphic (`btoa`/`atob` + `TextEncoder`/
`TextDecoder`), never `Buffer`
- the HS256 and EdDSA JWT headers are computed lazily on first use, not at
module load
- HMAC/Ed25519 signing, verification, and key generation resolve `node:crypto`
on demand via `process.getBuiltinModule` (server-only), so merely importing
the module never pulls the builtin into a browser graph

Token wire format is unchanged — already-issued tokens still verify.
160 changes: 112 additions & 48 deletions packages/agent-core/src/auth/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,6 @@
* - Sidecar tokens: Ed25519 (asymmetric — sidecar cannot forge tokens)
*/

import {
createHmac,
generateKeyPairSync,
randomBytes,
sign,
timingSafeEqual,
verify,
} from "node:crypto";
import type {
BatchScopedTokenPayload,
ProductAuthInfo,
Expand All @@ -24,13 +16,43 @@ import type {
TokenValidationResult,
} from "./types.js";

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();

type NodeCrypto = typeof import("node:crypto");
let nodeCryptoCache: NodeCrypto | undefined;

/**
* Resolve Node's `crypto` lazily and synchronously. Signing, verification, and
* key generation are server-only, but this module must stay importable in a
* browser bundle — a static `import "node:crypto"` forces bundlers to resolve
* the builtin at load and blanks the page. `process.getBuiltinModule` exists
* only on a Node runtime (Node >= 22.3); in a browser `globalThis.process` is
* undefined, so callers get a clear server-only error instead of a crash.
*/
function nodeCrypto(): NodeCrypto {
if (nodeCryptoCache) return nodeCryptoCache;
const resolved = (
globalThis as {
process?: { getBuiltinModule?: (id: string) => unknown };
}
).process?.getBuiltinModule?.("node:crypto") as NodeCrypto | undefined;
if (!resolved) {
throw new Error(
"@tangle-network/agent-core/auth: token signing, verification, and key generation are server-only (Node.js crypto) and cannot run in a browser.",
);
}
nodeCryptoCache = resolved;
return resolved;
}

/**
* Generate a cryptographically secure random string.
* @param prefix - Prefix for the generated string (e.g., "orch_prod_")
* @param bytes - Number of random bytes (default: 32 = 256 bits)
*/
export function generateSecureToken(prefix: string, bytes = 32): string {
return `${prefix}${randomBytes(bytes).toString("base64url")}`;
return `${prefix}${base64UrlEncode(nodeCrypto().randomBytes(bytes))}`;
}

/**
Expand All @@ -48,33 +70,45 @@ export function generateSigningSecret(): string {
}

/**
* Base64URL encode (RFC 7515).
* Base64URL encode (RFC 7515). Isomorphic: `btoa` + `TextEncoder` exist in Node
* and browsers, so — unlike `Buffer` — this never blanks a browser page that
* transitively imports the module.
*/
function base64UrlEncode(data: string | Buffer): string {
const buffer = typeof data === "string" ? Buffer.from(data) : data;
return buffer
.toString("base64")
function base64UrlEncode(data: string | Uint8Array): string {
const bytes = typeof data === "string" ? textEncoder.encode(data) : data;
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}

/**
* Base64URL decode.
* Base64URL decode (RFC 7515) to raw bytes.
*/
function base64UrlDecode(data: string): string {
function base64UrlToBytes(data: string): Uint8Array {
const padded = data + "=".repeat((4 - (data.length % 4)) % 4);
return Buffer.from(
padded.replace(/-/g, "+").replace(/_/g, "/"),
"base64",
).toString();
const binary = atob(padded.replace(/-/g, "+").replace(/_/g, "/"));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}

/**
* Base64URL decode to a UTF-8 string.
*/
function base64UrlDecode(data: string): string {
return textDecoder.decode(base64UrlToBytes(data));
}

/**
* Create HMAC-SHA256 signature.
*/
function createSignature(data: string, secret: string): string {
return base64UrlEncode(createHmac("sha256", secret).update(data).digest());
return base64UrlEncode(
nodeCrypto().createHmac("sha256", secret).update(data).digest(),
);
}

/**
Expand All @@ -92,10 +126,11 @@ function verifySignature(
signature: string,
secret: string,
): boolean {
const { createHmac, timingSafeEqual } = nodeCrypto();
const expectedBuf = createHmac("sha256", secret).update(data).digest();
let providedBuf: Buffer;
let providedBuf: Uint8Array;
try {
providedBuf = Buffer.from(signature, "base64url");
providedBuf = base64UrlToBytes(signature);
} catch {
return false;
}
Expand All @@ -108,11 +143,18 @@ function verifySignature(
}

/**
* JWT header (always the same for our use case).
* Base64URL-encoded JWT header `{"alg":"HS256","typ":"JWT"}`. Computed on first
* use (not at module load) and memoized.
*/
const JWT_HEADER = base64UrlEncode(
JSON.stringify({ alg: "HS256", typ: "JWT" }),
);
let jwtHeaderCache: string | undefined;
function jwtHeader(): string {
if (jwtHeaderCache === undefined) {
jwtHeaderCache = base64UrlEncode(
JSON.stringify({ alg: "HS256", typ: "JWT" }),
);
}
return jwtHeaderCache;
}

/**
* Issue a read token (JWT) for WebSocket authentication.
Expand All @@ -139,7 +181,7 @@ export function issueReadToken(
} as ReadTokenPayload;

const encodedPayload = base64UrlEncode(JSON.stringify(fullPayload));
const data = `${JWT_HEADER}.${encodedPayload}`;
const data = `${jwtHeader()}.${encodedPayload}`;
const signature = createSignature(data, signingSecret);

return `${data}.${signature}`;
Expand Down Expand Up @@ -201,10 +243,17 @@ export function issueBatchScopedToken(
);
}

// Ed25519 JWT header (asymmetric — sidecar cannot forge tokens)
const JWT_HEADER_EDDSA = base64UrlEncode(
JSON.stringify({ alg: "EdDSA", typ: "JWT" }),
);
// Ed25519 JWT header (asymmetric — sidecar cannot forge tokens); computed on
// first use, not at module load, so the module stays browser-importable.
let jwtHeaderEddsaCache: string | undefined;
function jwtHeaderEddsa(): string {
if (jwtHeaderEddsaCache === undefined) {
jwtHeaderEddsaCache = base64UrlEncode(
JSON.stringify({ alg: "EdDSA", typ: "JWT" }),
);
}
return jwtHeaderEddsaCache;
}

/**
* Capability strings carried in a sidecar token's `cap` claim. This is the
Expand Down Expand Up @@ -237,10 +286,13 @@ export function generateSidecarKeyPair(): {
privateKey: string;
publicKey: string;
} {
const { privateKey, publicKey } = generateKeyPairSync("ed25519", {
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
const { privateKey, publicKey } = nodeCrypto().generateKeyPairSync(
"ed25519",
{
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
},
);
return { privateKey, publicKey };
}

Expand Down Expand Up @@ -280,6 +332,7 @@ export function issueSidecarAccessToken(
const now = Math.floor(Date.now() / 1000);
// jti (JWT ID) enables token revocation — orchestrator adds jti to blocklist
// on sandbox delete, sidecar checks blocklist on every request.
const { randomBytes, sign } = nodeCrypto();
const jti = `${payload.cid}:${now}:${randomBytes(8).toString("hex")}`;
const fullPayload = {
...payload,
Expand All @@ -289,8 +342,10 @@ export function issueSidecarAccessToken(
exp: now + ttlMinutes * 60,
};
const encodedPayload = base64UrlEncode(JSON.stringify(fullPayload));
const data = `${JWT_HEADER_EDDSA}.${encodedPayload}`;
const signature = base64UrlEncode(sign(null, Buffer.from(data), privateKey));
const data = `${jwtHeaderEddsa()}.${encodedPayload}`;
const signature = base64UrlEncode(
sign(null, textEncoder.encode(data), privateKey),
);
return `${data}.${signature}`;
}

Expand All @@ -316,22 +371,25 @@ export function verifySidecarToken(
const parts = token.split(".");
if (parts.length !== 3) return null;

const headerJson = Buffer.from(parts[0], "base64url").toString("utf-8");
const headerJson = base64UrlDecode(parts[0]);
const header = JSON.parse(headerJson);
if (header.alg !== "EdDSA") return null;

// SECURITY: verify signature BEFORE inspecting claims.
// Parsing claims before verification creates a timing oracle that
// leaks valid container IDs via early-return timing differences.
const data = `${parts[0]}.${parts[1]}`;
const signatureBuffer = Buffer.from(parts[2], "base64url");
const valid = verify(null, Buffer.from(data), publicKey, signatureBuffer);
const signatureBuffer = base64UrlToBytes(parts[2]);
const valid = nodeCrypto().verify(
null,
textEncoder.encode(data),
publicKey,
signatureBuffer,
);
if (!valid) return null;

// Signature verified — now safe to parse and inspect claims
const payload = JSON.parse(
Buffer.from(parts[1], "base64url").toString("utf-8"),
);
const payload = JSON.parse(base64UrlDecode(parts[1]));

if (payload.typ !== "sidecar") return null;
if (typeof payload.jti !== "string" || payload.jti.length === 0) {
Expand Down Expand Up @@ -516,7 +574,7 @@ export function verifyReadToken(
// with an extra `kid` claim) fall through to signature verification,
// which covers the actual header bytes — so any tampering with the
// header still fails the signature check.
if (header !== JWT_HEADER) {
if (header !== jwtHeader()) {
let parsedAlg: string | null = null;
try {
const decoded = JSON.parse(base64UrlDecode(header)) as Record<
Expand Down Expand Up @@ -677,15 +735,18 @@ function getApiKeyHashSalt(): string {
* WARNING: Changing the salt will invalidate all existing API key hashes.
*/
export function hashApiKey(apiKey: string): string {
return createHmac("sha256", getApiKeyHashSalt()).update(apiKey).digest("hex");
return nodeCrypto()
.createHmac("sha256", getApiKeyHashSalt())
.update(apiKey)
.digest("hex");
}

/**
* Hash an API key with an explicit salt.
* Use this when you need to verify against a specific salt.
*/
export function hashApiKeyWithSalt(apiKey: string, salt: string): string {
return createHmac("sha256", salt).update(apiKey).digest("hex");
return nodeCrypto().createHmac("sha256", salt).update(apiKey).digest("hex");
}

/**
Expand All @@ -696,7 +757,10 @@ export function verifyApiKey(provided: string, stored: string): boolean {
return false;
}
try {
return timingSafeEqual(Buffer.from(provided), Buffer.from(stored));
return nodeCrypto().timingSafeEqual(
textEncoder.encode(provided),
textEncoder.encode(stored),
);
} catch {
return false;
}
Expand Down
71 changes: 71 additions & 0 deletions packages/agent-core/tests/auth/browser-safety.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Browser Import Safety
*
* `@tangle-network/agent-core`'s root re-exports this token module, so it lands
* in browser bundles transitively (e.g. via `@tangle-network/sdk-telemetry`).
* It must stay importable and usable with no global `Buffer` — otherwise the
* consuming SPA boot-crashes with `ReferenceError: Buffer is not defined`.
*/

import { afterEach, describe, expect, it } from "vitest";
import {
decodeToken,
generateSidecarKeyPair,
hashApiKey,
issueReadToken,
issueSidecarAccessToken,
verifyApiKey,
verifySidecarToken,
} from "../../src/auth/tokens.js";

const SECRET = "orch_sign_browser_safety_secret_long_enough";

describe("auth token module browser import safety", () => {
const savedBuffer = (globalThis as { Buffer?: unknown }).Buffer;

afterEach(() => {
(globalThis as { Buffer?: unknown }).Buffer = savedBuffer;
});

it("issues and decodes read tokens with no global Buffer", () => {
(globalThis as { Buffer?: unknown }).Buffer = undefined;

const token = issueReadToken(SECRET, { sub: "u", sid: "s", pid: "p" }, 15);
expect(token.split(".")).toHaveLength(3);

const payload = decodeToken(token);
expect(payload?.sub).toBe("u");
expect((payload as { sid?: string } | null)?.sid).toBe("s");
});

it("keeps the HS256 JWT header wire format stable", () => {
const token = issueReadToken(SECRET, { sub: "u", sid: "s", pid: "p" }, 15);
// A stable header keeps already-issued tokens verifiable; the base64url
// rewrite must not change a single byte.
expect(token.split(".")[0]).toBe("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9");
});

it("round-trips Ed25519 sidecar tokens with no global Buffer", () => {
(globalThis as { Buffer?: unknown }).Buffer = undefined;

const { privateKey, publicKey } = generateSidecarKeyPair();
const token = issueSidecarAccessToken(
privateKey,
{ sub: "u", pid: "p", cid: "container_123" },
15,
);
expect(verifySidecarToken(token, publicKey, "container_123")?.cid).toBe(
"container_123",
);
expect(verifySidecarToken(token, publicKey, "other_container")).toBeNull();
});

it("hashes and verifies API keys with no global Buffer", () => {
(globalThis as { Buffer?: unknown }).Buffer = undefined;

const hash = hashApiKey("orch_prod_abc");
expect(hashApiKey("orch_prod_abc")).toBe(hash);
expect(verifyApiKey(hash, hash)).toBe(true);
expect(verifyApiKey(hash, hashApiKey("orch_prod_xyz"))).toBe(false);
});
});
Loading