Files
Shade/packages/shade-storage-encrypted/src/crypto/row-codec.ts

304 lines
11 KiB
TypeScript
Raw Normal View History

/**
* 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<Uint8Array> {
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<string> {
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<Uint8Array> {
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<Uint8Array> {
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<Uint8Array> {
return sealString(km, TBL.identity, COL.identity, '1', serializeIdentityKeyPair(kp));
}
export async function openIdentity(km: KeyManager, blob: Uint8Array): Promise<IdentityKeyPair> {
return deserializeIdentityKeyPair(await openString(km, TBL.identity, COL.identity, '1', blob));
}
export async function sealConfig(km: KeyManager, key: string, value: string): Promise<Uint8Array> {
return sealString(km, TBL.config, COL.config, key, value);
}
export async function openConfig(km: KeyManager, key: string, blob: Uint8Array): Promise<string> {
return openString(km, TBL.config, COL.config, key, blob);
}
export async function sealSignedPreKey(km: KeyManager, k: SignedPreKey): Promise<Uint8Array> {
return sealString(km, TBL.signedPrekeys, COL.signedPrekey, String(k.keyId), serializeSignedPreKey(k));
}
export async function openSignedPreKey(km: KeyManager, keyId: number, blob: Uint8Array): Promise<SignedPreKey> {
return deserializeSignedPreKey(await openString(km, TBL.signedPrekeys, COL.signedPrekey, String(keyId), blob));
}
export async function sealOneTimePreKey(km: KeyManager, k: OneTimePreKey): Promise<Uint8Array> {
return sealString(km, TBL.oneTimePrekeys, COL.oneTimePrekey, String(k.keyId), serializeOneTimePreKey(k));
}
export async function openOneTimePreKey(km: KeyManager, keyId: number, blob: Uint8Array): Promise<OneTimePreKey> {
return deserializeOneTimePreKey(await openString(km, TBL.oneTimePrekeys, COL.oneTimePrekey, String(keyId), blob));
}
export async function sealSession(km: KeyManager, address: string, state: SessionState): Promise<Uint8Array> {
return sealString(km, TBL.sessions, COL.session, address, serializeSessionState(state));
}
export async function openSession(km: KeyManager, address: string, blob: Uint8Array): Promise<SessionState> {
return deserializeSessionState(await openString(km, TBL.sessions, COL.session, address, blob));
}
export async function sealTrust(km: KeyManager, address: string, identityKey: Uint8Array): Promise<Uint8Array> {
return sealString(km, TBL.trustedIdentities, COL.trustedIdentity, address, toBase64(identityKey));
}
export async function openTrust(km: KeyManager, address: string, blob: Uint8Array): Promise<Uint8Array> {
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<Uint8Array> {
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<RetiredIdentity> {
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<Uint8Array> {
return sealString(km, TBL.streamState, COL.streamSensitive, s.streamId, JSON.stringify(packStreamSensitive(s)));
}
export async function openStreamSensitive(
km: KeyManager,
streamId: string,
blob: Uint8Array,
): Promise<Pick<PersistedStreamState, 'metadataJson' | 'partitionJson' | 'laneStateJson' | 'ioDescriptorJson' | 'secretEnc' | 'secretNonce' | 'overallHashState'>> {
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<PersistedStreamState, 'metadataJson' | 'partitionJson' | 'laneStateJson' | 'ioDescriptorJson' | 'secretEnc' | 'secretNonce' | 'overallHashState'>;
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<Uint8Array> {
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;
}