Skip to content

Commit ff1c673

Browse files
committed
fix!: close streams gracefully
- Refactors `.close`, `closeRead` and `.closeWrite` methods on the `Stream` interface to be async - The `Connection` interface now has `.close` and `.abort` methods - `.close` on `Stream`s and `Connection`s wait for the internal message queues to empty before closing - `.abort` on `Stream`s and `Connection`s close the underlying stream immediately and discards any unsent data - `@chainsafe/libp2p-yamux` now uses the `AbstractStream` class from `@libp2p/interface` the same as `@libp2p/mplex` and `@libp2p/webrtc` Follow-up PRs will be necessary to `@chainsafe/libp2p-yamux`, `@chainsafe/libp2p-gossipsub` and `@chainsafe/libp2p-noise` though they will not block the release as their code is temporarily added to this repo to let CI run. Fixes #1793 Fixes #656 BREAKING CHANGE: the `.close`, `closeRead` and `closeWrite` methods on the `Stream` interface are now asynchronous
1 parent 88a4cba commit ff1c673

File tree

113 files changed

+4066
-1444
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

113 files changed

+4066
-1444
lines changed

doc/METRICS.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ const node = await createLibp2p({
6565
To define component metrics first get a reference to the metrics object:
6666

6767
```ts
68-
import type { Metrics } from '@libp2p/interface-metrics'
68+
import type { Metrics } from '@libp2p/interface/metrics'
6969

7070
interface MyClassComponents {
7171
metrics: Metrics
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
{
2+
"name": "@chainsafe/libp2p-noise",
3+
"version": "12.0.1",
4+
"author": "ChainSafe <[email protected]>",
5+
"license": "Apache-2.0 OR MIT",
6+
"homepage": "https://github.com/ChainSafe/js-libp2p-noise#readme",
7+
"repository": {
8+
"type": "git",
9+
"url": "git+https://github.com/ChainSafe/js-libp2p-noise.git"
10+
},
11+
"bugs": {
12+
"url": "https://github.com/ChainSafe/js-libp2p-noise/issues"
13+
},
14+
"keywords": [
15+
"crypto",
16+
"libp2p",
17+
"noise"
18+
],
19+
"engines": {
20+
"node": ">=16.0.0",
21+
"npm": ">=7.0.0"
22+
},
23+
"type": "module",
24+
"types": "./dist/src/index.d.ts",
25+
"files": [
26+
"src",
27+
"dist",
28+
"!dist/test",
29+
"!**/*.tsbuildinfo"
30+
],
31+
"exports": {
32+
".": {
33+
"types": "./dist/src/index.d.ts",
34+
"import": "./dist/src/index.js"
35+
}
36+
},
37+
"eslintConfig": {
38+
"extends": "ipfs",
39+
"parserOptions": {
40+
"sourceType": "module"
41+
},
42+
"rules": {
43+
"@typescript-eslint/no-unused-vars": "error",
44+
"@typescript-eslint/explicit-function-return-type": "warn",
45+
"@typescript-eslint/strict-boolean-expressions": "off"
46+
},
47+
"ignorePatterns": [
48+
"src/proto/payload.js",
49+
"src/proto/payload.d.ts",
50+
"test/fixtures/node-globals.js"
51+
]
52+
},
53+
"scripts": {
54+
"bench": "node benchmarks/benchmark.js",
55+
"clean": "aegir clean",
56+
"dep-check": "aegir dep-check",
57+
"build": "aegir build",
58+
"lint": "aegir lint",
59+
"lint:fix": "aegir lint --fix",
60+
"test": "aegir test",
61+
"test:node": "aegir test -t node",
62+
"test:browser": "aegir test -t browser -t webworker",
63+
"test:electron-main": "aegir test -t electron-main",
64+
"docs": "aegir docs",
65+
"proto:gen": "protons ./src/proto/payload.proto",
66+
"prepublish": "npm run build"
67+
},
68+
"dependencies": {
69+
"@libp2p/crypto": "^1.0.11",
70+
"@libp2p/interface": "~0.0.1",
71+
"@libp2p/logger": "^2.0.5",
72+
"@libp2p/peer-id": "^2.0.0",
73+
"@stablelib/chacha20poly1305": "^1.0.1",
74+
"@noble/hashes": "^1.3.0",
75+
"@stablelib/x25519": "^1.0.3",
76+
"it-length-prefixed": "^9.0.1",
77+
"it-length-prefixed-stream": "^1.0.0",
78+
"it-byte-stream": "^1.0.0",
79+
"it-pair": "^2.0.2",
80+
"it-pipe": "^3.0.1",
81+
"it-stream-types": "^2.0.1",
82+
"protons-runtime": "^5.0.0",
83+
"uint8arraylist": "^2.3.2",
84+
"uint8arrays": "^4.0.2"
85+
},
86+
"devDependencies": {
87+
"@libp2p/interface-compliance-tests": "^3.0.0",
88+
"@libp2p/peer-id-factory": "^2.0.0",
89+
"@types/sinon": "^10.0.14",
90+
"aegir": "^39.0.5",
91+
"iso-random-stream": "^2.0.2",
92+
"protons": "^7.0.0",
93+
"sinon": "^15.0.0"
94+
},
95+
"browser": {
96+
"./dist/src/alloc-unsafe.js": "./dist/src/alloc-unsafe-browser.js",
97+
"util": false
98+
}
99+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export type bytes = Uint8Array
2+
export type bytes32 = Uint8Array
3+
export type bytes16 = Uint8Array
4+
5+
export type uint64 = number
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { bytes } from './basic.js'
2+
import type { NoiseSession } from './handshake.js'
3+
import type { NoiseExtensions } from '../proto/payload.js'
4+
import type { PeerId } from '@libp2p/interface/peer-id'
5+
6+
export interface IHandshake {
7+
session: NoiseSession
8+
remotePeer: PeerId
9+
remoteExtensions: NoiseExtensions
10+
encrypt: (plaintext: bytes, session: NoiseSession) => bytes
11+
decrypt: (ciphertext: bytes, session: NoiseSession, dst?: Uint8Array) => { plaintext: bytes, valid: boolean }
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { bytes, bytes32, uint64 } from './basic.js'
2+
import type { KeyPair } from './libp2p.js'
3+
import type { Nonce } from '../nonce.js'
4+
5+
export type Hkdf = [bytes, bytes, bytes]
6+
7+
export interface MessageBuffer {
8+
ne: bytes32
9+
ns: bytes
10+
ciphertext: bytes
11+
}
12+
13+
export interface CipherState {
14+
k: bytes32
15+
// For performance reasons, the nonce is represented as a Nonce object
16+
// The nonce is treated as a uint64, even though the underlying `number` only has 52 safely-available bits.
17+
n: Nonce
18+
}
19+
20+
export interface SymmetricState {
21+
cs: CipherState
22+
ck: bytes32 // chaining key
23+
h: bytes32 // handshake hash
24+
}
25+
26+
export interface HandshakeState {
27+
ss: SymmetricState
28+
s: KeyPair
29+
e?: KeyPair
30+
rs: bytes32
31+
re: bytes32
32+
psk: bytes32
33+
}
34+
35+
export interface NoiseSession {
36+
hs: HandshakeState
37+
h?: bytes32
38+
cs1?: CipherState
39+
cs2?: CipherState
40+
mc: uint64
41+
i: boolean
42+
}
43+
44+
export interface INoisePayload {
45+
identityKey: bytes
46+
identitySig: bytes
47+
data: bytes
48+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { bytes32 } from './basic.js'
2+
import type { NoiseExtensions } from '../proto/payload.js'
3+
import type { ConnectionEncrypter } from '@libp2p/interface/connection-encrypter'
4+
5+
export interface KeyPair {
6+
publicKey: bytes32
7+
privateKey: bytes32
8+
}
9+
10+
export interface INoiseConnection extends ConnectionEncrypter<NoiseExtensions> {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const NOISE_MSG_MAX_LENGTH_BYTES = 65535
2+
export const NOISE_MSG_MAX_LENGTH_BYTES_WITHOUT_TAG = NOISE_MSG_MAX_LENGTH_BYTES - 16
3+
4+
export const DUMP_SESSION_KEYS = Boolean(globalThis.process?.env?.DUMP_SESSION_KEYS)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { bytes32, bytes } from './@types/basic.js'
2+
import type { Hkdf } from './@types/handshake.js'
3+
import type { KeyPair } from './@types/libp2p.js'
4+
5+
export interface ICryptoInterface {
6+
hashSHA256: (data: Uint8Array) => Uint8Array
7+
8+
getHKDF: (ck: bytes32, ikm: Uint8Array) => Hkdf
9+
10+
generateX25519KeyPair: () => KeyPair
11+
generateX25519KeyPairFromSeed: (seed: Uint8Array) => KeyPair
12+
generateX25519SharedKey: (privateKey: Uint8Array, publicKey: Uint8Array) => Uint8Array
13+
14+
chaCha20Poly1305Encrypt: (plaintext: Uint8Array, nonce: Uint8Array, ad: Uint8Array, k: bytes32) => bytes
15+
chaCha20Poly1305Decrypt: (ciphertext: Uint8Array, nonce: Uint8Array, ad: Uint8Array, k: bytes32, dst?: Uint8Array) => bytes | null
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { hkdf } from '@noble/hashes/hkdf'
2+
import { sha256 } from '@noble/hashes/sha256'
3+
import { ChaCha20Poly1305 } from '@stablelib/chacha20poly1305'
4+
import * as x25519 from '@stablelib/x25519'
5+
import type { bytes, bytes32 } from '../@types/basic.js'
6+
import type { Hkdf } from '../@types/handshake.js'
7+
import type { KeyPair } from '../@types/libp2p.js'
8+
import type { ICryptoInterface } from '../crypto.js'
9+
10+
export const pureJsCrypto: ICryptoInterface = {
11+
hashSHA256 (data: Uint8Array): Uint8Array {
12+
return sha256(data)
13+
},
14+
15+
getHKDF (ck: bytes32, ikm: Uint8Array): Hkdf {
16+
const okm = hkdf(sha256, ikm, ck, undefined, 96)
17+
18+
const k1 = okm.subarray(0, 32)
19+
const k2 = okm.subarray(32, 64)
20+
const k3 = okm.subarray(64, 96)
21+
22+
return [k1, k2, k3]
23+
},
24+
25+
generateX25519KeyPair (): KeyPair {
26+
const keypair = x25519.generateKeyPair()
27+
28+
return {
29+
publicKey: keypair.publicKey,
30+
privateKey: keypair.secretKey
31+
}
32+
},
33+
34+
generateX25519KeyPairFromSeed (seed: Uint8Array): KeyPair {
35+
const keypair = x25519.generateKeyPairFromSeed(seed)
36+
37+
return {
38+
publicKey: keypair.publicKey,
39+
privateKey: keypair.secretKey
40+
}
41+
},
42+
43+
generateX25519SharedKey (privateKey: Uint8Array, publicKey: Uint8Array): Uint8Array {
44+
return x25519.sharedKey(privateKey, publicKey)
45+
},
46+
47+
chaCha20Poly1305Encrypt (plaintext: Uint8Array, nonce: Uint8Array, ad: Uint8Array, k: bytes32): bytes {
48+
const ctx = new ChaCha20Poly1305(k)
49+
50+
return ctx.seal(nonce, plaintext, ad)
51+
},
52+
53+
chaCha20Poly1305Decrypt (ciphertext: Uint8Array, nonce: Uint8Array, ad: Uint8Array, k: bytes32, dst?: Uint8Array): bytes | null {
54+
const ctx = new ChaCha20Poly1305(k)
55+
56+
return ctx.open(nonce, ciphertext, ad, dst)
57+
}
58+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { TAG_LENGTH } from '@stablelib/chacha20poly1305'
2+
import { NOISE_MSG_MAX_LENGTH_BYTES, NOISE_MSG_MAX_LENGTH_BYTES_WITHOUT_TAG } from '../constants.js'
3+
import { uint16BEEncode } from '../encoder.js'
4+
import type { IHandshake } from '../@types/handshake-interface.js'
5+
import type { MetricsRegistry } from '../metrics.js'
6+
import type { Transform } from 'it-stream-types'
7+
import type { Uint8ArrayList } from 'uint8arraylist'
8+
9+
// Returns generator that encrypts payload from the user
10+
export function encryptStream (handshake: IHandshake, metrics?: MetricsRegistry): Transform<AsyncIterable<Uint8Array>> {
11+
return async function * (source) {
12+
for await (const chunk of source) {
13+
for (let i = 0; i < chunk.length; i += NOISE_MSG_MAX_LENGTH_BYTES_WITHOUT_TAG) {
14+
let end = i + NOISE_MSG_MAX_LENGTH_BYTES_WITHOUT_TAG
15+
if (end > chunk.length) {
16+
end = chunk.length
17+
}
18+
19+
const data = handshake.encrypt(chunk.subarray(i, end), handshake.session)
20+
metrics?.encryptedPackets.increment()
21+
22+
yield uint16BEEncode(data.byteLength)
23+
yield data
24+
}
25+
}
26+
}
27+
}
28+
29+
// Decrypt received payload to the user
30+
export function decryptStream (handshake: IHandshake, metrics?: MetricsRegistry): Transform<AsyncIterable<Uint8ArrayList>, AsyncIterable<Uint8Array>> {
31+
return async function * (source) {
32+
for await (const chunk of source) {
33+
for (let i = 0; i < chunk.length; i += NOISE_MSG_MAX_LENGTH_BYTES) {
34+
let end = i + NOISE_MSG_MAX_LENGTH_BYTES
35+
if (end > chunk.length) {
36+
end = chunk.length
37+
}
38+
39+
if (end - TAG_LENGTH < i) {
40+
throw new Error('Invalid chunk')
41+
}
42+
const encrypted = chunk.subarray(i, end)
43+
// memory allocation is not cheap so reuse the encrypted Uint8Array
44+
// see https://github.com/ChainSafe/js-libp2p-noise/pull/242#issue-1422126164
45+
// this is ok because chacha20 reads bytes one by one and don't reread after that
46+
// it's also tested in https://github.com/ChainSafe/as-chacha20poly1305/pull/1/files#diff-25252846b58979dcaf4e41d47b3eadd7e4f335e7fb98da6c049b1f9cd011f381R48
47+
const dst = chunk.subarray(i, end - TAG_LENGTH)
48+
const { plaintext: decrypted, valid } = handshake.decrypt(encrypted, handshake.session, dst)
49+
if (!valid) {
50+
metrics?.decryptErrors.increment()
51+
throw new Error('Failed to validate decrypted chunk')
52+
}
53+
metrics?.decryptedPackets.increment()
54+
yield decrypted
55+
}
56+
}
57+
}
58+
}

0 commit comments

Comments
 (0)