(feat): crypto migrations
This commit is contained in:
parent
930663d495
commit
cd2c2f8167
17 changed files with 839 additions and 825 deletions
|
|
@ -162,7 +162,6 @@ export default defineConfig({
|
||||||
"@tensamin/chat",
|
"@tensamin/chat",
|
||||||
"@tensamin/crypto",
|
"@tensamin/crypto",
|
||||||
"@tensamin/crypto/context",
|
"@tensamin/crypto/context",
|
||||||
"@tensamin/crypto/worker",
|
|
||||||
"@tensamin/markdown",
|
"@tensamin/markdown",
|
||||||
"@tensamin/mtp",
|
"@tensamin/mtp",
|
||||||
"@tensamin/notifications",
|
"@tensamin/notifications",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { useChat } from "../context";
|
||||||
import { useMTP } from "@tensamin/mtp";
|
import { useMTP } from "@tensamin/mtp";
|
||||||
import { log, toast } from "@tensamin/shared/log";
|
import { log, toast } from "@tensamin/shared/log";
|
||||||
import { cn, useIsMobile } from "@tensamin/ui";
|
import { cn, useIsMobile } from "@tensamin/ui";
|
||||||
import { encryptText } from "@tensamin/crypto/worker";
|
|
||||||
import { useSession } from "@tensamin/storage/session";
|
import { useSession } from "@tensamin/storage/session";
|
||||||
import GifPicker from "./gifPicker";
|
import GifPicker from "./gifPicker";
|
||||||
|
|
||||||
|
|
@ -28,8 +28,8 @@ export default function InputComponent({
|
||||||
}) {
|
}) {
|
||||||
const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false);
|
const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false);
|
||||||
|
|
||||||
const { send } = useMTP();
|
const { sendEncrypted } = useMTP();
|
||||||
const { addLiveMessage, sharedSecret, userId, inputBoxRef } = useChat();
|
const { addLiveMessage, userId, inputBoxRef } = useChat();
|
||||||
const { load, save } = useStorage();
|
const { load, save } = useStorage();
|
||||||
const { moveUserIdToTop } = useSession();
|
const { moveUserIdToTop } = useSession();
|
||||||
const gifPopoverRef = React.useRef<HTMLDivElement>(null);
|
const gifPopoverRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -64,11 +64,6 @@ export default function InputComponent({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sharedSecret) {
|
|
||||||
toast("error", "Still getting shared secret...");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log(3, "chat", "purple", "Message send init, adding live message ...");
|
log(3, "chat", "purple", "Message send init, adding live message ...");
|
||||||
|
|
||||||
const reference = addLiveMessage({
|
const reference = addLiveMessage({
|
||||||
|
|
@ -79,36 +74,38 @@ export default function InputComponent({
|
||||||
MessageState: "awaiting",
|
MessageState: "awaiting",
|
||||||
});
|
});
|
||||||
|
|
||||||
log(3, "chat", "purple", "Live message added, encrypting...");
|
log(3, "chat", "purple", "Live message added, sending encrypted frame...");
|
||||||
|
|
||||||
const encryptedContext = await encryptText(
|
void load("user_id")
|
||||||
sharedSecret,
|
.then((ownUserId) =>
|
||||||
currentValue,
|
sendEncrypted(
|
||||||
).catch((err) => {
|
"MessageSend",
|
||||||
toast("error", "Failed to encrypt message", String(err));
|
{
|
||||||
reference.setFailed(true);
|
Content: currentValue,
|
||||||
});
|
ReceiverId: userId,
|
||||||
|
SendTime: time,
|
||||||
if (!encryptedContext) return;
|
},
|
||||||
|
{
|
||||||
log(3, "chat", "purple", "Content encrypted, sending message...");
|
senderUserId: String(ownUserId),
|
||||||
|
recipientUserId: String(userId),
|
||||||
send("MessageSend", {
|
},
|
||||||
Content: encryptedContext,
|
),
|
||||||
ReceiverId: userId,
|
)
|
||||||
SendTime: time,
|
.catch((e) => {
|
||||||
}).catch((e) => {
|
log(0, "Chat", "red", "Failed to send encrypted message", e, {
|
||||||
log(0, "Chat", "red", "Failed to send message", e, {
|
ReceiverId: userId,
|
||||||
content: currentValue,
|
SendTime: time,
|
||||||
encryptedContext,
|
});
|
||||||
ReceiverId: userId,
|
reference.setFailed(true);
|
||||||
SendTime: time,
|
toast(
|
||||||
|
"error",
|
||||||
|
e instanceof Error && e.message.includes("public key")
|
||||||
|
? "Recipient has no encryption public key available"
|
||||||
|
: "Failed to send encrypted message",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
reference.setFailed(true);
|
|
||||||
toast("error", "Failed to send message");
|
|
||||||
});
|
|
||||||
|
|
||||||
log(3, "chat", "purple", "Message sent");
|
log(3, "chat", "purple", "Encrypted message send queued");
|
||||||
|
|
||||||
moveUserIdToTop(userId);
|
moveUserIdToTop(userId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useRouterState } from "@tanstack/react-router";
|
import { useRouterState } from "@tanstack/react-router";
|
||||||
import type { InfiniteData } from "@tanstack/react-query";
|
import type { InfiniteData } from "@tanstack/react-query";
|
||||||
import type { LiveMessage, RawMessage, RawMessages } from "./values";
|
import type { LiveMessage, RawMessage, RawMessages } from "./values";
|
||||||
import { useCrypto } from "@tensamin/crypto/context";
|
|
||||||
import { useUser } from "@tensamin/user/context";
|
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import { useMTP } from "@tensamin/mtp";
|
import { useMTP } from "@tensamin/mtp";
|
||||||
import { log } from "@tensamin/shared/log";
|
import { log } from "@tensamin/shared/log";
|
||||||
|
|
@ -51,23 +49,15 @@ function updateMessageStateBySendTime<
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Provider({ children }: { children: ReactNode }) {
|
export default function Provider({ children }: { children: ReactNode }) {
|
||||||
const { getSharedSecret, decryptText } = useCrypto();
|
|
||||||
const { get } = useUser();
|
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const { send, subscribePush } = useMTP();
|
const { send, subscribePush, subscribeEncrypted, decryptEncryptedRecord } =
|
||||||
|
useMTP();
|
||||||
const { moveUserIdToTop } = useSession();
|
const { moveUserIdToTop } = useSession();
|
||||||
|
|
||||||
const [error, setError] = useState("");
|
const [error] = useState("");
|
||||||
const [errorDescription, setErrorDescription] = useState("");
|
const [errorDescription] = useState("");
|
||||||
|
|
||||||
const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);
|
const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);
|
||||||
const [currentSharedSecretState, setCurrentSharedSecretState] = useState<{
|
|
||||||
userId: number;
|
|
||||||
value: string;
|
|
||||||
}>({
|
|
||||||
userId: 0,
|
|
||||||
value: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const inputBoxRef = useRef<HTMLDivElement>(null);
|
const inputBoxRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -81,86 +71,40 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
return Number(rawId ?? 0);
|
return Number(rawId ?? 0);
|
||||||
}, [locationSearch]);
|
}, [locationSearch]);
|
||||||
|
|
||||||
const currentSharedSecret = useMemo(() => {
|
|
||||||
if (currentSharedSecretState.userId !== userIdValue) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentSharedSecretState.value;
|
|
||||||
}, [currentSharedSecretState, userIdValue]);
|
|
||||||
|
|
||||||
// Load shared secret
|
|
||||||
useEffect(() => {
|
|
||||||
if (!userIdValue) return;
|
|
||||||
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const recipientData = await get(userIdValue);
|
|
||||||
const ownId = await load("user_id");
|
|
||||||
const privateKey = await load("mtp_keyring");
|
|
||||||
const ownData = await get(ownId);
|
|
||||||
|
|
||||||
log(3, "chat", "purple", "Getting shared secret...", {
|
|
||||||
recipientData,
|
|
||||||
ownData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sharedSecret = await getSharedSecret(
|
|
||||||
privateKey,
|
|
||||||
ownData.PublicKey,
|
|
||||||
recipientData.PublicKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
log(2, "chat", "purple", "Got shared secret", {
|
|
||||||
sharedSecret,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (active) {
|
|
||||||
setCurrentSharedSecretState({
|
|
||||||
userId: userIdValue,
|
|
||||||
value: sharedSecret,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
log(
|
|
||||||
1,
|
|
||||||
"chat",
|
|
||||||
"red",
|
|
||||||
"An unknown error occured while getting a shared secret",
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
setError(err instanceof Error ? err.name : "Unknown Error");
|
|
||||||
setErrorDescription(err instanceof Error ? err.message : String(err));
|
|
||||||
if (active) {
|
|
||||||
setCurrentSharedSecretState({
|
|
||||||
userId: userIdValue,
|
|
||||||
value: "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [get, getSharedSecret, load, userIdValue]);
|
|
||||||
|
|
||||||
const getMessages = useCallback(
|
const getMessages = useCallback(
|
||||||
async (amount: number, offset: number) => {
|
async (amount: number, offset: number) => {
|
||||||
const messages = await send("MessagesGet", {
|
const response = await send("EncryptedMessagesGet", {
|
||||||
Amount: amount,
|
Limit: amount,
|
||||||
Offset: offset,
|
SenderUserId: String(userIdValue),
|
||||||
UserId: userIdValue,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (messages.type.startsWith("error")) {
|
if (response.type.startsWith("error")) {
|
||||||
throw new Error(messages.type);
|
throw new Error(response.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawMessages = messages.data.Messages;
|
const encryptedMessages = (response.data.Messages ?? []) as Record<
|
||||||
const sorted = [...rawMessages].sort((a, b) => a.SendTime - b.SendTime);
|
string,
|
||||||
|
unknown
|
||||||
|
>[];
|
||||||
|
const decrypted = await Promise.all(
|
||||||
|
encryptedMessages.slice(offset, offset + amount).map(async (record) => {
|
||||||
|
const inner = await decryptEncryptedRecord(record);
|
||||||
|
const data = inner.data as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
NotEncrypted: false,
|
||||||
|
SendTime: Number(
|
||||||
|
data.SendTime ?? record.CreatedAt ?? record.createdAt,
|
||||||
|
),
|
||||||
|
Content: String(data.Content ?? ""),
|
||||||
|
SentBySelf:
|
||||||
|
String(record.SenderUserId ?? record.senderUserId ?? "") ===
|
||||||
|
String(await load("user_id")),
|
||||||
|
MessageState: "received" as RawMessage["MessageState"],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sorted = [...decrypted].sort((a, b) => a.SendTime - b.SendTime);
|
||||||
|
|
||||||
if (sorted.length > 0) {
|
if (sorted.length > 0) {
|
||||||
const fetchedSendTimes = new Set(sorted.map((item) => item.SendTime));
|
const fetchedSendTimes = new Set(sorted.map((item) => item.SendTime));
|
||||||
|
|
@ -174,20 +118,9 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return await Promise.all(
|
return sorted;
|
||||||
sorted.map(async (message) => {
|
|
||||||
try {
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
content: await decryptText(currentSharedSecret, message.Content),
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[send, userIdValue, currentSharedSecret, decryptText],
|
[send, userIdValue, decryptEncryptedRecord, load],
|
||||||
);
|
);
|
||||||
|
|
||||||
const addLiveMessage = useCallback(
|
const addLiveMessage = useCallback(
|
||||||
|
|
@ -228,6 +161,30 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
setLiveMessagesState([]);
|
setLiveMessagesState([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribeEncrypted("MessageSend", (data) => {
|
||||||
|
const rawData = data as unknown as {
|
||||||
|
Content?: unknown;
|
||||||
|
ReceiverId?: unknown;
|
||||||
|
SendTime?: unknown;
|
||||||
|
};
|
||||||
|
const sendTime = Number(rawData.SendTime);
|
||||||
|
|
||||||
|
if (!Number.isFinite(sendTime) || typeof rawData.Content !== "string") {
|
||||||
|
log(3, "chat", "yellow", "Ignoring invalid encrypted live message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addLiveMessage({
|
||||||
|
NotEncrypted: false,
|
||||||
|
SendTime: sendTime,
|
||||||
|
Content: rawData.Content,
|
||||||
|
SentBySelf: false,
|
||||||
|
MessageState: "received",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [addLiveMessage, subscribeEncrypted]);
|
||||||
|
|
||||||
// Get live updates for message states
|
// Get live updates for message states
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return subscribePush((message) => {
|
return subscribePush((message) => {
|
||||||
|
|
@ -286,7 +243,6 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
const queryKey = [
|
const queryKey = [
|
||||||
"chat-messages",
|
"chat-messages",
|
||||||
String(userIdValue),
|
String(userIdValue),
|
||||||
currentSharedSecret.length > 0,
|
|
||||||
] as const;
|
] as const;
|
||||||
queryClient.setQueryData<InfiniteData<RawMessages>>(
|
queryClient.setQueryData<InfiniteData<RawMessages>>(
|
||||||
queryKey,
|
queryKey,
|
||||||
|
|
@ -322,7 +278,7 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [currentSharedSecret, subscribePush, userIdValue]);
|
}, [subscribePush, userIdValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|
@ -332,7 +288,7 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
liveMessages: () => liveMessagesState,
|
liveMessages: () => liveMessagesState,
|
||||||
addLiveMessage,
|
addLiveMessage,
|
||||||
clearLiveMessages,
|
clearLiveMessages,
|
||||||
sharedSecret: currentSharedSecret,
|
sharedSecret: "",
|
||||||
userId: userIdValue,
|
userId: userIdValue,
|
||||||
inputBoxRef,
|
inputBoxRef,
|
||||||
error,
|
error,
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,6 @@ export default function Screen() {
|
||||||
liveMessages,
|
liveMessages,
|
||||||
clearLiveMessages,
|
clearLiveMessages,
|
||||||
userId,
|
userId,
|
||||||
sharedSecret,
|
|
||||||
error,
|
error,
|
||||||
errorDescription,
|
errorDescription,
|
||||||
} = useChat();
|
} = useChat();
|
||||||
|
|
@ -106,13 +105,12 @@ export default function Screen() {
|
||||||
const [value, setValue] = React.useState("");
|
const [value, setValue] = React.useState("");
|
||||||
|
|
||||||
const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
|
const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
|
||||||
const hasSharedSecret = sharedSecret.length > 0;
|
|
||||||
|
|
||||||
const messagesQuery = useInfiniteQuery({
|
const messagesQuery = useInfiniteQuery({
|
||||||
queryKey: ["chat-messages", String(userId), hasSharedSecret],
|
queryKey: ["chat-messages", String(userId)],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: ({ pageParam }) => getMessages(PAGE_SIZE, Number(pageParam)),
|
queryFn: ({ pageParam }) => getMessages(PAGE_SIZE, Number(pageParam)),
|
||||||
enabled: hasValidChatUser && hasSharedSecret,
|
enabled: hasValidChatUser,
|
||||||
getNextPageParam: (lastPage, allPages) => {
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
if (lastPage.length < PAGE_SIZE) {
|
if (lastPage.length < PAGE_SIZE) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
"./context": "./src/context.tsx",
|
"./context": "./src/context.tsx",
|
||||||
"./worker": "./src/worker.ts"
|
"./encryptedDeviceSecret": "./src/encryptedDeviceSecret.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "pnpm exec prettier --write .",
|
"format": "pnpm exec prettier --write .",
|
||||||
|
|
@ -14,9 +14,6 @@
|
||||||
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
|
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/curves": "^2.0.1",
|
|
||||||
"@noble/hashes": "^2.0.1",
|
|
||||||
"comlink": "^4.4.2",
|
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("mtp", () => ({
|
||||||
|
crypto: {},
|
||||||
|
}));
|
||||||
|
|
||||||
import { createCryptoActions } from "./context";
|
import { createCryptoActions } from "./context";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -21,20 +21,47 @@ type CryptoContextType = {
|
||||||
|
|
||||||
export const context = createContext<CryptoContextType | undefined>(undefined);
|
export const context = createContext<CryptoContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function createCryptoActions(
|
||||||
|
getApi: () => CryptoContextType | null | undefined,
|
||||||
|
): CryptoContextType {
|
||||||
|
const requireApi = () => {
|
||||||
|
const api = getApi();
|
||||||
|
if (!api) {
|
||||||
|
throw new Error("Crypto API not initialized");
|
||||||
|
}
|
||||||
|
return api;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
decrypt: (secret, input) => requireApi().decrypt(secret, input),
|
||||||
|
decryptText: (secret, ciphertext) =>
|
||||||
|
requireApi().decryptText(secret, ciphertext),
|
||||||
|
encrypt: (secret, input) => requireApi().encrypt(secret, input),
|
||||||
|
encryptText: (secret, plaintext) =>
|
||||||
|
requireApi().encryptText(secret, plaintext),
|
||||||
|
getSharedSecret: (ownPrivateKey, ownPublicKey, otherPublicKey) =>
|
||||||
|
requireApi().getSharedSecret(ownPrivateKey, ownPublicKey, otherPublicKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownedBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||||
|
const out = new Uint8Array(bytes.byteLength);
|
||||||
|
out.set(bytes);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Provider(props: { children: React.ReactNode }) {
|
export default function Provider(props: { children: React.ReactNode }) {
|
||||||
return (
|
const actions = createCryptoActions(() => ({
|
||||||
<context.Provider
|
decrypt: async (secret, input) =>
|
||||||
value={{
|
ownedBytes(await crypto.decrypt(secret, input)),
|
||||||
decrypt: crypto.decrypt,
|
decryptText: crypto.decryptText,
|
||||||
encrypt: crypto.encrypt,
|
encrypt: async (secret, input) =>
|
||||||
decryptText: crypto.decryptText,
|
ownedBytes(await crypto.encrypt(secret, input)),
|
||||||
encryptText: crypto.encryptText,
|
encryptText: crypto.encryptText,
|
||||||
getSharedSecret: crypto.getSharedSecret,
|
getSharedSecret: crypto.getSharedSecret,
|
||||||
}}
|
}));
|
||||||
>
|
|
||||||
{props.children}
|
return <context.Provider value={actions}>{props.children}</context.Provider>;
|
||||||
</context.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCrypto(): CryptoContextType {
|
export function useCrypto(): CryptoContextType {
|
||||||
|
|
|
||||||
134
packages/crypto/src/encryptedDeviceSecret.ts
Normal file
134
packages/crypto/src/encryptedDeviceSecret.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
const textEncoder = new TextEncoder();
|
||||||
|
|
||||||
|
export const ENCRYPTED_DEVICE_SECRET_WRAPPING_SCHEME =
|
||||||
|
"webcrypto-aes-gcm-hkdf-sha256-v1";
|
||||||
|
|
||||||
|
export async function wrapDeviceSecret(args: {
|
||||||
|
rawSecret: Uint8Array;
|
||||||
|
wrappingSecret: Uint8Array | string;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
secretId: string;
|
||||||
|
version: number;
|
||||||
|
}): Promise<{
|
||||||
|
encryptedSecret: Uint8Array;
|
||||||
|
wrappingScheme: string;
|
||||||
|
wrappingPublicKeyId?: string;
|
||||||
|
}> {
|
||||||
|
if (!args.rawSecret.length) {
|
||||||
|
throw new Error("rawSecret must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await deriveWrappingKey(args);
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const aad = metadataAad(args);
|
||||||
|
|
||||||
|
const ciphertext = new Uint8Array(
|
||||||
|
await crypto.subtle.encrypt(
|
||||||
|
{ name: "AES-GCM", iv: ownedBytes(iv), additionalData: ownedBytes(aad) },
|
||||||
|
key,
|
||||||
|
ownedBytes(args.rawSecret),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const encryptedSecret = new Uint8Array(iv.length + ciphertext.length);
|
||||||
|
encryptedSecret.set(iv, 0);
|
||||||
|
encryptedSecret.set(ciphertext, iv.length);
|
||||||
|
|
||||||
|
return {
|
||||||
|
encryptedSecret,
|
||||||
|
wrappingScheme: ENCRYPTED_DEVICE_SECRET_WRAPPING_SCHEME,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unwrapDeviceSecret(args: {
|
||||||
|
encryptedSecret: Uint8Array;
|
||||||
|
wrappingSecret: Uint8Array | string;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
secretId: string;
|
||||||
|
version: number;
|
||||||
|
wrappingScheme: string;
|
||||||
|
}): Promise<Uint8Array> {
|
||||||
|
if (args.wrappingScheme !== ENCRYPTED_DEVICE_SECRET_WRAPPING_SCHEME) {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported encrypted device secret wrapping scheme: ${args.wrappingScheme}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (args.encryptedSecret.length <= 12) {
|
||||||
|
throw new Error("encryptedSecret is too short");
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await deriveWrappingKey(args);
|
||||||
|
const iv = args.encryptedSecret.slice(0, 12);
|
||||||
|
const ciphertext = args.encryptedSecret.slice(12);
|
||||||
|
const aad = metadataAad(args);
|
||||||
|
|
||||||
|
return new Uint8Array(
|
||||||
|
await crypto.subtle.decrypt(
|
||||||
|
{ name: "AES-GCM", iv: ownedBytes(iv), additionalData: ownedBytes(aad) },
|
||||||
|
key,
|
||||||
|
ownedBytes(ciphertext),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deriveWrappingKey(args: {
|
||||||
|
wrappingSecret: Uint8Array | string;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
secretId: string;
|
||||||
|
version: number;
|
||||||
|
}): Promise<CryptoKey> {
|
||||||
|
const wrappingSecret =
|
||||||
|
typeof args.wrappingSecret === "string"
|
||||||
|
? textEncoder.encode(args.wrappingSecret)
|
||||||
|
: args.wrappingSecret;
|
||||||
|
|
||||||
|
if (wrappingSecret.length < 16) {
|
||||||
|
throw new Error("No secure wrapping key available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseKey = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
ownedBytes(wrappingSecret),
|
||||||
|
"HKDF",
|
||||||
|
false,
|
||||||
|
["deriveKey"],
|
||||||
|
);
|
||||||
|
|
||||||
|
return crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: "HKDF",
|
||||||
|
hash: "SHA-256",
|
||||||
|
salt: ownedBytes(textEncoder.encode("tensamin-e2ee-device-secret-v1")),
|
||||||
|
info: ownedBytes(metadataAad(args)),
|
||||||
|
},
|
||||||
|
baseKey,
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
false,
|
||||||
|
["encrypt", "decrypt"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownedBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||||
|
const out = new Uint8Array(bytes.byteLength);
|
||||||
|
out.set(bytes);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataAad(args: {
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
secretId: string;
|
||||||
|
version: number;
|
||||||
|
}): Uint8Array {
|
||||||
|
return textEncoder.encode(
|
||||||
|
JSON.stringify({
|
||||||
|
userId: args.userId,
|
||||||
|
deviceId: args.deviceId,
|
||||||
|
secretId: args.secretId,
|
||||||
|
version: args.version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
import { describe, expect, test } from "vitest";
|
|
||||||
import { x448 } from "@noble/curves/ed448.js";
|
|
||||||
import {
|
|
||||||
decrypt,
|
|
||||||
decryptText,
|
|
||||||
encrypt,
|
|
||||||
encryptText,
|
|
||||||
getSharedSecret,
|
|
||||||
} from "./worker";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encodes bytes to URL-safe base64 without padding.
|
|
||||||
* @param value Input bytes.
|
|
||||||
* @returns Base64url string.
|
|
||||||
*/
|
|
||||||
function bytesToB64u(value: Uint8Array): string {
|
|
||||||
const alphabet =
|
|
||||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
||||||
|
|
||||||
let output = "";
|
|
||||||
|
|
||||||
for (let index = 0; index < value.length; index += 3) {
|
|
||||||
const first = value[index] ?? 0;
|
|
||||||
const second = value[index + 1] ?? 0;
|
|
||||||
const third = value[index + 2] ?? 0;
|
|
||||||
const chunk = (first << 16) | (second << 8) | third;
|
|
||||||
|
|
||||||
output += alphabet[(chunk >> 18) & 63];
|
|
||||||
output += alphabet[(chunk >> 12) & 63];
|
|
||||||
output += index + 1 < value.length ? alphabet[(chunk >> 6) & 63] : "=";
|
|
||||||
output += index + 2 < value.length ? alphabet[chunk & 63] : "=";
|
|
||||||
}
|
|
||||||
|
|
||||||
return output.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts bytes to lowercase hex.
|
|
||||||
* @param value Input bytes.
|
|
||||||
* @returns Hex string.
|
|
||||||
*/
|
|
||||||
function bytesToHex(value: Uint8Array): string {
|
|
||||||
return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates deterministic 56-byte private key material for tests.
|
|
||||||
* @param seed Offset seed used to vary generated bytes.
|
|
||||||
* @returns Deterministic private key bytes.
|
|
||||||
*/
|
|
||||||
function createPrivateKey(seed: number): Uint8Array {
|
|
||||||
const output = new Uint8Array(56);
|
|
||||||
|
|
||||||
for (let index = 0; index < output.length; index += 1) {
|
|
||||||
output[index] = (seed + index) % 255;
|
|
||||||
}
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("crypto worker", () => {
|
|
||||||
const textEncoder = new TextEncoder();
|
|
||||||
const textDecoder = new TextDecoder();
|
|
||||||
|
|
||||||
test("encrypt/decrypt byte round-trip returns original plaintext", async () => {
|
|
||||||
const secret = "0f".repeat(56);
|
|
||||||
const input = "hello encrypted world";
|
|
||||||
|
|
||||||
const encryptedContent = await encrypt(secret, textEncoder.encode(input));
|
|
||||||
const decryptedContent = await decrypt(secret, encryptedContent);
|
|
||||||
|
|
||||||
expect(textDecoder.decode(decryptedContent)).toBe(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("encryptText/decryptText round-trip returns original plaintext", async () => {
|
|
||||||
const secret = "0f".repeat(56);
|
|
||||||
const input = "hello encrypted world";
|
|
||||||
|
|
||||||
const ciphertext = await encryptText(secret, input);
|
|
||||||
const plaintext = await decryptText(secret, ciphertext);
|
|
||||||
|
|
||||||
expect(plaintext).toBe(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("decrypt fails with wrong shared secret", async () => {
|
|
||||||
const secret = "0f".repeat(56);
|
|
||||||
const wrongSecret = "f0".repeat(56);
|
|
||||||
const input = "sensitive";
|
|
||||||
|
|
||||||
const ciphertext = await encrypt(secret, textEncoder.encode(input));
|
|
||||||
|
|
||||||
let failed = false;
|
|
||||||
try {
|
|
||||||
await decrypt(wrongSecret, ciphertext);
|
|
||||||
} catch {
|
|
||||||
failed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(failed).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("getSharedSecret matches noble x448 derivation", async () => {
|
|
||||||
const ownPrivateBytes = createPrivateKey(7);
|
|
||||||
const peerPrivateBytes = createPrivateKey(23);
|
|
||||||
|
|
||||||
const ownPublicBytes = x448.getPublicKey(ownPrivateBytes);
|
|
||||||
const peerPublicBytes = x448.getPublicKey(peerPrivateBytes);
|
|
||||||
|
|
||||||
const expected = bytesToHex(
|
|
||||||
new Uint8Array(x448.getSharedSecret(ownPrivateBytes, peerPublicBytes)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const actual = await getSharedSecret(
|
|
||||||
bytesToB64u(ownPrivateBytes),
|
|
||||||
bytesToB64u(ownPublicBytes),
|
|
||||||
bytesToB64u(peerPublicBytes),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(actual).toBe(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,482 +0,0 @@
|
||||||
import * as Comlink from "comlink";
|
|
||||||
|
|
||||||
type Base64URLString = string;
|
|
||||||
|
|
||||||
type JWK = {
|
|
||||||
kty: string;
|
|
||||||
crv: string;
|
|
||||||
x?: string;
|
|
||||||
d?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const textEncoder = new TextEncoder();
|
|
||||||
const textDecoder = new TextDecoder();
|
|
||||||
const crypto = globalThis.crypto;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encodes bytes as standard base64 text.
|
|
||||||
* @param bytes Bytes to encode.
|
|
||||||
* @returns Base64 string.
|
|
||||||
*/
|
|
||||||
function bytesToBase64(bytes: Uint8Array): string {
|
|
||||||
let binary = "";
|
|
||||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
||||||
return btoa(binary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decodes standard base64 text into bytes.
|
|
||||||
* @param base64 Base64 string.
|
|
||||||
* @returns Decoded bytes.
|
|
||||||
*/
|
|
||||||
function base64ToBytes(base64: string): Uint8Array<ArrayBuffer> {
|
|
||||||
const binary = atob(base64);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let index = 0; index < binary.length; index += 1) {
|
|
||||||
bytes[index] = binary.charCodeAt(index);
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypts bytes with a symmetric key derived from a hex shared secret.
|
|
||||||
* @param secret Hex-encoded shared secret.
|
|
||||||
* @param input Plaintext bytes to encrypt.
|
|
||||||
* @returns Ciphertext bytes.
|
|
||||||
*/
|
|
||||||
export async function encrypt(
|
|
||||||
secret: string,
|
|
||||||
input: Uint8Array<ArrayBuffer>,
|
|
||||||
): Promise<Uint8Array<ArrayBuffer>> {
|
|
||||||
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,
|
|
||||||
input,
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Uint8Array(encryptedBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypts bytes with a symmetric key derived from a hex shared secret.
|
|
||||||
* @param secret Hex-encoded shared secret.
|
|
||||||
* @param input Ciphertext bytes to decrypt.
|
|
||||||
* @returns Plaintext bytes.
|
|
||||||
*/
|
|
||||||
export async function decrypt(
|
|
||||||
secret: string,
|
|
||||||
input: Uint8Array<ArrayBuffer>,
|
|
||||||
): Promise<Uint8Array<ArrayBuffer>> {
|
|
||||||
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,
|
|
||||||
["decrypt"],
|
|
||||||
);
|
|
||||||
|
|
||||||
const decryptedBuffer = await crypto.subtle.decrypt(
|
|
||||||
{
|
|
||||||
name: "AES-GCM",
|
|
||||||
iv: nonce,
|
|
||||||
},
|
|
||||||
aesKey,
|
|
||||||
input,
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Uint8Array(decryptedBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypts UTF-8 text and returns base64 ciphertext for easy transport/storage.
|
|
||||||
* @param secret Hex-encoded shared secret.
|
|
||||||
* @param plaintext Text to encrypt.
|
|
||||||
* @returns Base64 ciphertext.
|
|
||||||
*/
|
|
||||||
export async function encryptText(
|
|
||||||
secret: string,
|
|
||||||
plaintext: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const encrypted = await encrypt(secret, textEncoder.encode(plaintext));
|
|
||||||
return bytesToBase64(encrypted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypts base64 ciphertext into UTF-8 text.
|
|
||||||
* @param secret Hex-encoded shared secret.
|
|
||||||
* @param ciphertext Base64 ciphertext.
|
|
||||||
* @returns Decrypted text.
|
|
||||||
*/
|
|
||||||
export async function decryptText(
|
|
||||||
secret: string,
|
|
||||||
ciphertext: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const decrypted = await decrypt(secret, base64ToBytes(ciphertext));
|
|
||||||
return textDecoder.decode(decrypted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 (off >= view.length) throw new Error("DER: truncated");
|
|
||||||
let len = view[off++];
|
|
||||||
if (len & 0x80) {
|
|
||||||
const n = len & 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++) len = (len << 8) | view[off++];
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSubtle = () => globalThis.crypto?.subtle;
|
|
||||||
|
|
||||||
const myJwk: JWK = normalizeOkpX448Jwk(ownJwk, "own_jwk");
|
|
||||||
const peerJwk: JWK = normalizeOkpX448Jwk(otherJwk, "other_jwk");
|
|
||||||
|
|
||||||
const subtle = getSubtle();
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use getSharedSecret instead.
|
|
||||||
* @param ownPrivateKey Local private key.
|
|
||||||
* @param ownPublicKey Local public key.
|
|
||||||
* @param otherPublicKey Peer public key.
|
|
||||||
* @returns Shared secret derived by getSharedSecret.
|
|
||||||
*/
|
|
||||||
export async function get_shared_secret(
|
|
||||||
ownPrivateKey: string,
|
|
||||||
ownPublicKey: string,
|
|
||||||
otherPublicKey: string,
|
|
||||||
): Promise<string> {
|
|
||||||
return await getSharedSecret(ownPrivateKey, ownPublicKey, otherPublicKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether the current runtime context is a worker global scope.
|
|
||||||
* @returns True when executed inside a worker-like runtime.
|
|
||||||
*/
|
|
||||||
function isWorkerRuntime(): boolean {
|
|
||||||
return "postMessage" in globalThis && "importScripts" in globalThis;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isWorkerRuntime()) {
|
|
||||||
Comlink.expose({
|
|
||||||
encrypt,
|
|
||||||
decrypt,
|
|
||||||
encryptText,
|
|
||||||
decryptText,
|
|
||||||
getSharedSecret,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -45,6 +45,237 @@ function base64ToUint8Array(b64: string) {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bytesFromProtocol(value: unknown): Uint8Array {
|
||||||
|
if (value instanceof Uint8Array) return value;
|
||||||
|
if (Array.isArray(value)) return new Uint8Array(value);
|
||||||
|
if (typeof value === "string") return base64ToUint8Array(value);
|
||||||
|
throw new Error("expected protocol bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedFrameLike = {
|
||||||
|
id?: number;
|
||||||
|
type: string;
|
||||||
|
data: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MTPSessionState {
|
||||||
|
version: 1;
|
||||||
|
conversationId: string;
|
||||||
|
ownClientId: bigint;
|
||||||
|
peerClientId: bigint;
|
||||||
|
peerPublicKey: Uint8Array;
|
||||||
|
sendChainKey: Uint8Array;
|
||||||
|
recvChainKey: Uint8Array;
|
||||||
|
sendCount: number;
|
||||||
|
recvCount: number;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MTPSessionStorage {
|
||||||
|
getSession(conversationId: string): Promise<MTPSessionState | null>;
|
||||||
|
setSession(state: MTPSessionState): Promise<void>;
|
||||||
|
deleteSession(conversationId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EncryptedDeviceSecretRecord {
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
secretId: string;
|
||||||
|
version: number;
|
||||||
|
encryptedSecret: Uint8Array;
|
||||||
|
wrappingPublicKeyId?: string;
|
||||||
|
wrappingScheme: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MTPEncryptedDeviceSecretProvider {
|
||||||
|
setEncryptedDeviceSecret(record: EncryptedDeviceSecretRecord): Promise<void>;
|
||||||
|
getEncryptedDeviceSecret(query: {
|
||||||
|
userId: string;
|
||||||
|
deviceId?: string;
|
||||||
|
secretId?: string;
|
||||||
|
}): Promise<EncryptedDeviceSecretRecord | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeSessionState(
|
||||||
|
state: MTPSessionState,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
ownClientId: state.ownClientId.toString(),
|
||||||
|
peerClientId: state.peerClientId.toString(),
|
||||||
|
peerPublicKey: Array.from(state.peerPublicKey),
|
||||||
|
sendChainKey: Array.from(state.sendChainKey),
|
||||||
|
recvChainKey: Array.from(state.recvChainKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deserializeSessionState(value: unknown): MTPSessionState | null {
|
||||||
|
if (!value || typeof value !== "object") return null;
|
||||||
|
const raw = value as Record<string, unknown>;
|
||||||
|
if (typeof raw.conversationId !== "string") return null;
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
conversationId: raw.conversationId,
|
||||||
|
ownClientId: BigInt(String(raw.ownClientId)),
|
||||||
|
peerClientId: BigInt(String(raw.peerClientId)),
|
||||||
|
peerPublicKey: new Uint8Array(raw.peerPublicKey as number[]),
|
||||||
|
sendChainKey: new Uint8Array(raw.sendChainKey as number[]),
|
||||||
|
recvChainKey: new Uint8Array(raw.recvChainKey as number[]),
|
||||||
|
sendCount: Number(raw.sendCount),
|
||||||
|
recvCount: Number(raw.recvCount),
|
||||||
|
createdAt: Number(raw.createdAt),
|
||||||
|
updatedAt: Number(raw.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocalMTPSessionStorage implements MTPSessionStorage {
|
||||||
|
constructor(
|
||||||
|
private readonly load: <K extends "e2ee_sessions">(
|
||||||
|
key: K,
|
||||||
|
) => Promise<Record<string, unknown> | undefined>,
|
||||||
|
private readonly save: <K extends "e2ee_sessions">(
|
||||||
|
key: K,
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
) => Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getSession(conversationId: string): Promise<MTPSessionState | null> {
|
||||||
|
const sessions = (await this.load("e2ee_sessions")) ?? {};
|
||||||
|
return deserializeSessionState(sessions[conversationId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSession(state: MTPSessionState): Promise<void> {
|
||||||
|
const sessions = { ...((await this.load("e2ee_sessions")) ?? {}) };
|
||||||
|
sessions[state.conversationId] = serializeSessionState(state);
|
||||||
|
await this.save("e2ee_sessions", sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSession(conversationId: string): Promise<void> {
|
||||||
|
const sessions = { ...((await this.load("e2ee_sessions")) ?? {}) };
|
||||||
|
delete sessions[conversationId];
|
||||||
|
await this.save("e2ee_sessions", sessions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameField<T>(
|
||||||
|
frame: ParsedFrameLike,
|
||||||
|
pascal: string,
|
||||||
|
camel: string,
|
||||||
|
): T {
|
||||||
|
const data = frame.data as Record<string, unknown>;
|
||||||
|
return (data[pascal] ?? data[camel]) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnectedMTPClient = Awaited<ReturnType<typeof MTPClient.create>> & {
|
||||||
|
request(
|
||||||
|
type: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
options?: { responseType?: string },
|
||||||
|
): Promise<ParsedFrameLike>;
|
||||||
|
sendEncrypted(
|
||||||
|
type: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
options: {
|
||||||
|
recipientClientId?: bigint | number | string;
|
||||||
|
recipientPublicKey?: string | Uint8Array | number[];
|
||||||
|
senderUserId?: string;
|
||||||
|
recipientUserId?: string;
|
||||||
|
recipientDeviceId?: string;
|
||||||
|
},
|
||||||
|
): Promise<void>;
|
||||||
|
subscribeEncrypted(
|
||||||
|
type: string,
|
||||||
|
handler: (data: unknown, meta: ParsedFrameLike) => void,
|
||||||
|
): () => void;
|
||||||
|
decryptEncryptedRecord(
|
||||||
|
frameData: Record<string, unknown>,
|
||||||
|
): Promise<ParsedFrameLike>;
|
||||||
|
};
|
||||||
|
|
||||||
|
class NetworkEncryptedDeviceSecretProvider implements MTPEncryptedDeviceSecretProvider {
|
||||||
|
#client: ConnectedMTPClient | null = null;
|
||||||
|
|
||||||
|
attach(client: ConnectedMTPClient): void {
|
||||||
|
this.#client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setEncryptedDeviceSecret(
|
||||||
|
record: EncryptedDeviceSecretRecord,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.#client) {
|
||||||
|
throw new Error("encrypted device secret provider is not attached");
|
||||||
|
}
|
||||||
|
if (!record.encryptedSecret?.length || !record.wrappingScheme) {
|
||||||
|
throw new Error(
|
||||||
|
"encrypted device secret record is missing ciphertext metadata",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#client.request("SetEncryptedDeviceSecret", {
|
||||||
|
UserId: record.userId,
|
||||||
|
DeviceId: record.deviceId,
|
||||||
|
SecretId: record.secretId,
|
||||||
|
VersionNumber: record.version,
|
||||||
|
EncryptedSecret: record.encryptedSecret,
|
||||||
|
WrappingPublicKeyId: record.wrappingPublicKeyId,
|
||||||
|
WrappingScheme: record.wrappingScheme,
|
||||||
|
CreatedAt: record.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEncryptedDeviceSecret(query: {
|
||||||
|
userId: string;
|
||||||
|
deviceId?: string;
|
||||||
|
secretId?: string;
|
||||||
|
}): Promise<EncryptedDeviceSecretRecord | null> {
|
||||||
|
if (!this.#client) {
|
||||||
|
throw new Error("encrypted device secret provider is not attached");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.#client.request(
|
||||||
|
"GetEncryptedDeviceSecret",
|
||||||
|
{
|
||||||
|
UserId: query.userId,
|
||||||
|
DeviceId: query.deviceId,
|
||||||
|
SecretId: query.secretId,
|
||||||
|
},
|
||||||
|
{ responseType: "EncryptedDeviceSecretResponse" },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.type === "ErrorNotFound") return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: frameField(response, "UserId", "userId"),
|
||||||
|
deviceId: frameField(response, "DeviceId", "deviceId"),
|
||||||
|
secretId: frameField(response, "SecretId", "secretId"),
|
||||||
|
version: Number(frameField(response, "VersionNumber", "versionNumber")),
|
||||||
|
encryptedSecret: bytesFromProtocol(
|
||||||
|
frameField(response, "EncryptedSecret", "encryptedSecret"),
|
||||||
|
),
|
||||||
|
wrappingPublicKeyId: frameField(
|
||||||
|
response,
|
||||||
|
"WrappingPublicKeyId",
|
||||||
|
"wrappingPublicKeyId",
|
||||||
|
),
|
||||||
|
wrappingScheme: frameField(
|
||||||
|
response,
|
||||||
|
"WrappingScheme",
|
||||||
|
"wrappingScheme",
|
||||||
|
),
|
||||||
|
createdAt: Number(frameField(response, "CreatedAt", "createdAt")),
|
||||||
|
updatedAt: Number(frameField(response, "UpdatedAt", "updatedAt")),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (String(error).includes("ErrorNotFound")) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type ProtocolMessage<
|
export type ProtocolMessage<
|
||||||
T extends keyof Schemas & string = keyof Schemas & string,
|
T extends keyof Schemas & string = keyof Schemas & string,
|
||||||
> = {
|
> = {
|
||||||
|
|
@ -59,15 +290,38 @@ export type BoundSendFn = <T extends keyof Schemas & string>(
|
||||||
options?: { id?: number },
|
options?: { id?: number },
|
||||||
) => Promise<ProtocolMessage<T>>;
|
) => Promise<ProtocolMessage<T>>;
|
||||||
|
|
||||||
|
export type BoundSendEncryptedFn = <T extends keyof Schemas & string>(
|
||||||
|
type: T,
|
||||||
|
data: z.infer<Schemas[T]["request"]>,
|
||||||
|
options: {
|
||||||
|
recipientClientId?: bigint | number | string;
|
||||||
|
recipientPublicKey?: string | Uint8Array | number[];
|
||||||
|
senderUserId?: string;
|
||||||
|
recipientUserId?: string;
|
||||||
|
recipientDeviceId?: string;
|
||||||
|
},
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
export type PushHandler = (message: ProtocolMessage) => void;
|
export type PushHandler = (message: ProtocolMessage) => void;
|
||||||
|
|
||||||
type ContextType = {
|
type ContextType = {
|
||||||
send: BoundSendFn;
|
send: BoundSendFn;
|
||||||
|
sendEncrypted: BoundSendEncryptedFn;
|
||||||
subscribe: <T extends keyof Schemas & string>(
|
subscribe: <T extends keyof Schemas & string>(
|
||||||
type: T,
|
type: T,
|
||||||
handler: (message: ProtocolMessage<T>) => void,
|
handler: (message: ProtocolMessage<T>) => void,
|
||||||
) => () => void;
|
) => () => void;
|
||||||
subscribePush: (handler: PushHandler) => () => void;
|
subscribePush: (handler: PushHandler) => () => void;
|
||||||
|
subscribeEncrypted: <T extends keyof Schemas & string>(
|
||||||
|
type: T,
|
||||||
|
handler: (
|
||||||
|
data: z.infer<Schemas[T]["response"]>,
|
||||||
|
meta: ProtocolMessage<T>,
|
||||||
|
) => void,
|
||||||
|
) => () => void;
|
||||||
|
decryptEncryptedRecord: (
|
||||||
|
frameData: Record<string, unknown>,
|
||||||
|
) => Promise<ParsedFrameLike>;
|
||||||
readyState: number;
|
readyState: number;
|
||||||
ownPing: number;
|
ownPing: number;
|
||||||
iotaPing: number;
|
iotaPing: number;
|
||||||
|
|
@ -134,7 +388,7 @@ export function Provider(props: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
blockConnection?: boolean;
|
blockConnection?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { load } = useStorage();
|
const { load, save } = useStorage();
|
||||||
|
|
||||||
const [readyState, setReadyState] = useState<number>(
|
const [readyState, setReadyState] = useState<number>(
|
||||||
ConnectionState.Disconnected,
|
ConnectionState.Disconnected,
|
||||||
|
|
@ -149,9 +403,7 @@ export function Provider(props: {
|
||||||
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
|
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
|
||||||
const [freshCalls, setFreshCalls] = useState<Calls>([]);
|
const [freshCalls, setFreshCalls] = useState<Calls>([]);
|
||||||
|
|
||||||
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
|
const clientRef = useRef<ConnectedMTPClient | null>(null);
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const connected = readyState === ConnectionState.Connected;
|
const connected = readyState === ConnectionState.Connected;
|
||||||
|
|
||||||
|
|
@ -180,6 +432,57 @@ export function Provider(props: {
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sendEncrypted: BoundSendEncryptedFn = useMemo(
|
||||||
|
() => async (type, data, options) => {
|
||||||
|
const client = clientRef.current;
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
throw new Error("mtp is not connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
let recipientClientId = options.recipientClientId;
|
||||||
|
let recipientPublicKey = options.recipientPublicKey;
|
||||||
|
if (recipientClientId == null) {
|
||||||
|
if (!options.recipientUserId) {
|
||||||
|
throw new Error("recipientClientId or recipientUserId is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientUserId = Number(options.recipientUserId);
|
||||||
|
if (!Number.isSafeInteger(recipientUserId) || recipientUserId <= 0) {
|
||||||
|
throw new Error("recipientUserId must be a valid user id");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await client.request(
|
||||||
|
"GetUserData",
|
||||||
|
{ UserId: recipientUserId },
|
||||||
|
{ responseType: "GetUserData" },
|
||||||
|
);
|
||||||
|
if (response.type === "ErrorNotFound") {
|
||||||
|
throw new Error("Recipient user data not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
recipientPublicKey = frameField<string>(
|
||||||
|
response,
|
||||||
|
"PublicKey",
|
||||||
|
"publicKey",
|
||||||
|
);
|
||||||
|
if (!recipientPublicKey) {
|
||||||
|
throw new Error("Recipient has no encryption public key available");
|
||||||
|
}
|
||||||
|
recipientClientId = BigInt(recipientUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.sendEncrypted(type, data as Record<string, unknown>, {
|
||||||
|
recipientClientId,
|
||||||
|
recipientPublicKey,
|
||||||
|
senderUserId: options.senderUserId,
|
||||||
|
recipientUserId: options.recipientUserId,
|
||||||
|
recipientDeviceId: options.recipientDeviceId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const subscribe = useCallback<ContextType["subscribe"]>((type, handler) => {
|
const subscribe = useCallback<ContextType["subscribe"]>((type, handler) => {
|
||||||
const client = clientRef.current;
|
const client = clientRef.current;
|
||||||
if (!client) {
|
if (!client) {
|
||||||
|
|
@ -191,6 +494,34 @@ export function Provider(props: {
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const subscribeEncrypted = useCallback<ContextType["subscribeEncrypted"]>(
|
||||||
|
(type, handler) => {
|
||||||
|
const client = clientRef.current;
|
||||||
|
if (!client) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.subscribeEncrypted(type, (data, meta) => {
|
||||||
|
handler(data as z.infer<Schemas[typeof type]["response"]>, {
|
||||||
|
id: meta.id,
|
||||||
|
type: meta.type,
|
||||||
|
data: data as z.infer<Schemas[typeof type]["response"]>,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const decryptEncryptedRecord = useCallback<
|
||||||
|
ContextType["decryptEncryptedRecord"]
|
||||||
|
>(async (frameData) => {
|
||||||
|
const client = clientRef.current;
|
||||||
|
if (!client) throw new Error("mtp is not connected");
|
||||||
|
return client.decryptEncryptedRecord(frameData);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const decryptEncryptedRecordForQueue = decryptEncryptedRecord;
|
||||||
|
|
||||||
const subscribePush = useCallback((handler: PushHandler) => {
|
const subscribePush = useCallback((handler: PushHandler) => {
|
||||||
const client = clientRef.current;
|
const client = clientRef.current;
|
||||||
if (!client) {
|
if (!client) {
|
||||||
|
|
@ -299,6 +630,7 @@ export function Provider(props: {
|
||||||
|
|
||||||
await MTPClient.init();
|
await MTPClient.init();
|
||||||
|
|
||||||
|
const userId = await load("user_id");
|
||||||
const forcedOmikronUrl = await load("forced_omikron_url");
|
const forcedOmikronUrl = await load("forced_omikron_url");
|
||||||
const forcedOmikronPublicKey = await load("forced_omikron_public_key");
|
const forcedOmikronPublicKey = await load("forced_omikron_public_key");
|
||||||
|
|
||||||
|
|
@ -309,9 +641,7 @@ export function Provider(props: {
|
||||||
omikronPublicKey = forcedOmikronPublicKey;
|
omikronPublicKey = forcedOmikronPublicKey;
|
||||||
} else {
|
} else {
|
||||||
log(2, "mtp", "purple", "Fetching Omikron data.");
|
log(2, "mtp", "purple", "Fetching Omikron data.");
|
||||||
const data = await fetch(
|
const data = await fetch(`${mtpUrl}api/get/omikron/${userId}`);
|
||||||
`${mtpUrl}api/get/omikron/${await load("user_id")}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (data.status === 404) {
|
if (data.status === 404) {
|
||||||
sonnerToast.error("We couldn't reach your Iota", {
|
sonnerToast.error("We couldn't reach your Iota", {
|
||||||
|
|
@ -350,16 +680,31 @@ export function Provider(props: {
|
||||||
|
|
||||||
log(2, "mtp", "green", "Connecting to: " + url);
|
log(2, "mtp", "green", "Connecting to: " + url);
|
||||||
|
|
||||||
const client = await MTPClient.create({
|
const encryptedDeviceSecretProvider =
|
||||||
|
new NetworkEncryptedDeviceSecretProvider();
|
||||||
|
|
||||||
|
const createMTPClient = MTPClient.create as unknown as (
|
||||||
|
options: Record<string, unknown>,
|
||||||
|
) => Promise<ConnectedMTPClient>;
|
||||||
|
const client = await createMTPClient({
|
||||||
url,
|
url,
|
||||||
credentials: {
|
credentials: {
|
||||||
clientId: await load("user_id"),
|
clientId: BigInt(userId),
|
||||||
keyring: base64ToUint8Array(await load("mtp_keyring")),
|
keyring: base64ToUint8Array(await load("mtp_keyring")),
|
||||||
},
|
},
|
||||||
hostPublicKey: omikronPublicKey,
|
hostPublicKey: omikronPublicKey,
|
||||||
descriptor: "client",
|
descriptor: "client",
|
||||||
pings: true,
|
pings: true,
|
||||||
logger: (event) => {
|
encryptedDeviceSecretProvider,
|
||||||
|
sessionStorage: new LocalMTPSessionStorage(
|
||||||
|
load as never,
|
||||||
|
save as never,
|
||||||
|
),
|
||||||
|
logger: (event: {
|
||||||
|
type: string;
|
||||||
|
data?: unknown;
|
||||||
|
direction?: "send" | "recv";
|
||||||
|
}) => {
|
||||||
if (event.type === "state") {
|
if (event.type === "state") {
|
||||||
setReadyState(
|
setReadyState(
|
||||||
clientRef.current?.state ?? ConnectionState.Disconnected,
|
clientRef.current?.state ?? ConnectionState.Disconnected,
|
||||||
|
|
@ -395,6 +740,8 @@ export function Provider(props: {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
encryptedDeviceSecretProvider.attach(client);
|
||||||
|
|
||||||
clientRef.current = client;
|
clientRef.current = client;
|
||||||
setReadyState(client.state);
|
setReadyState(client.state);
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
@ -527,7 +874,7 @@ export function Provider(props: {
|
||||||
setIdentifying(false);
|
setIdentifying(false);
|
||||||
sonnerToast.dismiss("mtp-connection-toast");
|
sonnerToast.dismiss("mtp-connection-toast");
|
||||||
};
|
};
|
||||||
}, [mtpUrl, props.blockConnection, load]);
|
}, [mtpUrl, props.blockConnection, load, save]);
|
||||||
|
|
||||||
// No Iota check
|
// No Iota check
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -561,8 +908,11 @@ export function Provider(props: {
|
||||||
() =>
|
() =>
|
||||||
createAsyncQueue<{
|
createAsyncQueue<{
|
||||||
send: typeof send;
|
send: typeof send;
|
||||||
|
sendEncrypted: typeof sendEncrypted;
|
||||||
subscribe: typeof subscribe;
|
subscribe: typeof subscribe;
|
||||||
subscribePush: typeof subscribePush;
|
subscribePush: typeof subscribePush;
|
||||||
|
subscribeEncrypted: typeof subscribeEncrypted;
|
||||||
|
decryptEncryptedRecord: typeof decryptEncryptedRecordForQueue;
|
||||||
}>(),
|
}>(),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
@ -570,11 +920,25 @@ export function Provider(props: {
|
||||||
if (connected && identified && mtpUrl) {
|
if (connected && identified && mtpUrl) {
|
||||||
mtpRef.set({
|
mtpRef.set({
|
||||||
send,
|
send,
|
||||||
|
sendEncrypted,
|
||||||
subscribe,
|
subscribe,
|
||||||
subscribePush,
|
subscribePush,
|
||||||
|
subscribeEncrypted,
|
||||||
|
decryptEncryptedRecord: decryptEncryptedRecordForQueue,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [connected, identified, mtpUrl, send, subscribe, subscribePush, mtpRef]);
|
}, [
|
||||||
|
connected,
|
||||||
|
identified,
|
||||||
|
mtpUrl,
|
||||||
|
send,
|
||||||
|
sendEncrypted,
|
||||||
|
subscribe,
|
||||||
|
subscribePush,
|
||||||
|
subscribeEncrypted,
|
||||||
|
decryptEncryptedRecordForQueue,
|
||||||
|
mtpRef,
|
||||||
|
]);
|
||||||
|
|
||||||
const sendQueued: BoundSendFn = useMemo(
|
const sendQueued: BoundSendFn = useMemo(
|
||||||
() => async (type, data, options) => {
|
() => async (type, data, options) => {
|
||||||
|
|
@ -584,12 +948,32 @@ export function Provider(props: {
|
||||||
[mtpRef],
|
[mtpRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sendEncryptedQueued: BoundSendEncryptedFn = useMemo(
|
||||||
|
() => async (type, data, options) => {
|
||||||
|
const mtp = await mtpRef.get();
|
||||||
|
return mtp.sendEncrypted(type, data, options);
|
||||||
|
},
|
||||||
|
[mtpRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const decryptEncryptedRecordQueued: ContextType["decryptEncryptedRecord"] =
|
||||||
|
useMemo(
|
||||||
|
() => async (frameData) => {
|
||||||
|
const mtp = await mtpRef.get();
|
||||||
|
return mtp.decryptEncryptedRecord(frameData);
|
||||||
|
},
|
||||||
|
[mtpRef],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MTPContext.Provider
|
<MTPContext.Provider
|
||||||
value={{
|
value={{
|
||||||
send: sendQueued,
|
send: sendQueued,
|
||||||
|
sendEncrypted: sendEncryptedQueued,
|
||||||
subscribe,
|
subscribe,
|
||||||
subscribePush,
|
subscribePush,
|
||||||
|
subscribeEncrypted,
|
||||||
|
decryptEncryptedRecord: decryptEncryptedRecordQueued,
|
||||||
readyState,
|
readyState,
|
||||||
ownPing,
|
ownPing,
|
||||||
iotaPing,
|
iotaPing,
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,7 @@
|
||||||
export { Provider, useMTP } from "./context";
|
export { Provider, useMTP } from "./context";
|
||||||
export type { BoundSendFn, PushHandler, ProtocolMessage } from "./context";
|
export type {
|
||||||
|
BoundSendEncryptedFn,
|
||||||
|
BoundSendFn,
|
||||||
|
PushHandler,
|
||||||
|
ProtocolMessage,
|
||||||
|
} from "./context";
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,13 @@ const fileFromMessage = z.object({
|
||||||
type: z.enum(["image", "image_top_right", "file"]),
|
type: z.enum(["image", "image_top_right", "file"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const bytesLike = z.union([
|
||||||
|
z.instanceof(Uint8Array),
|
||||||
|
z.array(z.number().int().min(0).max(255)),
|
||||||
|
z.base64(),
|
||||||
|
]);
|
||||||
|
const clientIdLike = z.union([z.bigint(), z.number(), z.string()]);
|
||||||
|
|
||||||
export const Message = z.object({
|
export const Message = z.object({
|
||||||
NotEncrypted: z.boolean().optional(),
|
NotEncrypted: z.boolean().optional(),
|
||||||
SentBySelf: z.boolean().optional(),
|
SentBySelf: z.boolean().optional(),
|
||||||
|
|
@ -40,26 +47,30 @@ export const failedUser = {
|
||||||
|
|
||||||
const authPayload = z.object({
|
const authPayload = z.object({
|
||||||
Communities: z.array(z.object({})).default([]),
|
Communities: z.array(z.object({})).default([]),
|
||||||
Contacts: z.array(
|
Contacts: z
|
||||||
z.object({
|
.array(
|
||||||
LastMessageAt: z.number(),
|
z.object({
|
||||||
UserId: z.number(),
|
LastMessageAt: z.number(),
|
||||||
LastMessage: z
|
UserId: z.number(),
|
||||||
.object({
|
LastMessage: z
|
||||||
content: z.base64(),
|
.object({
|
||||||
SenderId: z.number(),
|
content: z.base64(),
|
||||||
})
|
SenderId: z.number(),
|
||||||
.optional(),
|
})
|
||||||
Messages: z.array(Message),
|
.optional(),
|
||||||
}),
|
Messages: z.array(Message),
|
||||||
).default([]),
|
}),
|
||||||
Calls: z.array(
|
)
|
||||||
z.object({
|
.default([]),
|
||||||
CallId: z.string(),
|
Calls: z
|
||||||
CallSecret: z.base64().optional(),
|
.array(
|
||||||
CallMembers: z.array(z.number()),
|
z.object({
|
||||||
}),
|
CallId: z.string(),
|
||||||
).default([]),
|
CallSecret: z.base64().optional(),
|
||||||
|
CallMembers: z.array(z.number()),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Contacts = z.infer<typeof authPayload.shape.Contacts>;
|
export type Contacts = z.infer<typeof authPayload.shape.Contacts>;
|
||||||
|
|
@ -241,6 +252,95 @@ export const mtp = {
|
||||||
SenderId: z.number().optional(),
|
SenderId: z.number().optional(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
SetEncryptedDeviceSecret: {
|
||||||
|
request: z.object({
|
||||||
|
UserId: z.string(),
|
||||||
|
DeviceId: z.string(),
|
||||||
|
SecretId: z.string(),
|
||||||
|
VersionNumber: z.number(),
|
||||||
|
EncryptedSecret: bytesLike,
|
||||||
|
WrappingPublicKeyId: z.string().optional(),
|
||||||
|
WrappingScheme: z.string(),
|
||||||
|
CreatedAt: z.number(),
|
||||||
|
}),
|
||||||
|
response: z.object({}),
|
||||||
|
},
|
||||||
|
GetEncryptedDeviceSecret: {
|
||||||
|
request: z.object({
|
||||||
|
UserId: z.string(),
|
||||||
|
DeviceId: z.string().optional(),
|
||||||
|
SecretId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
response: z.object({}),
|
||||||
|
},
|
||||||
|
EncryptedDeviceSecretResponse: {
|
||||||
|
request: z.object({}).optional(),
|
||||||
|
response: z.object({
|
||||||
|
UserId: z.string(),
|
||||||
|
DeviceId: z.string(),
|
||||||
|
SecretId: z.string(),
|
||||||
|
VersionNumber: z.number(),
|
||||||
|
EncryptedSecret: bytesLike,
|
||||||
|
WrappingPublicKeyId: z.string().optional(),
|
||||||
|
WrappingScheme: z.string(),
|
||||||
|
CreatedAt: z.number(),
|
||||||
|
UpdatedAt: z.number(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
EncryptedMessage: {
|
||||||
|
request: z.object({
|
||||||
|
MessageId: z.string(),
|
||||||
|
ConversationId: z.string(),
|
||||||
|
SenderClientId: clientIdLike,
|
||||||
|
RecipientClientId: clientIdLike,
|
||||||
|
SenderUserId: z.string().optional(),
|
||||||
|
RecipientUserId: z.string().optional(),
|
||||||
|
CreatedAt: z.number(),
|
||||||
|
EncryptionVersion: z.literal(1),
|
||||||
|
EncryptedPayload: bytesLike,
|
||||||
|
}),
|
||||||
|
response: z.object({}),
|
||||||
|
},
|
||||||
|
EncryptedMessageAck: {
|
||||||
|
request: z.object({
|
||||||
|
MessageId: z.string(),
|
||||||
|
ConversationId: z.string(),
|
||||||
|
RecipientClientId: clientIdLike,
|
||||||
|
SendTime: z.number().optional(),
|
||||||
|
GetTime: z.number().optional(),
|
||||||
|
}),
|
||||||
|
response: z.object({}),
|
||||||
|
},
|
||||||
|
EncryptedMessagesGet: {
|
||||||
|
request: z.object({
|
||||||
|
ConversationId: z.string().optional(),
|
||||||
|
PeerClientId: clientIdLike.optional(),
|
||||||
|
SenderUserId: z.string().optional(),
|
||||||
|
Since: z.number().optional(),
|
||||||
|
Limit: z.number().optional(),
|
||||||
|
}),
|
||||||
|
response: z.object({}),
|
||||||
|
},
|
||||||
|
EncryptedMessagesResponse: {
|
||||||
|
request: z.object({}).optional(),
|
||||||
|
response: z.object({
|
||||||
|
Messages: z.array(
|
||||||
|
z.object({
|
||||||
|
MessageId: z.string(),
|
||||||
|
ConversationId: z.string(),
|
||||||
|
SenderClientId: clientIdLike,
|
||||||
|
RecipientClientId: clientIdLike,
|
||||||
|
SenderUserId: z.string().optional(),
|
||||||
|
RecipientUserId: z.string().optional(),
|
||||||
|
CreatedAt: z.number(),
|
||||||
|
EncryptionVersion: z.literal(1),
|
||||||
|
EncryptedPayload: bytesLike,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
HasMore: z.boolean().optional(),
|
||||||
|
NextCursor: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
ErrorNoIota: {
|
ErrorNoIota: {
|
||||||
request: z.object({}).optional(),
|
request: z.object({}).optional(),
|
||||||
response: z.object({}),
|
response: z.object({}),
|
||||||
|
|
@ -254,6 +354,8 @@ export interface Storage extends SettingsStorageDefaults {
|
||||||
session_id: number;
|
session_id: number;
|
||||||
user_id: number;
|
user_id: number;
|
||||||
mtp_keyring: string;
|
mtp_keyring: string;
|
||||||
|
e2ee_device_id: string;
|
||||||
|
e2ee_sessions: Record<string, unknown>;
|
||||||
ppandtos_done: boolean;
|
ppandtos_done: boolean;
|
||||||
accepted_terms_of_service: boolean;
|
accepted_terms_of_service: boolean;
|
||||||
accepted_privacy_policy: boolean;
|
accepted_privacy_policy: boolean;
|
||||||
|
|
@ -288,6 +390,8 @@ export const storageDefaults: Storage = {
|
||||||
session_id: 0,
|
session_id: 0,
|
||||||
user_id: 0,
|
user_id: 0,
|
||||||
mtp_keyring: "",
|
mtp_keyring: "",
|
||||||
|
e2ee_device_id: "",
|
||||||
|
e2ee_sessions: {},
|
||||||
ppandtos_done: false,
|
ppandtos_done: false,
|
||||||
accepted_terms_of_service: false,
|
accepted_terms_of_service: false,
|
||||||
accepted_privacy_policy: false,
|
accepted_privacy_policy: false,
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,12 @@ import { useMTP } from "@tensamin/mtp";
|
||||||
import { log, toast } from "@tensamin/shared/log";
|
import { log, toast } from "@tensamin/shared/log";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
import { decryptText } from "@tensamin/crypto/worker";
|
|
||||||
|
|
||||||
export default function Wrapper({ children }: { children: ReactNode }) {
|
export default function Wrapper({ children }: { children: ReactNode }) {
|
||||||
const { get } = useUser();
|
const { get } = useUser();
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const { send } = useMTP();
|
const { send } = useMTP();
|
||||||
const { getSharedSecret } = useCrypto();
|
const { decryptText, getSharedSecret } = useCrypto();
|
||||||
const { searchStr } = useLocation();
|
const { searchStr } = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
|
||||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
|
|
@ -603,15 +603,6 @@ importers:
|
||||||
|
|
||||||
packages/crypto:
|
packages/crypto:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@noble/curves':
|
|
||||||
specifier: ^2.0.1
|
|
||||||
version: 2.2.0
|
|
||||||
'@noble/hashes':
|
|
||||||
specifier: ^2.0.1
|
|
||||||
version: 2.2.0
|
|
||||||
comlink:
|
|
||||||
specifier: ^4.4.2
|
|
||||||
version: 4.4.2
|
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.0
|
specifier: ^19.2.0
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
|
|
@ -1451,10 +1442,6 @@ packages:
|
||||||
'@emnapi/core': ^1.7.1
|
'@emnapi/core': ^1.7.1
|
||||||
'@emnapi/runtime': ^1.7.1
|
'@emnapi/runtime': ^1.7.1
|
||||||
|
|
||||||
'@noble/curves@2.2.0':
|
|
||||||
resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==}
|
|
||||||
engines: {node: '>= 20.19.0'}
|
|
||||||
|
|
||||||
'@noble/hashes@1.4.0':
|
'@noble/hashes@1.4.0':
|
||||||
resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==}
|
resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 16'}
|
||||||
|
|
@ -2534,9 +2521,6 @@ packages:
|
||||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
comlink@4.4.2:
|
|
||||||
resolution: {integrity: sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==}
|
|
||||||
|
|
||||||
commander@11.1.0:
|
commander@11.1.0:
|
||||||
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
|
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
@ -5625,10 +5609,6 @@ snapshots:
|
||||||
'@tybys/wasm-util': 0.10.3
|
'@tybys/wasm-util': 0.10.3
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@noble/curves@2.2.0':
|
|
||||||
dependencies:
|
|
||||||
'@noble/hashes': 2.2.0
|
|
||||||
|
|
||||||
'@noble/hashes@1.4.0': {}
|
'@noble/hashes@1.4.0': {}
|
||||||
|
|
||||||
'@noble/hashes@2.2.0': {}
|
'@noble/hashes@2.2.0': {}
|
||||||
|
|
@ -6675,8 +6655,6 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
delayed-stream: 1.0.0
|
delayed-stream: 1.0.0
|
||||||
|
|
||||||
comlink@4.4.2: {}
|
|
||||||
|
|
||||||
commander@11.1.0: {}
|
commander@11.1.0: {}
|
||||||
|
|
||||||
commander@14.0.3: {}
|
commander@14.0.3: {}
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,13 @@ type_maps:
|
||||||
AppChallengeResponse: 133
|
AppChallengeResponse: 133
|
||||||
AppIdentificationResponse: 134
|
AppIdentificationResponse: 134
|
||||||
LoadTxtRecord: 135
|
LoadTxtRecord: 135
|
||||||
|
SetEncryptedDeviceSecret: 139
|
||||||
|
GetEncryptedDeviceSecret: 140
|
||||||
|
EncryptedDeviceSecretResponse: 141
|
||||||
|
EncryptedMessage: 142
|
||||||
|
EncryptedMessageAck: 143
|
||||||
|
EncryptedMessagesGet: 144
|
||||||
|
EncryptedMessagesResponse: 145
|
||||||
DataTypes:
|
DataTypes:
|
||||||
ErrorType: 32
|
ErrorType: 32
|
||||||
ErrorProtocol: 33
|
ErrorProtocol: 33
|
||||||
|
|
@ -237,3 +244,25 @@ type_maps:
|
||||||
AppData: 131
|
AppData: 131
|
||||||
TauriToken: 132
|
TauriToken: 132
|
||||||
Challenge: 133
|
Challenge: 133
|
||||||
|
EncryptedPayload: 134
|
||||||
|
SecurePayload: 135
|
||||||
|
DeviceId: 136
|
||||||
|
ClientId: 137
|
||||||
|
SecretId: 142
|
||||||
|
VersionNumber: 143
|
||||||
|
EncryptedSecret: 144
|
||||||
|
WrappingPublicKeyId: 145
|
||||||
|
WrappingScheme: 146
|
||||||
|
UpdatedAt: 147
|
||||||
|
MessageId: 148
|
||||||
|
ConversationId: 149
|
||||||
|
SenderClientId: 150
|
||||||
|
RecipientClientId: 151
|
||||||
|
SenderUserId: 152
|
||||||
|
RecipientUserId: 153
|
||||||
|
EncryptionVersion: 154
|
||||||
|
HasMore: 155
|
||||||
|
NextCursor: 156
|
||||||
|
Since: 157
|
||||||
|
Limit: 158
|
||||||
|
PeerClientId: 159
|
||||||
|
|
|
||||||
7
vitest.config.ts
Normal file
7
vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
exclude: ["**/node_modules/**", "**/.direnv/**", "**/dist/**"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue