(feat): add first release

This commit is contained in:
Alois 2026-04-14 00:58:38 +02:00
commit 2ba049f576
19 changed files with 1819 additions and 0 deletions

439
src/crypto.ts Normal file
View file

@ -0,0 +1,439 @@
type Base64URLString = string;
type JWK = {
kty: string;
crv: string;
x?: string;
d?: string;
};
const textEncoder = new TextEncoder();
const crypto = globalThis.crypto;
export async function encrypt(
secret: string,
plaintext: string,
): Promise<string> {
const sharedSecret = new Uint8Array(
secret.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)),
);
const hkdfKey = await crypto.subtle.importKey(
"raw",
sharedSecret,
"HKDF",
false,
["deriveBits"],
);
const okm = await crypto.subtle.deriveBits(
{
name: "HKDF",
hash: "SHA-256",
salt: new Uint8Array([]),
info: textEncoder.encode("x448-aes-gcm-no-overhead"),
},
hkdfKey,
44 * 8,
);
const okmBytes = new Uint8Array(okm);
const keyBytes = okmBytes.slice(0, 32);
const nonce = okmBytes.slice(32, 44);
const aesKey = await crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "AES-GCM" },
false,
["encrypt"],
);
const encryptedBuffer = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce },
aesKey,
textEncoder.encode(plaintext),
);
return btoa(String.fromCharCode(...new Uint8Array(encryptedBuffer)));
}
/**
* Decrypts base64 ciphertext with a symmetric key derived from a hex shared secret.
* @param secret Hex-encoded shared secret.
* @param ciphertext Base64 ciphertext to decrypt.
* @returns Decrypted UTF-8 plaintext.
*/
export async function decrypt(
secret: string,
ciphertext: Base64URLString | string,
): Promise<string> {
const sharedSecret = new Uint8Array(
secret.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)),
);
const ciphertextBytes = Uint8Array.from(atob(ciphertext), (c) =>
c.charCodeAt(0),
);
const hkdfKey = await crypto.subtle.importKey(
"raw",
sharedSecret,
"HKDF",
false,
["deriveBits"],
);
const okm = await crypto.subtle.deriveBits(
{
name: "HKDF",
hash: "SHA-256",
salt: new Uint8Array([]),
info: textEncoder.encode("x448-aes-gcm-no-overhead"),
},
hkdfKey,
44 * 8,
);
const okmBytes = new Uint8Array(okm);
const keyBytes = okmBytes.slice(0, 32);
const nonce = okmBytes.slice(32, 44);
const aesKey = await crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "AES-GCM" },
false,
["decrypt"],
);
const decryptedBuffer = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: nonce,
},
aesKey,
ciphertextBytes,
);
return new TextDecoder().decode(decryptedBuffer);
}
/**
* Computes an X448 shared secret from local and peer key material.
* @param ownPrivateKey Local private key in raw/base64/base64url or PKCS#8-wrapped form.
* @param ownPublicKey Local public key in raw/base64/base64url or SPKI-wrapped form.
* @param otherPublicKey Peer public key in raw/base64/base64url or SPKI-wrapped form.
* @returns Hex-encoded shared secret, or a failure message when key material is missing/invalid.
*/
export async function getSharedSecret(
ownPrivateKey: string,
ownPublicKey: string,
otherPublicKey: string,
): Promise<string> {
const otherJwk: JWK = { kty: "OKP", crv: "X448", x: otherPublicKey };
const ownJwk: JWK = {
kty: "OKP",
crv: "X448",
x: ownPublicKey,
d: ownPrivateKey,
};
/**
* Converts bytes to a lowercase hex string.
* @param u8 Byte array.
* @returns Hex string.
*/
const bytesToHex = (u8: Uint8Array): string =>
Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join("");
/**
* Decodes standard base64 text into bytes.
* @param s Base64 string.
* @returns Decoded bytes.
*/
const b64ToBytes = (s: Base64URLString): Uint8Array => {
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
};
/**
* Decodes URL-safe base64 text into bytes.
* @param s Base64url string.
* @returns Decoded bytes.
*/
const b64uToBytes = (s: Base64URLString): Uint8Array => {
const b64 =
s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4);
return b64ToBytes(b64);
};
/**
* Encodes bytes as URL-safe base64 without padding.
* @param u8 Byte array.
* @returns Base64url string.
*/
const bytesToB64u = (u8: Uint8Array): string => {
const b64 = btoa(String.fromCharCode(...u8));
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
};
/**
* Decodes either base64 or base64url text into bytes.
* @param s Base64/base64url string.
* @returns Decoded bytes.
*/
const decodeBase64Auto = (s: string): Uint8Array =>
/[-_]/.test(s) ? b64uToBytes(s) : b64ToBytes(s);
/**
* Reads a DER TLV item from the provided offset.
* @param view DER-encoded bytes.
* @param off Start offset.
* @returns Parsed TLV metadata with tag, length, and boundaries.
*/
const readTLV = (view: Uint8Array, off: number) => {
const tag = view[off];
if (tag === undefined) throw new Error("DER: truncated");
off += 1;
const lenByte = view[off];
if (lenByte === undefined) throw new Error("DER: truncated");
off += 1;
let len = lenByte;
if (lenByte & 0x80) {
const n = lenByte & 0x7f;
if (n === 0) throw new Error("DER: indefinite length not supported");
if (off + n > view.length) throw new Error("DER: truncated length");
len = 0;
for (let i = 0; i < n; i++) {
const b = view[off];
if (b === undefined) throw new Error("DER: truncated length");
len = (len << 8) | b;
off += 1;
}
}
const start = off;
const end = off + len;
if (end > view.length) throw new Error("DER: content truncated");
return { tag, len, start, end };
};
/**
* Validates that a DER OID matches X448.
* @param view DER-encoded bytes.
* @param start Offset of the OID TLV.
* @returns True when the OID is X448.
*/
const ensureOidX448 = (view: Uint8Array, start: number): boolean => {
const oid = readTLV(view, start);
if (oid.tag !== 0x06) return false;
const len = oid.end - oid.start;
if (len !== 3) return false;
return (
view[oid.start] === 0x2b &&
view[oid.start + 1] === 0x65 &&
view[oid.start + 2] === 0x6f
);
};
/**
* Extracts raw 56-byte X448 public key material from SPKI bytes.
* @param spkiBytes DER-encoded SPKI bytes.
* @returns Raw X448 public key bytes.
*/
const extractRawX448FromSPKI = (spkiBytes: Uint8Array): Uint8Array => {
const view = spkiBytes;
const outer = readTLV(view, 0);
if (outer.tag !== 0x30) throw new Error("SPKI: expected SEQUENCE");
const alg = readTLV(view, outer.start);
if (alg.tag !== 0x30) throw new Error("SPKI: expected AlgorithmIdentifier");
if (!ensureOidX448(view, alg.start)) throw new Error("SPKI: not X448");
const bitstr = readTLV(view, alg.end);
if (bitstr.tag !== 0x03) throw new Error("SPKI: expected BIT STRING");
const unusedBits = view[bitstr.start];
if (unusedBits !== 0x00) throw new Error("SPKI: unexpected unused bits");
const raw = view.subarray(bitstr.start + 1, bitstr.end);
if (raw.length !== 56)
throw new Error("SPKI: X448 public key must be 56 bytes");
return raw;
};
/**
* Extracts raw 56-byte X448 private key material from PKCS#8 bytes.
* @param pkcs8Bytes DER-encoded PKCS#8 bytes.
* @returns Raw X448 private key bytes.
*/
const extractRawX448FromPKCS8 = (pkcs8Bytes: Uint8Array): Uint8Array => {
const view = pkcs8Bytes;
const outer = readTLV(view, 0);
if (outer.tag !== 0x30) throw new Error("PKCS8: expected SEQUENCE");
let off = outer.start;
const version = readTLV(view, off);
if (version.tag !== 0x02)
throw new Error("PKCS8: expected version INTEGER");
off = version.end;
const alg = readTLV(view, off);
if (alg.tag !== 0x30)
throw new Error("PKCS8: expected AlgorithmIdentifier");
if (!ensureOidX448(view, alg.start)) throw new Error("PKCS8: not X448");
off = alg.end;
const priv = readTLV(view, off);
if (priv.tag !== 0x04)
throw new Error("PKCS8: expected privateKey OCTET STRING");
let raw = view.subarray(priv.start, priv.end);
// Some encoders nest another OCTET STRING inside
if (raw[0] === 0x04) {
const inner = readTLV(raw, 0);
if (inner.tag === 0x04) {
raw = raw.subarray(inner.start, inner.end);
}
}
if (raw.length !== 56)
throw new Error("PKCS8: X448 private key must be 56 bytes");
return raw;
};
/**
* Normalizes X448 JWK fields into raw base64url key material.
* @param jwk Candidate JWK.
* @param label Error label for diagnostics.
* @returns Normalized JWK suitable for WebCrypto import.
*/
const normalizeOkpX448Jwk = (jwk: JWK, label: string): JWK => {
if (!jwk || jwk.kty !== "OKP" || jwk.crv !== "X448") {
throw new Error(`${label}: expected OKP JWK with crv "X448"`);
}
const out = { ...jwk };
if (out.x) {
const xBytes = decodeBase64Auto(out.x);
let rawX: Uint8Array;
try {
rawX = extractRawX448FromSPKI(xBytes);
} catch {
if (xBytes.length !== 56) {
throw new Error(
`${label}: "x" is not a valid X448 SPKI or raw 56-byte key`,
);
}
rawX = xBytes;
}
out.x = bytesToB64u(rawX);
}
if (out.d) {
const dBytes = decodeBase64Auto(out.d);
let rawD: Uint8Array;
try {
rawD = extractRawX448FromPKCS8(dBytes);
} catch {
if (dBytes.length !== 56) {
throw new Error(
`${label}: "d" is not a valid X448 PKCS#8 or raw 56-byte key`,
);
}
rawD = dBytes;
}
out.d = bytesToB64u(rawD);
}
return out;
};
/**
* Returns WebCrypto subtle API when available.
* @returns SubtleCrypto instance or undefined.
*/
const getSubtle = () => globalThis.crypto?.subtle;
{
/*
const hkdfAesGcmFromShared = async (
sharedSecret: BufferSource,
infoStr: string
): Promise<CryptoKey> => {
const subtle = getSubtle();
if (!subtle) throw new Error("WebCrypto subtle not available");
const info = textEncoder.encode(infoStr);
const baseKey = await subtle.importKey(
"raw",
sharedSecret,
"HKDF",
false,
["deriveKey"]
);
return await subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: new Uint8Array(0),
info,
},
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
};
*/
}
const myJwk: JWK = normalizeOkpX448Jwk(ownJwk, "own_jwk");
const peerJwk: JWK = normalizeOkpX448Jwk(otherJwk, "other_jwk");
const subtle = getSubtle();
//const infoStr = `ECDH-X448-AES-GCM-v1|my=${myJwk.x}|peer=${peerJwk.x}`;
if (subtle) {
const algorithms = [{ name: "ECDH", namedCurve: "X448" }, { name: "X448" }];
for (const algorithm of algorithms) {
try {
const [myPriv, peerPub] = await Promise.all([
subtle.importKey("jwk", myJwk, algorithm, false, ["deriveBits"]),
subtle.importKey("jwk", peerJwk, algorithm, false, []),
]);
const sharedBits = await subtle.deriveBits(
{ name: algorithm.name, public: peerPub },
myPriv,
448,
);
const sharedSecret = new Uint8Array(sharedBits);
//const aeadKey = await hkdfAesGcmFromShared(sharedSecret, infoStr);
return bytesToHex(sharedSecret);
} catch {
// Browser doesn't support this algorithm, try next or fall through to software fallback
}
}
}
const { d: dMyB64u } = myJwk;
//const { x: xMyB64u, d: dMyB64u } = myJwk;
const { x: xPeerB64u } = peerJwk;
if (!dMyB64u || !xPeerB64u) {
return "Failed to get shared secret due to missing keys";
}
const [dRaw, xRawPeer] = [b64uToBytes(dMyB64u), b64uToBytes(xPeerB64u)];
if (dRaw.length !== 56 || xRawPeer.length !== 56) {
return "Failed to get shared secret due to invalid key lengths";
}
const { x448 } = await import("@noble/curves/ed448.js");
const sharedSecret = new Uint8Array(x448.getSharedSecret(dRaw, xRawPeer));
//const aeadKey = await hkdfAesGcmFromShared(sharedSecret, infoStr);
return bytesToHex(sharedSecret);
}

10
src/index.html Normal file

File diff suppressed because one or more lines are too long

254
src/index.ts Normal file
View file

@ -0,0 +1,254 @@
import {
createTransportClient,
type TransportClient,
READY_STATE,
} from "@tensamin/ttp-core";
import { createSchema } from "./schema";
import type { ZodObject } from "zod";
import { x448 } from "@noble/curves/ed448.js";
import Bun from "bun";
import { get } from "./user";
import { decrypt, encrypt, getSharedSecret } from "./crypto";
import { readFile } from "node:fs/promises";
/**
* @param redirectUrl The URL should point to the TAuth HTTP Server at http://hostname:port/callback
*/
export class TAuthClient {
frontendUrl: string;
identifier: string;
privateKey: string;
publicKey: string;
redirectUrl: URL;
schema: ReturnType<typeof createSchema>;
httpServer: {
port: number;
hostname: string;
};
clientMap = new Map<string, TransportClient<typeof this.schema>>();
constructor({
frontendUrl,
identifier,
privateKey,
publicKey,
redirectUrl,
appData,
httpServer,
htmlSuccessPage,
}: {
frontendUrl: string;
identifier: string;
privateKey: string;
publicKey: string;
redirectUrl: string;
appData: ZodObject;
httpServer?: {
port: number;
hostname: string;
};
htmlSuccessPage?: string;
}) {
this.frontendUrl = frontendUrl;
this.identifier = identifier;
this.privateKey = privateKey;
this.publicKey = publicKey;
this.redirectUrl = new URL(redirectUrl);
this.schema = createSchema(appData);
this.httpServer = httpServer ?? {
port: 7878,
hostname: "localhost",
};
Bun.serve({
hostname: this.httpServer.hostname,
port: this.httpServer.port,
fetch: async (request) => {
const url = new URL(request.url);
switch (url.pathname) {
case "/callback": {
const userId = Number(url.searchParams.get("userId"));
if (!userId) {
return new Response("Missing userId", { status: 400 });
}
const potentialChallenge = url.searchParams.get("challenge");
if (!potentialChallenge) {
const challenge = await this.generateChallenge(userId);
const newLink = this.generateLink(challenge);
return new Response(null, {
status: 302,
headers: {
Location: newLink,
},
});
}
const originalChallenge = url.searchParams.get("originalChallenge");
if (!originalChallenge) {
return new Response("Missing originalChallenge", { status: 400 });
}
const potentialSessionId = Number(
url.searchParams.get("sessionId"),
);
if (!potentialSessionId || isNaN(potentialSessionId)) {
return new Response("Invalid or missing sessionId", {
status: 400,
});
}
if (
(await this.solveChallenge(userId, originalChallenge)) ===
potentialChallenge
) {
const { ip_address } = await fetch(
"https://omega.tensamin.net/api/get/omikron/" + userId,
).then((res) => res.json());
this.createTTP(userId, potentialSessionId, ip_address);
return new Response(
htmlSuccessPage || (await readFile("./index.html")),
{
headers: {
"Content-Type": "text/html",
},
status: 200,
},
);
} else {
return new Response("Failed to solve challenge", { status: 400 });
}
}
case "/auth": {
return new Response(null, {
status: 302,
headers: {
Location: this.generateLink(),
},
});
}
default: {
return new Response("Not Found", { status: 404 });
}
}
},
});
console.log(
`[tauth] HTTP Server running at http://${this.httpServer.hostname}:${this.httpServer.port}`,
);
}
async generateChallenge(userId: number): Promise<string> {
const userData = await get(userId);
if (!userData) {
throw new Error("User not found");
}
const sharedSecret = await getSharedSecret(
this.privateKey,
this.publicKey,
userData.public_key,
);
const encrypted = await encrypt(sharedSecret, crypto.randomUUID());
const decrypted = await decrypt(sharedSecret, encrypted);
console.log(
`[tauth] Generated challenge for user ${userId}: ${decrypted} (encrypted: ${encrypted})`,
);
return Array.from(atob(encrypted))
.map((chat) => chat.charCodeAt(0).toString(16).padStart(2, "0"))
.join("");
}
async solveChallenge(userId: number, challenge: string) {
const userData = await get(userId);
if (!userData) {
throw new Error("User not found");
}
const sharedSecret = await getSharedSecret(
this.privateKey,
this.publicKey,
userData.public_key,
);
return await decrypt(sharedSecret, challenge);
}
createTTP(userId: number, sessionId: number, omikronUrl: string) {
console.log(
`[tauth] Creating TTP client for user ${userId} with session ${sessionId} at ${omikronUrl}`,
);
this.clientMap.set(
`${userId}:${sessionId}`,
createTransportClient(this.schema, {
url: omikronUrl,
onReadyStateChange: (stateIndex) => {
const state = getFriendlyReadyState(stateIndex);
console.log(state);
if (state === "OPEN") {
this.clientMap
.get(`${userId}:${sessionId}`)
?.send("identification", {
app_identifier: this.identifier,
app_session_id: sessionId,
app_public_key: this.publicKey,
user_id: userId,
});
}
},
}),
);
this.clientMap.get(`${userId}:${sessionId}`)?.connect();
}
generateLink(challenge?: string): string {
return new URL(
`?identifier=${this.identifier}&redirect=${this.redirectUrl}${challenge ? `&challenge=${challenge}` : ""}`,
this.frontendUrl,
).toString();
}
}
export function getFriendlyReadyState(
stateIndex: number,
): keyof typeof READY_STATE {
return Object.keys(READY_STATE).find(
(key) => READY_STATE[key as keyof typeof READY_STATE] === stateIndex,
) as keyof typeof READY_STATE;
}
export function generateKeyPair(): { private: string; public: string } {
function toBase64(bytes: Uint8Array): string {
if (typeof Buffer !== "undefined") {
return Buffer.from(bytes).toString("base64");
}
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
const priv = new Uint8Array(56);
crypto.getRandomValues(priv);
const pub = x448.getPublicKey(priv);
return {
private: toBase64(priv),
public: toBase64(pub),
};
}
export * from "./crypto";
export * from "./schema";
export * from "./user";

26
src/schema.ts Normal file
View file

@ -0,0 +1,26 @@
import z, { ZodObject } from "zod";
export function createSchema(appData: ZodObject) {
return {
identification: {
request: z.object({
app_identifier: z.string(),
app_session_id: z.number(),
app_public_key: z.string(),
user_id: z.number(),
}),
response: z.object({
challenge: z.base64(),
public_key: z.base64(),
}),
},
challenge_response: {
request: z.object({
challenge: z.base64(),
}),
response: z.object({
app_data: appData,
}),
},
};
}

34
src/user.ts Normal file
View file

@ -0,0 +1,34 @@
export type User = {
status: string;
username: string;
public_key: string;
user_id: number;
iota_id: number;
sub_level: number;
sub_end: number;
display: string;
status_message: string;
about: string;
};
const userCacheMap = new Map<number, User>();
export async function get(userId: number): Promise<User | undefined> {
try {
const cachedUser = userCacheMap.get(userId);
if (cachedUser) {
return cachedUser;
}
const fetchedUser = await fetch(
"https://omega.tensamin.net/api/get/user/" + userId,
).then((res) => res.json());
if (fetchedUser) {
userCacheMap.set(userId, fetchedUser);
}
return fetchedUser;
} catch (error) {
console.error("Error fetching user data for userId:", userId, error);
return undefined;
}
}