chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @pdsjs/spaces/kem - the shared secret a syncer and a repo host MAC one
// response under.
//
// ECDH on P-256 between the syncer's ephemeral key and the author's
// `#atproto_kem` key, with the secret derived as DHKEM(P-256, HKDF-SHA256)
// specifies (RFC 9180 §4.1). The syncer sends its ephemeral public key as a
// did:key, the `key_share` of Atproto-Space-Want-Mac.
import { compressPublicKey } from '@pdsjs/core/crypto';
import { publicKeyToDidKey } from '@pdsjs/core/plc';
import { parseDidKey } from '@pdsjs/core/verify';
import { concat, hkdfSha256, SpaceMacError, uint16be, utf8 } from './mac.js';
// HPKE-v1 || "KEM" || 0x0010, the suite id of DHKEM(P-256, HKDF-SHA256).
const KEM_PREFIX = concat(utf8('HPKE-v1'), utf8('KEM'), uint16be(0x0010));
const P256_P =
0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn;
const P256_B =
0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn;
/**
* @typedef {Object} Encapsulation
* @property {string} keyShare - the syncer's ephemeral public key as a did:key
* @property {Uint8Array} sharedSecret
*/
/**
* Syncer side: a fresh ephemeral key and the secret it shares with the
* author's KEM key. Use a new one for every request.
*
* @param {string} authorKemKey - the author's `#atproto_kem` did:key
* @returns {Promise<Encapsulation>}
*/
export async function encapsulate(authorKemKey) {
const authorKeyBytes = uncompressedKey(authorKemKey);
const ephemeral = /** @type {CryptoKeyPair} */ (
await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
false,
['deriveBits'],
)
);
const syncerKeyBytes = new Uint8Array(
await crypto.subtle.exportKey('raw', ephemeral.publicKey),
);
const dh = await ecdh(ephemeral.privateKey, authorKeyBytes);
return {
keyShare: publicKeyToDidKey(compressPublicKey(syncerKeyBytes), 'p256'),
sharedSecret: await deriveSharedSecret(dh, syncerKeyBytes, authorKeyBytes),
};
}
/**
* Repo host side: the same secret, from the syncer's key share and the
* author's KEM private key.
*
* @param {Uint8Array} authorPrivateKey - 32-byte P-256 scalar
* @param {string} keyShare - the syncer's did:key
* @returns {Promise<Uint8Array>}
* @throws {SpaceMacError} when the key share is not a P-256 did:key
*/
export async function decapsulate(authorPrivateKey, keyShare) {
const syncerKeyBytes = uncompressedKey(keyShare);
const privateKey = await importEcdhPrivateKey(authorPrivateKey);
const jwk = await crypto.subtle.exportKey('jwk', privateKey);
const authorKeyBytes = concat(
new Uint8Array([0x04]),
base64UrlToBytes(/** @type {string} */ (jwk.x)),
base64UrlToBytes(/** @type {string} */ (jwk.y)),
);
const dh = await ecdh(privateKey, syncerKeyBytes);
return deriveSharedSecret(dh, syncerKeyBytes, authorKeyBytes);
}
/**
* @param {Uint8Array} dh
* @param {Uint8Array} syncerKeyBytes - uncompressed
* @param {Uint8Array} authorKeyBytes - uncompressed
* @returns {Promise<Uint8Array>}
*/
function deriveSharedSecret(dh, syncerKeyBytes, authorKeyBytes) {
const ikm = concat(KEM_PREFIX, utf8('eae_prk'), dh);
const info = concat(
uint16be(32),
KEM_PREFIX,
utf8('shared_secret'),
syncerKeyBytes,
authorKeyBytes,
);
return hkdfSha256(ikm, new Uint8Array(0), info, 32);
}
/**
* The x-coordinate of the shared point.
* @param {CryptoKey} privateKey
* @param {Uint8Array} publicKeyBytes - uncompressed
* @returns {Promise<Uint8Array>}
*/
async function ecdh(privateKey, publicKeyBytes) {
/** @type {CryptoKey} */
let publicKey;
try {
publicKey = await crypto.subtle.importKey(
'raw',
/** @type {BufferSource} */ (publicKeyBytes),
{ name: 'ECDH', namedCurve: 'P-256' },
true,
[],
);
} catch {
throw new SpaceMacError('invalid KEM key');
}
return new Uint8Array(
await crypto.subtle.deriveBits(
{ name: 'ECDH', public: publicKey },
privateKey,
256,
),
);
}
/**
* @param {Uint8Array} scalar - 32 bytes
* @returns {Promise<CryptoKey>}
*/
function importEcdhPrivateKey(scalar) {
if (scalar.length !== 32) throw new Error('KEM key must be 32 bytes');
const pkcs8 = concat(
new Uint8Array([
0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48,
0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03,
0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20,
]),
scalar,
);
return crypto.subtle.importKey(
'pkcs8',
/** @type {BufferSource} */ (pkcs8),
{ name: 'ECDH', namedCurve: 'P-256' },
true,
['deriveBits'],
);
}
/**
* The uncompressed point (0x04 || x || y) a P-256 did:key names.
* @param {string} didKey
* @returns {Uint8Array}
* @throws {SpaceMacError}
*/
export function uncompressedKey(didKey) {
/** @type {{curve: string, publicKey: Uint8Array}} */
let parsed;
try {
parsed = parseDidKey(didKey);
} catch {
throw new SpaceMacError('invalid KEM key');
}
if (parsed.curve !== 'p256') {
throw new SpaceMacError('KEM key must be a P-256 did:key');
}
const key = parsed.publicKey;
if (key.length !== 33 || (key[0] !== 0x02 && key[0] !== 0x03)) {
throw new SpaceMacError('invalid KEM key');
}
const x = bytesToBigInt(key.subarray(1));
if (x >= P256_P) throw new SpaceMacError('invalid KEM key');
// y² = x³ - 3x + b, and p ≡ 3 (mod 4), so y = rhs^((p + 1) / 4).
const rhs = mod(x ** 3n - 3n * x + P256_B);
let y = modPow(rhs, (P256_P + 1n) / 4n);
if (mod(y * y) !== rhs) throw new SpaceMacError('invalid KEM key');
if (Number(y & 1n) !== (key[0] & 1)) y = P256_P - y;
return concat(new Uint8Array([0x04]), key.subarray(1), bigIntToBytes(y));
}
/** @param {bigint} n */
function mod(n) {
const r = n % P256_P;
return r < 0n ? r + P256_P : r;
}
/**
* @param {bigint} base
* @param {bigint} exp
* @returns {bigint}
*/
function modPow(base, exp) {
let result = 1n;
let b = mod(base);
let e = exp;
while (e > 0n) {
if (e & 1n) result = mod(result * b);
b = mod(b * b);
e >>= 1n;
}
return result;
}
/** @param {Uint8Array} bytes */
function bytesToBigInt(bytes) {
let n = 0n;
for (const b of bytes) n = (n << 8n) | BigInt(b);
return n;
}
/** @param {bigint} n */
function bigIntToBytes(n) {
const out = new Uint8Array(32);
let v = n;
for (let i = 31; i >= 0; i--) {
out[i] = Number(v & 0xffn);
v >>= 8n;
}
return out;
}
/** @param {string} b64 */
function base64UrlToBytes(b64) {
const std = b64.replace(/-/g, '+').replace(/_/g, '/');
return Uint8Array.from(
atob(std.padEnd(Math.ceil(std.length / 4) * 4, '=')),
(c) => c.charCodeAt(0),
);
}