Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
Lands the broadcast-channel primitive Prism asked for in Docs/shade-feature-request-sender-keys.md. The crypto in @shade/core/sender-keys.ts was already in place; this release wires it up as a first-class app-facing API, adds the persistence schema across all six storage backends (memory, sqlite, indexeddb + encrypted variants), introduces wire type 0x21 in @shade/proto, and ships Prism's three acceptance tests verbatim. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|