crypto.ts (4425B)
1 import { Code } from "#core/domain/code.ts"; 2 import { CODE_DIGITS, TOKEN_BYTES } from "#core/domain/constants.ts"; 3 import { Token } from "#core/domain/token.ts"; 4 import { UUID } from "#core/domain/uuid.ts"; 5 import { concat } from "$std/bytes/concat.ts"; 6 import { timingSafeEqual } from "$std/crypto/timing_safe_equal.ts"; 7 import { decodeBase58, encodeBase58 } from "$std/encoding/base58.ts"; 8 import { encodeBase64Url } from "$std/encoding/base64url.ts"; 9 import sodium from "libsodium-wrappers-sumo"; 10 11 await sodium.ready; 12 13 const encoder = new TextEncoder(); 14 const decoder = new TextDecoder(); 15 16 export class CryptoFailure extends Error { 17 constructor(options?: ErrorOptions) { 18 super("Crypto failure", options); 19 } 20 } 21 22 /*************************************************** 23 * Constant time equal (Safe string compare) 24 */ 25 26 export function isSafeEqual( 27 left: object | string | null | undefined, 28 right: object | string | null | undefined, 29 ) { 30 return left !== null && 31 left !== undefined && 32 right !== null && 33 right !== undefined && 34 timingSafeEqual( 35 encoder.encode(left.toString()), 36 encoder.encode(right.toString()), 37 ); 38 } 39 40 /*************************************************** 41 * Crypto-safe random generation 42 */ 43 44 export function nonceUUID() { 45 return new UUID(crypto.randomUUID()); 46 } 47 48 export function nonceToken(): Token { 49 return new Token( 50 encodeBase58(sodium.randombytes_buf(TOKEN_BYTES)), 51 ); 52 } 53 54 export function nonceCode() { 55 return new Code( 56 new Array(CODE_DIGITS) 57 .fill(null) 58 .map(() => sodium.randombytes_uniform(10)) 59 .join(""), 60 ); 61 } 62 63 /*************************************************** 64 * PKCE 65 */ 66 export async function isCodeVerifier( 67 challenge: object | string | null, 68 verifier: string | null, 69 ): Promise<boolean> { 70 if (challenge === null && verifier === null) { 71 return true; 72 } 73 const digest = await crypto.subtle.digest( 74 "SHA-256", 75 encoder.encode(verifier ?? ""), 76 ); 77 return isSafeEqual(challenge, encodeBase64Url(digest)); 78 } 79 80 /*************************************************** 81 * Password Hashing 82 */ 83 84 const OPSLIMIT = sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE; 85 const MEMLIMIT = sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE; 86 87 export class PasswordHash { 88 static hash(candidate: string) { 89 try { 90 return new PasswordHash( 91 sodium.crypto_pwhash_str(candidate, OPSLIMIT, MEMLIMIT), 92 ); 93 } catch (cause) { 94 throw new CryptoFailure({ cause }); 95 } 96 } 97 98 constructor(private value: string) { 99 } 100 101 verify(candidate: string) { 102 try { 103 if (!sodium.crypto_pwhash_str_verify(this.value, candidate)) { 104 return false; 105 } 106 if ( 107 sodium.crypto_pwhash_str_needs_rehash(this.value, OPSLIMIT, MEMLIMIT) 108 ) { 109 this.value = sodium.crypto_pwhash_str(candidate, OPSLIMIT, MEMLIMIT); 110 } 111 return true; 112 } catch (cause) { 113 throw new CryptoFailure({ cause }); 114 } 115 } 116 117 toString() { 118 return this.value; 119 } 120 } 121 122 /*************************************************** 123 * Encryption (AEAD) 124 */ 125 126 const AEAD_SECRET_BYTES = sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES; 127 const AEAD_NONCE_BYTES = sodium.crypto_aead_xchacha20poly1305_IETF_NPUBBYTES; 128 129 export class AEAD { 130 readonly secret: Uint8Array; 131 132 constructor(secret?: Uint8Array) { 133 try { 134 this.secret = secret ?? sodium.randombytes_buf(AEAD_SECRET_BYTES); 135 } catch (cause) { 136 throw new CryptoFailure({ cause }); 137 } 138 } 139 140 encrypt( 141 message: string, 142 additional: string | Uint8Array | null = null, 143 ): string { 144 try { 145 const nonce = sodium.randombytes_buf(AEAD_NONCE_BYTES); 146 return encodeBase58(concat([ 147 nonce, 148 sodium.crypto_aead_xchacha20poly1305_ietf_encrypt( 149 message, 150 additional, 151 nonce, 152 nonce, 153 this.secret, 154 ), 155 ])); 156 } catch (cause) { 157 throw new CryptoFailure({ cause }); 158 } 159 } 160 161 decrypt( 162 message: string, 163 additional: string | Uint8Array | null = null, 164 ): string { 165 try { 166 const bytes = decodeBase58(message); 167 const nonce = bytes.slice(0, AEAD_NONCE_BYTES); 168 return decoder.decode(sodium.crypto_aead_xchacha20poly1305_ietf_decrypt( 169 nonce, 170 bytes.slice(AEAD_NONCE_BYTES), 171 additional, 172 nonce, 173 this.secret, 174 )); 175 } catch (cause) { 176 throw new CryptoFailure({ cause }); 177 } 178 } 179 }