67 lines
2.4 KiB
TypeScript
67 lines
2.4 KiB
TypeScript
|
|
import type { CryptoProvider } from '@shade/core';
|
||
|
|
import { ValidationError } from '@shade/core';
|
||
|
|
import { STREAM_ID_BYTES, STREAM_SECRET_BYTES } from './ids.js';
|
||
|
|
|
||
|
|
const TEXT = new TextEncoder();
|
||
|
|
const STREAM_KEY_INFO = TEXT.encode('shade-stream/v1\0master');
|
||
|
|
const LANE_KEY_INFO_PREFIX = TEXT.encode('shade-stream/v1\0lane\0');
|
||
|
|
|
||
|
|
const STREAM_KEY_BYTES = 32;
|
||
|
|
export const LANE_KEY_BYTES = 32;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Derive the master streamKey from a streamSecret + streamId.
|
||
|
|
*
|
||
|
|
* streamKey = HKDF(ikm=streamSecret, salt=streamId,
|
||
|
|
* info="shade-stream/v1\0master", length=32)
|
||
|
|
*
|
||
|
|
* The streamKey is NEVER used to encrypt chunks directly — it is a root for
|
||
|
|
* per-lane key derivation (see `deriveLaneKey`).
|
||
|
|
*/
|
||
|
|
export async function deriveStreamKey(
|
||
|
|
crypto: CryptoProvider,
|
||
|
|
streamSecret: Uint8Array,
|
||
|
|
streamId: Uint8Array,
|
||
|
|
): Promise<Uint8Array> {
|
||
|
|
if (streamSecret.length !== STREAM_SECRET_BYTES) {
|
||
|
|
throw new ValidationError(
|
||
|
|
`streamSecret must be ${STREAM_SECRET_BYTES} bytes`,
|
||
|
|
'streamSecret',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (streamId.length !== STREAM_ID_BYTES) {
|
||
|
|
throw new ValidationError(`streamId must be ${STREAM_ID_BYTES} bytes`, 'streamId');
|
||
|
|
}
|
||
|
|
return crypto.hkdf(streamSecret, streamId, STREAM_KEY_INFO, STREAM_KEY_BYTES);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Derive a lane-specific AEAD key.
|
||
|
|
*
|
||
|
|
* laneKey[i] = HKDF(ikm=streamKey, salt=streamId,
|
||
|
|
* info="shade-stream/v1\0lane\0" || u32_be(laneId), length=32)
|
||
|
|
*
|
||
|
|
* Distinct laneIds produce independent keys; receiver derives the same key
|
||
|
|
* given the same (streamSecret, streamId, laneId).
|
||
|
|
*/
|
||
|
|
export async function deriveLaneKey(
|
||
|
|
crypto: CryptoProvider,
|
||
|
|
streamKey: Uint8Array,
|
||
|
|
streamId: Uint8Array,
|
||
|
|
laneId: number,
|
||
|
|
): Promise<Uint8Array> {
|
||
|
|
if (streamKey.length !== STREAM_KEY_BYTES) {
|
||
|
|
throw new ValidationError(`streamKey must be ${STREAM_KEY_BYTES} bytes`, 'streamKey');
|
||
|
|
}
|
||
|
|
if (streamId.length !== STREAM_ID_BYTES) {
|
||
|
|
throw new ValidationError(`streamId must be ${STREAM_ID_BYTES} bytes`, 'streamId');
|
||
|
|
}
|
||
|
|
if (!Number.isInteger(laneId) || laneId < 0 || laneId > 0xffff_ffff) {
|
||
|
|
throw new ValidationError(`laneId must fit in u32: ${laneId}`, 'laneId');
|
||
|
|
}
|
||
|
|
const info = new Uint8Array(LANE_KEY_INFO_PREFIX.length + 4);
|
||
|
|
info.set(LANE_KEY_INFO_PREFIX, 0);
|
||
|
|
new DataView(info.buffer).setUint32(LANE_KEY_INFO_PREFIX.length, laneId, false);
|
||
|
|
return crypto.hkdf(streamKey, streamId, info, LANE_KEY_BYTES);
|
||
|
|
}
|