Files
hosted-gateway/src/t4t/lib/crypto.ts
T
2026-05-20 15:08:45 +03:00

90 lines
3.5 KiB
TypeScript

/**
* ECIES payload encryption for Swarm chunks (spec §6).
*
* Hybrid scheme: secp256k1 ECDH → HKDF-SHA256 → AES-256-GCM.
*
* ciphertext = ephPubCompressed(33) || iv(12) || aesGcm(plaintext)
*
* The recipient's on-chain `pssPublicKey` is a 32-byte X coordinate (Y-even
* by convention; see lib/keys.ts), so we prepend 0x02 to reconstruct the
* compressed point. The ephemeral pub key is emitted compressed (33 bytes)
* since both Y parities can occur for ephemerals.
*/
import {gcm} from '@noble/ciphers/aes'
import {secp256k1} from '@noble/curves/secp256k1'
import {hkdf} from '@noble/hashes/hkdf'
import {sha256} from '@noble/hashes/sha2'
import {hexToBytes, randomBytes} from '@noble/hashes/utils'
import {pssPubKeyXToCompressed} from './keys'
import type {Hex} from './types'
const EPH_PUB_LEN = 33
const IV_LEN = 12
const AES_KEY_LEN = 32
const HKDF_INFO = new TextEncoder().encode('t4t-ecies-v1')
export interface PayloadCipher {
encrypt(plaintext: Uint8Array, recipientPubKeyX: Hex): Promise<Uint8Array>
decrypt(ciphertext: Uint8Array): Promise<Uint8Array>
}
export class EciesCipher implements PayloadCipher {
private readonly selfPriv: Uint8Array
constructor(selfPssPrivateKey: Hex) {
const priv = hexToBytes(selfPssPrivateKey.replace(/^0x/, ''))
if (priv.length !== 32) throw new Error(`PSS private key must be 32 bytes, got ${priv.length}`)
if (!secp256k1.utils.isValidPrivateKey(priv)) throw new Error('invalid secp256k1 private key')
this.selfPriv = priv
}
async encrypt(plaintext: Uint8Array, recipientPubKeyX: Hex): Promise<Uint8Array> {
const recipientPub = pssPubKeyXToCompressed(recipientPubKeyX)
const ephPriv = secp256k1.utils.randomPrivateKey()
const ephPub = secp256k1.getPublicKey(ephPriv, true) // 33 bytes
const shared = secp256k1.getSharedSecret(ephPriv, recipientPub, true) // [parity || X]
const key = hkdf(sha256, shared.subarray(1), undefined, HKDF_INFO, AES_KEY_LEN)
const iv = randomBytes(IV_LEN)
const ct = gcm(key, iv).encrypt(plaintext)
const out = new Uint8Array(EPH_PUB_LEN + IV_LEN + ct.length)
out.set(ephPub, 0)
out.set(iv, EPH_PUB_LEN)
out.set(ct, EPH_PUB_LEN + IV_LEN)
return out
}
async decrypt(ciphertext: Uint8Array): Promise<Uint8Array> {
if (ciphertext.length < EPH_PUB_LEN + IV_LEN + 16) {
throw new Error('ciphertext too short for ECIES envelope')
}
const ephPub = ciphertext.subarray(0, EPH_PUB_LEN)
const iv = ciphertext.subarray(EPH_PUB_LEN, EPH_PUB_LEN + IV_LEN)
const ct = ciphertext.subarray(EPH_PUB_LEN + IV_LEN)
const shared = secp256k1.getSharedSecret(this.selfPriv, ephPub, true)
const key = hkdf(sha256, shared.subarray(1), undefined, HKDF_INFO, AES_KEY_LEN)
return gcm(key, iv).decrypt(ct)
}
}
/**
* No-op cipher kept around for tests where you don't want to deal with key
* material. Production code paths construct EciesCipher.
*/
export class PassthroughCipher implements PayloadCipher {
async encrypt(plaintext: Uint8Array): Promise<Uint8Array> {
return plaintext
}
async decrypt(ciphertext: Uint8Array): Promise<Uint8Array> {
return ciphertext
}
}
export function jsonEncrypt(cipher: PayloadCipher, recipient: Hex, value: unknown): Promise<Uint8Array> {
return cipher.encrypt(new TextEncoder().encode(JSON.stringify(value)), recipient)
}
export async function jsonDecrypt<T>(cipher: PayloadCipher, ct: Uint8Array): Promise<T> {
const pt = await cipher.decrypt(ct)
return JSON.parse(new TextDecoder().decode(pt)) as T
}