Files
Shade/packages/shade-vault/src/crypto.ts

130 lines
4.3 KiB
TypeScript
Raw Normal View History

feat(vault): server-side kryptert fillager (V4.13) Shade kunne flytte filer mellom peers (@shade/files) og lagre én liten profil-blob per konto, men hadde ingen alltid-på lagring av krypterte filer. Uten den kan ingen Shade-app tilby backup, og ingen klient lese data mens peeren som eier dem er avslått. Objekter er innholdsadresserte på hashen av CHIFFERTEKSTEN, så relayen kan lagre, deduplisere og verifisere uten nøkkel — den regner om hashen ved opplasting og avviser feilnavngitte objekter. Stier bor inne i det krypterte manifestet, aldri i objektnavn: relayen skal ikke lære hva filene heter. Loggen er append-only, så historikk og rollback følger av modellen. SqliteVaultStore har med vilje INGEN minne-fallback, i motsetning til blob-storen. Den fallbacken slettet Prisms profil ved en rutine-redeploy 2026-08-12 fordi den fungerte helt til containeren ble recreated, uten en eneste feilmelding. En backup som glemmer er verre enn ingen backup, så uten SHADE_VAULT_DB_PATH mountes rutene ikke — med en logglinje som sier hvorfor. Én feil fanget av testene: pubkeyen ble først lagt på UTENFOR signaturen, som både brøt verifyPayload og ville latt hvem som helst bytte identitet i transit på den TOFU-pinnende førsteskrivingen. 16 vault- + 7 store-tester, alle mot de ekte rutehåndtererne gjennom Honos fetch. Kjeden er dessuten kjørt mot en ekte HTTP-server med et ekte workspace: 491 filer / 7,7 MB, alle bit-identiske etter gjenoppretting, og andre push etter én endring sendte 0 KB. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:58:37 +02:00
/**
* 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<SealedObject> {
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<Uint8Array> {
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<SealedObject> {
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<VaultManifest> {
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('');
}