/** * Row codec — bridges between the StorageProvider's typed values and the * AEAD-sealed BLOB that lives in an `_enc` table. * * The codec is deliberately shared between SQLite and Postgres backends so * the wire format (and AAD binding) is identical across them. A backup * created with one backend can be re-keyed and restored under the other. */ import type { IdentityKeyPair, OneTimePreKey, PersistedStreamState, RetiredIdentity, SessionState, SignedPreKey, } from '@shade/core'; import { serializeIdentityKeyPair, deserializeIdentityKeyPair, serializeOneTimePreKey, deserializeOneTimePreKey, serializeSessionState, deserializeSessionState, serializeSignedPreKey, deserializeSignedPreKey, toBase64, fromBase64, } from '@shade/core'; import { aeadOpen, aeadSeal } from './aead.js'; import { buildAad, deriveNonce } from './kdf.js'; import type { KeyManager } from './key-manager.js'; const TEXT_ENCODER = new TextEncoder(); const TEXT_DECODER = new TextDecoder(); /** Logical column identifiers — used for fieldKey + AAD binding. */ export const COL = { identity: 'identity', config: 'config', signedPrekey: 'signed_prekey', oneTimePrekey: 'one_time_prekey', session: 'session', trustedIdentity: 'trusted_identity', retiredIdentity: 'retired_identity', streamSensitive: 'stream_sensitive', broadcastChannelSensitive: 'broadcast_channel_sensitive', } as const; /** Logical table identifiers — used for fieldKey + AAD binding. */ export const TBL = { identity: 'identity', config: 'config', signedPrekeys: 'signed_prekeys', oneTimePrekeys: 'one_time_prekeys', sessions: 'sessions', trustedIdentities: 'trusted_identities', retiredIdentities: 'retired_identities', streamState: 'stream_state', broadcastChannels: 'broadcast_channels', } as const; /** Encrypt an arbitrary string payload bound to (table, column, pk). */ export async function sealString( km: KeyManager, table: string, column: string, pk: string, plaintext: string, ): Promise { const key = km.fieldKey(table, column); const nonce = deriveNonce(key, table, pk); const aad = buildAad(table, column, pk); return aeadSeal(key, nonce, TEXT_ENCODER.encode(plaintext), aad); } /** Decrypt a blob into a string, reconstructing AAD from (table, column, pk). */ export async function openString( km: KeyManager, table: string, column: string, pk: string, blob: Uint8Array, ): Promise { const key = km.fieldKey(table, column); const expectedNonce = deriveNonce(key, table, pk); const aad = buildAad(table, column, pk); const pt = await aeadOpen(key, blob, aad, expectedNonce); return TEXT_DECODER.decode(pt); } /** Encrypt arbitrary bytes payload. */ export async function sealBytes( km: KeyManager, table: string, column: string, pk: string, plaintext: Uint8Array, ): Promise { const key = km.fieldKey(table, column); const nonce = deriveNonce(key, table, pk); const aad = buildAad(table, column, pk); return aeadSeal(key, nonce, plaintext, aad); } /** Decrypt arbitrary bytes payload. */ export async function openBytes( km: KeyManager, table: string, column: string, pk: string, blob: Uint8Array, ): Promise { const key = km.fieldKey(table, column); const expectedNonce = deriveNonce(key, table, pk); const aad = buildAad(table, column, pk); return aeadOpen(key, blob, aad, expectedNonce); } // ─── Typed encoders for each StorageProvider entity ────────────────────── export async function sealIdentity(km: KeyManager, kp: IdentityKeyPair): Promise { return sealString(km, TBL.identity, COL.identity, '1', serializeIdentityKeyPair(kp)); } export async function openIdentity(km: KeyManager, blob: Uint8Array): Promise { return deserializeIdentityKeyPair(await openString(km, TBL.identity, COL.identity, '1', blob)); } export async function sealConfig(km: KeyManager, key: string, value: string): Promise { return sealString(km, TBL.config, COL.config, key, value); } export async function openConfig(km: KeyManager, key: string, blob: Uint8Array): Promise { return openString(km, TBL.config, COL.config, key, blob); } export async function sealSignedPreKey(km: KeyManager, k: SignedPreKey): Promise { return sealString(km, TBL.signedPrekeys, COL.signedPrekey, String(k.keyId), serializeSignedPreKey(k)); } export async function openSignedPreKey(km: KeyManager, keyId: number, blob: Uint8Array): Promise { return deserializeSignedPreKey(await openString(km, TBL.signedPrekeys, COL.signedPrekey, String(keyId), blob)); } export async function sealOneTimePreKey(km: KeyManager, k: OneTimePreKey): Promise { return sealString(km, TBL.oneTimePrekeys, COL.oneTimePrekey, String(k.keyId), serializeOneTimePreKey(k)); } export async function openOneTimePreKey(km: KeyManager, keyId: number, blob: Uint8Array): Promise { return deserializeOneTimePreKey(await openString(km, TBL.oneTimePrekeys, COL.oneTimePrekey, String(keyId), blob)); } export async function sealSession(km: KeyManager, address: string, state: SessionState): Promise { return sealString(km, TBL.sessions, COL.session, address, serializeSessionState(state)); } export async function openSession(km: KeyManager, address: string, blob: Uint8Array): Promise { return deserializeSessionState(await openString(km, TBL.sessions, COL.session, address, blob)); } export async function sealTrust(km: KeyManager, address: string, identityKey: Uint8Array): Promise { return sealString(km, TBL.trustedIdentities, COL.trustedIdentity, address, toBase64(identityKey)); } export async function openTrust(km: KeyManager, address: string, blob: Uint8Array): Promise { return fromBase64(await openString(km, TBL.trustedIdentities, COL.trustedIdentity, address, blob)); } /** * Retired identities are append-only with auto-incremented row IDs. We bind * AAD on (retiredAt as string) since retired_at is a unique-enough natural * key for this row; collisions are practically impossible (ms timestamp). */ export async function sealRetired(km: KeyManager, ri: RetiredIdentity): Promise { const pk = String(ri.retiredAt); return sealString(km, TBL.retiredIdentities, COL.retiredIdentity, pk, serializeIdentityKeyPair(ri.keyPair)); } export async function openRetired(km: KeyManager, retiredAt: number, blob: Uint8Array): Promise { const pk = String(retiredAt); const json = await openString(km, TBL.retiredIdentities, COL.retiredIdentity, pk, blob); return { keyPair: deserializeIdentityKeyPair(json), retiredAt }; } /** * Stream-state sensitive bundle. Plaintext fields (peer, status, dir, * timestamps, streamId) stay in their own columns so the storage backend * can run efficient queries; everything else lives in this encrypted blob. */ interface StreamSensitiveBundle { metadataJson: string; partitionJson: string; laneStateJson: string; ioDescriptorJson: string; secretEnc: string; // base64 secretNonce: string; // base64 overallHashState?: string; } function packStreamSensitive(s: PersistedStreamState): StreamSensitiveBundle { const out: StreamSensitiveBundle = { metadataJson: s.metadataJson, partitionJson: s.partitionJson, laneStateJson: s.laneStateJson, ioDescriptorJson: s.ioDescriptorJson, secretEnc: toBase64(s.secretEnc), secretNonce: toBase64(s.secretNonce), }; if (s.overallHashState !== undefined) out.overallHashState = s.overallHashState; return out; } export async function sealStreamSensitive(km: KeyManager, s: PersistedStreamState): Promise { return sealString(km, TBL.streamState, COL.streamSensitive, s.streamId, JSON.stringify(packStreamSensitive(s))); } export async function openStreamSensitive( km: KeyManager, streamId: string, blob: Uint8Array, ): Promise> { const json = await openString(km, TBL.streamState, COL.streamSensitive, streamId, blob); const b = JSON.parse(json) as StreamSensitiveBundle; const out = { metadataJson: b.metadataJson, partitionJson: b.partitionJson, laneStateJson: b.laneStateJson, ioDescriptorJson: b.ioDescriptorJson, secretEnc: fromBase64(b.secretEnc), secretNonce: fromBase64(b.secretNonce), } as Pick; if (b.overallHashState !== undefined) (out as { overallHashState?: string }).overallHashState = b.overallHashState; return out; } /** * Broadcast-channel sensitive bundle (V4.6). Routing fields (channelId, * ownerRole, ownerAddress, label, generation, createdAt, updatedAt) live * in plaintext columns so backends can list/query without unsealing every * row; the chain key, iteration, and signing keys all live in this sealed * blob. */ interface BroadcastChannelSensitiveBundle { chainKey: string; // base64(32B) iteration: number; signingPublicKey: string; // base64(32B) signingPrivateKey?: string; // base64; only when ownerRole === 'sender' } export async function sealBroadcastChannelSensitive( km: KeyManager, channelId: string, s: { chainKey: Uint8Array; iteration: number; signingPublicKey: Uint8Array; signingPrivateKey?: Uint8Array; }, ): Promise { const bundle: BroadcastChannelSensitiveBundle = { chainKey: toBase64(s.chainKey), iteration: s.iteration, signingPublicKey: toBase64(s.signingPublicKey), }; if (s.signingPrivateKey !== undefined) { bundle.signingPrivateKey = toBase64(s.signingPrivateKey); } return sealString( km, TBL.broadcastChannels, COL.broadcastChannelSensitive, channelId, JSON.stringify(bundle), ); } export async function openBroadcastChannelSensitive( km: KeyManager, channelId: string, blob: Uint8Array, ): Promise<{ chainKey: Uint8Array; iteration: number; signingPublicKey: Uint8Array; signingPrivateKey?: Uint8Array; }> { const json = await openString( km, TBL.broadcastChannels, COL.broadcastChannelSensitive, channelId, blob, ); const b = JSON.parse(json) as BroadcastChannelSensitiveBundle; const out: { chainKey: Uint8Array; iteration: number; signingPublicKey: Uint8Array; signingPrivateKey?: Uint8Array; } = { chainKey: fromBase64(b.chainKey), iteration: b.iteration, signingPublicKey: fromBase64(b.signingPublicKey), }; if (b.signingPrivateKey !== undefined) out.signingPrivateKey = fromBase64(b.signingPrivateKey); return out; }