Release Crypto Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 10:40:23 +02:00
parent 4b75f819e2
commit 82bb01b13f
37 changed files with 3673 additions and 140 deletions
+418
View File
@@ -0,0 +1,418 @@
import {
BaseBlock,
Integer,
Null,
ObjectIdentifier,
OctetString,
Sequence,
fromBER,
} from "asn1js";
const PBES2 = "1.2.840.113549.1.5.13";
const PBKDF2 = "1.2.840.113549.1.5.12";
const MAX_ENCRYPTED_BYTES = 8 * 1024 * 1024;
const MAX_ITERATIONS = 10_000_000;
export const MAX_TOTAL_PBKDF2_ITERATIONS = 20_000_000;
const PRFS: Record<string, string> = {
"1.2.840.113549.2.7": "SHA-1",
"1.2.840.113549.2.9": "SHA-256",
"1.2.840.113549.2.10": "SHA-384",
"1.2.840.113549.2.11": "SHA-512",
};
const CIPHERS: Record<
string,
{ name: "AES-CBC" | "AES-GCM"; length: 128 | 192 | 256 }
> = {
"2.16.840.1.101.3.4.1.2": { name: "AES-CBC", length: 128 },
"2.16.840.1.101.3.4.1.22": { name: "AES-CBC", length: 192 },
"2.16.840.1.101.3.4.1.42": { name: "AES-CBC", length: 256 },
"2.16.840.1.101.3.4.1.6": { name: "AES-GCM", length: 128 },
"2.16.840.1.101.3.4.1.26": { name: "AES-GCM", length: 192 },
"2.16.840.1.101.3.4.1.46": { name: "AES-GCM", length: 256 },
};
const PRIVATE_KEY_ALGORITHMS: Record<string, string> = {
"1.2.840.113549.1.1.1": "RSA",
"1.2.840.113549.1.1.10": "RSA-PSS",
"1.2.840.10045.2.1": "EC",
"1.3.101.112": "Ed25519",
"1.3.101.113": "Ed448",
"1.3.101.110": "X25519",
"1.3.101.111": "X448",
};
const CURVES: Record<string, string> = {
"1.2.840.10045.3.1.7": "P-256",
"1.3.132.0.34": "P-384",
"1.3.132.0.35": "P-521",
};
export interface Pbkdf2WorkBudget {
iterations: number;
}
export function reservePbkdf2Work(
budget: Pbkdf2WorkBudget,
iterations: number,
label: string,
): void {
if (
!Number.isSafeInteger(budget.iterations) ||
budget.iterations < 0 ||
!Number.isSafeInteger(iterations) ||
iterations < 1 ||
budget.iterations > MAX_TOTAL_PBKDF2_ITERATIONS - iterations
) {
throw new Error(
`${label} exceeds the ${MAX_TOTAL_PBKDF2_ITERATIONS.toLocaleString()}-iteration aggregate PBKDF2 safety budget. Inspect fewer encrypted objects at once.`,
);
}
budget.iterations += iterations;
}
type Block = BaseBlock;
function asBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
}
function parseDer(bytes: Uint8Array, name: string): Block {
if (bytes.byteLength === 0 || bytes.byteLength > MAX_ENCRYPTED_BYTES)
throw new Error(`${name} must contain 1 byte8 MiB.`);
const parsed = fromBER(asBuffer(bytes));
if (parsed.offset === -1 || parsed.offset !== bytes.byteLength)
throw new Error(`${name} is malformed DER or contains trailing bytes.`);
const canonical = new Uint8Array(parsed.result.toBER(false));
if (
canonical.length !== bytes.length ||
canonical.some((value, index) => value !== bytes[index])
)
throw new Error(`${name} must use canonical definite-length DER.`);
return parsed.result;
}
function sequence(block: Block | undefined, name: string): Block[] {
if (!(block instanceof Sequence))
throw new Error(`${name} must be an ASN.1 SEQUENCE.`);
return block.valueBlock.value;
}
function oid(block: Block | undefined, name: string): string {
if (!(block instanceof ObjectIdentifier))
throw new Error(`${name} must be an ASN.1 object identifier.`);
return block.getValue();
}
function octets(block: Block | undefined, name: string): Uint8Array {
if (!(block instanceof OctetString))
throw new Error(`${name} must be an ASN.1 OCTET STRING.`);
return new Uint8Array(block.getValue());
}
function integer(block: Block | undefined, name: string): bigint {
if (!(block instanceof Integer))
throw new Error(`${name} must be an ASN.1 INTEGER.`);
return block.toBigInt();
}
export interface Pbes2Inspection {
scheme: "PBES2";
kdf: "PBKDF2";
prf: string;
iterations: number;
saltBytes: number;
cipher: "AES-CBC" | "AES-GCM";
keyLength: 128 | 192 | 256;
ivOrNonceBytes: number;
tagLength?: number;
encryptedBytes: number;
}
interface ParsedPbes2 extends Pbes2Inspection {
salt: Uint8Array;
ivOrNonce: Uint8Array;
encryptedData: Uint8Array;
}
function parsePbes2(bytes: Uint8Array): ParsedPbes2 {
const root = sequence(
parseDer(bytes, "Encrypted PKCS #8"),
"EncryptedPrivateKeyInfo",
);
if (root.length !== 2)
throw new Error(
"EncryptedPrivateKeyInfo must contain an algorithm and encrypted data.",
);
return parsePbes2Algorithm(
root[0],
octets(root[1], "Encrypted PKCS #8 data"),
);
}
function parsePbes2Algorithm(
algorithmBlock: Block | undefined,
encryptedData: Uint8Array,
): ParsedPbes2 {
const algorithm = sequence(algorithmBlock, "Encryption algorithm");
if (
algorithm.length !== 2 ||
oid(algorithm[0], "Encryption algorithm") !== PBES2
)
throw new Error("Only PKCS #5 PBES2 encrypted PKCS #8 is supported.");
const params = sequence(algorithm[1], "PBES2 parameters");
if (params.length !== 2) throw new Error("PBES2 parameters are incomplete.");
const kdf = sequence(params[0], "PBES2 key derivation function");
if (kdf.length !== 2 || oid(kdf[0], "KDF algorithm") !== PBKDF2)
throw new Error("Only PBKDF2 key derivation is supported for PBES2.");
const pbkdf = sequence(kdf[1], "PBKDF2 parameters");
if (pbkdf.length < 2 || pbkdf.length > 4)
throw new Error("PBKDF2 parameters have an unsupported shape.");
const salt = octets(pbkdf[0], "PBKDF2 salt");
if (salt.length < 8 || salt.length > 1_024)
throw new Error("PBKDF2 salt must contain 81,024 bytes.");
const iterationBig = integer(pbkdf[1], "PBKDF2 iteration count");
if (iterationBig < 1n || iterationBig > BigInt(MAX_ITERATIONS))
throw new Error(
`PBKDF2 iteration count must be 1${MAX_ITERATIONS.toLocaleString()}.`,
);
let cursor = 2;
let declaredKeyLength: number | undefined;
if (pbkdf[cursor] instanceof Integer) {
const bytesLong = integer(pbkdf[cursor], "PBKDF2 key length");
if (bytesLong < 1n || bytesLong > 64n)
throw new Error("PBKDF2 key length is outside the supported bound.");
declaredKeyLength = Number(bytesLong) * 8;
cursor += 1;
}
let prf = "SHA-1";
if (pbkdf[cursor]) {
const prfAlgorithm = sequence(pbkdf[cursor], "PBKDF2 PRF");
if (
prfAlgorithm.length < 1 ||
prfAlgorithm.length > 2 ||
(prfAlgorithm[1] !== undefined && !(prfAlgorithm[1] instanceof Null))
)
throw new Error("PBKDF2 PRF parameters must be absent or NULL.");
prf = PRFS[oid(prfAlgorithm[0], "PBKDF2 PRF algorithm")] ?? "";
if (!prf)
throw new Error(
"PBKDF2 PRF is not supported by this WebCrypto workflow.",
);
cursor += 1;
}
if (cursor !== pbkdf.length)
throw new Error("PBKDF2 parameters contain unsupported trailing fields.");
const encryption = sequence(params[1], "PBES2 encryption scheme");
if (encryption.length !== 2)
throw new Error("PBES2 encryption scheme parameters are incomplete.");
const cipher = CIPHERS[oid(encryption[0], "PBES2 cipher")];
if (!cipher)
throw new Error("PBES2 cipher is not a supported AES-CBC/AES-GCM scheme.");
if (declaredKeyLength !== undefined && declaredKeyLength !== cipher.length)
throw new Error(
"PBKDF2 declared key length does not match the AES scheme.",
);
let ivOrNonce: Uint8Array;
let tagLength: number | undefined;
if (cipher.name === "AES-CBC") {
ivOrNonce = octets(encryption[1], "AES-CBC IV");
if (ivOrNonce.length !== 16)
throw new Error("AES-CBC IV must contain 16 bytes.");
} else {
const gcm = sequence(encryption[1], "AES-GCM parameters");
if (gcm.length < 1 || gcm.length > 2)
throw new Error("AES-GCM parameters have an unsupported shape.");
ivOrNonce = octets(gcm[0], "AES-GCM nonce");
if (ivOrNonce.length < 12 || ivOrNonce.length > 16)
throw new Error("AES-GCM nonce must contain 1216 bytes.");
const tagBytes = gcm[1] ? integer(gcm[1], "AES-GCM tag length") : 12n;
if (![12n, 13n, 14n, 15n, 16n].includes(tagBytes))
throw new Error("AES-GCM tag length must contain 1216 octets.");
tagLength = Number(tagBytes) * 8;
}
if (encryptedData.length === 0 || encryptedData.length > MAX_ENCRYPTED_BYTES)
throw new Error("PBES2 encrypted payload must contain 1 byte8 MiB.");
return {
scheme: "PBES2",
kdf: "PBKDF2",
prf,
iterations: Number(iterationBig),
saltBytes: salt.length,
cipher: cipher.name,
keyLength: cipher.length,
ivOrNonceBytes: ivOrNonce.length,
...(tagLength ? { tagLength } : {}),
encryptedBytes: encryptedData.length,
salt,
ivOrNonce,
encryptedData,
};
}
export function inspectEncryptedPkcs8(bytes: Uint8Array): Pbes2Inspection {
const parsed = parsePbes2(bytes);
return {
scheme: parsed.scheme,
kdf: parsed.kdf,
prf: parsed.prf,
iterations: parsed.iterations,
saltBytes: parsed.saltBytes,
cipher: parsed.cipher,
keyLength: parsed.keyLength,
ivOrNonceBytes: parsed.ivOrNonceBytes,
...(parsed.tagLength === undefined ? {} : { tagLength: parsed.tagLength }),
encryptedBytes: parsed.encryptedBytes,
};
}
export function inspectPbes2Payload(
algorithmIdentifier: Uint8Array,
encryptedData: Uint8Array,
): Pbes2Inspection {
const parsed = parsePbes2Algorithm(
parseDer(algorithmIdentifier, "PBES2 AlgorithmIdentifier"),
encryptedData,
);
return publicPbes2Inspection(parsed);
}
export interface Pkcs8Inspection {
algorithmOid: string;
algorithm: string;
curve?: string;
bytes: number;
}
export function inspectPkcs8(bytes: Uint8Array): Pkcs8Inspection {
const root = sequence(
parseDer(bytes, "PKCS #8 private key"),
"PrivateKeyInfo",
);
if (root.length < 3) throw new Error("PKCS #8 PrivateKeyInfo is incomplete.");
const version = integer(root[0], "PKCS #8 version");
if (version < 0n || version > 1n)
throw new Error("PKCS #8 version is unsupported.");
const algorithmIdentifier = sequence(root[1], "Private-key algorithm");
const algorithmOid = oid(algorithmIdentifier[0], "Private-key algorithm");
const algorithm = PRIVATE_KEY_ALGORITHMS[algorithmOid] ?? "Unknown";
const curveOid =
algorithm === "EC" && algorithmIdentifier[1] instanceof ObjectIdentifier
? algorithmIdentifier[1].getValue()
: undefined;
// Validates that the privateKey field is present and encoded as octets.
octets(root[2], "PKCS #8 privateKey");
return {
algorithmOid,
algorithm,
...(curveOid ? { curve: CURVES[curveOid] ?? `OID ${curveOid}` } : {}),
bytes: bytes.length,
};
}
export async function decryptEncryptedPkcs8(
bytes: Uint8Array,
password: string,
): Promise<{
bytes: Uint8Array;
inspection: Pbes2Inspection;
key: Pkcs8Inspection;
}> {
if (password.length > 100_000)
throw new Error("Password must contain at most 100,000 UTF-16 units.");
const parsed = parsePbes2(bytes);
const output = await decryptParsedPbes2(parsed, password);
try {
return {
bytes: output,
inspection: publicPbes2Inspection(parsed),
key: inspectPkcs8(output),
};
} catch (error) {
output.fill(0);
throw error;
}
}
export async function decryptPbes2Payload(
algorithmIdentifier: Uint8Array,
encryptedData: Uint8Array,
password: string,
): Promise<{ bytes: Uint8Array; inspection: Pbes2Inspection }> {
if (password.length > 100_000)
throw new Error("Password must contain at most 100,000 UTF-16 units.");
const parsed = parsePbes2Algorithm(
parseDer(algorithmIdentifier, "PBES2 AlgorithmIdentifier"),
encryptedData,
);
return {
bytes: await decryptParsedPbes2(parsed, password),
inspection: publicPbes2Inspection(parsed),
};
}
function publicPbes2Inspection(parsed: ParsedPbes2): Pbes2Inspection {
return {
scheme: parsed.scheme,
kdf: parsed.kdf,
prf: parsed.prf,
iterations: parsed.iterations,
saltBytes: parsed.saltBytes,
cipher: parsed.cipher,
keyLength: parsed.keyLength,
ivOrNonceBytes: parsed.ivOrNonceBytes,
...(parsed.tagLength === undefined ? {} : { tagLength: parsed.tagLength }),
encryptedBytes: parsed.encryptedBytes,
};
}
async function decryptParsedPbes2(
parsed: ParsedPbes2,
password: string,
): Promise<Uint8Array> {
const material = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveKey"],
);
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: asBuffer(parsed.salt),
iterations: parsed.iterations,
hash: parsed.prf,
},
material,
{ name: parsed.cipher, length: parsed.keyLength },
false,
["decrypt"],
);
let decrypted: ArrayBuffer;
try {
decrypted = await crypto.subtle.decrypt(
parsed.cipher === "AES-CBC"
? { name: "AES-CBC", iv: asBuffer(parsed.ivOrNonce) }
: {
name: "AES-GCM",
iv: asBuffer(parsed.ivOrNonce),
tagLength: parsed.tagLength ?? 96,
},
key,
asBuffer(parsed.encryptedData),
);
} catch {
throw new Error(
"PBES2 decryption failed. The password may be wrong, the data may be damaged, or this WebCrypto runtime may not support the parameters.",
);
}
return new Uint8Array(decrypted);
}