diff --git a/libraries/README.md b/libraries/README.md index 7360b5f..32048fe 100644 --- a/libraries/README.md +++ b/libraries/README.md @@ -12,6 +12,11 @@ Publish script: hatter script pub --type deno-mod deno-commons-mod.ts ``` +Test script: +```shell +deno test --allow-all deno-commons-mod.ts +``` + Published deno-mod at: [script deno mod](https://hatter.ink/script/list_script.jssp?type=deno-mod) diff --git a/libraries/deno-commons-mod.ts b/libraries/deno-commons-mod.ts index 96ccbbd..0f89035 100644 --- a/libraries/deno-commons-mod.ts +++ b/libraries/deno-commons-mod.ts @@ -3,8 +3,13 @@ import { assert } from "jsr:@std/assert/assert"; import { assertEquals } from "jsr:@std/assert"; +import { decodeBase64, encodeBase64 } from "jsr:@std/encoding/base64"; import { dirname } from "https://deno.land/std@0.208.0/path/mod.ts"; +// reference: https://docs.deno.com/examples/hex_base64_encoding/ +// import { decodeBase64, encodeBase64 } from "jsr:@std/encoding/base64"; +// import { decodeHex, encodeHex } from "jsr:@std/encoding/hex"; + export async function sleep(timeoutMillis: number): Promise { await new Promise((resolve) => setTimeout(resolve, timeoutMillis)); } @@ -28,7 +33,7 @@ export function compareVersion(ver1: string, ver2: string): 0 | 1 | -1 { return 0; } -export function isOn(val: string | undefined): boolean { +export function isOn(val: string | undefined | null): boolean { if ((val === null) || (val === undefined)) { return false; } @@ -37,7 +42,7 @@ export function isOn(val: string | undefined): boolean { lowerVal === "true"; } -export async function getEnv(key: string): string { +export async function getEnv(key: string): Promise { const homeDir = getHomeDir(); if ((homeDir !== null) && key) { const envValue = await readFileToString( @@ -47,11 +52,15 @@ export async function getEnv(key: string): string { return envValue.trim(); } } - return Deno.env.get(key); + return Deno.env.get(key) || null; } export function isEnvOn(envKey: string): boolean { - return isOn(getEnv(envKey)); + return isOn(Deno.env.get(envKey)); +} + +export async function isEnvOnAsync(envKey: string): Promise { + return isOn(await getEnv(envKey)); } export function formatHumanTime(timeMillis: number): string { @@ -345,6 +354,22 @@ export function hexStringToUint8Array(hex: string): Uint8Array { return uint8; } +export function decodeBase64Url(base64UrlString: string): Uint8Array { + let standardBase64 = base64UrlString.replace(/-/g, "+").replace(/_/g, "/"); + while (standardBase64.length % 4) { + standardBase64 += "="; + } + return decodeBase64(standardBase64); +} + +export function encodeBase64Url(input: ArrayBufferLike): string { + let standardBased64 = encodeBase64(input); + return standardBased64.replace(/\+/g, "-").replace(/\//g, "_").replace( + /=/g, + "", + ); +} + Deno.test("isOn", () => { assertEquals(false, isOn(undefined)); assertEquals(false, isOn("")); @@ -409,3 +434,18 @@ Deno.test("sleep", async () => { const t2 = new Date().getTime(); assert(Math.abs(1000 - (t2 - t1)) < 20); }); + +Deno.test("base64Url", () => { + assertEquals( + "_dxhVwI3qd9fMBlpEMmi6Q", + encodeBase64Url(decodeBase64Url("_dxhVwI3qd9fMBlpEMmi6Q")), + ); + assertEquals( + "1dxJeD7erjAYUNEmdVNE8KdhpPZs0pAHtb-kbSqYIe5j039PkTHbrQYOEoeEWN4UsDERhnUg7mY", + encodeBase64Url( + decodeBase64Url( + "1dxJeD7erjAYUNEmdVNE8KdhpPZs0pAHtb-kbSqYIe5j039PkTHbrQYOEoeEWN4UsDERhnUg7mY", + ), + ), + ); +}); diff --git a/libraries/deno-teencrypt-mod.ts b/libraries/deno-teencrypt-mod.ts index 1ab3cba..d4d2da4 100644 --- a/libraries/deno-teencrypt-mod.ts +++ b/libraries/deno-teencrypt-mod.ts @@ -1,8 +1,11 @@ import { + decodeBase64Url, + encodeBase64Url, getHomeDirOrDie, hexStringToUint8Array, - uint8ArrayToHexString, -} from "https://global.hatter.ink/script/get/@6/deno-commons-mod.ts"; +} from "https://global.hatter.ink/script/get/@8/deno-commons-mod.ts"; +import {getRandomValues} from "node:crypto"; +import {assertEquals} from "jsr:@std/assert"; const COMMONS_LOCAL_ENCRYPT_TINY_ENCRYPT_MASTER_KEY_FILE = getHomeDirOrDie() + "/.cache/commons-local-encrypt-tiny-encrypt-master-key"; @@ -12,6 +15,26 @@ interface TinyEncryptSimpleDecryptObject { result: string; } +let cachedCryptoMasterKey: CryptoKey | null = null; + +export async function lazyLoadCryptoMasterKey(): Promise { + if (cachedCryptoMasterKey == null) { + cachedCryptoMasterKey = await loadCryptoMasterKey(); + } + return cachedCryptoMasterKey; +} + +async function loadCryptoMasterKey(): Promise { + const key = await loadMasterKey(); + return await crypto.subtle.importKey( + "raw", + key, + "AES-GCM", + false, + ["encrypt", "decrypt"], + ); +} + async function loadMasterKey(): Promise { const masterKeyContent = Deno.readTextFileSync( COMMONS_LOCAL_ENCRYPT_TINY_ENCRYPT_MASTER_KEY_FILE, @@ -38,8 +61,55 @@ stderr: ${new TextDecoder().decode(stderr)}`); return hexStringToUint8Array(tinyEncryptSimpleDecryptObject.result); } -async function main() { - // TODO ... - console.log(uint8ArrayToHexString(await loadMasterKey())); +export async function teDecryptToString(ciphertext: string): Promise { + const decryptedValue = await teDecrypt(ciphertext); + return new TextDecoder().decode(decryptedValue); } -await main(); + +export async function teDecrypt(ciphertext: string): Promise { + if (!ciphertext.startsWith("te:")) { + throw new Error(`Invalid ciphertext: ${ciphertext}`); + } + const ciphertextParts = ciphertext.split(":"); + if (ciphertextParts.length !== 3) { + throw new Error(`Invalid ciphertext: ${ciphertext}`); + } + const nonce = decodeBase64Url(ciphertextParts[1]); + const ciphertextAndTag = decodeBase64Url(ciphertextParts[2]); + + const cryptoKey = await lazyLoadCryptoMasterKey(); + + return await crypto.subtle.decrypt( + { name: "AES-GCM", iv: nonce }, + cryptoKey, + ciphertextAndTag, + ); +} + +export async function teEncrypt(plaintext: string): Promise { + const nonce = randomNonce(); + const plaintextBuffer = new TextEncoder().encode(plaintext); + + const cryptoKey = await lazyLoadCryptoMasterKey(); + const encryptedData = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: nonce }, + cryptoKey, + plaintextBuffer, + ); + return `te:${encodeBase64Url(nonce)}:${encodeBase64Url(encryptedData)}`; +} + +function randomNonce(): ArrayBufferLike { + const buffer = new Uint8Array(12); + getRandomValues(buffer); + return buffer; +} + +Deno.test("teEncryptDecrypt", async () => { + assertEquals( + "hello world", + await teDecryptToString( + await teEncrypt("hello world"), + ), + ); +});