(feat): crypto migrations
This commit is contained in:
parent
930663d495
commit
cd2c2f8167
17 changed files with 839 additions and 825 deletions
|
|
@ -5,7 +5,7 @@
|
|||
"type": "module",
|
||||
"exports": {
|
||||
"./context": "./src/context.tsx",
|
||||
"./worker": "./src/worker.ts"
|
||||
"./encryptedDeviceSecret": "./src/encryptedDeviceSecret.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "pnpm exec prettier --write .",
|
||||
|
|
@ -14,9 +14,6 @@
|
|||
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"comlink": "^4.4.2",
|
||||
"react": "^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";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -21,20 +21,47 @@ type CryptoContextType = {
|
|||
|
||||
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 }) {
|
||||
return (
|
||||
<context.Provider
|
||||
value={{
|
||||
decrypt: crypto.decrypt,
|
||||
encrypt: crypto.encrypt,
|
||||
decryptText: crypto.decryptText,
|
||||
encryptText: crypto.encryptText,
|
||||
getSharedSecret: crypto.getSharedSecret,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</context.Provider>
|
||||
);
|
||||
const actions = createCryptoActions(() => ({
|
||||
decrypt: async (secret, input) =>
|
||||
ownedBytes(await crypto.decrypt(secret, input)),
|
||||
decryptText: crypto.decryptText,
|
||||
encrypt: async (secret, input) =>
|
||||
ownedBytes(await crypto.encrypt(secret, input)),
|
||||
encryptText: crypto.encryptText,
|
||||
getSharedSecret: crypto.getSharedSecret,
|
||||
}));
|
||||
|
||||
return <context.Provider value={actions}>{props.children}</context.Provider>;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue