130 lines
4.3 KiB
TypeScript
130 lines
4.3 KiB
TypeScript
|
|
/**
|
||
|
|
* 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('');
|
||
|
|
}
|