You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
555 lines
23 KiB
JavaScript
555 lines
23 KiB
JavaScript
"use strict";
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.ClientEncryption = void 0;
|
|
const bson_1 = require("../bson");
|
|
const deps_1 = require("../deps");
|
|
const utils_1 = require("../utils");
|
|
const cryptoCallbacks = require("./crypto_callbacks");
|
|
const errors_1 = require("./errors");
|
|
const index_1 = require("./providers/index");
|
|
const state_machine_1 = require("./state_machine");
|
|
/**
|
|
* @public
|
|
* The public interface for explicit in-use encryption
|
|
*/
|
|
class ClientEncryption {
|
|
/** @internal */
|
|
static getMongoCrypt() {
|
|
const encryption = (0, deps_1.getMongoDBClientEncryption)();
|
|
if ('kModuleError' in encryption) {
|
|
throw encryption.kModuleError;
|
|
}
|
|
return encryption.MongoCrypt;
|
|
}
|
|
/**
|
|
* Create a new encryption instance
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* new ClientEncryption(mongoClient, {
|
|
* keyVaultNamespace: 'client.encryption',
|
|
* kmsProviders: {
|
|
* local: {
|
|
* key: masterKey // The master key used for encryption/decryption. A 96-byte long Buffer
|
|
* }
|
|
* }
|
|
* });
|
|
* ```
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* new ClientEncryption(mongoClient, {
|
|
* keyVaultNamespace: 'client.encryption',
|
|
* kmsProviders: {
|
|
* aws: {
|
|
* accessKeyId: AWS_ACCESS_KEY,
|
|
* secretAccessKey: AWS_SECRET_KEY
|
|
* }
|
|
* }
|
|
* });
|
|
* ```
|
|
*/
|
|
constructor(client, options) {
|
|
this._client = client;
|
|
this._proxyOptions = options.proxyOptions ?? {};
|
|
this._tlsOptions = options.tlsOptions ?? {};
|
|
this._kmsProviders = options.kmsProviders || {};
|
|
if (options.keyVaultNamespace == null) {
|
|
throw new errors_1.MongoCryptInvalidArgumentError('Missing required option `keyVaultNamespace`');
|
|
}
|
|
const mongoCryptOptions = {
|
|
...options,
|
|
cryptoCallbacks,
|
|
kmsProviders: !Buffer.isBuffer(this._kmsProviders)
|
|
? (0, bson_1.serialize)(this._kmsProviders)
|
|
: this._kmsProviders
|
|
};
|
|
this._keyVaultNamespace = options.keyVaultNamespace;
|
|
this._keyVaultClient = options.keyVaultClient || client;
|
|
const MongoCrypt = ClientEncryption.getMongoCrypt();
|
|
this._mongoCrypt = new MongoCrypt(mongoCryptOptions);
|
|
}
|
|
/**
|
|
* Creates a data key used for explicit encryption and inserts it into the key vault namespace
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // Using async/await to create a local key
|
|
* const dataKeyId = await clientEncryption.createDataKey('local');
|
|
* ```
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // Using async/await to create an aws key
|
|
* const dataKeyId = await clientEncryption.createDataKey('aws', {
|
|
* masterKey: {
|
|
* region: 'us-east-1',
|
|
* key: 'xxxxxxxxxxxxxx' // CMK ARN here
|
|
* }
|
|
* });
|
|
* ```
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // Using async/await to create an aws key with a keyAltName
|
|
* const dataKeyId = await clientEncryption.createDataKey('aws', {
|
|
* masterKey: {
|
|
* region: 'us-east-1',
|
|
* key: 'xxxxxxxxxxxxxx' // CMK ARN here
|
|
* },
|
|
* keyAltNames: [ 'mySpecialKey' ]
|
|
* });
|
|
* ```
|
|
*/
|
|
async createDataKey(provider, options = {}) {
|
|
if (options.keyAltNames && !Array.isArray(options.keyAltNames)) {
|
|
throw new errors_1.MongoCryptInvalidArgumentError(`Option "keyAltNames" must be an array of strings, but was of type ${typeof options.keyAltNames}.`);
|
|
}
|
|
let keyAltNames = undefined;
|
|
if (options.keyAltNames && options.keyAltNames.length > 0) {
|
|
keyAltNames = options.keyAltNames.map((keyAltName, i) => {
|
|
if (typeof keyAltName !== 'string') {
|
|
throw new errors_1.MongoCryptInvalidArgumentError(`Option "keyAltNames" must be an array of strings, but item at index ${i} was of type ${typeof keyAltName}`);
|
|
}
|
|
return (0, bson_1.serialize)({ keyAltName });
|
|
});
|
|
}
|
|
let keyMaterial = undefined;
|
|
if (options.keyMaterial) {
|
|
keyMaterial = (0, bson_1.serialize)({ keyMaterial: options.keyMaterial });
|
|
}
|
|
const dataKeyBson = (0, bson_1.serialize)({
|
|
provider,
|
|
...options.masterKey
|
|
});
|
|
const context = this._mongoCrypt.makeDataKeyContext(dataKeyBson, {
|
|
keyAltNames,
|
|
keyMaterial
|
|
});
|
|
const stateMachine = new state_machine_1.StateMachine({
|
|
proxyOptions: this._proxyOptions,
|
|
tlsOptions: this._tlsOptions
|
|
});
|
|
const dataKey = await stateMachine.execute(this, context);
|
|
const { db: dbName, collection: collectionName } = utils_1.MongoDBCollectionNamespace.fromString(this._keyVaultNamespace);
|
|
const { insertedId } = await this._keyVaultClient
|
|
.db(dbName)
|
|
.collection(collectionName)
|
|
.insertOne(dataKey, { writeConcern: { w: 'majority' } });
|
|
return insertedId;
|
|
}
|
|
/**
|
|
* Searches the keyvault for any data keys matching the provided filter. If there are matches, rewrapManyDataKey then attempts to re-wrap the data keys using the provided options.
|
|
*
|
|
* If no matches are found, then no bulk write is performed.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // rewrapping all data data keys (using a filter that matches all documents)
|
|
* const filter = {};
|
|
*
|
|
* const result = await clientEncryption.rewrapManyDataKey(filter);
|
|
* if (result.bulkWriteResult != null) {
|
|
* // keys were re-wrapped, results will be available in the bulkWrite object.
|
|
* }
|
|
* ```
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // attempting to rewrap all data keys with no matches
|
|
* const filter = { _id: new Binary() } // assume _id matches no documents in the database
|
|
* const result = await clientEncryption.rewrapManyDataKey(filter);
|
|
*
|
|
* if (result.bulkWriteResult == null) {
|
|
* // no keys matched, `bulkWriteResult` does not exist on the result object
|
|
* }
|
|
* ```
|
|
*/
|
|
async rewrapManyDataKey(filter, options) {
|
|
let keyEncryptionKeyBson = undefined;
|
|
if (options) {
|
|
const keyEncryptionKey = Object.assign({ provider: options.provider }, options.masterKey);
|
|
keyEncryptionKeyBson = (0, bson_1.serialize)(keyEncryptionKey);
|
|
}
|
|
const filterBson = (0, bson_1.serialize)(filter);
|
|
const context = this._mongoCrypt.makeRewrapManyDataKeyContext(filterBson, keyEncryptionKeyBson);
|
|
const stateMachine = new state_machine_1.StateMachine({
|
|
proxyOptions: this._proxyOptions,
|
|
tlsOptions: this._tlsOptions
|
|
});
|
|
const { v: dataKeys } = await stateMachine.execute(this, context);
|
|
if (dataKeys.length === 0) {
|
|
return {};
|
|
}
|
|
const { db: dbName, collection: collectionName } = utils_1.MongoDBCollectionNamespace.fromString(this._keyVaultNamespace);
|
|
const replacements = dataKeys.map((key) => ({
|
|
updateOne: {
|
|
filter: { _id: key._id },
|
|
update: {
|
|
$set: {
|
|
masterKey: key.masterKey,
|
|
keyMaterial: key.keyMaterial
|
|
},
|
|
$currentDate: {
|
|
updateDate: true
|
|
}
|
|
}
|
|
}
|
|
}));
|
|
const result = await this._keyVaultClient
|
|
.db(dbName)
|
|
.collection(collectionName)
|
|
.bulkWrite(replacements, {
|
|
writeConcern: { w: 'majority' }
|
|
});
|
|
return { bulkWriteResult: result };
|
|
}
|
|
/**
|
|
* Deletes the key with the provided id from the keyvault, if it exists.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // delete a key by _id
|
|
* const id = new Binary(); // id is a bson binary subtype 4 object
|
|
* const { deletedCount } = await clientEncryption.deleteKey(id);
|
|
*
|
|
* if (deletedCount != null && deletedCount > 0) {
|
|
* // successful deletion
|
|
* }
|
|
* ```
|
|
*
|
|
*/
|
|
async deleteKey(_id) {
|
|
const { db: dbName, collection: collectionName } = utils_1.MongoDBCollectionNamespace.fromString(this._keyVaultNamespace);
|
|
return this._keyVaultClient
|
|
.db(dbName)
|
|
.collection(collectionName)
|
|
.deleteOne({ _id }, { writeConcern: { w: 'majority' } });
|
|
}
|
|
/**
|
|
* Finds all the keys currently stored in the keyvault.
|
|
*
|
|
* This method will not throw.
|
|
*
|
|
* @returns a FindCursor over all keys in the keyvault.
|
|
* @example
|
|
* ```ts
|
|
* // fetching all keys
|
|
* const keys = await clientEncryption.getKeys().toArray();
|
|
* ```
|
|
*/
|
|
getKeys() {
|
|
const { db: dbName, collection: collectionName } = utils_1.MongoDBCollectionNamespace.fromString(this._keyVaultNamespace);
|
|
return this._keyVaultClient
|
|
.db(dbName)
|
|
.collection(collectionName)
|
|
.find({}, { readConcern: { level: 'majority' } });
|
|
}
|
|
/**
|
|
* Finds a key in the keyvault with the specified _id.
|
|
*
|
|
* Returns a promise that either resolves to a {@link DataKey} if a document matches the key or null if no documents
|
|
* match the id. The promise rejects with an error if an error is thrown.
|
|
* @example
|
|
* ```ts
|
|
* // getting a key by id
|
|
* const id = new Binary(); // id is a bson binary subtype 4 object
|
|
* const key = await clientEncryption.getKey(id);
|
|
* if (!key) {
|
|
* // key is null if there was no matching key
|
|
* }
|
|
* ```
|
|
*/
|
|
async getKey(_id) {
|
|
const { db: dbName, collection: collectionName } = utils_1.MongoDBCollectionNamespace.fromString(this._keyVaultNamespace);
|
|
return this._keyVaultClient
|
|
.db(dbName)
|
|
.collection(collectionName)
|
|
.findOne({ _id }, { readConcern: { level: 'majority' } });
|
|
}
|
|
/**
|
|
* Finds a key in the keyvault which has the specified keyAltName.
|
|
*
|
|
* @param keyAltName - a keyAltName to search for a key
|
|
* @returns Returns a promise that either resolves to a {@link DataKey} if a document matches the key or null if no documents
|
|
* match the keyAltName. The promise rejects with an error if an error is thrown.
|
|
* @example
|
|
* ```ts
|
|
* // get a key by alt name
|
|
* const keyAltName = 'keyAltName';
|
|
* const key = await clientEncryption.getKeyByAltName(keyAltName);
|
|
* if (!key) {
|
|
* // key is null if there is no matching key
|
|
* }
|
|
* ```
|
|
*/
|
|
async getKeyByAltName(keyAltName) {
|
|
const { db: dbName, collection: collectionName } = utils_1.MongoDBCollectionNamespace.fromString(this._keyVaultNamespace);
|
|
return this._keyVaultClient
|
|
.db(dbName)
|
|
.collection(collectionName)
|
|
.findOne({ keyAltNames: keyAltName }, { readConcern: { level: 'majority' } });
|
|
}
|
|
/**
|
|
* Adds a keyAltName to a key identified by the provided _id.
|
|
*
|
|
* This method resolves to/returns the *old* key value (prior to adding the new altKeyName).
|
|
*
|
|
* @param _id - The id of the document to update.
|
|
* @param keyAltName - a keyAltName to search for a key
|
|
* @returns Returns a promise that either resolves to a {@link DataKey} if a document matches the key or null if no documents
|
|
* match the id. The promise rejects with an error if an error is thrown.
|
|
* @example
|
|
* ```ts
|
|
* // adding an keyAltName to a data key
|
|
* const id = new Binary(); // id is a bson binary subtype 4 object
|
|
* const keyAltName = 'keyAltName';
|
|
* const oldKey = await clientEncryption.addKeyAltName(id, keyAltName);
|
|
* if (!oldKey) {
|
|
* // null is returned if there is no matching document with an id matching the supplied id
|
|
* }
|
|
* ```
|
|
*/
|
|
async addKeyAltName(_id, keyAltName) {
|
|
const { db: dbName, collection: collectionName } = utils_1.MongoDBCollectionNamespace.fromString(this._keyVaultNamespace);
|
|
const value = await this._keyVaultClient
|
|
.db(dbName)
|
|
.collection(collectionName)
|
|
.findOneAndUpdate({ _id }, { $addToSet: { keyAltNames: keyAltName } }, { writeConcern: { w: 'majority' }, returnDocument: 'before' });
|
|
return value;
|
|
}
|
|
/**
|
|
* Adds a keyAltName to a key identified by the provided _id.
|
|
*
|
|
* This method resolves to/returns the *old* key value (prior to removing the new altKeyName).
|
|
*
|
|
* If the removed keyAltName is the last keyAltName for that key, the `altKeyNames` property is unset from the document.
|
|
*
|
|
* @param _id - The id of the document to update.
|
|
* @param keyAltName - a keyAltName to search for a key
|
|
* @returns Returns a promise that either resolves to a {@link DataKey} if a document matches the key or null if no documents
|
|
* match the id. The promise rejects with an error if an error is thrown.
|
|
* @example
|
|
* ```ts
|
|
* // removing a key alt name from a data key
|
|
* const id = new Binary(); // id is a bson binary subtype 4 object
|
|
* const keyAltName = 'keyAltName';
|
|
* const oldKey = await clientEncryption.removeKeyAltName(id, keyAltName);
|
|
*
|
|
* if (!oldKey) {
|
|
* // null is returned if there is no matching document with an id matching the supplied id
|
|
* }
|
|
* ```
|
|
*/
|
|
async removeKeyAltName(_id, keyAltName) {
|
|
const { db: dbName, collection: collectionName } = utils_1.MongoDBCollectionNamespace.fromString(this._keyVaultNamespace);
|
|
const pipeline = [
|
|
{
|
|
$set: {
|
|
keyAltNames: {
|
|
$cond: [
|
|
{
|
|
$eq: ['$keyAltNames', [keyAltName]]
|
|
},
|
|
'$$REMOVE',
|
|
{
|
|
$filter: {
|
|
input: '$keyAltNames',
|
|
cond: {
|
|
$ne: ['$$this', keyAltName]
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
];
|
|
const value = await this._keyVaultClient
|
|
.db(dbName)
|
|
.collection(collectionName)
|
|
.findOneAndUpdate({ _id }, pipeline, {
|
|
writeConcern: { w: 'majority' },
|
|
returnDocument: 'before'
|
|
});
|
|
return value;
|
|
}
|
|
/**
|
|
* A convenience method for creating an encrypted collection.
|
|
* This method will create data keys for any encryptedFields that do not have a `keyId` defined
|
|
* and then create a new collection with the full set of encryptedFields.
|
|
*
|
|
* @param db - A Node.js driver Db object with which to create the collection
|
|
* @param name - The name of the collection to be created
|
|
* @param options - Options for createDataKey and for createCollection
|
|
* @returns created collection and generated encryptedFields
|
|
* @throws MongoCryptCreateDataKeyError - If part way through the process a createDataKey invocation fails, an error will be rejected that has the partial `encryptedFields` that were created.
|
|
* @throws MongoCryptCreateEncryptedCollectionError - If creating the collection fails, an error will be rejected that has the entire `encryptedFields` that were created.
|
|
*/
|
|
async createEncryptedCollection(db, name, options) {
|
|
const { provider, masterKey, createCollectionOptions: { encryptedFields: { ...encryptedFields }, ...createCollectionOptions } } = options;
|
|
if (Array.isArray(encryptedFields.fields)) {
|
|
const createDataKeyPromises = encryptedFields.fields.map(async (field) => field == null || typeof field !== 'object' || field.keyId != null
|
|
? field
|
|
: {
|
|
...field,
|
|
keyId: await this.createDataKey(provider, { masterKey })
|
|
});
|
|
const createDataKeyResolutions = await Promise.allSettled(createDataKeyPromises);
|
|
encryptedFields.fields = createDataKeyResolutions.map((resolution, index) => resolution.status === 'fulfilled' ? resolution.value : encryptedFields.fields[index]);
|
|
const rejection = createDataKeyResolutions.find((result) => result.status === 'rejected');
|
|
if (rejection != null) {
|
|
throw new errors_1.MongoCryptCreateDataKeyError(encryptedFields, { cause: rejection.reason });
|
|
}
|
|
}
|
|
try {
|
|
const collection = await db.createCollection(name, {
|
|
...createCollectionOptions,
|
|
encryptedFields
|
|
});
|
|
return { collection, encryptedFields };
|
|
}
|
|
catch (cause) {
|
|
throw new errors_1.MongoCryptCreateEncryptedCollectionError(encryptedFields, { cause });
|
|
}
|
|
}
|
|
/**
|
|
* Explicitly encrypt a provided value. Note that either `options.keyId` or `options.keyAltName` must
|
|
* be specified. Specifying both `options.keyId` and `options.keyAltName` is considered an error.
|
|
*
|
|
* @param value - The value that you wish to serialize. Must be of a type that can be serialized into BSON
|
|
* @param options -
|
|
* @returns a Promise that either resolves with the encrypted value, or rejects with an error.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // Encryption with async/await api
|
|
* async function encryptMyData(value) {
|
|
* const keyId = await clientEncryption.createDataKey('local');
|
|
* return clientEncryption.encrypt(value, { keyId, algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' });
|
|
* }
|
|
* ```
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // Encryption using a keyAltName
|
|
* async function encryptMyData(value) {
|
|
* await clientEncryption.createDataKey('local', { keyAltNames: 'mySpecialKey' });
|
|
* return clientEncryption.encrypt(value, { keyAltName: 'mySpecialKey', algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' });
|
|
* }
|
|
* ```
|
|
*/
|
|
async encrypt(value, options) {
|
|
return this._encrypt(value, false, options);
|
|
}
|
|
/**
|
|
* Encrypts a Match Expression or Aggregate Expression to query a range index.
|
|
*
|
|
* Only supported when queryType is "rangePreview" and algorithm is "RangePreview".
|
|
*
|
|
* @experimental The Range algorithm is experimental only. It is not intended for production use. It is subject to breaking changes.
|
|
*
|
|
* @param expression - a BSON document of one of the following forms:
|
|
* 1. A Match Expression of this form:
|
|
* `{$and: [{<field>: {$gt: <value1>}}, {<field>: {$lt: <value2> }}]}`
|
|
* 2. An Aggregate Expression of this form:
|
|
* `{$and: [{$gt: [<fieldpath>, <value1>]}, {$lt: [<fieldpath>, <value2>]}]}`
|
|
*
|
|
* `$gt` may also be `$gte`. `$lt` may also be `$lte`.
|
|
*
|
|
* @param options -
|
|
* @returns Returns a Promise that either resolves with the encrypted value or rejects with an error.
|
|
*/
|
|
async encryptExpression(expression, options) {
|
|
return this._encrypt(expression, true, options);
|
|
}
|
|
/**
|
|
* Explicitly decrypt a provided encrypted value
|
|
*
|
|
* @param value - An encrypted value
|
|
* @returns a Promise that either resolves with the decrypted value, or rejects with an error
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // Decrypting value with async/await API
|
|
* async function decryptMyValue(value) {
|
|
* return clientEncryption.decrypt(value);
|
|
* }
|
|
* ```
|
|
*/
|
|
async decrypt(value) {
|
|
const valueBuffer = (0, bson_1.serialize)({ v: value });
|
|
const context = this._mongoCrypt.makeExplicitDecryptionContext(valueBuffer);
|
|
const stateMachine = new state_machine_1.StateMachine({
|
|
proxyOptions: this._proxyOptions,
|
|
tlsOptions: this._tlsOptions
|
|
});
|
|
const { v } = await stateMachine.execute(this, context);
|
|
return v;
|
|
}
|
|
/**
|
|
* @internal
|
|
* Ask the user for KMS credentials.
|
|
*
|
|
* This returns anything that looks like the kmsProviders original input
|
|
* option. It can be empty, and any provider specified here will override
|
|
* the original ones.
|
|
*/
|
|
async askForKMSCredentials() {
|
|
return (0, index_1.refreshKMSCredentials)(this._kmsProviders);
|
|
}
|
|
static get libmongocryptVersion() {
|
|
return ClientEncryption.getMongoCrypt().libmongocryptVersion;
|
|
}
|
|
/**
|
|
* @internal
|
|
* A helper that perform explicit encryption of values and expressions.
|
|
* Explicitly encrypt a provided value. Note that either `options.keyId` or `options.keyAltName` must
|
|
* be specified. Specifying both `options.keyId` and `options.keyAltName` is considered an error.
|
|
*
|
|
* @param value - The value that you wish to encrypt. Must be of a type that can be serialized into BSON
|
|
* @param expressionMode - a boolean that indicates whether or not to encrypt the value as an expression
|
|
* @param options - options to pass to encrypt
|
|
* @returns the raw result of the call to stateMachine.execute(). When expressionMode is set to true, the return
|
|
* value will be a bson document. When false, the value will be a BSON Binary.
|
|
*
|
|
*/
|
|
async _encrypt(value, expressionMode, options) {
|
|
const { algorithm, keyId, keyAltName, contentionFactor, queryType, rangeOptions } = options;
|
|
const contextOptions = {
|
|
expressionMode,
|
|
algorithm
|
|
};
|
|
if (keyId) {
|
|
contextOptions.keyId = keyId.buffer;
|
|
}
|
|
if (keyAltName) {
|
|
if (keyId) {
|
|
throw new errors_1.MongoCryptInvalidArgumentError(`"options" cannot contain both "keyId" and "keyAltName"`);
|
|
}
|
|
if (typeof keyAltName !== 'string') {
|
|
throw new errors_1.MongoCryptInvalidArgumentError(`"options.keyAltName" must be of type string, but was of type ${typeof keyAltName}`);
|
|
}
|
|
contextOptions.keyAltName = (0, bson_1.serialize)({ keyAltName });
|
|
}
|
|
if (typeof contentionFactor === 'number' || typeof contentionFactor === 'bigint') {
|
|
contextOptions.contentionFactor = contentionFactor;
|
|
}
|
|
if (typeof queryType === 'string') {
|
|
contextOptions.queryType = queryType;
|
|
}
|
|
if (typeof rangeOptions === 'object') {
|
|
contextOptions.rangeOptions = (0, bson_1.serialize)(rangeOptions);
|
|
}
|
|
const valueBuffer = (0, bson_1.serialize)({ v: value });
|
|
const stateMachine = new state_machine_1.StateMachine({
|
|
proxyOptions: this._proxyOptions,
|
|
tlsOptions: this._tlsOptions
|
|
});
|
|
const context = this._mongoCrypt.makeExplicitEncryptionContext(valueBuffer, contextOptions);
|
|
const result = await stateMachine.execute(this, context);
|
|
return result.v;
|
|
}
|
|
}
|
|
exports.ClientEncryption = ClientEncryption;
|
|
//# sourceMappingURL=client_encryption.js.map
|