/** * Sealing and naming for vault objects. * * Every object on the relay is `nonce(12) || ciphertext||tag`, named by the * SHA-256 of those bytes. Hashing the *ciphertext* rather than the plaintext * is what lets the relay verify that an upload is what it claims to be * without holding a key — it recomputes the hash and compares. * * The cost is that identical plaintext yields different names on each seal, * because the nonce is fresh. Deduplication is therefore *within* a version * chain (an unchanged file keeps its hash across commits because the client * reuses the object it already uploaded), not across independent seals. That * is the right trade: convergent encryption would leak which files two users * share, which is exactly what a blind store must not do. */ import { sha256 } from '@noble/hashes/sha2.js'; import type { CryptoProvider } from '@shade/core'; import type { VaultManifest } from './types.js'; const NONCE_LEN = 12; /** Lowercase hex SHA-256 — the name of an object. */ export function objectHash(sealed: Uint8Array): string { return Array.from(sha256(sealed)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } /** * AAD for an object. * * Binds the vault it belongs to, so a ciphertext lifted from one vault cannot * be planted in another even by someone who can write to both. The content * hash is deliberately NOT in the AAD: it is derived from the very bytes * being sealed, so it cannot be known before sealing, and the relay's own * hash check already covers substitution. */ function objectAad(vaultId: string): Uint8Array { return new TextEncoder().encode(`shade-vault-object-v1|${vaultId}`); } export interface SealedObject { hash: string; bytes: Uint8Array; } /** Encrypt one object and give it its name. */ export async function sealObject( crypto: CryptoProvider, contentKey: Uint8Array, vaultId: string, plaintext: Uint8Array, ): Promise { const { ciphertext, nonce } = await crypto.aesGcmEncrypt( contentKey, plaintext, objectAad(vaultId), ); const bytes = new Uint8Array(NONCE_LEN + ciphertext.length); bytes.set(nonce, 0); bytes.set(ciphertext, NONCE_LEN); return { hash: objectHash(bytes), bytes }; } /** * Decrypt one object. * * Throws when the hash does not match the bytes: a relay that returned the * wrong object would otherwise surface as an AEAD failure, which reads like * key corruption and sends the user looking in the wrong place. */ export async function openObject( crypto: CryptoProvider, contentKey: Uint8Array, vaultId: string, expectedHash: string, bytes: Uint8Array, ): Promise { if (bytes.length < NONCE_LEN + 16) { throw new Error('vault object too short to be a sealed object'); } const actual = objectHash(bytes); if (actual !== expectedHash) { throw new Error(`vault object hash mismatch: expected ${expectedHash}, got ${actual}`); } return crypto.aesGcmDecrypt( contentKey, bytes.subarray(NONCE_LEN), bytes.subarray(0, NONCE_LEN), objectAad(vaultId), ); } /** A manifest is stored as an ordinary object; this is the JSON step around it. */ export async function sealManifest( crypto: CryptoProvider, contentKey: Uint8Array, vaultId: string, manifest: VaultManifest, ): Promise { const json = new TextEncoder().encode(JSON.stringify(manifest)); return sealObject(crypto, contentKey, vaultId, json); } export async function openManifest( crypto: CryptoProvider, contentKey: Uint8Array, vaultId: string, expectedHash: string, bytes: Uint8Array, ): Promise { const plain = await openObject(crypto, contentKey, vaultId, expectedHash, bytes); const parsed = JSON.parse(new TextDecoder().decode(plain)) as VaultManifest; if (parsed.version !== 1) { // Refusing beats guessing: a future manifest may mean a file the reader // cannot represent, and silently dropping it would lose data on the next // commit made from this client. throw new Error(`unsupported vault manifest version: ${parsed.version}`); } return parsed; } /** Lowercase hex of arbitrary bytes — used for vaultId and pubkeys on the wire. */ export function toHex(bytes: Uint8Array): string { return Array.from(bytes) .map((b) => b.toString(16).padStart(2, '0')) .join(''); }