diff --git a/package-lock.json b/package-lock.json index b5704ee9b..6e7468b8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,7 @@ "fast-srp-hap": "^2.0.4", "futoin-hkdf": "^1.5.3", "source-map-support": "^0.5.21", - "tslib": "^2.8.1", - "tweetnacl": "^1.0.3" + "tslib": "^2.8.1" }, "devDependencies": { "@eslint/eslintrc": "^3.3.6", @@ -39,6 +38,7 @@ "simple-plist": "^1.4.0", "ts-jest": "^29.4.11", "ts-node": "^10.9.2", + "tweetnacl": "^1.0.3", "typedoc": "^0.28.20", "typescript": "^6.0.3" }, @@ -6306,6 +6306,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "dev": true, "license": "Unlicense" }, "node_modules/type-check": { diff --git a/package.json b/package.json index a126b39a0..06c9db482 100644 --- a/package.json +++ b/package.json @@ -59,8 +59,7 @@ "fast-srp-hap": "^2.0.4", "futoin-hkdf": "^1.5.3", "source-map-support": "^0.5.21", - "tslib": "^2.8.1", - "tweetnacl": "^1.0.3" + "tslib": "^2.8.1" }, "devDependencies": { "@eslint/eslintrc": "^3.3.6", @@ -82,6 +81,7 @@ "simple-plist": "^1.4.0", "ts-jest": "^29.4.11", "ts-node": "^10.9.2", + "tweetnacl": "^1.0.3", "typedoc": "^0.28.20", "typescript": "^6.0.3" }, diff --git a/src/lib/HAPServer.ts b/src/lib/HAPServer.ts index b0e630a65..194185f77 100644 --- a/src/lib/HAPServer.ts +++ b/src/lib/HAPServer.ts @@ -3,7 +3,6 @@ import createDebug from "debug"; import { EventEmitter } from "events"; import { SRP, SrpServer } from "fast-srp-hap"; import { IncomingMessage, ServerResponse } from "http"; -import tweetnacl from "tweetnacl"; import { URL } from "url"; import { consideredTrue, HAPMimeTypes, PairingStates, PairMethods, TLVValues } from "../internal-types"; import { @@ -634,7 +633,7 @@ export class HAPServer extends EventEmitter { const controllerInfo = Buffer.from("Pair-Setup-Controller-Sign-Info"); const outputKey = hapCrypto.HKDF("sha512", controllerSalt, S_private, controllerInfo, 32); const completeData = Buffer.concat([outputKey, clientUsername, clientLTPK]); - if (!tweetnacl.sign.detached.verify(completeData, clientProof, clientLTPK)) { + if (!hapCrypto.ed25519Verify(completeData, clientProof, clientLTPK)) { debug("[%s] Invalid signature", this.accessoryInfo.username); response.writeHead(HAPPairingHTTPCode.OK, { "Content-Type": "application/pairing+tlv8" }); response.end(tlv.encode(TLVValues.SEQUENCE_NUM, PairingStates.M6, TLVValues.ERROR_CODE, TLVErrorCode.AUTHENTICATION)); @@ -662,7 +661,7 @@ export class HAPServer extends EventEmitter { const usernameData = Buffer.from(this.accessoryInfo.username); const material = Buffer.concat([outputKey, usernameData, serverLTPK]); const privateKey = Buffer.from(this.accessoryInfo.signSk); - const serverProof = tweetnacl.sign.detached(material, privateKey); + const serverProof = hapCrypto.ed25519Sign(material, privateKey); const message = tlv.encode(TLVValues.USERNAME, usernameData, TLVValues.PUBLIC_KEY, serverLTPK, TLVValues.PROOF, serverProof); const encrypted = hapCrypto.chacha20_poly1305_encryptAndSeal(hkdfEncKey, Buffer.from("PS-Msg06"), null, message); @@ -723,7 +722,7 @@ export class HAPServer extends EventEmitter { const usernameData = Buffer.from(this.accessoryInfo.username); const material = Buffer.concat([publicKey, usernameData, clientPublicKey]); const privateKey = Buffer.from(this.accessoryInfo.signSk); - const serverProof = tweetnacl.sign.detached(material, privateKey); + const serverProof = hapCrypto.ed25519Sign(material, privateKey); const encSalt = Buffer.from("Pair-Verify-Encrypt-Salt"); const encInfo = Buffer.from("Pair-Verify-Encrypt-Info"); const outputKey = hapCrypto.HKDF("sha512", encSalt, sharedSec, encInfo, 32).subarray(0, 32); @@ -804,7 +803,7 @@ export class HAPServer extends EventEmitter { connection._pairVerifyState = undefined; return; } - if (!tweetnacl.sign.detached.verify(material, proof, clientPublicKey)) { + if (!hapCrypto.ed25519Verify(material, proof, clientPublicKey)) { debug("[%s] Client %s provided an invalid signature", this.accessoryInfo.username, clientUsername); response.writeHead(HAPPairingHTTPCode.OK, { "Content-Type": "application/pairing+tlv8" }); response.end(tlv.encode(TLVValues.STATE, PairingStates.M4, TLVValues.ERROR_CODE, TLVErrorCode.AUTHENTICATION)); diff --git a/src/lib/model/AccessoryInfo.ts b/src/lib/model/AccessoryInfo.ts index 51bca3b7a..ef3f81289 100644 --- a/src/lib/model/AccessoryInfo.ts +++ b/src/lib/model/AccessoryInfo.ts @@ -1,11 +1,11 @@ import assert from "assert"; import crypto from "crypto"; import createDebug from "debug"; -import tweetnacl from "tweetnacl"; import util from "util"; import { AccessoryJsonObject, CharacteristicJsonObject, MacAddress, ServiceJsonObject } from "../../types"; import { Categories } from "../Accessory"; import { EventedHTTPServer, HAPConnection, HAPUsername } from "../util/eventedhttp"; +import { generateEd25519KeyPair } from "../util/hapCrypto"; import { HAPStorage } from "./HAPStorage"; import { readFileSync } from "node:fs"; @@ -301,7 +301,7 @@ export class AccessoryInfo { accessoryInfo.lastFirmwareVersion = getVersion(); // Create a new unique key pair for this accessory. - const keyPair = tweetnacl.sign.keyPair(); + const keyPair = generateEd25519KeyPair(); accessoryInfo.signSk = Buffer.from(keyPair.secretKey); accessoryInfo.signPk = Buffer.from(keyPair.publicKey); diff --git a/src/lib/util/hapCrypto.spec.ts b/src/lib/util/hapCrypto.spec.ts index d4bfeb9d6..73065c1c0 100644 --- a/src/lib/util/hapCrypto.spec.ts +++ b/src/lib/util/hapCrypto.spec.ts @@ -1,6 +1,7 @@ import { HAPEncryption } from "./eventedhttp"; import * as hapCrypto from "./hapCrypto"; import crypto from "crypto"; +import tweetnacl from "tweetnacl"; function fromHex(hex: string): Buffer { return Buffer.from(hex.trim().replace(/ /g, ""), "hex"); @@ -163,4 +164,126 @@ describe("hapCrypto", () => { expect(S_decrypted1).toEqual(Buffer.concat([message1, message2])); }); }); + + describe("curve25519", () => { + // RFC 7748, section 6.1 + const alicePrivate = fromHex("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"); + const alicePublic = fromHex("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a"); + const bobPrivate = fromHex("5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb"); + const bobPublic = fromHex("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f"); + const shared = fromHex("4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742"); + + it("should compute the shared secret of the RFC 7748 test vector", () => { + expect(Buffer.from(hapCrypto.generateCurve25519SharedSecKey(alicePrivate, bobPublic))).toEqual(shared); + expect(Buffer.from(hapCrypto.generateCurve25519SharedSecKey(bobPrivate, alicePublic))).toEqual(shared); + }); + + it("should generate key pairs whose public key belongs to the secret key", () => { + for (let i = 0; i < 10; i++) { + const keyPair = hapCrypto.generateCurve25519KeyPair(); + expect(keyPair.secretKey.length).toBe(32); + expect(Buffer.from(keyPair.publicKey)).toEqual(Buffer.from(tweetnacl.scalarMult.base(keyPair.secretKey))); + } + }); + + it("should compute the same shared secret as tweetnacl", () => { + for (let i = 0; i < 10; i++) { + const ours = hapCrypto.generateCurve25519KeyPair(); + const theirs = tweetnacl.box.keyPair(); + expect(Buffer.from(hapCrypto.generateCurve25519SharedSecKey(ours.secretKey, theirs.publicKey))) + .toEqual(Buffer.from(tweetnacl.scalarMult(theirs.secretKey, ours.publicKey))); + } + }); + + it("should return zeros for a public key of small order, like tweetnacl", () => { + const secretKey = hapCrypto.generateCurve25519KeyPair().secretKey; + // points of small order (0, 1, a point of order 8, p - 1) + const smallOrder = [ + "0000000000000000000000000000000000000000000000000000000000000000", + "0100000000000000000000000000000000000000000000000000000000000000", + "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800", + "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + ].map(fromHex); + for (const publicKey of smallOrder) { + expect(Buffer.from(hapCrypto.generateCurve25519SharedSecKey(secretKey, publicKey))).toEqual(Buffer.alloc(32)); + expect(Buffer.from(tweetnacl.scalarMult(secretKey, publicKey))).toEqual(Buffer.alloc(32)); + } + }); + + it("should reject keys of the wrong size", () => { + expect(() => hapCrypto.generateCurve25519SharedSecKey(Buffer.alloc(31), alicePublic)).toThrow(); + expect(() => hapCrypto.generateCurve25519SharedSecKey(alicePrivate, Buffer.alloc(33))).toThrow(); + }); + }); + + describe("ed25519", () => { + // RFC 8032, section 7.1, TEST 1 and TEST 2 + const vectors = [ + { + seed: fromHex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"), + publicKey: fromHex("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"), + message: Buffer.alloc(0), + signature: fromHex( + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b", + ), + }, + { + seed: fromHex("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb"), + publicKey: fromHex("3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"), + message: fromHex("72"), + signature: fromHex( + "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00", + ), + }, + ]; + + it("should sign and verify the RFC 8032 test vectors", () => { + for (const vector of vectors) { + const secretKey = Buffer.concat([vector.seed, vector.publicKey]); + expect(hapCrypto.ed25519Sign(vector.message, secretKey)).toEqual(vector.signature); + expect(hapCrypto.ed25519Verify(vector.message, vector.signature, vector.publicKey)).toBe(true); + } + }); + + it("should generate key pairs in the layout of tweetnacl", () => { + const keyPair = hapCrypto.generateEd25519KeyPair(); + expect(keyPair.secretKey.length).toBe(64); + const fromSeed = tweetnacl.sign.keyPair.fromSeed(keyPair.secretKey.subarray(0, 32)); + expect(Buffer.from(keyPair.publicKey)).toEqual(Buffer.from(fromSeed.publicKey)); + expect(Buffer.from(keyPair.secretKey)).toEqual(Buffer.from(fromSeed.secretKey)); + }); + + it("should sign like tweetnacl and verify the signatures of tweetnacl", () => { + for (let i = 0; i < 10; i++) { + const keyPair = tweetnacl.sign.keyPair(); + const message = crypto.randomBytes(i * 17); + const signature = tweetnacl.sign.detached(message, keyPair.secretKey); + expect(hapCrypto.ed25519Sign(message, keyPair.secretKey)).toEqual(Buffer.from(signature)); + expect(hapCrypto.ed25519Verify(message, signature, keyPair.publicKey)).toBe(true); + } + }); + + it("should not verify a changed message, another key or a public key that is no point on the curve", () => { + const keyPair = hapCrypto.generateEd25519KeyPair(); + const message = crypto.randomBytes(64); + const signature = hapCrypto.ed25519Sign(message, keyPair.secretKey); + const changed = Buffer.from(message); + changed[0] ^= 1; + expect(hapCrypto.ed25519Verify(changed, signature, keyPair.publicKey)).toBe(false); + expect(hapCrypto.ed25519Verify(message, signature, hapCrypto.generateEd25519KeyPair().publicKey)).toBe(false); + for (let i = 0; i < 10; i++) { + const publicKey = crypto.randomBytes(32); + expect(hapCrypto.ed25519Verify(message, signature, publicKey)).toBe(tweetnacl.sign.detached.verify(message, signature, publicKey)); + } + }); + + it("should not verify a signature or public key of the wrong size", () => { + const keyPair = hapCrypto.generateEd25519KeyPair(); + const message = crypto.randomBytes(16); + const signature = hapCrypto.ed25519Sign(message, keyPair.secretKey); + expect(hapCrypto.ed25519Verify(message, signature.subarray(0, 63), keyPair.publicKey)).toBe(false); + expect(hapCrypto.ed25519Verify(message, signature, keyPair.publicKey.subarray(0, 31))).toBe(false); + expect(() => hapCrypto.ed25519Sign(message, keyPair.secretKey.subarray(0, 32))).toThrow(); + }); + }); }); diff --git a/src/lib/util/hapCrypto.ts b/src/lib/util/hapCrypto.ts index 9cf56217f..67928587b 100644 --- a/src/lib/util/hapCrypto.ts +++ b/src/lib/util/hapCrypto.ts @@ -1,7 +1,6 @@ import assert from "assert"; import crypto from "crypto"; import hkdf from "futoin-hkdf"; -import tweetnacl, { BoxKeyPair } from "tweetnacl"; import { HAPEncryption } from "./eventedhttp"; if (!crypto.getCiphers().includes("chacha20-poly1305")) { @@ -9,18 +8,105 @@ if (!crypto.getCiphers().includes("chacha20-poly1305")) { "At least a nodejs version of v10.17.0 (excluding v11.0 and v11.1) is required!"); } +const X25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b656e04220420", "hex"); +const X25519_SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex"); +const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +function privateKeyObject(prefix: Buffer, key: Uint8Array): crypto.KeyObject { + return crypto.createPrivateKey({ key: Buffer.concat([prefix, key]), format: "der", type: "pkcs8" }); +} + +function publicKeyObject(prefix: Buffer, key: Uint8Array): crypto.KeyObject { + return crypto.createPublicKey({ key: Buffer.concat([prefix, key]), format: "der", type: "spki" }); +} + +function rawKey(key: crypto.KeyObject): Buffer { + return key.export({ format: "der", type: key.type === "private" ? "pkcs8" : "spki" }).subarray(-32); +} + +/** + * @group Cryptography + */ +export interface Curve25519KeyPair { + publicKey: Uint8Array; + secretKey: Uint8Array; +} + /** + * An Ed25519 key pair. The secret key has 64 bytes: the 32-byte seed followed by the public key + * (the layout of tweetnacl, in which the long-term keys of accessories are stored). + * * @group Cryptography */ -export function generateCurve25519KeyPair(): BoxKeyPair { - return tweetnacl.box.keyPair(); +export interface Ed25519KeyPair { + publicKey: Uint8Array; + secretKey: Uint8Array; +} + +/** + * @group Cryptography + */ +export function generateCurve25519KeyPair(): Curve25519KeyPair { + const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519"); + return { publicKey: rawKey(publicKey), secretKey: rawKey(privateKey) }; } /** * @group Cryptography */ export function generateCurve25519SharedSecKey(priKey: Uint8Array, pubKey: Uint8Array): Uint8Array { - return tweetnacl.scalarMult(priKey, pubKey); + assert(priKey.length === 32, "bad private key size"); + assert(pubKey.length === 32, "bad public key size"); + try { + return crypto.diffieHellman({ + privateKey: privateKeyObject(X25519_PKCS8_PREFIX, priKey), + publicKey: publicKeyObject(X25519_SPKI_PREFIX, pubKey), + }); + } catch (error) { + // OpenSSL refuses a public key of small order, whose shared secret is all zeros; tweetnacl returned the zeros. + // A paired peer can still complete pair verify by signing with its long-term key, but the resulting + // session keys are derived from a publicly known zero secret; authentication does not preserve secrecy here. + if ((error as NodeJS.ErrnoException).code === "ERR_OSSL_FAILED_DURING_DERIVATION") { + return Buffer.alloc(32); + } + throw error; + } +} + +/** + * @group Cryptography + */ +export function generateEd25519KeyPair(): Ed25519KeyPair { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const rawPublicKey = rawKey(publicKey); + return { publicKey: rawPublicKey, secretKey: Buffer.concat([rawKey(privateKey), rawPublicKey]) }; +} + +/** + * Signs with the 64-byte secret key of an {@link Ed25519KeyPair}. + * + * @group Cryptography + */ +export function ed25519Sign(message: Uint8Array, secretKey: Uint8Array): Buffer { + assert(secretKey.length === 64, "bad secret key size"); + return crypto.sign(null, message, privateKeyObject(ED25519_PKCS8_PREFIX, secretKey.subarray(0, 32))); +} + +/** + * False also for a signature or public key of the wrong size, and for a public key that is no point on the curve. + * + * @group Cryptography + */ +export function ed25519Verify(message: Uint8Array, signature: Uint8Array, publicKey: Uint8Array): boolean { + if (signature.length !== 64 || publicKey.length !== 32) { + return false; + } + try { + return crypto.verify(null, message, publicKeyObject(ED25519_SPKI_PREFIX, publicKey), signature); + } catch (error) { + return false; + } } /**