import { Buffer } from 'buffer'
import { OpCode, Script } from 'bsv'
import cbor from 'borc'
import base64url from 'base64url'
import Header from './header.js'
import Recipient from './recipient.js'
import Signature from './signature.js'
import algs from './algs/index.js'
const UNIVRSE_PREFIX = 'UNIV'
/**
* Envelope class
*
* A Univrse Envelope is a structure containing a set of headers, a payload,
* and optionally one or more Signatures and Recipients.
*
* An Envelope can be converted to a compact CBOR encoded buffer, a Base64
* encoded string, or a Bitcoin OP_RETURN Script. This allows envelopes to be
* easily and efficiently transferred between different parties, who in turn can
* decode the wrapped payload.
*/
class Envelope {
/**
* Instantiates a new Envelope instance with the given params.
*
* Accepted params
*
* * `headers` - headers object
* * `payload` - data payload
* * `signature` - singature instance or array of signatures
* * `recipient` - recipient instance or array of recipients
*
* @param {Object} params
* @constructor
*/
constructor({ headers, payload, signature, recipient }) {
this.header = Header.wrap(headers)
this.payload = payload
this.signature = signature
this.recipient = recipient
}
/**
* Instantiates a new Envelope instance with the given array of parts.
*
* Used internally when decoding envelopes.
*
* @param {Array} parts
* @constructor
*/
static fromArray(parts) {
return new this({
headers: parts[0],
payload: parts[1],
signature: Array.isArray(parts[2]) ? Signature.fromArray(parts[2]) : undefined,
recipient: Array.isArray(parts[3]) ? Recipient.fromArray(parts[3]) : undefined
})
}
/**
* Decodes the given buffer and instantiates a new Envelope instance.
*
* @param {Buffer} buf
* @constructor
*/
static fromBuffer(buf) {
return this.fromArray(cbor.decode(buf))
}
/**
* Decodes the given Bitcoin Script and instantiates a new Envelope instance.
*
* @param {Script} script
* @constructor
*/
static fromScript(script) {
const idx = script.chunks.findIndex((c, i, chunks) => {
return c.opCodeNum === 106 && chunks[i+1].buf
&& chunks[i+1].buf.toString() === UNIVRSE_PREFIX
})
if (idx >= 0) {
const parts = script.chunks.slice(idx+2).map(c => cbor.decode(c.buf))
return this.fromArray(parts)
} else {
throw 'Invalid Univrse script'
}
}
/**
* Decodes the given base64 string and instantiates a new Envelope instance.
*
* @param {String} str
* @constructor
*/
static fromString(str) {
const parts = str.split('.')
.map(s => {
const buf = base64url.toBuffer(s)
return cbor.decode(buf)
})
return this.fromArray(parts)
}
/**
* Wraps the given payload and headers in a new Envelope instance.
*
* @param {*} payload
* @param {Object} headers
* @constructor
*/
static wrap(payload, headers = {}) {
return new this({ headers, payload })
}
/**
* CBOR encoded payload
*
* @type {Buffer}
*/
get encodedPayload() {
return cbor.encode(this.payload)
}
/**
* Sets the payload by decoding the given encoded payload.
*
* @type {Buffer}
*/
set encodedPayload(payload) {
this.payload = cbor.decode(payload)
}
/**
* Decrypts the payload using the given key.
*
* If the Envelope contains multiple recipients, it is assumed the key belongs
* to the first recipient. Otherwise, see `decryptAt()`.
*
* An second argument of options can be given for the relevant encryption
* algorithm.
*
* @param {Key} key
* @param {Object} opts
* @returns {Envelope}
*/
async decrypt(key, opts = {}) {
const header = Array.isArray(this.recipient) ? this.recipient[0].header : this.recipient.header
const { alg } = header.headers
const aad = cbor.encode([
'enc',
this.header.unwrap(),
opts.aad ? opts.aad : ''
])
const encOpts = {
...opts,
...['epk', 'iv', 'tag'].reduce((o, k) => { o[k] = header.headers[k]; return o }, {}),
aad
}
this.encodedPayload = await algs.decrypt(alg, this.payload, key, encOpts)
return this
}
/**
* Decrypts the payload by first decrypting the content key at the specified
* recipient index.
*
* The Envelope mustcontains multiple recipients.
*
* @param {Number} i recipient index
* @param {Key} key
* @param {Object} opts
* @returns {Envelope}
*/
async decryptAt(i, key, opts = {}) {
if (!Array.isArray(this.recipient) || this.recipient.length <= i) {
throw 'Invalid recipient index'
}
await this.recipient[i].decrypt(key, opts)
return this.decrypt(this.recipient[i].key, opts)
}
/**
* Encrypts the payload using the given key or array of keys.
*
* A headers object must be given including at least the encryption `alg`
* value.
*
* A third argument of options can be given for the relevant encryption
* algorithm.
*
* Where an array of keys is given, the first key is taken as the content key
* and used to encrypt the payload. The content key is then encrypted by each
* subsequent key and included in the Recipient instances that are attached to
* the envelope.
*
* When encrypting to multiple recipients, it is possible to specify different
* algorithms for each key by giving an array of two element arrays. The first
* element of each pair is the key and the second is a headers object.
*
* ## Examples
*
* Encrypts for a single recipient:
*
* envelope.encrypt(aesKey, { alg: 'A128GCM' })
*
* Encrypts for a multiple recipients using the same algorithm:
*
* envelope.encrypt([aesKey, recKey], { alg: 'A128GCM' })
*
* Encrypts for a multiple recipients using different algorithms:
*
* envelope.encrypt([
* aesKey,
* [rec1Key, { alg: 'ECDH-ES+A128GCM' }],
* [rec2Key, { alg: 'ECDH-ES+A128GCM' }],
* ], { alg: 'A128GCM' })
*
* @param {Key|Key[]|[Key, Object][]} key encryption key or array of keys
* @param {Object} headers
* @param {Object} opts
* @returns {Envelope}
*/
async encrypt(key, headers = {}, opts = {}) {
if (Array.isArray(key)) {
const [mkey, mheaders] = mergeKeyHeaders(key[0], headers)
await this.encrypt(mkey, mheaders)
let kkey, kheaders, krecipient
for (let i = 1; i < key.length; i++) {
[kkey, kheaders] = mergeKeyHeaders(key[i], headers)
krecipient = await mkey.encrypt(kkey, kheaders, opts)
this.pushRecipient(krecipient)
}
return this
}
const { alg } = headers
const aad = cbor.encode([
'enc',
this.header.unwrap(),
opts.aad ? opts.aad : ''
])
const encOpts = {
...opts,
...['iv'].reduce((o, k) => { o[k] = headers[k]; return o }, {}),
aad
}
const { encrypted, ...newHeaders } = await algs.encrypt(alg, this.encodedPayload, key, encOpts)
this.payload = encrypted
const recipient = Recipient.wrap(null, { ...headers, ...newHeaders })
return this.pushRecipient(recipient)
}
/**
* Attaches the given Recipient instance onto the Envelope.
*
* @param {Recipient} recipient
* @returns {Envelope}
*/
pushRecipient(recipient) {
if (Array.isArray(this.recipient)) {
this.recipient.push(recipient)
} else if (this.recipient) {
this.recipient = [this.recipient, recipient]
} else {
this.recipient = recipient
}
return this
}
/**
* Attaches the given Signature instance onto the Envelope.
*
* @param {Signature} signature
* @returns {Envelope}
*/
pushSignature(signature) {
if (Array.isArray(this.signature)) {
this.signature.push(signature)
} else if (this.signature) {
this.signature = [this.signature, signature]
} else {
this.signature = signature
}
return this
}
/**
* Signs the payload using the given key or array of keys.
*
* A headers object must be given including at least the signature `alg`
* value.
*
* Where an array of keys is given, it is possible to specify different
* algorithms for each key by giving an array of two element arrays. The first
* element of each pair is the key and the second is a headers object.
*
* ## Examples
*
* Creates a signature using a single key:
*
* envelope.sign(octKey, { alg: 'HS256' })
*
* Creates multiple signatures using the same algorithm:
*
* envelope.sign([userKey, appKey], { alg: 'HS256' })
*
* Creates multiple signatures using different algorithms:
*
* envelope.sign([
* octKey,
* [ecKey1, { alg: 'ES256K' }],
* [ecKey2, { alg: 'ES256K' }]
* ], { alg: 'HS256' })
*
* @param {Key|Key[]|[Key, Object][]} key signing key or array of keys
* @param {Object} headers
* @returns {Envelope}
*/
async sign(key, headers = {}) {
if (Array.isArray(key)) {
for (let i = 0; i < key.length; i++) {
if (Array.isArray(key[i]) && key[i].length === 2) {
await this.sign(key[i][0], { ...headers, ...key[i][1] })
} else {
await this.sign(key[i], headers)
}
}
return this
}
const alg = {
...this.header.headers,
...headers
}['alg']
const data = cbor.encode(this.toArray().slice(0, 2))
const sig = await algs.sign(alg, data, key)
return this.pushSignature(Signature.wrap(sig, headers))
}
/**
* Returns the Envelope as an array of component parts.
*
* Used internally prior to encoding the envelope.
*
* @returns {Array}
*/
toArray() {
const parts = [
this.header.unwrap(),
this.payload
]
if (Array.isArray(this.signature)) {
parts.push(this.signature.map(s => s.toArray()))
} else if (this.signature && typeof this.signature.toArray === 'function') {
parts.push(this.signature.toArray())
} else if (this.recipient) {
parts.push(null)
}
if (Array.isArray(this.recipient)) {
parts.push(this.recipient.map(s => s.toArray()))
} else if (this.recipient && typeof this.recipient.toArray === 'function') {
parts.push(this.recipient.toArray())
}
return parts
}
/**
* Returns the Envelope as a CBOR encoded Buffer.
*
* @returns {Buffer}
*/
toBuffer() {
return cbor.encode(this.toArray())
}
/**
* Returns the Envelope as bsv Script instance.
*
* @param {Boolean} [falseReturn=true]
* @returns {String}
*/
toScript(falseReturn = true) {
const script = new Script()
if (falseReturn) {
script.writeOpCode(OpCode.OP_FALSE)
}
script.writeOpCode(OpCode.OP_RETURN)
script.writeBuffer(Buffer.from(UNIVRSE_PREFIX))
return this.toArray()
.reduce((s, p) => {
return s.writeBuffer(cbor.encode(p))
}, script)
}
/**
* Returns the Envelope as Base64 encoded string.
*
* @returns {String}
*/
toString() {
return this.toArray()
.map(p => base64url.encode(cbor.encode(p)))
.join('.')
}
/**
* Verifies the signature(s) using the given Key or array of Keys.
*
* Where the envelope has multiple signature, can optionally specfify the
* signature index the key relates to.
*
* @param {Key|Key[]} key key or array of keys
* @param {Number} i signature index
* @returns {Boolean}
*/
async verify(key, i) {
// If index not provided and array of keys, iterate over each and verify each signature
if (!i && Array.isArray(key) && Array.isArray(this.signature) && key.length === this.signature.length) {
const verifications = key.map((k, i) => this.verify(k, i))
const results = await Promise.all(verifications)
return results.every(r => r === true)
}
// If index is given use the specified signature
let signature = this.signature
if (Number.isInteger(i) && Array.isArray(this.signature)) {
signature = this.signature[i]
}
const alg = {
...this.header.headers,
...signature.header.headers
}['alg']
const data = cbor.encode(this.toArray().slice(0, 2))
return algs.verify(alg, data, signature.signature, key)
}
}
/**
* Helper function to merge key headers
*/
function mergeKeyHeaders(key, headers) {
if (Array.isArray(key) && key.length === 2) {
return [key[0], { ...headers, ...key[1] }]
} else {
return [key, headers]
}
}
export default Envelope