ライブラリなしでデータの暗号化と復号をしたくて調べたら思ったより複雑だったのでメモです
まずこれが暗号化と復号をするサンプルです

const encrypt = async (key, data) => {
const iv = crypto.getRandomValues(new Uint8Array(16))
const result = await crypto.subtle.encrypt(
{
name: "AES-CBC",
iv,
},
key,
data
)
return new Uint8Array([...iv, ...new Uint8Array(result)])
}

const decrypt = async (key, cipher) => {
const iv = cipher.slice(0, 16)
const data = cipher.slice(16)
const result = await crypto.subtle.decrypt(
{
name: "AES-CBC",
iv,
},
key,
data
)
return new Uint8Array(result)
}

const encodeText = text => new TextEncoder().encode(text)

const passphraseToKey = async passphrase => {
const pass256 = await crypto.subtle.digest(
{ name: "SHA-256" },
encodeText(passphrase)
)
const key = await crypto.subtle.importKey(
"raw",
pass256,
{
name: "AES-CBC",
length: 256,
},
false,
["encrypt", "decrypt"]
)
return key
}

const main = async () => {
const key = await passphraseToKey("password")
const text = JSON.stringify({ a: 1, b: "ああ" })
const bin = encodeText(text)

console.log({ key, bin })

const encrypted = await encrypt(key, bin)
console.log({ encrypted })

const decrypted = await decrypt(key, encrypted)
console.log({ decrypted })

console.log(bin.join() === decrypted.join())
}

main()

main 関数がサンプルの実行なのでここを変更して色々試せます
ここからは詳しい解説です

暗号化

暗号化をするために使う関数は crypto.subtle.encrypt です
この関数にアルゴリズムとキーと暗号化したいデータを入れます

(注意)crypto.subtle は https のページか拡張機能じゃないと使えなくなってます

アルゴリズム

アルゴリズムはここを見ると 4 種類あるようです

◯ RSA-OAEP
◯ AES-CTR
◯ AES-CBC
◯ AES-GCM

どれがいいのかよくわからないので、ブロックチェーンて聞いたことあるし
くらいな感じで AES-CBC にしました

アルゴリズムに応じた追加のデータが必要で、 AES-CBC の場合は iv という 16 バイトの初期ベクトルが必要みたいです
これはランダムなものでよくて、毎回同じじゃないほうが良いらしいのでランダムなデータを作ります

ランダムなデータの作成には crypto.getRandomValues を使います
欲しい長さ分の Uint8Array を引数に渡すとランダムな値を入れてくれます

const iv = crypto.getRandomValues(new Uint8Array(16))

iv は暗号データを復号するときにも使うようで同じものじゃないといけません
これ自体はパスワードみたいに隠すものでもないみたいなので結果の一部として return に含めています

return new Uint8Array([...iv, ...new Uint8Array(result)])

iv は 16 バイト固定なので、復号のときは最初の 16 バイトを iv にして残りを暗号データとして復号します

キー

次にキーですが、パスワード文字列ではなくて特別なフォーマットのキーデータが必要です
普通に作る場合は crypto.subtle.generateKey を使います
しかし、これだとユーザのパスワードと関連付けられません

パスワードからキーを作るには代わりに crypto.subtle.importKey を使えば良いようです
importKey を使うときのパスワードは、ユーザが入力するような自由な文字数にはできず、決められた長さのバイト列にしないといけません
ここでは 256bit にするので、 SHA-256 のハッシュ値を使います

SHA-256 を計算するには crypto.subtle.digest を使います
そのときにパスワードの文字列はバイト列に変換する必要があります
これは TextEncoder を使うと簡単に変換できます
encodeText 関数がこの変換をするための関数です

const encodeText = text => new TextEncoder().encode(text)

これでキーを作るのに必要データは準備できたのでキーを作れます

const key = await crypto.subtle.importKey(
"raw",
pass256,
{
name: "AES-CBC",
length: 256,
},
false,
["encrypt", "decrypt"]
)

importKey はいくつかのフォーマットに対応していて、今回みたいなパスワードからキーを作りたいときは "raw" を選択します
次の引数には SHA-256 で作った 256bit のバイト列を入れます
その次には、このキーを使うアルゴリズムを指定します
その次は crypto.subtle.exportKey や crypto.subtle.wrapKey を使えるかどうかを指定します
特に使う予定はないので false です
最後にこのキーをどういう用途で使うかを設定します
暗号化と復号なので encrypt と decrypt を指定しました

この処理をまとめて、パスワードを入れるとキーを取得できるようにしたのが passphraseToKey 関数です

データ

最後の必要なものは暗号化するデータです
これもパスワードと同じでバイト列にしないといけないので encodeText 関数で変換します

encrypt 関数はキーとデータを入れると AES-CBC アルゴリズムで暗号化するようにしてるので、この関数に入れれば暗号化された結果を取得できます
結果は Uint8Array のバイト列です
ファイルに保存したりするならちょっとした変換が必要です

復号

復号に使う関数は crypto.subtle.decrypt です
この関数にアルゴリズムとキーと暗号化されたデータを入れます

アルゴリズムは暗号化のときに使ったものと一緒です
iv も必要なので encrypt 関数の結果の最初の 16 バイトを取り出して iv にします

キーも暗号化のときに使ったものと一緒です
passphraseToKey に同じパスワードを入れると同じキーになります
ユーザが入力したパスワードが間違ってると間違ったキーになって復号に失敗します

データは encrypt の結果から iv の 16 バイトを取り除いた残りの部分です

decrypt 関数に渡したものが正しければ暗号化する前のデータが取得できます
ただしこれもバイト列になっていて、データを encodeText に通したあとのものです

文字列に戻すには TextDecoder を使います
main 関数ではバイト列が一致してることの確認だけなのでデコードは省略してます