mirror of https://github.com/vapor/docs.git
crypto updates
This commit is contained in:
parent
7bc65a47c7
commit
c463dfa0f7
|
|
@ -1,17 +0,0 @@
|
|||
# Getting Started with Async
|
||||
|
||||
Vapor is powered by Apple's [SwiftNIO](https://github.com/apple/swift-nio), a powerful non-blocking networking framework. If you are using the fully-powered Vapor framework to create a website, then you will likely not need to use this directly. However, if you are using a lower-level library (like a Database framework) then you will need to understand a little about how it works.
|
||||
|
||||
## Workers
|
||||
|
||||
Vapor's async abstraction is built on a few pieces, including the async [`Worker`](https://github.com/vapor-community/async/blob/1.0.0-rc.1.1/Sources/Async/EventLoop/Worker.swift). This protocol has an `eventLoop`, which fits nicely with SwiftNIO's constructs.
|
||||
|
||||
### Example
|
||||
|
||||
You need to create a SwiftNIO [`EventGroup`](https://github.com/apple/swift-nio/blob/master/README.md#eventloops-and-eventloopgroups) that can power the connection:
|
||||
|
||||
```
|
||||
let database = MySQLDatabase(config: config)
|
||||
let worker = MultiThreadedEventLoopGroup(numThreads: System.coreCount)
|
||||
let futureConnection = database.makeConnection(on: worker)
|
||||
```
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
# Asymmetric Cryptography
|
||||
|
||||
Asymmetric cryptography (also called public-key cryptography) is a cryptographic system that uses multiple keys—usually a "public" and "private" key.
|
||||
|
||||
Read more about [public-key cryptography](https://en.wikipedia.org/wiki/Public-key_cryptography) on Wikipedia.
|
||||
|
||||
## RSA
|
||||
|
||||
A popular asymmetric cryptography algorithm is RSA. RSA has two key types: public and private.
|
||||
|
||||
RSA can create signatures from any data using a private key.
|
||||
|
||||
```swift
|
||||
let privateKey: String = ...
|
||||
let signature = try RSA.SHA512.sign("vapor", key: .private(pem: privateKey))
|
||||
```
|
||||
|
||||
!!! info
|
||||
Only private keys can _create_ signatures.
|
||||
|
||||
These signatures can be verified against the same data later using either the public or private key.
|
||||
|
||||
```swift
|
||||
let publicKey: String = ...
|
||||
try RSA.SHA512.verify(signature, signs: "vapor", key: .public(pem: publicKey)) // true
|
||||
```
|
||||
|
||||
If RSA verifies that a signature matches input data for a public key, you can be sure that whoever generated that signature had access to that key's private key.
|
||||
|
||||
### Algorithms
|
||||
|
||||
RSA supports any of the Crypto module's [`DigestAlgorithm`](https://api.vapor.codes/crypto/latest/Crypto/Classes/DigestAlgorithm.html).
|
||||
|
||||
```swift
|
||||
let privateKey: String = ...
|
||||
let signature512 = try RSA.SHA512.sign("vapor", key: .private(pem: privateKey))
|
||||
let signature256 = try RSA.SHA256.sign("vapor", key: .private(pem: privateKey))
|
||||
```
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
# Base64
|
||||
|
||||
Coming soon
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
# Cipher Algorithms
|
||||
|
||||
Ciphers allow you to encrypt plaintext data with a key yielding ciphertext. This ciphertext can be later decrypted by the same cipher using the same key.
|
||||
|
||||
Read more about [ciphers](https://en.wikipedia.org/wiki/Cipher) on Wikipedia.
|
||||
|
||||
## Encrypt
|
||||
|
||||
Use the global convenience variables for encrypting data with common algorithms.
|
||||
|
||||
```swift
|
||||
let ciphertext = try AES128.encrypt("vapor", key: "secret")
|
||||
print(ciphertext) /// Data
|
||||
```
|
||||
|
||||
## Decrypt
|
||||
|
||||
Decryption works very similarly to [encryption](#encrypt). The following snippet shows how to decrypt the ciphertext from our previous example.
|
||||
|
||||
```swift
|
||||
let plaintext = try AES128.decrypt(ciphertext, key: "secret")
|
||||
print(plaintext) /// "vapor"
|
||||
```
|
||||
|
||||
See the Crypto module's [global variables](https://api.vapor.codes/crypto/latest/Crypto/Global%20Variables.html#/Ciphers) for a list of all available cipher algorithms.
|
||||
|
||||
## Streaming
|
||||
|
||||
Both encryption and decryption can work in a streaming mode that allows data to be chunked. This is useful for controlling memory usage while encrypting large amounts of data.
|
||||
|
||||
```swift
|
||||
let key: Data // 16-bytes
|
||||
let aes128 = Cipher(algorithm: .aes128ecb)
|
||||
try aes128.reset(key: key, mode: .encrypt)
|
||||
var buffer = Data()
|
||||
try aes128.update(data: "hello", into: &buffer)
|
||||
try aes128.update(data: "world", into: &buffer)
|
||||
try aes128.finish(into: &buffer)
|
||||
print(buffer) // Completed ciphertext
|
||||
```
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
# Message Digests
|
||||
|
||||
Cryptographic hash functions (also known as message digest algorithms) convert data of arbitrary size to a fixed-size digest. These are most often used for generating checksums or identifiers for large data blobs.
|
||||
|
||||
Read more about [Cryptographic hash functions](https://en.wikipedia.org/wiki/Cryptographic_hash_function) on Wikipedia.
|
||||
|
||||
## Hash
|
||||
|
||||
Use the global convenience variables to create hashes using common algorithms.
|
||||
|
||||
```swift
|
||||
import Crypto
|
||||
|
||||
let digest = try SHA1.hash("hello")
|
||||
print(digest.hexEncodedString()) // aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d
|
||||
```
|
||||
|
||||
See the Crypto module's [global variables](https://api.vapor.codes/crypto/latest/Crypto/Global%20Variables.html#/Digests) for a list of all available hash algorithms.
|
||||
|
||||
### Streaming
|
||||
|
||||
You can create a [`Digest`](https://api.vapor.codes/crypto/latest/Crypto/Classes/Digest.html) manually and use its instance methods to create a hash for one or more data chunks.
|
||||
|
||||
```swift
|
||||
var sha256 = try Digest(algorithm: .sha256)
|
||||
try sha256.reset()
|
||||
try sha256.update(data: "hello")
|
||||
try sha256.update(data: "world")
|
||||
let digest = try sha256.finish()
|
||||
print(digest) /// Data
|
||||
```
|
||||
|
||||
## BCrypt
|
||||
|
||||
BCrypt is a popular hashing algorithm that has configurable complexity and handles salting automatically.
|
||||
|
||||
### Hash
|
||||
|
||||
Use the `hash(_:cost:salt:)` method to create BCrypt hashes.
|
||||
|
||||
```swift
|
||||
let digest = try BCrypt.hash("vapor", cost: 4)
|
||||
print(digest) /// data
|
||||
```
|
||||
|
||||
Increasing the `cost` value will make hashing and verification take longer.
|
||||
|
||||
### Verify
|
||||
|
||||
Use the `verify(_:created:)` method to verify that a BCrypt hash was created by a given plaintext input.
|
||||
|
||||
```swift
|
||||
let hash = try BCrypt.hash("vapor", cost: 4)
|
||||
try BCrypt.verify("vapor", created: hash) // true
|
||||
try BCrypt.verify("foo", created: hash) // false
|
||||
```
|
||||
|
||||
## HMAC
|
||||
|
||||
HMAC is an algorithm for creating _keyed_ hashes. HMAC will generate different hashes for the same input if different keys are used.
|
||||
|
||||
```swift
|
||||
let digest = try HMAC.SHA1.authenticate("vapor", key: "secret")
|
||||
print(digest.hexEncodedString()) // digest
|
||||
```
|
||||
|
||||
See the [`HMAC`](https://api.vapor.codes/crypto/latest/Crypto/Classes/HMAC.html) class for a list of all available hash algorithms.
|
||||
|
||||
### Streaming
|
||||
|
||||
HMAC hashes can also be streamed. The API is identical to [hash streaming](#streaming).
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
# Using Crypto
|
||||
|
||||
Crypto is a library containing all common APIs related to cryptography and security.
|
||||
Crypto is a library containing common APIs related to cryptography and data generation. The [vapor/crypto](https://github.com/vapor/crypto) package contains two modules:
|
||||
|
||||
- `Crypto`
|
||||
- `Random`
|
||||
|
||||
## With Vapor
|
||||
|
||||
|
|
@ -8,6 +11,7 @@ This package is included with Vapor by default, just add:
|
|||
|
||||
```swift
|
||||
import Crypto
|
||||
import Random
|
||||
```
|
||||
|
||||
## Without Vapor
|
||||
|
|
@ -25,9 +29,9 @@ let package = Package(
|
|||
.package(url: "https://github.com/vapor/crypto.git", .upToNextMajor(from: "x.0.0")),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "Project", dependencies: ["Crypto", ... ])
|
||||
.target(name: "Project", dependencies: ["Crypto", "Random", ... ])
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
Use `import Crypto` to access Crypto's APIs.
|
||||
Use `import Crypto` to access Crypto's APIs and `import Random` to access Random's APIs.
|
||||
|
|
|
|||
|
|
@ -1,102 +0,0 @@
|
|||
# Hash
|
||||
|
||||
Hashes are a one-directional encryption that is commonly used for validating files or one-way securing data such as passwords.
|
||||
|
||||
### Available hashes
|
||||
|
||||
Crypto currently supports a few hashes.
|
||||
|
||||
- MD5
|
||||
- SHA1
|
||||
- SHA2 (all variants)
|
||||
|
||||
MD5 and SHA1 are generally used for file validation or legacy (weak) passwords. They're performant and lightweight.
|
||||
|
||||
Every Hash type has a set of helpers that you can use.
|
||||
|
||||
## Hashing blobs of data
|
||||
|
||||
Every `Hash` has a static method called `hash` that can be used for hashing the entire contents of `Foundation.Data`, `ByteBuffer` or `String`.
|
||||
|
||||
The result is `Data` containing the resulting hash. The hash's length is according to spec and defined in the static variable `digestSize`.
|
||||
|
||||
```swift
|
||||
// MD5 with `Data`
|
||||
let fileData = Data()
|
||||
let fileMD5 = MD5.hash(fileData)
|
||||
|
||||
// SHA1 with `ByteBuffer`
|
||||
let fileBuffer: ByteBuffer = ...
|
||||
let fileSHA1 = SHA1.hash(fileBuffer)
|
||||
|
||||
// SHA2 variants with String
|
||||
let staticUnsafeToken: String = "rsadd14ndmasidfm12i4j"
|
||||
|
||||
let tokenHashSHA224 = SHA224.hash(staticUnsafeToken)
|
||||
let tokenHashSHA256 = SHA256.hash(staticUnsafeToken)
|
||||
let tokenHashSHA384 = SHA384.hash(staticUnsafeToken)
|
||||
let tokenHashSHA512 = SHA512.hash(staticUnsafeToken)
|
||||
```
|
||||
|
||||
## Incremental hashes (manual)
|
||||
|
||||
To incrementally process hashes you can create an instance of the Hash. This will set up a context.
|
||||
|
||||
All hash context initializers are empty:
|
||||
|
||||
```swift
|
||||
// Create an MD5 context
|
||||
let md5Context = MD5()
|
||||
```
|
||||
|
||||
To process a single chunk of data, you can call the `update` function on a context using any `Sequence` of `UInt8`. That means `Array`, `Data` and `ByteBuffer` work alongside any other sequence of bytes.
|
||||
|
||||
```swift
|
||||
md5Context.update(data)
|
||||
```
|
||||
|
||||
The data data need not be a specific length. Any length works.
|
||||
|
||||
When you need the result, you can call `md5Context.finalize()`. This will finish calculating the hash by appending the standard `1` bit, padding and message bitlength.
|
||||
|
||||
You can optionally provide a last set of data to `finalize()`.
|
||||
|
||||
After calling `finalize()`, do not update the hash if you want correct results.
|
||||
|
||||
### Fetching the results
|
||||
|
||||
The context can then be accessed to extract the resulting Hash.
|
||||
|
||||
```swift
|
||||
let hash: Data = md5Context.hash
|
||||
```
|
||||
|
||||
## Streaming hashes (Async)
|
||||
|
||||
Sometimes you need to hash the contents of a Stream, for example, when processing a file transfer. In this case you can use `ByteStreamHasher`.
|
||||
|
||||
First, create a new generic `ByteStreamHasher<Hash>` where `Hash` is the hash you want to use. In this case, SHA512.
|
||||
|
||||
```swift
|
||||
let streamHasher = ByteStreamHasher<SHA512>()
|
||||
```
|
||||
|
||||
This stream works like any `inputStream` by consuming the incoming data and passing the buffers to the hash context.
|
||||
|
||||
For example, draining a TCP socket.
|
||||
|
||||
```swift
|
||||
let socket: TCP.Socket = ...
|
||||
|
||||
socket.drain(into: streamHasher)
|
||||
```
|
||||
|
||||
This will incrementally update the hash using the provided TCP socket's data.
|
||||
|
||||
When the hash has been completely accumulated, you can `complete` the hash.
|
||||
|
||||
```swift
|
||||
let hash = streamHasher.complete() // Foundation `Data`
|
||||
```
|
||||
|
||||
This will reset the hash's context to the default configuration, ready to start over.
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
# Message authentication
|
||||
|
||||
Message authentication is used for verifying message authenticity and validity.
|
||||
|
||||
Common use cases are JSON Web Tokens.
|
||||
|
||||
For message authentication, Vapor only supports HMAC.
|
||||
|
||||
## Using HMAC
|
||||
|
||||
To use HMAC you first need to select the used hashing algorithm for authentication. This works using generics.
|
||||
|
||||
```swift
|
||||
let hash = HMAC<SHA224>.authenticate(message, withKey: authenticationKey)
|
||||
```
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
# Password hashing
|
||||
|
||||
Password management is critical for good user security and doesn't need to cost a lot of effort. No software is perfect. Even if your software is perfect, other software on the same server likely isn't. Good password encryption security prevents users' passwords from leaking out in case of a hypothetical future data breach.
|
||||
|
||||
For password hashing Vapor supports PBKDF2 and BCrypt.
|
||||
|
||||
We recommend using BCrypt over PBKDF2 for almost all scenarios. Whilst PBKDF2 is a proven standard, it's much more easily brute-forced than BCrypt and is less future-proof.
|
||||
|
||||
## BCrypt
|
||||
|
||||
BCrypt is an algorithm specifically designed for password hashing. It's easy to store and verify.
|
||||
|
||||
### Deriving a key
|
||||
|
||||
Unlike PBKDF2 you don't need to generate and store a salt, that's part of the BCrypt hashing and verification process.
|
||||
|
||||
The output is a combination of the BCrypt "cost" factor, salt and resulting hash. Meaning that the derived output contains all information necessary for verification, simplifying the database access.
|
||||
|
||||
```swift
|
||||
let result: Data = try BCrypt.make(message: "MyPassword")
|
||||
|
||||
guard try BCrypt.verify(message: "MyPassword", matches: result) else {
|
||||
fatalError("This never triggers, since the verification process will always be successful for the same password and conditions")
|
||||
}
|
||||
```
|
||||
|
||||
The default cost factor is `12`, based on the official recommendations.
|
||||
|
||||
### Storing the derived key as a String
|
||||
|
||||
BCrypt always outputs valid ASCII/UTF-8 for the resulting hash.
|
||||
|
||||
This means you can convert the output `Data` to a `String` as such:
|
||||
|
||||
```swift
|
||||
guard let string = String(bytes: result, encoding: .utf8) else {
|
||||
// This must never trigger
|
||||
}
|
||||
```
|
||||
|
||||
## PBKDF2
|
||||
|
||||
PBKDF2 is an algorithm that is almost always (and in Vapor, exclusively) used with HMAC for message authentication.
|
||||
|
||||
PBKDF2 can be paired up with any hashing algorithm and is simple to implement. PBKDF2 is used all over the world through the WPA2 standard, securing WiFi connections. But we still recommend PBKDF2 above any normal hashing function.
|
||||
|
||||
For PBKDF2 you also select the Hash using generics.
|
||||
|
||||
### Deriving a key
|
||||
|
||||
In the following example:
|
||||
|
||||
- `password` is either a `String` or `Data`
|
||||
- The `salt` is `Data`
|
||||
- Iterations is defaulted to `10_000` iterations
|
||||
- The keySize is equivalent to 1 hash's length.
|
||||
|
||||
```swift
|
||||
// Generate a random salt
|
||||
let salt: Data = OSRandom().data(count: 32)
|
||||
|
||||
let hash = try PBKDF2<SHA256>.derive(fromPassword: password, salt: salt)
|
||||
```
|
||||
|
||||
You can optionally configure PBKDF2 to use a different iteration count and output keysize.
|
||||
|
||||
```swift
|
||||
// Iterates 20'000 times and outputs 100 bytes
|
||||
let hash = try PBKDF2<SHA256>.derive(fromPassword: password, salt: salt, iterating: 20_000, derivedKeyLength: 100)
|
||||
```
|
||||
|
||||
### Storing the results
|
||||
|
||||
When you're storing the PBKDF2 results, be sure to also store the Salt. Without the original salt, iteration count and other parameters you cannot reproduce the same hash for validation or authentication.
|
||||
|
|
@ -1,49 +1,32 @@
|
|||
# Random
|
||||
|
||||
Crypto has two primary random number generators.
|
||||
The `Random` module deals with random data generation including random number generation.
|
||||
|
||||
OSRandom generates random numbers by calling the operating system's random number generator.
|
||||
## Data Generator
|
||||
|
||||
URandom generates random numbers by reading from `/dev/urandom`.
|
||||
The [`DataGenerator`]() class powers all of the random data generators.
|
||||
|
||||
## Accessing random numbers
|
||||
### Implementations
|
||||
|
||||
- [`OSRandom`](https://api.vapor.codes/crypto/latest/Random/Classes/OSRandom.html): Provides a random data generator using a platform-specific method.
|
||||
|
||||
|
||||
- [`URandom`](https://api.vapor.codes/crypto/latest/Random/Classes/URandom.html) provides random data generation based on the `/dev/urandom` file.
|
||||
|
||||
|
||||
- [`CryptoRandom`](https://api.vapor.codes/crypto/latest/Crypto/Classes/CryptoRandom.html) from the `Crypto` module provides cryptographically-secure random data using OpenSSL.
|
||||
|
||||
First, create an instance of the preferred random number generator:
|
||||
|
||||
```swift
|
||||
let random = OSRandom()
|
||||
let random: DataGenerator ...
|
||||
let data = try random.generateData(bytes: 8)
|
||||
```
|
||||
|
||||
or
|
||||
### Generate
|
||||
|
||||
`DataGenerator`s are capable of generating random primitive types using the `generate(_:)` method.
|
||||
|
||||
```swift
|
||||
let random = try URandom()
|
||||
```
|
||||
|
||||
### Reading integers
|
||||
|
||||
For every Swift integer a random number function exists.
|
||||
|
||||
```swift
|
||||
let int8: Int8 = try random.makeInt8()
|
||||
let uint8: UInt8 = try random.makeUInt8()
|
||||
let int16: Int16 = try random.makeInt16()
|
||||
let uint16: UInt16 = try random.makeUInt16()
|
||||
let int32: Int32 = try random.makeInt32()
|
||||
let uint32: UInt32 = try random.makeUInt32()
|
||||
let int64: Int64 = try random.makeInt64()
|
||||
let uint64: UInt64 = try random.makeUInt64()
|
||||
let int: Int = try random.makeInt()
|
||||
let uint: UInt = try random.makeUInt()
|
||||
```
|
||||
|
||||
### Reading random data
|
||||
|
||||
Random buffers of data are useful when, for example, generating tokens or other unique strings/blobs.
|
||||
|
||||
To generate a buffer of random data:
|
||||
|
||||
```swift
|
||||
// generates 20 random bytes
|
||||
let data: Data = random.data(count: 20)
|
||||
let int = try OSRandom().generate(Int.self)
|
||||
print(int) // Int
|
||||
```
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ router.post("login") { req -> Future<HTTPStatus> in
|
|||
}
|
||||
```
|
||||
|
||||
We use `.map(to:)` here since `req.content.decode(_:)` returns a [future](futures.md).
|
||||
We use `.map(to:)` here since `req.content.decode(_:)` returns a [future](async.md).
|
||||
|
||||
### Other Request Types
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ final class HelloController {
|
|||
Controller methods should always accept a `Request` and return something `ResponseEncodable`.
|
||||
|
||||
!!! note
|
||||
[Futures](futures.md) whose expectations are `ResponseEncodable` (i.e, `Future<String>`) are also `ResponseEncodable`.
|
||||
[Futures](async.md) whose expectations are `ResponseEncodable` (i.e, `Future<String>`) are also `ResponseEncodable`.
|
||||
|
||||
To use this controller, we can simply initialize it, then pass the method to a router.
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ Most of your interaction with services will happen through a container. A contai
|
|||
- [Services](#services): A collection of registered services.
|
||||
- [Config](#config): Declared preferences for certain services over others.
|
||||
- [Environment](#environment): The application's current environment type (testing, production, etc)
|
||||
- [Worker](futures.md#event-loop): The event loop associated with this container.
|
||||
- [Worker](async.md#event-loop): The event loop associated with this container.
|
||||
|
||||
The most common containers you will interact with in Vapor are:
|
||||
|
||||
|
|
|
|||
|
|
@ -94,9 +94,9 @@ Visiting this route should display your MySQL version.
|
|||
|
||||
A `MySQLConnection` is normally created using the `Request` container and can perform two different types of queries.
|
||||
|
||||
### Create (with Request)
|
||||
### Create
|
||||
|
||||
There are two methods for creating a `MySQLConnection`.
|
||||
There are a few methods for creating a `MySQLConnection` with a `Container` (typically a `Request`).
|
||||
|
||||
```swift
|
||||
return req.withPooledConnection(to: .mysql) { conn in
|
||||
|
|
@ -109,9 +109,7 @@ return req.withConnection(to: .mysql) { conn in
|
|||
|
||||
As the names imply, `withPooledConnection(to:)` utilizes a connection pool. `withConnection(to:)` does not. Connection pooling is a great way to ensure your application does not exceed the limits of your database, even under peak load.
|
||||
|
||||
### Create (manually)
|
||||
|
||||
If you are writing a simple tool and would like to use the `MySQL` wrapper directly, it is also quite simple. You should read up on Vapor's [async `Worker`](../../async/getting-started.md) to power the connection.
|
||||
You can also create a connection manually using `MySQLDatabase.makeConnection(on:)` and passing a [`Worker`](../getting-started/async.md).
|
||||
|
||||
### Simply Query
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ pages:
|
|||
- 'Controllers': 'getting-started/controllers.md'
|
||||
- 'Routing': 'getting-started/routing.md'
|
||||
- 'Content': 'getting-started/content.md'
|
||||
- 'Futures': 'getting-started/futures.md'
|
||||
- 'Async': 'getting-started/async.md'
|
||||
- 'Services': 'getting-started/services.md'
|
||||
- 'Deployment': 'getting-started/cloud.md'
|
||||
- 'Routing':
|
||||
|
|
@ -61,10 +61,9 @@ pages:
|
|||
- 'Getting Started': 'websocket/websocket.md'
|
||||
- 'Crypto':
|
||||
- 'Getting Started': 'crypto/getting-started.md'
|
||||
- 'Base64': 'crypto/base64.md'
|
||||
- 'Hashes': 'crypto/hash.md'
|
||||
- 'Message authentication': 'crypto/mac.md'
|
||||
- 'Password hashing': 'crypto/passwords.md'
|
||||
- 'Digests': 'crypto/digests.md'
|
||||
- 'Ciphers': 'crypto/ciphers.md'
|
||||
- 'Asymmetric': 'crypto/asymmetric.md'
|
||||
- 'Random': 'crypto/random.md'
|
||||
- 'Testing':
|
||||
- 'Getting Started': 'testing/getting-started.md'
|
||||
|
|
|
|||
Loading…
Reference in New Issue